调试视频通话、监控摄像头、WebRTC或者任何 SIP 音视频应用的时候,我经常会遇到一种很尴尬的情况:对端说“画面有问题”,但本地日志看起来一切正常。这时候唯一能还原现场的东西,就是抓下来的 pcap 文件。而 pcap 里承载视频的,绝大多数情况就是 RTP 包,里面封装着 H.264 码流。问题来了——Wireshark 虽然能看到 RTP 包,也能告诉你这个包是 H.264 的某个分片,但你想把它还原成一段能直接拖进播放器播放的视频文件,还是得自己动手。
这个需求听起来很小,但干起来涉及的东西不少:pcap 格式解析、以太网/IP/UDP 头剥除、RTP 头过滤、H.264 载荷的三种形态(单包、FU-A 分片、STAP-A 聚合)处理,最后还得按正确的姿势把 NALU 写入二进制文件。本文就用 C 语言把这些步骤完整过一遍,适合正在做协议分析、音视频开发,或单纯想搞懂“从抓包到可见视频”整个链路的朋友。读完之后你不仅能拿到可用的代码思路,还能知道那些不试一遍绝对发现不了的坑。
1. 项目拆解:底层协议关系和整体思路
1.1 从pcap到H.264裸流,中间隔着哪几层
先把整个数据链路理顺。一个标准的 VoIP 视频通话或者监控摄像头推流,数据包在网络上的封装顺序是:以太网头(14字节)→ IP 头(通常 20 字节)→ UDP 头(8 字节)→ RTP 头(至少 12 字节)→ H.264 载荷。而 pcap 文件本身又在这个数据包外面套了一层格式:先是 24 字节的全局文件头,然后每条报文前面有一个 16 字节的记录头(记录时间戳和长度)。
所以我们要做的事,就是一层一层“剥洋葱”:
- 解析 pcap 全局头,拿到链路类型和抓包长度限制。
- 循环读取每条数据包的记录头,定位真实报文数据。
- 剥掉以太网头,剥掉 IP 头,剥掉 UDP 头,拿到 RTP 数据。
- 解析 RTP 头,根据载荷类型(Payload Type)和 SSRC 确认这是不是我们想要的 H.264 流。
- 把 RTP 载荷中 H.264 的 NALU 提取出来,必要时完成 FU-A 分片重组。
- 给每个 NALU 加上起始码,写入 .h264 文件。
每一步单独看都不复杂,但组合起来,任何一步判断失误都会导致最终文件播放失败。比如链路类型不是以太网而是 Linux 的 cooked capture,或者 IP 头里有选项字段导致头长度不是固定 20 字节,甚至 RTP 包中间夹杂了 RTCP 包——这些都是实际操作中一定会遇到的干扰项。
1.2 H.264在RTP中的三种载荷形态
H.264 编码器输出的原始码流叫 Elementary Stream(ES),里面是一串 NALU。每个 NALU 在文件里通常带有起始码(00 00 01 或 00 00 00 01)。但是进入 RTP 传输时,起始码会被去掉,只保留 NALU 头和数据部分。
由于网络的 MTU 通常限制在 1500 字节以内,而一个 NALU(特别是 I 帧的编码切片)可能远大于这个值,所以 RFC 6184 规定了三种 RTP 载荷方式:
| 载荷类型 | Type值 | 适用场景 | 处理难度 |
|---|---|---|---|
| 单 NALU 包 | 1-23 | NALU 长度小于 MTU | 直接提取 |
| FU-A 分片包 | 28 | 大 NALU 拆成多个 RTP 包传输 | 需要重组 |
| STAP-A 聚合包 | 24 | 多个小 NALU 合并在一个 RTP 包里 | 需要拆分 |
可以类比成快递运输:小包裹直接走普通快递(单 NALU),大件家具会被拆成几个箱子分开运(FU-A),几件小商品则可以塞进同一个大纸箱(STAP-A)。接收方要做的事情,就是把这些大大小小的包裹恢复成原来完整的那件货。
1.3 为什么自己用C语言写,而不是直接调现成库
市面上有 libpcap、ffmpeg 这些现成方案,但我的看法是:如果是学习协议解析或者做嵌入式平台的抓包分析工具,纯 C 语言手写解析是最值得投入的。
第一,可控性。libpcap 帮我们抓包和分析链路层头没问题,但如果目标平台是某个精简的 Linux 环境或者嵌入式板子,未必方便编译第三方库。自己解析 pcap 文件只需要标准文件 I/O,零依赖。
第二,协议解析本身并不复杂,核心代码也就几百行。与其依赖黑盒库,不如把 pcap、RTP、H.264 这些格式彻底摸透。这就像做菜,用料理包当然快,但自己从原材料开始做一次,你才知道每道工序为什么存在。
第三,C 语言适合这种“把字节流按结构体切开”的活儿。读文件、取字段、按位操作、memcpy 到缓冲区,这些操作在 C 语言里直来直去,性能也好,处理几百 MB 的 pcap 完全没压力。后面我会把每一步的关键代码贴出来。
2. pcap文件解析:全局头、记录头与链路层处理
2.1 pcap全局文件头结构
pcap 文件开头是 24 字节的全局文件头,在 C 语言里定义成结构体非常直观:
typedef struct _pcap_file_header { unsigned int magic; // 0xa1b2c3d4,用于判别字节序 unsigned short version_major; // 主版本号 unsigned short version_minor; // 次版本号 unsigned int thiszone; // 时区修正,一般不用管 unsigned int sigfigs; // 时间戳精度,通常为0 unsigned int snaplen; // 每包最大抓取长度 unsigned int network; // 链路层类型,1表示以太网 } pcap_file_header_t;读取的时候直接 fread 24 字节进来。有个细节:magic 字段的值是 0xa1b2c3d4,如果读出来是这个值说明文件是小端字节序(x86 平台常见),如果反了(比如看到 0xd4c3b2a1)则需要把所有多字节字段做字节序翻转。大多数场景直接按小端处理即可,但写代码时最好做个判断,否则拿到别的工具生成的 pcap 会直接蒙圈。
snaplen 是一个值得留意的字段。抓包工具为了防止单包过大拖垮性能,通常默认只抓取每个包的前一部分字节,比如 65535 或 1514。如果 snaplen 小于完整帧长度,后面解析时一定要用记录头里的实际捕获长度来裁剪,不能想当然地按完整包解析。
network 字段表示链路类型:1 是以太网,0 是 BSD loopback(头部多 4 字节),101 表示 RAW IP(没有以太网头,直接就是 IP 包)。我们写程序时主要针对以太网,但最好预留一个判断。
2.2 数据包记录头与从以太网到UDP的剥壳流程
全局头后面紧跟的,就是所有数据包记录。每条记录先有 16 字节头部:
typedef struct _pcap_pkthdr { unsigned int ts_sec; // 秒 unsigned int ts_usec; // 微秒 unsigned int incl_len; // 实际捕获的长度 unsigned int orig_len; // 原始包长度 } pcap_pkthdr_t;然后接着 incl_len 个字节的真实数据。C 语言解析流程就是反复 fread 这 16 字节,再根据 incl_len 跳到数据区,处理完后再读下一条记录。
拿到以太网帧后,先跳过 14 字节(若没有 VLAN 标签的话)。以太网头里的 EtherType(第12-13字节)需要确认是 0x0800(IPv4),如果是 0x86DD 就是 IPv6,0x8100 则是 VLAN 标签,得再跳过 4 字节。IPv4 头的长度不固定,它有个 IHL 字段(第一个字节的低四位),表示以 4 字节为单位的头部长度,所以计算方式为ihl * 4。解析出源 IP、目的 IP、协议号(应为 17 即 UDP)之后,再跳过 IP 头长度,就到了 UDP 头。
UDP 头很简单,8 字节:源端口、目的端口、长度、校验和。跳过这个头之后,剩下的就是 UDP 载荷。这个载荷可能是 RTP,也可能是 RTCP,甚至可能是 SIP 协议(如果抓的是信令端口)——所以我们不能拿到载荷就当成 RTP 解析,必须靠载荷类型字段去区分。
3. RTP协议解析:识别RTP流、区分RTCP和载荷类型
3.1 RTP固定头逐字节拆解
RTP 头最小 12 字节,定义如下:
typedef struct _rtp_header { unsigned char cc:4; // CSRC count,通常为0 unsigned char x:1; // 扩展位 unsigned char p:1; // 填充位 unsigned char v:2; // 版本,固定为2 unsigned char pt:7; // Payload Type unsigned char m:1; // Marker unsigned short seq; // 序列号 unsigned int timestamp; // 时间戳 unsigned int ssrc; // 同步源标识 } rtp_header_t;注意位域在不同编译器、不同字节序下的排列不一定一致,所以更稳妥的做法是直接读字节,然后手动移位:
unsigned char b0 = payload[0]; unsigned char version = (b0 >> 6) & 0x03; unsigned char pt = payload[1] & 0x7F; unsigned short seq = (payload[2] << 8) | payload[3]; unsigned int timestamp = (payload[4] << 24) | (payload[5] << 16) | (payload[6] << 8) | payload[7]; unsigned int ssrc = (payload[8] << 24) | (payload[9] << 16) | (payload[10] << 8) | payload[11];RTP 设计规范要求版本字段等于 2。如果解析出来版本不是 2,那么这包大概率不是 RTP,比如是 RTCP 或者别的协议伪装在 UDP 端口上。
3.2 用SSRC、PT字段过滤目标媒体流
H.264 在 RTP 里通常使用动态载荷类型,也就是 PT 值落在 96-127 之间。具体是 96 还是 99 还是 108,完全取决于协商结果。SIP 协商、WebRTC 的 SDP 里会明确写a=rtpmap:96 H264/90000,这一段就是告诉你 PT 值是 96,编码格式是 H.264,时钟频率是 90000 Hz。
所以程序里不要写死 PT == 96。更好的做法是:先在 Wireshark 里打开 pcap,看一下目标流的 PT 值是多少,然后作为一个参数传进来。或者程序启动时打印一串提示,让用户输入 PT 和 SSRC。
SSRC 是同步源标识,用来区分同一台设备上不同的 RTP 媒体流。典型场景是:一个通话有两个 SSRC,一个对应视频(PT=96),一个对应音频(PT=0 或 8)。我们的程序只关心视频流,所以除了解析 PT,还要检查 SSRC 是否匹配我们想提取的那一路。
3.3 RTCP包的识别与跳过
这个坑非常高发。很多 pcap 里 RTP 和 RTCP 共用一个 UDP 端口。RTCP 的第一个字节也有版本号,通常也是 2,且 Payload Type 的取值落在 200-204 之间(对应 SR、RR、SDES、BYE、APP 五种包类型)。如果程序不判断类型,把 RTCP 当成 RTP 去解析,会得到莫名其妙的“NALU”。
判断 RTCP 的方法很简单:解析第一个字节的版本(应为 2),然后看第二个字节的 PT。如果 PT >= 200,就是 RTCP,直接跳过。RTP 载荷类型的最高值是 127,所以只要 PT 在 200-204 范围,就绝对不可能是 RTP。
还有一种情况:UDP 端口不止承载 RTP/RTCP,还混着 SIP 信令(常在实时媒体之前的 5060 端口)。但一般抓视频流时会设置过滤条件udp.port == 具体端口,这种情况相对少见。总之,在解析流程里强化“先判版本、再判 PT、再看 SSRC”的顺序,能挡掉大部分垃圾包。
4. H.264 RTP载荷拆包:三种NALU形态的C语言处理
4.1 单NALU包:直接提取并写入
拿到 RTP 载荷之后,第一个字节就是 NALU 头。NALU 头只有 1 字节(不考虑扩展),排列如下:
- F(1 bit):forbidden_zero_bit,正常编码器输出为 0,RTP 解析时见到 1 通常表示传输错误。
- NRI(2 bit):重要性指示,11 表示关键参考帧,00 表示非参考帧。
- Type(5 bit):NALU 类型。
Type 的取值里有几个关键数字:1-5 是 VCL 层数据,7 是 SPS,8 是 PPS,9 是访问单元分隔符,28 是 FU-A,24 是 STAP-A。
对于 Type 在 1-23 之间的单包,处理最简单:直接把这个 NALU(包含 NALU 头和数据部分)取出来,在前面加 4 字节起始码,写入输出文件。SPS 和 PPS 也不需要特殊处理,因为它们也落在 1-23 这个范围内(7、8),按普通 NALU 写就好。
这里要特别说明:RTP 载荷里的 NALU 是不带起始码的。很多人第一次写这个程序时,把整个 RTP 载荷直接写进文件,最后播放器一脸懵。正确的姿势是给每个 NALU 手动补上起始码。
4.2 FU-A分片包:按时间戳重组完整NALU
Type 为 28 的包就是 FU-A 分片。此时 RTP 载荷的开头不是 NALU 头,而是两个特殊的字节:
- FU indicator(第1字节):结构与 NALU 头一致,Type 部分固定为 28(二进制的 11100)。
- FU header(第2字节):S(1 bit)表示这是分片的开始,E(1 bit)表示这是分片的结束,R 保留位,Type(5 bit)才是原始 NALU 的真实类型。
重组流程是这样的:
- 遇到 S=1 的分片,开始一个新缓冲区,把这个分片数据里从第2字节之后的部分存进去,同时记录原始 NALU 的 Type(从 FU header 低5位取)。
- 遇到 S=0、E=0 的中间分片,继续拼到缓冲区尾部。
- 遇到 E=1 的分片,拼到缓冲区尾部,此时得到一个完整的 NALU。
- 给这个缓冲区前面加一个合成好的 NALU 头,即 FU indicator 的高3位(F、NRI)保持不变,低5位换成 FU header 里的原始 Type。
一个关键校验:同一个 NALU 的所有 FU-A 分片,RTP 序列号必须连续。如果中间断号,说明有分片丢失,此时不能再继续拼下去,否则数据错乱。正确做法是丢弃这个缓冲区,等下一个 S=1 的包再重新开始。
另一个容易忽略的点是:同一帧的多个分片共享同一个 RTP 时间戳。所以在代码里可以记录当前重组分片的时间戳,只有时间戳相同的才算同一帧。如果时间戳突然变了但还没收到 E=1,说明帧不完整,果断丢弃。
4.3 STAP-A聚合包:循环拆出多个NALU
Type 为 24 的包是 STAP-A 聚合包,表示一个 RTP 包里塞了多个 NALU。它的载荷结构是:
[2字节NALU长度] [NALU1] [2字节NALU长度] [NALU2] ...
注意 NALU 长度是网络字节序(大端)。处理方式:
- 从 RTP 载荷 + 2 字节(跳过聚合包类型头)开始,先读 2 字节得到当前 NALU 的长度。
- 校验长度不超过 RTP 载荷剩余长度,否则截断或丢弃。
- 把这个 NALU(含 NALU 头)加上起始码写入文件。
- 移动到下一个 NALU 的起点,循环直到载荷结束。
STAP-A 里单个 NALU 一般都比较小,常见于 SPS、PPS、SEI 或小的切片包。处理起来比 FU-A 简单,但同样要注意剩余长度的校验。如果长度字段明显不合理,比如 0xFFFF,那大概率是解析位置不对,报错并跳出循环比硬解更安全。
5. 核心代码实现与写文件细节
5.1 读pcap并解析IP/UDP的完整流程
这部分用代码展示主干流程。整体思路:把文件里每一条数据包记录循环读出来,规范化地往上层解析,直到拿到 RTP 载荷后再进入处理函数。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> #pragma pack(push, 1) typedef struct { uint32_t magic; uint16_t version_major; uint16_t version_minor; uint32_t thiszone; uint32_t sigfigs; uint32_t snaplen; uint32_t network; } pcap_file_hdr_t; typedef struct { uint32_t ts_sec; uint32_t ts_usec; uint32_t incl_len; uint32_t orig_len; } pcap_pkt_hdr_t; #pragma pack(pop) typedef struct { uint32_t ssrc; uint8_t pt; FILE *out; uint8_t fu_buffer[1024 * 1024]; int fu_len; uint32_t fu_timestamp; uint16_t fu_last_seq; int fu_active; } stream_ctx_t; static void write_nalu(stream_ctx_t *ctx, const uint8_t *nalu, int nalu_len) { static const uint8_t start_code[4] = {0x00, 0x00, 0x00, 0x01}; fwrite(start_code, 1, 4, ctx->out); fwrite(nalu, 1, nalu_len, ctx->out); } static void handle_rtp_payload(stream_ctx_t *ctx, const uint8_t *payload, int payload_len, uint32_t timestamp, uint16_t seq) { if (payload_len < 2) return; uint8_t nal_header = payload[0]; uint8_t nal_type = nal_header & 0x1F; if (nal_type <= 23) { /* 单NALU包 */ write_nalu(ctx, payload, payload_len); } else if (nal_type == 28) { /* FU-A 分片 */ uint8_t fu_header = payload[1]; uint8_t fu_start = (fu_header >> 7) & 0x01; uint8_t fu_end = (fu_header >> 6) & 0x01; uint8_t orig_type = fu_header & 0x1F; const uint8_t *fu_data = payload + 2; int fu_data_len = payload_len - 2; if (fu_start) { ctx->fu_len = 0; ctx->fu_active = 1; ctx->fu_timestamp = timestamp; ctx->fu_last_seq = seq; ctx->fu_buffer[0] = (nal_header & 0xE0) | orig_type; memcpy(ctx->fu_buffer + 1, fu_data, fu_data_len); ctx->fu_len = 1 + fu_data_len; } else if (ctx->fu_active && seq == (uint16_t)(ctx->fu_last_seq + 1) && timestamp == ctx->fu_timestamp) { ctx->fu_last_seq = seq; memcpy(ctx->fu_buffer + ctx->fu_len, fu_data, fu_data_len); ctx->fu_len += fu_data_len; if (ctx->fu_len > (int)sizeof(ctx->fu_buffer)) { ctx->fu_active = 0; } } if (fu_end && ctx->fu_active) { write_nalu(ctx, ctx->fu_buffer, ctx->fu_len); ctx->fu_active = 0; } } else if (nal_type == 24) { /* STAP-A 聚合包 */ int off = 1; while (off + 2 <= payload_len) { uint16_t nalu_len = (payload[off] << 8) | payload[off + 1]; off += 2; if (nalu_len == 0 || off + nalu_len > payload_len) break; write_nalu(ctx, payload + off, nalu_len); off += nalu_len; } } }上面这段代码里,handle_rtp_payload的输入就是去掉了 RTP 头之后、用户数据区紧跟 NALU 数据的地方。nal_type <= 23涵盖了 SPS(7)、PPS(8)、SEI(6)以及普通切片(1-5)。FU-A 重组用了一个 1MB 的静态缓冲,足以承接绝大多数以太网 MTU 下拆成几段的大 I 帧。
主函数部分就该读取 pcap 文件,循环处理:
int extract_h264(const char *pcap_path, const char *out_path, uint8_t target_pt, uint32_t target_ssrc) { FILE *fp = fopen(pcap_path, "rb"); if (!fp) return -1; pcap_file_hdr_t fhdr; if (fread(&fhdr, 1, sizeof(fhdr), fp) != sizeof(fhdr)) { fclose(fp); return -1; } if (fhdr.magic != 0xa1b2c3d4 && fhdr.magic != 0xd4c3b2a1) { fclose(fp); return -1; } stream_ctx_t ctx; memset(&ctx, 0, sizeof(ctx)); ctx.pt = target_pt; ctx.ssrc = target_ssrc; ctx.out = fopen(out_path, "wb"); if (!ctx.out) { fclose(fp); return -1; } pcap_pkt_hdr_t phdr; uint8_t frame[65536]; while (fread(&phdr, 1, sizeof(phdr), fp) == sizeof(phdr)) { if (phdr.incl_len > sizeof(frame)) { fseek(fp, phdr.incl_len, SEEK_CUR); continue; } if (fread(frame, 1, phdr.incl_len, fp) != phdr.incl_len) break; /* 以太网头14字节,EtherType 0x0800为IPv4 */ if (phdr.incl_len < 14 + 20 + 8 + 12) continue; uint16_t ether_type = (frame[12] << 8) | frame[13]; if (ether_type != 0x0800) continue; int ip_off = 14; int ip_ihl = (frame[ip_off] & 0x0F) * 4; if (frame[ip_off + 9] != 17) continue; /* 不是UDP */ int udp_off = ip_off + ip_ihl; int udp_len = ((frame[udp_off + 4] << 8) | frame[udp_off + 5]) & 0xFFFF; int payload_len = udp_len - 8; if (payload_len < 12) continue; const uint8_t *rtp = frame + udp_off + 8; uint8_t rtp_version = (rtp[0] >> 6) & 0x03; uint8_t rtp_pt = rtp[1] & 0x7F; uint32_t rtp_ssrc = (rtp[8] << 24) | (rtp[9] << 16) | (rtp[10] << 8) | rtp[11]; if (rtp_version != 2) continue; if (rtp_pt != target_pt) continue; if (rtp_ssrc != target_ssrc) continue; int rtp_hdr_len = 12; if (rtp[0] & 0x10) rtp_hdr_len += 4 * (rtp[12] & 0x0F); /* CSRC */ if (rtp[0] & 0x20) { /* 扩展头 */ int ext_len = ((rtp[rtp_hdr_len + 2] << 8) | rtp[rtp_hdr_len + 3]) * 4; rtp_hdr_len += 4 + ext_len; } if (rtp[0] & 0x01) { /* 有填充,忽略或跳过 */ continue; /* 简单起见直接跳过,实际应考虑填充长度 */ } int rtp_len = payload_len - rtp_hdr_len; if (rtp_len <= 0) continue; uint32_t rtp_ts = (rtp[4] << 24) | (rtp[5] << 16) | (rtp[6] << 8) | rtp[7]; uint16_t rtp_seq = (rtp[2] << 8) | rtp[3]; handle_rtp_payload(&ctx, rtp + rtp_hdr_len, rtp_len, rtp_ts, rtp_seq); } fclose(ctx.out); fclose(fp); return 0; }这段代码可以处理普通的单播 RTP 流。实际工程中,RTCP 的判断其实已经包含在rtp_pt != target_pt里了,因为 RTCP 的 PT 是 200-204,不可能是 96,所以不会误处理。但如果你要处理的流 PT 正好落在异常范围,或者 pcap 里混入了奇怪协议,建议再加一个显式的 RTCP 过滤。
5.2 处理RTP载荷并输出.h264文件
上面的handle_rtp_payload已经把三种 NALU 形态都覆盖到了。但是有几个边界情况需要展开说明。
第一,RTP 头的长度不是固定的 12 字节。CSRC 字段存在时,RTP 头会额外增加 4 字节 × CC 数量的长度;RTP 扩展头存在时,要解析扩展头长度。很多实际抓包里 CC 和 X 位都是 0,但 WebRTC 的包经常带扩展头,必须处理。代码里我加了 CSRC 和扩展头的跳过逻辑,但“有填充”的情况我选择了跳过,因为填充位的存在意味着 RTP 载荷末尾有填充数据,直接忽略会丢掉尾部字节,这会对 STAP-A 的最后几个 NALU 造成影响。严谨的做法是读取 RTP 头最后一个字节得到填充长度,从载荷里减掉,而不是直接跳过整包。
第二,FU-A 重组时,除了序列号连续性,还要注意字节序。seq 是 16 位无符号整数,比较seq == ctx->fu_last_seq + 1时,如果 seq 回绕到 0(65535 后变 0),用无符号整型比较是天然没问题的。同理,时间戳也是 32 位回绕的,直接认为相等就好。这属于协议解析里最容易忽略的细节,但写出来之后基本不用操心回绕。
第三,写文件时如果每次 NALU 都调用 fwrite,频繁的系统调用会影响性能。建议加一层缓冲,比如用setvbuf(ctx->out, NULL, _IOFBF, 1 << 16),或者自己维护一块缓冲区攒够了再写。对于普遍几百 MB 的 pcap,不优化也能跑完,但如果你是处理几十 GB 的监控视频抓包,缓冲区优化能明显降低耗时。
5.3 写裸流文件的三个细节
把 NALU 写入 .h264 裸流文件,有三个细节值得单独拎出来强调。
第一个是起始码的选择。Annex B 格式支持三字节00 00 01和四字节00 00 00 01。我的建议是统一用四字节。原因很简单:在真正的 H.264 码流里,SPS、PPS、IDR 切片前都习惯用四字节起始码,四字节出现歧义的概率更低,很多解码器对四字节的容错更好。虽然四字节比三字节多一字节,但对文件体积的影响可以忽略。
第二个是 SPS/PPS 的重复写入问题。如果 pcap 里有多个关键帧,每一个关键帧前都可能带 SPS/PPS,直接按顺序全写进去没有任何问题,播放器会自行处理重复参数集。但如果你想输出精简的码流,可以减少重复:记录是否已经写过 SPS(Type=7)和 PPS(Type=8),如果写过就跳过。这个优化在文件大到一定程度时有用,但并不是必须的。需要注意的是:如果 pcap 是中途抓的,可能一开始根本没有 SPS/PPS,这时候输出文件依然能解码,但播放器看到第一个关键帧时才会拿到参数集,视频播放过程可能出现起始阶段黑屏。这个问题后面会详细讲。
第三个是收尾处理。所有 RTP 包处理完毕后,如果fu_active仍然为 1,说明最后一个 NALU 的分片没收到 E=1,此时不要再把半截数据写入文件。程序退出前记得fflush(ctx->out)并fclose,避免缓冲区数据丢失。这里看起来是常识,但我在实际代码里见过很多次因为忘了 fclose 导致输出文件大小为零的情况。
6. 实战踩坑:丢包、乱序、多流、播放失败怎么办
6.1 最常见的失败原因与排查顺序
写这个程序的过程中,我踩过不少坑,这里把最典型的按“多见程度”列出来。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 输出文件是空的 | PT 或 SSRC 配置不对 | 先用 Wireshark 确认媒体流的 PT 和 SSRC |
| 输出文件有数据但播放器无法识别 | 没加起始码,或多个 NALU 之间少了起始码 | 检查写文件逻辑,确认每个 NALU 前都写了 00 00 00 01 |
| 播放花屏/画面撕裂 | FU-A 分片丢失导致 NALU 损坏 | 检查 seq 连续性的判断逻辑,丢掉不完整的帧 |
| 前几秒黑屏 | 缺少 SPS/PPS | 从 pcap 里找 Type=7/8 的包,看是否出现在文件开头 |
| 播放器报“未知格式” | 输出文件没有保存为 .h264 后缀,或写入了非 H.264 数据 | 用 ffprobe 看文件流信息 |
最诡异的当属“PT 和 SSRC 都对,文件也有数据,但 ffplay 提示无法识别”。这种大概率是你在写文件时把整个 RTP 载荷(包含 RTP 头)都写进去了,而不是从 RTP 载荷头开始取 NALU。RTP 头的第一个字节恰好可能是合法的 NALU 头,但第二个字节以后就全错位了。
另一种高发情况是抓包工具默认过滤了非本机 IP 的包,或者 pcap 文件搞了分片重组(TCP segmentation offload 之类对 UDP 也有影响),导致以太网帧的实际长度大于 snaplen,后面的数据不完整。如果解析出来的 RTP 长度与你预期不符,可以在调试程序里打印每个包的 UDP 长度与 payload 长度,快速定位。
6.2 多路并发通话时如何快速定位目标流
实际抓包很少只有一路视频流。一个多方通话可能有十路 SSRC 混在同一个 pcap 里。如果程序里只给一个固定的 SSRC 参数,你需要先把 pcap 里所有 RTP 流的 SSRC 和 PT 列出来。最简单的办法是用 Wireshark 的“Telephony -> RTP -> RTP Streams”统计视图,它会列出所有 RTP 流对应的 SSRC、PT、起始时间、包数。
如果你希望程序自己扫描全部流而不依赖 Wireshark,可以在主循环里不按 SSRC 过滤,而是维护一个 SSRC 到输出文件的映射表:遇到新的 SSRC 就新建一个文件,文件名带上 SSRC,比如stream_12345678.h264。这样一次就能把所有视频流全部提取出来,后面再按需筛选。
这个做法我在处理某个监控平台的多路摄像头抓包时用过:一个 pcap 里同时有 8 路 H.264 视频流,端口各不相同,但 PT 都一样。只按端口过滤很容易漏,按 SSRC 自动分文件则全部无脑提取,效率高得多。
6.3 用ffplay/VLC验证提取结果的小技巧
提取完成后,第一件事就是播放验证。推荐用 ffplay 直接播放裸流:
ffplay -f h264 output.h264如果 ffplay 能正常出画,说明码流本身没问题。如果 VLC 播放,建议把文件后缀改成 .h264 再拖进去,VLC 对裸流的识别有时会依赖扩展名。更严谨的验证方式是用 ffprobe 看基本信息:
ffprobe output.h264它会告诉你码流的编码格式、解析到的 SPS/PPS 情况、分辨率、帧率等。如果 ffprobe 报错或者只能看到一个空流,那大概率是 SPS/PPS 缺失或者文件写入格式有问题。
还有一个我没少用的小技巧:如果提取出来的文件播放速度不对,不是快进就是慢放,多半是 RTP 时间戳没有正确保留导致帧率判断错误。H.264 裸流本来不含时间信息,播放器靠起始码和帧内容推断。如果你用 .h264 裸流方式不理想,下一步可以把提取到的 NALU 写入 MP4/MKV 容器,并携带 RTP 时间戳,但这就超出本文范围了。
最后分享一个实用经验:处理大型 pcap 时,程序跑完几千个包可能只需要几秒钟,但排查输出视频“只黑屏不出画”往往要花上半小时。我的习惯是先在程序里加一个统计输出:一共处理了多少个 RTP 包、写了多少个 NALU、其中 Type=7、8、5、1 各多少个。这个统计信息能快速暴露问题——比如写了几万个 NALU 但一个 Type=7 都没有,那基本就能断定 pcap 是中途抓的,或者抓包时机太晚,直接放弃前几秒黑屏的预期,或者调整过滤器重新抓一次。
从 pcap 提取 H.264 这件事,做一次之后,你对 RTP、pcap、NALU 三者的理解会上一个台阶。很多人一开始会去找现成工具,但自己用 C 语言写过一遍之后,你以后拿到任何封装格式的码流,第一反应都能快速拆到 NALU 级别,这种底层能力是任何黑盒工具都给不了的。接下来如果还想继续折腾,同一个程序稍加改动就可以支持 H.265(Type 改成 49 分片,加 VPS/SPS/PPS)、G.711 音频提取、Opus 音频,核心思路完全一致。