☰
G.711封装RTP传输:从PCM编码到UDP收发的窄带语音实践
2026/11/6 5:33:32 网站建设 项目流程

简介:围绕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 一包封 RTP,UDP 发出去,接收端解包写回 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 和 receiver
include/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_t;rtp.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 receiver

CFLAGS里的-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.02
G711 payload160 字节每样本压成 1 字节
RTP 头12 字节无 CSRC、无扩展
PT 值0 或 80 是 PCMU,8 是 PCMA
时间戳步长160每包按样本数递增
序列号步长1每包递增,16bit 回绕

按 20ms 一包算,payload 是 160 字节,加上 RTP 12 字节、UDP 8 字节、IPv4 20 字节,一个包约 200 字节。折算带宽大约 80kbps,其中 G711 载荷占 64kbps,头开销约 16kbps。这个数字在局域网和对讲场景很常见,也能解释为什么 G711 到现在还没被完全替换:实现简单、延迟低、互通性好。

注意:PT 值必须和编码方式对应。PCMU 用 0,PCMA 用 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 是 0,PCMA 的 PT 是 8,这个对应关系不能混。项目里如果只做一种,先把 PCMU 跑通最省事,因为大量播放器和抓包工具默认就认 PT=0。

编码前要确认输入是 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 "sine=frequency=1000:duration=3" \ -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/CC0x80V=2,P=0,X=0,CC=0
1M/PT0x00 或 0x08M=0,PT=0 是 PCMU,PT=8 是 PCMA
2-3sequence递增每包加 1,16bit 回绕
4-7timestamp递增每包加 160,32bit 回绕
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; /* V=2, P=0, X=0, CC=0 */ 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++每包加 1,ts += 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必须是 2,cc是 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 href="https://download.csdn.net/download/luoyourong/3335510" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询