代码内存安全和效率示例

了解更多关于内存安全和效率的信息
60 位读者喜欢这篇文章。
An introduction to GNU Screen

Opensource.com

C 是一种高级语言,具有接近底层的特性,这使得它有时看起来更像是一种可移植的汇编语言,而不是 Java 或 Python 的同类。在这些特性中,内存管理涵盖了正在执行的程序对内存的安全和高效使用。本文通过 C 语言的代码示例以及现代 C 编译器生成的汇编语言代码片段,详细介绍了内存安全和效率。

尽管代码示例是用 C 语言编写的,但安全高效内存管理的指导原则对于 C++ 也是相同的。这两种语言在各种细节上有所不同(例如,C++ 具有 C 语言所缺乏的面向对象特性和泛型),但这些语言在内存管理方面面临着相同的挑战。

执行程序内存概述

对于正在执行的程序(也称为进程),内存被划分为三个区域:静态区。以下是每个区域的概述,后面将有完整的代码示例。

作为通用 CPU 寄存器的备份,为代码块(例如函数或循环体)内的局部变量提供临时存储。传递给函数的参数在此上下文中也算作局部变量。考虑一个简短的例子

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



参数 ab 以及局部变量 n 的存储将来自栈,除非编译器可以找到通用寄存器来代替。编译器倾向于使用这些寄存器作为临时存储,因为 CPU 访问这些寄存器的速度很快(一个时钟周期)。然而,在台式机、笔记本电脑和手持设备的标准架构上,这些寄存器数量很少(大约十六个)。

在实现层面,只有汇编语言程序员才能看到,栈被组织成 LIFO(后进先出)列表,具有 push(插入)和 pop(移除)操作。栈顶指针可以用作偏移量的基地址;通过这种方式,可以访问 栈顶 之外的栈位置。例如,表达式 top+16 指向 栈顶 之上 16 个字节的位置,表达式 top-16 指向 栈顶 之下 16 个字节的位置。因此,实现临时存储的栈位置可以通过 栈顶 指针访问。在标准的 ARM 或 Intel 架构上,栈从高内存地址向低内存地址增长;因此,递减 栈顶 就是为进程增长栈。

使用栈就是轻松高效地使用内存。编译器而不是程序员编写代码来管理栈,通过分配和释放所需的临时存储;程序员声明函数参数和局部变量,将实现留给编译器。此外,相同的栈存储可以在连续的函数调用和代码块(例如循环)之间重复使用。设计良好的模块化代码使栈存储成为临时存储的首选内存选项,优化编译器尽可能使用通用寄存器而不是栈。

提供了通过程序员代码显式分配的存储,尽管堆分配的语法在不同语言中有所不同。在 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)给出。因此,该数组保存 128,000 x sizeof(int) 字节,在标准设备上为 512,000 字节,因为在这些设备上 int 为四个字节。然后将数据读入数组(使用库函数 fread),在循环中处理,并保存到本地文件 outgoing.dat(使用库函数 fwrite)。

process_data 函数返回给其调用者 main 时,process_data 函数大约 500MB 的栈临时存储可供 stack 程序中的其他函数用作临时存储。在本例中,main 接下来调用存根函数 other_task1 和 other_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 调用函数 get_array,参数为 128,被调用的函数随后使用该参数创建一个此大小的局部数组。get_array 函数初始化数组,并将数组的标识符 arr 返回给 mainarr 是一个指针常量,它保存数组的第一个 int 元素的地址。

局部数组 arr 当然可以在 get_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)的代码块内访问。因此,函数永远不应返回指向基于栈的存储的指针。

堆存储

几个代码示例突出了在 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, "%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 程序有两个函数: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 的参数是 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,现在 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("%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;

