边缘AI落到Cortex-M这类MCU上时,唤醒词识别几乎是最典型的落地场景。ARM在2019年前后开源的ML-KWS-for-MCU,用一套Speech Commands数据集、若干组训练脚本和一份纯C/C++部署工程,把关键词识别完整塞进几MB Flash、几百KB RAM的微控制器里。我最近把这套源码做了一轮静态评测和工程架构全景解析,结论是:尽管项目发布已有好几年,其中的设计思路依然比很多新出的商业SDK更值得读。这篇文章不打算教你怎么跑demo,也不会逐行贴注释,而是站在源码审计的角度,把音频前端、推理链路、工具链边界和迁移成本一次说清,给想研究边缘AI部署或ARM生态的开发者一份可复用的判断框架。

1. 为什么一个几年前的唤醒词工程,今天依然值得逐行读

1.1 唤醒词识别在MCU端的特殊性

语音唤醒在手机和智能音箱上已经被当成标配能力,但放到MCU上完全是另一回事。MCU通常只有几百KB RAM、几MB Flash,CPU主频从几十MHz到几百MHz不等,而且大部分场景要求always-on——麦克风始终在采集,系统必须用很低的功耗持续监听音频。唤醒词识别(Keyword Spotting,KWS)恰好是这类约束下的产物:它不需要听懂整句话,只需要从连续音频流里识别出几个固定词,比如“Alexa”“Hey Siri”,或者项目里默认的“yes”“no”“up”“down”这一组命令。

这个任务看起来简单,但拆开之后并不轻松。它至少包含四段独立工程:音频采集、特征提取、神经网络推理、结果后处理。每一段在PC上都有成熟方案,可是一旦落到MCU,内存、算力、功耗会被同时收紧,几乎所有“理所当然”的库都不能直接拿过来。ML-KWS-for-MCU的价值就在于,它是ARM官方把这条完整链路真正跑在Cortex-M上的参考实现,而且代码是开放的。读这份代码,约等于看ARM自己认为“MCU上的语音AI应该怎么组织工程”。

1.2 ARM开源这个项目的真实动机

ARM本质上是一家IP公司,主要收入来自处理器内核授权,而不是卖软件。它开源ML-KWS-for-MCU,核心目的不是做一个可以直接商用的产品,而是向芯片厂商和终端开发者示范一件事:Cortex-M系列加上CMSIS生态,已经有能力承载实时关键词识别这类边缘AI负载。

项目背后的论文(Hello Edge: Keyword Spotting on Microcontrollers)和代码是配套发布的。论文负责解释算法选型和实验结果,代码负责回答“具体怎么实现”。这种“论文+源码”的组合,在那个年代非常少见。大部分MCU厂商的AI demo都是“给你一个编好的lib,黑盒跑起来就行”,而ARM把训练脚本、数据预处理、模型结构和部署代码全部摊开,等于把整套方法论交了出来。

理解这一点很重要。读这份源码时,不能用“这个工程能不能直接量产”来评价它,而应该问“它示范了哪些关键取舍”。很多设计,比如为什么训练用浮点MFCC而部署用固定点、为什么把模型和音频前端解耦、为什么用12类输出而不是简单的二分类,都是在回答这一类工程问题。

1.3 静态评测的观察窗口

所谓源码静态评测,就是不带板子、不跑训练,只靠通读代码和构建脚本来还原工程全貌。很多人觉得这不如直接跑demo来得实在,但我的经验是,对于嵌入式AI项目,静态评测往往是性价比最高的第一步。

原因很简单:MCU工程的痛点通常不在算法本身,而在依赖版本、内存布局、编译器特性和平台绑定。这些信息几乎全部藏在代码、Makefile和README里。比如CMSIS版本换了,某个DSP函数签名变了,链接阶段会报一堆晦涩错误;再比如ARM Compiler 5和6的语法差异,会让同一份代码在Keil里一个版本编译通过、另一个版本直接报错。这类坑,跑demo之前不读代码,根本意识不到。静态评测的目的,就是提前把可能的时间黑洞找出来。

2. 全景拆目录:train与deploy双栈如何各司其职

2.1 根目录结构:一个典型的“双栈”工程

把仓库clone下来之后,第一眼就能看出这不是普通MCU例程。它的根目录下至少可以分为两大部分:训练侧(train)和部署侧(deploy),外加模型归档和文档。训练侧用Python写,部署侧是C/C++,两者通过TensorFlow的模型文件和特征提取规则衔接。

用“双栈”来概括这个设计再合适不过。训练栈负责离线完成数据准备、模型训练、精度验证和模型导出;部署栈负责在资源受限的MCU上加载模型、跑推理、处理音频。两者基于完全不同的语言生态和运行环境,但必须对同一个东西保持一致:MFCC特征的计算结果。训练时你用Python的librosa或TensorFlow里的Signal库提取特征,部署时你在MCU上用C代码重新实现同一套特征提取。两边数值越接近,训练出的模型在端侧效果就越好。

这种双栈结构在今天看来是MCU AI工程的标配,但在项目刚开源那几年,很多团队还在用“MATLAB训练+手工移植”的模式。ARM把双栈的目录边界直接展示出来,其实就是在示范一套标准做法。

2.2 训练侧脚本到底在做什么

训练侧的代码主要围绕Speech Commands数据集展开。这是一个大规模语音命令数据集,包含“yes”“no”“up”“down”“left”“right”“on”“off”“stop”“go”等命令,同时还有背景噪声和未知词片段。ML-KWS-for-MCU在模型设计上不是做一个简单的二分类“唤醒/不唤醒”,而是输出12个类别:10个命令词、1个unknown(未知词)、1个silence(静音)。

这个设计非常关键。如果没有unknown类,模型面对任何听过的命令之外的音频都会强行归类到某个已知词,误唤醒率会高到没法用。加上unknown和silence之后,模型学会了“不确定时保持沉默”,这在实际产品里比提升单个词识别率更重要。

训练侧脚本价值不在于“能训练出多高的精度”,而在于提供了一条可复现的基准线。你可以在PC上用自己的数据微调,也可以直接跳过训练阶段,用仓库内的预训练模型继续走部署流程。我甚至建议初学者不要一上来就重新训练,先把导出流程跑通,再回去动网络结构,这样调试时更容易定位问题出在训练端还是部署端。

2.3 部署侧代码的分层逻辑

部署侧的代码能看出明显的分层意识。最底层是平台相关代码,负责具体板子的音频采集、屏幕显示、LED控制、串口输出;往上是一层平台无关的核心逻辑,包含特征提取、神经网络调用、识别结果判定和应用回调。

比较典型的是音频输入被抽象成一个流接口。主循环不关心麦克风硬件是什么型号,也不管数据来自ADC、PDM接口还是I2S,它只从抽象接口里拿到固定长度的PCM数据。这样带来的好处很直接:你想把工程迁移到自己的板子,核心工作往往只剩两个,一是把音频驱动接到这个抽象接口上,二是确认特征提取的参数和训练时一致。其他东西基本不动。

这个分层设计并不惊艳,但非常扎实。很多MCU厂商的AI demo之所以难移植,就是因为把模型调用和板级驱动写在一个大文件里,换一块板子几乎等于重写。ARM这种“平台相关薄薄一层、核心逻辑保持独立”的做法,值得直接抄。

2.4 依赖项考据:CMSIS、TFLM和编译器版本的敏感性

读这份源码时,有一件事会反复提醒你嵌入式工程的版本敏感:CMSIS、TensorFlow Lite for Microcontrollers(TFLM)、arm-none-eabi-gcc、ARM Compiler 5/6,每一环都可能因为版本差异带来编译或运行问题。

项目对CMSIS-DSP的依赖主要用于FFT和部分矩阵运算,对CMSIS-NN的依赖用于Cortex-M上神经网络算子加速,而TFLM则作为解释器读取模型文件。三个大依赖彼此又有版本耦合。CMSIS 5.x的不同minor版本之间,某些DSP函数的参数类型都有变化;TFLM本身迭代极快,早期版本和今天的API差异非常大。也就是说,想把项目本身构建出来,不能直接拿最新版依赖强行替换,而是要先确认仓库README里锁定的版本范围。

