实例讲解代码之内存安全与效率

Marty Kalin 的头像

·

·

·

3,915 次阅读

了解有关内存安全和效率的更多信息。

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 'get_array':
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        /
             +-+   +>|int1|int2|...|intN|
              +-+   +

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

作者:[Marty Kalin](https://opensource.com/users/mkalindepauledu) 选题:[lujun9972](https://github.com/lujun9972) 译者:[unigeorge](https://github.com/unigeorge) 校对:[wxy](https://github.com/wxy)

本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创编译,[Linux中国](https://linux.cn/) 荣誉推出

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注