内存布局:代码段、数据段、BSS段、堆、栈
各位同学,今天我们来聊聊C程序在内存里到底是怎么“住”的。说白了,就是你的代码编译链接后,操作系统是怎么给它分配地盘的。
我刚开始学C的时候,总觉得“内存”就是个黑盒子。变量放进去,函数调一调,完事。直到有一次我在嵌入式项目里遇到了一个诡异的崩溃——程序跑着跑着就挂了,查了两天才发现是栈溢出了。嗯,从那以后,我老老实实把内存布局啃了个透。
一个程序的内存长什么样?
我们先画一张总览图。一个典型的C程序,在内存里大致分成这么几块:
这张图是从高地址到低地址画的。栈在最上面,往下是堆,再往下是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。改成不初始化后,文件立马瘦身。
堆:动态分配的战场
堆是程序运行时动态分配内存的地方。malloc、calloc、realloc、free这些函数操作的就是堆。
堆的大小在编译时是不知道的。你写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) | 从分配到释放 |
| 栈 | 局部变量、函数参数、返回地址 | 运行时(函数调用时) | 函数执行期间 |
编译链接时,这些段是怎么分配的?
好,我们回到编译链接这个主题。整个过程大致是这样的:
- 编译阶段:每个源文件(.c)被编译成目标文件(.o)。每个目标文件里都有自己的代码段、数据段、BSS段。这时候地址还是相对的,从0开始。
- 链接阶段:链接器把所有目标文件合并成一个可执行文件。它会做几件事:
- 把所有代码段合并到一起,重新分配地址
- 把所有数据段合并到一起,重新分配地址
- 把所有BSS段合并到一起,计算总大小
- 解析符号引用(比如你调用了
printf,链接器要找到它的地址) - 生成最终的内存布局
- 加载阶段:当你运行程序时,加载器把可执行文件映射到内存里。代码段映射到只读区域,数据段和BSS段映射到可读写区域,然后跳转到入口点(比如
main)开始执行。
说白了,编译链接时确定的是“地址布局”,运行时确定的是“实际内存位置”。现代操作系统还有ASLR(地址空间布局随机化),每次加载的基地址都不一样,这是为了安全考虑。
一句话总结:代码段、数据段、BSS段的大小和相对位置在链接时就定死了。堆和栈的大小在运行时动态变化,但它们的“活动范围”是固定的——栈从高地址往低长,堆从低地址往高长。
好了,这一章就到这里。内存布局是理解C程序运行机制的基石,后面讲变量作用域、函数调用、动态内存管理的时候,我们还会反复提到这些概念。你先把这张图画在脑子里,后面的内容就好理解了。
公众号:蓝海资料掘金营,微信deep3321