C语言实战:从pcap抓包提取H.264视频流与RTP解析
调试视频通话、监控摄像头、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 StreamES里面是一串 NALU。每个 NALU 在文件里通常带有起始码00 00 01 或 00 00 00 01。但是进入 RTP 传输时起始码会被去掉只保留 NALU 头和数据部分。由于网络的 MTU 通常限制在 1500 字节以内而一个 NALU特别是 I 帧的编码切片可能远大于这个值所以 RFC 6184 规定了三种 RTP 载荷方式载荷类型Type值适用场景处理难度单 NALU 包1-23NALU 长度小于 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字节需要确认是 0x0800IPv4如果是 0x86DD 就是 IPv60x8100 则是 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 里会明确写artpmap:96 H264/90000这一段就是告诉你 PT 值是 96编码格式是 H.264时钟频率是 90000 Hz。所以程序里不要写死 PT 96。更好的做法是先在 Wireshark 里打开 pcap看一下目标流的 PT 值是多少然后作为一个参数传进来。或者程序启动时打印一串提示让用户输入 PT 和 SSRC。SSRC 是同步源标识用来区分同一台设备上不同的 RTP 媒体流。典型场景是一个通话有两个 SSRC一个对应视频PT96一个对应音频PT0 或 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 字节不考虑扩展排列如下F1 bitforbidden_zero_bit正常编码器输出为 0RTP 解析时见到 1 通常表示传输错误。NRI2 bit重要性指示11 表示关键参考帧00 表示非参考帧。Type5 bitNALU 类型。Type 的取值里有几个关键数字1-5 是 VCL 层数据7 是 SPS8 是 PPS9 是访问单元分隔符28 是 FU-A24 是 STAP-A。对于 Type 在 1-23 之间的单包处理最简单直接把这个 NALU包含 NALU 头和数据部分取出来在前面加 4 字节起始码写入输出文件。SPS 和 PPS 也不需要特殊处理因为它们也落在 1-23 这个范围内7、8按普通 NALU 写就好。这里要特别说明RTP 载荷里的 NALU 是不带起始码的。很多人第一次写这个程序时把整个 RTP 载荷直接写进文件最后播放器一脸懵。正确的姿势是给每个 NALU 手动补上起始码。4.2 FU-A分片包按时间戳重组完整NALUType 为 28 的包就是 FU-A 分片。此时 RTP 载荷的开头不是 NALU 头而是两个特殊的字节FU indicator第1字节结构与 NALU 头一致Type 部分固定为 28二进制的 11100。FU header第2字节S1 bit表示这是分片的开始E1 bit表示这是分片的结束R 保留位Type5 bit才是原始 NALU 的真实类型。重组流程是这样的遇到 S1 的分片开始一个新缓冲区把这个分片数据里从第2字节之后的部分存进去同时记录原始 NALU 的 Type从 FU header 低5位取。遇到 S0、E0 的中间分片继续拼到缓冲区尾部。遇到 E1 的分片拼到缓冲区尾部此时得到一个完整的 NALU。给这个缓冲区前面加一个合成好的 NALU 头即 FU indicator 的高3位F、NRI保持不变低5位换成 FU header 里的原始 Type。一个关键校验同一个 NALU 的所有 FU-A 分片RTP 序列号必须连续。如果中间断号说明有分片丢失此时不能再继续拼下去否则数据错乱。正确做法是丢弃这个缓冲区等下一个 S1 的包再重新开始。另一个容易忽略的点是同一帧的多个分片共享同一个 RTP 时间戳。所以在代码里可以记录当前重组分片的时间戳只有时间戳相同的才算同一帧。如果时间戳突然变了但还没收到 E1说明帧不完整果断丢弃。4.3 STAP-A聚合包循环拆出多个NALUType 为 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涵盖了 SPS7、PPS8、SEI6以及普通切片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 回绕到 065535 后变 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直接按顺序全写进去没有任何问题播放器会自行处理重复参数集。但如果你想输出精简的码流可以减少重复记录是否已经写过 SPSType7和 PPSType8如果写过就跳过。这个优化在文件大到一定程度时有用但并不是必须的。需要注意的是如果 pcap 是中途抓的可能一开始根本没有 SPS/PPS这时候输出文件依然能解码但播放器看到第一个关键帧时才会拿到参数集视频播放过程可能出现起始阶段黑屏。这个问题后面会详细讲。第三个是收尾处理。所有 RTP 包处理完毕后如果fu_active仍然为 1说明最后一个 NALU 的分片没收到 E1此时不要再把半截数据写入文件。程序退出前记得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 里找 Type7/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、其中 Type7、8、5、1 各多少个。这个统计信息能快速暴露问题——比如写了几万个 NALU 但一个 Type7 都没有那基本就能断定 pcap 是中途抓的或者抓包时机太晚直接放弃前几秒黑屏的预期或者调整过滤器重新抓一次。从 pcap 提取 H.264 这件事做一次之后你对 RTP、pcap、NALU 三者的理解会上一个台阶。很多人一开始会去找现成工具但自己用 C 语言写过一遍之后你以后拿到任何封装格式的码流第一反应都能快速拆到 NALU 级别这种底层能力是任何黑盒工具都给不了的。接下来如果还想继续折腾同一个程序稍加改动就可以支持 H.265Type 改成 49 分片加 VPS/SPS/PPS、G.711 音频提取、Opus 音频核心思路完全一致。

