资讯详情 G.711语音RTP封装实战:20ms帧、抖动缓冲与丢包补偿
📅 2026/11/6 5:33:37
简介面向数字音频通信与VoIP入门及进阶开发者的G.711 over RTP实践资料围绕将G.711μ-law/A-law音频数据按RTP头结构封装、经UDP发送并由VLC接收播放的完整链路帮助理解实时音频流从编码、打包到会话描述与播放验证的关键环节。压缩包共4个文件、约2.16MB包含C语言封装源码、SDP会话描述、G.711测试音频及说明文档分别对应封装实现、连接参数描述、样例数据与使用指引便于对照代码梳理序列号、时间戳和负载插入等处理。已有1487人学习下载适合需要快速搭建实验环境、验证RTP封装结果并排查播放问题的读者参考。1. 一条 20ms 语音帧的旅程G.711 封装 RTP 传输卡在哪两个人对着说话对面听到的声音像被掐住脖子断字、发闷、偶尔还夹一声咔哒。这种现场我碰到过不止一次最后翻出来的问题都不在编解码算法本身而在 G.711 采出来的那 160 个字节怎么被塞进 RTP 包、时间戳又是怎么往前推的。这项目干的就是这一件事把 8kHz/16bit 的 PCM 按 20ms 切片压成 A 律或 μ 律的 160 字节负载套一个 12 字节 RTP 头走 UDP 发出去收端反着来解包、排序、补丢帧、落回 PCM。整套东西只有一个发送循环、一个抖动缓冲和两张压扩查找表不依赖任何媒体框架适合想搞清楚“语音包到底长什么样”、又不想一上来就啃整栈媒体引擎的人。2. 协议核对G.711 的 160 字节怎么对上 RTP 头的三个字段整条链路就三个数8000、160、12。8000 是采样率160 是一帧的采样数8kHz 下正好 20ms12 是 RTP 固定头长度。这三个数任何一个对不上听到的就是噪声、快放或者静音。先把它们之间的关系钉死后面写代码基本就是填空。2.1 两个律制、两种位宽输入都不是 16 位G.711 有两条曲线A 律欧洲、国内固话网用得多和 μ 律北美、日本用得多。两者都把 16 位线性 PCM 压成 8 位64kbps 恒定码率区别在于分段折线的拐点和量化间隔。容易出错的地方在于两个编码器的入参位宽不一样。μ 律吃的是 14 位符号 1 位 幅度 13 位A 律吃的是 13 位符号 1 位 幅度 12 位。直接在 16 位 PCM 上调用 A 律编码器绝大多数采样点会直接顶到饱和段听感上就是“破音 沙沙”。所以 A 律那条路上进编码器之前必须先把采样右移 3 位折到 13 位或者干脆把幅度预钳到 ±4095。这个细节很多二手实现里都悄悄吞掉了抄过去才发现音量一大就糊。负数的处理也不能用-pcm简单了事。A 律标准实现里对负数是-pcm - 1目的是让 0 附近的正负两个区间对称避免静音段出现直流偏移。2.2 12 字节 RTP 头逐字段落地RTP 固定头没有可选字段时就是 12 字节所有多字节字段按网络字节序大端写。G.711 的负载是纯字节流本身不涉及字节序这一点和 16bit PCM 不一样别把两者搞混。字段位宽本场景取值说明V22版本号写死 2P10无填充省一个尾部长度字节X10无扩展头对端若发 X1 必须跳过扩展CC40无 CSRC 列表M10静音期后第一包置 1其余 0PT70 或 80PCMU(μ律)8PCMA(A 律)静态载荷类型seq16每包 1到 65535 自然回绕timestamp32每包 160按采样数推进不是按包数SSRC32会话内随机且固定同一路音频不能变PT 0 和 8 属于 RFC 3551 里的静态载荷类型好处是 SDP 里可以只写RTP/AVP 0不写 rtpmap坏处是一旦发收两端律制不一致你收到的就是满耳朵白噪声而且不会有任何报错提示你。2.3 序列号与时间戳的推进规则序列号回答“这是第几个包”时间戳回答“这包音频在时间轴上的起点是第几个采样”。前者用来排序和算丢包后者用来给播放端做节奏对齐二者职责不同不能互相顶替。时间戳的初值必须是随机的RFC 3550 要求但增量是确定的每包加本包的采样数。20ms 包就是 16010ms 包就是 80。丢包时时间戳照样按“本该发多少”推进不能因为丢了一包就少加 160否则播放端会把后续音频提前积累几分钟就成了慢放。ptime每包采样数G.711 负载整包含 RTP 头加上 IP/UDP 头10ms8080 B92 B120 B20ms160160 B172 B200 B30ms240240 B252 B280 B40ms320320 B332 B360 B即使 40ms 也只有 360 字节离 1500 的 MTU 很远所以 G.711 不需要做分片也就没有分片重组那套麻烦。代价是单包开销比例高20ms 包里有 12 字节是纯头部占 7%这是 G.711 在窄带上要付的固定税。3. 封装链路实现从 PCM 缓冲到 sendto 的完整代码这一章按数据流顺序拆采集侧凑帧、压扩查表、组包发送、收端解包与抖动缓冲。四个部分的分工很清晰改任何一处都不太会牵连其它三处。3.1 采集侧把不定长 PCM 切成 160 采样的整帧声卡或文件读出来的 PCM 长度是任意的可能是 1024 个采样也可能是 441 个。RTP 包必须是固定节奏所以要有一层凑帧缓冲来多少收多少凑够 160 就吐一帧出来。#include string.h #include stdint.h #define PCM_FRAME_SAMPLES 160 /* 20ms 8kHz */ typedef struct { int16_t buf[PCM_FRAME_SAMPLES]; size_t len; /* 已缓存采样数0..159 */ } pcm_framer_t; /* 输入任意长度 PCM输出凑满的整帧返回产出帧数 */ size_t framer_push(pcm_framer_t *f, const int16_t *in, size_t n, int16_t out[][PCM_FRAME_SAMPLES], size_t out_cap) { size_t produced 0; while (n 0 produced out_cap) { size_t room PCM_FRAME_SAMPLES - f-len; size_t take (n room) ? n : room; memcpy(f-buf f-len, in, take * sizeof(int16_t)); f-len take; in take; n - take; if (f-len PCM_FRAME_SAMPLES) { memcpy(out[produced], f-buf, PCM_FRAME_SAMPLES * sizeof(int16_t)); f-len 0; /* 清空继续攒下一帧 */ } } return produced; }framer_push是纯状态机不碰时钟也不碰 socket所以可以直接单测喂 320 个采样应该吐出 2 帧喂 159 个采样吐 0 帧且len 159再喂 1 个吐 1 帧。这种“无副作用 边界可枚举”的结构比把凑帧逻辑写在发送循环里好排查得多。注意f-len在凑满时必须归零而不是减 160。如果有重采样环节重采样后的采样数不一定还是 160归一化到 8kHz 之后再进 framer能避免小数采样带来的漂移。3.2 压扩两张查找表与一次符号折叠编码器用标准的分段查找法先取符号、折叠成幅度、加偏置、查段号、拼指数和尾数、异或回符号位。#include stdint.h static int seg_search(int val, const int16_t *table, int size) { for (int i 0; i size; i) if (val table[i]) return i; return size; } /* μ 律输入 14 位有符号输出 8 位 */ uint8_t linear2ulaw(int16_t pcm_val) { static const int16_t seg_uend[8] {0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF}; const int BIAS 0x84; /* 132保证最小段有量化台阶 */ const int CLIP 32635; /* 防溢出上界 */ int mask, pcm pcm_val; if (pcm 0) { pcm -pcm; mask 0x7F; } else { mask 0xFF; } if (pcm CLIP) pcm CLIP; pcm BIAS; int seg seg_search(pcm, seg_uend, 8); if (seg 8) return (uint8_t)(0x7F ^ mask); /* 饱和 */ uint8_t uval (uint8_t)((seg 4) | ((pcm (seg 3)) 0x0F)); return (uint8_t)(uval ^ mask); } /* A 律输入须先折到 13 位即调用方传 pcm 3 */ uint8_t linear2alaw(int16_t pcm_val) { static const int16_t seg_aend[8] {0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF}; int mask, pcm pcm_val; if (pcm 0) { mask 0xD5; } else { mask 0x55; pcm -pcm - 1; } /* 负数偏 1保持对称 */ int seg seg_search(pcm, seg_aend, 8); if (seg 8) return (uint8_t)(0x7F ^ mask); uint8_t aval (uint8_t)(seg 4); if (seg 2) aval | (pcm 4) 0x0F; /* 前两段台阶更细 */ else aval | (pcm (seg 3)) 0x0F; return (uint8_t)(aval ^ mask); }解码方向是编码的逆运算同样两次移位一次异或int16_t ulaw2linear(uint8_t u_val) { u_val (uint8_t)~u_val; /* 先还原符号位 */ int t ((u_val 0x0F) 3) 0x84; t (u_val 0x70) 4; return (int16_t)((u_val 0x80) ? (0x84 - t) : (t - 0x84)); } int16_t alaw2linear(uint8_t a_val) { a_val ^ 0x55; int t (a_val 0x0F) 4; int seg (a_val 0x70) 4; switch (seg) { case 0: t 8; break; case 1: t 0x108; break; default: t 0x108; t seg - 1; } return (int16_t)((a_val 0x80) ? t : -t); }这两组函数最好是先做锚点自测再接网络linear2ulaw(0)应该得0xFFlinear2alaw(0)应该得0xD5linear2ulaw(-1)得0x7F。锚点过了剩下的值用来回转换的最大误差做校验A 律满载误差在 1% 以内。这一步花五分钟能省掉后面抓包猜半天的时间。3.3 组包build_rtp_packet 与发送循环组包函数只负责把 12 字节头拼好再贴负载不负责推进状态状态推进放在发送循环里两者分开才好复用。#include string.h #include stdint.h #define RTP_FIXED_HEADER 12 int build_rtp_packet(uint8_t *out, size_t out_cap, const uint8_t *payload, size_t payload_len, uint32_t ssrc, uint16_t seq, uint32_t ts, uint8_t pt, int marker) { if (out_cap RTP_FIXED_HEADER payload_len) return -1; out[0] 0x80; /* V2 P0 X0 CC0 */ out[1] (uint8_t)((marker ? 0x80 : 0x00) | (pt 0x7F)); out[2] (uint8_t)(seq 8); /* 大端 */ out[3] (uint8_t)(seq 0xFF); out[4] (uint8_t)(ts 24); out[5] (uint8_t)(ts 16); out[6] (uint8_t)(ts 8); out[7] (uint8_t)(ts); out[8] (uint8_t)(ssrc 24); out[9] (uint8_t)(ssrc 16); out[10] (uint8_t)(ssrc 8); out[11] (uint8_t)(ssrc); memcpy(out RTP_FIXED_HEADER, payload, payload_len); return (int)(RTP_FIXED_HEADER payload_len); }发送侧把压扩、组包、sendto、状态推进串起来typedef struct { uint16_t seq; uint32_t ts; uint32_t ssrc; uint8_t pt; /* 0PCMU, 8PCMA */ uint8_t first; /* 首包置 marker */ } rtp_sender_t; int rtp_send_frame(rtp_sender_t *s, int fd, const struct sockaddr_in *dst, const int16_t *pcm, size_t samples) { uint8_t payload[PCM_FRAME_SAMPLES]; if (samples PCM_FRAME_SAMPLES) return -1; for (size_t i 0; i samples; i) { if (s-pt 0) payload[i] linear2ulaw(pcm[i]); else payload[i] linear2alaw((int16_t)(pcm[i] 3)); /* 折到 13 位 */ } uint8_t pkt[RTP_FIXED_HEADER PCM_FRAME_SAMPLES]; int n build_rtp_packet(pkt, sizeof(pkt), payload, samples, s-ssrc, s-seq, s-ts, s-pt, s-first); if (n 0) return -1; ssize_t sent sendto(fd, pkt, (size_t)n, 0, (const struct sockaddr *)dst, sizeof(*dst)); if (sent 0) return -2; /* EAGAIN 交给上层决定丢帧还是重试 */ s-first 0; s-seq; /* uint16 自动回绕不用手写判断 */ s-ts (uint32_t)samples; /* 关键按采样数推进 */ return (int)sent; }节奏控制别用sendto之后usleep(20000)那样每次系统调用的耗时都会累积进周期一秒下来实际只发 4849 个包播放端听感就是越来越慢。用单调时钟的绝对时间轴static int64_t now_us(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return (int64_t)ts.tv_sec * 1000000 ts.tv_nsec / 1000; } int64_t deadline now_us(); for (;;) { deadline 20000; /* 固定步长不随发送耗时漂移 */ int64_t wait deadline - now_us(); if (wait 0) { struct timespec req { wait / 1000000, (wait % 1000000) * 1000 }; nanosleep(req, NULL); } else { deadline now_us(); /* 落后太多就重新对齐别滚雪球 */ } /* rtp_send_frame(...) */ }3.4 收端解析、抖动缓冲与丢帧补偿解析要处理的坑比组包多。有些实现会在固定头后面带扩展头有些会带 CSRC 列表硬编码payload data 12就会把扩展头当音频播出去听起来是一声短促的杂音。typedef struct { uint16_t seq; uint32_t ts; uint32_t ssrc; uint8_t pt; uint8_t marker; const uint8_t *payload; size_t payload_len; } rtp_view_t; int parse_rtp(const uint8_t *buf, size_t len, rtp_view_t *v) { if (len RTP_FIXED_HEADER) return -1; if ((buf[0] 6) ! 2) return -1; /* 版本必须是 2 */ int cc buf[0] 0x0F; int has_ext (buf[0] 4) 0x01; int has_pad (buf[0] 5) 0x01; size_t off RTP_FIXED_HEADER (size_t)cc * 4; if (len off) return -1; if (has_ext) { /* 扩展头 4 字节 N 个 32bit 字 */ if (len off 4) return -1; size_t words ((size_t)buf[off 2] 8) | buf[off 3]; off 4 words * 4; if (len off) return -1; } size_t end len; if (has_pad) { /* 末尾填充长度字节 */ if (end off) return -1; end - buf[len - 1]; if (end off) return -1; } v-marker (buf[1] 7) 0x01; v-pt buf[1] 0x7F; v-seq (uint16_t)((buf[2] 8) | buf[3]); v-ts ((uint32_t)buf[4] 24) | ((uint32_t)buf[5] 16) | ((uint32_t)buf[6] 8) | (uint32_t)buf[7]; v-ssrc ((uint32_t)buf[8] 24) | ((uint32_t)buf[9] 16) | ((uint32_t)buf[10] 8) | (uint32_t)buf[11]; v-payload buf off; v-payload_len end - off; return 0; }抖动缓冲用定长环形槽8 个槽就是 160ms 窗口对局域网和一般公网够用。#define JB_SLOTS 8 typedef struct { int16_t pcm[JB_SLOTS][PCM_FRAME_SAMPLES]; int16_t last[PCM_FRAME_SAMPLES]; /* 上一帧用于丢包补偿 */ uint8_t valid[JB_SLOTS]; uint16_t base_seq; int primed; } jitter_buffer_t; int jb_push(jitter_buffer_t *jb, uint16_t seq, const uint8_t *payload, size_t len, uint8_t pt) { if (!jb-primed) { jb-base_seq seq; jb-primed 1; } /* 用 16 位差值比较跨回绕也成立直接比大小会在回绕时翻车 */ int16_t diff (int16_t)(seq - jb-base_seq); if (diff -(JB_SLOTS / 2)) return 0; /* 太旧丢弃 */ if (diff 0) { jb-base_seq seq; diff 0; } /* 迟到但在窗口内顺延 */ if (diff JB_SLOTS) return -1; /* 超前太多溢出 */ for (size_t i 0; i len i PCM_FRAME_SAMPLES; i) jb-pcm[diff][i] (pt 0) ? ulaw2linear(payload[i]) : alaw2linear(payload[i]); jb-valid[diff] 1; return 1; } void jb_pop(jitter_buffer_t *jb, int16_t *out) { if (jb-valid[0]) { memcpy(out, jb-pcm[0], PCM_FRAME_SAMPLES * sizeof(int16_t)); memcpy(jb-last, out, PCM_FRAME_SAMPLES * sizeof(int16_t)); } else { memcpy(out, jb-last, PCM_FRAME_SAMPLES * sizeof(int16_t)); /* 重复上一帧 */ } for (int i 1; i JB_SLOTS; i) { memcpy(jb-pcm[i - 1], jb-pcm[i], PCM_FRAME_SAMPLES * sizeof(int16_t)); jb-valid[i - 1] jb-valid[i]; } jb-valid[JB_SLOTS - 1] 0; jb-base_seq; }丢包补偿这里用的是最土的办法——重复上一帧。它对语音里的稳态段元音、背景噪声还行碰上爆破音会有一点点“黏”。想再进一步就做线性插值拿上一帧的最后一个采样和下一帧的第一个采样做斜坡过渡会自然些。4. 跑通验证SDP 协商、抓包、回放三步走代码写完了不等于通了。G.711 的好处是验证手段极其简单坏处是失败时的现象都很像都是噪声或没声必须有固定的三步流程才能定位。4.1 最小 SDP少一行 rtpmap 就只能听噪声要给 ffplay、VLC 或者对端软电话喂描述写一个纯文本.sdp就够了# g711.sdp v0 o- 0 0 IN IP4 127.0.0.1 sg711-test cIN IP4 127.0.0.1 t0 0 maudio 5004 RTP/AVP 0 artpmap:0 PCMU/8000 aptime:20maudio 5004 RTP/AVP 0里的 0 是载荷类型编号不是端口也不是数量。artpmap:0 PCMU/8000是可以省的一行0 是静态类型默认就是 PCMU/8000但我建议一直写因为一旦你换成 8PCMA或者换成动态类型 96这行就是唯一能说明律制的地方。aptime:20表示建议 20ms 打包。它只是建议真实值必须由发送方实际决定接收方要能容忍 10ms 和 30ms 混着来。用 ffplay 收ffplay -protocol_whitelist file,udp,rtp -fflags nobuffer g711.sdp-fflags nobuffer会关掉 ffplay 自己的缓冲能更直接地暴露抖动问题。如果加了这个参数听着难受说明你的发送节奏或抖动缓冲确实有问题别急着怪播放器。4.2 抓包只看三个字段Wireshark 的过滤表达式用rtp然后在Telephony → RTP → Show all streams里能看到统计。真正需要盯的只有三个地方第一是包间隔。在 RTP 流分析里看 Max Delay 和 Max Jitter正常情况下 20ms 包的最大抖动不该超过几毫秒。如果 Max Delay 出现几十毫秒的尖峰问题多半在发送侧的usleep累积漂移而不是网络。第二是序列号断档。RTP → Stream Analysis会列出 Lost 包数。少一两个没事但如果一直丢且丢得均匀先检查接收端 socket 缓冲区SO_RCVBUF默认值在突发时会溢出表现和网络丢包一模一样。第三是时间戳步进。在包列表里点两个相邻包看 timestamp 差值。差值不是 160 就说明推进逻辑写错了可能是按包计数、可能是丢包时漏加了。这个错误在主观听感上往往要几分钟后才显形抓包一秒钟就能看出来。顺便说RTP 端口按约定是偶数RTCP 用 RTP1。如果你只绑了 5004 没绑 5005有些对端会持续往 5005 发 RTCP 报告而你不回几十秒后对端判你会话超时直接把流掐了。4.3 用 60 行 Python 落一个 wav 出来听抓包看结构听感还得靠耳朵。写个最小接收端把收到的包直接拼成 wav比在 Wireshark 里点 Play 更可信因为它用的是你自己的解码路径。import socket, struct, wave def ulaw2lin(u): u ~u 0xFF t ((u 0x0F) 3) 0x84 t (u 0x70) 4 return (0x84 - t) if (u 0x80) else (t - 0x84) sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 20) # 防突发溢出 sock.bind((0.0.0.0, 5004)) sock.settimeout(5.0) # 静音 5 秒即收尾 frames [] while True: try: data, _ sock.recvfrom(2048) except socket.timeout: break if len(data) 12 or (data[0] 6) ! 2: continue pt data[1] 0x7F if pt ! 0: # 只处理 μ 律 continue cc data[0] 0x0F off 12 cc * 4 payload data[off:] frames.append(b.join(struct.pack p a hrefhttps://download.csdn.net/download/luoyourong/3335510 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p