读懂一台端侧语音 AI 的第一步:拆解小智 ESP32 的状态机
读懂一台端侧语音 AI 的第一步:拆解小智 ESP32 的状态机
上一篇我比了三块便宜开发板,最后选了 ESP32-S3。这篇是它的下文——我把开源语音助手**小智(xiaozhi-esp32)**刷进了一块三十多块的 ESP32-S3,跑通了"你好小智 → 对话"。
但跑通不是目的,看懂才是。这篇不讲怎么烧录(那是体力活),讲一件我认为更值的事:当你想读懂任何一台嵌入式设备的代码,应该从哪里下手。
我的答案是:状态机。
下面以小智的源码为例,带你拆它的状态机。代码都是真实的,文件路径我标在每段前面,你可以自己对着看。
一、为什么从状态机入手
一台语音助手开机后要干一长串事:连 WiFi、连服务器、激活、待命、听你说话、把音频传上云、播放回复……这些事不能乱来——没连上网就不能对话,正在播音时不该同时录音。
管住这套"什么时候能干什么"的东西,就是状态机。读懂了状态机,你就拿到了整个项目的骨架地图,之后每个模块挂在哪根骨头上,一目了然。反过来,一头扎进音频/网络的某个细节,很容易只见树木。
二、入口:极简到你会会心一笑
main/main.cc:
extern "C" void app_main(void) {
nvs_flash_init(); // 初始化存储(存 WiFi/配置)
auto& app = Application::GetInstance(); // 单例
app.Initialize(); // setup:建好各模块
app.Run(); // loop:主循环,永不返回
}
如果你玩过 Arduino 的 setup()/loop(),这就是同一套心智模型:初始化一次,然后进一个永不退出的主循环。所有复杂度都被收进了 Application 和它背后的状态机。
三、11 个状态 + 一张真实的状态图
main/device_state.h 定义了设备一生可能处的全部状态:
enum DeviceState {
kDeviceStateUnknown, kDeviceStateStarting, kDeviceStateWifiConfiguring,
kDeviceStateIdle, kDeviceStateConnecting, kDeviceStateListening,
kDeviceStateSpeaking, kDeviceStateUpgrading, kDeviceStateActivating,
kDeviceStateAudioTesting, kDeviceStateFatalError
};
光看枚举没用,关键是它们之间怎么跳。这藏在 main/device_state_machine.cc 的 IsValidTransition() 里——它本质上是一张手写的状态转移表。我把它反推成图:
unknown → starting
starting → wifi_configuring(没WiFi) | activating(已配网)
wifi_configuring ⇄ audio_testing, → activating
activating → idle(激活成功) | upgrading | wifi_configuring(出错回退)
idle → connecting | listening | speaking | activating | upgrading | wifi_configuring
connecting → listening(成功) | idle(失败)
listening ⇄ speaking ← 对话循环
listening / speaking → idle
fatal_error → (终态,出不去,只能重启)
把开发板用串口接上电脑看日志,你会亲眼看到这条主线在滚:
StateMachine: State: idle -> listening (你说话,它在听)
StateMachine: State: listening -> speaking (它在回答)
StateMachine: State: speaking -> listening (接着听)
StateMachine: State: listening -> idle (聊完,回待命)
源码里的状态图 = 日志里的真实现象。 当这两者在你脑子里对上的那一刻,你就不是在"看别人的代码",而是在"看懂一台设备怎么活着"。
四、三个值得细看的工程点
枚举和转移表是 What,下面是 Why——这才是读源码真正长功力的地方。
1. 状态为什么是 std::atomic
std::atomic<DeviceState> current_state_{kDeviceStateUnknown};
因为小智里多个任务在并发读写这个状态:音频任务、网络任务、主循环,各自跑在不同的 FreeRTOS 任务上,都要看它、改它。用 atomic 保证读写不被打断、不撕裂,而且无需加锁就线程安全。
一个普通 enum 在这里就是 data race,多核(ESP32-S3 是双核)下迟早出诡异 bug。
2. 观察者模式:别让模块去"轮询"状态
int AddStateChangeListener(StateCallback callback);
LED、屏幕、音频模块都不去反复查"现在啥状态了",而是注册一个回调,状态一变就被通知。这是典型的观察者模式,把"状态"和"对状态的反应"解耦——以后想加个"状态变化时亮个灯",加个 listener 就行,不用改状态机本身。
3. 一个防死锁的细节(最容易被略过,但最见功底)
NotifyStateChange() 通知所有监听者时,是这么写的:
void DeviceStateMachine::NotifyStateChange(DeviceState old_s, DeviceState new_s) {
std::vector<StateCallback> callbacks_copy;
{
std::lock_guard<std::mutex> lock(mutex_); // 锁内:只拷贝回调列表
for (const auto& [id, cb] : listeners_) callbacks_copy.push_back(cb);
}
for (const auto& cb : callbacks_copy) cb(old_s, new_s); // 锁外:执行回调
}
为什么先把回调拷出来、出了锁再执行,而不是直接在锁里挨个调用?
因为回调里完全可能反过来再操作状态机(比如某个回调里又触发一次状态转换、又去加/删监听者)。如果此时还握着 mutex_,就会自己等自己的锁 = 死锁。“锁内只做最小的事(拷贝),把耗时/不可控的回调放到锁外执行”,是并发编程里一条非常实用的纪律。
这种细节,调库的人看不到,读源码的人才吃得到。
五、状态机还是个调试利器
讲个我自己踩的真坑。设备刷好后,能唤醒、却一直不出声。我接上串口看日志,发现它卡在:
StateMachine: State: starting -> activating
然后就不动了。对照上面的状态图:activating 要往 idle 走才能进入正常对话,而它去 idle 的前提是激活成功(连上云服务器)。日志里紧跟着一串 Failed to connect to ... code=0x8006——连不上服务器,激活不了,自然困在 activating,自然没对话、没声音。
根因不在音频,在网络。 是状态机这张图,让我一眼定位了"卡在哪一步、缺哪个前提"。这就是读懂骨架的回报:出问题时你知道往哪看。
(顺带两个小陷阱:非法转换会被直接拒绝并打 Invalid state transition 警告,所以状态不会乱跳;fatal_error 是个死胡同,进去只能重启——这是故意的,宁可重启也不要带病运行。)
六、小结
- 读嵌入式设备代码,先找状态机,它是骨架。
- 把源码里的状态图和串口日志里的真实跳转对上,理解会立刻变扎实。
- 别只看 What(枚举、转移表),多问 Why:为什么 atomic、为什么观察者、为什么回调要出锁执行——这些才是把"会用"变成"会做"的地方。
下一篇,我顺着状态机往下挖:声音是怎么从麦克风一路流到云端、再流回喇叭的——小智的音频流水线。
我是做嵌入式 / 端侧落地的工程师,这个系列用真实工程师视角拆 AI 硬件的工程难点,不讲 PPT。代码均来自开源项目 xiaozhi-esp32,文件路径已标注,欢迎对着源码一起看。
更多推荐


所有评论(0)