调试实时音视频系统时,最常遇到的场景之一,就是手里只有一个抓包工具导出的pcap文件,里面全是看起来毫无规律的UDP包,但你知道里面藏着RTP,RTP里又封着H.264视频码流。目标很简单:把H.264码流完整提出来,还原成我们能直接播放、分析的裸流文件,也就是常说的264文件。这个需求在视频花屏排查、推流端开发、协议学习里都特别常见,而且用C语言写一个不依赖任何第三方库的小工具,大概几百行就能跑通。本文就把我实际做这个工具的过程、协议细节和踩过的坑完整记录一遍,适合对网络协议和音视频封装有点基础、但想自己动手实现一遍的开发者。
1. 整体思路:从pcap文件到264裸流,到底经历了什么
1.1 一个类比看懂整条处理链
可以这样理解:pcap文件就像一个快递仓库的录像,RTP包像录像里一个个贴了标签的快递箱,H.264码流是箱子里面真正要拼回来的货品。我们要做的,就是逐层拆开快递箱,把货品碎片按标签重新拼装成完整的商品。
具体到协议栈,就是下面这条处理链:
- pcap全局文件头:告诉你文件从哪里开始、链路层类型是什么
- pcap记录头:告诉你每个包的长度、抓包时间
- 链路层头(常见是以太网头或Linux SLL头):拆掉之后得到IP包
- IP头:拆掉之后得到UDP段,同时确认上层协议是UDP
- UDP头:拆掉之后得到RTP包
- RTP头:拆掉之后得到载荷,也就是H.264分包数据
- H.264分包重组:把FU-A分片重新拼回NALU,按Annex B格式写入文件
所以所谓“提取264码流”,本质上是一个逐层解封装的过程。每一层都有各自的头部结构,任何一个字段解错,后面全是乱的。
1.2 为什么用C语言,而不是Python脚本
这类任务用Python其实更省事,尤其配合抓包解析库,几行就能搞定。但我在实际项目里选择C语言,原因很实际:
一是嵌入式设备上常常没有Python环境,更没有第三方库,而C代码可以直接交叉编译扔到板子上跑,抓包日志拷回来说解就解。二是我们当时是在帮同事查一个持续出现但难以复现的视频卡顿问题,抓包文件动辄几百MB,C语言处理大文件的性能和内存占用更可控。三是我自己也想把RTP封装H.264的协议细节彻底搞明白,手写一遍比用现成库看十遍文档都管用。
不依赖libpcap也能解析pcap文件,因为pcap文件本身就是一种公开格式,文件头和记录头都非常规整,手动解析并不复杂。这样编译出来的二进制只有一个文件,部署极其轻量。
2. 协议细节是第一个坑:链路层、IP/UDP、RTP头逐一拆开
2.1 pcap文件格式与magic number识别
pcap全局文件头固定是24字节。我一般只关心两个字段:最前面4字节的magic number,以及偏移20处的网络链路层类型linktype。
标准pcap的magic number是0xa1b2c3d4,如果抓包文件是nanosecond精度,则是0xa1b23c4d。这两种都能正常解析包记录,区别只在时间戳精度,不影响我们提取码流。但我遇到过某工具导出的文件,前4字节读出来是d4 c3 b2 a1,这说明写入方用了小端字节序保存多字节字段。遇到这种情况,所有16位、32位字段都要反转,否则后面的链路类型、包长度全会读错。
读pcap记录时,每条记录头16字节,其中偏移8处是incl_len,代表这条记录在文件里实际保存的字节数。pcap文件本身就可能因为snaplen设置,只保存了每个包的前一部分。所以后面解析链路层、IP头时,所有越界检查都基于incl_len,不能想当然认为整个包都在。
2.2 链路层头与常见linktype选择
linktype决定第一个需要剥掉的头结构。我实际处理过三种:
- linktype 1:标准以太网,头14字节。如果见到
0x8100的VLAN tag,还要再多剥4字节,这是新手特别容易忽略的 - linktype 0:BSD环回口,头4字节是地址族,常见值2代表IPv4
- linktype 113:Linux cooked SLL,头16字节。用“任意接口”方式抓包时就是这种,很多人在服务器上抓包会踩这个坑
剥完链路层后,如果下一层是IPv4,就检查IP头第10个字节(协议号),等于17说明上层是UDP。IPv4头的基本长度是IHL字段乘以4,IHL常见值是5,也就是20字节。有些包带选项,IHL会大于5,代码里必须动态计算。
2.3 RTP固定头与载荷偏移
RTP头固定12字节,这是我每次都要反复看的结构:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1字节 | V/P/X/CC | 版本、填充位、扩展位、CSRC数量 |
| 1 | 1字节 | M/PT | 标记位、负载类型 |
| 2-3 | 2字节 | sequence number | 序列号,大端 |
| 4-7 | 4字节 | timestamp | 时间戳,大端 |
| 8-11 | 4字节 | SSRC | 同步源标识,大端 |
载荷偏移不是简单从第12字节开始,因为还有三个变数:
CC字段大于0时,固定头后面还有CC个CSRC,每个4字节X扩展位等于1时,CSRC后面还有扩展头,扩展头本身4字节,其中后2字节表示扩展数据占多少个32位字P填充位等于1时,载荷最后一个字节是填充长度,这部分要从载荷里去掉
我见过不少提取脚本不处理扩展头和填充位,一旦遇到带扩展头的RTP包,解析出来的H.264数据全是乱的,关键还不好排查,因为大部分包正常,偶尔几个包错误,最终体现出来就是画面偶尔花一下。
2.4 H.264在RTP里的三种封包形态
H.264码流的基本单位是NALU,RTP封H.264的规则定义在RFC 3984/6184里。实际抓包最常见的就三种形态:
第一种是单一NALU包。RTP载荷的第一个字节本身就是NALU头,后面的字节全是NALU的负载数据。SPS、PPS、SEI大多以这种方式传输。
第二种是FU-A分片包。一个大的NALU被拆成多个RTP包,这是抓包里最常见的形态,因为像IDR帧这种大NALU,单个UDP包根本装不下。FU-A的载荷至少2字节:第一个字节叫FU indicator,第二个字节叫FU header。重组的关键就在这两个字节上。
第三种是STAP-A聚合包。多个小NALU被塞进同一个RTP包,载荷第一个字节是STAP-A类型24,后面每个NALU用2字节长度再加内容拼接。实际抓包里STAP-A相对少见,但也要处理,不一定哪个设备就用了。
3. C语言实现:一个可直接编译的提取工具
3.1 核心结构与辅助函数
我实现时没有把所有逻辑塞进一个main函数,而是拆成几个模块化片段。下面这些片段拼在一起,就是一个完整的单文件程序。
辅助字节序函数非常重要,不要用结构体直接强转指针,因为会有内存对齐和字节序问题:
#include <stdio.h> #include <stdlib.h> #include <stdint.h> #include <string.h> static uint16_t rd16(const uint8_t *p) { return (uint16_t)((p[0] << 8) | p[1]); } static uint32_t rd32(const uint8_t *p) { return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; }主上下文结构体里,我记录了当前正在组装的H.264 NALU缓存、上一包的RTP信息、统计计数和目标PT:
typedef struct { FILE *out; uint8_t *buf; size_t cap; size_t len; int assembling; uint16_t last_seq; uint32_t last_ssrc; uint32_t last_ts; int target_pt; int n_complete; int n_drop; } h264_ctx;这里的assembling标志特别重要。FU-A分片不是每个包都成一个NALU,我们需要在收到起始分片S时开始缓存,收到结束分片E时才认为这个NALU完整。
3.2 从链路层到RTP包的定位
我用一个函数负责从原始帧里剥出UDP载荷的偏移和长度:
static int get_udp_payload(const uint8_t *frame, int cap_len, int linktype, int *payload_off, int *payload_len) { int l2 = 0; if (linktype == 1) { /* ethernet */ if (cap_len < 14) return -1; l2 = 14; if (rd16(frame + 12) == 0x8100) { /* vlan tag */ if (cap_len < 18) return -1; l2 += 4; } } else if (linktype == 0) { /* null/loopback */ l2 = 4; } else if (linktype == 113) { /* linux cooked sll */ l2 = 16; } else { return -1; } if (cap_len < l2 + 20) return -1; if ((frame[l2] >> 4) == 4) { /* IPv4 */ int ihl = (frame[l2] & 0x0f) * 4; if (frame[l2 + 9] != 17) return -1; /* UDP */ if (cap_len < l2 + ihl + 8) return -1; *payload_off = l2 + ihl + 8; *payload_len = rd16(frame + l2 + ihl + 4) - 8; if (*payload_len <= 0) return -1; if (*payload_off + *payload_len > cap_len) *payload_len = cap_len - *payload_off; return 0; } return -1; }这段代码没有处理IPv6,实际项目里如果需要支持IPv6,判断(frame[l2] >> 4) == 6后,把IPv6头从20字节换成40字节,再找Next Header字段等于17的位置即可。
3.3 RTP载荷的判定与H.264三种模式处理
拿到UDP载荷后,先解析RTP头,再进入H.264分发逻辑。处理RTP扩展头、填充位和PT过滤的代码需要仔细写:
static void process_rtp(h264_ctx *ctx, uint8_t *rtp, int rtp_len) { int cc, x, p, pt, payload_off; uint16_t seq; uint32_t ts, ssrc; if (rtp_len < 12) return; cc = rtp[0] & 0x0f; p = (rtp[0] >> 5) & 0x01; x = (rtp[0] >> 4) & 0x01; pt = rtp[1] & 0x7f; seq = rd16(rtp + 2); ts = rd32(rtp + 4); ssrc = rd32(rtp + 8); payload_off = 12 + 4 * cc; if (ctx->target_pt != 0 && pt != ctx->target_pt) return; if (x) { if (payload_off + 4 > rtp_len) return; int ext_words = rd16(rtp + payload_off + 2); payload_off += 4 + ext_words * 4; } if (payload_off >= rtp_len) return; if (p) { int pad = rtp[rtp_len - 1]; if (rtp_len - payload_off - pad >= 0) rtp_len -= pad; } ctx->last_seq = seq; ctx->last_ssrc = ssrc; ctx->last_ts = ts; handle_h264_payload(ctx, rtp + payload_off, rtp_len - payload_off); }这里有个值得注意的细节:target_pt默认0表示不按PT过滤。如果你知道H.264的RTP负载类型是98,就能用参数-p 98只处理这一路流。不加过滤的时候,RTCP甚至其他协议的UDP包都可能被当作RTP解析,导致输出混乱。
H.264的处理函数是核心中的核心:
static void ensure_cap(h264_ctx *ctx, size_t need) { if (ctx->cap >= ctx->len + need) return; while (ctx->cap < ctx->len + need) ctx->cap = ctx->cap ? ctx->cap * 2 : 65536; ctx->buf = realloc(ctx->buf, ctx->cap); } static void write_annexb(h264_ctx *ctx) { static const uint8_t startcode[4] = {0x00, 0x00, 0x00, 0x01}; if (ctx->len == 0) return; fwrite(startcode, 1, 4, ctx->out); fwrite(ctx->buf, 1, ctx->len, ctx->out); ctx->n_complete++; ctx->len = 0; ctx->assembling = 0; }处理时先看NALU头部的低5位类型:
static void handle_h264_payload(h264_ctx *ctx, uint8_t *payload, int payload_len) { int nal_type; uint8_t fu_indicator, fu_header; uint8_t combined_header; if (payload_len < 1) return; nal_type = payload[0] & 0x1f; if (nal_type == 28) { /* FU-A */ if (payload_len < 2) return; fu_indicator = payload[0]; fu_header = payload[1]; /* 如果遇到新的起始分片,说明上一个缓冲还没结束,属于丢包状态 */ if ((fu_header & 0x80) && ctx->assembling) { ctx->n_drop++; ctx->len = 0; } if (fu_header & 0x80) { /* S=1 */ ctx->assembling = 1; ctx->len = 0; combined_header = (fu_indicator & 0xe0) | (fu_header & 0x1f); ensure_cap(ctx, 1); ctx->buf[ctx->len++] = combined_header; } if (!ctx->assembling) return; ensure_cap(ctx, payload_len - 2); memcpy(ctx->buf + ctx->len, payload + 2, payload_len - 2); ctx->len += payload_len - 2; if (fu_header & 0x40) { /* E=1 */ write_annexb(ctx); } } else if (nal_type == 24) { /* STAP-A */ int off = 1; while (off + 2 <= payload_len) { int nalu_len = rd16(payload + off); off += 2; if (off + nalu_len > payload_len) break; write_annexb(ctx); fwrite(payload + off, 1, nalu_len, ctx->out); ctx->n_complete++; off += nalu_len; } } else { /* 单一NALU */ write_annexb(ctx); fwrite(payload, 1, payload_len, ctx->out); ctx->n_complete++; } }注意看FU-A重组时NALU头的合成方式,这是最容易写错的地方。combined_header = (fu_indicator & 0xe0) | (fu_header & 0x1f),意思是保留FU indicator里前3位的F和NRI,再从FU header取出真正的NALU类型低5位。比如一个完整NALU头是0x65,FU indicator是0xFC,起始分片的FU header是0x85,合出来就是(0xFC & 0xE0) | (0x85 & 0x1F) = 0x65,完全还原。
3.4 主循环和命令行入口
主循环负责读pcap头,遍历每条记录:
int main(int argc, char *argv[]) { FILE *fp; uint8_t file_hdr[24]; uint8_t rec_hdr[16]; uint8_t *frame; int linktype; h264_ctx ctx; int off, len; int total = 0; if (argc < 3) { fprintf(stderr, "usage: %s input.pcap output.264 [-p pt]\n", argv[0]); return 1; } fp = fopen(argv[1], "rb"); if (!fp) return 1; ctx.out = fopen(argv[2], "wb"); if (!ctx.out) return 1; memset(&ctx, 0, sizeof(ctx)); ctx.target_pt = 0; ctx.buf = malloc(65536); ctx.cap = 65536; if (argc >= 5 && strcmp(argv[3], "-p") == 0) ctx.target_pt = atoi(argv[4]); if (fread(file_hdr, 1, 24, fp) != 24) { fprintf(stderr, "not a pcap file\n"); return 1; } if (rd32(file_hdr) != 0xa1b2c3d4 && rd32(file_hdr) != 0xa1b23c4d) { fprintf(stderr, "unsupported pcap magic: 0x%08x\n", rd32(file_hdr)); return 1; } linktype = rd32(file_hdr + 20); while (fread(rec_hdr, 1, 16, fp) == 16) { int incl_len = rd32(rec_hdr + 8); if (incl_len <= 0 || incl_len > 256 * 1024) { fseek(fp, incl_len, SEEK_CUR); continue; } frame = malloc(incl_len); if (fread(frame, 1, incl_len, fp) != (size_t)incl_len) break; if (get_udp_payload(frame, incl_len, linktype, &off, &len) == 0) { process_rtp(&ctx, frame + off, len); total++; } free(frame); } fclose(fp); fclose(ctx.out); fprintf(stderr, "udp packets: %d, complete nalus: %d, dropped: %d\n", total, ctx.n_complete, ctx.n_drop); return 0; }这段代码直接存成rtp2h264.c,用主流C编译器编译就能跑。如果测试文件比较大,建议用-O2开优化。
4. 实操现场:跑通一次从抓包到裸流
4.1 测试数据与准备工作
我用来测试的pcap文件,是某局域网内一个实时预览画面卡顿问题时抓的。抓包前加了一个只保留UDP的过滤条件,端口范围限定在8000到9000之间,这样文件不至于太大。另一个经验是,抓包时建议同时开启抓包工具的“按RTP解析”功能,回头对照检查会方便很多。
编译命令很简单:
cc -O2 -o rtp2h264 rtp2h264.c ./rtp2h264 capture.pcap video.264 -p 98如果不知道具体的RTP负载类型PT是多少,就先不传-p参数,让程序把所有UDP载荷都尝试按H.264解析。实际处理时,判断某个包是不是H.264 RTP,最靠谱的还是看NALU头低5位是否落在常见类型集合里,比如1、5、6、7、8、24、28等。
4.2 用两个里程碑验证提取结果
第一次跑通后,我先检查输出文件开头的字节:
00 00 00 01 67 42 00 1e ... 00 00 00 01 68 ce 3c 80 ... 00 00 00 01 65 b8 ...看到67开头就知道SPS出来了,68开头是PPS,65开头是IDR帧,这也是H.264裸流里最标准的Annex B格式特征。如果前几秒的码流里没有67和68,那要么是抓包没抓到关键帧,要么是SPS/PPS走了其他传输通道,输出文件播放器是没法正常解码的。
第二个里程碑是用通用媒体工具尝试读取输出文件。能读出来分辨率、帧率这些信息,基本说明重组成功了一半。我当时第一次跑完,工具直接能解码播放,但画面在某个时间点开始花屏,往下一查,正好对应pcap里有一个RTP包丢了,FU-A分片没凑齐。这个现象也让我彻底理解了为什么丢包会导致花屏。
4.3 意料之中的VLAN和SLL坑
这次实际抓包遇到的第一只拦路虎,是服务器上抓包时链路层类型是113,也就是Linux SLL头。最开始代码只写了以太网头解析,结果一跑,统计显示UDP包全是0。打印帧头十六进制才发现,前14字节根本不是以太网目的MAC和源MAC,而是00 00 00 01 00 00...这种SLL结构。把linktype 113的处理补上后,UDP包计数立刻就正常了。
另一个容易忽略的是VLAN。如果现场网络里交换机带了VLAN标签,以太网头的类型字段不是0x0800而是0x8100,紧接着4字节VLAN头之后才是真正的IP头。不做VLAN跳过,IP头和UDP头解析会错位,出来的H.264码流大概率是坏的,而且错误没有规律,非常坑。
5. 实际抓包里躲不开的坑:问题排查与心得
5.1 字节序、magic与pcap变体速查
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| magic不是0xa1b2c3d4 | 文件被小端写入或改了格式 | 读取后按字节反转,或直接判断d4 c3 b2 a1 |
| 能读文件但linktype异常 | 时间戳精度变体导致解析错位 | 先识别0xa1b23c4d纳秒magic,再解析 |
| 所有包都被跳过 | linktype类型没有实现 | 用十六进制查看帧头,手工分析对应链路类型 |
| 文件末尾报错 | 抓包过程中数据没写入完整 | 适当放宽越界判断,尽量在有效记录上处理 |
字节序问题最大的迷惑性在于,文件看起来能打开,结构也能读,但所有长度和偏移全是错的。所以我在解析pcap文件头时,一旦magic不是预期的值,宁可立即报错退出,也不要用错误解析结果硬撑。
5.2 RTCP和杂包混进来怎么办
RTP和RTCP经常共用同一对端口,裸UDP过滤很难分开。RTCP包头长得很像RTP头,前两位同样是版本号10,载荷类型值却通常是200到204这种范围。
我处理这个问题的办法有三个层次:
- 抓包阶段尽量用工具先识别出RTP流,或者按目标端口过滤
- 程序里加
-p参数指定PT - 如果实在无法指定,就对每个UDP载荷做启发式判断,看NALU类型是否合法
实际派生产品里,音频RTP也会混进来。比如同一路通话里视频PT是98,音频PT是111,不按PT过滤就会把音频载荷也当成H.264处理,输出文件里会出现一堆非法NALU。
5.3 丢包导致的半帧NALU要不要丢弃
这是最值得展开的一个问题。RTP是UDP承载的,会丢包、乱序。如果FU-A分片已经收到了起始分片,后续中间丢了几个分片,那么即使最后的分片到了,这个NALU也是残缺的。
我的策略:只要发现seq gap大于1,当前正在组装的NALU直接丢弃。判断代码如下:
if (ctx->assembling && ctx->last_ssrc == ssrc) { uint16_t gap = (uint16_t)(seq - ctx->last_seq); if (gap > 1) { ctx->n_drop++; ctx->len = 0; ctx->assembling = 0; } }为什么敢这么处理?因为视频码流中,关键帧大部分用FU-A分片,如果分片缺失,即使强行输出,解码器收到的也是不完整帧,花屏反而更严重。丢弃半帧后,下一个完整关键帧到来时就能恢复正常解码。
不过要注意seq回绕。seq是16位,最大值65535,回到0是正常现象。上面用uint16_t做减法,0 - 65535计算出来就是1,不会误判为丢包。我在第一版代码里用了int判断,结果一遇到seq回绕就错误丢弃,查了好久才发现问题。
5.4 多SSRC混合流的处理思路
一个pcap文件里可能同时存在多路RTP流,甚至同一个H.264 payload type对应多个SSRC。简单处理方式是只关心第一个出现的SSRC,但这样很可能丢掉后面真正的目标流。
更稳妥的方案是把组装状态从单个h264_ctx改成以SSRC为key的哈希表,每个SSRC维护一套独立的last_seq、last_ts、组装缓存。我在代码里暂时没有贴这部分,因为多数提取场景只需要一路视频流,而且过滤条件已经把范围收得很窄了。如果读者需要处理多路,思路就是“按SSRC隔离状态”,切换SSRC时不共享任何缓存。
6. 收尾:一些让我少走弯路的小习惯
6.1 见过很多次之后,我养成了两个固定习惯
第一个习惯是,解析任何协议头都先写一个十六进制dump函数,每个包处理前先看原始字节。调试RTP解析时,直接对照抓包工具解析出的字段值,一眼就能发现偏移错了还是字节序错了,比反复读文档管用得多。
第二个习惯是,处理完一个里程碑就验证一次:先看SPS/PPS能不能正常输出,再验证IDR帧是否完整,最后用解码工具播放整个文件。不要等到所有代码写完再一次性验证,那样一旦出错,定位问题的时间会翻好几倍。
6.2 这个工具还能往哪些方向扩展
如果是在生产环境里用,这个提取工具可以考虑继续支持IPv6、多SSRC隔离、纳秒时间戳pcap、以及自动识别H.264之外的负载类型。另一个很实用的方向是直接输出MP4或TS封装,省掉再用转封装工具处理一步。不过那些功能会让代码量明显膨胀,作为协议学习工具,从pcap文件、RTP包到H.264裸流这条主干路径已经足够清晰了。
最后分享一个体会:这类工具写一次,最大的收获不是那个能用的二进制,而是对“RTP包是分片的、NALU是拼出来的”这个概念的直观理解。以后再排查视频花屏、延迟、卡顿问题,你脑子里会自然浮现出当前这个包的seq、ts、FU-A的S/E标志到底处于什么状态。这个能力,靠读文档是学不来的。