当前位置:首页 > 技术学院 > 技术前线
[导读]不管是新手入门还是开发多年的工程师,理解程序运行时的内存分布,都是读懂底层运行逻辑、排查内存问题、写出高效代码的基础。很多人只知道写代码申请变量,却不知道这些变量在内存里到底放在哪里,不同区域的特性有什么区别,遇到内存越界、栈溢出这些问题的时候就一头雾水。

不管是新手入门还是开发多年的工程师,理解程序运行时的内存分布,都是读懂底层运行逻辑、排查内存问题、写出高效代码的基础。很多人只知道写代码申请变量,却不知道这些变量在内存里到底放在哪里,不同区域的特性有什么区别,遇到内存越界、栈溢出这些问题的时候就一头雾水。今天我们就从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;你知道调整结构体成员顺序能减少对齐浪费,就能让结构体更小,节省内存。

第三,理解程序运行逻辑:你知道函数调用的时候参数、返回地址存在栈里,就能理解函数调用的原理,遇到函数返回地址被破坏的问题,就能顺着栈去排查,看懂反汇编代码的时候也能对应上各个区域。

程序运行时的内存分布,是程序运行的底层骨架,所有代码和数据都要放在对应的区域里才能正常运行。从低地址的代码段到高地址的栈区,每个区域都有自己的特性和用途,理解了这些区域的分布关系,你就能从底层看懂程序的运行逻辑,遇到内存问题的时候也能快速定位解决。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
换一批
延伸阅读

在嵌入式开发领域,有一句老生常谈的话:“内存玩得转,开发一半顺”。和PC端开发不同,嵌入式系统的RAM资源往往极其紧缺——很多MCU的RAM大小只有几KB到几十KB,高端嵌入式芯片也不过几百MB,远不如PC动辄几个GB的...

关键字: 嵌入式 内存

GPIO(通用输入输出口)是嵌入式Linux开发中最基础也最常用的硬件资源,小到LED闪烁、按键检测,大到外设控制、引脚扩展,都离不开GPIO操作。不同于裸机开发中直接操作寄存器的方式,Linux内核提供了一套成熟的GP...

关键字: GPIO Linux

字符设备是Linux中最基础的设备类型之一,键盘、鼠标、串口、I2C设备、LED驱动这类常用的嵌入式设备,大多都属于字符设备,掌握字符设备驱动的基本框架,是学习Linux驱动开发的第一步。Linux内核从早期的2.6版本...

关键字: Linux 字符

树莓派3B凭借低成本、高性能、丰富的外设资源,一直是嵌入式爱好者和开发者学习RTOS的热门平台,而RT-Thread作为国内生态最完善的开源实时操作系统,对树莓派3B有着完善的原生支持。但很多刚接触的开发者,往往卡在环境...

关键字: RT-Thread Linux

本节演示如何使用AIE DIALECTS和AIE API,在AMD Ryzen AI Phoenix中对复杂数字信号在频域进行“相位变换”。

关键字: Linux 相位变换 AI

在360环视系统的初始验证阶段,我们采用了一套直观且广泛使用的技术栈:OpenCV负责从采集到显示的全部图像处理任务。功能层面,这套方案完全跑通了——四路鱼眼去畸变、透视投影、鸟瞰拼接,所有算法逻辑均正确。但当我们将目光...

关键字: GPU Linux CPU

台北2026年6月8日 /美通社/ -- 技嘉科技持续推进主板创新技术,为高性能表现树立产业新标竿。从 Ultra Durable™ 超耐久技术、以性能为核心的 Super Overclocking 超频技术,到最新 A...

关键字: 驱动技术 AI 内存 超频

台北2026年6月4日 /美通社/ -- 技嘉科技于 COMPUTEX 2026 展示支持CQDIMM 技术,以 Z890 AORUS TACHYON DUO X ICE 领军的 Z890...

关键字: 内存 TE COMPUT BSP

谈到Linux的最大并发数,很多开发者会本能想到系统配置里的ulimit -n,觉得改大这个值就能支持更多并发,甚至默认“Linux最大并发可以到几十万上百万”。但实际生产环境中,经常遇到明明把文件句柄数改到了10万,并...

关键字: Linux 并发数

当我们在代码里调用read读取文件,调用malloc分配内存,调用socket创建网络连接的时候,最终都会落到系统调用上。但很多开发者只知道系统调用是用户程序请求内核服务的接口,却说不清系统调用到底是怎么实现的:为什么用...

关键字: Linux 系统调用
关闭