嵌入式内存基础:RAM与Flash的区别、静态内存与动态内存、栈与堆的概念

TinyML 模型在微控制器(MCU)上部署时,最先遇到的硬约束往往不是算力,而是内存。一颗典型的 Arm Cortex-M 系列 MCU,其 SRAM 通常只有 16KB~512KB,Flash 在 64KB~2MB 之间。模型权重、中间激活值、推理栈帧、运行时日志——所有这些都要挤在这两片有限的空间里。如果对 RAM 与 Flash 的本质差异、静态与动态内存的分配机制、栈与堆的运行行为没有清晰认知,后续的峰值内存测量、Map 文件分析都将失去根基。本章从硬件存储介质出发,逐步深入到编译器视角的内存布局与运行时视角的分配机制,为 TinyML 内存测试建立完整的知识框架。

嵌入式内存全景:存储介质 × 分配机制 × 运行时区域 存储介质(硬件) Flash(非易失) 存放代码、模型权重、常量表 读快写慢,按扇区擦除 RAM / SRAM(易失) 存放变量、堆栈、中间激活值 断电即失,读写速度对称 分配机制(编译/运行) 静态内存 编译期确定地址与大小 全局变量、static 局部变量 动态内存 运行期申请/释放 malloc/free、堆管理 运行时内存区域(RAM内) 栈(Stack) 函数调用帧、局部变量、返回地址 堆(Heap) 动态分配的内存块 静态区(.bss/.data) 全局变量、static 变量 TinyML 内存测试关注的核心链路 ① 模型权重 → 存放在 Flash(.rodata 段),不占 RAM ② 中间激活值 → 存放在 RAM(栈或静态缓冲区),是 RAM 峰值的主要来源 ③ 推理框架运行时 → 栈帧 + 堆分配(若启用动态内存) ④ Map 文件 → 展示各段(.text/.data/.bss/.rodata)的 Flash/RAM 占用 ⑤ 峰值测量 → 栈深度探测 + 堆水位标记 + 静态段统计 Flash 决定模型容量上限,RAM 决定推理时的工作集大小——两者共同决定 TinyML 能否在目标 MCU 上运行

2.1 RAM 与 Flash:两种存储介质的本质差异

核心原理:为什么 Flash 存代码、RAM 跑数据

Flash 和 RAM 是 MCU 上两种截然不同的存储介质。Flash 属于非易失性存储器,断电后数据不丢失,用于存放程序代码(.text 段)、只读常量(.rodata 段)以及经过初始化且需要持久保存的变量副本。它的读取速度尚可,但写入速度远慢于读取,且必须按扇区(通常 4KB~64KB)擦除后才能重新写入。RAM(在 MCU 中通常为 SRAM)属于易失性存储器,断电即失,但读写速度对称且极快,用于存放所有运行时可变的数据——全局变量、局部变量、堆栈、动态分配的内存块。

在 TinyML 场景中,这种差异直接决定了模型部署策略:一个 300KB 的 TensorFlow Lite Micro 模型权重文件,应当作为常量数组存放在 Flash 中,而不是在启动时拷贝到 RAM。这样 Flash 占用 300KB,RAM 只占用极少量的指针和索引变量。反之,如果错误地将权重声明为可变的全局数组,RAM 会被瞬间吃掉 300KB——这在大多数 MCU 上直接导致启动失败。

对比维度FlashRAM(SRAM)
易失性非易失,断电保留易失,断电清零
存储内容代码、常量、模型权重变量、堆栈、堆、激活值
读取速度较快(有等待周期)极快(零等待或单周期)
写入速度慢,需先擦除扇区与读取对称,极快
容量范围64KB ~ 2MB(典型 MCU)16KB ~ 512KB(典型 MCU)
单位成本较低较高
TinyML 角色模型权重、推理代码中间激活值、栈帧、运行时数据结构

判断方法:如何快速区分一个变量应该放 Flash 还是 RAM

