代码内存安全和效率示例

了解更多关于内存安全和效率的信息
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 指针可以作为偏移量的基地址; 通过这种方式,可以访问 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)。 因此,该数组保存 128,000 x 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 使用参数 128 调用函数 get_array,被调用函数然后使用该参数创建此大小的局部数组。 get_array 函数初始化数组,并将数组的标识符 arr 返回给 mainarr 是一个指针常量,它保存数组的第一个 int 元素的地址。

局部数组 arr 当然可以在 get_array 函数中访问,但是一旦 get_array 返回,就不能合法地访问此数组。 尽管如此,函数 main 仍尝试使用栈地址 arr 打印基于栈的数组,函数 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 使用一个参数(当前为 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 的原因,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 实例分配堆存储,这需要为字段 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 个字节),它是指向动态分配数组中 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 的地址。 最初分配的 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("%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 */
   ...
}

如果在所有块之外定义了 static 变量(例如上面的 n1),则变量的作用域是定义该变量的文件。 无论在何处定义 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)。 如前所述,作为 externglobal_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("%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 指令代表 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)。

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

3 条评论

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

感谢这篇精彩的文章。 在“堆存储”部分,我注意到 main 函数检查了 /* malloc 失败 */,但是由于我们在同一个函数 (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; /* 返回(指针的副本) */
}

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

知识共享许可协议本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。
© . All rights reserved.