函数 get_heap_struct 尝试为 HeapStruct 实例分配堆存储,这涉及为指定数量的 float 变量分配堆存储,字段 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 个字节),这是一个指向动态分配数组中 float 元素的指针。因此,问题是 malloc 是否为该结构体传递字节,或者是否传递 NULL 以表示失败;如果为 NULL,则 get_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 的存储,以避免内存泄漏。如果没有动态 heap_nums 数组,HeapStruct 对于调用 get_heap_struct 的客户端函数来说可能没有用处;因此,应显式释放 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 元素将发生泄漏:仍然分配了字节,但无法访问,因此也无法释放。泄漏将持续到 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 个 float 元素中的第一个元素的地址。最初分配的十个 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("%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 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

具有 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 函数仅在包含该函数定义的文件中可见,而 static 变量仅在具有该变量定义的文件(或其中的块)中可见

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

如果像上面的 n1 这样的 static 变量在所有块之外定义,则该变量的作用域是定义该变量的文件。无论 static 变量可能在何处定义,该变量的存储都在内存的静态区中。

extern 函数或变量在给定文件的所有块之外定义,但如此定义的函数或变量随后可以在某些其他文件中声明。典型的做法是在头文件中声明此类函数或变量,然后在需要的地方包含该头文件。一些简短的示例阐明了这些棘手的问题。

假设 extern 函数 foofile1.c定义,无论是否带有关键字 extern

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

为了使该函数可见,必须在任何其他文件(或其中的块)中使用显式的 extern 声明该函数。以下是在文件 file2.c 中使 extern 函数 foo 可见的声明

/** 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 才能在变量的定义中显式声明

/** 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)。如前所述,global_nums 作为 extern 可以在多个文件中可见。

/** 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 函数,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    "%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 行)标记 main 函数的代码开始的地址,标签 .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”暗示“只读”,但也涵盖读/写变量,例如两个数组中的元素。尽管显示的汇编语言是针对 Intel 架构的,但 Arm6 汇编语言将非常相似。对于这两种架构,.text 区域中的变量(在本例中为两个数组中的元素)都会自动初始化为零。

总结

对于 C 语言中内存高效且内存安全的编程,指南很容易陈述,但可能难以遵循,尤其是在调用设计不良的库时。指南是

  • 尽可能使用栈存储,从而鼓励编译器使用通用寄存器优化临时存储。栈存储代表高效的内存使用,并促进清晰、模块化的代码。永远不要返回指向基于栈的存储的指针。
  • 谨慎使用堆存储。C (和 C++) 中的挑战在于确保动态分配的存储空间尽快被释放。良好的编程习惯和工具(例如 valgrind)有助于应对这一挑战。优先选择提供自有释放函数库,例如 nestedHeap 代码示例中的 free_all 函数。
  • 明智地使用静态存储,因为这种存储会从始至终影响进程的内存占用。特别是,尽量避免使用 externstatic 数组。

C 代码示例可在我的网站上找到 (https://condor.depaul.edu/mkalin)。

接下来阅读
标签
User profile image.
我是一名计算机科学领域的学者(德保罗大学计算与数字媒体学院),在软件开发方面拥有广泛的经验,主要是在生产计划和调度(钢铁行业)以及产品配置(卡车和巴士制造)方面。关于书籍和其他出版物的详细信息,请访问

3 条评论

谢谢你的这篇文章,Marty!写得真好。

感谢这篇精彩的文章。在“堆存储”部分,我注意到 main 函数检查了 /* malloc failed */,但是由于我们在同一个函数 (get_heap_array) 中初始化 *heap_nums,我们正在尝试分配内存,因此我们需要在尝试访问数组之前添加验证,以避免在分配失败时出现段错误 (SIGSEGV)。

int *get_heap_array(unsigned n) {
int *heap_nums = malloc(sizeof(int) * n);

/* 检查堆分配 */
if (NULL == heap_nums) /* 失败? */
return NULL; /* 如果是,则返回 NULL */

unsigned i;
for (i = 0; i < n; i++)
heap_nums[i] = i + 1; /* 初始化数组 */

/* 变量 heap_nums 和 i 的栈存储空间在
get_num_array 返回时自动释放 */
return heap_nums; /* 返回指针(副本) */
}

我希望能从您那里学到更多!

我同意——应该始终检查 malloc 是否返回 NULL。我当时想让第一个示例保持简单,但不应该避免检查。

Marty

回复 ,作者 Gilson Urbano

Creative Commons License本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 许可。
© . All rights reserved.