Linux中國

實例講解代碼之內存安全與效率

C 是一種高級語言,同時具有「 接近金屬 close-to-the-metal 」(LCTT 譯註:即「接近人類思維方式」的反義詞)的特性,這使得它有時看起來更像是一種可移植的彙編語言,而不像 Java 或 Python 這樣的兄弟語言。內存管理作為上述特性之一,涵蓋了正在執行的程序對內存的安全和高效使用。本文通過 C 語言代碼示例,以及現代 C 語言編譯器生成的彙編語言代碼段,詳細介紹了內存安全性和效率。

儘管代碼示例是用 C 語言編寫的,但安全高效的內存管理指南對於 C++ 是同樣適用的。這兩種語言在很多細節上有所不同(例如,C++ 具有 C 所缺乏的面向對象特性和泛型),但在內存管理方面面臨的挑戰是一樣的。

執行中程序的內存概述

對於正在執行的程序(又名 進程 process ),內存被劃分為三個區域: stack heap 靜態區 static area 。下文會給出每個區域的概述,以及完整的代碼示例。

作為通用 CPU 寄存器的替補, 為代碼塊(例如函數或循環體)中的局部變數提供暫存器存儲。傳遞給函數的參數在此上下文中也視作局部變數。看一下下面這個簡短的示例:

void some_func(int a, int b) {
   int n;
   ...
}

通過 ab 傳遞的參數以及局部變數 n 的存儲會在棧中,除非編譯器可以找到通用寄存器。編譯器傾向於優先將通用寄存器用作暫存器,因為 CPU 對這些寄存器的訪問速度很快(一個時鐘周期)。然而,這些寄存器在台式機、筆記本電腦和手持機器的標準架構上很少(大約 16 個)。

在只有彙編語言程序員才能看到的實施層面,棧被組織為具有 push(插入)和 pop(刪除)操作的 LIFO(後進先出)列表。 top 指針可以作為偏移的基地址;這樣,除了 top 之外的棧位置也變得可訪問了。例如,表達式 top+16 指向堆棧的 top 指針上方 16 個位元組的位置,表達式 top-16 指向 top 指針下方 16 個位元組的位置。因此,可以通過 top 指針訪問實現了暫存器存儲的棧的位置。在標準的 ARM 或 Intel 架構中,棧從高內存地址增長到低內存地址;因此,減小某進程的 top 就是增大其棧規模。

使用棧結構就意味著輕鬆高效地使用內存。編譯器(而非程序員)會編寫管理棧的代碼,管理過程通過分配和釋放所需的暫存器存儲來實現;程序員聲明函數參數和局部變數,將實現過程交給編譯器。此外,完全相同的棧存儲可以在連續的函數調用和代碼塊(如循環)中重複使用。精心設計的模塊化代碼會將棧存儲作為暫存器的首選內存選項,同時優化編譯器要儘可能使用通用寄存器而不是棧。

提供的存儲是通過程序員代碼顯式分配的,堆分配的語法因語言而異。在 C 中,成功調用庫函數 malloc(或其變體 calloc 等)會分配指定數量的位元組(在 C++ 和 Java 等語言中,new 運算符具有相同的用途)。編程語言在如何釋放堆分配的存儲方面有著巨大的差異:

  • 在 Java、Go、Lisp 和 Python 等語言中,程序員不會顯式釋放動態分配的堆存儲。

例如,下面這個 Java 語句為一個字元串分配了堆存儲,並將這個堆存儲的地址存儲在變數 greeting 中:

String greeting = new String("Hello, world!");

Java 有一個垃圾回收器,它是一個運行時實用程序,如果進程無法再訪問自己分配的堆存儲,回收器可以使其自動釋放。因此,Java 堆釋放是通過垃圾收集器自動進行的。在上面的示例中,垃圾收集器將在變數 greeting 超出作用域後,釋放字元串的堆存儲。

  • Rust 編譯器會編寫堆釋放代碼。這是 Rust 在不依賴垃圾回收器的情況下,使堆釋放實現自動化的開創性努力,但這也會帶來運行時複雜性和開銷。向 Rust 的努力致敬!
  • 在 C(和 C++)中,堆釋放是程序員的任務。程序員調用 malloc 分配堆存儲,然後負責相應地調用庫函數 free 來釋放該存儲空間(在 C++ 中,new 運算符分配堆存儲,而 deletedelete[] 運算符釋放此類存儲)。下面是一個 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 語言避免了垃圾回收器的成本和複雜性,但也不過是讓程序員承擔了堆釋放的任務。

