简介:这是一份面向C#开发者与局域网运维人员的网络监测源码资料,重点演示如何借助SharpPcap抓取ARP报文来发现局域网设备,并汇总存在IP冲突的设备列表,适合需要实现设备巡检、冲突告警与网络排障功能的读者参考。压缩包共36个文件,约1002KB,其中11个cs文件承载核心逻辑,解决方案与项目文件便于直接打开工程,dll、exe、config、resx等覆盖依赖、运行与配置环节;包内附抓包依赖组件、安装程序及使用示例,能减少环境搭建与联调成本。已有560人学习,读者可参考抓包类的封装方式、报文解析与设备识别流程、冲突判定思路以及示例调用方法,并据此验证抓包环境、定位配置问题,为后续扩展监测功能提供可复用的实现基础。尤其适合已有一定C#基础、希望理解底层报文处理与冲突检测细节的开发者。
1. ARP 是局域网里最不可靠的协议:IP 冲突为何总是最后才被发现
在局域网里,ARP 抓包、IP 冲突排查、设备列表盘点这几件事,本质上是在处理同一个问题:二层网络上并没有一份权威的设备登记表。交换机只维护 MAC 地址表,不知道 IP 归属;DHCP 服务器只知道谁申请过地址,不知道谁在使用地址;而 ARP 协议本身没有认证、没有序号、没有应答确认,任何一台机器都可以声明自己是某个 IP。C# 配 SharpPcap 从帧层面把 ARP 报文抓下来,就是把这份“口头声明”变成可追溯的证词。它解决的问题很具体:当两台设备的 IP 冲突时,网络里没有任何日志记录这件事,只能靠观察谁在广播里反复声明同一个地址。值得深入理解的原因在于:ARP 的行为决定了你能观测到什么,也决定了你抓到的设备列表有没有偏差。
2. ARP 报文剖析:28 个字节里藏着哪些判断依据
ARP 是极少数没有传输层、直接封装在以太网帧里的协议。它简单到只有 28 字节的有效载荷,但也正因为简单,所有判断设备身份的信息都必须从这几个字段里推导出来。想做出可用的设备列表和冲突检测,先要弄清每个字节代表什么、哪些字段在什么场景下会变成无效值。
2.1 逐字段拆解:偏移、长度与常见取值
ARP 报文固定 28 字节,紧跟在 14 字节的以太网头之后,以太类型字段为 0x0806。字段布局是这样的:
| 偏移(相对 ARP 载荷) | 长度 | 字段 | 关键取值与陷阱 |
|---|---|---|---|
| 0 | 2 | 硬件类型 | 1 表示以太网;为 1 才继续解析 |
| 2 | 2 | 协议类型 | 0x0800 表示 IPv4;IPv6 走 NDP,不在这里 |
| 4 | 1 | 硬件地址长度 | 固定 6 |
| 5 | 1 | 协议地址长度 | 固定 4 |
| 6 | 2 | 操作码 | 1 = 请求,2 = 应答,3/4 是 RARP |
| 8 | 6 | 发送方 MAC | 冲突检测的第一主键 |
| 14 | 4 | 发送方 IP | 冲突检测的第二主键 |
| 18 | 6 | 目标 MAC | 请求包里通常是全 0,不能当有效地址用 |
| 24 | 4 | 目标 IP | 请求包里是被询问的那一方 |
这里有一个容易出错的地方:以太网最小帧长是 60 字节(不含 CRC),而 ARP 帧只有 14 + 28 = 42 字节,不足的部分由网卡填充 18 字节的零。这意味着解析时不能用“帧长度减去头部”来推断载荷长度,必须按固定偏移读取。另外,操作码为 1 的请求包里,目标 MAC 字段是全零,如果有人把它当成“目标设备的真实 MAC”写进设备表,就会得到一堆 00:00:00:00:00:00 的无效条目。
2.2 请求广播与应答单播的不对称:决定了你只能看到一半
ARP 的工作方式是广播问、单播答。A 想知道 192.168.1.20 的 MAC,就向 ff:ff:ff:ff:ff:ff 发一个请求,交换机把这个帧泛洪到同一 VLAN 的所有端口,所以接在任何一个端口上的抓包程序都能看到这个请求。但 B 的应答是直接单播回给 A 的,交换机会查 MAC 地址表把它精确转发到 A 所在的端口,抓包机如果不在这个路径上,就完全看不到这个应答。
这个不对称性是整个方案的核心约束:被动抓包时,你能稳定看到的是“所有 ARP 请求”和“所有广播型 ARP(免费 ARP、ARP 探测)”,而“普通的 ARP 应答”只有在它恰好经过你所在的端口时才会出现。所以设备列表的完整性,取决于你是否同时利用了两个信息源:请求包的发送方字段(谁在问,说明谁在线)和应答包的发送方字段(谁在答,说明谁在线)。只统计应答,列表会少一大半;只统计请求,会漏掉那些只被查询、从不主动发问的静默设备,比如打印机、摄像头、部分 IoT 终端。
2.3 四类冲突信号:把“谁在说自己是某个 IP”变成可判定的证据
冲突检测的本质不是“发现两个 MAC”,而是“在合理的时间窗口内,同一个 IP 被两个不同的 MAC 声明为自有”。按证据强度从高到低,可以拆成四类信号:
- 信号 A,同一 IP 双 MAC 声明:窗口内 IP 相同、发送方 MAC 不同。这是最经典的 IP 冲突,但也可能是 DHCP 地址池回收后重新分配,需要用时间窗口区分。
- 信号 B,本机地址被他人声明:抓到的 ARP 请求里,目标 IP 等于本机 IP,而发送方 MAC 不是本机 MAC。这说明有人正在询问“谁是本机 IP”,而提问者自己很可能就占着这个地址——这是本机 IP 冲突最直接的证据。
- 信号 C,免费 ARP 不一致:发送方 IP 等于目标 IP 的报文(Gratuitous ARP),通常用于设备上线时通告自己、或者检测地址是否被占用。如果这个通告里的 MAC 与本机记录的历史 MAC 不符,就是抢占信号。
- 信号 D,单 MAC 多 IP 声明:一个 MAC 在窗口内声称拥有多个 IP。这大概率不是冲突,而是路由器子接口、虚拟化网桥、代理 ARP 或双 IP 配置。把它当成冲突上报,会让告警列表里有很多误报。
把 D 单独拆出来,是这套方案能否在生产环境可用的一条明显分界线。很多自制脚本把“一个 MAC 多个 IP”和“一个 IP 多个 MAC”混为一谈,结果在一个有虚拟化集群的网段里,告警会持续不断。
3. SharpPcap 抓包链路:从网卡混杂模式到托管回调
SharpPcap 本身不做协议解析,它只是 libpcap/Npcap 的托管封装。理解这一层封装在哪、不封装在哪,决定了你能不能写出不丢包、不乱序、不误判的采集器。整条链路是:打开设备 → 设置混杂模式与快照长度 → 下发 BPF 过滤器 → 注册回调 → 启动抓包循环。每一环都有明确的失败模式。
3.1 打开设备:混杂模式、读超时与快照长度
using SharpPcap; using SharpPcap.LibPcap; // 枚举所有网卡,优先挑选已启用、描述里带 Ethernet 的那块 var devices = CaptureDeviceList.Instance; var device = devices.FirstOrDefault(d => d.Description != null && d.Description.Contains("Ethernet", StringComparison.OrdinalIgnoreCase)) ?? devices[0]; device.Open(new DeviceConfiguration { Mode = DeviceModes.Promiscuous, // 混杂模式:收下所有经过网卡的帧,而不只是发给本机的 ReadTimeout = 1000, // 毫秒;没有包时让抓包循环及时返回 Snaplen = 65535 // 快照长度上限,ARP 帧才 42 字节,给足即可 }); device.Filter = "arp"; // BPF 表达式,必须在 StartCapture 之前下发 device.OnPacketArrival += OnArpArrived; device.StartCapture();Mode = Promiscuous是能否看到“别人的 ARP”的前提。默认模式下网卡只把目的 MAC 是自己或广播/组播的帧交给上层,混杂模式则全部上交。这里要注意两点:一是混杂模式在交换机环境下并不能突破端口隔离,你仍然只能看到泛洪到本端口的帧;二是ReadTimeout不是“抓包超时”,而是底层pcap_next_ex的等待上限,设得太大会让停止抓包时卡住,设得太小会增加空转开销,1000 毫秒是比较稳妥的取值。
3.2 BPF 过滤器:把裁剪放在内核态做
device.Filter = "arp"这行的价值常被低估。BPF 表达式会被编译成字节码下发给驱动,在数据进入用户态之前就完成过滤。一个千兆网段在业务高峰期的帧速率可以轻松到几十万 PPS,如果不加过滤全量上交,托管层的回调会被无关流量占满,ARP 包被丢弃在内核缓冲区里,表现出来就是“设备列表偶尔缺少某些设备”。
等价的写法还有ether proto 0x0806,可读性差一些但语义更直接。如果还想顺带判断 DHCP 租约,可以写成arp or (udp and (port 67 or port 68)),把地址分配的时间点和 ARP 声明的时间点对齐,误报率会明显下降。需要注意的细节是:过滤器要在StartCapture之前设置;如果抓包已经启动,修改Filter会触发重新下发,中间极短的时间窗口内容易漏包。
3.3 回调线程模型:单线程同步执行,不能在这里做重活
OnPacketArrival不是每个包起一个线程,而是在 libpcap 的抓包循环线程里同步调用。也就是说,回调里花的时间就是丢包的时间。慢一毫秒,内核环形缓冲区就多积压一批帧;积压满之后,pcap_stats的ps_drop计数开始上涨,而你从业务层面完全看不出来,只会觉得“抓包结果不稳定”。
正确的做法是回调里只做“取帧、拷贝、入队”,解析和聚合放到独立线程:
using System.Threading.Channels; // 有界队列:容量给到 8 万帧,满了就走 DropOldest,避免内存无限增长 var queue = Channel.CreateBounded<RawFrame>(new BoundedChannelOptions(80_000) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = true }); device.OnPacketArrival += (_, e) => { var raw = e.GetPacket(); // 只做最小化处理:拷贝数据 + 带上抓包时间戳 queue.Writer.TryWrite(new RawFrame(raw.Data, raw.Timeval.Date)); }; device.StartCapture(); // 消费线程:解析、聚合、判定都在这里做,不阻塞抓包循环 var consumer = Task.Run(async () => { await foreach (var frame in queue.Reader.ReadAllAsync()) _collector.Ingest(frame); });e.GetPacket().Data返回的是当次抓包缓冲区的一份拷贝,可以安全地跨线程传递;但Timeval.Date一定要用抓包时间戳而不是DateTime.Now,否则在队列积压时会因为消费延迟导致时间窗口判断整体偏移,把正常换机误判成冲突。这一点很容易被忽略,因为单机压测时队列几乎不满,问题只在真实网络里才会暴露,属于典型的“上线才触发的问题”。
4. 聚合模型:把杂乱报文整理成设备表与冲突表
抓包只是原料,真正决定结果质量的是聚合模型。原始 ARP 报文是一串无状态的事件流,同一个设备在几分钟内可能发出几十个请求、收到几十个应答。要把它变成“设备列表”和“冲突列表”两张表,需要确定三件事:主键怎么选、状态怎么流转、记录怎么老化。
4.1 数据结构与主键:为什么以 IP 为主、MAC 为辅
设备表的主键应该选 IP 而不是 MAC。原因是冲突检测的命题天然是“这个 IP 现在归谁”,MAC 只是证据。反过来以 MAC 为主键,一台设备配了多个 IP 时会被拆成多条记录,冲突判定也就失去了基准。
// 单个 MAC 对某个 IP 的声明证据 public sealed class MacEvidence { public DateTime FirstSeen { get; init; } public DateTime LastSeen { get; set; } public int HitCount { get; set; } public ushort LastOp { get; set; } // 1=请求 2=应答,区分"主动询问"与"主动声明" } public sealed class IpRecord { public IPAddress Ip { get; } public object Gate { get; } = new(); // 每个 IP 一把锁,避免全局锁成为瓶颈 public Dictionary<PhysicalAddress, MacEvidence> Macs { get; } = new(); public ConflictKind Verdict { get; set; } = ConflictKind.Normal; public IpRecord(IPAddress ip) => Ip = ip; }LastOp这个字段看着不起眼,实际决定了冲突的定性。如果某个 MAC 只在LastOp = 1(请求)里出现,说明它只是“问过这个 IP 是谁”,并不等于它自称拥有这个 IP——不过在多数实现里,问之前它自己往往已经配上了这个地址。真正强证据是LastOp = 2(应答)或免费 ARP,因为这两种报文里发送方 IP 必须是发送方自认为拥有的地址。
按 IP 分锁是为了并发。聚合线程通常是单线程消费,但如果你把解析拆成多个 worker,用全局锁会让高流量网段的吞吐直接掉一个数量级。
4.2 冲突判定:从观察到定性的状态流转
public void Observe(ushort op, PhysicalAddress smac, IPAddress sip, PhysicalAddress tmac, IPAddress tip, DateTime ts) { var rec = _byIp.GetOrAdd(sip, ip => new IpRecord(ip)); lock (rec.Gate) { if (!rec.Macs.TryGetValue(smac, out var ev)) rec.Macs[smac] = ev = new MacEvidence { FirstSeen = ts }; ev.LastSeen = ts; ev.HitCount++; ev.LastOp = op; // 只有落在时间窗口内的 MAC 才算"当前有效声明" var alive = rec.Macs.Count(kv => ts - kv.Value.LastSeen <= _window); rec.Verdict = alive > 1 ? ConflictKind.IpConflict : ConflictKind.Normal; } // 免费 ARP:发送方 IP == 目标 IP,通常是上线通告或地址占用检测 if (sip.Equals(tip)) _gratuitous.Record(smac, sip, ts); // 有人在问"谁是本机 IP",而提问者不是本机 —— 本机地址被占用 if (tip.Equals(_selfIp) && !smac.Equals(_selfMac)) _alerts.Push(new Alert(AlertLevel.High, sip, smac, "本机 IP 被他人声明")); }这段逻辑里有三个判断需要说清。第一,alive用的是ts - LastSeen <= _window,而不是“历史上出现过几个 MAC”,这样 DHCP 地址回收再分配不会误报。第二,sip.Equals(tip)这个条件能同时命中两种报文:免费 ARP 请求(操作码 1)和免费 ARP 应答(操作码 2),前者用于“我上线了”,后者用于“我确认这个地址是我的”,语义不同但都是强声明。第三,本机 IP 被占用这条告警是唯一可以做到“零延迟”的,因为它在报文到达的瞬间就能判定,不需要等时间窗口。
4.3 老化策略与导出快照
窗口长度不能随意设定。经验值取 DHCP 租约时间的一半:租约 8 小时就取 4 小时,租约 2 小时就取 1 小时。窗口太短,设备休眠再唤醒会被误判为“MAC 变更”;窗口太长,地址真正被重新分配后旧 MAC 迟迟不老化,冲突表里会长期保留已经解决的记录。
public IReadOnlyList<DeviceRow> Snapshot(DateTime now) { var rows = new List<DeviceRow>(); foreach (var rec in _byIp.Values) { List<KeyValuePair<PhysicalAddress, MacEvidence>> alive; lock (rec.Gate) { alive = rec.Macs .Where(kv => now - kv.Value.LastSeen <= _window) .OrderByDescending(kv => kv.Value.LastSeen) .ToList(); } if (alive.Count == 0) continue; // 全部老化,视为下线 rows.Add(new DeviceRow( Ip: rec.Ip, PrimaryMac: alive[0].Key, // 最近一次声明的 MAC 作为当前归属 MacCount: alive.Count, FirstSeen: alive.Min(kv => kv.Value.FirstSeen), LastSeen: alive.Max(kv => kv.Value.LastSeen), Verdict: alive.Count > 1 ? ConflictKind.IpConflict : ConflictKind.Normal)); } return rows.WithMacMultiIpMarked(_byMac); // 单独标记"一个 MAC 多个 IP" }PrimaryMac取最近一次声明的 MAC,这个选择在冲突场景下是有意义的:谁最后广播,谁大概率是刚刚配好地址的那台,往往也是“后来的那台”。把MacCount > 1直接映射成冲突,同时用_byMac反向索引标记多 IP 情况,两张表就能互相印证:如果某个 IP 冲突里的两个 MAC 同时都在“多 IP”列表里,那大概率是虚拟化环境,不是真冲突。
5. 避坑与排查:ARP 抓包在真实网络里的五个失败场景
5.1 设备列表只有请求方,没有应答方
现象:抓了几分钟,_byIp里有记录的设备,绝大多数只在LastOp = 1出现过,冲突判定几乎从不触发。
原因:交换机按 MAC 地址表精确转发单播帧,ARP 应答是单播,抓包机不在通信路径上就收不到。混杂模式在交换机环境里不能突破这个限制。
解决:把抓包点换到能看见全量流量的位置,比如网关所在端口、交换机镜像口,或者干脆接一个老式集线器做临时旁路。如果拿不到镜像权限,就补一条主动探测:定期向网段内所有地址发 ARP 请求,用应答补齐列表,代价是会引入额外流量,也可能触发某些终端的安全告警。
5.2 混杂模式没生效,收到的帧数和预期严重不符
现象:明明是有几百台设备的网段,OnPacketArrival一分钟只回调几十次。
原因:底层抓包驱动没装好或者没勾选支持混杂模式的选项,Open返回成功但模式被静默降级;另一种情况是抓包程序跑在虚拟机的虚拟网卡上,宿主机那一层已经把非本机流量过滤掉了。
解决:抓包前先打印device.MacAddress和device.Flags确认设备可用;用pcap_stats里的ps_recv和ps_drop对比,如果ps_recv本身就极小,说明根本没进混杂模式,换物理网卡、重装驱动后再试。
5.3 大量误报:DHCP 续租被当成 IP 冲突
现象:冲突列表里同一对 IP/MAC 反复出现又消失,时间点集中在整点或租约到期的时刻。
原因:设备续租或重新申请地址时可能短暂出现两个 MAC 声明同一 IP 的中间态;如果时间窗口设得很短(比如 60 秒),这个中间态会被直接判成冲突。
解决:把窗口放宽到租约时间的一半以上,并且对同一 IP 的冲突记录做“连续命中”要求——只有在连续 N 个采样周期内都能看到双 MAC,才升级为确认冲突。这一步能把误报压掉一个数量级。
5.4 一个 MAC 对应一堆 IP,被误判成冲突
现象:冲突表里出现同一台设备占用十几个 IP 的记录。
原因:路由器子接口、虚拟化网桥、代理 ARP、双栈配置都会造成这个现象,它们不是冲突,是正常的多地址行为。
解决:把“单 MAC 多 IP”和“单 IP 多 MAC”拆成两个独立的判定维度,前者只做标记不做告警;再叠加一条规则——如果某个 MAC 同时是网关地址的持有者,直接把它列入白名单。
5.5 时间戳用 DateTime.Now,窗口判定整体漂移
现象:抓包高峰期,冲突判定结果和实际发生时间对不上,回放同一份数据也无法复现。
原因:回调入队之后才打时间戳,队列积压时延迟可达数秒;用本地时间还会受系统时钟调整影响。
解决:一律用e.GetPacket().Timeval.Date,它在帧被内核接收时就已确定。回放 pcap 文件时也用它,同一份数据在不同机器上能跑出完全一致的结论。
6. 设计取舍:被动监听拿不到全貌,主动探测又要付出代价
把 ARP 抓包做成设备发现和冲突检测,最后一定会遇到一个问题:到底该信任观测结果到什么程度。被动监听的优点是零侵入、不改动网络、不会引入任何额外流量,缺点是它的可见范围完全由网络拓扑决定——在交换机环境里,你看得到广播,看不到单播,所以设备列表天然是“偏向爱说话的那部分设备”。主动探测补上了静默设备,代价是每个探测包都可能被终端的安全软件记录成“异常扫描”,在有些环境里这本身就是需要报备的行为。
我的取舍是:被动采集做基础事实,主动探测只做补漏,且严格限速。具体做法是把设备记录分成“确认”和“推测”两档,被动观测到的标为确认,只能通过主动探测发现的标为推测,导出时带上一列来源。这样一份设备列表在给别人看的时候,对方能知道哪条是硬证据、哪条是推断,比混在一起报一个“共 N 台设备”有用得多。
第二个取舍在冲突判定上。严格判定(要求双 MAC 都出现过应答或免费 ARP)误报极低,但会漏掉那些只发请求不发应答的场景;宽松判定(只要窗口内出现双 MAC 就算)覆盖全,但在虚拟化和 DHCP 频繁的环境里几乎不可用。我倾向于严格判定加一个“疑似”中间态,疑似记录不告警但保留证据链,等人去核对。这套东西的价值不在于自动下结论,而在于把“黑匣子”变成“可以翻的记录”——出了问题时能回答“这个 IP 在什么时间被哪些 MAC 声明过”,而不是只能说“好像是 IP 冲突”。
最后一个取舍是老化窗口。窗口越长,历史证据越完整,误判越少;但地址真的被重新分配之后,旧记录残留会让冲突表越滚越大,看的人很快就麻木了。我最后选的是双层窗口:判定窗口取租约一半,展示窗口取判定窗口的四倍,老记录降级成“历史”而不是删除。这样既不丢证据,也不会让当前告警被历史数据积压得难以处理。
从那以后,我每次做这类采集工具,都会强制先在目标网段跑一遍“只统计不判定”的观察模式,把ps_recv、ps_drop、请求与应答的比例、单 MAC 多 IP 的分布先看清楚,再决定窗口和阈值。这些参数没有通用解,只能拿真实流量去试。希望帮到你。
本文还有配套的精品资源,点击获取