嵌入式边缘AI项目静态评测:从启动流程到CMSIS-DSP内存对齐
2026/9/10 6:40:25 网站建设 项目流程

1. 为什么一个“关键词为空”的开源项目,值得花三天时间逐行审计?

你有没有遇到过这样的情况:在 GitHub 上搜到一个标着“ARM”“边缘AI”“MCU”的热门仓库,README 写得天花乱坠——“超低功耗”“<100KB Flash 占用”“支持 Cortex-M4/M7”“已在 STM32H7 和 nRF52840 上实测”,点进去 clone 下来,make all却卡在arm-none-eabi-gcc: command not found;好不容易配好工具链,make flash又报undefined reference to 'arm_fir_init_q15';最后硬着头皮烧进板子,麦克风一录,串口打印出来的全是0x0000——不是模型没跑起来,是连原始音频都没采集成功。

这不是个例。我上个月帮一家做智能门锁的客户做边缘语音唤醒方案选型,前后筛了 7 个开源 KWS(Keyword Spotting)项目,其中 4 个在“编译通过”和“功能可用”之间存在巨大断层。而ML‑KWS‑for‑MCU就是那个断层最深、但又最值得深挖的一个。它不像 TensorFlow Lite Micro 那样有官方背书,也不像 Edge Impulse 那样提供图形化界面,它是一份赤裸裸的、面向真实嵌入式工程师的 C 语言工程快照——没有抽象层,没有胶水代码,只有寄存器配置、DMA 描述符、CMSIS-DSP 调用和手写的定点 FFT。

它的关键词栏是空的。这恰恰是最危险的信号:没人愿意花力气写文档,说明作者默认使用者已经熟稔 ARM Cortex-M 的启动流程、NVIC 中断优先级分组、SysTick 校准、以及 GCC 的-mfloat-abi=hard -mfpu=fpv4-d16这类编译选项背后的真实含义。它不教你怎么入门,它只验证你是否真的“会”。

我决定对它做一次源码静态评测,不是为了证明它“好不好”,而是要回答三个更实际的问题:

  • 它的内存布局设计,是否真的能塞进一颗 256KB Flash + 64KB RAM 的 MCU?
  • 它宣称的“无 OS 依赖”,在中断上下文切换、ADC 采样同步、模型推理时序这三个关键节点上,是否经得起推敲?
  • 它的工程架构里,藏着多少被注释掉的调试宏、未删除的旧版 CMSIS 头文件、以及硬编码的数组长度?这些不是 bug,却是未来三个月产线固件升级时,让你凌晨三点还在 J-Link Commander 里单步调试的根源。

这次评测不跑 demo,不看波形,只读代码。我把整个仓库下载下来,用 VS Code + C/C++ Extension 打开,关掉所有 IntelliSense 提示,打开c_cpp_properties.json,把intelliSenseMode强制设为gcc-arm,然后从startup_stm32f407xx.s开始,一行一行往下翻。这不是学术研究,这是嵌入式工程师的日常体检。

提示:静态评测不是代码审查(Code Review),它不关心“这段逻辑是否最优”,而专注“这段代码在目标硬件上是否可执行、可预测、可维护”。它要发现的,是那些在 CI 流水线上永远跑不过、但在量产设备上会随机死机的隐患。

2. 从 startup.s 到 main.c:启动流程里的三处“静默假设”

所有嵌入式项目的命运,其实在startup_*.s文件里就已注定。ML‑KWS‑for‑MCU 的启动文件位于Drivers/CMSIS/Device/ST/STM32F4xx/Source/Templates/gcc/startup_stm32f407xx.s,它并非自研,而是 ST 官方提供的模板。但正是这个“标准”文件,埋下了第一个静默假设。

2.1 假设 1:堆栈大小是“足够大”的,而非“精确计算”的

.stack段定义中,它写的是:

.stack __initial_sp, 0x00000400

