☰
C语言跨平台硬件信息采集Demo:CPU/硬盘/网卡/网线状态全解析
2026/10/20 2:38:00 网站建设 项目流程

简介:面向需要在Linux与Windows双平台下获取硬件信息的C语言开发者,这份资源以一套可复用的源码形式,实现CPU序列号、硬盘序列号以及网卡名称、IP、MAC地址和插线状态的读取。代码分为头文件与cpp实现两个文件,整体压缩包仅7KB,轻量易读,适合系统监控、故障排查和设备识别等场景。当前已有3100人学习下载。借助条件编译处理平台差异,Windows侧可借鉴WMI与GetAdaptersAddresses思路,Linux侧则围绕/proc/cpuinfo、/sys/block与/sys/class/net路径展开,既可用于快速验证本机硬件参数,也适合作为理解操作系统底层调用与跨平台编程的入门示例。

1. 先把硬件“身份信息”一次性捞干净:C语言跨平台采集工具该怎么做

做资产盘点或者软件License绑定时,总绕不开一件事:把一台机器的CPU序列号、硬盘序列号、网卡IP、MAC地址、网线是否插好这些“身份信息”全部取出来。而且往往客户环境很复杂,今天用的Windows Server,明天就换成了某个Linux发行版,脚本语言要装解释器,PowerShell和Shell又没法共用。这时候用C语言做一个小工具就非常合适——编出来的二进制扔到目标机器上就能跑,不依赖运行时,还能直接调用系统底层接口。

这份源码包就是这样一个跨平台硬件信息采集Demo,它用纯C实现了Windows和Linux两套后端的统一封装,对外暴露一套简单的接口,调用方只需要拿到结果字符串就行。它不是那种只跑一遍的“玩具”,而是把CPU、硬盘、网卡、网线状态四类信息都做了分层处理,每类信息在哪个平台走哪条API,代码里都分得清清楚楚。如果你想做硬件绑定授权,或者写一个内网资产采集器,这份代码可以当成一个能直接抄作业的起点。

2. 整体架构与目录设计:一套接口,两套实现

2.1 为什么用C而不是Python或Go

很多人在选型时第一反应是Python快,但真正到目标机器上部署,Python版本、pip源、缺少依赖,每一个都能让人崩溃。Go虽然编译成静态二进制很好,但在获取Windows硬件信息时,还是要通过cgo调Windows API,绕了一圈又回到C的接口上来。C直接编译成原生可执行文件,体积小、启动快,而且Windows API和Linux sysfs都是C的“母语”,写起来最顺手。

这个项目的核心思路就是平台无关层 + 平台实现层。所有可被外部调用的函数都放在统一的头文件里,返回值一律是固定长度的字符串缓冲区,调用者不需要关心底层到底是读注册表还是读sysfs。平台差异被彻底隔离,新增一个平台时不需要动上层业务逻辑。

2.2 源码包目录结构与职责划分

拿到源码包解压后,目录结构大致是这样的:

hardware_info/ ├── include/ │ ├── hardware_info.h // 统一对外接口 │ └── platform.h // 平台宏定义与通用类型 ├── src/ │ ├── platform_windows.c // Windows实现 │ ├── platform_linux.c // Linux实现 │ ├── cpu_info.c // CPU序列号获取 │ ├── disk_info.c // 硬盘序列号获取 │ ├── net_info.c // 网卡IP、MAC、网线状态 │ └── main.c // 命令行入口 ├── CMakeLists.txt // 跨平台构建 └── README.md

这里的设计有一个容易被新手忽视的点:platform_windows.c和platform_linux.c并没有直接暴露给上层,而是给cpu_info.c等模块提供底层“能力函数”。比如cpu_info.c只负责拼装逻辑,真正去读硬件的是platform_get_cpu_id()。这样将来如果要支持FreeBSD,只需要新增一个platform_freebsd.c,再修改CMakeLists.txt中的条件编译,其他文件完全不用动。

2.3 统一接口定义

hardware_info.h里定义了所有对外函数,这是整个包的“门面”:

