C# UDP组播屏幕广播实战:从Socket到JPEG帧流
2026/9/14 3:24:08 网站建设 项目流程

简介:这份压缩包围绕局域网屏幕广播与多播技术,提供一套完整的Windows窗体应用程序源码及编译产物,面向网络编程学习者、软件开发人员,以及需要远程教学、会议演示等场景的技术人员。包内以C#工程为主,共64个文件,涵盖cs源码、exe可执行程序、pdb调试符号、resources资源配置以及Visual Studio工程与解决方案文件,整体仅701KB,小巧而完整。已有84人学习浏览。通过源码可深入理解IGMP多播组管理、UDP数据报发送、屏幕图像抓取与压缩编码、收发端同步等核心机制;直接运行exe可直观体验一对多屏幕实时广播效果。工程内含发送端与接收端分离模块,便于追踪数据流与调试排错,txt说明还可辅助快速上手。对计划自研局域网屏幕分发工具、或希望掌握多播编程要点的读者,这份代码是相当实用的参考起点。

1. 三十台机器同时看一块屏:先看多播怎么把带宽账抹平

培训室 40 台机器同时看讲师屏幕,如果每台机器都单独收一份画面,讲师的网卡和上行交换机先扛不住。这个压缩包里解出来的 C# WinForms 工程(发送端 SocketSend、接收端 SocketRecieve)解决的就是这个问题:把屏幕截成 JPEG 帧,通过 UDP 组播地址一次性丢给整个组,由交换机和路由器把数据报复制到每个成员端口,发送端只发一份。

它的实现路径很直接:发送端 Send.cs 负责抓屏、压缩、SendTo,接收端 recieve.cs 负责 UdpClient(port)、JoinMulticastGroup、BeginReceive 后把 Bitmap 贴到 PictureBox。整个链路踩到的点恰好把单播多播广播的区别、UDP 丢包、GDI+ 线程安全这些基础问题串了起来。适合两类人:一是需要搭局域网屏幕广播/教学广播原型的开发,二是想从 Socket 层面理解组播参数怎么影响画面质量的网络工程师。

2. 组播还是单播:屏幕广播场景下的传输选型

屏幕广播这类一对多、实时性要求高、允许偶发丢帧的业务,传输层的选型几乎决定了整个程序的架构走向。拆开这个项目之前,值得先算一笔带宽账,再决定用单播、广播还是组播。

2.1 三种传输模式的带宽账本

单播(Unicast)是一对一发数据,N 个接收端就要从发送端复制 N 份数据。广播(Broadcast)是一份数据发给同网段所有主机,接收方不管感不感兴趣,网卡都得把包收上来再判断要不要丢弃。多播(Multicast)介于两者之间,发送端只发一份,网络设备根据组成员关系把数据复制到有接收者的端口。

传输模式发送端流量无关主机影响接收端是否主动加入典型场景
单播 TCP/UDP每接收端一份,O(N)需要知道发送端 IP远程协助、点对点传文件
广播一份全部主机网卡都要处理不需要ARP、DHCP 发现
组播一份仅组成员收到数据需要 Join 组地址屏幕广播、IPTV、行情分发

算一笔实际账:1920×1080 屏幕截成 JPEG,质量 72 时单帧约 60KB,按 10fps 传输,单播给 30 个接收端就是 60KB × 10 × 30 ≈ 17.6MB/s,约 141Mbps,百兆交换机直接饱和。换成组播,发送端只产生 60KB × 10 ≈ 4.8Mbps 的流量,瓶颈变成接收端各自消化一帧解码的开销。这就是项目选择组播的核心动机。

2.2 组播地址与 IGMP 的加入动作

组播在 IPv4 里对应 D 类地址,范围 224.0.0.0~239.255.255.255。其中 239.0.0.0~239.255.255.255 是本地管理范围(Administratively Scoped),类似私网 IP,适合在局域网里随意分配,这个项目里发送端和接收端约定的组播地址通常会落在这一段。

接收端要收到组播数据,必须向所在网段发出 IGMP Membership Report,告诉交换机“我要加入 239.x.x.x 这个组”。支持 IGMP Snooping 的交换机会记录这个端口需要对应该组,后续组播数据只往这个端口转发;不支持的话,组播流量会退化成广播行为。Windows 上UdpClient.JoinMulticastGroup()封装的就是这个加入动作,同时把组的 TTL、本机网卡绑定等细节一并处理。

