C# 抓取 IP/TCP/UDP 数据包:SharpPcap 与 Raw Socket 实战

发布时间:2026/10/8 14:39:08
C# 抓取 IP/TCP/UDP 数据包:SharpPcap 与 Raw Socket 实战 简介这是一份面向C#开发者、网络管理员与安全分析人员的网络抓包工具源码基于WinPcap/Npcap或.NET Socket实现可监听指定网卡与端口捕获并解析IP、TCP、UDP数据包帮助理解网络通信细节、排查连接问题并开展安全分析。压缩包共82个文件约1.12MB以24个cs源码文件为核心配合14个png界面截图、8个resx与4个resources资源文件、4个ico图标及sln、csproj工程文件另含exe、pdb、dll等编译产物结构完整可直接运行调试。项目涵盖套接字编程、TCP/IP协议栈解析、数据包头部字段提取与界面展示等知识点代码中可见主窗体、过滤选项、抓包服务等模块划分便于读者对照学习协议解析流程与UI呈现方式。目前已有735人学习下载适合希望深入底层网络机制、提升抓包与调试能力的中级开发者参考。1. C# 抓取 IP/TCP/UDP 数据包从网卡到托管堆的那条路很多做 C# 上位机的同行第一次被要求“抓一下设备发过来的 UDP 报文看看内容”时第一反应是打开 Wireshark。Wireshark 当然好用但它是给人看的不是给程序用的。当你要把抓包能力嵌进自己的 C# 上位机、做成一个能自动解析 Modbus TCP 响应、能统计某台设备心跳间隔、能把异常 IP 包落库的工具时就必须在代码里把网卡收到的原始字节流拿到手。这件事在 C# 里能做而且路径不止一条。标题里的“抓取 IP TCP UDP 等网络数据包”拆开看是三层需求最底层要拿到链路层的帧或网络层的 IP 包中间层要按 TCP/UDP 协议把端口、序列号、载荷解析出来最上层要能在 C# 里以事件或队列的形式消费这些数据。适合谁看做工业上位机、网络调试工具、协议逆向、设备通信监控的 C# 开发者。如果你只是想在 Wireshark 里点两下这篇不用看如果你要把抓包变成自己程序的一个模块往下走。2. 选型先定死SharpPcap、Raw Socket 还是 WinPcap 直调2.1 三种抓包路径的能力边界在 Windows 上做 C# 抓包绕不开三个选择SharpPcap对 libpcap/Npcap 的 .NET 封装、原生 Socket 的 Raw Socket、以及直接 P/Invoke 调 wpcap.dll。它们不是替代关系是能力递减、权限递增的关系。SharpPcap 是最省事的。它把 Npcap 的打开网卡、设置过滤、收包回调都封装成了 C# 对象你拿到的是一个Packet对象里面已经帮你分好了 Ethernet、IPv4、TCP、UDP 各层。代价是它依赖 Npcap 驱动部署时要装驱动而且默认只能抓包不能发包发包要另开。Raw Socket 是 .NET 自带的Socket(AddressFamily.InterNetwork, SocketType.Raw, ProtocolType.IP)就能拿到 IP 层的数据包。它不需要第三方驱动但有两个硬限制一是只能收到发往本机或经过本机转发的包抓不到同网段其他机器的流量除非开混杂模式而 Windows 的 Raw Socket 混杂模式支持很有限二是需要管理员权限。适合抓本机收发的 TCP/UDP。直接调 wpcap.dll 是最后的手段只有在 SharpPcap 版本不兼容、或者你要用 Npcap 某个新特性时才考虑。绝大多数场景没必要。我的建议很直接要做通用抓包工具用 SharpPcap Npcap只抓本机自己的通信用 Raw Socket 就够了省掉驱动依赖。2.2 用 SharpPcap 打开网卡并列出设备先落一个最小可运行的环境。新建一个 .NET 控制台项目通过 NuGet 装 SharpPcap 和 PacketDotNetdotnet new console -n PacketSniffer cd PacketSniffer dotnet add package SharpPcap dotnet add package PacketDotNet然后写设备枚举代码using SharpPcap; using SharpPcap.LibPcap; // 获取所有可抓包的网卡设备 var devices CaptureDeviceList.Instance; if (devices.Count 0) { Console.WriteLine(没有找到任何抓包设备检查 Npcap 是否安装); return; } // 打印每块网卡的名字、描述和 MAC for (int i 0; i devices.Count; i) { var dev devices[i]; Console.WriteLine($[{i}] {dev.Name}); Console.WriteLine($ 描述: {dev.Description}); // MacAddress 在部分虚拟网卡上可能为空 Console.WriteLine($ MAC : {dev.MacAddress}); }这段代码的逻辑很直白CaptureDeviceList.Instance会去问 Npcap 当前系统有哪些网卡可以抓。每块网卡有Name类似\Device\NPF_{GUID}和Description人类可读的名字。参数上要注意Name才是打开设备时要传的标识Description只是给你看的。如果devices.Count为 0九成是 Npcap 没装或者装的时候没勾选“WinPcap API 兼容模式”。2.3 打开设备、设过滤、收第一个包选好网卡后打开设备并设置一个 BPF 过滤表达式。过滤在驱动层做能大幅减少无关包进入托管堆using SharpPcap; using PacketDotNet; // 假设选第 0 块网卡 var device CaptureDeviceList.Instance[0]; // 打开设备Snaplen 设 65535 保证不截断Promiscuous 开混杂模式 device.Open(new DeviceConfiguration { Mode DeviceModes.Promiscuous, ReadTimeout 1000, // 读超时 1 秒避免阻塞死等 Snaplen 65535 // 抓取完整帧 }); // 只抓 TCP 和 UDP过滤掉 ARP、ICMP 等 device.Filter tcp or udp; // 注册收包回调 device.OnPacketArrival (sender, capture) { var rawPacket capture.GetPacket(); var packet Packet.ParsePacket(rawPacket.LinkLayerType, rawPacket.Data); // 逐层解包可能为 null必须判空 var ipPacket packet.ExtractIPPacket(); if (ipPacket null) return; var tcp packet.ExtractTcpPacket(); var udp packet.ExtractUdpPacket(); if (tcp ! null) { Console.WriteLine($TCP {ipPacket.SourceAddress}:{tcp.SourcePort} - ${ipPacket.DestinationAddress}:{tcp.DestinationPort} $len{tcp.PayloadData?.Length ?? 0}); } else if (udp ! null) { Console.WriteLine($UDP {ipPacket.SourceAddress}:{udp.SourcePort} - ${ipPacket.DestinationAddress}:{udp.DestinationPort} $len{udp.PayloadData?.Length ?? 0}); } }; device.StartCapture(); Console.WriteLine(抓包中按回车停止...); Console.ReadLine(); device.StopCapture(); device.Close();逻辑说明Open里的DeviceConfiguration是关键参数集合。Snaplen设小了会把大包截断解析载荷时就会缺字节所以直接给 65535。ReadTimeout是驱动层读超时设 1000 毫秒能让StopCapture及时响应不然可能卡住。Filter用的是标准 BPF 语法tcp or udp是最常用的你也可以写host 192.168.1.100 and port 502只盯 Modbus TCP。Packet.ParsePacket是 PacketDotNet 的入口它根据链路层类型以太网、Raw IP 等决定怎么解析。ExtractIPPacket()会向上找 IP 层找不到返回 null所以判空是必须的别省。TCP 和 UDP 的PayloadData就是应用层载荷Modbus TCP 的 MBAP 头就在这里面。提示混杂模式在交换机组网的现代网络里抓不到其他主机的单播流量只能抓到广播、组播和发往本机的包。想抓交换机镜像口或集线器环境才行这是网络拓扑决定的不是代码问题。3. 不用第三方库Raw Socket 抓本机 TCP/UDP 的完整写法3.1 Raw Socket 能拿到什么、拿不到什么Raw Socket 的定位要摆正。它绑定在 IP 层收到的是 IP 包含 IP 头不是以太网帧。这意味着你看不到 MAC 地址也看不到 ARP。它的优势是零依赖、纯 .NET适合做轻量级的本机通信监控。限制说清楚Windows 下 Raw Socket 默认只能收到目的地址是本机的包以及本机发出的包。想收所有经过网卡的包需要IOControl设置RcvAll但即便如此混杂模式在 Windows 上对无线网卡基本无效对有线网卡也受驱动限制。所以别指望用 Raw Socket 做“网络嗅探器”它更适合“监控我自己程序的 TCP/UDP 收发”。3.2 绑定 IP 层并开启接收所有using System.Net; using System.Net.Sockets; // 绑定到本机任意 IP 的 IP 层 var socket new Socket( AddressFamily.InterNetwork, SocketType.Raw, ProtocolType.IP); // 绑定到具体网卡的 IP0.0.0.0 表示所有 socket.Bind(new IPEndPoint(IPAddress.Parse(192.168.1.50), 0)); // 开启接收所有经过本机的 IP 包 socket.IOControl(IOControlCode.ReceiveAll, new byte[] { 1, 0, 0, 0 }, null); // 设置接收缓冲区避免高流量丢包 socket.ReceiveBufferSize 1024 * 1024; var buffer new byte[65535]; while (true) { int len socket.Receive(buffer, 0, buffer.Length, SocketFlags.None); if (len 0) continue; // buffer[0] 是 IP 头长度的高 4 位单位是 4 字节 int ipHeaderLen (buffer[0] 0x0F) * 4; byte protocol buffer[9]; // IP 头的第 10 字节是协议号 if (protocol 6) // TCP { ParseTcp(buffer, len, ipHeaderLen); } else if (protocol 17) // UDP { ParseUdp(buffer, len, ipHeaderLen); } }逻辑说明IOControlCode.ReceiveAll是 Windows 特有的传入{1,0,0,0}开启。ReceiveBufferSize设 1MB 是血泪经验默认的 8KB 在高频 UDP 场景下会丢包而且丢得悄无声息。buffer[0] 0x0F取的是 IP 头的 IHL 字段乘以 4 得到 IP 头字节数因为 IP 头选项可变长。buffer[9]是协议字段6 是 TCP17 是 UDP这个偏移是固定的。3.3 手动解析 TCP 头和 UDP 头Raw Socket 不会帮你解析得自己按偏移取字段static void ParseTcp(byte[] buf, int len, int ipHeaderLen) { int tcpStart ipHeaderLen; if (len tcpStart 20) return; // TCP 头最小 20 字节 int srcPort (buf[tcpStart] 8) | buf[tcpStart 1]; int dstPort (buf[tcpStart 2] 8) | buf[tcpStart 3]; uint seq (uint)((buf[tcpStart 4] 24) | (buf[tcpStart 5] 16) | (buf[tcpStart 6] 8) | buf[tcpStart 7]); // TCP 头长度在偏移 12 的高 4 位单位 4 字节 int tcpHeaderLen ((buf[tcpStart 12] 4) 0x0F) * 4; int payloadStart tcpStart tcpHeaderLen; int payloadLen len - payloadStart; Console.WriteLine($TCP {srcPort}-{dstPort} seq{seq} payload{payloadLen}); } static void ParseUdp(byte[] buf, int len, int ipHeaderLen) { int udpStart ipHeaderLen; if (len udpStart 8) return; // UDP 头固定 8 字节 int srcPort (buf[udpStart] 8) | buf[udpStart 1]; int dstPort (buf[udpStart 2] 8) | buf[udpStart 3]; int udpLen (buf[udpStart 4] 8) | buf[udpStart 5]; int payloadStart udpStart 8; int payloadLen udpLen - 8; // UDP 长度字段包含头 Console.WriteLine($UDP {srcPort}-{dstPort} payload{payloadLen}); }参数说明TCP 头里偏移 12 的高 4 位是数据偏移Data Offset也就是 TCP 头长度单位是 4 字节所以乘 4。UDP 头固定 8 字节长度字段在偏移 4包含头本身所以载荷长度要减 8。这些偏移都是 RFC 里写死的不会变。注意大端序网络字节序都是大端C# 里用移位拼别用BitConverter因为BitConverter跟机器字节序走在 x86 上是小端会翻车。注意Raw Socket 需要管理员权限运行否则Bind或IOControl会抛SocketException。发布给用户时要么要求以管理员启动要么在清单里声明requireAdministrator。4. 把抓到的包变成可用数据过滤、解析与性能4.1 BPF 过滤表达式怎么写才不误伤过滤是抓包性能的第一道闸门。SharpPcap 的Filter属性走的是 BPF语法和 tcpdump 一致。写得好托管堆只处理你关心的包写得差每秒几万个包全进 GC程序直接卡死。常用表达式需求BPF 表达式只抓某台设备host 192.168.1.100只抓 Modbus TCPtcp port 502抓某网段 UDPudp and net 192.168.1.0/24排除本机 SSHtcp and not port 22抓特定源src host 10.0.0.5 and udp写过滤时有个坑port 502会同时匹配源端口和目的端口如果你只想抓发往设备的请求写dst port 502。另外 BPF 里的and、or、not优先级要小心不确定就加括号。4.2 用 PacketDotNet 提取应用层载荷拿到 TCP/UDP 对象后载荷在PayloadData里。以 Modbus TCP 为例MBAP 头 7 字节后面是 PDUvar tcp packet.ExtractTcpPacket(); if (tcp?.PayloadData ! null tcp.PayloadData.Length 8) { var payload tcp.PayloadData; // MBAP: 事务ID(2) 协议ID(2) 长度(2) 单元ID(1) int transactionId (payload[0] 8) | payload[1]; int protocolId (payload[2] 8) | payload[3]; int unitId payload[6]; byte functionCode payload[7]; // 协议ID 为 0 才是 Modbus if (protocolId 0) { Console.WriteLine($Modbus 事务{transactionId} 单元{unitId} 功能码0x{functionCode:X2}); } }逻辑说明PayloadData是byte[]可能为 null比如纯 ACK 包没有载荷所以先判空再判长度。MBAP 头的字段偏移是 Modbus 规范定的事务 ID 用于匹配请求和响应功能码告诉你这是读线圈还是读寄存器。这段代码可以直接嵌进上位机的通信监控模块把每次 Modbus 交互记下来。4.3 高流量下不丢包的三个设置抓包丢包是常态尤其是 UDP 打流场景。三个地方要调第一ReceiveBufferSize。SharpPcap 里通过device.Open的配置或直接设device.ReceiveBufferSize给到 4MB 以上。驱动缓冲区不够包在到达你的回调之前就丢了。第二回调里别做重活。OnPacketArrival是在抓包线程上同步执行的你在里面写数据库、打日志、做复杂解析就会阻塞收包。正确做法是回调里只做最小解析把原始字节塞进ConcurrentQueue另开线程消费。第三过滤尽量下沉到 BPF。能在驱动层过滤掉的绝不放进来。tcp or udp比不过滤强host x and port y又比tcp or udp强。using System.Collections.Concurrent; var queue new ConcurrentQueue(byte[] data, int len)(); device.OnPacketArrival (s, e) { var raw e.GetPacket(); // 只入队不解析 queue.Enqueue((raw.Data, raw.Data.Length)); }; // 消费线程 var consumer new Thread(() { while (true) { if (queue.TryDequeue(out var item)) { // 在这里做解析、落库、统计 } else { Thread.Sleep(1); } } }); consumer.IsBackground true; consumer.Start();这个生产者-消费者模式是抓包程序的标准骨架。入队用ConcurrentQueue保证线程安全消费线程做重活。Thread.Sleep(1)在空队列时让出 CPU别用忙等。5. 避坑与排查那些让抓包程序翻车的细节5.1 现象程序启动就抛“无法加载 wpcap.dll”原因Npcap 没装或者装的是 WinPcap 而 SharpPcap 版本要求 Npcap。SharpPcap 5.x 以后默认找 Npcap。解决去 Npcap 官网装最新版安装时勾选“Install Npcap in WinPcap API-compatible Mode”。装完重启别偷懒。如果还不行检查程序是 32 位还是 64 位Npcap 的 dll 位数要和进程匹配。5.2 现象能抓到包但 PayloadData 总是 null原因抓到的包是纯 ACK、SYN 这类没有载荷的控制包或者 Snaplen 设太小把载荷截断了。解决先判断tcp.PayloadData ! null tcp.PayloadData.Length 0再处理。如果是 Snaplen 问题把Snaplen设成 65535。还有一种可能是过滤表达式把带载荷的包滤掉了检查Filter是不是写成了tcp[tcpflags] tcp-push ! 0之类。5.3 现象UDP 高频场景丢包严重统计数对不上原因接收缓冲区太小或者回调里做了耗时操作阻塞了收包线程。解决ReceiveBufferSize调到 4MB 以上回调里只入队不解析如果还丢考虑用device.Filter缩小范围或者换用LibPcapLiveDevice的CaptureThread模式。另外Windows 的 UDP 本身在极高流量下就会丢这是协议栈行为不是代码能完全解决的。5.4 现象Raw Socket 的 Receive 一直阻塞收不到任何包原因IOControlCode.ReceiveAll没设置成功或者绑定的 IP 不对。还有一种情况是防火墙拦截了 Raw Socket。解决确认Bind的 IP 是本机某块网卡的真实 IP不是0.0.0.0部分系统上0.0.0.0行为不一致。确认IOControl调用没抛异常。检查 Windows 防火墙是否允许该程序。最后Raw Socket 收不到本机发往本机的回环包这是正常的。5.5 现象解析出来的端口号是乱的比如 502 变成 13058原因字节序搞反了。网络字节序是大端BitConverter.ToInt16在小端机器上会得到错误值。解决统一用移位拼接(buf[i] 8) | buf[i1]。别用BitConverter除非你先Array.Reverse。这个坑在解析 IP 地址、端口、长度字段时都会遇到养成手写大端解析的习惯。6. 进阶把抓包做成可复用的监控组件走到这里你已经能抓到包、解析出 TCP/UDP 和应用层载荷了。但一个真正能用的监控组件还需要解决“怎么持续跑、怎么验证抓得对、怎么不拖垮主程序”这三个问题。先说验证。抓包程序最怕的是“看起来在工作其实漏了一半”。我的习惯是做一个对照验证用iperf3打 UDP 流指定带宽和包数然后在 C# 程序里统计收到的包数和字节数和iperf3报告的数字对。比如iperf3 -c 192.168.1.100 -u -b 10M -t 30理论上 30 秒 10Mbps包大小默认 1470 字节左右能算出理论包数。如果 C# 侧统计少了超过 1%就要回去查缓冲区和回调。这个对照法比任何“感觉”都可靠。再说持续跑。抓包程序跑几天几夜是常事内存泄漏是头号敌人。OnPacketArrival里如果每次都new大对象GC 压力会很大。我的做法是复用缓冲区或者用ArrayPoolbyte.Shared租借数组。另外ConcurrentQueue要有上限消费跟不上时主动丢弃最旧的包而不是无限堆积把内存吃光。加一个计数器队列超过 10 万条就丢并记录丢弃数这样至少你知道自己漏了多少。最后说集成。把抓包组件做成一个类对外暴露事件或Channel让主程序订阅。别把抓包逻辑和业务逻辑搅在一起。下面是一个可复用的骨架using System.Threading.Channels; public class PacketMonitor { private readonly Channel(byte[] data, int len) _channel; private ICaptureDevice _device; public PacketMonitor(int capacity 100_000) { // 有界通道满了丢弃最旧防止内存爆 _channel Channel.CreateBounded(byte[], int)( new BoundedChannelOptions(capacity) { FullMode BoundedChannelFullMode.DropOldest }); } public ChannelReader(byte[] data, int len) Reader _channel.Reader; public void Start(int deviceIndex, string filter) { _device CaptureDeviceList.Instance[deviceIndex]; _device.Open(new DeviceConfiguration { Mode DeviceModes.Promiscuous, ReadTimeout 1000, Snaplen 65535 }); _device.Filter filter; _device.OnPacketArrival (s, e) { var raw e.GetPacket(); // TryWrite 不阻塞满了按 DropOldest 策略处理 _channel.Writer.TryWrite((raw.Data, raw.Data.Length)); }; _device.StartCapture(); } public void Stop() { _device?.StopCapture(); _device?.Close(); _channel.Writer.TryComplete(); } }这个骨架用Channel替代了手写的ConcurrentQueue 线程BoundedChannelFullMode.DropOldest直接解决了内存无限增长的问题。消费端用await foreach (var item in monitor.Reader.ReadAllAsync())就能异步消费和现代 C# 的异步模型无缝衔接。参数上capacity根据你的包速率调10 万条对大多数工业场景够用如果每秒几万包可以降到 1 万反正丢的是旧包不影响实时性。一个具体技巧如果你要抓的协议有固定端口比如 Modbus TCP 的 502在 BPF 里写tcp port 502然后在解析时只处理PayloadData.Length 8的包能过滤掉大量握手和 ACK消费端压力立降一个数量级。这个组合我在多个上位机项目里用过稳定跑一周不掉线。说到底C# 抓包这件事难点不在“能不能抓到”而在“抓得全不全、解析对不对、跑得久不久”。我踩过最深的坑是早期用BitConverter解析端口调试了一下午才发现是字节序问题从那以后所有网络字段一律手写移位。希望帮到你。本文还有配套的精品资源点击获取