从 H.264 裸流到 RTSP 推流:一次完整的工程实现

发布时间:2026/10/21 11:29:29

从 H.264 裸流到 RTSP 推流:一次完整的工程实现 本文基于一个实际的 Qt/C 工程完整走一遍H.264 裸流文件 → RTSP 推流 → FFmpeg 拉流播放的链路。不依赖 FFmpeg 库从字节层面手工实现 RTSP 信令、RTP 封包把每个环节都摊开讲清楚。读完你应该能明白H.264 裸流怎么读取、怎么按 AU 组帧SPS/PPS 怎么从码流里提取、怎么通过 SDP 传给服务器RTSP 推流的信令流程OPTIONS → ANNOUNCE → SETUP → RECORDRTP 怎么把一帧 NALU 切成单包 / FU-A 分片时间戳怎么换算、推流节奏怎么控制一、整体链路┌────────────────────────────────────────────────────────────┐ │ H264FileSource │ │ 读取 AnnexB 裸流 → 按 AU 组帧 → Frame(H264, AnnexB) │ ├────────────────────────────────────────────────────────────┤ │ RtspPushClient │ │ OPTIONS → ANNOUNCE(SDP) → SETUP → RECORD │ │ 按 DTS 墙钟节流 → 逐帧调用 RtpSender │ ├────────────────────────────────────────────────────────────┤ │ RtpSender │ │ 拆 NALU → 单包 / FU-A → 加 RTP 头 → UDP 发送 │ │ 定期发送 RTCP SR │ ├────────────────────────────────────────────────────────────┤ │ ZLMediaKit接收转发 │ │ ffmpeg拉流播放 │ └────────────────────────────────────────────────────────────┘整条链路的关键点一句话概括从 AnnexB 裸流里切出 AU把 SPS/PPS 抽出来放进 SDP 的sprop-parameter-sets剩下的 NALU 按 MTU 分成单包或 FU-A加 RTP 头后推给服务器。下面分阶段展开。二、阶段一读取 H.264 裸流2.1 AnnexB 的结构H.264 裸流是连续的 NALU之间用起始码分隔00 00 00 01 | NALU#1 | 00 00 00 01 | NALU#2 | 00 00 00 01 | NALU#3 ...起始码可能是 3 字节00 00 01或 4 字节00 00 00 01。我们工程里统一按 4 字节处理3 字节也兼容。每个 NALU 的第一个字节是 NALU Header0 1 2 3 4 5 6 7 -------- |F|NRI| Type | --------我们关心的 TypeType名称意义1非 IDR sliceP 帧 / B 帧5IDR sliceI 帧关键帧6SEI补充信息7SPS序列参数集8PPS图像参数集9AUD访问单元分隔符2.2 打开文件时扫描 SPS/PPS裸流文件开头通常是00 00 00 01 67 ... (SPS) 00 00 00 01 68 ... (PPS) 00 00 00 01 65 ... (IDR)H264FileSource::open()会一直读到 SPS 和 PPS 都拿到或者遇到第一个 VCL NAL 为止bool H264FileSource::open(const QString path) { _file.setFileName(path); if (!_file.open(QIODevice::ReadOnly)) return false; QByteArray sps, pps; while (!_file.atEnd()) { QByteArray nalu readNextNalu(); if (nalu.isEmpty()) break; uint8_t t nalu[0] 0x1F; if (t 7 sps.isEmpty()) sps nalu; else if (t 8 pps.isEmpty()) pps nalu; else if ((t 1 || t 5) (sps.isEmpty() || pps.isEmpty())) { break; // 流异常 } if (!sps.isEmpty() !pps.isEmpty()) break; } QByteArray extradata; extradata.append(\x00\x00\x00\x01, 4); extradata.append(sps); extradata.append(\x00\x00\x00\x01, 4); extradata.append(pps); TrackInfo ti; ti.codec CodecId::H264; ti.trackIndex 0; ti.clockRate 90000; ti.extradata extradata; // 后面生成 SDP 用 _tracks.append(ti); _file.seek(0); return true; }为什么一开始就要抽 SPS/PPS因为后面 ANNOUNCE 里要把它 Base64 编码后放进sprop-parameter-sets让服务器提前知道分辨率、profile。2.3 按 AU 组帧裸流里一个访问单元Access UnitAU通常由一个 AUD 开头紧跟 SPS/PPS/SEI然后是若干 VCL NAL。我们按下面的规则切分遇到 AUDtype 9一定切分遇到 VCLtype 1 或 5如果当前 AU 已经有 VCL 了则切分SPS/PPS/SEI 跟随当前 AU不主动切分bool H264FileSource::isAuStart(uint8_t nalType) const { if (nalType 9) return true; if (nalType 1 || nalType 5) return hasVclInAu(); return false; } Frame::Ptr H264FileSource::nextFrame() { while (!_file.atEnd()) { QByteArray nalu readNextNalu(); if (nalu.isEmpty()) break; uint8_t t nalu[0] 0x1F; if (!_auNalus.isEmpty() isAuStart(t)) { Frame::Ptr f buildFrame(); // 输出上一 AU _auNalus.append(nalu); // 当前 NAL 作为新 AU 起点 return f; } _auNalus.append(nalu); } if (!_auNalus.isEmpty()) { Frame::Ptr f buildFrame(); _eof true; return f; } return nullptr; }组帧时把 AU 内所有 NAL 用起始码拼成一个 AnnexB 数据块Frame::Ptr H264FileSource::buildFrame() { QByteArray data; bool key false; for (const auto n : _auNalus) { data.append(\x00\x00\x00\x01, 4); data.append(n); uint8_t t ((uint8_t)n[0]) 0x1F; if (t 5) key true; } _auNalus.clear(); _dts 90000 / _frameRate; // 90kHz 时基 return FrameFactory::create( CodecId::H264, data, _dts, _dts, key, false, 0); }到这里数据源就完成了每一帧是一个完整的 AU AnnexB 数据块附带90kHz 时间戳和是否为关键帧。三、阶段二RTP 封包封包这一步在RtpSender里做。核心思路一个 AU 可能包含多个 NALU逐个处理小于 MTU 的 NALU → 单包模式大于 MTU 的 NALU → FU-A 分片3.1 拆 NALUQListQByteArray RtpSender::splitAnnexB(const uint8_t* data, int size) { QListQByteArray nalus; int pos 0; while (pos size) { int scLen 0; if (pos 4 size data[pos]0 data[pos1]0 data[pos2]0 data[pos3]1) scLen 4; else if (pos 3 size data[pos]0 data[pos1]0 data[pos2]1) scLen 3; else { pos; continue; } int start pos scLen; int next start; while (next size) { if (next 4 size data[next]0 data[next1]0 data[next2]0 data[next3]1) break; if (next 3 size data[next]0 data[next1]0 data[next2]1) break; next; } if (next start) { nalus.append(QByteArray( reinterpret_castconst char*(data start), next - start)); } pos next; } return nalus; }3.2 RTP 头构建RTP 固定头 12 字节0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 -------------------------------- |V2|P|X| CC |M| PT | Sequence Number | -------------------------------- | Timestamp | -------------------------------- | SSRC | --------------------------------我们工程里RtpHeader结构体用位域定义struct RtpHeader { uint8_t cc:4; uint8_t x:1; uint8_t p:1; uint8_t version:2; uint8_t pt:7; uint8_t m:1; uint16_t seq; uint32_t timestamp; uint32_t ssrc; void toNetworkOrder() { seq qToBigEndian(seq); timestamp qToBigEndian(timestamp); ssrc qToBigEndian(ssrc); } };注意位域的字节序跟编译器有关实际项目中最好按字节显式拼移植性更稳。这里为了简洁沿用位域写法。字段含义字段取值说明version2固定pt96SDP 里约定m0/1一帧的最后一包为 1seq每次 1用于接收端检测丢包timestamp同帧相同90kHz 时基ssrc随机会话内唯一3.3 单包模式NALU 尺寸 ≤ MTU1400时直接result.append(nalu); // nalu 不带起始码3.4 FU-A 分片NALU 超过 MTU 时拆成多个 RTP 包每个包结构[RTP Header 12B] [FU Indicator 1B] [FU Header 1B] [分片数据]FU Indicator(NRI 5) | 28Type 固定 28FU HeaderS|E|R|TypeS1 表示首片E1 表示末片Type 是原始 NALU 类型uint8_t nalRefIdc (naluData[0] 0x60) 5; uint8_t fuIndicator (nalRefIdc 5) | 0x1C; // 28 0x1C const uint8_t* payloadData naluData 1; // 跳过原 NALU 头 int payloadSize size - 1; int offset 0; bool first true; while (payloadSize 0) { int chunkSize qMin(payloadSize, MTU - 2); QByteArray payload; payload.append(char(fuIndicator)); uint8_t fuHeader nalType; if (first) { fuHeader | 0x80; first false; } // S1 if (payloadSize - chunkSize 0) fuHeader | 0x40; // E1 payload.append(char(fuHeader)); payload.append(reinterpret_castconst char*(payloadData offset), chunkSize); result.append(payload); offset chunkSize; payloadSize - chunkSize; }最后一包的M 位会被置 1告诉接收端这一帧结束了。3.5 一帧内多个 NALU 的 M 位处理sendFrame()里遍历所有 payload只有最后一个置 M1for (int i 0; i payloads.size(); i) { bool marker (i payloads.size() - 1); sendRtpPacket(payloads[i], marker, ts); }3.6 发送 RTP 包bool RtpSender::sendRtpPacket(const QByteArray payload, bool marker, uint32_t ts) { RtpHeader hdr; hdr.pt _pt; hdr.m marker ? 1 : 0; hdr.seq _seq; hdr.timestamp ts; hdr.ssrc _ssrc; hdr.toNetworkOrder(); QByteArray packet; packet.append(reinterpret_castconst char*(hdr), 12); packet.append(payload); qint64 sent _socket-writeDatagram(packet, _destAddr, _destPort); _packetCount; _octetCount packet.size(); return sent packet.size(); }RTP 到这里就完成了。剩下的就是怎么把这个 sender 跟 RTSP 会话绑定—— 这就是下一阶段的活。四、阶段三RTSP 信令RTSP 推流跟拉流最大的区别是拉流推流OPTIONS → DESCRIBE → SETUP → PLAYOPTIONS → ANNOUNCE → SETUP → RECORD服务器告诉客户端 SDP客户端告诉服务器 SDP4.1 OPTIONS握手第一步查询服务器支持的方法OPTIONS rtsp://127.0.0.1:554/live/test RTSP/1.0 CSeq: 1 User-Agent: QtRTSPUSH服务器返回RTSP/1.0 200 OK CSeq: 1 Public: DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, ANNOUNCE, RECORD代码里sendRequest(OPTIONS, url)就完事不需要额外头。4.2 ANNOUNCE把 SDP 交给服务器这一步是关键。客户端要把自己要推的流描述成 SDP通过Content-Type: application/sdp发给服务器。SDP 生成RtspPushClient::generateSDP()v0 o- timestamp 0 IN IP4 127.0.0.1 sNative stream t0 0 acontrol:* mvideo 0 RTP/AVP 96 artpmap:96 H264/90000 afmtp:96 packetization-mode1;profile-level-id42a01e; sprop-parameter-setsZ0LAHtoCgPaEAAAAwAQAAAMDGxQ,aM4yyA acontrol:streamid0几个关键字段mvideo 0 RTP/AVP 96媒体类型 video载荷类型 96artpmap:96 H264/90000PT 96 对应 H264时钟 90kHzafmtp:96 packetization-mode1FU-A 分片模式profile-level-id从 SPS 前 3 字节拼出来sprop-parameter-setsSPS/PPS 的 Base64逗号分隔从 extradata 提取 SPS/PPSQString RtspPushClient::generateSDP() { auto tracks _source-tracks(); QString sdp; sdp v0\r\n; sdp o- QString::number(QDateTime::currentSecsSinceEpoch()) 0 IN IP4 _host \r\n; sdp sNative stream\r\n; sdp t0 0\r\n; sdp acontrol:*\r\n; for (const auto t : tracks) { if (t.codec CodecId::H264) { QByteArray sps, pps; for (const auto n : splitAnnexB(t.extradata)) { if (n.isEmpty()) continue; uint8_t type ((uint8_t)n[0]) 0x1F; if (type 7 sps.isEmpty()) sps n; else if (type 8 pps.isEmpty()) pps n; } QString spsB64 QString::fromLatin1(sps.toBase64()); QString ppsB64 QString::fromLatin1(pps.toBase64()); QString plid 42a01e; if (sps.size() 4) { uint32_t v (uint8_t(sps[1]) 16) | (uint8_t(sps[2]) 8) | uint8_t(sps[3]); plid QString(%1).arg(v, 6, 16, QChar(0)).toLower(); } sdp mvideo 0 RTP/AVP 96\r\n; sdp artpmap:96 H264/90000\r\n; sdp afmtp:96 packetization-mode1;profile-level-id plid ;sprop-parameter-sets spsB64 , ppsB64 \r\n; sdp acontrol:streamid QString::number(t.trackIndex) \r\n; } } return sdp; }为什么 profile-level-id 要单独拼它是 SPS 的第 2/3/4 字节拼接SPS[1] 16 | SPS[2] 8 | SPS[3]。取前三字节可以准确表达 profileBaseline/Main/High和 level3.0/4.0/…服务器用它来做兼容性检查。sprop-parameter-sets是必须的吗是。没有它服务器和后续拉流的客户端在收到第一个 I 帧之前不知道 SPS/PPS可能卡住、花屏或解码失败。它是 RTSP 推流秒开的关键。4.3 SETUP建传输通道每一路媒体都要单独 SETUPSETUP rtsp://127.0.0.1:554/live/test/streamid0 RTSP/1.0 CSeq: 3 Session: 12345678 Transport: RTP/AVP/UDP;unicast;client_port23456-23457;moderecord我们工程里用 UDP 模式客户端本地绑一个端口服务器往这个端口发void RtspPushClient::sendSetupForTrack(int streamId, CodecId codec) { uint16_t localRtpPort 20000 QRandomGenerator::global()-bounded(20000); auto sender std::make_sharedRtpSender(0); if (!sender-bindTo(localRtpPort)) { emit error(Failed to bind RTP port); return; } sender-setPayloadType(codec CodecId::H264 ? 96 : 97); int cseq _cseq; _cseqToStreamId[cseq] streamId; _pendingSetup[streamId] sender; QByteArray request; request SETUP _url.toString().toUtf8() /streamid QByteArray::number(streamId) RTSP/1.0\r\n; request CSeq: QByteArray::number(cseq) \r\n; request Session: _sessionId.toUtf8() \r\n; request Transport: RTP/AVP/UDP;unicast;client_port QByteArray::number(localRtpPort) - QByteArray::number(localRtpPort 1) ;moderecord\r\n; request \r\n; _tcpSocket-write(request); _tcpSocket-flush(); }服务器响应里会带上它自己的端口RTSP/1.0 200 OK CSeq: 3 Session: 12345678 Transport: RTP/AVP/UDP;unicast;client_port23456-23457;server_port33470-33471拿到server_port后给 sender 设置目标QHostAddress serverAddr(_host); sender-setDestination(serverAddr, serverRtpPort); sender-setRtcpDestination(serverAddr, serverRtcpPort); _rtpSenders[streamId] sender;多路 track 就循环 SETUP_setupIdx递增。4.4 RECORD启动推流RECORD rtsp://127.0.0.1:554/live/test RTSP/1.0 CSeq: 5 Session: 12345678 Range: npt0-服务器返回 200 之后正式进入推流状态。void RtspPushClient::handleRecordResponse() { _isPlaying true; emit connected(); sendConfigFrames(); // 先推 SPS/PPS startSending(); // 再启动节流循环 }4.5 为什么先推 config 帧RTSP 的服务器通常是来一帧转发一帧。如果推流刚开始就发 P 帧后面的拉流端会因为缺少 SPS/PPS 无法解码。所以在正式推流前先把 SPS/PPS 作为 config 帧独立发一遍void RtspPushClient::sendConfigFrames() { for (const auto t : _source-tracks()) { if (t.codec ! CodecId::H264) continue; auto it _rtpSenders.find(t.trackIndex); if (it _rtpSenders.end()) continue; for (const auto n : splitAnnexB(t.extradata)) { uint8_t type ((uint8_t)n[0]) 0x1F; if (type ! 7 type ! 8) continue; auto f FrameFactory::create( CodecId::H264, n, 0, 0, false, true, t.trackIndex); it.value()-sendFrame(f); } } }这样服务器和拉流端在建流阶段就拿到了 SPS/PPS即使是 mid-stream 加入也能秒开。五、阶段四按 DTS 节奏推流如果直接 while 循环把文件读空灌进 UDP服务器要么丢包要么缓冲爆掉。必须按帧的 DTS 差来节流。5.1 基准 墙钟每个 track 记录三个量首帧 DTS首帧发送时的墙钟微秒当前帧相对首帧的 DTS 差目标时刻 首帧墙钟 dts 差换算成的时间。当前时刻没到目标时刻就等下一 tick。void RtspPushClient::onSendTick() { int64_t nowUs QDateTime::currentMSecsSinceEpoch() * 1000LL; const int MAX_SEND_PER_TICK 10; for (int sent 0; sent MAX_SEND_PER_TICK; sent) { if (_pendingFrames.isEmpty()) { Frame::Ptr f _source-nextFrame(); if (!f) { if (!_source-seekToStart()) { _sendTimer-stop(); emit finished(); return; } _baseDtsMap.clear(); _baseWallUsMap.clear(); return; } _pendingFrames.append(f); } Frame::Ptr frame _pendingFrames.first(); int streamId frame-getIndex(); int clockRate clockRateOfStream(streamId); if (!_baseDtsMap.contains(streamId)) { _baseDtsMap[streamId] (int64_t)frame-dts(); _baseWallUsMap[streamId] nowUs; } int64_t elapsedDts (int64_t)frame-dts() - _baseDtsMap[streamId]; int64_t elapsedUs elapsedDts * 1000000LL / clockRate; int64_t targetUs _baseWallUsMap[streamId] elapsedUs; if (nowUs targetUs) return; // 还没到点下次再发 _pendingFrames.removeFirst(); auto it _rtpSenders.find(streamId); if (it ! _rtpSenders.end()) { it-value()-sendFrame(frame); } } }5.2 时间戳直接复用H264FileSource里 DTS 已经是 90kHz 时基RTP 时间戳也是 90kHz所以sender-sendFrame(frame); // 内部直接用 frame-dts() 作为 RTP timestamp如果是 AAC就得换成采样率时基rtpTs dts * sampleRate / 90000。5.3 周期性 RTCP SR每 5 秒发一次 RTCP Sender Report给接收端提供 NTP 时间戳做唇音同步void RtspPushClient::onRtcpTick() { if (!_isPlaying) return; for (auto it _rtpSenders.begin(); it ! _rtpSenders.end(); it) { it.value()-sendRtcpSr(); } }六、main极简测试入口cpp#include QCoreApplication #include QDebug #include QDir #include QFileInfo #include RtspPushClient.h int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QString filePath QDir(QCoreApplication::applicationDirPath()) .filePath(bin/output.h264); if (!QFileInfo::exists(filePath)) { qCritical() File not found: filePath; return -1; } QString rtspUrl rtsp://127.0.0.1:554/live/test; mymedia::RtspPushClient client; client.setFilePath(filePath); QObject::connect(client, mymedia::RtspPushClient::connected, []() { qDebug() [main] connected, pushing...; }); QObject::connect(client, mymedia::RtspPushClient::error, [](const QString msg) { qWarning() [main] error: msg; }); QObject::connect(client, mymedia::RtspPushClient::finished, [app]() { qDebug() [main] finished.; app.quit(); }); client.connectTo(rtspUrl); return app.exec(); }rtsp推流
延伸阅读

