3. 裸机环境下的内存布局:堆、栈、全局变量区、代码区的划分与保护

各位同学,咱们今天聊点实在的。

裸机环境下,没有操作系统帮你打理内存。你写的每一行代码,用的每一个变量,都得你自己操心放哪儿。我刚开始做嵌入式那会儿,就吃过这个亏——程序跑着跑着突然死机,查了三天,最后发现是栈溢出了,把全局变量区给踩了。嗯,那种感觉,就像你精心搭好的积木,被熊孩子一脚踹飞。

3.1 内存布局的四大金刚

一个典型的裸机程序,内存布局就四个区域。说白了,就是代码、全局变量、栈、堆。咱们一个一个说。

区域 存放内容 增长方向 生命周期
代码区(Text) 程序指令、常量字符串 固定 整个程序运行期
全局变量区(Data/BSS) 全局变量、静态变量 固定 整个程序运行期
栈区(Stack) 局部变量、函数参数、返回地址 向下增长(通常) 函数调用期间
堆区(Heap) 动态分配的内存(malloc/free) 向上增长 手动管理

你想想看,这四个区域在内存里是怎么排的?

通常,代码区放在最低地址,然后是全局变量区。栈和堆呢?它们俩从两头往中间长。栈从高地址往下长,堆从低地址往上长。为什么要这么设计?就是为了让它们能共享中间那块空闲区域,谁需要谁用。

核心要点:栈和堆相向生长,中间是自由区域。一旦它们碰头了,你的程序就挂了。这就是经典的“内存耗尽”问题。

3.2 栈区:最容易出事的区域

我个人习惯,做裸机项目时第一个关注的就是栈。

为什么?因为栈溢出太隐蔽了。你写个递归函数,或者局部变量声明了个大数组,栈空间瞬间就爆了。而且栈溢出不会立刻报错,它只是悄悄地把相邻区域的数据给覆盖了。

我曾经在一个电机控制项目里,遇到一个诡异的现象:电机转着转着,突然反向加速。查了两天,最后发现是某个中断服务函数里声明了一个 2KB 的局部数组,把全局变量区里的控制参数给覆盖了。电机读到错误的参数,自然就疯了。

避坑指南:我曾经在 STM32 上遇到过,默认栈大小只有 1KB。如果你用到了 RTOS 或者复杂的函数调用链,1KB 根本不够。建议至少留 4KB,如果用到递归,请直接上 8KB 以上。

栈的分配,通常在链接脚本里定义。比如:

/* 链接脚本片段 */
_stack_size = 0x1000;  /* 4KB 栈空间 */
_stack_start = ORIGIN(RAM) + LENGTH(RAM) - _stack_size;

3.3 堆区:用不好就是定时炸弹

堆,说白了就是动态内存。你用 malloc 申请,用 free 释放。

但在裸机环境下,我建议你——能不用就不用

为什么?因为堆管理有三大坑:

  • 碎片化:频繁申请释放,内存会碎成一片一片的。你明明还有 10KB 空闲,但就是申请不到一个 2KB 的连续块。
  • 不可预测性malloc 的执行时间不确定。在实时性要求高的场合,这就是灾难。
  • 忘记释放:内存泄漏。程序跑着跑着,堆空间越来越少,最后崩了。

我个人习惯,裸机项目里只用静态分配。实在需要动态分配,就用内存池——提前划分好固定大小的块,需要时直接取,用完还回去。这样既快又不会碎片化。

小技巧:如果你非要用堆,记得实现一个 malloc 失败的回调函数。当内存不足时,至少能打印个错误信息,而不是直接死机。

3.4 全局变量区:别让它成为“公共厕所”

全局变量,谁都能访问,谁都能改。这既是优点,也是缺点。

我见过一个项目,全局变量用了上百个,分布在十几个源文件里。调试的时候,你根本不知道哪个函数在什么时候改了哪个变量。那感觉,就像在公共厕所里找自己的牙刷——根本不可能。

我的建议:

  • 能用局部变量,就别用全局变量。
  • 必须用全局变量时,加 static 限制作用域。
  • 用结构体把相关的全局变量打包,别散落一地。

另外,全局变量区分为两块:

  • .data 段:存放已初始化的全局变量。这些值在程序启动时从 Flash 拷贝到 RAM。
  • .bss 段:存放未初始化的全局变量。启动时自动清零。

嗯,这里要注意:.bss 段不占用 Flash 空间,只占用 RAM。所以如果你有很多大数组,声明为未初始化能省 Flash。

3.5 代码区:只读,别乱动

代码区,存放的是你的程序指令和常量字符串。它通常是只读的。

为什么强调只读?因为有些 MCU 支持在 RAM 里运行代码(比如做 IAP 升级时)。如果你不小心把代码区映射到了可写的内存区域,一个野指针就能把你的程序给改了。

我遇到过最离谱的一次,是某个同事在代码里写了个 *(uint32_t*)0x08000000 = 0;,直接把 Flash 的第一个扇区给擦了。程序当场死机,再也起不来了。嗯,那哥们后来被项目经理请去喝茶了。

保护建议:如果 MCU 支持 MPU(内存保护单元),一定要把代码区设置为只读+可执行。这样任何写操作都会触发异常,你就能第一时间发现 bug。

3.6 用 MPU 给内存上把锁

说到保护,就不得不提 MPU。

MPU 是 Cortex-M3/M4/M7 等内核提供的一个硬件模块。它可以给不同的内存区域设置访问权限。比如:

  • 代码区:只读 + 可执行
  • 全局变量区:读写 + 不可执行
  • 栈区:读写 + 不可执行
  • 外设寄存器区:读写 + 不可执行 + 强序

配置 MPU 其实不复杂,核心就是几个寄存器:

/* MPU 配置示例:保护代码区 */
MPU->RNR  = 0;                    // 区域编号 0
MPU->RBAR = 0x08000000;           // 基地址:Flash 起始
MPU->RASR = (0x1F << 1) |        // 大小:1MB
            (0x0 << 16) |        // 不可执行
            (0x1 << 24) |        // 使能
            (0x3 << 28);         // 全权限(只读+可执行)

你想想看,有了 MPU,栈溢出想踩全局变量?门都没有。MPU 会直接触发 MemManage 异常,你就能在异常处理函数里抓到罪魁祸首。

个人经验:我习惯在项目初期就把 MPU 配好。虽然多花半小时,但后面调试能省几天时间。尤其是团队协作时,MPU 能帮你拦住很多低级错误。

3.7 总结一下

裸机下的内存布局,说白了就是四个区域:代码、全局变量、栈、堆。

  • 栈和堆相向生长,小心它们碰头。
  • 全局变量能少用就少用,用的时候加 static。
  • 代码区设成只读,别让野指针改了你的程序。
  • MPU 是个好东西,用上它,内存问题能少一半。

嗯,今天就聊到这儿。下一章咱们讲讲链接脚本怎么写,以及如何精确控制每个内存区域的位置和大小。到时候我会拿一个实际项目的 .ld 文件来拆解,保证你听完就能上手。