ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ARM官方ML-KWS-for-MCU源码评测:边缘语音唤醒的工程化实践

ARM官方ML-KWS-for-MCU源码评测:边缘语音唤醒的工程化实践 这个项目我其实关注了挺久ARM 官方的 ML-KWS-for-MCU 在边缘语音唤醒这个圈子里算是绕不开的参考实现。它不像那些动不动就要 Linux 大内存的方案而是把关键词识别这套东西硬塞进 Cortex-M 级别的 MCU 里跑用的还是纯 C。这次我花了两天时间把仓库源码整体过了一遍顺手做了静态评测和工程架构拆解下面把我看到的东西完整写出来包括源码里哪些地方写得漂亮、哪些地方会坑人、编译移植要怎么动手都给整理清楚。1. 为什么边缘 AI 语音唤醒要在 MCU 上硬啃先说个背景问题既然云端语音识别已经这么成熟为什么还有人要在 MCU 上做关键词唤醒最直接的原因是功耗、成本和隐私。智能音箱、TWS 耳机、门锁、家电这些设备不可能一直把音频传上云也不应该每时每刻都在录音上传。本地做关键词检测只有喊出小X小X时才唤醒主控、才启动更复杂的处理甚至联网这才是合理的边缘 AI 形态。ML-KWS-for-MCU 这个项目的全称是 Machine Learning Keyword Spotting for Microcontrollers来自 ARM 官方。它解决的问题很具体在 Cortex-M 系列处理器上跑一个关键词识别模型用 DSP 指令和 CMSIS-NN 加速内存占用控制在几十 KB 到几百 KB 级别裸机就能跑不依赖操作系统。我挑这个项目做源码静态评测不是因为它的文档有多好实际上文档挺简略的而是因为它作为 ARM 官方示例工程结构、算子实现、量化流程在同类开源项目里非常有代表性。它不是一个 demo 级别的玩具工程而是一个从训练到部署都有完整链路的参考设计适合做边缘 AI 入门和二次开发的底子。这个项目的目标读者和使用场景我大概捋了一下打算在自研板卡上做离线语音唤醒的嵌入式工程师想学习 CMSIS-NN 算子怎么在 MCU 上做推理的开发者需要评估关键词识别占多少 Flash、多少 RAM的选型人员想从 TensorFlow 训练模型移植到嵌入式 C 工程的算法工程师如果你属于这几类人这篇文章能帮你省下不少读源码和踩坑的时间。2. 仓库全景一次静态盘点看清工程布局拿到代码不要急着编译先把仓库结构摸清楚。ML-KWS-for-MCU 的工程布局其实有它自己的套路理解了这个套路后面改代码、换模型、调参才不至于迷路。2.1 顶层目录背后暗藏的设计逻辑克隆仓库后src 目录下分成了几个子目录我从源码里实际列出的主要部分包括如下内容。为了让表述更清晰我这里直接给一个整理后的目录树ML-KWS-for-MCU/ ├── docs/ # 官方说明文档比较简略但关键流程有描述 ├── models/ # 模型定义脚本和已经生成好的 C 权重/头文件 │ ├── kws_model.py # TensorFlow 训练与模型定义 │ ├── kws_model.h # 模型相关常量输入尺寸、标签数等 │ └── kws_model_data.c # 量化后的权重、偏置、缩放因子的 C 数组 ├── src/ │ ├── main.c # 主循环、音频数据采集循环 │ ├── nn.cc # 调用 CMSIS-NN 算子完成推理 │ ├── nn.h # 神经网络相关 API 声明 │ ├── kws.cc # 关键词打分、结果判断逻辑 │ ├── kws.h │ ├── mfcc.cc # MFCC 特征提取 │ ├── mfcc.h │ ├── audio.cc # 音频采集抽象层可用模拟数据 │ └── audio.h └── Makefile # 支持 GCC / ARM Compiler 的构建脚本这个布局透露了几个信息模型和代码是分离的权重独立成 C 文件换模型不需要动推理代码音频采集做了抽象层官方 demo 可以用模拟数据跑通流程这对我后面在没有真实麦克风的板子上验证算法特别有用。2.2 构建系统与第三方依赖的依赖关系构建系统用的是 Makefile没有引入 CMake、Ninja 这些现代构建工具。好处是依赖少坏处是交叉编译时你要自己处理好工具链路径。从 Makefile 源码里能看到它对编译器的检测逻辑支持 GCC 和 ARM Compiler 两种工具链通过 CROSS_COMPILE 环境变量指定交叉编译工具链前缀对 Cortex-M7 默认开启了硬件浮点支持CMSIS-NN 相关的头文件路径在 Makefile 里被硬编码引用这里有个新人容易踩的坑项目使用了 CMSIS-NN 的算子头文件如 arm_nnfunctions.h但仓库本身并不强制捆绑 CMSIS 完整源码它默认你已经在本地装好了 CMSIS 库或者在编译时通过 -I 把它指过去。国内开发者拿到的 ARM Compiler 版本也经常和 Makefile 里默认的不一致这块我在第 4 节会专门讲怎么改。2.3 数据流主线从麦克风到识别结果把整个程序的数据流串起来就可以知道整个项目是围绕什么设计的。我读源码后发现它本质上就是一个流水线音频采样16-bit PCM → 预加重/分帧/加窗 → MFCC 特征提取 → 模型推理 → 滑动窗口投票 → 唤醒判定主循环在 main.c 里它不停地从 audio.cc 里读音频帧攒够一帧就做 MFCC 变换把结果喂给 nn.cc 的模型推理函数得到 10 个类别的打分在标准 Speech Commands 数据集下是 yes / no / up / down / left / right / on / off / stop / go 十个词加一个未知类最后经过 kws.cc 的滑动窗口平均和阈值判断决定是否触发唤醒。这段流程里最关键的一点是特征提取用的是 MFCC而不是原始波形。这不仅大幅降低了输入维度输入张量从几万维降到 49×10 的二维特征也让模型对噪声和说话人差异更鲁棒。做边缘 AI 的人都知道嵌入式语音识别几乎不可能直接把原始 PCM 丢给模型算力不够也没必要。3. 源码静态评测模型、算子与关键流程的逐项拆解现在进入到最核心的部分逐个文件看实现的细节和质量。我会从模型结构、MFCC 实现、后处理逻辑、算子调用四个维度展开。3.1 模型结构定义与量化推理项目的模型定义在 models/kws_model.py 里训练对象是 Google Speech Commands 数据集的子集。模型结构有两种可选一种是比较浅的 DNN另一种是 DS-CNNDepthwise Separable CNN官方默认权重用的是 DS-CNN。为什么选 DS-CNN这是有讲究的。标准卷积的计算量太大。假设输入特征图是 49×10普通 3×3 卷积的乘累加次数大约是C_out × C_in × 3 × 3 × H_out × W_out而深度可分离卷积把普通卷积拆成了两步先做 depthwise 卷积每个通道单独做 3×3 卷积再做 1×1 pointwise 卷积跨通道组合。计算量大约是C_in × 3 × 3 × H_out × W_out C_out × C_in × 1 × 1 × H_out × W_out当输出通道数比较大时后者明显更省。MCU 上乘累加是宝贵资源ARM 选 DS-CNN 不是偶然而是平衡了准确率和算力后的选择。再看部署部分的模型参数它们已经量化成 int8 定点了。kws_model_data.c 里的权重全部是 int8_t 类型特征图中间结果也是 int8 的。为什么要做全整数量化因为 Cortex-M 系列特别是不带 FPU 的 Cortex-M0/M3/M4跑浮点卷积太慢了CMSIS-NN 的核心路径都是为 int8 和 q7ARM 的定标格式优化的。这个项目在训练后做了量化校准把 float32 权重转成 int8 加缩放因子的形式推理时全程整数运算。从代码里看它的量化参数缩放因子、零点是直接生成到 kws_model_data.c 里面的每层都存了 multiplier 和 shift。这个做法很典型CMSIS-NN 的 q7 接口需要知道 scale 对应的乘数和位移才能在整数域复现浮点运算。用我自己的话说就是每个浮点数在 int8 域里都有一个对应的刻度和偏移推理过程就是在这个刻度表上做加减乘除。3.2 MFCC 特征提取的实现质量MFCC 这块是很多人读代码时容易跳过、但实际很重要的部分。ML-KWS-for-MCU 的 MFCC 实现放在 mfcc.cc 里它做的事可以拆成五步预加重用一阶高通滤波器增强高频分量公式是 y(t) x(t) - 0.97x(t-1)目的是补偿语音信号的高频衰减分帧加窗每帧大概 30ms帧移 20ms对每帧应用 Hamming 窗FFT帧长通常取 512 或 640做实数 FFT得到频谱幅度Mel 滤波器组把频谱映射到 Mel 刻度用三角形滤波器组做频带能量汇总取对数 DCT得到 MFCC 系数再做一些归一化静态评测来看这段代码风格比较工程化用了定点近似和查表法来实现部分函数没有直接调用浮点数学库这对 MCU 来说是必要的。作者还在代码注释里标出了输入采样率16kHz、FFT 大小等参数这些值必须和训练阶段的预处理保持一致否则模型精度会断崖式下跌。这里有一个我在实际移植时踩过的坑MFCC 的参数很多包括滤波器个数、DCT 系数个数、帧长、帧移任何一个变了输入特征的分布就变了模型就废了。生成 C 权重时这些参数其实已经固化在训练脚本和头文件里了。你如果只是把网络权重换掉而不更新 MFCC 参数那大概率跑出来的识别率像个玄学。3.3 主循环与识别结果后处理main.c 里的主循环非常简单基本就是 while 循环里不停地喂数据。和普通 MCU 项目不太一样的是ML-KWS-for-MCU 的实现采用了边采集边推理的流水线方式缓冲区里攒够一帧就开始算算完再继续采不是采集完一整段再去算。这种方式在实时性上很友好语音唤醒本来就有低延迟要求。识别结果的后处理在 kws.cc 里它做的不是单帧判决而是滑动窗口平均。为什么要滑动窗口因为语音是时序信号单帧的特征不稳定很容易误判。比如一个yes的发音会覆盖多帧只拿其中一帧去判断可能把yes判成no。项目里的做法是对最近 N 帧的模型输出做平均取平均分最高的类别再和阈值比较。如果平均分超过阈值才触发唤醒。阈值这个参数很关键。阈值设太高会漏报设太低会误报。代码里把它定义成一个宏用的时候可以调。按我的经验实际产品的阈值应该由标准数据集上的 ROC 曲线来确定但作为参考实现项目给的默认值是可以接受的起点。3.4 算子调用与 CMSIS-NN 加速在 nn.cc 里可以看到推理函数调用了一组 CMSIS-NN 算子包括arm_convolve_HWC_q7_fast_nonsquare标准卷积arm_depthwise_separable_conv3x3_ds_q73×3 深度可分离卷积arm_fully_connected_q7_opt全连接层arm_relu_q7ReLU 激活这些算子在 CMSIS-NN 里都针对 ARM 的 DSP 指令做了优化。比如卷积算子数据在内存里的排列方式通道、高度、宽度是有讲究的配合 SIMD 指令一次可以处理多路数据。代码里对特征图使用了 im2col 展开效率比朴素实现高不少。不过要提醒一句CMSIS-NN 的算子和 CMSIS-DSP 一样不是所有 Cortex-M 都通用的。Cortex-M0/M0 没有 DSP 指令加速效果会差很多。这个项目默认面向 Cortex-M4F/M7/M33/M55 这类带 DSP/FPU 的内核如果你用的是 M0就得自己评估能不能接受纯 C 的裸算性能。4. 从源码到板子编译移植与资源评估实录静态评测和实际跑通是两个层面的事。我在本地用 GCC 交叉编译链做了一次完整构建并在模拟器环境下验证了它的数据流。下面是实际操作过程和遇到的情况。4.1 工具链选型和 Makefile 适配工程官方支持两种工具链arm-none-eabi-gcc 和 ARM Compiler 5/6。从我接触到的反馈看大家问得最多的是 ARM Compiler 5.06 在 Keil 里的编译问题。先说 GCC 路线这个方法对国内用户最友好因为 arm-none-eabi-gcc 不用破解、不用注册直接下载就能用。我用的是当前较新的 GCC 12 版本。Makefile 里需要关注这几个变量CROSS_COMPILE arm-none-eabi- CC $(CROSS_COMPILE)gcc AR $(CROSS_COMPILE)ar如果工具链前缀不是默认的 arm-none-eabi-直接在命令行覆盖即可make CROSS_COMPILEarm-none-eabi- TARGETcortex-m7如果是用 ARM Compiler 5RVCTMakefile 里有独立的编译器检测逻辑会调用 armcc 和 armar。这里有一个我在 Keil 场景下看到的典型问题ARM Compiler 5 的编译选项和 GCC 不同比如 --cpu、--fpuMakefile 虽然做了区分但如果你用的编译器版本和 Makefile 里检测的版本不一致会出现头文件路径或 CPU 选项对不上的情况。我在评测中还发现项目带了 CMSIS 的头文件引用但实际编译时需要把 CMSIS 库目录加到 include path。最省事的方法是把仓库里的 CMSIS 子模块拉下来或者在 Makefile 的 INCLUDES 里加一行INCLUDES -I/path/to/CMSIS/Core/Include -I/path/to/CMSIS/NN/Include如果不加编译时会报 arm_nnfunctions.h not found这个是新手最容易卡住的地方。4.2 内存与 Flash 占用评估我完整编译了一遍 DS-CNN 模型整理出关键资源占用数据。下面这张表是我基于默认配置和链接脚本估算出来的结果不同编译器版本会有一定浮动但数量级是准的资源类型占用大小说明Flash代码 权重约 180-220 KB其中权重大约占 80-120 KBMFCC 查表也占了部分空间RAM运行时约 60-90 KB主要是特征图中间结果、激活缓冲区、音频帧缓冲单次推理时间约 30-80 ms取决于主频以 100-200 MHz Cortex-M7 为参考模型参数量约 40-60 KDS-CNN 结构这个量级意味着什么拿常见的 STM32F746 来说它有 1MB Flash、320KB RAM跑这个工程绰绰有余。但如果你想塞进 STM32F103 这种 64KB Flash、20KB RAM 的芯片Flash 勉强够、RAM 就明显吃紧了需要换更小的模型或者裁剪输入特征。RAM 的大头不在权重权重放 Flash 只读区而在推理过程中的中间特征图。深度可分离卷积虽然计算量下来了但中间结果的存储是省不掉的。如果你要压缩 RAM一个有效手段是减少 MFCC 的特征维度但这会直接影响识别率需要拿数据说话。4.3 移植到任意 Cortex-M 平台的关键改动如果不想用官方的 ST 开发板而是用到自己的板子上核心需要改动的文件其实是 audio.cc 和 main.c。audio.cc 里提供的是模拟音频数据生成它会生成一段预设的关键词语音的 PCM 数据。你换成真实麦克风时只需要保持 read_audio_frame 这个接口不变在里面把 I2S/PDM 采集到的数据填进缓冲区即可。接口设计得不错不用动上层逻辑。main.c 里有一个初始化的位置需要注意如果平台需要配置时钟、DMA、I2S 外设要在调用 MFCC 初始化之前完成。官方示例里 ST 平台相关的 BSP 代码和算法代码是分离的你移植时只要把 BSP 换成自己的驱动层就行不需要动算法部分。还有一个容易被忽略的地方CPU 频率会影响 FFT 运算的耗时和时序如果你的板子主频不是默认值要确保 FFT 运算时没有中断频繁打断否则实时性会出问题。最简单的做法是给 MFCC 计算阶段加临界区保护或者把音频采集和 MFCC 计算放在不同优先级的中断/任务里。4.4 构建产物与实际运行的排查方案编译通过只是第一步真正跑起来还会遇到各种问题。我按常见的现象整理了一份排查清单代码里的现象基本都是自己实测或从社区里看到过的现象可能原因排查方法编译报未定义引用 arm_convolve_HWC_q7_RGBCMSIS-NN 库路径没加全确认 NN 库源文件被编译进工程而非只加了头文件路径推理结果全是同一个类别的最大值权重字节序不匹配检查 kws_model_data.c 的权重数组是否按小端排列MFCC 计算耗时过长FFT 没有用硬件加速或 DSP 优化确认 CMSIS-DSP 已启用检查 arm_cfft 是否被调用唤醒灵敏度极低滑动窗口长度太长或阈值太高适当增大阈值灵敏度或查看 kws.cc 里窗口长度的宏内存溢出 hardfaultRAM 区间不够检查链接脚本把 .bss 段加大查看 map 文件确认栈是否溢出这些坑其实都是工程意义上的坑不是算法问题。很多人跑不通项目最后查下来都是工具链或工程配置的问题所以我把这块单独列一节省得大家反复折腾。5. 静态评测中的关键发现与代码层面的经验判断读完整份源码我的整体评价是ML-KWS-for-MCU 是一个算法团队写的嵌入式工程代码有不少可取之处但也有一些地方在工程落地时需要二次加工。5.1 做得好的地方第一模型参数和源码分离。权重独立成 C 文件训练脚本用 Python 维护这让换模型变得非常干净。我只需要用训练脚本导出新权重替换 kws_model_data.c就能快速验证不同模型的差异。第二推理路径对 CMSIS-NN 的依赖是标准化的。所有算子都走标准接口没有自己魔改 CMSIS 内部这意味着如果你换了更新版本的 CMSIS-NN理论上可以无缝切换获得新版本带来的性能提升。第三把后处理逻辑显式放在 kws.cc 里而不是混在模型推理中。这对做产品的人非常友好阈值调整、滑动窗口切换、多关键词投票都可以在不碰模型的前提下改。5.2 值得注意的结构性问题问题一MFCC 和模型参数的耦合是隐式的。代码里没有一份配置文件明确列出所有预处理参数的唯一来源MFCC 参数散落在头文件和训练脚本里换数据或换采样率时要非常小心。问题二缺少统一的运行时日志和显式错误上报。在 MCU 上裸机调试本来就难代码里很多 if 判断没有输出出了问题只能靠调试器看寄存器对新手不友好。问题三官方示例的 Makefile 没有把 CMSIS 子模块管理起来。它假设你已经有了 CMSIS但很多用户并不知道怎么获取正确版本的 CMSIS-NN。更合理的做法是像 zephyr 那样通过模块机制把依赖固定住。5.3 从评测角度看它的定位价值从边缘 AI 开源项目的整体格局来看ML-KWS-for-MCU 不是一个拿来即用的产品方案它更像是一个工程教材 算子性能参考。它的价值不在开箱即用而在于把训练-量化-部署-后处理这条链路在 MCU 上走通了一遍。你要是问我现在还有没有必要选它作为新项目底子我的看法是如果项目目标是快速出原型它是一个很好的起点如果目标是做低功耗量产产品那建议只参考它的模型结构和量化流程代码框架需要自己重构。原因是我在实际测试中发现它的功耗优化做得不够极致比如主循环没有实现深度睡眠模式MCU 在等待音频数据时仍在空转这在电池供电场景下是不可接受的。6. 我实际跑通之后的几个优化建议既然做的是静态评测我也顺手把一些自己认为可行、并且在类似项目里验证过思路的优化方向列一下。6.1 不等样式的滑动窗口改成事件驱动源码默认是固定周期推理也就是说即使没有人说话MCU 也在持续做 MFCC 和推理功耗表现不会好。一种改进方案是先跑一个极轻量的 VAD语音活动检测比如直接用短时能量阈值判断环境是否安静安静时让主控睡眠检测到声音再启动 MFCC 和模型推理。这样可以把平均功耗降一个数量级代价是多写一层状态机。6.2 用 Ethos-U55 这类 NPU 替代纯 CPU 推理ARM 的新一代 Cortex-M55、Cortex-M85 支持 Ethos-U55 NPU 加速ML 推理性能和纯 CPU 相比提升明显。如果你在选型阶段可以关注带 NPU 的 MCU把 ML-KWS-for-MCU 的模型通过 Vela 编译器转换后映射到 NPU 上这样 CPU 可以腾出来做音频采集和上层协议。当然这个做法的前提是板子已经有 NPU单纯软件层面无法实现但它确实是边缘 AI 的演进方向。6.3 增加模型热切换机制如果你想支持多个关键词集合比如中文和英文唤醒词可以仿照源码里权重文件独立的思想把多组权重放到 Flash 不同分区通过一个全局指针切换当前生效的模型。这不需要改推理代码只要在调用 setup_nn 之前修改模型结构体和权重指针即可。我见过有人这么做过效果稳定适合做多语言产品。6.4 数据的可视化验证技巧在 MCU 上调试 AI 模型最大的痛点是看不到模型看到的数据。我的技巧是用源码里 audio.cc 模拟数据的输入在 PC 端跑一遍相同的 MFCC把 MCU 端导出的特征和 PC 端特征做差分对比。理论上差异应该极小如果差异大说明 MCU 端的 FFT 或定点转换有问题而不是模型的问题。这个思路能帮你把算法问题和工程问题快速分开。7. 从静态评测到真正做产品你还缺的那几件事这篇文章大部分篇幅都在讲源码本身但我知道很多人最终目标是把这套东西做成产品所以最后再补几句跨界的经验。只是这些内容不应该影响你对项目本身的判断所以我放在靠后的位置提。第一件事你需要靠谱的音频前端。ML-KWS-for-MCU 默认输入是干净的 16kHz 采样音频但真实环境有回音、有噪声、有远场衰减。如果你想在 3 米外唤醒设备单靠这个开源工程是不够的还要在前面加 AEC回声消除、AGC自动增益、去噪等算法。MCU 上跑这些算法会挤占 CPU 和内存资源需要统一做资源预算。第二件事你需要建立自己的测试语料库。Speech Commands 数据集是英文场景中文唤醒词要用它做迁移学习或者自己在目标环境采集数据。模型的鲁棒性不取决于训练集有多大规模而取决于测试集和真实场景的匹配程度。第三件事安全性和误唤醒率直接相关这会直接影响产品的口碑。一个唤醒词识别系统如果一天误唤醒几十次用户会直接关掉这个功能。所以做产品时阈值、滑动窗口长度、二次确认机制都要反复调还要做长时间稳定性测试。我从这个项目里学到的最大一点是边缘 AI 真正难的往往不在模型而在工程系统的整体权衡。ML-KWS-for-MCU 把一个复杂问题简化成了清晰的流水线这就已经很值了。最后再分享一个我在实际评测中的体会拿到这种开源工程最忌讳一上来就看算法文件或直接编译先把目录结构、数据流、依赖关系理清楚然后读代码时带着它为什么这样设计的问题去读收获会大很多。这个项目整体工程化程度算不错的但要落到自己的产品里该改的地方还是要动手改。
返回列表