简介:这是一个基于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.dsp | VC6 的工作区和工程文件,决定编译选项与文件列表 | 编译入口,必看 |
| 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 端口 10000,RTCP 自动用 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 发送程序,我都会先强制自己在回环地址上把这一整套流程走一遍,再换真实网络。字符串模拟看起来“傻”,但它能最快暴露发送端的时间戳、分片和端口问题,这个习惯帮我省掉了大量改完协议就通宵抓包的时间,希望帮到你。
本文还有配套的精品资源,点击获取