
简介面向需要在局域网内排查设备与IP冲突的C#开发者这份工程以SharpPcap抓取ARP报文为核心实现在线设备发现与冲突设备列表汇总。作者将抓包逻辑封装为独立类并配有调用示例、WinPcap安装文件及SharpPcap、PacketDotNet依赖库可在VS2015与.NET 4.5.2环境下编译调试适合网络监测、运维排障类项目参考。压缩包约1MB共36个文件以11个cs源码为主体辅以exe示例程序、dll依赖、config与resx资源文件及sln解决方案结构完整便于按模块阅读与二次集成。已有562人学习可从中了解ARP请求与响应解析、IP-MAC映射维护、冲突判定及设备列表输出等实现思路并基于封装类快速扩展自己的局域网监测工具。示例程序可帮助读者理解抓包类的调用方式与结果呈现依赖组件也已随包提供便于减少环境配置上的反复尝试。1. 抓 ARP 包这件事落地到局域网设备台账与冲突告警上周又碰到一次某台测试机被手工配了个已经被占用的 IP装好的服务起不来日志里全是「地址已被占用」之类的报错排查了一下午才发现是两台机器的 IP 撞了。这种故障在内网里特别常见又特别难定位——因为没有任何一台机器会主动告诉你「有人跟我抢地址」。这个项目就是冲着这个场景来的在局域网里用C# 配合 SharpPcap 抓 ARP 包被动收集网内所有在说话的设备维护一份IP ↔ MAC的设备清单同时监测同一个 IP 被多个 MAC 冒名顶替的情况输出冲突设备列表。整套东西不依赖交换机的 SNMP也不用装额外的服务端一台常开的机器跑个命令行程序就够了。适合手上有内网要维护、又不想为这点需求上一整套网管平台的开发者也适合想入门抓包编程、拿一个真能跑起来的项目练手的人。2. 架构与运行环境三层结构、依赖清单和第一次跑通这个项目最容易做错的地方是把「抓包」「解析」「判定」全部塞进一个OnPacketArrival回调里。回调是抓包线程直接调用的里面只要做一次磁盘写入或者一次耗时的字符串拼接丢包率立刻上去了。所以我在结构上做的第一件事就是切开采集层只管把帧捞上来解析层转成纯数据对象判定层在自己的消费者线程里慢慢算。2.1 目录结构与分层职责LanArpWatcher/ ├─ src/ │ ├─ LanArpWatcher.Core/ │ │ ├─ Capture/ │ │ │ ├─ ArpCaptureService.cs // 打开网卡、挂 BPF 过滤器、收帧 │ │ │ ├─ NicBinder.cs // 把 pcap 设备与系统网卡对上号 │ │ │ └─ ArpProber.cs // 主动发 ARP 请求补齐单播回包 │ │ ├─ Parsing/ │ │ │ └─ ArpFrameParser.cs // RawCapture - ArpFrame │ │ ├─ Detection/ │ │ │ ├─ DeviceTable.cs // (IP,MAC) 设备台账 │ │ │ └─ ConflictDetector.cs // 冲突状态机 冷却 │ │ ├─ Model/ │ │ │ ├─ ArpFrame.cs │ │ │ ├─ NetDevice.cs │ │ │ └─ ConflictRecord.cs │ │ └─ Sink/ │ │ ├─ IDeviceSink.cs │ │ └─ SqliteSink.cs │ └─ LanArpWatcher.Cli/ │ └─ Program.cs └─ tests/ └─ LanArpWatcher.Tests/采集和判定之间我用的是一条ChannelArpFrame有界队列容量给 8192满了就丢并计数。这样即使判定层在写数据库时卡了 200ms抓包线程也不会跟着卡。判定层单线程消费天然避免了多网卡场景下的并发写冲突——这一点在后面讲冲突状态机时会体现出来。依赖就三个包别的一概不加# 建工程 dotnet new console -n LanArpWatcher.Cli -o src/LanArpWatcher.Cli dotnet new classlib -n LanArpWatcher.Core -o src/LanArpWatcher.Core dotnet add src/LanArpWatcher.Cli reference src/LanArpWatcher.Core # 抓包与解析 dotnet add src/LanArpWatcher.Core package SharpPcap dotnet add src/LanArpWatcher.Core package PacketDotNet # 落库 dotnet add src/LanArpWatcher.Core package Microsoft.Data.Sqlite提示Windows 上 SharpPcap 底层要有一个包捕获驱动才能工作安装时记得勾上 WinPcap API 兼容模式否则CaptureDeviceList.Instance会拿到空列表而且不报错——这是新手最容易卡住的第一个点。程序本身必须以管理员权限启动否则打开网卡会抛权限异常。跑起来之后先别急着抓第一步是把设备列表打出来看看认出几块网卡# 编译并列出可用捕获设备 dotnet run --project src/LanArpWatcher.Cli -- --list # 指定网卡序号正式开抓CtrlC 结束 dotnet run --project src/LanArpWatcher.Cli -- --if 22.2 网卡绑定别让程序抓错网卡CaptureDeviceList.Instance返回的设备名在 Windows 上长这样\Device\NPF_{一串 GUID}跟你在「网络连接」里看到的「以太网 2」完全对不上。如果你只按顺序取第 0 个很可能抓到的是虚拟网卡或者已经断开的那块现象就是程序跑得好好的一条 ARP 都没有。我的做法是用系统网卡的 GUID 去反查 pcap 设备这样名字里带 GUID 的就一定能匹配上public sealed record NicBinding(NetworkInterface Nic, ICaptureDevice Device, IPAddress LocalIp, IPAddress Mask); public static class NicBinder { public static ListNicBinding Bind() { var result new ListNicBinding(); foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { // 只保留已启用、非回环、以太网或无线 if (nic.OperationalStatus ! OperationalStatus.Up) continue; if (nic.NetworkInterfaceType is NetworkInterfaceType.Loopback) continue; var ipProps nic.GetIPProperties(); var v4 ipProps.UnicastAddresses .FirstOrDefault(a a.Address.AddressFamily AddressFamily.InterNetwork); if (v4 is null) continue; // 没有 IPv4 的网卡对 ARP 没意义 // 关键点pcap 设备名里嵌着网卡 GUID var dev CaptureDeviceList.Instance.FirstOrDefault(d d.Name.Contains(nic.Id, StringComparison.OrdinalIgnoreCase)); if (dev is null) continue; result.Add(new NicBinding(nic, dev, v4.Address, v4.IPv4Mask)); } return result; } }nic.Id就是那块网卡的 GUID--list命令把它和 IP、子网掩码、设备描述一起打成一张表。多网卡有线 无线 一堆 Hyper-V 虚拟网卡的机器上这张表是后面所有操作的入口扫哪个网段、往哪个网段发探测包都靠它决定。3. 核心链路拆解从一个 ARP 帧到一条设备记录整个项目的技术含量集中在这一段。ARP 报文本身结构极简单——28 字节固定长度字段就六个发送方 MAC、发送方 IP、目标 MAC、目标 IP、操作码、协议类型。难点不在解析在于「你能看到哪些帧」和「看到的帧能不能信」。3.1 打开网卡与 BPF 过滤器过滤器一定要在内核层做。ARP 在整个内网流量里占比很低但如果你不加过滤卡上所有流量都会从内核态拷到用户态千兆网卡跑满的时候 CPU 直接吃满。BPF 表达式写成arp就够了.NET侧不再做二次判断。public sealed class ArpCaptureService : IDisposable { private readonly ListICaptureDevice _devices new(); public event EventHandlerArpFrame? FrameArrived; public long DroppedCount; // 队列满导致的丢弃计数 public void Start(IEnumerableNicBinding bindings) { foreach (var binding in bindings) { var device binding.Device; // 混杂模式能收到发给别人的广播帧1000ms 是读超时 device.Open(DeviceModes.Promiscuous, 1000); // BPF 过滤器只放行 ARP一定要在 Open 之后设置 device.Filter arp; device.OnPacketArrival (_, e) { var raw e.GetPacket(); var frame ArpFrameParser.Parse(raw); if (frame is null) return; if (!FrameArrived.HasValue) return; // 简单背压 // 交给上层写 Channel不在这里做任何耗时操作 FrameArrived.Invoke(this, frame); }; device.StartCapture(); _devices.Add(device); } } public void Dispose() { foreach (var d in _devices) { try { d.StopCapture(); } catch { /* 已停止 */ } d.Close(); } } }DeviceModes.Promiscuous让网卡接收所有经过它的帧readTimeout给 1000ms 是为了让StopCapture能及时返回不然退出时可能卡住一秒以上。FrameArrived的订阅方只做一件事channel.Writer.TryWrite(frame)写不进去就把DroppedCount加一。有一点必须说清楚混杂模式在交换网络里不会让你看到别人的单播。它只在集线器、镜像端口、或者虚拟交换机内部这几类环境下有用。所以后面真正的信息来源是广播帧和主动探测不是靠混杂模式硬抓。3.2 RawCapture 到 ArpFrame解析与字段清洗解析层是个纯函数输入RawCapture输出不可变记录出错返回null绝不抛异常——回调里抛异常会把整个抓包线程带崩。public static class ArpFrameParser { public static ArpFrame? Parse(RawCapture raw) { // 只处理以太网链路层raw 链路如 Linux cooked字段偏移不同 if (raw.LinkLayerType ! LinkLayers.Ethernet) return null; Packet packet; try { packet Packet.ParsePacket(raw.LinkLayerType, raw.Data); } catch { return null; } var arp packet.ExtractArpPacket(); if (arp is null) return null; // 双重校验硬件类型必须是以太网协议地址必须是 IPv4 if (arp.HardwareAddressType ! LinkLayers.Ethernet) return null; if (arp.ProtocolAddressType ! EthernetPacketType.IpV4) return null; var senderMac arp.SenderHardwareAddress; // 全 0 MAC 是探测帧的占位值不能当设备入账 if (senderMac.GetAddressBytes().All(b b 0)) return null; return new ArpFrame { Timestamp raw.Timeval.Date, // 用抓包时间不用 DateTime.Now Operation arp.Operation, // Request / Reply SenderMac senderMac, SenderIp arp.SenderProtocolAddress, TargetMac arp.TargetHardwareAddress, TargetIp arp.TargetProtocolAddress, IsGratuitous arp.Operation OperationCode.Request arp.SenderProtocolAddress.Equals(arp.TargetProtocolAddress) }; } }几个判断的理由HardwareAddressType校验是为了排除 PPP 之类链路上封装进来的怪东西raw.Timeval.Date是抓包驱动打的时间戳精度比DateTime.Now高而且不受 GC 停顿影响IsGratuitous标记的是免费 ARP——发送方 IP 和目标 IP 相同的那种通常是设备刚上电在宣告自己或者是冲突检测工具在探路后面判定阶段会用到这个标记。3.3 主动探测把看不见的单播回包变可见只靠被动监听会有一个致命的盲区。ARP 请求是广播的全内网都能看到但应答是单播的只发给请求方。也就是说你能知道「A 在问 B 的 MAC」但看不到「B 回答了什么 MAC」——除非请求方就是你自己。补救办法很直接自己当请求方。对网段内所有 IP 轮流发一遍 ARP 请求对方回的应答是单播给你的你自然能收到。public sealed class ArpProber { private readonly ICaptureDevice _device; private readonly PhysicalAddress _selfMac; private readonly IPAddress _selfIp; private static readonly PhysicalAddress Broadcast PhysicalAddress.Parse(FF-FF-FF-FF-FF-FF); private static readonly PhysicalAddress Zero PhysicalAddress.Parse(00-00-00-00-00-00); public ArpProber(ICaptureDevice device, PhysicalAddress selfMac, IPAddress selfIp) (_device, _selfMac, _selfIp) (device, selfMac, selfIp); // 构造并发送一个 ARP Request public void Probe(IPAddress target) { var arp new ArpPacket( LinkLayers.Ethernet, // 硬件类型 EthernetPacketType.IpV4, // 协议类型 OperationCode.Request, // 操作码请求 _selfMac, _selfIp, // 发送方 MAC / IP Zero, target); // 目标 MAC 未知填 0目标 IP 是探测对象 var eth new EthernetPacket(_selfMac, Broadcast, EthernetPacketType.Arp) { PayloadPacket arp }; _device.SendPacket(eth); } }网段枚举从前面拿到的LocalIp和Mask算把掩码取反得到主机位按 CIDR 遍历。public static IEnumerableIPAddress EnumerateHosts(IPAddress ip, IPAddress mask) { var ipBytes ip.GetAddressBytes(); var maskBytes mask.GetAddressBytes(); var network new byte[4]; var broadcast new byte[4]; for (int i 0; i 4; i) { network[i] (byte)(ipBytes[i] maskBytes[i]); broadcast[i] (byte)(network[i] | ~maskBytes[i]); } uint start BitConverter.ToUInt32(network.Reverse().ToArray(), 0) 1; // 跳过网络地址 uint end BitConverter.ToUInt32(broadcast.Reverse().ToArray(), 0) - 1; // 跳过广播地址 for (uint v start; v end v start; v) // v start 防溢出 { var b BitConverter.GetBytes(v).Reverse().ToArray(); yield return new IPAddress(b); } }探测节奏必须限速血泪经验一个 /16 网段65534 个地址如果不加间隔地猛发交换机控制平面的 ARP 队列会被打满一小段时间监控上能看到丢包告警严重的会触发端口保护。我一般设成每 20ms 发一个一轮 /24 网段大概 5 秒扫完一轮 /16 要二十多分钟那就拆成多个分片轮转。这个参数写成配置项别硬编码。3.4 设备台账(IP, MAC) 复合主键的双向索引台账这张表的主键设计很容易做错。如果拿 IP 当主键冲突时第二个 MAC 会把第一个覆盖掉冲突就永远发现不了。所以主键必须是(IP, MAC)组合IP 和 MAC 各自再建一个反查索引。public sealed class DeviceTable { private readonly Dictionary(string Ip, string Mac), NetDevice _byKey new(); private readonly Dictionarystring, ListNetDevice _byIp new(); private readonly Dictionarystring, ListNetDevice _byMac new(); public NetDevice Upsert(ArpFrame frame) { var ip frame.SenderIp.ToString(); var mac frame.SenderMac.ToString(); if (!_byKey.TryGetValue((ip, mac), out var dev)) { dev new NetDevice { Ip ip, Mac mac, Vendor OuiLookup.Resolve(frame.SenderMac), // 本地 OUI 前缀表纯离线 FirstSeen frame.Timestamp }; _byKey[(ip, mac)] dev; AddTo(_byIp, ip, dev); AddTo(_byMac, mac, dev); } dev.LastSeen frame.Timestamp; dev.HitCount; // 记录该 IP 上见过的 MAC 数量1 就是冲突候选 dev.PeerMacCount _byIp[ip].Count - 1; return dev; } public IReadOnlyListNetDevice DevicesOnIp(string ip) _byIp.TryGetValue(ip, out var list) ? list : Array.EmptyNetDevice(); private static void AddTo(Dictionarystring, ListNetDevice map, string key, NetDevice dev) { if (!map.TryGetValue(key, out var list)) map[key] list new ListNetDevice(2); list.Add(dev); } }Vendor用本地 OUI 前缀文本做前缀匹配离线、零依赖用不上任何在线接口。PeerMacCount这个字段是给上层做快速判断用的但它只是「历史上见过几个」真正的冲突判定还要考虑时间窗——因为一台设备换了 IP比如 DHCP 到期重租也会让旧的 IP 上出现两个 MAC这不算冲突。4. 冲突判定引擎把「见过两个 MAC」变成「现在正在冲突」这一层是整个项目里最容易被低估的部分。我第一版写得非常简单同一个 IP 出现两个 MAC 就报。上线第二天告警平台被刷爆了——一台笔记本从会议室走到工位DHCP 换了 IP旧 IP 上残留的记录就成了「冲突」。噪声压不住告警就等于没有。4.1 冲突的三种形态与各自的判定规则先把概念分清楚规则才能写对形态现象成因判定要点IP 冲突同一 IP 短时间内出现两个 MAC且两个 MAC 都在活跃手工配了静态 IP 撞车、DHCP 池重叠双方都有 ARP 流量窗口内交替出现地址漂移同一 IP 先后出现两个 MAC但旧的 MAC 不再出现DHCP 重新分配、设备换机旧 MAC 超过窗口无流量一机多 IP同一 MAC 对应多个 IP双网卡、虚拟化桥接、网关一般不算故障只做标记真正要报的是第一种。判定的核心是「活跃度」而不是「出现过」两个 MAC 在同一个滑动窗口内都有流量才说明它们此刻都在宣称拥有这个 IP。第二种情况的特征正好相反旧 MAC 在窗口内完全静默。public void Feed(ArpFrame frame) { if (frame.SenderIp.Equals(IPAddress.Any)) return; var ip frame.SenderIp.ToString(); var mac frame.SenderMac.ToString(); var now frame.Timestamp; ConflictRecord? hit null; lock (_gate) { if (!_window.TryGetValue(ip, out var macs)) _window[ip] macs new Dictionarystring, DateTime(StringComparer.OrdinalIgnoreCase); macs[mac] now; // 关键把窗口内已经静默的 MAC 剔除剩下才是当前活跃 foreach (var stale in macs.Where(kv now - kv.Value _activeWindow).Select(kv kv.Key).ToList()) macs.Remove(stale); if (macs.Count 2) hit BuildRecord(ip, macs.Keys.ToArray(), now); } if (hit is not null) ConflictFound?.Invoke(this, hit); }_activeWindow是这套判定里最关键的一个数。ARP 表项老化时间在多数系统上是几十秒到几分钟设备在线时会周期性地发免费 ARP 刷新所以窗口设成5 分钟比较稳短于这个值正常设备的心跳间隔可能被误判成静默长于这个值冲突消失后的残留会拖很久。这个值我在不同网段上调过几轮办公网 5 分钟够用物联网那类设备休眠唤醒周期长的场景要放宽到 15 分钟。4.2 冷却与状态机把重复告警压下去即使窗口算对了同一个冲突还是会持续触发——因为冲突双方都在不停地发 ARP每秒都可能重新满足条件。如果每帧都往外抛一条告警一分钟几百条。解决办法是给每个 IP 挂一个状态机状态在Normal → Suspect → Confirmed → Resolved之间迁移只在迁移时发事件private ConflictRecord? BuildRecord(string ip, string[] macs, DateTime now) { // 冷却期内不重复上报 if (_lastReported.TryGetValue(ip, out var last) now - last _cooldown) return null; _lastReported[ip] now; return new ConflictRecord { Ip ip, Macs macs, DetectedAt now, // 用最小 MAC 作为疑似真实设备的提示只是提示不做结论 PrimaryMac macs.OrderBy(m m, StringComparer.OrdinalIgnoreCase).First() }; }_cooldown给 10 分钟。同时还要有一条「恢复」路径某个 IP 在_activeWindow之后只剩一个 MAC就补发一条Resolved记录把数据库里那条resolved_at填上。没有恢复事件运维看到的永远是一张只增不减的告警表时间长了就没人看了。注意MAC 排序选「主设备」只是给人看的提示别拿它做自动化处置。真实环境中冲突双方哪台是「后来的」靠 ARP 是判断不出来的需要结合交换机端口 MAC 表或者 DHCP 租约记录那已经超出这个项目的范围了。4.3 落库与输出冲突设备列表怎么给存储用 SQLite两张表主键设计保证幂等重复运行不会产生脏数据CREATE TABLE IF NOT EXISTS device ( ip TEXT NOT NULL, mac TEXT NOT NULL, vendor TEXT, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, hit_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (ip, mac) ); CREATE TABLE IF NOT EXISTS ip_conflict ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL, mac_list TEXT NOT NULL, -- 逗号分隔 detected_at TEXT NOT NULL, resolved_at TEXT ); CREATE INDEX IF NOT EXISTS idx_conflict_ip ON ip_conflict(ip, detected_at DESC);写入用INSERT ... ON CONFLICT(ip, mac) DO UPDATE SET last_seen?, hit_counthit_count1一条语句搞定新增和更新。批量写的时候攒够 200 条或者超过 1 秒就 flush 一次别一帧一条——SQLite 在机械盘上单条事务的 fsync 开销能把这个程序拖到没法用。命令行输出给两种视图运维和开发各取所需# 设备台账按 IP 排序 dotnet run --project src/LanArpWatcher.Cli -- --if 2 --report devices --out devices.csv # 只看当前活跃冲突 dotnet run --project src/LanArpWatcher.Cli -- --if 2 --report conflicts --since 24h--report conflicts输出的是「resolved_at为空 最近 24 小时内检测到」的交集格式是IP | MAC列表 | 首次检测时间 | 厂商直接贴到工单里就能用。devices.csv那一路是给资产核对用的把vendor列按厂商聚合一下谁把家用路由器插到办公网里了一目了然。5. 避坑与排查五个真踩过的坑这一节写的全是上线后被现实教育过的内容现象、原因、解决一条条来。5.1 抓包链路上的三个坑现象一程序启动了日志里一条 ARP 都没有。先查三件事是不是管理员权限、设备列表里认出来几块网卡、Filter赋值有没有抛异常。最常见的原因是装包捕获驱动时没勾兼容模式CaptureDeviceList.Instance返回空集合但完全不报错foreach一圈什么都不做程序看起来「正常运行」。第二个原因是选错了网卡——Hyper-V、VMware、Docker 都会注册虚拟网卡序号排在最前面的往往是这些。解决就是前面那套 GUID 绑定加上--list先看一眼确认LocalIp是你预期的那个地址再往下走。现象二能看到大量 Request几乎看不到 Reply。这不是 bug是 ARP 的工作方式。请求是广播全内网可见应答是单播只有请求方能收到。如果你的程序只做被动监听那就只能靠 Request 里的SenderMac/SenderIp建表答不上来的设备永远缺失。解决办法就是第 3.3 节那个主动探测——自己发请求让应答指向自己。加完之后设备发现率能从三四成提到九成以上。剩下那一成多半是防火墙拦了 ICMP 又拦了 ARP 的设备或者跨网段被路由器隔开的抓不到就是抓不到别硬追。现象三抓了几分钟程序内存一路涨到几个 G。OnPacketArrival里直接做了数据库写入或者把RawCapture对象整个存进了 List。RawCapture持有底层缓冲区攒着不放等于把整条流量都留在内存里。正确做法是在回调里立刻转成ArpFrame值类型字段的小对象RawCapture引用当场丢掉。另外Channel一定要设上界我给的 8192 是经验值对应大约几秒钟的突发流量超过就丢并计数用DroppedCount在报表里体现出来——悄悄丢包比明确丢包危险得多。5.2 判定与告警上的两个坑现象四同一个 IP 一天报了上百次冲突。ARP 是周期性重播的冲突双方只要都活着条件就一直成立。第一版没有冷却每帧都触发一次事件。修法是给每个 IP 加冷却窗口10 分钟并且用状态机只在状态迁移时发事件。顺带说一个佐证手段Windows 在检测到 IP 冲突时会在系统事件日志里写一条事件编号 4199日志里带着冲突的 IP 和 MAC。程序报出来的冲突如果能在目标机器的事件日志里对上可信度就非常高了对不上多半是你的窗口设得太短把 DHCP 换租误判了。现象五设备表里塞满了02:00:4C:...这类 MAC还天天报冲突。这是虚拟网卡和隧道适配器。判断方法很简单MAC 的第一个字节的第二个最低位是 1就是本地管理地址locally administered真实厂商分配的 MAC 这一位是 0。这类地址在容器、虚拟网卡、蓝牙 PAN 上大量出现它们经常共用或者随机化地址进台账只会污染数据。在Upsert之前加一层过滤public static bool IsLocallyAdministered(PhysicalAddress mac) { var b mac.GetAddressBytes(); return b.Length 0 (b[0] 0x02) ! 0; // 第二低位为 1 即本地管理 }但别一刀切全丢掉——某些品牌的服务器管理口和集群心跳地址也用本地管理 MAC全过滤掉会漏设备。我一般是把它们标成IsVirtualtrue单独归类冲突判定时跳过台账里保留报表里用一列区分开。6. 二次开发做成常驻服务把冲突接到告警链路上命令行版跑通了之后真正好用的是常驻形态。改造成本很低Core那一层完全不用动只把Program.cs换成泛型主机采集服务实现IHostedService就行。public sealed class ArpWatcherHostedService : BackgroundService { private readonly ArpCaptureService _capture; private readonly ConflictDetector _detector; private readonly ChannelArpFrame _channel; protected override async Task ExecuteAsync(CancellationToken ct) { _capture.FrameArrived (_, f) { if (!_channel.Writer.TryWrite(f)) Interlocked.Increment(ref _dropped); }; _capture.Start(_bindings); await foreach (var frame in _channel.Reader.ReadAllAsync(ct)) { _table.Upsert(frame); _detector.Feed(frame); } } }消费循环放在ExecuteAsync里单线程串行处理ConflictDetector里的锁其实就可以去掉了StopAsync里调用_capture.Dispose()保证网卡句柄释放不然服务重启时会报设备被占用。对外接口我一般只加两个最小端点GET /devices返回台账分页GET /conflicts?since返回冲突列表。别一上来就做前端先把数据接口给出去让运维接到他们现成的告警平台上——大多数团队已经有 IM 机器人和工单系统重复造一个界面没人看。告警推送做成可插拔的IConflictNotifier实现类里判断一下record.Macs.Length 2再发一条消息带上 IP、两个 MAC、各自厂商和首次检测时间够定位了。验证方法我固定走这两步第一步在测试机上手工把一个已被占用的 IP 配成静态看多久能报出来——按前面的参数应该在 5 分钟窗口加 10 分钟冷却的范围内如果超过 20 分钟还没动静回去查主动探测是不是没覆盖到那个地址第二步拔掉一台设备的网线看resolved_at会不会被正确填上这一步最容易漏因为恢复路径的代码平时不执行坏了自己也不知道。还有个改造方向值得提一句把DeviceTable的历史数据留下来按天做快照对比就能看出「昨天在、今天不在」的设备。这个功能比冲突告警更常被运维部门用上——找失联设备、核对资产台账都能用。从那以后我每次做抓包类项目都会先把「能看到哪些帧、看不到哪些帧」在纸上画一遍再动手写解析——ARP 这个项目就是当初没画导致前三版一直在补单播回包的窟窿。希望这个拆解能帮你少走一遍。本文还有配套的精品资源点击获取