一、踩坑现场
在 EC600M 上做"语音 + 摄像头 + 显示屏"三合一的项目时,我遇到了一连串诡异现象:
- 在子线程里调摄像头拍照 → 板卡直接 dump(串口断开,需重新连接)
- 在第三个线程里发 WebSocket 数据 → 同样 dump
- 主线程发音频帧、接收线程发协议应答,同时写同一个 socket → `OSError(-27648)`日志里什么都没有,没有异常栈、没有内存错误,就是板卡复位。
二、根因:冲突不在 Python 层,在 C 驱动层
QuecPython 的架构是:
你的 Python 代码
↓
MicroPython 虚拟机(C 实现)
↓
移远预编译的 C 驱动(audio / camera / SSL / GPIO)
↓
硬件(DMA / I2S / SPI)
所谓"外设冲突",冲突双方其实是C 驱动里的状态和硬件 DMA/中断。Python 只是最上面一层皮。它有三个特点让问题特别难缠:
1. 驱动不是线程安全的,而且驱动是预编译的,你加不了锁。
2. DMA 是异步的:`opus_write()` 返回 ≠ 播放完成,此时 DMA 仍在读你给的那块内存。
3. 阻塞型调用会释放 GIL:`socket.send()`、`request.post()` 会让两个线程真正同时进到驱动内部。
这里最容易踩的认知陷阱是:Python 有 GIL,所以多线程是安全的。
错。GIL 只保护"Python 线程 vs Python 线程"的字节码执行,它完全管不到 DMA 和中断。而真正让你 dump 的,恰恰是"Python 线程 vs 硬件 DMA"这一类——没有任何保护。
三、两个方案的对比
| | C 语言 | QuecPython |
|—|—|—|
| 冲突是否存在 | 有(更常见) | 有(同一套 C 驱动) |
| 防护手段 | mutex / 信号量 / 关中断 / `volatile` | 几乎没有(驱动是黑盒) |
| 失败表现 | HardFault,可看栈回溯 | 板卡 dump,无栈回溯,只能猜 |
C 用"**锁**"把并发变串行;QuecPython 加不了锁,只能退化成用"架构"把并发消掉。
四、我的解法:外设单一所有者 + 队列解耦
核心原则一句话:每个外设对象只允许一个线程碰。
```
主线程(唯一所有者)
├── 音频设备(PCM / Opus / PA)
├── 摄像头
└── LCD
▲
│ 有界队列 + 标志位
│
WS 接收线程(只做三件事:解析、入队、置标志)
```
代码骨架:
```python
# 接收线程:只入队,绝不碰设备对象
def _on_tool_call(self, name):
if name == "self.take_photo()":
self.\_tool_queue.append(name) # 入队
\# 不在这里碰摄像头!
# 主线程:空闲时消费队列
def tools_step(self):
while self.\_tool_queue:
name = self.\_tool_queue.pop(0)
self.\_exec_tool(name) # 这里才真正操作设备
```
三条边界规则:
1. 接收线程只做三件事:解析、入队、置标志。永远不持有设备对象。
2. 重量级操作回主循环:拍照、播放、渲染,都靠"入队 + 主循环消费"。
3. 发送必须加锁:多线程写同一个 socket,用锁串行化(这是纯 CPU 操作,锁有效)。