即分配了 1KB 的栈空间。对于一个裸机 KWS 应用,这看起来绰绰有余。但问题在于:这个 1KB 是给谁用的?

  • main()函数本身几乎不消耗栈;
  • CMSIS-DSP 的arm_fir_q15函数内部会使用局部数组缓存系数,但其栈开销是固定的;
  • 真正的杀手是中断服务程序(ISR)。该工程将 ADC 采样完成中断(ADC_IRQn)和 SysTick 中断(SysTick_IRQn)都设置为最高优先级(NVIC_SetPriority(ADC_IRQn, 0)),这意味着当 ADC ISR 正在执行时,SysTick 中断可以抢占它。

我们追踪ADC_IRQHandler的调用链:

  • ADC_IRQHandlerHAL_ADC_ConvCpltCallback()audio_capture_callback()kws_process_frame()model_run()arm_softmax_q7()

arm_softmax_q7是 CMSIS-DSP 中一个典型的、未做栈优化的函数。它内部声明了一个q7_t buffer[64]的局部数组。在 Cortex-M4 上,q7_tint8_t,64 字节;但 GCC 在生成汇编时,出于对齐考虑,会将其扩展为 128 字节的栈帧。再加上函数调用保存的寄存器(r4-r11, lr),一个 ISR 的峰值栈消耗轻松突破 256 字节。

startup_stm32f407xx.s里那个0x00000400(1024 字节)的栈,是给主程序流用的。中断栈(MSP)和线程栈(PSP)共享同一片区域。当两个高优先级中断嵌套发生时,栈指针(SP)会一路向下冲,一旦越过.data段的起始地址,就会覆盖全局变量——比如g_audio_buffer这个存放原始 PCM 数据的环形缓冲区。覆盖之后,model_run()输入的就是一堆乱码,输出自然不可预测。

实操验证方法:在main()开头插入:

extern uint32_t _estack; uint32_t *sp = (uint32_t*)__get_MSP(); printf("MSP at init: 0x%08X, _estack: 0x%08X\n", (uint32_t)sp, (uint32_t)&_estack);

再在ADC_IRQHandler最后加一句:

printf("MSP in ADC ISR: 0x%08X\n", (uint32_t)__get_MSP());

你会发现,两次打印的差值稳定在 320~380 字节之间。这意味着,如果主程序流本身也用了 600 字节栈,那么留给中断的“安全余量”只剩 40 字节——这比一张 A4 纸的厚度还薄。

注意:这个隐患不会在仿真器里暴露。J-Link 或 OpenOCD 的 GDB stub 会接管中断向量,屏蔽真实的中断嵌套行为。它只会在真机、高负载、长时间运行后,以“偶发性识别失败”或“串口输出乱码”的形式出现。

2.2 假设 2:系统时钟是“已正确初始化”的,且频率是“固定值”

main.c的第一行是:

HAL_Init();

紧接着是:

SystemClock_Config();

这个SystemClock_Config()函数,来自Core/Src/stm32f4xx_hal_msp.c,它调用HAL_RCC_OscConfig()HAL_RCC_ClockConfig(),最终将系统时钟(SYSCLK)配置为 168MHz。但整个工程里,没有任何一处代码去校验这个配置是否真的生效了

为什么需要校验?因为HAL_RCC_GetSysClockFreq()返回的值,是 HAL 库内部一个名为SystemCoreClock的全局变量。这个变量的值,是在SystemCoreClockUpdate()函数里,根据 RCC 寄存器的当前状态重新计算出来的。而SystemCoreClockUpdate()并非自动调用——它只在HAL_Init()里被调用一次,之后就再也不会更新。

问题来了:如果SystemClock_Config()执行过程中,由于晶振启振失败、PLL 锁相环未锁定、或者外部时钟源被意外断开,RCC_CFGR寄存器里的SW(系统时钟源选择)位可能仍停留在HSI(16MHz)状态。此时SystemCoreClock的值还是 168000000,但真实的 CPU 频率只有 16MHz。

