H264文件實(shí)戰(zhàn):UDP裸流還原與播放鏈路解析)
簡介這份資源面向從事網(wǎng)絡(luò)視頻傳輸、監(jiān)控系統(tǒng)或流媒體開發(fā)的工程師與學(xué)習(xí)者聚焦于將RTP包中的H264數(shù)據(jù)解封裝并保存為本地文件同時(shí)借助UDP實(shí)現(xiàn)攝像頭數(shù)據(jù)的實(shí)時(shí)讀取。包內(nèi)共14個(gè)文件以6個(gè)C頭文件與4個(gè)cpp源文件為核心輔以vcxproj工程文件、filters過濾配置及ReadMe說明整體約9KB結(jié)構(gòu)緊湊便于直接編譯調(diào)試。內(nèi)容圍繞RTP頭部解析、NAL單元重組與H264文件寫入展開涉及Annex B與MP4兩種常見存儲(chǔ)格式的取舍并包含rtpserver服務(wù)端與UDP客戶端交互的代碼框架可幫助讀者理解RTP與H264協(xié)同工作的完整鏈路。目前已有1030人學(xué)習(xí)下載適合希望掌握實(shí)時(shí)視頻流處理、需要可運(yùn)行參考代碼的開發(fā)者對照實(shí)踐。1. RTP 轉(zhuǎn) H264 文件把 UDP 裸流落成可播放視頻的那條鏈路你抓過包就知道Wireshark 里一堆 UDP 報(bào)文端口 5004、8000、甚至 30000 以上Payload 里全是80 60開頭的字節(jié)看著像視頻又播不了。這就是 RTP 承載的 H264 裸流。它和直接存.h264的區(qū)別在于RTP 有 12 字節(jié)固定頭、有序列號、有 Marker 位、有分片規(guī)則而 H264 文件需要的是 Annex-B 起始碼和完整 NALU。把 UDP 收到的 RTP 包按序還原、去掉 RTP 頭、處理 FU-A 分片、補(bǔ)上00 00 00 01才能得到播放器認(rèn)的 H264 文件。這套鏈路在國標(biāo)平臺(tái)對接、IPC 抓流、udp 網(wǎng)絡(luò)調(diào)試?yán)锾焯煊靡彩桥挪閟tart preview failed maybe rtp session false這類問題的基本功。下面按我實(shí)際落地的順序講清楚。2. 先搞懂 RTP 載荷里到底裝了什么H264 的三種打包方式2.1 RTP 固定頭與 H264 NALU 的對應(yīng)關(guān)系RTP 頭固定 12 字節(jié)關(guān)鍵字段是序列號2 字節(jié)、時(shí)間戳4 字節(jié)、Marker 位1 bit、Payload Type。H264 的 NALU 頭是 1 字節(jié)結(jié)構(gòu)是F(1) NRI(2) Type(5)。Type 決定這個(gè)包是單包、分片還是聚合Type 值含義處理方式1-23單 NALU 包直接加起始碼寫入24STAP-A 聚合包拆出多個(gè) NALU 分別寫25-27STAP-B/MTAP少見一般丟棄或特殊處理28FU-A 分片首片帶原始 NALU 頭后續(xù)片拼接29FU-B 分片帶 DON兼容性差實(shí)際抓 IPC 流90% 以上是 Type 1、28、24 三種。搞不清這個(gè)對應(yīng)關(guān)系寫出來的文件要么花屏要么根本打不開。2.2 為什么不能直接把 UDP Payload 拼起來存文件有人圖省事把 UDP 收到的每個(gè)包 Payload 直接 append 到文件結(jié)果播放器打開一片綠或者直接報(bào)錯(cuò)。原因有三個(gè)第一RTP 頭 12 字節(jié)混在里面H264 解碼器看到的是垃圾數(shù)據(jù)第二FU-A 分片的首片和后續(xù)片 Payload 結(jié)構(gòu)不同首片第一個(gè)字節(jié)是 FU indicator第二個(gè)字節(jié)才是 FU header后續(xù)片沒有原始 NALU 頭第三UDP 本身不保證順序序列號亂序時(shí)直接拼會(huì)錯(cuò)位。所以必須按序列號排序、按 Type 分支處理、重組后再寫。2.3 最小可跑的 Python 解析腳本下面這段是我調(diào)試時(shí)最常用的骨架用 scapy 讀 pcap 或者直接接 UDP socket 都行。核心邏輯是維護(hù)一個(gè)分片緩沖遇到 Marker 位為 1 或下一個(gè)包序列號不連續(xù)時(shí)輸出完整 NALU。import struct # RTP 頭解析返回 seq, marker, payload def parse_rtp(data): if len(data) 12: return None # 版本號、填充位、擴(kuò)展位、CSRC 計(jì)數(shù) b0 data[0] version b0 6 if version ! 2: return None cc b0 0x0F # 第二個(gè)字節(jié)的 bit7 是 Marker marker (data[1] 7) 0x01 seq struct.unpack(!H, data[2:4])[0] # 跳過固定頭 CSRC offset 12 cc * 4 return seq, marker, data[offset:] # 處理 H264 載荷寫入輸出文件 def handle_h264_payload(payload, out_file, frag_buf): if not payload: return nalu_type payload[0] 0x1F if nalu_type 28: # FU-A 分片 fu_header payload[1] start (fu_header 7) 0x01 end (fu_header 6) 0x01 # 原始 NALU 頭 FU indicator 的 F/NRI FU header 的 Type orig_header (payload[0] 0xE0) | (fu_header 0x1F) if start: frag_buf.clear() frag_buf.append(orig_header) frag_buf.extend(payload[2:]) if end: out_file.write(b\x00\x00\x00\x01 bytes(frag_buf)) frag_buf.clear() elif nalu_type 24: # STAP-A idx 1 while idx 2 len(payload): size struct.unpack(!H, payload[idx:idx2])[0] idx 2 nalu payload[idx:idxsize] out_file.write(b\x00\x00\x00\x01 nalu) idx size elif 1 nalu_type 23: # 單 NALU out_file.write(b\x00\x00\x00\x01 payload)邏輯說明parse_rtp先校驗(yàn)版本號非 2 直接丟handle_h264_payload按 Type 分支FU-A 用frag_buf累積首片重建原始 NALU 頭尾片到達(dá)時(shí)補(bǔ)起始碼寫出。參數(shù)上frag_buf建議用bytearray而不是 list拼接效率更高輸出文件用wb模式每寫一個(gè) NALU 就 flush 一次防止進(jìn)程被殺丟數(shù)據(jù)。3. 從 UDP socket 到 H264 文件完整落地步驟與參數(shù)調(diào)優(yōu)3.1 接收端 socket 配置與緩沖區(qū)設(shè)置直接recvfrom(65535)在流量大時(shí)會(huì)丟包因?yàn)槟J(rèn) socket 接收緩沖區(qū)只有 64KB 左右。我一般這樣設(shè)import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) sock.bind((0.0.0.0, 5004))SO_RCVBUF設(shè) 4MB 是經(jīng)驗(yàn)值1080p 25fps 的流大概 2-4Mbps4MB 能扛住 8 秒左右的突發(fā)。注意 Linux 下SO_RCVBUF會(huì)被內(nèi)核限制在net.core.rmem_max用sysctl net.core.rmem_max看一下不夠就改。Windows 下這個(gè)值上限不同設(shè)太大反而報(bào)錯(cuò)建議先試 1MB。3.2 序列號排序與亂序處理策略UDP 亂序是常態(tài)尤其是跨公網(wǎng)或經(jīng)過多級交換。我的做法是維護(hù)一個(gè)滑動(dòng)窗口窗口大小 64 個(gè)包收到包先按 seq 插入有序位置然后從窗口頭部連續(xù)輸出。如果窗口滿了還有空洞說明丟包直接跳過空洞繼續(xù)同時(shí)記錄丟包數(shù)。丟包超過閾值比如 5%就告警因?yàn)?H264 丟一個(gè) I 幀分片后面全花。from collections import OrderedDict class ReorderBuffer: def __init__(self, max_size64): self.buf OrderedDict() self.max_size max_size self.next_seq None def push(self, seq, payload): if self.next_seq is None: self.next_seq seq self.buf[seq] payload # 窗口溢出強(qiáng)制推進(jìn) while len(self.buf) self.max_size: k next(iter(self.buf)) self.buf.pop(k) self.next_seq (k 1) 0xFFFF # 輸出連續(xù)部分 out [] while self.next_seq in self.buf: out.append(self.buf.pop(self.next_seq)) self.next_seq (self.next_seq 1) 0xFFFF return out參數(shù)說明max_size根據(jù)網(wǎng)絡(luò)抖動(dòng)調(diào)局域網(wǎng) 32 夠用公網(wǎng)建議 128。next_seq用 16 位掩碼回繞RTP 序列號是 uint16。3.3 寫文件時(shí)的起始碼與 SPS/PPS 處理H264 文件要能播開頭必須有 SPS 和 PPS。RTP 流里 SPS/PPS 通常在每個(gè) I 幀前以單 NALU 發(fā)送Type 7 和 8。如果抓包從中間開始可能錯(cuò)過 SPS/PPS這時(shí)文件播不了。解決辦法是緩存最近一組 SPS/PPS遇到 I 幀時(shí)先寫 SPS/PPS 再寫 I 幀。判斷 I 幀看 NALU Type 5。sps_pps_cache b def write_nalu(nalu, out_file): global sps_pps_cache nalu_type nalu[0] 0x1F if nalu_type 7 or nalu_type 8: sps_pps_cache b\x00\x00\x00\x01 nalu return if nalu_type 5 and sps_pps_cache: out_file.write(sps_pps_cache) out_file.write(b\x00\x00\x00\x01 nalu)注意sps_pps_cache不要無限增長每次遇到新的 SPS 就替換而不是追加否則文件頭會(huì)重復(fù)寫多組。3.4 用 ffmpeg 驗(yàn)證輸出文件是否可播寫完文件別急著交付先用 ffmpeg 過一遍ffmpeg -v error -i output.h264 -f null -沒有報(bào)錯(cuò)說明 NALU 結(jié)構(gòu)正確。再用ffprobe看幀信息ffprobe -v error -show_frames -select_streams v -of csv output.h264 | head -20如果報(bào)Invalid NAL unit size或no frame回去查 FU-A 重組邏輯大概率是首片 NALU 頭重建錯(cuò)了。4. 避坑與排查RTP 轉(zhuǎn) H264 最常見的 5 個(gè)翻車現(xiàn)場4.1 文件能播但花屏每隔幾秒綠一次現(xiàn)象播放器打開正常但周期性花屏。原因丟包導(dǎo)致 I 幀分片不完整或者 P 幀參考丟失。解決在重組邏輯里加丟包檢測發(fā)現(xiàn)序列號空洞且空洞跨越了 FU-A 的 start 和 end直接丟棄這個(gè)不完整 NALU不要寫半截?cái)?shù)據(jù)。同時(shí)統(tǒng)計(jì)丟包率超過 3% 就說明網(wǎng)絡(luò)需要優(yōu)化。4.2 文件頭有 SPS/PPS 但播放器仍報(bào)錯(cuò)現(xiàn)象用十六進(jìn)制看文件開頭確實(shí)有00 00 00 01 67但 VLC 打不開。原因SPS/PPS 寫在了第一個(gè) I 幀之后或者 SPS 和 PPS 之間插入了其他 NALU。解決確保 SPS、PPS、I 幀三者連續(xù)中間不能有 SEI 或其他類型。我一般緩存 SPS/PPS 后遇到 I 幀時(shí)按 SPS、PPS、I 的順序一次性寫出。4.3 UDP 收包丟包嚴(yán)重recvfrom 返回 EAGAIN現(xiàn)象recvfrom頻繁拋BlockingIOError或EAGAIN。原因socket 設(shè)了非阻塞但沒數(shù)據(jù)或者緩沖區(qū)太小。解決非阻塞模式下用select或epoll等待可讀緩沖區(qū)按 3.1 節(jié)調(diào)大。另外注意 Python GIL 在高包速率下會(huì)成為瓶頸超過 2000 包/秒建議用 C 或 Go 重寫接收端。4.4 序列號回繞導(dǎo)致排序錯(cuò)亂現(xiàn)象流跑了幾小時(shí)后突然大量丟包告警。原因RTP 序列號是 uint16從 65535 回繞到 0排序邏輯沒處理回繞。解決所有序列號比較都用(a - b) 0xFFFF的方式判斷先后不要直接比大小。上面的ReorderBuffer已經(jīng)用了掩碼但自定義邏輯容易漏。4.5 國標(biāo)平臺(tái)對接時(shí) start preview failed現(xiàn)象平臺(tái)下發(fā)預(yù)覽請求后返回start preview failed maybe rtp session false or preview links nun。原因SIP 信令協(xié)商的 RTP 端口和實(shí)際發(fā)流端口不一致或者 SSRC 不匹配。解決抓 SIP 包看 SDP 里的mvideo端口確認(rèn)發(fā)流目標(biāo)端口一致檢查 RTP 頭的 SSRC 是否和平臺(tái)期望的一致。這類問題用 udp 網(wǎng)絡(luò)調(diào)試工具對著端口抓一下就能定位。5. 進(jìn)階用 iperf3 打流驗(yàn)證接收端極限與性能調(diào)優(yōu)5.1 用 iperf3 UDP 模式測接收端吞吐上限寫完接收程序別只拿真實(shí)流測用 iperf3 打 UDP 流能壓出極限# 服務(wù)端接收端機(jī)器 iperf3 -s -u -p 5004 # 客戶端發(fā)送端機(jī)器 iperf3 -c 192.168.1.100 -u -p 5004 -b 50M -l 1400 -t 60-b 50M是目標(biāo)帶寬-l 1400是包長模擬 RTP 包大小。跑完后看服務(wù)端的Lost/Total和Jitter。如果 50Mbps 下丟包超過 1%說明接收端處理不過來需要優(yōu)化把 Python 換成 C 擴(kuò)展、用recvmmsg批量收包、或者上 DPDK。我實(shí)測 Python 單線程在 2.5GHz CPU 上大概能處理 30-40Mbps 的 RTP 流再高就丟。5.2 接收端零拷貝與批量收包技巧Linux 下用recvmmsg一次收多個(gè)包減少系統(tǒng)調(diào)用#define VLEN 32 struct mmsghdr msgs[VLEN]; struct iovec iovecs[VLEN]; char bufs[VLEN][2048]; for (int i 0; i VLEN; i) { iovecs[i].iov_base bufs[i]; iovecs[i].iov_len sizeof(bufs[i]); msgs[i].msg_hdr.msg_iov iovecs[i]; msgs[i].msg_hdr.msg_iovlen 1; } int n recvmmsg(sockfd, msgs, VLEN, MSG_WAITFORONE, NULL);VLEN設(shè) 32 是經(jīng)驗(yàn)值太大延遲高太小系統(tǒng)調(diào)用多。MSG_WAITFORONE表示收到一個(gè)就返回適合低延遲場景。這個(gè)改動(dòng)能把吞吐提升 2-3 倍。5.3 驗(yàn)證方法從文件反推 RTP 流質(zhì)量最后分享一個(gè)我常用的驗(yàn)證習(xí)慣把生成的 H264 文件用 ffmpeg 轉(zhuǎn)成 mp4然后看幀率和時(shí)長是否和預(yù)期一致。ffmpeg -fflags genpts -r 25 -i output.h264 -c copy output.mp4 ffprobe -v error -show_entries formatduration -of defaultnw1 output.mp4如果時(shí)長明顯偏短說明丟幀多如果幀率不對檢查時(shí)間戳處理。我一般會(huì)在接收端同時(shí)記錄 RTP 時(shí)間戳和文件幀數(shù)做交叉驗(yàn)證。這套流程跑熟之后拿到任何一路 UDP RTP 流從抓包到出可播文件半小時(shí)內(nèi)能搞定。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取