这个发现也是很多开发者第一次clone失败的原因。GitHub上大量issue都和“我拉取了最新CMSIS后链接出错”相关。静态评测到这一步,结论已经很明确:这个项目不适合无脑“git clone + make”,需要先做好依赖版本对齐。

3. 音频前端逐段走读:从PCM到MFCC的定点化之路

3.1 整条流水线:PCM、分帧、加窗

语音特征提取是MCU上KWS最容易被低估的部分。很多人以为神经网络是性能瓶颈,实际在多数Cortex-M4上,MFCC这类前端计算的耗时占比相当可观。ML-KWS-for-MCU的前端流程可以还原成一条清晰的链路:麦克风采集到16kHz、16bit单声道PCM数据后,先按固定帧长切分,通常取30ms左右为一帧,然后以10-20ms为步进做滑窗;每一帧先做预加重,再乘Hann窗,接着做FFT,取幅值平方得到功率谱,再经过Mel滤波器组、取对数、做DCT,最终输出一组MFCC系数。

分帧和加窗看似简单,实际直接影响识别效果。不用窗函数直接做FFT,频谱会因截断效应出现严重泄漏;而步长选得太大,会漏掉短促命令词的关键发音信息。项目里这些参数并不是拍脑袋定的,而是对齐训练阶段的标准做法。移植时最忌讳手痒去改窗长或步进,因为一旦改了,训练时的特征分布就和你设备上算出来的特征分布不一致,模型精度会莫名其妙下降。

3.2 FFT和CMSIS-DSP的使用方式

在MCU上做FFT,一般不会手写基2蝶形算法,而是直接调CMSIS-DSP库。ML-KWS-for-MCU沿用了这套思路,在Cortex-M上使用arm_cfft相关接口完成实序列FFT。这里有个容易被忽略的点:CMSIS-DSP的FFT接口针对q15、q31和f32各有一套,定点版本还需要额外的位反转和缩放处理。

如果只看了算法文档就动手移植,很容易踩中缩放陷阱。FFT的定标(scaling)策略会直接影响频谱幅值,进而影响Mel能量和最终MFCC。我在审计代码时特别注意了这部分,发现项目里对FFT输出的后处理比很多人自己写的版本要仔细,因为每个stage的缩放都必须在后级做对应补偿。如果你在自己的工程里发现MFCC值和PC端对不上,先别怀疑Mel滤波器算错,回头查FFT定标往往更快。

3.3 Mel滤波器组、对数压缩与DCT

功率谱算出来之后,要经过一组Mel刻度滤波器。人耳对频率的感知不是线性的,低频分辨率高、高频分辨率低,Mel滤波器组就是模拟这种听觉特性,把频谱压缩成几十个频带能量。项目里通常用20到40个Mel频带,然后取对数压缩动态范围,最后做DCT-II去相关性,得到MFCC。

MCU上没有log2函数库可以随便调用,定点化后通常用查表加线性插值近似。这里训练和部署的差异风险极大:训练阶段Python里的log是双精度浮点,部署阶段是低位定点近似,两者之间总会有微小误差。如果误差控制在较小范围,模型依然能稳定工作;一旦定点格式选得过低或者近似方法太粗糙,识别率会明显下降。这也是为什么项目里会倾向于用16位定点而不是8位来保存MFCC中间结果。

3.4 定点化是训练到部署中最大的刺客

很多开发者第一次把模型跑到MCU上,发现识别率比PC仿真差一大截,第一反应是模型量化出了问题,其实更常见的元凶是特征提取不一致:训练时给模型喂的是浮点MFCC,部署时喂的是定点MFCC,两者分布都不重合了,后面的神经网络再准也白搭。

ML-KWS-for-MCU的处理方式是用固定点重写整条前端,同时尽量保证和训练侧特征一致,并且在README和代码注释中明确标注格式转换关系。这也是我比较推荐的做法:不要只对神经网络量化,要连特征提取一起纳入“端侧一致性”的验证范围。更实用的排查手段是,在PC上提前导出一段音频的浮点MFCC,再用MCU代码对同一段音频输出定点MFCC,把两组特征打印出来逐维对比,误差超过阈值就说明前端实现有问题。这个步骤看着麻烦,但能省下后面几天排查模型的时间。

