资讯动态

纯C跨平台硬件信息采集与设备指纹实现

发布时间:2026/10/24 7:51:04 来源:尧图企业网站定制
简介面向需要在 Linux 与 Windows 下完成硬件信息采集的 C/C 开发者与学习者这份小型代码包聚焦 CPU 序列号、硬盘序列号、网卡名称、IP 地址、MAC 地址以及网线连接状态的获取可服务于系统监控、设备识别、故障排查和课程实验等场景。压缩包共 2 个文件包含 1 个 .h 头文件与 1 个 .cpp 源文件整体约 7KB头文件用于接口声明源文件承载跨平台实现结构紧凑便于按功能阅读和移植。已有 3101 人学习具备一定参考热度。通过阅读可了解条件编译区分平台差异、系统接口调用与文件系统信息读取的组织方式并借鉴将多项硬件查询分别封装为函数的做法适合作为底层系统编程的入门样例和实验参考尤其对需要理解两套操作系统差异、整理可复用采集代码的读者较为友好。1. 授权绑定现场翻车之后一份纯 C 的跨平台硬件信息采集代码C 语言获取硬件信息这件事我最早是被一个授权绑定的需求逼着做的程序要在不联网的机器上认出这一台CPU 序列号、硬盘序列号、网卡 IP 和 MAC 地址、网线有没有插四个字段凑一起当设备指纹。当时的想法很朴素几个system()调命令就完了结果在客户机房翻车——采到的序列号是一串空格采集函数偶尔卡住十几秒还有一台机器直接返回空指针。后来老老实实用系统 API 重写了一遍把 Windows 和 Linux 两套实现压进同一个头文件。这份压缩包就是那套代码整理后的版本纯 C、零第三方库、编译期分平台做设备指纹、资产采集、工控上位机自检的人可以直接拿去改。2. 结构体与编译分流把 Windows 和 Linux 压进同一个头文件跨平台采集最容易写歪的地方不是 API 难调而是两边返回值长得不一样业务层被迫写两套判断。所以第一件事不是写采集函数而是先把数据形状定死让上层只认一个结构体。2.1 为什么先把 system() 全部拆掉命令行的好处是上手快坏处是每一条都会在生产环境变成事故。下面这段是当年写的第一版能在开发机上跑出正确结果上线三天就出问题/* 反例能跑但不可用于交付 */ int bad_get_disk_serial(char *out, size_t n) { FILE *f popen(wmic diskdrive get serialnumber, r); if (!f) return -1; fgets(out, (int)n, f); /* 第一行是表头 SerialNumber */ fgets(out, (int)n, f); /* 第二行才是值尾部还挂着 \r\n */ pclose(f); return 0; }这段代码有三处硬伤。第一popen没有超时机制子进程卡住父进程就跟着卡住界面上表现为点一下采集按钮整个窗口没反应。第二wmic在较新的 Windows 版本里已经是按需安装的可选组件机器上不一定有命令不存在时popen依然返回非空fgets拿到的是空内容函数却返回成功。第三命令输出受系统区域设置影响表头文字、字段顺序、编码都可能变靠读第二行这种位置假设迟早会崩。我一般会把这类采集统一改成直接调系统接口Linux 走/proc、/sys和getifaddrs()Windows 走 IP Helper API 和DeviceIoControl。调用失败时能拿到明确的错误码而不是一段需要猜的文本。2.2 采集结构体哪些字段能进指纹哪些只能做展示指纹字段的挑选标准只有一条——同一台机器重装系统、换驱动、插拔网线之后值必须不变。按这个标准过一遍四个字段的定位其实差别很大字段Linux 来源Windows 来源稳定性是否进指纹CPU 标识/proc/cpuinfo特征位 CPUIDWin32_Processor.ProcessorId/__cpuid同批次可能重复作为权重较低的一项硬盘序列号/sys/block/dev/device/serialIOCTL_STORAGE_QUERY_PROPERTY换盘即变虚拟机常为空主键但必须允许为空MAC 地址getifaddrs()的AF_PACKETGetAdaptersAddresses的PhysicalAddress可被修改虚拟网卡会漂只取第一块物理网卡IP 地址getifaddrs()的AF_INETFirstUnicastAddressDHCP 下每次开机都可能变不进指纹仅展示链路状态/sys/class/net/if/carrierMediaConnectState插拔即变不进指纹仅展示据此定下的公共结构体两边实现都只负责往里填/* hw_info.h —— 上层业务只依赖这个头文件 */ #ifndef HW_INFO_H #define HW_INFO_H #define HW_MAX_NIC 16 #define HW_STR_SLOT 128 typedef struct { char name[64]; /* 网卡名Linux 是 eth0Windows 是以太网 */ char ipv4[46]; /* 点分十进制未配置时为空串 */ char mac[18]; /* 大写冒号分隔如 A0:B1:C2:D3:E4:F5 */ int link_up; /* 1物理链路通 0断开 -1无法判定 */ } hw_nic_t; typedef struct { char cpu_id[HW_STR_SLOT]; /* 归一化后的 CPU 指纹 */ char disk_serial[HW_STR_SLOT]; /* 主硬盘序列号可能为空 */ hw_nic_t nic[HW_MAX_NIC]; int nic_count; } hw_info_t; int hw_collect(hw_info_t *info); /* 0 成功非 0 为错误码 */ #endiflink_up特意留了-1这个取值。虚拟网卡、容器网桥、部分无线网卡根本没有物理链路概念硬报 0 会让上层误判成网线没插这是后面第 5 章要展开的坑。hw_collect的返回值不做部分成功处理任何一项失败都返回错误码由调用方决定是否降级。这样做的好处是排错时不用猜——失败就是失败不会出现结构体里一半有值一半是脏数据的状态。2.3 三种编译目标与目录组织代码按平台拆成三个文件公共头文件不含任何平台宏平台宏只在实现文件里出现# 目录结构 # hwprobe/ # hw_info.h 公共结构体与接口声明 # hwprobe.c 入口负责输出 # hw_linux.c Linux 实现 # hw_win.c Windows 实现 # Linux 本机编译开满告警 gcc -stdc11 -O2 -Wall -Wextra -Wshadow \ -o hwprobe hwprobe.c hw_linux.c # 验证 32 位下的结构体对齐老工控机还在跑 32 位系统 gcc -m32 -stdc11 -O2 -o hwprobe32 hwprobe.c hw_linux.c # Windows在开发者命令行里执行 cl /nologo /W3 /std:c11 hwprobe.c hw_win.c \ iphlpapi.lib ws2_32.lib ole32.lib advapi32.lib # 跑一次看结果 ./hwprobe --oneline-Wshadow是我个人习惯加上的。跨平台代码里index、time、link这类名字很容易和系统头文件里的符号撞车Linux 上编过、Windows 上就报重复定义加了告警能提前发现。hwprobe.c里用编译期开关选择实现而不是在运行时判断/* hwprobe.c —— 入口输出一行可 diff 的文本 */ #include stdio.h #include string.h #include hw_info.h int main(int argc, char **argv) { hw_info_t info; memset(info, 0, sizeof(info)); int ret hw_collect(info); if (ret ! 0) { fprintf(stderr, collect failed, code%d\n, ret); return ret; } if (argc 1 strcmp(argv[1], --oneline) 0) { /* 单行输出方便脚本 diff 和回归比对 */ printf(cpu%s disk%s nic%s ip%s mac%s link%d\n, info.cpu_id, info.disk_serial, info.nic_count ? info.nic[0].name : -, info.nic_count ? info.nic[0].ipv4 : -, info.nic_count ? info.nic[0].mac : -, info.nic_count ? info.nic[0].link_up : -1); } return 0; }单行输出的格式是刻意设计的字段之间用空格分隔、不带引号、值内部不含空格这样sort、uniq -c、diff都能直接吃不需要再写解析脚本。后面第 6 章的稳定性回归就靠这个格式。3. CPU 与硬盘序列号x86 拿不到唯一值时怎么退而求其次这两个字段是被问得最多的也是最容易给出错误承诺的。很多需求文档上写着读取 CPU 序列号作为唯一标识实际在 x86 上根本拿不到实现的人如果不提前说清楚交付时就要背锅。3.1 CPUID、DMI、WMI 三条路径的可信度分层把三条路摆在一起看差距非常明显路径平台实际拿到的内容唯一性权限要求CPUID叶 0x01x86 双平台家族、型号、步进、特征位同型号同批次完全一致无/proc/cpuinfoLinux厂商、型号名、flags同上弱于序列号无/sys/class/dmi/id/product_uuidLinux主板/整机 UUID较强虚拟机常为全 0通常需 root/proc/cpuinfo的SerialARM Linux芯片唯一序列号强无Win32_Processor.ProcessorIdWindowsCPUID 返回值的拼接同批次一致无Win32_ComputerSystemProduct.UUIDWindows整机 SMBIOS UUID较强无结论很直接x86 上没有可用的 CPU 序列号。早年 Pentium III 时代 CPUID 叶 0x03 能返回 96 位处理器序列号后来因为隐私争议被关掉了现在这条指令在绝大多数机器上返回全 0。所以代码里对 CPU 字段的定位必须是指纹的一项输入而不是唯一 ID。真正稳定的做法是把 CPU 特征和主板 UUID 一起用。Linux 上优先读 DMI读不到再退回 CPUID 组合Windows 上走 WMI 拿整机 UUID再拿 ProcessorId 做补充。3.2 x86 上的 CPU 指纹CPUID 打底、特征位补足Linux 侧的实现分两段先尝试 DMI失败再拼 CPUID 特征/* hw_linux.c —— CPU 指纹采集 */ #include stdio.h #include string.h #include ctype.h /* 读文件首行并裁掉尾部空白返回 0 表示拿到非空内容 */ static int read_trim(const char *path, char *out, size_t n) { FILE *f fopen(path, r); if (!f) return -1; if (!fgets(out, (int)n, f)) { fclose(f); return -1; } fclose(f); size_t len strlen(out); while (len 0 isspace((unsigned char)out[len - 1])) out[--len] \0; return len 0 ? 0 : -1; } /* FNV-1a 32 位用来把多个特征压成一个短串 */ static unsigned int fnv1a(const char *s) { unsigned int h 2166136261u; while (*s) { h ^ (unsigned char)*s; h * 16777619u; } return h; } int get_cpu_id(char *out, size_t n) { char buf[256]; /* 路径一整机 UUIDx86 上最接近唯一的一项 */ if (read_trim(/sys/class/dmi/id/product_uuid, buf, sizeof(buf)) 0) { snprintf(out, n, %s, buf); return 0; } /* 路径二ARM 平台 /proc/cpuinfo 里的 Serial 字段 */ FILE *f fopen(/proc/cpuinfo, r); if (!f) return -1; unsigned int h 2166136261u; int hit 0; while (fgets(buf, sizeof(buf), f)) { if (!strncmp(buf, Serial, 6)) { /* ARM 独有 */ char *p strchr(buf, :); if (p) { snprintf(out, n, %s, p 2); fclose(f); return 0; } } /* x86把厂商、家族、型号、步进一起喂进哈希 */ if (!strncmp(buf, vendor_id, 9) || !strncmp(buf, cpu family, 10) || !strncmp(buf, model name, 10) || !strncmp(buf, stepping, 8)) { h ^ fnv1a(buf); h * 16777619u; hit 1; } } fclose(f); if (!hit) return -2; snprintf(out, n, CPUF-%08X, h); return 0; }几个参数和返回值需要说明。read_trim里的isspace判断是必要的/sys下的文件普遍带一个换行符不裁掉会让后续strcmp永远不相等。fnv1a选 32 位而不是 CRC32是因为它实现短、无查表、分布够用碰撞概率对设备指纹这个量级完全够。返回值定义成 0 成功、-1 文件不存在、-2 什么都没读到调用方据此决定是字段留空还是整体失败。注意product_uuid在虚拟机上经常是全 0 或者全 F代码里没做过滤这一步放到第 3.4 节的清洗规则里统一处理避免逻辑散落。Windows 侧用__cpuid打底走 WMI 拿整机 UUID 作为补充两者拼起来/* hw_win.c —— CPU 与整机标识 */ #include windows.h #include intrin.h int get_cpu_id(char *out, size_t n) { /* 先用 CPUID 拿家族/型号/步进作为兜底值 */ int regs[4] {0}; __cpuid(regs, 0x00000001); unsigned int family_model_stepping (unsigned int)regs[0]; unsigned int feature_flags (unsigned int)regs[3]; /* 再尝试从 SMBIOS 的整机 UUID 取更强的一项 */ char uuid[128] {0}; if (get_smbios_uuid(uuid, sizeof(uuid)) 0 uuid[0]) { snprintf(out, n, %s, uuid); return 0; } snprintf(out, n, CPUF-%08X%08X, family_model_stepping, feature_flags); return 0; }get_smbios_uuid内部走 WMI 的Win32_ComputerSystemProduct需要先CoInitializeEx再CoCreateInstance拿IWbemLocator连接ROOT\CIMV2命名空间执行SELECT UUID FROM Win32_ComputerSystemProduct。这段 COM 代码有七八十行压缩包里是独立文件这里不铺开。要提醒的是CoInitializeEx的第二个参数用COINIT_APARTMENTTHREADED如果采集函数会被工作线程调用记得改成COINIT_MULTITHREADED否则会拿到RPC_E_CHANGED_MODE。%08X%08X拼出来是 16 个十六进制字符看着像序列号实际只是 CPUID 返回值的直译。这一点必须在接口注释里写死不然接手的人真的会拿它当唯一 ID 用。3.3 硬盘序列号Linux 走 sysfsWindows 走 IOCTL硬盘序列号比 CPU 靠谱得多但坑集中在设备名不固定和权限上。Linux 侧的核心是遍历/sys/block跳过虚拟设备然后按驱动暴露的路径依次试探/* hw_linux.c —— 硬盘序列号 */ #include dirent.h #include stdio.h #include string.h static int is_virtual_dev(const char *name) { /* 这些前缀对应的都不是物理盘loop 是回环、ram 是内存盘、 dm-/md 是 LVM 与软 RAID、sr 是光驱、zram 是压缩内存盘 */ static const char *prefix[] { loop, ram, zram, dm-, md, sr, fd, nbd }; for (size_t i 0; i sizeof(prefix) / sizeof(prefix[0]); i) if (!strncmp(name, prefix[i], strlen(prefix[i]))) return 1; return 0; } int get_disk_serial(char *out, size_t n) { DIR *d opendir(/sys/block); if (!d) return -1; struct dirent *e; while ((e readdir(d)) ! NULL) { if (e-d_name[0] . || is_virtual_dev(e-d_name)) continue; char path[256]; /* 路径一SATA / SAS / NVMe 走 device/serial */ snprintf(path, sizeof(path), /sys/block/%s/device/serial, e-d_name); if (read_trim(path, out, n) 0) { closedir(d); return 0; } /* 路径二部分驱动把 serial 直接放在块设备目录下 */ snprintf(path, sizeof(path), /sys/block/%s/serial, e-d_name); if (read_trim(path, out, n) 0) { closedir(d); return 0; } /* 路径三兜底用 wwid格式不同但同样能标识这块盘 */ snprintf(path, sizeof(path), /sys/block/%s/device/wwid, e-d_name); if (read_trim(path, out, n) 0) { closedir(d); return 0; } } closedir(d); return -2; /* 一块物理盘都没匹配上 */ }readdir返回的顺序不保证和内核枚举顺序一致所以多盘机器上第一次跑和第二次跑可能拿到不同的盘。要固定顺序得在循环结束后按e-d_name排序再取第一块压缩包里用的是一个固定大小的数组缓存加qsort。这一点在多盘服务器上很关键不排序的指纹会随机漂移。另外read_trim复用了第 3.2 节的实现但函数是static的两个文件各有一份。这不是偷懒而是刻意的——跨平台代码里最怕的就是为了复用把static去掉最后链接期出现同名符号冲突。Windows 侧不用wmic直接对\\.\PhysicalDriveN发控制码/* hw_win.c —— 硬盘序列号走 IOCTL 而非 WMI */ #include windows.h #include winioctl.h int get_disk_serial(char *out, size_t n) { for (int i 0; i 8; i) { /* 最多探 8 块盘 */ char dev[32]; snprintf(dev, sizeof(dev), \\\\.\\PhysicalDrive%d, i); /* dwDesiredAccess 传 0 即可查询属性不需要读权限 但打开 PhysicalDrive 本身仍然要有管理员权限 */ HANDLE h CreateFileA(dev, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (h INVALID_HANDLE_VALUE) continue; STORAGE_PROPERTY_QUERY q; memset(q, 0, sizeof(q)); q.PropertyId StorageDeviceProperty; q.QueryType PropertyStandardQuery; BYTE buf[4096]; DWORD ret 0; BOOL ok DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, q, sizeof(q), buf, sizeof(buf), ret, NULL); CloseHandle(h); if (!ok) continue; STORAGE_DEVICE_DESCRIPTOR *d (STORAGE_DEVICE_DESCRIPTOR *)buf; if (d-SerialNumberOffset 0) continue; const char *serial (const char *)buf d-SerialNumberOffset; if (!serial[0]) continue; snprintf(out, n, %s, serial); return 0; } return -1; }dwDesiredAccess传 0 是个容易踩的点很多人下意识写GENERIC_READ结果在某些机器上因为卷被占用而打开失败。查询属性类控制码不需要读权限传 0 反而更稳。STORAGE_DEVICE_DESCRIPTOR的SerialNumberOffset是相对缓冲区首地址的偏移量不是指针。这里必须用buf而不是d做基址运算写成(const char *)d offset会偏出去一截拿到的是一堆乱码。这个错误我在早期代码里犯过一次排查了半天。返回值可能是带空格的也可能是厂商写死的占位字符统一交给下一节处理。3.4 取值异常时的清洗规则原始取值不能直接进指纹必须先过一遍清洗。压缩包里有一个sanitize()函数规则就四条/* hw_common.c —— 所有字符串字段统一过这一道 */ #include ctype.h #include string.h int sanitize(char *s, size_t n) { /* 规则一裁掉首尾空白保留中间空格 */ char *p s; while (*p isspace((unsigned char)*p)) p; if (p ! s) memmove(s, p, strlen(p) 1); size_t len strlen(s); while (len 0 isspace((unsigned char)s[len - 1])) s[--len] \0; /* 规则二长度不足 4 的直接判废避免 0 N/A 混进指纹 */ if (len 4) { s[0] \0; return -1; } /* 规则三全 0 或全 F 视为无效虚拟机和 OEM 主板常见 */ int all_zero 1, all_f 1; for (size_t i 0; i len; i) { if (s[i] ! 0) all_zero 0; if (s[i] ! F s[i] ! f) all_f 0; } if (all_zero || all_f) { s[0] \0; return -1; } /* 规则四厂商占位符黑名单比较忽略大小写 */ static const char *bads[] { to be filled by o.e.m., default string, none, unknown, not specified, system serial number }; for (size_t i 0; i sizeof(bads) / sizeof(bads[0]); i) if (_stricmp(s, bads[i]) 0) { s[0] \0; return -1; } return 0; }四条规则对应四类真实数据带空格的序列号IOCTL返回的典型形态、0和N/A这类噪音、虚拟机全 0 UUID、OEM 主板占位符。_stricmp是 Windows 的写法Linux 上对应strcasecmp头文件分别是string.h和strings.h。压缩包里用一个#ifdef _WIN32包了两行宏没有把整个文件拆成两份。清洗失败的字段留空hw_collect仍然返回成功由上层决定指纹降级还是拒绝运行。这个决策不该由采集层替业务做。4. 网卡 IP 与 MACgetifaddrs 与 GetAdaptersAddresses 的字段对照网卡部分的难点不在取不到值而在取到的值太多。一台开发机动辄十几条记录回环、容器网桥、虚拟机网卡混在一起不过滤的话指纹每天都在变。4.1 Linux一次遍历拿全 AF_PACKET 和 AF_INETgetifaddrs()返回的是链表每块网卡至少两条记录一条AF_PACKET带 MAC一条AF_INET带 IPv4。这两条记录靠ifa_name关联/* hw_linux.c —— 网卡枚举 */ #include ifaddrs.h #include net/if.h #include netinet/in.h #include arpa/inet.h #include sys/socket.h #include linux/if_packet.h #include string.h #include stdio.h int enum_nic(hw_nic_t *out, int max, int *count) { struct ifaddrs *ifa NULL, *p NULL; *count 0; if (getifaddrs(ifa) ! 0) return -1; for (p ifa; p *count max; p p-ifa_next) { if (!p-ifa_addr) continue; /* 只从 AF_PACKET 记录起手一条记录对应一块网卡 */ if (p-ifa_addr-sa_family ! AF_PACKET) continue; if (p-ifa_flags IFF_LOOPBACK) continue; /* 回环不要 */ if (!(p-ifa_flags IFF_UP)) continue; /* 未启用的不要 */ struct sockaddr_ll *sll (struct sockaddr_ll *)p-ifa_addr; /* 隧道设备 halen 为 0正好在这里挡掉 */ if (sll-sll_halen ! 6) continue; hw_nic_t *cur out[*count]; memset(cur, 0, sizeof(*cur)); snprintf(cur-name, sizeof(cur-name), %s, p-ifa_name); snprintf(cur-mac, sizeof(cur-mac), %02X:%02X:%02X:%02X:%02X:%02X, sll-sll_addr[0], sll-sll_addr[1], sll-sll_addr[2], sll-sll_addr[3], sll-sll_addr[4], sll-sll_addr[5]); /* 二次遍历找同名网卡的 IPv4一块网卡可能有多个地址取第一个 */ struct ifaddrs *q NULL; for (q ifa; q; q q-ifa_next) { if (!q-ifa_addr || q-ifa_addr-sa_family ! AF_INET) continue; if (strcmp(q-ifa_name, p-ifa_name) ! 0) continue; inet_ntop(AF_INET, ((struct sockaddr_in *)q-ifa_addr)-sin_addr, cur-ipv4, sizeof(cur-ipv4)); break; } /* IFF_RUNNING 在内核里映射到 netif_carrier_ok可作初判 */ cur-link_up (p-ifa_flags IFF_RUNNING) ? 1 : 0; (*count); } freeifaddrs(ifa); return *count 0 ? 0 : -2; }几个参数值得单独说。sll_halen ! 6这一条过滤掉了绝大多数隧道设备和部分虚拟接口它们的硬件地址长度不是 6进来也拼不出合法 MAC。inet_ntop的缓冲区必须至少INET_ADDRSTRLEN16 字节结构体里给了 46 是为了同时容纳 IPv6虽然当前版本只填 IPv4。IFF_RUNNING只能作初判它的确切语义依赖驱动实现第 5 章会讲为什么最终判定不能只靠它。二次遍历是 O(n²)网卡数量在几十这个量级性能上完全无感可读性比一次性建哈希表好得多。我一般会优先选可读性。4.2 Windows缓冲区协商与宽字符落盘GetAdaptersAddresses的调用模式是两段式先给一个估计大小不够会返回ERROR_BUFFER_OVERFLOW同时把size改成实际需要的大小再调一次即可。压缩包里包了三轮重试防止枚举过程中网卡数量变化/* hw_win.c —— 网卡枚举 */ #include winsock2.h #include ws2tcpip.h #include iphlpapi.h #include ipifcons.h #include windows.h static int wide_to_utf8(const WCHAR *w, char *out, int n) { return WideCharToMultiByte(CP_UTF8, 0, w, -1, out, n, NULL, NULL); } int enum_nic(hw_nic_t *out, int max, int *count) { ULONG size 16 * 1024; IP_ADAPTER_ADDRESSES *buf NULL, *cur NULL; DWORD ret ERROR_SUCCESS; *count 0; for (int round 0; round 3; round) { buf (IP_ADAPTER_ADDRESSES *)malloc(size); if (!buf) return -1; ret GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER, NULL, buf, size); if (ret ERROR_SUCCESS) break; free(buf); buf NULL; if (ret ! ERROR_BUFFER_OVERFLOW) return -2; /* size 已被改写成所需大小下一轮直接用 */ } if (!buf) return -3; for (cur buf; cur *count max; cur cur-Next) { if (cur-IfType IF_TYPE_SOFTWARE_LOOPBACK) continue; if (cur-IfType IF_TYPE_TUNNEL) continue; if (cur-OperStatus ! IfOperStatusUp) continue; if (cur-PhysicalAddressLength ! 6) continue; hw_nic_t *nic out[*count]; memset(nic, 0, sizeof(*nic)); /* FriendlyName 是 WCHAR统一转 UTF-8 再落进结构体 */ wide_to_utf8(cur-FriendlyName, nic-name, sizeof(nic-name)); snprintf(nic-mac, sizeof(nic-mac), %02X:%02X:%02X:%02X:%02X:%02X, cur-PhysicalAddress[0], cur-PhysicalAddress[1], cur-PhysicalAddress[2], cur-PhysicalAddress[3], cur-PhysicalAddress[4], cur-PhysicalAddress[5]); for (PIP_ADAPTER_UNICAST_ADDRESS ua cur-FirstUnicastAddress; ua; ua ua-Next) { if (ua-Address.lpSockaddr-sa_family ! AF_INET) continue; inet_ntop(AF_INET, ((struct sockaddr_in *)ua-Address.lpSockaddr)-sin_addr, nic-ipv4, sizeof(nic-ipv4)); break; } /* OperStatus 是协议层状态链路层要另外查见第 5 章 */ nic-link_up (cur-OperStatus IfOperStatusUp) ? 1 : 0; (*count); } free(buf); return *count 0 ? 0 : -4; }AF_UNSPEC表示同时枚举 IPv4 和 IPv6因为要知道一块网卡有没有地址两族都得看。GAA_FLAG_SKIP_ANYCAST和GAA_FLAG_SKIP_MULTICAST跳过任意播和组播地址能显著减少返回条数也避免把组播地址误当成本机 IP。GAA_FLAG_SKIP_DNS_SERVER是较新版本才支持的标志老系统上如果编译器报未定义去掉它即可不影响结果。PhysicalAddressLength ! 6这一条和 Linux 那边的sll_halen ! 6是同一个思路。Windows 上有些虚拟网卡比如某些隧道适配器的PhysicalAddressLength是 0 或者 8不过滤就拼不出合法的 MAC 字符串。FriendlyName是中文系统的以太网、英文系统的 Ethernet可能是中文也可能是英文所以结构体里给到 64 字节。WideCharToMultiByte传CP_UTF8而不是CP_ACP是为了让同一份程序在中英文系统上输出一致脚本处理时不用再判断编码。inet_ntop在 Windows 上需要ws2tcpip.h并且使用前要WSAStartup。压缩包里在hw_collect入口调了一次WSAStartup(MAKEWORD(2,2), wsa)退出时WSACleanup这段不贴了但漏掉会直接返回WSANOTINITIALISED。4.3 过滤口径把回环、隧道、虚拟网桥挡在外面两边都过滤之后还剩一类漏网的——宿主机上的虚拟网桥。它们有合法的 6 字节 MACOperStatus也是 Up看着完全像一块物理网卡。压缩包里的做法是加一层名称前缀黑名单平台需要排除的前缀说明Linuxdocker、br-、veth、virbr、vmnet、tap、tun容器与虚拟机网桥Linuxbond、team聚合口MAC 可能随主备切换变化WindowsvEthernet、VMware、VirtualBox、Loopback虚拟化平台适配器黑名单匹配要忽略大小写。压缩包里hw_nic_match_blacklist()用strncasecmp/_strnicmp做前缀比较两个平台各一行宏。过滤完之后按有 IPv4 的优先、链路通的优先排序取第一块作为主网卡。排序规则写在一个nic_cmp()里改动排序策略只需要动这一个函数比散在采集逻辑里强。还有一点MAC 地址是可以被改的某些网卡驱动支持在系统设置里修改改完指纹就变了。所以 MAC 在指纹里的权重必须低于硬盘序列号这一点在第 2.2 节的表里已经标注过。5. 网线插拔与权限坑5 条踩坑记录和一条排查路径网线有没有插这个需求听起来最简单实际是四个字段里最容易翻车的。核心原因是它有三层含义完全不同的状态混用就会出现网线拔了程序还说通或者网线插着程序说断。5.1 物理链路、协议状态、驱动标志三层信息别混用Linux 上有三个地方能看# 物理层有没有检测到对端信号1 通 0 断 cat /sys/class/net/eth0/carrier # 协议层up / down / unknown / dormant / lowerlayerdown cat /sys/class/net/eth0/operstate # 驱动层速率无链路时读会失败 cat /sys/class/net/eth0/speed # 网卡自身是否启用注意这和网线无关 ip link show eth0 | head -1carrier才是真正的物理层状态operstate是协议层状态ip link里的UP只是网卡被ifconfig up启用了跟网线插没插一点关系都没有。多少人把IFF_UP当成网线已插就是这个混淆造成的。封装成函数/* hw_linux.c —— 链路状态三层依次降级 */ enum { LINK_DOWN 0, LINK_UP 1, LINK_UNKNOWN -1 }; int get_link_state(const char *ifname) { char path[128], buf[64]; /* 第一层carrier物理链路最可信 */ snprintf(path, sizeof(path), /sys/class/net/%s/carrier, ifname); if (read_trim(path, buf, sizeof(buf)) 0) return (buf[0] 1) ? LINK_UP : LINK_DOWN; /* 第二层operstate协议层虚拟设备常为 unknown */ snprintf(path, sizeof(path), /sys/class/net/%s/operstate, ifname); if (read_trim(path, buf, sizeof(buf)) 0) { if (!strcmp(buf, up)) return LINK_UP; if (!strcmp(buf, unknown)) return LINK_UNKNOWN; /* 判不了就说判不了 */ return LINK_DOWN; } return LINK_UNKNOWN; }这里的关键设计是LINK_UNKNOWN。carrier读失败有两种情况网卡被 down 掉了内核返回EINVAL或者这个设备根本没有 carrier 概念无线网卡、tun 设备。这两种情况报 0 都是错的报-1让本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