简介:RTP.NET是一套基于.NET框架实现的RTP(实时传输协议)封装库,面向需要构建VoIP、视频会议、流媒体服务等实时通信应用的.NET开发者。库中提供RTPSession会话管理、RTPParticipant参与者元数据、数据包化、负载类型识别、事件驱动API及多播支持等核心能力,配合RTCP的丢包检测与恢复机制,可帮助开发者快速搭建RTP收发链路并理解实时数据传输的同步原理。压缩包共26个文件,约300KB,以cs源码、dll库、exe可执行示例、chm帮助文档、sln工程文件等为主,另含少量调试缓存与构建辅助文件,结构清晰,便于直接打开工程或查阅帮助文档上手。目前已有104人参与学习,适合具备一定C#基础并希望深入理解RTP协议实现与实时传输细节的开发者。
1. 用 RTP.NET 接实时音视频:为什么还要单独写一篇拆包笔记
做 VoIP 或实时视频推流时,最耗时间的往往不是业务逻辑,而是 RTP 协议本身的封装和解封:序列号、时间戳、SSRC 的维护,RTCP 的收发同步,每一样都有人踩坑。RTP.NET 就是把这一整套收进 .NET 类库的老牌实现,RTPSession、RTPParticipant、Packetization、负载类型识别、事件回调都有现成接口。适合正用 .NET Framework 做音视频采集、传输、接收端还原的人:新手不用从 RFC 3550 逐条啃起,熟手可以拿它做原型验证,再替换成自研栈。下面把协议分层、接入步骤、部署里的真实坑一一说清,照着代码能跑通第一个会话。
2. 先懂 RTP 和 RTCP 的分工:序列号、时间戳、SSRC 分别管什么
2.1 RTP 头部三个关键字段,不是随便填的数字
很多第一次接触 RTP 的人会拿它和 TCP 做对比,结论往往是“这协议怎么这么简单”。RTP 确实只负责把媒体数据装进报文,剩下的可靠性工作都丢给应用层。但这不代表头部字段是摆设,尤其是三个最核心的字段。
sequence number(序列号)是发送端每发一个包就递增一次的计数器,接收端靠它发现丢包和乱序。注意,乱序到达的包要用缓存按序列号重排,而不是按到达顺序播放。timestamp(时间戳)表示媒体数据的采样时刻,不是系统当前时间。比如 8kHz 采样的 PCM 音频,每帧 20ms,时间戳步长就是 160。SSRC(同步源标识符)标识一路媒体流的来源,多路流混在一起时靠它区分你正在处理的是谁的声音。
这三个字段是接收端做抖动缓冲、音画同步、丢包统计的地基。如果应用直接拿原始 UDP 传音视频,这三套状态全要自己维护,稍微一乱就是断音、花屏、对不上口型。RTP.NET 的价值就在这里:它在 Packetization 阶段自动填充序列号和 SSRC,时间戳按你在会话里配置的采样率计算。你要操心的,只有“什么时候发、发什么数据”。
2.2 RTP 不管可靠传输:丢包由接收端发现,而不是等待重传
RTP 不保证可靠,也不靠服务器通知丢包。发送端发出去就完事,接收端根据序列号的间隙判断丢了哪些包。这个设计对实时媒体是对的:语音和视频对延迟敏感,按 RFC 3550 的语义,RTP 宁可丢掉迟到太久的包,也不停下来等重传。音频晚到一两百毫秒,播出来已经是刺耳的噪音。
如果你的应用要求一个字节都不丢,那 RTP 不合适,应当走 TCP 通道或者自建确认重传机制。RTP.NET 的封装也遵守这个边界:它提供丢包检测的线索,比如序列号和 RTCP 报告里的统计,但不会自己去“要回”丢掉的包。想清楚这一条,后续排查问题时能少绕很多弯路。
2.3 RTCP 是 RTP 的搭班:端口配对与质量反馈
RTP 通常和 RTCP 一起出现。标准端口规划是 RTP 占用偶数端口,RTCP 占用紧随其后的奇数端口。RTP 在 5004,RTCP 就在 5005。RTCP 周期性地交换发送方报告(SR)和接收方报告(RR),里面携带累计丢包数、往返时延、抖动等统计量,应用层据此调整缓冲策略。
SR 报告里还带着一个 NTP 时间戳和对应的 RTP 时间戳,这一对映射关系是音画同步的关键。接收端把音频流和视频流各自的 RTP 时间戳对齐到同一个 NTP 参考时钟上,才能知道“这一帧声音该配哪一帧画面”。在调试双流同步时,我会先看 RTCP 报告里的这两个时间戳是否在同一参考系下,再去看应用层逻辑。
RTP.NET 会对 RTCP 报文做处理并把统计信息暴露给上层。我一般拿丢包率做接收缓冲的动态调整:丢包超过 3% 就把 jitter buffer 调大,网络质量好时再调小。核心认识是,RTCP 负责告诉你“网络怎么样”,RTP 包本身只负责“数据怎么还原成媒体”。两个通道各司其职,测试时只盯其中一个都会漏掉问题。
2.4 RTP.NET 的对象模型:RTPSession、RTPParticipant、PayloadType 怎么对应
把协议概念映射到库的对象模型,能立刻降低学习成本。RTPSession 是会话容器,表示一路媒体通道,创建时绑定 RTP 端口;RTPParticipant 表示对端实体,携带源地址、SSRC 和同步信息;PayloadType 负责识别负载格式,区分音频、视频以及具体编码。
| RTP 概念 | RTP.NET 对象 | 主要用途 |
|---|---|---|
| 媒体流会话 | RTPSession | 创建/关闭会话,收发媒体包 |
| 对端实体与同步 | RTPParticipant | 记录参与者 SSRC、地址,管理接收方 |
| 负载格式标识 | PayloadType | 识别音频/视频编码,决定后续解析路径 |
| 数据拆分/还原 | Packetization | 把一帧数据拆成 RTP 包,或把包还原成负载 |
实际开发里,一路音频对应一个 RTPSession,一路视频再对应一个 RTPSession,音视频通话通常是两个会话并存。每个会话可以注册多个参与者,多对多场景下这个模型最顺手。理解了这个分层,再打开 API 文档就不是一堆零散类,而是几个固定插槽:会话、参与者、负载类型,各归其位。
3. 把 RTP.NET 接进 .NET 工程:从解包到第一个会话跑通
3.1 压缩包里哪些文件要留、哪些是历史包袱
拿到 RTP.NET.rar 解压后,第一眼会看到一堆文件:RTP.NET.dll、RTP.NET.HELP.chm、RtpNetCsharp.csproj、RtpNetCsharp.sln、Program.cs、bin 目录、obj 目录、UpgradeLog.XML、_UpgradeReport_Files 等。运行时要用的只有两个:RTP.NET.dll 是类库主程序集,RTP.NET.HELP.chm 是 API 文档。查 RTPSession 的构造签名、Send 方法的重载,这个 chm 比任何二手教程都可靠。
bin 和 obj 是编译输出与历史缓存,RtpNetCsharp.suo 是 Visual Studio 的用户选项文件,记录窗口布局和断点位置,换机器后基本没用。UpgradeLog.XML 和 _UpgradeReport_Files 是旧工程升级向导留下的施工日志,不是给你读的文档。我的习惯是新开一个类库工程,只引用 RTP.NET.dll,不沿用原来的 .sln。
这里还要提醒一个搜索时的常见误会:搜“rtp”时经常混进 RPG Maker 的 RTP 运行库,那个 RTP 是 Runtime Package,用来跑游戏素材的,和网络传输完全两码事。下载前先看包里有没有 DLL 和 chm,就能分辨清楚。
这个库诞生时间较早,看文件结构就能猜出它大概率面向 .NET Framework。如果你的新工程是 .NET 6 或 .NET 8,直接引用可能加载失败。常见做法是单独保留一个 .NET Framework 的进程来跑 RTP 会话,用进程间通信把媒体帧转发给新框架的业务层;或者干脆只在老框架工程里用它做媒体收发。
3.2 创建 RTPSession:先配好后端端口、远端地址和负载类型
用库的第一步是创建会话实例。代码不长,但每个参数背后都有对应关系:
using RTP; using System.Net; // 本地 RTP 监听端口用偶数,RTCP 会自动占用下一个奇数端口 IPEndPoint localEP = new IPEndPoint(IPAddress.Any, 5004); // 远端地址:对端主机的 IP 和它的 RTP 监听端口 IPEndPoint remoteEP = new IPEndPoint(IPAddress.Parse("192.168.1.100"), 5004); // 创建会话,绑定本地端口 RTPSession session = new RTPSession(localEP); // 负载类型用 PCMU(Payload Type 0),8kHz 采样 session.PayloadType = 0; session.SamplingRate = 8000; // 注册对端参与者,之后 Send 的数据会发给 remoteEP session.AddParticipant(remoteEP);这段代码做了三件事:绑定本机 UDP 端口 5004;声明负载类型为 PCMU,库在封包时把 payload type 字段写成 0;注册远端参与者,后续发送时数据会发往 remoteEP。三件事缺一不可。
端口选择有个硬规则:RTP 用偶数端口,RTCP 自动占用下一个奇数端口。本地用 5004,意味着 5005 必须空闲。如果错配成奇数端口,RTCP 会跟别的程序冲突,或者对端上报的统计根本收不到。IP 地址也要分清楚:IPAddress.Any监听所有本机网卡,适合接收端;单机自测用IPAddress.Loopback,数据只在本地回环绕一圈。把 Any 和 Loopback 混用,最典型的症状是收不到任何包。
提示:跨机联调前,先把两端端口规划和负载类型写进开发文档。端口可以不一致,但每端自己的 RTP/RTCP 端口对必须符合“RTP 偶数 + RTCP 奇数”的规则。
3.3 发送路径:从 PCM 帧到 RTP 包的封装过程
会话准备好之后,发送一帧音频代码很简洁:
// 假设采集到 20ms 一帧的 PCM 数据,8kHz * 20ms = 160 字节 byte[] pcmFrame = CaptureMicrophone(160); // 数据包化并发送,第三个参数是 marker bit,连续音频传 false session.Send(pcmFrame, pcmFrame.Length, false);Send内部完成 RTP 头填充、序列号递增、时间戳累积和 UDP 发送。第三个参数是 RTP 的 marker bit,主要用来标记视频帧边界或语音突发开始。连续音频流通常传 false;视频关键帧后的第一个包,按惯例传 true,方便接收端识别帧起始位置。
实际控制发送节奏的,是“采集一帧就立刻 Send 一帧”这个循环。PCM 音频按 20ms 一帧采集,发送循环就应当恰好每 20ms 发一次,而不是在一个循环里猛发几十帧。库不管发送节流,你把 100 帧一口气发出去,对端接收缓冲会瞬间被打穿,丢包率直线上升。
3.4 接收路径:事件回调取包,包还原不要在回调里做
接收是事件驱动模型。库在底层线程收到 UDP 包后完成解包,然后触发事件:
// 注册数据接收回调 session.PacketReceived += OnPacketReceived; private void OnPacketReceived(object sender, RtpPacketEventArgs e) { // 事件参数里取出已解包的 RTP 包负载 byte[] payload = e.GetPacket().Payload; // 入队后立刻返回,别在回调里做播放或文件写入 PlaybackQueue.Enqueue(payload); }回调拿到的已经是解包后的负载,不用自己处理 RTP 头。有一点必须强调:回调运行在库的工作线程上,如果你在里面做声卡播放、文件写入、界面刷新,任何阻塞都会拖慢后续包的处理,结果就是丢包、事件堆积、音频断裂。正确做法是把 payload 放进并发队列,由独立播放线程消费。队列可以短,但阻塞绝不能延伸进回调线程。
3.5 参数配置搞明白后,附一张速查表
联调时我习惯照着这张表逐项核对:
| 参数 | 常用值 | 说明 |
|---|---|---|
| RTP 端口 | 5004(偶数) | RTCP 自动占用 5005 |
| 负载类型 | 0(PCMU)、8(PCMA)、96+ | 动态类型需两端约定一致 |
| 采样率 | 8000 / 48000 | 决定时间戳递增步长 |
| 会话超时 | 3-10 秒 | RTCP 长时间无报告视为断线 |
最容易出错的是负载类型两端不一致:本端用 PCMU(0),对端按 PCMA(8)解析,音频解出来全是噪声,而且不报任何错误。所有“看起来正常但声音不对”的问题,九成出在这一项。
4. 实战一个最小音频推流程序:线程模型、发送节奏与多播边界
4.1 完整骨架:采集、回调、播放三条线的划分
把第 3 章的散点代码整合起来,就能看到一个最小推流程序的结构:
using System; using System.Collections.Concurrent; using System.Threading; using RTP; using System.Net; class RtpDemo { static ConcurrentQueue<byte[]> recvQueue = new ConcurrentQueue<byte[]>(); static void Main(string[] args) { // 单机自测:收发都走 Loopback,两个会话端口不同 IPEndPoint local = new IPEndPoint(IPAddress.Loopback, 5004); IPEndPoint remote = new IPEndPoint(IPAddress.Loopback, 5006); using (RTPSession session = new RTPSession(local)) { session.PayloadType = 0; session.SamplingRate = 8000; // 收包入队,播放线程消费 session.PacketReceived += (s, e) => recvQueue.Enqueue(e.GetPacket().Payload); session.AddParticipant(remote); // 发送线程:每 20ms 发一帧 160 字节 PCM Thread sender = new Thread(() => { byte[] frame = new byte[160]; while (true) { FillAudioFrame(frame); // 这里接真实采集 session.Send(frame, frame.Length, false); Thread.Sleep(20); } }); sender.IsBackground = true; sender.Start(); // 接收播放:消费队列,不阻塞回调 while (true) { if (recvQueue.TryDequeue(out byte[] frame)) { PlayFrame(frame); // 投递到声卡 } Thread.Sleep(5); } } } }这个骨架把程序分成三条线:发送线程负责采集和发送;库的工作线程负责收包并触发回调;主线程消费接收队列做播放。三条线用并发队列解耦,回调只入队,不做任何 IO。用IPEndPoint分别在 5004 和 5006 起两个会话,数据在本地回环绕一圈,就能验证发送到接收的完整链路,不需要第二台设备。
4.2 发送节奏控制:Thread.Sleep(20) 只是最小可用方案
严格讲,Thread.Sleep(20)在 Windows 上并不精确,受系统时钟粒度和线程调度影响,常见偏差在 5-15ms 之间。对 RTP 来说,发送节奏不稳直接体现在对端收到的时间戳间隔上:一会儿间隔 160 采样点,一会儿 190,接收端 jitter buffer 就得不停调整,听感就是忽快忽慢。
真正要控节奏,建议用音频采集 API 的回调来驱动发送。采集设备每凑满 160 字节触发一次回调,在回调里调用 Send,时间戳步长就绑定在硬件时钟上,比 Sleep 准一个量级。实在拿不到硬件回调,也可以用 Stopwatch 做精细延时,Sleep 一个近似值后再忙等补齐偏差。总之,别把Thread.Sleep当精确时钟用。
这套模型还有个隐患:接收队列长期没人消费,内存会涨。真实应用里要在消费循环做背压判断,队列超过 200 帧就丢弃最老的音频帧,而不是堆着等播放。音频播放能容忍丢一帧短时噪声,但延迟不能拖到听感明显的地步。
4.3 多播场景:一对多分发与局域网边界
RTP 原生支持多播,RTP.NET 的参与者机制也允许把多播地址注册成 Participant。一对一场景用不到多播,但当接收端数量变大,比如局域网直播分发、多房间监听,多播的优势很明显:一份流在网络设备层面复制给所有组成员,发送端带宽压力远小于对每个接收端逐个单播。
把AddParticipant的参数换成 239.x.x.x 的组播地址即可。前提是网络设备要配合:交换机和路由器必须开启 IGMP 监听,否则多播报文会在二层被当广播泛洪,所有主机都能收到但谁也不处理,反而拖垮网络。云服务器基本不提供多播能力,所以多播方案严格限定在局域网或自建的组播网络中。跨公网做多人分发,还是单播加 RTCP 反馈的老路更稳妥。
不管单播还是多播,有一件事贯穿始终:对端必须监听同一个端口和负载类型,否则包发出去没人接,或者接了也解不出正确音频。联调时我习惯先在 Wireshark 里确认,是否有入站 RTP 包真正落在预期端口上,再谈上层逻辑。
5. RTP.NET 避坑与排查:五个真实翻车记录
5.1 程序集加载失败:AnyCPU 和旧目标框架的恩怨
现象:新工程引用 RTP.NET.dll 后编译通过,运行到 new RTPSession 就抛 FileNotFoundException 或 BadImageFormatException。
原因:这个库年代较早,大概率按 x86 编译,而新工程默认 AnyCPU,在 64 位进程中加载 32 位汇编直接翻车。另一种情况是工程目标框架版本高于库能支持的最高 CLR 版本,库找不到匹配的运行时。
解决:项目属性“生成”标签里把平台目标改成 x86,重新编译。如果还报错,把目标框架降到 .NET Framework 版本,重编一次。这两个操作要一起做,分开排查容易来回折腾。
5.2 能发不能收:防火墙和 RTP/RTCP 端口规则
现象:程序启动后发送正常,对端能看到报文,但本机收不到任何入站媒体。或者单机 Loopback 能收,换成局域网 IP 就收不到。
原因:Windows 防火墙默认拦截 UDP 入站,5004/5005 这种非系统服务端口尤其严格。Loopback 流量不受防火墙影响,所以自测没问题,跨机就掉链子。
解决:在“高级安全 Windows Defender 防火墙”里添加入站规则,协议选 UDP,端口范围写 5004-5005,作用域限定本地子网。如果公司网络有统一安全策略,这一步要找网络管理员配合放行。
注意:调试 RTP 时先把防火墙规则按端口放行,再抓包定位。否则 Wireshark 里只能看到发出去的包,收不到回包,容易误判成对端问题。
5.3 声音变成金属噪音:负载类型和采样率两端不一致
现象:两端会话都能建立,RTP 包也在跑,对端播放出来全是刺耳噪声,没有任何可辨识的人声。
原因:负载类型不一致,本端配 PCMU(0),对端按 PCMA(8)解析。A-law 和 µ-law 的编码完全互不兼容。另一个常见原因是采样率不匹配,发送端 8kHz,对端按 44.1kHz 解码。
解决:把 PayloadType 和 SamplingRate 写进双方配置约定,建立会话前先在日志里打印这两个值,两边核对。最快的调试方法是用一段固定录音文件循环发送,抓包确认 payload type 字段的实际数值。
5.4 长时间运行越来越卡:事件订阅泄漏和 Participant 残留
现象:程序刚启动时收发正常,跑一两个小时之后延迟变大、丢包率上升、内存占用逐步走高。
原因:RTPSession 没有释放,事件订阅只增不减。每次对端重连或换地址都会新增 Participant,旧对象没人移除,会话里的参与者列表越攒越长。
解决:把 session 放进 using 块,确保不再使用时调用 Dispose;对端退出时主动 RemoveParticipant;事件回调如果挂在长生命周期对象上,结束时显式取消订阅。事件泄漏是 C# 里最容易忽略的内存问题,RTP 这种长连接场景尤其明显。
5.5 老工程打不开:别跟 .sln 死磕,重建工程最快
现象:打开 RtpNetCsharp.sln 后,Visual Studio 提示项目格式不支持,或者编译抛出一堆迁移错误。_UpgradeReport_Files 里挂着一大串警告,不知道怎么下手。
原因:工程文件经历了多次 VS 版本变更,引用路径和项目格式都旧了,硬迁移成本比重建还高。
解决:新建一个类库项目,手动添加 RTP.NET.dll 引用,把需要的源码文件复制过去重新编译,全程不超过十分钟。UpgradeLog.XML 和 _UpgradeReport_Files 只是升级向导的施工日志,不是给开发者看的说明文档,不用花时间研究。
6. 上真实网络前先验证:抓包核对时间戳步长与丢包
验证手段比想象的简单,不必只看业务日志。Wireshark 一抓就能看出问题出在哪一层。发送端采集一帧、Send 一次,打开 Wireshark,显示过滤器输入:
udp.port == 5004 && rtp这时能看到每个 RTP 包的头部字段,按列排序后重点看三项:序列号是否逐包加一,时间戳是否有固定步长,SSRC 是否保持不变。
| 检查项 | 正常形态 | 异常含义 |
|---|---|---|
| 序列号 | 相邻差值恒为 1 | 差值大于 1 说明中间有丢包 |
| 时间戳 | 步长固定,如 160 | 步长抖动说明发送端采样节奏不稳 |
| SSRC | 同一路流内不变 | 值变化通常代表会话被重建 |
时间戳步长是最容易被忽略的一项。发送端用了Thread.Sleep(20)这种粗糙节奏时,Wireshark 的 time delta 会有明显波动,步长波动会直接影响接收端 jitter buffer 的收敛效果。看到步长乱跳,先回采集端修节奏,而不是去调接收端参数。
另一个我常用的快速自测是:同一台机器上开两个 RTPSession,一个监听 5004、一个监听 5006,互相对发固定测试帧。在回调里统计收到的包数和序列号跳变次数,跑两分钟,数字对得上,协议层就是通的。再把 IP 从 Loopback 换成局域网地址重复一遍,网络层有没有问题也清楚了。
从那以后,每次接到 RTP 相关的任务,我都强制先跑一遍这个双会话自测,抓包确认时间戳步长稳定,再上真实设备联调。这套流程帮我避开了不少“看起来在推流、实际全是废包”的尴尬,希望帮到你。
本文还有配套的精品资源,点击获取