ML-KWS-for-MCU源码解析:Cortex-M上关键词唤醒与边缘AI部署实战
先说结论:ML-KWS-for-MCU 是 ARM 官方放出的一个面向 Cortex-M 系列单片机的关键词唤醒参考工程,项目全称 Machine Learning Keyword Spotting for MCU。它解决的是“如何在几十 KB 内存、几百 MHz 主频、甚至电池供电的微控制器上,把语音唤醒模型真正跑起来”这个问题。我前后花了好几个晚上把这个仓库从训练脚本到 MCU 端 C 代码完整过了一遍,这篇就按源码静态评测和工程架构两条线展开,尽量把模块拆开讲清楚,给打算做边缘 AI 部署或者想在 Arm 生态里做端侧推理的朋友一个可以直接参考的路线图。
这个项目表面看只是个“语音唤醒 demo”,但深入看下来,它是把“TensorFlow 训练 -> 模型量化 -> C 数组落地 -> Cortex-M 推理 -> CMSIS-NN 算子加速”这条完整链路串在一起的少有的开源案例。也就是说,它不只是一个算法示例,而是一个可以真正编译、烧录、运行的端到端边缘 AI 工程。无论你是刚接触嵌入式 AI 的新手,还是在做产品选型的老工程师,这个仓库都值得花时间通读一遍。
1. 项目背景与核心价值:为什么 MCU 上的关键词唤醒值得全景拆解
1.1 一句话讲清楚 ML-KWS-for-MCU 是做什么的
关键词唤醒(Keyword Spotting,KWS)是语音交互设备里最前置的一环。手机里的“Hey Siri”、智能音箱里的“小爱同学”“天猫精灵”,本质上都是设备在待机状态下持续监听麦克风,识别到指定唤醒词后才把主系统拉起来。
以前想做哪怕一个很简单的唤醒功能,绝大多数方案都是把音频传到云端,靠服务器端的语音模型来判断。这条路的问题很直接:网络延迟、功耗、服务器成本,还有用户隐私的顾虑。后来出现了专门的低功耗 DSP 或者音频前端芯片,但可定制性不够,很多场景还是想要一个普适的 MCU 方案——用一颗几块钱的 Cortex-M4 或者 M55、M7 芯片,直接在本地跑完整个唤醒流程。
ML-KWS-for-MCU 就是在这样的背景下产生的。ARM 把完整的 KWS 示例工程开源出来,里面包含 TensorFlow 训练脚本、模型转换工具、MCU 端推理代码和针对 Arm Cortex-M 优化的 CMSIS-NN 算子库。它的目的很明确:告诉开发者“在单片机里面做关键词识别,是一件完全可行的事”,并且把可行的路径给到你。
1.2 边缘 AI 在 MCU 端的独特约束与价值
做边缘 AI 的人经常把“边缘”挂在嘴边,但不同人说的边缘可能差着几个数量级。服务器边缘侧跑的是 100W 的 GPU 卡,网关边缘侧跑的是带 Linux 的 SoC,而 ML-KWS-for-MCU 针对的是最“紧”的那一档:裸机或者 RTOS 环境、Flash 也许只有 256KB、RAM 只有几十 KB、主频在 100MHz 到 400MHz 之间,很多时候还要靠电池供电。
在这种约束下,你不能依赖任何框架自动帮你做算子融合、内存优化和能耗调度,每一点计算资源都必须精打细算。这恰好是这个项目最有价值的地方——它把“怎样在如此受限的资源下塞进一个神经网络”这件事完整地示范了一遍。
我举个例子你就理解这个约束有多硬核了。一个典型的 KWS 模型输入是 1 秒音频,16kHz 采样就是 16000 个采样点,经过 MFCC 特征提取后变成约 49 帧 x 13 维的矩阵。这个矩阵本身不大,但每一维都要经过 FFT、梅尔滤波、对数运算、DCT 变换等步骤。如果所有计算都走浮点,在 Cortex-M0 上几乎是不可能完成的任务,即使勉强跑起来,算力和功耗也很难看。所以这个项目里大量使用了定点化和查表法,把浮点运算换算成 Q7 或者 Q15 格式的整数运算,后面我们会详细拆。
1.3 为什么拿它做源码静态评测
市面上能“点开就能编译”的 MCU 端 AI 项目本来就不多,能像它一样把训练、转换、部署、测试全链路串起来的,更是凤毛麟角。
“静态评测”这个词听起来很高大上,其实我做的主要是这几件事:第一,把仓库拉下来后不看文档先撸代码,从 Makefile 和头文件入手,把工程结构先画出来;第二,把每个核心模块(MFCC、模型推理、输出后处理)的代码逐段过一遍,看它的实现方式、资源使用逻辑和潜在风险;第三,估算它在目标 MCU 上的内存和 Flash 占用;第四,在仿真器(FVP)和真实开发板上各跑了一遍,记录下编译、烧录、运行过程中碰到的坑。
做完之后我的感受是:这个项目设计上很克制,没有多余的抽象层,也没有华而不实的封装,但细节里的“坑”是真的多。比如训练脚本依赖老版本 TensorFlow,比如模型数据文件动辄几万行的数组,比如 CMSIS 版本不匹配会导致编译失败。这些坑构成了一个实质性的门槛,所以我把它们全部整理成可快速对照的实操清单。
2. 源码静态评测:工程架构与模块地图
2.1 顶层目录结构与各目录职责
我这个版本的仓库整体分成两大部分:一部分是 Python 写的训练和工具脚本,另一部分是 C/C++ 写的 MCU 端部署代码。目录命名在不同 commit 里会有小幅调整,但职责划分基本保持一致。
训练侧的核心东西我梳理下来主要有三块。第一块是模型定义脚本,里面提供了多种网络结构的选择,包括 DNN(全连接网络)、CNN(卷积网络)、DS-CNN(深度可分离卷积网络),你可以在训练参数里指定用哪一种。第二块是训练流程脚本,负责加载语音数据集、生成 MFCC 特征、训练模型并评估准确率。第三块是数据准备相关工具,包括生成测试音频的脚本和把训练好的模型权重转成 C 数组的脚本。
MCU 端部署代码就是真正跑在单片机上的那部分。它遵循一个很典型的嵌入式软件组织方式:
- 主入口文件:负责初始化外设,循环读取音频数据并触发处理流程;
- MFCC 特征提取模块:把 PCM 音频原始数据变成神经网络可用的输入特征;
- 模型结构文件:把网络每一层的算子拼接起来,形成一个前向推理流程;
- 权重数据文件:包含转换后的模型权重数组,通常非常大,动辄几万甚至几十万行;
- 推理引擎:底层算子调用,这里就是 CMSIS-NN 的接入点。
这种“训练侧和部署侧分离”的做法非常舒服。训练的时候你完全不用关心 MCU 上的内存约束,跑在 PC 上随便玩;部署侧代码只依赖模型输出的权重和结构描述,训练和部署通过一个统一的“模型描述文件”来衔接。这其实是我们做嵌入式 AI 工程时经常强调的“离线转换,在线部署”思想,这个项目从第一天起就是按这个思路设计的。
2.2 核心模块的数据流与代码组织
我在梳理代码时把整条数据流画成了一条直线:
麦克风采集 PCM 音频 -> 音频停帧检测 -> MFCC 特征提取 -> 神经网络推理 -> softmax 得分输出 -> 阈值判断 -> 唤醒结果
每个环节对应一两个独立文件,接口之间通过明确的数据结构传参。MFCC 模块接收一个 16000 样本的 int16 数组,输出定长的特征向量;模型模块接收特征向量,输出各类别的得分数组;后处理模块拿到得分数组后判断最大得分是否超过阈值。模块之间没有隐藏的全局状态,这对静态评测来说非常友好——你可以一个一个模块单独看,不用担心“蝴蝶效应”。
这个设计思路非常值得学。很多嵌入式 AI 工程到最后代码能跑,但别人改不动,因为所有逻辑都搅在一起。这个项目把每一块拆得很干净,换模型、换特征参数、换后端硬件都只动局部代码。我们后面在讲“如何移植到自研板”时,你会体会到这种解耦带来的巨大便利。
2.3 依赖关系与第三方组件
从静态依赖来看,MCU 端代码的核心依赖只有两个:CMSIS 和 CMSIS-NN。CMSIS 是 ARM 提供的 Cortex-M 芯片标准软件接口库,里面包含 DSP 数学库和底层硬件抽象;CMSIS-NN 是专门为嵌入式人工智能设计的神经网络算子库,针对 Cortex-M 系列的 SIMD 指令做了深度优化。
我实际在工程里点开看,MFCC 模块里大量调用了 CMSIS-DSP 库的实数 FFT 函数 arm_rfft_q15,模型推理部分用到了 CMSIS-NN 的 arm_convolve_HWC_q7_fast、arm_fully_connected_q7 这类算子。这部分依赖对工程影响很大:版本不对编译不过,但版本太旧性能又不行。后面我专门列了一个小标题讲版本搭配,这里先记住一点:不要在“最新版 CMSIS”上直接跑这个项目,否则大概率撞到 API 变更的墙。
3. 关键实现细节剖析
3.1 MFCC 特征提取的实现与局限
MFCC(梅尔频率倒谱系数)是语音识别里最经典的特征。它模拟人耳对不同频率声音的非线性感知,把原始波形转成一系列系数来表征语音内容。ML-KWS-for-MCU 的 MFCC 实现做得很务实,但也相当“抠”。
它采用了 Q15 定点格式。原始音频是 16 位整型 PCM,刚好可以直接拿来做定点运算,省去了浮点转定点的开销。分帧时默认用的是 30ms 窗长、20ms 帧移,16kHz 采样率下就是每帧 480 个采样点,步长 320 点。1 秒音频大约能分到 49 帧,每帧提取 13 维 MFCC 系数,最终输入到模型里的就是一个 49x13 的特征矩阵。
这里有个容易被忽视的细节:MFCC 的中间步骤里包含一个取对数的运算。在浮点域这就是一行代码,但在定点域,对数运算非常昂贵,如果直接用数学库的 log 函数,性能会崩掉。这个项目里的做法是用查表法——把可能的对数区间离散成一张查找表,以整数索引直接拿到近似对数值,牺牲一点精度换几倍的速度提升。这种“没有免费午餐,但你可以主动选择把精度换成速度”的思路,是整个嵌入式 AI 代码的常态。
说它“扣”是好事。在做源码评测的时候我看了代码里的中间 buffer,每个数组的大小都精打细算过,连一个多余的临时变量都不留。这在 PC 上有点强迫症,但在几十 KB RAM 的单片机上就很有必要了。不过这也是一个潜在局限:如果你想改参数,比如帧长改大或者滤波器个数增多,必须手动去核对每个 buffer 的上限是否够用,编译器和运行期都不会给你提示,只能靠人工保证。
3.2 模型推理:从浮点模型到定点数组
模型侧的细节是这个项目的重头戏。训练是在 PC 上以 32 位浮点精度完成的,但 MCU 端推理用的是 8 位整型权重,中间这一步训练后量化转换决定了整个部署的成败。
转换流程大概是这样:训练完成后,脚本先保存一份浮点模型 checkpoint,然后统计权重和中间激活值的数值范围,把浮点参数映射到 int8(q7)区间,得到一份量化后的权重文件,再用脚本生成 C 头文件或者 C 源文件,把几万个权重值一字排开。MCU 端代码里只是简单地把这些权重数组声明为 const 数组,然后按索引取出来参与定点卷积计算。
这种“训练后量化”方案的最大优势是实现简单、转换时间短,不需要重新训练。缺点也很明显,就是你没法在训练过程中自适应地补偿量化误差,极端情况下模型精度会掉得比较多。看到这个项目里用的是 8 位量化(有的版本还支持 16 位权重选项),我特意把默认小模型的参数数了一遍,大约是 41K 左右的参数量,对应到 Flash 里就是几十 KB 的权重数组。这个体量对一个 KWS 模型来说是合理的,分类精度在干净音频下能做到 90% 以上,在噪声环境下会下滑到 80% 左右,不过这本来就是一个参考实现,不是用来量产的产品。
3.3 CMSIS-NN 算子加速原理与调用链
如果只是把权重换成整数然后硬算,在 MCU 上跑一个卷积模型会慢到不可接受。CMSIS-NN 做的事就是针对这块专门做优化,它的核心优化手段有三个:16bit 乘法累加指令、单指令多数据(SIMD)加速、算子融合。
以最常调用的 arm_convolve_HWC_q7_fast 为例,它在卷积内层循环里会用 SMLAD 这类指令一次完成两个整型乘法和累加,配合 unroll 展开减少循环开销。像 DS-CNN 这种深度可分离卷积网络,本身计算量就比普通 CNN 低,加上 CMSIS-NN 的算子加速,在 Cortex-M55 上跑一帧推理的时间可以压缩到几十毫秒甚至更低,具体取决于主频和 Flash 执行还是 RAM 执行。
调用链上,模型脚本不会直接调用 CMSIS-NN,而是通过一层薄薄的封装函数来组织每一层的计算。比如卷积层调用 arm_convolve_HWC_q7_fast,全连接层调用 arm_fully_connected_q7,池化层调用 arm_pool_q7_HWC,算子内部的实现在 CMSIS-NN 库里。这一层封装的对于用户的价值太大了——你可以用 C 语言按顺序“描述”一个网络,然后底层的算子和定点优化完全交给 ARM 搞定,自己不需要写一行汇编。
4. 工程架构里的隐藏技术与设计哲学
4.1 资源受限下的内存布局策略
静态评测中一个很有意思的观察点,是这个项目怎么规划内存。MCU 上 RAM 是最金贵的资源,ML-KWS-for-MCU 的做法是“静态分配为主,栈上声明为辅”。
模型权重是 const 数组,放在 Flash 里;中间特征图 buffer 在文件作用域或者函数内栈上声明,按生命周期复用。整个推理过程中不会调用 malloc,不存在堆碎片问题,也不依赖操作系统,裸机环境直接能跑。这种做法带来的好处很直接:行为可预期,内存使用在编译期就能算清楚,对安全关键的应用场景尤其重要。
我在评测时特意统计了这种静态分配策略下的分配粒度。一个 49x13 的 MFCC 特征 buffer 约占 1274 字节,模型中间层激活值 buffer 会根据模型结构动态叠加,但都控制在几百到几千字节量级。整体下来,典型实例的 RAM 峰值在十几 KB 左右,Flash 占用在 100KB 左右,完全可以放进低成本 MCU。
4.2 模块解耦与配置化管理
从工程实现的角度,我觉得这个项目最值得夸奖的地方是“模块解耦”。MFCC 参数、模型参数、后处理阈值这些统统可以通过头文件里的宏来配置。换一个模型,你只需要替换权重数组文件和模型结构定义,其他模块完全不用动。
这种配置化管理方式在 PC 上很常见,但在嵌入式 AI 项目里却很少见,因为很多项目都是“论文代码到板上硬移植”,一个文件里既有算法又有外设操作,改哪儿都牵一发动全身。这个项目保持了这个平台独立性:真正和硬件耦合的部分被隔离在 main 入口和音频采集接口里,算法部分完全与硬件无关。这正是它能持续移植到不同开发板的基础。
4.3 从架构看 ARM 的生态野心
读懂代码之后,你再回头看看这个工程的定位,会明白它远不止是一个代码仓库。把整个链路摊开看,训练脚本捆绑 TensorFlow,依赖 CMSIS 和 CMSIS-NN,部署演示在 Corstone-300 这样的 Arm 虚拟硬件环境和 Arm 开发板上。这其实是 ARM 在“普及端侧 AI 开发范式”,意图让你养成“训练用 TensorFlow、部署走 Arm 平台”的习惯。
理解了这一层,就会明白为什么这个工程的代码风格那么像“标准示范。它不只是给你一个能用的方案,更重要的是给你一套方法论:如何做模型选择、如何做定点转换、如何评估资源占用、如何在受限设备上调试。这套方法论比代码本身更有迁移价值,你完全可以照着这个思路去做图像分类、异常检测等其他端侧 AI 场景。
5. 实操环节:把源码工程在 Arm 环境里跑起来
5.1 环境准备与工具链选择
代码看完,还是得把它跑起来才放心。我建议你先在 Arm 虚拟硬件(Arm Virtual Hardware / FVP)上做验证,不要一上来就烧板子,这样省掉很多硬件调试的痛苦。FVP 是 ARM 提供的仿真器,在 PC 上模拟一个完整的 Cortex-M 系统,包括异常、外设、中断,基本能还原真机行为。
工具链方面我试过 GCC 系和 Arm Compiler 6 系列,两者都能编译通过,但如果你后面想对比最优性能,Arm Compiler 6 配合 CMSIS 的版本兼容性更好。CMSIS 的版本非常关键,我拉工程的时候默认带的 CMSIS 版本是 C89 风格的头文件组织,如果你贸然升级到新版本,CMSIS-NN 的很多函数签名和宏定义会变,编译阶段就卡住了。
我的操作路径是:克隆代码到 Ubuntu(20.04)环境,安装 Arm GNU Toolchain(版本选 10.3 左右,太新的版本反而容易在标准库头文件上出幺蛾子),然后直接编译示例工程,生成 axf 映像文件,再通过 FVP 加载运行。整个流程大概二十分钟能完成,第一次跑通自己拉一套镜像还是很有成就感的。
5.2 训练与模型转换的完整流程
训练侧的复现比 MCU 端编译要折腾一些,因为脚本依赖 TensorFlow 1.x。如果你现在用的 TensorFlow 2.x,直接跑训练脚本大概率报错,需要创建一个独立 Python 3.7 + TensorFlow 1.15 的虚拟环境。我试过在 Python 3.9 上会直接失败,换到 3.7 环境后就顺了。
训练流程整体分为四步。第一步下载数据,推荐用 Speech Commands 数据集,项目里已经写好了数据集划分逻辑。第二步配置模型结构,你可以在脚本里指定 training_mode 为 dnn、cnn 或者 ds_cnn。第三步执行训练,在 GPU 上大概几十分钟到几小时不等,CPU 上会更久。第四步评估和导出,运行时会在输出目录里生成 checkpoint 和模型评估报告。
导出阶段最核心的是运行一个转换脚本,它会把浮点 checkpoint 转换为 C 数组,并生成对应的头文件。这个步骤遇到一个小坑:脚本对路径很敏感,不推荐在含空格的路径下操作。另外,C 文件生成后建议你打开看一眼头部注释,确认模型的输入尺寸和类别顺序跟 MCU 端代码里的配置一致。这一点太重要了,因为在 MCU 端 MCU 代码并不会校验权重文件和你当前代码里的模型结构是否匹配,结构对不上跑出来就是一堆乱码。
5.3 性能摸底与结果记录
编译通过之后,我分别在两种环境里做了性能验证:一个是 Corstone-300 FVP 仿真器,默认 Cortex-M55 配置,主频设置在 320MHz 左右;另一个是在手头的一块开发板上直接跑(芯片是 Cortex-M55 系列)。
先看速度。Cortex-M55 加上 ARMv8.1-M 的 MVE 指令支持,DS-CNN 小模型单次推理时间大约在 20ms~40ms 区间,即使考虑 MFCC 特征提取,单帧音频处理整体控制在几十毫秒内,这对 KWS 场景完全够用——因为你本来就是一秒一帧,处理速度远大于实时要求就可以。
再看资源。编译后的 Map 文件显示 Flash 占用确实非常可控,大概模型权重占了大头,整个工程 Flash 也就 150KB 左右,RAM 峰值在 15KB 上下。这个数字意味着,如果你做产品拿它做基准,完全能塞进一颗 M4 或者 M55 芯片,给业务代码留出大量余量。
6. 常见问题与我踩过的坑
6.1 编译阶段的高频问题
我把跑工程过程中和网上反馈较多的问题整理成一个速查表,方便你对照排查:
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 编译报 undefined reference to arm_convolve_HWC_q7_fast | CMSIS-NN 没有加入编译路径 | 检查 CMSIS 路径是否包含 NN 子目录,或在 Makefile 中显式加入 |
| 编译错误 missing: Compiler version 5 | Keil 环境未安装 Arm Compiler 5 兼容包 | 在工程配置里切换编译器版本或安装对应编译器插件 |
| CMSIS 结构体定义不一致报错 | 工程里的 CMSIS 版本与编译器的 CMSIS 版本冲突 | 统一使用仓库自带的 CMSIS,不要混用系统全局安装的 CMSIS |
| 模型输出全是全向量,识别率很低 | 权重数组和模型结构不匹配 | 核对模型输入维度和类别顺序,确认转换脚本是否使用了正确的 checkpoint |
| 仿真器运行卡在 MFCC 初始化 | FVP 版本与 CMSIS-DSP 函数不兼容 | 升级 FVP 或降低 CMSIS-DSP 版本,保证 arm_rfft 相关 API 匹配 |
这里最典型的就是 Compiler version 5 报错。ARM 的 Keil 在 2022 年后默认不再自带老编译器,需要单独装 Compatibility Pack,而 ML-KWS-for-MCU 这个仓库的早期示例工程的配置还是按 V5 编译器写的。如果你打开 Keil 工程直接编译,往往会看到 missing: Compiler version 5 这一行。解决办法就是在 Pack Installer 里补装 V5 编译器,或者把工程迁移到 ACS 6 模式下。
6.2 移植到自研板时的经验
很多朋友拿到这个工程,目标并不是在官方开发板上跑,而是想移植到自己的板子上。如果你也是这个情况,我建议你参考下面几条实操经验。
第一,音频采集接口是主要适配点。官方工程对音频输入的抽象其实很薄,它假设你已经拿到了完整一段 1 秒的 16kHz 16bit 单声道 raw PCM 数据。在你的板子上,你要做的就是实现一个回调函数,把麦克风的数据不断填充到大的数据缓冲里,填满后触发处理流程。
第二,动态内存不要用。项目自身不用 malloc 不代表你的驱动层不用。如果你在音频驱动里用了 malloc 且频繁申请释放,最好改成静态环形缓冲,否则跑上几小时后可能因为堆碎片出现随机卡死。
第三,步进模式可以帮大忙。很多 MCU 调试器像 ST-Link 或者 J-Link 支持周期计数(cycle counter),你可以把推理延迟和每一阶段的耗时打出来。如果发现某一层很慢,优先检查是否在 Flash 中执行,试试把热函数放 RAM 里运行;另外看看 CMSIS-NN 的编译优化等级是否拉满了,我用 -O3 比默认优化快了将近一倍。
第四,阈值不能照抄。不同麦克风、不同语音环境的背景噪声差异很大,示例里的唤醒阈值在安静环境表现良好,但在你手上可能一直误唤醒或者一直不唤醒。正确的做法是,先拿背景噪声反复跑一段,记录下不认识类别的得分分布,再做阈值设定。这是一个很有价值的优化空间,项目在代码里给了默认值,实际产品一定要自己标定。
结尾:分享一点我自己的体会
最后说点感性的东西。这几年做边缘 AI,我看了太多“PPT 架构”和“只在服务器上漂亮的模型”,真正能在几十 KB 内存里稳定运行的工程其实很少。ML-KWS-for-MCU 让我找回了一种“务实”的感觉:它不追求模型的精度上限,不堆砌华丽的算子图,而是把从算法到芯片这条路上每一个环节都扎扎实实地打通了。读这种源码,比看一万行“框架封装”学到的东西都多。
如果你打算照着这个工程做自己的产品或者学习样本,我的建议是不要只停留在“能编译能跑”的层面,而是从训练侧开始动手做一遍:换一批语音数据,调一下 MFCC 参数,改一个网络结构,再回到 MCU 端感受一下资源占用和精度的变化。这个循环走完一遍,你对嵌入式 AI 里“为什么是这套流程、为什么这样量化、为什么这样加速”的理解,就完全不一样了。
更多推荐
所有评论(0)