当前位置:首页 > 技术学院 > 技术前线
[导读]很多嵌入式开发者写了很多年代码,天天调用变量、申请内存,却从来没有真正理清过程序编译完成之后,在运行时物理内存里到底是怎么分布的。新手遇到程序崩溃、内存溢出的问题时,只会盲目加打印调试,完全不知道不同类型的变量分别落在内存的哪个区域,不同内存区域的读写权限、生命周期有什么差异,排查问题的时候根本找不到切入点。

很多嵌入式开发者写了很多年代码,天天调用变量、申请内存,却从来没有真正理清过程序编译完成之后,在运行时物理内存里到底是怎么分布的。新手遇到程序崩溃、内存溢出的问题时,只会盲目加打印调试,完全不知道不同类型的变量分别落在内存的哪个区域,不同内存区域的读写权限、生命周期有什么差异,排查问题的时候根本找不到切入点。

实际上不管是桌面端的Linux程序,还是嵌入式MCU上跑的裸机程序,程序运行时的内存分布都遵循一套非常清晰的通用逻辑。从程序被加载进内存的那一刻开始,不同属性的代码和数据会被划分到不同的独立内存区域里,每个区域都有自己明确的用途、权限规则和生命周期,理解这套分布逻辑,你才能在遇到内存相关的疑难杂症时,快速定位根因,写出更稳定、更高效的代码。今天我们就从程序的编译链接过程讲起,逐层拆解程序运行时的全内存分布关系,帮你彻底理清内存里的每一块空间到底存了什么。

从编译链接到内存加载的完整流程

想要理解程序运行时的内存分布,首先要搞清楚程序从源代码到最终运行的完整链路。你写的C语言源代码,经过编译器预处理、编译、汇编三个步骤之后,会生成多个独立的目标文件,每个目标文件里包含自己的代码段、数据段和符号表,这些独立的目标文件本身是无法直接运行的,它们的地址都是相对偏移地址,根本不知道自己在整个程序内存空间中的绝对位置。

接下来链接器登场,它的核心工作就是把所有分散的目标文件和用到的库文件整合到一起,按照预先定义的链接脚本规则,把所有的代码和数据分门别类地合并起来,给每一段内容分配最终的绝对虚拟地址,生成最终的可执行程序。这个可执行程序存放在Flash或者磁盘这类非易失性存储介质里,当操作系统或者Bootloader要启动这个程序的时候,会把程序的不同段按照规则加载到RAM物理内存中,程序从此开始正式运行,形成我们看到的运行时内存分布。

不同平台的内存分布细节会有差异,比如32位MCU的裸机程序没有虚拟内存机制,所有地址都是直接对应的物理内存地址;Linux用户态程序使用虚拟地址空间,每个进程都有独立的4GB虚拟地址空间,但是底层的核心内存分段逻辑是完全共通的。理解这套通用的分布逻辑,不管你面对的是单片机还是高性能处理器,都能快速理清内存的布局规则。

运行时内存的核心五大区域

程序运行时的整个内存空间,从低地址往高地址排列,会被清晰地划分成五个独立的核心区域,每个区域的特性完全不同。

第一个区域是代码段,也叫文本段,它的内存地址是整个程序空间里最低的部分,里面存放的是所有经过编译生成的CPU机器指令。这个区域的特性是只读,操作系统或者MPU硬件会配置这个内存区域的属性为不可写,只允许CPU从这里读取指令执行,不允许程序往这个区域里写入任何数据。这样设计的目的是为了防止程序的指令被意外篡改,避免出现恶意代码注入或者程序跑飞修改代码逻辑的问题。代码段里的内容在程序运行的整个生命周期里都不会发生任何变化,程序退出之前,这些机器指令会一直保存在这个区域里。很多时候代码段里还会嵌入一些只读的常量数据,比如你代码里定义的字符串常量,这些内容也会被放到代码段里,和指令一起受到只读保护。

