简介面向C语言开发者、系统编程学习者以及需要跨平台硬件信息采集的工程师这份示例代码包聚焦Windows与Linux环境下CPU序列号、硬盘序列号、网卡名称、IP地址、MAC地址以及网线是否插入等硬件信息的获取可服务于系统监控、设备识别、软件故障排查等真实场景也适合作为操作系统与网络编程课程的实践参考。压缩包共2个文件由1个h头文件和1个cpp源文件组成整体仅7KB结构紧凑但功能闭合既可直接阅读学习也可摘取其中函数集成到自研工具或学习项目中。目前已有3095人学习说明该主题受到不少开发者关注。源码通过条件编译优雅处理平台差异Windows侧涵盖WMI查询等API用法Linux侧则读取/proc/cpuinfo、/sys/class/net等系统文件同时给出网线连接状态的判断逻辑便于在实际网络中验证。读者可以拿到一套可运行、可验证的代码骨架快速理解两种操作系统下硬件信息获取的系统调用与文件接口节省自行查阅文档与调试时间无论是初学者想弄懂底层原理还是有经验的工程师想快速复用这份小体积代码包都能提供不错的起点。 最近整理了一个自用的C语言小工具用来在Windows系统上直接获取本机硬件信息包括CPU标识、硬盘序列号、网卡IP、MAC地址以及网线插拔状态。别小看这几样东西装机维护、软件授权绑定、资产盘点、远程排查网络问题全都用得上。压缩包解出来就是完整工程底层走的都是Windows原生API和CPUID指令不需要额外运行时双击就能跑。这篇文章把每个模块的实现思路、关键代码、踩坑经验都拆开讲一遍适合正在写系统工具或者想深入理解Windows硬件枚举机制的C语言开发者。先说清楚一个前提等会儿提到的“CPU序列号”严格来说并不等于Intel当年提出的处理器序列号PSN因为现代CPU已经不再暴露公开的唯一序列号了。实际项目里我们拿的是CPUID指令返回的家族号、型号、步进再配合能读到的扩展信息拼成一个“CPU标识串”。这个标识串做授权绑定、设备指纹完全够用而且比读注册表里的ProcessorNameString更底层不容易被篡改也更稳定。1. 项目整体设计与思路拆解1.1 为什么偏偏用C语言干这件事很多人第一反应是这玩意儿用PowerShell或者Python写不更快吗确实几条命令就能拿到结果。但这套东西的使用场景恰恰是那些不方便装解释器或者不方便跑大框架的环境比如PE环境下做系统部署、给老旧的Windows机器做资产采集、写嵌入到安装包里的硬件绑定模块。C语言写出来的程序就是一个独立的exe静态编译之后丢到任何Windows机器上都能跑不依赖安装Python不依赖PowerShell版本这一点在实际运维工作中太重要了。另外一个原因是我需要精细控制底层调用。读写硬盘属性要走DeviceIoControl拿网卡信息要走IP Helper API查询CPU信息要执行CPUID指令这些接口的入参、缓冲区、返回码都是C语言风格的。如果用Python去封一层ctypes倒也能做但调试起来隔靴搔痒尤其是我要处理宽字符、对齐、内存缓冲区这些问题C是零距离接触。1.2 功能模块怎么划分整个工程按硬件类型拆成三个模块外加一个主程序做汇总输出CPU模块执行CPUID指令提取厂商字符串、家族型号步进、可能的序列号字段格式化后返回给主程序。磁盘模块按索引依次尝试打开物理驱动器用IOCTL_STORAGE_QUERY_PROPERTY读取存储设备描述符从描述符中取出序列号字符串。网卡模块用GetAdaptersInfo遍历所有适配器拿到适配器名、MAC地址、IPv4地址、子网掩码、网关等基础信息再用GetAdaptersAddresses补充链路状态即网线是否插入。主程序调用上述模块把结果格式化输出到控制台同时返回给调用方。这样划分的好处是单个模块的代码量不大出了问题也好排查。比如某台机器磁盘序列号读不出来你只需要单独调试磁盘模块不需要把网卡和CPU的代码翻出来反复看。1.3 可行性边界与兼容性说明我测试过的环境覆盖Windows 10、Windows 11的64位系统用Visual Studio和MinGW两种工具链编译过均能通过。32位程序在64位系统上也能正常运行因为在整个调用链里基本没有指针宽度依赖只要正确设置结构体对齐和缓冲区长度即可。需要提前给读者打个预防针不是所有硬件都100%能读到序列号。某些NVMe固态硬盘在特定驱动版本下不返回序列号部分虚拟机的虚拟磁盘序列号是空的无线网卡在未连接任何网络时链路状态可能显示Down。这些属于正常的兼容性边界不是代码写错了。在实际工程里我会用字符串“NotAvailable”做兜底避免程序崩溃或者输出空白。2. 核心细节解析与实操要点2.1 CPU标识CPUID指令到底在读什么CPUID是一条汇编指令执行之后会按功能叶子号EAX寄存器返回一组数据到EAX、EBX、ECX、EDX四个寄存器。比如叶子0返回厂商标识叶子1返回家族号、型号、步进叶子3在老奔腾3处理器上返回96位的处理器序列号。在C语言里不需要真的写内联汇编VC编译器提供了__cpuid和__cpuidex这两个Intrinsic函数MinGW下也有对应实现。我提取的信息包括厂商字符串从叶子0的EBX、EDX、ECX字节序还原成“GenuineIntel”这类字符串。家族型号步进从叶子1的EAX里拆位。这一步要特别小心因为现代CPU引入了扩展家族号ExtendedFamily和扩展型号ExtendedModel必须做高位组合否则i7和老的Pentium会读出相同家族号。序列号字段执行叶子3把ECX和EDX拼成一个64位十六进制串。如果两个寄存器都是0说明处理器不暴露序列号就返回NotAvailable。有人会问这些信息拿来干嘛最常见的用途是把CPU标识和硬盘序列号、MAC地址组合成一个软件授权指纹。因为单独拿CPU型号做绑定的话同一批出厂电脑全部相同等于没绑。再加上硬盘序列号之后基本可以做到一机一码。2.2 硬盘序列号不通过IOCTL就等着被“截胡”简单粗暴的方式是直接读磁盘的物理扇区然后去偏移位置解析ATA Identify Data。这种方式在Windows下很容易失效因为磁盘控制器、驱动、加密卷、虚拟磁盘这些层级都会拦截裸设备访问你根本读不到原始扇区。正确姿势是用DeviceIoControl向磁盘设备发送控制码。流程分三步用CreateFile打开\\.\PhysicalDrive0等物理驱动器句柄这里索引从0开始。填充STORAGE_PROPERTY_QUERY结构属性设为StorageDeviceProperty查询类型设为PropertyStandardQuery。调用DeviceIoControl控制码是IOCTL_STORAGE_QUERY_PROPERTY返回的数据缓冲区里是一份STORAGE_DEVICE_DESCRIPTOR结构。描述符有一个关键字段SerialNumberOffset它专门用来做序列号定位。这里有个大坑SerialNumberOffset是一个相对缓冲区起始位置的偏移量不是一个绝对地址。很多人第一次写的时候直接用指针访问结果读到一堆乱码或者直接访问违例。正确做法是把缓冲区当成BYTE数组用buf offset的方式拿到字符串指针而且这个字符串通常是UTF-16宽字符需要调用WideCharToMultiByte做过一次编码转换否则控制台上显示的就是“WD-WCC6Y2LA2791”被截断或者变成乱码。2.3 网卡IP与MACGetAdaptersInfo的结构化处理获取网卡信息用的是IP Helper API里的GetAdaptersInfo这个函数把结果放进一个IP_ADAPTER_INFO链表。我是在第一次调用时传NULL缓冲区让它返回需要的字节数然后malloc动态分配再调用一次真正填充数据。链表的每一个节点代表一块网卡节点里的Address字段是MAC地址原始字节AddressLength是可用的字节数。这里有一个习惯我要特意说一下不要直接打印Address数组的所有字节先判断AddressLength是不是6。无线网卡、虚拟网卡、某些蓝牙设备返回的字节长度不一定等于6如果贸然按6字节打印轻则输出一串没意义的十六进制重则越界读内存。MAC地址的展示也建议用%02X格式化并且用短横线分隔这样看起来就是标准的物理地址样式方便复制到登记表格里。另外IP地址在结构体里是IP_ADDR_STRING链表不是定长数组。正常情况下IPv4地址只有一个但如果你开着多个IP配置比如一个物理IP加一个环回IP这里就会出现多个节点。项目里我只取第一个节点实际业务中如果要做更严谨的采集应该遍历所有节点。2.4 网线状态检测别只看网卡是否启用网线插入状态和网卡是否启用是两码事。网卡设备被禁用时肯定拿不到任何状态位这时候需要去设备管理器确认。但网卡启用却不一定代表网线插着尤其是有线网卡启用状态只是说你允许这块网卡工作物理链路什么情况还要靠底层协议去探测。我用的方法是调用GetAdaptersAddresses这个函数返回的IP_ADAPTER_ADDRESSES结构里有OperStatus字段。如果这个值是IfOperStatusUp说明底层链路协议已经协商成功可以简单理解成网线插好了并且对端设备交换机、路由器处于工作状态如果值是IfOperStatusDown那就是物理链路没起来可能网线没插也可能插了但对端关机了。还有一个更直接的方案是通过NDIS的OID_GEN_MEDIA_CONNECT_STATUS去查询网卡的媒体连接状态。这两个方法我都在项目中保留着默认走GetAdaptersAddresses因为它拿到的信息更丰富不需要再单独打开网卡设备句柄兼容性也更好。3. 实操过程与核心代码3.1 环境准备与工程结构我在Visual Studio 2022里建了一个空的控制台工程代码只有一个hwinfo.c文件结构非常简单。如果读者习惯用MinGW编译命令我放到后面统一给。工程引入的头文件主要有这些windows.h基础Windows API。intrin.h提供__cpuid和__cpuidex。winioctl.h和ntddstor.h定义存储相关的控制码和结构体。winsock2.h和iphlpapi.h网卡信息相关函数和结构。链接库方面需要额外带上iphlpapi.lib和ws2_32.lib。如果是Visual Studio我直接在源码顶部用#pragma comment(lib, iphlpapi.lib)这种方式声明省得再去工程设置里翻找。3.2 CPU标识代码实现下面这段是核心的CPU信息提取函数。注意__cpuid(cpuInfo, 3)这一步在老处理器上可能返回全0所以要加一个兜底判断#include intrin.h #include stdio.h #include string.h void GetCpuInfo(char *vendor, char *brand, char *serial) { int cpuInfo[4] {0}; __cpuid(cpuInfo, 0); memcpy(vendor, cpuInfo[1], 4); memcpy(vendor 4, cpuInfo[3], 4); memcpy(vendor 8, cpuInfo[2], 4); vendor[12] 0; __cpuidex(cpuInfo, 1, 0); int stepping cpuInfo[0] 0x0F; int model ((cpuInfo[0] 4) 0x0F) | (((cpuInfo[0] 16) 0x0F) 4); int family ((cpuInfo[0] 8) 0x0F) | (((cpuInfo[0] 20) 0xFF) 4); sprintf(brand, Family %d Model %d Stepping %d, family, model, stepping); __cpuid(cpuInfo, 3); if (cpuInfo[2] ! 0 || cpuInfo[3] ! 0) { sprintf(serial, %08X-%08X, cpuInfo[3], cpuInfo[2]); } else { strcpy(serial, NotAvailable); } }家族号和型号的位运算组合最容易写错建议拆成两行分别算别硬写到一个表达式里。Model的最高4位来自EAX[19:16]最低4位来自EAX[7:4]Family的最高8位来自EAX[27:20]最低4位来自EAX[11:8]。我这段代码里的位移组合就是按这个规范来的。3.3 硬盘序列号代码实现磁盘部分的代码量不大但两个细节直接影响成败CreateFile的访问权限用0就够了不要用GENERIC_READ因为有些磁盘在系统占用中会拒绝共享读缓冲区大小建议至少1024字节我用的是栈上数组减少一次malloc。#include windows.h #include winioctl.h #include ntddstor.h BOOL GetDiskSerial(int diskIndex, char *out, int outLen) { char path[32]; sprintf(path, \\\\.\\PhysicalDrive%d, diskIndex); HANDLE hDev CreateFileA(path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDev INVALID_HANDLE_VALUE) { return FALSE; } STORAGE_PROPERTY_QUERY query; memset(query, 0, sizeof(query)); query.PropertyId StorageDeviceProperty; query.QueryType PropertyStandardQuery; BYTE buf[1024]; memset(buf, 0, sizeof(buf)); DWORD bytesReturned 0; BOOL ok DeviceIoControl(hDev, IOCTL_STORAGE_QUERY_PROPERTY, query, sizeof(query), buf, sizeof(buf), bytesReturned, NULL); CloseHandle(hDev); if (!ok) { return FALSE; } STORAGE_DEVICE_DESCRIPTOR *desc (STORAGE_DEVICE_DESCRIPTOR *)buf; if (desc-SerialNumberOffset 0) { return FALSE; } wchar_t *wsSerial (wchar_t *)(buf desc-SerialNumberOffset); WideCharToMultiByte(CP_ACP, 0, wsSerial, -1, out, outLen, NULL, NULL); return TRUE; }注意buf desc-SerialNumberOffset这里的偏移计算必须保持buf是BYTE数组类型如果buf被定义成其他结构体指针算术就会出错。主程序里对物理磁盘索引从0开始循环到9哪个索引能打开并且能读到序列号就输出哪个。大部分机器只有一块物理盘索引是0双硬盘或者RAID阵列的机器索引可能是1、2循环探测是最稳妥的策略。3.4 网卡信息核心代码网卡信息这块我分成两个函数一个用GetAdaptersInfo取MAC和IP一个用GetAdaptersAddresses补链路状态。这里先把第一个函数的代码贴出来#include winsock2.h #include iphlpapi.h #pragma comment(lib, ws2_32.lib) #pragma comment(lib, iphlpapi.lib) void ShowNetInfo() { ULONG len 0; GetAdaptersInfo(NULL, len); IP_ADAPTER_INFO *adapters (IP_ADAPTER_INFO *)malloc(len); if (adapters NULL) return; if (GetAdaptersInfo(adapters, len) ! NO_ERROR) { free(adapters); return; } for (IP_ADAPTER_INFO *p adapters; p ! NULL; p p-Next) { if (p-AddressLength ! 6) continue; printf(网卡: %s\n, p-Description); printf(MAC: ); for (DWORD i 0; i p-AddressLength; i) { printf(%s%02X, i 0 ? - : , p-Address[i]); } printf(\n); printf(IP: %s\n, p-IpAddressList.IpAddress.String); printf(掩码: %s\n, p-IpAddressList.IpMask.String); printf(网关: %s\n, p-GatewayList.IpAddress.String); printf(\n); } free(adapters); }链路状态的判断用一段辅助代码需要调用GetAdaptersAddresses这个函数在Vista之后才支持但现在的Windows 10/11完全没问题。核心逻辑是先按适配器名匹配再比对OperStatusIP_ADAPTER_ADDRESSES *addrBuf malloc(len); GetAdaptersAddresses(AF_INET, 0, NULL, addrBuf, len); for (IP_ADAPTER_ADDRESSES *a addrBuf; a ! NULL; a a-Next) { if (a-OperStatus IfOperStatusUp) { // 当前适配器物理链路是通的 } }这里有个关键点GetAdaptersInfo拿到的AdapterName是一个GUID字符串GetAdaptersAddresses结构里的AdapterName也是同一个字符串两者可以精确匹配。在我的工程里主程序先把GetAdaptersInfo的信息打印完再拿到适配器名去过滤链路状态这样输出结果是一一对应的。3.5 编译、运行与效果Visual Studio下直接编译即可。MinGW环境我用这条命令gcc hwinfo.c -o hwinfo.exe -liphlpapi -lws2_32运行之前建议右键以管理员身份启动。因为读取磁盘序列号时CreateFile打开PhysicalDrive设备可能受UAC权限限制普通权限下部分系统能打开、部分系统直接返回拒绝访问。实测下来Windows 10 21H2以前普通权限基本没问题Windows 11就需要管理员权限了可能是安全策略收紧导致的。程序运行效果大致如下项目值CPU VendorGenuineIntelCPU BrandFamily 6 Model 158 Stepping 10CPU SerialNotAvailableDisk0 SerialWD-WCC6Y2LA2791网卡Intel(R) Ethernet Connection I219-VMAC3C-7C-3F-8A-2B-1DIP192.168.1.100掩码255.255.255.0网关192.168.1.1Link StatusUp4. 常见问题与排查技巧实录4.1 硬盘序列号总是读不到先查这三个原因我在测试群里被问得最多的就是“为什么我的固态硬盘读不到序列号”。总结下来就是三种情况第一种物理驱动器索引不对。有些笔记本虽然系统装在C盘但C盘对应的物理驱动器索引不是0而是1因为索引0被一块隐藏的恢复分区占掉了。我在项目里做成循环尝试通常能规避。第二种NVMe协议的固态硬盘在早期驱动下不响应IOCTL_STORAGE_QUERY_PROPERTY的存储设备属性查询。如果你手头的机器比较旧SSD驱动又是微软默认的stornvme可能就会遇到。这种我暂时没能用纯API强制读出来目前比较可靠的方案是转用WMI的MSFT_PhysicalDisk类但那是另一个话题了。第三种权限不够。前面已经提过磁盘设备句柄的打开受用户权限限制只用普通权限跑DeviceIoControl会返回失败错误码5。所以我在主程序启动时直接调用IsUserAnAdmin检测权限不是管理员就跑提示框省得代码报错后一脸懵。4.2 网卡显示00-00-00或者跳出一堆虚拟网卡这种情况我太熟悉了尤其是装过VMware、VirtualBox、Hyper-V的机器。虚拟网卡的AddressLength虽然也是6但MAC地址很多是厂商走的本地管理地址看起来像00-0C-29-XX-XX-XX如果不做过滤会把整个输出列表撑得很长。我的过滤策略是在遍历时跳过Description里包含“VMware”“VirtualBox”“Hyper-V”的项再结合网关过滤没有默认网关的网卡基本可以认定不是主网卡因为物理网卡接入局域网之后一定会拿到网关地址。当然这个策略在纯内网隔离环境下不成立可以退一步看IP是否以169.254开头如果是就说明DHCP没分配到地址这条链路基本不可用。4.3 中文乱码宽字符转换是个绕不开的坑硬盘序列号在Windows存储体系里统一以宽字符UTF-16存储直接用printf输出会乱码或者只显示第一个字符。我用WideCharToMultiByte转成GBK再输出这样在中文Windows控制台里能正常显示。有个更隐蔽的问题如果代码里Buffer大小给太小转换会失败并返回0这时候序列号就是空字符串。我建议缓冲区至少给256字节别抠门。另外WideCharToMultiByte的最后一个参数建议传NULL让它自动替换无法映射的字符而不是返回错误中断整个流程。4.4 多网卡环境下如何选“主网卡”在实际做资产采集时我们往往只关心真正的物理网卡。我前面讲了过滤虚拟网卡但还有一个顺序问题如果机器同时有有线网卡和无线网卡有线通常是第一块无线是第二块但这不绝对。我后来加了一个小逻辑遍历所有网卡时记录下第一个OperStatus为Up的IPv4地址并且网关不为空作为“当前活动网卡”。这个选择规则简单但实用因为链路Up说明它正在和网络通信绑授权的话就绑这台的MAC和CPU标识。等哪一天无线断了切有线采集结果会变成有线网卡如果你固定要绑某一个物理网口可以在代码里按AdapterName做白名单。最后再分享一点后续扩展的想法这个工具从最早的单CPU信息版本到现在加入磁盘和网卡中间改了很多轮。我在实际使用中最深的体会是单靠硬盘序列号做软件授权绑定并不保险因为部分SSD确实拿不到序列号组合方案更靠谱。我现在在做的是把CPU标识、磁盘序列号、MAC地址、主板UUID四样东西组合成一串设备指纹任意两个可用就可以判定设备合法这样既照顾了兼容性又保证了唯一性。如果你打算在这个基础上继续扩展我建议下一站加SMART健康状态读取或者网卡link speed查询。这两个功能用到的底层API我们都已经铺垫好了无非是换个控制码和数据结构的事情。项目本身就是一套“Windows硬件信息枚举底层API的集合作业”拿它去当学习材料也好改成生产工具也好都能直接上手跑。本文还有配套的精品资源点击获取