C语言获取硬件信息:CPU序列号、硬盘序列号、MAC地址与网线状态实战

发布时间:2026/9/7 3:01:11
C语言获取硬件信息:CPU序列号、硬盘序列号、MAC地址与网线状态实战 简介面向C/C开发者和系统管理员的跨平台硬件信息获取示例代码重点解决Windows与Linux环境下CPU序列号、硬盘序列号、网卡IP、MAC地址及网线连接状态的编程采集问题。代码针对两套系统分别做了适配Windows侧涉及WMI查询、DeviceIoControl等系统APILinux侧则通过读取/proc/cpuinfo、/sys/block//serial、/sys/class/net//address等文件获取关键参数并利用OperStatus或operstate判断网卡是否插入网线。资源包共2个文件含1个.h头文件和1个.cpp源文件整体仅7KB结构清晰适合直接嵌入现有工程或作为系统编程学习案例。目前已有3095人学习下载适合有一定C语言基础、想了解条件编译与底层硬件信息机制的读者参考。通过这份资源可快速掌握跨平台采集思路获得一套可复用、易扩展的封装代码节省自行摸索API和文件系统路径的时间为系统监控、故障排查、设备识别等场景提供实用支撑。 拿到那个“C语言获取硬件信息CPU序列号硬盘序列号、网卡IP、MAC地址、是否插入网线.zip”压缩包的时候我第一反应是这又是一个在网上被反复搜的“设备指纹采集”小工具。但把代码铺开一看发现它其实很值得拆因为它把 Windows 平台下三类完全不同的信息采集方式全碰到了CPU 要过 CPUID 指令硬盘要走 DeviceIoControl 和驱动层查询网卡又要提 IP Helper API。如果你正准备用 C 写一个真正能跑的本机信息采集器或者想做机器硬件指纹、装机登记、远程协助前的设备体检这个项目就是一份很典型的实战样本。不过原代码当时挺粗糙的文件名起得像正经项目打开却只有几个 .c 拼在一起注释全靠猜部分 API 也用了早就该淘汰的版本。我后来把它重整理了一遍把每块功能分别用方案对比的方式过了一遍也把运行中遇到的坑记了下来。这篇文章就按我重写时的思路来写代码都是可直接落地的片段重点放在“为什么要这么选”和“出问题怎么查”上。1. 需求拆解与技术选型这个zip背后到底想干什么1.1 不要把“取信息”当成几个printf先说标题里的几个信息点CPU序列号、硬盘序列号、网卡IP、MAC地址、是否插入网线。这些名字放在一起很容易让人误以为就是调几个系统函数然后打印字符串实际拆开就不一样了。CPU 序列号不是一个标准概念。CPU 出厂时并没有像硬盘那样提供一个固定的字符串序列号能做到的是通过 CPUID 指令读出厂商、型号、步进等参数再组合成一个“指纹”。硬盘序列号要区分物理硬盘序列号和卷序列号前者是硬盘固件里存的后者是格式化时随机生成的。一般装机脚本里那个dir c:显示出来的序列号其实是卷序列号机器重装系统或格式化后会变如果你拿它当硬件标识就要踩坑。网卡信息相对简单用GetAdaptersInfo或者GetAdaptersAddresses都能拿 IP 和 MAC。“是否插入网线”这个判断反而最玄操作系统没有直接提供“网线插没插”的 API只能通过适配器当前操作状态去推测而这个状态受驱动、虚拟网卡、无线网卡的影响很大很多人的代码就是在这里翻车。所以这个 zip 看起来是采集信息实际上是把 CPU 指令集、设备控制接口、网络协议栈三块全摸了一遍很值得当做一个完整的 C 语言实战项目去研究。1.2 为什么用 C 而不是 PowerShell 或 C#有人会问现在都什么年代了PowerShell 一句Get-CimInstance就能拿到大部分信息何必用 C 重造轮子。这个说法有一定道理但场景不同。如果你的目标只是临时查一下某台机器PowerShell、WMIC、设备管理器确实更快。但如果你要做的是把这些采集逻辑嵌进自己的装机工具、防护软件、资产登记程序或者需要直接控制底层句柄和缓冲区C 的不可替代性就出来了编译后不依赖庞大的运行时内存布局可控调用 DeviceIoControl、CPUID 这类底层接口最直接。另外从这个项目的学习价值来讲C 是唯一能把“API 参数、结构体、底层原理”都暴露出来的语言。你写完一个取硬盘序列号的函数就会真正理解什么叫IOCTL_STORAGE_QUERY_PROPERTY而不只是记住一行命令。2. CPU序列号先破除一个常见误解2.1 CPUID指令并不是永远都有“序列号”很多人以为 CPU 序列号就是调用 CPUID 指令后能读出来的一段固定编号这个印象主要来自 Pentium III 时代。当时的 CPU 确实通过 CPUID 的 EAX3 功能号提供 Processor Serial Number但后来因为隐私问题从 Pentium 4 开始就基本不提供了现代 CPU 或虚拟机里这个功能号返回的数据基本不可用。所以做项目时不要一上来就奔着“官方序列号”去。实用的做法是把 CPUID 能读到的厂商字符串、Family/Model/Stepping、特性标志位甚至是 0x80000002 到 0x80000004 号功能返回的 Brand String 组合起来生成一个相对稳定的 CPU 指纹。同一批设备可能指纹相同但作为辅助判断已经够用。2.2 用__cpuid组装一条稳定可比的CPU指纹Visual Studio 环境下直接用编译器内置函数__cpuid避免写内联汇编也方便 32/64 位通用。核心代码我整理成了这样#include stdio.h #include string.h #include intrin.h void get_cpu_fingerprint(char *out, size_t out_len) { int cpuInfo[4] {0}; char vendor[13] {0}; char brand[49] {0}; // 功能号0获取厂商字符串 __cpuid(cpuInfo, 0); memcpy(vendor, cpuInfo[1], 4); // EBX memcpy(vendor 4, cpuInfo[3], 4); // EDX memcpy(vendor 8, cpuInfo[2], 4); // ECX vendor[12] \0; // 功能号1拿 Family/Model/Stepping __cpuid(cpuInfo, 1); unsigned int family ((cpuInfo[0] 8) 0xf) | ((cpuInfo[0] 20) 0xff); unsigned int model ((cpuInfo[0] 4) 0xf) | ((cpuInfo[0] 16) 0xf); unsigned int stepping cpuInfo[0] 0xf; // 功能号0x80000002~0x80000004拿完整型号名 for (int i 0; i 3; i) { __cpuid(cpuInfo, 0x80000002 i); memcpy(brand i * 16, cpuInfo, 16); } brand[48] \0; snprintf(out, out_len, %s|%u-%u-%u|%s, vendor, family, model, stepping, brand); }如果编译器是 GCC 系的 MinGW-w64更省事的路径是引入cpuid.h里的__get_cpuid原理一样。输出里的vendor|family-model-stepping|brand虽然不乱但稳定可用。要注意的是CPU 指纹不要做得太强。不要以为 CPUID 每次调用会返回同样的值就一定不变实际上虚拟机迁移、BIOS 升级、部分主板微码更新都可能导致 Family/Model 甚至是 Brand String 出现差异。我一般把它当成硬件指纹的一部分而不是唯一主键。2.3 补充一个注册表方式有些旧的参考代码会告诉你直接读注册表HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0\ProcessorNameString这个方式其实也能用只是读出来的是处理器的型号字符串不是序列号而且注册表内容可能会被系统或者安全软件改写。代码上就是RegOpenKeyExA加RegQueryValueExA没有太多技术含量。把它当参考没问题但如果项目里有“唯一标识”的需求还是建议走 CPUID 组合方案。3. 硬盘序列号物理盘序列号和卷序列号是两码事3.1 两者的区别硬盘相关的“序列号”是项目里最容易混淆的地方。物理硬盘序列号由硬盘厂商写在盘体固件里比如希捷、西数的出厂编号代表这块物理盘的身份理论上不会因为格式化而改变。卷序列号是文件系统格式化时随机生成的一个 32 位值存在引导扇区里每次快速格式化都可能变化。你要做硬件资产登记应该拿物理硬盘序列号你要做的只是给某个分区做标识卷序列号反而更简单也更合适。原 zip 里的代码把两者混在一起用这是最大的问题。3.2 用DeviceIoControl拿物理硬盘序列号在 Windows 上获取物理硬盘序列号的经典做法是打开\\.\PhysicalDrive0然后向驱动发送IOCTL_STORAGE_QUERY_PROPERTY查询STORAGE_DEVICE_DESCRIPTOR。代码骨架如下#include windows.h #include winioctl.h #include ntddstor.h #include stdio.h BOOL get_physical_drive_serial(int drive_index, char *serial, size_t serial_len) { char path[64]; HANDLE hDevice; STORAGE_PROPERTY_QUERY query; BYTE buffer[1024]; DWORD bytes_returned 0; PSTORAGE_DEVICE_DESCRIPTOR desc; snprintf(path, sizeof(path), \\\\.\\PhysicalDrive%d, drive_index); // 注意访问方式传0尽量不要带GENERIC_READ否则在某些系统上会占用磁盘句柄 hDevice CreateFileA(path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDevice INVALID_HANDLE_VALUE) { return FALSE; } memset(query, 0, sizeof(query)); query.PropertyId StorageDeviceProperty; query.QueryType PropertyStandardQuery; if (!DeviceIoControl(hDevice, IOCTL_STORAGE_QUERY_PROPERTY, query, sizeof(query), buffer, sizeof(buffer), bytes_returned, NULL)) { CloseHandle(hDevice); return FALSE; } desc (PSTORAGE_DEVICE_DESCRIPTOR)buffer; if (desc-SerialNumberOffset 0) { CloseHandle(hDevice); return FALSE; } snprintf(serial, serial_len, %s, (char *)(buffer desc-SerialNumberOffset)); CloseHandle(hDevice); return TRUE; }实际运行时这块代码通常不需要管理员权限但是如果你在服务程序或者被系统独占的磁盘上执行也可能遇到ERROR_ACCESS_DENIED。还有一种情况是返回的 SerialNumberOffset 为 0常见于某些 RAID 卡、虚拟磁盘、NVMe 控制器没有把序列号暴露到这个标准的存储描述符里。这时候常用 fallback 是走 WMI 的Win32_DiskDrive.SerialNumber但那样就需要引入 COM 初始化复杂度会上升。3.3 不建议直接用卷标那个“序列号”当机器标识如果你只是想快速演示用GetVolumeInformationA拿C:\的卷序列号最简单char volume_name[MAX_PATH 1] {0}; char file_system[MAX_PATH 1] {0}; DWORD volume_serial 0; GetVolumeInformationA(C:\\, volume_name, MAX_PATH, volume_serial, NULL, NULL, file_system, MAX_PATH); printf(卷序列号: %08X\n, volume_serial);这段代码短但不要拿它冒充硬件序列号。它对于同一块盘在不同分区、不同文件系统、不同格式化时间下会变化做资产追踪会闹笑话。我的建议是项目里同时保留两个函数先尝试物理序列号失败时再用卷序列号兜底并在输出里明确标识是哪种来源。4. 网卡信息IP、MAC和网线状态一次性说清楚4.1 从GetAdaptersInfo到GetAdaptersAddresses网卡信息这块老代码往往还在用GetAdaptersInfo。这个 API 在 XP 时代没有太大问题但它不支持 IPv6也不直接给适配器操作状态判断网线是否插入非常别扭。新版 SDK 推荐的是GetAdaptersAddresses它返回的IP_ADAPTER_ADDRESSES结构里包含OperStatus字段可以直接看适配器当前是 Up 还是 Down比自己去猜靠谱得多。如果只需要 IPv4第一个参数传AF_INET如果 IPv4/IPv6 都要传AF_UNSPEC。第二个参数GAA_FLAG_INCLUDE_PREFIX视需求而定只是查 IP 和 MAC 不传也行但传了能拿到更完整的网络前缀信息。还有一个隐藏的坑这个函数第一次调用时会把缓冲区长度写入bufLen然后你需要再调用一次真正拿数据。很多新手只调用一次就会拿到ERROR_BUFFER_OVERFLOW然后直接报错。4.2 遍历IP和MAC的代码骨架#include winsock2.h #include iphlpapi.h #include ws2tcpip.h #include stdio.h #include stdlib.h #pragma comment(lib, iphlpapi.lib) #pragma comment(lib, ws2_32.lib) void print_network_info() { ULONG buf_len 0; PIP_ADAPTER_ADDRESSES p_addresses NULL; PIP_ADAPTER_ADDRESSES p_curr NULL; DWORD ret; // 第一次调用获取需要的缓冲区长度 ret GetAdaptersAddresses(AF_INET, GAA_FLAG_INCLUDE_PREFIX, NULL, NULL, buf_len); if (ret ! ERROR_BUFFER_OVERFLOW ret ! ERROR_SUCCESS) { printf(GetAdaptersAddresses failed: %lu\n, ret); return; } p_addresses (PIP_ADAPTER_ADDRESSES)malloc(buf_len); if (!p_addresses) return; ret GetAdaptersAddresses(AF_INET, GAA_FLAG_INCLUDE_PREFIX, NULL, p_addresses, buf_len); if (ret ! ERROR_SUCCESS) { free(p_addresses); return; } for (p_curr p_addresses; p_curr ! NULL; p_curr p_curr-Next) { // 过滤掉回环和虚拟网卡避免输出一堆没意义的设备 if (p_curr-IfType IF_TYPE_SOFTWARE_LOOPBACK) continue; if (p_curr-OperStatus ! IfOperStatusUp p_curr-OperStatus ! IfOperStatusDown) continue; printf(适配器: %S\n, p_curr-FriendlyName); printf( 描述: %S\n, p_curr-Description); // MAC地址 printf( MAC: ); for (int i 0; i (int)p_curr-PhysicalAddressLength; i) { printf(%02X, p_curr-PhysicalAddress[i]); if (i ! (int)p_curr-PhysicalAddressLength - 1) printf(:); } printf(\n); // 第一个单播IP if (p_curr-FirstUnicastAddress ! NULL) { SOCKADDR *sa p_curr-FirstUnicastAddress-Address.lpSockaddr; if (sa-sa_family AF_INET) { sockaddr_in *ipv4 (sockaddr_in *)sa; char ip_str[INET_ADDRSTRLEN] {0}; inet_ntop(AF_INET, (ipv4-sin_addr), ip_str, sizeof(ip_str)); printf( IPv4: %s\n, ip_str); } } // 网线/链路状态 if (p_curr-IfType IF_TYPE_ETHERNET_CSMACD) { printf( 网线状态: %s\n, p_curr-OperStatus IfOperStatusUp ? 已连接 : 未连接); } } free(p_addresses); }需要注意FriendlyName和Description都是宽字符在 printf 里要用%S如果工程配置成宽字符还是窄字符输出格式会不一样这是很多人编译出来乱码的根源。另外我习惯在遍历时过滤掉回环网卡和虚拟网卡不然 VMware、VirtualBox、WSL 的虚拟适配器会全部弹出来输出能刷一屏。最简单的方法就是判断IfType。4.3 判断“是否插入网线”的边界这是整个项目里口径最难定义的项。OperStatus IfOperStatusUp只能说明操作系统认为这个适配器处于“可用”状态并不能百分百代表物理网线上有电信号。真实环境里你会遇到三种情况有线以太网卡拔了线OperStatus通常会从 Up 变 Down能判断。有线网卡连的交换机端口被禁用OperStatus可能是 Down但网线明明是插着的。无线网卡没有网线概念OperStatus也能是 Up如果不加过滤就会被误判为“已插网线”。所以我在代码里加了IF_TYPE_ETHERNET_CSMACD判断只对有线以太网卡输出网线状态。如果业务要求更严格还可以进一步读GetIfEntry2里的InOctets和OutOctets连续几次采样看流量是否变化来辅助判断链路是不是真的通。这种做法更适合做网络诊断而不是简单的状态展示。5. 拼装成完整工具工程结构、编码与权限5.1 工程结构怎么摆原 zip 的问题是把所有代码堆在一个 main.c 里后面想加功能非常痛苦。我重新整理的时候建议按模块拆hardware_info/ ├── main.c # 入口负责调用各模块并汇总输出 ├── cpu.c ├── cpu.h ├── disk.c ├── disk.h ├── network.c ├── network.h └── common.h # 公共头文件定义统一错误码/输出宏在 Visual Studio 里创建“控制台应用”项目然后把iphlpapi.lib、ws2_32.lib加上去可以写在工程配置里也可以用#pragma comment(lib, iphlpapi.lib)省掉配置步骤。MinGW-w64 的话链接时加-liphlpapi -lws2_32即可。5.2 编码、字符集与输出乱码问题C 语言在 Windows 控制台输出中文最容易翻车的就是字符集。建议统一用 ANSI 版本的 API也就是CreateFileA、GetVolumeInformationA、GetAdaptersAddressesA或者直接用宽字符加wprintf。最省心的方案是源码文件里不要混编码统一存成 UTF-8 with BOM代码开头执行一次SetConsoleOutputCP(CP_UTF8)再配合printf输出。不要迷信某个编译器默认编码。实测中Visual Studio 默认 ANSI 编译时UTF-8 源码里的中文字符串经常变成乱码改用/utf-8编译选项可以解决。MinGW-w64 则基本默认 UTF-8注意控制台代码页跟上就行。5.3 权限和系统兼容性这个工具大部分功能不需要管理员权限但只要涉及打开物理磁盘、查询 S.M.A.R.T. 数据或写入驱动控制码就要考虑用户权限。我的经验是普通权限下CreateFileA访问\\.\PhysicalDrive0通常能拿到序列号但在 Windows 11 某些安全加固策略下可能会被拒绝遇到这种情况要么提示用户以管理员身份运行要么降级到卷序列号。系统兼容性方面GetAdaptersAddresses需要 Windows XP SP2 以上__cpuid只要是 x86/x64 都支持ARM64 的 Windows 上则要换成别的方案。如果直接把这份代码拿到 ARM 设备上跑__cpuid这一块得去掉用注册表方式替代。6. 实测中出现的问题与排查我按这个结构跑了几轮收集到的高频问题如下问题现象可能原因解决方案硬盘序列号返回为空虚拟磁盘/RAID/NVMe不暴露标准描述符改用WMI的Win32_DiskDrive.SerialNumber做兜底编译报错找不到__cpuid编译器环境不是MSVC或者没包含intrin.hMinGW-w64改用cpuid.h的__get_cpuid输出IP只有IPv6没有IPv4传了AF_UNSPEC遍历错了AddressFamily参数改AF_INET或者按sa_familyAF_INET过滤打印机/VirtualBox虚拟网卡混入没有按IfType过滤增加IF_TYPE_SOFTWARE_LOOPBACK等判断多网卡机器重复输出枚举逻辑没跳过隐藏/禁用适配器检查OperStatus禁用的直接跳过中文乱码源码编码和控制台代码页不一致加SetConsoleOutputCP(CP_UTF8)编译加/utf-8网线状态在切换网络时判断迟钝链路状态有驱动延迟连续采样几次或配合流量字节变化判断手写结构体过时导致数据偏移错乱系统头文件版本不一致直接用官方ntddstor.h里的结构定义这里重点说一下多网卡问题。笔记本电脑往往同时存在有线网卡、无线网卡、蓝牙 PAN 和虚拟网卡如果程序全部枚举用户会被一屏信息淹没。我建议最终输出时按“物理有线 物理无线 虚拟”的优先级只展示前 1-2 个有效适配器并且单独标记哪个是当前默认路由所在网卡。默认路由这个信息可以通过GetBestInterface或者GetIpForwardTable拿实测效果很好适合做“当前在用的IP/MAC”展示。另一个很隐蔽的坑在GetAdaptersAddresses的缓冲区释放上。结构体里其他字段的指针大多是指向同一块缓冲区内部的如果你在释放缓冲区之前不小心free了它后面再访问FriendlyName就是野指针。所以我的习惯是先拷贝出需要的字段再释放整个缓冲区不要在释放后继续使用任何返回的结构体。7. 这个项目还能怎么继续扩展把基础功能跑通以后剩下的事情其实更有意思。这个工具其实很适合继续发展成一个小型“机器身份指纹生成器”。我自己的做法是把 CPU 指纹、物理硬盘序列号、主板 UUID通过 SMBIOS 读取、MAC 地址四样东西拼成一个字符串再做一次 MD5 或 SHA-256生产一个 32 位的机器码。这个机器码比单独用 CPU 序列号或 MAC 地址要稳定得多即使某个网卡被禁用、某些虚拟磁盘被替换机器码也不会轻易变。很多商业软件的授权验证用的就是这个思路。还可以加入更多硬件信息比如内存容量、显卡型号、BIOS 版本、电池序列号。这些都可以通过SetupDiGetDeviceRegistryProperty去枚举设备接口获取。C 写起来会繁琐但代码路径清晰很适合当一个系统学习 Win32 设备管理的练手项目。最后再分享一个小技巧也是踩了几次坑之后才养成的习惯采集类程序一定要把所有获取函数的返回值都检查一遍哪怕某个硬件查询失败也别忘了把失败的 API 名和错误码打出来。现场排查时一条GetLastError()日志能省掉你半天的猜谜时间。我自己把这个工具做完后最大的体会就是Windows 下“读硬件信息”这件事真正难的不是 API 怎么调而是每种信息来源都有例外和降级策略先想清楚兜底方案代码才不会一跑就崩。本文还有配套的精品资源点击获取