0%

浅析C语言运行时内存管理

主要讨论C语言怎样组织正在运行的程序的数据结构的细节。

我们知道知道在UNIX操作系统中,一个C语言文件经过预处理(cpp),编译(cc1),汇编(as)和链接(ld)后可以得到可执行文件a.out。我们可以用size命令(或nm、dump)检查可执行文件,它会告诉你这个文件中的三个段(文本段.text,数据段.data,.bss段)的大小。文本段包含程序的指令。由于程序的文本内容和大小都不会变化,链接器把指令直接从文件拷贝到内存中,不再处理。数据段包含经过初始化的全局和静态变量以及它们的值。BSS段的大小从可执行文件中得到,然后链接器得到这个大小的内存块,紧跟在数据段之后。当这个内存区进入程序的地址空间后全部清零,通常是memset全部置0。通常把数据段和BSS段统一称为数据区。上面这些内容可以通过size命令很直观的查看与验证。除了上面提到的程序指令、全局和静态变量,我们还有局部变量等数据需要存储。这就引出了堆栈段(stack)和堆空间(heap)。下面将详细讨论数据区、堆栈段和堆空间。

1.数据区存放着全局变量与静态变量,包括初始化和未初始化。其中未初始化的在进入程序地址空间的时候统一清零。这些数据的生命周期都是主程序运行时间。所谓静态变量,是指由static修饰的变量,可以是全局变量,也可以是局部变量。当修饰子函数的局部变量的时候,如果有初始化的话只在第一次进入子函数的时候执行初始化语句,并且在子函数退出的时候依然有效。关于static的另一个作用便是对链接器不可见,这个在以后再说。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

void foo()
{
  static int a = 0;
  a ++;
  printf("%d\n", a);
}

int main()
{
  foo();
  foo();
  foo();
  return 0;
}
//print: 1, 2, 3

2.堆栈段有三个主要的用途:一是为函数内部声明的局部变量(也称“自动变量”)提供存储空间;二是在进行函数调用时,堆栈存储与此相关的一些维护性信息。这些信息被称为堆栈结构(stack frame),或者过程活动记录(precedure activation recored),其中包括函数调用地址、任何不适合装入寄存器的参数以及一些寄存器的保存;三是用作暂时存储区。有时程序需要一些临时存储,比如算术表达式计算。值得注意的是堆栈的入栈是由高地址到低地址,并且局部变量的生命周期在子函数退出的时候也释放了。下面看一个程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include &lt;stdio.h&gt;

int foo(int i)
{
  volatile int a = 1;
  volatile int b[2] = {0};
  return b[i];
}

int main()
{
  int b = foo(2);
  printf("%d\n", b);
  return 0;
}

由于C语言中对数组越界并不会作检查,所以上述程序是可以编译通过并执行的。

volatile是为了防止编译器对代码进行优化,我们来看下foo子函数的堆栈段的情况,由高到低,压入i,a,b[1],b[0],则i==2时,返回的实际上是a。

上面提出局部变量的生命周期,如果一个函数返回了一个局部变量会怎么样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

int foo1()
{
  int a = 100;
  return a;
}

int *foo2()
{
  int a = 101;
  return &amp;a;
}

int main()
{
  int *y = foo2();
  int x = foo1();

  printf("%d, %d\n", x, *y);
  return 0;
}

两个函数是不一样的,第一个函数返回int型变量,第二个函数返回给一个指针,指向int类型。第一个函数是没有错的,return的时候return的是局部变量a的一个拷贝。而第二个函数返回了局部变量的地址,这是很不安全的。由于函数foo()退出后,局部变量a的地址不受保护,可能会被用作他用,如上面的程序先调用foo2(),再调用foo1()时,把之前的y指向的地址的内容修改了,所以上述程序输出为100, 100。

是不是感觉不过瘾,那再来一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

void foo1()
{
  int a = 100;
}

void foo2()
{
  int a;
  printf("%d\n", a);
}

int main()
{
  foo1();
  foo2();
  return 0;
}

如果根据第二个例子,我们可以很容易推出结果是:100。但是结果对吗?跑一下就知道了,gcc -O2 test test.c。这里加入了优化选项,从程序中可以看出foo()函数其实什么都没有做,经过编译器可能直接路过foo1(),执行foo2(),所以结果是个随机数。验证也很简单,在foo1()中对a进行一次相关操作就好了。好了,这个有点涉及程序优化了,以后再说。

3.堆(heap)空间用于动态内存的分配,用于malloc相关函数。上面提到堆栈段,注意堆栈段就是栈,是向下生长的,而堆是向上生长的,正好形成一个闭区间。当我们调用malloc等分配内存的时候,要进行检查,因为如果分配失败的话,返回NULL指针,访问的话就会出现段错误了。还有一点要注意是内存泄漏,不用的时候需要显示free()释放。有些语言是加入了垃圾回收机制的,如LISP。对了,还有一点需要提防的就是野指针。野指针在我们进行free之后,指针的指向还是没有变的,所以需要手动置为NULL。

上面主要对C语言运行时的几个段进行一些讨论,当然更深入的学习还是要靠大家自己去实验。

参考:《C专家编程》,《深入理解计算机系统》