最近花费37大洋,买了个ESP32的开发板,简单体验了一下用Rust做嵌入式开发,但因为对这部分确实不熟,所以在尝试的过程中遇到不少问题,在此分享一下。不过,虽说是"嵌入式开发",但实际上基本上没有涉及到十分底层的地方,如果有想从纯软件开发转到嵌入式的同学,可以来参考一下这篇文章。
项目地址:https://github.com/xgpxg/esp32-voice-assistant-demo
硬件准备
因为要做一个可以对话的语音助手,那肯定需要连接网络的,因为目前来说,在几十块的板子上跑LLM还是不现实的。所以ESP32是比较合适的,它自带WiFi和蓝牙,并支持I2S,可以方便的处理音频输入和输出。
ESP32有不同的型号,主要区别如下(截取部分):

本次选择其中"最贵的"ESP32-S3。
然后语音输入需要一个麦克风和喇叭,看下来INM441和MAX98357A是大家用的比较多的,价格也便宜,就几块钱。
硬件只需要这3个就可以了。
这是接好线后的,有点乱。

开发环境准备
ESP32官方提供了Rust的SDK,叫做esp-svc-idf,也有比较详细的环境配置文档,可参考:https://github.com/esp-rs/esp-idf-template 。
我的环境为:Windows + WSL2
需要安装以下必要组件:
shellp
# 依赖包
sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0
# 工具包
cargo install cargo-generate
cargo install ldproxy
cargo install espup
cargo install espflash
# 安装esp
espup install
# 安装usbipd(用于将Windows的USB设备提供给WSL)
winget install usbipd
烧录程序:
直接 cargo run -r 即可,不用debug模式是因为debug打包出来的体积较大,传输也慢,不太合适。
在安装以及构建过程中,需要从github下载文件,会比较慢,或者超时,建议开启代理。
连接麦克风
麦克风用的是INM441,它长这样:

其中各个引脚的功能为:
- VDD:电源输入,3.3V。
- GND:接地。
- SD:数据输入。
- L/R:左声道/右声道。
- WS:字选择时钟,标识传输的数据是左声道还是右声道。
- SCK:也就是BCLK,用于数据位同步。
然后我们需要将它连接到ESP32-S3上。

其中L/R为低电平时为左声道,高电平时为右声道。默认接地为左声道即可。
SD、WS和SCK这三个,可自己选择IO口接,和代码中分配的保持一致即可。
连接喇叭
喇叭其实包含两部分:
- 解码器
MAX98357A,将数字信号转为模拟信号 - 一个小喇叭,用来振动发声。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
其中各个引脚的功能为:
- LRC:即左声道/右声道时钟,和麦克风的
SD类似。 - BCLK:用于数据位同步,和麦克风的SCK类似。
- DIN:数据输入。
- GAIN:暂未用到。
- SD:暂未用到。
- VDD:电源输入,2.5V - 5.5V。
- GND:接地。