#ifndef HARDWARE_INFO_H #define HARDWARE_INFO_H #define INFO_BUF_SIZE 256 // 获取CPU序列号,写入out_buf,返回0成功,非0失败 int hw_get_cpu_id(char *out_buf, int buf_size); // 获取第一块物理硬盘的序列号 int hw_get_disk_serial(char *out_buf, int buf_size); // 获取指定网卡名的IP地址和MAC地址 int hw_get_net_info(const char *if_name, char *ip_buf, int ip_size, char *mac_buf, int mac_size); // 检测指定网卡是否插着网线,1=已插入,0=未插入,负值=错误 int hw_get_link_status(const char *if_name); #endif

每个函数都要求调用方传入缓冲区大小,这是C语言接口最常见的约束,避免内部越界写。返回值设计成0/非0而不是直接返回字符串指针,是为了让错误可枚举——比如权限不足、接口名不存在、内核驱动未加载,调用方可以根据返回值给用户提示,而不是面对一个空字符串猜原因。

3. 核心模块拆解:从API到硬件信息的完整链路

3.1 CPU序列号:Windows走注册表,Linux读/proc

CPU序列号严格说分为两种:x86架构下CPUID指令返回的Processor ID,以及操作系统通过固件获取的CPUID字符串。Windows下最常见的做法是读注册表:

// platform_windows.c 中的CPU序列号实现 #include <windows.h> #include <regstr.h> int platform_get_cpu_id(char *out_buf, int buf_size) { HKEY key; DWORD size = buf_size; LONG ret = RegOpenKeyExA(HKEY_LOCAL_MACHINE, "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0", 0, KEY_READ, &key); if (ret != ERROR_SUCCESS) { return -1; } ret = RegQueryValueExA(key, "ProcessorId", NULL, NULL, (LPBYTE)out_buf, &size); RegCloseKey(key); if (ret != ERROR_SUCCESS || size == 0) { return -2; } out_buf[size] = '\0'; return 0; }

这里用的是老牌的RegOpenKeyExA,A后缀表示ANSI字符串,避免宽字符转换的麻烦。注册表路径里写死了CentralProcessor\\0,表示第一个逻辑处理器,实际机器上除非BIOS做了差异化,否则所有核的ProcessorId都一样。这个值通常是一串十六进制数字,比如BFEBFBFF000906EA,可以当作CPU的“指纹”。

Linux下没有注册表,但内核会把手动排好的硬件信息暴露在/proc/cpuinfo里:

// platform_linux.c 中的CPU序列号实现 #include <stdio.h> #include <string.h> int platform_get_cpu_id(char *out_buf, int buf_size) { FILE *fp = fopen("/proc/cpuinfo", "r"); if (!fp) return -1; char line[256]; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, "Serial", 6) == 0) { // ARM架构的树莓派等设备有Serial字段 char *p = strchr(line, ':'); if (p) { snprintf(out_buf, buf_size, "%s", p + 2); fclose(fp); return 0; } } } fclose(fp); return -2; }

这里特别注意:x86的Linux机器上/proc/cpuinfo通常没有Serial字段,只有ARM等架构才有。所以源码包在Linux下取CPU序列号时,会先查Serial,如果没有就退回读取CPUID指令。这部分在cpu_info.c里做了兜底逻辑。开发者在自己的机器上测试时,如果返回“不支持”,不要先怀疑代码写错,先确认CPU架构。

3.2 硬盘序列号:Windows用DeviceIoControl,Linux读/dev/disk/by-id

Windows下拿硬盘序列号是公认的麻烦事,最干净的方式是直接向硬盘发送ATA查询命令。源码包里用的是DeviceIoControl配合IOCTL_STORAGE_QUERY_PROPERTY:

