3、内存基础概念:RAM/Flash/堆/栈、静态内存与动态内存、内存对齐、内存碎片
在深入 TFLite Micro 的 runtime 内存 profiling 之前,必须先建立一套清晰的内存基础概念框架。嵌入式系统与桌面系统最大的差异在于资源极度受限:一块典型的 MCU 可能只有 16KB 的 SRAM 和 128KB 的 Flash。TFLite Micro 的 interpreter 在设计上刻意避免使用标准库的 malloc/free,而是通过 arena(内存池)统一管理。理解 RAM/Flash 的物理差异、堆/栈的运行机制、静态/动态内存的分配策略、内存对齐的硬件约束以及内存碎片的产生原理,是后续分析 profiling 数据、定位内存瓶颈的必备前提。本章将逐一拆解这些基础概念,并建立它们与 TFLite Micro 实际运行机制之间的映射关系。
3.1 RAM 与 Flash:存储介质的角色分工
核心原理:Flash 是非易失性存储器,用于存放程序代码、只读常量以及 TFLite 模型文件本身。RAM 是易失性存储器,用于存放全局变量、栈、堆以及模型推理过程中的中间张量(tensor)。在典型的 Arm Cortex-M 系列 MCU 上,Flash 和 RAM 是独立的地址空间,CPU 通过不同的总线访问它们。Flash 的读取速度通常比 RAM 慢(尤其是带等待状态时),但掉电后数据不丢失。RAM 的读写速度接近 CPU 主频,但掉电即清零。
判断方法:查看链接脚本(.ld 文件)或芯片手册中的内存映射表,可以明确 Flash 和 RAM 的起始地址与大小。例如 STM32F411 的 Flash 起始地址为 0x08000000,大小为 512KB;SRAM 起始地址为 0x20000000,大小为 128KB。在编译生成的 .map 文件中,可以查看每个段(.text、.data、.bss)被放置在哪个区域。
| 属性 | Flash | RAM |
|---|---|---|
| 易失性 | 非易失 | 易失 |
| 存储内容 | 代码、模型权重、常量 | 变量、栈、堆、中间张量 |
| 访问速度 | 较慢(可能带等待周期) | 快(与CPU同频) |
| 写入方式 | 需要擦除/编程,寿命有限 | 直接读写,无寿命限制 |
| 容量典型值 | 256KB ~ 2MB | 64KB ~ 512KB |
| TFLite Micro 用途 | 模型文件、算子实现 | arena、中间结果、激活值 |
3.2 堆与栈:动态与临时的内存管理
核心原理:栈(Stack)由编译器自动管理,用于函数调用时的参数传递、返回地址和局部变量。栈从高地址向低地址增长,每次函数调用压栈,返回时弹栈。栈的大小在链接时确定,超出则导致栈溢出(stack overflow)。堆(Heap)由程序员手动管理,通过 malloc/free 或 new/delete 分配和释放。堆从低地址向高地址增长,与栈相向而行,两者之间是空闲内存区域。
操作流程:在嵌入式系统中,堆的大小通常由链接脚本中的 _heap_size 定义。TFLite Micro 的默认实现并不使用标准堆,而是使用一个静态分配的 arena 缓冲区。这个 arena 在编译时分配,运行时由 TFLite 内部的 memory planner 管理。这样做的好处是:避免了标准库堆管理的不可预测性,也避免了碎片问题。
// TFLite Micro 中 arena 的典型定义方式
static uint8_t tensor_arena[1024 * 10]; // 10KB 静态数组
tflite::MicroInterpreter interpreter(
model, resolver, tensor_arena, sizeof(tensor_arena));
案例拆解:假设一个语音唤醒词检测模型,输入特征为 40 维 MFCC,帧长 30ms。在推理过程中,每一层卷积的输出张量都需要在 arena 中分配空间。如果 arena 设置过小,interpreter 初始化时会返回错误。通过打印 interpreter.arena_used_bytes() 可以精确知道实际使用了多少字节。例如,某模型实际需要 8.5KB,但开发者分配了 10KB,则浪费了 1.5KB。如果 MCU 只有 16KB RAM,这 1.5KB 的浪费可能影响其他功能模块。
3.3 静态内存与动态内存:生命周期与分配策略
核心原理:静态内存(static memory)在程序启动时分配,生命周期贯穿整个程序运行。包括全局变量、静态局部变量以及 const 常量。动态内存(dynamic memory)在运行时按需分配,生命周期由程序员控制。在嵌入式环境中,动态内存的使用需要格外谨慎,因为堆的碎片化和不可预测性可能导致运行时分配失败。
判断方法:查看变量的声明位置和修饰符:函数体外声明的变量是全局静态;函数体内用 static 修饰的变量是静态局部;用 malloc 或 new 分配的是动态内存。在 TFLite Micro 中,所有张量数据都在 arena 中分配,arena 本身是静态数组,但内部的管理是动态的——由 memory planner 在初始化时一次性规划好所有张量的偏移量。
| 维度 | 静态内存 | 动态内存(标准堆) | TFLite Arena |
|---|---|---|---|
| 分配时机 | 编译/启动时 | 运行时 | 初始化时规划 |
| 释放时机 | 程序结束 | 手动 free | 整个生命周期 |
| 碎片风险 | 无 | 高 | 无(线性分配) |
| 分配速度 | 无分配过程 | 较慢(查找空闲块) | O(1) 偏移计算 |
| 可预测性 | 完全确定 | 不确定 | 完全确定 |
3.4 内存对齐:硬件约束与性能权衡
核心原理:内存对齐是指数据在内存中的起始地址必须是某个值的整数倍。ARM Cortex-M 处理器通常要求 4 字节对齐的 32 位访问,部分内核(如 Cortex-M7)支持非对齐访问但性能下降。对于浮点运算和 SIMD 指令,可能需要 8 字节或 16 字节对齐。TFLite Micro 的算子实现中,大量使用指针类型转换和批量内存操作,对齐不当会导致硬件异常(HardFault)或性能严重下降。
操作流程:在定义结构体时,编译器会自动插入填充字节(padding)以满足对齐要求。例如:
typedef struct {
int8_t a; // 1 字节
int32_t b; // 4 字节,需要 3 字节填充
int16_t c; // 2 字节
} ExampleStruct; // 总大小 12 字节(而非 7 字节)
案例拆解:在 TFLite Micro 的 arena 分配中,memory planner 必须确保每个张量的起始地址满足其数据类型对齐要求。例如,int8 张量只需 1 字节对齐,但 float32 张量需要 4 字节对齐。如果 planner 不处理对齐,可能导致后续算子访问非对齐地址。实际调试中,一个常见的错误是:自定义算子中使用了 reinterpret_cast<float*>(data),而 data 的地址不是 4 的倍数,导致 HardFault。解决方法是使用 alignas(4) 或 memcpy 到对齐的临时缓冲区。
(uintptr_t)ptr % 4 可以快速验证。3.5 内存碎片:内部碎片与外部碎片的本质
核心原理:内存碎片分为两类:内部碎片(internal fragmentation)和外部碎片(external fragmentation)。内部碎片是分配的内存块大于实际需求,多出的部分被浪费。例如,分配 10 字节但实际块大小为 12 字节(对齐填充)。外部碎片是空闲内存被分割成多个不连续的小块,即使总空闲空间足够,也无法满足一个大块分配请求。标准堆的 malloc/free 交替使用是外部碎片的主要来源。
判断方法:对于标准堆,可以通过统计堆中最大连续空闲块的大小来判断碎片程度。如果最大空闲块远小于总空闲空间,说明碎片严重。TFLite Micro 的 arena 采用线性分配策略,所有张量在初始化时一次性规划,不存在运行时的释放和再分配,因此完全避免了外部碎片。但内部碎片仍然存在——由于对齐要求和张量大小的不匹配,arena 中可能存在少量填充字节。
| 碎片类型 | 产生原因 | TFLite Micro 中的表现 | 缓解策略 |
|---|---|---|---|
| 内部碎片 | 对齐填充、分配粒度 | arena 中少量 padding | 优化张量大小、调整对齐策略 |
| 外部碎片 | 多次 malloc/free 导致空闲块不连续 | 不存在(arena 线性分配) | 使用 arena 替代标准堆 |
mallinfo() 查看 uordblks(已用块)和 fordblks(空闲块)的比值。实战场景:假设一个智能门锁项目,使用 TFLite Micro 做人脸识别。MCU 为 Cortex-M7,RAM 512KB,Flash 1MB。模型大小 300KB 存放在 Flash,arena 分配 150KB。在 profiling 过程中发现:arena_used_bytes() 返回 142KB,但实际张量数据只占 135KB,剩余 7KB 是对齐填充和 planner 的元数据。进一步分析发现,某个中间张量的大小为 1003 字节,但被对齐到 1008 字节(4 字节对齐),浪费 5 字节。虽然单次浪费很小,但整个模型中这样的张量有 20 多个,累计浪费约 100 字节。通过调整张量维度(例如将 1003 改为 1004),可以减少部分填充。更重要的是,通过查看 .map 文件,发现栈空间分配了 32KB,但实际峰值只有 12KB,可以安全地减少栈大小,将释放的 20KB 重新分配给 arena,从而支持更大的模型或更高的输入分辨率。
总结复盘:内存基础概念是 TFLite Micro profiling 的基石。理解 Flash 与 RAM 的分工,有助于判断模型权重是否合理驻留;理解堆与栈的机制,有助于避免栈溢出和堆碎片;理解静态与动态内存的区别,有助于选择正确的分配策略;理解内存对齐,有助于避免硬件异常和性能退化;理解碎片本质,有助于优化 arena 大小和布局。在实际项目中,建议在开发初期就建立内存预算表,明确每个模块的 RAM/Flash 占用,并在每次迭代中对比实际值与预算值,及时发现异常增长。