最近一直在折腾一个小硬件的语音助手方案,本来想直接用树莓派 + Linux 起步,后来看到有人用纯 C 在 ESP32 上做了一套叫 MimiClaw 的 AI 助手,不加 Linux、不跑 Node.js,整套逻辑全部用 C 语言实现,有点颠覆我对“AI 助手必须靠大系统”的刻板印象。如果你也和我一样,喜欢在便宜得不像话的 MCU 上挑战 AI 落地,这篇文章可以当一份完整参考:从硬件选型到任务划分,从对接大模型接口到避坑经验,都给捋一遍。
1. 为什么是 MimiClaw:几十块钱的板子也要能“听懂人话”
1.1 传统语音助手的“重量级玩法”
一提到做语音助手,多数人的第一反应是:树莓派 + Linux 系统 + Python 或 Node.js 脚本。这个组合确实成熟,什么库都有,麦克风驱动不用自己写,语音识别接个 SDK 就能跑,大模型接口更是几行代码的事。但代价也很明显:树莓派加外围模块起步就要两百块,再算上电源、散热、网线或 SD 卡,整套下来不便宜;Linux 系统启动要等,功耗也在 3~5W 左右,做一个固定在家里插电的盒子还行,想塞进一个小音箱外壳里就很尴尬。
MimiClaw 的思路是反着来的:不跑 Linux,也不跑 Node.js,直接把 AI 对话助手跑在一颗 ESP32 系列芯片上。你别觉得不可能,ESP32 虽然只有一块橡皮擦大小、主频 240MHz、内存几百 KB 到几 MB,但它天然带 Wi-Fi 和蓝牙,外接一个数字麦克风、一个小功放喇叭,就能组成一条完整的“语音采集 → 网络请求 → TTS 播放”闭环。MimiClaw 这个名字里那个 Claw,大概就是说它像一只小爪子,把设备端能做到的事情牢牢抓住,剩下搞不定的推理和语义理解,才放到云端去。
1.2 “无 Linux、无 Node.js”到底意味着什么
先说一点,很多人对“无 Linux”有误解,觉得 MCU 上跑 Linux 也不是不行。确实,ESP32 社区里也有人把 Linux 移植到某些开发板上,但那是极冷门的玩法,稳定性和外设支持都远不如原生的 ESP-IDF 环境。Linux 内核、根文件系统、进程调度、内存管理这一堆东西,跑起来至少需要几 MB 级别的 RAM,而 ESP32 经典款内置 SRAM 只有 520KB,加个伪静态 RAM 引脚也很吃紧,强行塞 Linux 只能换来漫长启动和多到数不清的兼容性问题。
再来说“无 Node.js”。很多 IoT 原型机喜欢用 Node.js 跑在树莓派上,因为 npm 生态里和 AI 相关的 SDK 太多了,写起来确实省事。可 JavaScript 引擎本身就是个内存大户。ESP32 上虽然也有嵌入式 JavaScript 运行时,但跑起来之后堆里几乎装不下别的模块,音频缓冲、TLS 连接、JSON 解析同时工作,分分钟就把内存吃光。MimiClaw 选择纯 C 实现,本质上是把每一块内存、每一个任务的调度责任都攥在自己手里,这是嵌入式系统做 AI 落地最靠谱的姿态。
1.3 MimiClaw 的最终形态
从软件架构上看,MimiClaw 是一个基于 ESP-IDF 构建的 C 语言工程,核心模块包括:Wi-Fi 连接管理、HTTP/HTTPS 客户端组件、cJSON 数据封装、音频采集与播放、离线唤醒词识别,以及对云端大模型 API 的调用逻辑。硬件部分也很简单,一块 ESP32 开发板、一颗 INMP441 数字麦克风、一块 MAX98357A I2S 功放模块加一个小喇叭,整个物料成本甚至可以控制在 50 元以内。整体跑起来以后,你喊一声“你好小咪”,设备唤醒,麦克风开始录音,录到停止后把音频丢给云端接口,拿到回复文本再合成语音从喇叭播出来。
这套东西解决的实际问题不是“我能做一个 Demo”,而是它验证了一个挺重要的事情:AI 助手不一定要靠昂贵硬件,只需要在网络请求、内存管理和音频流转上做到精细控制,一块 9 块 9 包邮的芯片也能拥有流畅的对话体验。
2. 硬件选型与音频链路构建
2.1 为什么要拿 ESP32-S3 做主控
MimiClaw 这类项目首选的主控其实是 ESP32-S3,原因有三:第一,S3 全系列几乎都带 PSRAM,这让它可以在内存里同时摆下音频缓冲区、TLS 握手缓冲区和解析后的 JSON 数据,不至于捉襟见肘;第二,S3 是双核 240MHz,双核并行可以让其中一个核跑音频采集,另一个核跑 Wi-Fi 和网络协议栈,降低互相拖累的概率;第三,S3 带可用于轻量级 AI 计算的向量指令扩展,本地跑唤醒词模型或命令词识别时,比老款 ESP32 更快更稳。
如果手上只有普通 ESP32 经典款,也不是完全不能跑,只是需要把音频缓冲区调小、把模型参数和栈空间重新抠一遍。这就是嵌入式开发的常态:功能流程都一样,细节决定能不能跑起来。下面是几款常见开发板的横向对比,方便你按手里的库存来决策:
| 芯片/开发板 | 内存能力 | 推荐理由 | 风险点 |
|---|---|---|---|
| ESP32 经典款 | 520KB SRAM + 可选 PSRAM | 便宜、存量多、资料全 | 内存紧张,需吞栈优化 |
| ESP32-S3 | 512KB SRAM + 最多 8MB PSRAM | 有 AI 指令扩展,适合语音处理 | 部分模组需自行确认 PSRAM 是否焊接 |
| ESP32-C3 | 400KB SRAM 左右 | 低功耗、外形小巧 | 单核性能偏弱,跑全套 voip 类流程吃力 |
| ESP8266 | 160KB 可用 RAM | 能连 Wi-Fi,价格最低 | 无 I2S 硬件能力,基本告别语音助手 |
从跑通 MimiClaw 全流程的角度,我更推荐带 PSRAM 的 ESP32-S3 开发板,比如 ESP32-S3-DevKitC-1 或者合宙的 S3 系列,都能直接用,不用折腾飞线。
2.2 数字麦克风与 I2S 功放的接线
音频链路是整个项目的地基,而 ESP32 和麦克风、功放之间全靠 I2S 总线沟通。I2S 和我们熟悉的 I2C 不是一回事,它是用来高保真传输数字音频的协议,至少有三根线:SCK 时钟线、WS 声道选择线、SD 数据线。MimiClaw 里常用的麦克风模组是 INMP441,功放模组是 MAX98357A,这两块都是贴片模组转出来的,引脚很少,非常适合飞线。
推荐接线如下:
| 信号 | INMP441 麦克风 | MAX98357A 功放 | ESP32-S3 示例引脚 |
|---|---|---|---|
| SCK/BLCK | SCK | BCLK | GPIO 5 |
| WS/LRC | WS | LRC | GPIO 4 |
| SD/数据 | SD | DIN | GPIO 6 |
| GND | GND | GND | GND |
| VDD | 3.3V | VIN | 3.3V(功放可接 5V) |
需要注意的是,我把麦克风和功放挂到了同一组 I2S 引脚上,这样只需一条 I2S 外设就能同时完成录音和放音。SPI 模式下会有两个不同设备抢总线的问题,但 I2S 的 TDM 特性允许你在同一总线上传输多个设备的数据,ESP-IDF 会通过 WS 通道切换和 slot 配置来区分收发方向。如果你分开接引脚,反而要多配置一条 I2S 总线、多占两个 GPIO 和一组 DMA 通道,没必要。
2.3 离线唤醒词:不联网也能叫醒它
很多语音助手产品的唤醒词是云端算的,这意味着每次喊唤醒词都得先走一次网络,体验极差。MimiClaw 的做法是把唤醒词识别放到本地,使用乐鑫提供的 ESP-SR 组件,离线跑一个类似“Hi,Mimi”的关键词模型。ESP-SR 在 ESP32-S3 上只占用几百 KB 内存,双核随便匀一点算力就能实时监听取证。唤醒之后,系统才进入“录音并发送到云端”状态,这样既省流量又省响应时间。
这里的重点是 VAD(语音活动检测):唤醒词响应的瞬间开始缓冲麦克风数据,同时检测语音尾部静音,超过大约 700~1200ms 的静音就视为一句话结束,然后把这段缓冲音频压缩或直接以 PCM 格式发送出去。纯 C 实现里,这套状态机并不复杂,但要注意音频采样率和位宽要始终一致,比如统一 16kHz、16bit、单声道,否则后续发给云端识别时容易出怪音或识别失败。
3. 软件架构:为什么选 ESP-IDF 而不是 Arduino
3.1 选型的第一性原因:内存控制力
做 ESP32 项目时,很多人会默认用 Arduino 框架,因为它简单、函数名熟、社区例程多。但 MimiClaw 这类对资源敏感的项目,我更推荐直接用 ESP-IDF。原因很直接:Arduino 框架为了让普通用户好用,隐藏了太多底层细节,比如任务栈是自动分配的、堆管理策略是默认的、各种组件之间可能互相初始化得并不干净。一旦你想把内存精确控制在 60% 以内,就会觉得处处隔了一层纱。
ESP-IDF 则保留了对 FreeRTOS 的全量控制。我可以明确指定每个任务的栈大小、优先级、运行核心编号,可以使用 xPortGetFreeHeapSize 实时观察堆余量,也可以精细控制 Wi-Fi 缓冲池、LwIP 协议栈缓冲和 TLS 内存段。MimiClaw 的整个会话流程不复杂,但每一步都在跟内存打交道,这种控制力直接决定项目能不能稳定跑过 48 小时。
3.2 任务怎么划分:双核调度是一门艺术
MimiClaw 运行期间,系统里至少有这些并发任务在忙:音频采集、唤醒词检测、网络事件循环、HTTP 流式接收、TTS 播放。不能让它们一锅粥抢 CPU,所以每个任务都要明确栈空间和优先级。下面是我在配置时常用的任务表,你可以根据自己固件改动再调整:
| 任务名称 | 栈大小 | 优先级 | 核心 | 作用 |
|---|---|---|---|---|
| audio_capture_task | 4096 | 6 | 核心0 | 从 I2S 读取麦克风 PCM 数据,放入环形缓冲 |
| wake_word_task | 4096 | 6 | 核心0 | 拉取缓冲数据喂给 ESP-SR,做唤醒词识别 |
| network_task | 6144 | 5 | 核心1 | Wi-Fi 事件处理、HTTP 长连接管理 |
| llm_request_task | 8192 | 4 | 核心1 | 构造 JSON 请求,解析 SSE 流式返回 |
| tts_play_task | 4096 | 5 | 核心0 | 从队列取音频数据,经 I2S 播放 |
这个划分的核心逻辑是:所有音频硬实时任务集中在核心0,网络和协议栈相关任务集中在核心1,尽量降低互相打扰。音频采集任务优先级最高,但它的优点是每次都只是快速把数据搬进环形缓冲,不会长时间霸占 CPU。网络任务优先级次之,负责响应 TLS 和 TCP 事件,防止 Wi-Fi 断流导致连接悬挂。LLM 请求任务栈给到 8KB,因为它要处理较大的 HTTP 响应头、JSON 文本和临时解析缓冲。
3.3 不用 Node.js,WebSocket 和 HTTP 客户端照样好写
有人会担心,不用 Node.js 的话,WebSocket 长连接是不是要从零写起?其实不必。ESP-IDF 官方提供了 esp_http_client 和 esp_websocket_client 组件,都是纯 C 实现,只要在 CMakeLists 里声明依赖即可。esp_websocket_client 内部已经处理好了帧解析、心跳 ping/pong 和重连逻辑,我们只需要用事件回调函数接收数据就行。
代码结构大致长这样:
#include "esp_websocket_client.h" esp_websocket_client_config_t ws_cfg = { .uri = "wss://你的模型服务/ws", .reconnect_timeout_ms = 5000, }; esp_websocket_client_handle_t ws = esp_websocket_client_init(&ws_cfg); esp_websocket_register_events(ws, WEBSOCKET_EVENT_DATA, on_ws_data, NULL); esp_websocket_client_start(ws);回调里做的事情无非是用 cJSON 解析收到的文本,然后判断是中间增量还是完整回复。整条链路没有任何 JavaScript 运行时参与,所有变量都在函数栈上和堆里,跑得又稳又省。这正是“纯 C 实现”的意义:不需要虚拟机,不需要垃圾回收,程序员自己管好生命周期,就很踏实。
4. 与大模型接口对接的临门一脚
4.1 对话请求的 JSON 组装
MimiClaw 端侧做不了大模型推理,它扮演的角色更像一个“语音代理”:麦克风采到的语音先转成文本,再拼成当前会话的上下文,发给远端大模型接口。当前很多厂商提供 OpenAI 兼容的对话补全接口,MimiClaw 走的是这个通用路子。在 C 中构造一个 JSON 请求也没有想象中痛苦,ESP-IDF 官方推荐用 cJSON 库,API 十分顺手:
cJSON *payload = cJSON_CreateObject(); cJSON_AddStringToObject(payload, "model", "qwen-turbo"); cJSON *messages = cJSON_AddArrayToObject(payload, "messages"); cJSON *msg = cJSON_CreateObject(); cJSON_AddStringToObject(msg, "role", "user"); cJSON_AddStringToObject(msg, "content", user_text); cJSON_AddItemToArray(messages, msg); char *json_str = cJSON_PrintUnformatted(payload); size_t len = strlen(json_str); esp_http_client_set_method(client, HTTP_METHOD_POST); esp_http_client_set_header(client, "Content-Type", "application/json"); esp_http_client_set_post_field(client, json_str, len); esp_err_t err = esp_http_client_perform(client);这里有个很关键的小细节:请求前要给 HTTP 客户端设置Authorization: Bearer <token>,但这个 token 千万别硬编码在固件里发布到 GitHub 仓库。开发阶段可以放到menuconfig的配置项里,甚至做成分区内的配置文件,上线前再改成动态读取,免得哪天不小心把仓库公开了把自己密钥泄露出去。
4.2 流式响应怎么边收边解
大模型接口默认用 SSE 流式返回,也就是返回体被切分成一段段以data:开头的内容。对 ESP32 这种小内存设备,流式接收比一次性接收整段 JSON 安全得多,因为你不必在堆里预先分配大块内存来装完整回复。你需要做的是在 ESP-IDF 的 HTTP 事件回调中,把每次收到的小段数据累积到缓冲区,检测到换行符后,就尝试用 cJSON 解析这一行 JSON 载荷。
SSE 的解析思路可以展开成一个小状态机:
- 收到
data: {...}\n\n时,把大括号内容截出来,解析choices[0].delta.content字段。 - 如果 delta 里有文本,输出到屏幕上或在语音场景下缓存进“待合成文本”缓冲区。
- 如果 data 是
[DONE],说明流结束,进入 TTS 阶段。
注意解析时顺手做一个长度保护,比如单条消息超过 512 字节就截断,防止串行数据破坏 JSON 解析。这个坑我踩过两次,第一次以为是大模型返回特殊字符,排查半天发现是 TCP 分片把两个事件揉成了一个包,后来在解析里严格按\n\n边界切分,老实多了。
4.3 让助手“说人话”:TTS 音频流播放
拿到最终回复文本后,下一步是把文本变成语音。MimiClaw 通常直接请求云端 TTS 服务,拿到音频数据就往 I2S 功放播出去。可千万别直接在 llm_request_task 里边收音频边播放,这样一旦 Wi-Fi 速度抖动,播放就会卡顿;更好的做法是设计一个音频队列:TTS 请求任务把收到的音频块塞进队列,tts_play_task 从队列另一端取数据并写 I2S。
队列深度一般设置 8~16 个块,每个块 2KB 左右,这样即使 Wi-Fi 不稳定把某个块多延误了几十毫秒,播放端也能靠队列里剩余数据撑过去。缓冲太少容易断音,太多又会让首字延迟变大,实际调优时我倾向于在起始阶段先预压两到三块音频再开始播放,做到听感连续的同时不会让人感觉反应慢半拍。
5. 避坑实录:从接线到上电的几处“鬼打墙”
5.1 I2S 引脚冲突:看起来没问题,其实已经打架
我第一次跑 MimiClaw,烧好固件后上电,麦克风始终采不到声音,功放偶尔还发出刺耳的咔嗒声。排查到最后发现是引脚冲突:我把麦克风 SCK 接到了 GPIO 5 没问题,但同时又把蜂鸣器接到了 GPIO 4,和 WS 撞了车。GPIO 4 被两个驱动占着,虽然编译时没报错,但运行后输出信号互相干扰,什么输出都乱了。
所以强烈建议,开始飞线前先画一张引脚分配表,把所有音频引脚、调试串口引脚、I2C 引脚和按键引脚全部列出来,再和开发板的 BOOT 引脚、Flash 引脚对照一遍。ESP32 里有些引脚(比如 GPIO 12)默认是 Strapping 引脚,上电瞬间对电平敏感,拿它做音频时钟很容易起摆异常。推荐直接用腿部标记大的纯音频引脚,例如 S3 的 GPIO 5、4、6,配合调试端口 GPIO 8/9,避开 GPIO 0、12、15。
5.2 Wi-Fi 调度导致唤醒立变“聋”
另一个让我头疼的问题是唤醒后第一次对话经常吞字。现象是:喊出唤醒词后马上说指令,但麦克风采集到的是大量空白或半截语音,云端只能听到几个词。原因不在麦克风,而在 Wi-Fi。Wi-Fi 驱动为了让协议栈保持连接,会周期性触发高优先级任务处理 Beacon 帧和 TCP 事件,如果这些网络中断恰好撞上麦克风 DMA 搬运事件,音频数据就可能丢一小段。
解决办法是在音频采集任务里提高对 DMA 有效性的监控,同时把 Wi-Fi 的任务优先级调整到略低于音频任务。另外可以配置 Wi-Fi 的 modem sleep 为 None,保证网络活动不会任意打断音频采样。牺牲一点功耗,换来语音不吞字,值。
5.3 跑一会就崩毁:内存溢出到底怎么定位
MimiClaw 跑起来后过了半小时自动重启,串口日志显示abort() was called at PC 0x...或者Task watchdog got triggered,这类问题多半和栈溢出或堆溢出有关。我第一次遇到时纯靠猜,后来老老实实开了 ESP-IDF 的CONFIG_COMPILER_STACK_CHECK,并在任务创建时改成动态分配,这样任务被杀后会打印当前任务名和栈高水位。排查结果显示是 llm_request_task 栈不够,SSE 解析时如果回复特别长,局部 char 数组就会穿越栈底,直接踩掉相邻任务的上下文。
我的建议是:一旦项目启动,先把每个任务的栈乘以 1.2 再发布,宁可多耗一点内存也不要半夜被 watchdog 唤醒。内存余量可以靠esp_get_free_heap_size()采集,把这个值放进诊断日志,观察不同场景下的最小值,再做针对性裁剪。
5.4 走有线网络时:LAN8720 以太网模块的接线要点
不是所有场景都适合 Wi-Fi。如果你想给 MimiClaw 接一个固定底座,彻底摆脱网络掉线和干扰问题,可以挂一颗 LAN8720 以太网模块。ESP32 的 EMAC 控制器支持 RMII 接口,接线核心是外面那颗 50MHz 参考时钟必须供给正确。常见踩坑有:LAN8720 的 nINT 引脚不要接被占用的 GPIO,CLK 引脚要选支持 RMII 时钟输出的 GPIO 0 或 GPIO 16,并且上电顺序上 LAN8720 要先于 ESP32 稳定。
如果只接机器人小车做移动端,就别折腾以太网了;但如果是智能音箱固定在家里,链路升级成网线确实能显著降低响应延迟。反正 MimiClaw 的 C 代码结构里,HTTP 客户端组件并不关心底层走 Wi-Fi 还是以太网,替换驱动以后上层逻辑一行不改。
6. 实测效果、性能数据与可扩展方向
6.1 跑起来之后的数据感受
我实际搭建的测试设备是 ESP32-S3-DevKitC-1 + INMP441 + MAX98357A + 8 欧姆 1 瓦小喇叭,电源使用普通 5V 充电宝。测试时 Wi-Fi 信号约 70dBm,距路由器大约 4 米。整个链路在大模型兼容接口上跑,测得的数据如下:
| 指标 | 实测值 | 备注 |
|---|---|---|
| 冷启动到完成 Wi-Fi 连接 | 约 2.8s | 不含配网等待 |
| 唤醒词被识别到录音开始 | 约 40~80ms | 本地 ESP-SR |
| 一句话结束到发送完毕 | 约 120~300ms | 取决于说话长度 |
| 远端返回首个文本增量 | 平均 1.2s | 与网络质量正相关 |
| 语音播放首字节延迟 | 约 450ms | TTS 预压两块缓冲 |
| 整机运行平均内存余量 | 约 45~60% | PSRAM 没爆表 |
| 整机功耗 | 约 1.8W | 播放和待机差异明显 |
这个水平虽然比不上手机上动辄几秒钟内完成的智能助手,但作为自制桌面语音终端,体感已经足够自然。尤其是唤醒词几乎零延迟、首字回来也比较快,整体交互节奏不会让你觉得在和一台迟钝的机器说话。
6.2 本地意图识别:少走一次网络
MimiClaw 最值得扩展的方向是本地意图识别。你可以把几个常用的本地技能(开灯、关灯、查温度、播放下一首)用 ESP-SR 的命令词识别跑在端侧,命中本地技能时根本不用访问云端,只有遇到复杂开放域对话才请求大模型。这样不仅缩短响应时间,而且断网时设备依然能执行基础控制逻辑。
结合 ESP32 的 GPIO 和驱动,这套逻辑无非是在识别到唤醒词后,把后续语音放本地关键词表里过一遍,再加上动作执行状态机。C 代码里写个 switch-case 比前端拿 JavaScript 做还结实,每命中一个命令,把动作回调指针挂上就行,以后想加新命令也只需在表格里加一行。
6.3 从单机助手到多设备协同
另一个很自然的延展方向,是让 MimiClaw 通过 MQTT 和家里其他 ESP32 节点通信。你现在做的 AI 助手可以只负责语音识别和语义理解,它之后把“开客厅灯”“调空调到 26 度”这类指令继续投递给灯光节点或空调控制器。整个控制链路还是纯 C,MQTT 有官方 esp-mqtt 组件,跟 HTTP 里边的连接逻辑类似,不需要引入额外运行时。
最后说句掏心窝的经验:这种小设备做 AI 助手,最容易犯的错误是一上来就把复杂模型往设备里塞,非要在本地跑 LLM 才算真本事。MimiClaw 给我的启发是用好云端和端侧的各自优势,端侧管好唤醒词、低延迟音频和稳定连接,云端负责重推理,搭配出来的体验又便宜又实用。如果你手上正好落灰着一块 ESP32-S3,花一个周末照着这个思路做个语音助手出来,比单纯刷个点灯 Demo 有意思多了。