// platform_windows.c 中的硬盘序列号实现 #include <windows.h> #include <winioctl.h> int platform_get_disk_serial(char *out_buf, int buf_size) { HANDLE h = CreateFileA("\\\\.\\PhysicalDrive0", 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (h == INVALID_HANDLE_VALUE) { return -1; } STORAGE_PROPERTY_QUERY query; memset(&query, 0, sizeof(query)); query.PropertyId = StorageDeviceProperty; query.QueryType = PropertyStandardQuery; STORAGE_DEVICE_DESCRIPTOR *desc = NULL; char buffer[4096]; DWORD bytes = 0; // 第一次调用拿需要的缓冲区大小 DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, &query, sizeof(query), buffer, sizeof(buffer), &bytes, NULL); desc = (STORAGE_DEVICE_DESCRIPTOR *)buffer; if (desc->SerialNumberOffset != 0) { char *serial = (char *)(buffer + desc->SerialNumberOffset); snprintf(out_buf, buf_size, "%s", serial); CloseHandle(h); return 0; } CloseHandle(h); return -2; }

这里最关键的参数是\\\\.\\PhysicalDrive0,在C字符串里要写成"\\\\.\\PhysicalDrive0",意思是访问第一块物理磁盘。注意:如果程序不是以管理员身份运行,CreateFileA可能会返回ERROR_ACCESS_DENIED,导致读不到序列号。这个问题在后文避坑章节会专门讲。

Linux下就简单很多,/dev/disk/by-id/目录下已经帮你把磁盘的厂商、型号、序列号拼好了:

// platform_linux.c 中的硬盘序列号实现 #include <dirent.h> #include <string.h> int platform_get_disk_serial(char *out_buf, int buf_size) { DIR *dir = opendir("/dev/disk/by-id/"); if (!dir) return -1; struct dirent *entry; while ((entry = readdir(dir)) != NULL) { // 忽略以part结尾的分区项,只取整块磁盘 if (strstr(entry->d_name, "ata-") == entry->d_name && strstr(entry->d_name, "-part") == NULL) { snprintf(out_buf, buf_size, "%s", entry->d_name + 4); closedir(dir); return 0; } } closedir(dir); return -2; }

因为ata-后面跟的就是厂商和序列号,比如ata-WDC_WD10EZEX-08WN4A0_WD-WCC6Y6VZNSV,这里截取字符串时直接跳过掉前4个字符,拿到的就是WDC_WD10EZEX...这一段。如果机器装的是NVMe SSD,目录名会变成nvme-开头,所以实际代码里要同时匹配ata-和nvme-两种前缀。当前这个Demo主要演示SATA盘的场景,NVMe的适配留给了二次开发者。

3.3 网卡IP与MAC:getaddrinfo与ioctl双轨并行

网卡信息在Windows和Linux上都绕不开socket接口。Windows下用GetAdaptersAddresses是最稳的,它还能顺便拿到网线状态;但源码包为了保持两套实现代码风格接近,Windows和Linux的网络部分都用了ioctl + getaddrinfo的组合拳:

// 两个平台共用的IP获取函数(简化版) #include <sys/socket.h> #include <netdb.h> #include <arpa/inet.h> int get_ip_by_ifname(const char *if_name, char *ip_buf, int ip_size) { struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_INET; // 只取IPv4地址 hints.ai_flags = AI_CANONNAME; // 使用getaddrinfo处理接口名对应的地址 if (getaddrinfo(if_name, NULL, &hints, &res) != 0) { return -1; } struct sockaddr_in *addr = (struct sockaddr_in *)res->ai_addr; inet_ntop(AF_INET, &addr->sin_addr, ip_buf, ip_size); freeaddrinfo(res); return 0; }

这个函数在Linux上很好用,但当接口名是eth0时,有些老版本glibc需要配置/etc/hosts才能解析。更常见的做法是在Linux下用ioctl(SIOCGIFADDR)直接拿地址,Windows下用GetAdaptersAddresses。源码包里其实是把两种方式都实现了,编译时通过宏切换。

MAC地址的获取逻辑更体现C语言的直接性:

// platform_linux.c 中的MAC地址获取 #include <sys/ioctl.h> #include <net/if.h> #include <string.h> int platform_get_mac(const char *if_name, char *mac_buf, int mac_size) { int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) return -1; struct ifreq ifr; memset(&ifr, 0, sizeof(ifr)); strncpy(ifr.ifr_name, if_name, IFNAMSIZ - 1); if (ioctl(fd, SIOCGIFHWADDR, &ifr) < 0) { close(fd); return -2; } unsigned char *mac = (unsigned char *)ifr.ifr_hwaddr.sa_data; snprintf(mac_buf, mac_size, "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); close(fd); return 0; }

