简介:面向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地址,最好还能告诉你现在网线有没有插上。很多人第一反应是搜一堆API,Windows下一个套路、Linux下另一个套路,两边代码风格还不一样,拼起来就是一场灾难。这份zip的工程把两边的完整读取逻辑都整理好了,Windows侧走系统API和注册表、Linux侧走sysfs和ioctl,拿到手的不是片段,是可编译的工程。适合要做硬件指纹、机器绑定、网络诊断的C开发者,新手照着模子改,熟手直接抽代码合并到自己的模块里。下面的内容会按“方案怎么选、代码怎么写、坑在哪”展开。
2. 跨平台方案取舍:Windows走系统API,Linux走sysfs,别上来就引第三方库
2.1 为什么不用WMI和libudev全家桶
拿到这个需求的时候,我第一版也想偷懒:Windows直接调WMI,Linux直接链libudev。后来发现WMI在C语言里要搞COM初始化、IEnumWbemClassObject遍历,代码量直接翻倍,编译出来的exe还得带一堆初始化分支,出了问题根本分不清是权限问题还是COM没初始化好。工程里Windows侧用的是DeviceIoControl加GetAdaptersAddresses,不走WMI,原因很简单:这两个API是内核态直接回话,不用起WMI服务,在精简版系统上也跑得动。Linux侧也没用libudev,直接读/sys和/proc,理由更朴素——目标机器上不一定装了对应该版本的libudev,动态库一缺就是黑匣子。
选型时我的判断标准就三条:不依赖系统版本相关的第三方库、不需要图形界面、能在一个进程里拿全所有信息。以这个标准回头看,Windows侧的cpuid指令、注册表项、DeviceIoControl,Linux侧的/sys/class/net、/proc/cpuinfo、ioctl,都是系统自带的最短路径。
2.2 工程结构和条件编译入口
zip里解出来是典型的双平台工程:Windows侧是VS的sln加一个hardware_win.c,Linux侧是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 part+revision”当成一个标识,用第一个核心的结果,但先检查有没有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字节的以太网MAC,InfiniBand之类的是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或者1,0表示链路down,1表示链路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 denied,sudo执行就正常。 原因:/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返回-1,IP和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默认也是这个格式,两边能对上就说明读取链路没断。从那以后我每次写完这类代码都会强制走一遍对拍流程,不让程序自己骗自己。这份工程的代码结构让我省掉了从零搭框架的时间,希望帮到你。
本文还有配套的精品资源,点击获取