ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

DirectShow RTP/RTCP发送端实战:从打包到码率自适应

DirectShow RTP/RTCP发送端实战:从打包到码率自适应 简介这是一份基于DirectShow框架实现的RTP/RTCP实时传输协议发送端程序源码面向学习流媒体传输、网络编程与Windows多媒体开发的初学者及进阶开发者帮助理解RTP数据包封装、RTCP控制反馈与DirectShow过滤器协作机制。压缩包共16个文件约21KB包含4个h头文件与4个cpp源文件构成核心逻辑另有ico图标、dsp/dsw工程文件、rc资源脚本及ReadMe说明结构完整可直接用Visual Studio打开编译。程序以字符串模拟数据流演示通过RTP协议完成数据发送的收发流程便于读者对照代码梳理发送端初始化、数据打包与传输调度的实现思路。目前已有142人学习下载适合作为网络传输课程实验或流媒体项目开发的参考范例帮助快速搭建可运行的RTP发送端原型并理解RTCP协同工作方式。1. 从 rtp_send.rar 说起DirectShow 里怎么把 RTP 和 RTCP 一起跑通如果你手里有一个叫rtp_send.rar的压缩包里面是 DirectShow 的 Filter 工程目标是把采集到的音视频用 RTP 发出去同时还要跑 RTCP——那你大概率已经踩过这几个坑RTP 包发出去了但接收端花屏、RTCP 的 SR/RR 报文根本没发、时间戳对不上导致音画不同步。这个标题里的关键词其实已经把技术栈说清楚了DirectShow 负责采集和编码链路RTP 负责媒体数据传输RTCP 负责传输质量反馈和时钟同步而rtp_send就是那个把三者串起来的发送端 Filter。这套方案适合谁适合需要在 Windows 平台上做实时音视频推流、又不想引入完整 WebRTC 栈的工程师。DirectShow 虽然老但在采集设备兼容性和硬件编码器对接上依然是最省事的选择。RTP/RTCP 则是标准协议接收端可以是 FFmpeg、VLC 或者自研的播放器。整条链路的核心难点不在 RTP 打包本身而在于 DirectShow 的推送模型和 RTP 的时钟模型怎么对齐——这也是后面几章要重点拆开讲的地方。2. DirectShow 发送端 Filter 的骨架从 Pin 到 RTP 打包2.1 为什么选 DirectShow 做采集和发送的粘合层DirectShow 的核心模型是 Filter GraphSource Filter 负责从摄像头或采集卡拿数据Transform Filter 负责编码或格式转换Renderer Filter 负责输出。要做 RTP 发送最常见的做法是写一个自定义的 Renderer Filter把它挂在编码器后面在Receive方法里拿到编码后的帧数据然后做 RTP 打包和发送。选 DirectShow 而不是 Media Foundation 的理由很实际大量工业相机、采集卡的驱动只提供了 DirectShow 接口Media Foundation 的兼容性反而更差。而且 DirectShow 的 Sample 时间戳机制和 RTP 的时间戳映射逻辑可以做到比较干净的对应关系——只要你知道怎么换算。一个典型的 Graph 长这样[Camera Source] - [Decoder/Converter] - [Encoder] - [RTP Sender Filter]RTP Sender Filter 需要实现两个关键接口IBaseFilter和IMemInputPin。IMemInputPin::Receive是数据入口每次收到一个IMediaSample就取出数据和时间戳做 RTP 打包。2.2 RTP 打包的核心逻辑与代码骨架RTP 打包本身不复杂核心是每个 RTP 包有 12 字节固定头包含序列号、时间戳、SSRC负载部分根据 MTU 决定是否分片。对于视频帧如果一帧大于 MTU需要做 FU-A 分片H.264或类似的分片策略。下面是一个最小可用的 RTP 打包函数骨架// RTP 头结构12 字节固定头 struct RTPHeader { uint8_t version; // 版本号固定为 2 uint8_t payloadType; // 负载类型如 H.264 为 96 uint16_t sequenceNumber; // 序列号每发一个包 1 uint32_t timestamp; // 时间戳基于采样率换算 uint32_t ssrc; // 同步源标识 }; // 打包一帧数据为多个 RTP 包 void PackFrameToRTP(const uint8_t* frameData, size_t frameSize, uint32_t frameTimestamp, uint8_t pt, uint16_t seqNum, uint32_t ssrc, std::vectorstd::vectoruint8_t outPackets) { const size_t MTU 1400; // 保守值避免 IP 分片 const size_t headerSize 12; // RTP 固定头 const size_t maxPayload MTU - headerSize; size_t offset 0; while (offset frameSize) { size_t chunkSize std::min(maxPayload, frameSize - offset); std::vectoruint8_t packet(headerSize chunkSize); // 填充 RTP 头 packet[0] 0x80; // V2, P0, X0, CC0 packet[1] pt 0x7F; // M0, PT packet[2] (seqNum 8) 0xFF; packet[3] seqNum 0xFF; packet[4] (frameTimestamp 24) 0xFF; packet[5] (frameTimestamp 16) 0xFF; packet[6] (frameTimestamp 8) 0xFF; packet[7] frameTimestamp 0xFF; packet[8] (ssrc 24) 0xFF; packet[9] (ssrc 16) 0xFF; packet[10] (ssrc 8) 0xFF; packet[11] ssrc 0xFF; // 拷贝负载 memcpy(packet.data() headerSize, frameData offset, chunkSize); // 最后一个包设置 M 位标记帧结束 if (offset chunkSize frameSize) { packet[1] | 0x80; } outPackets.push_back(std::move(packet)); offset chunkSize; seqNum; } }这段代码里几个参数需要特别注意。MTU取 1400 是保守值实际网络环境如果确定没有 PPPoE 开销可以到 1440 左右但超过 1500 就会触发 IP 分片丢一个分片整帧就废了。payloadType对于 H.264 通常用 96这是动态负载类型接收端需要 SDP 协商一致。timestamp的换算后面单独讲这是最容易翻车的地方。2.3 时间戳换算DirectShow 的 REFERENCE_TIME 到 RTP 时间戳DirectShow 的IMediaSample::GetTime返回的是REFERENCE_TIME单位是 100 纳秒。RTP 的时间戳单位取决于负载类型视频通常是 90kHz音频是采样率如 48000Hz。换算公式// 将 DirectShow 的 REFERENCE_TIME 转为 RTP 时间戳 // rt: 100ns 单位的时间 // clockRate: 视频 90000音频 48000 uint32_t RefTimeToRtpTimestamp(REFERENCE_TIME rt, uint32_t clockRate) { // 先转成秒再乘以时钟频率 // 注意rt 可能为负数但正常采集流不会 return (uint32_t)((rt / 10000000.0) * clockRate); }这里有个血泪经验不要用rt / 10000000 * clockRate的整数运算因为rt在 100ns 单位下数值很大整数除法会丢掉精度导致时间戳抖动。用浮点算完再转uint32_t虽然也有精度损失但在 90kHz 下误差可以忽略。更严谨的做法是用 64 位整数做定点运算但实际项目中浮点版本足够。另外RTP 时间戳的起始值应该是随机的不要从 0 开始。这是协议要求虽然很多实现不遵守也能跑但遇到严格的接收端会出问题。3. RTCP 不是可选项SR/RR 报文怎么发、什么时候发3.1 RTCP 在发送端到底承担什么角色很多人做 RTP 发送时只发 RTP 不发 RTCP短期看接收端也能播但一旦网络有抖动或者需要多端同步问题就暴露了。RTCP 在发送端主要做三件事第一发送 SRSender Report告诉接收端「我发了多少包、多少字节、当前 NTP 时间是多少」。接收端用 SR 里的 NTP 时间和对应的 RTP 时间戳做映射才能把 RTP 时间戳换算成绝对时间这是音画同步的基础。第二接收 RRReceiver Report了解接收端的丢包率、抖动情况。发送端可以根据 RR 调整码率或做重传决策。第三维护 SSRC 的一致性。如果发送端重启SSRC 应该变化接收端通过 RTCP BYE 包知道旧流结束。RTCP 的发送周期有协议建议通常占会话带宽的 5%最小间隔 5 秒。但在实际低延迟场景里很多人会缩短到 1 秒甚至更短代价是增加一点带宽开销。3.2 构造并发送 SR 报文的代码实现SR 报文的结构比 RTP 复杂一些包含发送者信息块和报告块。发送端至少要填发送者信息块// 构造 RTCP SR 报文 // 发送端只需要填 Sender InfoReport Block 可以为空 std::vectoruint8_t BuildRtcpSR(uint32_t ssrc, uint32_t rtpTimestamp, uint32_t packetCount, uint32_t octetCount, uint64_t ntpTimestamp) { std::vectoruint8_t sr(28); // 固定头 4 字节 发送者信息 24 字节 // RTCP 头 sr[0] 0x80; // V2, P0, RC0无报告块 sr[1] 200; // PT200 表示 SR uint16_t length 6; // 长度字段 (28/4) - 1 6 sr[2] (length 8) 0xFF; sr[3] length 0xFF; // SSRC sr[4] (ssrc 24) 0xFF; sr[5] (ssrc 16) 0xFF; sr[6] (ssrc 8) 0xFF; sr[7] ssrc 0xFF; // NTP 时间戳64 位高 32 位是秒低 32 位是小数部分 sr[8] (ntpTimestamp 56) 0xFF; sr[9] (ntpTimestamp 48) 0xFF; sr[10] (ntpTimestamp 40) 0xFF; sr[11] (ntpTimestamp 32) 0xFF; sr[12] (ntpTimestamp 24) 0xFF; sr[13] (ntpTimestamp 16) 0xFF; sr[14] (ntpTimestamp 8) 0xFF; sr[15] ntpTimestamp 0xFF; // RTP 时间戳 sr[16] (rtpTimestamp 24) 0xFF; sr[17] (rtpTimestamp 16) 0xFF; sr[18] (rtpTimestamp 8) 0xFF; sr[19] rtpTimestamp 0xFF; // 发送包数 sr[20] (packetCount 24) 0xFF; sr[21] (packetCount 16) 0xFF; sr[22] (packetCount 8) 0xFF; sr[23] packetCount 0xFF; // 发送字节数 sr[24] (octetCount 24) 0xFF; sr[25] (octetCount 16) 0xFF; sr[26] (octetCount 8) 0xFF; sr[27] octetCount 0xFF; return sr; }NTP 时间戳的构造需要把系统时间转成 NTP 格式高 32 位是从 1900 年 1 月 1 日开始的秒数低 32 位是小数部分。Windows 上可以用GetSystemTimeAsFileTime然后换算注意 FILETIME 的起始时间是 1601 年中间差 369 年换算时别搞错。发送 SR 的时机每发送一定数量的 RTP 包或者每隔固定时间发一次。我一般会在发送端维护一个计数器每 500 个 RTP 包或者每 1 秒触发一次 SR 发送取先到的条件。3.3 接收 RR 并解析丢包率接收 RR 需要一个额外的 UDP socket 监听 RTCP 端口。通常 RTP 和 RTCP 使用相邻端口比如 RTP 用 5004RTCP 用 5005。收到 RR 后解析报告块里的丢包率和抖动值// 解析 RR 中的报告块 struct ReportBlock { uint32_t ssrc; // 被报告的 SSRC uint8_t fractionLost; // 丢包率0-255实际丢包率 fractionLost / 256 int32_t cumulativeLost; // 累计丢包数 uint32_t extendedSeq; // 扩展序列号 uint32_t jitter; // 抖动值 }; ReportBlock ParseReportBlock(const uint8_t* data) { ReportBlock rb; rb.ssrc (data[0] 24) | (data[1] 16) | (data[2] 8) | data[3]; rb.fractionLost data[4]; rb.cumulativeLost (data[5] 16) | (data[6] 8) | data[7]; rb.extendedSeq (data[8] 24) | (data[9] 16) | (data[10] 8) | data[11]; rb.jitter (data[12] 24) | (data[13] 16) | (data[14] 8) | data[15]; return rb; }丢包率的计算要注意fractionLost是 8 位定点数实际丢包率是fractionLost / 256.0。累计丢包数是 24 位有符号数可能为负表示重复包比丢包多。抖动值是以 RTP 时间戳单位为单位的要换算成毫秒需要除以时钟频率再乘 1000。4. 避坑与排查RTP/RTCP 发送端最常见的 5 个翻车现场4.1 接收端花屏但 RTP 包确实发出去了现象Wireshark 抓包能看到 RTP 流序列号连续但接收端画面花屏或者绿屏。原因最常见的是分片标志位没设对。H.264 的 FU-A 分片需要设置 FU indicator 和 FU header其中 FU header 的 S 位开始和 E 位结束必须正确。如果所有分片都当成独立帧发接收端解码器会直接报错。解决检查分片逻辑确保第一片设置 S1、E0中间片 S0、E0最后一片 S0、E1。另外确认 RTP 头的 M 位只在最后一帧的最后一个包上设置不要每个包都设。4.2 RTCP SR 发了但接收端不同步现象音视频单独播放都正常但合在一起就不同步偏差越来越大。原因SR 里的 NTP 时间和 RTP 时间戳的对应关系不对。常见错误是 NTP 时间戳用了本地时间但没转成 NTP 格式或者 RTP 时间戳用了错误的时钟频率。解决打印出 SR 里的 NTP 和 RTP 时间戳和接收端解析出来的做对比。NTP 高 32 位应该是 Unix 时间戳加上 22089888001900 到 1970 的秒数。RTP 时间戳应该和同一时刻发送的 RTP 包的时间戳一致。4.3 序列号回绕导致接收端丢包率飙升现象发送端跑了几个小时后接收端报告丢包率突然变成 100%。原因RTP 序列号是 16 位的从 65535 回到 0 时如果接收端没有处理回绕会认为所有包都乱了。解决发送端不需要特殊处理但接收端必须实现序列号回绕逻辑。如果你自己写接收端判断丢包时要用扩展序列号不能直接比较大小。发送端能做的是在序列号接近回绕时记录日志方便排查。4.4 DirectShow 的 Sample 时间戳跳变现象RTP 时间戳突然跳变接收端画面卡顿一下然后快进。原因DirectShow 的 Source Filter 在某些采集卡上会出现时间戳不连续的情况比如从 0 突然跳到很大的值。如果直接拿这个时间戳做 RTP 时间戳就会出问题。解决在发送端维护一个时间戳偏移量检测到跳变时重新计算基准。具体做法是记录上一帧的 REFERENCE_TIME如果当前帧和上一帧的差值超过阈值比如 1 秒就认为发生了跳变更新偏移量使输出时间戳连续。4.5 RTCP 端口被防火墙拦截现象RTP 能发出去接收端也能播但发送端收不到 RR无法做自适应码率。原因很多防火墙默认只放行 RTP 端口RTCP 端口通常是 RTP1被拦截了。解决确认防火墙规则或者把 RTCP 和 RTP 复用同一个端口RTP/RTCP 复用是 RFC 5761 定义的但需要接收端支持。如果无法改防火墙至少要在日志里明确提示 RTCP 不可达不要静默失败。5. 进阶技巧用 RTCP RR 做发送端码率自适应5.1 从 RR 里读出可用的带宽信号RTCP RR 里的丢包率和抖动值是最直接的网络质量信号。丢包率低于 2% 时可以尝试提高码率丢包率高于 10% 时必须降码率。抖动值本身不直接决定码率但持续高抖动通常意味着网络拥塞应该配合丢包率一起判断。我一般会维护一个滑动窗口记录最近 10 个 RR 的丢包率取平均值做决策。单次 RR 的丢包率可能因为统计周期问题有波动平滑后的值更可靠。5.2 码率调整的具体策略和参数调整策略可以用简单的 AIMD加性增、乘性减// 基于 RTCP RR 的码率自适应 class RateController { double currentBitrate; // 当前码率单位 bps double minBitrate 500000; // 最低 500kbps double maxBitrate 8000000; // 最高 8Mbps public: void OnReceiverReport(double fractionLost) { if (fractionLost 0.02) { // 丢包率低加性增每次增加 5% currentBitrate std::min(currentBitrate * 1.05, maxBitrate); } else if (fractionLost 0.10) { // 丢包率高乘性减直接降到 70% currentBitrate std::max(currentBitrate * 0.7, minBitrate); } // 中间区间保持不变避免频繁调整 } double GetTargetBitrate() const { return currentBitrate; } };参数说明加性增的系数 1.05 和乘性减的系数 0.7 是经验值实际项目中可以根据网络类型调整。Wi-Fi 环境下可以更激进一些有线网络可以更保守。最低码率不要低于 500kbps否则视频质量没法看最高码率不要超过接收端解码能力。5.3 验证自适应是否生效的方法最直接的验证方法是在发送端打日志记录每次码率调整的时间、触发原因丢包率值和调整后的码率。然后在接收端同时记录实际接收码率和丢包率两边对比。如果发现码率降了但丢包率没降说明瓶颈不在发送端码率可能是网络中间节点的问题继续降码率也没用。这时候应该考虑换传输协议或者加 FEC而不是一味降码率。另一个验证技巧是用tcLinux或者clumsyWindows模拟丢包和延迟观察码率控制器的反应是否符合预期。我一般会模拟 5% 丢包和 100ms 延迟看码率是否在 10 秒内降到合理水平。这套 DirectShow RTP/RTCP 的方案我从第一次跑通到稳定用在项目里前后调了将近两个月。最大的教训是不要等到接收端反馈问题才去查 RTCP发送端从一开始就要把 SR 发对、把 RR 收全。RTCP 不是可选项它是你唯一的眼睛。希望帮到你。本文还有配套的精品资源点击获取
返回列表