嵌入式内存基础:Flash、SRAM、寄存器映射与段的概念

在深入TFLite Micro的Map文件分析之前,必须先建立扎实的嵌入式内存模型。MCU(微控制器)的内存布局与PC系统有本质差异:没有虚拟内存、没有磁盘交换分区,所有代码和数据都必须在物理存储介质上精确定位。理解Flash、SRAM、寄存器映射以及代码段、数据段、BSS段、堆栈的划分,是解读链接脚本(Linker Script)和Map文件的先决条件。本章将系统梳理这些基础概念,并建立一套用于后续章节的内存分析思维框架。

MCU内存模型总览 Flash(非易失性存储) .text(代码段)— 函数指令、向量表 .rodata(只读数据)— 常量、字符串 .data(初始值拷贝)— 启动时复制到SRAM SRAM(易失性存储) .data(已初始化全局/静态变量) .bss(零初始化全局/静态变量) 堆(Heap)— 动态分配 栈(Stack)— 函数调用、局部变量 外设寄存器映射(Memory-Mapped I/O) GPIO、UART、SPI、I2C、TIMER、ADC等外设控制寄存器 通过指针访问固定地址,如 *(volatile uint32_t*)0x40021000 启动时复制 总线访问 关键地址空间(以STM32F4为例) Flash: 0x0800_0000 ~ 0x080F_FFFF(1MB) SRAM: 0x2000_0000 ~ 0x2003_FFFF(256KB) 外设寄存器: 0x4000_0000 ~ 0x5FFF_FFFF(AHB/APB总线)

2.1 存储介质与地址空间:Flash、SRAM与寄存器映射

MCU的内存系统由三种物理实体构成:Flash(非易失性闪存)、SRAM(静态随机存取存储器)和寄存器映射(Memory-Mapped Registers)。三者通过总线矩阵连接到CPU核心,但具有截然不同的电气特性和使用场景。

Flash存储器用于存放程序指令(.text段)和只读常量(.rodata段)。其特点是断电后数据不丢失,但写入速度慢(微秒级)、擦写寿命有限(通常10万次)。在嵌入式系统中,Flash的容量决定了程序可以有多大。例如,STM32F103C8T6拥有64KB Flash,而ESP32-S3则提供8MB Flash。TFLite Micro模型通常以C数组形式存储在Flash中,因此模型大小直接受Flash容量约束。

SRAM用于存放运行时数据:全局变量、静态变量、堆和栈。SRAM速度快(与CPU同频)、可无限次读写,但断电即失。SRAM容量通常远小于Flash(典型比例为1:4到1:8)。例如,STM32F103C8T6只有20KB SRAM,而TFLite Micro的Tensor Arena(张量分配区)就驻留于此。SRAM的紧张程度往往比Flash更严重,因为模型推理时的中间张量、激活值、临时缓冲区全部需要SRAM。

寄存器映射是MCU外设控制的核心机制。ARM Cortex-M系列将外设寄存器映射到固定的地址区间(如0x4000_0000~0x5FFF_FFFF),CPU通过普通内存读写指令(LDR/STR)即可操作外设。例如,控制GPIO引脚电平只需向特定地址写入数据:

// 以STM32F4为例,点亮LED(PA5)
#define GPIOA_BASE   0x40020000UL
#define GPIOA_ODR    (GPIOA_BASE + 0x14UL)
*(volatile uint32_t*)GPIOA_ODR |= (1UL << 5);

这种设计使得外设操作与普通内存访问统一,但同时也意味着寄存器映射区域占据了地址空间的一部分,在阅读Map文件时不会出现这些地址,但理解它们有助于区分“内存占用”与“外设地址空间”。

经验提醒:在分析TFLite Micro内存时,务必先查阅芯片手册中的内存映射表。不同厂商的Flash/SRAM起始地址不同(STM32的Flash从0x0800_0000开始,而NXP的LPC系列从0x0000_0000开始),这直接影响链接脚本的配置和Map文件的解读。

