盒子
导航
文章目录
  1. 代码段
  2. 数据段
  3. BSS段
  4. 堆栈段
  5. 函数及其调用
  6. 参考资料

理解C程序的内存布局

C语言很接近于硬件,是说相比于具有自动内存管理等机制的Java等高层面的程序设计语言,C程序员对于一点一滴内存都可能需要自己管理,这就要求思维需要很紧密,要清晰地了解内存结构和程序的工作机制。是的,有时候很繁琐,当你发现你需要加长字符串也要自己申请内存的时候,就发现这些实在很花费时间,不如Java神马的来得快,但是,但是这里面的乐趣也是Java神马的所不能给你的。在写C/C++程序的时候,稍不注意,就有可能导致内存泄漏了,所以,知道C程序的内存布局和工作机制,是非常有必要的。后面举了一个例子,它展示了在某种情况下将指针初始化为同一个值将导致的内存泄漏问题,从该例子来窥探指针是怎么工作的,为什么导致了内存泄漏。

一个典型的C程序在内存中的布局大致会包括以下几个部分,各个部分按内存地址从地到高依次为:

  1. 代码段
  2. 数据段
  3. BSS段
  4. 堆栈段

如下图左边部分所示:
C/C++内存布局与内存泄漏

代码段

又称为文本段。它包含程序的执行指令内容,它一般会被安排在低地址部分,这样做是为了避免产生堆栈段在分配动态内存时可能产生的内存溢出会覆盖这部分内存的危险。这部分内容通常是只读的,但也有可能是共享的。

数据段

指已初始化数据段,这部分包含了已初始化的全局变量和静态变量。这部分内存并不完全是只读的,如字符串常量值会被存放在只读部分,而可变更的变量则会被存放在可读/写区。

BSS段

BSS指的是Block Started by Symbol,由符号开始的段。BSS段指未初始化数据段,包括了没有明确初始化的全局变量和静态变量。这些变量在程序开始运行的时候将由程序内核负责初始化为0或者为空。如一个未初始化的静态变量static int a;未初始化的全局变量int b;将存放在该段。

堆栈段

包括了栈(stack)和堆(heap)两部分。不知道哪个SB在写书的时候将stack翻译为堆栈,这是一个非常误人子弟的翻译。栈和堆都是程序在运行的时候可以动态分配使用的内存部分,但是栈由操作系统内核来使用,而允许程序员使用的部分是堆,即我们所调用的malloc()系内存分配所得到的内存是在堆上的。

  • 内存泄漏:

内存泄漏指分配的内存在使用过后不做释放,导致不能重复利用,除非程序终止,否则这部分内存就再也不能被使用。若程序一直申请内存而不释放,久而久之将使可用内存变少,进而支持虚拟内存的系统出现非常频繁的段页交换,使得系统运行变慢。看一个例子(伪码)是我在写base64编码/解码的测试用例的时候遇到的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>

/* int b64_encode(char *source, char **encoded); */
/* int b64_decode(char *source, char **decoded); */
/* @return: return the size of encoded/decoded string */

/* Use void b64_free(char **ptr); to free ptr */

int main()
{
int rval = 0;
char *init = "Temp";
char **ptr1, **ptr2;
char *str = "This string is to be test.";

ptr1 = ptr2 = &init; /* init to a save value */
printf("The string is: %s\n", str);

rval = b64_encode(str, ptr1);
printf("encoded: %s\n", *ptr1);

/* memory leak here: */
rval = b64_decode(*ptr1, ptr2);
printf("decoded: %s\n", *ptr2)

b64_free(ptr1);
b64_free(ptr2); /* error */

return 0;
}

在这个例子中,有两个函数,分别做base64编码的编码和解码工作,函数返回编码/解码后的字符串长度,然后在参数中使用二维指针来返回得到的编码/解码结果,结果是在函数内部根据结果字符串长度来动态分配内存存储的。这里在编码之后直接使用编码结果作为解码函数的输入来执行解码,然后最后26、27行来释放这两个指针。乍一看,好像代码挺对的,没啥问题,但实际上第27行出错了,提示内存已经释放了!靠,为毛?我们再来看一眼程序在内存中的布局,我将上述代码的一部分画入了下图右边部分:
C/C++内存布局与内存泄漏

