C语言实战:用Windows API获取CPU标识、硬盘序列号与网卡信息
2026/9/8 12:43:02 网站建设 项目流程

简介:面向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向磁盘设备发送控制码。流程分三步:

  1. 用CreateFile打开\\.\PhysicalDrive0等物理驱动器句柄,这里索引从0开始。
  2. 填充STORAGE_PROPERTY_QUERY结构,属性设为StorageDeviceProperty,查询类型设为PropertyStandardQuery。
  3. 调用DeviceIoControl,控制码是IOCTL_STORAGE_QUERY_PROPERTY,返回的数据缓冲区里是一份STORAGE_DEVICE_DESCRIPTOR结构。

描述符有一个关键字段SerialNumberOffset,它专门用来做序列号定位。这里有个大坑:SerialNumberOffset是一个相对缓冲区起始位置的偏移量,不是一个绝对地址。很多人第一次写的时候直接用指针访问,结果读到一堆乱码或者直接访问违例。正确做法是把缓冲区当成BYTE数组,用buf + offset的方式拿到字符串指针,而且这个字符串通常是UTF-16宽字符,需要调用WideCharToMultiByte做过一次编码转换,否则控制台上显示的就是“WD-WCC6Y2LA2791”被截断或者变成乱码。

2.3 网卡IP与MAC:GetAdaptersInfo的结构化处理

获取网卡信息用的是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.hntddstor.h:定义存储相关的控制码和结构体。
  • winsock2.hiphlpapi.h:网卡信息相关函数和结构。

链接库方面需要额外带上iphlpapi.libws2_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完全没问题。核心逻辑是先按适配器名匹配,再比对OperStatus:

IP_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 VendorGenuineIntel
CPU BrandFamily 6 Model 158 Stepping 10
CPU SerialNotAvailable
Disk0 SerialWD-WCC6Y2LA2791
网卡Intel(R) Ethernet Connection I219-V
MAC3C-7C-3F-8A-2B-1D
IP192.168.1.100
掩码255.255.255.0
网关192.168.1.1
Link StatusUp

4. 常见问题与排查技巧实录

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的集合作业”,拿它去当学习材料也好,改成生产工具也好,都能直接上手跑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询