简介:面向VoIP和实时音频流开发者的G.711封装RTP传输工程示例,完整演示如何将G.711(A-law/μ-law)编码的音频数据封装成RTP数据包,并通过UDP发送给VLC媒体播放器进行接收与播放。压缩包内共4个文件,包体约2.16MB,包含C语言封装源码、SDP会话描述文件、G711音频测试样本以及说明文档,覆盖从读取音频文件、构造RTP头、填充时间戳与序列号,到发送数据包和会话协商的完整流程。通过阅读源码可深入理解RTP头部字段的作用和G.711与RTP结合的基本原理;利用SDP文件可掌握媒体会话的参数描述方法;使用附带的音频样本能直接验证VLC播放效果,为搭建简易VoIP测试链路或学习实时传输协议提供了实用参考。已有1486人学习下载,资源包结构简洁,适合具备基础C语言和网络编程知识的开发者快速上手。
1. G.711 封装成 RTP:为什么最简单的负载反而是最多的坑
我排查过不少音视频联调问题,发现一个规律:凡是负载格式是 G.711 的 RTP 流,出问题时查起来最费劲。不是因为协议复杂,恰恰相反,G.711 是所有 RTP 负载里最简单的一种——标准里明确写了,payload 就是裸的 PCM 样本,不需要任何附加头。但正因为简单,很多人在封装时只盯着“把数据塞进包”这一步,忽略了 PT 号、时间戳单位、序列号回绕这些细节。结果就是:包发了,Wireshark 也看到了,对端却是一片噪声,或者语速快得像快进,再或者运行几个小时后通话卡死。
这篇文章想把 G.711 上 RTP 这条路彻底走通。从协议定义、编码选择、封装实现,到 Wireshark 验证、SIP 对接、以及我踩过的五个坑,全部展开。适合正在写 Voip 网关、嵌入式音频传输、或者用现成库做实时通话的开发者。你有现成的 PCM 数据,想把它变成对端能解码播放的 RTP 流,看这篇就够了。
2. 封装前的协议底子:RFC 3551 静态 PT 与时间戳单位
G.711 封装成 RTP,协议依据主要来自 RFC 3550(RTP 本身)和 RFC 3551(RTP profile for audio)。很多人直接抄代码,却不知道 RFC 3551 里对 G.711 作了静态 payload type 分配,这直接决定了你填在 RTP 头里的 PT 字段必须是特定值。先把这个底子打牢,后面封装才不会抓瞎。
2.1 RTP 固定头的 12 字节,逐个字段过一遍
一个标准的 RTP 包头固定占 12 字节,全部是大端序。封装 G.711 时,我们只需要填好这 12 字节,后面直接跟压缩后的 PCM 数据。字段结构如下表:
| 字段 | 长度 | 说明 |
|---|---|---|
| V | 2 bit | RTP 版本号,固定为 2 |
| P | 1 bit | 填充标志,G.711 数据无填充即为 0 |
| X | 1 bit | 扩展头标志,一般为 0 |
| CC | 4 bit | CSRC 计数,单点通话为 0 |
| M | 1 bit | 标记位,语音帧起始时置 1,否则为 0 |
| PT | 7 bit | 负载类型,PCMU=0,PCMA=8 |
| 序列号 | 16 bit | 每发一包加 1,可判断丢包和乱序 |
| 时间戳 | 32 bit | 负载首样本的采样时刻,单位是采样周期 |
| SSRC | 32 bit | 同步源标识,同一路流内保持固定 |
第一个字节在代码里通常直接写成 0x80,即版本号 2、无填充、无扩展、CSRC 为 0。第二个字节是 M 位和 PT 值的组合:如果 PT=0(PCMU),第二字节就是 0x00 或 0x80(M 置位时)。注意这里别把 PT 填成 96 这类动态值,G.711 的静态 PT 就是 0 和 8,除非对方在 SDP 里明确协商成动态 PT,否则优先用静态值,兼容性最好。
2.2 PCMA 还是 PCMU:编码选择的逻辑
G.711 有两种压缩律:A-law 和 μ-law。RTP 里分别叫 PCMA(PT=8)和 PCMU(PT=0)。二者都是 8kHz 采样、8bit 量化、64kbps 码率,算法上本质是对 16bit PCM 做对数压缩,把每个样本从 2 字节压成 1 字节。选哪个不取决于你的代码习惯,而取决于对端设备所在区域:
| 编码 | 标准 | 典型使用地区 |
|---|---|---|
| PCMU(μ-law) | ITU-T G.711 μ-law | 北美、日本 |
| PCMA(A-law) | ITU-T G.711 A-law | 欧洲、中国、国际线路 |
中国默认 PCMA,但很多开源软交换默认配置是 PCMU。联调时如果发现对端有声音但音调不对、或者噪声,先看双方协商出来的 PT 号再下结论。另外,A-law 编码在生成时有个细节:所有 bit 的偶数位会做取反处理,目的是保证线路上的 DC 平衡。这个细节不影响你调库,但如果你是自己实现编码表,拿标准表对拍时看到偶数位不一致,别怀疑是表错了。
2.3 时间戳增量怎么算:20ms 帧对应的采样单位
RTP 时间戳的单位不是毫秒,而是采样周期。G.711 的采样率固定 8000Hz,意味着 1 秒音频对应 8000 个时间戳单位。一个 20ms 的语音帧,包含 160 个样本,因此时间戳增量是 160。这是整个封装过程里最容易被算错的地方。
常见错误是直接把“毫秒数”当成时间戳填进去。比如 20ms 的帧填 20,对端按 8000Hz 的时钟消费,实际播放速率会变成正常速率的 8 倍,听着像快进。正确的做法是:每发一包,时间戳加 160;如果是 30ms 帧长,加 240。换算公式很简单:采样率 ÷ 1000 × 帧毫秒数。8000Hz 的话,1ms=8,所以 20ms=160,30ms=240,40ms=320。封装前先把这个数字定死,写进常量,不要每次现算。
3. 从 PCM 到 RTP 包:一个自包含的封装器实现
这里给出一套完整的 G.711 封装实现,用 Python 写,不依赖任何第三方库,也不依赖系统音频接口。输入是一个 16bit PCM 的 WAV 文件(8kHz、单声道),输出是标准的 RTP 包,直接通过 UDP 发送到指定地址。
3.1 用查表代替编码器,先构建 G.711 转换表
G.711 的压缩算法可以用公式硬算,但工程上更多是查表。一张 256 字节的表,把 16bit PCM 映射到 8bit G.711 码字。下面这段代码生成 μ-law 表,A-law 的生成逻辑类似,只是分段点和偏置不同:
def build_ulaw_table(): table = [] for i in range(256): # μ-law 编码:先反量化得到对应的线性值,再按分段映射 # 标准最终码字 = 取反后的 8bit,表中直接存最终结果 ulaw = i ^ 0xFF # 线路传输时所有 bit 取反 # 解析出 segment 和 mantissa segment = (ulaw >> 4) & 0x07 mantissa = ulaw & 0x0F # 反量化:还原成 16bit 线性 PCM 参考值 if segment == 0: ref = (mantissa << 2) + 2 else: ref = ((mantissa + 16) << (segment + 2)) - 16 - 2 # 反向转换:带符号 16bit 线性值 -> 8bit 码字 # 符号位在最高位,正值和负值共用同一张表 table.append(ref & 0xFFFF) return table这段代码的逻辑是先按 ITU-T G.711 附录里的分段公式,把每个 8bit 码字反量化成一个 16bit 参考值,然后反转符号处理。实际做正向转换时,输入一个 16bit PCM 样本,遍历这 256 个参考值找到最接近的一项,返回对应的索引即可。查表法在嵌入式上尤其常用,因为省 CPU,一张表只有 256 字节,放 Flash 里毫无压力。
3.2 组装 RTP 头并发送:完整脚本
有了转换表,封装器的主循环就很简单了:读取 PCM 样本、压缩成 G.711、组装 RTP 头、通过 UDP 发出去。下面是完整的发送端实现:
import socket import struct import random # 20ms @ 8kHz = 160 个样本 FRAME_SIZE = 160 SAMPLE_RATE = 8000 PT_PCMU = 0 # PCMU 静态 PT PT_PCMA = 8 # PCMA 静态 PT,按需切换 def build_rtp_header(pt, seq, ts, ssrc, marker=0): # 12 字节 RTP 固定头,!表示大端序 first_byte = 0x80 # V=2, P=0, X=0, CC=0 second_byte = (marker << 7) | (pt & 0x7F) return struct.pack('!BBHII', first_byte, second_byte, seq & 0xFFFF, ts & 0xFFFFFFFF, ssrc & 0xFFFFFFFF) def pcm_to_g711(sample, table): # 简单最近邻查表:返回与 PCM 样本最接近的编码值索引 best_idx, best_diff = 0, 0xFFFFFFFF for idx, ref in enumerate(table): diff = abs(sample - ref) if diff < best_diff: best_diff, best_idx = diff, idx return best_idx def main(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) dest = ('127.0.0.1', 5004) # 打开 8kHz 16bit 单声道 WAV 文件 with open('input.wav', 'rb') as f: # 跳过 WAV 44 字节文件头(仅支持标准 PCM WAV) f.seek(44) table = build_ulaw_table() ssrc = random.getrandbits(32) seq = random.getrandbits(16) ts = random.getrandbits(32) while True: raw = f.read(FRAME_SIZE * 2) if len(raw) < FRAME_SIZE * 2: break # 解包 160 个 signed 16bit 样本 samples = struct.unpack(f'<{FRAME_SIZE}h', raw) # 每个样本压缩成 1 字节 G.711 码字 payload = bytes(pcm_to_g711(s, table) for s in samples) # 组装 RTP 头 + 负载并发送 rtp_pkt = build_rtp_header(PT_PCMU, seq, ts, ssrc) sock.sendto(rtp_pkt + payload, dest) # 序列号和时间戳逐步累加,注意按位与防止溢出 seq = (seq + 1) & 0xFFFF ts = (ts + FRAME_SIZE) & 0xFFFFFFFF sock.close() if __name__ == '__main__': main()关键点有三个。第一,RTP 头用 struct.pack 的大端格式一次性打包,避免手动拼字节出错。第二,时间戳累加用的是加法而不是乘法,并且做了 32 位回绕处理,这样长时间运行时间戳溢出后也能自动回到 0,RFC 3550 允许这种回绕行为。第三,表生成只做一次,发每帧时只需要查表,性能足够支撑实时发送。如果你要发的是 A-law,把 PT_PCMU 改成 PT_PCMA,并换一张 A-law 表即可,其余逻辑完全不用动。
3.3 接收端快速验证程序
写完发送端,最好同时写一个接收端来验证行为。下面这个接收程序不做解码播放,只解析 RTP 头并打印序列号和时间戳增量,用来检查封装逻辑是否正确:
import socket import struct def main(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(('0.0.0.0', 5004)) last_ts = None while True: data, addr = sock.recvfrom(2048) # 解析 RTP 头:前 12 字节 b0, b1, seq, ts, ssrc = struct.unpack('!BBHII', data[:12]) pt = b1 & 0x7F delta = ts - last_ts if last_ts is not None else 0 print(f'PT={pt} seq={seq} ts={ts} delta={delta} len={len(data)-12}') last_ts = ts if __name__ == '__main__': main()把发送端和接收端跑在两个终端里,观察输出。正常情况下 delta 恒定等于 160,seq 连续递增,PT=0。如果 delta 出现 0 或非 160 的数值,说明发送端时间戳逻辑有问题,马上回头检查封装代码。这套组合在真机联调前就能完成自测,比直接接对端排查快得多。
3.4 帧长参数怎么调
帧长是影响延迟和带宽利用率的关键参数。20ms 是业界默认,因为 RTP 包负载正好 160 字节,加上 IP/UDP/RTP 头 40 字节,包总长 200 字节,以太网 MTU 下完全不需要分片。有些场景会用到 30ms 或 40ms,比如为了降低 CPU 中断频率、或者匹配上游语音引擎的缓冲粒度。
改帧长只需要改 FRAME_SIZE 一个常量,然后同步修改接收程序里的 delta 期望值。但要注意,SIP 协商时 SDP 里的 a=ptime 必须与实际封装帧长一致。对方如果发了 a=ptime:30,而你发的是 20ms 的包,部分网关会产生缓冲不匹配,表现为周期性卡顿。这个后面第 4 章讲对接时还会再提。
4. 抓包验证与对接:把封装结果放到 Wireshark 和 SIP 里检验
代码写完只是第一步,封装是否正确要以抓包结果为准。Wireshark 对 RTP 的解析非常成熟,但它只能验证“你发的包对不对”,不能验证“对端认不认”。所以这章先讲怎么用 Wireshark 自检,再讲和 SIP 网关对接时怎么核对协商参数。
4.1 Wireshark 过滤器与 RTP Streams 面板
发送端跑起来后,在 Wireshark 里选择对应网卡,输入过滤器rtp就能看到 RTP 流。如果想只看某个端口,用udp.port == 5004。找到第一条包后,右键选择 Decode As,把 UDP 端口 5004 强制解码为 RTP,防止 Wireshark 因为不认识动态端口而只显示为 UDP。
更直观的方式是走菜单 Telephony → RTP → RTP Streams。这个面板会把同一 SSRC 的包聚合成一条流,直接列出包数、丢包率、时间戳增量等关键指标。只要看到两条流出现,说明 SSRC 发生了变化,通常是发送进程重启或者多个线程各生成各的 SSRC 导致,对端会把它们当成两路不同信号。
4.2 三个必须检查的字段
在 RTP Streams 面板里选中流,点 Graph 或直接看包列表,重点核对三个字段:
| 检查项 | 期望值 | 说明 |
|---|---|---|
| PT | 0(PCMU)或 8(PCMA) | 如果显示 96/97 等动态值,说明协商不一致 |
| Seq | 连续递增,无跳变 | 跳变意味着封装端丢包或并发发送 |
| 时间戳 Delta | 160(20ms) | 等于你设定的帧长对应采样数,个别允许 ±1 抖动 |
时间戳 delta 是关键中的关键。Wireshark 在 RTP 包详情里会显示 Delta time 这个字段,它是相邻两包时间戳的差值。正常情况恒为 160。如果你看到恒为 0,说明发送端根本没有更新时间戳;如果看到忽大忽小,说明发送线程的调度不稳定,或者有多个线程在往同一个 socket 写包。
4.3 对接 FreeSWITCH / 网关时的 SDP 核对方法
和 SIP 软交换对接时,问题往往不在 RTP 封装本身,而在协商。G.711 的 SDP 描述长这样:
m=audio 5004 RTP/AVP 8 a=rtpmap:8 PCMA/8000 a=ptime:20第一行末尾的 8 是静态负载类型,表示对方愿意收 PCMA。如果这行是 0,那就对应 PCMU。你的发送端必须严格遵守这个协商结果。拿到对方的 200 OK 或 INVITE 时,先 grepm=audio行,看支持的 PT 号,再决定封装时填 0 还是 8。我见过有人代码里写死 PT=8,而对方只收 PCMU,结果每次通话都是噪声,排查了半天才发现是协商和封装各说各话。
a=ptime 字段也要对齐。对方通告 ptime:20,你的发送端就必须每 20ms 发一包。部分网关对 ptime 不敏感,但有些老式 IAD 设备严格要求一致,不一致时会出现“前几句话正常、后面持续卡顿”的怪象。最简单的做法:解析 SDP 里的 ptime,动态设置封装帧长。
4.4 静态 PT 与动态 PT 的兼容性问题
G.711 在 RFC 3551 里有静态 PT,但实际网络里经常看到用动态 PT(比如 96、97)承载 G.711 的,尤其是在视频通话场景里,动态 PT 用来区分多路媒体。遇到这种对端,你需要解析 SDP 里的 rtpmap 映射,把动态 PT 对应到真实编码。比如:
m=audio 5004 RTP/AVP 96 a=rtpmap:96 PCMA/8000这种情况下,虽然负载还是 G.711,但 RTP 头里的 PT 字段必须填 96,而不是 8。对端是根据 PT 号去查 rtpmap 才知道负载格式的。封装器最好把 PT 做成可配置项,而不是硬编码。静态 PT 适合自己独占一条流,动态 PT 适合多路媒体混流或与第三方客户端互通。两者都能通,但前提是封装端和 RFC 3551 / SDP 语义对齐。
5. 避坑记录:G.711 RTP 联调中最常见的五个问题
这一章全部来自实际排查经验。每个问题都按现象、原因、解决的方式来写。你能在联调中少走很多弯路。
5.1 现象:接通后对端全是噪声,抓包看不出异常
抓包看 PT、序列号、时间戳增量全正常,但对端扬声器出来的声音是持续的白噪声,完全听不出语音内容。原因基本可以锁定在 A-law 和 μ-law 不一致上。中国的设备默认 PCMA,北美的设备默认 PCMU,两边一个发 A-law 一个收 μ-law,解码出来的信号自然是噪声。解决方法是先看 SDP 协商结果,确定双方约定的 PT 值,然后检查封装端用的编码表是否匹配。我通常会直接互换 PT 号试一次:把 PT=0 改成 PT=8,如果噪声立刻消失,说明就是编码律不匹配,问题解决。
5.2 现象:播放速率变成原来的 8 倍,像快进
语音内容清晰,但语速极快,音调变高。这是最典型的时间戳单位错误。RTP 时间戳的单位是采样周期,不是毫秒。8kHz 采样下 1ms 对应 8 个时间戳单位,20ms 帧对应的增量是 160,不是 20。如果填 20,对端按 8000Hz 采样率消费数据,会以 8 倍速度播放。解决方式:把时间戳增量写成一个常量 TS_INCREMENT = 8000 / 1000 * ptime_ms,并且加注释说明换算过程,避免后来维护的人再犯。另外,wireshark 里 Delta 字段非 160 也是帮你发现这个问题的最快途径。
5.3 现象:通话运行几个小时后突然卡死,重启恢复
长时间运行后对端开始丢包,重开会话又正常。大概率是序列号回绕处理出了问题。RTP 序列号是 16 位无符号整数,范围 0-65535,回绕是正常的,从 65535 回到 0。如果你用的是有符号 short,回绕后变成负数,对端的 jitter buffer 会把后续所有包当成乱序包直接丢弃。解决方式:发送端用seq = (seq + 1) & 0xFFFF强制回绕;接收端做乱序判断时用无符号差值比较,不要直接比大小。
5.4 现象:说话时有声音,停顿片刻后对端显示“网络断开”
这种情况通常发生在启用了 VAD(静音检测)的封装端。说话时正常发包,静音时停发,对端如果没做舒适噪声处理,就会因为长时间收不到 RTP 包而判定链路中断,或者本地静音听起来像断线。解决方式:在对接阶段先关闭 VAD,让封装器静音时也持续发送包含静音样本的 RTP 包,确认链路稳定后再开启。如果非要启用 VAD,需要同时支持 RFC 3389 的 CNG 包机制,在静音起始时发送一个 CNG 包,让对端生成舒适的背景噪声。
5.5 现象:脚本在本地正常,换到纯净环境报 ImportError
用 Python 写封装器时,如果用了标准库的 audioop 模块做 G.711 转换,Python 3.13 之后会直接报 ImportError,因为这个模块已经被移出了标准库,改成独立维护的第三方包。解决方式有两个:一是彻底避免依赖 audioop,直接像第 3 章那样内置查表;二是在代码里做兼容处理:
try: import audioop except ImportError: import audioop # pip install audioop 后可用我自己的习惯是选第一种,因为查表实现不依赖任何版本特性,拿到任何一台机器都能跑,而且转换逻辑完全可控,排查编码问题时不至于被库的实现细节干扰。
6. 进阶:给封装器加离线抓包能力,顺便复用成一个工具
联调时最尴尬的情况是:现场环境和业务方确认好了,问题只出现在特定时刻,你又不能一直开着 Wireshark。我从一个项目之后养成了习惯——让封装器自己把发出的 RTP 包同时写进 pcap 文件,随时可以离线复盘,不需要再依赖现场网络环境。
实现思路很简单:在发送循环里,把每个发出包的完整 IP/UDP/RTP 报文或者仅 RTP 部分,按 pcap 格式落盘。下面这段代码展示了如何把 RTP 包追加写入 pcap 文件:
import os import struct import time pcap_path = 'g711_rtp.pcap' # pcap 全局头:magic、版本、时区、精度、snaplen、链路类型(1=Ethernet) with open(pcap_path, 'wb') as pcap: pcap.write(struct.pack('<IHHiIII', 0xa1b2c3d4, 2, 4, 0, 0, 65535, 1)) # 每发一个 RTP 包,同步写入一条记录 for rtp_pkt in rtp_packets: now = time.time() ts_sec = int(now) ts_usec = int((now - ts_sec) * 1e6) pcap.write(struct.pack('<IIII', ts_sec, ts_usec, len(rtp_pkt), len(rtp_pkt))) pcap.write(rtp_pkt)写入 pcap 后,直接把这个文件扔进 Wireshark,就能离线看到完整的 RTP 流、PT 号、时间戳 delta 和序列号走势。配合 Wireshark 的 Play Streams 功能,甚至能直接听出音频内容。这样在家就能复现现场问题,不用再求着运维开 tcpdump。
进一步,你还可以用tcpreplay把这 pcap 文件重放到一台测试软交换上,模拟一路真实的 G.711 通话,用来验证对端在静态 PT、动态 PT、不同 ptime 下的行为差异。整个封装器至此变成了一个可回放、可分析、可离线验证的调试工具,而不是一次性脚本。
从那以后,我每次写音频封装相关的代码,都强制要求自己先把 pcap 导出功能加上,哪怕只是 20 行代码。花的时间不超过十分钟,但在后续查问题时省下的时间从来不只是十分钟。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取