资讯动态

用C语言从pcap文件提取RTP中H.264裸流的完整方案

发布时间:2026/10/15 18:15:04 来源:尧图企业网站定制
调试实时音视频系统时最常遇到的场景之一就是手里只有一个抓包工具导出的pcap文件里面全是看起来毫无规律的UDP包但你知道里面藏着RTPRTP里又封着H.264视频码流。目标很简单把H.264码流完整提出来还原成我们能直接播放、分析的裸流文件也就是常说的264文件。这个需求在视频花屏排查、推流端开发、协议学习里都特别常见而且用C语言写一个不依赖任何第三方库的小工具大概几百行就能跑通。本文就把我实际做这个工具的过程、协议细节和踩过的坑完整记录一遍适合对网络协议和音视频封装有点基础、但想自己动手实现一遍的开发者。1. 整体思路从pcap文件到264裸流到底经历了什么1.1 一个类比看懂整条处理链可以这样理解pcap文件就像一个快递仓库的录像RTP包像录像里一个个贴了标签的快递箱H.264码流是箱子里面真正要拼回来的货品。我们要做的就是逐层拆开快递箱把货品碎片按标签重新拼装成完整的商品。具体到协议栈就是下面这条处理链pcap全局文件头告诉你文件从哪里开始、链路层类型是什么pcap记录头告诉你每个包的长度、抓包时间链路层头常见是以太网头或Linux SLL头拆掉之后得到IP包IP头拆掉之后得到UDP段同时确认上层协议是UDPUDP头拆掉之后得到RTP包RTP头拆掉之后得到载荷也就是H.264分包数据H.264分包重组把FU-A分片重新拼回NALU按Annex B格式写入文件所以所谓“提取264码流”本质上是一个逐层解封装的过程。每一层都有各自的头部结构任何一个字段解错后面全是乱的。1.2 为什么用C语言而不是Python脚本这类任务用Python其实更省事尤其配合抓包解析库几行就能搞定。但我在实际项目里选择C语言原因很实际一是嵌入式设备上常常没有Python环境更没有第三方库而C代码可以直接交叉编译扔到板子上跑抓包日志拷回来说解就解。二是我们当时是在帮同事查一个持续出现但难以复现的视频卡顿问题抓包文件动辄几百MBC语言处理大文件的性能和内存占用更可控。三是我自己也想把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 0BSD环回口头4字节是地址族常见值2代表IPv4linktype 113Linux cooked SLL头16字节。用“任意接口”方式抓包时就是这种很多人在服务器上抓包会踩这个坑剥完链路层后如果下一层是IPv4就检查IP头第10个字节协议号等于17说明上层是UDP。IPv4头的基本长度是IHL字段乘以4IHL常见值是5也就是20字节。有些包带选项IHL会大于5代码里必须动态计算。2.3 RTP固定头与载荷偏移RTP头固定12字节这是我每次都要反复看的结构偏移长度字段说明01字节V/P/X/CC版本、填充位、扩展位、CSRC数量11字节M/PT标记位、负载类型2-32字节sequence number序列号大端4-74字节timestamp时间戳大端8-114字节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码流的基本单位是NALURTP封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信息、统计计数和目标PTtypedef 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) { /* S1 */ 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) { /* E1 */ 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头是0x65FU 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开头是PPS65开头是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标志到底处于什么状态。这个能力靠读文档是学不来的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