以实例说明代码内存安全和效率(附代码)

190 阅读21分钟

C语言是一种高级语言,具有贴近金属的特性,这使得它有时看起来更像一种可移植的汇编语言,而不是Java或Python的兄弟姐妹。这些特性中包括内存管理,它涵盖了执行中的程序对内存的安全和有效使用。本文通过C语言的代码实例和现代C语言编译器生成的汇编语言的代码段,来探讨内存安全和效率的细节。

尽管代码例子是在C语言中,但安全和有效的内存管理的准则对C++来说是一样的。这两种语言在各种细节上有所不同(例如,C++有面向对象的特性和C缺乏的泛型),但这些语言在内存管理方面有非常相同的挑战。

执行中的程序的内存概述

对于一个执行中的程序(又称进程),内存被划分为三个区域。堆栈静态区域。下面是每个区域的概述,后面有完整的代码例子。

作为通用CPU寄存器的备份,堆栈为代码块中的局部变量提供了刮板存储,如函数或循环体。在这种情况下,传递给函数的参数也算作局部变量。考虑一个简短的例子。

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

在参数ab中传递的参数以及局部变量n的存储将来自堆栈,除非编译器能够找到通用的寄存器来代替。编译器倾向于用这样的寄存器做scratchpad,因为CPU对这些寄存器的访问是很快的(一个时钟周期)。然而,在台式机、笔记本电脑和手持机的标准架构上,这些寄存器很少(大约有16个)。

在只有汇编语言程序员才能看到的实现层面上,堆栈被组织成一个LIFO(Last In, First Out)列表,有(插入)和(删除)操作。顶部指针可以作为偏移的基址;这样一来,顶部以外的堆栈位置可以被访问。例如,表达式top+16指向堆栈顶部以上16个字节的位置,表达式top-16指向顶部以下16个字节。因此,实现scratchpad存储的堆栈位置可以通过top指针访问。在标准的ARM或Intel架构上,堆栈从高到低的内存地址增长;因此,递减top就是为一个进程增长堆栈。

使用堆栈就是毫不费力和有效地使用内存。编译器,而不是程序员,通过分配和删除所需的刮板存储来编写管理堆栈的代码;程序员声明函数参数和局部变量,将实现工作留给编译器。此外,相同的堆栈存储可以在连续的函数调用和代码块(如循环)中重复使用。精心设计的模块化代码使堆栈存储成为scratchpad的第一个内存选项,优化的编译器尽可能使用通用寄存器而不是堆栈。

提供了通过程序员代码明确分配的存储,尽管不同语言的堆分配的语法不同。在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操作符分配了堆存储,而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语言避免了垃圾收集器的成本和复杂性,但只是让程序员承担了堆去分配的任务。

内存的静态区域为可执行代码提供了存储空间,如C语言函数、字符串字面,如 "你好,世界!",以及全局变量。

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

这个区域是静态的,因为它的大小从进程执行的开始到结束都是固定的。因为静态区域相当于一个进程的固定大小的内存足迹,所以经验法则是通过避免使用全局数组等方式尽可能地减少这一区域。

下面几节的代码例子充实了这个概述。

堆栈存储

想象一下,有一个程序要连续执行各种任务,包括处理每隔几分钟通过网络下载并存储在本地文件中的数字数据。下面的堆栈程序简化了处理过程(奇数的整数值变成了偶数),以保持对堆栈存储的好处的关注。

#include 
#include 
#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;
}

底部的函数首先调用process_data函数,该函数创建了一个基于堆栈的数组,其大小由参数n给出(在当前例子中为128,000)。因此,该数组可以容纳128,000 xsizeof(int)字节,在标准设备上为512,000字节,因为一个int在这些设备上为4个字节。然后数据被读入数组(使用库函数fread),在一个循环中处理,并保存到本地文件outgoing.dat(使用库函数fwrite)。

process_data函数返回给它的调用者main时,process_data 函数的大约500MB的堆栈scratchpad就可以供堆栈程序中的其他函数作为scratchpad使用。在这个例子中,main接下来调用存根函数other_task1other_task2。这三个函数是由main连续调用的,这意味着这三个函数都可以使用相同的堆栈存储作为scratchpad。因为是由编译器而不是程序员来写堆栈管理代码,所以这种方法既高效又便于程序员操作。

在C语言中,任何在块内定义的变量(例如,一个函数的或循环的主体)默认有一个自动存储类,这意味着该变量是基于堆栈的。存储类寄存器现在已经过时了,因为C语言的编译器很积极,在他们自己的努力下,尽可能使用CPU寄存器。只有定义在块内的变量可以是寄存器,如果没有CPU寄存器可用,编译器会将其改为自动。基于堆栈的编程可能是首选方式,但这种风格确实有其挑战。下面的badStack程序说明了这一点。

