内存布局:代码段、数据段、BSS段、堆、栈

各位同学,今天我们来聊聊C程序在内存里到底是怎么“住”的。说白了,就是你的代码编译链接后,操作系统是怎么给它分配地盘的。

我刚开始学C的时候,总觉得“内存”就是个黑盒子。变量放进去,函数调一调,完事。直到有一次我在嵌入式项目里遇到了一个诡异的崩溃——程序跑着跑着就挂了,查了两天才发现是栈溢出了。嗯,从那以后,我老老实实把内存布局啃了个透。

一个程序的内存长什么样?

我们先画一张总览图。一个典型的C程序,在内存里大致分成这么几块:

栈(Stack) 局部变量、函数参数、返回地址 堆(Heap) malloc / free 动态分配 BSS段(未初始化全局/静态变量) 数据段(已初始化全局/静态变量) 代码段(Text / Code) 机器指令、只读常量 低地址 高地址

这张图是从高地址到低地址画的。栈在最上面,往下是堆,再往下是BSS段、数据段,最下面是代码段。你想想看,这其实是有讲究的——栈和堆相向生长,这样能更高效地利用中间的空闲内存。

代码段:你的程序指令住在这里

代码段,也叫文本段(Text Segment)。这里面放的是啥?就是你的CPU要执行的机器指令。还有那些用const修饰的只读常量,也搁这儿。

我个人习惯把代码段想象成“只读的剧本”。程序运行的时候,CPU照着这个剧本一句一句演。你不能在运行时改剧本——操作系统也不允许你改,代码段在内存里是只读的。你要是敢写个指针去改代码段里的内容,立马给你个段错误。

关键点:代码段在编译链接时就确定了大小和内容。链接器把各个目标文件(.o)的代码段合并到一起,生成最终的可执行文件。运行时,加载器把它映射到内存里,标记为只读+可执行。

数据段:已经初始化的全局变量

数据段(Data Segment)放的是那些已经初始化了的全局变量和静态变量。比如:

int global_count = 42;           // 放在数据段
static int file_scope_val = 100; // 也放在数据段

这些变量在编译时就有确定的值。链接器会把所有目标文件里的数据段合并,然后分配地址。我记得有一次做跨平台移植,发现不同编译器对数据段的对齐方式不一样,结果结构体大小算错了,折腾了半天。嗯,对齐这事儿后面会细讲。

BSS段:未初始化的全局变量

BSS段(Block Started by Symbol)放的是那些没有初始化的全局变量和静态变量。比如:

int global_uninit;               // 放在BSS段
static int static_uninit;        // 也放在BSS段

你可能会问:这些变量没赋值,那它们占不占磁盘空间?

答案是:不占。BSS段在可执行文件里只记录一个“大小信息”,不存储实际数据。程序加载时,操作系统会把这个区域全部清零。所以未初始化的全局变量默认是0,就是这个道理。

一个小技巧:如果你有大量全局缓冲区,尽量放在BSS段(不初始化),这样可执行文件会小很多。我曾经接手过一个项目,有人把一个大数组初始化为全0,结果可执行文件大了好几MB。改成不初始化后,文件立马瘦身。

堆:动态分配的战场

堆是程序运行时动态分配内存的地方。malloccallocreallocfree这些函数操作的就是堆。

堆的大小在编译时是不知道的。你写malloc(1024),编译器根本不管,它只生成调用malloc的指令。真正分配内存是在运行时,由操作系统和C运行时库共同完成。

堆的地址是从低往高长的(和栈相反)。这样做的好处是:栈和堆可以共享中间的空闲区域,内存利用率更高。

我曾经踩过的坑:堆内存泄漏。写了一个网络服务程序,每次处理请求都malloc一块内存,但忘了free。跑了三天,内存占用从10MB涨到了2GB,然后系统OOM(Out Of Memory)把进程杀了。从那以后,我每次malloc都强迫自己马上写对应的free。

栈:函数的临时舞台

栈(Stack)是存放局部变量、函数参数、返回地址的地方。每次调用一个函数,系统就会在栈上分配一块“栈帧”。函数返回时,这块栈帧就被回收了。

栈的特点是:自动管理、速度快、大小有限。你想想看,局部变量不用你手动释放,函数结束就没了,多省心。但栈的大小是有限的——Linux默认一般是8MB,Windows默认1MB。如果你在函数里定义一个很大的局部数组:

void foo() {
    char big_buffer[10 * 1024 * 1024]; // 10MB,栈会爆!
}

这就叫栈溢出。程序会直接崩溃,连个错误提示都不给你。

内存段 存储内容 分配时机 生命周期
代码段 机器指令、只读常量 编译链接时 程序整个运行期
数据段 已初始化的全局/静态变量 编译链接时 程序整个运行期
BSS段 未初始化的全局/静态变量 编译链接时(只记录大小) 程序整个运行期
动态分配的内存 运行时(malloc/free) 从分配到释放
局部变量、函数参数、返回地址 运行时(函数调用时) 函数执行期间

编译链接时,这些段是怎么分配的?

好,我们回到编译链接这个主题。整个过程大致是这样的:

  1. 编译阶段:每个源文件(.c)被编译成目标文件(.o)。每个目标文件里都有自己的代码段、数据段、BSS段。这时候地址还是相对的,从0开始。
  2. 链接阶段:链接器把所有目标文件合并成一个可执行文件。它会做几件事:
    • 把所有代码段合并到一起,重新分配地址
    • 把所有数据段合并到一起,重新分配地址
    • 把所有BSS段合并到一起,计算总大小
    • 解析符号引用(比如你调用了printf,链接器要找到它的地址)
    • 生成最终的内存布局
  3. 加载阶段:当你运行程序时,加载器把可执行文件映射到内存里。代码段映射到只读区域,数据段和BSS段映射到可读写区域,然后跳转到入口点(比如main)开始执行。

说白了,编译链接时确定的是“地址布局”,运行时确定的是“实际内存位置”。现代操作系统还有ASLR(地址空间布局随机化),每次加载的基地址都不一样,这是为了安全考虑。

一句话总结:代码段、数据段、BSS段的大小和相对位置在链接时就定死了。堆和栈的大小在运行时动态变化,但它们的“活动范围”是固定的——栈从高地址往低长,堆从低地址往高长。

好了,这一章就到这里。内存布局是理解C程序运行机制的基石,后面讲变量作用域、函数调用、动态内存管理的时候,我们还会反复提到这些概念。你先把这张图画在脑子里,后面的内容就好理解了。


公众号:蓝海资料掘金营,微信deep3321