在 KB 级 RAM 的 MCU 上构建可靠系统
小 RAM MCU 的现实约束
STM32F103C8T6 是很多项目的入门选择,但它的 20KB RAM 和 64KB Flash 在实际开发中往往捉襟见肘。更现实的情况是 LoRa 远传节点——在 STM32L4 的 32KB RAM 里,LoRaWAN 协议栈要吃掉 8KB,传感器驱动 2KB,数据缓冲队列 4KB,OTA 预留 6KB,留给业务逻辑的只有 12KB。
在这种资源约束下写代码,和在 256KB RAM 的 STM32F4 上完全是两种思路。不是功能能不能实现的问题,而是每分配一个缓冲区都要先算一遍还剩多少堆、栈还有多少空间。
小 RAM 系统的核心痛点有三个:栈溢出、堆碎片化、缓冲区大小妥协。每一个都可能导致 HardFault,而且排查起来非常耗时——因为崩溃现场往往已经被破坏了。
栈溢出:最隐蔽的杀手
栈溢出在开发阶段往往不暴露,因为测试时中断嵌套少、函数调用浅。到了量产环境,中断密集时一层层栈帧叠上去,突然就溢出了。
STM32 的栈是从高地址向低地址生长的。如果栈顶越过了 BSS 段的起始地址,就会破坏全局变量,表现出来的症状是某个全局变量莫名其妙被改了。
解决思路是给栈加"水位标记"。在启动时把栈空间填满一个魔术值(比如 0xA5),运行一段时间后检查有多少魔术值没被覆盖,就知道栈的高水位线在哪里:
// 实测于 STM32F103C8T6, GCC 12.2, -O2
extern unsigned int _estack; // 链接脚本定义的栈顶
extern unsigned int _Min_Stack_Size;
#define STACK_CANARY 0xA5A5A5A5
void Stack_InitCanary(void) {
uint32_t *stack_start = (uint32_t *)((uint32_t)&_estack - 4096);
uint32_t *stack_end = (uint32_t *)&_estack;
while (stack_start < stack_end - 16) {
*stack_start++ = STACK_CANARY;
}
}
uint32_t Stack_GetWatermark(void) {
uint32_t *p = (uint32_t *)((uint32_t)&_estack - 4096);
uint32_t used = 0;
while (*p == STACK_CANARY && (uint32_t)p < (uint32_t)&_estack) {
p++;
used++;
}
return 4096 - used * 4; // 返回栈使用了多少字节
}
实测记录:
在 LoRa 节点上运行 24 小时后检查,栈高水位线在 2.3KB 处。开发时以为只用 1KB 就够了,实际多出来一倍,主要是中断嵌套时 LoRaWAN 的 MAC 层函数调用链很深,每层都申请了局部数组。
偏差分析:测试中断嵌套场景时发现 watermark 比单次中断高出 800 字节,怀疑是 SysTick 中断和 LoRa CAD 检测中断同时触发时的栈叠加。后续调高了栈大小到 4KB 才稳定。
堆碎片化:用静态分配替代 malloc
在小 RAM 系统上用 malloc/free 是灾难性的。堆碎片化会导致即使总剩余空间足够,也无法分配出一块连续的大块。
我的策略是:根本不用堆。所有缓冲区改成静态分配,在编译阶段就固定大小和位置。
// 不用 malloc,用静态数组 + 编译期大小
typedef struct {
uint8_t buf[256];
uint16_t len;
} Packet;
// 编译期分配 8 个 Packet 池
static Packet packet_pool[8];
static uint8_t pool_used[8];
Packet* Packet_Alloc(void) {
for (int i = 0; i < 8; i++) {
if (!pool_used[i]) {
pool_used[i] = 1;
return &packet_pool[i];
}
}
return NULL;
}
void Packet_Free(Packet* p) {
uint32_t idx = p - packet_pool;
if (idx < 8) pool_used[idx] = 0;
}
代价是池子大小必须编译期确定,不能动态扩展。但好处是不会碎片化、回收可预测、调试时能看到完整的占用情况。
缓冲区大小:用时间换空间
20KB RAM 不够让你同时缓存 N 路 CAN 报文和传感器数据。妥协的方式是用"时间换空间"——加大抽样间隔,减小单帧缓冲。
比如原来缓存 100 帧 CAN 数据等一起发送,现在改成 32 帧就发。延迟从 100ms 降到 32ms,但内存占用从 1.5KB 降到 480 字节。
实际项目里另一个有效手段是复用缓冲区。同一个 256 字节的缓冲区,在采集阶段装传感器数据,发送阶段装 LoRa 报文。只要两个阶段不重叠,就能安全复用。需要的状态机为了避免覆盖,要用一个 buf_state 标志位严格管控:
enum {
BUF_FREE = 0,
BUF_COLLECTING,
BUF_TX_PENDING,
BUF_TXING,
};
static uint8_t shared_buf[256];
static uint8_t buf_state = BUF_FREE;
定位 HardFault 的技巧
小 RAM 系统崩溃了,光看 lr 和 pc 往往不够。把崩溃时的栈内容 dump 出来,用 addr2line 反查到源码行号,是最可靠的定位方法:
// 实测于 STM32F103C8T6, GCC arm-none-eabi
void HardFault_Handler(void) {
uint32_t *stack;
__asm volatile("tst lr, #4 \n"
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"mov %0, r0" : "=r"(stack));
// 写到备用寄存器里,调试器抓取
volatile uint32_t saved_r0 = stack[0];
volatile uint32_t saved_r1 = stack[1];
volatile uint32_t saved_lr = stack[5];
volatile uint32_t saved_pc = stack[6];
volatile uint32_t saved_xpsr = stack[7];
while (1); // 在这里断下,用 arm-none-eabi-addr2line 反查 pc
}
适用边界
这套策略适合:
- STM32F1 系列 64KB Flash / 20KB RAM 级别
- 中低数据吞吐的 LoRa / NB-IoT 节点
- 对实时性要求高、不允许动态调度的场合
如果是 STM32F4 级别(192KB RAM),就没必要这么折磨自己了——直接按教科书来,malloc 该用就用。
遗留问题
- 任务切换时栈切换会不会影响水位检测的准确性?目前只在 FreeRTOS 之外验证过,静态调度场景下 OK,多任务未测。
- 共享缓冲区方案在协程(protothread)场景下的状态机复杂度会指数增长,有没有更优雅的复用模式,待调研。
关联笔记
关联笔记:DMA 双缓冲与事件驱动:STM32L4 传感器数据采集的功耗优化 中关于 DMA 缓冲区大小与唤醒频率的权衡讨论