相关新闻

Claude Code 配置管理与监控利器:claude-code-templates 模板化实践

Claude Code 配置管理与监控利器:claude-code-templates 模板化实践

1. 为什么我们需要一个 Claude Code 配置管家第一次接触 Claude Code 的人,十有八九会在配置环节卡住。不是命令记不住,而是配置项散落在太多地方:全局的~/.claude目录、项目根目录的.claude文件夹、环境变量、MCP 服务器注册表、权限白名单、…

2026/10/15 20:21:54 阅读更多 →
dsh subscriptions插件安装实战:版本兼容与headless部署避坑指南

dsh subscriptions插件安装实战:版本兼容与headless部署避坑指南

dsh-plugin-subscriptions 这个插件,我前前后后装了三轮才算彻底摸透。如果你也在搞 dsh 的本地化部署,大概率会遇到跟我一样的困惑:核心装好了,插件却报plugin tree failed to load,或者明明照着文档走,认…

2026/10/15 20:21:54 阅读更多 →
VulnHub DC-4靶机渗透全记录:hydra爆破登录与sudo tee提权

VulnHub DC-4靶机渗透全记录:hydra爆破登录与sudo tee提权

打穿VulnHub靶场DC-4的完整记录,从登录爆破到sudo提权。这篇文章是我在本地虚拟化环境里从零开始打DC-4的实操复盘,全程没有跳过任何步骤,每一条命令都是我亲手敲过的。DC-4在VulnHub系列里属于难度适中的靶机,不涉及内核漏洞利用…

2026/10/15 20:21:54 阅读更多 →

最新新闻

从快照看 Wing 语言自定义 scope 语义:new_jsii.test.w 与 Hangar 测试机制解析

从快照看 Wing 语言自定义 scope 语义:new_jsii.test.w 与 Hangar 测试机制解析

编程语言编译器云原生语言运行时 【免费下载链接】wing A programming language for the cloud ☁️ A unified programming model, combining infrastructure and runtime code into one language ⚡ 项目地址: https://gitcode.com/gh_mirrors/wi/wing 点击查看 免…

2026/10/15 21:14:21 阅读更多 →
Android横向分页LayoutManager性能优化实战

Android横向分页LayoutManager性能优化实战

1. 项目概述:为什么一个横向分页布局管理器值得花三天重写“HorizontalPageLayoutManager”——光看这个名字,你可能觉得它只是 RecyclerView 里一个不起眼的自定义 LayoutManager,无非是让列表左右滑动、一页一屏。但我在某跨平台内容展示类…

2026/10/15 21:14:21 阅读更多 →
H5 Canvas粒子爆炸动画源码拆解:从粒子系统到可配置特效模块

H5 Canvas粒子爆炸动画源码拆解:从粒子系统到可配置特效模块

简介:这是一份面向前端开发者与网页动效爱好者的H5 Canvas粒子爆炸动画特效源码包,适合用于个人主页、活动页或产品展示页的背景动画开发,帮助解决静态页面缺乏视觉冲击力的问题。压缩包共6个文件,以3个JavaScript脚本为核心&…

2026/10/15 21:14:21 阅读更多 →
STM32开发环境搭建:Eclipse + arm-none-eabi-gcc + J-Link 实战指南

STM32开发环境搭建:Eclipse + arm-none-eabi-gcc + J-Link 实战指南

入行前几年,我一直用一套商业集成环境做 STM32 开发。那时候觉得编译、下载、调试都“一键完成”,挺好的。直到同事把一摊子藏在维护期里的老代码交给我,那套 IDE 的毛病全冒出来了:打开十几万行的工程要卡半分钟,跨平…

2026/10/15 21:14:21 阅读更多 →
SDK-6.0.22.1401嵌入式协议栈实战:Modbus RTU开发与自适应波特率

SDK-6.0.22.1401嵌入式协议栈实战:Modbus RTU开发与自适应波特率

简介:本资源为Dialog Semiconductor DA14585系列蓝牙低功耗SoC专用SDK开发包(v6.0.22.1401),面向嵌入式IoT开发者、可穿戴设备固件工程师及BLE协议栈初学者,用于快速构建低功耗蓝牙应用、外设驱动与协议栈移植。压缩包…

2026/10/15 21:14:21 阅读更多 →
CameraX实战:移动端相机预览与拍照开发指南

CameraX实战:移动端相机预览与拍照开发指南

1. 为什么移动端相机开发总让人头疼做过移动端相机功能的人大概都有类似的体验:打开系统相机拍一张照片,看起来只是调个API的事,但真到自己动手实现预览加拍照的完整链路,坑就一个接一个冒出来了。预览画面拉伸变形、旋转角度对不…

2026/10/15 21:13:20 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/15 19:41:51 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →