小 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 缓冲区大小与唤醒频率的权衡讨论
DMA 双缓冲与事件驱动：STM32L4 传感器数据采集的功耗优化
传感器采集的功耗困境在电池供电的传感器节点中，MCU 大部分时间应该处于睡眠状态，只在数据就绪时被唤醒处理。但很多实现中，MCU 要么轮询 ADC，要么用中断频繁唤醒，导致实际睡眠时间远小于预期。DMA 双缓冲加事件驱动，是解决这个问题的经典思路。DMA 双缓冲的核心价值是：CPU 不参与数据搬运，且两块缓冲区交替使用——一块在被外设计数填充时，另一块正在被 CPU 处理。这避免了单缓冲方案中"等 DMA 传输完才能处理"的空窗期。STM32L4 系列的 Stop2 模式是个好东西。静态功耗 1.4μA，SRAM 内容保持，DMA 可以继续工作，唤醒延迟 35μs。这意味着传感器采集可以在 CPU 睡眠时进行，只在缓冲区满时才唤醒 CPU。双缓冲的工作逻辑双缓冲的关键是"乒乓切换"。DMA 配置两块缓冲区，用 Circular 模式让 DMA 在两块之间循环。每次半传输完成（HT）和传输完成（TC）中断都会触发 CPU 唤醒，处理对应的半块数据。设计代价是：内存占用翻倍，且需要同步机制防止 DMA 和 CPU 访问同一块缓冲区。简单说，HT 中断处理前半块，TC 中断处理后半块，两块交替使用，天然避免了竞争。缓冲区布局： [Buffer A (512B)] [Buffer B (512B)] ↑ ↑ HT 中断处理 TC 中断处理 (DMA 正在填 B) (DMA 正在填 A) 实操配置以下配置基于 STM32L432KC，用 CubeMX 生成 HAL 框架代码，手动补充双缓冲逻辑：// 实测于 STM32L432KC, STM32Cube HAL 1.17.0 // ADC1 通道 5（PB1），DMA 双缓冲，12 位分辨率 #define BUF_SIZE 512 static uint16_t adc_buf[2][BUF_SIZE]; // 双缓冲 void ADC_DMA_Init(void) { // ADC 配置：72MHz/6 = 12MHz，采样时间 47.5 周期 // 单次转换约 4μs，512 次约 2ms hadc1.Init.ContinuousConvMode = ENABLE; hadc1.Init.DMAContinuousRequests = ENABLE; // DMA 配置：Circular 模式，半字传输 hdma_adc1.Init.Mode = DMA_NORMAL; // 用 Normal + 手动重启实现双缓冲切换 // 注意：HAL 库的 DMA_CIRCULAR 模式下 HT/TC 中断稳定 // 但实测发现双缓冲切换用 DMA_NORMAL + 手动重启更可靠 hdma_adc1.Init.Mode = DMA_CIRCULAR; HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, BUF_SIZE * 2); // 注册 HT 和 TC 回调 HAL_ADC_RegisterCallback(&hadc1, HAL_ADC_CONV_HALF_CB_ID, ADC_HT_Callback); HAL_ADC_RegisterCallback(&hadc1, HAL_ADC_CONV_CPLT_CB_ID, ADC_TC_Callback); } void ADC_HT_Callback(ADC_HandleTypeDef* hadc) { // DMA 正在填 Buffer B，处理 Buffer A ProcessData(adc_buf[0], BUF_SIZE); } void ADC_TC_Callback(ADC_HandleTypeDef* hadc) { // DMA 正在填 Buffer A，处理 Buffer B ProcessData(adc_buf[1], BUF_SIZE); } 进入 Stop2 模式前的配置很关键。必须先挂起所有外设时钟，只保留 ADC 和 DMA 时钟，否则 Stop2 进不去：void EnterStop2(void) { // 关闭不需要的外设时钟 __HAL_RCC_GPIOA_CLK_SLEEP_DISABLE(); // 保留 ADC 和 DMA 时钟 __HAL_RCC_ADC_CLK_SLEEP_ENABLE(); __HAL_RCC_DMA1_CLK_SLEEP_ENABLE(); // 进入 Stop2 HAL_PWREx_ControlLowPowerRunMode(PWR_LOWPOWERRUNMODE_ENABLE); HAL_PWREx_EnterSTOP2Mode(PWR_STOPENTRY_WFI); // 唤醒后恢复时钟 SystemClock_Config(); } 实测记录理论预期：MCU 在 Stop2 模式下被 DMA 中断唤醒处理数据，整体平均功耗应接近 Stop2 的 1.4μA 加上短暂的唤醒处理时间。实测结果（用万用表 μA 档测 VDD 电流）：- 纯 Stop2 模式：1.6μA（手册标称 1.4μA，偏差 0.2μA）- 双缓冲运行时（512 点采集 + 处理）：平均 18μA- 同样的轮询方案（CPU 不睡眠）：平均 2.8mA偏差分析：Stop2 静态功耗略高于手册值，怀疑是板上 LDO 漏电流导致。双缓冲运行时的 18μA 远高于静态，说明唤醒处理的开销不可忽略——每次唤醒要恢复时钟、处理数据、再进入 Stop2，这个流程约 50μs。优化思路：增大缓冲区。从 512 点改到 2048 点后，唤醒频率降低 4 倍，平均功耗降到 8μA。代价是数据延迟从 2ms 涨到 8ms，对低频传感器（温度、压力）可以接受。（这里我一开始想用 DMA 双缓冲的硬件切换功能——GD32 有专门的 MDA M0/M1 寄存器，但 STM32L4 的 HAL 库支持不完整，手动管理反而更可控。）适用边界这套方案的前提：- 传感器采样率低（kHz 级以下）。如果是音频这种高采样率场景，Stop2 的唤醒开销反而比一直跑更划算。- 数据处理可以批量进行。如果每点都要实时处理，双缓冲没有意义。- VDD 稳定。Stop2 模式下 VDD 波动会导致 SRAM 数据丢失，电池供电要有稳压。遗留问题 DMA 在 Stop2 模式下能否继续工作？手册写的是"SRAM 保持，DMA 可工作"，但实测中如果 DMA 源是 ADC，需要确认 ADC 时钟是否在 Stop2 下保持开启。这部分手册描述模糊，待用逻辑分析仪抓时序验证。 多 ADC 通道扫描时，DMA 双缓冲的 HT/TC 中断会变得复杂。如果采样频率不一致，需要按最慢的通道计算唤醒间隔，否则会丢数据。 关联笔记关联笔记：STM32 ADC 采样时间计算：从手册到实测 中关于 ADC 采样时间与信号源阻抗的讨论