下面是按源码逻辑还原出的MCU端MFCC调用链,可以用来说明整体结构:

// 伪代码:以说明MFCC前端的调用关系
audio_streamer_capture(pcm_buf, frame_len);      // 获取一帧PCM
pre_emphasis(pcm_buf, frame_len, 0.97f);
window_hanning(pcm_buf, frame_len);
arm_cfft_q15(&fft_instance, pcm_buf, 0, 1);     // 定点FFT
compute_power_spectrum(pcm_buf, spectrum);
mel_filter_bank_apply(spectrum, mel_energy);
fix16_log_approximation(mel_energy, log_energy);
dct_ii(log_energy, mfcc_out, num_mfcc_features);

4. 推理链路:TFLite Micro与CMSIS-NN怎么协同

4.1 从Keras模型到tflite导出

训练侧的模型用Keras这类高层API构建,训练完成后再导出为TensorFlow Lite格式。这个格式是一个flatbuffer序列化后的文件,包含模型结构、权重和算子信息。部署侧不直接读取Keras的H5文件,而是把这个tflite文件转成一个C数组,编进MCU固件。

这里有一个值得留意的设计取舍:为什么不在MCU上直接用C代码手写前向推理,非要经过TFLite Micro解释器?答案是通用性。手写网络前向,换一个网络结构就要改一遍推理代码,而TFLite Micro把算子注册、张量管理和执行流程标准化了,模型结构变了,只要算子集没变,部署代码基本不用动。这个取舍在模型迭代频繁的项目里能省大量时间,代价是多了一个解释器层,会多占一点Flash和RAM。

4.2 Tensor Arena与静态内存复用

TFLite Micro和桌面版TFLite最大的不同是没有动态内存分配。推理所需的Tensor缓冲区,全部来自一个预先分配好的静态区域,也就是Tensor Arena。AllocateTensors调用会在这个区域内规划和分配所有中间张量,之后每次Invoke都复用同一片内存,避免malloc带来的不确定性和碎片。

这个小机制直接决定了MCU内存预算方式。你需要的RAM大约是:模型权重、激活Tensor、输入输出Tensor、音频帧缓冲和特征缓冲的总和。其中激活Tensor大小和模型结构强相关,网络越深通道越多,arena就越大。ML-KWS-for-MCU的示例工程里,arena大小通常设置在几十KB量级,对不同模型功耗差异明显。这也是部署端必须按模型分别测试的原因:DNN可能只要很小的arena,DS-CNN可能就要翻倍。

4.3 CMSIS-NN的加速边界

CMSIS-NN是CMSIS生态里的神经网络内核库,提供针对Cortex-M优化的卷积、全连接、激活和池化算子。它和TFLite Micro的关系是:TFLite Micro负责解释模型和调度算子,CMSIS-NN负责把算子在具体MCU上跑得更快,两者不是互斥方案。

但CMSIS-NN不是万能加速器。它对量化模型(尤其是int8)的优化最充分,因为定点计算可以充分利用Cortex-M的SIMD指令和DSP扩展。如果你直接跑float32的模型,CMSIS-NN能做的优化就非常有限,主要靠编译器自动向量化和硬件FPU,加速比远不如量化后明显。所以是否量化的决策,不只是为了省Flash和RAM,还会直接影响推理速度。

4.4 不同网络结构在MCU端意味着什么

ML-KWS-for-MCU支持不止一种网络结构,常见的有DNN、CNN、DS-CNN(深度可分离卷积网络),部分版本还探索过LSTM和CRNN。这些网络在PC上精度差异可能只有一两个百分点,但在MCU上的开销差异可能是成倍的。

模型类型 主要算子 相对计算量 内存占用 适合MCU程度
DNN 全连接为主 低 低 最容易落地,但精度上限一般
CNN 标准卷积 中 中 精度与开销比较均衡
DS-CNN 深度可分离卷积 中低 中 ML-KWS-for-MCU重点推荐,性价比高
LSTM/CRNN 循环/时序算子 高 高 更适合算力更高的平台,MCU上需要权衡