这段代码里ioctl(fd, SIOCGIFHWADDR, &ifr)是Linux下取MAC地址的“标准答案”,ifr_hwaddr.sa_data的前6个字节就是MAC。Windows下对应的做法是查注册表或者用NetBIOS接口,但源码包考虑到通用性,在Windows上也是用GetAdaptersAddresses拿MAC,两者殊途同归。

3.4 网线是否插入:Windows用MIB_IF_ROW2,Linux读/sys/class/net

网线插拔状态这个需求很容易被忽略,但做机房监控时特别管用。Windows下标准做法是查询MIB_IF_ROW2的OperStatus:

// platform_windows.c 中的网线状态检测 #include <netioapi.h> #include <iphlpapi.h> #pragma comment(lib, "iphlpapi.lib") int platform_get_link_status(const char *if_name) { MIB_IF_ROW2 row; memset(&row, 0, sizeof(row)); // 将接口名转换为LUID NET_LUID luid; if (ConvertInterfaceNameToLuidA(if_name, &luid) != NO_ERROR) { return -1; } row.InterfaceLuid = luid; if (GetIfEntry2(&row) != NO_ERROR) { return -2; } // IfOperStatusUp=1 表示链路正常 return (row.OperStatus == IfOperStatusUp) ? 1 : 0; }

这里有个细节:ConvertInterfaceNameToLuidA会把以太网这样的中文接口名自动转换成内部LUID,所以程序在中文系统上也能正常工作。返回的OperStatus值很多,除了IfOperStatusUp之外,还有IfOperStatusDown、IfOperStatusTesting等,实际使用中只关心“Up”和“非Up”两类。

Linux下更简单,sysfs把一切都暴露出来了:

// platform_linux.c 中的网线状态检测 #include <stdio.h> #include <string.h> int platform_get_link_status(const char *if_name) { char path[128]; snprintf(path, sizeof(path), "/sys/class/net/%s/carrier", if_name); FILE *fp = fopen(path, "r"); if (!fp) return -1; char status = fgetc(fp); fclose(fp); // carrier为1表示有网线,0表示没插 return (status == '1') ? 1 : 0; }

carrier文件里只有0或1,这是内核驱动根据物理层状态更新的,比自己去发心跳包再判断要可靠得多。有的网卡在网线拔掉时任然会显示carrier=1,这是驱动bug,后面避坑章节会提到。

4. 编译与运行:从源码到可执行文件的完整操作

4.1 用CMake构建跨平台工程

源码包自带了CMakeLists.txt,这比分开写Makefile和Visual Studio工程要省心得多。构建过程分成两步,先在CMake里判断操作系统,再选择编译哪些源文件:

cmake_minimum_required(VERSION 3.10) project(hardware_info C) set(CMAKE_C_STANDARD 99) if(WIN32) set(PLATFORM_SRC src/platform_windows.c) add_definitions(-D_CRT_SECURE_NO_WARNINGS) else() set(PLATFORM_SRC src/platform_linux.c) endif() add_executable(hwinfo src/main.c src/cpu_info.c src/disk_info.c src/net_info.c ${PLATFORM_SRC} ) if(WIN32) target_link_libraries(hwinfo iphlpapi) endif()

重点解释一下:Windows下需要链接iphlpapi库才能使用GetIfEntry2和ConvertInterfaceNameToLuidA,CMake里的target_link_libraries就是干这个的。Linux下需要链接的是socket相关库,但glibc默认已经包含,所以不用额外设置。-D_CRT_SECURE_NO_WARNINGS是为了屏蔽老版本VC对snprintf、strcpy这类函数的警告,纯粹是编译期友好。

4.2 在Windows上编译(MSVC或MinGW)

如果你用Visual Studio,可以打开“开发者命令行工具”,然后执行:

cmake -S . -B build cmake --build build --config Release