第二个区域是只读数据段,很多资料里也叫rodata段,这个区域专门用来存放程序里所有的只读常量,比如用const关键字修饰的全局变量,#define定义的大体积常量数组,这些数据在程序运行过程中绝对不会被修改,所以放到这个只读区域里。和代码段一样,这个区域的内存属性也是只读的,任何尝试修改这个区域内容的操作,都会直接触发内存访问错误,引发程序崩溃。把常量单独放到这个区域里,既可以得到硬件的写保护,提升系统的稳定性,也可以在很多支持XIP原地执行的嵌入式平台上,直接把这个段放到Flash里运行,完全不需要占用宝贵的RAM空间,大幅节省内存资源。

第三个区域是初始化数据段,也叫data段,这个区域里存放的是所有在定义的时候就被赋予非零初值的全局变量和静态变量。程序加载的时候,会直接把存储介质里保存的变量初值原样拷贝到这个内存区域里,程序运行之后,代码可以自由读写这个区域里的变量,修改的数值会一直保留,直到程序退出。比如你定义一个全局的int变量,初始值设置为100,这个变量就会被放到data段里,程序启动之后它的初始值就是100,后续你可以随意修改它的数值。

第四个区域是未初始化数据段,也叫bss段,这个区域里存放的是所有定义的时候没有指定初值,或者初值为0的全局变量和静态变量。这个段有一个非常特殊的特性:程序加载的时候,不需要从非易失性存储介质里加载任何初值,系统只会在程序启动的瞬间,把整个bss段的所有内存全部清零。这也是为什么C语言标准规定,未初始化的全局变量默认初值为0的根本原因。这个段的存在可以极大节省可执行文件的体积,如果你定义一个100KB的全零全局数组,不需要在可执行文件里存储100KB的全零数据,只需要在文件头里记录这个段的总大小即可,程序启动的时候直接清零这块内存,可执行文件的体积几乎没有任何增加。

第五个区域是动态内存区域,这个区域又被划分成两个部分:位于低地址方向的堆,和位于高地址方向的栈。堆空间是向上增长的,也就是往高地址方向扩展,专门用来给动态内存分配函数提供空间,你调用malloc、new这类函数申请的内存,全部都是从堆空间里分配出来的,申请的内存必须手动释放,否则就会出现内存泄漏,堆空间会随着分配的进行不断往高地址方向延伸。而栈空间是向下增长的,往低地址方向扩展,函数调用过程中产生的局部变量、函数参数、返回地址,全部都是临时存放在栈空间里,函数执行完成之后,对应的栈空间会自动回收,完全不需要手动管理。

内存分布的工程实践指导意义

理清程序运行时的内存分布关系,不是为了应付面试的理论知识点,它能直接指导我们写出更高效、更稳定的嵌入式代码。

首先你可以精准控制变量的存放位置,把所有不会修改的常量全部放到rodata段,既节省RAM空间,又能得到硬件的写保护,避免野指针意外篡改常量数值。同时合理利用bss段的特性,大体积的全局数组如果不需要非零初值,就不要手动给它写全零初始化,直接默认不初始化放到bss段,既可以减小可执行文件体积,也能节省程序加载的时间。

其次你可以精准排查各类内存异常问题,程序如果出现修改代码段的崩溃,那一定是函数指针跑飞了,指向了代码段里的某个地址,误把指令当成数据修改了;如果程序出现栈溢出崩溃,那一定是局部变量定义得太大,或者函数嵌套层数太深,栈空间耗尽往低地址方向覆盖了其他区域的内存。通过程序编译生成的map文件,你可以直接看到每一个段的起始地址和总大小,精准掌握每一块内存的使用情况,提前发现内存溢出的风险。

在资源极其受限的嵌入式MCU场景下,你甚至可以手动修改链接脚本,调整不同段的内存分布,把关键的实时代码放到紧耦合内存区域,把非关键的延迟加载数据放到低速内存里,最大化利用不同特性的内存资源,在有限的硬件条件下实现最优的性能。

程序运行时的内存分布,本质上是操作系统和编译器共同给你搭建好的一套有序的内存规则,理解这套规则,你就不再是对着变量盲目写代码的新手,而是可以精准掌控每一个字节内存用途的资深工程师。

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