內存的 靜態區 為可執行代碼(例如 C 語言函數)、字元串文字(例如「Hello, world!」)和全局變數提供存儲空間:

int n;                       /* global variable */
int main() {                 /* function */
   char* msg = "No comment"; /* string literal */
   ...
}

該區域是靜態的,因為它的大小從進程執行開始到結束都固定不變。由於靜態區相當於進程固定大小的內存佔用,因此經驗法則是通過避免使用全局數組等方法來使該區域儘可能小。

下文會結合代碼示例對本節概述展開進一步講解。

棧存儲

想像一個有各種連續執行的任務的程序,任務包括了處理每隔幾分鐘通過網路下載並存儲在本地文件中的數字數據。下面的 stack 程序簡化了處理流程(僅是將奇數整數值轉換為偶數),而將重點放在棧存儲的好處上。

#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 函數首先調用 process_data 函數,該函數會創建一個基於棧的數組,其大小由參數 n 給定(當前示例中為 128,000)。因此,該數組佔用 128000 * sizeof(int) 個位元組,在標準設備上達到了 512,000 位元組(int 在這些設備上是四個位元組)。然後數據會被讀入數組(使用庫函數 fread),循環處理,並保存到本地文件 outgoing.dat(使用庫函數 fwrite)。

process_data 函數返回到其調用者 main 函數時,process_data 函數的大約 500MB 棧暫存器可供 stack 程序中的其他函數用作暫存器。在此示例中,main 函數接下來調用存根函數 other_task1other_task2。這三個函數在 main 中依次調用,這意味著所有三個函數都可以使用相同的堆棧存儲作為暫存器。因為編寫棧管理代碼的是編譯器而不是程序員,所以這種方法對程序員來說既高效又容易。

在 C 語言中,在塊(例如函數或循環體)內定義的任何變數默認都有一個 auto 存儲類,這意味著該變數是基於棧的。存儲類 register 現在已經過時了,因為 C 編譯器會主動嘗試儘可能使用 CPU 寄存器。只有在塊內定義的變數可能是 register,如果沒有可用的 CPU 寄存器,編譯器會將其更改為 auto。基於棧的編程可能是不錯的首選方式,但這種風格確實有一些挑戰性。下面的 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 函數使用 16(LCTT 譯註:原文為 128,應為作者筆誤)作為參數調用函數 get_array,然後被調用函數會使用傳入參數來創建對應大小的本地數組。get_array 函數會初始化數組並返回給 main 中的數組標識符 arrarr 是一個指針常量,保存數組的第一個 int 元素的地址。

當然,本地數組 arr 可以在 get_array 函數中訪問,但是一旦 get_array 返回,就不能合法訪問該數組。儘管如此,main 函數會嘗試使用函數 get_array 返回的堆棧地址 arr 來列印基於棧的數組。現代編譯器會警告錯誤。例如,下面是來自 GNU 編譯器的警告:

badStack.c: In function &apos;get_array&apos;:
badStack.c:9:10: warning: function returns address of local variable [-Wreturn-local-addr]
return arr;  /** ERROR **/

一般規則是,如果使用棧存儲實現局部變數,應該僅在該變數所在的代碼塊內,訪問這塊基於棧的存儲(在本例中,數組指針 arr 和循環計數器 i 均為這樣的局部變數)。因此,函數永遠不應該返回指向基於棧存儲的指針。

堆存儲

接下來使用若干代碼示例凸顯在 C 語言中使用堆存儲的優點。在第一個示例中,使用了最優方案分配、使用和釋放堆存儲。第二個示例(在下一節中)將堆存儲嵌套在了其他堆存儲中,這會使其釋放操作變得複雜。

