简介:rtp2h264 是一份面向网络视频开发初学者及监控、嵌入式方向工程师的实战型代码包,聚焦将 UDP 收到的 RTP 包中的 H264 数据解封装、重组并写入文件,生成可播放的 H264 文件,适用于摄像头实时取流、流媒体服务端调试等场景。压缩包共 14 个文件,约 9KB,以 6 个 .h 头文件和 4 个 .cpp 源文件为主体:rtpdecode 负责 RTP 头解析与 NAL 单元重排,MyUdp 封装套接字收发,CommonCode 提供通用工具函数;另有 vcxproj 工程文件、filters 与 user 配置及 ReadMe 说明,可用 Visual Studio 打开编译。目前已有 1032 人学习下载。代码围绕 RTP 序列号、时间戳与 NAL 单元边界处理展开,读者可据此理解 Annex B 起始码补写、分包与粘包修复思路,并以此为骨架扩展 RTCP 反馈、多路并发与文件写入策略,适合对照调试与二次开发。
1. 从一段抓不下来的 RTP 流说起
前阵子碰到个现场问题:设备侧只开了一个 UDP 端口,里面是 RTP 封装的 H.264 码流,对方要的却是一个能直接交给第三方的.h264裸流文件。用抓包工具先存成 pcap 再离线转,几百兆的文件转一次要十几分钟,调试时根本等不起。后来干脆写了个小工具,绑住端口直接收 UDP、剥掉 RTP 头、把被切碎的 NAL 重新拼回去、补上 Annex-B 起始码再写文件,跑起来之后边收边能播。这套东西原理不复杂,但 RTP 转 H.264 的坑几乎全藏在细节里:FU-A 分片丢一个包整帧就废,SPS/PPS 没带上播放器直接黑屏,缓冲区小一点内核悄悄帮你丢包。下面按模块拆,从 UDP 收包一路讲到文件落盘,每一段都能单独拎出来改。
2. 项目定位与技术选型:为什么不用现成工具,非要自己收 UDP
2.1 两个技术路线的取舍
把 RTP 里的 H.264 存成文件,市面上大致两条路。第一条是先抓包存 pcap,再用离线工具二次解析导出裸流;第二条是在进程里直接收 UDP,内存里剥完头就落盘。前者胜在通用,任何协议都能抓,但代价很实在:磁盘上先躺一份和原始码流差不多大的 pcap,再跑一次全量解析,中间还要人工挑流、挑端口。现场调试时,一段十分钟的流转完,问题现象可能已经过去了。
后者则是把 RTP 当成一个"带序号和时间戳的信封"来看待。RTP 头本身固定 12 字节,加上可选的 CSRC 列表和扩展头,剩下的全是载荷;载荷里的内容是不是 H.264、怎么切片的,由载荷类型和 NAL 头决定。剥信封这件事的工作量很小,写一遍两三百行就够,真正耗时间的是边界情况:分片乱序、起始包丢失、参数集缺失。所以这个工具选的是第二条路,定位很明确——一个只做一件事的命令行收流器:监听 UDP 端口,把 RTP 载荷还原成 Annex-B 裸流写进文件。
它适合的人群也比较具体:做 IPC/网络摄像机接入的、做直播拉流网关的、做嵌入式设备调试的,以及那些手上有一路 RTP 源但拿不到录制文件的开发者。不适合拿来做通用抓包分析,也不适合替代完整的媒体服务器——它没有 RTCP 反馈、没有 jitter buffer、不做丢包重传,就是一个"能收多少存多少"的务实工具。
2.2 技术栈与模块划分
语言选 C99,依赖只有 POSIX socket 和 stdio,不引第三方库。原因很实际:这类工具最终经常要跑到 ARM 板子、边缘盒子上,交叉编译一次就完事,多一个依赖就多一层编译麻烦。构建用一份 Makefile,三个源文件,加起来不到八百行。
目录结构大致是这样:
rtp2h264/ ├── Makefile ├── src/ │ ├── main.c # 参数解析、socket 建立、收包主循环、信号处理 │ ├── rtp.h/.c # RTP 固定头 + CSRC + 扩展头解析,输出纯载荷 │ ├── h264.h/.c # NAL 类型分派、FU-A 重组、SPS/PPS 缓存、写盘 │ └── writer.h/.c # 文件句柄、起始码写入、缓冲与 flush 策略 └── scripts/ └── recv.py # 20 行 Python 版本,用于快速验证端口有没有数据模块职责拆得比较干净,方便二次开发时替换其中一层:
| 文件 | 职责 | 关键接口 |
|---|---|---|
rtp.c | 校验版本号、跳过 CSRC 与扩展头、去掉 padding、输出载荷指针和长度 | int rtp_parse(const uint8_t *buf, size_t len, rtp_header_t *out) |
h264.c | 按 NAL 类型分派、FU-A 状态机拼包、维护 SPS/PPS 副本 | int h264_on_payload(h264_ctx_t *ctx, const uint8_t *p, size_t n, uint32_t ts) |
writer.c | 写00 00 00 01起始码、控制setvbuf与fflush时机 | int annexb_write(FILE *fp, const uint8_t *nal, size_t len) |
main.c | 选项解析、SO_RCVBUF、收包循环、SIGINT 收尾 | int main(int argc, char **argv) |
这种分层的好处是:想把输出从文件换成管道、换成 socket 转发、换成内存环形缓冲,只需要改writer.c,rtp.c和h264.c完全不动。我后面做"边收边转 fMP4"的改造时,就是靠这个边界省了大事。
2.3 一条数据从 socket 到文件的完整走向
把整条链路的时序摊开,大致是五步。第一步,recvfrom从绑定的端口取走一个 UDP 数据报,缓冲区开到 65535,避免被截断。第二步,rtp_parse校验版本号是不是 2,跳过CC * 4字节的 CSRC 列表,如果扩展位X=1就再跳过4 + length * 4字节的扩展头,最后按 padding 位裁掉尾部填充。第三步,拿到payload和payload_len之后,看载荷首字节的低 5 位——也就是 NAL 类型——决定走哪条分支:1~23是完整 NAL,24是 STAP-A 聚合包,28是 FU-A 分片。第四步,完整 NAL 直接写;分片进状态机缓冲,等收到E=1的那一片再一次性写;聚合包拆成多个 NAL 依次写。第五步,每次写盘之前先补 4 字节起始码,同时把SPS(7)、PPS(8)顺手缓存一份,等遇到 IDR 帧时检查是否需要补插。
这中间有个容易被忽略的点:写进.h264文件的是一条没有容器、没有时间戳、没有帧率的裸流。播放器打开它的时候,只能靠解析 NAL 边界自己找帧,播放速度全靠猜。所以文件能播不代表工具写对了,能播且画面不花、不跳、不卡在某一帧,才算真的跑通。
3. RTP 头拆解与 H.264 载荷还原:三种打包模式怎么分别处理
3.1 12 字节固定头与可变长尾部
RTP 头的前 12 个字节是固定的,按位分布如下:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 字节 bit6-7 | V | 版本号,必须是 2 |
| 0 字节 bit5 | P | 是否有尾部填充 |
| 0 字节 bit4 | X | 是否有扩展头 |
| 0 字节 bit0-3 | CC | CSRC 数量,每个 4 字节 |
| 1 字节 bit7 | M | marker,通常标记一帧的最后一个包 |
| 1 字节 bit0-6 | PT | 载荷类型,H.264 一般用动态类型 96 |
| 2-3 字节 | seq | 序列号,网络字节序,用于丢包判断 |
| 4-7 字节 | timestamp | 时间戳,H.264 下时钟频率是 90kHz,用来切帧 |
| 8-11 字节 | SSRC | 同步源标识,多路复用时要靠它分流 |
解析函数要处理的就是"固定头之后还有多少东西要跳":
#include <string.h> #include <arpa/inet.h> #include "rtp.h" int rtp_parse(const uint8_t *buf, size_t len, rtp_header_t *out) { if (len < RTP_FIXED_HEADER) return -1; out->version = (buf[0] >> 6) & 0x03; out->padding = (buf[0] >> 5) & 0x01; out->extension = (buf[0] >> 4) & 0x01; out->csrc_count = buf[0] & 0x0F; out->marker = (buf[1] >> 7) & 0x01; out->payload_type = buf[1] & 0x7F; /* 用 memcpy 规避非对齐指针访问,ARM 上直接强转取址会崩 */ uint16_t seq_be; memcpy(&seq_be, buf + 2, 2); uint32_t ts_be; memcpy(&ts_be, buf + 4, 4); uint32_t ssrc_be; memcpy(&ssrc_be, buf + 8, 4); out->seq = ntohs(seq_be); out->timestamp = ntohl(ts_be); out->ssrc = ntohl(ssrc_be); if (out->version != 2) return -1; /* 版本不对直接丢 */ size_t off = RTP_FIXED_HEADER + (size_t)out->csrc_count * 4; /* 扩展头:2 字节 profile + 2 字节 length,length 以 4 字节为单位 */ if (out->extension) { if (len < off + 4) return -1; uint16_t ext_len_be; memcpy(&ext_len_be, buf + off + 2, 2); off += 4 + (size_t)ntohs(ext_len_be) * 4; } if (len <= off) return -1; size_t plen = len - off; /* padding 的最后一个字节就是填充总长度,包含它自己 */ if (out->padding && plen > 0) { uint8_t pad = buf[len - 1]; if (pad == 0 || pad > plen) return -1; plen -= pad; } out->payload = buf + off; out->payload_len = plen; return 0; }这里的参数说明有两处值得盯:off的累加顺序必须是 CSRC 在前、扩展头在后,写反了在带扩展头的源上会解析出一堆乱码;plen的裁剪用buf[len - 1]而不是buf[off + plen - 1],两者在大端小端上等价,但前者对 padding 缺失的畸形包更宽容。另外,payload_type一般都做过滤,只收配置里指定的那个值(默认 96),不然同一个端口上混进别的流会污染输出。
3.2 单 NAL、STAP-A、FU-A 三种模式的分派
拿到载荷后,第一件事是看首字节的低 5 位,这就是 NAL 类型。H.264 在 RTP 里一共有三种打包方式,判定和处理的逻辑集中在一个函数里:
/* payload[0] & 0x1F 就是 NAL type */ #define NAL_SLICE 1 #define NAL_IDR 5 #define NAL_SPS 7 #define NAL_PPS 8 #define NAL_STAP_A 24 #define NAL_FU_A 28 int h264_on_payload(h264_ctx_t *ctx, const uint8_t *p, size_t n, uint32_t ts) { if (n < 1) return -1; uint8_t type = p[0] & 0x1F; /* 时间戳变了说明进入新的一帧,没拼完的分片作废 */ if (ctx->assembling && ts != ctx->cur_ts) { ctx->assembling = 0; ctx->buf_len = 0; } if (type >= 1 && type <= 23) { /* 模式一:一个 RTP 包装一个完整 NAL */ return on_complete_nal(ctx, p, n); } if (type == NAL_STAP_A) { /* 模式二:聚合包,1 字节头 + 若干个 [2 字节长度 + NAL] */ size_t i = 1; while (i + 2 <= n) { size_t sz = ((size_t)p[i] << 8) | p[i + 1]; i += 2; if (sz == 0 || i + sz > n) break; if (on_complete_nal(ctx, p + i, sz) < 0) return -1; i += sz; } return 0; } if (type == NAL_FU_A) { return on_fu_a(ctx, p, n, ts); } return 0; /* STAP-B / MTAP / FU-B 少见,先忽略 */ }on_complete_nal做三件事:把 NAL 写进文件、如果是 SPS 或 PPS 就更新缓存副本、如果是 IDR 就触发一次参数集补插检查。STAP-A 在真实码流里非常常见,尤其是编码器把 SPS 和 PPS 捆在一个包里发的时候,一个 24 类型的包解开就是两个 NAL,必须循环遍历到底,漏掉后半段就会出现"有 SPS 没 PPS"的经典黑屏。
3.3 FU-A 分片重组的状态机
FU-A 是坑最多的地方。当一个 NAL 大到超过 MTU,发送端会把它切成若干个 FU-A 包,每个包的头两字节是 FU indicator 和 FU header:
FU indicator: F(1) | NRI(2) | Type=28(5) FU header: S(1) | E(1) | R(1) | Type(5)S=1的分片是起始片,E=1是结束片,中间片两个位都是 0。还原出来的 NAL 头由FU indicator的 F、NRI 两位和FU header里的真实 NAL 类型拼出来。实现上就是一个把分片往缓冲区里粘的过程:
static int on_fu_a(h264_ctx_t *ctx, const uint8_t *p, size_t n, uint32_t ts) { if (n < 3) return -1; uint8_t fu_ind = p[0]; uint8_t fu_hdr = p[1]; int start = (fu_hdr >> 7) & 0x01; int end = (fu_hdr >> 6) & 0x01; uint8_t nal_type = fu_hdr & 0x1F; size_t body = n - 2; if (start) { if (body > H264_MAX_NAL) return -1; ctx->buf[0] = (fu_ind & 0xE0) | nal_type; /* 还原 NAL 头 */ memcpy(ctx->buf + 1, p + 2, body); ctx->buf_len = 1 + body; ctx->assembling = 1; ctx->cur_ts = ts; return 0; } if (!ctx->assembling) return 0; /* 中途接入,前片已丢 */ if (ctx->buf_len + body > H264_MAX_NAL) { /* 超长直接丢弃,防越界 */ ctx->assembling = 0; ctx->buf_len = 0; return -1; } memcpy(ctx->buf + ctx->buf_len, p + 2, body); ctx->buf_len += body; if (end) { int r = on_complete_nal(ctx, ctx->buf, ctx->buf_len); ctx->assembling = 0; ctx->buf_len = 0; return r; } return 0; }H264_MAX_NAL我一般开到 4MB。4K 分辨率下一个 IDR 帧可能到两三兆,留够余量;开太大则在内存紧张的板子上会被 OOM killer 干掉。几个参数的含义:ctx->cur_ts用来判断"这一组分片属于哪一帧",一旦时间戳变化就说明上一帧的分片缺了尾巴,直接丢掉重建;ctx->assembling是"当前是否有半截数据"的标志,中途接入到一路已经在推的流时,第一个收到的 FU-A 大概率不是起始片,这时候必须安静地丢弃,而不是把中间片当成 NAL 头写进文件——后者会让播放器在解析时直接报错退出。
3.4 收包主循环与缓冲区参数
外层循环看起来平淡,但参数调不对会凭空丢包:
int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) { perror("socket"); return 1; } /* 调大内核接收缓冲,默认 208KB 在 4Mbps 码流下几秒就溢出 */ int rcvbuf = 8 * 1024 * 1024; setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(port); if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } uint8_t pkt[65536]; /* 缓冲区必须大于最大 UDP 数据报,否则静默截断 */ while (g_running) { ssize_t n = recvfrom(fd, pkt, sizeof(pkt), 0, NULL, NULL); if (n <= 0) continue; rtp_header_t rtp; if (rtp_parse(pkt, (size_t)n, &rtp) < 0) continue; if (rtp.payload_type != payload_type) continue; /* 按 PT 过滤 */ if (ssrc_filter && rtp.ssrc != ssrc_filter) continue; /* 序列号连续性检查,不连续就标记当前重组失效 */ if (ctx.last_seq && (uint16_t)(rtp.seq - ctx.last_seq) != 1) { ctx.assembling = 0; ctx.buf_len = 0; ctx.lost_pkts += (uint16_t)(rtp.seq - ctx.last_seq - 1); } ctx.last_seq = rtp.seq; h264_on_payload(&ctx, rtp.payload, rtp.payload_len, rtp.timestamp); }SO_RCVBUF有个隐藏限制:Linux 上内核会把设置值砍到net.core.rmem_max的一半,设了 8MB 实际可能只有 200KB。稳妥做法是setsockopt之后再getsockopt读回来打印一次,看到真实值再判断够不够。pkt缓冲区开 65536 是因为 UDP 数据报理论上最多 65507 字节,开 1500 的话遇到巨帧或者 IP 分片重组后的包会被recvfrom直接截断,而且不报错——这个坑我后面单独讲。
4. 落盘细节:起始码、SPS/PPS 缓存与裸流可播放性
4.1 Annex-B 起始码与写入时机
.h264裸流用的是 Annex-B 格式,每个 NAL 前面挂一个起始码。理论上 3 字节的00 00 01也合法,但 4 字节的00 00 00 01兼容性更好,尤其在文件开头第一个 NAL 上,3 字节版本有时会被误判成前一个 NAL 的尾部。所以统一写 4 字节:
static const uint8_t START_CODE[4] = { 0x00, 0x00, 0x00, 0x01 }; int annexb_write(FILE *fp, const uint8_t *nal, size_t len) { if (nal == NULL || len == 0) return 0; if (fwrite(START_CODE, 1, 4, fp) != 4) return -1; if (fwrite(nal, 1, len, fp) != len) return -1; return (int)len; }写盘策略上有个权衡:每写一个 NAL 就fflush一次,能保证程序被强杀时数据不丢,但 IO 次数暴增,高码率下 CPU 会明显飙高;完全不 flush 靠setvbuf的缓冲,性能好但异常退出会丢掉最后几十 KB。折中方案是用setvbuf(fp, NULL, _IOFBF, 1 << 20)设 1MB 全缓冲,然后在主循环里按时间戳变化(也就是一帧结束时)调用一次fflush。这样最坏情况丢一帧,IO 次数降到帧率级别。
另外一个必须做的是信号处理。Ctrl+C直接杀进程的话,FILE缓冲区里的内容不会写出去:
static volatile sig_atomic_t g_running = 1; static void on_sigint(int sig) { (void)sig; g_running = 0; } signal(SIGINT, on_sigint); /* 主循环结束后 */ if (fp) { fflush(fp); fclose(fp); }注意g_running必须声明成volatile sig_atomic_t,普通int在-O2下可能被优化进寄存器,导致信号改了但循环看不到,程序停不下来。
4.2 SPS/PPS 缓存与 IDR 前补插
播放器要解码一段 H.264,必须知道分辨率、profile、帧率相关信息,这些都写在 SPS 里;PPS 则带着熵编码参数。问题在于很多编码器只在流开始时发一次 SPS/PPS,中途不再重复。工具如果是在流开始之后才启动,就永远收不到参数集,写出来的文件在任何播放器里都是黑的。
解决办法是缓存加补插:
typedef struct { uint8_t sps[128]; size_t sps_len; uint8_t pps[128]; size_t pps_len; uint32_t last_ps_write_ts; /* 上次写参数集时的时间戳 */ } h264_ctx_t; static int on_complete_nal(h264_ctx_t *ctx, const uint8_t *nal, size_t len) { uint8_t type = nal[0] & 0x1F; if (type == NAL_SPS && len <= sizeof(ctx->sps)) { memcpy(ctx->sps, nal, len); ctx->sps_len = len; } else if (type == NAL_PPS && len <= sizeof(ctx->pps)) { memcpy(ctx->pps, nal, len); ctx->pps_len = len; } /* IDR 前确保参数集已经写过,且距离上次写超过 2 秒(90kHz 时钟) */ if (type == NAL_IDR && ctx->sps_len && ctx->pps_len) { if (ctx->last_ps_write_ts == 0 || (uint32_t)(ctx->cur_ts - ctx->last_ps_write_ts) > 180000u) { annexb_write(ctx->fp, ctx->sps, ctx->sps_len); annexb_write(ctx->fp, ctx->pps, ctx->pps_len); ctx->last_ps_write_ts = ctx->cur_ts; } } return annexb_write(ctx->fp, nal, len); }这段逻辑的好处是同时兼容两种场景:从头开始收时,SPS/PPS 本来就在流里,缓存一下不影响输出顺序;中途接入时,只要等到第一个 IDR 帧,参数集就会被补在它前面,播放器从这一帧开始就能正常解码。180000这个数字是按 90kHz 时钟算的 2 秒,调大可以让文件头更干净,调小则对随机跳转更友好。代价是会往文件里多写一些重复的 SPS/PPS,体积上基本可以忽略。
4.3 二十行的 Python 验证脚本
正式版本用 C 写,但排查"端口到底有没有数据""收到的包结构对不对"这类问题时,用 Python 更快。这个脚本不追求健壮,只求扔到任何一台装了 Python 的机器上就能跑:
import socket, struct, sys START = b'\x00\x00\x00\x01' def main(port, out_path): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 << 20) sock.bind(('0.0.0.0', port)) buf, assembling = bytearray(), False with open(out_path, 'wb') as f: while True: pkt, _ = sock.recvfrom(65535) if len(pkt) < 12: continue cc = pkt[0] & 0x0F ext = (pkt[0] >> 4) & 0x01 off = 12 + cc * 4 if ext: ext_len = struct.unpack('!H', pkt[off + 2:off + 4])[0] off += 4 + ext_len * 4 payload = pkt[off:] if not payload: continue t = payload[0] & 0x1F if 1 <= t <= 23: # 完整 NAL f.write(START + payload) elif t == 28: # FU-A fu = payload[1] if fu & 0x80: # 起始分片 buf = bytearray([(payload[0] & 0xE0) | (fu & 0x1F)]) buf += payload[2:] assembling = True elif assembling: buf += payload[2:] if (fu & 0x40) and assembling: # 结束分片 f.write(START + bytes(buf)) assembling = False f.flush() if __name__ == '__main__': main(int(sys.argv[1]), sys.argv[2])它省掉了 STAP-A、丢包检测、时间戳切帧,只保留主干,用来确认"端口有数据、载荷类型是 28 或 1~23、写出来的文件能被播放器识别"。如果这个脚本能出画面而 C 版本不行,问题基本就锁定在 C 版本多出来的那些逻辑里——丢包处理、参数集补插、缓冲区大小,排查范围一下就缩小了。
4.4 怎么判断文件真的写对了
拿到.h264之后,先看文件头几个字节。正常的文件开头应该是00 00 00 01 67(SPS)或者00 00 00 01 68(PPS),如果开头是00 00 00 01 65,说明参数集没写进去,播放器大概率黑屏。用xxd -l 64 out.h264看一眼就能确认。
然后是播放验证。裸流没有容器,播放器不知道帧率,只能按默认值(常见是 25fps)播放,所以"画面动得比实际快/慢"不是工具的锅,是文件格式本身的限制。要看真实速度,得把它转封装成带时间戳的容器。命令行形式大致是这样,用你机器上惯用的转封装工具,关键是显式指定输入帧率:
# -framerate 填采集端的真实帧率,其余参数按该工具的用法来 <你的转封装工具> -framerate 25 -i out.h264 -c copy out.mp4判断画面质量的土办法是拖进度条:随机跳到几个位置,看有没有大片马赛克或整段黑屏。局部马赛克对应丢包导致的分片残缺,整段黑屏对应参数集缺失,卡在同一帧不动则一般是 FU-A 状态机在某个畸形包之后没复位。
5. 避坑与排查:收包、重组、播放三个环节的血泪记录
5.1 收包阶段:设了缓冲区却还在丢包
现象:码率一上到 4Mbps 以上,写出来的文件就开始周期性花屏,间隔大约几秒一次,日志里lost_pkts持续增长。
原因:setsockopt(SO_RCVBUF)设的是 8MB,但 Linux 内核会把它限制在net.core.rmem_max的一半,实际生效可能只有 200KB 左右。按 4Mbps 算,200KB 只能缓 0.4 秒,主循环稍微被fwrite卡一下,内核就开始丢包。
解决:设置之后用getsockopt把真实值读回来打印,确认生效值;不够的话要么调大系统参数,要么把写盘改成异步(单独一个写线程 + 环形缓冲),让收包线程永远不阻塞在 IO 上。
5.2 收包阶段:recvfrom 返回的包长度总是 1500 上下且内容不全
现象:抓到的包长固定接近 1500,但解析出来的 NAL 明显被截断,载荷长度对不上。
原因:接收缓冲区开小了。recvfrom在缓冲区不足时会截断数据报,且默认不报错,函数照样返回缓冲区大小。UDP 数据报理论上能到 65507 字节,遇到巨帧或者对端一次性发大包时,1500 的缓冲区必然截断。
解决:缓冲区直接开到 65536。想确认有没有发生截断,可以给recvfrom加MSG_TRUNC标志,这样返回值会是被截断前的真实长度,和缓冲区大小一比就知道。
5.3 重组阶段:文件能播但每隔几秒一大片马赛克
现象:播放时整体流畅,但每隔几秒出现一次持续半秒左右的马赛克,之后自动恢复。
原因:FU-A 的起始分片丢了。中间片和结束片都收到了,但状态机因为没看到S=1而拒绝拼包,直到下一个 IDR 帧才恢复。这类丢包往往来自网络抖动或者接收缓冲溢出,单看文件根本看不出原因。
解决:在序列号连续性检查里加统计,一旦发现不连续就立刻把assembling复位并把当前缓冲清空,避免把上一次的残留数据拼到这一次上。同时把丢包计数和最近的序列号打到日志里,方便判断是偶发还是持续。想减少马赛克持续时间,可以让发送端调高 IDR 帧频率,但这属于对端配置,工具这边只能做到"丢包后尽快恢复干净状态"。
5.4 重组阶段:STAP-A 解出来的只有 SPS 没有 PPS
现象:文件开头能看到67(SPS),但紧跟着直接是65(IDR),播放器黑屏或者只显示第一帧。
原因:STAP-A 的循环遍历写错了边界。聚合包的格式是1 字节头 + N × (2 字节长度 + NAL),循环条件写成i < n而不是i + 2 <= n,最后一次迭代读长度字段时越界,拿到一个巨大的sz,然后i + sz > n触发 break,把后面的 PPS 直接跳过了。
解决:循环条件必须是i + 2 <= n,取完长度后还要再判一次i + sz <= n,两道检查缺一不可。另外sz == 0也要单独挡掉,否则会陷入死循环。
5.5 播放阶段:Ctrl+C 之后文件尾部总是缺一截
现象:每次手动结束录制,最后半秒到一秒的画面丢失,文件末尾是半个 NAL。
原因:FILE用了全缓冲,Ctrl+C触发默认的信号处理直接终止进程,缓冲区里的数据没来得及fflush。加上写盘时没有按 NAL 边界对齐,缓冲区截断点刚好落在 NAL 中间。
解决:注册SIGINT处理器把g_running置 0,让主循环正常退出,退出前依次fflush和fclose。同时在每帧结束(时间戳变化)时主动 flush 一次,把最坏情况从"丢一秒"压缩到"丢一帧"。如果对完整性要求极高,可以再写一个.part临时文件,正常退出后才改名,这样异常中断时会留下明显标记,不会被误当成完整文件。
6. 二次开发:从裸流文件到可回放、可封装的流水线
裸流文件只是第一步。真要做产品,下一步基本都是"能按时间回放"或者"能直接给别人播",这时候需要的是帧级索引,而不是一个连续的字节流。
改造点在on_complete_nal里:每写完一个 NAL,记录当前文件偏移和长度;遇到时间戳变化(一帧结束)时,把这一帧包含的 NAL 偏移归拢成一条记录,追加写到一个.idx文件里:
typedef struct { uint32_t ts; /* 90kHz 时间戳 */ uint64_t offset; /* 帧首字节在 .h264 中的偏移 */ uint32_t size; /* 帧总字节数 */ uint8_t is_idr; /* <p> <a href="https://download.csdn.net/download/ljxh401/11631609" 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>