从 RTSP 拉流到 FFmpeg 解码播放(H.264 单视频流版)

发布时间:2026/10/21 20:45:11
从 RTSP 拉流到 FFmpeg 解码播放(H.264 单视频流版)
本文是《H.264 裸流 → RTSP 推流》的对称篇完整走一遍RTSP 拉流 → RTP 解包 → H.264 解码 → 屏幕播放的链路。协议层用 Qt 手工实现只在解码显示时使用 FFmpeg。读完你应该能明白RTSP 拉流信令OPTIONS → DESCRIBE → SETUP → PLAY 每一步做什么SDP 里的sprop-parameter-sets怎么解析、怎么转成 AnnexB extradataRTP 到达后怎么拆包单包 / FU-A 重组 / STAP-A 聚合Frame 怎么交给 FFmpeg 解码、AVFrame 怎么显示为什么 config 帧要单独发一次只讨论H.264 单视频流暂不涉及 AAC。一、整体链路┌────────────────────────────────────────────────────────────┐ │ RtspClient │ │ OPTIONS → DESCRIBE → SETUP → PLAY → TEARDOWN │ │ TCP 收发信令 UDP 收 RTP/RTCP │ ├────────────────────────────────────────────────────────────┤ │ RTP 解包层RtspClient 内部 │ │ 单包 / FU-A 重组 / STAP-A 聚合 → AnnexB Frame │ ├────────────────────────────────────────────────────────────┤ │ PullWidget │ │ Frame → AVPacket → avcodec_send_packet │ │ AVFrame → sws_scale → QImage → QLabel │ ├────────────────────────────────────────────────────────────┤一句话概括用 RTSP 跟服务器握手拿到 SDP建好 RTP socket 后进入接收态按 NALU 类型重组出完整的 H.264 帧塞进 FFmpeg 解码器再把 AVFrame 转成 QImage 上屏。跟推流方向最大的区别推流方是 SDP 的生成者拉流方是 SDP 的解析者。二、阶段一RTSP 信令拉流信令跟推流是镜像的推流拉流OPTIONS → ANNOUNCE → SETUP → RECORDOPTIONS → DESCRIBE → SETUP → PLAY客户端发 SDP服务器发 SDPmoderecordmodeplay2.1 状态机RtspClient用一个自增的_cseq当状态机。每个请求分配一个 CSeq响应回来按 CSeq 分发void RtspClient::processResponse(const QByteArray data) { // 解析状态行 QString statusLine QString::fromUtf8(data.left(data.indexOf(\r\n))); QRegExp rx(RTSP/\\d\\.\\d (\\d)); if (rx.indexIn(statusLine) 0) return; int code rx.cap(1).toInt(); if (code ! 200) { emit error(...); return; } int cseq extractHeader(data, CSeq).toInt(); if (cseq 1) { // OPTIONS 响应 sendRequest(DESCRIBE, _url.toString()); return; } if (cseq 2) { // DESCRIBE 响应 _sessionId extractHeader(data, Session); int bodyPos data.indexOf(\r\n\r\n); if (bodyPos 0) parseSdpTracks(data.mid(bodyPos 4)); sendNextSetup(); return; } if (_cseqToTrackIdx.contains(cseq)) { // SETUP 响应 handleSetupResponse(data); return; } if (cseq _playCseq) { // PLAY 响应 handlePlayResponse(); return; } }跟推流代码几乎是同一个骨架只是把 ANNOUNCE 换成 DESCRIBE、把 RECORD 换成 PLAY。2.2 OPTIONSOPTIONS rtsp://127.0.0.1:554/live/test RTSP/1.0 CSeq: 1 User-Agent: QtRtspClient服务器返回RTSP/1.0 200 OK CSeq: 1 Public: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSETCP 一连接就发void RtspClient::onTcpConnected() { sendRequest(OPTIONS, _url.toString()); }2.3 DESCRIBE拿 SDPDESCRIBE rtsp://127.0.0.1:554/live/test RTSP/1.0 CSeq: 2 Accept: application/sdp服务器返回H.264 单视频流RTSP/1.0 200 OK CSeq: 2 Content-Type: application/sdp Content-Base: rtsp://127.0.0.1:554/live/test/ Content-Length: 289 v0 o- 123456 123456 IN IP4 192.168.1.100 sLive Stream t0 0 acontrol:* mvideo 0 RTP/AVP 96 artpmap:96 H264/90000 afmtp:96 packetization-mode1;profile-level-id42C01E;sprop-parameter-setsZ0LAHtoCgPaEAAAAwAQAAAMDGxQ,aM4yyA acontrol:streamid0这一段是拉流最关键的地方视频解码参数全在 SDP 里。2.4 parseSdpTracks从 SDP 抽 SPS/PPSvoid RtspClient::parseSdpTracks(const QByteArray sdp) { _tracks.clear(); QString s QString::fromUtf8(sdp); QStringList lines s.split(\r\n, QString::SkipEmptyParts); TrackInfo current; bool inMedia false; for (const QString line : lines) { if (line.startsWith(m)) { // 新的媒体段收尾上一个 if (inMedia) _tracks.append(current); current TrackInfo(); inMedia true; QStringList parts line.mid(2).split( ); if (parts.size() 4) { current.mediaType parts[0]; // video current.payloadType parts[3].toInt(); // 96 } } else if (line.startsWith(artpmap:)) { // artpmap:96 H264/90000 QRegExp rx(artpmap:(\\d) ([^/])/(\\d)(?:/(\\d))?); if (rx.indexIn(line) 0) { current.codec rx.cap(2); // H264 current.clockRate rx.cap(3).toInt(); // 90000 } } else if (line.startsWith(afmtp:)) { // 只关心 H.264 的 sprop-parameter-sets QRegExp rx(sprop-parameter-sets([^;\\r\\n\\s])); if (rx.indexIn(line) 0) { QString spsPpsStr rx.cap(1); // xxx,yyy QStringList parts spsPpsStr.split(,); if (parts.size() 2) { current.sps QByteArray::fromBase64(parts[0].toLatin1()); current.pps QByteArray::fromBase64(parts[1].toLatin1()); } } } else if (line.startsWith(acontrol:)) { current.control line.mid(10).trimmed(); // streamid0 } } if (inMedia) _tracks.append(current); qDebug() parsed _tracks.size() tracks; }这一段跟推流的generateSDP是完全对称的操作推流拉流从 extradata 拆 SPS/PPS从sprop-parameter-sets解码出 SPS/PPSsps.toBase64()拼进 SDPQByteArray::fromBase64()还原从 Track 信息拼artpmap正则解析出 codec 和 clockRate自己构造 streamid从acontrol:streamidX读为什么sprop-parameter-sets这么重要它是拉流端解码器能初始化的唯一途径。如果服务器 SDP 里缺这个字段就得等第一个 IDR 帧里带的 SPS/PPS首帧延迟明显变大甚至解码失败。2.5 SETUP建 RTP socketH.264 视频流只需要一次 SETUP。走 UDP 模式时客户端分配本地端口服务器往这里发void RtspClient::sendSetupForTrack(int idx) { auto t _tracks[idx]; // 随机选本地 RTP/RTCP 端口 t.clientRtpPort 20000 QRandomGenerator::global()-bounded(20000); t.clientRtcpPort t.clientRtpPort 1; int cseq _cseq; _cseqToTrackIdx[cseq] idx; QString setupUrl _url.toString(); if (!t.control.isEmpty()) setupUrl / t.control; QByteArray req; req SETUP setupUrl.toUtf8() RTSP/1.0\r\n; req CSeq: QByteArray::number(cseq) \r\n; req Session: _sessionId.toUtf8() \r\n; req Transport: RTP/AVP/UDP;unicast;client_port QByteArray::number(t.clientRtpPort) - QByteArray::number(t.clientRtcpPort) \r\n; req \r\n; _tcpSocket-write(req); _tcpSocket-flush(); }服务器响应RTSP/1.0 200 OK CSeq: 3 Session: 12345678 Transport: RTP/AVP/UDP;unicast;client_port23456-23457;server_port33470-33471关键是server_port客户端只用来发 RTCP RR接收方向不用管——UDP 是无连接的服务器直接往client_port打 RTP本地 socket 收就是。2.6 handleSetupResponse绑本地端口void RtspClient::handleSetupResponse(const QByteArray data) { int cseq extractHeader(data, CSeq).toInt(); int idx _cseqToTrackIdx.value(cseq, -1); if (idx 0) return; auto t _tracks[idx]; // 解析 server_port QString transport extractHeader(data, Transport); QRegExp rx(server_port(\\d)-(\\d)); if (rx.indexIn(transport) 0) { emit error(No server_port in Transport); return; } t.serverRtpPort rx.cap(1).toUShort(); t.serverRtcpPort rx.cap(2).toUShort(); // 绑定本地 RTP socket t.rtpSocket new QUdpSocket(this); if (!t.rtpSocket-bind(QHostAddress::AnyIPv4, t.clientRtpPort, QUdpSocket::ShareAddress)) { emit error(Failed to bind RTP port); return; } connect(t.rtpSocket, QUdpSocket::readyRead, this, [this, idx]() { onRtpReadyRead(idx); }); // 绑定本地 RTCP socket t.rtcpSocket new QUdpSocket(this); t.rtcpSocket-bind(QHostAddress::AnyIPv4, t.clientRtcpPort, QUdpSocket::ShareAddress); connect(t.rtcpSocket, QUdpSocket::readyRead, this, [this, idx]() { onRtcpReadyRead(idx); }); _setupIdx; sendNextSetup(); }2.7 PLAY启动传输PLAY rtsp://127.0.0.1:554/live/test RTSP/1.0 CSeq: 4 Session: 12345678 Range: npt0-服务器返回 200 后开始往客户端端口打 RTP 包。void RtspClient::handlePlayResponse() { _isPlaying true; // ★ 关键为视频 track 构造 VideoTrack把 SPS/PPS 送出去 for (int i 0; i _tracks.size(); i) { auto t _tracks[i]; if (t.codec ! H264) continue; // 1. 构造 VideoTrack携带 extradata auto vt std::make_sharedVideoTrack(CodecId::H264, i); QByteArray extra; extra.append(\x00\x00\x00\x01, 4); extra.append(t.sps); extra.append(\x00\x00\x00\x01, 4); extra.append(t.pps); vt-setExtradata(extra); // 自动 setReady emit videoTrackReady(vt); // 2. config 帧让解码器提前建参数 auto cfgFrame FrameFactory::create( CodecId::H264, extra, 0, 0, false, // key true, // config i); emit frameReceived(cfgFrame); } // RTCP RR 定时器 if (!_rrTimer) { _rrTimer new QTimer(this); connect(_rrTimer, QTimer::timeout, this, RtspClient::onRrTick); } _rrTimer-start(5000); emit connected(); }为什么 PLAY 后要立刻构造 config 帧因为下游的PullWidget拿到 config 帧后会立即发一次avcodec_send_packet让 FFmpeg 把 SPS/PPS 记到解码上下文里。等真正第一个 IDR 到达时解码器已经初始化完毕没有首帧延迟。2.8 TEARDOWN断开时发送TEARDOWN rtsp://127.0.0.1:554/live/test RTSP/1.0 CSeq: 5 Session: 12345678void RtspClient::disconnect() { if (_isPlaying _tcpSocket-state() QAbstractSocket::ConnectedState) { QByteArray req; req TEARDOWN _url.toString().toUtf8() RTSP/1.0\r\n; req CSeq: QByteArray::number(_cseq) \r\n; req Session: _sessionId.toUtf8() \r\n\r\n; _tcpSocket-write(req); _tcpSocket-flush(); _tcpSocket-waitForBytesWritten(500); } // 关闭 socket、清空 track ... }三、阶段二RTP 解包H.264PLAY 之后 RTP 包开始到达。这是拉流方向最费脑子的地方——要把分片的 NALU 重新拼回一个完整的 AU。3.1 入口void RtspClient::onRtpReadyRead(int trackIdx) { auto t _tracks[trackIdx]; while (t.rtpSocket-hasPendingDatagrams()) { QByteArray d; d.resize(int(t.rtpSocket-pendingDatagramSize())); t.rtpSocket-readDatagram(d.data(), d.size()); if (d.size() 12) continue; // 从 RTP 头解析时间戳和 SSRC const uint8_t* p reinterpret_castconst uint8_t*(d.constData()); uint32_t ts (uint32_t(p[4]) 24) | (uint32_t(p[5]) 16) | (uint32_t(p[6]) 8) | uint32_t(p[7]); t.remoteSsrc (uint32_t(p[8]) 24) | (uint32_t(p[9]) 16) | (uint32_t(p[10]) 8) | uint32_t(p[11]); handleH264Rtp(trackIdx, d); } }3.2 H.264 RTP 三种模式NALU Type名称含义1~23单 NALU 包一个 RTP 包 一个完整 NALU24STAP-A聚合多个 NALU 在一个 RTP 包28FU-A一个 NALU 被拆成多个 RTP 包三种都要处理。3.3 handleH264Rtpcppvoid RtspClient::handleH264Rtp(int trackIdx, const QByteArray pkt) { auto t _tracks[trackIdx]; if (pkt.size() 12) return; const uint8_t* p reinterpret_castconst uint8_t*(pkt.constData()); uint32_t ts (uint32_t(p[4]) 24) | (uint32_t(p[5]) 16) | (uint32_t(p[6]) 8) | uint32_t(p[7]); const uint8_t* payload p 12; int len pkt.size() - 12; if (len 0) return; uint8_t nalType payload[0] 0x1F; // ---- FU-A 分片 ---- if (nalType 28) { if (len 2) return; uint8_t fuHeader payload[1]; bool start (fuHeader 0x80) ! 0; bool end (fuHeader 0x40) ! 0; uint8_t origType fuHeader 0x1F; if (start) { t.fuABuffer.clear(); // 重建 NALU HeaderF|NRI 保留Type 用原始类型 uint8_t nalHeader (payload[0] 0xE0) | origType; t.fuABuffer.append(char(nalHeader)); t.fuABuffer.append( reinterpret_castconst char*(payload 2), len - 2); t.fuAInProgress true; } else if (t.fuAInProgress) { t.fuABuffer.append( reinterpret_castconst char*(payload 2), len - 2); if (end) { emitVideoFrame(t.fuABuffer, ts, trackIdx); t.fuABuffer.clear(); t.fuAInProgress false; } } return; } // ---- STAP-A 聚合 ---- if (nalType 24) { int pos 1; // 跳过 STAP-A 头 while (pos 2 len) { int nalSize (payload[pos] 8) | payload[pos 1]; pos 2; if (pos nalSize len) break; QByteArray nalu( reinterpret_castconst char*(payload pos), nalSize); emitVideoFrame(nalu, ts, trackIdx); pos nalSize; } return; } // ---- 单 NALU 包 ---- if (nalType 1 nalType 23) { QByteArray nalu(reinterpret_castconst char*(payload), len); emitVideoFrame(nalu, ts, trackIdx); } }FU-A 重组的关键细节首片FU Header的 S1用它里的 origType 重建原始 NALU Header保留 F 和 NRI 位把 Type 换成原始类型中间片只追加数据不动 header末片FU Header的 E1追加完数据后 emit 一整帧3.4 emitVideoFrame加起始码 → Framevoid RtspClient::emitVideoFrame(const QByteArray nalu, uint32_t ts, int trackIdx) { if (nalu.isEmpty()) return; // 加 4 字节起始码转成 AnnexB 格式 QByteArray data; data.append(\x00\x00\x00\x01, 4); data.append(nalu); bool key ((nalu[0] 0x1F) 5); // NAL type 5 IDR auto frame FrameFactory::create( CodecId::H264, data, ts, ts, // dts pts 90kHz 时间戳 key, // key false, // config trackIdx); emit frameReceived(frame); }到这里RTP 层的任务结束把 UDP 收到的分片包重组成AnnexB 格式的 Frame带上 90kHz 时间戳和 key 标记。四、阶段三FFmpeg 解码PullWidget拿到 Frame 后交给 FFmpeg 解码并显示。4.1 解码器初始化void PullWidget::initDecoder() { const AVCodec* codec avcodec_find_decoder(AV_CODEC_ID_H264); if (!codec) { appendLog(未找到 H.264 解码器); return; } _decCtx avcodec_alloc_context3(codec); if (!_decCtx) return; if (avcodec_open2(_decCtx, codec, nullptr) 0) { appendLog(打开 H.264 解码器失败); avcodec_free_context(_decCtx); _decCtx nullptr; return; } _frame av_frame_alloc(); }注意这里没有手动设置 SPS/PPS。因为我们会在 config 帧到达时通过avcodec_send_packet把 AnnexB 格式的 SPS/PPS 喂给解码器FFmpeg 会自己从码流里提取参数。4.2 Frame → AVPacket → 解码void PullWidget::onFrame(mymedia::Frame::Ptr frame) { if (!frame || !_decCtx || !_frame) return; if (frame-codecId() ! CodecId::H264) return; // 1. Frame 内容 → AVPacket AVPacket* pkt av_packet_alloc(); if (!pkt) return; const QByteArray payload frame-raw(); if (av_new_packet(pkt, payload.size()) 0) { av_packet_free(pkt); return; } memcpy(pkt-data, payload.constData(), payload.size()); if (frame-keyFrame() || frame-configFrame()) pkt-flags | AV_PKT_FLAG_KEY; // 2. 送进解码器 int ret avcodec_send_packet(_decCtx, pkt); av_packet_free(pkt); if (ret 0) return; // 3. 取所有解出的帧 while (avcodec_receive_frame(_decCtx, _frame) 0) { displayFrame(_frame); av_frame_unref(_frame); } }为什么一次 send 可能对应多次 receiveFFmpeg 内部有帧缓冲一个 packet 进去可能吐出 0 个、1 个或多个 frame比如有 B 帧时。4.3 AVFrame → QImagevoid PullWidget::displayFrame(AVFrame* frame) { int w frame-width; int h frame-height; if (w 0 || h 0) return; // 尺寸变化时重建 sws 和 QImage if (!_sws || w ! _lastW || h ! _lastH) { if (_sws) sws_freeContext(_sws); _sws sws_getContext( w, h, (AVPixelFormat)frame-format, // 通常是 YUV420P w, h, AV_PIX_FMT_RGB32, // 目标32 位 RGB SWS_BILINEAR, nullptr, nullptr, nullptr); _lastW w; _lastH h; _image QImage(w, h, QImage::Format_RGB32); } if (!_sws) return; // sws_scale 做颜色空间转换 uint8_t* dst[1] { _image.bits() }; int dstStride[1] { (int)_image.bytesPerLine() }; sws_scale(_sws, frame-data, frame-linesize, 0, h, dst, dstStride); // 上屏按窗口缩放保持宽高比 QPixmap pix QPixmap::fromImage(_image) .scaled(_videoLabel-size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); _videoLabel-setPixmap(pix); }三个要点sws 上下文要缓存只在分辨率变化时重建避免每帧重建的 CPU 开销AV_PIX_FMT_RGB32和QImage::Format_RGB32的字节序要一致都是 0xffRRGGBBbytesPerLine可能大于 width*4对齐填充sws_scale 用 stride 参数处理五、RTCP RR周期反馈拉流方向也需要定期发 RTCP Receiver Report告诉服务器丢包和延迟情况。每 5 秒一次void RtspClient::onRrTick() { if (!_isPlaying) return; for (int i 0; i _tracks.size(); i) { if (_tracks[i].rtcpSocket _tracks[i].remoteSsrc ! 0) { sendReceiverReport(i); } } } void RtspClient::sendReceiverReport(int trackIdx) { auto t _tracks[trackIdx]; int64_t nowUs QDateTime::currentMSecsSinceEpoch() * 1000LL; uint32_t dlsr 0; if (t.lastSrRecvUs 0) { dlsr uint32_t((nowUs - t.lastSrRecvUs) * 65536 / 1000000); } QByteArray rr buildRrPacket(_localSsrc, t.remoteSsrc, t.lastSeq, t.lastSrNtp, dlsr); t.rtcpSocket-writeDatagram(rr, QHostAddress(_host), t.serverRtcpPort); }RTCP 主要是给服务器做 QoS 用不发给客户端也不影响拉流。生产环境建议发。六、完整时序主线程 / TCP 线程 UDP 线程 ───────────────────────────────────────────────────────────── connectTo(url) └─ _tcpSocket-connectToHost(host, port) │ ▼ TCP 连接成功 onTcpConnected() └─ sendRequest(OPTIONS) CSeq1 │ ▼ 200 OK processResponse() ──► cseq1 └─ sendRequest(DESCRIBE) CSeq2 │ ▼ 200 OK processResponse() ──► cseq2 ├─ extractHeader(Session) ├─ parseSdpTracks(sdp) │ └─ 从 sprop-parameter-sets 解出 SPS/PPS └─ sendNextSetup() └─ sendSetupForTrack(0) CSeq3 │ ▼ 200 OK handleSetupResponse() ├─ 解析 server_port ├─ bind 本地 RTP/RTCP socket └─ sendPlay() CSeq4 │ ▼ 200 OK handlePlayResponse() ├─ 构造 VideoTrack SPS/PPS extradata → emit videoTrackReady ├─ 构造 config Frame → emit frameReceived ├─ 启动 RTCP RR 定时器 └─ emit connected() │ ▼ 服务器开始打 RTP ──────────► onRtpReadyRead() ├─ handleH264Rtp() │ ├─ FU-A 重组 │ ├─ STAP-A 拆包 │ └─ 单包直通 └─ emitVideoFrame() └─ emit frameReceived() │ ▼ PullWidget::onFrame() ├─ avcodec_send_packet() └─ avcodec_receive_frame() └─ displayFrame() └─ sws_scale QLabel七、函数职责速查函数所在类职责onTcpConnectedRtspClientTCP 连上 → 发 OPTIONSsendRequestRtspClient唯一的 RTSP 请求构造出口processResponseRtspClient解析状态行 按 CSeq 分发extractHeaderRtspClient大小写不敏感取头字段parseSdpTracksRtspClient从 SDP 解析 SPS/PPS/clockRatesendSetupForTrackRtspClient分配本地端口、发 SETUPhandleSetupResponseRtspClient解析 server_port、bind RTP sockethandlePlayResponseRtspClient构造 VideoTrack config 帧、启动 RRonRtpReadyReadRtspClientRTP 数据到达入口handleH264RtpRtspClientFU-A 重组 / STAP-A 拆包 / 单包直通emitVideoFrameRtspClient加起始码 → Frame → emitinitDecoderPullWidget创建 H.264 解码器上下文onFramePullWidgetFrame → AVPacket → send_packet → receive_framedisplayFramePullWidgetsws_scale → QImage → QLabel八、踩坑速查现象原因PLAY 后一直黑屏SETUP 绑端口失败或服务器 server_port 解析错花屏FU-A 重组时丢了分片或 SPS/PPS 未先喂解码器首帧延迟大没有主动构造 config 帧等 IDR 才喂 SPS/PPS卡死解码器只 send 不 receive帧缓冲满了sws_scale 输出花屏sws 上下文没在分辨率变化时重建内存暴涨每帧都new AVPacket但忘了av_packet_free收到 RTP 但无画面检查 NALU Type 是否被正确识别28/24/1~23 都要处理播放一会儿就停RTCP RR 没发服务器主动断流部分服务器九、小结拉流方向的三层职责RTSP 层OPTIONS → DESCRIBE → SETUP → PLAY拿到 SDP 里的 SPS/PPS 和 server_portRTP 层按 NALU Type 分发到 FU-A / STAP-A / 单包重组出 AnnexB Frame解码层Frame → AVPacket → FFmpeg 解码 → AVFrame → QImage 上屏跟推流方向对照着看几乎每个环节都能找到镜像推流拉流generateSDP()生成parseSdpTracks()解析sendConfigFrames()发 SPS/PPShandlePlayResponse()收 SPS/PPSpackH264()拆成 RTP 包handleH264Rtp()重组成 NALUsendRtpPacket()发onRtpReadyRead()收onSendTick()节流发displayFrame()实时显示两边对照着读RTSP/RTP/H.264 三条边界就一目了然了。rtsp推拉流