基于DirectShow的RTP/RTCP发送模块设计与工程实践

发布时间:2026/9/24 23:29:49
基于DirectShow的RTP/RTCP发送模块设计与工程实践 简介面向初学RTP/RTCP实时传输协议的开发者这份压缩包提供了一套基于DirectShow框架的发送端示例程序。程序以字符串模拟数据流演示了如何通过RTP协议进行数据发送并包含RTCP控制逻辑适合需要理解流媒体传输基础流程的读者。资源共16个文件其中包括4个头文件与4个C源文件构成核心实现还附有DSP/DSW工程文件、图标资源及RC资源文件便于在Visual Studio环境中直接打开编译另含ReadMe说明文档。压缩包整体仅21KB结构精简适合快速阅读代码。目前已有142人学习使用。借助这份代码读者可以掌握RTP打包发送的基本步骤、RTCP交互的简单方法以及DirectShow下资源与工程的配置方式可作为课程设计或入门实验的参考。1. rtp_send 到底解决什么问题DirectShow 与 RTP/RTCP 之间缺的正是这个发送模块拿到 rtp_send.rar 这个名字很多人第一反应是把注意力放在 RTP 报文头怎么填、PT 值怎么配。实际做过一遍就会发现真正卡住进度的往往不是协议本身而是 DirectShow 采集管线和发送线程之间那段“没人管的距离”帧从摄像头出来是一回事能不能按时、按序、按时间戳变成 RTP 报文扔到网卡上是另一回事。这个标题指向的就是 DirectShow 取流 RTP 封装 RTCP 反馈这条发送链路的完整落地。它适合做音视频推流 Demo、IPC 设备对接、VoIP 媒体发送的工程师也适合被“视频画面出来了但语音对端没声音”这类问题折磨的人。下面按我从采集到上线的顺序把这个方向讲透。2. rtp_send 的协议骨架RTP 载荷规则、RTCP 反馈与 SIP 协同2.1 RTP 只负责搬运媒体RTCP 负责把链路的沉默打破RTP 协议本身并不保证到达它只给每份媒体数据贴上一张“快递单”序列号用来排序和检测丢包时间戳用来恢复播放节奏SSRC 用来区分同一会话里的不同媒体流。真正让发送端知道“对方到底收到没有”的是 RTCP。RTCP 里最核心的是 SRSender Report包发送端周期性发出里面有 NTP 时间戳、RTP 时间戳、累计发送的包数和字节数。接收端拿到 SR 后会回 RRReceiver Report包告诉发送端丢包率、抖动、往返时延。这个闭环在本地 Demo 里可有可无但只要链路稍微复杂一点——比如走公网、跨运营商、经过转发服务器——没有 RTCP 反馈的发送端就是在闭着眼睛丢数据。我一般会在 rtp_send 工程里把 RTCP 发送周期固定为 5 秒一次包结构与字段顺序严格按 RFC 3550 来填。很多半成品工程把 RTCP 省掉了接收端还能出画面但一查延迟和丢包统计就两眼一抹黑。把它加上不是为了协议完整是为了出问题时手里有数据。2.2 SIP 与 RTP 怎样配合信令签字盖章媒体直接上场在 VoIP 场景里SIP 和 RTP 是两条完全独立的通道。SIP 走信令端口默认 5060用 INVITE、200 OK、ACK 这三条消息把双方的 IP、端口、编码格式协商好协商完成之后媒体流直接在协商好的 UDP 端口上走 RTPSIP 服务器不参与媒体转发。配合方式用案例说最清楚。A 要给 B 打电话A 发 INVITE里面带的 SDP 长这样mvideo 10000 RTP/AVP 96 artpmap:96 H264/90000 afmtp:96 profile-level-id42C01F; packetization-mode1 maudio 10002 RTP/AVP 8 artpmap:8 PCMA/8000这里 m 行里的 10000 和 10002就是 A 的 rtp_send 模块要用的本地 RTP 端口。B 回 200 OK 时也带一份 SDP里面写 B 的地址和端口。A 收到后发送端往 B 的 IP 和端口发 RTPRTCP 走 UDP 端口号 1 的端口。SIP 只负责“签字盖章”RTP 才是真正上场干活的人。rtp_send 的角色在这个流程里的定位很清楚上层 SIP 栈把对端地址、SSRC、PT、编解码参数解析出来之后它只负责按这些参数把 DirectShow 采到的帧变成流发出去。自己做集成时不要让它去兼任信令解析否则耦合在一起任何一端出问题都很难定位。2.3 rtp_send 的职责边界封装发送做扎实协商控制交给上层一个合理的 rtp_send 模块应该只做四件事从 DirectShow 管线取帧、把帧封装成 RTP 包、按节奏把包发出去、周期上报 RTCP。不该做的事包括解析 SIP 消息、维护呼叫状态、重传逻辑。这里有个常见的越界操作。有些工程为了让“发送”显得完整会顺手把 SDP 解析也塞进来结果 SDP 里一个空格、一个 CRLF 不对整个发送模块就初始化失败。按我调这个方向的惯例SDP 解析结果在进入 rtp_send 之前就转成结构体rtp_send 只管收结构体里的字段。接口干净了后面换 TCP 替换 UDP、加加密、改 FEC都不需要动框架。判断手里的 rtp_send 工程是否可用的标准也很简单把 USB 摄像头接上喂进去 H.264 裸流在另一台机器上能不能用 VLC 打开 SDP 稳定播放同时 Wireshark 里能看到每 5 秒一次的 RTCP SR。做到这三条这个模块才算完成了一半。3. 用 DirectShow 搭建取流管线Graph 构建、Sample Grabber 与时间戳换算3.1 为什么 DirectShow 在采集到网络这条链路上仍然好使DirectShow 的 Filter Graph 常被人嫌老但在采集设备适配这件事上它依然是 Windows 里覆盖面最广的方案。USB 摄像头、采集卡、HDMI 输入大部分厂商提供的驱动接口都能被 DirectShow 的 WDM 捕捉器包一层后暴露出来拿到 IMediaSample 里的数据再交给 RTP 发送比调用厂商私有 SDK 的可移植性好不少。特别是底层只提供 DirectShow 接口的设备绕开它只能自己写内核流处理成本完全不成比例。rtp_send 这样的工程选 DirectShow 做取流层不是因为协议新而是因为驱动生态稳。真正需要动脑筋的是让 Graph 里的数据流节奏和发送线程的节奏对上。3.2 最小可跑 Graph创建一个能出数据的采集管线不挂预览窗口、不做录制最小可跑的取流管线由三个部分组成采集 Filter、Sample Grabber 和 Null Renderer。代码骨架如下// 初始化 COM 并创建 Filter Graph HRESULT hr CoInitializeEx(NULL, COINIT_MULTITHREADED); IFilterGraph* pGraph NULL; ICaptureGraphBuilder2* pBuilder NULL; hr CoCreateInstance(CLSID_FilterGraph, NULL, CLSCTX_INPROC_SERVER, IID_IFilterGraph, (void**)pGraph); hr CoCreateInstance(CLSID_CaptureGraphBuilder2, NULL, CLSCTX_INPROC_SERVER, IID_ICaptureGraphBuilder2, (void**)pBuilder); pBuilder-SetFiltergraph(pGraph);// 用系统枚举器拿到第一个视频采集设备 IMoniker* pMoniker NULL; ICreateDevEnum* pDevEnum NULL; CoCreateInstance(CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC_SERVER, IID_ICreateDevEnum, (void**)pDevEnum); IEnumMoniker* pClassEnum NULL; pDevEnum-CreateClassEnumerator(CLSID_VideoInputDeviceCategory, pClassEnum, 0); pClassEnum-Next(1, pMoniker, NULL); // 绑定采集 Filter IBaseFilter* pCapFilter NULL; pMoniker-BindToObject(0, 0, IID_IBaseFilter, (void**)pCapFilter); pGraph-AddFilter(pCapFilter, LCapture);逻辑说明这不是完整代码但已经覆盖了 DirectShow 建 Graph 的固定次序——先创建 Graph 和 CaptureGraphBuilder2再枚举设备类得到设备 Moniker绑定成 IBaseFilter 加入图中。后面连接拍摄 Pin、设置 Sample Grabber 的媒体类型都属于同样的流程一段一段照着加就行。参数上新工程建议直接把 COINIT_MULTITHREADED 写死避免和发送线程的套接字初始化产生模型冲突。3.3 OnBufferCB 回调里做什么拷贝、入队、别阻塞取数据的核心在 Sample Grabber 的回调。这里最常犯的错是直接在回调里做 sendto、写文件、甚至加锁等待这几样全部会反向阻塞采集管线。// 设置 Sample Grabber 的回调 ISampleGrabber* pGrabber NULL; pGrabber-SetCallback(myCallback, 0); // 回调内部实现只做拷贝和入队 long OnBufferCB(double SampleTime, BYTE* pBuffer, long BufferLen) { if (BufferLen MY_MAX_FRAME) return S_OK; // 帧过大直接丢弃 myRingBuffer.Push(pBuffer, BufferLen, SampleTime); return S_OK; }逻辑说明回调函数在 DirectShow 的流线程上执行任何阻塞都会让采集管线停摆。pBuffer 指向的内存只在回调返回前有效所以必须做显式拷贝。这里用了一个自定义的环形队列每一帧带两个字段数据长度和 SampleTime。myRingBuffer.Push 内部如果队列已满直接覆盖最老的帧这正是实时流该有的行为——丢老帧不丢实时性。参数上需要注意 Grabber 的媒体类型要和采集 Pin 匹配。如果采集端输出的是 MEDIASUBTYPE_I420而回调拿到的是 RGB24会出现画面尺寸和颜色通道都对不上。拿到数据后第一件事是打印 BufferLen 和媒体子类型确认和预期一致再继续打包。3.4 时间戳换算REFERENCE_TIME 到 RTP 时间戳DirectShow 里的 SampleTime 单位是 100 纳秒而 RTP 视频时间戳的惯例单位是 1/90000 秒换算公式并不是乘一个系数而是直接改基准rtp_ts (ref_time * 90000) / 10000000等价写法是ref_time * 9 / 1000因为 90000 和 10000000 有公约数。音频的换算同理把 90000 换成采样率就可以48kHz 音频就是ref_time * 48000 / 10000000。这里有个隐蔽的坑。形参 SampleTime 是取流开始后的相对时间不是系统绝对时间。直接把整个会话的第一帧时间戳设成 0 没问题但如果拿系统时间做基准接收端播放器会因为时间戳跳跃过大而拒绝显示。我一般会在回调里记录第一帧到达时间之后每帧 RTP 时间戳 (当前 ref_time - 首帧 ref_time) 换算结果。用这个方式VLC 打开后画面从第 0 秒开始节奏稳定拖拽进度条也准。4. RTP 打包与发送的实现拆分从 H.264 裸流到可播放的报文4.1 构造 RTP 头版本位、PT、序列号与 SSRC一个 RTP 头的标准结构是 12 字节固定头版本号占 2 位填充位 1 位扩展位 1 位CSRC 计数 4 位标记位 1 位负载类型 7 位序列号 16 位时间戳 32 位SSRC 32 位。构造函数按位填充即可typedef struct { uint8_t version:2; uint8_t padding:1; uint8_t extension:1; uint8_t csrc_count:4; uint8_t marker:1; uint8_t payload_type:7; uint16_t sequence; uint32_t timestamp; uint32_t ssrc; } rtp_header_t; void fill_rtp_header(rtp_header_t* h, uint16_t seq, uint32_t ts, uint32_t ssrc, uint8_t pt) { h-version 2; h-padding 0; h-extension 0; h-csrc_count 0; h-marker 0; // 一帧的最后一个包置 1 h-payload_type pt; h-sequence htons(seq); h-timestamp htonl(ts); h-ssrc htonl(ssrc); }逻辑说明这个函数把协议字段按 RTP 的线格式约束填好。有两个细节容易漏一是网络字节序转换sequence、timestamp、ssrc 三个字段必须调字节序没做这步在局域网同架构机器上可能侥幸能跑跨端就是花屏二是 marker 位的处理H.264 视频里通常把一帧图像的最后一个 RTP 包标记为 1接收端用这个位判断一帧是否收齐辅助显示刷新rtp_send 的打包循环里要记得在分片结尾置位。SSRC 在会话开始时生成一次随机即可。接收端如果收到两个相同 SSRC 且不同源的流会判定为冲突并丢流所以尽量不要把 SSRC 设成固定值工程上建议取rand() | (time(NULL) 16)这类不可预测的值。4.2 H.264 载荷封包小于 MTU 直接发大了走 FUA从 DirectShow 出来的 H.264 通常是 Annex-B 格式每帧以 00 00 00 01 或 00 00 01 开头。打包前要先去掉起始码只保留 NAL 单元本身。// 将 Annex-B 格式 H.264 封装成 RTP 包返回发送的包数 int rtp_send_h264_frame(uint8_t* frame, size_t len, RtpSession* sess, uint32_t ts) { size_t offset find_start_code(frame, len); uint8_t nal_type frame[offset] 0x1F; size_t payload_len len - offset; if (payload_len MAX_RTP_PAYLOAD) { send_packet(sess, frame offset, payload_len, ts, 1); return 1; } // FUA 分片第一个分片和中间分片各占一个 RTP 包 uint8_t fu_indicator (frame[offset] 0xE0) | 28; uint8_t fu_header_base nal_type; size_t remaining payload_len; int pkt_count 0; while (remaining 0) { size_t part_len min(remaining, MAX_RTP_PAYLOAD - 2); uint8_t packet[MAX_RTP_PAYLOAD 14]; packet[12] fu_indicator; packet[13] (pkt_count 0 ? 0x80 : 0x00) | ((remaining part_len) ? 0x40 : 0x00) | fu_header_base; memcpy(packet 14, frame offset (payload_len - remaining), part_len); send_packet(sess, packet, part_len 14, ts, 0); remaining - part_len; pkt_count; } return pkt_count; }逻辑说明小于最大载荷的直接一个包发掉大于 MTU 的按 FUA 协议拆包。FUA 的组成是一个 FU indicator 加一个 FU header前者高位保留 NAL 头的 NRI低 5 位固定填 28后者首包置 S 位尾包置 E 位。参数上 MAX_RTP_PAYLOAD 建议取 1200常见做法是按 1500 字节 MTU 减去 IP 头20、UDP 头8和 RTP 头12再打一点余量。拆分的循环里remaining part_len这个条件写的是“剩余长度小于本次取走的长度”最后一个分片据此置 E 位不要把等于的情况漏进去否则尾包永远标不出来。4.3 发送线程与缓冲策略丢得起帧丢不起堆积取流回调入队、发送线程出队发包这是 rtp_send 最稳的结构。发送线程单独跑一个循环从队列取帧按 4.2 的封装函数发包然后按帧率 sleep 到下一帧的发送时间。缓冲策略上有一条血泪经验发送队列一定要有上限。很多工程上线后出问题都是因为网络偶发拥塞队列里的帧积压延迟从 200 毫秒涨到 2 秒画面越来越卡却找不到代码里的死循环。队列容量按帧率折算丢帧策略是保最新的帧比如一个 25fps 的视频流队列深度留 10 帧就够——超过直接覆盖最老的帧。发送线程本身的优先级不需要特意调高。有人为追求“实时”把线程优先级设成 TIME_CRITICAL结果反噬到 DirectShow 的采集线程拿不到 CPU画面跟着抖。保持普通优先级靠队列削峰效果远比调优先级稳。4.4 上线前必调的 6 个发送参数参数建议值/常见取值说明视频 PT96~127 动态范围要与 SDP 协商一致错过对端不显示画面SSRC随机生成固定值会与历史会话残留冲突MTU1200 字节留足 IP/UDP/RTP 头空间复杂网络适当调小RTCP 周期5 秒太短浪费带宽太长丢包反馈延迟高发送队列深度10 帧视频超过丢老帧保证实时性RTP 本地端口偶数RTCP 用端口 1UDP 惯例偶数给 RTP这张表是发送方向上的最小参数集。PT 和端口要在会话开始前确定RTCP 周期和队列深度可以在运行中热调整。每次改完参数务必在接收端重新抓包确认发送端的参数表写错了不一定会报错只会表现为“对端为什么就是不出画”。5. rtp_send 的踩坑排查从花屏、断流到延迟越堆越大的 5 个现场5.1 花屏但有声音载荷类型和 SDP 里写的不一致现象接收端能识别到 RTP 流Wireshark 也看得到数据包但 VLC 显示出来是满屏绿色色块或画面破碎。音频正常。原因rtp_send 发送时 PT 值为 96而 SDP 里写的artpmap:97 H264/90000接收端按 97 去映射编码格式把 H.264 载荷按错误协议解析。还有一种情况是同一路流里 I 帧和 P 帧的 PT 混了。解决先把 Wireshark 过滤条件写成rtp ip.addr对端IP看 Info 列里每个报文的 PT 值再对照发起端的 SDP 文本。这个坑的特征是全部视频包显示为同一种 PT但和协商结果对不上。修正方法简单把发送端的 PT 常量改成 SDP 里声明的值或者让 rtp_send 在会话开始时从解析好的结构体里取不硬编码。5.2 画面停住几秒才动回调里塞了阻塞操作现象程序刚启动时画面正常几秒后图像停滞过一会儿又跳几帧如此反复对端看到的画面像幻灯片。原因OnBufferCB 回调里调用了 sendto、日志写盘或加锁DirectShow 的流线程被阻塞。采集管线有背压一旦 Source Filter 的缓冲填满它就不再投递新帧。解决把回调里的发送逻辑全部移出去只保留拷贝入队。日志输出控制在失败路径上。排查时可以在回调入口用QueryPerformanceCounter记录执行耗时超过 5 毫秒的基本就是阻塞点。治本的方法是把回调函数体压到只剩一次内存拷贝其他事情全部交给发送线程。5.3 RTCP 反馈一直收不到端口号理解错了现象发送端能发 RTP接收端也有画面但 rtp_send 里周期间隔收不到任何 RTCP 的 RR 包丢包率、抖动全是未知。原因RTCP 的端口约定是 RTP 端口 1但工程里只建了一个 socket把 RTP 包和 RTCP 包混在一个端口上收发。RTP 偶数端口、RTCP 奇数端口这个惯例在实际对接时经常被忽略对端回 RR 到奇数端口而发送端只在偶数端口等自然收不到。解决在 rtp_send 里建立两个 UDP socket一个绑定到协商的 RTP 端口另一个绑定到该端口 1 的端口RTCP 的接收逻辑单独走第二个。如果对端是非标准实现可以从对端发来的第一个包解析它的源端口再反向回包。5.4 延迟随运行时间越堆越大发送队列无上限现象刚启动时端到端延迟约 200 毫秒运行一小时后变成几秒网络状态看起来没有明显恶化。原因发送队列是无界队列某一次网络闪断期间积压了大量帧恢复后发送线程按顺序发包积压的数据全部发完才轮到新帧延迟自然线性增长。解决给队列设硬上限并监控队列深度。超过上限时不做“丢弃新帧”而是“丢弃最老帧”保证队列里的数据始终是最近的。这个现象就是典型的玄学式问题——代码不报错CPU 不高内存不出来只有延迟曲线一路爬坡。调试时在发送线程里定长打印队列长度就能把黑匣子打开。5.5 排查顺序抓包、统计、再动代码排此类问题我固定的顺序是先抓包再改代码。Wireshark 里输入rtp过滤打开 Statistics 菜单下的 RTP Stream Analysis这一屏信息能一次性给出序列号跳变、时间戳抖动、丢包率、乱序四个关键指标。看到 RTP Sequence delta 出现大段空白说明丢包在发送端或网络看到时间戳倒挂说明打包层的时间戳换算有问题。没有这份统计就盲目调参数和猜谜差不多。抓包后确认方向再回代码里逐段加日志效率高得多。6. 交付前用三样工具给 rtp_send 做体检抓包核对、软播放端验证、模拟丢包上线前不要急着接对端设备先在局域网里自测三件套。第一件启动 rtp_send 后用 Wireshark 的 RTP Stream Analysis 核对丢包率是否为 0、序列号是否连续同时确认 RTCP SR 每 5 秒出现一次。任何一个指标不对都不要进入下一步。第二件用软播放端验证可播放性。把 SDP 里的 IP 和端口改成接收端本机地址保存成.sdp文件用 VLC 打开或ffplay -protocol_whitelist file,udp,rtp xxx.sdp。这一步能直接暴露打包层的问题比如时间戳换算错误、FUA 尾包标记遗漏播放器如果卡在等待首帧优先检查 SDP 里的afmtp行有没有带sprop-parameter-sets。H.264 的 SPS、PPS 需要显式传递rtp_send 通常会在关键帧前面带上参数集没有就补到 SDP 里。第三件模拟丢包再拉一遍统计。Linux 上用sudo tc qdisc add dev eth0 root netem loss 5%测完tc qdisc del dev eth0 root清理。Windows 上可以用 clumsy 或直接对网线做物理断连测试。这时候重点看 RTCP RR 携带的丢包率和 rtp_send 的发送端统计是否吻合顺便让发送线程的队列上限策略在真实丢包下跑一次观察延迟是否维持在秒级以内。这三件套跑完rtp_send 才算真正达到可交付的状态。这套流程跑顺之后我习惯把每个版本的抓包统计文件存下来方便回头对比。做实时流媒体这行最怕的就是凭感觉改参数——数据在手改动才有依据。希望帮到你。本文还有配套的精品资源点击获取