
简介这是一个基于DirectShow实时流媒体框架实现的RTP/RTCP发送端示例工程面向网络音视频开发者及协议学习者目标是演示如何通过RTP协议传输数据流并以字符串模拟实际数据流避免采集编码环节专注发送逻辑理解。RAR压缩包共16个文件约21KB包含4个C源文件、4个头文件以及dsp/dsw工程配置、ico图标、rc资源与简短的ReadMe说明目录结构清晰核心代码集中便于直接在Visual C 6.0中打开编译。已有142人学习下载属于轻量级入门样例。借助该工程读者能完整看到RTP发送端的初始化、字符串数据封装、发送缓冲区管理以及RTCP控制交互等关键步骤并可将此框架扩展为真实音视频流发送或接收端学习是快速上手RTP编程的实用参考。对于希望搭建RTP通信原型、验证自定义数据流传输的初级及中级开发者这份精简的工程刚好可以作为直接起步的蓝本。1. rtp_send 是什么一个用字符串模拟实时流的 RTP/RTCP 发送端做音视频传输的老哥应该都有体会RTP/RTCP 实时传输协议的 RFC 写得清楚可一动手写发送端就卡住不是 UDP 封装不对就是时间戳乱跳。rtp_send 正是为了打破这个局面而存在的。它是一个 MFC 对话框工程用字符串模拟数据流把 RTP 数据包的发送、RTCP 反馈包的周期性输出完整跑起来。你不需要先接摄像头或声卡就能在局域网里验证发送链路。如果你正在找 DirectShow 采集之后怎么把数据推到网络的答案或者想拿一份能改的 RTP 发送端骨架这份资源比空读 RFC 实在得多。它不解决接收端播放只解决“发送端到底怎么写、怎么调”这件事。2. 拆解 rtp_send 工程MFC 文件结构、协议职责与发送初始化2.1 发送端在协议栈里的位置RTP 背负载RTCP 做反馈RTP/RTCP 是一对配合工作的协议。RTP 负责承载实际数据比如音频帧、视频帧或者这个工程里的字符串RTCP 负责反馈传输质量周期性地发送发送端报告 SR 包、接收端报告 RR 包携带丢包率、抖动、往返时延这类统计信息。两者都跑在 UDP 之上RTP 用偶数端口RTCP 用相邻的奇数端口。rtp_send 既然是发送端它的工作就分成两块一块是把要发送的字符串按 RTP 固定头封装成一个个数据包并按实时节奏发出去另一块是定期构造 RTCP SR 包让接收端能算出丢包和抖动。用字符串模拟数据流是这套设计里最聪明的取舍。真实的音频视频数据需要采集设备、编码器调试时很难分辨是协议问题还是数据源问题。字符串一眼能看懂内容RTP 包发到抓包工具里负载是什么、长度对不对、序列号涨得顺不顺全部一目了然。把字符串链路跑通了后面换成 DirectShow 采样数据只是替换负载来源。这个工程选择 MFC 对话框作为外壳也有它的道理。RTP 发送需要一个持续触发的节奏MFC 的OnTimer消息天生适合做周期发送对话框上的编辑框又能直接填目标 IP、端口和负载内容不需要另外搭调试界面。对于实验性质的发送端这是成本最低的 UI 方案。2.2 rtp_send 工程文件清单哪个文件决定发送逻辑解压 rtp_send.rar 之后看到的是一套典型的 VC6 MFC 工程。要快速读懂它先分清哪些是 AppWizard 自动生成的哪些是真正要改的。下表把这批文件的角色说清楚。文件用途需要重点看吗rtp_send.dsw / rtp_send.dspVC6 的工作区和工程文件决定编译选项与文件列表编译入口必看rtp_send.cpp应用类实现负责初始化 MFC 程序一般不用改rtp_sendDlg.cpp / rtp_sendDlg.h对话框类实现定时器、按钮事件、发送逻辑基本都在这必看核心rtp_send.h / rtp_send.cpp标记为 RTP 发送相关的类和接口封装必看核心StdAfx.cpp / StdAfx.h预编译头文件RTP 库的头文件通常会在 StdAfx.h 里 include改头文件时可以看rtp_send.rc / res / .ico窗口资源、菜单和图标不需要动ReadMe.txt工程说明先读一遍1.cpp可能是单独测试发送函数的文件参考第一次打开工程我的习惯是先看rtp_sendDlg.cpp里的OnInitDialog和OnTimer。OnInitDialog里会完成 RTP 会话的初始化、目标地址设置、启动定时器OnTimer里则是每次发送的入口。如果代码里把 RTP 相关操作封装成了类似rtp_send的类那么rtp_send.h里能看到发送接口的声明。2.3 初始化一个 RTP 会话创建会话、配置目标地址和发送参数发送端的初始化路径不管用哪个 RTP 库套路都差不多。这里以最常见的 JRTPLIB 为例展示 rtp_send 这类工程的核心初始化逻辑。代码逻辑如下// 初始化 RTP 会话 RTPSession session; RTPUDPv4TransmissionParams transParams; transParams.SetPortbase(10000); // RTP 端口 10000RTCP 自动用 10001 RTPSessionParams sessionParams; sessionParams.SetOwnTimestampUnit(1.0 / 1000.0); // 时间戳单位 1ms sessionParams.SetUsePollThread(false); // 手动处理 RTCP 包 // 创建会话后添加一个目标地址 uint32_t destIP inet_addr(192.168.1.100); session.Create(sessionParams, transParams); session.AddDestination(destIP, 5004); // 目标 RTP 端口 5004 session.SetDefaultPayloadType(96); // 动态负载类型 session.SetDefaultMark(false); // 默认不标记分片结束这里要解释几个参数。SetPortbase(10000)是告诉底层 UDP 收发器RTP 包从本地 10000 端口发出RTCP 包从 10001 端口发出。SetOwnTimestampUnit(1.0 / 1000.0)表示我们自己维护的 RTP 时间戳单位是 1ms这样发送视频帧时可以根据帧率算出步长。AddDestination里的 IP 是接收端地址端口是接收端监听的 RTP 端口注意不是本地端口。SetDefaultPayloadType(96)选用 96是因为 0-95 的负载类型都分给了 G.711、H.264 等标准编码96 到 127 是留给动态负载的实验阶段用 96 最省事。初始化完成之后最好先做一个回环测试目标 IP 填 127.0.0.1本机同时跑一个接收线程确认数据能出去。很多发送端的第一个 bug 不在发送而在初始化参数没配对导致包发出去了但接收端不认。3. 把字符串数据流发出去定时器、时间戳与负载格式的配合3.1 从对话框输入框到 RTP 负载一个简单的打包流程初始化完成之后核心就是把对话框里的字符串变成 RTP 包的负载。这个流程在rtp_sendDlg.cpp里可以拆成三步取文本、拼数据、调发送接口。void CRtpSendDlg::OnBnClickedSend() { CString strData; GetDlgItemText(IDC_EDIT_DATA, strData); // 从输入框读字符串 char sendBuf[1024] {0}; int len strData.GetLength(); memcpy(sendBuf, strData.GetBuffer(len), len); // 调用 RTP 发送接口返回发送字节数 int ret m_rtpSend.SendPacket((unsigned char*)sendBuf, len); if (ret 0) { // 每次发送成功后RTP 时间戳和序列号由底层自动递增 SetDlgItemText(IDC_EDIT_STATUS, _T(send ok)); } else { SetDlgItemText(IDC_EDIT_STATUS, _T(send failed)); } }这一步的逻辑不复杂但有三个容易忽略的地方。第一GetBuffer之后不需要ReleaseBuffer吗在只读拷贝到sendBuf的场景里可以不调用因为CString的内容没有被修改但严谨一点应该在拷贝完成后手动ReleaseBuffer。第二发送缓冲区最好固定用unsigned char*因为 RTP 负载是任意字节流字符串可能含中文编码不能只按 ASCII 处理。第三SendPacket 的返回值一定要判断很多发送端代码不判断返回值结果目标地址被路由器丢弃了还不知道只能靠抓包发现问题。3.2 模拟实时采样的关键定时器周期与时间戳步进如果只是为了“点一下发一个包”上面代码就够了。但 rtp_send 的设计目标是模拟数据流也就是要像摄像头一样每帧数据按固定节奏持续发送。MFC 对话框里最直接的做法是用SetTimer。// 在 OnInitDialog 里启动一个 40ms 的定时器模拟 25fps 的视频帧 SetTimer(1, 40, NULL); void CRtpSendDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { // 每次触发构造一个带序号和时间戳的字符串 char buffer[128]; m_frameSeq; int len sprintf(buffer, frame_%d_time_%u, m_frameSeq, m_rtpTimestamp); m_rtpSend.SendPacket((unsigned char*)buffer, len); // 25fps 的视频RTP 时间戳以 90000Hz 为单位的话每帧步进 3600 m_rtpTimestamp 3600; } CDialog::OnTimer(nIDEvent); }这里的时间戳逻辑是最值得细看的。RTP 时间戳不是随便给个递增整数就行它的单位由负载采样率决定。模拟视频时常用 90000Hz 作为 RTP 时钟频率于是每一帧的步长是90000 / 帧率也就是 25fps 时步进 3600模拟 G.711 语音时采样率是 8000Hz每 20ms 一帧步进是8000 / (1000 / 20) 160。rtp_send 用字符串模拟数据流时同样要维护一个发送端时间戳接收端才能正确计算抖动和播放时序。定时器周期和时间戳步进必须匹配。定时器设 40ms时间戳步进就应该按 40ms 来算如果定时器周期是 10ms但时间戳步进按 25fps 算接收端看到的播放速度就不对。这是新手最容易犯的错误。另外MFC 的SetTimer最小精度只有毫秒级且受 Windows 消息队列影响40ms 的周期不会很准但用来模拟发送链路够用。3.3 用 Wireshark 验证发送结果过滤 rtp 和 rtcp 的抓包姿势代码写完后先别急着接接收端直接在发送本机抓包验证。Wireshark 打开后选择发送端的网卡在过滤栏输入下面的表达式udp.port 10000 || udp.port 10001这是假设本地 RTP 端口是 10000、RTCP 端口是 10001。如果工程里用的是其他端口把过滤条件换成实际端口。抓包后应该看到两类包。一类是偶数端口的 RTP 包Wireshark 会直接解析出 Sequence number、Timestamp、SSRC 和 Payload type另一类是奇数端口的 RTCP 包能看到 Sender report。最需要确认的是三个递增关系序列号是不是每次加 1时间戳是不是按设定好的步长递增RTCP 包是不是周期性出现。这三个关系全对发送端就基本没问题了。如果抓到的包只有 RTCP没有 RTP问题通常出在发送接口没被调起来。如果序列号有跳变说明除了定时器还有别的地方也在发送包或者出现了并发调用。如果 RTCP 完全没出现多半是RTPSession的 RTCP 功能没启用或者是端口配置成了同一个值导致 RTP 和 RTCP 争用端口。4. 接进 DirectShow从字符串负载到真实音视频帧的三个改动点4.1 DirectShow 与 RTP 的衔接边界采样回调里该做什么标题里挂着 DirectShow说明读者真正关心的是把 DirectShow 采集到的音视频流送进 RTP 发送端。rtp_send 用字符串模拟数据流本质上是把数据源的问题先绕开了。要换成真实流只需要找到 DirectShow 的采样回调位置把字符串替换成IMediaSample的数据指针。DirectShow 的一个典型回调长这样HRESULT SampleCB(IMediaSample *pSample) { BYTE *pData NULL; pSample-GetPointer(pData); // 拿到帧数据首地址 long dataLen pSample-GetActualDataLength(); // 实际数据长度 // 直接复用 rtp_send 的发送接口 m_rtpSend.SendPacket(pData, dataLen); return S_OK; }这里的改动点只有一个就是把原来从编辑框取字符串改成了从IMediaSample取内存指针。但有一个细节要处理好GetActualDataLength返回的是有效数据长度不是缓冲区长度发送时必须用它否则会把填充位也发出去。另外DirectShow 的采样回调运行在流线程里不要在回调里面做界面刷新否则会把 UI 线程卡死。4.2 时间戳换算DirectShow 的 100ns 单位与 RTP 采样率接进 DirectShow 后的第一个大坑是时间戳。DirectShow 的时间单位是 100ns也就是说一秒钟等于 10000000 个单位。RTP 的时间戳单位却是采样率的倒数语音一般是 8000Hz视频常用 90000Hz。两者不能直接互相拷贝。如果发送的是视频帧我一般会这样维护时间戳// DirectShow 的采样时间单位是 100ns // RTP 视频时钟频率通常用 90000Hz // 每帧 RTP 时间戳增量 帧时长(100ns) * 90000 / 10000000 LONGLONG startTime, stopTime; pSample-GetTime(startTime, stopTime); LONGLONG duration100ns stopTime - startTime; uint32_t rtpStep (uint32_t)(duration100ns * 90000 / 10000000); m_rtpTimestamp rtpStep;这样改前提是 DirectShow 的采样时间戳是连续的。如果采集器没有提供时间戳GetTime会返回无效值那就得退回上一种做法根据设定的帧率固定步进。对于不同负载时间戳频率可以参考这张表数据类别RTP 时间戳频率常见步进算法视频90000 Hz每帧步进 90000 / 实际帧率G.711 音频8000 Hz每帧步进 帧长(ms) * 8字符串模拟流自定义根据定时器周期设定测试时常用 1000 Hz这里有一个生产里常犯的错误视频的时间戳频率不是帧率而是固定 90000Hz。90000 是 24、25、30、50、60 这些常见帧率的公倍数是为了让整数步进不产生小数而不是随便选的“大数”。4.3 发送带宽和分片IMediaSample 数据放进 RTP 包之前的处理真实音视频帧通常比字符串大得多。一帧 1080p 视频可能几十 KB而 RTP 包的最佳负载长度在 1400 字节左右超过这个值很容易在 IP 层被分片。分片后只要丢一个分片整帧就废了接收端直接显示花屏。所以把IMediaSample的数据传给SendPacket之前必须先做 RTP 分片。把一帧拆成多个 RTP 包每包负载不超过 1400 字节最后一包把 Mark 位设为真#define RTP_PAYLOAD_MAX 1400 uint32_t offset 0; bool isLast false; while (!isLast) { int remain dataLen - offset; int packetLen (remain RTP_PAYLOAD_MAX) ? RTP_PAYLOAD_MAX : remain; isLast (remain RTP_PAYLOAD_MAX); m_rtpSend.SendPacket( pData offset, packetLen, m_rtpTimestamp, isLast, 96); // payload type 96 offset packetLen; }这段代码的关键在isLast参数。RTP 头里有一个 Marker 位约定一帧数据的最后一个分片要把这个位置 1接收端才能知道“这一帧收完了”。字符串模拟阶段不需要关心 Mark 位但真实视频流必须处理。如果 RTP 库的接口不直接支持 Mark 位通常有一个SetDefaultMark或单包设置的方法。另一个需要改的地方是发送节奏。DirectShow 回调什么时候来什么时候发这是对的不能像字符串模拟那样在定时器里强制发。如果 DirectShow 的帧率和 RTP 时间戳对不上优先以 DirectShow 的时间为准RTP 时间戳只负责表达“这一帧和上一帧隔了多久”。5. 避坑指南rtp_send 从编译到抓包最容易翻车的五个位置5.1 编译报错RTP 库版本与 VC6 / VS 工程不匹配现象用 VC6 打开工程后编译报出一堆fatal error C1083: Cannot open include file: rtpsession.h或者链接时报unresolved external symbol。原因这个工程依赖的 RTP 库没有随资源附带或者附带的是一个老版本库。VC6 的工程用的是旧格式VS2013 之后的编译器对老库头文件里的语法兼容性很差导致头文件打开失败。解决确认自己编译环境里 RTP 库的安装路径。如果工程用到了 JRTPLIB打开工程设置里的 C/C 预处理器和链接器目录把库头文件的 include 目录和 lib 目录分别加进去。如果使用的是新版 JRTPLIB而工程文件是 VC6 的最好用 VS 打开 .dsp 工程时选择“转换”转换后重新配置附加依赖项。不要直接双击 .dsp 就指望能编译通过。5.2 只发出 RTCP看不到 RTP先检查 SendPacket 调没调现象Wireshark 里能看到周期性的 RTCP 包但就是没有 RTP 数据包。原因这类问题大多不是协议配置错而是发送逻辑压根没触发。很多封装类把 RTCP 线程放在初始化函数里自动启动但 RTP 发送函数要等按钮事件或定时器消息来调。调试时只点了“初始化”没点“发送”自然只有 RTCP 包在跑。另一种可能是在某个版本里SendPacket之前必须先AddDestination否则目标地址为空RTP 包被底层丢弃但 RTCP 还是照常发给空地址。解决在发送代码入口处打一个断点确认SendPacket确实被调用了。再检查目标地址是否成功添加。我自己的习惯是发送前把目标 IP 和端口打印到状态栏用肉眼确认不是 0.0.0.0 或者 0 端口。5.3 接收端收不到Wireshark 却看得到现象在发送本机抓包RTP 包正常发出但另一台机器的接收程序收不到任何消息。原因Windows 防火墙拦截了入站 UDP 包。发送端抓包是在数据进入协议栈时抓到的防火墙在更靠后的位置所以出口能看到、入口被丢弃。另一种常见原因是接收进程绑定的端口和 RTP 包的目标端口不一致例如发送端把 RTP 发到了 5004 端口接收端却只监听了 5005。解决先关掉防火墙做一次验证确认问题是防火墙导致后再加一条入站规则放行 UDP 端口。然后把接收端监听端口改成和发送端AddDestination的端口完全一致。在接收端跑netstat -ano | findstr 5004能看到监听状态最稳妥。5.4 负载超过 MTU分片与重组要自己管现象发送大字符串或真实视频帧时接收端偶尔能收到但大部分时间丢包、花屏Wireshark 里看到同一个 RTP 包的 IP 分片标记。原因把整个视频帧塞进一个 RTP 包IP 层会按 MTU 分片。如果网络中有路由器丢弃分片或者某个分片超时底层 IP 重组失败整个包作废。RTP 本身不知道 IP 分片。解决发送端在上层按 1400 字节切割负载一块一块发并设置 Mark 位。这是架构问题不是参数问题。字符串模拟阶段数据量小不会触发这个坑但做 DirectShow 接流后一定会遇到早改早省事。5.5 定时器漂移导致时间戳跳变现象发送端代码看起来没问题定时器 40ms 一个包时间戳步进 3600但接收端计算出来的抖动值忽大忽小甚至出现播放速度不稳定。原因MFC 的SetTimer依赖窗口消息泵系统忙的时候消息排队定时器触发延迟甚至合并。实际发送间隔变成了 50ms、60ms但 RTP 时间戳还是按固定 3600 递增接收端按时间戳推算播放时刻就会判断出抖动。解决在OnTimer里用GetTickCount64()或者QueryPerformanceCounter测量真实间隔根据真实间隔动态计算时间戳步进LONGLONG now GetTickCount64(); double elapsedMs now - m_lastTick; m_lastTick now; // 实际经过了多少个采样周期就补多少步进 uint32_t step (uint32_t)(elapsedMs * 90.0); // 视频 90000Hz 时1ms 90 个时间戳单位 m_rtpTimestamp step;如果对实时性要求更高就不要用SetTimer改用CreateTimerQueueTimer或者独立线程加Sleep。从那以后我每次写发送端都会先确认发出间隔和时间戳增量是不是匹配不再默认定时器一定准时。6. 进阶用法把 rtp_send 改造成命令行可控的发送实验台6.1 用参数控制目标地址、负载类型和发送频率字符串模拟数据流跑通之后你会发现每次改目标 IP 都要在对话框里重新填很麻烦。把 rtp_send 改造成命令行工具能极大加快验证速度。在InitInstance里解析命令行参数然后传给对话框类的成员变量CString cmdLine m_lpCmdLine; int pos cmdLine.Find( ); CString destIP cmdLine.Left(pos); // 第一个参数目标 IP int destPort _ttoi(cmdLine.Mid(pos 1)); // 第二个参数目标端口调用方式就可以变成rtp_send.exe 192.168.1.100 5004 96 25对应参数依次是目标 IP、RTP 端口、负载类型、模拟帧率。这样在自动化测试脚本里可以直接替换参数不用每次启动都手动输入。改造时要记得在启动定时器之前应用这些参数否则定时器一启动发送逻辑已经用默认值跑起来了。6.2 用 RTCP 反馈验证发送质量命令行化的另一层价值是能看到 RTCP 反馈。接收端收到 RTP 包后会周期性发 RTCP RR 包里面包含了丢包率和延迟抖动。发送端在收到 RR 包时解析出来打印到控制台就能大概评估链路质量。这里不需要自己解析整个 RTCP 包很多 RTP 库提供了回调接口只需把统计字段打印出来void OnGotRTCPPacket(RTCPPacket *packet) { uint32_t fractionLost packet-GetSenderInfo().GetFractionLost(); // fractionLost 高 8 位表示丢包比例0-255 printf(lost ratio: %d.%02d%%\n, fractionLost * 100 / 256, (fractionLost * 10000 / 256) % 100); }这条链路跑通后我再调试发送端就不需要反复猜了发送前抓一次包确认 RTP 包结构接收端跑一轮确认 RTCP 反馈的丢包率在期望范围内。现在不管改哪个 RTP 发送程序我都会先强制自己在回环地址上把这一整套流程走一遍再换真实网络。字符串模拟看起来“傻”但它能最快暴露发送端的时间戳、分片和端口问题这个习惯帮我省掉了大量改完协议就通宵抓包的时间希望帮到你。本文还有配套的精品资源点击获取