
简介面向网络视频流开发者的实时传输协议RTP转H.264工程示例重点解决从用户数据报协议UDP接收的RTP包中提取H.264码流并保存为文件的问题适用于摄像头实时取流、监控系统、在线会议等场景。代码基于Visual Studio工程组织RAR压缩包共14个文件含6个头文件、4个C实现文件以及工程配置和说明文档整体仅9KB结构轻量便于快速阅读和二次修改。项目内按服务端程序、实时传输协议数据解码、公共工具函数和用户数据报协议网络封装等模块划分覆盖实时传输协议包解析、网络抽象层单元重组、H.264视频文件写入等关键步骤。读者可对照代码理清从网络包到文件的完整处理链路了解字节顺序处理、实时传输协议头解析与网络抽象层单元边界识别等细节。已有1032人学习/下载适合需要快速上手RTP/H.264协议转换并希望边读代码边验证的初中级音视频开发者。1. 用UDP收RTP流存H264文件先搞懂这几件事手里的摄像头、编码器或者模拟项目X把视频编码成H264后塞进RTP包再通过UDP扔到某个端口。你要做的就是从那个端口把数据抓下来拼成一个能双击播放的H264文件。听起来就是收包、解包、写文件三步但实际操作里翻车点非常多——直接拿UDP负载写进文件出来的多半是花屏或者只有第一帧能看的残废文件。原因是RTP对H264做了分片、打包、序号和时间戳处理你得先把这些协议层的东西理清楚才能正确还原出原始的H264字节流。这篇文章我按自己实际拆过的方案来写从协议原理讲到接收脚本再到常见坑和进阶封装每个步骤都给了可以直接跑的代码和参数说明。适合刚接触RTP流接收、或者在做视频传输落地的从业者。2. RTP与H264封装为什么不能把UDP包直接写进文件很多第一次接触RTP的人会以为UDP payload里就是完整的H264数据。实际上RTP只是传输层外面的信封这个信封里有12字节的头部信息还包括分片标记、时间戳、序号真正的H264数据可能被拆成多片放在不同RTP包里。如果跳过解包直接写文件等于把信封和信的内容混在一起播放器根本没法解析。2.1 RTP包头结构12字节里藏着顺序和时间标准RTP包头固定为12字节按顺序是版本号2 bit、填充位1 bit、扩展位1 bit、CSRC计数4 bit、标记位1 bit、载荷类型7 bit、序号16 bit、时间戳32 bit、SSRC32 bit。其中对我们保存H264最关键的是三个字段。第一个是序号Sequence Number每发一个RTP包加1接收端靠它判断丢包和乱序。第二个是时间戳Timestamp单位不是毫秒而是由采样时钟决定H264通常用90kHz时钟所以1秒等于90000个时间戳单位。第三个是载荷类型Payload TypeH264常用96到127之间的动态值具体由SDP协商。我们写接收脚本时至少要把这12个字节正确解析出来才能定位真正的H264负载起始位置。有个容易踩的坑是有些设备开了RTP扩展头X bit为1那么12字节后面还有扩展字段负载位置要额外偏移。我一般会先打印前两个字节的二进制确认解析器偏移对不对。import struct def parse_rtp_header(data): # RTP固定头至少12字节 if len(data) 12: return None # 第一个字节: V(2) P(1) X(1) CC(4) first_byte data[0] version (first_byte 6) 0x03 padding (first_byte 5) 0x01 extension (first_byte 4) 0x01 cc_count first_byte 0x0F # 第二个字节: M(1) PT(7) second_byte data[1] marker (second_byte 7) 0x01 payload_type second_byte 0x7F # 序号和时间戳用大端格式读取 sequence struct.unpack(!H, data[2:4])[0] timestamp struct.unpack(!I, data[4:8])[0] ssrc struct.unpack(!I, data[8:12])[0] # 计算负载偏移: 12 4*CSRC(如果有) 4*扩展长度(如果有) offset 12 cc_count * 4 if extension: ext_len struct.unpack(!H, data[offset2:offset4])[0] offset 4 ext_len * 4 if padding: # 最后一个字节表示填充长度 pad_len data[-1] payload data[offset:-pad_len] else: payload data[offset:] return { sequence: sequence, timestamp: timestamp, ssrc: ssrc, marker: marker, payload_type: payload_type, payload: payload }这段代码把RTP头拆开并返回负载内容。参数上需要注意struct.unpack(!H)表示大端无符号短整型RTP序号就是这种格式timestamp是32位无符号整型后面做时间戳差值时会用到marker标记位在H264中通常表示一帧的最后一个分片后续重组分片时可以参考它判断当前帧是否结束。2.2 H264负载类型单包、STAP-A与FU-A分片拿到RTP负载后还要继续分析里面是什么类型的H264数据。RFC 6184定义了三种常见打包模式单一NALU包类型1-23直接放负载、STAP-A类型24多个小NALU聚合在一个RTP包、FU-A类型28大NALU分成多个RTP包发送。设备通常根据NALU大小自动选择模式。我实际处理时先读负载的第一个字节低5位就是NALU类型。如果是1到23说明这个RTP包包含一个完整的NALU直接取出就是H264字节流。如果是28说明是FU-A分片需要把多个RTP包里的分片按顺序拼回一个完整NALU。如果是24则要拆开聚合包里的各个NALU。def extract_h264_from_rtp(rtp): payload rtp[payload] if not payload: return None nal_type payload[0] 0x1F if nal_type in range(1, 24): # 单NALU包负载本身就是H264内容含起始码需自行添加 return [payload] elif nal_type 28: # FU-A fu_header payload[1] start_bit (fu_header 7) 0x01 end_bit (fu_header 6) 0x01 # 重建NALU头: 原头的前3位(通常为0) FU指示头的后5位 nal_header bytes([(payload[0] 0xE0) | (fu_header 0x1F)]) fragment_data payload[2:] if start_bit: # 分片开始记录这个NALU return {start: True, end: end_bit, header: nal_header, data: fragment_data} else: return {start: False, end: end_bit, header: None, data: fragment_data} elif nal_type 24: # STAP-A # 聚合包: 按2字节长度NAL数据 依次拆包 nal_units [] idx 1 while idx len(payload): if idx 2 len(payload): break nalu_len struct.unpack(!H, payload[idx:idx2])[0] idx 2 nal_units.append(payload[idx:idxnalu_len]) idx nalu_len return nal_units else: return None这段代码把三种情况都处理了但要注意返回值格式不统一单包和STAP-A返回NALU列表FU-A返回分片信息。实际写脚本时我会用字典包一层让调用方好处理。参数上FU-A的start_bit和end_bit是判断分片边界的核心只有start_bit1的那个包需要把NALU头拼上中间的包直接去掉RTP负载的前两个字节即可。2.3 选型直接存H264还是转封装MP4到这里你有两个选择把恢复出来的NALU按起始码00 00 00 01连起来写一个.h264裸流文件或者进一步封装成.mp4、.ts文件。如果只做测试验证裸流文件就够了VLC、ffplay都能播放。但裸流文件有个致命问题没有索引信息播放器没法拖进度条只能用-fs参数从开头顺序读。如果你需要的是保存一段录像之后能自由定位建议直接写MP4或者TS。MP4需要把每个关键帧的偏移量记下来写moov box这个工作量不小TS则相对简单把H264包分成188字节的TS包即可。常见做法是先用裸流文件做验证确认数据恢复正确后再用ffmpeg或者自研转封装模块转成MP4。我在后面的章节里会给出具体转换命令和边收边转的方案。3. 实现UDP到H264的落地解析RTP并重组NAL这一章进入正题我用Python写一个最小可用的接收脚本从绑定UDP端口开始到生成一个可播放的H264裸流文件。代码不复杂但每一步都有隐形的坑比如缓冲区大小、非阻塞模式、文件写入时机等3.1 建立UDP接收端端口、缓冲与丢包处理UDP接收端的核心是socket绑定和缓冲设置。要接收RTP流先创建一个UDP socket绑定到发送端的目标端口然后进入死循环recvfrom。缓冲区大小要看RTP包的实际尺寸如果payload是FU-A分片单个包通常小于1500字节避免IP分片如果是STAP-A聚合包可能达到几KB。我习惯把SO_RCVBUF设成4MB以上防止突发流量导致内核缓冲区溢出丢包。import socket UDP_IP 0.0.0.0 UDP_PORT 5000 BUFFER_SIZE 65536 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) sock.bind((UDP_IP, UDP_PORT)) # 非阻塞模式配合select处理超时 sock.settimeout(5.0) print(fListening on {UDP_IP}:{UDP_PORT}) while True: try: data, addr sock.recvfrom(BUFFER_SIZE) # 这里开始处理每个RTP包 process_rtp_packet(data) except socket.timeout: print(5秒没有收到数据结束接收) break注意sock.settimeout(5.0)设的是阻塞接收超时如果5秒内没收到UDP包就会抛异常。实际抓流场景中如果你要一直监听可以改成不超时或者更长。SO_REUSEADDR允许端口复用方便重启脚本时快速绑定。BUFFER_SIZE设成65536是避免应用层截断内核实际收到的包不会超过MTU但聚合包可能较大。3.2 解析RTP头部并提取负载结合第二章的parse_rtp_header和extract_h264_from_rtp这里的核心是维护一个分片重组缓冲区。因为一个大的H264 NALU会被分成多个FU-A片跨多个RTP包传递你需要按序号顺序把它们拼接起来。同时还需要判断乱序和丢包一旦发现序号不连续说明中间有包丢了这时要么丢弃整个NALU要么等下一帧关键帧再恢复否则拼出来的数据是坏的。# 全局状态 fragments {} # 用SSRC时间戳作为key, 存储分片 current_key None def assemble_fu_a(rtp, fu_info): global current_key # 这里用时间戳和SSRC唯一标识一个NALU key (rtp[ssrc], rtp[timestamp]) if fu_info[start]: # 新分片开始 current_key key fragments[key] fu_info[header] fu_info[data] elif key current_key: # 中间或结尾分片 fragments[key] fu_info[data] if fu_info[end]: # 最后一个分片 nal_unit fragments.pop(key) current_key None return nal_unit return None这个函数只处理FU-A的拼接没有考虑乱序。如果网络乱序严重end分片先到start后到拼接就会失败。我一般会在这里加一个按序号排序的步骤把所有分片先存到fragments[key]的字典里键是分片序号等收到end分片时再统一排序拼接。另外如果中间发现序号间隔超过阈值直接丢弃整个帧。3.3 处理SPS/PPS注入与关键帧判断H264解码器需要SPS和PPS参数集才能开始解码。如果RTP流里的SPS/PPS只是周期性发送你恰好从中间开始接收可能等不到它们。这时要么等待下一个关键帧IDR要么手动从其他地方拿到SPS/PPS字节流注入到文件头部。判断关键帧的方法很简单查看NALU类型。类型5是IDR关键帧类型7是SPS类型8是PPS。裸流文件里SPS、PPS和IDR必须按顺序出现在文件开头。很多设备的RTP流不会把SPS/PPS放在每个关键帧前面而是只在会话开始时发一次。所以脚本的最佳实践是收到第一个SPS/PPS后立刻写入文件同时把关键帧数据也写上如果开机后收到的第一个NALU不是SPS/PPS则需要等关键帧并且在关键帧前手动写入已知的SPS/PPS。def handle_nal_unit(nal_unit, out_file): nal_type nal_unit[0] 0x1F start_code b\x00\x00\x00\x01 if nal_type 7: # SPS: 缓存下来以后直接写 global sps_data sps_data nal_unit elif nal_type 8: global pps_data pps_data nal_unit elif nal_type 5: # 关键帧: 确保SPS/PPS已写入 if sps_data and pps_data: out_file.write(start_code sps_data) out_file.write(start_code pps_data) out_file.write(start_code nal_unit) else: out_file.write(start_code nal_unit)上面简化了逻辑实际中要处理SPS/PPS是否已经写入过的标记避免每个关键帧前重复写。写重复的SPS/PPS在裸流里没问题但转成MP4时会导致moov信息异常。我习惯用两个布尔变量记录写入状态只有第一次遇到关键帧前才写SPS/PPS。3.4 写入文件的格式选择与参数设置裸流H264文件的扩展名是.h264或.264内容就是连续的NALU每个NALU前加4字节起始码00 00 00 01也可以用3字节00 00 01但4字节起始码在解码器中兼容性更好。写入时建议用缓冲写攒够一定字节再flush到磁盘避免每个包都触发一次磁盘I/O。out_file open(output.h264, wb) # 写入缓冲由Python内部管理, 我们每收到一帧就flush一次 frame_count 0 while True: try: data, addr sock.recvfrom(BUFFER_SIZE) rtp parse_rtp_header(data) if rtp is None: continue result extract_h264_from_rtp(rtp) if isinstance(result, list): for nal in result: handle_nal_unit(nal, out_file) elif isinstance(result, dict): complete_nal assemble_fu_a(rtp, result) if complete_nal: handle_nal_unit(complete_nal, out_file) frame_count 1 if frame_count % 30 0: out_file.flush() except socket.timeout: break out_file.close()这个主循环把所有逻辑串起来了。参数上frame_count % 30表示每30帧flush一次这样即使脚本崩溃文件里也至少有最近30帧的数据。如果要做长时间录像可以改成每秒钟flush一次或者按时间判断。4. 常见问题排查时间戳不对、花屏、文件不能拖进度条这段时间我用这套脚本处理过好几路不同的RTP流把遇到的典型问题整理一下。每一条都是实际触发的现象和对应解法。4.1 现象文件播放只有第一帧往播放器里拖进去画面固定在第一帧后续不更新。原因是接收到的RTP包里大多数帧被当成非关键帧丢弃了。如果播放器从文件开头读只有第一个关键帧解出来后面都是参考帧但缺少关键帧刷新解码器等不到IDR就卡住。解决方法是保证每个关键帧都写入并且SPS/PPS放在文件开头。检查代码里nal_type 5的判断是否正确有些设备的关键帧类型不是5而是自定义类型需要在RTP负载的第一个字节上多打印几组数据确认。还有一种情况是FU-A分片模式下IDR的start_bit和end_bit没匹配上导致关键帧数据没有重组完整。4.2 现象画面花屏且无法定位关键帧花屏通常有三种来源分片拼错、丢包后数据断链、SPS/PPS参数不匹配。我最常遇到的是乱序分片导致拼接错位。比如FU-A的中间分片先于开始分片到达让拼接缓冲区中的数据多了不完整的片段。解决的办法是为每个NALU维护一个按序号排序的列表等开始和结束分片都到齐后按顺序拼接。另外如果UDP丢包率超过2%视频画面会周期性花屏这是不可避免的。常见做法是开启前向纠错FEC或者让发送端重传关键帧。在接收端最简单的策略就是检测到序号缺口时丢弃当前NALU直到下一个关键帧再恢复写入。4.3 现象收到的RTP包序号乱序不少网络设备发出的UDP包会因为交换机缓冲造成乱序序号跳变不一定是丢包可能只是先发的包后到。如果脚本严格按照顺序拼接就会把乱序当丢包处理。我建议给每个RTP包打上接收时间戳然后在NALU重组前做2到3个包的排序缓冲。具体做法是维护一个长度为3的滑动窗口窗口内的包按序号升序排列后再交给分片拼接函数。这样能容忍轻微的乱序又不引入太大延迟。4.4 现象SPS/PPS不在流内有些编码器只在会话建立时发送一次SPS/PPS之后就不再重复。如果你不是从第0个RTP包开始接收就可能从头到尾都拿不到参数集。这时播放器会报无解码器或者只有声音。解决方法是外部获取参数集从SDP文件里查看sprop-parameter-sets字段它是base64编码的SPS/PPS解码后拼到文件头。我一般会在脚本启动时先尝试缓存一段时间的RTP包解析出SPS/PPS后再写入文件如果缓存超时还没拿到就从SDP手动注入。4.5 现象UDP丢包后文件时间戳跳变时间戳跳变会让播放器出现快进或者卡顿。原因是丢包后后续帧的时间戳还是按照原来的节奏递增但实际数据显示数量变少看起来像是时间跳跃。裸流文件没有时间戳所以播放器按帧率估算播放时长丢包会让估算变短。如果想做精确的时间戳控制需要在写裸流的同时记录原始RTP时间戳生成一个时间戳索引文件。或者直接封装TS/MP4把RTP时间戳映射到容器时间。我在第五章会说明简单的转封装方案。5. 进阶把H264转TS/MP4让播放器能拖进度条裸流文件虽然方便验证但在真实项目中几乎没有视频平台会直接用它。原因很简单没有索引播放器无法seek到指定时间点没有封装容器很多播放器不支持。所以进阶的目标是把恢复出来的H264流封装成TS或MP4。5.1 为什么裸流不便拖动裸流H264是连续的NALU从开头连续读就能解码播放但如果你拖到文件中间播放器必须从最近的IDR开始解码。裸流文件没有关键帧的位置表播放器只能从文件头开始逐帧扫描这在几百MB的视频上完全不可行。TS封装则每188字节包可能包含PES头MP4有moov box记录每个sample的偏移和时长两种方式都支持随机访问。5.2 用ffmpeg把裸流转成MP4接收完得到output.h264后用ffmpeg一行命令就能转成MP4ffmpeg -f h264 -i output.h264 -c:v copy output.mp4参数说明-f h264指定输入格式为裸流-c:v copy表示视频流不重新编码直接复制到MP4容器中。如果你的裸流里已经有SPS/PPSffmpeg会自动生成moov box如果缺参数集需要先注入再转。另外可以用-movflags faststart把moov box挪到文件开头适合网络播放。ffmpeg -f h264 -i output.h264 -c:v copy -movflags faststart output.mp4转完后用ffprobe检查文件信息确认视频流是否存在、编码器和分辨率是否正确。5.3 实时转封装方案边接收边封装如果你不想先存裸流再转那就在接收脚本里直接写TS或MP4。TS封装实现起来相对简单把H264 NALU切成长度小于65535的PES再打包成188字节TS包。但这需要处理PAT、PMT、PES头等多种表格自己写工作量较大。常见做法是用GStreamer或FFmpeg库作为依赖在收到RTP包后直接喂给库函数。我用过一次某跨平台系统方案是Python接收RTP通过管道把裸流喂给ffmpeg的stdinffmpeg实时转成TS输出。ffmpeg -f h264 -i pipe:0 -c:v copy -f mpegts pipe:1这样接收端只要不断写入stdinstdout就输出TS流。好处是ffmpeg会处理PTS和索引缺点是依赖外部进程部署稍复杂。适合对稳定性要求高、不想自己造轮子的场景。6. 最后验证ffprobe确认文件完整性以及我个人的收包习惯所有步骤走完之后一定要用ffprobe确认一下文件不是能打开但实质错误的状态。命令如下ffprobe -v error -show_entries streamcodec_name,width,height,avg_frame_rate -of defaultnoprint_wrappers1 output.mp4如果输出里能看到codec_nameh264、width1920、height1080、avg_frame_rate25/1说明文件封装正常。如果报错或者没有轨道回头检查裸流里SPS/PPS是否完整、关键帧是否连续。还有一个我自己养成的习惯每次开始接收RTP流之前先做10秒预接收测试把收到的前100个RTP包打印出来确认序号连续、时间戳递增、NALU类型分布合理。不要等到录了三小时发现问题才去排查那时候数据已经丢了。另外文件命名要带时间戳防止多次接收把数据覆盖。我现在每写一个接收脚本都会强制走一遍预接收检查 → 正式接收 → ffprobe验证三步流程这套流程帮我避开了不少协议解析的暗坑。希望帮到你。本文还有配套的精品资源点击获取