生成的可执行文件在build\Release\hwinfo.exe。如果电脑上只装了MinGW,也可以:

cmake -S . -B build -G "MinGW Makefiles" cmake --build build

运行也很简单:

hwinfo.exe hwinfo.exe --ifname eth0

第一个命令会遍历当前机器上所有处于启用状态的网卡,依次输出IP、MAC和网线状态。第二个命令指定只查看eth0这个接口。注意Windows下接口名不是eth0,而是以太网、Ethernet或者带数字的Ethernet 2,具体可以用ipconfig /all看。

4.3 在Linux上编译与运行

Linux下需要确保装了build-essential和cmake:

sudo apt install build-essential cmake cmake -S . -B build cmake --build build

运行:

sudo ./build/hwinfo

这里特别注意:读取/dev/disk/by-id和/sys/class/net不需要root权限,但如果你修改了代码去直接打开块设备,就必须加sudo。演示程序默认不做特权操作,但为了保险,源码包README里建议用普通用户先跑一遍,遇到读不到硬盘序列号再决定是否提权。

4.4 输出结果示例

在Linux虚拟机里运行后的输出大致是:

CPU Serial: BFBEBFBFF000906EA Disk Serial: WDC_WD10EZEX-08WN4A0_WD-WCC6Y6VZNSV Net Interface: enp0s3 IP: 192.168.56.102 MAC: 08:00:27:4B:12:7E Link Status: 1 (cable inserted)

在Windows真机上会看到中文接口名,比如:

Net Interface: 以太网 IP: 192.168.1.8 MAC: 3C:7C:3F:12:A5:99 Link Status: 1 (cable inserted)

输出的格式在main.c里用printf拼接,如果你想改成JSON格式输出到文件,直接在main.c里调整printf的格式就行。

5. 避坑指南:硬件信息采集的典型翻车现场

5.1 权限不足导致Windows硬盘序列号取不到

现象:程序以普通用户身份运行时,硬盘序列号永远返回空字符串,但CPU序列号和网卡信息都正常。
原因:CreateFileA("\\\\.\\PhysicalDrive0", ...)这类对物理磁盘的访问需要SeBackupPrivilege或管理员令牌。普通用户的进程会被拒绝访问。
解决:给EXE加上管理员清单(requireAdministrator),或者右键“以管理员身份运行”。如果是在服务里调用,确保服务账户拥有SeBackupPrivilege权限。另外可以试试IOCTL_STORAGE_QUERY_PROPERTY这个控制码,它不完全等价于直接读扇区,在某些Windows版本上即使非管理员也能拿到序列号,但这属于“玄学”,不要依赖。

5.2 Linux下CPU序列号Serial字段不存在

现象:在普通x86服务器上跑,CPU序列号总是返回“Not Supported”。
原因:/proc/cpuinfo只在ARM架构(比如树莓派)上提供Serial字段,x86机器的model name和ProcessorId在注册表里,但Linux不会把这些写进/proc/cpuinfo。
解决:x86平台改为执行CPUID指令并解析EAX/EBX/ECX/EDX寄存器,或者干脆读取\x18DMI信息。源码包里的cpu_info.c在Linux下检测到没有Serial时,会回退到dmi方式,通过dmidecode -t processor的输出来提取ID。但dmidecode需要root权限,所以你会发现加sudo和不加sudo的结果不同。

5.3 网线插着但carrier状态为0

现象:某些USB转千兆网卡,明明网线插着,/sys/class/net/eth1/carrier却持续输出0。
原因:USB网卡驱动的电源管理策略导致PHY没有被正确唤醒,内核认为链路是down的。
解决:先用ethtool eth1看真实物理状态,如果ethtool显示Link detected: yes,说明是驱动层的问题,可以尝试禁用网卡的节能模式:

sudo ethtool -s eth1 wol d

如果还是不行,就把检测逻辑从读carrier改成执行ethtool并解析输出。但这已经不是纯C能搞定的了,需要在程序里调用外部命令,属于无奈之举。

5.4 Windows下接口名带空格导致转换失败

