资讯详情 G.711封装RTP传输:从PCM编码到UDP收发的窄带语音实践
📅 2026/11/6 5:33:37
简介围绕G.711编码音频的RTP封装与播放验证这份资料面向VoIP、实时音频传输及多媒体流开发的学习者聚焦将G.711音频打包为RTP包并经UDP传输、再由VLC接收播放的完整实践链路。压缩包共4个文件约2.16MB包含C语言实现源码、SDP会话描述文件、G.711测试音频和说明文档源码对应RTP头构造、序列号与时间戳生成、载荷写入及发送流程SDP用于描述媒体类型、地址与端口测试音频便于直接复现实验说明文档则给出使用指引。已有1487人学习适合需要从代码层面理解G.711 over RTP封装细节、排查VLC接收问题的开发者参考也能作为VoIP与实时音频流项目的入门样例和调试对照。1. 从一段“能听清但占带宽”的录音说起g711封装rtp传输到底在解决什么做语音对讲、坐席录音回传或简单喊话功能时g711封装rtp传输几乎是绕不开的一步采集到的 16bit PCM 太占带宽先压成 G.711 的 8bit 字节流再塞进 RTP 头走 UDP对端拆包解码就能播。这个项目就是一条最小可跑的链路C 语言实现 PCMU/PCMA 编解码按 20ms 一包封 RTPUDP 发出去接收端解包写回 PCM 或 ulaw 文件。它不适合拿来做音乐传输也不解决弱网对抗定位就是窄带语音的最小闭环。刚接触实时音频传输的人可以用它看清 RTP 头每个字段怎么填手里有自己 SDK 的人也可以把 g711.c 和 rtp.c 直接抠出来当底稿。我见过不少项目一上来就上 Opus、上 WebRTC结果连 8kHz 单声道的时间戳步长都算错声音一播放就像快进。这个工程的意义在于把“编码—打包—发送—拆包—解码”拆开摆明先把 20ms 一包的节奏跑顺再谈后面的 jitter buffer 和丢包隐藏。2. 工程骨架与数据流从 PCM 文件到 UDP 包的目录拆解拿到一份音频传输源码先别急着改业务逻辑先看文件分工。G711 over RTP 这种小工程最怕把所有东西塞进一个 main.c编码、组头、socket、文件读写全混在一起出了问题是真成黑匣子。下面这套目录是我按项目正文里的功能链路整理出来的典型结构核心模块边界清楚编译也不绕。2.1 目录结构与构建方式路径作用Makefile编译 sender 和 receiverinclude/g711.hμ-law/A-law 编解码函数声明src/g711.cG711 编解码实现include/rtp.hRTP 头长度、PT 常量、打包函数声明src/rtp.cRTP 头序列化与解析src/sender.c读 PCM、分帧、编码、UDP 发送src/receiver.cUDP 收包、RTP 解析、解码写文件tools/gen_pcm.sh生成测试用 PCM 音频README.md编译、运行和参数说明g711.c只负责样本级压缩输入int16_t输出uint8_trtp.c只负责 12 字节头的读写不碰编码sender.c和receiver.c才是业务入口。这样拆的好处是后面把文件输入换成麦克风采集时只动sender.c的读数据循环编码和打包逻辑不用重写。构建用 Makefile 就够不需要上复杂的构建系统。下面这段是我一般会保留的最小版本CC gcc CFLAGS -O2 -Wall -Wextra -Iinclude SRC src/g711.c src/rtp.c all: sender receiver sender: $(SRC) src/sender.c $(CC) $(CFLAGS) -o $ $^ receiver: $(SRC) src/receiver.c $(CC) $(CFLAGS) -o $ $^ clean: rm -f sender receiverCFLAGS里的-Iinclude让源文件能直接#include g711.h-Wall -Wextra别省RTP 头字段多类型截断和符号问题靠警告能提前抓一批。-O2是发布用调试时改成-g -O0方便用 gdb 看每包序列号。编译命令就是make清理由make clean完成。如果目标板是交叉编译环境把CC换成对应的交叉编译器即可其他不用动。2.2 一条音频包从读到发的完整数据流发送端的主线很清楚打开 PCM 文件按 160 个样本一组读出来逐个样本做 G711 编码写 12 字节 RTP 头把 payload 拼在头后面sendto发到目标 UDP 端口然后时间戳加 160序列号加 1睡 20ms 再发下一包。接收端反过来recvfrom收到 UDP 报文先检查版本号和头长度跳过 CSRC 和扩展头取出 payload逐字节解码成int16_tPCM写到输出文件。如果只是验证链路输出 ulaw 文件更省事播放器指定-f ulaw就能听如果要进自己的音频处理模块就解码成 PCM。这条链路里有两个“隐形参数”最容易翻车序列号和时间戳。序列号每包加 1用来发现丢包和乱序时间戳按实际样本数递增8kHz 下 20ms 就是 160。时间戳不是加 1也不是按毫秒加 20而是按采样点加 160。这个点后面在避坑章还会展开。2.3 关键参数表采样率、包长和 PT 值参数典型值说明采样率8000 HzG711 窄带语音常用采样率声道1单声道语音场景够用输入格式16bit 小端 PCM编码前样本格式帧长20ms实时语音常用打包间隔每包样本数1608000 × 0.02G711 payload160 字节每样本压成 1 字节RTP 头12 字节无 CSRC、无扩展PT 值0 或 80 是 PCMU8 是 PCMA时间戳步长160每包按样本数递增序列号步长1每包递增16bit 回绕按 20ms 一包算payload 是 160 字节加上 RTP 12 字节、UDP 8 字节、IPv4 20 字节一个包约 200 字节。折算带宽大约 80kbps其中 G711 载荷占 64kbps头开销约 16kbps。这个数字在局域网和对讲场景很常见也能解释为什么 G711 到现在还没被完全替换实现简单、延迟低、互通性好。注意PT 值必须和编码方式对应。PCMU 用 0PCMA 用 8。发送端写 0、接收端按 8 解声音会变成噪声或者完全不对。3. G711 编解码模块16bit PCM 怎么压成 8bit 字节流G711 不是简单把 16bit 砍掉 8bit它用的是非均匀量化小信号划分细大信号划分粗用 8bit 保住语音的可懂度。μ-law 和 A-law 是两条常见路线前者在北美和日本用得多后者在欧洲和国际链路里常见。工程里一般两个都留用宏或者运行时参数切换。3.1 μ-law 与 A-law 的取舍μ-law 的压扩曲线对小信号更友好语音听起来动态范围自然一些A-law 的抗噪表现和跨网互通更常见。RTP 里 PCMU 的 PT 是 0PCMA 的 PT 是 8这个对应关系不能混。项目里如果只做一种先把 PCMU 跑通最省事因为大量播放器和抓包工具默认就认 PT0。编码前要确认输入是 16bit 线性 PCM采样率 8kHz单声道。如果采集端是 16kHz直接丢给 G711 编码声音会变调时间戳也会对不上。常见做法是先重采样到 8kHz再进编码模块重采样放在采集回调里做别放在打包循环里不然每包处理时间不稳定。3.2 编码函数逐段拆解下面这段是 μ-law 编码的核心实现输入一个 16bit PCM 样本输出一个 8bit G711 字节。代码里保留了段表查找和符号处理逻辑和常见实现一致。#include stdint.h #define BIAS 0x84 #define CLIP 32635 /* 8 个量化段的右边界用于查找当前样本落在哪一段 */ static int16_t seg_uend[8] { 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF }; static int search_seg(int16_t val) { int i; for (i 0; i 8; i) { if (val seg_uend[i]) { return i; } } return 8; } uint8_t linear2ulaw(int16_t pcm_val) { int mask; int seg; uint8_t uval; int pcm pcm_val 2; /* 16bit 样本压到 14bit 量级 */ if (pcm 0) { pcm -pcm; mask 0x7F; /* 负数符号位 */ } else { mask 0xFF; } if (pcm CLIP) { pcm CLIP; } pcm (BIAS 2); /* 加偏置保证段内量化不越界 */ seg search_seg(pcm); if (seg 8) { return (uint8_t)(0x7F ^ mask); /* 最大段直接饱和 */ } uval (uint8_t)((seg 4) | ((pcm (seg 1)) 0x0F)); return (uint8_t)(uval ^ mask); /* 负数按位取反 */ }pcm_val 2把 16bit 输入压到 14bit 量级这是 μ-law 常见做法。mask负责符号正数用0xFF负数用0x7F最后异或回去。BIAS是 0x84右移两位后加进去作用是让量化段内的舍入更平滑。search_seg找当前样本落在第几段段号越大代表幅度越大。段内 4bit 量化通过(pcm (seg 1)) 0x0F取出。CLIP是饱和保护防止极端样本溢出。参数上输入pcm_val必须是 16bit 有符号样本输出uint8_t就是 G711 字节。如果项目里用 A-law函数结构类似但段表和符号处理不同别直接拿 μ-law 的表改个名字就上声音会明显失真。3.3 解码与文件回放验证解码是编码的逆过程先按符号位还原正负再取段号和段内量化值左移还原幅度最后减去偏置。下面这段是 μ-law 解码int16_t ulaw2linear(uint8_t u_val) { int t; u_val ~u_val; /* 编码时负数取反过这里先还原 */ t ((u_val 0x0F) 3) BIAS; t ((u_val 0x70) 4); return (int16_t)((u_val 0x80) ? (BIAS - t) : (t - BIAS)); }u_val 0x0F取段内量化值左移 3 位补回精度(u_val 0x70) 4取段号用来决定整体左移多少u_val 0x80判断符号负数返回BIAS - t正数返回t - BIAS。输出直接是 16bit PCM可以写文件也可以送进声卡。验证时先生成一个 1kHz 正弦波 PCM采样率 8kHz、单声道、16bit 小端# 生成 3 秒 1kHz 正弦波8kHz 单声道 16bit PCM ffmpeg -f lavfi -i sinefrequency1000:duration3 \ -ar 8000 -ac 1 -f s16le test.pcm # 发送端把 PCM 编成 G711 并打 RTP 包 ./sender 127.0.0.1 5004 test.pcm # 另一个终端接收并写成 ulaw 文件 ./receiver 5004 out.ulaw # 用支持裸 G711 的播放器播放 ffplay -f ulaw -ar 8000 -ac 1 out.ulaw如果听到的是稳定 1kHz 蜂鸣说明编码、打包、解码这条链基本通了。如果声音变尖、变慢或者夹杂噪声先查采样率和时间戳步长再查接收端是按 ulaw 还是 PCM 打开文件。这个验证方法很土但比一上来就放真实语音更容易定位问题血泪经验是正弦波不变调后面才有资格谈音质。4. RTP 封装与发送12 字节头里每个字段怎么填G711 只解决“样本怎么压”RTP 解决“包怎么排”。RTP 头固定 12 字节后面跟 payload。实时语音场景一般不加 CSRC也不加扩展头所以头长度就是 12。字段填错抓包工具会直接标红接收端也可能把它当普通 UDP 丢掉。4.1 RTP 头结构体与网络字节序字节偏移字段典型值说明0V/P/X/CC0x80V2P0X0CC01M/PT0x00 或 0x08M0PT0 是 PCMUPT8 是 PCMA2-3sequence递增每包加 116bit 回绕4-7timestamp递增每包加 16032bit 回绕8-11SSRC随机值标识一条流同一会话保持不变RTP 头里的多字节字段必须按网络字节序写也就是大端。直接用结构体位域跨平台会翻车编译器对位域顺序和填充的处理不一致。我一般用手写序列化逻辑清楚抓包也容易对。#include stdint.h static void write_u16_be(uint8_t *p, uint16_t v) { p[0] (uint8_t)(v 8); p[1] (uint8_t)(v); } static void write_u32_be(uint8_t *p, uint32_t v) { p[0] (uint8_t)(v 24); p[1] (uint8_t)(v 16); p[2] (uint8_t)(v 8); p[3] (uint8_t)(v); } int rtp_build_header(uint8_t *buf, uint16_t seq, uint32_t ts, uint32_t ssrc, uint8_t pt, int marker) { buf[0] 0x80; /* V2, P0, X0, CC0 */ buf[1] (uint8_t)((marker ? 0x80 : 0x00) | (pt 0x7F)); write_u16_be(buf 2, seq); write_u32_be(buf 4, ts); write_u32_be(buf 8, ssrc); return 12; }buf[0]固定 0x80表示 RTP 版本 2没有填充、没有扩展、没有 CSRC。buf[1]的高位是 marker语音包里一般设 0除非是静音后的第一个包或者一帧的结束包低 7 位是 PT。write_u16_be和write_u32_be负责大端写入避免依赖主机字节序。返回值 12 是头长度后面拼 payload 时直接用。4.2 打包循环序列号和时间戳的推进发送循环是整条链路的核心。下面这段把读文件、编码、组头、发送、节奏控制串在一起参数按 8kHz、20ms 一包写死改采样率时记得同步改samples_per_packet。int send_g711_rtp(const char *pcm_path, const char *ip, int port) { FILE *fp fopen(pcm_path, rb); if (!fp) return -1; int sock socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in dst {0}; dst.sin_family AF_INET; dst.sin_port htons(port); inet_pton(AF_INET, ip, dst.sin_addr); const int samples_per_packet 160; /* 20ms 8kHz */ int16_t pcm[160]; uint8_t payload[160]; uint8_t packet[12 160]; uint16_t seq 0; uint32_t ts 0; uint32_t ssrc 0x12345678; while (fread(pcm, sizeof(int16_t), samples_per_packet, fp) samples_per_packet) { for (int i 0; i samples_per_packet; i) { payload[i] linear2ulaw(pcm[i]); } rtp_build_header(packet, seq, ts, ssrc, 0, 0); memcpy(packet 12, payload, samples_per_packet); sendto(sock, packet, 12 samples_per_packet, 0, (struct sockaddr *)dst, sizeof(dst)); ts samples_per_packet; /* 按采样点递增不是按毫秒 */ usleep(20000); /* 20ms 发送节奏 */ } close(sock); fclose(fp); return 0; }fread每次读 160 个int16_t读不满就结束所以测试 PCM 长度最好是 320 字节的整数倍。payload存 G711 字节packet前面 12 字节是头后面 160 字节是载荷。seq每包加 1ts samples_per_packet每包加 160这两个步长别写反。usleep(20000)是节奏控制实际项目里可以用定时器或者音频设备回调驱动避免系统调度导致发送忽快忽慢。注意usleep的精度在普通桌面系统上一般够用但如果包长降到 10ms 甚至 5ms最好换成高精度定时器或者声卡回调不然发送间隔会抖。4.3 接收端拆包与简化 jitter 缓冲接收端先校验版本号再算头长度。没有 CSRC 和扩展头时头长度就是 12有 CSRC 时是12 cc * 4有扩展头时还要加 4 字节扩展头和扩展数据。下面这段解析逻辑把常见情况都覆盖了接收端可以直接用。int rtp_parse(const uint8_t *buf, int len, uint8_t *payload, int *payload_len, uint16_t *seq, uint32_t *ts) { if (len 12) return -1; int version (buf[0] 6) 0x03; if (version ! 2) return -1; int cc buf[0] 0x0F; int x (buf[0] 4) 0x01; int p (buf[0] 5) 0x01; int header_len 12 cc * 4; if (x) { if (len header_len 4) return -1; int ext_len (buf[header_len 2] 8) | buf[header_len 3]; header_len 4 ext_len * 4; } if (len header_len) return -1; *seq (uint16_t)((buf[2] 8) | buf[3]); *ts ((uint32_t)buf[4] 24) | ((uint32_t)buf[5] 16) | ((uint32_t)buf[6] 8) | buf[7]; int pad p ? buf[len - 1] : 0; *payload_len len - header_len - pad; memcpy(payload, buf header_len, *payload_len); return 0; }version必须是 2cc是 CSRC 个数x是扩展位p是填充位。扩展头长度按 32bit 字算所以ext_len * 4。填充字节数在最后一个字节里计算 payload 长度时要减掉。seq和ts按大端拼回来后面做丢包统计和 jitter 缓冲都靠它们。真实项目里不会收到一包就立刻解码播放一般会加一个简化 jitter buffer先缓存 2 到 3 个包按序列号排序发现序列号跳跃就记丢包超过一定延迟的包直接丢。这个工程可以只做“按序写文件”但二次开发时jitter buffer 是第一个要补的模块。4.4 用 tcpdump 和 Wireshark 验证包结构发送端和接收端跑起来后别只看文件有没有变大抓包最直接。本机回环测试可以这样抓# 抓 5004 端口的 UDP 包保存为 p 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