编写代码
创建项目
esp-svc-idf 提供了一个基于cargo-generate的快速生成模板项目的方式,可以快速创建一个项目:
shell
cargo generate esp-rs/esp-idf-template cargo
创建完后,执行 cargo run -r 尝试将程序烧录到板子上,如果之前的环境配置没问题,就可以在控制台看到相应的日志输出。
在烧录前需要将板子的COM口连接到电脑,在WSL下,需要在Windows上执行以下操作:
shell
# 查看有哪些设备
usbipd list
# 输出
Connected:
BUSID VID:PID DEVICE STATE
1-3 05ac:12a8 Apple Mobile Device USB Composite Device Not shared
1-4 1a86:55d3 USB-Enhanced-SERIAL CH343 (COM3) Shared
1-6 1532:0084 Razer DeathAdder V2, USB 输入设备 Not shared
1-9 13d3:56a2 USB2.0 HD UVC WebCam Not shared
1-10 0b05:1866 USB 输入设备 Not shared
1-14 8087:0aaa 英特尔(R) 无线 Bluetooth(R) Not shared
# 找到板子对应的COM口,然后将其转移到WSL
usbipd bind --busid=1-4
usbipd attach --wsl --busid=1-4
# 在WSL查看连接的设备
ls /dev/ttyACM*
# 输出:/dev/ttyACM0
这样就能在WSL上向板子烧录程序了。
连接WiFi
ESP32带有WiFi模块,可以连接WiFi(STA模式),也可以作为热点提供一个WiFi出来(AP模式)。
为了能让板子连接到网络,需要提供一个Http Server以及配置页面来配置需要连接的WiFi账号和密码。
所以,整体思路如下:
- 启动一个HTTP服务,用于配置的读写。
- WiFi同时启用STA和AP,STA用于连接到已知网络,AP用于访问配置页面。
- 当网络信息已经配置时,后续启动自动连接。
esp-idf-svc 也提供了HTTP的服务端模块:EspHttpServer。
rust
// 创建HTTP服务
let mut server = EspHttpServer::new(&Configuration::default()).unwrap();
// 注册静态文件
server::register_static_files(&mut server).unwrap();
// 网络相关接口
server::network::register(&mut server, wifi.clone(), nvs.clone()).unwrap();
详细代码可参考仓库的
main.rs和server模块。
需要注意的时,EspHttpServer 无法实现动态路由,并且配置相关功能使用频率较低,所以前端尽量不要使用太重的框架,以此来减少可执行文件体积。如果是熟悉 Vue 的同学,可以使用这个轻量版的:https://github.com/vuejs/petite-vue ,适合在嵌入式中集成。
前端部分在
pages目录下
在WiFi配置完成后,可以将其写入到磁盘,也就是 NVS 中,在下次启动时,从 NVS 中读取SSID和密码后自动连接。
rust
fn init_wifi(wifi: &mut EspWifi<'static>, nvs: &EspDefaultNvs) -> anyhow::Result<()> {
let ssid = Config::get_wifi_ssid(nvs);
let password = Config::get_wifi_password(nvs);
// 混合模式,同时支持AP和STA
let cfg = wifi::Configuration::Mixed(
ClientConfiguration {
ssid: ssid.as_str().try_into().unwrap(),
password: password.as_str().try_into().unwrap(),
..Default::default()
},
AccessPointConfiguration {
ssid: SSID.try_into().unwrap(),
ssid_hidden: false,
auth_method: AuthMethod::WPA2Personal,
password: PASSWORD.try_into().unwrap(),
channel: CHANNEL,
..Default::default()
},
);
wifi.set_configuration(&cfg)?;
wifi.start()?;
// 尝试连接
if !ssid.is_empty() {
log::info!("Wifi连接中: {}", ssid);
wifi.connect()?;
}
Ok(())
}
麦克风输入以及扬声器输出
这两个都比较简单,都是标准的I2S协议,芯片已经封装好了,初始化后,调用 read 和 write 即可,例如:
rust
let std_config = StdConfig::new(
Config::default(),
StdClkConfig::from_sample_rate_hz(Self::SAMPLE_RATE),
StdSlotConfig::philips_slot_default(DataBitWidth::Bits16, SlotMode::Mono),
StdGpioConfig::new(false, false, false),
);
let mclk = AnyIOPin::none();
let mut i2s = I2sDriver::<I2sRx>::new_std_rx(i2s1, &std_config, bclk, sd, mclk, ws)?;
需要注意的是不要使用 StdConfig::philips() 的默认配置,它的默认配置的 SlotMode 是 Stereo,即双声道的,而模型侧要求的输入音频是单声道,所以如果不匹配会导致无法正确识别音频。
同样的,在喇叭输出时,采样率和声道也要和模型返回的匹配。
调用模型
完成一次语音对话,在非多模态的情况下,需要3个模型:
- 语音识别(STT)
- 语言模型(LLM)
- 文本转语言(TTS)
其中STT和TTS均使用Websocket,LLM使用HTTP。模型调用这一部分按照模型提供商给的API文档完成逻辑即可。
整体流程如下:
- SST 将麦克风的音频转为文字。
- 将文字发送给 LLM。
- TTS 将LLM 返回的文字转为音频。
- 输出到喇叭播放。
SDK默认未启用Websocket,需要在 sdkconfig.defaults 中开启:
text
CONFIG_HTTPD_WS_SUPPORT=y
还有需要注意的有两个地方:一是 TLS 配置问题,二是 Channel 阻塞问题。
HTTPS 和 WSS 的 TLS 配置
对于这两个协议,需要启用 TLS,如果未启用,则会收到以下错误:
text
E (9892) esp-tls-mbedtls: No server verification option set in esp_tls_cfg_t structure. Check esp_tls API reference
E (9892) esp-tls-mbedtls: Failed to set client configurations, returned [0x8017] (ESP_ERR_MBEDTLS_SSL_SETUP_FAILED)
E (9902) esp-tls: create_ssl_handle failed
E (9912) esp-tls: Failed to open new connection
在 esp-idf-svc 给的样例中,给出了样例配置,需要先获取服务器的证书链,然后配置为:
rust
const SERVER_ROOT_CERT: &[u8] = b"";
let config = EspWebSocketClientConfig {
server_cert: Some(X509::pem_until_nul(SERVER_ROOT_CERT)),
..Default::default()
};
这样需要手动导出服务器的证书,并固化在代码中,是不太好的。
另一种方式是使用如下配置:
rust
let mut config = EspWebSocketClientConfig::default();
config.crt_bundle_attach = Some(esp_idf_svc::sys::esp_crt_bundle_attach);
config.use_global_ca_store = true;
注意需要增加依赖:
toml
critical-section = { version = "1.1", features = ["std"], default-features = false }
标准库中mpsc::channel导致的线程阻塞问题
在处理Websoket事件时,如果有多个地方调用了rx.recv() 可能会导致线程阻塞,可以更换为embassy-sync下的Channel(类型Tokio的),或者使用 循环 + try_recv +休眠 的方式。例如:
rust
loop {
if let Ok(event) = rx.try_recv() {
match event {
WsEvent::ResultGenerated(text) => result.push_str(&text),
_ => {
break;
}
}
break;
}
Timer::after_millis(100).await;
}
虽然embassy-sync的Channel挺好用,但是,esp-idf-svc 提供的Websocket客户端的回调函数是同步的,无法利用embassy-sync的异步性。
启用片外RAM
由于ESP32-S3的内存只有 512KB,对于习惯了软件开发的同学来说,因为通常情况下可能不会注意这几KB的内存,所以可能导致内存不够用,不过,我们可以通过启用片外内存(PSRAM)来扩展。
ESP32-S3 有 8M 的 PSRAM 可用,对于语音对话来说是完全够用的。
PSRAM 默认是不开启的,需要在 sdkconfig.defaults 中启用:
text
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
需要注意的是,当启用了PSRAM后 33-37 这几个口就不能用了,否则会收到如下信息导致一直重启:
text
W (51) boot.esp32s3: PRO CPU has been reset by WDT.
W (56) boot.esp32s3: APP CPU has been reset by WDT.
具体原因为:
