资讯详情

C++手写RTSP/RTP客户端:跨平台拉流与H.264分片重组实战

📅 2026/10/25 4:06:06 | 华诺云谱 👁 阅读
C++手写RTSP/RTP客户端:跨平台拉流与H.264分片重组实战
简介面向流媒体协议学习与音视频客户端开发者的C RTSP/RTP客户端源码可在Linux与Windows双平台编译并已通过live555服务器实测。代码完整实现OPTIONS、DESCRIBE、SETUP、PLAY、PAUSE、TEARDOWN六类RTSP交互配合TCP/UDP套接字封装、线程与互斥锁、日志打印、时间处理等基础模块便于理解会话建立、媒体协商与传输控制的全过程。压缩包共32个文件、约189KB以11个cpp与10个h源文件为核心另含Visual Studio工程文件、Linux下的makefile与common.mk以及一份RTSP客户端源码分析文档兼顾Windows与Linux两套构建方式。已有2324人学习下载。读者可据此对照源码分析文档梳理协议流程在本地复现客户端与服务器通信并借鉴其套接字与线程封装思路为二次开发或排查连接问题提供参考。1. 从一份能跑通的 C RTSP/RTP 客户端源码说起手上有一份 C 写的 RTSP/RTP 流媒体客户端源码目标很明确在 Linux 和 Windows 上都能把一路 RTSP 流拉下来完成 RTSP 信令交互、RTP 收包、解包成帧最后拿到可用的视频数据。这类东西在工程里出现频率很高——做网络摄像机对接、做视频监控预览、做边缘设备上的轻量拉流绕不开 RTSP 和 RTP 这两层协议。很多开发者第一次接触时会被 SDP 协商、RTP 时间戳、分片重组这些细节绊住翻车点集中在“信令通了但收不到包”和“收到包但拼不出完整帧”这两处。这份源码的价值就在于把整条链路摊开给你看适合想搞懂协议细节、又不想从零造轮子的 C 开发者也适合需要把拉流能力嵌进自己项目的人。2. 项目定位与技术选型为什么是纯 C 手写协议栈2.1 这个客户端到底解决什么问题RTSP 本身只是控制协议真正搬视频数据的是 RTP两者配合才能完成一次完整的拉流。市面上不少方案是套一层 FFmpeg 的avformat_open_input几行代码就能拉流但代价是整个 FFmpeg 依赖链被拖进来编译体积大、跨平台适配麻烦出问题时排查链路也长。这份源码走的是另一条路自己实现 RTSP 的请求响应、自己解析 SDP、自己收 RTP 包并做 H.264/H.265 的拆包重组。好处是依赖极轻基本只靠系统 socket 和标准库Linux 下用 epoll 或 selectWindows 下用 Winsock编译出来就是一个干净的可执行文件或静态库。它适合的场景也很清楚设备端资源有限、不想引入重型多媒体框架、需要对收包过程做精细控制比如自己统计丢包、自己控制缓冲策略。如果你只是想在桌面端快速播个流用现成播放器更省事但如果你要把拉流能力做成模块嵌进自己的服务或者要针对特定摄像头做兼容处理手写协议栈的可控性就是实打实的优势。2.2 技术栈与跨平台抽象技术栈上核心是 C11 及以上、标准库、系统 socket API没有第三方网络库。跨平台的关键在于把平台差异收敛到薄薄一层封装里常见做法是抽一个 socket 抽象层把socket、connect、send、recv、close这些调用按平台做条件编译。下面是一个典型的封装骨架我一般会这么写// net_socket.h —— 跨平台 socket 薄封装 #ifdef _WIN32 #include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) using socket_t SOCKET; #define CLOSE_SOCKET closesocket #else #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h using socket_t int; #define CLOSE_SOCKET close #define INVALID_SOCKET (-1) #endif class NetSocket { public: bool init(); // Windows 下做 WSAStartup bool connectTo(const std::string ip, int port); int sendAll(const char* buf, int len); int recvSome(char* buf, int len); // 返回实际字节数0 表示异常 void close(); private: socket_t fd_ INVALID_SOCKET; };这段代码的逻辑很直白用socket_t和CLOSE_SOCKET把类型和关闭函数统一Windows 的SOCKET是无符号句柄、Linux 是 int 文件描述符不统一后面到处是坑。init()在 Windows 下必须调WSAStartupLinux 下直接返回 true。sendAll要循环发送直到发完因为 TCP 是流式的一次send不保证全发出去这是新手最容易忽略的点。参数上recvSome返回实际字节数调用方负责判断是 0对端关闭还是负值错误。2.3 目录结构长什么样源码的组织一般按职责分层下面是我拆这类项目时常见的目录形态具体文件名可能不同但分层逻辑基本一致rtsp-client/ ├── include/ # 对外头文件 │ ├── rtsp_client.h # 客户端主入口 │ ├── rtp_receiver.h # RTP 收包与解包 │ └── media_frame.h # 帧数据结构 ├── src/ │ ├── rtsp_client.cpp # RTSP 信令状态机 │ ├── sdp_parser.cpp # SDP 解析 │ ├── rtp_receiver.cpp# RTP 头解析、分片重组 │ ├── net_socket.cpp # 跨平台 socket 封装 │ └── main.cpp # 示例程序 ├── CMakeLists.txt └── README.mdrtsp_client负责 OPTIONS、DESCRIBE、SETUP、PLAY、TEARDOWN 这条信令链sdp_parser从 DESCRIBE 的响应体里抠出媒体描述rtp_receiver处理 RTP 头、序列号、时间戳和 H.264 的 FU-A 分片。分层清楚的好处是改一处不影响另一处比如你要换 UDP 为 TCP 传输只动rtp_receiver的收包部分即可。3. 核心链路拆解从 RTSP 握手到一帧视频落地3.1 RTSP 信令状态机怎么走RTSP 交互本质是一串带CSeq的文本请求服务端按顺序响应。一个最小可用的拉流流程是OPTIONS 探能力DESCRIBE 拿 SDPSETUP 建传输通道PLAY 开始推流。每一步都要校验响应码常见做法是封装一个sendRequest统一处理请求构造和响应读取// 发送一条 RTSP 请求并读取响应头 bool RtspClient::sendRequest(const std::string method, const std::string url, const std::string extraHeader, std::string response) { std::ostringstream req; req method url RTSP/1.0\r\n CSeq: cseq_ \r\n User-Agent: MyRtspClient\r\n extraHeader \r\n; std::string data req.str(); if (sock_.sendAll(data.data(), (int)data.size()) 0) return false; // 先读到 \r\n\r\n 为止拿到响应头 response.clear(); char buf[2048]; while (response.find(\r\n\r\n) std::string::npos) { int n sock_.recvSome(buf, sizeof(buf)); if (n 0) return false; response.append(buf, n); } return response.find(200 OK) ! std::string::npos || response.find(401) ! std::string::npos; // 401 留给鉴权分支 }逻辑说明CSeq每发一条自增服务端靠它对齐请求和响应乱序或重复会导致响应错配。extraHeader用来塞Authorization、Transport这类方法相关的头。读取响应时只读到\r\n\r\n因为 DESCRIBE 的响应体SDP在头之后需要按Content-Length单独读混在一起读会把 SDP 截断。参数上url必须是完整的rtsp://ip:port/path少一段服务端直接 404。DESCRIBE 之后要解析 SDP关键字段是mvideo 0 RTP/AVP 96和artpmap:96 H264/90000前者告诉你媒体类型和 payload type后者告诉你编码格式和时钟频率。acontrol:给出 SETUP 要用的控制 URL可能是相对路径需要和基础 URL 拼接。这一步解析错了后面 SETUP 一定失败。3.2 RTP 收包与 H.264 分片重组SETUP 成功后服务端开始往你指定的端口发 RTP 包。RTP 头固定 12 字节关键字段是序列号判断丢包、时间戳判断帧边界、marker 位判断一帧结束。H.264 在 RTP 里通常用 FU-A 分片一个 NAL 单元被切成多个包每个包带FU indicator和FU header。重组逻辑是这份源码的核心也是最容易写错的地方// 处理一个 RTP 包重组 H.264 帧 void RtpReceiver::onRtpPacket(const uint8_t* pkt, int len) { if (len 12) return; uint16_t seq (pkt[2] 8) | pkt[3]; bool marker pkt[1] 0x80; int payloadOff 12 4 * (pkt[0] 0x0F); // 跳过 CSRC const uint8_t* payload pkt payloadOff; int payloadLen len - payloadOff; uint8_t nalType payload[0] 0x1F; if (nalType 28) { // FU-A 分片 bool start payload[1] 0x80; bool end payload[1] 0x40; uint8_t realNalType payload[1] 0x1F; if (start) { frameBuf_.clear(); frameBuf_.push_back((payload[0] 0xE0) | realNalType); // 重建 NAL 头 } frameBuf_.insert(frameBuf_.end(), payload 2, payload payloadLen); if (end) { deliverFrame(frameBuf_); // 一帧完整交给上层 frameBuf_.clear(); } } else if (nalType 1 || nalType 5) { // 单包 NAL deliverFrame(std::vectoruint8_t(payload, payload payloadLen)); } lastSeq_ seq; }逻辑说明payloadOff要跳过 CSRC 列表pkt[0] 0x0F是 CSRC 个数很多实现直接写死 12 字节偏移遇到带 CSRC 的流就解析错位。FU-A 的start位表示分片起点此时用FU indicator的高 3 位和FU header的低 5 位拼回原始 NAL 头这是协议规定的重建方式拼错了解码器直接报错。marker位标记一帧的最后一个包但更稳妥的做法是结合时间戳变化判断帧边界因为部分设备 marker 位不可靠。参数上seq用于检测丢包发现跳号要丢弃当前不完整帧否则拼出来的帧是坏的。3.3 收包循环与缓冲策略收包主循环一般用select或epoll监听 socket 可读读到一个 UDP 包就交给onRtpPacket。这里有个缓冲策略的选择是收到就立刻交付还是攒够一帧再交付。前者延迟低但上层要自己处理分片后者简单但增加一帧的延迟。我一般会在deliverFrame里做一个小的帧队列配合环形缓冲避免解码线程和收包线程互相阻塞。UDP 收包缓冲区要调大默认的几十 KB 在高码率下容易丢包setsockopt设SO_RCVBUF到几 MB 是常规操作。4. 编译与运行Linux 和 Windows 两套走法4.1 Linux 下的构建与验证Linux 下用 CMake 最省事CMakeLists.txt里把源文件列全链接 pthread 即可。构建命令很标准mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j4 ./rtsp_client rtsp://192.168.1.10:554/stream1跑起来后先看信令日志OPTIONS 和 DESCRIBE 返回 200 说明地址和鉴权没问题SETUP 返回 200 且带Transport头说明通道建好了PLAY 之后应该能看到 RTP 包计数在涨。如果 PLAY 成功但包计数不动八成是 UDP 端口没通检查防火墙和Transport里协商的端口。验证帧是否完整可以看deliverFrame的调用频率是否和帧率吻合以及每帧大小是否在合理范围1080p 的 I 帧通常几十 KB 到上百 KB。4.2 Windows 下的差异点Windows 下用 MSVC 或 MinGW 都行关键差异在三点一是必须链接ws2_32.lib二是启动时调WSAStartup三是recv的返回类型是 int 但send的参数是const char*和 Linux 的ssize_t有符号性差异封装层要抹平。用 CMake 的话加一段平台判断if(WIN32) target_link_libraries(rtsp_client ws2_32) target_compile_definitions(rtsp_client PRIVATE _WIN32_WINNT0x0601) endif()_WIN32_WINNT设成 0x0601 是为了让inet_pton等函数可用不设的话老版本头文件里可能找不到声明。跑起来后如果卡在connect先确认目标地址可达、端口没被占用如果WSAStartup失败通常是没链接库或者版本参数不对。4.3 鉴权与传输方式的选择不少摄像头开了 Digest 鉴权第一次请求返回 401 并带WWW-Authenticate需要按 nonce 算 MD5 再重发。源码里一般会留一个鉴权分支sendRequest检测到 401 后解析 realm 和 nonce用MD5(username:realm:password)和MD5(method:url)拼出 response。传输方式上UDP 延迟低但易丢包TCP 可靠但延迟高Transport头里写RTP/AVP/TCP;interleaved0-1就走 TCP此时 RTP 包会嵌在 RTSP 的 TCP 连接里前面多 4 个字节的$通道标识收包逻辑要相应调整。5. 避坑与排查几个真实会卡住人的地方5.1 信令通了但收不到 RTP 包现象是 PLAY 返回 200日志里 RTP 计数一直是 0。原因通常是 SETUP 时协商的客户端端口和服务端实际发送的端口不一致或者本机防火墙拦了 UDP。解决方法是打印 SETUP 响应里的Transport头确认client_port和你绑定的端口一致再在收包 socket 上抓一下有没有数据到达Linux 下用tcpdump -i any udp port port一看便知。还有一种情况是服务端要求先发一个 NAT 穿透包部分设备需要客户端先往服务端端口发个空包才肯回发。5.2 收到包但拼不出完整帧现象是 RTP 计数在涨但deliverFrame很少被调用或者调用出来的帧解码花屏。原因多半是 FU-A 重组时 NAL 头拼错或者丢包后没有丢弃不完整帧。解决方法是检查start位处理时 NAL 头的重建公式确认(payload[0] 0xE0) | realNalType没写反同时在序列号跳号时清空frameBuf_宁可丢一帧也不要交坏帧。花屏还有一种可能是时间戳没对齐跨帧的 P 帧引用了已丢弃的 I 帧。5.3 Windows 下编译过但运行崩溃现象是 MSVC 编译通过一运行就崩在 socket 调用附近。原因是WSAStartup没调或者调晚了所有 socket 函数在初始化前调用都是未定义行为。解决方法是在main最开头、任何网络操作之前调WSAStartup(MAKEWORD(2,2), wsaData)退出时WSACleanup。另外SOCKET是UINT_PTR和 int 互转时要注意截断封装层统一用socket_t就不会出这类问题。5.4 高码率下丢包严重现象是低码率流正常切到 4K 或高码率就大量丢包、画面卡顿。原因是 UDP 接收缓冲区太小内核来不及交给应用层就丢了。解决方法是用setsockopt把SO_RCVBUF调大Linux 下还受net.core.rmem_max限制需要系统层面放开同时收包线程要尽量轻不要在收包回调里做耗时操作把解码和渲染放到独立线程。6. 二次开发把拉流能力嵌进自己的工程拿到这份源码后最常见的改造方向是把它从“示例程序”变成“可复用模块”。我的做法是先砍掉main.cpp里的演示逻辑把RtspClient的接口收敛成三个open(url)、setFrameCallback(cb)、close()上层只关心帧回调不关心 RTSP 细节。回调里传MediaFrame结构带数据指针、长度、时间戳、是否关键帧这样上层接解码器或直接存文件都方便。第二个改造点是传输方式的运行时切换。把Transport头的构造抽成配置项UDP 和 TCP 各走一条收包路径TCP 模式下要处理$通道标识和粘包逻辑和 UDP 不同但可以共用onRtpPacket。第三个点是重连。网络抖动导致连接断开时RtspClient要能检测到 socket 异常并自动重走一遍 OPTIONS 到 PLAY 的流程重连间隔做指数退避避免频繁重连打爆服务端。验证改造是否成功我一般用三步先用本地搭的测试流跑通基本拉流再用真实设备跑长时间稳定性挂几个小时看内存有没有涨、帧率有没有掉最后模拟断网重连看恢复是否正常。下面这个接口收敛后的调用示例基本就是嵌进业务代码的样子RtspClient client; client.setFrameCallback([](const MediaFrame f) { if (f.isKeyFrame) printf(I frame, %zu bytes\n, f.size); // 交给解码器或写入文件 }); if (!client.open(rtsp://192.168.1.10:554/stream1)) { // 记录日志触发重连 }参数上isKeyFrame靠 NAL 类型判断类型 5 是 IDRsize是重组后的完整帧长度。回调里不要做阻塞操作否则会拖慢收包线程需要落盘或解码就丢到队列里异步处理。血泪经验是这类协议栈项目最容易被忽略的不是主流程而是异常分支——丢包、乱序、半包、鉴权失败、服务端提前断流每一个都能让程序在长时间运行后出问题。我现在的习惯是每次改完收包逻辑都强制用一段带丢包模拟的测试流跑一遍确认坏帧被正确丢弃而不是悄悄交给上层。希望这份拆解能帮你少走点弯路把这份源码真正跑起来、改起来。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