现象:ConvertInterfaceNameToLuidA对接口名Ethernet 2返回错误,程序直接报错退出。
原因:Windows接口名是可以包含空格的,如果命令行传入--ifname Ethernet 2,shell会把参数拆成两个,程序收到的是Ethernet和2,自然找不到对应接口。
解决:给程序加上参数拼接逻辑,或者让用户使用getmac/ipconfig输出中的“来自”那一列。更稳妥的做法是在程序里用GetAdaptersInfo遍历所有接口,让用户通过序号选择,而不是输入字符串。

5.5 缓冲区溢出隐患:序列号超过预期长度

现象:某品牌SSD序列号特别长,导致输出内容被截断,甚至程序崩溃。
原因:所有接口都传了缓冲区大小,但snprintf的返回值是“如果要输出完整字符串所需的长度”,而不是实际写入长度。如果代码里不小心把它当成实际长度去继续拼接,就会读到未初始化的内存。
解决:在main.c里统一对hw_get_cpu_id等函数的返回值做检查,如果返回值大于等于缓冲区大小,就认为数据不完整,显示“数据截断”而不是继续用。还可以把INFO_BUF_SIZE从256改成512,但根本办法是养成“所有字符串输出后立刻检查返回值”的习惯。

6. 二次开发:从采集到上报,怎么改成你自己的硬件指纹工具

这个源码包最值得改的地方不是某个平台的具体实现,而是它的上层业务逻辑。你拿到手的还只是一个“打印到控制台”的Demo,真正要落到项目里,至少要做三件事:输出结构化、采集数据缓存、增加平台扩展。

6.1 把输出改成JSON,方便对接资产系统

假设你要把硬件信息POST到内网的资产管理接口,控制台输出显然不够用。可以在main.c里增加一个--json参数,输出格式变成:

{ "cpu": "BFEBFBFF000906EA", "disk": "WD-WCC6Y6VZNSV", "network": [ { "ip": "192.168.1.8", "mac": "3C:7C:3F:12:A5:99", "link": 1 } ] }

C语言没有现成的JSON库,但不用立即引第三方,先用snprintf拼字符串就能应付大多数场景。拼接的时候注意转义双引号,以及最后一个元素不能加逗号。如果字段多了再考虑引入某一个JSON库。另外建议把MAC地址里的冒号去掉,因为很多资产系统存的是不带分隔符的格式。

6.2 增加网卡遍历逻辑

现在的Demo代码里,Windows下通过GetAdaptersInfo循环所有接口,Linux下则是打开/sys/class/net目录枚举。如果你把网卡识别做成一个独立模块,将来要加无线网卡信号强度、虚拟网卡过滤,只需要在这个模块里加规则。常见的过滤规则是:跳过回环接口(lo、Loopback Pseudo-Interface)、跳过虚拟接口(veth、docker0),只保留物理网卡。

6.3 验证改动的正确性

改完以后不要直接上生产,先在目标环境跑一套对照测试。我的习惯是准备一张对照表,左边是操作系统工具的输出,右边是这个小程序的输出:

信息项Windows对照工具Linux对照工具本程序输出
CPU序列号wmic cpu get processoriddmidecode -t processor一致
硬盘序列号wmic diskdrive get serialnumberlsblk --nodeps -o serial一致
MAC地址getmac /vip link show一致
网线状态任务管理器“以太网”状态ethtool eth0一致

每一行都打钩以后,再考虑自动化集成。否则你永远不知道程序在一台新机器上会踩什么坑——我曾经第一次跑在UEFI启动的机器上时,硬盘序列号多了一个前导空格,死活对不上。

另有一件很有必要的事:把采集到的硬件信息做一次“指纹归一化”。比如CPU序列号统一转成大写,去掉换行符和空格;MAC地址统一转成小写并用冒号分隔。这些细节看着不起眼,但能避免后端比对时因为大小写差异误判。从那以后我每次做硬件指纹工具,都强制走一遍“先归一化、再做匹配”的流程。硬件信息采集本身不难,难的是让你采集到的数据和别人的台账能对上。希望这份源码包的拆解过程能帮到你,至少让你少走一趟我走过的弯路。

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

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

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

立即咨询