更多相关文章

2026/10/21 11:29:28

企业微信自动回复上线前,先做哪三组测试?

上线前要过的测试不用多,三组。三组分别对应三类事故:答错、接不住、断线。每组都要有写死的通过标准,否则测完只是跑了一遍流程,谁也不敢说可以放量。[外链图片转存中…(img-jscSpNRn-1791594241511)]## 三组测试各防什么**边界测…

2026/10/21 11:24:28

【面试题】Python 面试题

1. Python中列表和元组的区别是什么?分别适合什么场景? list 列表 []:可变序列,增删改;内存占用略高。 tuple 元组 ():不可变序列,元素一旦定义不可修改;可哈希,能作为字…

2026/10/21 12:25:17

电赛无线传输信号模拟系统:零中频架构与信道模拟工程实现

简介:这份资源是2024年全国大学生电子设计竞赛C题「无线传输信号模拟系统」的江苏省第一名完整方案,面向备赛电赛的本科生、研究生及电子设计爱好者,尤其适合需要攻克DDS信号生成、调幅波合成与多径传输模拟等难点的中高级选手。方案以STM32F…

2026/10/21 12:25:17

RAG从入门到实战:为大模型构建外挂知识库的完整指南

1. 先说结论:RAG是什么,一句话能不能讲清楚 如果你在技术圈泡过一阵子,肯定绕不开RAG这个缩写。全称Retrieval-Augmented Generation,中文叫检索增强生成。我见过太多人一解释RAG就搬出概念图、向量数据库、Embedding模型、相似度…