#include 
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调用函数get_array,参数为128,然后被调用的函数用它来创建一个这个大小的本地数组。get_array函数初始化了数组,并向main返回数组的标识符arr,这是一个指针常数,用于保存数组的第一个int元素的地址。

当然,本地数组arrget_array函数中是可以访问的,但是一旦get_array返回,这个数组就不能被合法地访问。尽管如此,函数main试图通过使用函数get_array返回的堆栈地址arr来打印基于堆栈的数组。现代编译器对这个错误发出警告。例如,这里是GNU编译器的警告。

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

一般的规则是,基于堆栈的存储应该只在包含用堆栈存储实现的局部变量(在这种情况下,数组指针arr和循环计数器 i)的代码块中被访问。因此,一个函数不应该返回一个指向基于堆栈存储的指针。

堆存储

在第一个例子中,堆存储被分配、使用,然后按照最佳实践释放。第二个例子将堆存储嵌套在其他的堆存储中,这使得取消分配的操作更加复杂。

#include 
#include 
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;
}

上面的程序有两个函数:main调用get_heap_array,其参数(目前是100)指定了数组应该有多少个int元素。因为堆分配可能会失败,main检查get_heap_array是否返回了NULL,这是失败的信号。如果分配成功,main将打印数组中的int 值--然后立即调用库函数free,将堆分配的存储空间去掉。这是最好的做法。

get_heap_array函数以这个语句开头,值得仔细研究。

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

malloc库函数及其变体处理的是字节;因此,malloc的参数是n个 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函数将这个地址(副本)返回给mainmain现在负责在打印数组的整数后,通过调用库函数free来明确地取消堆栈存储。

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

malloc函数没有初始化堆分配的存储,因此它包含随机值。相比之下,calloc 变体将分配的存储空间初始化为零。两个函数都返回NULL作为失败的信号。

的例子中,main在调用free后立即返回,执行的程序终止,这使得系统可以回收任何分配的堆存储。尽管如此,程序员应该养成习惯,一旦不再需要堆存储,就明确地释放它。

嵌套的堆分配

下一个代码例子比较棘手。C语言有各种库函数,它们返回一个指向堆存储的指针。下面是一个熟悉的场景。

1.C程序调用一个库函数,该函数返回一个指向基于堆的存储的指针,通常是一个集合,如数组或结构。

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

2.2.该程序然后使用分配的存储。

3.3.对于清理,问题是简单的调用free是否会清理库函数分配的所有堆分配的存储。例如,SomeStructure实例可能有一些字段,这些字段反过来指向堆分配的存储。一个特别麻烦的情况是动态分配的结构数组,每个结构都有一个字段指向更多的动态分配的存储。下面的代码例子说明了这个问题,并着重于设计一个库,安全地向客户提供堆分配的存储。

#include 
#include 
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;
}

上面的嵌套Heap例子的中心是一个结构HeapStruct,它有一个名为heap_nums的指针字段。

typedef struct {
  unsigned id;
  unsigned len;
  float*   heap_nums; /** pointer **/
} HeapStruct;

函数get_heap_struct试图为HeapStruct实例分配堆存储,这需要为heap_nums字段所指向的指定数量的浮点变量分配堆存储。成功调用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个),它是一个指向动态分配的数组中的浮点元素的指针。那么,问题在于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数组中应该有多少个浮点数元素。如果所需的浮点元素可以被分配,那么该函数在返回HeapStruct的堆地址之前设置结构的idlen字段。然而,如果尝试分配失败,则需要两个步骤来满足最佳实践。

1.必须释放HeapStruct的存储,以避免内存泄漏。如果没有动态的heap_nums数组,HeapStruct可能对调用get_heap_struct的客户函数没有用处;因此,HeapStruct实例的字节应该明确地被解配,以便系统可以为未来的堆分配回收这些字节。

2.2.返回NULL作为失败的信号。

如果对get_heap_struct函数的调用成功,那么释放堆存储也是很棘手的,因为它涉及到按适当顺序进行的两次释放操作。因此,该程序包括一个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没有被释放,那么数组中的浮点元素就会被泄露:仍然是被分配的字节,但是没有可能被访问--因此是被取消分配的。泄漏将持续到nestedHeap程序退出和系统回收泄漏的字节。

关于free库函数的一些警告性说明是有必要的。回顾上面的示例调用。

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

