简介:这份资源是一套基于C#与SharpPcap库实现的网络抓包程序及配套源码,面向希望学习数据包捕获、协议解析与网络诊断的开发者及初学者,也可作为C#网络编程课程的实践案例。压缩包共17个文件、约2.24MB,涵盖可执行程序、源码压缩包、DLL与XML文档、配置文件、界面截图、Word说明文档及抓包驱动安装程序等,既可配置依赖后直接运行抓包工具,也能对照源码研究SharpPcap的调用方式和数据包处理流程。编译好的程序便于快速上手,源码部分适合按模块拆解学习,截图与文档则帮助理解界面操作、运行环境配置和常见问题排查。已有307人学习,读者可据此了解捕获过滤、数据包字段解析与异常流量识别等典型环节,对专业开发者是网络编程技能提升素材,对初学者则是边做边学的完整案例。
1. 从一次“日志干净但数据丢了”的排查说起
线上某个接口偶尔返回空数组,服务端日志干干净净,浏览器开发者工具只能看到 HTTP 那一层,再往下全是尚未被观察到的区域。当时手边只有一个 C# 写的桌面程序,基于 SharpPcap 做的网络抓包工具,选好网卡、敲一行过滤表达式,TCP 握手和 RST 包直接摊在列表里,问题十分钟就定位了。这份源码就是那类程序的完整实现:网卡枚举、混杂模式抓包、BPF 过滤、TCP/UDP/ICMP 解析、会话统计、pcap 落盘回放,一条链路全在里面。适合三类人:想在 .NET 里做网络监控的、要给自家私有协议配调试工具的、以及需要一份结构清楚能直接改的教学样例的。下面按“能不能跑—长什么样—怎么改”的顺序拆。
2. 工程骨架与依赖分层:SharpPcap 托管层怎么接上内核驱动
2.1 三层结构:为什么 C# 抓包绕不开本地库
很多人第一反应是System.Net.Sockets里能不能直接读原始帧,答案是不能。Socket 面向的是传输层,绑定到 IP 层就封顶了,以太网头、TCP 选项字段、ARP 报文这些更底下的东西拿不到。要抓原始帧,必须走操作系统提供的抓包接口,Windows 上就是 libpcap 兼容的那一层。于是整个依赖天然分成三层,源码里也是按这个层次组织的:
| 层次 | 组件 | 职责 |
|---|---|---|
| 应用层 | WinForms / WPF 界面 | 设备选择、过滤输入、列表渲染、导出按钮 |
| 托管封装层 | SharpPcap | 设备枚举、打开关闭、设置过滤器、包到达事件 |
| 协议解析层 | PacketDotNet | 以太网/IP/TCP/UDP 逐层拆字段,与抓包解耦 |
| 本地库 | libpcap 兼容的wpcap.dll、Packet.dll | 承载抓包 API 的实际实现 |
| 内核层 | 抓包驱动 | 在 NDIS 层挂接,把帧复制一份给用户态 |
这个分层带来的好处很直接:SharpPcap 只负责“把字节捞上来”,PacketDotNet 只负责“把字节翻译成人话”,两边互不依赖。二次开发时想把解析换成自己的实现,只要替换 PacketDotNet 那一层,抓包部分一行都不用动。
选型上还有个小细节值得说。SharpPcap 从 5.x 到 6.x 的 API 改过一轮,老版本是device.Open(bool promiscuous, int readTimeout)这种平铺参数,新版本改成了Open(DeviceConfiguration)配置对象。拿到一份别人写的源码,先看lib/目录里 dll 的版本,再决定按哪套写法改,比对着报错一行行试要快得多。这是踩过坑之后养成的习惯。
2.2 目录结构与模块边界
源码的工程划分把核心逻辑全压在Sniffer.Core里,界面工程只做展示,这个边界划得很关键——核心层不引用任何 UI 类型,后面想把它塞进 Windows 服务或者做成命令行工具,直接引用 dll 就行。
Sniffer.sln ├─ Sniffer.Core/ # 不依赖 UI,可单独复用 │ ├─ DeviceManager.cs # 枚举网卡、打开、关闭、异常包装 │ ├─ CaptureEngine.cs # 启停控制、包到达回调、丢包计数 │ ├─ PacketParser.cs # RawCapture -> PacketInfo 的翻译层 │ ├─ SessionTable.cs # 五元组会话聚合与流量统计 │ └─ PcapExporter.cs # 落盘 writer 与回放 reader ├─ Sniffer.UI/ │ ├─ MainForm.cs # 工具栏、网卡下拉、过滤框 │ └─ PacketListView.cs # 虚拟模式列表,只渲染可见行 └─ lib/ ├─ SharpPcap.dll └─ PacketDotNet.dllCaptureEngine是全局唯一的抓包句柄持有者,DeviceManager只管生命周期,PacketParser是纯函数式的无状态翻译。这种切法在排查时特别省事:抓不到包看DeviceManager,抓到了但字段错看PacketParser,两者都对但界面不动看 UI 的刷新逻辑,责任边界清楚,不用到处加断点。
2.3 编译前必须落地的三件事
第一件,装抓包驱动。Windows 上没有它CaptureDeviceList.Instance会返回空集合,程序看起来像是坏了,其实只是缺底层组件。安装时记得勾上兼容模式那个选项,否则老代码里按旧名字加载 dll 会失败。
第二件,把平台目标统一。驱动是分位数的,进程是 AnyCPU 又跑在 64 位系统上,加载 32 位 dll 就会抛BadImageFormatException。稳妥做法是在项目属性里把目标平台显式设成 x64,别留着 AnyCPU 让它自己猜。
第三件,引用包或手动引 dll。新工程用命令行最省事:
# 两个包职责不同:一个负责抓,一个负责拆 dotnet add package SharpPcap dotnet add package PacketDotNet老工程如果是 packages.config 那套,就老实把lib/下的 dll 拖进引用,并确认“复制到输出目录”设成 true,不然编译过了运行时报找不到程序集。
提示:抓包进程在 Windows 上需要管理员权限,在 app.manifest 里加
requireAdministrator比让用户手动右键管理员运行更省事,也能避免枚举设备不全这种奇怪现象。
3. 抓包主链路:一帧数据从网卡到列表的完整链路
3.1 枚举设备:Name 与 Description 别搞混
枚举看着简单,但两个属性的用途完全不同,混用会在后面埋雷:
using SharpPcap; using SharpPcap.LibPcap; // Name 是驱动层的设备标识,形如 \Device\NPF_{GUID} // Description 是给人看的友好名,比如“以太网 2” foreach (var dev in CaptureDeviceList.Instance) { Console.WriteLine($"{dev.Name} | {dev.Description}"); }Name是唯一稳定的标识,配置持久化、命令行参数、日志里都应该存它;Description只用于界面下拉框展示。有些网卡的描述字符串是空或者重复的,拿它当 key 做下拉选择,用户一换机器配置就对不上了。另外枚举顺序在不同机器上不一致,源码里如果依赖索引选网卡,跨机器跑必然出问题。
3.2 打开设备:DeviceConfiguration 四个参数怎么填
打开设备的参数直接决定抓包行为,默认值往往不适合真实场景:
var device = CaptureDeviceList.Instance .FirstOrDefault(d => d.Name == selectedName); if (device == null) throw new InvalidOperationException("网卡不存在,或抓包驱动未安装"); device.Open(new DeviceConfiguration { Mode = DeviceModes.Promiscuous, // 混杂模式:能收到目的 MAC 不是本机的帧 ReadTimeout = 1000, // 毫秒,决定停止抓包的响应速度 BufferSize = 4 * 1024 * 1024, // 内核缓冲区,突发流量靠它兜底 });Mode设成Promiscuous意味着交换机的镜像口、同一广播域里别人的帧也能收到,做流量分析必须开;只想看本机收发就设Normal,能少收一大截无关数据。ReadTimeout容易被忽略,它控制的是回调线程多久没收到包时醒一次,设得太大(比如 5000),用户点“停止”之后要等好几秒才有反应,体验很差;设得太小(比如 10),空转次数上去,CPU 占用就不好看。1000 毫秒是个比较折中的值。
BufferSize是丢包率的关键。包速率一上来,用户态来不及消费,内核缓冲区满了就直接丢,而且这种丢包在应用层是静默的,只能靠抓包前后的计数对比发现。默认值通常只有 1MB 甚至更小,跑千兆流量时建议开到 4MB 到 8MB。
| 参数 | 常用值 | 改大了会怎样 | 改小了会怎样 |
|---|---|---|---|
| Mode | Promiscuous | 无关流量变多,过滤压力大 | 看不到非本机流量 |
| ReadTimeout | 1000 ms | 停止响应慢 | 空转多,CPU 偏高 |
| BufferSize | 4 MB | 内存占用上升 | 突发时静默丢包 |
3.3 BPF 过滤器:把无关流量挡在回调之前
过滤器是这套程序最实用的功能,也是新手最容易写错的地方。它的价值在于过滤发生在驱动层,不匹配的帧根本不会复制到用户态,比“先全抓回来再在 C# 里 if 判断”省下来的开销是数量级的。
| 表达式 | 含义 |
|---|---|
tcp port 80 | 只抓 80 端口的 TCP |
tcp port 80 or tcp port 443 | 多端口用 or 连接 |
host 10.0.0.5 and not arp | 指定主机,排除 ARP |
udp and portrange 5000-5100 | UDP 端口段 |
tcp[tcpflags] & tcp-rst != 0 | 只抓带 RST 标志的包,排查连接被重置很快 |
设置时机很关键,必须在Open之后、StartCapture之前,否则赋值会被忽略且不报任何错:
try { // Filter 赋值会立刻触发驱动侧编译,语法错误在这行就抛 device.Filter = filterExpression; } catch (PcapException ex) { // 把原始报错透出去,用户才知道是哪个 token 写错了 throw new ArgumentException($"过滤表达式无法编译:{ex.Message}", ex); }那个catch是必须的。BPF 语法错误抛的是本地库的异常,消息里会带出错位置,直接吞掉的话,用户只会看到“过滤器好像没生效”,其实是表达式压根没设上去,全量抓包了。
3.4 回调线程到界面线程:队列加批量刷新
包到达回调跑在工作线程上,在这里碰 UI 控件必然抛跨线程异常,而每个包Invoke一次又会让界面直接卡死。源码里用的是有界队列加定时批量取的模式:
private void OnPacketArrival(object sender, PacketCapture e) { var raw = e.GetPacket(); // RawCapture:Data / Timeval / LinkLayerType // 有界队列,满了就丢,绝不让回调线程阻塞 if (!_queue.TryAdd(raw)) { Interlocked.Increment(ref _droppedCount); // 丢包计数要暴露到界面上 return; } } // UI 侧用 Timer 每 100ms 取一批,一次性刷新,避免逐包调度 private void FlushTimer_Tick(object sender, EventArgs e) { var batch = new List<PacketRow>(256); while (batch.Count < 256 && _queue.TryDequeue(out var raw)) batch.Add(PacketParser.Parse(raw)); if (batch.Count > 0) _listView.AppendBatch(batch); // 虚拟列表,只渲染可见行 }_queue用的是无锁的并发队列,容量设个上限比如 10 万条。这个上限不是拍脑袋定的,它决定了高负载下内存的天花板,也决定了丢包时程序是崩还是降级。丢包计数一定要显示在状态栏上,否则用户会以为自己抓全了。
3.5 停止与释放:句柄泄漏最容易被忽略
停止抓包不是调一个StopCapture就完事,顺序错了会留句柄:
if (device != null) { device.StopCapture(); // 先停驱动侧的采集 device.OnPacketArrival -= OnPacketArrival; // 再摘事件,防止停的过程中还有回调进来 device.Close(); // 最后关设备,释放内核缓冲区 }顺序反过来的话,Close之后可能还有一两个在途的包触发回调,访问已释放的对象就会崩。切换网卡的时候尤其明显——用户连点两次下拉框,程序就退了。
4. 协议解析与结果落地:拆包、会话统计、pcap 导出回放
4.1 RawCapture 逐层拆解:Extract 的顺序和空判断
RawCapture里只有三样东西:原始字节、时间戳、链路层类型。要变成人能看的字段,得靠 PacketDotNet 一层层剥:
public static PacketInfo Parse(RawCapture raw) { var info = new PacketInfo { Time = raw.Timeval.Date, Length = raw.Data.Length }; // 第一个参数必须用 raw.LinkLayerType,写死 Ethernet 在回环网卡上会全解析失败 var packet = Packet.ParsePacket(raw.LinkLayerType, raw.Data); var eth = packet.Extract<EthernetPacket>(); if (eth != null) { info.SrcMac = eth.SourceHardwareAddress?.ToString(); info.DstMac = eth.DestinationHardwareAddress?.ToString(); } var ip = packet.Extract<IPPacket>(); if (ip == null) return info; // ARP、LLDP 这类没有 IP 层,直接返回别硬转 info.SrcIp = ip.SourceAddress.ToString(); info.DstIp = ip.DestinationAddress.ToString(); info.Ttl = ip.TimeToLive; info.Protocol = ip.Protocol.ToString(); switch (ip.Protocol) { case ProtocolType.Tcp: var tcp = packet.Extract<TcpPacket>(); info.SrcPort = tcp.SourcePort; info.DstPort = tcp.DestinationPort; info.Flags = tcp.Flags.ToString(); // 排查 RST、SYN 重传全靠它 break; case ProtocolType.Udp: var udp = packet.Extract<UdpPacket>(); info.SrcPort = udp.SourcePort; info.DstPort = udp.DestinationPort; break; } return info; }几个容易翻车的点。第一,Extract<T>找不到对应层时返回 null,不做空判断直接取属性就是空引用异常,而抓包场景里 null 是常态不是异常。第二,ip.Protocol判断 UDP 之后还去Extract<TcpPacket>会拿到 null,switch 分支写全一点省事。第三,PacketInfo里只放基础类型字段,不要把 PacketDotNet 的对象存进列表,那些对象持有字节数组引用,存十万条内存直接爆。
4.2 会话统计:五元组归一与并发计数
列表看的是离散的包,但排查问题往往要看会话。会话表的核心是把双向流量归到同一个 key 上:
private readonly ConcurrentDictionary<string, SessionStat> _sessions = new(); public void Update(PacketInfo p) { if (p.SrcIp == null || p.SrcPort == 0) return; // 按 IP 字符串排序,保证 A->B 和 B->A 落到同一个 key var key = string.CompareOrdinal(p.SrcIp, p.DstIp) <= 0 ? $"{p.SrcIp}:{p.SrcPort}-{p.DstIp}:{p.DstPort}/{p.Protocol}" : $"{p.DstIp}:{p.DstPort}-{p.SrcIp}:{p.SrcPort}/{p.Protocol}"; var stat = _sessions.GetOrAdd(key, _ => new SessionStat()); Interlocked.Increment(ref stat.Packets); Interlocked.Add(ref stat.Bytes, p.Length); }用ConcurrentDictionary是因为解析可能放在后台线程池里跑,多线程同时更新同一张表;计数用Interlocked而不是普通++,否则数据量一大就少算。会话表还要配一个空闲超时清理,TCP 那种长连接不用管,UDP 会话一直不清理,跑一天下来字典能涨到几十万条。
4.3 pcap 落盘与回放验证
导出这块简单到容易被忽略,但它是整个工具最值钱的功能——现场抓一次,拿回来慢慢分析,还能反复回放复现:
// 写入的必须是原始 RawCapture,解析后的 PacketInfo 落盘会丢掉时间戳精度 using var writer = new CaptureFileWriterDevice("capture.pcap"); writer.Open(); foreach (var raw in packets) writer.Write(raw);回放的时候别一次性读进内存,大文件会直接吃掉几个 G:
using var reader = new CaptureFileReaderDevice("capture.pcap"); reader.Open(); RawCapture raw; while ((raw = reader.GetNextPacket()) != null) { // 走和实时抓包完全相同的解析路径,这样回放结果才能和线上对齐 PacketParser.Parse(raw); }注意回放要复用同一个PacketParser,不要在回放时另写一套解析。两边逻辑一旦分叉,回放出来的统计和现场抓的对不上,排查就变成了两个问题一起查。
5. 避坑与排查:驱动、权限、线程、编码的五处出错场景
5.1 设备列表是空集合
现象:程序启动后网卡下拉框一片空白,CaptureDeviceList.Instance.Count返回 0。
原因:抓包驱动没装,或者安装时没勾兼容模式导致按老名字加载 dll 失败;也有可能是进程位数和驱动位数不匹配,64 位进程硬加载 32 位 dll。
解决:重新安装并勾选兼容选项,把项目平台目标显式设成 x64,再在启动代码里对Count == 0做一次明确提示,不要让它静默变成空列表——用户根本不知道该去装什么。
5.2 抓到包了,但字段全是 null
现象:列表有行,长度、时间都对,源 IP、目的 IP、端口全是空的。
原因:Packet.ParsePacket的第一个参数链路层类型传错了,常见写法是硬编码LinkLayerType.Ethernet;抓到回环网卡时链路层类型不是以太网,头部结构完全不同,解析一层就断了。
解决:一律用raw.LinkLayerType,不要在代码里写死常量。回环流量如果确实要支持,单独对Null、Loop这两类链路层做分支。
5.3 界面卡死,内存一路涨
现象:包速率一高,界面拖动都费劲,任务管理器里内存几分钟涨到一两个 G 不回落。
原因:在OnPacketArrival里直接操作控件触发跨线程调度,每个包一次;或者把RawCapture对象全塞进List只增不减。
解决:回调里只做入队,界面用定时器批量取;列表设条数上限,比如 5 万行环形缓冲,超出丢最旧的;PacketInfo里只保留基础类型,别持有原始字节数组。
5.4 过滤器设了但好像没生效
现象:只写了tcp port 80,结果 ARP、UDP 全都抓上来了。
原因:device.Filter = ...赋值写在Open之前,被直接忽略;或者赋值时抛的异常被一个空的catch { }吞了,表达式压根没编译成功。
解决:赋值顺序固定成 Open → Filter → StartCapture,赋值外面套 try/catch 并把原始异常消息透到界面上。空 catch 在这个项目里是绝对的坏味道。
5.5 导出 pcap 时抛异常,路径明明是合法的
现象:点“保存”按钮,CaptureFileWriterDevice构造函数直接抛,换台机器又好了。
原因:底层是 C 库,对非 ASCII 路径的处理在不同环境下表现不一致,中文目录、带空格的用户名目录都可能出问题。
解决:先写到Path.GetTempPath()下的纯英文临时文件,写完再File.Move到用户选的目标路径。多一次 IO,但省掉一堆说不清的环境问题。
6. 二次开发:挂一层自定义协议解码与吞吐优化
想给自家的私有协议配个调试面板,改动最小的地方是解析出口,不是抓包链路。定义一个解码器接口,按端口注册:
public interface IPayloadDecoder { // 先按端口粗筛,再用魔数精校,避免同端口的其他流量误判 bool CanDecode(PacketInfo p, byte[] payload); string Decode(byte[] payload); } // 注册表放在配置里,加协议不用改编译逻辑 private static readonly Dictionary<int, IPayloadDecoder> _byPort = new() { [9000] = new CustomBinaryDecoder(), [9001] = new JsonLineDecoder(), };挂接点在PacketParser出口处,取ip.PayloadPacket?.PayloadData,长度为零直接跳过,命中注册表就调Decode填一个PayloadText字段。这样一来原程序所有功能不动,只是列表里多一列。
吞吐优化集中在三处,改完前后对比很明显:
| 位置 | 改法 | 效果 |
|---|---|---|
| 包到达回调 | 只做TryAdd,不解析不绘图 | 高包速率下不再阻塞驱动线程 |
| 解析环节 | 丢给Channel交给后台线程池 | UI 线程负荷下降一个量级 |
| 列表刷新 | 环形缓冲上限 5 万条,批量AppendBatch | 内存占用封顶,不再单调上涨 |
改完怎么验证?我的做法是先存一份基准 pcap,改造前跑一遍记住会话数和总字节数,改造后拿同一个文件回放,两个数字必须完全一致。差一条都要查到原因,因为解析器的偏移错误往往只体现在极少数报文上,靠肉眼翻列表是翻不出来的。
血泪经验是:不要一边改解析逻辑一边在真网卡上比对结果,线上流量每次都不一样,你永远说不清差异是改出来的还是本来就有的。从那以后我每次动抓包解析的代码,都强制先跑一遍 pcap 回放对拍,数字对上了才允许上真机。希望帮到你。
本文还有配套的精品资源,点击获取