
简介该资源面向在 Windows 平台使用 Visual Studio 2017 进行网络状态检测开发的 C 学习者与工程人员聚焦「网线插入/拔出状态监测」这一具体问题属于网络编程中接口状态监控的入门到进阶实践素材。压缩包共 27 个文件约 14.93MB包含 cpp 与 h 源文件及头文件、vcxproj 工程与筛选器配置、sln 解决方案以及 exe、pdb、ilk、obj、ipch 等编译产物和调试中间文件可直接用 VS2017 打开复现工程。已有 875 人学习下载。核心代码围绕 GetAdaptersAddresses 遍历网络接口依据 OperStatus 判断链路连通状态读者可借此掌握 iphlpapi 的调用方式、缓冲区溢出处理与多网卡遍历思路描述中另附事件驱动方案借助 RegisterDeviceNotification 监听设备变更消息用于实时监测网线插拔并提示有线与无线切换、多网卡差异等设计注意事项便于将检测逻辑封装复用。1. 判断网线插没插别盯着 IP 看最常见的翻车现场是这样的程序定时读GetAdaptersInfo发现网卡还有 IP就认为“网线还在”。结果线被拔掉之后Windows 仍会保留地址租约或静态配置状态栏早就变红而代码仍稳如泰山。反过来网线刚插上、DHCP 还没回包的那几秒IP 是空的但物理链路其实已经通了。网线插入状态本质上属于链路层事件只要用MediaConnectState这个字段去读跟 IP 有没有配好完全是两码事。这篇把 VS2017 Windows 下的落地路径拆开先用NotifyIpInterfaceChange拿到接口变更通知再回到MIB_IF_ROW2里读链路状态最后处理多网卡、虚拟网卡和去抖。适合要做设备在线监测、工控上位机、断网告警弹窗的开发者。2. 三套方案摆上桌通知 API、WMI 事件与定时轮询2.1 先把物理链路、IP 地址、网络可达性拆成三层很多需求文档里只写一句“检测网线插拔”落到实现上必须先拆清楚要检测的是哪一层否则选型一定跑偏。第一层是物理链路层。网线插上、对端交换机通电、PHY 协商完成网卡的介质连接状态就会变成 Connected。这一层跟有没有 IP 无关跟能不能上网更无关。Windows 上读这一层最直接的字段是MIB_IF_ROW2::MediaConnectState取值只有MediaConnectStateConnected、MediaConnectStateDisconnected、MediaConnectStateUnknown三种。第二层是 IP 地址层。DHCP 拿没拿到地址、静态地址有没有配上、IPv4 和 IPv6 各自是什么状态。这一层用GetAdaptersAddresses或GetUnicastIpAddressTable读但要注意地址存在不等于链路通地址为空也不等于链路断。第三层是网络可达性。能不能访问网关、能不能出公网这一层是INetworkListManager::GetConnectivity或 NLM 的IsConnectedToInternet负责的它反映的是路由和 DNS 层面的判断延迟通常比链路层大几百毫秒到几秒。把这三层混在一起就会出现“网线拔了程序还说在线”或者“网线插了但没拨号程序判成断线”的经典误判。检测网线插拔锁定第一层就够了如果业务还要判断“能不能用”再叠第二层和第三层。2.2 四种实现方式的对比与选型在 Windows 上做链路状态感知常见有四条路各自的开销和精度差别很大。方案核心 API通知延迟能否反映物理链路依赖条件适用场景IP 接口变更通知NotifyIpInterfaceChangeGetIfEntry2毫秒到百毫秒级能读MediaConnectStateVista 及以上首选桌面与服务都能用地址变更通知NotifyAddrChange取决于 DHCP 回包不能只知道地址变了全版本只关心 IP 变化的场景WMI 事件订阅Win32_NetworkAdapter的__InstanceModificationEvent秒级能但字段绕依赖 WMI 服务已有 WMI 框架的项目定时轮询GetIfTable2/GetIfEntry2等于轮询间隔能无兜底、低频检测选NotifyIpInterfaceChange的理由很直接它是事件驱动不用自己起定时器CPU 占用接近零回调里能直接拿到MIB_IPINTERFACE_ROW再用InterfaceLuid回查MIB_IF_ROW2就能读到链路状态而且它对“网线插上但没拿到 IP”这种情况同样会触发不会像NotifyAddrChange那样漏掉没有 DHCP 响应的插线动作。WMI 不是不能用问题是慢。__InstanceModificationEvent的轮询间隔由 WMI 内部决定实测从插线到收到事件经常在一秒以上而且第一次查询Win32_NetworkAdapter就要几百毫秒服务刚启动时体验很差。轮询则适合做兜底主通道用通知再挂一个 30 秒一次的GetIfTable2对账防止某些网卡驱动漏发通知。提示NotifyIpInterfaceChange从 Windows Vista 起提供。如果目标机器里还有 XP需要用GetProcAddress从Iphlpapi.dll动态取函数地址避免程序启动时因导入失败直接退出。2.3 VS2017 工程骨架与依赖配置新建项目选“Windows 桌面应用程序”里的空项目平台工具集保持 Visual Studio 2017 (v141)Windows SDK 选 10.0.17763.0 或更高。字符集用 Unicode因为MIB_IF_ROW2里的Alias和Description都是宽字符。头文件顺序是第一个坑winsock2.h必须排在windows.h之前否则windows.h会把winsock.h拉进来跟winsock2.h撞定义。常见写法是先包含winsock2.h和ws2tcpip.h再包含windows.h最后是netioapi.h和iphlpapi.h。链接库要加三个ws2_32.lib、iphlpapi.lib、ole32.lib。前两个是网络 API 的基础ole32.lib留给后面可能用到的 COM 初始化。可以在代码里用#pragma comment(lib, ...)写死省得每次配工程属性。// LinkMonitor.cpp : 控制台版网线插拔监听骨架 #include winsock2.h #include ws2tcpip.h #include windows.h #include netioapi.h #include iphlpapi.h #include stdio.h #pragma comment(lib, ws2_32.lib) #pragma comment(lib, iphlpapi.lib) int wmain() { // 注册接口变更通知AF_UNSPEC 表示 IPv4/IPv6 都监听 HANDLE hNotify nullptr; DWORD dwRet NotifyIpInterfaceChange( AF_UNSPEC, nullptr, // 回调函数下一步补 nullptr, // 上下文 TRUE, // 注册时先补一次当前状态 hNotify); if (dwRet ! NO_ERROR) { wprintf(LNotifyIpInterfaceChange failed: %lu\n, dwRet); return 1; } // 控制台程序靠消息循环或 Sleep 撑着先简单阻塞 Sleep(INFINITE); return 0; }上面这段只验证工程能不能编过、函数能不能链上回调先传空指针会直接返回ERROR_INVALID_PARAMETER所以要等第 3 章补齐回调后再跑。参数里AF_UNSPEC是关键传AF_INET只能收到 IPv4 接口的变化双栈机器上会漏掉 IPv6 侧的事件。InitialNotification传TRUE是让系统在注册成功的那一刻先回调一次当前所有接口的状态省去自己再遍历一遍网卡表。3. NotifyIpInterfaceChange 落地回调签名、句柄回收与退出时机3.1 回调签名与 InitialNotification 的取舍先看函数原型。NotifyIpInterfaceChange有五个参数回调类型是PIPINTERFACE_CHANGE_CALLBACK定义在netioapi.h里。DWORD NotifyIpInterfaceChange( ADDRESS_FAMILY Family, // AF_INET / AF_INET6 / AF_UNSPEC PIPINTERFACE_CHANGE_CALLBACK Callback, // 回调函数指针 PVOID CallerContext, // 透传给回调的上下文 BOOLEAN InitialNotification, // 是否立刻回调一次现有接口 HANDLE *NotificationHandle // 输出句柄退出时用来反注册 );回调签名是VOID NETIOAPI_API_ Callback(PVOID CallerContext, PMIB_IPINTERFACE_ROW Row, MIB_NOTIFICATION_TYPE NotificationType)。第三个参数NotificationType有四种取值MibParameterNotification表示接口参数变化MibAddInstance表示新接口加入MibDeleteInstance表示接口移除MibInitialNotification对应注册时的首次回调。真正判网线插拔MibParameterNotification和MibAddInstance是高频路径。有些 USB 网卡拔线时会走MibDeleteInstance接口直接从表里消失回调里的Row指针也可能只剩一个 LUID所以回调里不要假设接口一定还在回查失败要按“断开”处理。InitialNotification建议传TRUE。原因是程序启动时往往需要先渲染一次初始状态传FALSE的话得自己再跑一遍GetIfTable2遍历代码路径分叉容易漏。传TRUE的唯一代价是回调会在注册线程上同步执行一次回调里千万别做重活。3.2 回调里读 MediaConnectState 的完整代码回调拿到的是MIB_IPINTERFACE_ROW里面只有接口索引、LUID、MTU、度量值这些 IP 层信息没有链路状态。要拿MediaConnectState得用InterfaceLuid填一个MIB_IF_ROW2再调GetIfEntry2回查。#include atomic #include cstdio static std::atomicint g_liveCount{0}; // 当前处于连接态的物理网卡数 // 判断是否是需要关注的物理以太网卡 static bool IsWiredEthernet(const MIB_IF_ROW2 row) { // IF_TYPE_ETHERNET_CSMACD 6排除无线、拨号、隧道 if (row.Type ! IF_TYPE_ETHERNET_CSMACD) return false; // HardwareInterface 为真才是真实硬件虚拟网卡多为假 if (!row.InterfaceAndOperStatusFlags.HardwareInterface) return false; return true; } // 回调运行在系统网络栈的工作线程上禁止阻塞 VOID NETIOAPI_API_ OnIpInterfaceChanged( PVOID callerContext, PMIB_IPINTERFACE_ROW ipRow, MIB_NOTIFICATION_TYPE notifyType) { if (ipRow nullptr) return; MIB_IF_ROW2 ifRow {}; ifRow.InterfaceLuid ipRow-InterfaceLuid; // 用 LUID 定位索引会复用 DWORD dwRet GetIfEntry2(ifRow); if (dwRet ! NO_ERROR) { // 接口已消失例如 USB 网卡被拔走按断开计 wprintf(L[%llu] LUID%llu 接口已不存在, type%d\n, GetTickCount64(), ipRow-InterfaceLuid.Value, (int)notifyType); return; } if (!IsWiredEthernet(ifRow)) return; const wchar_t* stateText (ifRow.MediaConnectState MediaConnectStateConnected) ? L已连接 : (ifRow.MediaConnectState MediaConnectStateDisconnected) ? L已断开 : L未知; wprintf(L[%llu] %s | MediaConnectState%s | OperStatus%d\n, GetTickCount64(), ifRow.Description, stateText, (int)ifRow.OperStatus); }这段代码里有两个参数选择值得说。MIB_IF_ROW2用InterfaceLuid定位而不是InterfaceIndex因为索引在接口删除后会被系统复用LUID 是 64 位且单调分配做状态缓存的键更安全。IsWiredEthernet里同时判Type和HardwareInterface前者过滤掉无线和隧道后者过滤掉 TAP、VMware、Hyper-V 这些软件网卡。MediaConnectState有三种取值代码里做了三路分支。实际运行中如果在虚拟机里跑Unknown出现得不算少这时候不能简单当成断开得看OperStatus兜底IfOperStatusUp就按连接算否则按断开算。3.3 线程模型回调线程能做什么、不能做什么这一点是血泪经验。回调由系统网络栈线程调用跟创建通知的线程不是同一个。在这个回调里做下面几件事会出问题第一不要弹MessageBox。回调线程被阻塞期间系统可能堆积后续通知表现是插拔几次之后程序卡住不动。第二不要等锁。如果主线程持有某个std::mutex同时又调用了会触发网络栈的 API就会形成环调试器里看到的是回调线程停在WaitForSingleObject。第三不要直接操作 UI 控件。MFC 的CWnd和 Win32 的窗口句柄都有线程亲和性跨线程SetWindowText要么无效要么崩溃。正确做法是只做三件事读状态、更新一个受保护的缓存、PostThreadMessage或PostMessage把消息丢给主线程。缓存更新用一个std::atomicuint64_t存 LUID 位图或者用一个轻量自旋锁保护的小结构体临界区控制在几十条指令以内。// 主线程的消息循环里处理状态变化 #define WM_LINK_CHANGED (WM_USER 100) // 回调里只投递不等待 if (g_hMainWnd ! nullptr) { PostMessage(g_hMainWnd, WM_LINK_CHANGED, (WPARAM)ifRow.InterfaceLuid.Value, (LPARAM)ifRow.MediaConnectState); }PostMessage是异步的投递完立刻返回不会阻塞回调线程。主线程在WndProc里收到WM_LINK_CHANGED再做弹窗、写日志、发 HTTP 请求这些慢操作。控制台程序没有窗口就改成PostThreadMessage配一个GetMessage循环或者在回调里往std::queue塞一条记录主线程用条件变量消费。3.4 退出时反注册与句柄泄漏NotifyIpInterfaceChange返回的HANDLE必须用CancelMibChangeNotify2反注册否则进程退出时系统要等所有回调线程退出表现是“程序点了关闭但任务管理器里还挂几秒”。// 退出顺序先停通知再清缓存最后退出消息循环 if (hNotify ! nullptr) { CancelMibChangeNotify2(hNotify); // 返回后保证不会再有新回调进来 hNotify nullptr; }注意CancelMibChangeNotify2返回时只保证“不再发起新回调”如果此刻已经有一个回调正在执行它会等这个回调跑完才返回。所以回调里绝对不能调CancelMibChangeNotify2否则自己等自己直接死锁。正确的反注册位置是主线程的退出流程里且要在关窗口之前。4. 把“接口变了”翻译成“网线插了还是拔了”4.1 用 NET_LUID 做键的状态缓存回调只会告诉你“某个接口变了”不会告诉你“从连到断”还是“从断到连”。要做业务告警必须自己维护上一状态。缓存结构用std::unordered_mapUINT64, MediaConnectState键是InterfaceLuid.Value值是上次的链路状态。#include unordered_map #include mutex struct LinkSnapshot { MediaConnectState state MediaConnectStateUnknown; ULONGLONG lastChangeTick 0; std::wstring alias; }; static std::unordered_mapUINT64, LinkSnapshot g_linkCache; static std::mutex g_cacheMutex; // 返回 true 表示状态确实发生了变化 static bool UpdateLinkState(UINT64 luid, MediaConnectState newState, const std::wstring alias, bool outConnected) { std::lock_guardstd::mutex lock(g_cacheMutex); auto it g_linkCache.find(luid); ULONGLONG now GetTickCount64(); if (it g_linkCache.end()) { // 首次见到这个接口记录初始状态不算一次“变化” g_linkCache[luid] { newState, now, alias }; outConnected (newState MediaConnectStateConnected); return false; } if (it-second.state newState) { // 同一状态重复上报吃掉 return false; } it-second.state newState; it-second.lastChangeTick now; outConnected (newState MediaConnectStateConnected); return true; }这个函数做两件事一是把重复通知吃掉二是把绝对状态翻译成“发生变化”。判重逻辑放在锁内因为回调线程可能并发进来。lastChangeTick后面做去抖会用到。4.2 过滤虚拟网卡、无线网卡与回环一台开发机上同时挂着物理以太网、Wi-Fi、VMware 虚拟网卡、Docker 的vEthernet、蓝牙 PAN还有几个 TAP 适配器是常态。如果在回调里不做过滤日志会被刷屏业务告警也会误报。过滤分三层。第一层看Type只要IF_TYPE_ETHERNET_CSMACD把无线IF_TYPE_IEEE80211、回环IF_TYPE_SOFTWARE_LOOPBACK、隧道IF_TYPE_TUNNEL全部挡掉。第二层看InterfaceAndOperStatusFlags.HardwareInterface虚拟网卡这个位一般是 0。第三层看Description关键字黑名单。static bool IsVirtualAdapter(const MIB_IF_ROW2 row) { static const wchar_t* kBlackList[] { LVMware, LVirtualBox, LHyper-V, LTAP-Windows, LLoopback, LBluetooth, LWAN Miniport, LMicrosoft Wi-Fi }; for (auto kw : kBlackList) { if (wcsstr(row.Description, kw) ! nullptr) return true; } return false; }黑名单是兜底不能替代HardwareInterface判断因为不同厂商的驱动在Description里写的词五花八门。实际项目里更稳的做法是让用户手动选网卡配置界面枚举所有接口让用户勾一个“监听的网卡”程序只认这一块。工控场景通常网卡是固定的绑定Description里的型号字符串就行。4.3 去抖插拔瞬间的重复通知怎么吃掉网线插拔的物理过程不是瞬时的RJ45 触点弹跳、PHY 重新协商、驱动上报链路事件可能在两三百毫秒内触发好几次通知。如果每来一次就发一条告警业务侧会收到一串重复消息。去抖用时间窗。在UpdateLinkState检测到状态变化后再判一次now - lastChangeTick是否小于阈值常见 500ms 到 1s小于就吞掉。// 去抖阈值单位毫秒 constexpr ULONGLONG kDebounceMs 800; bool connected false; bool changed UpdateLinkState(luid, newState, alias, connected); if (changed) { std::lock_guardstd::mutex lock(g_cacheMutex); auto snap g_linkCache[luid]; if (now - snap.lastChangeTick kDebounceMs) { // 短时间内再次变化认为是抖动不对外发事件 snap.lastChangeTick now; return; } snap.lastChangeTick now; // 到这里才真正对外通知网线插入 / 网线拔出 NotifyBusiness(luid, connected); }阈值不能设太大设成 5 秒会把真实的“拔了又插”吞掉。工控和机房环境一般 500ms 到 1s 足够桌面办公场景可以放宽到 1.5s因为用户手动插拔的间隔通常更长。4.4 投递到业务层的两种写法第一种是窗口消息适合 MFC 或 Win32 桌面程序。主窗口在OnCreate里注册通知回调收到变化后PostMessage(WM_LINK_CHANGED, luid, state)WndProc里统一处理。这种方式的好处是业务逻辑天然跑在主线程上可以直接操作控件。第二种是事件加队列适合控制台程序、Windows 服务、或者嵌入到别的框架里的模块。回调往std::deque里塞一条记录再SetEvent唤醒一个消费者线程消费者线程WaitForSingleObject然后批量取。这种方式要注意队列上限网络栈异常时可能短时间灌进来上百条超过 1024 条就直接丢最老的防止内存涨上去。注意不管用哪种投递方式都不要在回调里做 DNS 解析、HTTP 请求、文件写日志。这些操作的阻塞时间不可控会拖住整个网络栈通知链路。5. 排查六个让检测在真机上翻车的坑5.1 坑一GetAdaptersInfo读不到链路状态现象是代码跑起来只能拿到 IP 和网关找不到任何跟“插没插线”有关的字段。原因是GetAdaptersInfo从 Windows 2000 时代就有返回的IP_ADAPTER_INFO结构里压根没有介质连接状态这个设计。用GetAdaptersAddresses也一样IP_ADAPTER_ADDRESSES里有OperStatus但没有MediaConnectState。解决办法是换到GetIfEntry2从MIB_IF_ROW2里读或者用GetIfTable2批量拉一张表再按 LUID 匹配。5.2 坑二回调里弹窗导致主线程卡死现象是插拔网线几次之后界面无响应调试器里看到回调线程停在NtUserMessageCall。原因是回调运行在网络栈线程上MessageBox会在该线程上建一个模态消息循环把后续通知全堵在队列里。解决办法是回调里只做状态读取和PostMessage弹窗、声音、日志全部丢到主线程并且加一个“同一状态 800ms 内只处理一次”的去抖。5.3 坑三虚拟网卡和蓝牙适配器污染状态现象是拔掉物理网线程序还在报“已连接”。排查发现日志里最后一条变化来自VMware Network Adapter VMnet8或Bluetooth Device (PAN)它们的MediaConnectState一直是 Connected。原因是枚举接口时没做过滤。解决办法是按Type IF_TYPE_ETHERNET_CSMACD加HardwareInterface TRUE双条件过滤再叠一层Description黑名单最后在配置层允许用户手动绑定唯一网卡。5.4 坑四网卡被禁用和拔网线是两回事现象是在设备管理器里“禁用”网卡程序报了“网线拔出”用户以为线松了跑去机房查。原因是网卡被管理员禁用时OperStatus变成IfOperStatusDownMediaConnectState也常常跟着变 Disconnected但物理线缆其实还插着。区分方法是读MIB_IF_ROW2::AdminStatusNET_IF_ADMIN_STATUS_DOWN表示被管理端禁用NET_IF_ADMIN_STATUS_UP才是正常插拔。告警文案要分开写“网卡被禁用”和“网线断开”给运维的处置动作完全不同。5.5 坑五MediaConnectState报 Unknown现象是在某些 USB 网卡、虚拟机网卡、或者驱动比较老的机器上插拔时读到的MediaConnectState一直是MediaConnectStateUnknown状态判断彻底失效。原因是这部分驱动没有正确实现 NDIS 的链路状态上报或者走的是 USB 转以太网的桥接芯片状态上报滞后。解决办法是加兜底判断MediaConnectState为 Unknown 时改看OperStatus IfOperStatusUp和InterfaceAndOperStatusFlags.NotMediaConnected位两者组合起来判断准确率能到九成以上。再不行就退化到轮询GetIfTable2以 2 秒间隔对账。5.6 坑六服务程序里的回调被会话隔离现象是把检测逻辑塞进 Windows 服务之后日志里一条回调都没有进程活着但什么也不输出。原因是服务跑在 Session 0没有交互桌面如果代码里试图用FindWindow找主窗口再PostMessage拿到的是空句柄消息直接丢了。解决办法是服务里不走窗口消息改成命名事件加共享内存或者用PostThreadMessage配一个服务内部的工作线程消息循环。UI 侧的客户端通过命名管道订阅服务端的状态变化。6. 上线前的自检用 netsh 和事件日志把检测跑通插拔网线这件事在机房里不好复现上线前得有一套手工制造链路变化的办法。我一般用五种手段交替验证第一物理拔插最真实但最慢第二netsh interface set interface 以太网 admindisable再adminenable模拟网卡禁用用来验证AdminStatus分支第三设备管理器里禁用再启用网卡走的是驱动卸载重载路径能验证MibDeleteInstance和MibAddInstance第四用一台小交换机做对端拔交换机侧的水晶头验证对端掉电的情况第五虚拟机里把“已连接”勾选框取消再勾上验证 USB 网卡和虚拟网卡这类Unknown状态兜底。# 以管理员身份运行制造一次管理态禁用/启用 netsh interface set interface 以太网 admindisable timeout /t 3 netsh interface set interface 以太网 adminenable # 查看当前所有接口的链路状态PowerShell Get-NetAdapter | Select-Object Name, Status, MediaConnectionState, LinkSpeed这段命令里的MediaConnectionState就是MIB_IF_ROW2::MediaConnectState的 PowerShell 投影跟 C 里读到的是同一个值。自检时一边跑命令一边看程序日志两条时间戳能对上说明通知链路是通的如果命令行状态已经变了而日志没动问题就出在回调注册或者过滤条件上先把IsWiredEthernet的返回值打出来看看。回归验证我习惯写一个简单的日志对账脚本程序每次状态变化打一行[tick][luid][state]手工操作一遍之后把日志拉出来按 LUID 分组看状态序列是不是“断→连→断”交替出现中间有没有重复的连续同态。连续同态说明去抖没吃掉交替缺失说明某次通知漏了通常是HardwareInterface过滤太严把真实网卡也挡掉了。这套对账跑通之后再把检测逻辑接进业务告警心里就有底了。在那之后我每次做网卡状态相关的需求都会先在纸上画一张三层表——物理链路、IP 地址、网络可达——把每一层用哪个 API、延迟多少、失败时兜底看什么字段写清楚再动手敲代码。跳过这一步直接写GetAdaptersInfo的基本上都要返工。希望帮到你。本文还有配套的精品资源点击获取