读懂一台端侧语音 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.ccIsValidTransition() 里——它本质上是一张手写的状态转移表。我把它反推成图:

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,文件路径已标注,欢迎对着源码一起看。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