判断规则非常直接:如果变量在编译期值已确定且运行期永不修改,就放 Flash;只要运行期可能被写入,就必须放 RAM。具体操作时,在 C/C++ 中通过 const 关键字声明常量,编译器会自动将其放入 Flash 的 .rodata 段。对于模型权重,推荐使用类似以下的声明方式:

// 正确做法:权重作为常量,存放在 Flash
const uint8_t model_data[] = {0x1c, 0x2a, 0x3f, ...};  // 约 300KB

// 错误做法:去掉 const,权重被拷贝到 RAM
uint8_t model_data[] = {0x1c, 0x2a, 0x3f, ...};  // RAM 被占用 300KB

在嵌入式链接脚本(.ld 文件)中,Flash 对应 FLASH 区域,RAM 对应 RAM 区域。编译后通过 arm-none-eabi-size 命令可以快速查看各段占用:

$ arm-none-eabi-size build/model.elf
   text    data     bss     dec     hex filename
  45232    1232    8912   55376    d850 build/model.elf

其中 text 表示 Flash 中的代码和常量,data 表示需要从 Flash 拷贝到 RAM 的已初始化全局变量,bss 表示未初始化但运行期清零的全局变量(占 RAM)。

经验提醒:在 TinyML 工程中,模型权重数组务必加 const 修饰。一个常见的性能陷阱是:某些代码生成工具默认生成非 const 数组,导致 RAM 被无谓占用。每次编译后都应检查 .rodata 段大小,确认权重确实留在了 Flash。

2.2 静态内存与动态内存:编译期决定与运行期博弈

核心原理:静态内存的确定性 vs 动态内存的灵活性

静态内存(Static Memory)在编译期就确定了大小和地址,包括全局变量、文件作用域的 static 变量、函数内的 static 局部变量。它们在程序启动时(或首次使用时)被创建,直到程序结束才销毁。静态内存的分配不依赖运行时的堆管理器,因此不存在碎片化问题,访问速度也最快——编译器可以直接生成地址访问指令。

动态内存(Dynamic Memory)在运行期通过 malloc/free(C 语言)或 new/delete(C++)从堆(Heap)中申请和释放。堆是 RAM 中预留的一块区域,由运行时库管理。动态内存的优点是灵活——可以在运行时根据实际需要调整缓冲区大小;缺点是引入了不确定性:堆碎片化、分配失败、释放遗漏(内存泄漏)等问题在资源受限的 MCU 上尤为致命。

在 TinyML 推理框架中,两种策略都有应用。TensorFlow Lite Micro 的设计哲学是尽量避免动态内存——它通过内存规划器(Memory Planner)在编译期或初始化阶段一次性分配所有张量缓冲区,推理过程中不再申请新内存。而一些轻量级推理引擎(如 CMSIS-NN 的部分示例)则允许在推理时动态分配临时缓冲区。

判断方法:何时选择静态内存,何时允许动态内存

选择依据可以归纳为以下三条原则:

  1. 确定性优先:如果内存需求在编译期完全可知(如模型输入输出张量大小固定),一律使用静态内存。
  2. 峰值可控:如果运行期存在多个互斥的临时缓冲区(如先处理音频再处理图像),可以复用同一块静态内存,而不是分别动态申请。
  3. 避免碎片:MCU 上的堆通常很小(几 KB 到几十 KB),频繁的 malloc/free 会产生碎片,导致明明有足够总空间却分配不出连续块。因此,除非必要,不要在推理热路径中使用动态内存。
风险提示:在 MCU 上使用 malloc 后必须检查返回值是否为 NULL。很多嵌入式开发者习惯性地忽略动态内存分配失败的检查,这在 PC 上可能只是程序崩溃,在 MCU 上则会导致静默的数据损坏或死机。TinyML 推理过程中一次 malloc 失败,可能让整个设备进入不可恢复的异常状态。

2.3 栈与堆:运行时内存的两大动态区域

核心原理:栈的自动管理 vs 堆的手动管理

