實(shí)例講解代碼之內(nèi)存安全與效率
了解有關(guān)內(nèi)存安全和效率的更多信息。
C 是一種高級(jí)語(yǔ)言,同時(shí)具有“接近金屬close-to-the-metal”(LCTT 譯注:即“接近人類思維方式”的反義詞)的特性,這使得它有時(shí)看起來(lái)更像是一種可移植的匯編語(yǔ)言,而不像 Java 或 Python 這樣的兄弟語(yǔ)言。內(nèi)存管理作為上述特性之一,涵蓋了正在執(zhí)行的程序?qū)?nèi)存的安全和高效使用。本文通過(guò) C 語(yǔ)言代碼示例,以及現(xiàn)代 C 語(yǔ)言編譯器生成的匯編語(yǔ)言代碼段,詳細(xì)介紹了內(nèi)存安全性和效率。
盡管代碼示例是用 C 語(yǔ)言編寫的,但安全高效的內(nèi)存管理指南對(duì)于 C++ 是同樣適用的。這兩種語(yǔ)言在很多細(xì)節(jié)上有所不同(例如,C++ 具有 C 所缺乏的面向?qū)ο筇匦院头盒?,但在內(nèi)存管理方面面臨的挑戰(zhàn)是一樣的。
執(zhí)行中程序的內(nèi)存概述
對(duì)于正在執(zhí)行的程序(又名 進(jìn)程process),內(nèi)存被劃分為三個(gè)區(qū)域:棧stack、堆heap 和 靜態(tài)區(qū)static area。下文會(huì)給出每個(gè)區(qū)域的概述,以及完整的代碼示例。
作為通用 CPU 寄存器的替補(bǔ),棧 為代碼塊(例如函數(shù)或循環(huán)體)中的局部變量提供暫存器存儲(chǔ)。傳遞給函數(shù)的參數(shù)在此上下文中也視作局部變量。看一下下面這個(gè)簡(jiǎn)短的示例:
- void some_func(int a, int b) {
- int n;
- ...
- }
通過(guò) a 和 b 傳遞的參數(shù)以及局部變量 n 的存儲(chǔ)會(huì)在棧中,除非編譯器可以找到通用寄存器。編譯器傾向于優(yōu)先將通用寄存器用作暫存器,因?yàn)?CPU 對(duì)這些寄存器的訪問(wèn)速度很快(一個(gè)時(shí)鐘周期)。然而,這些寄存器在臺(tái)式機(jī)、筆記本電腦和手持機(jī)器的標(biāo)準(zhǔn)架構(gòu)上很少(大約 16 個(gè))。
在只有匯編語(yǔ)言程序員才能看到的實(shí)施層面,棧被組織為具有 push(插入)和 pop(刪除)操作的 LIFO(后進(jìn)先出)列表。 top 指針可以作為偏移的基地址;這樣,除了 top 之外的棧位置也變得可訪問(wèn)了。例如,表達(dá)式 top+16 指向堆棧的 top 指針上方 16 個(gè)字節(jié)的位置,表達(dá)式 top-16 指向 top 指針下方 16 個(gè)字節(jié)的位置。因此,可以通過(guò) top 指針訪問(wèn)實(shí)現(xiàn)了暫存器存儲(chǔ)的棧的位置。在標(biāo)準(zhǔn)的 ARM 或 Intel 架構(gòu)中,棧從高內(nèi)存地址增長(zhǎng)到低內(nèi)存地址;因此,減小某進(jìn)程的 top 就是增大其棧規(guī)模。
使用棧結(jié)構(gòu)就意味著輕松高效地使用內(nèi)存。編譯器(而非程序員)會(huì)編寫管理?xiàng)5拇a,管理過(guò)程通過(guò)分配和釋放所需的暫存器存儲(chǔ)來(lái)實(shí)現(xiàn);程序員聲明函數(shù)參數(shù)和局部變量,將實(shí)現(xiàn)過(guò)程交給編譯器。此外,完全相同的棧存儲(chǔ)可以在連續(xù)的函數(shù)調(diào)用和代碼塊(如循環(huán))中重復(fù)使用。精心設(shè)計(jì)的模塊化代碼會(huì)將棧存儲(chǔ)作為暫存器的首選內(nèi)存選項(xiàng),同時(shí)優(yōu)化編譯器要盡可能使用通用寄存器而不是棧。
堆 提供的存儲(chǔ)是通過(guò)程序員代碼顯式分配的,堆分配的語(yǔ)法因語(yǔ)言而異。在 C 中,成功調(diào)用庫(kù)函數(shù) malloc(或其變體 calloc 等)會(huì)分配指定數(shù)量的字節(jié)(在 C++ 和 Java 等語(yǔ)言中,new 運(yùn)算符具有相同的用途)。編程語(yǔ)言在如何釋放堆分配的存儲(chǔ)方面有著巨大的差異:
在 Java、Go、Lisp 和 Python 等語(yǔ)言中,程序員不會(huì)顯式釋放動(dòng)態(tài)分配的堆存儲(chǔ)。
例如,下面這個(gè) Java 語(yǔ)句為一個(gè)字符串分配了堆存儲(chǔ),并將這個(gè)堆存儲(chǔ)的地址存儲(chǔ)在變量 greeting 中:
- String greeting = new String("Hello, world!");
Java 有一個(gè)垃圾回收器,它是一個(gè)運(yùn)行時(shí)實(shí)用程序,如果進(jìn)程無(wú)法再訪問(wèn)自己分配的堆存儲(chǔ),回收器可以使其自動(dòng)釋放。因此,Java 堆釋放是通過(guò)垃圾收集器自動(dòng)進(jìn)行的。在上面的示例中,垃圾收集器將在變量 greeting 超出作用域后,釋放字符串的堆存儲(chǔ)。
Rust 編譯器會(huì)編寫堆釋放代碼。這是 Rust 在不依賴?yán)厥掌鞯那闆r下,使堆釋放實(shí)現(xiàn)自動(dòng)化的開(kāi)創(chuàng)性努力,但這也會(huì)帶來(lái)運(yùn)行時(shí)復(fù)雜性和開(kāi)銷。向 Rust 的努力致敬!
在 C(和 C++)中,堆釋放是程序員的任務(wù)。程序員調(diào)用 malloc 分配堆存儲(chǔ),然后負(fù)責(zé)相應(yīng)地調(diào)用庫(kù)函數(shù) free 來(lái)釋放該存儲(chǔ)空間(在 C++ 中,new 運(yùn)算符分配堆存儲(chǔ),而 delete 和 delete[] 運(yùn)算符釋放此類存儲(chǔ))。下面是一個(gè) C 語(yǔ)言代碼示例:
- 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 語(yǔ)言避免了垃圾回收器的成本和復(fù)雜性,但也不過(guò)是讓程序員承擔(dān)了堆釋放的任務(wù)。
內(nèi)存的 靜態(tài)區(qū) 為可執(zhí)行代碼(例如 C 語(yǔ)言函數(shù))、字符串文字(例如“Hello, world!”)和全局變量提供存儲(chǔ)空間:
- int n; /* global variable */
- int main() { /* function */
- char* msg = "No comment"; /* string literal */
- ...
- }
該區(qū)域是靜態(tài)的,因?yàn)樗拇笮倪M(jìn)程執(zhí)行開(kāi)始到結(jié)束都固定不變。由于靜態(tài)區(qū)相當(dāng)于進(jìn)程固定大小的內(nèi)存占用,因此經(jīng)驗(yàn)法則是通過(guò)避免使用全局?jǐn)?shù)組等方法來(lái)使該區(qū)域盡可能小。
下文會(huì)結(jié)合代碼示例對(duì)本節(jié)概述展開(kāi)進(jìn)一步講解。
棧存儲(chǔ)
想象一個(gè)有各種連續(xù)執(zhí)行的任務(wù)的程序,任務(wù)包括了處理每隔幾分鐘通過(guò)網(wǎng)絡(luò)下載并存儲(chǔ)在本地文件中的數(shù)字?jǐn)?shù)據(jù)。下面的 stack 程序簡(jiǎn)化了處理流程(僅是將奇數(shù)整數(shù)值轉(zhuǎn)換為偶數(shù)),而將重點(diǎn)放在棧存儲(chǔ)的好處上。
- #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ù)首先調(diào)用 process_data 函數(shù),該函數(shù)會(huì)創(chuàng)建一個(gè)基于棧的數(shù)組,其大小由參數(shù) n 給定(當(dāng)前示例中為 128,000)。因此,該數(shù)組占用 128000 * sizeof(int) 個(gè)字節(jié),在標(biāo)準(zhǔn)設(shè)備上達(dá)到了 512,000 字節(jié)(int 在這些設(shè)備上是四個(gè)字節(jié))。然后數(shù)據(jù)會(huì)被讀入數(shù)組(使用庫(kù)函數(shù) fread),循環(huán)處理,并保存到本地文件 outgoing.dat(使用庫(kù)函數(shù) fwrite)。
當(dāng) process_data 函數(shù)返回到其調(diào)用者 main 函數(shù)時(shí),process_data 函數(shù)的大約 500MB 棧暫存器可供 stack 程序中的其他函數(shù)用作暫存器。在此示例中,main 函數(shù)接下來(lái)調(diào)用存根函數(shù) other_task1 和 other_task2。這三個(gè)函數(shù)在 main 中依次調(diào)用,這意味著所有三個(gè)函數(shù)都可以使用相同的堆棧存儲(chǔ)作為暫存器。因?yàn)榫帉憲9芾泶a的是編譯器而不是程序員,所以這種方法對(duì)程序員來(lái)說(shuō)既高效又容易。
在 C 語(yǔ)言中,在塊(例如函數(shù)或循環(huán)體)內(nèi)定義的任何變量默認(rèn)都有一個(gè) auto 存儲(chǔ)類,這意味著該變量是基于棧的。存儲(chǔ)類 register 現(xiàn)在已經(jīng)過(guò)時(shí)了,因?yàn)?C 編譯器會(huì)主動(dòng)嘗試盡可能使用 CPU 寄存器。只有在塊內(nèi)定義的變量可能是 register,如果沒(méi)有可用的 CPU 寄存器,編譯器會(huì)將其更改為 auto。基于棧的編程可能是不錯(cuò)的首選方式,但這種風(fēng)格確實(shí)有一些挑戰(zhàn)性。下面的 badStack 程序說(shuō)明了這點(diǎn)。
- #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 程序中的控制流程很簡(jiǎn)單。main 函數(shù)使用 16(LCTT 譯注:原文為 128,應(yīng)為作者筆誤)作為參數(shù)調(diào)用函數(shù) get_array,然后被調(diào)用函數(shù)會(huì)使用傳入?yún)?shù)來(lái)創(chuàng)建對(duì)應(yīng)大小的本地?cái)?shù)組。get_array 函數(shù)會(huì)初始化數(shù)組并返回給 main 中的數(shù)組標(biāo)識(shí)符 arr。 arr 是一個(gè)指針常量,保存數(shù)組的第一個(gè) int 元素的地址。
當(dāng)然,本地?cái)?shù)組 arr 可以在 get_array 函數(shù)中訪問(wèn),但是一旦 get_array 返回,就不能合法訪問(wèn)該數(shù)組。盡管如此,main 函數(shù)會(huì)嘗試使用函數(shù) get_array 返回的堆棧地址 arr 來(lái)打印基于棧的數(shù)組。現(xiàn)代編譯器會(huì)警告錯(cuò)誤。例如,下面是來(lái)自 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ī)則是,如果使用棧存儲(chǔ)實(shí)現(xiàn)局部變量,應(yīng)該僅在該變量所在的代碼塊內(nèi),訪問(wèn)這塊基于棧的存儲(chǔ)(在本例中,數(shù)組指針 arr 和循環(huán)計(jì)數(shù)器 i 均為這樣的局部變量)。因此,函數(shù)永遠(yuǎn)不應(yīng)該返回指向基于棧存儲(chǔ)的指針。
堆存儲(chǔ)
接下來(lái)使用若干代碼示例凸顯在 C 語(yǔ)言中使用堆存儲(chǔ)的優(yōu)點(diǎn)。在第一個(gè)示例中,使用了最優(yōu)方案分配、使用和釋放堆存儲(chǔ)。第二個(gè)示例(在下一節(jié)中)將堆存儲(chǔ)嵌套在了其他堆存儲(chǔ)中,這會(huì)使其釋放操作變得復(fù)雜。
- #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 程序有兩個(gè)函數(shù): main 函數(shù)使用參數(shù)(示例中為 100)調(diào)用 get_heap_array 函數(shù),參數(shù)用來(lái)指定數(shù)組應(yīng)該有多少個(gè) int 元素。因?yàn)槎逊峙淇赡軙?huì)失敗,main 函數(shù)會(huì)檢查 get_heap_array 是否返回了 NULL;如果是,則表示失敗。如果分配成功,main 將打印數(shù)組中的 int 值,然后立即調(diào)用庫(kù)函數(shù) free 來(lái)對(duì)堆存儲(chǔ)解除分配。這就是最優(yōu)的方案。
get_heap_array 函數(shù)以下列語(yǔ)句開(kāi)頭,該語(yǔ)句值得仔細(xì)研究一下:
- int* heap_nums = malloc(sizeof(int) * n); /* heap allocation */
malloc 庫(kù)函數(shù)及其變體函數(shù)針對(duì)字節(jié)進(jìn)行操作;因此,malloc 的參數(shù)是 n 個(gè) int 類型元素所需的字節(jié)數(shù)(sizeof(int) 在標(biāo)準(zhǔn)現(xiàn)代設(shè)備上是四個(gè)字節(jié))。malloc 函數(shù)返回所分配字節(jié)段的首地址,如果失敗則返回 NULL .
如果成功調(diào)用 malloc,在現(xiàn)代臺(tái)式機(jī)上其返回的地址大小為 64 位。在手持設(shè)備和早些時(shí)候的臺(tái)式機(jī)上,該地址的大小可能是 32 位,或者甚至更小,具體取決于其年代。堆分配數(shù)組中的元素是 int 類型,這是一個(gè)四字節(jié)的有符號(hào)整數(shù)。這些堆分配的 int 的地址存儲(chǔ)在基于棧的局部變量 heap_nums 中。可以參考下圖:
- heap-based
- k-based /
- \ +----+----+ +----+
- -nums--->|int1|int2|...|intN|
- +----+----+ +----+
一旦 get_heap_array 函數(shù)返回,指針變量 heap_nums 的棧存儲(chǔ)將自動(dòng)回收——但動(dòng)態(tài) int 數(shù)組的堆存儲(chǔ)仍然存在,這就是 get_heap_array 函數(shù)返回這個(gè)地址(的副本)給 main 函數(shù)的原因:它現(xiàn)在負(fù)責(zé)在打印數(shù)組的整數(shù)后,通過(guò)調(diào)用庫(kù)函數(shù) free 顯式釋放堆存儲(chǔ):
- free(heap_nums); /* free the heap storage */
malloc 函數(shù)不會(huì)初始化堆分配的存儲(chǔ)空間,因此里面是隨機(jī)值。相比之下,其變體函數(shù) calloc 會(huì)將分配的存儲(chǔ)初始化為零。這兩個(gè)函數(shù)都返回 NULL 來(lái)表示分配失敗。
在 heap 示例中,main 函數(shù)在調(diào)用 free 后會(huì)立即返回,正在執(zhí)行的程序會(huì)終止,這會(huì)讓系統(tǒng)回收所有已分配的堆存儲(chǔ)。盡管如此,程序員應(yīng)該養(yǎng)成在不再需要時(shí)立即顯式釋放堆存儲(chǔ)的習(xí)慣。
嵌套堆分配
下一個(gè)代碼示例會(huì)更棘手一些。C 語(yǔ)言有很多返回指向堆存儲(chǔ)的指針的庫(kù)函數(shù)。下面是一個(gè)常見(jiàn)的使用情景:
1、C 程序調(diào)用一個(gè)庫(kù)函數(shù),該函數(shù)返回一個(gè)指向基于堆的存儲(chǔ)的指針,而指向的存儲(chǔ)通常是一個(gè)聚合體,如數(shù)組或結(jié)構(gòu)體:
- SomeStructure* ptr = lib_function(); /* returns pointer to heap storage */
2、 然后程序使用所分配的存儲(chǔ)。
3、 對(duì)于清理而言,問(wèn)題是對(duì) free 的簡(jiǎn)單調(diào)用是否會(huì)清理庫(kù)函數(shù)分配的所有堆分配存儲(chǔ)。例如,SomeStructure 實(shí)例可能有指向堆分配存儲(chǔ)的字段。一個(gè)特別麻煩的情況是動(dòng)態(tài)分配的結(jié)構(gòu)體數(shù)組,每個(gè)結(jié)構(gòu)體有一個(gè)指向又一層動(dòng)態(tài)分配的存儲(chǔ)的字段。下面的代碼示例說(shuō)明了這個(gè)問(wèn)題,并重點(diǎn)關(guān)注了如何設(shè)計(jì)一個(gè)可以安全地為客戶端提供堆分配存儲(chǔ)的庫(kù)。
- #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 程序示例以結(jié)構(gòu)體 HeapStruct 為中心,結(jié)構(gòu)體中又有名為 heap_nums 的指針字段:
- typedef struct {
- unsigned id;
- unsigned len;
- float* heap_nums; /** pointer **/
- } HeapStruct;
函數(shù) get_heap_struct 嘗試為 HeapStruct 實(shí)例分配堆存儲(chǔ),這需要為字段 heap_nums 指向的若干個(gè) float 變量分配堆存儲(chǔ)。如果成功調(diào)用 get_heap_struct 函數(shù),并將指向堆分配結(jié)構(gòu)體的指針以 hs 命名,其結(jié)果可以描述如下:
- hs-->HeapStruct instance
- id
- len
- heap_nums-->N contiguous float elements
在 get_heap_struct 函數(shù)中,第一個(gè)堆分配過(guò)程很簡(jiǎn)單:
- HeapStruct* heap_struct = malloc(sizeof(HeapStruct));
- if (NULL == heap_struct) /* failure? */
- return NULL; /* if so, return NULL */
sizeof(HeapStruct) 包括了 heap_nums 字段的字節(jié)數(shù)(32 位機(jī)器上為 4,64 位機(jī)器上為 8),heap_nums 字段則是指向動(dòng)態(tài)分配數(shù)組中的 float 元素的指針。那么,問(wèn)題關(guān)鍵在于 malloc 為這個(gè)結(jié)構(gòu)體傳送了字節(jié)空間還是表示失敗的 NULL;如果是 NULL,get_heap_struct 函數(shù)就也返回 NULL 以通知調(diào)用者堆分配失敗。
第二步嘗試堆分配的過(guò)程更復(fù)雜,因?yàn)樵谶@一步,HeapStruct 的堆存儲(chǔ)已經(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 指明動(dòng)態(tài)分配的 heap_nums 數(shù)組中應(yīng)該有多少個(gè) float 元素。如果可以分配所需的若干個(gè) float 元素,則該函數(shù)在返回 HeapStruct 的堆地址之前會(huì)設(shè)置結(jié)構(gòu)的 id 和 len 字段。 但是,如果嘗試分配失敗,則需要兩個(gè)步驟來(lái)實(shí)現(xiàn)最優(yōu)方案:
- 必須釋放 HeapStruct 的存儲(chǔ)以避免內(nèi)存泄漏。對(duì)于調(diào)用 get_heap_struct 的客戶端函數(shù)而言,沒(méi)有動(dòng)態(tài) heap_nums 數(shù)組的 HeapStruct 可能就是沒(méi)用的;因此,HeapStruct 實(shí)例的字節(jié)空間應(yīng)該顯式釋放,以便系統(tǒng)可以回收這些空間用于未來(lái)的堆分配。
- 返回 NULL 以標(biāo)識(shí)失敗。
如果成功調(diào)用 get_heap_struct 函數(shù),那么釋放堆存儲(chǔ)也很棘手,因?yàn)樗婕耙哉_順序進(jìn)行的兩次 free 操作。因此,該程序設(shè)計(jì)了一個(gè) free_all 函數(shù),而不是要求程序員再去手動(dòng)實(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 指針此時(shí)仍然是有效的。先釋放 heap_struct 的做法是錯(cuò)誤的。一旦 heap_nums 被釋放,heap_struct 就可以釋放了。如果 heap_struct 被釋放,但 heap_nums 沒(méi)有被釋放,那么數(shù)組中的 float 元素就會(huì)泄漏:仍然分配了字節(jié)空間,但無(wú)法被訪問(wèn)到——因此一定要記得釋放 heap_nums。存儲(chǔ)泄漏將一直持續(xù),直到 nestedHeap 程序退出,系統(tǒng)回收泄漏的字節(jié)時(shí)為止。
關(guān)于 free 庫(kù)函數(shù)的注意事項(xiàng)就是要有順序。回想一下上面的調(diào)用示例:
- free(heap_struct->heap_nums); /* first free encapsulated aggregate */
- free(heap_struct); /* then free containing structure */
這些調(diào)用釋放了分配的存儲(chǔ)空間——但它們并 不是 將它們的操作參數(shù)設(shè)置為 NULL(free 函數(shù)會(huì)獲取地址的副本作為參數(shù);因此,將副本更改為 NULL 并不會(huì)改變?cè)刂飞系膮?shù)值)。例如,在成功調(diào)用 free 之后,指針 heap_struct 仍然持有一些堆分配字節(jié)的堆地址,但是現(xiàn)在使用這個(gè)地址將會(huì)產(chǎn)生錯(cuò)誤,因?yàn)閷?duì) free 的調(diào)用使得系統(tǒng)有權(quán)回收然后重用這些分配過(guò)的字節(jié)。
使用 NULL 參數(shù)調(diào)用 free 沒(méi)有意義,但也沒(méi)有什么壞處。而在非 NULL 的地址上重復(fù)調(diào)用 free 會(huì)導(dǎo)致不確定結(jié)果的錯(cuò)誤:
- free(heap_struct); /* 1st call: ok */
- free(heap_struct); /* 2nd call: ERROR */
內(nèi)存泄漏和堆碎片化
“內(nèi)存泄漏”是指動(dòng)態(tài)分配的堆存儲(chǔ)變得不再可訪問(wèn)。看一下相關(guān)的代碼段:
- float* nums = malloc(sizeof(float) * 10); /* 10 floats */
- nums[0] = 3.14f; /* and so on */
- nums = malloc(sizeof(float) * 25); /* 25 new floats */
假如第一個(gè) malloc 成功,第二個(gè) malloc 會(huì)再將 nums 指針重置為 NULL(分配失敗情況下)或是新分配的 25 個(gè) float 中第一個(gè)的地址。最初分配的 10 個(gè) float 元素的堆存儲(chǔ)仍然處于被分配狀態(tài),但此時(shí)已無(wú)法再對(duì)其訪問(wèn),因?yàn)?nums 指針要么指向別處,要么是 NULL。結(jié)果就是造成了 40 個(gè)字節(jié)(sizeof(float) * 10)的泄漏。
在第二次調(diào)用 malloc 之前,應(yīng)該釋放最初分配的存儲(chǔ)空間:
- 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 */
即使沒(méi)有泄漏,堆也會(huì)隨著時(shí)間的推移而碎片化,需要對(duì)系統(tǒng)進(jìn)行碎片整理。例如,假設(shè)兩個(gè)最大的堆塊當(dāng)前的大小分別為 200MB 和 100MB。然而,這兩個(gè)堆塊并不連續(xù),進(jìn)程 P 此時(shí)又需要分配 250MB 的連續(xù)堆存儲(chǔ)。在進(jìn)行分配之前,系統(tǒng)可能要對(duì)堆進(jìn)行 碎片整理 以給 P 提供 250MB 連續(xù)存儲(chǔ)空間。碎片整理很復(fù)雜,因此也很耗時(shí)。
內(nèi)存泄漏會(huì)創(chuàng)建處于已分配狀態(tài)但不可訪問(wèn)的堆塊,從而會(huì)加速碎片化。因此,釋放不再需要的堆存儲(chǔ)是程序員幫助減少碎片整理需求的一種方式。
診斷內(nèi)存泄漏的工具
有很多工具可用于分析內(nèi)存效率和安全性,其中我最喜歡的是 valgrind。為了說(shuō)明該工具如何處理內(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ù)調(diào)用了 get_ints 函數(shù),后者會(huì)試著從堆中 malloc 32 個(gè) 4 字節(jié)的 int,然后初始化動(dòng)態(tài)數(shù)組(如果 malloc 成功)。初始化成功后,main 函數(shù)會(huì)調(diào)用 print_ints函數(shù)。程序中并沒(méi)有調(diào)用 free 來(lái)對(duì)應(yīng) malloc 操作;因此,內(nèi)存泄漏了。
如果安裝了 valgrind 工具箱,下面的命令會(huì)檢查 leaky 程序是否存在內(nèi)存泄漏(% 是命令行提示符):
- % valgrind --leak-check=full ./leaky
絕大部分輸出都在下面給出了。左邊的數(shù)字 207683 是正在執(zhí)行的 leaky 程序的進(jìn)程標(biāo)識(shí)符。這份報(bào)告給出了泄漏發(fā)生位置的詳細(xì)信息,本例中位置是在 main 函數(shù)所調(diào)用的 get_ints 函數(shù)中對(duì) malloc 的調(diào)用處。
- ==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ù)改成在對(duì) print_ints 的調(diào)用之后,再加上一個(gè)對(duì) free 的調(diào)用,valgrind 就會(huì)對(duì) leaky 程序給出一個(gè)干凈的內(nèi)存健康清單:
- ==218462== All heap blocks were freed -- no leaks are possible
靜態(tài)區(qū)存儲(chǔ)
在正統(tǒng)的 C 語(yǔ)言中,函數(shù)必須在所有塊之外定義。這是一些 C 編譯器支持的特性,杜絕了在另一個(gè)函數(shù)體內(nèi)定義一個(gè)函數(shù)的可能。我舉的例子都是在所有塊之外定義的函數(shù)。這樣的函數(shù)要么是 static ,即靜態(tài)的,要么是 extern,即外部的,其中 extern 是默認(rèn)值。
C 語(yǔ)言中,以 static 或 extern 修飾的函數(shù)和變量駐留在內(nèi)存中所謂的 靜態(tài)區(qū) 中,因?yàn)樵诔绦驁?zhí)行期間該區(qū)域大小是固定不變的。這兩個(gè)存儲(chǔ)類型的語(yǔ)法非常復(fù)雜,我們應(yīng)該回顧一下。在回顧之后,會(huì)有一個(gè)完整的代碼示例來(lái)生動(dòng)展示語(yǔ)法細(xì)節(jié)。在所有塊之外定義的函數(shù)或變量默認(rèn)為 extern;因此,函數(shù)和變量要想存儲(chǔ)類型為 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ù)或變量可以實(shí)現(xiàn)跨文件可見(jiàn)(需要聲明)。相比之下,static 修飾的函數(shù)僅在 定義 該函數(shù)的文件中可見(jiàn),而 static 修飾的變量?jī)H在 定義 該變量的文件(或文件中的塊)中可見(jiàn):
- static int n1; /* scope is the file */
- void func() {
- static int n2; /* scope is func's body */
- ...
- }
如果在所有塊之外定義了 static 變量,例如上面的 n1,該變量的作用域就是定義變量的文件。無(wú)論在何處定義 static 變量,變量的存儲(chǔ)都在內(nèi)存的靜態(tài)區(qū)中。
extern 函數(shù)或變量在給定文件中的所有塊之外定義,但這樣定義的函數(shù)或變量也可以在其他文件中聲明。典型的做法是在頭文件中 聲明 這樣的函數(shù)或變量,只要需要就可以包含進(jìn)來(lái)。下面這些簡(jiǎn)短的例子闡述了這些棘手的問(wèn)題。
假設(shè) extern 函數(shù) foo 在 file1.c 中 定義,有無(wú)關(guān)鍵字 extern 效果都一樣:
- /** file1.c **/
- int foo(int n) { return n * 2; } /* definition has a body {...} */
必須在其他文件(或其中的塊)中使用顯式的 extern 聲明 此函數(shù)才能使其可見(jiàn)。以下是使 extern 函數(shù) foo 在文件 file2.c 中可見(jiàn)的聲明語(yǔ)句:
- /** file2.c: make function foo visible here **/
- extern int foo(int); /* declaration (no body) */
回想一下,函數(shù)聲明沒(méi)有用大括號(hào)括起來(lái)的主體,而函數(shù)定義會(huì)有這樣的主體。
為了便于查看,函數(shù)和變量聲明通常會(huì)放在頭文件中。準(zhǔn)備好需要聲明的源代碼文件,然后就可以 #include 相關(guān)的頭文件。下一節(jié)中的 staticProg 程序演示了這種方法。
至于 extern 的變量,規(guī)則就變得更棘手了(很抱歉增加了難度!)。任何 extern 的對(duì)象——無(wú)論函數(shù)或變量——必須 定義 在所有塊之外。此外,在所有塊之外定義的變量默認(rèn)為 extern:
- /** outside all blocks **/
- int n; /* defaults to extern */
但是,只有在變量的 定義 中顯式初始化變量時(shí),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 的變量在另一個(gè)文件(例如 file2.c)中可見(jiàn),該變量必須在 file2.c 中顯式 聲明 為 extern 并且不能初始化(初始化會(huì)將聲明轉(zhuǎn)換為定義):
- /** file2.c **/
- extern int n1; /* declaration of n1 defined in file1.c */
為了避免與 extern 變量混淆,經(jīng)驗(yàn)是在 聲明 中顯式使用 extern(必須),但不要在 定義 中使用(非必須且棘手)。對(duì)于函數(shù),extern 在定義中是可選使用的,但在聲明中是必須使用的。下一節(jié)中的 staticProg 示例會(huì)把這些點(diǎn)整合到一個(gè)完整的程序中。
staticProg 示例
staticProg 程序由三個(gè)文件組成:兩個(gè) C 語(yǔ)言源文件(static1.c 和 static2.c)以及一個(gè)頭文件(static.h),頭文件中包含兩個(gè)聲明:
- /** header file static.h **/
- #define NumCount 100 /* macro */
- extern int global_nums[NumCount]; /* array declaration */
- extern void fill_array(); /* function declaration */
兩個(gè)聲明中的 extern,一個(gè)用于數(shù)組,另一個(gè)用于函數(shù),強(qiáng)調(diào)對(duì)象在別處(“外部”)定義:數(shù)組 global_nums 在文件 static1.c 中定義(沒(méi)有顯式的 extern),函數(shù) fill_array 在文件 static2.c 中定義(也沒(méi)有顯式的 extern)。每個(gè)源文件都包含了頭文件 static.h。static1.c 文件定義了兩個(gè)駐留在內(nèi)存靜態(tài)區(qū)域中的數(shù)組(global_nums 和 more_nums)。第二個(gè)數(shù)組有 static 修飾,這將其作用域限制為定義數(shù)組的文件 (static1.c)。如前所述, extern 修飾的 global_nums 則可以實(shí)現(xiàn)在多個(gè)文件中可見(jià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 文件中)調(diào)用;fill_array 函數(shù)會(huì)給名為 global_nums 的 extern 數(shù)組中的元素賦值,該數(shù)組在文件 static1.c 中定義。使用兩個(gè)文件的唯一目的是凸顯 extern 變量或函數(shù)能夠跨文件可見(jiàn)。
- /** 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
從匯編語(yǔ)言看更多細(xì)節(jié)
現(xiàn)代 C 編譯器能夠處理 C 和匯編語(yǔ)言的任意組合。編譯 C 源文件時(shí),編譯器首先將 C 代碼翻譯成匯編語(yǔ)言。這是對(duì)從上文 static1.c 文件生成的匯編語(yǔ)言進(jìn)行保存的命令:
- % gcc -S static1.c
生成的文件就是 static1.s。這是文件頂部的一段代碼,額外添加了行號(hào)以提高可讀性:
- .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 行)之類的匯編語(yǔ)言指令以句點(diǎn)開(kāi)頭。顧名思義,指令會(huì)指導(dǎo)匯編程序?qū)R編語(yǔ)言翻譯成機(jī)器代碼。.rodata 指令(第 6 行)表示后面是只讀對(duì)象,包括字符串常量 "%4i\t%4i\n"(第 8 行),main 函數(shù)(第 12 行)會(huì)使用此字符串常量來(lái)實(shí)現(xiàn)格式化輸出。作為標(biāo)簽引入(通過(guò)末尾的冒號(hào)實(shí)現(xiàn))的 main 函數(shù)(第 12 行),同樣也是只讀的。
在匯編語(yǔ)言中,標(biāo)簽就是地址。標(biāo)簽 main:(第 12 行)標(biāo)記了 main 函數(shù)代碼開(kāi)始的地址,標(biāo)簽 .LC0:(第 7 行)標(biāo)記了格式化字符串開(kāi)頭所在的地址。
global_nums(第 3 行)和 more_nums(第 4 行)數(shù)組的定義包含了兩個(gè)數(shù)字:400 是每個(gè)數(shù)組中的總字節(jié)數(shù),32 是每個(gè)數(shù)組(含 100 個(gè) int 元素)中每個(gè)元素的比特?cái)?shù)。(第 5 行中的 .comm 指令表示 common name,可以忽略。)
兩個(gè)數(shù)組定義的不同之處在于 more_nums 被標(biāo)記為 .local(第 4 行),這意味著其作用域僅限于其所在文件 static1.s。相比之下,global_nums 數(shù)組就能在多個(gè)文件中實(shí)現(xiàn)可見(jiàn),包括由 static1.c 和 static2.c 文件翻譯成的匯編文件。
最后,.text 指令在匯編代碼段中出現(xiàn)了兩次(第 2 行和第 9 行)。術(shù)語(yǔ)“text”表示“只讀”,但也會(huì)涵蓋一些讀/寫變量,例如兩個(gè)數(shù)組中的元素。盡管本文展示的匯編語(yǔ)言是針對(duì) Intel 架構(gòu)的,但 Arm6 匯編也非常相似。對(duì)于這兩種架構(gòu),.text 區(qū)域中的變量(本例中為兩個(gè)數(shù)組中的元素)會(huì)自動(dòng)初始化為零。
總結(jié)
C 語(yǔ)言中的內(nèi)存高效和內(nèi)存安全編程準(zhǔn)則很容易說(shuō)明,但可能會(huì)很難遵循,尤其是在調(diào)用設(shè)計(jì)不佳的庫(kù)的時(shí)候。準(zhǔn)則如下:
- 盡可能使用棧存儲(chǔ),進(jìn)而鼓勵(lì)編譯器將通用寄存器用作暫存器,實(shí)現(xiàn)優(yōu)化。棧存儲(chǔ)代表了高效的內(nèi)存使用并促進(jìn)了代碼的整潔和模塊化。永遠(yuǎn)不要返回指向基于棧的存儲(chǔ)的指針。
- 小心使用堆存儲(chǔ)。C(和 C++)中的重難點(diǎn)是確保動(dòng)態(tài)分配的存儲(chǔ)盡快解除分配。良好的編程習(xí)慣和工具(如 valgrind)有助于攻關(guān)這些重難點(diǎn)。優(yōu)先選用自身提供釋放函數(shù)的庫(kù),例如 nestedHeap 代碼示例中的 free_all 釋放函數(shù)。
- 謹(jǐn)慎使用靜態(tài)存儲(chǔ),因?yàn)檫@種存儲(chǔ)會(huì)自始至終地影響進(jìn)程的內(nèi)存占用。特別是盡量避免使用 extern 和 static 數(shù)組。