一句话摘要:给 4G 模块写拨号代码时,
ATD<号码>;立刻返回OK,我以为接通成功了, 结果整条链路永远停在"拨号中"。真相是:OK只代表命令被接受,通话结果在几百毫秒后 以另一种形式的行异步到达 ------而我的读取循环在OK上就break 了。
一、先说现象
项目背景是一个 Twilio 兼容的本地电话网关(把云端 telephony 换成本地硬件), 目标设备是 SIM7600 4G 模块和 SIM800C 2G 模块。代码逻辑是这样的:
arduino
上层请求通话 → 解析 TwiML 拿到 Stream URL → ATD 拨号 → 建立 WebSocket → 双向送音频
第一次实机测试:WebSocket 建起来了,但没有声音。
按常理,这说明是音频通路的问题------采样率不对、编解码不对、或者 PCM 没送到。 于是我先去查音频。
查了一圈:采样率是 8kHz μ-law、base64 编码正确、WebSocket 双向都通。 越查越觉得音频没问题,那问题在哪?
换个思路,去看模块到底有没有真的接通。AT 口打印出来是这样:
sql
> ATD13800138000;
OK
<过了大概 400ms>
VOICE CALL: BEGIN
就这两行。
看到这行的时候我停住了。VOICE CALL: BEGIN 出现了,模块确实接通了 ------ 但这行是在 OK 之后才到的。
而我的代码长这样:
python
def _do() -> str:
ser.reset_input_buffer()
ser.write(f"{cmd}\r\n".encode())
lines = []
end = time.monotonic() + timeout
while time.monotonic() < end:
raw = ser.readline()
if not raw:
continue
line = raw.decode().strip()
if not line:
continue
lines.append(line)
if any(kw in line for kw in ("OK", "ERROR", "CONNECT", "BUSY",
"NO CARRIER", "NO ANSWER", "NO DIALTONE")):
break # ← 凶手在这里
return "\n".join(lines)
读到 OK → 加进结果 → break → 函数返回。 VOICE CALL: BEGIN 还在串口缓冲区里,永远没被读出来。
然后 dial() 拿到返回值后是这么判断的:
python
self._state = CallState.DIALING
await self._at_cmd(f"ATD{number};", timeout=_DIAL_TIMEOUT)
if await self._wait_for_connect(timeout=30):
... # 接通
_wait_for_connect 轮询 self._state,而 self._state 只有 URC 线程会改 。 URC 线程没读到那行 → state 永远是 DIALING → 30 秒后超时 → 判定拨号失败。
所以现象是:每次都拨不通,拨号命令明明发出去了,模块也确实接通了。
而且------没有任何报错。日志里只有一行超时,连个警告都没有。 这是最坑的地方:你不知道该查哪里,因为「命令发送成功」和「通话建立」在这套代码里 被当成了同一件事。
二、为什么它这么反直觉
问题出在 AT 命令的两层语义上:
| 层 | 由什么标识 | 时机 |
|---|---|---|
| 命令层 | 同一端口回显 OK / ERROR |
立即,同步 |
| 事件层 | VOICE CALL: BEGIN / NO CARRIER 等 URC |
异步,几百 ms 到几十秒后 |
我们平时把 ATD 当成一个"有返回值的函数"来用: 发出命令 → 等一个返回值 → 用这个返回值判断成功失败。
但 ATD 的返回值回答的是另一个问题:"你的命令我收到了吗?" 不是:"你的电话接通了吗?"
这两件事被 AT 规范刻意解耦了,因为真实的电话呼叫本来就是异步的------ 从摘机到对方接听可能要20 秒。任何同步的"发命令、等结果"模型在这里都会失配。
用过 Python
pyserial官方示例的人可能对readline()的行为有印象: 它是阻塞 的,没有数据就一直等。所以如果你在ATD之后只读一次, 大概率正好读到OK然后就走了。
三、修复一:给一个收报窗口
最直接的修法是:读到结束行之后不要立刻 break,再读一会儿。
python
_URC_DRAIN_S = 0.5 # 收到结束行后继续读这么久
# ...循环内
if any(kw in line for kw in ("OK", "ERROR", "CONNECT", ...)):
terminated_at = time.monotonic()
# 注意:不 break,落到下面的窗口逻辑
# 循环退出后
if terminated_at is not None and drain_s > 0:
deadline = terminated_at + drain_s
while time.monotonic() < deadline:
raw = self._at_serial.readline()
if not raw:
continue
line = raw.decode("ascii", errors="replace").strip()
if line and _looks_like_urc(line):
urc.append(line)
但这里立刻冒出第二个问题:怎么区分「命令回显」和「URC」?
如果不分,OK 之后的 VOICE CALL: BEGIN 会被当成命令响应的一部分返回, 或者反过来,URC 被当成结束标记让循环提前退出。
我最初的分类表是这么写的:
python
_URC_PREFIXES = (
"VOICE CALL:", "+CLIP", "RING", "NO CARRIER", "BUSY",
"RINGING", "CALL READY",
)
看起来挺合理。结果埋了个更深的坑。
四、修复二:BUSY 和 NO CARRIER 是双重身份
写完跑测试,BUSY 场景的表现是:
scss
dial() = False 耗时 31.10 秒
拨不通(对),但慢得离谱------它没有快速失败,而是走完了 30 秒超时。
我一开始以为是收报窗口的问题,去调 _URC_DRAIN_S。 调小到 0.05 秒,没用。
于是回去打印 _at_cmd 的实际返回值:
python
>>> resp = await b._at_cmd("ATD138;", timeout=45)
>>> resp
'' # ← 空字符串!
空字符串。 BUSY 不见了。
原因就是我上面那张表把 BUSY 放进了 URC 前缀。 而BUSY 同时也是呼叫结果码------它告诉调用方"这个电话打不通"。
结果:_at_cmd 认为"BUSY 是条 URC,收走", 把它从返回结果里剔除了;调用方拿到的空字符串里既没有 OK 也没有 BUSY, 只能继续往下走,最后超时。
dial() 里的快速失败判断本来是写好的:
python
if any(k in resp for k in ("BUSY", "NO ANSWER", "NO DIALTONE")):
return False # ← 这段是对的
但它永远拿不到 BUSY 这个字符串。
修法是调整分类原则:结果码必须回给调用方,不能当通知吞掉。
python
_URC_PREFIXES = (
"VOICE CALL:", "+CLIP", "RING", "RINGING", "CALL READY",
)
# 注意:NO CARRIER / BUSY / NO ANSWER / NO DIALTONE 移出这张表
它们的含义是"这次呼叫的结论",属于返回值,不属于通知。
改完的实测对比:
| 场景 | 修复前 | 修复后 |
|---|---|---|
| 正常接通 | 30s 超时失败 | 1.01s 接通 |
| 忙线 | 31.10s 超时 | 0.50s 快速失败 |
五、修复三:单口模块根本不该起 URC 线程
改完上面这些,SIM7600(双口模块)跑通了。但 SIM800C 是单口模块------ AT 口和音频口是同一个 COM口。
代码里原本有这么一段保护:
python
def _start_urc_monitor(self) -> None:
if self._urc_running.is_set():
return
if self._audio_port == self._at_port:
return # 单口就不起线程
...
这个判断本身是对的,但它带来一个后果:单口模块没有任何东西在读URC。
所以单口模块上,VOICE CALL: BEGIN 只可能在 _at_cmd 读到它的时候被处理。 如果窗口逻辑没覆盖住,就彻底丢了。
修法是让 _at_cmd 成为 URC 的唯一处理点:
python
self._pending_urc: list[str] = [] # URC 暂存
python
# _at_cmd 内部,摘出 URC 后
self._pending_urc.extend(urc)
for line in urc:
self._handle_urc(line)
_wait_for_connect 在等待过程中顺便复查暂存区:
python
async def _wait_for_connect(self, timeout: float = 30) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self._state == CallState.ACTIVE:
return True
if self._state == CallState.IDLE:
return False
with self._serial_lock:
pending, self._pending_urc = self._pending_urc, []
for line in pending:
self._handle_urc(line)
if self._state == CallState.ACTIVE:
return True
await asyncio.sleep(0.3)
return False
这样单口和双口走同一套逻辑,不需要分两条路径。
六、修复四:pyserial 不是线程安全的
双口模块上AT 口和音频口是分开的,于是起了一个 URC 线程专门读 AT 口。 它和 _at_cmd 里的读取操作共享同一个 pyserial 对象。
pyserial 的 readline() 内部没有锁,两个线程同时调用会互相抢缓冲区里的字节。
症状是:偶发的「ATD 无响应」------同一份代码,十次里有一次不回。 这种问题最难查,因为你没法稳定复现。
唯一可靠的解法是不要试图去修它,而是从一开始就不让并发发生:
python
self._serial_lock = threading.RLock()
python
def _do() -> str:
with self._serial_lock: # 整段读写都在锁内
self._at_serial.reset_input_buffer()
self._at_serial.write(...)
...读循环...
URC 线程的读取也要持同一把锁:
python
def _urc_loop(self) -> None:
while self._urc_running.is_set():
try:
with self._serial_lock:
if self._at_serial.in_waiting > 0:
line = self._at_serial.readline().decode(...).strip()
else:
line = ""
...
教训 :并发 bug 的正确处理方式通常不是"加个判断避免竞态", 而是让共享状态根本不被并发访问。锁是最后手段, 能用"单线程 + 消息传递"解决的,优先用后者。
七、顺带发现:另外两个"假成功"
修完上面这些,我在通读代码时又发现两个同类问题------ 都是失败被上报成成功:
hangup() 无论成败都 return True
python
async def hangup(self) -> bool:
try:
await self._at_cmd("ATH")
self._state = CallState.IDLE
return True# ← 异常分支里也是 True
except Exception:
logger.exception(...)
self._state = CallState.IDLE
return True # ← 这里
如果 ATH 返回 ERROR(模块拒绝挂断),桥接层仍然认为已挂断。 线路实际还占着,于是下一次拨号拨不通。
更迷惑的是:挂断失败和"下一次拨号失败"之间隔了一个功能点, 日志里没有任何关联线索。修法就是按响应区分:
python
resp = await self._at_cmd("ATH")
ok = any(k in resp for k in ("OK", "NO CARRIER"))
if not ok:
logger.warning("ATH unexpected response: %s", resp)
self._state = CallState.IDLE
return ok
注意 NO CARRIER 也算成功------它表示线路已被对端释放,这是正常的挂断完成信号。
_enable_pcm() 失败也不管
python
async def _enable_pcm(self) -> None:
resp = await self._at_cmd("AT+CPCMREG=1")
if "OK" in resp:
logger.debug(...)
else:
logger.warning(...) # ← 只记日志,不告诉调用方
调用方照样把状态置成 ACTIVE 并返回成功。
结果是"接通但完全没声音" ------ 对端听着是接通状态,但听不到任何音频。 这比"响铃失败"难查得多,因为从呼叫记录上看这是一通成功的通话。
改成返回 bool,失败就挂断:
python
async def _enable_pcm(self) -> bool:
resp = await self._at_cmd("AT+CPCMREG=1")
if "OK" in resp:
return True
logger.warning("PCM enable failed: %s", resp)
return False
python
# 调用方
self._state = CallState.ACTIVE
if not await self._enable_pcm():
# 没有音频通路的通话必须挂断,而不是假装接通
self._state = CallState.IDLE
await self._at_cmd("ATH")
return False
八、附带:时间戳不该跟着"拿到了几帧"走
媒体桥接还有个独立问题。上行循环原本是这样:
python
timestamp_ms = 0
while True:
pcm = await backend.read_audio(160)
if not pcm:
await asyncio.sleep(0.01)
continue # ← 时间戳、序号都不动
timestamp_ms += 20
self._seq_num += 1
await ws.send_audio(payload, self._seq_num, self._chunk_num, timestamp_ms)
timestamp 是累积值,但只在拿到音频时才递增。 用户静音的那两秒里,时间戳停走了。
而 WebSocket Media Streams 的 timestamp 语义是媒体在流中的绝对位置, 下游用它对齐播放节奏。时间戳停走 = 下游认为断流了。
改法是让时钟跟随墙钟,和是否取到音频解耦:
python
started = time.monotonic()
while True:
pcm = await backend.read_audio(160)
if pcm:
timestamp_ms = int((time.monotonic() - started) * 1000)
self._seq_num += 1
await ws.send_audio(...)
else:
await asyncio.sleep(0.01)
任何"计数器 + 事件驱动递增"的组合,都要问一句:事件不来的时候它意味着什么? 答案通常是错的。
九、最后一个坑:audioop 在 Python 3.13 没了
修完上面这些,运行测试时又崩了:
vbnet
ModuleNotFoundError: No module named 'audioop'
Python 3.13 移除了标准库的 audioop。代码原本是这么写的:
python
try:
import audioop
except ImportError:
import audioop_lts as audioop # 第三方回退
问题在于 audioop-lts 在很多环境里装不上(离线、内网、无预编译轮子)。 两条路都断了 = 模块导入即崩 = 整条媒体链路起不来。
解法是内置一份纯 Python 实现,只依赖标准库:
python
try:
import audioop as _audioop # Python <= 3.12
except ImportError:
try:
import audioop_lts as _audioop # 装了回退包
except ImportError:
_audioop = None # 都没有 → 走纯 Python
慢一点(大概1~2 个数量级),但零依赖,嵌入式设备和离线环境都能跑。
写 μ-law 最容易错的地方
自己实现 G.711 μ-law 时,我写错了两次。两次都是靠实测数据发现的,不是靠读代码看出来的。
第一次我把满量程和段边界表都用了 16-bit 的值,跑出来往返信噪比只有 -0.3 dB------ 比不压缩还差。负数幅值和大幅值全错。
查了 ITU-T G.711 的参考实现才发现:输入要先算术右移 2 位压到 14 bit, 所以段边界表也必须是 14 bit 量级:
python
# ❌ 错的(16bit量级)
_CLIP = 32635
_SEG_END = (0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF, 0x3FFF, 0x7FFF)
# ✅ 对的(配合 >>2 预移位)
_CLIP = 8159
_SEG_END = (0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF)
第二次我在解码时多补了一次 << 2 想把14 bit 还原成 16 bit, 结果又错到 SNR 20 dB。
最后用三个可验证的指标锁定正确实现:
python
# ① 解码满量程必须是 32124(μ-law 的标准值)
assert decode(0x80) == 32124
# ② 静音必须编码成 0xFF
assert encode(0) == 0xFF
# ③ 语音信号往返 SNR 必须在 36~40 dB
# 小信号误差应显著小于大信号(这正是 μ-law 分段压缩的意义)
第 ③ 条我一开始还测出了 12.9 dB------查下来是我的测试代码有索引偏移 (拿sig[i] 去比 sigD[200:]),不是实现的问题。
最终结果:
| 指标 | 实测 | 标准 |
|---|---|---|
| 往返 SNR(语音信号) | 37.1 dB | 36~40 dB |
| 小信号相对误差 | 49.5 dB | 应优于整体 |
| 大信号相对误差 | 30.1 dB | 应劣于小信号 |
| 解码满量程 | 32124 | 32124 |
这类"看着差不多对"的代码是最危险的 :它不会崩,能跑, 只是音质差一点点。所以验证标准必须是可量化的外部指标, 而不是"我觉得对了"。
十、六个缺陷的共同点
| # | 缺陷 | 表象 | 真实原因 |
|---|---|---|---|
| 1 | ATD 收到 OK 即break | 永远拨不通,无报错 | 混淆命令层与事件层 |
| 2 | URC 吞掉结果码 | 忙线要等 31 秒 | 双重身份未区分 |
| 3 | 单口模块无URC 读取 | 单口必然拨不通 | 依赖了不存在的线程 |
| 4 | pyserial 并发读 | 偶发 ATD 无响应 | 共享可变状态无保护 |
| 5 | hangup 无条件成功 | 下一次拨不通 | 失败被吞 |
| 6 | PCM 失败仍接通 | 接通但没声音 | 失败被吞 |
六个里有四个(1/2/3/6)的根因是同一个:
代码把"某件事成功了"和"我要求的那个目标达成了"当成了同一件事。
命令发送成功 ≠ 通话建立 URC 收到 ≠ 通话建立 状态置成 ACTIVE ≠ 音频通路可用
涉及异步、有外部依赖、有人工介入的流程, 每一步都问一句"失败了会怎样",比"成功了为什么"更容易发现这类问题。
十一、如果你也在写 telephony 网关
几条可以直接用的检查项:
ATD/ATA之后有没有收报窗口? 没有就一定会漏 URC。- 结果码(
BUSY/NO CARRIER/NO ANSWER/NO DIALTONE)有没有被 当成 URC 吞掉? 这会让快速失败退化成超时。 - 单口模块靠谁读 URC? 不能是一个"为了双口才起的线程"。
- 同一串口有没有被多个线程读? 有就上锁,或者合并成单线程。
dial/answer/hangup/_enable_pcm在失败时真的返回False了吗? 逐个检查except分支和错误响应分支。timestamp之类的时间量,跟随的是墙钟还是事件计数?
这几条看起来都是常识,但这六条我全踩中了------ 而且其中四条在我写下它们的时候,我确信代码是对的。
附:修复后的实测数据
| 场景 | 修复前 | 修复后 |
|---|---|---|
| SIM800C(单口)接通 | 30.24s 超时失败 | 1.01s |
| SIM7600(双口)接通 | 30.24s 超时失败 | 1.01s |
| 忙线 | 31.10s 超时 | 0.50s |
hangup 返回 ERROR |
仍报成功 | 正确上报失败 |
hangup 返回 NO CARRIER |
报成功 | 报成功(正确) |
| PCM 使能失败 | 报接通成功 | 挂断并上报失败 |
| 挂断后再拨号 | --- | 可重复拨号 |
⚠️ 验证边界说明 :以上全部为逻辑层验证 ------用桩件(fake serial) 驱动真实代码路径,覆盖命令收发、状态机迁移、并发时序。 未做真机端到端验证。桩件能证明"代码逻辑按预期分支", 不能证明"真实模块行为与桩件一致"。上真机前建议先按第九节的方式 抓一遍实际 AT 交互日志核对。