写 telephony 网关时,我把「拨不通」查了三天:AT 命令回 OK 不代表通话接通

1 阅读13分钟

一句话摘要:给 4G 模块写拨号代码时,ATD<号码>; 立刻返回 OK,我以为接通成功了, 结果整条链路永远停在"拨号中"。真相是:OK 只代表命令被接受,通话结果在几百毫秒后 以另一种形式的行异步到达——而我的读取循环在 OK 上就break 了。


一、先说现象

项目背景是一个 Twilio 兼容的本地电话网关(把云端 telephony 换成本地硬件), 目标设备是 SIM7600 4G 模块和 SIM800C 2G 模块。代码逻辑是这样的:

上层请求通话 → 解析 TwiML 拿到 Stream URL → ATD 拨号 → 建立 WebSocket → 双向送音频

第一次实机测试:WebSocket 建起来了,但没有声音。

按常理,这说明是音频通路的问题——采样率不对、编解码不对、或者 PCM 没送到。 于是我先去查音频。

查了一圈:采样率是 8kHz μ-law、base64 编码正确、WebSocket 双向都通。 越查越觉得音频没问题,那问题在哪?

换个思路,去看模块到底有没有真的接通。AT 口打印出来是这样:

> ATD13800138000;
OK
<过了大概 400ms>
VOICE CALL: BEGIN

就这两行。

看到这行的时候我停住了。VOICE CALL: BEGIN 出现了,模块确实接通了—— 但这行是在 OK 之后才到的。

而我的代码长这样:

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() 拿到返回值后是这么判断的:

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,再读一会儿。

_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 被当成结束标记让循环提前退出。

我最初的分类表是这么写的:

_URC_PREFIXES = (
    "VOICE CALL:", "+CLIP", "RING", "NO CARRIER", "BUSY",
    "RINGING", "CALL READY",
)

看起来挺合理。结果埋了个更深的坑。


四、修复二:BUSY 和 NO CARRIER 是双重身份

写完跑测试,BUSY 场景的表现是:

dial() = False    耗时 31.10 秒

拨不通(对),但慢得离谱——它没有快速失败,而是走完了 30 秒超时。

我一开始以为是收报窗口的问题,去调 _URC_DRAIN_S。 调小到 0.05 秒,没用。

于是回去打印 _at_cmd 的实际返回值:

>>> resp = await b._at_cmd("ATD138;", timeout=45)
>>> resp
''          # ← 空字符串!

空字符串。 BUSY 不见了。

原因就是我上面那张表把 BUSY 放进了 URC 前缀。 而BUSY 同时也是呼叫结果码——它告诉调用方"这个电话打不通"。

结果:_at_cmd 认为"BUSY 是条 URC,收走", 把它从返回结果里剔除了;调用方拿到的空字符串里既没有 OK 也没有 BUSY, 只能继续往下走,最后超时。

dial() 里的快速失败判断本来是写好的:

if any(k in resp for k in ("BUSY", "NO ANSWER", "NO DIALTONE")):
    return False          # ← 这段是对的

但它永远拿不到 BUSY 这个字符串。

修法是调整分类原则:结果码必须回给调用方,不能当通知吞掉。

_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口。

代码里原本有这么一段保护:

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 的唯一处理点:

self._pending_urc: list[str] = []   # URC 暂存
# _at_cmd 内部,摘出 URC 后
self._pending_urc.extend(urc)
for line in urc:
    self._handle_urc(line)

_wait_for_connect 在等待过程中顺便复查暂存区:

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 无响应」——同一份代码,十次里有一次不回。 这种问题最难查,因为你没法稳定复现。

唯一可靠的解法是不要试图去修它,而是从一开始就不让并发发生:

self._serial_lock = threading.RLock()
def _do() -> str:
    with self._serial_lock:      # 整段读写都在锁内
        self._at_serial.reset_input_buffer()
        self._at_serial.write(...)
        ...读循环...

URC 线程的读取也要持同一把锁:

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

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(模块拒绝挂断),桥接层仍然认为已挂断。 线路实际还占着,于是下一次拨号拨不通。

