做 Cortex-M 上的语音关键词唤醒,绕不开 Arm 官方开源的 ML-KWS-for-MCU 仓库。我最初只是想找个不用自己从零搭训练流程的 KWS 参考实现,结果发现这个仓库的价值远不止一个“能跑通的 Demo”:它把 TensorFlow 训练、模型量化导出、MFCC 前端、CMSIS-NN 推理、M7 板级集成这一整条边缘 AI 链路完整地串了一遍。这篇博文不做纯使用教程,而是以源码静态评测为主,把工程架构的每一层分工、依赖关系和隐藏问题摊开来讲。适合想往自家板卡迁移、或者想用 C 语言视角理解完整 KWS 推理管线的 MCU 工程师。
1. ML-KWS-for-MCU 的定位与它解决的真实问题
1.1 为什么官方偏偏挑中了 KWS 这个场景
Arm 官方仓库里和 Cortex-M 相关的机器学习示例不止一个,但 ML-KWS-for-MCU 的知名度最高,原因是它选了一个对 MCU 非常“苛刻”且足够真实的任务:关键词唤醒。KWS 要持续监听麦克风,在低功耗场景下检测特定唤醒词,功耗预算往往只有毫瓦级别。这个场景天然不适合把音频传到云端处理,必须在设备本地把推理做掉。而它又和静态图像分类不同,音频是时序信号,必须在滑动窗口上反复抽取特征并执行推理,对 RAM 和延迟的要求更高。
ML-KWS-for-MCU 用一个模型完成了 12 类分类:10 个关键词(yes、no、up、down、left、right、on、off、stop、go)加上 silence 和 unknown。模型输入不是原始 PCM 波形,而是经过 MFCC 特征提取后的 2 维特征图。这个输入形态让模型复杂度大大降低,也为后续在 MCU 上做定点推理提供了条件。项目真正聪明的地方在于:它并不追求在 MCU 上训练模型,而是把训练全部放进 TensorFlow,再把训练好的权重和网络结构导出成纯 C 数组和算子序列,MCU 端只负责执行前向推理。
1.2 项目交付物不是“一份代码”,而是一整套工具链
静态评测的第一步是先厘清仓库里到底有什么。表面上看它是一个 STM32F746 Nucleo 工程,但把目录展开后会发现它包含四个层面的内容:
training/:TensorFlow 1.x 的模型训练与微调脚本,包含数据下载、预处理、DS-CNN 网络定义、训练循环和导出逻辑。models/:官方已经训练好的预训练模型,以及脚本运行后生成的 TFLite、C 数组文件存放位置。deployment/或根目录下的Source/、Inc/:STM32F746 的 C 工程源码,包括 main、音频采集、LCD 显示、MFCC、神经网络推理、CMSIS-NN 算子封装。Makefile+ 链接脚本 + 启动文件:完整的交叉编译构建体系,目标平台是 Cortex-M7,通过 arm-none-eabi-gcc 工具链构建。
也就是说,这个仓库把一条完整的模型生产流水线交付出来了,而不是像大多数 MCU AI Demo 那样只给一个编译好的工程。对于想复用自己的数据、换关键词、换板卡的开发者,真正值钱的反而是训练端脚本和模型导出逻辑,部署端的 C 代码只是验证这条链路可行性的落地点。
2. 仓库结构与构建体系:从 Makefile 看工程组装逻辑
2.1 目录划分与分层思想
把仓库克隆下来,顶层目录非常清晰,我用表格整理了一下核心目录和职责:
| 目录/文件 | 职责 | 静态评测印象 |
|---|---|---|
training/ | TensorFlow 训练脚本、数据下载与模型导出 | 代码组织良好,但依赖 TensorFlow 1.x,环境搭建成本高 |
models/ | 预训练模型、量化模型、C 数组输出 | 适合快速验证,避免重新训练 |
Source/ | STM32 工程 C 源码:main、神经网络、MFCC、音频驱动 | 核心逻辑集中在少量文件中,可读性尚可 |
Inc/ | 对应头文件,包含宏配置和接口声明 | 宏开关较多,需要逐个确认 |
Makefile | 工程构建入口 | 目标文件、CMSIS 路径、编译选项全部手写,透明但偏老旧 |
README.md | 使用说明 | 对该项目的硬件、工具链、运行步骤记录得比较清楚 |
从工程架构看,它保持了“训练端”和“推理端”的强隔离。推理端不包含任何 TensorFlow 运行时依赖,模型被转换为静态 C 数组后,直接编译进固件。这种做法直到今天仍然是 MCU 端部署 AI 模型的标准姿势,因为板上资源有限,不可能跑解释器或加载大型权重文件。
2.2 Makefile 的交叉编译链与 Cortex-M7 目标
打开根目录的 Makefile,能看到一个典型的手写嵌入式工程模板。核心信息有这么几个:
- 交叉编译器前缀:
arm-none-eabi-,默认使用arm-none-eabi-gcc。 - 芯片型号:
STM32F746xx,对应 Cortex-M7 内核,带双精度硬件 FPU。 - 启动文件和链接脚本:通过
STM32F746XX_FLASH.ld指定 Flash 和 RAM 布局。 - 宏定义:
ARM_MATH_CM7、__FPU_PRESENT=1、ARM_MATH_DSP等。这些宏主要影响 CMSIS-DSP 和 CMSIS-NN 的编译路径。
这里有一个值得注意的点:CMSIS-NN 的 q7 算子并不依赖 FPU,甚至在 Cortex-M3/M4 上也能跑。STM32F746 的 FPU 主要服务于 CMSIS-DSP 里的浮点 FFT、mfcc 等算法。如果在迁移到 Cortex-M0 之类无 FPU 的平台上,MFCC 部分可能需要换用定点实现,但神经网络推理算子仍然可以直接复用。项目把 MCU 底层硬件能力通过 CMSIS 头文件做了抽象,理论上换芯片时只需要重写启动文件、链接脚本和板级外设驱动,核心算法文件不用大幅改动。
2.3 第三方依赖:CMSIS 与 STM32 标准外设库
静态评测中还要盘点依赖。项目并不是完全自包含的,它依赖两块外部代码:
- STM32 标准外设库(Standard Peripheral Library),用于初始化时钟、GPIO、UART、I2S 等外设。
- CMSIS-DSP 和 CMSIS-NN,前者提供 FFT、矩阵运算等基础函数,后者提供针对 Arm Cortex-M 优化的神经网络算子。
这两部分在工程中往往以Drivers/、CMSIS/目录形式引入。迁移到新平台时,必须把这两个依赖同步换掉或移植。实际踩坑中,很多“编译不过”的问题并不是项目自身代码导致的,而是 CMSIS 头文件路径不对,或者标准外设库版本不同导致的中断函数命名冲突。因此做源代码静态评测时,我建议先把 Makefile 里的依赖树画进脑子里,再开始逐文件阅读。
3. 训练端源码拆解:从 TensorFlow 到 TFLite 再到 C 数组
3.1 数据流水线:Speech Commands 数据集与预处理
训练端用的是 Google 的 Speech Commands Dataset,这是一个专门面向 KWS 研究的开源音频数据集,包含约 10 万个 1 秒长的音频片段,覆盖多个人说的各种关键词。training/下的脚本会下载数据,并做一些基础的标签处理、训练/验证/测试集划分。
数据预处理的关键是把原始音频变成 MFCC 特征。整个流程可以概括为:
- 预加重:对信号做一阶高通滤波,补偿高频分量衰减。
- 分帧:用一个约 40ms 的窗口切分音频,窗口之间重叠 20ms。1 秒音频能产生约 49 帧。
- 加窗:对每帧乘一个汉明窗,减少频谱泄漏。
- 短时傅里叶变换(STFT):得到频谱幅值。
- Mel 滤波器组:把频域映射到 Mel 刻度,得到各频带能量。
- 对数压缩和 DCT:最终得到 MFCC 系数。
这里有个对 MCU 部署非常关键的参数:项目只保留了num_features=10个 MFCC 系数,而不是更常用的 26 或 40。输入张量形状为 49 帧 × 10 系数,单通道,约 490 个浮点输入。这个尺寸远小于图像分类模型的输入,是让 DS-CNN 能在 MCU 上跑起来的重要原因。做模型优化时,如果压缩帧数或 MFCC 系数,就能进一步降低计算量,但精度可能会掉,需要实测取舍。
3.2 DS-CNN 模型定义:深度可分离卷积为什么适合 MCU
训练脚本里定义的模型叫 DS-CNN,全称 Depthwise Separable Convolutional Neural Network,即深度可分离卷积神经网络。它和 MobileNet 的核心思想一致:把标准卷积拆成“深度卷积 + 逐点卷积”两步。
标准卷积的计算量是输出通道数 × 输入通道数 × 卷积核大小。而深度卷积只看单通道,计算量是输入通道数 × 卷积核大小;逐点卷积是 1×1 卷积,负责通道组合。两者理论计算量比标准卷积低 8~9 倍。对于 MCU 这种对乘加次数极其敏感的设备,这个优势是决定性的。在 ML-KWS-for-MCU 中,第一层是标准卷积,用于从 MFCC 特征图中提取低级模式,后续堆叠多个 Depthwise Separable 卷积块,最后接全连接层和 Softmax。整体参数量控制在几十万到 100 万级别,权重以 int8/fixed-point 形式存进 Flash,ROM 占用可以控制在几百 KB 以内。
从源码结构上看,KWS 网络定义使用了 TensorFlow 的tf.layers和tensorflow.contrib等 1.x API。这意味着如果今天重新安装 TensorFlow 2.x,脚本大概率跑不起来。官方仓库也停止了版本更新,所以做训练复现时要么使用旧版 TensorFlow 1.14 或 1.15 的虚拟环境,要么手动把 API 迁移到 Keras 等新框架。这里是静态评测中评价“工程完备性”时最需要提醒别人的一点。
3.3 模型导出与权重量化
训练完成后,脚本并不会把整个 TensorFlow 模型文件直接丢给 MCU。由于板上环境无法运行完整 TensorFlow 运行时,项目会做两步导出:
- 冻结图:把训练好的变量全部变成常量,保存成 GraphDef 格式的
.pb文件。 - 量化与 C 数组化:将浮点权重转换为定点/整型表示,然后生成一份
.c文件,里面是一个unsigned char或q7_t类型的数组,把这个数组连同网络结构一起编译进 STM32 固件。
这个“模型即数组”的做法没有额外的文件系统依赖,不需要从 Flash 动态加载权重,也不涉及格式解析,非常适合裸机 MCU。实际迁移时,如果你的模型结构变了,但还是在 MCU 上做前向推理,通常只需要修改网络定义和 C 数组大小,推理框架可以保持不动。这也是这个项目至今仍被当作 MCU AI 参考设计的原因:结构思想没有过时。
4. 部署端推理链路:MFCC → DS-CNN → CMSIS-NN 的串联方式
4.1 工程入口:main 函数的执行脉络
STM32 工程的入口是main.c。代码逻辑并不复杂,大致顺序是:
- 初始化系统时钟、GPIO、UART、I2S 等外设。
- 初始化 LCD 显示和音频编解码器。
- 循环里持续从麦克风采集 PCM 数据到一块缓冲。
- 在滑动窗口上调用 MFCC 函数,把原始音频转换为特征图。
- 调用神经网络推理函数,得到 12 类的得分。
- 根据得分判断当前是否检测到唤醒词,并把结果显示在 LCD 或串口上。
这个流程本身和绝大多数 KWS 产品一致,但源码静态评测时要注意:它默认从开发板上的数字麦克风通过 I2S 接口读音频,如果你的硬件用的是模拟麦克风加 Codec 芯片,采集部分需要重写。核心的 MFCC 和推理代码则与硬件解耦,可以原样保留。
4.2 MFCC 特征提取的实现与定点化处理
MFCC 在代码中通常以若干个模块存在,比如mfcc.cpp、fp_mfcc或者位于Source/下的预处理文件夹。实现细节包括:
- 使用 CMSIS-DSP 的
arm_rfft_fast_f32做浮点 FFT。 - Mel 滤波器组系数以查找表形式存放在 Flash。
- DCT 使用
arm_dct4_f32或手写矩阵乘。
到这里就出现一个很有意思的架构分层:MFCC 仍用浮点计算,而神经网络推理则使用 q7 定点计算。原因是 MFCC 阶段的数据范围变化大,尤其是 Mel 滤波器组和 log 运算,做定点化比较麻烦;而神经网络推理经过量化校准后,用 int8 可以很好地逼近浮点精度,且 CMSIS-NN 已经提供成熟的 q7 算子,直接使用就能获得稳定的性能收益。
从实时性角度估算,STM32F746 主频 216MHz,跑完一次 49×10 的 MFCC + DS-CNN 推理大概需要几十毫秒。这个延迟对唤醒词场景完全够用,因为音频滑动窗口是持续进行的,推理可以被视为每帧数据的周期性任务。如果你把模型输入压缩到 30 帧,推理时间会更短,但精度会受影响。
4.3 CMSIS-NN 算子替换与两层封装
神经网络推理部分直接调用 CMSIS-NN,核心是这些算子:
arm_convolve_HWC_q7_basic或arm_convolve_HWC_q7_fast:普通卷积。arm_depthwise_separable_conv_HWC_q7:深度可分离卷积。arm_fully_connected_q7:全连接层。arm_relu_q7、arm_softmax_q7:激活函数和输出归一化。
这些都是 Arm 官方免费提供的汇编级优化函数,针对 Cortex-M3/M4/M7 做了指令流水线优化。ML-KWS-for-MCU 的价值在于它用这些底层算子搭建了一个 KWS 推理框架,并封装成简单的调用接口。阅读代码时,你不需要关心每个算子内部的卷积滑窗细节,但需要理解每一层网络对应哪个算子函数、输入和输出 buffer 分别在哪块内存,以及如何避免 buffer 大小越界。
这里有一个经典问题:CMSIS-NN 的 q7 卷积函数对输入输出 buffer 对齐和大小有严格约束,如果你的网络层前后通道数不匹配,运行时会出现数组越界或错误计算。官方工程能跑通,是因为网络超参数与模型定义保持了一致。迁移到新模型时,最容易出问题的就是这一层,务必逐个打印中间层输出 shape 与 C 代码中#define的尺寸核对。
5. 静态评测:源码质量、可移植性与隐藏的坑
5.1 代码可读性与注释水平
从整体代码风格看,项目接近“实验室代码 + 官方示例”的混合体,注释不算非常丰富,但关键接口有说明。推理部分大量使用缩写,比如q7、HWC、dim等。对熟悉 CMSIS-NN 的人来说,阅读难度不大;对刚入门 MCU AI 的开发者,建议先看一下kws_net_params.h或ds_cnn_ops.h中每个层的大小定义,心情会平静很多。
| 评测维度 | 评分感受 | 说明 |
|---|---|---|
| 代码组织 | 中上 | 目录清晰,算法部分与硬件驱动分离 |
| 注释覆盖 | 中 | 核心函数有头注释,但缺少行内注释和原理说明 |
| 可读性 | 中 | 整体规范,部分宏命名较隐晦 |
| 模块解耦 | 较好 | MFCC、神经网络、板级驱动拆分明确 |
| 可测试性 | 较弱 | 依赖硬件和音频输入,难以做纯单元测试 |
5.2 精度和性能平衡的实测视角
静态评测不能只看代码,还要对照项目公布的精度数据和实际部署数据。官方 README 中给出的测试环境以 Speech Commands 测试集为标准,DS-CNN 模型的准确率在 90% 上下,对于 12 分类问题来说是不错的成绩。但真实环境中的准确率会打折扣,因为模型训练集里的背景噪声有限,麦克风硬件、信噪比、主人说话口音都会影响识别。把模型跑在 STM32F746 上,Flash 占用和 RAM 占用都在可接受范围内,推理时间也没有压力。
不过注意一点:该项目默认没有做“连续唤醒”策略优化,比如双阈值、状态机去抖。它只是每帧推理出得分,最终输出最大得分对应的类别。在实际产品里,通常还要加一个“唤醒确认”机制,也就是连续几帧都检测到同一关键词,才触发唤醒事件。否则误唤醒的概率会很高。这不是源码的缺陷,而是示例工程简化了产品逻辑。
5.3 仓库多年未更新带来的工具链兼容性问题
这是源码静态评测中最应该强调的坑。仓库最后一次大更新停留在 2019 年左右,使用的技术栈和今天的工具链存在明显代差:
- TensorFlow 1.x API 很难在 TensorFlow 2.x/3.x 环境直接运行。
- Python 依赖库版本老旧,新版本 pip 安装经常出现依赖冲突。
- CMSIS-NN 版本较旧,Arm 后来更新过算子接口,部分函数签名有变化。
- arm-none-eabi-gcc 的优化行为、编译告警随版本变化,老工程直接编译可能出现一堆 warning。
如果只是想在 Nucleo-746 上复现 Demo,用官方推荐的旧工具链版本最省事。如果想在自家板卡上跑,我建议把训练端和部署端分开处理:训练端尽量沿用旧环境训练并导出 C 数组,部署端把工程迁移到新 CMSIS 版本,或者干脆只复用模型和网络定义,推理代码用新 CMSIS-NN API 重写一层。
6. 迁移到自研板卡的关键步骤与避坑建议
6.1 最小替换方案:换 MCU 时改什么文件
假设你现在手里的板子是另一颗 Cortex-M4 或 Cortex-M7 芯片,想最短路径跑通这个 KWS 示例,我建议按下面的优先级处理:
- 先不动算法,确认芯片主频足够:建议至少 100MHz,否则 MFCC + 推理延迟会偏高。
- 替换启动文件、链接脚本、系统时钟配置。这是板级 BSP 部分,与算法无关。
- 适配音频采集。I2S + 数字麦克风是最接近原工程的方案;如果只有模拟麦克风,要加 ADC 采样和重采样,代码改动较大。
- 打开 CMSIS-NN 宏定义:
ARM_MATH_CM4或ARM_MATH_CM7,并确保 DSP 库编译进入工程。 - 用官方预生成的 C 数组模型先验证整条链路,确认推理输出和预期一致后,再自己重新训练模型并更新权重数组。
6.2 从静态评测看哪些代码可以直接复用
经过逐文件阅读,我把可复用程度分成三档:
| 模块 | 复用程度 | 说明 |
|---|---|---|
| MFCC 特征提取 | 高 | 与平台无关的算法,迁移时只需提供 FFT 函数 |
| CMSIS-NN 推理封装 | 高 | 直接调用 CMSIS-NN,换芯片也能用 |
| 网络模型 C 数组 | 中 | 想换关键词需要重新训练导出 |
| 音频采集与 LCD 显示 | 低 | 与具体板卡高度耦合,建议重写 |
| Makefile 与链接脚本 | 低 | 仅适用于 STM32F746 工程 |
这个项目最大的隐藏价值,是把“从训练到部署”的路径打通了。你可以先下载预训练模型跑通 Demo,然后把训练脚本里的数据处理部分保存下来,用于自己的关键词数据集;当你确认准确率以后,再重新量化导出权重。整个过程如果没人指点,容易卡在工具链版本上。我在实际尝试中,训练端用了虚拟环境固定 Python 3.6 + TensorFlow 1.14,部署端用旧的 arm-none-eabi-gcc 7.x,一次跑通的概率最大。
最后再分享一个细节:如果你想在工程上做裁剪,MFCC 的 Mel 滤波器查找表占据了相当一部分 Flash,把滤波器带宽从 8kHz 降到 4kHz 可以显著节省存储,但对唤醒词这类高频能量敏感的语音,效果需要实测。这个项目最大的价值不是给你一个“最优模型”,而是给你一个可以逐层修改、验证的基准平台。把它吃透以后,再去看其他 MCU 语音识别方案,心里就有一根非常清晰的坐标轴了。