这些调用释放了已分配的存储空间--但它们并没有将参数设置为NULL。(free函数得到一个地址的副本作为参数;因此,将副本改为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个浮点数中的第一个地址。最初的10个浮点元素的堆存储仍然被分配,但是现在无法访问了,因为nums指针要么指向其他地方,要么是NULL。结果是四十个字节**(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。为了说明该工具对内存泄漏的作用,这里是泄漏的程序。

#include 
#include 
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调用get_ints,试图从堆中malloc三十二个4字节的ints,如果malloc成功,则初始化动态数组。一旦成功,main函数就会调用print_ints。没有调用free来匹配对malloc的调用;因此,内存泄漏了。

在安装了valgrind工具箱后,下面的命令可以检查泄漏程序的内存泄漏情况**(%**是命令行提示)。

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

下面是大部分的输出。左边的数字,207683,是正在执行的泄漏程序的进程标识符。报告提供了泄漏发生的细节,在本例中,是来自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给这个泄漏的程序一个干净的健康证明。

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

静态区域存储

在正统的C语言中,一个函数必须定义在所有块之外。这就排除了将一个函数定义在另一个函数的主体内的情况,有些C语言编译器支持这一功能。我的例子坚持使用定义在所有块之外的函数。这样的函数要么是静态的,要么是外在的,默认是外在的。

staticextern为存储类别的C函数和变量驻留在我所说的内存的静态区域,因为这个区域在程序执行期间有固定的大小。这两个存储类的语法很复杂,值得回顾一下。在回顾之后,一个完整的代码示例将语法细节带回到现实中。在所有块之外定义的函数或变量默认为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 int n1;    /* scope is the file */
void func() {
   static int n2; /* scope is func's body */
   ...
}

如果一个静态变量,如上面的n1,是在所有块之外定义的,那么该变量的范围就是定义该变量的文件。无论在哪里定义静态变量,该变量的存储都是在内存的静态区域。

一个外部函数或变量被定义在一个给定文件的所有块之外,但这样定义的函数或变量可以在其他文件中声明。典型的做法是在头文件中声明这样的函数或变量,并将其包含在需要的地方。一些简短的例子澄清了这些棘手的问题。

假设在file1.c定义了 extern函数foo,无论是否有关键字extern

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

这个函数必须在任何其他文件(或其中的块)中用明确的extern声明,才能使这个函数可见。下面是使extern函数foofile2.c中可见的声明。

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

回顾一下,函数声明并没有用大括号括起来的主体,而函数定义则有这样一个主体。

为了复习,头文件通常包含函数和变量的声明。需要这些声明的源代码文件就会**#包括相关的头文件。下一节中的staticProg**程序说明了这种方法。

对于外部变量,规则变得更加棘手(抱歉!)。任何外部对象--函数或变量--必须在所有块之外定义。而且,在所有块之外定义的变量默认为extern

/** outside all blocks **/
int n; /* defaults to extern */

然而,只有在变量被明确初始化的情况下,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.h.static1.c文件定义了位于内存静态区域的两个数组,global_numsmore_nums。第二个数组有一个静态存储类,它的范围限制在定义该数组的文件**(static1.c**)中。如前所述,global_nums作为extern可以在多个文件中可见。

/** static1.c **/
#include 
#include 
#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函数,main(在static1.c文件中)调用了该函数;fill_array函数填充了在static1.c 文件中定义的名为global_nums外部数组。

/** 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    "%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**(第12行)用它来格式化输出。函数main(第12行)作为一个标签引入(结尾的冒号使其成为标签),同样也是只读的。

在汇编语言中,标签是地址。标签main:(第12行)标志着函数的代码开始的地址,而标签**.LC0**:(第7行)标志着格式化字符串开始的地址。

global_nums(第3行)和more_nums(第4行)数组的定义包括两个数字。400是每个数组中的总字节数,32是每个数组中100个int元素的比特数。(第5行的**.comm指令代表通用名称**,可以忽略)。

这两个数组定义的不同之处在于,more_nums被标记为**.local**(第4行),这意味着它的范围仅限于包含文件static1.s。相反,global_nums数组可以在多个文件中可见,包括static1.cstatic2.c文件的翻译。

最后,.text指令在汇编代码段中出现了两次(第2和9行)。术语 "text "表示 "只读",但也包括读/写变量,如两个数组中的元素。尽管显示的是英特尔架构的汇编语言,但Arm6的汇编也很相似。对于这两种架构,.text区域的变量(在本例中,两个数组中的元素)被自动初始化为零。

包裹起来

对于C语言中的内存效率和内存安全编程,指导方针很容易说明,但可能很难遵循,特别是在调用设计不良的库时。这些准则是

  • 尽可能地使用堆栈存储,从而鼓励编译器用通用寄存器来优化scratchpad。堆栈存储代表了有效的内存使用,并促进了干净的、模块化的代码。永远不要向基于堆栈的存储返回一个指针。
  • 谨慎使用堆存储。C(和C++)中的挑战是确保动态分配的存储被尽快地取消分配。良好的编程习惯和工具(如valgrind)有助于应对这一挑战。喜欢那些提供自己的去分配函数的库,比如嵌套Heap代码示例中的free_all函数。
  • 谨慎地使用静态存储,因为这种存储从头到尾都会影响进程的内存足迹。特别是,尽量避免使用extern静态数组。