G.711 封装 RTP 实战:PCM 采集、打包与抖动缓冲还原
简介围绕G.711音频封装RTP传输的实践包面向VoIP、实时音频流开发及多媒体协议学习者聚焦将G.711编码音频按RTP头结构打包并通过UDP传输、最终由VLC接收播放这一完整链路。内容涉及μ-law/A-law编码特点、RTP序列号与时间戳组织、SDP会话描述及播放器解码支持适合已有一定网络与音频基础、希望打通封装与收流环节的开发者参考。压缩包共4个文件约2.16MB包含C语言实现源码、SDP描述文件、G.711测试音频和说明文档分别对应封装逻辑、会话参数配置、样例数据与使用指引便于对照理解RTP包生成和接收端配置。已有1486人学习说明该主题在实时音频传输练习中具有一定关注度。读者可从中获得G.711到RTP封装的代码思路、RTP头字段填充与发送流程、SDP与VLC播放联调方法以及基于样例音频验证链路的排错参考适合用作VoIP或流媒体入门的动手素材。1. 从一次对讲回声说起G.711 封装 RTP 到底在解决什么有个现象挺反直觉都这么多年了语音对讲、会议旁路、呼叫中心这些场景里跑得最多的音频编码还是 G.711。它不是最省的64 kbps 的固定码率比 Opus 差一大截也不是最保真的量化信噪比只有三十多 dB。但它几乎被所有终端、所有媒体服务端默认支持而且实现简单到几十 KB 的 MCU 上也能用纯位运算跑完——这就是它一直没被换掉的原因。把 G.711 封装进 RTP 传输要解决的问题其实很朴素设备端采到的是 16 bit PCM一秒钟 16 KB直接丢 UDP 既没边界也没时间信息对端根本不知道哪一段该在什么时候播放。加上 12 字节的 RTP 头带上序号、时间戳、SSRC接收端就能排序、能发现丢包、能按采样时钟还原节奏。这套东西适合做语音对讲、SIP 链路旁路采集、AI 语音对话的音频通道也适合想弄懂 RTP 却不想啃完整 RFC 的人。2. 链路拆解从麦克风采样到 UDP 出网8kHz 音频这一路要过几道手2.1 把参数先固定下来A-law、8kHz、20ms、160 字节音频链路最容易出问题的地方不是代码写错而是参数没有统一。采样率、位宽、声道数、编码律、打包时长这五个值只要有一个和对端不一致现象往往不是报错而是“能听到声音但是像在水里说话”排查起来非常麻烦。所以动手写代码之前先把这张表定下来后面所有模块都围绕它来写。参数取值依据采样率8000 Hz电话语音有效带宽 300~3400 Hz8k 采样对应 4 kHz 奈奎斯特上限够用采样位宽16 bit 有符号声卡和采集库的通用格式编码前统一转换声道1G.711 是单声道编码立体声必须先拆帧再分别编码编码律A-lawPCMA国内和欧洲线路默认μ-law 见下文差异打包时长20 ms160 个采样点和传统电话的时隙节奏对齐也是绝大多数协商里的默认值每包载荷160 字节8000 × 0.02 160 个采样每采样 1 字节包速率50 pps1 / 0.02载荷码率64 kbps160 字节 × 8 bit × 50 包算一下开销会更直观每包 12 字节 RTP 头 8 字节 UDP 头 20 字节 IP 头 40 字节50 包就是 16 kbps 的额外开销加上 64 kbps 载荷端到端大约 80 kbps。这也是为什么打包时长不能无限拉长——拉长到 60 ms 确实能把开销压到 5 kbps 左右但单包一旦丢失就是 60 ms 的音频缺口抖动缓冲的延迟也会跟着涨。20 ms 是一个被无数产品验证过的平衡点我一般不做改动除非链路的丢包率已经高到需要靠冗余来对抗。A-law 和 μ-law 的选择是个纯粹的对接问题不是技术优劣问题。国内线路、大部分国产设备默认 A-law对应 RTP 的静态载荷类型 8PCMA北美、日本方向常用 μ-law对应载荷类型 0PCMU。两种编码的位宽都是 8 bit码率一模一样唯一区别是对数压缩曲线的分段方式。选错不会有任何报错抓包看 RTP 头也完全正常只是解出来的波形是一堆噪声。这个坑后面还会详细说。2.2 目录结构和模块边界让编码层不认识网络这套东西规模不大但如果一开始就把“采集、编码、打包、发送”写在一个函数里后面想换编码或者加个抖动缓冲就得重新拆分。我习惯按职责切成四层编码层完全不知道网络的存在RTP 层完全不知道音频的存在这样二次开发时替换任意一层都不会牵连其他代码。g711-rtp-demo/ ├── include/ │ ├── g711.h # 编解码接口只依赖 stdint方便移植到 MCU │ ├── rtp.h # RTP 头读写 会话上下文不感知音频格式 │ └── jitter.h # 抖动缓冲只认序号和时间戳 ├── src/ │ ├── g711.c # A-law / μ-law 的查表与位运算实现 │ ├── rtp.c # 12 字节头的组包、解包、字段校验 │ ├── sender.c # 采集 - 编码 - 打包 - UDP 发送 │ ├── receiver.c # UDP 收 - 解包 - 抖动缓冲 - 解码 - 播放 │ └── wav_io.c # 离线自测用的 PCM16 单声道 wav 读写 ├── tools/ │ ├── wav2rtp.py # 把 wav 按 20ms 打成裸 RTP 流便于抓包比对 │ └── rtp2wav.py # 把抓到的 RTP 载荷还原成 wav做听感验证 ├── tests/ │ ├── test_g711.c # 编码往返误差上界测试 │ └── test_jitter.c # 乱序、丢包、序号回绕三类用例 └── CMakeLists.txt这个划分里最值得说一句的是g711.h只包含stdint.h。看起来是个小细节但真要往嵌入式设备上移植时这一条能省掉一堆麻烦不用把标准库、网络库、线程库一起拖进交叉编译环境直接把这个 .c 文件和头文件拷过去就能用。tools/下的两个 Python 脚本也不是可有可无的东西它们承担了整个链路的“对照组”职责——当你不确定问题出在发送端还是接收端时用脚本把同一份 wav 打成标准 RTP 流再用抓包工具比对能比较快地定位问题。sender.c和receiver.c都依赖rtp.c但互相不依赖。这个约束是为了让收发可以分别部署在两台设备上也方便在单机上做回环测试。wav_io.c只在离线自测里用正式产品里采集和播放走的是声卡或音频框架的接口替换掉它不影响主链路。3. 核心封装一帧 PCM 怎么变成带序号和时间戳的 RTP 包3.1 G.711 A-law 编码13 位有效位加对数分段G.711 的本质是把 16 bit 的线性 PCM 压成 8 bit。压缩思路不是均匀截断而是按对数曲线分段——小信号分的段多、步长细大信号分的段少、步长粗这样在听觉上信噪比分布更均匀。实现上先右移 3 位把 16 bit 降到 13 bit 有效位舍弃低 3 位听感上察觉不到的细节再查分段表定位到 8 个段落最后用掩码做位交替取反。/* g711.c 线性 PCM(16bit) - A-law(8bit) */ #define SEG_SHIFT 4 #define QUANT_MASK 0x0F /* 8 个段落的上边界对应 13 bit 有效位的分段点 */ static const int16_t seg_aend[8] {0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF}; static int search_seg(int val) { for (int i 0; i 8; i) { if (val seg_aend[i]) return i; } return 8; /* 超出最大段走饱和分支 */ } uint8_t pcm16_to_alaw(int16_t pcm) { int pcm_val pcm 3; /* 16bit - 13bit 有效位 */ int mask, seg; uint8_t aval; if (pcm_val 0) { mask 0xD5; /* 正数偶数位取反 */ } else { mask 0x55; /* 负数奇数位取反 */ pcm_val -pcm_val - 1; /* 先取反再减一避开 -32768 溢出 */ } seg search_seg(pcm_val); if (seg 8) return (uint8_t)(0x7F ^ mask); /* 饱和到最大码 */ aval (uint8_t)(seg SEG_SHIFT); if (seg 2) aval | (pcm_val 4) QUANT_MASK; /* 前两段步长固定 */ else aval | (pcm_val (seg 3)) QUANT_MASK; /* 其余段按段号移位 */ return aval ^ mask; }这段代码有三个地方容易写错。第一个是pcm 3右移的位数必须和分段表的取值范围匹配如果表的边界是按 16 bit 写的却只移了 1 位编码结果会大面积饱和听感上是严重的削波失真。第二个是负数处理-pcm_val - 1这个写法看着别扭但它保证了输入 -32768 时不会溢出成 32768用-pcm_val就会在某些编译器上得到一个负数结果。第三个是最后的^ mask正负数的掩码不同这是 A-law 标准里为了避免全 0 码静音和传输链路上的误判而特意设计的位交替漏掉这一步解码出来全是噪音。参数上输入是int16_t范围 -32768~32767输出是 0~255 的无符号字节。这是有损压缩往返一趟会有量化误差工程上一般认为误差上限在 ±1% 满量程附近听感上属于“电话音质”。如果你的对接方用的是 μ-law函数结构完全一样只需要把分段表换成 15 段的seg_uend边界到 0x1FFF正数掩码改成 0xFF、负数改成 0x7F移位逻辑相应调整。我一般把两种编码写成同一个文件里的两个函数用编译开关或者运行时配置来选择避免维护两份几乎一样的代码。3.2 RTP 头 12 字节哪些字段必须随机哪些必须递增RTP 头固定 12 字节格式是网络字节序大端。我倾向于逐字节写入而不是用结构体加htonl原因很简单结构体会引入对齐和填充问题不同编译器上的sizeof可能不是 12而逐字节写永远是对的也不用担心字节序转换函数在异常输入下的表现。/* rtp.h */ typedef struct { uint16_t seq; /* 序号每包 1允许回绕 */ uint32_t ts; /* 时间戳单位是采样点每包 160 */ uint32_t ssrc; /* 会话标识同一个会话内必须固定 */ uint8_t pt; /* 载荷类型PCMA8, PCMU0 */ } rtp_ctx_t; /* rtp.c 返回写入的头部字节数固定 12 */ int rtp_write_header(uint8_t *buf, const rtp_ctx_t *ctx, int marker) { buf[0] 0x80; /* V2, P0, X0, CC0无扩展无填充 */ buf[1] (uint8_t)((marker ? 0x80 : 0x00) | (ctx-pt 0x7F)); buf[2] (uint8_t)(ctx-seq 8); buf[3] (uint8_t)(ctx-seq 0xFF); buf[4] (uint8_t)(ctx-ts 24); buf[5] (uint8_t)(ctx-ts 16); buf[6] (uint8_t)(ctx-ts 8); buf[7] (uint8_t)(ctx-ts 0xFF); buf[8] (uint8_t)(ctx-ssrc 24); buf[9] (uint8_t)(ctx-ssrc 16); buf[10] (uint8_t)(ctx-ssrc 8); buf[11] (uint8_t)(ctx-ssrc 0xFF); return 12; }各个字段在 G.711 场景下的取值和含义用一张表对照着看最清楚字段位宽G.711 场景取值说明V版本22不是 2 的包接收端应该直接丢弃P填充10音频包不需要填充填了反而要多做一次剥除X扩展10有 CSRC 或扩展头才会置位CC40贡献源个数单路音频用不上M标记1一段话的首包置 1也可恒为 0但置 1 能让对端更快判断会话起点PT载荷类型78 PCMA0 PCMU必须和信令里协商的值一致seq序号16每包 1到 65535 后回绕到 0比较时要用有符号差值ts时间戳32每包 160单位是采样点不是毫秒SSRC32随机会话标识同一路音频必须固定变化会被当成新会话时间戳这一行值得单独强调。很多人第一次写会把时间戳按毫秒累加每包 20结果对端播放速度是对的但一旦和对端协商的时钟基准、或者和同一个会话里的其他流比如视频做同步就会全部错位。RTP 的时间戳单位是采样点数8 kHz 下 20 ms 就是 160这个换算关系必须建立起来。SSRC 的作用也不是“随便填个随机数”就完了它标识一路流接收端靠它区分不同说话人或者不同媒体源如果一个会话里每包都换 SSRC接收端会不停重建会话表现出来就是声音断断续续。3.3 发送节奏用绝对时间累加别用 sleep(0.02)打包逻辑是纯计算真正让新手卡住的是节奏控制。经常见到这样的写法循环里发一个包然后usleep(20000)。短期跑没问题跑几分钟就开始漂——因为编码耗时、send系统调用耗时、调度延迟都会累加到下一次循环实际包间隔变成 20 ms 加上这些开销一秒发出去的包少于 50 个接收端的抖动缓冲慢慢就空了听感上会出现周期性的“卡一下”。#define SAMPLE_RATE 8000 #define FRAME_MS 20 #define SAMPLES_PER_FRAME (SAMPLE_RATE * FRAME_MS / 1000) /* 160 */ /* 绝对时间推进把下一帧的发送时刻算好睡到那个时刻而不是睡固定时长 */ static void sleep_until_next(struct timespec *next, int frame_ms) { next-tv_nsec (long)frame_ms * 1000000L; while (next-tv_nsec 1000000000L) { next-tv_nsec - 1000000000L; next-tv_sec 1; } clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, next, NULL); } void sender_loop(int sock, const int16_t *pcm, size_t total_samples) { uint8_t pkt[12 SAMPLES_PER_FRAME]; rtp_ctx_t ctx { .seq (uint16_t)(rand() 0xFFFF), .ts (uint32_t)rand(), /* 初始时间戳随机符合 RFC 建议 */ .ssrc (uint32_t)rand(), .pt 8 }; /* PCMA */ struct timespec next; clock_gettime(CLOCK_MONOTONIC, next); size_t off 0; int first 1; while (off total_samples) { size_t n total_samples - off; if (n SAMPLES_PER_FRAME) n SAMPLES_PER_FRAME; int hdr rtp_write_header(pkt, ctx, first); for (size_t i 0; i n; i) { pkt[hdr i] pcm16_to_alaw(pcm[off i]); /* 逐点编码载荷紧跟在头后面 */ } ssize_t sent send(sock, pkt, hdr n, 0); if (sent 0) { /* 真实工程这里要区分 EAGAIN / EINTRUDP 发送失败不能重传音频 */ } ctx.seq; /* 16 位自然回绕不用手动处理 */ ctx.ts (uint32_t)n;/* 按实际送出的采样数步进不是固定 160 */ first 0; off n; sleep_until_next(next, FRAME_MS); } }时间戳这里写的是ctx.ts n而不是 160是个有意的处理。当最后一帧不满 160 个采样时按实际样本数推进才能保证时间轴连续如果硬加 160尾巴上会多出一段不存在的静音录音时长和原始 wav 对不上做回环测试时误差会一直累积。同理序号用uint16_t自增溢出自然回绕到 0不需要额外判断。还有一个容易被忽略的点发送线程和采集线程要解耦。真实产品里采集是阻塞的声卡每隔 20 ms 回调一次给你 160 个采样点如果直接在回调里做编码和send一旦send卡住对端不收、网络缓冲满采集回调就会被拖慢声卡侧直接丢采样听到的是“咔咔”声。稳妥的做法是采集回调只往一个环形队列里写发送线程按 20 ms 从队列里取。队列长度稳定在一两帧说明节奏正常持续增长就说明下游消费跟不上这时候该做的是丢帧而不是无限缓冲——音频流不怕丢怕的是越积越多的延迟。4. 接收侧还原从 RTP 包到可播放 PCM 的三步4.1 解包先把头处理正确再谈解码接收端第一件事是把 12 字节头解析出来同时做防御性校验。网络上的数据不能信任长度不够、版本不对、扩展头越界这些情况在真实链路上都会出现尤其是和第三方设备对接时。/* rtp.c */ typedef struct { uint16_t seq; uint32_t ts; uint32_t ssrc; uint8_t pt; int marker; } rtp_hdr_t; /* 返回 0 成功-1 表示包非法 */ int rtp_parse(const uint8_t *buf, size_t len, rtp_hdr_t *out, const uint8_t **payload, size_t *plen) { if (len 12) return -1; if ((buf[0] 6) ! 2) return -1; /* 版本必须是 2 */ int cc buf[0] 0x0F; /* 贡献源个数 */ out-marker buf[1] 7; out-pt buf[1] 0x7F; out-seq (uint16_t)((buf[2] 8) | buf[3]); out-ts ((uint32_t)buf[4] 24) | ((uint32_t)buf[5] 16) | ((uint32_t)buf[6] 8) | (uint32_t)buf[7]; out-ssrc ((uint32_t)buf[8] 24) | ((uint32_t)buf[9] 16) | ((uint32_t)buf[10] 8) | (uint32_t)buf[11]; size_t off 12 (size_t)cc * 4; /* 跳过 CSRC 列表 */ if (buf[0] 0x10) { /* X1存在扩展头 */ if (len off 4) return -1; size_t ext_words ((size_t)buf[off 2] 8) | buf[off 3]; off 4 ext_words * 4; } if (buf[0] 0x20) { /* P1末尾有填充字节 */ if (len off 1) return -1; off buf[len - 1]; } if (off len) return -1; *payload buf off; *plen len - off; return 0; }几个参数的取法要留意。CSRC 的个数在buf[0]的低 4 位每个占 4 字节必须跳过否则会把贡献源 ID 当成音频数据解码声音会出一段噪音。扩展头的长度字段单位是 32 位字不是字节所以要乘 4。填充字节的数量记录在包的最后一个字节里同样要跳过。这三个分支在只和自己人对接收发时可以都不触发但一旦接入别的设备或者经过某些中间设备就会冒出来。我一般宁可多写这十几行也不想在深夜对着一段莫名其妙的噪音抓包。载荷长度plen也要检查G.711 正常应该是 160 字节出现明显大于 160 的包要么是对端打包时长不一致要么是包被拼接过处理策略是按 160 切分或者直接丢弃整包具体看业务对连续性的要求。4.2 抖动缓冲用序号回绕差值排队按时间戳出队网络到达的包不会按发送节奏均匀到达可能三个包挤在一起然后 60 ms 一个都没有。如果来一个播一个听感就是忽快忽慢。抖动缓冲的作用是用一点延迟换平稳先把包按序号放进固定深度的槽位攒够几帧再开始播之后每次取“期望序号”的那个槽位缺了就走丢包补偿。/* jitter.h */ #define JB_SLOTS 16 /* 缓冲深度16 帧 320ms 上限 */ #define JB_TARGET 4 /* 起播前攒 4 帧 80ms */ typedef struct { uint8_t data[JB_SLOTS][160]; uint16_t seq[JB_SLOTS]; uint8_t used[JB_SLOTS]; uint16_t expect; /* 下一个该播放的序号 */ uint16_t first; int primed; /* 是否已进入正常播放态 */ int fill; } jitter_t; /* 有符号差值天然处理 16 位序号回绕比绝对比较靠谱得多 */ static inline int16_t seq_diff(uint16_t a, uint16_t b) { return (int16_t)(a - b); } void jb_push(jitter_t *jb, uint16_t s, const uint8_t *payload, size_t n) { if (n 0 || n 160) return; int slot s % JB_SLOTS; if (jb-used[slot] jb-seq[slot] s) return; /* 重复包静默丢弃 */ if (jb-primed seq_diff(s, jb-expect) 0) return; /* 已经播过的迟到包 */ if (jb-primed seq_diff(s, jb-expect) JB_SLOTS) return;/* 超前的包装不下 */ memcpy(jb-data[slot 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