news 2026/11/6 2:05:40

C#与SharpPcap实现ARP抓包:局域网设备台账与IP冲突告警

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#与SharpPcap实现ARP抓包:局域网设备台账与IP冲突告警

简介:面向需要在局域网内排查设备与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/

采集和判定之间我用的是一条Channel<ArpFrame>(有界队列,容量给 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 # 指定网卡序号正式开抓,Ctrl+C 结束 dotnet run --project src/LanArpWatcher.Cli -- --if 2

2.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 List<NicBinding> Bind() { var result = new List<NicBinding>(); 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 List<ICaptureDevice> _devices = new(); public event EventHandler<ArpFrame>? FrameArrived; public long DroppedCount; // 队列满导致的丢弃计数 public void Start(IEnumerable<NicBinding> 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.Extract<ArpPacket>(); 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 IEnumerable<IPAddress> 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 Dictionary<string, List<NetDevice>> _byIp = new(); private readonly Dictionary<string, List<NetDevice>> _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 IReadOnlyList<NetDevice> DevicesOnIp(string ip) => _byIp.TryGetValue(ip, out var list) ? list : Array.Empty<NetDevice>(); private static void AddTo(Dictionary<string, List<NetDevice>> map, string key, NetDevice dev) { if (!map.TryGetValue(key, out var list)) map[key] = list = new List<NetDevice>(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 Dictionary<string, 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_count=hit_count+1,一条语句搞定新增和更新。批量写的时候攒够 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,全过滤掉会漏设备。我一般是把它们标成IsVirtual=true单独归类,冲突判定时跳过,台账里保留,报表里用一列区分开。


6. 二次开发:做成常驻服务,把冲突接到告警链路上

命令行版跑通了之后,真正好用的是常驻形态。改造成本很低,Core那一层完全不用动,只把Program.cs换成泛型主机,采集服务实现IHostedService就行。

public sealed class ArpWatcherHostedService : BackgroundService { private readonly ArpCaptureService _capture; private readonly ConflictDetector _detector; private readonly Channel<ArpFrame> _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 这个项目就是当初没画,导致前三版一直在补单播回包的窟窿。希望这个拆解能帮你少走一遍。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 2:05:14

C++从零手写卷积神经网络:MNIST训练与im2col实战

简介&#xff1a;这份 C 深度学习实践包面向希望从底层理解神经网络、提升工程实现能力的初学者与进阶开发者&#xff0c;围绕卷积神经网络与全连接神经网络的从零搭建展开&#xff0c;并用 MNIST 手写数字数据集完成训练与精度验证&#xff0c;适合课程设计、自学复现和底层原…

作者头像 李华
网站建设 2026/11/6 2:03:25

笔记++23

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 2:02:42

18|DeepSeek Harness研究资料更新后,怎样找出还引用旧证据的笔记?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 2:00:59

大一自学C语言|哔哩哔哩翁恺笔记整理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 2:00:42

经营分析常用公式大全:收入、利润、成本、效率,一篇收齐

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 1:59:57

glb 修复 ab补面

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华