2.3 UDP 的选型理由与 UdpClient 关键参数

为什么不走 TCP?屏幕广播一个关键特性是“帧有时候是坏的,但画面不能停下来等”。TCP 有重传和拥塞控制,某一帧丢了,发送端要等接收端 ACK 超时后重传,画面撕裂 200ms 以上,在演示场景里比丢掉一帧更难受。UDP 丢包就丢包,下一帧继续来,对实时画面来说这是合理的取舍。

这个项目用的是 .NET 的 UdpClient,核心参数集中在发送端的 Socket 配置上:

using System.Net; using System.Net.Sockets; // 发送端初始化,对应项目里 Send.cs 的 Start 方法 var udp = new UdpClient { Ttl = 32, // 组播包允许跨越的路由跳数 MulticastLoopback = true // 发送端自己也接收一份,用于本机预览 }; IPAddress group = IPAddress.Parse("239.255.10.10"); // 本地管理组播地址 udp.JoinMulticastGroup(group); // 加入组,之后 Send 的目标端口由 endpoint 指定 var endpoint = new IPEndPoint(group, 5000); byte[] jpeg = JpegEncode(CaptureScreen(), 72); udp.Send(jpeg, jpeg.Length, endpoint);

需要注意,JoinMulticastGroup对发送端是可选的;如果发送端不 Join,组播数据仍然可以正常发出去,只是发送端自己的网卡不会把这份数据回环给本机。对演示程序来说,发送端往往也希望看到当前广播的画面,所以保留这个调用更合理。

关键参数的作用:

UdpClient 属性影响本项目建议值
Ttl限制组播包跨路由器的范围32,局域网内够用
MulticastLoopback是否回环到本机 Sockettrue,便于自测
Client.ReceiveBufferSize接收端内核缓冲,防止解码慢时丢包4MB
ExclusiveAddressUse同一台机器多接收端共存默认false

2.4 端口绑定与多接收端的坑

接收端代码里new UdpClient(port)隐含了一次Bind0.0.0.0:port。在同一台机器上开两个接收端实例,第二个实例会抛SocketException地址被占用。这是 Windows 默认行为,因为没设SO_REUSEADDR。调试屏幕广播原型时,如果在一台机器上同时跑多个接收窗口验证效果,需要让每个接收端绑定不同端口,或者设置client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)。项目里 Form1 只承载一个接收画面,默认不处理这个场景,但改成多画面监看时第一个遇到的就是它。

3. 发送端 Send.cs 实战:抓屏、JPEG 压缩与发送频率控制

发送端的核心逻辑可以拆成三步:抓屏、压缩、发送。每一步都有对应的边界条件需要处理,先看抓屏。

3.1 虚拟屏幕抓取与 CopyFromScreen 的陷阱

CopyFromScreen是 GDI+ 提供的屏幕捕获入口,它支持两个参数:源坐标和目的矩形。项目的picture.cs里封装了屏幕快照逻辑,最简单的版本是抓主屏幕:

