内存分布的完整结构详解
不管是新手入门还是开发多年的工程师,理解程序运行时的内存分布,都是读懂底层运行逻辑、排查内存问题、写出高效代码的基础。很多人只知道写代码申请变量,却不知道这些变量在内存里到底放在哪里,不同区域的特性有什么区别,遇到内存越界、栈溢出这些问题的时候就一头雾水。今天我们就从C语言程序出发,把程序运行时的内存分布拆解得明明白白,帮你理清各个区域的关系和特性。
一、从程序文件到运行内存:完整的加载流程
要理解运行时内存分布,得先从程序的生命周期说起:我们写的C语言代码,经过编译、链接之后,会生成一个可执行文件存在硬盘里,这个可执行文件本身就已经按照不同段分好了布局,当我们点击运行(或者嵌入式系统把程序加载到内存)的时候,操作系统会把可执行文件里不同的段加载到内存对应的区域,程序才开始运行。
简单来说,可执行文件里的段和运行时内存的段是对应的:
代码段(.text):从可执行文件加载到内存的只读区域
数据段(.data和.bss):从可执行文件加载到内存的可读可写区域
堆、栈:程序运行后由操作系统分配动态区域
我们常说的“运行时内存分布”,指的就是程序运行起来之后,整个虚拟地址空间(32位系统是0~4GB,64位空间更大)从低地址到高地址的分段布局,接下来我们从低地址到高地址逐个拆解每个区域的作用和特性。
二、从低地址到高地址:内存分布的完整结构
一个典型的32位Linux用户态程序,运行时内存从低地址到高地址依次分为:代码段、数据段(.data)、BSS段(.bss)、堆区、内存映射区、栈区、内核空间,我们一个个来看:
1. 代码段(.text段):存放程序执行指令
代码段是整个程序运行的入口,位于内存低地址区域,存放的是CPU执行的机器指令,也就是我们写的所有函数编译后的二进制代码。
代码段的核心特性是只读,因为程序指令运行的时候不需要修改,如果允许修改会很容易导致程序被恶意篡改,所以操作系统会把代码段 mapped 到只读的内存区域。同时,代码段是共享的,即使同一个程序运行多个实例,内存里也只需要保存一份代码段,节省内存空间。
代码段的大小由程序的代码量决定,你写的函数越多、代码越长,代码段就越大,一般来说代码段占用的是Flash/硬盘空间,加载到内存后也是固定大小,不会运行时动态改变。
举个例子:我们写的main函数、自定义的功能函数,编译之后的机器指令都存在代码段里。
2. .data段(已初始化数据段):存放已初始化的全局/静态变量
.data段紧挨着代码段,放在高地址方向,它存放的是已经初始化,并且初始值不为0的全局变量和静态变量。这个区域是可读可写的,因为程序运行的时候可以修改这些变量的值。
举个简单例子:
// 全局变量,已初始化,非0,存在.data段
int global_init = 10;
// 静态全局变量,已初始化,存在.data段
static int static_global = 20;
void func() {
// 静态局部变量,已初始化,也存在.data段
static int local_static = 30;
}
这里需要注意,不管是全局的静态变量还是局部的静态变量,只要是已初始化非0,都会放在.data段,和作用域无关。.data段的大小是在编译的时候就确定的,运行的时候不会改变,因为变量的数量已经固定了。
3. .bss段(未初始化数据段):存放未初始化/零初始化的全局/静态变量
.bss段紧挨着.data段的高地址方向,存放的是未初始化,或者初始化为0的全局变量和静态变量。
.bss段和.data段最大的区别是:.bss段不会在可执行文件中占用实际空间,只是记录了大小,程序加载的时候,操作系统会把这个区域全部清零,所以所有未初始化的全局变量默认值都是0,这就是为什么我们定义全局变量不初始化,默认值是0的原因。
举个例子:
// 未初始化的全局变量,存在.bss段
int global_uninit;
// 初始化为0的全局变量,也存在.bss段
int global_zero = 0;
为什么要分成.data和.bss两个段?主要是为了节省可执行文件的空间:因为.bss段都是0,不需要在可执行文件里存每个字节的0,只需要记录大小就可以,加载的时候再清零,能大幅缩小可执行文件的体积。
到这里,我们可以总结一下前三个段的共性:代码段、.data段、.bss段的大小都是编译的时候就确定的,运行的时候不会改变,所以这些区域也叫做“静态内存区”。
4. 堆区(Heap):动态内存分配的区域
堆区位于BSS段的高地址方向,是用来给程序运行时动态分配内存的区域,我们用malloc、calloc、realloc申请的内存,就来自堆区,申请的内存需要我们手动用free释放,如果不释放就会造成内存泄漏。
堆区的特性是从低地址往高地址增长,你每次申请内存,都会在现有堆的最高地址往上分配新的空间。堆区的大小是运行时动态变化的,可以越用越大(只要还有可用内存),不需要提前在编译的时候确定大小。
对于很多小内存的嵌入式系统,堆区的大小是在链接脚本里提前定义好的,最大不会超过设定的大小;在有操作系统的平台比如Linux,堆可以动态向操作系统申请内存,只要进程虚拟地址空间还有剩余,就能一直分配。
堆区管理比较复杂,频繁的分配和释放容易产生内存碎片,分配释放的速度也比栈慢,适合存放大小不确定、生命周期比较长的数据。
5. 内存映射区:文件和动态库的映射区域
在堆区和栈区之间,有一个内存映射区,这个区域是操作系统提供的,用来做文件映射、动态库加载、匿名内存映射用的。
比如我们程序运行的时候需要用到libc.so这些动态链接库,操作系统就会把动态库的代码段、数据段映射到这个区域,不需要把整个库复制一份到内存,节省内存空间。我们用mmap系统调用申请的内存,也来自这个区域。这个区域一般不用开发者太多关心,由操作系统和动态链接器自动管理。
6. 栈区(Stack):存放局部变量和函数调用信息
栈区位于内存高地址区域(用户态),和堆区正好相反,栈区的增长方向是从高地址往低地址增长,由编译器自动管理,遵循“先进后出”的规则:函数调用的时候会把参数、返回地址、局部变量压入栈,函数返回的时候会自动把这些数据弹出栈,不需要我们手动管理。
栈区存放的内容主要包括:
函数的形式参数
函数的局部变量(非静态的)
函数调用的返回地址,保存调用完之后要回到哪里
中断/异常发生的时候,当前的CPU寄存器上下文(也就是现场)
栈区的大小是固定的,在程序启动的时候就由操作系统或者链接脚本确定了,比如Linux默认用户态栈大小是8MB,嵌入式MCU一般是几KB到几十KB。因为栈空间有限,所以不能定义太大的局部数组,否则就会造成栈溢出,覆盖其他区域的数据,导致程序跑飞。
这里有一个很容易混淆的点:堆和栈增长方向相反,堆从低往高长,栈从高往低长,所以如果堆不断分配内存,栈不断压栈,最容易碰撞的地方就是堆和栈中间的区域,一旦溢出就会互相覆盖,这也是很多内存错误的来源。
7. 内核空间:操作系统内核的区域
在32位系统里,最高的1GB地址是内核空间,用来存放操作系统内核的代码和数据,用户态程序不能直接访问,只有通过系统调用才能进入内核态访问,这个区域是所有用户进程共享的,我们一般开发应用程序不需要关心这个区域的细节。
三、一张图理清:各个区域的位置和关系
我们把上面的内容整理一下,32位用户态程序从低地址到高地址的分布大概是这样:
低地址 → 高地址
.text(代码段,只读)→ .data(已初始化数据,可读可写)→ .bss(未初始化数据,可读可写)→ 堆区(动态分配,低→高增长)→ 内存映射区 → 栈区(自动管理,高→低增长)→ 内核空间
我们再用一个完整的代码例子,把每个变量对应到区域,就能一目了然:
#include
#include
// 1. 未初始化全局变量,放在.bss段
int uninit_global;
// 2. 已初始化非0全局变量,放在.data段
int init_global = 10;
// 3. 初始化为0全局变量,放在.bss段
int zero_global = 0;
// 4. const常量,放在代码段(只读)
const int const_global = 20;
// 函数,机器指令放在代码段
void func(int param) {
// param是参数,放在栈区
// 5. 局部变量,放在栈区
int local_val = 30;
// 6. 静态局部变量,已初始化,放在.data段
static int static_local = 40;
// 7. 局部数组,放在栈区
int local_arr;
// 8. 动态分配内存,来自堆区
int *heap_ptr = malloc(10 * sizeof(int));
}
int main() {
// main函数本身,指令在代码段
func(10);
return 0;
}
所有变量对应哪个区域,一下子就能对应上,再也不会混淆了。
四、常见问题解析:理清容易混淆的知识点
很多人对内存分布有一些常见的误区,我们在这里一一澄清:
误区1:栈是先进后出,所以地址一定是高地址先存,低地址后存?
大部分架构确实是这样,但栈的增长方向是架构决定的,也有少数架构栈是从低往高增长,只是我们常用的x86、ARM都是高往低增长,所以我们说栈从高往低增长是针对常用架构来说的。
误区2:所有的局部变量都存在栈区?
不对,静态局部变量存在.data/.bss段,不管你是定义在函数里面还是外面,静态变量都是存在静态区,只有非静态的局部变量才存在栈区。还有变长数组是存在栈区,而动态分配的不管在哪里都在堆区。
误区3:const修饰的变量一定存在代码段?
不对,只有const修饰的全局常量才会放在代码段,const修饰的局部变量还是存在栈区,因为它是局部变量,只是编译器不允许修改它的值,内存位置还是栈区。
误区4:栈和堆会不会碰到一起?
正常情况下不会,因为内存空间足够大,栈不会用满,堆也不会分配到栈的区域,但如果栈溢出,或者堆一直分配不释放,就有可能碰到一起,一旦覆盖就会产生不可预期的错误,也就是我们常说的“内存越界”。
误区5:嵌入式程序和PC程序内存分布一样吗?
整体结构是一样的,嵌入式没有虚拟地址空间,所以是直接操作物理内存,结构同样是:代码段放在Flash,data、bss、堆、栈都放在RAM,顺序和我们上面说的一样,只是没有内核空间和内存映射区这些操作系统相关的部分。
五、理解内存分布对开发有什么用?
很多人觉得理解内存分布是底层开发才需要的,做应用开发不需要懂,实际上理解内存分布能帮我们解决很多实际问题:
第一,排查内存问题:遇到栈溢出,你知道是太大的局部变量放在栈里导致的,解决方法就是改成全局或者动态分配;遇到内存泄漏,你知道是堆里的内存没释放,就能顺着去找哪里忘记释放了。
第二,优化内存占用:你知道const常量放在Flash不占RAM,所以把大的查表数据加上const,就能省出大量RAM;你知道调整结构体成员顺序能减少对齐浪费,就能让结构体更小,节省内存。
第三,理解程序运行逻辑:你知道函数调用的时候参数、返回地址存在栈里,就能理解函数调用的原理,遇到函数返回地址被破坏的问题,就能顺着栈去排查,看懂反汇编代码的时候也能对应上各个区域。
程序运行时的内存分布,是程序运行的底层骨架,所有代码和数据都要放在对应的区域里才能正常运行。从低地址的代码段到高地址的栈区,每个区域都有自己的特性和用途,理解了这些区域的分布关系,你就能从底层看懂程序的运行逻辑,遇到内存问题的时候也能快速定位解决。





