简介面向DirectShow与RTP/RTCP协议学习者的发送端示例工程演示用字符串模拟数据流经RTP协议完成实时传输适合入门者理解RTP打包、RTCP反馈及发送链路。压缩包共16个文件整体仅21KB主要包含4个头文件、4个CPP源文件以及dsp/dsw工程文件、rc/rc2界面资源、ico图标和ReadMe说明结构紧凑清晰。已有142人浏览学习。通过工程可快速查看发送端对话框实现、资源定义与工程配置梳理从数据模拟、RTP发送到RTCP交互的完整流程对于希望动手接触RTP/RTCP编程的开发者是一份轻量且可直接打开的参考示例。1. 一份用字符串模拟数据流的 RTP 发送端VC6 工程里藏着协议学习最快的路径刚上手 RTP/RTCP 实时传输协议的人十有八九会卡在第一步没有摄像头、没有采集卡不知道从哪里弄一路“真实媒体流”来练手。这份 rtp_send 工程的做法反直觉——它直接拿字符串模拟数据流把 MFC 对话框程序做成 RTP 发送端。字符串既当载荷又当你要理解的数据流模型解决了协议链路怎么跑通、包怎么发、序列号和时间戳怎么涨的问题。它适合想一两天看懂 RTP 发送代码的学生、流媒体传输的接手开发者也适合准备把 DirectShow 采集帧替换进发送逻辑的人——把字符串链路调通后换真实数据只改一个 memcpy。2. RTP/RTCP 发送链路设计不是先抓摄像头而是先让字符串“说话”2.1 RTP 是什么站在 UDP 肩膀上的时间戳协议RTP 即 Real-time Transport Protocol它并不是一个完全独立的传输层协议一般跑在 UDP 之上负责给应用层的数据流加上时间信息与包编号信息。很多第一次读 RTP 代码的人会被误导以为要自己实现可靠传输、重传与拥塞控制——那是 TCP 的职责范畴。RTP 的核心诉求是实时性数据包从发送端到接收端的时延尽可能小宁丢勿等。字符串模拟数据流看起来只是在“发文字”实际上是在构建一个最简载荷模型任何一个可以被切割成小块的数据不管是 PCM 音频、H.264 视频还是普通文本都能按照同一套 RTP 封装逻辑发送。参考这份工程的 rtp_send.h、rtp_sendDlg.cpp 等文件程序的关键动作是构造 RTP 包头填入字符串载荷再通过 UDP socket 发往对端。RTP 包头固定 12 字节起后面可以跟扩展头但基础发送端只需要填好前 12 字节。常见做法是定义一个结构体描述包头按网络字节序逐字段填充再连同载荷一起交给 sendto。这里有个初学者容易忽略的问题RTP 头部字段的 bit 顺序和 TCP/IP 头一样都是大端序直接用一个本地结构体强转发送在 x86 小端机器上会得到错乱的版本号、负载类型和序列号抓包时几乎每个字段都是反的。RTP 头字段的布局可以用下面这张表来记它比对着 RFC3550 原文舒服得多——RFC 把字段写得很严谨却没有告诉你发送端该填什么具体值实际工程里用的就是这组简化约定字段位宽位置发送端填写建议V2 bit字节 0 高 2 位固定 2表示 RTP 版本P1 bit字节 0 bit20表示不填充X1 bit字节 0 bit30表示不使用扩展头CC4 bit字节 0 低 4 位0表示无 CSRCM1 bit字节 1 高位字符串流可填 0PT7 bit字节 1 低 7 位建议 96动态负载类型sequence number16 bit字节 2~3每发一个包自增 1timestamp32 bit字节 4~7按采样频率递增SSRC32 bit字节 8~11随机取值且保持不变调试的时候把这张表放在屏幕边上对着抓包结果看能省掉大半的排查时间。2.2 RTCP 的作用发送端如何知道对端在不在听很多人会把 RTP 和 RTCP 混为一谈。这份工程的摘要里明确写了“RTP/RTCP 实时传输协议此程序是发送端”意思是不光要发 RTP 数据包还要周期性发 RTCP 控制包用来报告发送数据量、包数与时间戳信息。RTCP 里最常用的是 SRSender Report发送端报告里面携带 RTP 时间戳与 NTP 时间戳的映射关系接收端拿到 SR 后才能做音视频同步和播放延迟校正同时接收端会回 RRReceiver Report接收端报告告诉发送端丢包率、抖动、往返时延。如果只写 RTP 发送不写 RTCP对端虽然也能收到数据但标准播放器可能因为缺少同步信息而无法正确排序。这里有个值得注意的分工RTP 数据包使用一个 UDP 端口比如 5004RTCP 通常使用这个端口号加 1 的相邻端口也就是 5005。发送端如果要跑完整链路得准备两个 socket或者在设计阶段给 RTCP 保留端口。常见做法是用一个定时器每 5 秒生成一次 RTCP SR内容包含累计发送包数、累计字节数然后从 RTCP 地址发出去。字符串模拟数据流场景下 RTCP 包体和真实音视频流没有区别因为 RTCP 不关心载荷类型只关心统计信息。2.3 为什么用字符串模拟数据流和 DirectShow 的分工关系DirectShow 出现在这个工程的关键词里不是偶然。生产环境里RTP 发送端的前一级往往是 DirectShow 过滤器链采集卡通过 Graph 输出 YUV 帧编码器把帧压缩成 H.264 ES 流再把 ES 流切割进 RTP 打包器。这个过程涉及采集、转换、编码、打包、发送五个环节任何一个环节出错都很难定位。这份工程的聪明之处在于用字符串模拟最后两个环节的载荷先把数据流降维成一行文本把协议链路跑通之后再把字符串替换成 DirectShow 采样回调里的真实帧数据。我一般会建议把这份资源看作一个协议学习母版。先用字符串把 RTP 发送端调通抓包确认序列号连续、时间戳递增、载荷完整再考虑接 DirectShow 的 ISampleGrabber 回调在回调函数里把 media sample 的 buffer 替换掉字符串的位置。替换点通常只有一个函数——把字符串放进 RTP 载荷的那次 memcpy。这样二段式推进排错范围小得多也符合从简到繁的工程节奏。另外提醒一句在搜索引擎里搜 RTP很容易先撞上 RPG Maker VX Ace RTP 这种游戏运行库资源那个 RTP 是 Runtime Package和这里的 Real-time Transport Protocol 完全是两码事。别下错包也别在技术沟通里把这俩混用这是最容易犯的一个名词乌龙。3. 把字符串装进 RTP 数据包头部字段填充与 sendto 打包实战3.1 RTP 头结构12 字节的每个 bit 都不能错先建立一个正确的 RTP 头模型。最基础版本的 RTP 头是 12 字节没有扩展头、没有 CSRC 列表的情况下字节 0 的高 2 位是版本号 V接着是 P、X、CC字节 1 是 M 和 PT字节 2~3 是序列号字节 4~7 是时间戳字节 8~11 是 SSRC。后面所有内容都是载荷。写发送端代码时我更习惯先声明一个 12 字节的头数组按偏移量填充而不是定义一个结构体再 memcpy。原因很简单结构体在 VC6 编译器里可能按 4 字节对齐而 RTP 头是位紧凑排列的结构体方案要求用#pragma pack(1)并且处理字节序数组方案更直观可控性也更好。下面这段代码是发送端打包 RTP 包时的核心写法// rtp_send.cpp 中发送函数的核心片段 void SendRtpString(const char* str, unsigned short seq, unsigned int ts, unsigned int ssrc) { char rtpPacket[1472]; // 包头 12 字节 载荷整体控制在 MTU 内 memset(rtpPacket, 0, sizeof(rtpPacket)); // 填充 12 字节 RTP 头 rtpPacket[0] 0x80; // V2, P0, X0, CC0 rtpPacket[1] 96; // M0, PT96动态负载类型 rtpPacket[2] (char)((seq 8) 0xFF); // 序列号高字节 rtpPacket[3] (char)(seq 0xFF); // 序列号低字节 rtpPacket[4] (char)((ts 24) 0xFF); // 时间戳最高字节 rtpPacket[5] (char)((ts 16) 0xFF); rtpPacket[6] (char)((ts 8) 0xFF); rtpPacket[7] (char)(ts 0xFF); rtpPacket[8] (char)((ssrc 24) 0xFF); rtpPacket[9] (char)((ssrc 16) 0xFF); rtpPacket[10] (char)((ssrc 8) 0xFF); rtpPacket[11] (char)(ssrc 0xFF); int payloadLen strlen(str); if (payloadLen 1456) payloadLen 1456; // 最多留 1456 字节载荷 memcpy(rtpPacket 12, str, payloadLen); int packetLen 12 payloadLen; // 目标地址由对话框界面上的 IP 和端口输入框决定 sendto(sock, rtpPacket, packetLen, 0, (sockaddr*)destAddr, sizeof(destAddr)); seq; // 序列号自增 ts 160; // 模拟每包 20ms 音频的时间戳步进 }这段代码背后有三个参数值得说明。第一PT96 属于动态负载类型范围96~127。RFC 把 PCMU、PCMA 等固定负载类型分配在 0~95字符串不是标准媒体类型不能占用静态区域只能用 96 这类动态值真实场景里再通过 SDP 额外声明。如果只是自测96 就够了后面接真实音视频时要把 PT 改成对应编码的标准类型。第二时间戳步进 160 是按 8000Hz 采样率算出来的一个 20ms 音频帧的采样点数是 0.02 × 8000 160。字符串模拟流本来没有采样率概念但为了让对端看到时间戳稳定增长必须维持一个固定步进否则接收端会以为这是一路乱序包。第三1456 字节载荷上限是用 1500 标准 MTU 减去 20 字节 IP 头、8 字节 UDP 头、12 字节 RTP 头得出的结果。这不是 RFC 强制值超过之后 IP 层会分片公网上分片包很容易丢所以我一般控制在 1456 以内。有一个问题很多人问过为什么不用位域直接定义 RTP 头结构体因为位域在不同编译器下内存布局不保证一致而且 RFC3550 里字段是一个字节一个字节挤在一起的位域写法调试时可读性很差。数组方案把每个字节每个 bit 的归属写得清清楚楚翻车概率低很多。3.2 字符串载荷打包从 CString 到网络字节序在 MFC 对话框程序里字符串往往以 CString 形式从编辑框控件取出来。CString 在 VC6 默认字符集下是 char 类型多字节但在 Unicode 工程里就是 wchar_t 类型。如果要在两种配置下都能工作常见做法是用 CStringA 强制转换或者干脆把工程维持在多字节字符集状态。下面是例子// 从对话框编辑框控件取文本并转为普通 char 数组 CString strText; GetDlgItemText(IDC_EDIT_MSG, strText); // 统一转成单字节 char 缓冲区避免宽字符直接进 RTP 载荷 CStringA strA strText; const char* payload (const char*)strA;注意 RTP 载荷本身不要求字符串以\0结尾它就是一串二进制字节。如果发送端用 strlen 计算长度载荷里就不能出现嵌入式\0否则会被提前截断。反过来接收端拿到载荷后如果想按字符串打印一定要在载荷末尾补上结束符否则 printf 会越界读到下一个包的残留数据。这个“结束符只在接收端补不在发送端加”的原则是字符串模拟数据流最容易踩的细节之一。另外RTP 头部的时间戳和序列号以网络字节序传输。上面的代码用位移和 0xFF手工转换而不是 htonl是因为直接把整数值赋给rtpPacket[4]~[7]时大整数在内存里的排列方式在小端机器上恰好和网络序要求相反。手工位移的好处是不依赖平台字节序假设任何编译环境下结果一致。VC6 工程的 Debug 和 Release 都会得到相同结果也避开了一种“Debug 能跑 Release 乱码”的玄学问题。3.3 发送节奏控制时间戳与发送间隔字符串模拟数据流时很多人会把所有包一次性发完然后发现接收端收到的包乱序、时间戳全一样。RTP 的实时性体现在发送节奏上时间戳告诉接收端所有包之间的时间关系但真正让接收端等量的是发送端按固定时间间隔发包。常见做法是开一个辅助线程在 while 循环里 Sleep(20) 然后发一包相当于 50 包/秒的发送速率这正好匹配 8000Hz、每包 160 采样点的音频模型。// 发送线程每 20ms 发一个 RTP 包 void RtpSendThread(LPVOID param) { while (!g_bStopSend) { char buf[64]; sprintf(buf, rtp_send test message #%d, g_seq); SendRtpString(buf, g_seq, g_ts, g_ssrc); Sleep(20); // 20ms 间隔保持发送节奏 } }这里的 Sleep(20) 不是精确计时Windows 默认时钟精度一般是 15.6msSleep(20) 实际误差可能接近 ±15ms。如果对端对抖动敏感就得用 timeBeginPeriod(1) 提高时间片精度或者用 RDTSC 做忙等待。字符串模拟场景下20ms 的误差不影响功能验证抓包时能看出时间戳按 160 步进即可。4. 在 VC6 MFC 对话框工程里跑通整个发送流程4.1 工程文件结构从 .dsw 到 StdAfx.h 的职责划分把压缩包里的文件按职责拆开会发现这是一个非常标准的 VC6 对话框程序工程文件作用rtp_send.dsw / rtp_send.dspVC6 工作空间和项目文件双击 .dsw 打开工程rtp_send.h / rtp_send.cpp应用类定义与实现程序入口在这里rtp_sendDlg.h / rtp_sendDlg.cpp主对话框类界面逻辑和发送按钮事件在这里Resource.h / rtp_send.rc资源文件对话框布局、控件 ID 宏定义StdAfx.h / StdAfx.cppMFC 预编译头集中包含通用头文件res/MSN.ICO、rtp_send.ico程序图标资源ReadMe.txtVC6 自动生成的工程说明RCa02236资源编译器偶发的临时文件编译后可以忽略这里面最容易被忽略的是 StdAfx.h。VC6 的预编译头机制要求所有 .cpp 文件第一行写#include StdAfx.h如果新增的 .cpp 文件漏了这一行编译时会出现 C1010 这类莫名其妙的错误。不少人往这个工程里加网络发送模块时新建了 rtp_socket.cpp 却忘了包含 StdAfx.h导致 MFC 头文件里的 AFX 宏无法识别。解决办法不是去翻编译器设置而是让所有源文件统一从 StdAfx.h 开始。rtp_send.rc 是对话框资源文件定义界面上那几个控件的布局目标 IP 输入框、目标端口输入框、发送内容输入框、开始/停止按钮。Resource.h 里会定义对应的 IDC_ 宏。如果直接手工修改 .rc 文本风险很大因为 VC6 的资源格式有自己的一套语法最好通过 VC6 的资源编辑器去改而不是用文本编辑器。这是做界面功能扩展时最容易翻车的位置之一。4.2 对话框启动流程socket 初始化与线程拉起在 MFC 对话框程序里控制流程从 rtp_send.cpp 里的 CWinApp 派生类开始启动后进入对话框的 OnInitDialog 函数。网络初始化建议放在 OnInitDialog 里做确保界面加载完成后再开网络资源。常见做法如下BOOL CRtp_sendDlg::OnInitDialog() { CDialog::OnInitDialog(); // 1. 初始化 Winsock 库UDP 发送用 1.1 版本足够 WSADATA wsaData; WSAStartup(MAKEWORD(1, 1), wsaData); // 2. 创建 UDP socket g_sock socket(AF_INET, SOCK_DGRAM, 0); if (g_sock INVALID_SOCKET) { AfxMessageBox(socket 创建失败); return FALSE; } // 3. 填充目标地址结构体IP 和端口实际从编辑框读取 memset(g_destAddr, 0, sizeof(g_destAddr)); g_destAddr.sin_family AF_INET; g_destAddr.sin_port htons(5004); g_destAddr.sin_addr.s_addr inet_addr(127.0.0.1); return TRUE; // 返回 TRUE 表示由系统自动设置焦点 }这里有个细节UDP 发送端的 socket 不需要 bind直接 sendto 即可。很多人套用 TCP 服务端的习惯先 bind 再 send在 UDP 发送端属于多余操作。如果 bind 到 0 端口系统会自动分配随机端口作为源端口如果显式 bind 到固定端口比如 5004接收端回送 RTCP 包时就能对准这个端口。实际调试时我建议 bind 到本地固定端口这样 Wireshark 过滤规则可以写成udp.port 5004简洁且不容易混入其他流量。MAKEWORD(1,1) 表示请求 Winsock 1.1这是 VC6 时代最稳妥的选择。Winsock 2 功能更多但这个发送端场景用不到多播、QoS 等特性1.1 足矣。后续如果接 DirectShow 实时帧改成 MAKEWORD(2,2) 也可以API 层面完全兼容。4.3 发送线程与 RTCP 心跳的并行处理对话框的“开始发送”按钮点击事件里不能直接写 while 循环发包否则界面会卡死按钮也点不动。正确做法是用 AfxBeginThread 拉起工作线程发送线程只负责按节奏发 RTP 包RTCP 则通过周期计数或者独立线程处理。有一种常见误区是让 RTP 和 RTCP 共用同一线程在发完一组 RTP 包后立刻发一个 RTCP SR。这在功能上能跑但不符合协议习惯。标准做法是 RTP 包按媒体采样节奏发RTCP 按固定周期发RFC3550 建议 RTCP 间隔在 5 秒左右。字符串模拟场景不用严格做带宽分配但至少应该把 RTP 发送循环和 RTCP 生成逻辑分开。比如 RTP 线程里每发送 250 个包约 5 秒额外构造一次 SR// 发送线程内维护计数器每 250 包发一次 RTCP SR g_packetCount; if (g_packetCount % 250 0) { char rtcpPacket[28]; BuildRtcpSR(rtcpPacket, g_ts, g_packetCount); // RTP 用 5004RTCP 用相邻端口 5005 sockaddr_in rtcpAddr g_destAddr; rtcpAddr.sin_port htons(ntohs(g_destAddr.sin_port) 1); sendto(g_sock, rtcpPacket, 28, 0, (sockaddr*)rtcpAddr, sizeof(rtcpAddr)); }RTCP SR 的第一个字节是 0x80第二个字节是 0xC9PT201后面跟着长度、SSRC、NTP 时间戳、RTP 时间戳、包计数、字节计数。28 字节的包长就是 4 字节头加 24 字节 SR 内容。对端即使不解析 SRWireshark 也能把它识别成 RTCP Sender Report这就是协议栈正确的直接证据。BuildRtcpSR 的具体实现参照 3.1 的头部填充方式把 PT 字段改成 201 即可。这里有一个值得固化的经验排查发送链路时如果看到 RTP 包正常、RTCP 包也有但接收端仍然不播放不显示问题大概率不在发送端而在接收端的 SDP 协商或者载荷解析。所以调试顺序永远是先抓包看 RTP 是否在发再看 RTCP 是否周期到达最后才去怀疑接收端代码。字符串模拟的好处就在这里——没有编解码器和播放器干扰判断链路通没通抓包一把梭就知道。5. 避坑排查VC6 RTP 发送实践里最容易翻车的五个细节5.1 编译期运行时库与 MFC 静态链接冲突现象打开 rtp_send.dsw 后直接编译链接阶段报错错误信息里出现 nafxcw.lib 和 libc.lib 冲突或者 LNK2005 重复定义。原因VC6 里 MFC 静态链接库默认使用多线程 DLL 版运行时库而工程设置可能选了单线程运行时库 /ML。MFC 静态库和 C 运行时库混用时两者都包含了 malloc、free 等符号的实现链接器不知道该用哪一份。解决打开 Project - Settings - C/C 选项卡在 Code Generation 里把 Use run-time library 改成 Multithreaded DLL/MD同时在 General 选项卡里把 Microsoft Foundation Classes 选成 Use MFC in a Static Library两者统一成 /MD。这组配置我在每个 VC6 工程里都要检查一遍。用 /FORCE:MULTIPLE 强行链接是治标不治本不推荐。5.2 环境期.dsw 工程在 Win10/11 上的打开姿势现象双击 rtp_send.dsw 后VC6 要么打不开要么闪烁一下崩溃要么打开后菜单乱码。原因VC6 是 2000 年前后的编译器对高版本 Windows 的兼容性不佳另外如果机器上装了多个 Visual Studio 版本.dsw 文件关联可能被新版 VS 抢走。解决最可靠的方式是在虚拟机里装 Windows XP 来编译这个工程这是当年 VC6 的官方支持环境。次选是右键 vc6.exe 属性勾选“以兼容模式运行 Windows XP SP3”和“以管理员身份运行”。还有一种做法是不打开 IDE直接命令行msdev rtp_send.dsp /MAKE编译。如果只读代码用 VS Code 直接打开目录看 .cpp/.h 文件也可以不必非得编译。这份工程的价值在于代码逻辑不在于能否在 Win11 上点出界面。5.3 抓包期Wireshark 看不到 RTP 包的三种原因现象程序显示已经发送但 Wireshark 抓不到 RTP 包或者抓到了 UDP 包但协议列显示为 Data而不是 RTP。原因一是过滤规则写错。Wireshark 默认只能识别 0~95 的静态负载类型PT96 的动态负载类型在没有 SDP 协商时不会被自动识别为 RTP所以协议列显示 UDP 或 Data。二是发包目标地址是公网 IP 而本机出口走 NAT抓包要在发送端本机抓才能看到。三是 socket 创建失败sendto 返回 SOCKET_ERROR但界面上没有提示。解决抓包前先检查 socket 返回值并在每次 sendto 后判断返回值是否等于 packetLen。Wireshark 里用udp.port 5004作为过滤条件不要直接用 rtp 协议过滤器想强制识别成 RTP可以在 Decode As 里把 UDP 端口按 RTP 解码。这个坑非常典型90% 的“发出去但抓不到 RTP”案例其实是过滤器问题不是程序问题。5.4 编码期中文载荷的乱码问题现象发送端对话框输入中文接收端收到的字符串变成乱码或者发送端拷贝到缓冲区后 strlen 长度比预期大一倍。原因VC6 工程默认是 MBCS 多字节字符集CString 内部是 char中文字符在 GBK 编码里占 2 字节如果工程被切换成 Unicode 字符集CString 内部就是 wchar_t每个字符占 2 字节直接 memcpy 后长度和内容都对不上。网络传输不会自动做编码转换发送端用 GBK 发、接收端用 UTF-8 解析必乱码。解决发送前统一把字符串转成 UTF-8 或 GBK 单字节序列最简单是使用 CStringA 强制转换接收端用同样的编码解析。测试阶段可以全部用 ASCII 字符串比如固定格式RTP_MSG_XXX先把链路调通再处理编码。我个人的习惯是载荷字段里永不直接放 CString 的内部 buffer而是先转成 char 数组再复制避免字符集切换导致的隐性 bug。5.5 运行期界面卡死与线程退出残留现象点击“开始发送”后窗口失去响应按钮弹不出来或者关闭窗口时程序不退出任务管理器里进程还在或者再次点击“开始发送”时报 socket 已存在的错误。原因UI 线程里直接执行了 while 循环发送关闭窗口时没有通知发送线程退出线程还在 Sleep/发送循环里socket 没有关闭Winsock 资源没有释放。这三个问题本质上是同一个根因线程生命周期管理没做好。解决发送循环必须放在工作线程里用 AfxBeginThread 创建线程退出要用一个 volatile BOOL 标志g_bStopSend关闭窗口时置 TRUE再 WaitForSingleObject 等待线程结束最后关闭 socket调用 WSACleanup。另一个稳健做法是把 socket 设为对话框类成员而不是全局变量这样每次按钮点击都重新创建避免二次初始化冲突。6. 用 Wireshark 和回环脚本给发送端做“体检”给发送端做验证别直接上公网测试。最稳妥的顺序是先回环、再局域网、最后才考虑跨网段。回环测试能让发送端把包发到 127.0.0.1用一个 30 行的 Python 脚本收包验证三个问题包发出来没有、序列号连续吗、载荷是不是预期字符串。import socket s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind((127.0.0.1, 5004)) s.settimeout(10) seq_last None while True: data, addr s.recvfrom(2048) if len(data) 12: continue seq (data[2] 8) | data[3] ts int.from_bytes(data[4:8], big) payload data[12:] if seq_last is not None and seq ! (seq_last 1) 0xFFFF: print(fseq jump: {seq_last} - {seq}) seq_last seq print(fseq{seq} ts{ts} payload{payload[:50]})输出里 seq 应该是 0、1、2 这样连续递增ts 按 160 步进递增payload 前缀应该是发送的字符串内容。如果 seq 跳变说明发送线程重进了或者有别的进程在往这个端口发包如果 ts 不递增说明发送端的步进逻辑没生效如果 payload 前 50 字节里看不到预期字符串检查字符集转换和载荷长度。回环测试通过后再把目标 IP 改成局域网地址用对端机器跑同样的收包脚本就能确认跨主机链路。这套流程跑通之后再谈接 DirectShow 采集、换真实媒体数据才有意义。从那以后我每次拿到新的 RTP 发送端代码都会强制走一遍先看 RTP 头字段有没有按位填充再抓包看序列号和事件戳的递增规律最后才考虑接真实数据。这个顺序帮我避开了很多“明明发了却没人收到”的半夜排查希望帮到你。本文还有配套的精品资源点击获取