using System.Drawing; using System.Windows.Forms; private static Bitmap CaptureVirtualScreen() { // 多显示器场景:取所有屏幕并集作为虚拟桌面 int left = int.MaxValue, top = int.MaxValue; int right = int.MinValue, bottom = int.MinValue; foreach (Screen screen in Screen.AllScreens) { left = Math.Min(left, screen.Bounds.Left); top = Math.Min(top, screen.Bounds.Top); right = Math.Max(right, screen.Bounds.Right); bottom = Math.Max(bottom, screen.Bounds.Bottom); } int width = right - left; int height = bottom - top; var bmp = new Bitmap(width, height, PixelFormat.Format24bppRgb); using (Graphics g = Graphics.FromImage(bmp)) { // CaptureBlt 标志用于抓取被其他窗口遮挡区域 g.CopyFromScreen(left, top, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy | CopyPixelOperation.CaptureBlt); } return bmp; }

这里最容易踩坑的是 DPI 缩放。Windows 上显示缩放设置为 125% 或 150% 时,Screen.Bounds返回的是逻辑像素,而CopyFromScreen实际拷贝的是物理像素。如果不做SetProcessDPIAware(),抓出来的图会偏移,画面右侧和下侧出现黑边。在 Program.cs 入口处调用SetProcessDPIAware()可以规避:

using System.Runtime.InteropServices; [DllImport("user32.dll")] private static extern bool SetProcessDPIAware(); static void Main() { SetProcessDPIAware(); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); // 启动 Form1 }

3.2 JPEG 编码参数:质量、编码器与单包尺寸

抓到的 Bitmap 是原始的 RGB 数据,1920×1080 全屏一帧就占 6MB,直接 UDP 发送不现实。项目里采用 JPEG 压缩,JPEG 对屏幕内容中大面积平坦区域(文字、窗口背景)压缩率很高,质量 70 左右视觉损失可接受。

private static byte[] JpegEncode(Bitmap image, int quality) { using (var ms = new MemoryStream()) { ImageCodecInfo jpegCodec = ImageCodecInfo.GetImageEncoders() .First(c => c.FormatID == ImageFormat.Jpeg.Guid); var encoderParams = new EncoderParameters(1) { Param = { [0] = new EncoderParameter(Encoder.Quality, quality) } }; image.Save(ms, jpegCodec, encoderParams); return ms.ToArray(); } }

这段代码里有个容易被忽略的细节:Encoder.Quality的取值是 0~100,不是百分比,而是量化表缩放系数。质量 95 在纯色桌面上可能比质量 70 的文件大 5 倍,收益却只在渐变区域有感知差异。我一般把质量做成配置项,默认 72,做远程演示时降到 60,换取更低码率。

JPEG 帧尺寸还受 UDP 包上限约束。以太网 MTU 是 1500 字节,扣除 IP 和 UDP 头,单帧超过 1472 字节就会 IP 分片。UDP 数据报理论最大 65507 字节,但分片越多,任一分片丢失都导致整包丢弃,所以发送端在压缩后应主动检查:

byte[] payload = JpegEncode(bmp, _quality); // 超过 60KB 的帧在接收端极大概率因为分片丢失而整包报废 if (payload.Length > 60000) { payload = JpegEncode(bmp, 55); // 降质量重压一次 }

对于 1920×1080 的全屏截图,质量 72 时通常落在 40KB~90KB,降一档质量能把压缩率从 1:12 提到 1:20 左右。

3.3 发送频率控制:不要让压缩拖垮帧率

很多初版屏幕广播程序的问题是 Timer 间隔固定为 100ms,却忽略了一次“抓屏+压缩”本身要花 50ms。结果 Timer 回调还没执行完,下一个 Tick 又来了,队列堆积,画面延迟越来越大。

正确的做法是用 Stopwatch 补偿压缩耗时:

using System.Diagnostics; private void BroadcastLoop() { var timer = new Stopwatch(); timer.Start(); while (_running) { long frameStart = timer.ElapsedMilliseconds; using (Bitmap frame = CaptureVirtualScreen()) { byte[] payload = JpegEncode(frame, _quality); _udp.Send(payload, payload.Length, _endpoint); } long encodeCost = timer.ElapsedMilliseconds - frameStart; int sleepMs = (int)Math.Max(33 - encodeCost, 5); // 目标 30ms 一帧 Thread.Sleep(sleepMs); } }

逻辑说明:sleepMs取“目标帧间隔减去编码耗时”的下限,避免压缩慢时 sleep 负值导致空转。Math.Max(..., 5)是强制让出 CPU,防止高频空转把单核跑满。这个循环放在后台线程,界面上的“停止广播”按钮用一个volatile bool _running来中断循环,比 Kill Thread 安全得多。

当编码耗时超过 33ms 时,实际帧率会自动降到 20fps 左右,这是合理的降级策略——画面延迟优先于帧率平滑。实测在 i5 平台上,1920×1080、质量 72 的 JPEG 编码约 25ms~40ms,10~20fps 是这个方案能稳定维持的范围。

4. 接收端 recieve.cs 实战:解码绘制、丢包处理与 UI 线程安全

接收端是这套工程里最容易写出“看起来对、跑起来花屏”的部分,问题往往不在接收,而在 GDI+ 对象生命周期和线程模型。

4.1 加入组播并异步收包

接收端首先要绑定端口并加入组播组,然后再启动异步接收循环:

using System.Net; using System.Net.Sockets; private UdpClient _udp; public void Start(string group, int port) { _udp = new UdpClient(port); // 绑定本地端口 _udp.JoinMulticastGroup(IPAddress.Parse(group), 32); _udp.Client.ReceiveBufferSize = 4 * 1024 * 1024; // 加大内核缓冲 _udp.BeginReceive(OnReceive, null); // 启动异步接收,不阻塞 UI } private void OnReceive(IAsyncResult ar) { IPEndPoint remote = new IPEndPoint(IPAddress.Any, 0); byte[] payload; try { payload = _udp.EndReceive(ar, ref remote); } catch (SocketException ex) { // socket 被关闭时,EndReceive 会抛异常,这里直接退出 return; } // TODO: 解码绘制 _udp.BeginReceive(OnReceive, null); // 继续接收下一帧 }

BeginReceive的回调跑在线程池线程上,所以_udp.BeginReceive(OnReceive, null)必须在回调末尾再次调用,形成串行化的收包循环。EndReceive每次只取一个 UDP 数据报,即使内核缓冲已经堆了几百帧,回调也只会触发一次,不会出现“突发流量时回调被压垮”的情况。

ReceiveBufferSize设置很关键。默认值只有 8KB,而屏幕广播的 JPEG 帧动辄几十 KB。接收端解码慢的时候,内核缓冲会被新来的包覆写,未读取的包直接丢失,画面表现为频繁“卡跳”。调到 4MB 后,网络抖动容忍度明显改善。

4.2 解码 Bitmap 的深拷贝与 UI 刷新

MemoryStream构造 Bitmap 时,GDI+ 会保持对流的引用。如果直接用new Bitmap(ms)返回的实例赋值给 PictureBox,然后Dispose掉流,稍后在重绘时可能抛出ArgumentException: 参数无效。标准做法是深拷贝:

private void OnReceive(IAsyncResult ar) { // ... EndReceive 拿到 payload ... try { using (var ms = new MemoryStream(payload)) using (var src = new Bitmap(ms)) { Bitmap display = new Bitmap(src); // 深拷贝,脱离流生命周期 // 回到 UI 线程再操作控件 _host.BeginInvoke((Action)(() => { PictureBox box = _host as PictureBox; Image old = box.Image; box.Image = display; old?.Dispose(); // 释放上一帧 GDI+ 对象 })); } } catch (Exception) { // 半帧、损坏帧直接丢弃,不中断接收循环 } _udp.BeginReceive(OnReceive, null); }

这段代码包含了三个核心实践:深拷贝避免 Bitmap 悬空引用、BeginInvoke保证 PictureBox 只在 UI 线程被赋值、old?.Dispose()防止内存被 GDI+ 对象吃满。一个容易忽略的问题:如果上一帧还在被绘制,Dispose会不会导致绘制异常?不会,Image.Dispose只释放句柄,已经发到显存的内容不受影响,GDI+ 的内部引用计数会兜底。但“赋值下一帧时先 Dispose 旧的”这个顺序不能反过来,先 Dispose 再赋值会让 PictureBox 短暂处于无图状态,屏幕出现闪烁。

4.3 帧序号与乱序过滤

UDP 不保证数据包顺序,两个组播包到达接收端的顺序可能与发送端不一致。JPEG 每帧是独立完整的数据报,乱序只会导致“先解出后发的帧”,视觉上表现为画面在几帧内轻微倒跳。在屏幕广播场景,更实际的问题是从哪个包开始解码——如果第一个收到的就是残缺帧,解码会直接抛异常。

在 Payload 前加 4 字节帧序号是最轻量的处理:

// 发送端:在 JPEG 数据前写入递增序号 byte[] frameNo = BitConverter.GetBytes(_frameSequence++); byte[] packet = new byte[4 + payload.Length]; Buffer.BlockCopy(frameNo, 0, packet, 0, 4); Buffer.BlockCopy(payload, 0, packet, 4, payload.Length);
// 接收端:解析序号,过滤明显乱序的旧帧 int seq = BitConverter.ToInt32(payload, 0); // 只保留比当前最新序号更新的帧,允许 3 帧内的乱序窗口 if (seq != 0 && (seq - _lastSeq) < -3) { // 判断为陈旧帧,丢弃 _udp.BeginReceive(OnReceive, null); return; } _lastSeq = seq;

需要注意帧序号回绕问题。int 最大值 21 亿,10fps 下要连续跑 6.8 年才会回绕,实际项目可以忽略;但如果用byte做序号,256 帧就会回绕,不能简单用差值比较,必须用“最近 128 帧内”的重叠窗口判断。这里用 int 是最省心的。

这个机制也承担了部分同步职责:接收端只认新帧,滞后画面会被直接跳过,保证观察者看到的始终是发送端的最新屏幕,而不是缓冲队列里的旧帧排队播放。

5. 进阶:多网卡绑定、防火墙例外和从 JPEG 向 H.264 演进

基础收发链路跑通后,真正影响落地的往往是环境问题,这里给三个最常遇到的方向。

5.1 多网卡机器必须绑定网卡

带 Wi-Fi 和有线双网卡的笔记本上,UdpClient.Send走的接口由路由表决定。如果组播包走了错误的网卡,接收端收不到任何数据。绑定指定网卡的常见做法是用Socket.SetSocketOption显式指定组播接口:

Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); // 指定本地 IP 作为组播接口,解决多网卡路由漂移 IPAddress localIp = IPAddress.Parse("192.168.1.101"); IPAddress groupIp = IPAddress.Parse("239.255.10.10"); s.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(groupIp, localIp)); s.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, localIp.GetAddressBytes());

MulticastOption构造函数的第二个参数是本地接口地址,用来替代系统路由表的默认选择。MulticastInterface设置组播发送走的本地 IP,Windows 上必须传小端序的字节数组,直接用GetAddressBytes()即可。

对应的 Windows 防火墙放行规则,需要在管理员 PowerShell 里执行:

netsh advfirewall firewall add rule name="ScreenBroadcast" dir=in action=allow protocol=UDP localport=5000

这条规则只放行 UDP 5000 端口的入站流量,比关防火墙或全放行 UDP 安全得多。公司域环境如果组策略锁了防火墙,需要走 IT 审批流程在组策略里统一放行,本机改规则会被下次策略刷新覆盖。

5.2 验证组播是否真的通了

发送端和接收端都启动后,先在接收端机器上确认已经加入组播组:

netsh interface ip show joins

输出里能看到本机所有接口以及已加入的组播地址。如果在接口列表里看不到 239.x.x.x,说明JoinMulticastGroup出错或者防火墙拦截了 IGMP。再用 Wireshark 抓udp.port == 5000 || igmp过滤器,IGMP Membership Report 和后续组播数据流一目了然。常见问题是:交换机关闭了 IGMP Snooping 时,组播包在二层广播,接收端能看到包但性能下降;交换机开了 Snooping 但组成员老化时间太短,接收端频繁重发 Report,画面偶尔断开,调整交换机 IGMP 查询间隔即可。

5.3 从 JPEG 帧流到 H.264 的方向

JPEG 方案每帧独立压缩,屏幕静止时也在空耗带宽。想进一步降码率,可以用 ffmpeg 做对照实验,验证 H.264 在同一屏幕内容下的码率差距:

ffmpeg -f gdigrab -framerate 10 -i desktop -c:v libx264 -preset ultrafast -tune zerolatency -f mpegts udp://239.255.1.1:5000

gdigrab 是 Windows 下的 GDI 抓屏输入设备,-preset ultrafast拉低编码延迟,-tune zerolatency关闭 B 帧和缓冲。实测静态桌面场景下,1920×1080、10fps 的 JPEG 组播码率约 3~6Mbps,libx264 ultrafast 能压到 0.5~1.5Mbps,且画面文字边缘更干净。代价是编码延迟更高(通常 200ms~500ms),交互式演示场景需要权衡。如果落地到 C# 工程,可以用 Media Foundation 的 H.264 Encoder MFT 替换 JpegEncode,但帧类型(IDR 间隔)、annexb 封装和音视频同步都要自己管理,复杂度高一到两个量级。先拿 ffmpeg 跑通链路,再决定是否值得替换。

如果发现 1500 字节 MTU 下 JPEG 分片重组频繁丢帧,优先把压缩质量降到 60 或把抓屏分辨率缩到 1280×720,让单帧控制在 40KB 以内,再回来调整 H.264 的 KeyInt 间隔,这是调这个项目最直接有效的一步。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询