2、内存模型基础:栈区、堆区、全局区、代码段在嵌入式环境中的划分

做嵌入式安全,说白了就是跟内存打交道。你想想看,AES CCM 跑在资源受限的 MCU 上,栈溢出了、堆碎片化了、全局变量被意外改写了——任何一个环节出问题,加密就可能形同虚设。所以这一章,咱们先把嵌入式环境下的内存模型彻底捋清楚。

我个人习惯把嵌入式内存想象成一块地皮。代码段是盖好的厂房,全局区是仓库,栈是临时工位,堆是随时可以租用的储物间。每个区域怎么划分、怎么管理,直接决定了你的 AES CCM 实现能不能稳定跑起来。

2.1 四大内存区域的物理划分

先看一张总图,把四个区域的位置和关系搞清楚。

嵌入式内存模型总览 代码段(.text) 只读,存放 AES CCM 指令、启动代码、中断向量表 全局区(.data + .bss + .rodata) .data:已初始化全局/静态变量(如 AES 密钥缓冲区) .bss:未初始化全局/静态变量(运行时清零) 堆区(Heap) 动态分配,malloc/free,AES CCM 临时缓冲区 栈区(Stack) 局部变量、函数调用帧、返回地址,向下增长 低地址 → 高地址 Flash 映射到代码段

这张图我建议你存下来。每次写 AES CCM 代码前扫一眼,心里就有数了。

2.2 代码段:只读的指令仓库

代码段存放的是编译后的机器指令。在嵌入式环境里,它通常被映射到 Flash 或 ROM 上。为什么强调只读?因为任何试图修改代码段的行为,要么是自修改代码(SMC),要么就是缓冲区溢出攻击。

我记得有一次调试 AES CCM 的加速实现,发现程序莫名其妙跳转到错误地址。查了半天,原来是栈溢出把返回地址覆盖了,CPU 从代码段里取了一条「假指令」——嗯,那之后我就在所有关键函数入口加了栈保护。

避坑指南: 我曾经在 STM32F4 上遇到过代码段被意外改写的情况。原因是 DMA 配置错误,把外设数据写到了 Flash 地址上。解决方案很简单:在链接脚本里把代码段标记为只读区域,并启用 MPU(内存保护单元)。

2.3 全局区:静态数据的安身之所

全局区包含三个子段:.data、.bss 和 .rodata。它们的区别我列个表,一目了然。

段名 存储内容 初始化方式 生命周期 AES CCM 典型用途
.data 已初始化的全局/静态变量 启动时从 Flash 拷贝到 RAM 整个程序运行期 密钥缓冲区、计数器初始值
.bss 未初始化的全局/静态变量 启动时清零 整个程序运行期 临时认证标签、状态标志
.rodata 只读常量 直接放在 Flash 中 整个程序运行期 S-box 查找表、固定 IV

这里有个关键点:.data 段在启动时要从 Flash 拷贝到 RAM,这会占用额外的启动时间。如果你的 AES CCM 实现里有一张大 S-box 表(比如 256 字节),放在 .rodata 里直接读 Flash 就行,别放 .data 里浪费 RAM。

2.4 栈区:临时变量的战场

栈是嵌入式开发中最容易出问题的区域。它向下增长(高地址往低地址),每个函数调用都会压入栈帧。栈帧里包含:局部变量、返回地址、保存的寄存器、以及可能的参数。

我见过太多 AES CCM 实现因为栈溢出而崩溃。举个例子:你在一个函数里声明了 uint8_t buffer[1024],如果栈大小只有 512 字节,那这个函数一调用就炸。更隐蔽的是,递归调用或者多层嵌套函数调用,栈帧一层层叠上去,不知不觉就溢出了。

警告: 在 FreeRTOS 等 RTOS 环境下,每个任务都有自己的独立栈。我曾经调试过一个 AES CCM 加解密任务,任务栈设了 256 字节,结果函数调用链一深,栈直接踩到了相邻任务的 TCB(任务控制块)——系统随机死机,查了三天才定位到。

怎么算栈大小?我有个土办法:把所有可能同时调用的函数栈帧大小加起来,再乘以 1.5 的安全系数。对于 AES CCM,核心函数 aes_ccm_encrypt() 内部可能调用 aes_encrypt_block()、ctr_generate()、mac_update(),每个函数栈帧按 64-128 字节估算,加上局部缓冲区,总栈需求通常在 512 字节以内。但如果你用了递归或者大数组,那就另说了。

2.5 堆区:动态分配的利与弊

堆区用于动态内存分配,通过 malloc/free 管理。在嵌入式环境里,我建议你慎用堆。原因有三:

  • 碎片化: 频繁分配释放会产生内存碎片,导致后续大块分配失败。AES CCM 的临时缓冲区如果反复 malloc/free,堆很快就不连续了。
  • 不可预测性: malloc 的执行时间不确定,对于实时性要求高的加解密任务,这可能是个隐患。
  • 安全风险: 堆溢出攻击是常见手法。攻击者通过溢出堆上的缓冲区,改写相邻堆块的管理信息,最终劫持控制流。

我个人习惯的做法是:在初始化阶段一次性分配好 AES CCM 需要的所有缓冲区,然后在整个生命周期内复用,绝不动态释放。这样既避免了碎片,也减少了安全攻击面。

推荐实践: 使用静态池分配代替堆分配。比如预定义 static uint8_t aes_ccm_buf[256];,然后通过指针传递使用。这样内存位置固定,调试方便,安全性也更高。

2.6 链接脚本与内存布局

说了这么多理论,最后落到实际工具上。链接脚本(Linker Script)是定义内存布局的关键文件。以 GCC 的 .ld 文件为例,核心部分长这样:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text : {
        *(.text*)
        *(.rodata*)
    } > FLASH

    .data : {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH

    .bss : {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM
}

注意看 .data 段的 AT> FLASH,这表示 .data 的初始值存储在 Flash 中,启动时由启动代码拷贝到 RAM。如果你在 AES CCM 里用了大数组做全局变量,记得检查 .data 段的大小,别把 Flash 撑爆了。