简介:该资源面向需要在局域网内做设备发现与IP冲突排查的C#开发者、网络运维人员及安全入门学习者,通过抓取ARP报文识别网内在线设备,并整理出存在IP地址冲突的设备清单,适合作为轻量级网络监测功能直接集成到自有工具中。压缩包共36个文件,约1002KB,以11个cs源码文件为主体,辅以sln与csproj工程文件、exe可执行程序、config配置、dll依赖及resources资源文件,工程结构完整可直接编译调试。作者已将抓取ARP包的核心逻辑封装成一个独立类,并附上调用示例,同时打包了WinPcap_4_1_3安装程序、SharpPcap.dll与PacketDotNet.dll等必需依赖,省去读者自行查找底层抓包库与环境配置的麻烦。开发环境为VS2015、C#与.NET 4.5.2。目前已有560人学习下载,便于参考其类设计思路与报文过滤方式,快速复用到网络设备巡检、冲突告警等实际场景。
1. 局域网里最完整的设备清单,靠监听而不是 ping 扫段
多数人排查局域网的第一步,是写个 for 循环把整个网段 ping 一遍,认为这样能把在线设备找全。而在局域网里,一台设备只要想发起通信,就必须先用 ARP 包把自己的 IP 与 MAC 的映射关系广播出去;当两台设备争抢同一个 IP 时,冲突也会以“两个不同 MAC 回答同一个 IP”的形式直接暴露在链路上。也就是说,网络设备列表和 IP 冲突记录原来靠监听,ping 扫段并不能拿到。这件事值得搞清楚的地方在于:一次原本像“网有点卡”的模糊排障,因此变成能列出时间、IP、MAC 三要素的取证过程。而用 C# 配合 SharpPcap 抓 ARP 包,正是把这种被动监测做成常驻工具最短的一条路。
2. 为什么被动抓包比主动扫段更接近真相:ARP 协议栈的机制拆解
主动扫描得到的是“某个瞬间有人回了我一个包”,被动监听得到的是“这台设备自己承认了自己的地址”。两者的信息质量不在一个量级上,差别来自 ARP 协议本身的设计。
2.1 28 字节里的字段语义:ARP 帧究竟告诉了你什么
以太网帧头之后紧跟着 28 字节的 ARP 载荷(IPv4 over Ethernet 的固定形态)。它不是自描述协议,没有版本号也没有长度字段,全靠固定偏移去读,所以每一段的含义必须记牢:
| 偏移 | 长度 | 字段 | 常见取值与含义 |
|---|---|---|---|
| 0 | 2 | 硬件类型 | 0x0001,以太网 |
| 2 | 2 | 协议类型 | 0x0800,IPv4 |
| 4 | 1 | 硬件地址长度 | 6 |
| 5 | 1 | 协议地址长度 | 4 |
| 6 | 2 | 操作码 opcode | 1 请求,2 应答 |
| 8 | 6 | 发送方 MAC | 请求方自己的 MAC |
| 14 | 4 | 发送方 IP | 请求方自己的 IP |
| 18 | 6 | 目标 MAC | 请求时通常全 0,应答时是对方 MAC |
| 24 | 4 | 目标 IP | 想问的那个 IP |
关键在于最后两段。请求帧里的目标 MAC 一般填全 0,因为发送方根本不知道对方 MAC,这正是它发问的原因;而应答帧里,发送方 MAC 与发送方 IP 一定是自洽的一对。任何一台设备在链路上“自称”的身份,都写在这 8 个字节里。
opcode 只有 1 和 2 两种语义,看起来简陋,却带来一个很好的性质:ARP 是无状态、无序号、无认证的。它不校验“这个 IP 是不是真的属于你”,只负责把答案抄下来。这个设计对协议本身是效率上的胜利,对监测工具则是自由:只要有人声称某个 IP 归他,你就能看到;两个人同时声称,你也能看到。
2.2 免费 ARP 与 IP 冲突:冲突为什么总在 ARP 层先露头
IPv4 协议本身没有地址冲突检测机制——这一点常被忽略。协议栈给你配上一个 IP,就默认它是唯一的,重复了也没人拦。真正会“拦一下”的,是设备开机时自己做的探测:常见的实现思路是,主机在正式使用某个 IP 之前,先发一个发送方 IP 填 0.0.0.0、目标 IP 填待用地址的 ARP 请求(业界叫 ARP Probe,免费 ARP 的一种形态)。如果这个请求收到了应答,说明链路上已经有人占着这个地址,系统就弹窗报冲突。
这就是冲突监测的原理基础:冲突不是被“检测算法”发现的,而是被 ARP 协议本身逼出来的。免费 ARP 通常有三种形态——探测(发送方 IP 为 0)、通告(发送方 IP 等于目标 IP,用于告诉别人“我换 MAC 了”)、以及无故应答(没人问也回答,多用于冗余设备切换)。做监测时,这三种都值得单独打标记,因为它们的“可信度”和“意图”完全不同。
同样值得记住的是冲突的两种表现形态。第一种是同一 IP 被两个 MAC 声称,这是真冲突;第二种是同一 MAC 声称了多个 IP,这绝大多数时候不是冲突,而是网关做代理 ARP、负载均衡设备的虚拟 MAC、或者一台机器配了多个地址。把后者当冲突报出来,是把工具做成噪音的最快方式。
2.3 被动监听的物理边界:你能看到多少取决于你站在哪
抓包工具最容易被误解的一点,是以为开了混杂模式就能看到全网。混杂模式只是让网卡不再丢弃“目的 MAC 不是我”的帧,但交换机根本不会把这些帧送到你所在的端口上。交换机按 MAC 表转发,只有当帧是广播、组播、未知单播泛洪,或者目标就是你时,才会出现在你的口上。
好消息是 ARP 恰好大量使用广播:ARP 请求的目的 MAC 是全 F,天然会泛洪到同一广播域的所有端口。所以你在一个接入端口上,就能看到整个 VLAN 里大部分请求流量。看不到的是别人的应答——应答是单播,交换机会只送给问的人。
这就决定了几个硬边界:跨 VLAN 看不到(除非配了镜像或汇聚);跨路由看不到,因为路由器不转发广播,你只能看到自己网关的 ARP 交互;无线侧还叠加了客户端隔离和省电休眠,设备静默期可能长达几分钟。
提示:如果你的目标只是“摸清我所在的这个网段有哪些设备、有没有地址冲突”,那接入端口加混杂模式是够的。想做全楼层的资产盘点,抓包点必须挪到镜像口或者核心交换机上,这不是代码能弥补的。
3. SharpPcap 的收包链路:从网卡驱动到 C# 回调的每一跳
知道了该看什么,接下来的问题是:包怎么从网卡进到你的 C# 代码里,中间哪几跳最容易出问题。SharpPcap 本质上是一层托管封装,真正干活的是底层的抓包驱动,它在内核态维护一个环形缓冲区,你的进程只是从里面搬运。
3.1 打开设备:混杂模式、读超时和缓冲区各管什么
初始化的顺序很有讲究,参数填错的表现往往不是报错,而是“能跑但抓不到”:
using SharpPcap; using SharpPcap.LibPcap; // 1. 枚举网卡,实际项目里应让用户选,按描述和 IP 匹配更稳 var devices = CaptureDeviceList.Instance; var device = devices.FirstOrDefault(); if (device == null) throw new InvalidOperationException("没有可用抓包设备,检查驱动是否安装"); // 2. 注册回调要在 StartCapture 之前,否则会漏掉开头几个包 device.OnPacketArrival += OnArpPacketArrived; // 3. 打开设备:混杂模式 + 1000ms 读超时 + 4MB 内核缓冲 // 不同版本的 SharpPcap 重载不同,等价老写法是 device.Open(DeviceModes.Promiscuous, 1000) device.Open(new DeviceConfiguration { Mode = DeviceModes.Promiscuous, // 接收目的 MAC 不是本机的帧 ReadTimeout = 1000, // 无包时的返回间隔,影响关闭响应速度 BufferSize = 4 * 1024 * 1024 // 内核环形缓冲,抗突发 }); // 4. 过滤器交给驱动编译,只把 ARP 帧搬进用户态 device.Filter = "arp"; device.StartCapture();三个参数的作用要分清。混杂模式决定网卡是否丢弃“别人的帧”,在读超时期间没抓到包,底层调用会正常返回而不是一直阻塞,这个值设太大(比如 10 秒)会让停止采集时卡顿明显。缓冲区大小是最容易被忽略的一个:ARP 流量平时稀疏,但设备批量上电或 DHCP 集中续租时会瞬间冲高,缓冲区太小就直接丢包,而丢包在你代码里是完全静默的,表里少了设备你还以为是没人。
Filter是字符串形式的 BPF 表达式,由驱动在内核态编译执行。写成arp意味着所有非 ARP 帧在进入用户态之前就被丢掉,CPU 和内存开销能降一到两个数量级。想再窄一点可以写arp and ether broadcast,只收广播帧,代价是丢掉所有单播应答——而单播应答恰恰是判断“这台设备真的在这个 IP 上”的最强证据,一般不建议这么收窄。
3.2 回调线程模型:为什么不能在里面更新界面
OnPacketArrival是在抓包线程上同步触发的。这个线程的职责只有一个:把帧从内核缓冲区搬走。一旦你在里面做耗时操作——查库、写日志文件、更新 WinForm 控件、发 HTTP 请求——搬运速度就会掉下来,内核缓冲区填满后开始丢包,表现是“平时挺好,一忙就漏设备”。
正确的做法是回调只做最廉价的一件事:拷贝字节,塞进无锁队列,立刻返回。解析和入库交给另一个线程批量消费。
// 抓包线程与解析线程之间的唯一交接点 private readonly System.Collections.Concurrent.ConcurrentQueue<byte[]> _rawQueue = new(); private void OnArpPacketArrived(object sender, PacketCapture e) { // GetPacket() 返回的数组是复用的,必须自己拷一份再入队 var data = e.GetPacket().GetPacket().Data.ToArray(); _rawQueue.Enqueue(data); // 这里不要做任何解析、日志、UI 操作 }这里有一处实现细节很容易出错:底层给到你的缓冲区会被循环复用,同一个数组下一秒就可能装进另一个包。所以入队前必须拷贝。反过来,如果解析写得很轻(只是读固定偏移、更新字典),直接在回调里做也不是不行,但一旦以后加了“冲突时发邮件”这种逻辑,就会变成间歇性丢包的隐患——这类问题事后排查成本极高,属于不报错的失败。
消费侧的节奏建议按批处理:每次从队列里取到 500 条或累计 200 毫秒就处理一轮,然后Thread.Sleep(10)让出 CPU。批处理的好处是字典的锁竞争从“每包一次”降到“每批一次”,这在千兆环境下差别很明显。
4. 设备表与冲突表怎么建:状态机与判定阈值
抓到的包本身没有价值,价值在于把一串无状态的帧收敛成有状态的表。这里面真正需要设计的是三件事:键怎么选、时间窗口怎么定、什么条件下才敢下冲突结论。
4.1 表结构:二级字典加时间戳,别用单一映射
最直觉的做法是维护一个Dictionary<IPAddress, PhysicalAddress>。这个结构一旦遇到冲突就崩了:同一个 IP 只能存一个 MAC,第二条记录要么覆盖第一条(冲突就此消失),要么被丢弃(冲突同样消失)。冲突检测的前提是允许一个键对应多个值,所以要用二级结构。
public sealed class ArpRecord { public IPAddress Ip = IPAddress.None; public PhysicalAddress Mac = PhysicalAddress.None; public long FirstSeenTicks; // 用单调时钟,别用 DateTime.Now public long LastSeenTicks; public int HitCount; // 该 (IP, MAC) 对被确认的次数 public bool FromGratuitous; // 是否来自免费 ARP } public sealed class ArpTable { // 外层键是 IP,内层键是 MAC,天然容纳"一个 IP 多个 MAC" private readonly Dictionary<IPAddress, Dictionary<PhysicalAddress, ArpRecord>> _byIp = new(); private readonly Dictionary<PhysicalAddress, HashSet<IPAddress>> _byMac = new(); }时间戳用Environment.TickCount64或Stopwatch,不用DateTime.Now。原因很实在:系统校时、夏令时切换、笔记本休眠唤醒,都会让墙上时钟出现跳跃,一个向前跳两分钟就足以让时间窗口判断全部失准,你会在日志里看到“两台设备同时出现”却怎么也对不上。单调时钟只增不减,专为测间隔设计。
_byMac这张反向表是专门为“同一 MAC 多个 IP”准备的。它不产出冲突告警,但它能告诉你一台设备换了地址、或者网关在做代理 ARP,这在解读结果时很有用。
4.2 冲突判定:两个条件同时成立才下结论
判定逻辑的核心是“两个 MAC 在时间上重叠地活跃着”。如果一个 MAC 已经十分钟没出现过,那更可能是 DHCP 把地址重新分给了新设备,属于正常流转,不是冲突。
// 判定窗口:两个 MAC 必须都在这个时间内被确认过,才算"同时活跃" private readonly long _activeWindowMs = 120_000; private const int MinHits = 2; // 单次出现不算,压低随机丢包造成的误报 public IEnumerable<ConflictInfo> DetectConflicts(long nowTicks) { foreach (var (ip, macs) in _byIp) { var active = macs.Values .Where(r => nowTicks - r.LastSeenTicks <= _activeWindowMs && r.HitCount >= MinHits) .OrderByDescending(r => r.LastSeenTicks) .ToList(); if (active.Count < 2) continue; // 最新的两个 MAC 作为冲突双方,其余作为附加证据一并带出 yield return new ConflictInfo { Ip = ip, Current = active[0].Mac, Previous = active[1].Mac, DetectedAtTicks = nowTicks, MacCount = active.Count }; } }_activeWindowMs取 120 秒是个经验值。太短(比如 10 秒)会漏掉那些只在开机时喊一次就沉默的设备;太长(比如半小时)会把正常的地址回收再分配误判成冲突。这个值跟你的 DHCP 租约时间、设备换机频率直接相关,实际部署时值得按现场情况调。
MinHits是抑制误报的第二道闸。只看单次出现就报警,遇到丢包重传、抓包点切换、甚至同一帧被两次捕获(镜像口配置重叠时真会发生),都会产生假阳性。要求同一个 (IP, MAC) 对至少被确认两次,代价是首次发现的延迟多几秒,换来的是告警可信度大幅提升。
判断流程本身也要有先后:先判断这个 IP 是不是网关、是不是已知的虚拟地址段(冗余网关的虚拟 MAC 有固定格式,末尾通常是 00:00:5E:00:01:xx),命中的直接标记为“疑似代理/虚拟”,不进入冲突候选。这一步能把大部分噪音在源头掐掉。
4.3 主动免费 ARP 补齐被动盲区
被动监听有个天生的短板:只能看到“有人在说话”。那些安静的服务器、打印机、IoT 设备,可能几小时都不发一个包,你在表里永远看不到它。
补齐的办法是有限度地主动发问:构造一个发送方 IP 为 0.0.0.0 的 ARP 请求,目标 IP 填你要探测的地址,广播出去,谁占着这个地址谁就会应答。应答帧是单播回给你的,所以这一招不受交换机转发限制。SharpPcap 打开设备后可以直接发送原始帧。
// 构造 ARP Probe:SPA 填 0.0.0.0,避免污染对方的 ARP 缓存 private static byte[] BuildArpProbe(PhysicalAddress selfMac, IPAddress targetIp) { var frame = new byte[42]; // 14 以太网头 + 28 ARP Array.Fill(frame, (byte)0xFF, 0, 6); // 目的 MAC 全 F,广播 selfMac.GetAddressBytes().CopyTo(frame, 6); // 源 MAC frame[12] = 0x08; frame[13] = 0x06; // EtherType = ARP frame[14] = 0x00; frame[15] = 0x01; // 硬件类型 以太网 frame[16] = 0x08; frame[17] = 0x00; // 协议类型 IPv4 frame[18] = 6; frame[19] = 4; // 地址长度 frame[20] = 0x00; frame[21] = 0x01; // opcode = request selfMac.GetAddressBytes().CopyTo(frame, 22); // 偏移 28..31 是发送方 IP,保持全 0:这是 Probe 与普通请求的关键区别 targetIp.GetAddressBytes().CopyTo(frame, 38); return frame; }发送方 IP 填 0 不是可选项。填成你自己的 IP,对方的协议栈会顺手把这个映射写进它的 ARP 缓存,等于你悄悄改了别人的表;填 0 则不会产生任何缓存副作用,这就是 ARP Probe 的标准做法。
探测要控速。整个 /24 扫一遍是 254 个包,按每秒 20 个发,十几秒结束,对现代交换机的冲击可以忽略;但如果无节制地全速发,会明显拉高广播域负载,在老旧设备上甚至可能触发广播抑制。扫描间隔建议不低于 5 分钟,把主动探测当成定期补漏,主力仍然是持续被动监听。
5. 抓不到包、误报冲突:五条踩坑记录
这一节记录的都是会真实消耗时间的问题。它们的共同点是:程序不报错,甚至看起来在正常工作,只是结果不对。
5.1 现象:程序能启动,但列表里只有本机
原因排查到最后基本落在两处。一是抓包点选错:笔记本接在接入交换机的一个普通口上,交换机只把广播和发给你的帧送过来,别人的单播应答你一条都看不到,表里自然只有自己。二是根本没装抓包驱动,CaptureDeviceList.Instance返回空集合,代码里FirstOrDefault()拿到 null 后被后续逻辑悄悄绕过。
解决办法是先在代码里显式断言设备列表非空并抛出明确异常,别让空集合流入后续流程。抓包点的问题只能靠环境解决:要么把程序挪到核心交换机的镜像口,要么接受“只能看到广播 ARP 请求”这个现实,并且把界面上“设备总数”的口径写清楚,别让使用者误以为是全网资产数。
5.2 现象:抓到的 MAC 全是乱码,IP 也对不上
十有八九是 VLAN 环境。带 802.1Q 标签的帧,以太网头后面多了 4 字节标签,ARP 载荷的起始偏移从 14 变成了 18。代码里如果硬编码frame[14]开始读硬件类型,读到的其实是标签和 ARP 头的混合体,解析出来的 MAC 自然是一串无意义的字节。
解决方法是解析前先看 EtherType:等于 0x8100 就说明有标签,偏移加 4,再看下一层的 EtherType 是不是 0x0806。这个判断要写成循环而不是一次 if,因为双标签(QinQ)在汇聚链路上确实存在。另外 BPF 表达式里的vlan关键字会改变后续偏移的语义,过滤器写vlan and arp时,驱动层的偏移假设可能和你的解析代码不一致,两边必须对齐着改。
5.3 现象:冲突告警刷屏,但实际网络没问题
这是把工具做成噪音的典型场景。来源通常有三种:笔记本和手机在 DHCP 续租前后短暂出现两个 MAC;手机开启了 MAC 随机化,同一个 IP 在不同时间对应不同随机 MAC;网络里存在冗余网关或负载均衡,它们用同一个虚拟 MAC 应答多个 IP。
对应解法是三层过滤。第一层在时间上,要求两个 MAC 的活跃窗口重叠,而不是“历史上都出现过”。第二层在频次上,单次出现不报警,要求至少两次确认。第三层在语义上,维护一份排除清单,把网关 MAC、虚拟 MAC 段、已知的代理 ARP 设备放进去。三层叠加后,剩下的告警基本都值得看一眼。
5.4 现象:跑一段时间后设备越来越少,重启就恢复
这是缓冲区溢出导致的静默丢包。表现很有迷惑性:启动后数据很全,越跑越稀疏,重启程序又正常,看起来像内存泄漏,实际上是消费速度跟不上生产速度。ARP 流量本身不大,但如果回调里做了写数据库、更新界面这类操作,单包处理时间上到毫秒级,峰值期就顶不住。
解法是把回调压到极限:只拷贝字节、入队、返回。解析和存储全部挪到消费线程批处理。判断有没有丢包,可以对照底层统计里的接收计数和实际入队计数,两个数字对不上就说明中间漏了。这个对照最好做成常驻指标,而不是出问题才想起来加。
5.5 现象:程序在开发机正常,部署到目标机器就报权限错误
抓包是特权操作。Windows 上需要管理员权限并且装了对应的抓包驱动;Linux 上要么 root,要么给二进制加 CAP_NET_RAW 能力。更隐蔽的一种是:程序以服务方式运行时用的是系统账户,权限有,但选中的网卡索引在服务会话里跟交互式会话不一致,枚举出来的设备名可能对不上。
解法是启动时先做一次自检:枚举设备、尝试打开、立即关闭,任何一步失败就输出带明确原因的错误,而不是等到运行几分钟后没数据才让人去猜。设备选择也别靠索引,用描述字符串或者绑定的 IP 去匹配,稳定性好得多。
6. 设计取舍:被动为主、主动为辅,以及阈值这件事没人能替你决定
这套东西做完之后,最值得回头看的一个决定是:为什么不干脆全用主动扫描。答案在于两类手段的失败模式完全不同。主动扫描的失败是“漏”,设备开了防火墙、静默不响应,你就永远看不到它;被动监听的失败是“慢”,设备不说话,你就要等到它下次开口。两者叠加,漏报被互补掉了,代价是要维护两套逻辑和一份排除清单。
第二个取舍是抓包位置。把程序放在每个终端上跑,部署成本低但视野只有自己那一口;放在镜像口上,视野是整个广播域,但部署要网络管理员配合,而且镜像口的带宽是共享的,抓全量流量会拖垮采集端。我的选择是镜像口只放 ARP 过滤器,把采集压到最窄,用极小的流量换取全域视野。
第三个取舍关于阈值。_activeWindowMs和MinHits这两个数字,没有任何一组取值能适配所有网络。写字楼里设备频繁换机,窗口要短;机房里的服务器地址几年不变,窗口长一点反而更准。所以这两个值必须做成可配置,并且在界面上把“当前窗口”显示出来——让人知道自己看的是多大时间尺度上的结论,比偷偷用一组默认值诚实得多。
验证方法其实很朴素:找两台机器手动配上同一个空闲 IP,看工具在多少秒内报出来、报的是哪两个 MAC;再把其中一台的网线拔掉,看这条告警多久之后自动过期。这两个动作能把判定逻辑的正确性和时效性都验一遍,比看代码可靠。毕竟冲突检测这件事,没能报出来和报了假警,对使用者的伤害是同等的。
从那以后,我每接手一个陌生网段,第一件事都是先挂十分钟纯被动监听,把设备表和冲突表拉出来看一遍,再决定要不要做主动探测——先偷听,再发问,顺序反了就容易把排查变成制造问题。希望帮到你。这套 C# 工程把上面提到的帧解析、二级表结构、冲突判定和排除清单都写成了可直接编译运行的代码,拿下来对着自己的网段跑一遍,比读十遍协议文档都直观。
本文还有配套的精品资源,点击获取