简介:面向VoIP与实时音频流开发者,这份RAR压缩包用于解决G.711语音编码数据封装成RTP实时传输协议包,并让VLC播放器成功接收播放的问题。资源共4个文件,包括C语言源码、SDP会话描述文件、G.711测试音频以及说明文档,整体大小2.16MB。其中,C源码演示了从读取G.711文件、构造RTP头到填充音频数据并通过UDP发送的完整流程,同时涉及时间戳、序列号等关键字段;SDP文件用于描述媒体会话参数,辅助VLC快速建立RTP连接;测试音频与说明文档则便于直接验证效果并了解使用步骤。该资源还介绍了G.711标准下A律与μ律两种编码方式的差异以及64kbps带宽特性,适合正在学习RTP协议封装或需要快速实现G.711音频网络传输的开发者,已有1486人学习下载,是一份可参考运行的实用示例,有助于节省协议实现与联调排查时间。
1. G711 封装 RTP 传输:代码不到 100 行,问题却总出在几个参数上
有一次做国标平台接入,设备那边 G711A 裸流已经拿到手,封成 RTP 包发出去,平台却回了一句 “start preview failed maybe rtp session false or preview links' nun”。排查一整天,抓包才发现是 RTP 包头里 PT 值写错,平台按 PCMU 解码,出来的全是噪声。G711 封装 RTP 传输是 VoIP、GB28181 平台、网络摄像机音频上传里最常见的活儿:8kHz 采样、每帧 160 字节,加一个 12 字节的 RTP 包头就能发。逻辑简单不等于不出错,时间戳增量、负载类型、打包时长这些参数,一个不对就是无声、变调、平台拒收。下面按“参数定夺 → 逐字节封装 → 抓包验证”的顺序来写,给可直接复用的 C/Python 实现和一份避坑清单,适合正在写 SIP 话机、媒体网关或做监控接入的开发者。
2. 封装前的参数定夺:采样率、PTime 与 PT 值,一个都不能拍脑袋
G711 是 ITU-T G.711 建议书定义的电话带宽语音编码标准,两种变体 A-law 和 μ-law,采样率固定 8kHz,每个样值 8bit,编码后码率就是 64kbps。这里只算媒体负载的话,A 律和 U 律没有区别。但 RTP 包在线路上的实际带宽要加上 12 字节 RTP 头、8 字节 UDP 头和 20 字节 IP 头,一共 40 字节开销。按 20ms 打包,每包 160 字节负载加 40 字节头,每秒 50 包就是 200×50=10000B/s,折算下来约 80kbps,不是 64kbps。做多路并发带宽预算时一定要按这个算,我有一次低估了负载,一路两路没事,推到 30 路就开始丢包。
还有一个必须提前说清楚的细节:A-law 在编码时对偶数位做了取反,μ-law 则是先把线性 PCM 加偏置再压缩。所以你在做对接时,从第三方编码器拿到什么字节就封什么字节,不要因为觉得“顺序不对”再查一次表反转。G711 数据是最终编码结果,拿到手直接往 RTP 负载区拷贝即可,二次处理大概率把数据搞坏。这个低级错误我见过两次,一次在监控平台接入,一次在自研网关,症状都是对端噪声,和 PT 填错完全一样。
2.1 先确认源端是 8kHz 单声道,再做重采样
G711 的输入要求是 8kHz 采样、单声道、16bit 线性 PCM 压缩后的数据。如果你从采集卡、IPC 或软件编码器里拿到的是 48kHz 或 16kHz 音频流,必须先重采样到 8kHz 再交给 G711 编码器,否则后面所有打包参数都会失真。判断方法很简单:看源 PCM 数据的块大小,8kHz 单声道 16bit 下,1ms 音频是 16 字节,10ms 160 字节,20ms 320 字节。如果拿到的块大小对不上这个规律,先查声道数和采样率,别急着进封装流程。
常见做法是用 ffmpeg 先把音频源规整到 8kHz 单声道 s16le,再喂给 G711 编码器:
ffmpeg -i input.wav -ar 8000 -ac 1 -f s16le - | your_g711_encoder参数说明:-ar 8000强制采样率 8kHz,-ac 1强制单声道,-f s16le输出小端 16bit PCM。经过这一步,20ms 音频就是 320 字节的 PCM。但注意,G711 编码之后每个样值变 8bit,20ms 帧变成 160 字节,很多人在这一步把编码前的 320 字节当成 G711 帧直接填入 RTP,负载长度翻倍,对端按 160 字节解析就会出现周期性错位和爆音。自测链路时用 ffmpeg 规整数据没问题,但真实工程里还是要确保编码线程拿到的就是 8kHz 的 PCM 流。
2.2 PTime 与包率的工程取舍:20ms 是默认值,但不是万能值
PTime 是 SDP 里声明的每包音频时长。对 G711 来说,RTP 时间戳的时钟频率固定等于采样率 8000Hz,因此每包时间戳增量等于毫秒数乘 8。20ms 的 ts 增量是 160,40ms 是 320。负载字节、ts 增量、包率三者关系如下:
| PTime | 负载字节数 | 时间戳增量 | 包率 |
|---|---|---|---|
| 10ms | 80 | 80 | 100 包/s |
| 20ms | 160 | 160 | 50 包/s |
| 30ms | 240 | 240 | 约 33 包/s |
| 40ms | 320 | 320 | 25 包/s |
| 60ms | 480 | 480 | 约 17 包/s |
为什么默认是 20ms?两个原因。第一,20ms 包对应的 UDP 净荷约 172 字节,离 MTU 的 1500 字节很远,基本不会触发 IP 分片,公共网络上丢包率比超大包低。第二,20ms 对应 50 包/秒,接收端抖动缓冲只要留 2~3 个包,就能抗住 40~60ms 的网络抖动,正好覆盖大多数局域网和运营商承载网的延迟波动。
选 PTime 时注意两点:一是对端 SIP 应答里如果带了 a=ptime,发送端实际打包时长必须和它一致,否则接收端抖动缓冲要么放不下要么供不应求,表现就是周期性咔哒声;二是网络丢包超过 1% 的环境不要盲目加大 PTime,一个 60ms 包丢了,损失的是 60ms 完整语音,比三个 20ms 包丢一个更难听。
2.3 PT 值:PCMU 是 0,PCMA 是 8,A 律别塞到 0 里
RFC 3551 给 G711 两种变体分配了静态负载类型:PCMU(μ-law)=0,PCMA(A-law)=8。使用静态 PT 时,SDP 里理论上可以不写 rtpmap,但工程上我建议显式声明,因为国内很多平台、NVR、IPC 的实现很死板,缺一行参数就可能不给媒体。标准写法是:
m=audio 5004 RTP/AVP 8 a=rtpmap:8 PCMA/8000 a=ptime:20如果源端是 G711A 但 PT 填成 0,对端按 μ-law 解码,整段音频就是持续噪声。反过来,G711U 塞到 PT=8 也一样。这个错不影响 SIP 信令,只影响媒体层,所以现场很难定位,我开篇说的那次正是这种情况。另有一些设备喜欢用动态 PT,比如 96、98、104,这时必须从 SDP 的 rtpmap 里反查映射关系,动态解析后再回填 RTP 头,不能把 PT 号写死在代码里。
3. 逐字节封装 RTP 包:可复制的 C/Python 实现
RTP 固定头最小 12 字节,排布是:第 0 字节高 2 位是版本号(固定 2),接着是填充位、扩展位、CSRC 计数;第 1 字节是 1bit 标记位 M 加 7bit 负载类型 PT;第 2~3 字节是序列号;第 4~7 字节是时间戳;第 8~11 字节是 SSRC。对 G711 这种没有帧内边界的编码,扩展位和 CSRC 都用不上,所以第 0 字节固定是 0x80。
3.1 RTP 包头字段对应表
| 偏移 | 字段 | 长度 | 本例取值 |
|---|---|---|---|
| 0 | V=2, P=0, X=0, CC=0 | 1 | 0x80 |
| 1 | M=0, PT=8 | 1 | 0x08 |
| 2~3 | 序列号 | 2 | 每包 +1,16bit 回绕 |
| 4~7 | 时间戳 | 4 | 8kHz 采样计数,每包 +160 |
| 8~11 | SSRC | 4 | 随机固定值,同一流不变 |
最容易忽略的是时间戳单位。RTP 时间戳不是 Unix 毫秒,也不是 clock_gettime 的返回值,而是采样周期计数。8kHz 采样时每个采样点间隔 125 微秒,一个 20ms 包正好对应 160 个采样点。发送端必须维护一个按采样点累加的计数,不能拿系统时间去算。序列号则是发包计数,每发一个包加 1,接收端靠它检测丢包和乱序。16bit 回绕是正常的,发送端直接seq++即可,不需要特殊处理;接收端做差时要按无符号回绕处理,不能直接用>比较。
G711 的负载很小,20ms 包总共 172 字节,远小于 MTU,所以不会触发 IP 分片。即使采用 60ms 打包,也才 532 字节,仍然安全。封装时不需要考虑 RTP 层分片,这和 H.264 那种几十 KB 一帧的负载完全不同。
3.2 C 封装函数 g711_rtp_pack
#include <string.h> #include <stdint.h> /* * g711_rtp_pack: 把一帧 G711 负载封装为 RTP 包 * rtp_buf 输出缓冲区,至少 12 + pcm_len 字节 * pcm_data G711A/U 编码后的负载(160/320/480...) * pcm_len 负载长度 * seq 本包序列号,调用方维护自增 * ts 本包 RTP 时间戳,调用方维护自增 * ssrc 媒体流标识,一个流内保持一致 * pt 负载类型,PCMA 是 8,PCMU 是 0 * marker 标记位,连续语音流填 0 * 返回值 RTP 包总长度 */ int g711_rtp_pack(uint8_t *rtp_buf, const uint8_t *pcm_data, int pcm_len, uint16_t seq, uint32_t ts, uint32_t ssrc, uint8_t pt, uint8_t marker) { rtp_buf[0] = 0x80; // V=2, P=0, X=0, CC=0 rtp_buf[1] = (uint8_t)(((marker & 1) << 7) | (pt & 0x7F)); rtp_buf[2] = (uint8_t)(seq >> 8); rtp_buf[3] = (uint8_t)(seq & 0xFF); rtp_buf[4] = (uint8_t)(ts >> 24); rtp_buf[5] = (uint8_t)(ts >> 16); rtp_buf[6] = (uint8_t)(ts >> 8); rtp_buf[7] = (uint8_t)(ts & 0xFF); rtp_buf[8] = (uint8_t)(ssrc >> 24); rtp_buf[9] = (uint8_t)(ssrc >> 16); rtp_buf[10] = (uint8_t)(ssrc >> 8); rtp_buf[11] = (uint8_t)(ssrc & 0xFF); memcpy(rtp_buf + 12, pcm_data, pcm_len); return 12 + pcm_len; }参数说明:seq 和 ts 必须由外部维护,因为接收端靠这两个字段做丢包检测和播放排序。rtp_buf[0] 用整字节赋 0x80,是把版本位、填充位、扩展位、CSRC 计数一次性置正确,避免缓冲区里残留的旧数据污染头部。第 1 字节的 marker 对连续语音流填 0,只有在一个语音片段开始时才可能需要置 1,但 G711 是连续 PCM 压缩,没有帧边界语义,填 0 最安全。
发送循环:
struct sockaddr_in remote; remote.sin_family = AF_INET; remote.sin_port = htons(5004); inet_pton(AF_INET, "192.168.1.100", &remote.sin_addr); uint8_t rtp_buf[12 + 512]; uint8_t enc[160]; // 从 G711 编码器读出的 20ms 帧 uint32_t ts = 1000; // 初值任意,但后续必须按采样计数累加 uint16_t seq = 2000; // 初值建议随机,工程上常用随机值 while (1) { if (read_g711_frame(enc, 160) != 160) { break; // 正常结束或出错 } int n = g711_rtp_pack(rtp_buf, enc, 160, seq, ts, 0x12345678, 8, 0); sendto(sockfd, rtp_buf, n, 0, (struct sockaddr *)&remote, sizeof(remote)); seq++; ts += 160; // 20ms @ 8kHz 采样 = 160 个采样点 }这段是典型发送逻辑:有帧就发,发完更新 seq 和 ts。read_g711_frame 是占位函数,实际工程里它可能是从音频采集线程的环形队列里取数据,也可能是 ALSA 或 ffmpeg 解码回调里拿缓冲块。ts += 160 这一行不能改成 ts += 当前时间差或 ts += 包长度,否则播放端采样边界会错位。SSRC 用 0x12345678 只是示例,实际部署建议用随机数生成,多路并发时不要重复。
3.3 Python 快速原型:struct.pack 一行装配
import socket import struct def pack_g711_rtp(pcm: bytes, seq: int, ts: int, ssrc: int, pt: int = 8) -> bytes: """把一帧 G711 负载封装为 RTP 包。pcm 长度需匹配 ptime。""" if len(pcm) % 160 != 0: raise ValueError(f"G711 负载应为 160 字节的整数倍,实际 {len(pcm)}") header = struct.pack("!BBHII", 0x80, pt, seq & 0xFFFF, ts & 0xFFFFFFFF, ssrc) return header + pcm sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.connect(("192.168.1.100", 5004)) ssrc = 0x12345678 seq = 0 ts = 0 while not stop_flag: frame = read_160_bytes_from_encoder() # 取一帧 20ms G711 数据 sock.send(pack_g711_rtp(frame, seq, ts, ssrc, pt=8)) seq = (seq + 1) & 0xFFFF ts = (ts + 160) & 0xFFFFFFFF time.sleep(0.020)struct.pack 的格式串!BBHII表示网络字节序下的两个字节、一个 16bit、两个 32bit,正好对应 RTP 头第 0~11 字节。第二个字节直接传 pt=8 的前提是 M 位为 0,如果哪帧要置 M 位,需要改成(1 << 7) | pt。用& 0xFFFF和& 0xFFFFFFFF模拟无符号回绕,避免 Python 无界整数干扰协议。
原型阶段用 time.sleep(0.020) 控节奏没问题,但上真实设备时,sleep 的累计误差会把包间隔的抖动越放越大。正确做法是由音频源节拍驱动发送,有帧就发、没帧不发,让包间隔等于真实采样间隔。
4. G711 RTP 传输避坑记录:五个高频翻车现象与修复方法
G711 跨网传输可能出现在自研网关、国标平台、软交换、对讲机服务器等不同场景,参数各异,但坑的套路基本集中在五类。每一条都是现场换来的经验,写下来省得你重新踩。
4.1 声音变调或播放加速
现象:对端听到的人声像开了倍速,音调偏高,但 RTP 包序列号连续,wireshark 看不出乱序。
原因:发送端把时间戳当成了毫秒计数器。常见两种写法,一是每 20ms 把 ts 加 1,二是直接把系统毫秒数填进 ts 字段。接收端按 8kHz 采样时钟解析,20ms 包期望 ts 增量是 160,看到错误增量就按错误的采样速率播放,音频整体被压缩或拉伸。
解决:ts 必须按采样计数递增,20ms 包加 160,40ms 包加 320。如果链路里有重采样环节,还要确保重采样后的数据确实是 8kHz 单声道再进打包函数。我习惯在发送循环里加一段自检:打印相邻两包的 ts 差值,差值除以 160 再乘以 20ms,应约等于两包真实发送间隔,偏差超过 2ms 就要查编码线程取数节奏。
4.2 对端能收到包但没声音
现象:SIP 信令 200 OK 正常,wireshark 能看到 RTP 包持续发出,但对端扬声器静音或全是噪声。
原因:PT 与 SDP 协商不一致。最典型的是 SDP 里写a=rtpmap:8 PCMA/8000,发送端却把 PT 填成 0,对端按 PCMU 解码,A-law 的内容解出来就是噪声。另一个变体是 SDP 声明 a=ptime:40,实际发包全是 160 字节的 20ms 帧,对端抖动缓冲按 40ms 消化,缓冲区一会空一会满,表现就是断续丢人声。
解决:封装前把 PT 和 ptime 作为可配置参数,从 SDP 解析结果回填。用静态 PT 也建议在 SDP 里显式写 rtpmap,再用 wireshark 核对:RTP 头里的 PT 必须等于 SDP rtpmap 映射出来的值,包负载长度必须等于 ptime 对应的字节数。
4.3 第一个包正常,后续偶发坏包
现象:对端日志偶发出现 RTP packet format error、wrong timestamp,本地封装逻辑看着没问题,重启后又消失。
原因:发送缓冲区里的历史脏数据。很多 C 代码复用栈上或缓冲池里的内存区存 RTP 包,但只写了头和负载,没把整包清干净。如果包头附近的旧字节里扩展位 X 为 1,接收端按 RFC 3550 会再去读 4 字节扩展头,负载位置整体错位。CC 位被污染成非 0 也是同理,会多出 CSRC 段,解析全乱。
解决:封装函数第一行就用整字节赋值把第 0、1 字节彻底覆盖,不要用|=只改其中几位。第 0 字节固定写 0x80,第 1 字节用(marker << 7) | (pt & 0x7F)完整算出后赋值。从那以后我写所有 RTP 封装函数都先清 12 字节头再做字段填充,宁可多花几条指令,也不赌缓冲区是干净的。
4.4 国标平台报 start preview failed maybe rtp session false
现象:GB28181 平台信令全部走完,平台端持续报 start preview failed,后面提示指向 rtp session 或 preview links 的问题。
原因:这类报错在国标平台里通常指“超时时间内没收到设备侧 RTP 媒体包”,或收到的包与 SDP 参数不一致。现场遇到最多三种:一是 200 OK 响应的 SDP m 行媒体端口写成了设备源端口,平台往那个端口收不到流;二是设备实际发送的目标 IP 或端口和平台 SDP 收流地址不一致;三是负载类型与 SDP 不一致,平台直接丢弃。
解决:按顺序排查。第一步看 200 OK 里 SDP 的 m 行端口是否和平台 INVITE 里声明的一致;第二步在设备侧抓包,确认 RTP 包的目的 IP 和端口正是平台 SDP 里的收流地址;第三步核对 PT,很多平台只收 PT=8 的 PCMA 流。三步走完这个报错基本都能定位,很多“死活不通”其实就是一个端口或一个 PT 值差了一点。
4.5 提高 PTime 后对端反而频繁丢包
现象:为了降低 50 包/秒的包率,把 PTime 改成 40ms 甚至 60ms,结果抖动缓冲溢出,丢包率比之前更高。
原因:接收端抖动缓冲通常按 SDP ptime 协商的帧大小初始化。SDP 写 20ms、实际发 40ms 包,接收端按 20ms 粒度取数据,缓冲区周期性溢出;反过来 SDP 写 40ms 但发 20ms 包,缓冲区又会饥饿。另一个原因是网络本身丢包时,大包被路由器丢弃的概率比 172 字节的小包更高。
解决:多帧打包前,先在对端 SDP 应答里把 a=ptime 与实际负载长度对齐,40ms 包声明 ptime:40,ts 增量用 320,不能还按 160 算。网络丢包超过 1% 的环境不要用 60ms 大招,保持 20ms 并接受包率更高的现实。负载长度、ptime、ts 增量三者必须一一对应,这是 G711 封装 RTP 传输里从头到尾要贯彻的原则。
5. 验证与进阶:抓包确认封装合格,再接 SIP 网关
5.1 Wireshark 三看 RTP 流
发包前先抓包。Wireshark 自带 RTP 解析器,过滤器写rtp || udp.port==5004就行。抓到包后重点看三处:一是 RTP 头的 PT 是否与 SDP 对应;二是相邻包 timestamp 增量是否等于 160 或 160 的整数倍;三是 sequence number 是否连续递增。Telephony 菜单下的 RTP 流分析还能直接给出 jitter、丢包数、到达时间间隔,这些数值比自己的日志更能反映真实传输状态。
5.2 自测脚本:不依赖平台也能验证打包正确性
手头没有 SIP 网关时,用下面这个 Python 小脚本解析收到的 RTP 包,重点检查 ts 增量和 seq 连续性,正好能把前面几个坑一次性揪出来。
import socket import struct def parse_rtp(pkt: bytes): """解析 RTP 固定头,返回关键字段;非 RTP 包返回 None""" if len(pkt) < 12: return None b0, b1 = pkt[0], pkt[1] if (b0 >> 6) != 2: return None cc = b0 & 0x0F pt = b1 & 0x7F seq, ts, ssrc = struct.unpack("!HII", pkt[2:12]) payload_len = len(pkt) - 12 - 4 * cc if b0 & 0x10: # 有扩展头 ext_len = struct.unpack("!H", pkt[12 + 2:12 + 4])[0] payload_len -= 4 + 4 * ext_len return {"pt": pt, "seq": seq, "ts": ts, "ssrc": ssrc, "payload_len": payload_len} sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(("0.0.0.0", 5004)) last_ts = None last_seq = None while True: pkt, addr = sock.recvfrom(2048) r = parse_rtp(pkt) if r is None: continue if last_ts is not None: delta_ts = (r["ts"] - last_ts) & 0xFFFFFFFF delta_seq = (r["seq"] - last_seq) & 0xFFFF if delta_ts % 160 != 0: print(f"[warn] ts 增量 {delta_ts} 不是 160 的倍数") if delta_seq != 1: print(f"[warn] seq 跳变 {delta_seq}") last_ts, last_seq = r["ts"], r["seq"]这段脚本绑定 UDP 5004 收包,解析后校验增量。正常情况下 delta_ts 恒为 160 的倍数、delta_seq 恒为 1。如果脚本反复打印警告,问题基本在发送端,先回去查第 3 章的 seq、ts 递增逻辑,不要去折腾接收端。多帧合包场景下 delta_ts 会是 320、480 这种 160 的整数倍,此时 delta_seq 仍是 1,自检逻辑同样适用。
5.3 对接前要过的最后一道关:SDP 参数一致性
最后提醒一个对接习惯:把媒体参数当成协议的一部分看待。和 SIP 网关或国标平台对接前,我会先把双方的 SDP 逐行对齐,确保 rtpmap、ptime、端口号、IP 地址四项全部一致,再开始灌音频流。有些平台开着 RTCP 周期 SR 包,发送端如果只发 RTP 不发 RTCP,部分网关超时没看到 SR 会判定流失去活性,因此自研媒体网关时建议加一个每 5 秒一次的 RTCP SR 发送任务,成本很低,能省掉不少超时断流的排查时间。
对接陌生平台时,我的固定动作是:先在本地起抓包工具,让发送端跑 10 秒 G711 流,回放抓包文件确认 PT、ts、seq、负载长度四个值全对上,再让对端出声音。每次新增一种负载类型都强制走一遍这个流程。有过一次只改 PT 不改 ptime 的前车之鉴后,我对媒体参数一致性检查就不再打折了。RTP 传输这摊事,90% 不是玄学,是字段和约定没对齐。希望帮到你。
本文还有配套的精品资源,点击获取