ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

本文基于一个实际的 Qt/C++ 工程,完整走一遍H.264 裸流文件 → RTSP 推流 → FFmpeg 拉流播放的链路。不依赖 FFmpeg 库,从字节层面手工实现 RTSP 信令、RTP 封包,把每个环节都摊开讲清楚。

读完你应该能明白:

  • H.264 裸流怎么读取、怎么按 AU 组帧

  • SPS/PPS 怎么从码流里提取、怎么通过 SDP 传给服务器

  • RTSP 推流的信令流程:OPTIONS → ANNOUNCE → SETUP → RECORD

  • RTP 怎么把一帧 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 Header:

0 1 2 3 4 5 6 7 +-+-+-+-+-+-+-+-+ |F|NRI| Type | +-+-+-+-+-+-+-+-+

我们关心的 Type:

Type名称意义
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 Unit,AU)通常由一个 AUD 开头,紧跟 SPS/PPS/SEI,然后是若干 VCL NAL。我们按下面的规则切分:

  • 遇到 AUD(type 9),一定切分

  • 遇到 VCL(type 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 拆 NALU

QList<QByteArray> RtpSender::splitAnnexB(const uint8_t* data, int size) { QList<QByteArray> nalus; int pos = 0; while (pos < size) { int scLen = 0; if (pos + 4 <= size && data[pos]==0 && data[pos+1]==0 && data[pos+2]==0 && data[pos+3]==1) scLen = 4; else if (pos + 3 <= size && data[pos]==0 && data[pos+1]==0 && data[pos+2]==1) scLen = 3; else { ++pos; continue; } int start = pos + scLen; int next = start; while (next < size) { if (next + 4 <= size && data[next]==0 && data[next+1]==0 && data[next+2]==0 && data[next+3]==1) break; if (next + 3 <= size && data[next]==0 && data[next+1]==0 && data[next+2]==1) break; ++next; } if (next > start) { nalus.append(QByteArray( reinterpret_cast<const 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 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |V=2|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一帧的最后一包为 1
seq每次 +1用于接收端检测丢包
timestamp同帧相同90kHz 时基
ssrc随机会话内唯一

3.3 单包模式

NALU 尺寸 ≤ MTU(1400)时直接:

result.append(nalu); // nalu 不带起始码

3.4 FU-A 分片

NALU 超过 MTU 时拆成多个 RTP 包,每个包结构:

[RTP Header 12B] [FU Indicator 1B] [FU Header 1B] [分片数据]
  • FU Indicator:(NRI << 5) | 28,Type 固定 28

  • FU Header:S|E|R|Type,S=1 表示首片,E=1 表示末片,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; } // S=1 if (payloadSize - chunkSize == 0) fuHeader |= 0x40; // E=1 payload.append(char(fuHeader)); payload.append(reinterpret_cast<const char*>(payloadData + offset), chunkSize); result.append(payload); offset += chunkSize; payloadSize -= chunkSize; }

最后一包的M 位会被置 1,告诉接收端这一帧结束了。

3.5 一帧内多个 NALU 的 M 位处理

sendFrame()里遍历所有 payload,只有最后一个置 M=1:

for (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_cast<const 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客户端告诉服务器 SDP

4.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()):

v=0 o=- <timestamp> 0 IN IP4 127.0.0.1 s=Native stream t=0 0 a=control:* m=video 0 RTP/AVP 96 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1;profile-level-id=42a01e; sprop-parameter-sets=Z0LAHtoCgPaEAAAAwAQAAAMDGxQ=,aM4yyA== a=control:streamid=0

几个关键字段:

  • m=video 0 RTP/AVP 96:媒体类型 video,载荷类型 96

  • a=rtpmap:96 H264/90000:PT 96 对应 H264,时钟 90kHz

  • a=fmtp:96 packetization-mode=1:FU-A 分片模式

  • profile-level-id:从 SPS 前 3 字节拼出来

  • sprop-parameter-sets:SPS/PPS 的 Base64,逗号分隔

从 extradata 提取 SPS/PPS:

QString RtspPushClient::generateSDP() { auto tracks = _source->tracks(); QString sdp; sdp += "v=0\r\n"; sdp += "o=- " + QString::number(QDateTime::currentSecsSinceEpoch()) + " 0 IN IP4 " + _host + "\r\n"; sdp += "s=Native stream\r\n"; sdp += "t=0 0\r\n"; sdp += "a=control:*\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 += "m=video 0 RTP/AVP 96\r\n"; sdp += "a=rtpmap:96 H264/90000\r\n"; sdp += "a=fmtp:96 packetization-mode=1;profile-level-id=" + plid + ";sprop-parameter-sets=" + spsB64 + "," + ppsB64 + "\r\n"; sdp += "a=control:streamid=" + QString::number(t.trackIndex) + "\r\n"; } } return sdp; }

为什么 profile-level-id 要单独拼?
它是 SPS 的第 2/3/4 字节拼接:SPS[1] << 16 | SPS[2] << 8 | SPS[3]。取前三字节可以准确表达 profile(Baseline/Main/High)和 level(3.0/4.0/…),服务器用它来做兼容性检查。

sprop-parameter-sets是必须的吗?
是。没有它,服务器和后续拉流的客户端在收到第一个 I 帧之前不知道 SPS/PPS,可能卡住、花屏或解码失败。它是 RTSP 推流秒开的关键。

4.3 SETUP:建传输通道

每一路媒体都要单独 SETUP:

SETUP rtsp://127.0.0.1:554/live/test/streamid=0 RTSP/1.0 CSeq: 3 Session: 12345678 Transport: RTP/AVP/UDP;unicast;client_port=23456-23457;mode=record

我们工程里用 UDP 模式(客户端本地绑一个端口,服务器往这个端口发):

void RtspPushClient::sendSetupForTrack(int streamId, CodecId codec) { uint16_t localRtpPort = 20000 + QRandomGenerator::global()->bounded(20000); auto sender = std::make_shared<RtpSender>(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) + ";mode=record\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_port=23456-23457;server_port=33470-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: npt=0-

服务器返回 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推流

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进