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

一句话摘要:给 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 网关

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

  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 交互日志核对。

相关推荐
方方洛2 小时前
ai-agent教程-08-多智能体协作
人工智能·llm·agent
styshoo2 小时前
[snapshot]2-snapshot部署
人工智能
changshuaihua0012 小时前
LangChain · LangGraph · LangSmith 全景解析:LLM应用开发完整生态
人工智能·大模型·ai编程
ITOM运维行者2 小时前
WAN监控怎么做?从链路质量指标到路径级定位的完整方法
数据库·人工智能·机器学习
shaibdoio3 小时前
瞬维AI技术拆解:AI获客智能体的双层漏斗算法原理
大数据·人工智能·算法
用户674874273343 小时前
我踩了3个坑才搞懂智能体提效该从哪下手
人工智能
Mickey同学3 小时前
生产级 RAG:从文档切分、混合检索到引用与评估
人工智能
lucas_AI3 小时前
别让旗舰大模型当调度员:APUS 开源 Reflex Agent,4.5B 小模型管事、大模型干活
人工智能
同城验收3 小时前
本地部署一个小模型,我踩了五个坑:编译、量化、显存、上下文、开机自启
人工智能