静态评测看到这一层,就能理解ARM为什么在项目里花大量篇幅验证DS-CNN:它的设计哲学就是“用尽量少的乘加次数逼近标准CNN的精度”,非常契合MCU的算力短板。到今天,这个思路依然是边缘AI模型结构设计的核心方向。

5. 静态审计出值得借鉴的设计与藏在角落的坑

5.1 值得抄作业的三件事

逐文件读下来,有三个设计我会直接在新项目里复用。

第一个是unknown和silence类别。凡是做唤醒词的人,都应该默认加上这两类,而不是只对目标词做二分类。模型只有学会了“不知道”和“没人说话”,才能在实际环境里稳定工作。

第二个是在音频流上做滑窗式连续识别。MCU端不可能等用户说完整句话再做判断,它必须边采边算,每隔一个步进就产生一次预测。ML-KWS-for-MCU能实现接近实时的响应,核心就是滑窗和特征缓存机制。这个模式对任何流式音频处理任务都通用。

第三个是前后端分离的事件回调。推理得到置信度之后,不是直接操作外设,而是触发一个识别事件,由上层应用决定点亮LED、播放提示音还是发送到蓝牙。这种解耦让算法工程师和应用工程师能并行开发,也是工程化程度的一个重要标志。

5.2 值得吐槽的地方

如果你拿生产级代码的标准去审计它,问题也不少。

首先是代码风格不统一。训练侧Python脚本相对规范,但部署侧有些文件仍然带着较重的demo气质,变量命名和函数拆分水平参差不齐。其次,项目将演示代码(比如LED控制、屏幕打印)和核心识别逻辑放在同一个仓库里,初学者很容易把注意力放在无关外设上,反而忽略关键路径。文档更新也明显滞后于代码,个别参数在README和实际默认配置里对不上,需要自己对着源码确认。

这些问题说明它距离“开箱即用的量产参考”还有距离,但并不影响学习价值。把“参考实现”和“生产模板”区分开,是读这类项目时必须保持的心态。

5.3 ARM Compiler 5与6的版本诅咒

嵌入式开发者大概率在Keil里见过这样一个报错:missing compiler version 5。ML-KWS-for-MCU及同期很多ARM MCU工程,默认工程文件基于ARM Compiler 5(AC5)编写。AC5是armcc编译器,Keil MDK老版本默认使用;后来ARM Compiler 6(AC6)基于Clang架构,编译速度更快、C标准支持更好,成了新版本MDK的默认编译器。但AC6对旧代码的更严格语法检查、内联汇编差异,以及CMSIS兼容策略,导致很多老工程直接切换后编译不过。

这个“版本诅咒”几乎是ARM生态迁移的必经之痛,不止ML-KWS-for-MCU一个项目会遇到。解决方向基本是两个:要么安装ARM Compiler 5.06u7这类旧编译器,继续用AC5编译老工程;要么花时间把工程迁移到AC6,修正代码中的编译告警和汇编兼容问题。

对比项 ARM Compiler 5 (armcc) ARM Compiler 6 (clang)
编译内核 传统armcc 基于LLVM/Clang
C标准支持 偏保守 支持新标准更彻底
代码检查 宽松 严格,告警更多
内联汇编 语法与GCC差异大 与GCC更接近
老工程兼容性 好 需要迁移调整
当前推荐度 仅用于老工程维护 新工程建议直接用

如果只是自用学习,我更建议直接用GNU工具链,也就是arm-none-eabi-gcc,配上一份CMake或简单Makefile,绕过Keil的工程文件历史包袱。交叉编译逻辑一样,并且命令行可控性强,问题定位直观。

5.4 CMSIS和TFLM版本错位的实际报错

顺着“拉取最新依赖”的思路做一次暴力替换,大概率会遇到两类报错。一类是CMSIS-DSP里的函数签名不匹配,例如FFT初始化结构体从旧版接口变成新版接口,导致编译阶段“参数数目”错误;另一类是TFLM的API变化,解释器构造函数、运算符注册方式在不同版本之间调整过多次,老代码直接编译链接失败。