2026/10/21 12:25:17

Qwen开源多模态嵌入模型:图文统一语义空间与RAG落地实践

Qwen家族这波更新里,真正让我兴奋的其实不是又大一号的对话模型,而是那款开源多模态嵌入大模型。做过多模态检索和多模态RAG的朋友应该都有同感:向量检索这几年在纯文本侧已经卷得非常成熟,但图、文混在一起想用一个统一语义空间做…

2026/10/21 12:25:17

OpenClaw部署实战:从移动云到Windows本地,零基础玩转AI技能插件

如果你刷到过别人用AI助理自动整理资料、定时跑脚本、随手调用各种工具,但一直觉得那是“高玩专属”,那这篇就是写给你的。OpenClaw(也叫Clawdbot)是一个开源的智能体运行框架,简单说就是把大模型从一个“只会聊天的对…

2026/10/21 12:20:15

Flutter鸿蒙跨端开发:async/await与图片下载实践

做 Flutter 鸿蒙跨端开发,几乎绕不开一个场景:从网络拉取一张图片,加载出来、展示给用户,甚至保存到本地。图片下载四个字看起来简单,可真到了鸿蒙设备上,异步模型、网络请求、缓存策略、系统权限会全部凑到…

2026/10/21 4:53:08

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/21 0:00:35

交通流仿真实战:IDM跟驰与MOBIL换道模型参数调优与避坑指南

简介:这份资源是面向交通工程学习者与Python开发者的微观交通流仿真项目,核心实现智能驾驶模型(IDM)下的车辆跟驰与换道行为,可用于理解速度、密度与流量之间的动态关系,适合具备Python基础、希望深入交通模…

2026/10/21 0:00:35

揭穿智能体表面顺从:用追踪代码重建任务执行确定性

1. 表面顺从不是智能体的“礼貌”,而是任务执行链路上的隐性断点最近在调试一个跨平台长周期任务调度系统时,我反复遇到一种令人困惑的现象:智能体在用户指令发出后,几乎秒级响应“已收到”“正在处理”“请稍候”,UI上…

2026/10/21 0:00:35

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事:AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里,按宣传口径,本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”,而是这话背后的含金量——它把过去只能…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