从上图可以看到,在代码16行,将两个二维指针初始化成为了同一个值,意味着什么呢?看图,意味着这两个指针指向了同一个“中转站”即init这个指针,函数在被调用之前,ptr1和ptr2并不知道即将申请的内存的地址(因为还没申请嘛!)它俩只知道“中转站”init在哪儿,函数在调用之后申请了内存,将地址填入init中,然后ptr1和ptr2再从“中转站”中得到新申请的内存的地址,从而读取编码/解码的结果。注意:由于这里使用了同一个“中转站”(一维指针),在第23行代码执行之前,“中转站”init指针存放的是19行编码函数内申请的内存地址,而在23行解码函数调用之后,“中转站”init中的地址就被覆盖了!!!也就是说第一个编码函数内申请的内存到了这里没有人再知道它的地址是什么了,等到了26行去freeptr1的时候,释放的是23行解码函数内申请的那块内存,而27行再释放ptr2时,ptr2指向的“中转站”init的值也就是第二次申请的那块内存刚刚在26行被释放了,于是这里报错,无法释放内存。

好了,如果b64_free里面将行参值置空会怎么样?即在b64_free函数内在释放内存后加一句*ptr = NULL;那么结果就是第27行代码不会报错了,但是内存还是泄漏了。嗯,如果你使用splint程序检验你的代码,就能发现这个问题。但如果了解这背后的机理,那么很多问题也就不会产生了。

函数及其调用

这里讲讲我对函数及其调用的理解:
函数调用

函数是一段可执行代码,这段代码内的变量在被调用时由系统在栈(stack)上动态分配存储空间(静态变量除外),这段执行代码指令编译后存放于代码段当中,它有一个起始地址,即为函数入口地址,可以声明一个指针来指向这个入口地址,称这个指针为函数指针,说到函数指针就要说到指针函数,指针函数指的是返回值是指针的函数。函数指针是一个指针,而指针是变量,它可以指向其他函数的入口地址,但要求函数的返回类型、行参类型要相同。声明了函数指针,这样就可以在运行时动态地根据需要指向不同的函数,到了C++里面,这个机制就称之为多态

例如有一个类Father,它有一个虚函数(用virtual关键字声明)叫speek();它有一个子类叫Son,Son也有一个函数叫speek()。当你用Father类声明一个对象obj,但给它分配的内存类型却是Son的,那么在你调用obj.speek()的时候这个隐含的函数指针就会给你指向Son的speek()实现,但是如果Son没有实现这个speek()函数,这个调用就会指向Father的实现,而如果没有使用virtual关键字,那么这个”多态性”就消失了,即virtual关键字就是为了告诉编译器选择合适的一个实现来调用。这其中就暗含着函数指针的运作,只是C++给我们抽象了,编译器来帮忙完成了这个事情,我们需要做的只是加个virtual关键字以及做同名的不同实现。

那么,函数调用是怎么样的呢,由图上看,当一个函数被调用的时候,系统根据入口地址找到函数代码段来开始执行,同时为其返回值变量分配一个存储空间,为其中用到的局部变量和形参变量也分配存储空间,这些存储空间是在栈中分配的。各次函数调用都会分配空间,但静态变量是在数据段/BSS段中分配,所以会被各次函数调用所“共享”。之后将实参的值拷贝给栈中的形参变量(由此你可以知道C语言中只有一种传参方式,就是传值,指针本身也是变量,值是地址而已;但到了C++就多了一个传引用,这意味着实际上函数调用时不会为形参在栈中分配空间,而是在函数执行需要用到形参时直接去读取实参的值,这也意味着在C语言中外部变量在函数内部不能使用的规则给“打破”了,实参实际上通过引用这种方式作用到了函数内部。)然后执行完毕后将返回值(即图中X,Y部分)的值拷贝给函数调用的接收者,进而这些在栈中分配的局部变量存储空间被释放回收,函数生命周期结束。

特别地,多线程时并发的情况存在,当函数是一个线程函数并且被多个线程共用时,这时候如果存在静态变量,虽然函数每次调用都会分配新的空间,但由于静态变量共享,这个时候多个线程共享一个变量,这将使得程序逻辑不再局部于线程本身,而极大可能会被其他线程影响和修改结果,这个就是线程安全问题,这个函数就是线程不安全的。

参考资料

  • 《C Traps and Pitfalls》
  • 《Expert C Programming》
  • 《The C Programming Language》
  • 《Programming Principles and Practice Using C++》