遇到这类问题,我的排查习惯是先看项目README里声明的依赖版本,再决定是“降级依赖”还是“升级代码”。ML-KWS-for-MCU这类老项目,最快路径往往是选择它当时对应的CMSIS和TFLM版本,而不是反过来改源码适配最新库。除非你有强烈需求必须升级,否则不要在这个环节浪费太多精力。

6. 把它迁移到自己板子前的决策清单

6.1 先确定音频输入方案,再谈其他

迁移这份源码,第一个要确定的是音频从哪里来。项目默认的抽象接口需要一个能持续提供16kHz、16bit单声道PCM数据的上游模块。你的板子上如果是PDM麦克风,需要先经过PDM到PCM的转换;如果是模拟麦克风加ADC,要确认ADC的采样率和位宽是否匹配;如果是从I2S数字麦克风输入,则要注意左右声道对齐和时钟配置。

音频链路不准备好,后面全是白费。因为这个项目的主循环本质上是一个“从音频流里不断取帧”的状态机,没有稳定的PCM数据,MFCC算不出来,模型推理也就无从谈起。我见过不少人在没有验证音频数据有效性的情况下直接调模型,最后发现识别率低是因为麦克风采集的数据都是削顶失真的噪声。

6.2 内存和Flash预算要提前估算

迁移之前,先按这套粗算方法盘一下资源:模型权重占多少Flash、Tensor Arena占多少RAM、音频帧和特征缓冲占多少RAM、系统其他模块占多少资源。不要等代码写完才发现芯片容量不够。

一般来说,像ML-KWS-for-MCU中的DNN或DS-CNN模型,权重在几十到一两百KB量级,Tensor Arena在十几到几十KB量级,音频和特征缓冲通常也在几十KB以内。整体算下来,一颗带足够Flash和64KB以上RAM的Cortex-M4/M7芯片,基本就能跑。如果选Cortex-M0+这类更低端的核,要么把模型压缩到很极端,要么直接不用考虑了。

6.3 工具链选择建议

新工程我建议直接用arm-none-eabi-gcc或ARM Compiler 6,不要再用AC5从零搭工程。AC5只适合维护老代码,新构建环境还用AC5,等于主动增加未来迁移成本。用GCC工具链时,你可以用如下命令做一次交叉编译验证:

arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
  -DCORE_CM4 -std=gnu11 -O2 -I./deploy/include -I./CMSIS/Core/Include \
  -c deploy/src/kws_main.c -o build/kws_main.o

关键点有两个:CPU型号和浮点ABI必须与你的芯片一致;CMSIS头文件路径必须先用源码或Pack安装好。这里如果 -mfloat-abi 配错,代码会在运行时进入硬件异常。很多新手觉得“编译器报错才算问题”,其实嵌入式里更怕编译通过但ABI不匹配,运行起来才崩溃。

6.4 从MCU到ARMv8边缘设备的移植视角

如果迁移目标不是MCU,而是树莓派这类ARMv8边缘盒子,ML-KWS-for-MCU里的思路依然成立,但实现方式要换。特征提取和模型结构可以保留,推理引擎可以从TFLite Micro升级到完整TFLite或ONNX Runtime,联网能力、模型热更新、多路音频输入这些在MCU上不敢想的能力,此时都能放开手脚。

这里也要提醒一句:ARM和x86的差异在这个层面依然存在,但远没有MCU上那么致命。边缘盒子的内存和算力都充裕,性能瓶颈更多在模型本身和软件框架效率上。直接沿用ML-KWS-for-MCU里“按滑动窗口持续识别”的架构,再结合更复杂的后处理逻辑,往往比推倒重来更稳妥。

我自己审计这份源码最大的体会是:MCU上的边缘AI,算法只占一小部分,真正的功夫都在工程取舍上。音频前端怎么定标、特征怎么与训练对齐、依赖版本怎么锁定、编译器ABI怎么匹配,每一件单独看都不难,但串在一起就是大多数移植失败的原因。如果你正打算在ARM架构的MCU上做唤醒词或小型音频分类,花两三天时间把ML-KWS-for-MCU从头到尾读一遍,比盲目从零写代码要划算得多。

Logo

邀请您加入社区

更多推荐