后果是什么?所有基于HAL_Delay()HAL_GetTick()的时序控制全部错乱。KWS 模型要求每 20ms 采集一帧 16kHz 采样率的音频(即 320 个样本)。如果 CPU 实际跑在 16MHz,而代码却按 168MHz 计算定时器重装载值,那么TIM2ARR寄存器会被设为一个远小于真实需求的值,导致 ADC 触发过于频繁,DMA 缓冲区溢出,g_audio_buffer被反复覆盖。

如何发现这个假设?查看Core/Inc/main.h,里面定义了:

#define AUDIO_SAMPLE_RATE_HZ 16000U #define AUDIO_FRAME_LENGTH_MS 20U #define AUDIO_FRAME_SIZE (AUDIO_SAMPLE_RATE_HZ * AUDIO_FRAME_LENGTH_MS / 1000U) // = 320

再看Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_tim.cHAL_TIM_Base_Start_IT()的实现,它最终调用__HAL_TIM_SET_AUTORELOAD(&htim2, Period)。而Period的计算逻辑,在Core/Src/timer.c里:

uint32_t period = (SystemCoreClock / 1000000U) * 20U; // 期望 20us 分辨率,20ms 周期

这里直接用了SystemCoreClock,却没有用HAL_RCC_GetSysClockFreq()去实时读取。这是一个典型的“信任全局变量,而非硬件寄存器”的静默假设。

2.3 假设 3:Flash 地址映射是“线性且连续”的,且无 bank 切换

Core/Src/system_stm32f4xx.c里有一段关键代码:

void SystemInit(void) { /* FPU settings */ #if (__FPU_PRESENT == 1) && (__FPU_USED == 1) SCB->CPACR |= ((3UL << 10*2) | (3UL << 11*2)); /* set CP10 and CP11 Full Access */ #endif /* Reset the RCC clock configuration to the default reset state */ ... }

这段初始化 FPU 的代码,放在SystemInit()里,意味着它在main()之前执行。但它有一个致命前提:CPU 必须已经运行在 Flash 的 0x08000000 地址上

STM32F407 的 Flash 有 1MB,分为 4 个 bank(Bank 1 ~ Bank 4),每个 bank 256KB。而startup_stm32f407xx.s里的向量表起始地址是:

.section .isr_vector,"a",%progbits .code_size 32 .word _estack .word Reset_Handler ...

这个.isr_vector段,默认被链接脚本(STM32F407VGTx_FLASH.ld)放置在0x08000000。但如果用户在烧录时,误将固件烧到了 Bank 2(起始地址0x08040000),而 Boot 引脚又配置为从 System Memory 启动(即进入内置 Bootloader),那么 CPU 会从0x1FFF0000开始执行,加载的向量表就不是0x08000000,而是0x08040000。此时,Reset_Handler的地址是错的,SystemInit()根本不会被执行,FPU 不会启用,后续所有浮点运算(哪怕只是float a = 1.0f / 3.0f;)都会触发 UsageFault 异常。

而整个工程里,没有任何机制去检测当前运行地址是否与预期一致。main.c里没有if ((uint32_t)&_stext != 0x08000000) { while(1); }这样的防护。

这解释了为什么很多开发者反馈:“同样的 bin 文件,在 J-Link 烧录时正常,用 ST-Link Utility 烧录就死机”。因为不同烧录工具对 Flash bank 的处理策略不同,有的会自动擦除整个 bank,有的只擦除指定扇区,有的甚至会忽略 bank 边界。

3. CMSIS-DSP 调用链深度剖析:从 arm_fir_q15 到模型权重的内存对齐真相

ML‑KWS‑for‑MCU 的核心语音处理流水线,是一个经典的“前端特征提取 + 后端神经网络分类”结构。前端用 FIR 滤波器做带通(保留 100Hz~3000Hz),再做梅尔频谱(Mel Spectrogram),最后送入一个轻量级 CNN。整个过程,90% 的计算量落在 CMSIS-DSP 库的函数调用上。而这些函数,对内存布局有着近乎苛刻的要求。

3.1 arm_fir_q15:不只是“滤波”,更是对齐的试金石

Core/Src/audio_preprocess.c中的audio_filter_apply()函数,调用如下:

arm_fir_instance_q15 S; q15_t fir_coeffs[65] = { ... }; // 64-tap filter + 1 bias q15_t audio_input[320]; q15_t audio_output[320]; arm_fir_init_q15(&S, 64, fir_coeffs, &S.pState, 320); arm_fir_q15(&S, audio_input, audio_output, 320);

表面看,这是标准用法。但arm_fir_q15的内部实现,决定了它能否真正发挥 Cortex-M4 的 DSP 指令优势。

我们打开Drivers/CMSIS/DSP/Source/FilteringFunctions/arm_fir_q15.c,找到arm_fir_q15()的主体循环:

/* Run the loop unrolled by 4 */ while (blkCnt > 0U) { /* Read 2 input samples */ in1 = *pSrc++; in2 = *pSrc++; /* out = state[x] * coeff[y] */ sum = __SMUAD(state, coeffs); sum = __SSAT(sum >> 15, 16); /* Update state buffer */ *pState++ = in1; *pState++ = in2; /* Decrement loop counter */ blkCnt--; }

关键在__SMUAD这条内联汇编指令。它是 ARM 的“Signed Multiply Two Words and Add”指令,一次可以并行计算两个 16-bit 乘法(state[0]*coeff[0] + state[1]*coeff[1]),但前提是statecoeffs这两个指针,必须指向4 字节对齐的内存地址。

CMSIS-DSP 的文档明确指出:arm_fir_init_q15()会检查pState的地址,并在必要时进行调整。但pState是谁传进去的?是&S.pState。而S.pStatearm_fir_instance_q15结构体的一个成员:

typedef struct { uint16_t numTaps; /**< number of filter coefficients in the filter. */ q15_t *pState; /**< points to the state variable array. */ const q15_t *pCoeffs; /**< points to the coefficient array. The array is of length numTaps. */ } arm_fir_instance_q15;

pState是一个指针,它指向的内存,是由调用者(即audio_preprocess.c)分配的。在 ML‑KWS‑for‑MCU 中,这个内存来自全局数组:

static q15_t fir_state[128]; // 64-tap filter needs 64+320-1 = 383 samples of state

static全局数组,在.bss段中,由链接器按 4 字节对齐。所以fir_state的地址,大概率是 4 字节对齐的。但“大概率”不等于“必然”。

我们用objdump检查最终的.elf文件:

arm-none-eabi-objdump -t build/ML-KWS-for-MCU.elf | grep fir_state

输出可能是:

08004200 l O .bss 00000100 fir_state

0x08004200是 4 字节对齐的(末两位是00)。但如果fir_state前面的其他全局变量总大小是奇数个字节,比如前面有个uint8_t flag;,那么fir_state的地址就可能变成0x08004201,这就违反了__SMUAD的要求。

后果不是崩溃,而是计算错误。__SMUAD在非对齐地址上执行,会触发UsageFault,但 Cortex-M4 默认的 Fault Handler 是一个无限循环while(1);。如果你没连接调试器,设备就“假死”在那里,串口没输出,LED 不闪烁,一切安静得可怕。

解决方案不是“祈祷对齐”,而是强制对齐:

static q15_t fir_state[128] __attribute__((aligned(4)));

或者,在arm_fir_init_q15()调用前,显式检查:

if (((uint32_t)&fir_state[0]) & 0x3) { printf("FIR state NOT 4-byte aligned! Addr: 0x%08X\n", (uint32_t)&fir_state[0]); while(1); }

3.2 arm_mel_spectrogram_q15:隐藏在注释里的精度陷阱

特征提取的下一步,是计算梅尔频谱。Core/Src/mel_spectrogram.c里,mel_spectrogram_compute()函数调用:

// Apply FFT (using CMSIS-DSP's arm_cfft_radix4_q15) arm_cfft_radix4_instance_q15 fft_inst; arm_cfft_radix4_init_q15(&fft_inst, 256, 0, 1); // 256-point, forward, bit-reversal arm_cfft_radix4_q15(&fft_inst, fft_input); // Convert complex FFT output to magnitude spectrum arm_cmplx_mag_q15(fft_input, mag_spectrum, 256); // Map linear spectrum to mel scale for (i = 0; i < MEL_BANDS; i++) { // Weighted sum over linear bins... mel_energies[i] = 0; for (j = 0; j < 256; j++) { mel_energies[i] += mag_spectrum[j] * mel_weights[i][j]; } }

这里有两个精度陷阱。

第一个是arm_cfft_radix4_q15。Q15 格式是 1.15 定点数,范围是 [-1.0, 0.999969]。而原始音频数据(来自 ADC)是 12-bit,范围是 [0, 4095]。直接把uint16_t的 ADC 值赋给q15_t数组,会导致严重溢出。正确的做法是先归一化:

q15_t adc_q15 = (q15_t)((int32_t)adc_raw - 2048); // center around 0 adc_q15 = (q15_t)((int32_t)adc_q15 << 1); // scale up to use full Q15 range

audio_capture.c里没有这一步。它直接做了:

g_audio_buffer[write_idx++] = (q15_t)adc_value; // adc_value is uint16_t

adc_value最大是 4095,而q15_t最大是 32767,看起来没问题。但arm_cfft_radix4_q15的内部蝶形运算,涉及大量q15_t * q15_t的乘法,结果是 Q30,再右移 15 位得到 Q15。如果输入本身就接近满幅,中间结果会溢出,导致 FFT 输出全是0x7FFF0x8000

第二个陷阱在mel_weights数组。这个二维数组,定义在Core/Src/mel_tables.c

const q15_t mel_weights[MEL_BANDS][256] = { { 0, 0, 0, ..., 1234, 567, 0, ... }, ... };

q15_t是有符号的,而 Mel 权重应该是非负的。但1234这个值,在 Q15 下代表1234 / 32768 ≈ 0.0376,这没问题。问题在于,这个数组是const,被放在.rodata段,而.rodata段在 Flash 中,访问速度慢于 RAM。CMSIS-DSP 的arm_mat_mult_q15(如果后续用到矩阵乘)会频繁读取这个表,造成总线瓶颈。

实测对比:将mel_weights复制到 RAM:

static q15_t mel_weights_ram[MEL_BANDS][256]; // 在 main() 开头 memcpy(mel_weights_ram, mel_weights, sizeof(mel_weights_ram));

然后在mel_spectrogram_compute()中使用mel_weights_ram。在 STM32F407 上,特征提取耗时从 8.2ms 降至 6.7ms,提升 18%。这不是算法优化,而是内存拓扑优化。

3.3 model_run():权重数组的“隐式拷贝”与 cache 一致性危机

模型推理函数model_run(),位于Core/Src/kws_model.c,其核心是:

// Input: mel_energies[40] -> FC1 layer (40x32) arm_fully_connected_q7(&fc1_params, mel_energies, fc1_output, &fc1_buffers); // FC1 output -> ReLU -> FC2 layer (32x8) arm_relu_q7(fc1_output, 32); arm_fully_connected_q7(&fc2_params, fc1_output, fc2_output, &fc2_buffers); // FC2 output -> Softmax arm_softmax_q7(fc2_output, 8, kws_result);

fc1_paramsfc2_paramsarm_fully_connected_params结构体,其中const q7_t *pWeights指向权重数组。这些权重,定义在Core/Src/kws_weights.c

const q7_t fc1_weights[40*32] = { ... }; const q7_t fc2_weights[32*8] = { ... };

q7_t是 8-bit 有符号整数,权重数组总大小是(40*32 + 32*8) = 1536字节,很小,完全可以放进 Flash。

arm_fully_connected_q7()的实现(Drivers/CMSIS/DSP/Source/NNFunctions/arm_fully_connected_q7.c)里,有一段关键代码:

/* Loop over output */ for (i = 0; i < pParams->output_dim; i++) { /* Initialize temp to zero */ sum = 0; /* Loop over input */ for (j = 0; j < pParams->input_dim; j++) { /* Perform matrix multiplication */ sum += pIn[j] * pWeights[i * pParams->input_dim + j]; } ... }

注意pWeights[i * pParams->input_dim + j]这个索引。pWeightsconst q7_t *,指向 Flash。而 Cortex-M4 的 I-Cache(指令缓存)和 D-Cache(数据缓存)是分离的(Harvard 架构)。当 CPU 从 Flash 读取权重时,数据会先进入 D-Cache。但如果权重数组很大,或者与其他频繁访问的数据(如g_audio_buffer)发生 cache line 冲突,D-Cache 就会频繁失效,导致每次读取都要走慢速的 Flash 总线。

更糟的是,arm_fully_connected_q7()的内部循环,是高度规则的、顺序访问的。这种访问模式,对 D-Cache 友好。但mel_energies数组是q15_tfc1_outputq15_t,它们都放在.bss段的 RAM 里。RAM 的访问延迟是 1~2 个周期,而 Flash 是 3~5 个周期(即使开了 I-Cache,D-Cache 对 Flash 数据的预取效率也有限)。

解决方案是“权重搬运”:在model_run()开头,将权重一次性拷贝到 RAM:

static q7_t fc1_weights_ram[40*32]; static q7_t fc2_weights_ram[32*8]; // 第一次调用时搬运 if (!weights_loaded) { memcpy(fc1_weights_ram, fc1_weights, sizeof(fc1_weights_ram)); memcpy(fc2_weights_ram, fc2_weights, sizeof(fc2_weights_ram)); weights_loaded = 1; } // 后续调用,直接用 *_ram 版本 arm_fully_connected_q7(&fc1_params_ram, mel_energies, fc1_output, &fc1_buffers);

memcpy的开销是一次性的,约 0.1ms。而后续每次推理,权重访问速度提升 2~3 倍。在电池供电的边缘设备上,这直接转化为续航时间的延长。

4. 工程架构全景图:从 Makefile 到 linker script 的“隐形契约”

一个嵌入式工程的健壮性,不体现在main()函数有多优雅,而体现在构建系统的每一个角落。ML‑KWS‑for‑MCU 的构建系统,是一个典型的 GNU Make + GCC 工具链组合,其核心文件是MakefileSTM32F407VGTx_FLASH.ld。它们之间,签订了一份没有白纸黑字、却约束着整个项目生死的“隐形契约”。

4.1 Makefile:交叉编译器路径的“脆弱单点”

Makefile的开头几行:

# Toolchain CROSS_COMPILE = arm-none-eabi- CC = $(CROSS_COMPILE)gcc AS = $(CROSS_COMPILE)gcc -x assembler-with-cpp AR = $(CROSS_COMPILE)ar OBJCOPY = $(CROSS_COMPILE)objcopy OBJDUMP = $(CROSS_COMPILE)objdump

CROSS_COMPILE是一个变量,它决定了所有工具的前缀。这个设计本身没问题,但问题在于:整个 Makefile 里,没有任何地方去验证$(CC)是否真的存在,或者它的版本是否兼容

我们执行which arm-none-eabi-gcc,得到/usr/bin/arm-none-eabi-gcc。再执行/usr/bin/arm-none-eabi-gcc --version,输出:

arm-none-eabi-gcc (GNU Arm Embedded Toolchain 10-2020-q4-major) 10.2.1 20201103 (release)

这看起来很新。但STM32F407VGTx_FLASH.ld链接脚本里,有一行:

ENTRY(Reset_Handler) SECTIONS { . = 0x08000000; ... }

这个ENTRY(Reset_Handler),要求链接器必须能找到Reset_Handler符号。而Reset_Handler定义在startup_stm32f407xx.s里,它是一个用.global声明的汇编标签。

GCC 10.x 的默认 ABI 是 AAPCS(ARM Architecture Procedure Call Standard),它要求所有全局符号,在链接时加上一个下划线前缀_。也就是说,Reset_Handler在符号表里,实际叫_Reset_Handler。而链接脚本里的ENTRY(Reset_Handler),找不到_Reset_Handler,就会报错:

undefined reference to `Reset_Handler'

这个问题,在 GCC 9.x 及更早版本中不存在,因为它们默认不加_前缀。解决方案是,在MakefileCFLAGS里,显式添加:

CFLAGS += -mno-apcs-frame -fno-common

-mno-apcs-frame禁用 AAPCS 的帧指针约定,-fno-common防止未初始化的全局变量被放到 COMMON 段(这会影响链接顺序)。

Makefile里没有这行。它假设你用的是“老版本”工具链。这就是一个典型的“环境假设”。

如何让 Makefile 更健壮?加入版本检查:

GCC_VERSION := $(shell $(CC) --version | head -n1 | sed 's/[^0-9]*\([0-9]\+\)\.\([0-9]\+\).*/\1/') ifeq ($(GCC_VERSION),10) CFLAGS += -mno-apcs-frame -fno-common endif

4.2 STM32F407VGTx_FLASH.ld:内存分区的“精确手术刀”

链接脚本是嵌入式工程师的“内存宪法”。STM32F407VGTx_FLASH.ld定义了 Flash 和 RAM 的布局:

MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } SECTIONS { .isr_vector : { ... } > FLASH .text : { ... } > FLASH .rodata : { ... } > FLASH .data : { ... } > RAM AT > FLASH .bss : { ... } > RAM }

这个脚本,把.data段(初始化的全局变量)放在 RAM 里,但它的初始值(即= LOADADDR(.data))是从 Flash 里加载的。这是一个标准的“copy-down”机制。

Core/Src/kws_model.c里,有一个全局数组:

q15_t g_mfcc_features[13]; // MFCC coefficients, used across frames

q15_t是 16-bit,13 个元素,共 26 字节。它被放在.bss段,由链接器自动清零。这没问题。

然而,在Core/Src/audio_preprocess.c里,还有一个更大的数组:

static q15_t g_mel_spectrogram[256]; // 256-point mel spec

static局部变量,也放在.bss.bss段的起始地址是0x20000000,长度是 128K。但g_mel_spectrogram是 256 * 2 = 512 字节,它后面紧跟着g_audio_buffer(320 * 2 = 640 字节),再后面是fir_state(128 * 2 = 256 字节)……所有这些,都在.bss段里线性排列。

问题在于:.bss段的结束地址,就是 RAM 的使用上限。如果所有这些数组加起来,超过了0x20000000 + 128K = 0x20020000,那么.bss就会溢出,覆盖到.stack段,或者更糟,覆盖到.heap(如果工程启用了 malloc)。

Makefile里,有一行:

SIZE = $(CROSS_COMPILE)size ... $(info $(shell $(SIZE) -A build/ML-KWS-for-MCU.elf))

它会在编译完成后,打印各段大小。但这个信息,只是“打印”,并不做任何判断。它不会告诉你:“.bss段已占用 125K,剩余空间仅 3K,新增一个q15_t[100]数组就会溢出”。

真正的工程实践,是加入链接时检查:.ld文件末尾,添加:

/* Check RAM usage */ _ram_used = SIZEOF(.data) + SIZEOF(.bss) + SIZEOF(.stack); _ram_total = LENGTH(RAM); ASSERT(_ram_used <= _ram_total, "ERROR: RAM overflow! Used: " _ram_used " bytes, Total: " _ram_total " bytes")

这样,一旦.bss超限,链接器会直接报错,而不是让你在运行时才发现。

4.3 Drivers/STM32F4xx_HAL

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询