栈(Stack)是 RAM 中一块向下增长(地址递减)的区域,由编译器自动管理。每次函数调用时,系统将返回地址、寄存器上下文、局部变量压入栈中;函数返回时,这些数据自动弹出。栈的分配和释放是纯粹的指针移动操作,开销极低。但栈的大小在链接时固定(由链接脚本指定,典型值 1KB~8KB),如果函数调用嵌套过深或单个函数的局部变量过大,就会发生栈溢出——这是嵌入式系统中最隐蔽的故障之一。

堆(Heap)是 RAM 中另一块区域,通常位于栈的对面(低地址端),向上增长。堆由运行时库管理,维护一个空闲块链表。每次 malloc 需要遍历链表寻找合适的空闲块,free 需要将块合并回链表。堆的管理开销远高于栈,且存在碎片化问题。

在典型的 MCU 内存布局中,从低地址到高地址依次为:.data(已初始化全局变量)、.bss(未初始化全局变量)、堆(向上增长)、栈(向下增长)。堆和栈相向生长,中间是空闲区域。如果堆向上增长过多或栈向下增长过多,两者就会碰撞——这种碰撞通常不会立即报错,而是静默地覆盖对方的数据,导致难以排查的随机故障。

操作流程:如何评估栈和堆的当前使用量

在 TinyML 内存测试中,评估栈和堆的使用量是核心任务之一。推荐以下操作流程:

  1. 栈深度探测:在启动文件中将栈区域预先填充为特定模式(如 0xDEADBEEF),程序运行一段时间后扫描该区域,找到最后一个未被覆盖的标记位置,即可估算栈的最大使用深度。
  2. 堆水位标记:在初始化时记录堆的起始地址,定期检查当前堆指针(通过 sbrk 或 _sbrk 函数获取)与起始地址的差值,得到堆的当前使用量。
  3. 静态分析:通过 Map 文件查看 .data 和 .bss 段的大小,这部分是静态内存的固定开销。
// 栈填充示例(启动文件 startup.s 中)
LoopFillZerobss:
  ldr r3, =__bss_start__
  ldr r4, =__bss_end__
  movs r0, #0
  b   FillZerobss

FillStack:
  ldr r3, =__stack_top__
  ldr r4, =__stack_limit__
  ldr r0, =0xDEADBEEF
  b   StackFillLoop

案例拆解:一次典型的 TinyML 栈溢出排查

某智能传感器项目使用 STM32L476(192KB RAM),部署一个 2 层全连接神经网络进行振动信号分类。模型推理在裸机环境下运行,未使用 RTOS。现象:设备运行约 2 小时后偶发死机,复位后恢复正常,但故障间隔越来越短。

初步分析:死机无规律,且与运行时长相关,怀疑内存泄漏或栈溢出。使用调试器查看,发现栈指针(SP)已经低于栈底(__stack_limit__),确认栈溢出。

关键数据:链接脚本中栈大小设置为 4KB。通过栈填充法测量,发现推理函数 run_inference() 的栈帧深度达到 3.2KB,加上中断嵌套(最高 3 级),总栈需求约 4.5KB。

分析判断:问题根源在于推理函数中声明了一个 2KB 的局部数组作为中间激活缓冲区,加上框架调用链的栈帧开销,超出了栈容量。更糟糕的是,该数组本可以声明为静态或全局,因为推理过程是串行的,不存在重入问题。

实际操作:将局部数组改为文件作用域的 static 数组,栈需求降至 1.5KB。同时将栈大小调整为 6KB 以保留余量。修改后连续运行 72 小时无故障。

复盘经验:在 TinyML 中,大数组(>1KB)永远不要声明为函数局部变量。要么声明为 static,要么在编译期就规划好全局缓冲区。栈是稀缺资源,应该留给函数调用帧和小的局部变量。

总结复盘:嵌入式内存管理的核心口诀——常量进 Flash,变量进 RAM;大数组用静态,小变量用栈;堆能不用就不用,用了必查返回值。在 TinyML 场景中,模型权重是常量,必须放 Flash;中间激活值是大数组,必须用静态内存或精心规划的缓冲区;推理框架的临时对象尽量复用,避免动态分配。理解这些基础概念后,下一章将深入 Map 文件,学习如何精确统计每一字节的 Flash 和 RAM 占用。