#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, "%sn", "malloc(...) failed...");
  else {
    for (i = 0; i < n; i++) printf("%in", heap_nums[i]);
    free(heap_nums); /* free the heap storage */
  }
  return 0; 
}

上面的 heap 程序有兩個函數: main 函數使用參數(示例中為 100)調用 get_heap_array 函數,參數用來指定數組應該有多少個 int 元素。因為堆分配可能會失敗,main 函數會檢查 get_heap_array 是否返回了 NULL;如果是,則表示失敗。如果分配成功,main 將列印數組中的 int 值,然後立即調用庫函數 free 來對堆存儲解除分配。這就是最優的方案。

get_heap_array 函數以下列語句開頭,該語句值得仔細研究一下:

int* heap_nums = malloc(sizeof(int) * n); /* heap allocation */

malloc 庫函數及其變體函數針對位元組進行操作;因此,malloc 的參數是 nint 類型元素所需的位元組數(sizeof(int) 在標準現代設備上是四個位元組)。malloc 函數返回所分配位元組段的首地址,如果失敗則返回 NULL .

如果成功調用 malloc,在現代台式機上其返回的地址大小為 64 位。在手持設備和早些時候的台式機上,該地址的大小可能是 32 位,或者甚至更小,具體取決於其年代。堆分配數組中的元素是 int 類型,這是一個四位元組的有符號整數。這些堆分配的 int 的地址存儲在基於棧的局部變數 heap_nums 中。可以參考下圖:

                 heap-based
 stack-based        /
             +----+----+   +----+
 heap-nums--->|int1|int2|...|intN|
              +----+----+   +----+

一旦 get_heap_array 函數返回,指針變數 heap_nums 的棧存儲將自動回收——但動態 int 數組的堆存儲仍然存在,這就是 get_heap_array 函數返回這個地址(的副本)給 main 函數的原因:它現在負責在列印數組的整數後,通過調用庫函數 free 顯式釋放堆存儲:

free(heap_nums); /* free the heap storage */

malloc 函數不會初始化堆分配的存儲空間,因此裡面是隨機值。相比之下,其變體函數 calloc 會將分配的存儲初始化為零。這兩個函數都返回 NULL 來表示分配失敗。

heap 示例中,main 函數在調用 free 後會立即返回,正在執行的程序會終止,這會讓系統回收所有已分配的堆存儲。儘管如此,程序員應該養成在不再需要時立即顯式釋放堆存儲的習慣。

嵌套堆分配

下一個代碼示例會更棘手一些。C 語言有很多返回指向堆存儲的指針的庫函數。下面是一個常見的使用情景:

1、C 程序調用一個庫函數,該函數返回一個指向基於堆的存儲的指針,而指向的存儲通常是一個聚合體,如數組或結構體:

SomeStructure* ptr = lib_function(); /* returns pointer to heap storage */

2、 然後程序使用所分配的存儲。

3、 對於清理而言,問題是對 free 的簡單調用是否會清理庫函數分配的所有堆分配存儲。例如,SomeStructure 實例可能有指向堆分配存儲的欄位。一個特別麻煩的情況是動態分配的結構體數組,每個結構體有一個指向又一層動態分配的存儲的欄位。下面的代碼示例說明了這個問題,並重點關注了如何設計一個可以安全地為客戶端提供堆分配存儲的庫。

#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("%12fn", 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;

函數 get_heap_struct 嘗試為 HeapStruct 實例分配堆存儲,這需要為欄位 heap_nums 指向的若干個 float 變數分配堆存儲。如果成功調用 get_heap_struct 函數,並將指向堆分配結構體的指針以 hs 命名,其結果可以描述如下:

hs-->HeapStruct instance
        id
        len
        heap_nums-->N contiguous float elements

get_heap_struct 函數中,第一個堆分配過程很簡單:

HeapStruct* heap_struct = malloc(sizeof(HeapStruct));
if (NULL == heap_struct) /* failure? */
  return NULL;           /* if so, return NULL */

