嵌入式Linux——开发踩坑记:从 WebSocket 死锁到 PortAudio 音频丢包的硬核调试
为Linux开发板开发AIChat 客户端“踩坑”实录:从死锁到静音的排查之旅
这篇文档记录了我在开发 AIChat 客户端时遇到的四个Bug。为了方便理解,我让ai把枯燥的代码逻辑比作生活中的场景,并标注了背后的硬核技术点和具体的代码实现。
第一关:TTS End 死锁 —— “电话亭与送货员”的故事
🛠️ 涉及技术点:
- 并发编程:多线程 (
std::thread)、互斥锁 (std::mutex)、原子变量 (std::atomic)- 网络编程:WebSocket 协议 (
websocketpp)、异步回调 (Async Callback)
1. 场景设定
- WebSocket 连接锁 = 电话亭的钥匙
- 采集线程 = 送货员
- WebSocket 回调线程 = 老板
2. 案发现场(死锁是怎么发生的?)
老板(WS 回调线程)拿着钥匙进电话亭接电话,想等送货员(采集线程)下班。送货员拿着货想进电话亭发货,不给钥匙就不下班。两人互相等待,导致死锁。
3. 破局之道(异步停止)
我们规定:老板接电话时,绝对不能在电话亭里等送货员!
💡 形象比喻
老板接到“TTS 结束”电话,只在门口贴张条子:“送货员,一会儿下班啊”,然后立刻交出钥匙走人。主循环看到条子后,在外面等着送货员下班。
💻 代码实现
“条子”的定义 (Application.h):
// 这是一个原子变量,保证多线程读写的安全性(不需要额外的锁)
std::atomic<bool> pending_force_idle_{false};
“贴条子” (Application.cc):
void Application::HandleTtsStreamEnd() {
// 不直接调用 Stop(),因为那会死锁
// 而是设置原子标志位(贴条子)
pending_force_idle_ = true;
}
“看条子” (Application.cc):
void Application::MainLoop() {
while (running_) {
// 主循环每轮检查一次条子
if (pending_force_idle_) {
pending_force_idle_ = false; // 撕掉条子
StopListeningAudio(); // 安全地停止录音(此时没拿 WS 锁)
state_machine_->ChangeState(GetIdleState());
}
// ...
}
}
第二关:Listening 超时死锁 —— “叫不醒的装睡人”
🛠️ 涉及技术点:
- 音频开发:PortAudio 库、ALSA 驱动交互
- I/O 模型:阻塞 I/O (
Blocking I/O) vs 非阻塞轮询 (Non-blocking Polling)- 线程同步:
std::thread::join()
1. 场景设定
- Pa_ReadStream (阻塞读) = 进仓库等货
- Pa_AbortStream (终止流) = 老板的大喇叭
2. 案发现场
送货员进仓库(阻塞读)等货,但因为驱动 Bug,老板在外面喊破喉咙(Abort)他也听不见。老板在门口死等(join),导致死锁。
3. 破局之道(从“轮询”到“混合模式”的演进)
我们经历了两个阶段才彻底解决这个问题。
第一阶段:纯轮询 (Pure Polling) —— “礼貌的送货员”
我们规定:送货员绝对不能进仓库死等,必须先问问有没有货。
- 做法:送货员每 5 毫秒问一声看门大爷(
Pa_GetStreamReadAvailable):“有货吗?”- 有货 -> 进仓库搬。
- 没货 -> 玩手机。
- 结果:死锁解除了!(因为送货员一直在门口玩手机,老板一叫就能应)。
- 新问题:饿死(Starvation)。因为看门大爷(驱动)撒谎,一直说“没货”,送货员就一直玩手机,结果一点声音都没录到。
第二阶段:混合模式 (Hybrid Mode) —— “急眼的送货员”
我们规定:送货员不能太老实,如果等太久了,不管大爷说什么,都要冲进去看看。
- 做法:引入一个计数器。如果连续 20 次(约 100ms)大爷都说没货,送货员就强制冲进仓库搬一次。
- 结果:既避免了死锁(大部分时间在玩手机),又保证了能录到声音(定期强制搬货)。
💻 代码实现
关键变量定义 (AudioProcess.h):
PaStream *stream_; // PortAudio 流句柄(仓库)
std::atomic<bool> capturing_; // 录音控制标志(老板的命令)
“混合模式”逻辑 (AudioProcess.cc):
void AudioProcess::CaptureLoop() {
int poll_count = 0;
while (capturing_) {
// 1. 问大爷:有货吗?
long available = Pa_GetStreamReadAvailable(stream_);
// 2. 没货(或者货不够)
if (available < frame_samples_) {
poll_count++;
// 如果还没等到急眼(< 20次)
if (poll_count < 20) {
std::this_thread::sleep_for(std::chrono::milliseconds(5)); // 玩手机
continue;
}
// 否则:急眼了!强制往下走(去搬货)!
} else {
poll_count = 0; // 有货,计数器清零
}
// 3. 进仓库搬货(可能是因为有货,也可能是因为急眼了)
Pa_ReadStream(stream_, ...);
}
}
第三关:录音静音 —— “撒谎的侦察兵”与“爆仓”
🛠️ 涉及技术点:
- 缓冲区管理:缓冲区溢出 (
Buffer Overflow)、饿死 (Starvation)- API 调试:错误码分析 (
Error Handling)
1. 场景设定
- Pa_GetStreamReadAvailable = 看门大爷(侦察兵)
- paInputOverflowed = 爆仓警告单
2. 案发现场
大爷(驱动)老眼昏花,一直说“没货”,导致送货员一直玩手机。等我们强制送货员进去搬货时,发现货堆满了(Overflow),送货员吓得把货全扔了(continue),导致服务器收不到声音。
3. 破局之道(容忍溢出)
我们告诉送货员:“爆仓警告单”不是“报废单”!
💡 形象比喻
只要搬出来的货是好的,不管有没有收到“爆仓警告单”,都要发给服务器。
💻 代码实现
“无视警告单” (AudioProcess.cc):
// 强制去搬货后...
PaError err = Pa_ReadStream(stream_, ...);
if (err != paNoError) {
// 以前:看到错误直接扔货 (continue)
// 现在:如果是爆仓警告,仅仅打印个日志,货照样发!
if (err == paInputOverflowed) {
std::cerr << "爆仓了,但货还要发!" << std::endl;
// 不写 continue,让代码继续往下走
} else {
// 其他严重错误才扔货
continue;
}
}
// 继续编码、发送...
EncodeAndSend(buffer);
第四关:TTS Start 死锁
🛠️ 涉及技术点:
- 资源竞争:Resource Contention
- 串行化:Serialization
1. 场景设定
- 送货员 = 录音线程
- 搬运工 = 播放线程
- 仓库 = 声卡驱动
2. 案发现场(问题复现与排查)
现象:
当服务器下发 TTS 播放指令时,应用界面卡死,日志停在 [Application] StopListeningAudio begin。
排查过程:
- 加日志:我们在
AudioProcess::Stop中增加了细粒度日志。 - 发现异常:日志显示
Pa_AbortStream返回 0(成功),但随后的capture_thread_.join()永远不返回。 - 定位根因:与此同时,WebSocket 线程正在调用
audio_player_->BeginStream()。- 结论:“关录音”和“开播放”撞车了。底层声卡驱动无法处理这种并发操作(Stop Capture 与 Start Playback 同时进行),导致驱动内部死锁,进而卡住了应用层的
join。
- 结论:“关录音”和“开播放”撞车了。底层声卡驱动无法处理这种并发操作(Stop Capture 与 Start Playback 同时进行),导致驱动内部死锁,进而卡住了应用层的
3. 失败的尝试
我们曾试图绕过死锁:
- 方案:如果
join超过 200ms 没反应,就强制detach录音线程,让主程序继续运行。 - 结果:程序直接崩溃(Segmentation Fault)。
- 原因:被遗弃的录音线程并没有停止运行,它继续尝试访问已经处于异常状态的驱动资源,导致了非法的内存访问。这证明了在驱动层死锁时,应用层是无法安全“逃生”的。
4. 破局之道(串行化)
核心思想:设立主循环串行执行停止录音和开始播放,严格指挥:必须等送货员彻底离开仓库(录音完全停止),才允许搬运工进入(播放开始)。
代码实现 (Application.cc):
// 1. WS 线程(老板)只写条子申请,绝不触碰驱动
void Application::HandleTtsStreamStart() {
pending_stop_listening_ = true; // 申请关录音
pending_start_tts_stream_ = true; // 申请开播放
}
// 2. 主循环(交通警察)严格执法,串行执行
void Application::MainLoop() {
// 第一步:先清场
if (pending_stop_listening_) {
StopListeningAudio(); // 执行 Stop -> Join。此时播放器未启动,驱动无竞争,Stop 顺利完成。
}
// 第二步:再进场
if (pending_start_tts_stream_) {
audio_player_->BeginStream(); // 此时录音线程已退出,播放器独占驱动,Start 顺利完成。
}
}
总结
通过这四关,我们不仅修好了 Bug,更深刻理解了底层原理:
- 电话亭:用 异步标志 解决锁顺序倒置。
- 仓库:用 混合轮询 解决阻塞 I/O 死锁。
- 警告单:用 容忍策略 解决数据丢弃。
- 交通警察:用 串行化 解决资源竞争。
现在,AIChat 客户端终于拥有了一个健壮的“心脏”!
更多推荐



所有评论(0)