實例講解代碼之內(nèi)存安全與效率
了解有關內(nèi)存安全和效率的更多信息。
C 是一種高級語言,同時具有“接近金屬close-to-the-metal”(LCTT 譯注:即“接近人類思維方式”的反義詞)的特性,這使得它有時看起來更像是一種可移植的匯編語言,而不像 Java 或 Python 這樣的兄弟語言。內(nèi)存管理作為上述特性之一,涵蓋了正在執(zhí)行的程序對內(nèi)存的安全和高效使用。本文通過 C 語言代碼示例,以及現(xiàn)代 C 語言編譯器生成的匯編語言代碼段,詳細介紹了內(nèi)存安全性和效率。
盡管代碼示例是用 C 語言編寫的,但安全高效的內(nèi)存管理指南對于 C++ 是同樣適用的。這兩種語言在很多細節(jié)上有所不同(例如,C++ 具有 C 所缺乏的面向對象特性和泛型),但在內(nèi)存管理方面面臨的挑戰(zhàn)是一樣的。
執(zhí)行中程序的內(nèi)存概述
對于正在執(zhí)行的程序(又名 進程process),內(nèi)存被劃分為三個區(qū)域:棧stack、堆heap 和 靜態(tài)區(qū)static area。下文會給出每個區(qū)域的概述,以及完整的代碼示例。
作為通用 CPU 寄存器的替補,棧 為代碼塊(例如函數(shù)或循環(huán)體)中的局部變量提供暫存器存儲。傳遞給函數(shù)的參數(shù)在此上下文中也視作局部變量??匆幌孪旅孢@個簡短的示例:
- void some_func(int a, int b) {
- int n;
- ...
- }
通過 a 和 b 傳遞的參數(shù)以及局部變量 n 的存儲會在棧中,除非編譯器可以找到通用寄存器。編譯器傾向于優(yōu)先將通用寄存器用作暫存器,因為 CPU 對這些寄存器的訪問速度很快(一個時鐘周期)。然而,這些寄存器在臺式機、筆記本電腦和手持機器的標準架構上很少(大約 16 個)。
在只有匯編語言程序員才能看到的實施層面,棧被組織為具有 push(插入)和 pop(刪除)操作的 LIFO(后進先出)列表。 top 指針可以作為偏移的基地址;這樣,除了 top 之外的棧位置也變得可訪問了。例如,表達式 top+16 指向堆棧的 top 指針上方 16 個字節(jié)的位置,表達式 top-16 指向 top 指針下方 16 個字節(jié)的位置。因此,可以通過 top 指針訪問實現(xiàn)了暫存器存儲的棧的位置。在標準的 ARM 或 Intel 架構中,棧從高內(nèi)存地址增長到低內(nèi)存地址;因此,減小某進程的 top 就是增大其棧規(guī)模。
使用棧結構就意味著輕松高效地使用內(nèi)存。編譯器(而非程序員)會編寫管理棧的代碼,管理過程通過分配和釋放所需的暫存器存儲來實現(xiàn);程序員聲明函數(shù)參數(shù)和局部變量,將實現(xiàn)過程交給編譯器。此外,完全相同的棧存儲可以在連續(xù)的函數(shù)調用和代碼塊(如循環(huán))中重復使用。精心設計的模塊化代碼會將棧存儲作為暫存器的首選內(nèi)存選項,同時優(yōu)化編譯器要盡可能使用通用寄存器而不是棧。
堆 提供的存儲是通過程序員代碼顯式分配的,堆分配的語法因語言而異。在 C 中,成功調用庫函數(shù) malloc(或其變體 calloc 等)會分配指定數(shù)量的字節(jié)(在 C++ 和 Java 等語言中,new 運算符具有相同的用途)。編程語言在如何釋放堆分配的存儲方面有著巨大的差異:
在 Java、Go、Lisp 和 Python 等語言中,程序員不會顯式釋放動態(tài)分配的堆存儲。
例如,下面這個 Java 語句為一個字符串分配了堆存儲,并將這個堆存儲的地址存儲在變量 greeting 中:
- String greeting = new String("Hello, world!");
Java 有一個垃圾回收器,它是一個運行時實用程序,如果進程無法再訪問自己分配的堆存儲,回收器可以使其自動釋放。因此,Java 堆釋放是通過垃圾收集器自動進行的。在上面的示例中,垃圾收集器將在變量 greeting 超出作用域后,釋放字符串的堆存儲。
Rust 編譯器會編寫堆釋放代碼。這是 Rust 在不依賴垃圾回收器的情況下,使堆釋放實現(xiàn)自動化的開創(chuàng)性努力,但這也會帶來運行時復雜性和開銷。向 Rust 的努力致敬!
在 C(和 C++)中,堆釋放是程序員的任務。程序員調用 malloc 分配堆存儲,然后負責相應地調用庫函數(shù) free 來釋放該存儲空間(在 C++ 中,new 運算符分配堆存儲,而 delete 和 delete[] 運算符釋放此類存儲)。下面是一個 C 語言代碼示例:
- char* greeting = malloc(14); /* 14 heap bytes */
- strcpy(greeting, "Hello, world!"); /* copy greeting into bytes */
- puts(greeting); /* print greeting */
- free(greeting); /* free malloced bytes */
C 語言避免了垃圾回收器的成本和復雜性,但也不過是讓程序員承擔了堆釋放的任務。
內(nèi)存的 靜態(tài)區(qū) 為可執(zhí)行代碼(例如 C 語言函數(shù))、字符串文字(例如“Hello, world!”)和全局變量提供存儲空間:
- int n; /* global variable */
- int main() { /* function */
- char* msg = "No comment"; /* string literal */
- ...
- }
該區(qū)域是靜態(tài)的,因為它的大小從進程執(zhí)行開始到結束都固定不變。由于靜態(tài)區(qū)相當于進程固定大小的內(nèi)存占用,因此經(jīng)驗法則是通過避免使用全局數(shù)組等方法來使該區(qū)域盡可能小。
下文會結合代碼示例對本節(jié)概述展開進一步講解。
棧存儲
想象一個有各種連續(xù)執(zhí)行的任務的程序,任務包括了處理每隔幾分鐘通過網(wǎng)絡下載并存儲在本地文件中的數(shù)字數(shù)據(jù)。下面的 stack 程序簡化了處理流程(僅是將奇數(shù)整數(shù)值轉換為偶數(shù)),而將重點放在棧存儲的好處上。
- #include <stdio.h>
- #include <stdlib.h>
- #define Infile "incoming.dat"
- #define Outfile "outgoing.dat"
- #define IntCount 128000 /* 128,000 */
- void other_task1() { /*...*/ }
- void other_task2() { /*...*/ }
- void process_data(const char* infile,
- const char* outfile,
- const unsigned n) {
- int nums[n];
- FILE* input = fopen(infile, "r");
- if (NULL == infile) return;
- FILE* output = fopen(outfile, "w");
- if (NULL == output) {
- fclose(input);
- return;
- }
- fread(nums, n, sizeof(int), input); /* read input data */
- unsigned i;
- for (i = 0; i < n; i++) {
- if (1 == (nums[i] & 0x1)) /* odd parity? */
- nums[i]--; /* make even */
- }
- fclose(input); /* close input file */
- fwrite(nums, n, sizeof(int), output);
- fclose(output);
- }
- int main() {
- process_data(Infile, Outfile, IntCount);
- /** now perform other tasks **/
- other_task1(); /* automatically released stack storage available */
- other_task2(); /* ditto */
- return 0;
- }
底部的 main 函數(shù)首先調用 process_data 函數(shù),該函數(shù)會創(chuàng)建一個基于棧的數(shù)組,其大小由參數(shù) n 給定(當前示例中為 128,000)。因此,該數(shù)組占用 128000 * sizeof(int) 個字節(jié),在標準設備上達到了 512,000 字節(jié)(int 在這些設備上是四個字節(jié))。然后數(shù)據(jù)會被讀入數(shù)組(使用庫函數(shù) fread),循環(huán)處理,并保存到本地文件 outgoing.dat(使用庫函數(shù) fwrite)。
當 process_data 函數(shù)返回到其調用者 main 函數(shù)時,process_data 函數(shù)的大約 500MB 棧暫存器可供 stack 程序中的其他函數(shù)用作暫存器。在此示例中,main 函數(shù)接下來調用存根函數(shù) other_task1 和 other_task2。這三個函數(shù)在 main 中依次調用,這意味著所有三個函數(shù)都可以使用相同的堆棧存儲作為暫存器。因為編寫棧管理代碼的是編譯器而不是程序員,所以這種方法對程序員來說既高效又容易。
在 C 語言中,在塊(例如函數(shù)或循環(huán)體)內(nèi)定義的任何變量默認都有一個 auto 存儲類,這意味著該變量是基于棧的。存儲類 register 現(xiàn)在已經(jīng)過時了,因為 C 編譯器會主動嘗試盡可能使用 CPU 寄存器。只有在塊內(nèi)定義的變量可能是 register,如果沒有可用的 CPU 寄存器,編譯器會將其更改為 auto?;跅5木幊炭赡苁遣诲e的首選方式,但這種風格確實有一些挑戰(zhàn)性。下面的 badStack 程序說明了這點。
- #include <stdio.h>;
- const int* get_array(const unsigned n) {
- int arr[n]; /* stack-based array */
- unsigned i;
- for (i = 0; i < n; i++) arr[i] = 1 + 1;
- return arr; /** ERROR **/
- }
- int main() {
- const unsigned n = 16;
- const int* ptr = get_array(n);
- unsigned i;
- for (i = 0; i < n; i++) printf("%i ", ptr[i]);
- puts("\n");
- return 0;
- }
badStack 程序中的控制流程很簡單。main 函數(shù)使用 16(LCTT 譯注:原文為 128,應為作者筆誤)作為參數(shù)調用函數(shù) get_array,然后被調用函數(shù)會使用傳入?yún)?shù)來創(chuàng)建對應大小的本地數(shù)組。get_array 函數(shù)會初始化數(shù)組并返回給 main 中的數(shù)組標識符 arr。 arr 是一個指針常量,保存數(shù)組的第一個 int 元素的地址。
當然,本地數(shù)組 arr 可以在 get_array 函數(shù)中訪問,但是一旦 get_array 返回,就不能合法訪問該數(shù)組。盡管如此,main 函數(shù)會嘗試使用函數(shù) get_array 返回的堆棧地址 arr 來打印基于棧的數(shù)組?,F(xiàn)代編譯器會警告錯誤。例如,下面是來自 GNU 編譯器的警告:
- badStack.c: In function 'get_array':
- badStack.c:9:10: warning: function returns address of local variable [-Wreturn-local-addr]
- return arr; /** ERROR **/
一般規(guī)則是,如果使用棧存儲實現(xiàn)局部變量,應該僅在該變量所在的代碼塊內(nèi),訪問這塊基于棧的存儲(在本例中,數(shù)組指針 arr 和循環(huán)計數(shù)器 i 均為這樣的局部變量)。因此,函數(shù)永遠不應該返回指向基于棧存儲的指針。
堆存儲
接下來使用若干代碼示例凸顯在 C 語言中使用堆存儲的優(yōu)點。在第一個示例中,使用了最優(yōu)方案分配、使用和釋放堆存儲。第二個示例(在下一節(jié)中)將堆存儲嵌套在了其他堆存儲中,這會使其釋放操作變得復雜。
- #include <stdio.h>
- #include <stdlib.h>
- int* get_heap_array(unsigned n) {
- int* heap_nums = malloc(sizeof(int) * n);
- unsigned i;
- for (i = 0; i < n; i++)
- heap_nums[i] = i + 1; /* initialize the array */
- /* stack storage for variables heap_nums and i released
- automatically when get_num_array returns */
- return heap_nums; /* return (copy of) the pointer */
- }
- int main() {
- unsigned n = 100, i;
- int* heap_nums = get_heap_array(n); /* save returned address */
- if (NULL == heap_nums) /* malloc failed */
- fprintf(stderr, "%s\n", "malloc(...) failed...");
- else {
- for (i = 0; i < n; i++) printf("%i\n", heap_nums[i]);
- free(heap_nums); /* free the heap storage */
- }
- return 0;
- }
上面的 heap 程序有兩個函數(shù): main 函數(shù)使用參數(shù)(示例中為 100)調用 get_heap_array 函數(shù),參數(shù)用來指定數(shù)組應該有多少個 int 元素。因為堆分配可能會失敗,main 函數(shù)會檢查 get_heap_array 是否返回了 NULL;如果是,則表示失敗。如果分配成功,main 將打印數(shù)組中的 int 值,然后立即調用庫函數(shù) free 來對堆存儲解除分配。這就是最優(yōu)的方案。
get_heap_array 函數(shù)以下列語句開頭,該語句值得仔細研究一下:
- int* heap_nums = malloc(sizeof(int) * n); /* heap allocation */
malloc 庫函數(shù)及其變體函數(shù)針對字節(jié)進行操作;因此,malloc 的參數(shù)是 n 個 int 類型元素所需的字節(jié)數(shù)(sizeof(int) 在標準現(xiàn)代設備上是四個字節(jié))。malloc 函數(shù)返回所分配字節(jié)段的首地址,如果失敗則返回 NULL .
如果成功調用 malloc,在現(xiàn)代臺式機上其返回的地址大小為 64 位。在手持設備和早些時候的臺式機上,該地址的大小可能是 32 位,或者甚至更小,具體取決于其年代。堆分配數(shù)組中的元素是 int 類型,這是一個四字節(jié)的有符號整數(shù)。這些堆分配的 int 的地址存儲在基于棧的局部變量 heap_nums 中。可以參考下圖:
- heap-based
- k-based /
- \ +----+----+ +----+
- -nums--->|int1|int2|...|intN|
- +----+----+ +----+
一旦 get_heap_array 函數(shù)返回,指針變量 heap_nums 的棧存儲將自動回收——但動態(tài) int 數(shù)組的堆存儲仍然存在,這就是 get_heap_array 函數(shù)返回這個地址(的副本)給 main 函數(shù)的原因:它現(xiàn)在負責在打印數(shù)組的整數(shù)后,通過調用庫函數(shù) free 顯式釋放堆存儲:
- free(heap_nums); /* free the heap storage */
malloc 函數(shù)不會初始化堆分配的存儲空間,因此里面是隨機值。相比之下,其變體函數(shù) calloc 會將分配的存儲初始化為零。這兩個函數(shù)都返回 NULL 來表示分配失敗。
在 heap 示例中,main 函數(shù)在調用 free 后會立即返回,正在執(zhí)行的程序會終止,這會讓系統(tǒng)回收所有已分配的堆存儲。盡管如此,程序員應該養(yǎng)成在不再需要時立即顯式釋放堆存儲的習慣。
嵌套堆分配
下一個代碼示例會更棘手一些。C 語言有很多返回指向堆存儲的指針的庫函數(shù)。下面是一個常見的使用情景:
1、C 程序調用一個庫函數(shù),該函數(shù)返回一個指向基于堆的存儲的指針,而指向的存儲通常是一個聚合體,如數(shù)組或結構體:
- SomeStructure* ptr = lib_function(); /* returns pointer to heap storage */
2、 然后程序使用所分配的存儲。
3、 對于清理而言,問題是對 free 的簡單調用是否會清理庫函數(shù)分配的所有堆分配存儲。例如,SomeStructure 實例可能有指向堆分配存儲的字段。一個特別麻煩的情況是動態(tài)分配的結構體數(shù)組,每個結構體有一個指向又一層動態(tài)分配的存儲的字段。下面的代碼示例說明了這個問題,并重點關注了如何設計一個可以安全地為客戶端提供堆分配存儲的庫。
- #include <stdio.h>
- #include <stdlib.h>
- typedef struct {
- unsigned id;
- unsigned len;
- float* heap_nums;
- } HeapStruct;
- unsigned structId = 1;
- HeapStruct* get_heap_struct(unsigned n) {
- /* Try to allocate a HeapStruct. */
- HeapStruct* heap_struct = malloc(sizeof(HeapStruct));
- if (NULL == heap_struct) /* failure? */
- return NULL; /* if so, return NULL */
- /* Try to allocate floating-point aggregate within HeapStruct. */
- heap_struct->heap_nums = malloc(sizeof(float) * n);
- if (NULL == heap_struct->heap_nums) { /* failure? */
- free(heap_struct); /* if so, first free the HeapStruct */
- return NULL; /* then return NULL */
- }
- /* Success: set fields */
- heap_struct->id = structId++;
- heap_struct->len = n;
- return heap_struct; /* return pointer to allocated HeapStruct */
- }
- void free_all(HeapStruct* heap_struct) {
- if (NULL == heap_struct) /* NULL pointer? */
- return; /* if so, do nothing */
- free(heap_struct->heap_nums); /* first free encapsulated aggregate */
- free(heap_struct); /* then free containing structure */
- }
- int main() {
- const unsigned n = 100;
- HeapStruct* hs = get_heap_struct(n); /* get structure with N floats */
- /* Do some (meaningless) work for demo. */
- unsigned i;
- for (i = 0; i < n; i++) hs->heap_nums[i] = 3.14 + (float) i;
- for (i = 0; i < n; i += 10) printf("%12f\n", hs->heap_nums[i]);
- free_all(hs); /* free dynamically allocated storage */
- return 0;
- }
上面的 nestedHeap 程序示例以結構體 HeapStruct 為中心,結構體中又有名為 heap_nums 的指針字段:
- typedef struct {
- unsigned id;
- unsigned len;
- float* heap_nums; /** pointer **/
- } HeapStruct;
函數(shù) get_heap_struct 嘗試為 HeapStruct 實例分配堆存儲,這需要為字段 heap_nums 指向的若干個 float 變量分配堆存儲。如果成功調用 get_heap_struct 函數(shù),并將指向堆分配結構體的指針以 hs 命名,其結果可以描述如下:
- hs-->HeapStruct instance
- id
- len
- heap_nums-->N contiguous float elements
在 get_heap_struct 函數(shù)中,第一個堆分配過程很簡單:
- HeapStruct* heap_struct = malloc(sizeof(HeapStruct));
- if (NULL == heap_struct) /* failure? */
- return NULL; /* if so, return NULL */
sizeof(HeapStruct) 包括了 heap_nums 字段的字節(jié)數(shù)(32 位機器上為 4,64 位機器上為 8),heap_nums 字段則是指向動態(tài)分配數(shù)組中的 float 元素的指針。那么,問題關鍵在于 malloc 為這個結構體傳送了字節(jié)空間還是表示失敗的 NULL;如果是 NULL,get_heap_struct 函數(shù)就也返回 NULL 以通知調用者堆分配失敗。
第二步嘗試堆分配的過程更復雜,因為在這一步,HeapStruct 的堆存儲已經(jīng)分配好了:
- heap_struct->heap_nums = malloc(sizeof(float) * n);
- if (NULL == heap_struct->heap_nums) { /* failure? */
- free(heap_struct); /* if so, first free the HeapStruct */
- return NULL; /* and then return NULL */
- }
傳遞給 get_heap_struct 函數(shù)的參數(shù) n 指明動態(tài)分配的 heap_nums 數(shù)組中應該有多少個 float 元素。如果可以分配所需的若干個 float 元素,則該函數(shù)在返回 HeapStruct 的堆地址之前會設置結構的 id 和 len 字段。 但是,如果嘗試分配失敗,則需要兩個步驟來實現(xiàn)最優(yōu)方案:
- 必須釋放 HeapStruct 的存儲以避免內(nèi)存泄漏。對于調用 get_heap_struct 的客戶端函數(shù)而言,沒有動態(tài) heap_nums 數(shù)組的 HeapStruct 可能就是沒用的;因此,HeapStruct 實例的字節(jié)空間應該顯式釋放,以便系統(tǒng)可以回收這些空間用于未來的堆分配。
- 返回 NULL 以標識失敗。
如果成功調用 get_heap_struct 函數(shù),那么釋放堆存儲也很棘手,因為它涉及要以正確順序進行的兩次 free 操作。因此,該程序設計了一個 free_all 函數(shù),而不是要求程序員再去手動實現(xiàn)兩步釋放操作?;仡櫼幌?,free_all 函數(shù)是這樣的:
- void free_all(HeapStruct* heap_struct) {
- if (NULL == heap_struct) /* NULL pointer? */
- return; /* if so, do nothing */
- free(heap_struct->heap_nums); /* first free encapsulated aggregate */
- free(heap_struct); /* then free containing structure */
- }
檢查完參數(shù) heap_struct 不是 NULL 值后,函數(shù)首先釋放 heap_nums 數(shù)組,這步要求 heap_struct 指針此時仍然是有效的。先釋放 heap_struct 的做法是錯誤的。一旦 heap_nums 被釋放,heap_struct 就可以釋放了。如果 heap_struct 被釋放,但 heap_nums 沒有被釋放,那么數(shù)組中的 float 元素就會泄漏:仍然分配了字節(jié)空間,但無法被訪問到——因此一定要記得釋放 heap_nums。存儲泄漏將一直持續(xù),直到 nestedHeap 程序退出,系統(tǒng)回收泄漏的字節(jié)時為止。
關于 free 庫函數(shù)的注意事項就是要有順序。回想一下上面的調用示例:
- free(heap_struct->heap_nums); /* first free encapsulated aggregate */
- free(heap_struct); /* then free containing structure */
這些調用釋放了分配的存儲空間——但它們并 不是 將它們的操作參數(shù)設置為 NULL(free 函數(shù)會獲取地址的副本作為參數(shù);因此,將副本更改為 NULL 并不會改變原地址上的參數(shù)值)。例如,在成功調用 free 之后,指針 heap_struct 仍然持有一些堆分配字節(jié)的堆地址,但是現(xiàn)在使用這個地址將會產(chǎn)生錯誤,因為對 free 的調用使得系統(tǒng)有權回收然后重用這些分配過的字節(jié)。
使用 NULL 參數(shù)調用 free 沒有意義,但也沒有什么壞處。而在非 NULL 的地址上重復調用 free 會導致不確定結果的錯誤:
- free(heap_struct); /* 1st call: ok */
- free(heap_struct); /* 2nd call: ERROR */
內(nèi)存泄漏和堆碎片化
“內(nèi)存泄漏”是指動態(tài)分配的堆存儲變得不再可訪問??匆幌孪嚓P的代碼段:
- float* nums = malloc(sizeof(float) * 10); /* 10 floats */
- nums[0] = 3.14f; /* and so on */
- nums = malloc(sizeof(float) * 25); /* 25 new floats */
假如第一個 malloc 成功,第二個 malloc 會再將 nums 指針重置為 NULL(分配失敗情況下)或是新分配的 25 個 float 中第一個的地址。最初分配的 10 個 float 元素的堆存儲仍然處于被分配狀態(tài),但此時已無法再對其訪問,因為 nums 指針要么指向別處,要么是 NULL。結果就是造成了 40 個字節(jié)(sizeof(float) * 10)的泄漏。
在第二次調用 malloc 之前,應該釋放最初分配的存儲空間:
- float* nums = malloc(sizeof(float) * 10); /* 10 floats */
- nums[0] = 3.14f; /* and so on */
- free(nums); /** good **/
- nums = malloc(sizeof(float) * 25); /* no leakage */
即使沒有泄漏,堆也會隨著時間的推移而碎片化,需要對系統(tǒng)進行碎片整理。例如,假設兩個最大的堆塊當前的大小分別為 200MB 和 100MB。然而,這兩個堆塊并不連續(xù),進程 P 此時又需要分配 250MB 的連續(xù)堆存儲。在進行分配之前,系統(tǒng)可能要對堆進行 碎片整理 以給 P 提供 250MB 連續(xù)存儲空間。碎片整理很復雜,因此也很耗時。
內(nèi)存泄漏會創(chuàng)建處于已分配狀態(tài)但不可訪問的堆塊,從而會加速碎片化。因此,釋放不再需要的堆存儲是程序員幫助減少碎片整理需求的一種方式。
診斷內(nèi)存泄漏的工具
有很多工具可用于分析內(nèi)存效率和安全性,其中我最喜歡的是 valgrind。為了說明該工具如何處理內(nèi)存泄漏,這里給出 leaky 示例程序:
- #include <stdio.h>
- #include <stdlib.h>
- int* get_ints(unsigned n) {
- int* ptr = malloc(n * sizeof(int));
- if (ptr != NULL) {
- unsigned i;
- for (i = 0; i < n; i++) ptr[i] = i + 1;
- }
- return ptr;
- }
- void print_ints(int* ptr, unsigned n) {
- unsigned i;
- for (i = 0; i < n; i++) printf("%3i\n", ptr[i]);
- }
- int main() {
- const unsigned n = 32;
- int* arr = get_ints(n);
- if (arr != NULL) print_ints(arr, n);
- /** heap storage not yet freed... **/
- return 0;
- }
main 函數(shù)調用了 get_ints 函數(shù),后者會試著從堆中 malloc 32 個 4 字節(jié)的 int,然后初始化動態(tài)數(shù)組(如果 malloc 成功)。初始化成功后,main 函數(shù)會調用 print_ints函數(shù)。程序中并沒有調用 free 來對應 malloc 操作;因此,內(nèi)存泄漏了。
如果安裝了 valgrind 工具箱,下面的命令會檢查 leaky 程序是否存在內(nèi)存泄漏(% 是命令行提示符):
- % valgrind --leak-check=full ./leaky
絕大部分輸出都在下面給出了。左邊的數(shù)字 207683 是正在執(zhí)行的 leaky 程序的進程標識符。這份報告給出了泄漏發(fā)生位置的詳細信息,本例中位置是在 main 函數(shù)所調用的 get_ints 函數(shù)中對 malloc 的調用處。
- ==207683== HEAP SUMMARY:
- ==207683== in use at exit: 128 bytes in 1 blocks
- ==207683== total heap usage: 2 allocs, 1 frees, 1,152 bytes allocated
- ==207683==
- ==207683== 128 bytes in 1 blocks are definitely lost in loss record 1 of 1
- ==207683== at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
- ==207683== by 0x109186: get_ints (in /home/marty/gc/leaky)
- ==207683== by 0x109236: main (in /home/marty/gc/leaky)
- ==207683==
- ==207683== LEAK SUMMARY:
- ==207683== definitely lost: 128 bytes in 1 blocks
- ==207683== indirectly lost: 0 bytes in 0 blocks
- ==207683== possibly lost: 0 bytes in 0 blocks
- ==207683== still reachable: 0 bytes in 0 blocks
- ==207683== suppressed: 0 bytes in 0 blocks
如果把 main 函數(shù)改成在對 print_ints 的調用之后,再加上一個對 free 的調用,valgrind 就會對 leaky 程序給出一個干凈的內(nèi)存健康清單:
- ==218462== All heap blocks were freed -- no leaks are possible
靜態(tài)區(qū)存儲
在正統(tǒng)的 C 語言中,函數(shù)必須在所有塊之外定義。這是一些 C 編譯器支持的特性,杜絕了在另一個函數(shù)體內(nèi)定義一個函數(shù)的可能。我舉的例子都是在所有塊之外定義的函數(shù)。這樣的函數(shù)要么是 static ,即靜態(tài)的,要么是 extern,即外部的,其中 extern 是默認值。
C 語言中,以 static 或 extern 修飾的函數(shù)和變量駐留在內(nèi)存中所謂的 靜態(tài)區(qū) 中,因為在程序執(zhí)行期間該區(qū)域大小是固定不變的。這兩個存儲類型的語法非常復雜,我們應該回顧一下。在回顧之后,會有一個完整的代碼示例來生動展示語法細節(jié)。在所有塊之外定義的函數(shù)或變量默認為 extern;因此,函數(shù)和變量要想存儲類型為 static ,必須顯式指定:
- /** file1.c: outside all blocks, five definitions **/
- int foo(int n) { return n * 2; } /* extern by default */
- static int bar(int n) { return n; } /* static */
- extern int baz(int n) { return -n; } /* explicitly extern */
- int num1; /* extern */
- static int num2; /* static */
extern 和 static 的區(qū)別在于作用域:extern 修飾的函數(shù)或變量可以實現(xiàn)跨文件可見(需要聲明)。相比之下,static 修飾的函數(shù)僅在 定義 該函數(shù)的文件中可見,而 static 修飾的變量僅在 定義 該變量的文件(或文件中的塊)中可見:
- static int n1; /* scope is the file */
- void func() {
- static int n2; /* scope is func's body */
- ...
- }
如果在所有塊之外定義了 static 變量,例如上面的 n1,該變量的作用域就是定義變量的文件。無論在何處定義 static 變量,變量的存儲都在內(nèi)存的靜態(tài)區(qū)中。
extern 函數(shù)或變量在給定文件中的所有塊之外定義,但這樣定義的函數(shù)或變量也可以在其他文件中聲明。典型的做法是在頭文件中 聲明 這樣的函數(shù)或變量,只要需要就可以包含進來。下面這些簡短的例子闡述了這些棘手的問題。
假設 extern 函數(shù) foo 在 file1.c 中 定義,有無關鍵字 extern 效果都一樣:
- /** file1.c **/
- int foo(int n) { return n * 2; } /* definition has a body {...} */
必須在其他文件(或其中的塊)中使用顯式的 extern 聲明 此函數(shù)才能使其可見。以下是使 extern 函數(shù) foo 在文件 file2.c 中可見的聲明語句:
- /** file2.c: make function foo visible here **/
- extern int foo(int); /* declaration (no body) */
回想一下,函數(shù)聲明沒有用大括號括起來的主體,而函數(shù)定義會有這樣的主體。
為了便于查看,函數(shù)和變量聲明通常會放在頭文件中。準備好需要聲明的源代碼文件,然后就可以 #include 相關的頭文件。下一節(jié)中的 staticProg 程序演示了這種方法。
至于 extern 的變量,規(guī)則就變得更棘手了(很抱歉增加了難度!)。任何 extern 的對象——無論函數(shù)或變量——必須 定義 在所有塊之外。此外,在所有塊之外定義的變量默認為 extern:
- /** outside all blocks **/
- int n; /* defaults to extern */
但是,只有在變量的 定義 中顯式初始化變量時,extern 才能在變量的 定義 中顯式修飾(LCTT 譯注:換言之,如果下列代碼中的 int n1; 行前加上 extern,該行就由 定義 變成了 聲明):
- /** file1.c: outside all blocks **/
- int n1; /* defaults to extern, initialized by compiler to zero */
- extern int n2 = -1; /* ok, initialized explicitly */
- int n3 = 9876; /* ok, extern by default and initialized explicitly */
要使在 file1.c 中定義為 extern 的變量在另一個文件(例如 file2.c)中可見,該變量必須在 file2.c 中顯式 聲明 為 extern 并且不能初始化(初始化會將聲明轉換為定義):
- /** file2.c **/
- extern int n1; /* declaration of n1 defined in file1.c */
為了避免與 extern 變量混淆,經(jīng)驗是在 聲明 中顯式使用 extern(必須),但不要在 定義 中使用(非必須且棘手)。對于函數(shù),extern 在定義中是可選使用的,但在聲明中是必須使用的。下一節(jié)中的 staticProg 示例會把這些點整合到一個完整的程序中。
staticProg 示例
staticProg 程序由三個文件組成:兩個 C 語言源文件(static1.c 和 static2.c)以及一個頭文件(static.h),頭文件中包含兩個聲明:
- /** header file static.h **/
- #define NumCount 100 /* macro */
- extern int global_nums[NumCount]; /* array declaration */
- extern void fill_array(); /* function declaration */
兩個聲明中的 extern,一個用于數(shù)組,另一個用于函數(shù),強調對象在別處(“外部”)定義:數(shù)組 global_nums 在文件 static1.c 中定義(沒有顯式的 extern),函數(shù) fill_array 在文件 static2.c 中定義(也沒有顯式的 extern)。每個源文件都包含了頭文件 static.h。static1.c 文件定義了兩個駐留在內(nèi)存靜態(tài)區(qū)域中的數(shù)組(global_nums 和 more_nums)。第二個數(shù)組有 static 修飾,這將其作用域限制為定義數(shù)組的文件 (static1.c)。如前所述, extern 修飾的 global_nums 則可以實現(xiàn)在多個文件中可見。
- /** static1.c **/
- #include <stdio.h>
- #include <stdlib.h>
- #include "static.h" /* declarations */
- int global_nums[NumCount]; /* definition: extern (global) aggregate */
- static int more_nums[NumCount]; /* definition: scope limited to this file */
- int main() {
- fill_array(); /** defined in file static2.c **/
- unsigned i;
- for (i = 0; i < NumCount; i++)
- more_nums[i] = i * -1;
- /* confirm initialization worked */
- for (i = 0; i < NumCount; i += 10)
- printf("%4i\t%4i\n", global_nums[i], more_nums[i]);
- return 0;
- }
下面的 static2.c 文件中定義了 fill_array 函數(shù),該函數(shù)由 main(在 static1.c 文件中)調用;fill_array 函數(shù)會給名為 global_nums 的 extern 數(shù)組中的元素賦值,該數(shù)組在文件 static1.c 中定義。使用兩個文件的唯一目的是凸顯 extern 變量或函數(shù)能夠跨文件可見。
- /** static2.c **/
- #include "static.h" /** declarations **/
- void fill_array() { /** definition **/
- unsigned i;
- for (i = 0; i < NumCount; i++) global_nums[i] = i + 2;
- }
staticProg 程序可以用如下編譯:
- % gcc -o staticProg static1.c static2.c
從匯編語言看更多細節(jié)
現(xiàn)代 C 編譯器能夠處理 C 和匯編語言的任意組合。編譯 C 源文件時,編譯器首先將 C 代碼翻譯成匯編語言。這是對從上文 static1.c 文件生成的匯編語言進行保存的命令:
- % gcc -S static1.c
生成的文件就是 static1.s。這是文件頂部的一段代碼,額外添加了行號以提高可讀性:
- .file "static1.c" ## line 1
- .text ## line 2
- .comm global_nums,400,32 ## line 3
- .local more_nums ## line 4
- .comm more_nums,400,32 ## line 5
- .section .rodata ## line 6
- .LC0: ## line 7
- .string "%4i\t%4i\n" ## line 8
- .text ## line 9
- .globl main ## line 10
- .type main, @function ## line 11
- main: ## line 12
- ...
諸如 .file(第 1 行)之類的匯編語言指令以句點開頭。顧名思義,指令會指導匯編程序將匯編語言翻譯成機器代碼。.rodata 指令(第 6 行)表示后面是只讀對象,包括字符串常量 "%4i\t%4i\n"(第 8 行),main 函數(shù)(第 12 行)會使用此字符串常量來實現(xiàn)格式化輸出。作為標簽引入(通過末尾的冒號實現(xiàn))的 main 函數(shù)(第 12 行),同樣也是只讀的。
在匯編語言中,標簽就是地址。標簽 main:(第 12 行)標記了 main 函數(shù)代碼開始的地址,標簽 .LC0:(第 7 行)標記了格式化字符串開頭所在的地址。
global_nums(第 3 行)和 more_nums(第 4 行)數(shù)組的定義包含了兩個數(shù)字:400 是每個數(shù)組中的總字節(jié)數(shù),32 是每個數(shù)組(含 100 個 int 元素)中每個元素的比特數(shù)。(第 5 行中的 .comm 指令表示 common name,可以忽略。)
兩個數(shù)組定義的不同之處在于 more_nums 被標記為 .local(第 4 行),這意味著其作用域僅限于其所在文件 static1.s。相比之下,global_nums 數(shù)組就能在多個文件中實現(xiàn)可見,包括由 static1.c 和 static2.c 文件翻譯成的匯編文件。
最后,.text 指令在匯編代碼段中出現(xiàn)了兩次(第 2 行和第 9 行)。術語“text”表示“只讀”,但也會涵蓋一些讀/寫變量,例如兩個數(shù)組中的元素。盡管本文展示的匯編語言是針對 Intel 架構的,但 Arm6 匯編也非常相似。對于這兩種架構,.text 區(qū)域中的變量(本例中為兩個數(shù)組中的元素)會自動初始化為零。
總結
C 語言中的內(nèi)存高效和內(nèi)存安全編程準則很容易說明,但可能會很難遵循,尤其是在調用設計不佳的庫的時候。準則如下:
- 盡可能使用棧存儲,進而鼓勵編譯器將通用寄存器用作暫存器,實現(xiàn)優(yōu)化。棧存儲代表了高效的內(nèi)存使用并促進了代碼的整潔和模塊化。永遠不要返回指向基于棧的存儲的指針。
- 小心使用堆存儲。C(和 C++)中的重難點是確保動態(tài)分配的存儲盡快解除分配。良好的編程習慣和工具(如 valgrind)有助于攻關這些重難點。優(yōu)先選用自身提供釋放函數(shù)的庫,例如 nestedHeap 代碼示例中的 free_all 釋放函數(shù)。
- 謹慎使用靜態(tài)存儲,因為這種存儲會自始至終地影響進程的內(nèi)存占用。特別是盡量避免使用 extern 和 static 數(shù)組。