sizeof(HeapStruct) 包括了 heap_nums 欄位的位元組數(32 位機器上為 4,64 位機器上為 8),heap_nums 欄位則是指向動態分配數組中的 float 元素的指針。那麼,問題關鍵在於 malloc 為這個結構體傳送了位元組空間還是表示失敗的 NULL;如果是 NULLget_heap_struct 函數就也返回 NULL 以通知調用者堆分配失敗。

第二步嘗試堆分配的過程更複雜,因為在這一步,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;                         /* and then return NULL */
}

傳遞給 get_heap_struct 函數的參數 n 指明動態分配的 heap_nums 數組中應該有多少個 float 元素。如果可以分配所需的若干個 float 元素,則該函數在返回 HeapStruct 的堆地址之前會設置結構的 idlen 欄位。 但是,如果嘗試分配失敗,則需要兩個步驟來實現最優方案:

1、 必須釋放 HeapStruct 的存儲以避免內存泄漏。對於調用 get_heap_struct 的客戶端函數而言,沒有動態 heap_nums 數組的 HeapStruct 可能就是沒用的;因此,HeapStruct 實例的位元組空間應該顯式釋放,以便系統可以回收這些空間用於未來的堆分配。

2、 返回 NULL 以標識失敗。

如果成功調用 get_heap_struct 函數,那麼釋放堆存儲也很棘手,因為它涉及要以正確順序進行的兩次 free 操作。因此,該程序設計了一個 free_all 函數,而不是要求程序員再去手動實現兩步釋放操作。回顧一下,free_all 函數是這樣的:

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 */  
}

檢查完參數 heap_struct 不是 NULL 值後,函數首先釋放 heap_nums 數組,這步要求 heap_struct 指針此時仍然是有效的。先釋放 heap_struct 的做法是錯誤的。一旦 heap_nums 被釋放,heap_struct 就可以釋放了。如果 heap_struct 被釋放,但 heap_nums 沒有被釋放,那麼數組中的 float 元素就會泄漏:仍然分配了位元組空間,但無法被訪問到——因此一定要記得釋放 heap_nums。存儲泄漏將一直持續,直到 nestedHeap 程序退出,系統回收泄漏的位元組時為止。

關於 free 庫函數的注意事項就是要有順序。回想一下上面的調用示例:

free(heap_struct->heap_nums); /* first free encapsulated aggregate */
free(heap_struct);            /* then free containing structure */

這些調用釋放了分配的存儲空間——但它們並 不是 將它們的操作參數設置為 NULLfree 函數會獲取地址的副本作為參數;因此,將副本更改為 NULL 並不會改變原地址上的參數值)。例如,在成功調用 free 之後,指針 heap_struct 仍然持有一些堆分配位元組的堆地址,但是現在使用這個地址將會產生錯誤,因為對 free 的調用使得系統有權回收然後重用這些分配過的位元組。

使用 NULL 參數調用 free 沒有意義,但也沒有什麼壞處。而在非 NULL 的地址上重複調用 free 會導致不確定結果的錯誤:

free(heap_struct);  /* 1st call: ok */
free(heap_struct);  /* 2nd call: ERROR */

內存泄漏和堆碎片化