2.2 段(Section)的概念:代码段、数据段、BSS段

编译器将源代码编译后,输出目标文件(.o),其中包含多个段(Section)。链接器将这些段按照链接脚本的规则合并到最终的可执行文件中。理解段的概念是阅读Map文件的基础,因为Map文件本质上就是各段在内存中的分布清单。

段名 存储位置 内容 生命周期 典型示例
.text Flash 函数指令、中断向量表 程序运行期间始终存在 main()、TFLite推理函数
.rodata Flash 只读常量、字符串字面量 程序运行期间始终存在 模型权重数组、错误消息
.data 初始值在Flash,运行时复制到SRAM 已初始化的全局/静态变量 启动时初始化,运行期存在 int counter = 10;
.bss SRAM(零初始化) 未初始化或零初始化的全局/静态变量 启动时清零,运行期存在 int buffer[256];
堆(Heap) SRAM 动态分配的内存 malloc/free管理 TFLite Micro的Tensor Arena
栈(Stack) SRAM 函数调用帧、局部变量 函数调用期间 递归调用、局部数组

.text段是代码段,包含所有编译后的机器指令。在TFLite Micro场景中,.text段的大小取决于模型推理代码的复杂度。例如,包含完整算子库的TFLite Micro可能占用50-100KB Flash,而裁剪掉不需要的算子后可以缩减到20KB以下。

.rodata段存储只读数据。对于TFLite Micro,模型权重通常以const unsigned char[]数组形式存放在.rodata段中。一个1MB的模型文件,其权重数组就会占用约1MB的Flash空间。这也是为什么模型压缩(量化、剪枝)对嵌入式部署至关重要的原因。

.data段比较特殊:它的初始值存放在Flash中,但变量本身在SRAM中。系统启动时,C运行时库(crt0)会将Flash中的初始值复制到SRAM中的对应位置。例如:

// 全局变量,已初始化
int inference_count = 0;  // 存储在.data段
// Flash中保存初始值0,启动时复制到SRAM

.bss段存放未初始化或显式零初始化的全局/静态变量。启动时,crt0会将整个.bss段清零。由于不需要在Flash中保存初始值,.bss段不占用Flash空间,只占用SRAM。例如:

// 全局缓冲区,未初始化
static int32_t tensor_arena[10000];  // 存储在.bss段,占用40KB SRAM
核心原理:在阅读Map文件时,需要特别关注.data段的“双份占用”特性——它同时占用Flash(存放初始值)和SRAM(存放运行时变量)。Map文件中通常会分别列出load region(加载地址,即Flash中的位置)和execution region(执行地址,即SRAM中的位置)。如果忽略这一点,可能会低估Flash的实际占用。

2.3 堆与栈:动态内存与函数调用的基石

栈(Stack)是向下生长的内存区域,由编译器自动管理。每次函数调用时,参数、返回地址、局部变量被压入栈中;函数返回时,这些数据被弹出。栈的大小在链接时确定,通常由链接脚本指定。栈溢出是嵌入式系统最常见的崩溃原因之一,尤其是在递归调用或声明大型局部数组时。

堆(Heap)是向上生长的内存区域,由程序员通过malloc/free(C语言)或new/delete(C++)管理。TFLite Micro的一个重要设计决策是避免使用堆,而是使用静态分配的Tensor Arena。这是因为嵌入式环境中动态内存分配可能导致碎片化、不可预测的延迟和内存泄漏。Tensor Arena本质上是一个预分配的静态缓冲区,TFLite Micro在初始化时将其划分为多个张量区域。

// TFLite Micro典型内存分配方式
// 静态分配Tensor Arena,避免使用堆
static uint8_t tensor_arena[1024 * 100];  // 100KB静态缓冲区

void setup() {
  static tflite::MicroMutableOpResolver<10> resolver;
  static tflite::MicroInterpreter interpreter(
      model, resolver, tensor_arena, sizeof(tensor_arena));
  // 解释器从tensor_arena中分配张量内存
}

