简介:这是一份面向C#开发者、网络管理员与安全分析人员的网络抓包工具源码,基于WinPcap/Npcap或.NET Socket实现,可监听指定网卡与端口,捕获并解析IP、TCP、UDP数据包,帮助理解协议栈细节、排查通信问题。压缩包共82个文件,约1.12MB,以24个cs源码文件为核心,配合14个png界面截图、8个resx与4个resources资源文件、4个ico图标,以及sln解决方案、csproj工程、exe可执行程序、dll依赖库和txt说明等,构成可直接运行的完整工程。项目涵盖套接字编程、TCP/IP头部字段解析、数据包过滤与界面展示等知识点,代码中可见主窗体、过滤选项、抓包服务等模块划分,便于读者对照学习底层通信机制。目前已有734人学习下载,适合希望深入网络调试与协议分析的中级开发者参考。
1. 抓包这件事,C# 到底能不能干、值不值得干
很多人第一次听到“用 C# 抓 IP/TCP/UDP 数据包”,第一反应是:这不是 Wireshark 的活吗?我写个上位机,凭什么要自己抓包?答案往往出现在你没法装 Wireshark 的那台机器上——产线工控机、客户现场只允许跑自家程序的 Windows 主机、需要把抓到的报文实时喂给业务逻辑做告警的网关。这时候你要的不是一个给人看的抓包界面,而是一个能把 IP 包头、TCP 三次握手、UDP 数据报这些原始字节流拿进代码里自己解析的库。
C# 能做这件事,路径有三条:一是基于 WinPcap/Npcap 的 SharpPcap,能拿到链路层原始帧,最接近 Wireshark 的能力;二是 .NET 自带的 Socket 原始套接字,受系统权限和协议栈限制较多;三是只监听本机流量的 TcpListener/UdpClient,够用但看不到别人的包。选哪条,取决于你要抓的是“本机进程的流量”还是“网卡上流过的所有流量”。这篇就把这三条路讲清楚,让你知道什么场景该上哪套,参数怎么设,坑在哪。
2. 三条抓包路线的选型:SharpPcap、原始套接字、Socket 监听
2.1 先搞清楚你要抓的是哪一层
抓包这件事,第一个要问的不是“用什么库”,而是“我要抓哪一层的包”。链路层(以太网帧)能看到 MAC 地址、VLAN 标签;网络层(IP 包)能看到源目 IP、TTL、分片标志;传输层(TCP/UDP)能看到端口、序列号、标志位;应用层才是 HTTP、Modbus 这些业务数据。SharpPcap 走的是链路层,拿到的是完整帧,往上你自己剥。原始套接字一般停在网络层,能拿到 IP 包头但拿不到以太网头。TcpListener/UdpClient 直接给你应用层数据,中间的头全被系统吃掉了。
这个分层决定了你的解析代码要写多深。如果你只是想知道“哪个 IP 在跟我的设备通信”,Socket 监听就够;如果你要分析 TCP 三次握手有没有完成、有没有 RST 异常断开,就必须拿到 TCP 头,得用 SharpPcap 或原始套接字。热词里常出现的“tcp三次握手四次挥手”“ip包头”“udp划分ip数据报片”,这些分析都要求你能看到传输层和网络层的原始头,Socket 监听做不到。
还有一个现实约束:Windows 上原始套接字从 Vista 之后被大幅限制,绑定到具体 IP 的原始套接字只能收发给本机的包,想抓网卡上所有流量基本走不通。所以真正要“抓网卡”的场景,SharpPcap + Npcap 是事实标准。Npcap 是 Wireshark 安装时可选装的驱动,装完之后 SharpPcap 才能通过它拿到网卡列表和原始帧。
2.2 SharpPcap 抓包的最小可运行代码
先装包,NuGet 上搜 SharpPcap,同时要把 Npcap 驱动装上(安装时勾选 WinPcap API 兼容模式)。下面这段代码列出所有网卡、打开第一块可用网卡、抓 10 个包并打印基本信息。
using SharpPcap; using SharpPcap.LibPcap; using PacketDotNet; // 1. 列出所有网卡,确认你要抓哪一块 var devices = CaptureDeviceList.Instance; foreach (var dev in devices) { Console.WriteLine($"{dev.Name} | {dev.Description}"); } // 2. 打开第一块网卡,超时 1000ms var device = devices[0]; device.Open(new DeviceConfiguration { Mode = DeviceModes.Promiscuous, // 混杂模式,抓经过网卡的所有帧 ReadTimeout = 1000 // 读超时,毫秒 }); // 3. 注册收包回调 int count = 0; device.OnPacketArrival += (sender, e) => { var rawPacket = e.GetPacket(); var packet = Packet.ParsePacket(rawPacket.LinkLayerType, rawPacket.Data); var ipPacket = packet.Extract<IPPacket>(); if (ipPacket == null) return; var tcpPacket = packet.Extract<TcpPacket>(); var udpPacket = packet.Extract<UdpPacket>(); if (tcpPacket != null) { Console.WriteLine($"[TCP] {ipPacket.SourceAddress}:{tcpPacket.SourcePort} -> " + $"{ipPacket.DestinationAddress}:{tcpPacket.DestinationPort} " + $"Flags={tcpPacket.Flags} Seq={tcpPacket.SequenceNumber}"); } else if (udpPacket != null) { Console.WriteLine($"[UDP] {ipPacket.SourceAddress}:{udpPacket.SourcePort} -> " + $"{ipPacket.DestinationAddress}:{udpPacket.DestinationPort} " + $"Len={udpPacket.PayloadData?.Length ?? 0}"); } if (++count >= 10) device.StopCapture(); }; // 4. 开始抓,主线程等回调结束 device.StartCapture(); Console.ReadLine(); device.Close();逻辑说明:CaptureDeviceList.Instance拿到的是 Npcap 枚举出来的网卡,dev.Name形如\Device\NPF_{GUID},选错网卡是最常见的“抓不到包”原因。DeviceModes.Promiscuous是混杂模式,交换机组网下你只能看到广播和发往本机的帧,想抓别人的流量得在镜像口或集线器环境。Packet.ParsePacket是 PacketDotNet 提供的解析入口,它按链路层类型自动往下剥,Extract<IPPacket>()拿到 IP 层,再Extract<TcpPacket>()拿到 TCP 层。
参数说明:ReadTimeout设太小会导致频繁空轮询,设太大会让StopCapture响应变慢,1000ms 是常用折中。tcpPacket.Flags是个枚举,Syn、Ack、Fin、Rst都在里面,判断三次握手就是看Syn和Syn|Ack的配对。udpPacket.PayloadData是 UDP 载荷,注意 UDP 长度字段和实际载荷长度可能因为分片不一致,热词里“udp划分ip数据报片”说的就是这种情况,IP 层分片后单个 UDP 报文会被拆成多个帧,你在回调里看到的是分片后的帧,要自己按 IP 头的 Identification 和 FragmentOffset 重组。
2.3 只抓本机流量:TcpListener 和 UdpClient 的边界
如果你的需求只是“我的上位机跟设备之间的通信我要记下来”,那根本不用上 SharpPcap。TcpListener 接受连接后,NetworkStream读到的就是应用层数据;UdpClient 的Receive拿到的就是一个个数据报。这条路简单、不需要驱动、不需要管理员权限,但代价是你只能看到自己进程参与的连接。
// TCP 服务端:监听 502 端口,打印每个客户端发来的原始字节 var listener = new TcpListener(IPAddress.Any, 502); listener.Start(); Console.WriteLine("Listening on 502..."); while (true) { var client = listener.AcceptTcpClient(); var remote = client.Client.RemoteEndPoint as IPEndPoint; Console.WriteLine($"Client connected: {remote}"); var stream = client.GetStream(); var buffer = new byte[4096]; int read; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) { // 把字节转成十六进制,方便对照协议文档 Console.WriteLine($"[{remote}] {BitConverter.ToString(buffer, 0, read)}"); } client.Close(); }逻辑说明:AcceptTcpClient阻塞等待连接,每个连接单独开线程或 Task 处理是常规做法,热词里“c# tcplistener 多客户端”问的就是这个,简单场景用Task.Run包一层即可,连接数上千再考虑异步 Accept。stream.Read返回 0 表示对端关闭,这时候要Close释放。UDP 那边更简单,UdpClient.Receive(ref remoteEP)一次拿一个数据报,注意 UDP 不保证顺序和到达,丢包是正常的。
参数说明:IPAddress.Any表示监听所有网卡,只想监听某块网卡就填具体 IP。缓冲区大小 4096 对 Modbus 这类小报文够用,如果传文件要调大。这里拿不到 TCP 头,所以你看不到三次握手,只能看到连接建立后的数据,这是这条路线的硬边界。
3. 解析 IP/TCP/UDP 头:字段偏移、字节序和分片重组
3.1 IP 包头的固定 20 字节怎么读
拿到原始字节后,自己解析 IP 头是绕不开的基本功。IPv4 头最小 20 字节,字段布局是固定的:版本+头长占 1 字节,服务类型 1 字节,总长度 2 字节,标识 2 字节,标志+片偏移 2 字节,TTL 1 字节,协议 1 字节,头校验和 2 字节,源 IP 4 字节,目的 IP 4 字节。网络字节序是大端,C# 里BitConverter默认小端,所以要么手动移位,要么用BinaryPrimitives.ReadUInt16BigEndian。
using System.Buffers.Binary; // data 是从 SharpPcap 拿到的 IP 层起始字节 int ihl = (data[0] & 0x0F) * 4; // 头长,单位是 4 字节 int totalLen = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(2)); int identification = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(4)); int flagsAndOffset = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(6)); bool moreFragments = (flagsAndOffset & 0x2000) != 0; int fragmentOffset = (flagsAndOffset & 0x1FFF) * 8; // 单位 8 字节 byte ttl = data[8]; byte protocol = data[9]; // 6=TCP, 17=UDP var srcIp = new IPAddress(data.AsSpan(12, 4)); var dstIp = new IPAddress(data.AsSpan(16, 4)); Console.WriteLine($"IP {srcIp} -> {dstIp} proto={protocol} ttl={ttl} " + $"id=0x{identification:X4} fragOff={fragmentOffset} mf={moreFragments}");逻辑说明:data[0] & 0x0F取低 4 位是头长,因为 IP 头可能有选项字段,所以不能硬编码 20。flagsAndOffset的高 3 位是标志,其中第 2 位是 MF(More Fragments),低 13 位是片偏移。protocol字段决定后面是 TCP 还是 UDP,6 和 17 是最常见的两个值。
参数说明:fragmentOffset乘以 8 才是字节偏移,这是 IP 协议的规定。identification同一个数据报的所有分片相同,重组时按这个字段分组,再按fragmentOffset排序,最后一片moreFragments为 false。热词里“udp划分ip数据报片”就是这个机制,UDP 本身不管分片,是 IP 层在分,重组也在 IP 层,你的应用层代码如果直接用 Socket 收 UDP,系统已经帮你重组好了,只有自己解析原始帧时才需要处理。
3.2 TCP 头的关键字段和三次握手识别
TCP 头最小 20 字节,源端口、目的端口各 2 字节,序列号 4 字节,确认号 4 字节,数据偏移+保留+标志共 2 字节,窗口 2 字节,校验和 2 字节,紧急指针 2 字节。标志位里 SYN、ACK、FIN、RST、PSH、URG 各占一位,判断连接状态全靠它们。
// tcpData 是 IP 载荷的起始,即 TCP 头开始处 int srcPort = BinaryPrimitives.ReadUInt16BigEndian(tcpData.AsSpan(0)); int dstPort = BinaryPrimitives.ReadUInt16BigEndian(tcpData.AsSpan(2)); uint seq = BinaryPrimitives.ReadUInt32BigEndian(tcpData.AsSpan(4)); uint ack = BinaryPrimitives.ReadUInt32BigEndian(tcpData.AsSpan(8)); int dataOffset = (tcpData[12] >> 4) * 4; // TCP 头长 byte flags = tcpData[13]; bool syn = (flags & 0x02) != 0; bool ackFlag = (flags & 0x10) != 0; bool fin = (flags & 0x01) != 0; bool rst = (flags & 0x04) != 0; string state = (syn && !ackFlag) ? "SYN" : (syn && ackFlag) ? "SYN-ACK" : (fin) ? "FIN" : (rst) ? "RST" : "DATA"; Console.WriteLine($"TCP {srcPort}->{dstPort} seq={seq} ack={ack} {state}");逻辑说明:tcpData[12] >> 4取高 4 位是数据偏移,也就是 TCP 头长度,单位 4 字节。flags在tcpData[13],SYN 是 0x02,ACK 是 0x10,FIN 是 0x01,RST 是 0x04。三次握手的识别就是看同一个四元组(源 IP、源端口、目的 IP、目的端口)上先出现 SYN,再出现 SYN-ACK,最后出现 ACK。四次挥手则是 FIN、ACK、FIN、ACK 的序列。
参数说明:seq和ack是 32 位无符号数,会回绕,做流重组时要用差值而不是直接比较大小。dataOffset之后才是应用层数据,长度是 IP 总长度减去 IP 头长再减去 TCP 头长。热词里“tcp连接”“tcp三次握手”在代码层面就是这几个标志位的组合判断,没有玄学,就是位运算。
3.3 UDP 头只有 8 字节,但分片是坑
UDP 头极简:源端口 2 字节,目的端口 2 字节,长度 2 字节,校验和 2 字节,后面全是载荷。长度字段包含头本身,所以载荷长度是长度减 8。UDP 不保证可靠,也没有连接状态,抓到的每个 UDP 报文都是独立的。
int srcPort = BinaryPrimitives.ReadUInt16BigEndian(udpData.AsSpan(0)); int dstPort = BinaryPrimitives.ReadUInt16BigEndian(udpData.AsSpan(2)); int udpLen = BinaryPrimitives.ReadUInt16BigEndian(udpData.AsSpan(4)); int payloadLen = udpLen - 8; Console.WriteLine($"UDP {srcPort}->{dstPort} payload={payloadLen} bytes");逻辑说明:UDP 长度字段是 16 位,最大 65535,减去 8 字节头就是载荷上限。如果 IP 层发生了分片,你在这个 UDP 报文里看到的载荷可能只是完整数据报的一部分,udpLen仍然是完整长度,但实际字节数不够,这时候要靠 IP 层的分片重组逻辑先把数据拼回来再解析 UDP。
参数说明:UDP 校验和在 IPv4 里是可选的,全 0 表示不校验,IPv6 里强制。做 UDP 网络调试时,热词里“udp测试工具 ascii 命令输入”这类需求,本质就是把收到的字节按 ASCII 或十六进制打印出来,注意区分文本协议和二进制协议,二进制协议直接转字符串会乱码。
4. 避坑与排查:抓不到包、乱码、权限报错的真实原因
4.1 现象:代码跑起来一个包都抓不到
原因:最常见的是网卡选错。CaptureDeviceList.Instance列出的第一块网卡往往是虚拟网卡(VMware、Hyper-V、Loopback),不是真正跑流量的那块。其次是没装 Npcap 或装的时候没勾 WinPcap 兼容模式,SharpPcap 打开设备会直接抛异常或返回空列表。还有一种情况是网卡没开混杂模式,交换机组网下只能看到广播和本机流量。
解决:先把所有网卡的Name和Description打印出来,对照ipconfig /all里的描述找到物理网卡。确认 Npcap 服务npcap在运行。如果还是抓不到,用 Wireshark 在同一块网卡上抓一下,Wireshark 能抓到而你的代码抓不到,问题就在代码的过滤条件或打开参数上。
4.2 现象:抓到的中文是乱码,十六进制看着对但转字符串不对
原因:字节到字符串的编码搞错了。网络协议里的文本不一定是 UTF-8,老设备常用 GBK 或 ASCII。另外 TCP 是流,一个应用层报文可能被拆到多个 TCP 段里,你按单个包转字符串,遇到多字节字符被切断就乱码。
解决:先按十六进制打印确认字节本身没错,再确定编码。GBK 用Encoding.GetEncoding("GBK"),需要注册CodePagesEncodingProvider。TCP 流式协议要自己维护缓冲区,按协议约定的分隔符或长度字段拼完整报文再解码,不能一个包一个包地转。
4.3 现象:Open设备时报权限错误或“无法打开适配器”
原因:抓包需要管理员权限,普通用户运行 SharpPcap 打开网卡会被拒绝。另外如果 Npcap 安装时选了“仅管理员可访问”模式,非管理员进程一律打不开。
解决:以管理员身份运行程序,或者在项目里加app.manifest声明requireAdministrator。如果是产线部署不想每次提权,重装 Npcap 时选允许普通用户访问。注意这属于系统权限配置,不同 Windows 版本表现略有差异,以实际报错为准。
4.4 现象:UDP 收到的数据比预期短,或者干脆收不到
原因:IP 分片。发送方发的 UDP 数据报超过 MTU(通常 1500 字节),IP 层会分片,接收端如果没等所有分片到齐就解析,拿到的就是残缺数据。另外 UDP 本身不保证到达,网络拥塞时丢包很正常。
解决:自己解析原始帧时,按 IP 头的identification缓存分片,等moreFragments为 false 的那片到了再拼装。用 Socket 收 UDP 时系统已经重组,但如果缓冲区设小了,Receive会截断数据报,把缓冲区调到 65535 以上。热词里“read udp: unknown error (code=10054)”这类报错,在 Windows 上通常是对方端口不可达触发的 ICMP 导致的,属于正常现象,捕获异常忽略即可。
4.5 现象:TCP 分析时序列号对不上,重组出来的数据错位
原因:TCP 序列号是字节流编号,不是包编号。重传、乱序、窗口滑动都会让序列号看起来“跳变”。如果只按到达顺序拼接,遇到乱序就错位。
解决:维护一个按序列号排序的缓冲区,收到包先按seq插入正确位置,再按连续区间取出可交付数据。重传的包序列号重复,直接丢弃。这是 TCP 流重组的核心逻辑,SharpPcap 只给你原始包,重组要自己写,或者用现成的 TCP 重组库。
5. 进阶:把抓包做成能长期跑的服务,几个实用技巧
5.1 用过滤器减少无效包,别让回调被淹没
抓包服务跑久了,最大的问题是包太多,回调处理不过来就丢包。SharpPcap 支持 BPF 过滤表达式,在打开设备时设置,让驱动层就把不要的包丢掉,比在回调里判断高效得多。
device.Open(new DeviceConfiguration { Mode = DeviceModes.Promiscuous, ReadTimeout = 1000 }); // 只抓 502 端口的 TCP,以及 68 端口的 UDP device.Filter = "tcp port 502 or udp port 68";逻辑说明:Filter是标准的 BPF 语法,tcp、udp、port、host、net这些关键字都支持。过滤在驱动层生效,回调收到的就是过滤后的包,CPU 占用会明显下降。
参数说明:表达式越精确越好,tcp port 502比tcp好,host 192.168.1.10 and tcp port 502更精确。注意过滤器写错不会报错,只是抓不到包,调试时先用 Wireshark 验证表达式。
5.2 把抓到的包落盘,方便事后复盘
实时分析容易漏,把原始帧按 pcap 格式写文件,事后用 Wireshark 打开复盘,是排查疑难问题的后悔药。SharpPcap 自带CaptureFileWriterDevice。
var writer = new CaptureFileWriterDevice("capture.pcap"); device.OnPacketArrival += (sender, e) => { writer.Write(e.GetPacket()); // 原始帧直接写,保留所有层 }; device.StartCapture();逻辑说明:CaptureFileWriterDevice写出来的是标准 pcap 格式,Wireshark 直接能开。写入的是原始帧,包含以太网头,所以事后能看到所有细节。
参数说明:文件会一直增长,长期跑要按时间或大小切分,比如每小时换一个文件。写盘是 IO 操作,高流量下可能成为瓶颈,可以先用内存队列缓冲,后台线程批量写。
5.3 一个我自己的习惯:先验证再优化
我做过好几个抓包相关的上位机,血泪经验是:不要一上来就写复杂的解析和重组逻辑。先用最简单的代码把包抓下来、落盘、用 Wireshark 确认抓到的内容是对的,再在这个基础上加解析。抓包本身受驱动、权限、网卡影响太大,如果解析代码写了几百行才发现根本抓不到包,排查起来就是黑匣子。先跑通“能抓到、能落盘、Wireshark 能打开”这条最小链路,后面所有分析都建立在这个可信的原始数据上。这个习惯帮我省了很多返工,希望帮到你。
本文还有配套的精品资源,点击获取