「內存泄漏」是指動態分配的堆存儲變得不再可訪問。看一下相關的代碼段:

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 元素的堆存儲仍然處於被分配狀態,但此時已無法再對其訪問,因為 nums 指針要麼指向別處,要麼是 NULL。結果就是造成了 40 個位元組(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 */

即使沒有泄漏,堆也會隨著時間的推移而碎片化,需要對系統進行碎片整理。例如,假設兩個最大的堆塊當前的大小分別為 200MB 和 100MB。然而,這兩個堆塊並不連續,進程 P 此時又需要分配 250MB 的連續堆存儲。在進行分配之前,系統可能要對堆進行 碎片整理 以給 P 提供 250MB 連續存儲空間。碎片整理很複雜,因此也很耗時。

內存泄漏會創建處於已分配狀態但不可訪問的堆塊,從而會加速碎片化。因此,釋放不再需要的堆存儲是程序員幫助減少碎片整理需求的一種方式。

診斷內存泄漏的工具

有很多工具可用於分析內存效率和安全性,其中我最喜歡的是 valgrind。為了說明該工具如何處理內存泄漏,這裡給出 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("%3in", 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 函數調用了 get_ints 函數,後者會試著從堆中 malloc 32 個 4 位元組的 int,然後初始化動態數組(如果 malloc 成功)。初始化成功後,main 函數會調用 print_ints函數。程序中並沒有調用 free 來對應 malloc 操作;因此,內存泄漏了。

如果安裝了 valgrind 工具箱,下面的命令會檢查 leaky 程序是否存在內存泄漏(% 是命令行提示符):

% valgrind --leak-check=full ./leaky

絕大部分輸出都在下面給出了。左邊的數字 207683 是正在執行的 leaky 程序的進程標識符。這份報告給出了泄漏發生位置的詳細信息,本例中位置是在 main 函數所調用的 get_ints 函數中對 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 函數改成在對 print_ints 的調用之後,再加上一個對 free 的調用,valgrind 就會對 leaky 程序給出一個乾淨的內存健康清單:

==218462== All heap blocks were freed -- no leaks are possible

靜態區存儲

在正統的 C 語言中,函數必須在所有塊之外定義。這是一些 C 編譯器支持的特性,杜絕了在另一個函數體內定義一個函數的可能。我舉的例子都是在所有塊之外定義的函數。這樣的函數要麼是 static ,即靜態的,要麼是 extern,即外部的,其中 extern 是默認值。

C 語言中,以 staticextern 修飾的函數和變數駐留在內存中所謂的 靜態區 中,因為在程序執行期間該區域大小是固定不變的。這兩個存儲類型的語法非常複雜,我們應該回顧一下。在回顧之後,會有一個完整的代碼示例來生動展示語法細節。在所有塊之外定義的函數或變數默認為 extern;因此,函數和變數要想存儲類型為 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 */

externstatic 的區別在於作用域:extern 修飾的函數或變數可以實現跨文件可見(需要聲明)。相比之下,static 修飾的函數僅在 定義 該函數的文件中可見,而 static 修飾的變數僅在 定義 該變數的文件(或文件中的塊)中可見:

static int n1;    /* scope is the file */
void func() {
   static int n2; /* scope is func&apos;s body */
   ...
}

如果在所有塊之外定義了 static 變數,例如上面的 n1,該變數的作用域就是定義變數的文件。無論在何處定義 static 變數,變數的存儲都在內存的靜態區中。

extern 函數或變數在給定文件中的所有塊之外定義,但這樣定義的函數或變數也可以在其他文件中聲明。典型的做法是在頭文件中 聲明 這樣的函數或變數,只要需要就可以包含進來。下面這些簡短的例子闡述了這些棘手的問題。

假設 extern 函數 foofile1.c定義,有無關鍵字 extern 效果都一樣:

/** file1.c **/
int foo(int n) { return n * 2; } /* definition has a body {...} */

必須在其他文件(或其中的塊)中使用顯式的 extern 聲明 此函數才能使其可見。以下是使 extern 函數 foo 在文件 file2.c 中可見的聲明語句:

/** file2.c: make function foo visible here **/
extern int foo(int); /* declaration (no body) */

回想一下,函數聲明沒有用大括弧括起來的主體,而函數定義會有這樣的主體。

為了便於查看,函數和變數聲明通常會放在頭文件中。準備好需要聲明的源代碼文件,然後就可以 #include 相關的頭文件。下一節中的 staticProg 程序演示了這種方法。

至於 extern 的變數,規則就變得更棘手了(很抱歉增加了難度!)。任何 extern 的對象——無論函數或變數——必須 定義 在所有塊之外。此外,在所有塊之外定義的變數默認為 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 變數混淆,經驗是在 聲明 中顯式使用 extern(必須),但不要在 定義 中使用(非必須且棘手)。對於函數,extern 在定義中是可選使用的,但在聲明中是必須使用的。下一節中的 staticProg 示例會把這些點整合到一個完整的程序中。

staticProg 示例

staticProg 程序由三個文件組成:兩個 C 語言源文件(static1.cstatic2.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,一個用於數組,另一個用於函數,強調對象在別處(「外部」)定義:數組 global_nums 在文件 static1.c 中定義(沒有顯式的 extern),函數 fill_array 在文件 static2.c 中定義(也沒有顯式的 extern)。每個源文件都包含了頭文件 static.hstatic1.c 文件定義了兩個駐留在內存靜態區域中的數組(global_numsmore_nums)。第二個數組有 static 修飾,這將其作用域限制為定義數組的文件 (static1.c)。如前所述, extern 修飾的 global_nums 則可以實現在多個文件中可見。

/** 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("%4it%4in", global_nums[i], more_nums[i]);

  return 0;  
}

下面的 static2.c 文件中定義了 fill_array 函數,該函數由 main(在 static1.c 文件中)調用;fill_array 函數會給名為 global_numsextern 數組中的元素賦值,該數組在文件 static1.c 中定義。使用兩個文件的唯一目的是凸顯 extern 變數或函數能夠跨文件可見。

/** 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

從彙編語言看更多細節

現代 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    "%4it%4in"       ## line  8
    .text                         ## line  9
    .globl    main                ## line 10
    .type    main, @function      ## line 11
main:                             ## line 12
...

諸如 .file(第 1 行)之類的彙編語言指令以句點開頭。顧名思義,指令會指導彙編程序將彙編語言翻譯成機器代碼。.rodata 指令(第 6 行)表示後面是只讀對象,包括字元串常量 "%4it%4in"(第 8 行),main 函數(第 12 行)會使用此字元串常量來實現格式化輸出。作為標籤引入(通過末尾的冒號實現)的 main 函數(第 12 行),同樣也是只讀的。

在彙編語言中,標籤就是地址。標籤 main:(第 12 行)標記了 main 函數代碼開始的地址,標籤 .LC0:(第 7 行)標記了格式化字元串開頭所在的地址。

global_nums(第 3 行)和 more_nums(第 4 行)數組的定義包含了兩個數字:400 是每個數組中的總位元組數,32 是每個數組(含 100 個 int 元素)中每個元素的比特數。(第 5 行中的 .comm 指令表示 common name,可以忽略。)

兩個數組定義的不同之處在於 more_nums 被標記為 .local(第 4 行),這意味著其作用域僅限於其所在文件 static1.s。相比之下,global_nums 數組就能在多個文件中實現可見,包括由 static1.cstatic2.c 文件翻譯成的彙編文件。

最後,.text 指令在彙編代碼段中出現了兩次(第 2 行和第 9 行)。術語「text」表示「只讀」,但也會涵蓋一些讀/寫變數,例如兩個數組中的元素。儘管本文展示的彙編語言是針對 Intel 架構的,但 Arm6 彙編也非常相似。對於這兩種架構,.text 區域中的變數(本例中為兩個數組中的元素)會自動初始化為零。

總結

C 語言中的內存高效和內存安全編程準則很容易說明,但可能會很難遵循,尤其是在調用設計不佳的庫的時候。準則如下:

  • 儘可能使用棧存儲,進而鼓勵編譯器將通用寄存器用作暫存器,實現優化。棧存儲代表了高效的內存使用並促進了代碼的整潔和模塊化。永遠不要返回指向基於棧的存儲的指針。
  • 小心使用堆存儲。C(和 C++)中的重難點是確保動態分配的存儲儘快解除分配。良好的編程習慣和工具(如 valgrind)有助於攻關這些重難點。優先選用自身提供釋放函數的庫,例如 nestedHeap 代碼示例中的 free_all 釋放函數。
  • 謹慎使用靜態存儲,因為這種存儲會自始至終地影響進程的內存佔用。特別是盡量避免使用 externstatic 數組。

本文 C 語言代碼示例可在我的網站(https://condor.depaul.edu/mkalin)上找到。

via: https://opensource.com/article/21/8/memory-programming-c

作者:Marty Kalin 選題:lujun9972 譯者:unigeorge 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國