堆和栈共享SRAM空间,它们相向生长。如果堆向上增长到栈的底部,或者栈向下增长到堆的顶部,就会发生内存重叠,导致数据损坏。在Map文件中,堆和栈通常被定义在SRAM的特定区域,链接脚本会为它们预留空间。

风险提示:在TFLite Micro项目中,Tensor Arena的大小直接影响模型能否成功加载。如果Tensor Arena过小,解释器初始化时会返回错误(如kTfLiteError)。常见的做法是先设置一个较大的值(如芯片SRAM的70%),然后通过interpreter->arena_used_bytes()查询实际使用量,再调整到合适大小。

2.4 链接脚本与Map文件的关系

链接脚本(Linker Script,通常以.ld或.icf为后缀)定义了各段在内存中的布局规则。Map文件则是链接器根据链接脚本实际执行后的输出报告。理解两者的关系,是定位内存问题的关键。

一个典型的ARM GCC链接脚本片段如下:

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

SECTIONS
{
  .text :
  {
    KEEP(*(.isr_vector))
    *(.text*)
    *(.rodata*)
    _etext = .;
  } > FLASH

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

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

这段脚本定义了:.text段放在Flash中;.data段的执行地址在SRAM,但加载地址在Flash(AT> FLASH);.bss段直接在SRAM中。链接器生成Map文件时,会详细列出每个符号(变量、函数)的地址和大小。

Map文件通常包含以下关键信息:

  • 内存配置:Flash和SRAM的总容量、起始地址
  • 段分布:每个段(.text、.data、.bss等)的起始地址、结束地址、总大小
  • 符号表:每个全局变量、函数的名称、地址、大小
  • 交叉引用:哪些目标文件(.o)贡献了哪些符号
实战场景:假设你正在调试一个TFLite Micro项目,编译后提示region SRAM overflowed by 4096 bytes。打开Map文件,搜索.bss段,发现tensor_arena占用了80KB,而芯片SRAM只有128KB。进一步查看,发现tensor_arena被定义为static uint8_t tensor_arena[80 * 1024],而实际模型推理只需要50KB。解决方案:将数组缩小到60KB,重新编译后SRAM溢出消失。这个过程中,Map文件帮助你快速定位了内存占用的“大头”。

2.5 常见误区与总结复盘

在嵌入式内存基础的学习中,初学者常犯以下错误:

  • 混淆Flash与SRAM的容量:误以为模型文件大小等于SRAM占用。实际上,模型权重在Flash中,而推理时的中间张量在SRAM中。
  • 忽略.data段的双份占用:一个已初始化的全局数组,既占用Flash(存放初始值)又占用SRAM(运行时数据)。在计算总内存时,需要分别计入。
  • 低估栈的需求:TFLite Micro的算子实现可能使用较大的局部数组,如果栈设置过小,会导致静默的数据损坏或HardFault。
  • 将寄存器映射地址误认为内存占用:外设寄存器地址(如0x40000000)不属于Flash或SRAM,不应计入内存统计。
检查项 方法 预期结果
Flash总占用 Map文件中.text + .rodata + .data(加载地址部分) 小于Flash容量的80%
SRAM总占用 Map文件中.data(执行地址部分) + .bss + 堆 + 栈 小于SRAM容量的85%
Tensor Arena大小 代码中静态数组定义 大于arena_used_bytes()返回值
栈使用峰值 使用__stack_chk_guard或调试器查看 不超过栈区域

总结复盘:嵌入式内存管理的核心在于“静态规划”。与PC不同,MCU没有虚拟内存和交换空间,所有内存分配必须在编译期确定。TFLite Micro的设计哲学正是顺应这一约束——通过静态Tensor Arena避免动态分配,通过链接脚本精确控制段布局。掌握Flash、SRAM、寄存器映射和段的概念,是后续分析Map文件、定位内存瓶颈的基石。下一层次的学习中,我们将深入Map文件的具体格式,学习如何从符号级别追踪内存占用。