
简介面向VoIP和实时音频流开发者的G.711封装RTP传输工程示例完整演示如何将G.711A-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 3550RTP 本身和 RFC 3551RTP profile for audio。很多人直接抄代码却不知道 RFC 3551 里对 G.711 作了静态 payload type 分配这直接决定了你填在 RTP 头里的 PT 字段必须是特定值。先把这个底子打牢后面封装才不会抓瞎。2.1 RTP 固定头的 12 字节逐个字段过一遍一个标准的 RTP 包头固定占 12 字节全部是大端序。封装 G.711 时我们只需要填好这 12 字节后面直接跟压缩后的 PCM 数据。字段结构如下表字段长度说明V2 bitRTP 版本号固定为 2P1 bit填充标志G.711 数据无填充即为 0X1 bit扩展头标志一般为 0CC4 bitCSRC 计数单点通话为 0M1 bit标记位语音帧起始时置 1否则为 0PT7 bit负载类型PCMU0PCMA8序列号16 bit每发一包加 1可判断丢包和乱序时间戳32 bit负载首样本的采样时刻单位是采样周期SSRC32 bit同步源标识同一路流内保持固定第一个字节在代码里通常直接写成 0x80即版本号 2、无填充、无扩展、CSRC 为 0。第二个字节是 M 位和 PT 值的组合如果 PT0PCMU第二字节就是 0x00 或 0x80M 置位时。注意这里别把 PT 填成 96 这类动态值G.711 的静态 PT 就是 0 和 8除非对方在 SDP 里明确协商成动态 PT否则优先用静态值兼容性最好。2.2 PCMA 还是 PCMU编码选择的逻辑G.711 有两种压缩律A-law 和 μ-law。RTP 里分别叫 PCMAPT8和 PCMUPT0。二者都是 8kHz 采样、8bit 量化、64kbps 码率算法上本质是对 16bit PCM 做对数压缩把每个样本从 2 字节压成 1 字节。选哪个不取决于你的代码习惯而取决于对端设备所在区域编码标准典型使用地区PCMUμ-lawITU-T G.711 μ-law北美、日本PCMAA-lawITU-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 的话1ms8所以 20ms16030ms24040ms320。封装前先把这个数字定死写进常量不要每次现算。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, marker0): # 12 字节 RTP 固定头!表示大端序 first_byte 0x80 # V2, P0, X0, CC0 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 位回绕处理这样长时间运行时间戳溢出后也能自动回到 0RFC 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(fPT{pt} seq{seq} ts{ts} delta{delta} len{len(data)-12}) last_ts ts if __name__ __main__: main()把发送端和接收端跑在两个终端里观察输出。正常情况下 delta 恒定等于 160seq 连续递增PT0。如果 delta 出现 0 或非 160 的数值说明发送端时间戳逻辑有问题马上回头检查封装代码。这套组合在真机联调前就能完成自测比直接接对端排查快得多。3.4 帧长参数怎么调帧长是影响延迟和带宽利用率的关键参数。20ms 是业界默认因为 RTP 包负载正好 160 字节加上 IP/UDP/RTP 头 40 字节包总长 200 字节以太网 MTU 下完全不需要分片。有些场景会用到 30ms 或 40ms比如为了降低 CPU 中断频率、或者匹配上游语音引擎的缓冲粒度。改帧长只需要改 FRAME_SIZE 一个常量然后同步修改接收程序里的 delta 期望值。但要注意SIP 协商时 SDP 里的 aptime 必须与实际封装帧长一致。对方如果发了 aptime: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 或直接看包列表重点核对三个字段检查项期望值说明PT0PCMU或 8PCMA如果显示 96/97 等动态值说明协商不一致Seq连续递增无跳变跳变意味着封装端丢包或并发发送时间戳 Delta16020ms等于你设定的帧长对应采样数个别允许 ±1 抖动时间戳 delta 是关键中的关键。Wireshark 在 RTP 包详情里会显示 Delta time 这个字段它是相邻两包时间戳的差值。正常情况恒为 160。如果你看到恒为 0说明发送端根本没有更新时间戳如果看到忽大忽小说明发送线程的调度不稳定或者有多个线程在往同一个 socket 写包。4.3 对接 FreeSWITCH / 网关时的 SDP 核对方法和 SIP 软交换对接时问题往往不在 RTP 封装本身而在协商。G.711 的 SDP 描述长这样maudio 5004 RTP/AVP 8 artpmap:8 PCMA/8000 aptime:20第一行末尾的 8 是静态负载类型表示对方愿意收 PCMA。如果这行是 0那就对应 PCMU。你的发送端必须严格遵守这个协商结果。拿到对方的 200 OK 或 INVITE 时先 grepmaudio行看支持的 PT 号再决定封装时填 0 还是 8。我见过有人代码里写死 PT8而对方只收 PCMU结果每次通话都是噪声排查了半天才发现是协商和封装各说各话。aptime 字段也要对齐。对方通告 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 对应到真实编码。比如maudio 5004 RTP/AVP 96 artpmap: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 号试一次把 PT0 改成 PT8如果噪声立刻消失说明就是编码律不匹配问题解决。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、链路类型1Ethernet 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 行代码。花的时间不超过十分钟但在后续查问题时省下的时间从来不只是十分钟。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取