简介面向C语言开发者的跨平台硬件信息采集示例覆盖Windows与Linux两大系统解决CPU序列号、硬盘序列号、网卡IP/MAC及连接状态获取等常见需求适用于系统监控、设备识别、故障排查等场景。压缩包仅含2个文件——1个头文件与1个C源文件整体约7KB结构精简便于阅读和移植到实际工程中。代码在Windows下借助WMI、GetAdaptersAddresses等API在Linux下读取/proc/cpuinfo、/sys/block/、/sys/class/net/等接口并通过条件编译屏蔽系统差异可直接对照学习或作为基础工具函数复用。已有3100人学习下载适合想深入理解系统调用与硬件信息读取逻辑的C语言学习者也可为系统开发人员提供跨平台实现的快速参考。1. C语言获取硬件信息为什么这份zip能直接拿去做设备指纹做设备授权、资产盘点或者写机房巡检工具时绕不开的一个需求就是用C语言拿到CPU序列号、硬盘序列号、网卡IP、MAC地址最好还能告诉你现在网线有没有插上。很多人第一反应是搜一堆APIWindows下一个套路、Linux下另一个套路两边代码风格还不一样拼起来就是一场灾难。这份zip的工程把两边的完整读取逻辑都整理好了Windows侧走系统API和注册表、Linux侧走sysfs和ioctl拿到手的不是片段是可编译的工程。适合要做硬件指纹、机器绑定、网络诊断的C开发者新手照着模子改熟手直接抽代码合并到自己的模块里。下面的内容会按“方案怎么选、代码怎么写、坑在哪”展开。2. 跨平台方案取舍Windows走系统APILinux走sysfs别上来就引第三方库2.1 为什么不用WMI和libudev全家桶拿到这个需求的时候我第一版也想偷懒Windows直接调WMILinux直接链libudev。后来发现WMI在C语言里要搞COM初始化、IEnumWbemClassObject遍历代码量直接翻倍编译出来的exe还得带一堆初始化分支出了问题根本分不清是权限问题还是COM没初始化好。工程里Windows侧用的是DeviceIoControl加GetAdaptersAddresses不走WMI原因很简单这两个API是内核态直接回话不用起WMI服务在精简版系统上也跑得动。Linux侧也没用libudev直接读/sys和/proc理由更朴素——目标机器上不一定装了对应该版本的libudev动态库一缺就是黑匣子。选型时我的判断标准就三条不依赖系统版本相关的第三方库、不需要图形界面、能在一个进程里拿全所有信息。以这个标准回头看Windows侧的cpuid指令、注册表项、DeviceIoControlLinux侧的/sys/class/net、/proc/cpuinfo、ioctl都是系统自带的最短路径。2.2 工程结构和条件编译入口zip里解出来是典型的双平台工程Windows侧是VS的sln加一个hardware_win.cLinux侧是Makefile加hardware_linux.c两个文件都include一个共用的hardware_info.h。头文件里用宏做了平台开关把两套实现隔离干净。代码骨架如下// hardware_info.h #ifndef HARDWARE_INFO_H #define HARDWARE_INFO_H #if defined(_WIN32) #define HW_PLATFORM_WINDOWS 1 #elif defined(__linux__) #define HW_PLATFORM_LINUX 1 #else #error Unsupported platform #endif #define HW_CPU_ID_LEN 64 #define HW_DISK_ID_LEN 64 #define HW_MAC_LEN 32 #define HW_IP_LEN 64 typedef struct { char cpu_id[HW_CPU_ID_LEN]; char disk_id[HW_DISK_ID_LEN]; char mac[HW_MAC_LEN]; char ip[HW_IP_LEN]; int link_status; // 1: 网线插入 0: 未插入 } hardware_info_t; #endif这段头文件的核心是两个条件编译宏HW_PLATFORM_WINDOWS和HW_PLATFORM_LINUX。结构体hardware_info_t把四个硬件信息和网线状态统一到一个结构里上层业务只需要调一个函数就能填好全部字段不用关心底下是哪个系统。link_status用int而不是布尔是为了后续能扩展成-1表示“接口不支持”的状态。头文件里的长度宏都是保守值MAC地址格式化后最长17字节留32字节是给冒号分隔和以后可能的扩展。2.3 统一入口的函数设计两个平台的实现文件里对外暴露的函数签名完全一致这样上层调用代码可以跨平台复用。Windows侧和Linux侧各自实现内部细节但都遵循同一个流程先清空结构体再逐项填充。调用侧长这样// main.c #include stdio.h #include string.h #include hardware_info.h int main(void) { hardware_info_t info; memset(info, 0, sizeof(info)); if (hw_collect_info(info) ! 0) { fprintf(stderr, collect hardware info failed\n); return 1; } printf(CPU : %s\n, info.cpu_id); printf(Disk: %s\n, info.disk_id); printf(MAC : %s\n, info.mac); printf(IP : %s\n, info.ip); printf(Link: %s\n, info.link_status ? UP : DOWN); return 0; }hw_collect_info这个函数是工程里唯一需要关心的入口返回值0表示全部字段成功填充非0表示至少有一个重要字段读取失败。实际使用中我一般建议把返回值细化成按位掩码第0位表示CPU失败、第1位表示硬盘失败这样上层能明确知道哪一项失败。工程里为了简单用统一错误码也能理解但真正接业务时改成掩码会让你排查问题省很多时间。3. CPU序列号和硬盘序列号两段读取代码与数据清洗细节3.1 Windows侧读CPU序列号CPUID指令与回退方案工程里Windows侧读CPU序列号用的是__cpuid内建函数但这里有一个关键细节老的Intel处理器可以通过CPUID的leaf 3返回一个96位的Processor Serial Number现代处理器大部分把这个功能关了。所以读完leaf 3之后还要验证leaf 1里的PSN支持位如果没拿到有效的序列号就回退到读取注册表里的ProcessorNameString加Family/Model/Stepping拼一个稳定标识。#include intrin.h #include windows.h int get_cpu_id_windows(char *out, size_t out_len) { int cpu_info[4]; __cpuid(cpu_info, 1); // EDX bit 18: PSN supported if (cpu_info[3] (1 18)) { __cpuid(cpu_info, 3); // leaf 3 fills eax, ebx, ecx with processor serial number snprintf(out, out_len, %08X%08X%08X, cpu_info[0], cpu_info[1], cpu_info[2]); return 0; } // fallback: combine family/model/stepping name hash DWORD buf_len 0; BYTE reg_buf[4096]; HKEY key; if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0, 0, KEY_READ, key) ! ERROR_SUCCESS) { return -1; } DWORD name_len sizeof(reg_buf); RegQueryValueExA(key, ProcessorNameString, NULL, NULL, reg_buf, name_len); RegCloseKey(key); SYSTEM_INFO si; GetSystemInfo(si); snprintf(out, out_len, F%X-M%X-S%X-%s, si.wProcessorRevision, (si.wProcessorRevision 8) 0x0F, si.wProcessorRevision 0x0F, (char*)reg_buf); return 0; }这段代码的逻辑分两层第一层尝试CPUID leaf 3拿真正的PSN只有EDX的第18位为1时结果才可信第二层是回退方案把CPU厂商字符串和版本号拼起来虽然名称不那么“唯一”但在同一批采购的机器上足够区分。回退方案里的F、M、S对应Family、Model、Stepping是厂商文档里标准的CPU版本标识。这里要注意RegQueryValueExA的name_len一开始必须初始化为缓冲区大小否则函数会返回ERROR_MORE_DATA。我在实际调试时踩过这个坑buffer明明够大但因为len传了0系统认为空间不够。3.2 Linux侧读CPU序列号/proc/cpuinfo解析加多核去重Linux侧的CPU序列号读取没有Windows那么统一x86_64的/proc/cpuinfo里通常没有Serial字段只有vendor_id和model name很多国产ARM板子上反而带Serial。工程里的处理方式是把每个逻辑核心的“型号字符串CPU partrevision”当成一个标识用第一个核心的结果但先检查有没有Serial字段有就用没有就退到型号组合。#include stdio.h #include string.h #include stdlib.h int get_cpu_id_linux(char *out, size_t out_len) { FILE *fp fopen(/proc/cpuinfo, r); if (!fp) return -1; char line[256]; char model[128] {0}; char part[64] {0}; char rev[64] {0}; char serial[128] {0}; int has_serial 0; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, Serial, 6) 0) { sscanf(line, Serial\t: %127s, serial); has_serial 1; } else if (strncmp(line, model name, 10) 0) { sscanf(line, model name\t: %127[^\n], model); } else if (strncmp(line, CPU part, 8) 0) { sscanf(line, CPU part\t: %63s, part); } else if (strncmp(line, CPU revision, 10) 0) { sscanf(line, CPU revision\t: %63s, rev); } // 已经拿到所有关键字段就停止避免重复解析 if (has_serial model[0] part[0] rev[0]) break; } fclose(fp); if (has_serial) { snprintf(out, out_len, %s, serial); return 0; } snprintf(out, out_len, %s-%s-%s, model, part, rev); return out[0] ? 0 : -1; }这段代码用fgets逐行读/proc/cpuinfo解析以“Serial”“model name”“CPU part”“CPU revision”开头的行。每次读到的内容追加进对应的字符数组最后按优先级组合。需要注意sscanf的%127s会过滤掉空白符而%127[^\n]会保留包括空格在内的整行内容后者对“model name : Intel(R) Core(TM) i7-8700 CPU 3.20GHz”这种带空格的字段是必须的。多核机器上/proc/cpuinfo会给每个逻辑核重复一段所以拿到必要字段就break避免后面空行把状态搞乱。3.3 硬盘序列号DeviceIoControl与sysfs两条路Windows侧读硬盘序列号用的API是DeviceIoControl加IOCTL_STORAGE_QUERY_PROPERTY这个调用会返回一个STORAGE_DEVICE_DESCRIPTOR结构里面SerialNumberOffset字段指向序列号字符串在缓冲区里的偏移。代码里需要先发一次查询拿结构体长度再分配大缓冲区做第二次查询拿不到就重试。Linux侧更直接读/sys/block/sda/device/serial就能拿到字符串。// windows: 核心查询逻辑精简了错误处理 HANDLE hDevice CreateFileA( \\\\.\\PhysicalDrive0, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDevice INVALID_HANDLE_VALUE) return -1; STORAGE_PROPERTY_QUERY query; memset(query, 0, sizeof(query)); query.PropertyId StorageDeviceProperty; query.QueryType PropertyStandardQuery; BYTE buffer[4096]; DWORD bytes_returned; BOOL ok DeviceIoControl(hDevice, IOCTL_STORAGE_QUERY_PROPERTY, query, sizeof(query), buffer, sizeof(buffer), bytes_returned, NULL); CloseHandle(hDevice); if (!ok) return -1; PSTORAGE_DEVICE_DESCRIPTOR desc (PSTORAGE_DEVICE_DESCRIPTOR)buffer; if (desc-SerialNumberOffset 0) return -1; // 无序列号常见于虚拟盘 char *serial (char*)(buffer desc-SerialNumberOffset); snprintf(out, out_len, %s, serial);这段代码里最容易被忽视的是CreateFileA的路径写法\\.\PhysicalDrive0表示物理磁盘0。如果目标是拿逻辑分区而不是物理盘路径改成\.\C:然后IOCTL会拿到分区卷的序列号那不是我们想要的物理硬件标识。desc-SerialNumberOffset为0的情况在虚拟机里很常见Virtual Disk这类驱动可以不提供序列号这也是第5章会详细说的避坑点。还要注意DeviceIoControl的缓冲区给的是4096字节USB设备或者带多命名空间的NVMe盘可能返回更长的描述符稳妥做法是先调一次拿到返回长度再动态分配工程里的做法是直接给4096绝大多数场景够用。4. 网络信息IP、MAC、网线插入判断的读取链路4.1 Windows侧网络信息GetAdaptersAddresses链表遍历Windows读网卡信息推荐用GetAdaptersAddresses它代替了老旧的GetAdaptersInfo能拿到的信息更多包括OperStatus这个网线状态字段。这个API的第一个调用通常不会直接返回成功而是返回ERROR_BUFFER_OVERFLOW并告诉你需要的缓冲区大小分配够大的内存后再调一次。工程里已经处理了这个逻辑但我在拿到这份代码前自己在业务里写过两遍每次都因为偷懒直接用固定数组而踩到多网卡机器上buffer不够的坑。ULONG buf_len 0; GetAdaptersAddresses(AF_UNSPEC, 0, NULL, NULL, buf_len); PIP_ADAPTER_ADDRESSES pAddrs (PIP_ADAPTER_ADDRESSES)malloc(buf_len); ULONG ret GetAdaptersAddresses(AF_UNSPEC, 0, NULL, pAddrs, buf_len); if (ret ! ERROR_SUCCESS) { free(pAddrs); return -1; } PIP_ADAPTER_ADDRESSES cur pAddrs; while (cur) { // 跳过环回和隧道虚拟网卡 if (cur-IfType ! IF_TYPE_ETHERNET_CSMACD cur-IfType ! IF_TYPE_IEEE80211) { cur cur-Next; continue; } // OperStatus 只有在适配器存在时才可靠 int link_up (cur-OperStatus IfOperStatusUp); // 第一个IP地址Unicast列表取IPv4 PIP_ADAPTER_UNICAST_ADDRESS ua cur-FirstUnicastAddress; while (ua) { if (ua-Address.lpSockaddr-sa_family AF_INET) { // 此时 ua-Address.lpSockaddr 可转成 sockaddr_in // 用 inet_ntop 格式化成字符串 break; } ua ua-Next; } // MAC 地址在 cur-Address 里长度是 cur-PhysicalAddressLength cur cur-Next; } free(pAddrs);这段遍历逻辑的核心是理解适配器链表里每一层的关系GetAdaptersAddresses返回一个适配器链表每个适配器下有单播地址链表、网关地址链表、DNS地址链表。代码里先用IfType过滤掉环回和隧道适配器只保留以太网和WiFi然后取Unicast链表里第一个AF_INET的地址作为IP。OperStatus是适配器级别的不是IP级别的所以网线插拔只影响这个字段不影响IP地址是否还在枚举列表里。实际部署时我发现某些虚拟机里OperStatus始终是IfOperStatusUp因为虚拟网卡驱动模拟了物理链路所以这个字段只能作为参考不能作为唯一判断依据。4.2 Linux侧网络信息getifaddrs与carrier文件Linux侧工程用了getifaddrs函数遍历网络接口它比ioctl的方式更安全不会因为接口名长度溢出。但getifaddrs拿MAC地址的时候有个小坑返回的sockaddr是AF_PACKET类型真正有数据的不是通用的sockaddr结构需要强转成sockaddr_ll才能拿到sll_addr和sll_halen长度是8而不是固定6字节前6字节才是我们要的MAC。#include ifaddrs.h #include netpacket/packet.h #include net/if.h int get_net_info_linux(char *mac, char *ip, size_t mac_len, size_t ip_len) { struct ifaddrs *ifaddr, *ifa; if (getifaddrs(ifaddr) 0) return -1; for (ifa ifaddr; ifa; ifa ifa-ifa_next) { if (!ifa-ifa_addr) continue; if (ifa-ifa_addr-sa_family AF_PACKET) { struct sockaddr_ll *sll (struct sockaddr_ll*)ifa-ifa_addr; // 只要以太网接口跳过lo if (sll-sll_ifindex 0) continue; if (sll-sll_halen 6) { snprintf(mac, mac_len, %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]); break; } } } // IP 地址另起一个循环因为AF_INET和AF_PACKET是两个独立节点 for (ifa ifaddr; ifa; ifa ifa-ifa_next) { if (!ifa-ifa_addr) continue; if (ifa-ifa_addr-sa_family AF_INET) { // 排除回环地址 127.0.0.1 struct sockaddr_in *sin (struct sockaddr_in*)ifa-ifa_addr; if (ntohl(sin-sin_addr.s_addr) 0x7F000001) continue; inet_ntop(AF_INET, sin-sin_addr, ip, ip_len); break; } } freeifaddrs(ifaddr); return (mac[0] ip[0]) ? 0 : -1; }这段代码把MAC和IP的获取拆成了两个循环因为getifaddrs返回的链表里每个接口会有多个节点比如eth0会有一个AF_INET节点和一个AF_PACKET节点。先遍历拿MAC再单独遍历拿IP语义清晰且不会漏。判断环回的方式是直接把地址转成网络字节序的整数和0x7F000001比较这种方式比strcmp(127.0.0.1, ip)简洁。sll_halen判断的意义在于不是所有AF_PACKET地址都是6字节的以太网MACInfiniBand之类的是20字节不做长度检查会造成越界。网线插入检测在Linux侧独立于getifaddrs读的是/sys/class/net/eth0/carrier这个文件。eth0是第一个有线网卡如果机器做了bonding或者接口命名不是eth0需要先扫描/sys/class/net/下的所有接口找到类型为physical且有carrier文件的。int check_link_status_linux(const char *ifname) { char path[128]; snprintf(path, sizeof(path), /sys/class/net/%s/carrier, ifname); FILE *fp fopen(path, r); if (!fp) return -1; // 接口不存在或权限不足 char buf[8]; if (!fgets(buf, sizeof(buf), fp)) { fclose(fp); return -1; } fclose(fp); return atoi(buf) 0 ? 1 : 0; // 1表示carrier present }carrier文件的内容只有0或者10表示链路down1表示链路up。要注意的是在某些USB网卡或者有问题的驱动上读carrier文件可能返回类似“Invalid argument”的错误原因是驱动没有实现ndo_validate_addr。这种场景下工程的选择是返回-1让上层知道“接口存在但读不到链路状态”而不是硬判成down。我一般在业务层把-1当作“未知”不参与最终判定只在日志里记录。5. 避坑与排查五条血泪踩坑记录5.1 CPUID leaf 3在新款CPU上拿不到序列号现象Windows侧在Intel 10代以后的CPU上跑读出来的CPU序列号是空的或者同一批机器读出来完全一样。 原因现代Intel处理器默认关闭了Processor Serial Number功能CPUID leaf 3返回的寄存器内容没有实际意义EDX里的PSN支持位也是0。 解决先检查EDX bit 18为0就直接走回退方案不要相信leaf 3的任何返回值。如果业务上必须用真正的CPU唯一序列号只能走SMBIOS但SMBIOS里的Processor Information字段也不是所有厂商都填了序列号实际效果没比“Family/Model/Stepping厂商名”好多少。5.2 Linux下读硬盘序列号要root权限现象普通用户执行程序硬盘序列号拿到空的或者直接报Permission deniedsudo执行就正常。 原因/sys/block/sda/device/serial文件的读取权限默认只有root和disk组的用户可以读普通用户没有权限。这不是工程代码的bug是内核sysfs的权限设计。 解决把执行用户加入disk组或者部署时用udev规则给特定设备的serial文件设置0444权限。不要直接在程序里setuid提权那样会把整个进程的安全边界撕开。5.3 GetAdaptersAddresses第一次调用返回ERROR_BUFFER_OVERFLOW现象第一次调用GetAdaptersAddresses拿到的buf_len很小分配内存后第二次调用仍然报ERROR_BUFFER_OVERFLOW数据始终填不进去。 原因第一次调用返回的length只是当时所有适配器信息的总长度第二次调用期间网卡状态发生了变化比如DHCP获取了新地址、虚拟网卡被创建导致需要的缓冲区比之前计算的更大。 解决把第二次调用包在一个循环里返回值是ERROR_BUFFER_OVERFLOW就free掉重新分配再调一次最多重试3次。我见过最夸张的一次在虚拟机里重试了4次才拿到完整数据因为桌面系统里网卡状态变化太频繁。5.4 虚拟机和云主机上序列号是空的或全0现象拿这份工程在VMware或某云厂商的裸金属机器上跑硬盘序列号读出来是空字符串MAC地址正常但CPU序列号是F000-M000-S000的默认组合。 原因虚拟化平台无论是VMware、KVM还是Hyper-V可以控制是否向guest暴露硬盘序列号很多模板默认不填。CPUID的PSN功能在虚拟化环境下即使透传了指令也会被虚拟CPU屏蔽。 解决接受现实把“序列号为空”当成一种合法状态。业务上做机器指纹时建议把硬盘序列号、MAC地址、CPU标识这三个字段组合后再做hash不要依赖单一字段否则换一块虚拟硬盘就认定是一台新机器会被运维骂死。5.5 Linux网卡接口名不是eth0现象程序里写死了eth0拿到新服务器上跑link_status返回-1IP和MAC也全是空的。 原因新版systemd的命名规则会把接口改成enp3s0、eno1这类基于PCI槽位的名字老代码写死在eth0上自然读不到。另外CentOS和Ubuntu对物理网卡和虚拟网卡的命名策略也不一样。 解决让程序在/sys/class/net/目录下遍历所有接口找到有carrier文件的非lo接口就当作可用网卡。判断条件要排除bonding和vlan子接口用的好方法是检查/sys/class/net/ /type文件值为1表示物理以太网设备。这个技巧工程里没写完整我接手时补上了这一层比原来写死接口名健壮很多。6. 把硬件信息变成稳定指纹哈希化与跨平台验证读出来的原始信息需要拼成指纹再用我习惯的做法是先把四个字段按固定顺序拼接出字符串再计算SHA-256把前16字节转成十六进制作为机器指纹。拼接分隔符用“|”避免CPU型号里自带空格导致不同机器拼出相同字符串的情况。// 拼接规则CPU|Disk|MAC|IP unsigned char digest[SHA256_DIGEST_LENGTH]; char raw_fp[256]; snprintf(raw_fp, sizeof(raw_fp), %s|%s|%s|%s, info.cpu_id, info.disk_id, info.mac, info.ip); SHA256((unsigned char*)raw_fp, strlen(raw_fp), digest); // digest 转 hex 字符串输出前16字节即可拼接顺序不建议改一旦定了顺序后续版本升级必须保持一致否则老机器的指纹全部失效。IP这个字段我个人认为不应该参与指纹计算因为DHCP环境下IP是会变的你拿IP做指纹的一部分结果是同一台机器每次DHCP续期后指纹都变。工程原版的代码把IP也拼了进去我在自己的分支里去掉了IP只保留CPU、Disk、MAC三个稳定字段。验证环节做的最后一件事是“对拍”Windows下打开设备管理器展开处理器、磁盘驱动器、网络适配器三个节点把程序输出和界面显示核对一遍Linux下分别用lscpu、lsblk -d -o SERIAL、ethtool eth0对照。程序打印出来的MAC地址格式是大写冒号分隔ethtool默认也是这个格式两边能对上就说明读取链路没断。从那以后我每次写完这类代码都会强制走一遍对拍流程不让程序自己骗自己。这份工程的代码结构让我省掉了从零搭框架的时间希望帮到你。本文还有配套的精品资源点击获取