ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ML-KWS-for-MCU源码解析:MCU上实现语音关键词识别

ML-KWS-for-MCU源码解析:MCU上实现语音关键词识别 ML-KWS-for-MCU 这个项目我在工作里反复翻过好几遍每次看都会有新发现。它是 ARM 官方在 GitHub 上开源的一个关键词识别Keyword SpottingKWS工程示例目标平台是 Cortex-M 系列单片机核心思路是在资源极端受限的 MCU 上跑通一套完整的语音唤醒链路。平时在社区里经常看到有人问“MCU 上怎么做语音识别”“CMSIS-NN 到底怎么用”“TFLite Micro 和这个项目有什么区别”这些问题在该项目源码里基本都能找到答案只是官方文档说得比较含蓄很多细节藏在代码里不逐行读根本看不出来。这几个月我借着做边缘音频方案的机会把 ML-KWS-for-MCU 的源码从头到尾做了一次静态评测又顺着工程目录把整个架构串了一遍期间踩了不少坑也理清了很多之前模糊的认知。这篇文章不打算做那种“看完等于没看”的宏观介绍而是直接进入源码层把数据流、算子实现、内存布局、工程组织这些核心环节逐一拆开同时结合部署时的实际体验把值得注意的地方和容易踩的坑一起写出来。适合正在做 MCU 端语音产品的工程师、打算入门边缘 AI 的嵌入式开发者以及想搞懂 ARM 官方代码风格的朋友参考。1. 项目定位为什么偏偏要在 MCU 上跑语音识别1.1 边缘 AI 与本地唤醒词的现实需求先聊一个基础问题为什么需要在单片机这种“小芯片”上做语音识别而不是直接把音频丢给云端答案很简单功耗、延迟、隐私三者缺一不可。拿智能家居的语音面板举例如果每一次唤醒都要经过“录音→上传→云端识别→返回结果”这条链路一来一回至少几百毫秒网络稍微抖动一下用户体验就是灾难级别的。更关键的是音频数据长期上传会带来隐私风险这在住宅、办公场景里非常敏感。所以行业内普遍采用两层结构本地先做关键词检测确认用户确实说了唤醒词再启动云端或本地大模型做后续的语义理解。本地检测这层对算力的要求不高但需要实时响应、功耗极低、成本可控正好是 Cortex-M 系列 MCU 的舒适区。ARM 官方做 ML-KWS-for-MCU 这个项目目的就是给出一个完整可参考的参考实现告诉开发者在几百 KB RAM、几 MB Flash 的芯片上关键词识别不仅能跑还能跑得挺稳。1.2 项目整体方案选型与架构概览ML-KWS-for-MCU 的整体链路可以概括为麦克风采集音频数据经过预处理环节得到 MFCC 特征再送入一个 CNN 模型做分类最终输出“是否命中关键词”的结论。整个流程全部在本地完成不带任何云端依赖。模型方面项目默认提供 21 个关键词 1 个静音/未知类别的分类模型用的是深度可分离卷积Depthwise Separable Convolution结构参数量控制在几十 KB 级别在 Cortex-M4 或 Cortex-M7 上单次推理的耗时大约在几十到几百毫秒之间。推理引擎没有自己造轮子而是直接基于 ARM 的 CMSIS-NN 算子库实现。CMSIS-NN 是 ARM 为 Cortex-M 系列定制的一套神经网络推理函数库针对 SIMD 指令、内存访问模式做了大量优化比直接用 CMSIS-DSP 或手写卷积要高效得多。从整个项目的文件布局看ARM 把代码分成了几个逻辑清晰的部分。顶层目录下有一个trained_models文件夹存放已经训练好的模型权重source文件夹里是全部源码gcc和mdk文件夹分别对应 GCC 和 Keil MDK 两套构建系统。这种结构对做嵌入式工程的人来说相当友好可以直接照着它的样子组织自己的项目。提示如果你之前只接触过在 PC 或手机端跑 TensorFlow/PyTorch第一次看这个项目可能会觉得“模型怎么这么小”。这是正常的MCU 上的 AI 模型和云端模型本来就是两个物种目标是在极低算力下完成有限任务而不是追求大而全。2. 源码静态评测从 SDK 解压到逐模块代码走读2.1 工程下载、目录结构与构建系统细节要评测源码第一步当然是拿到工程并搭建环境。项目在 GitHub 上的仓库名就是ML-KWS-for-MCUARM 官方长期维护除了 KWS 之外还有一批类似的 ML 示例工程组成了完整的 ARM 嵌入式机器学习参考库。下载后建议先不急着打开 IDE而是用文本方式把目录结构完整过一遍因为很多设计思想都藏在目录命名里。仓库的核心结构大概是这样的ML-KWS-for-MCU/ ├─ docs/ # 说明文档、迁移指南、性能数据 ├─ gcc/ # GCC 构建系统Makefile、链接脚本 ├─ mdk/ # Keil MDK 工程文件 ├─ model/ # 模型定义、训练脚本、权重转 C 数组的工具 ├─ source/ │ ├─ nn_func/ # 神经网络相关实现 │ ├─ preprocess/ # 音频预处理MFCC 等 │ ├─ kws/ # KWS 主逻辑 │ ├─ platform/ # 平台抽象层 │ ├─ data/ # 样本数据/测试数据 │ └─ main.c # 入口 ├─ trained_models/ # 预训练模型权重头文件 ├─ scripts/ # 辅助脚本权重转换、测试工具等 └─ README.md从构建系统看ARM 提供了两套方案GCC 和 Keil MDK。GCC 方案适合 Linux 环境下用命令行交叉编译配合arm-none-eabi-工具链使用MDK 方案适合 Windows 下跑 Keil打开mdk目录下的工程文件就能直接编译。两种方案生成的固件功能一致但链接脚本、启动文件、编译器优化选项各不相同如果你要做二次开发建议先二选一不要两边同步改容易混乱。我在评测时用了 GCC 工具链版本 9.3.1操作系统是 Ubuntu 20.04。如果手头是 ARM Compiler 5.06 这类老版本工具链也能编译通过但需要注意编译器优化级别对推理时间的影响。CMSIS-NN 在-O2下表现最好-O0或-Os下性能差异明显这是后话。2.2 音频前端MFCC 特征提取的完整实现解析语音识别里有个基本共识原始音频波形不适合直接送进神经网络因为维度太高、信息冗余太大。行业通用的做法是先做特征提取把一帧音频转成一个固定维度的特征向量再交给模型处理。ML-KWS-for-MCU 用的特征就是 MFCCMel-Frequency Cepstral Coefficients原理可以简单理解成模拟人耳对频率的非线性感知把声音从时域变换到频域再进行压缩和降维最终得到一组最能代表语音特征的系数。MFCC 的具体流程在项目源码中被拆成几个模块每一步都对应一个独立函数。首先是preprocessing阶段原始音频通常以 16kHz、16bit 采样单声道。代码里用环形缓冲区管理 PCM 数据每次喂入一帧帧长一般取 30ms~40ms帧移 20ms。这种“滑窗”方式保证相邻帧之间有重叠避免语音特征在边界处发生突变。代码实现里使用了 CMSIS-DSP 的arm_mult_f32、arm_fir_f32等函数前者做点乘后者做滤波效率比手写循环高一大截。然后是 FFT 阶段CMSIS-DSP 里封装的arm_cfft_f32是核心它基于混合基算法实现支持 16/32/64/128/256/512/1024/2048/4096 点变换。ML-KWS-for-MCU 默认使用 512 点 FFT对应 16kHz 采样率下大约 32ms 的窗口。FFT 输出是复数数组紧接着取模得到幅度谱。这里有个细节CMSIS-DSP 的 FFT 输出顺序不是教科书里的自然顺序而是按位逆序存储的所以代码里要配合arm_cmplx_mag_f32或手动重排。刚开始看源码的人容易忽略这一点直接用数组下标去取频点结果得到的频序全乱了。再往后就是 Mel 滤波器组。源码里预定义了一组三角滤波器频率范围通常覆盖 20Hz~4000Hz集中在人耳敏感区。这一步本质上是把一个高维的线性频谱压缩成低维的 Mel 频谱每个滤波器的输出等于该频带内能量的加权和。最后对 Mel 频谱取对数、做 DCT离散余弦变换得到的就是 MFCC 特征。项目里默认取前 10 维系数作为特征加上 Delta 和 Delta-Delta一阶差分和二阶差分组合成总共 30 维的特征向量。这个设计很经典既能捕获静态特征又能捕获动态变化信息。整个预处理链路的实现风格非常嵌入式没有动态内存分配所有中间变量都是静态数组所有运算都用 CMSIS-DSP 函数库完成在关键热点比如 FFT、矩阵运算处代码里大量使用了__SIMD32之类的 intrinsics利用 Cortex-M4/M7 的 SIMD 指令加速。这也是这个项目对普通开发者最有价值的地方——可以把它当作一份“在 MCU 上做数字信号处理”的高质量范本。2.3 推理引擎CMSIS-NN 算子的封装与卷积计算细节预处理完成后MFCC 特征向量被送入 CNN 模型进行分类这里就要说到 ML-KWS-for-MCU 的另一个核心CMSIS-NN 推理实现。源码里对神经网络的封装并不复杂nn_func目录下主要包含arm_convolve_HWC_q7_RGB.cpp、arm_convolve_HWC_q7_fast.cpp、arm_maxpool_q7_HWC.cpp、arm_fully_connected_q7_opt.cpp等一系列算子实现这些文件实际是 CMSIS-NN 官方库的函数项目直接复用并针对 KWS 模型做了具体实例化。你会发现整个推理过程没有一个“神经网络框架”的概念没有Model类也没有Session对象一切都靠函数调用和全局结构体完成这种风格对 MCU 来说完全是合理的——不需要动态图、不需要自动求导只需要一个前向传播函数就行。模型结构本身在源码里并不是直观的层列表而是通过权重数组和偏移数组来体现。权重存在trained_models/kws_model_weights.h这样的头文件里直接用const int8_t数组存储其中卷积核权重、全连接权重、BN批归一化参数等被依次排布。源码里有一系列#define宏定义如INPUT_SIZE、CONV1_IN_CH、CONV1_OUT_CH等来标明各层维度模型结构一目了然。实际推理时调用的核心函数是arm_convolve_HWC_q7_fast它实现了一个高度优化的 3x3 卷积层。这个函数内部先把输入做 im2col 展开再调用矩阵乘法完成卷积。CMSIS-NN 在这里使用了基于 DSP 指令的 16bit乘法加法和饱和累加指令权重和激活都量化为 int8/int16 定点格式。为什么不直接用浮点因为 Cortex-M4/M7 虽然有 FPU但只支持单精度浮点浮点计算在存储和耗时上都不如定点划算。代码里大量使用了定点数表示小数Q 格式这是嵌入式 DSP 领域的基本功。池化层和全连接层同样用 CMSIS-NN 实现。池化采用的是arm_maxpool_q7_HWC窗口大小一般为 2x2步长 2作用是把特征图尺寸减半、保留最显著特征。最后一层全连接之后接 softmax但源码里并没有真正计算 softmax 的指数和除法——在推理代码里输出默认是各类别的 logit逻辑值实际分类时只需取最大值对应的 index。如果需要输出概率值可以在后处理里自行实现但为了省资源和时间绝大多数 MCU 端 KWS 应用都直接跳过 softmax。注意如果你打算把这个项目移植到自己的板子上千万不要尝试把 CMSIS-NN 整体替换成自己的手写算子库除非你有充分的理由。CMSIS-NN 里面每个算子都针对 Cortex-M 的指令集做了精调手写版本几乎不可能在同样资源约束下达到相同性能。比如arm_convolve_HWC_q7_fast里面的 inner loop 大量使用了SMLAD、SMLALD、USAT这类 DSP 指令编译器通常不会自动生成这些组合只有手写优化才能达到这种程度。3. 工程架构全景从训练到部署的完整链路3.1 模型训练、权重导出与 C 数组转换流程源码静态评测只是一半工作另一半是理解它的工程架构。ML-KWS-for-MCU 不仅是一个推理工程还包含完整的模型训练和部署链路。训练部分在model/目录下ARM 使用了 TensorFlow 来训练模型。模型本身是一个深度可分离卷积网络结构上比标准 CNN 更轻量通过将标准卷积拆成“逐通道卷积 逐点卷积”两步大幅减少参数量和计算量。训练完成后权重被导出为浮点格式接着在scripts/目录下有一个量化和格式转换脚本负责把浮点权重映射为定点数并生成 C 头文件。量化过程并不是后训练量化Post Training Quantization而是在训练过程中模拟了量化误差这种方法叫 QATQuantization-Aware Training能有效减小定点化后的精度损失。权重导出为 C 数组的过程非常关键。转换脚本会先按卷积层顺序读取权重然后做 shape 调整将数据按照 CMSIS-NN 期望的内存布局重排。这也是很多人自己改模型时最容易出错的地方CMSIS-NN 的卷积权重存储格式是有讲究的比如arm_convolve_HWC_q7_fast期望权重按“输出通道、输入通道、核高、核宽”的顺序展开而且要满足 16bit 对齐条件。如果直接从 TensorFlow 导出原样数组跑出来的结果大概率是错的。KWS 模型输出的标签集合在labels.txt文件中定义默认支持以下指令包括“未知类别”unknownsilenceyesnoupdownleftrightonoffstopgo实际运行时每次滑动窗口对当前音频片段做一次推理得到每个类别的 score取最大值对应的标签作为当前结果的输出。如果最大 score 低于某个阈值则归类为未知。“语音唤醒词”这个场景就是专门监听yes、no这类短指令词一旦命中就触发后续动作。3.2 平台适配层与 main 控制流分析接着看 main 控制流。source/main.c是整个程序的入口它做的事情可以概括为三步初始化硬件外设、配置推理引擎参数、进入循环监听音频输入。初始化阶段最核心的是对音频接口的处理。项目默认适配了 STM32F746 Nucleo 开发板依靠片上 ADC 配合 DMA 采集麦克风信号。不同的板子或音频编解码芯片在这一层需要做针对性修改。平台相关的代码全部集中在source/platform/目录下通过#ifdef宏选择不同硬件实现。比如#ifdef STM32F746xx #include platform/stm32f746/audio.h #elif defined(STM32L4xx) #include platform/stm32l4/audio.h #endif这种抽象方式虽然比较原始但相当直观后续移植到新平台时只需要新增对应的platform/子目录并实现audio_init()、audio_record()这些接口即可。循环监听部分是一个典型的实时系统DMA 持续采集音频填充双缓冲当前缓冲区满时触发中断或标志位主循环检测到标志后取走数据送入预处理环节。整个流程里音频处理和神经网络推理是分时复用的并没有使用 RTOS全部靠裸机状态机完成。这种“裸机 中断”的组合在简单的唤醒词场景里完全够用也避免了 RTOS 带来的额外资源开销。我在评测时特意测试了不同平台的耗时占比。在一颗 216MHz 的 Cortex-M7 上一次完整的预处理加推理大约需要 80ms~150ms取决于模型配置其中 MFCC 特征提取约占 30% 的时间卷积层约占 60%全连接层和池化层合计不到 10%。整个 KWS 系统的实时性取决于“处理时间”和“音频帧时长”的比率只要单帧处理时间小于帧移间隔系统就能做到实时。3.3 代码风格与工程素养官方源码里值得学习的地方做源码评测时代码风格和工程素养也是我重点考察的维度因为 ARM 的官方示例很大程度上代表了嵌入式 C 代码的“最佳实践”参考。首先是数据类型的严格定义。源码里随处可见int8_t、q7_t、q15_t、uint32_t这类明确宽度类型几乎不用原生int、char表示信号数据。这样做的好处是代码在不同平台间移植时不会因为字长不同而发生行为偏差尤其在处理定点数时区分q7_t和int8_t直接关系到数值范围。其次是常量数组的const限定。所有权重、偏移、滤波器系数全部用const修饰存储在 Flash 而不是 RAM。对于 MCU 这种 RAM 资源极其有限的环境这是生死攸关的优化。我在移植到一颗只有 128KB RAM 的芯片时如果把权重误放到 RAM编译後直接溢出根本跑不起来。另一个值得学习的地方是注释风格。源码在核心数据结构处都有较详细的注释比如在nn_func里写明“输入数据维度是 CHW布局是 HWC”之类的说明。但也有一些函数尤其 CMSIS-NN 内部的算子注释偏少需要对照 ARM 官方文档来看。如果你想深入了解算子的算法细节建议直接读官方 Arm CMSIS-NN 的 GitHub 仓库那里有更详细的文档和单元测试用例。4. 实操部署从零上手 ML-KWS-for-MCU 的完整记录4.1 环境准备、编译与烧录验证过程如果你想把 ML-KWS-for-MCU 跑起来而不是仅仅停留在读代码层面下面这套流程是我实测可用的。第一步是准备硬件。项目官方支持的开发板是 STM32F746G-Discovery板载一个数字麦克风可以直接收音不需要外接音频模块。如果你手头是其他板子比如 STM32F4 系列或 NUCLEO 板也可以跑但需要改音频接口和 DMA 配置工作量不小。建议第一次接触的朋友直接用官方支持的板子等整个流程跑通后再做移植。第二步是准备工具链。我用的方案是 ARM GCC版本要求不低于 9.3.0。安装好之后在gcc目录下运行make如果编译成功会在gcc/build目录下生成kws.bin。烧录可以使用 ST-Link 配套的st-flash命令如下st-flash write build/kws.bin 0x08000000烧录完成后板子复位对着麦克风说“yes”串口波特率 115200 下会看到输出日志显示识别结果和 score 值。如果日志没有任何输出优先检查麦克风方向是不是对着你以及板载音频初始化有没有失败。第三步是调参。项目里最常用到的参数就是触发阈值。检测到关键词的条件不只是“当前帧分类为 yes”而是要求连续 N 帧都分类为 yes 或 score 超过某个阈值否则容易误唤醒。这个参数在source/kws/kws.h里定义为类似DETECTION_THRESHOLD的宏按我的经验阈值一般落在 0.6~0.8 之间太低容易被环境噪音误触发太高会导致漏唤醒。实测环境噪音较大时还会配合开启“静音检测”逻辑先把环境能量低的数据直接判为 silence不喂给模型能显著降低误唤醒率。4.2 内存布局、Flash 占用与能耗优化方向嵌入式 AI 应用内存和能耗是两个绕不开的话题。先说内存。ML-KWS-for-MCU 的模型参数量不大默认模型权重大约在 40KB~80KB 之间全部放在 Flash。运行时对 RAM 的需求主要在音频缓冲区和特征缓冲区双缓冲机制下一帧 30ms 的 16kHz 16bit 单声道音频大约占 960B两个缓冲约 2KBMFCC 特征缓冲区加上中间 FFT 计算用的复数缓冲区大约 4KB~6KB再加上 CMSIS-NN 算子内部的临时缓冲整体 RAM 占用在 20KB~30KB 这个量级。也就是说一颗 64KB RAM 的 MCU 完全可以跑起来128KB RAM 则会比较宽裕。能耗方面MCU 端 KWS 的优势在于超低功耗。可以在系统空闲时进入休眠模式由麦克风数据通过中断唤醒。由于推理本身只消耗几十毫秒占空比可以做得非常低。实测在一颗意法半导体 STM32L4 系列低功耗 MCU 上以 1% 的占空比进行周期性监听平均电流可以控制在几十微安级别。对电池供电产品来说这个数字很有吸引力。如果要进一步降耗可以考虑更换更小的 DNN 模型或降低监听频率但后者会直接影响唤醒延迟需要权衡。4.3 模型自定义改造训练自己的唤醒词官方模型默认支持的唤醒词是若干英文单词对中文场景不太友好。实际项目里很多时候我们需要“小爱同学”“你好小艺”这类自定义唤醒词。ML-KWS-for-MCU 因为整个工程架构是开放的所以完全可以训练自己的模型接入。改造路径大体是用 TensorFlow 训练一个同样的 CNN 模型输入维度保持与官方一致MFCC 特征格式、帧长、帧移一致输出类别改为你的唤醒词加未知类别。训练完成后量化权重并按照 CMSIS-NN 的内存布局导出 C 数组替换掉trained_models里的权重头文件然后重新编译。难度最大的环节是模型量化对齐官方训练脚本里对输入做了均值减除和缩放如果你的训练流程没有对齐这一步部署出来的模型效果会大打折扣。另一个值得注意的地方是 MFCC 参数必须保持训练与推理一致。如果训练时用的是 20ms 帧移而推理代码里配置成 30ms模型的输入分布完全错位识别率会急剧下降。这点在社区里被反复问过我在这里也重点提醒一下任何修改特征提取参数的尝试都必须连同训练环节一起做。5. 移植经验与常见问题排查实录5.1 典型部署问题速查表读代码和跑 demo 是两回事真实部署中会遇到很多奇奇怪怪的问题。我把这一路评测中遇到的典型问题整理成了速查表给即将入坑的朋友做个参考。问题现象常见原因解决方案编译报错undefined reference to arm_convolve...链接时没有包含 CMSIS-NN 源文件确保nn_func目录下的 .c 文件都被加入工程识别结果永远是unknown特征提取参数与模型训练参数不一致检查MFCC_NUM_FILTERS、FFT_SIZE等宏定义推理时间过长实时性不达标编译器优化级别太低或用了-O0改用-O2优化开启-mcpucortex-m7 -mfpufpv5-d16 -mfloat-abihard烧录后无输出日志串口波特率不匹配或板载麦克风初始化失败检查串口配置确认audio_init()返回值误唤醒率很高检测阈值设置过低调高DETECTION_THRESHOLD或增加连续命中机制RAM 溢出权重数组被定义在 RAM 而非 Flash检查权重数组是否加了const限定安静环境下仍频繁唤醒环境噪声被识别为特定关键词开启静音检测或训练专属噪声类别5.2 交叉编译中的路径与工具链问题小记在 Linux 下交叉编译时最容易遇到的坑是工具链路径和环境变量问题。ARM 官方在gcc目录下的 Makefile 里写死了CROSS_COMPILE变量默认值是arm-none-eabi-。如果你安装的工具链不在系统 PATH 里需要先导出环境变量export PATH$PATH:/opt/gcc-arm-none-eabi/bin另外一个容易踩的坑是链接脚本。项目默认链接脚本是基于 STM32F746 的 Flash 和 RAM 大小写的如果你换成了其他型号必须同步修改*.ld文件里的FLASH和RAM长度定义。否则固件烧录后可能直接 HardFault或者在运行时随机崩溃这类问题排查起来很费时。如果你用的是 Keil MDK 方案同样要注意编译器版本。官方的 Keil 工程基于 ARM Compiler 5.06 构建换成 AC6ARM Compiler 6.x之后一些底层的汇编文件和 intrinsics 可能因为编译器的语法差异而报错。建议严格按官方说明使用 AC5 或保持原版除非你有能力修复汇编兼容性问题。5.3 数据采集与验证别拿口头预期当识别率移植完成、能跑通之后很多人就直接宣布“成功了”。但真要把这个系统用于实际产品还有一道关要过数据的系统性验证。我见过不少团队在 demo 阶段效果不错一上真机就翻车核心原因就是验证时用的人声太标准、环境太安静没有覆盖真实工况。我的建议是准备至少三组测试音频一组是安静环境下不同说话人的正向样本说话人说唤醒词一组是近场/远场混合的负向样本包括环境噪音、电视声、非唤醒词还有一组是故意混淆的测试比如用相近发音的单词测试。每组样本量不少于 200 条统计得到识别率和误唤醒率再据此调整阈值策略。这一步没有任何捷径数据量不够时模型指标就是虚的。另外板载麦克风跟训练时使用的麦克风频响差异会影响识别率。如果实际产品用的麦克风和官方开发板的型号差别很大最好采集一批目标麦克风的音频做一次简单的数据增强或重新校准而不是直接沿用官方模型。6. 写在最后这个项目在我实际工作里扮演的角色做边缘 AI 这些年我的一个体会是市面上讲“AI 部署”的资料大多集中在手机和 Linux 网关这类“富资源”设备上真正认真讲 MCU 端落地的干货反而稀缺。ML-KWS-for-MCU 恰好把“模型量化—算子优化—工程移植—低功耗运行”这条完整链路摊开给你看虽然它的定位是“参考示例”但实际价值远远超过一个 demo更像是一本可以对照着做项目的嵌入式 AI 教科书。如果你只是想在项目中快速用上关键词识别直接照抄它的代码结构和优化思路能少走很多弯路如果你想深入理解 CMSIS-NN 或者定点神经网络在 MCU 上的工作原理这个项目更是一个绝佳入口。以后遇到有人说“单片机跑不了 AI”你可以拿这个工程回答他不是跑不了而是要看懂怎么跑。
返回列表