简介:这是一份基于DirectShow框架实现的RTP/RTCP实时传输协议发送端程序源码,面向学习流媒体传输、网络编程与音视频开发的初学者及进阶开发者,帮助理解RTP协议在真实工程中的收发流程与数据封装方式。压缩包共16个文件,约21KB,包含4个h头文件与4个cpp源文件构成核心逻辑,另有ico图标、dsp/dsw工程文件、rc资源脚本及ReadMe说明文档,可直接用Visual Studio打开编译运行。程序以字符串模拟数据流,通过RTP协议完成发送端的数据传输,便于读者对照代码梳理RTCP反馈机制与RTP打包发送的调用关系。目前已有142人学习下载,适合作为网络协议课程实验、流媒体入门练手或二次开发的基础模板,帮助快速搭建可调试的RTP发送环境并理解DirectShow过滤器协作方式。
1. 从 rtp_send.rar 说起:DirectShow 采集 + RTP/RTCP 发送到底解决什么问题
如果你手头有一个叫rtp_send.rar的压缩包,标题里还带着 Directshow、RTP、RTCP、rtp_send 这几个词,那它大概率是一个 Windows 平台上的实时音视频发送端示例:用 DirectShow 从摄像头或采集卡抓帧,编码之后通过 RTP 打包发出去,同时用 RTCP 做会话控制和状态反馈。这类东西在安防国标平台对接、远程预览、录播推流里非常常见,很多人搜「rtp_send」就是因为在现场遇到了start preview failed maybe rtp session false or preview links' nun这种报错,想找一个能跑通的最小实现。
它适合两类人:一类是刚接手 DirectShow 采集、需要把本地画面推到网络上的新手,另一类是做过多路摄像头、被 RTCP 反馈和会话状态折磨过的老手。核心链路其实就三段——采集、打包、发送,难点全在细节:DirectShow 的 SampleGrabber 回调在哪个线程、RTP 时间戳怎么递增、RTCP 的 SR/RR 什么时候发。下面按「先立住原理,再动手复现,最后讲坑」的顺序拆开讲。
2. DirectShow 采集链路:从 UVC 摄像头到一帧可发送的原始数据
2.1 为什么用 DirectShow 而不是 Media Foundation
在 Windows 上抓摄像头,常见做法有两套:DirectShow 和 Media Foundation。rtp_send这类老项目基本都用 DirectShow,原因是它的 Filter Graph 模型足够简单,SampleGrabber 能直接拿到未压缩帧,兼容的 UVC 摄像头和采集卡范围也广。Media Foundation 更现代,但异步模型对新手不友好,很多国标平台 SDK 至今还在用 DirectShow 的接口约定。
选型上我一般这样判断:如果只是抓 YUV/RGB 原始帧自己编码,DirectShow + SampleGrabber 最省事;如果要硬编码、低延迟、多路高分辨率,再考虑 Media Foundation 或直接上采集卡的 SDK。rtp_send走的是前者,所以理解它的采集部分,重点就是 Filter Graph 怎么搭、回调怎么接。
2.2 搭一个最小采集 Graph 的代码
下面这段是典型的 DirectShow 采集骨架,用 SampleGrabber 抓帧。注意它只是采集侧,编码和 RTP 打包在后面章节。
// 初始化 COM,DirectShow 所有接口都依赖它 HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); IGraphBuilder* pGraph = NULL; ICaptureGraphBuilder2* pBuilder = NULL; IBaseFilter* pCap = NULL; // 采集源 IBaseFilter* pGrabberF = NULL; // SampleGrabber 过滤器 ISampleGrabber* pGrabber = NULL; // 抓帧接口 CoCreateInstance(CLSID_FilterGraph, NULL, CLSCTX_INPROC_SERVER, IID_IGraphBuilder, (void**)&pGraph); CoCreateInstance(CLSID_CaptureGraphBuilder2, NULL, CLSCTX_INPROC_SERVER, IID_ICaptureGraphBuilder2, (void**)&pBuilder); pBuilder->SetFiltergraph(pGraph); // 绑定第一个视频采集设备(多摄像头时这里要按名称/索引选) CoCreateInstance(CLSID_VideoInputDeviceCategory, NULL, CLSCTX_INPROC_SERVER, IID_IBaseFilter, (void**)&pCap); pGraph->AddFilter(pCap, L"Capture Source"); // 插入 SampleGrabber,并设置它要拿的媒体类型 CoCreateInstance(CLSID_SampleGrabber, NULL, CLSCTX_INPROC_SERVER, IID_IBaseFilter, (void**)&pGrabberF); pGraph->AddFilter(pGrabberF, L"Sample Grabber"); pGrabberF->QueryInterface(IID_ISampleGrabber, (void**)&pGrabber); AM_MEDIA_TYPE mt; ZeroMemory(&mt, sizeof(mt)); mt.majortype = MEDIATYPE_Video; mt.subtype = MEDIASUBTYPE_YUY2; // 常见 UVC 输出格式,也可设 RGB24 pGrabber->SetMediaType(&mt); pGrabber->SetBufferSamples(FALSE); // 不缓存,回调里直接处理 pGrabber->SetOneShot(FALSE); // 连接 采集源 -> SampleGrabber -> NullRenderer pBuilder->RenderStream(&PIN_CATEGORY_CAPTURE, &MEDIATYPE_Video, pCap, pGrabberF, NULL);逻辑说明:SetBufferSamples(FALSE)表示不复制到内部缓冲,回调拿到的是采集缓冲的引用,处理要快,否则会丢帧。SetMediaType里指定的 subtype 必须和摄像头实际输出一致,设错了RenderStream会失败。参数上,YUY2 每像素 2 字节,RGB24 是 3 字节,带宽差 1.5 倍,直接影响后面 RTP 打包的 MTU 分片数量。
2.3 回调里区分多路摄像头
热搜里有人问「c# directshow uvc 回调里区分多个摄像头」,这是多路采集的经典问题。SampleGrabber 的回调是全局的,如果你给每个摄像头都挂一个 SampleGrabber,回调函数里拿不到「这是哪一路」的信息。常见做法是给每路封装一个类,回调里通过pSample反查,或者干脆每路用独立的 Grabber 对象,把路号作为成员变量传进回调上下文。
// 每路摄像头一个 GrabberContext,回调里靠它区分 struct GrabberContext { int channelId; ISampleGrabber* grabber; }; HRESULT STDMETHODCALLTYPE SampleCB(double t, IMediaSample* pSample) { GrabberContext* ctx = (GrabberContext*)m_pContext; // 构造时绑定 BYTE* pData = NULL; pSample->GetPointer(&pData); long len = pSample->GetActualDataLength(); // 用 ctx->channelId 区分是哪一路,再送进对应编码器 OnFrame(ctx->channelId, pData, len, pSample->GetTime(NULL, NULL)); return S_OK; }参数说明:GetActualDataLength是这一帧的有效字节数,不要用GetSize,后者是缓冲上限。GetTime拿到的参考时钟时间戳,是后面换算 RTP 时间戳的基准,单位是 100ns。多路场景下每路的时钟可能不同步,建议统一用一个系统时钟做基准,否则 RTCP 的抖动计算会失真。
3. RTP 打包:时间戳、序列号与 MTU 分片怎么设
3.1 RTP 头里每个字段的取值逻辑
RTP 头 12 字节,真正需要你操心的就四个字段:序列号、时间戳、SSRC、负载类型。序列号每发一个包加一,回绕到 65535 后从 0 继续;时间戳按采样率递增,视频常用 90000Hz,一帧 40ms 就加 3600;SSRC 标识这一路流,多路摄像头必须不同,否则接收端会混流;负载类型要和 SDP 里协商的一致,比如 H264 用 96(动态)或 102。
很多人第一次写 rtp_send 会犯的错是时间戳按帧号递增,结果接收端播放速度完全不对。正确做法是:时间戳增量 = 帧间隔秒数 × 时钟频率。25fps 就是 1/25 × 90000 = 3600。
3.2 一个可复用的 RTP 打包函数
import struct, time class RtpSender: def __init__(self, ssrc, payload_type=96, clock_rate=90000): self.seq = 0 self.ssrc = ssrc self.pt = payload_type self.clock = clock_rate self.ts = 0 self.last_time = None def next_packet(self, payload: bytes, marker: bool, frame_ts: float): # frame_ts 是采集回调给的秒级时间,换算成 RTP 时间戳 if self.last_time is None: self.ts = 0 else: self.ts += int((frame_ts - self.last_time) * self.clock) self.last_time = frame_ts header = struct.pack( '!BBHII', 0x80, # V=2, 无填充/扩展/CSRC (0x80 if marker else 0) | self.pt, # M 位 + 负载类型 self.seq & 0xFFFF, self.ts & 0xFFFFFFFF, self.ssrc ) self.seq = (self.seq + 1) & 0xFFFF return header + payload逻辑说明:marker位标记一帧的最后一个包,接收端靠它判断帧边界。struct.pack的!表示网络字节序,RTP 所有多字节字段都是大端。参数上,clock_rate视频固定 90000,音频按采样率(8000/16000/48000)。ssrc建议用随机数生成后固定,不要每帧变。
3.3 MTU 分片:为什么 1400 是常用值
以太网 MTU 1500,减去 IP 头 20、UDP 头 8、RTP 头 12,留给负载的是 1460。但实际链路里可能有 PPPoE、隧道封装,所以工程上普遍取 1400 甚至 1200 做保守值。一帧 H264 如果 8KB,就要切成 6 个包,每个包带同样的时间戳,只有最后一个包 marker 置 1。
分片本身 RTP 不管,是编码层(如 H264 的 FU-A)负责的。rtp_send里如果直接把整帧塞进一个 UDP 包,超过 MTU 后 IP 层会分片,一旦丢一个分片整帧就废了,而且 NAT 环境下分片包很容易被丢。所以正确做法是在应用层按 1400 切,再交给 RTP 打包。
4. RTCP 会话控制:SR/RR 反馈与 start preview failed 的排查
4.1 RTCP 到底在传什么
RTP 只管发,RTCP 负责「发得怎么样」。核心两种包:SR(Sender Report)由发送端周期性发出,带发送的包数、字节数和 NTP 时间戳;RR(Receiver Report)由接收端回,带丢包率、抖动、最高序列号。发送端拿到 RR 后可以判断网络状况,决定是否降码率。
rtp_send里如果只发 RTP 不发 RTCP,很多平台会认为会话没建立,直接报start preview failed maybe rtp session false。因为平台侧要靠 RTCP 的 SR 来同步音视频时间戳,没有 SR 它不知道你的时间基准。
4.2 发一个最小 SR 包的代码
import struct, time def build_sr(ssrc, ntp_sec, ntp_frac, rtp_ts, pkt_count, octet_count): # RTCP 头:V=2, P=0, RC=0, PT=200(SR), length=6 (28字节/4 - 1) header = struct.pack('!BBH', 0x80, 200, 6) sender_info = struct.pack('!IIIIII', ssrc, ntp_sec, ntp_frac, rtp_ts, pkt_count & 0xFFFFFFFF, octet_count & 0xFFFFFFFF) return header + sender_info # 每 5 秒发一次 SR,这是 RFC 3550 推荐的视频最小间隔 def ntp_now(): t = time.time() sec = int(t) + 2208988800 # 1900 到 1970 的秒差 frac = int((t - int(t)) * (1 << 32)) return sec, frac逻辑说明:SR 包固定 28 字节,length字段是「包长/4 - 1」,所以是 6。ntp_sec要加 2208988800 把 Unix 时间转成 NTP 纪元。pkt_count和octet_count是累计值,从会话开始一直加,不要清零。参数上,视频 SR 间隔建议 5 秒,音频可以 5 秒以内,太频繁会占带宽。
4.3 start preview failed 的三条排查路径
遇到start preview failed maybe rtp session false or preview links' nun,按这个顺序查:第一,看 RTCP 有没有发出去,抓包确认对端有没有收到 SR,没有 SR 平台就认为会话没起来;第二,看 SDP 协商的负载类型和实际发的 RTP 头里的 PT 是否一致,不一致平台直接丢包;第三,看 SSRC 是否冲突,多路流用了同一个 SSRC,平台侧会当成一路,预览自然失败。这三条覆盖了现场八成以上的报错。
5. 避坑与排查:rtp_send 落地时最容易翻车的五个点
5.1 现象:画面能出但花屏、卡顿
原因:RTP 分片时 MTU 设太大,或者分片后没按序发。IP 层分片在 NAT 下丢一片整帧就废,表现就是花屏。解决:应用层按 1400 切,每个分片带同一时间戳,最后一个包 marker 置 1,发送顺序严格递增序列号。
5.2 现象:接收端播放速度忽快忽慢
原因:RTP 时间戳增量算错,常见的是按帧号递增而不是按时间递增,或者采集回调的时间戳单位没统一。解决:统一用 100ns 或秒做基准,时间戳增量 = 实际帧间隔 × 90000,不要用固定值。
5.3 现象:多路摄像头只有一路能预览
原因:SSRC 重复,或者 RTCP 的 SR 里 SSRC 和 RTP 头不一致。解决:每路生成独立随机 SSRC 并全程固定,SR 里的 SSRC 必须和 RTP 头一致,多路 RTCP 端口可以用同一对端口靠 SSRC 区分。
5.4 现象:SampleGrabber 回调里处理稍慢就丢帧
原因:SetBufferSamples(FALSE)时回调持有采集缓冲,处理时间超过帧间隔,采集源就会覆盖缓冲。解决:回调里只做拷贝,把编码和发送放到独立线程队列,队列满了主动丢旧帧而不是阻塞回调。
5.5 现象:平台报 rtp session false 但抓包能看到 RTP
原因:只发了 RTP 没发 RTCP,或者 RTCP 端口和 SDP 里声明的不一致。解决:确认 SR 按 5 秒周期发出,RTCP 端口是 RTP 端口 +1(除非 SDP 显式指定),抓包看对端有没有回 RR。
6. 进阶技巧:用 Wireshark 验证 rtp_send 的完整链路
调 rtp_send 最有效的手段不是加日志,是抓包。Wireshark 能直接解析 RTP 和 RTCP,把「发得对不对」变成看得见的东西。具体做法:在发送端用dumpcap抓 UDP 端口,过滤rtp || rtcp,然后看三个指标。
第一,看 RTP 序列号有没有跳变。在 Wireshark 里点开一个 RTP 包,展开 RTP 头,序列号应该是连续递增的,出现大跳变说明中间丢了包或者发送端序列号管理有 bug。第二,看时间戳增量是否稳定。选中同一路流的多个包,对比时间戳差值,25fps 下应该稳定在 3600 附近,波动大说明采集回调的时间基准有问题。第三,看 RTCP SR 的间隔和内容。过滤rtcp,SR 包应该每 5 秒一个,里面的包计数和字节计数应该单调递增,如果计数不涨说明发送端统计逻辑没接上。
下面这个过滤表达式可以直接用:
# 只看 RTP 和 RTCP,排除其他 UDP 干扰 udp and (rtp or rtcp) # 只看某一路 SSRC 的流,SSRC 换成你实际的十六进制值 rtp and rtp.ssrc == 0x1a2b3c4d # 看 RTCP 的 SR 包 rtcp and rtcp.pt == 200参数说明:rtp.ssrc在 Wireshark 里是十六进制显示,你代码里如果是十进制要转换。rtcp.pt == 200过滤 SR,205 是 RR,BYE 是 203。抓包时建议在发送端和接收端同时抓,对比两边看到的序列号,能快速定位是发送丢了还是网络丢了。
我自己的习惯是:任何 rtp_send 的问题,先抓 30 秒包,看序列号、时间戳、SR 三样,八成问题当场就能定位,比翻代码快得多。这套链路搭通一次之后,后面换编码格式、换分辨率都只是改参数的事。希望帮到你。
本文还有配套的精品资源,点击获取