更迷惑的是:挂断失败和"下一次拨号失败"之间隔了一个功能点, 日志里没有任何关联线索。修法就是按响应区分:

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() 失败也不管

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,失败就挂断:

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
# 调用方
self._state = CallState.ACTIVE
if not await self._enable_pcm():
    # 没有音频通路的通话必须挂断,而不是假装接通
    self._state = CallState.IDLE
    await self._at_cmd("ATH")
    return False

八、附带:时间戳不该跟着"拿到了几帧"走

媒体桥接还有个独立问题。上行循环原本是这样:

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 语义是媒体在流中的绝对位置, 下游用它对齐播放节奏。时间戳停走 = 下游认为断流了。

改法是让时钟跟随墙钟,和是否取到音频解耦:

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 没了

修完上面这些,运行测试时又崩了:

ModuleNotFoundError: No module named 'audioop'

Python 3.13 移除了标准库的 audioop。代码原本是这么写的:

try:
    import audioop
except ImportError:
    import audioop_lts as audioop   # 第三方回退

问题在于 audioop-lts 在很多环境里装不上(离线、内网、无预编译轮子)。 两条路都断了 = 模块导入即崩 = 整条媒体链路起不来。

解法是内置一份纯 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 量级:

# ❌ 错的(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。

最后用三个可验证的指标锁定正确实现:

# ① 解码满量程必须是 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 dB36~40 dB
小信号相对误差49.5 dB应优于整体
大信号相对误差30.1 dB应劣于小信号
解码满量程3212432124

这类"看着差不多对"的代码是最危险的:它不会崩,能跑, 只是音质差一点点。所以验证标准必须是可量化的外部指标, 而不是"我觉得对了"。


十、六个缺陷的共同点

#缺陷表象真实原因
1ATD 收到 OK 即break永远拨不通,无报错混淆命令层与事件层
2URC 吞掉结果码忙线要等 31 秒双重身份未区分
3单口模块无URC 读取单口必然拨不通依赖了不存在的线程
4pyserial 并发读偶发 ATD 无响应共享可变状态无保护
5hangup 无条件成功下一次拨不通失败被吞
6PCM 失败仍接通接通但没声音失败被吞

六个里有四个(1/2/3/6)的根因是同一个:

代码把"某件事成功了"和"我要求的那个目标达成了"当成了同一件事。

命令发送成功 ≠ 通话建立 URC 收到 ≠ 通话建立 状态置成 ACTIVE ≠ 音频通路可用

涉及异步、有外部依赖、有人工介入的流程, 每一步都问一句"失败了会怎样",比"成功了为什么"更容易发现这类问题。


十一、如果你也在写 telephony 网关

几条可以直接用的检查项:

  1. ATD / ATA 之后有没有收报窗口? 没有就一定会漏 URC。
  2. 结果码(BUSY / NO CARRIER / NO ANSWER / NO DIALTONE)有没有被 当成 URC 吞掉? 这会让快速失败退化成超时。
  3. 单口模块靠谁读 URC? 不能是一个"为了双口才起的线程"。
  4. 同一串口有没有被多个线程读? 有就上锁,或者合并成单线程。
  5. dial / answer / hangup / _enable_pcm 在失败时真的返回 False 了吗? 逐个检查 except 分支和错误响应分支。
  6. timestamp 之类的时间量,跟随的是墙钟还是事件计数?

这几条看起来都是常识,但这六条我全踩中了—— 而且其中四条在我写下它们的时候,我确信代码是对的。


附:修复后的实测数据

场景修复前修复后
SIM800C(单口)接通30.24s 超时失败1.01s
SIM7600(双口)接通30.24s 超时失败1.01s
忙线31.10s 超时0.50s
hangup 返回 ERROR仍报成功正确上报失败
hangup 返回 NO CARRIER报成功报成功(正确)
PCM 使能失败报接通成功挂断并上报失败
挂断后再拨号—可重复拨号

⚠️ 验证边界说明:以上全部为逻辑层验证——用桩件(fake serial) 驱动真实代码路径,覆盖命令收发、状态机迁移、并发时序。 未做真机端到端验证。桩件能证明"代码逻辑按预期分支", 不能证明"真实模块行为与桩件一致"。上真机前建议先按第九节的方式 抓一遍实际 AT 交互日志核对。