纯C实现Windows硬件身份采集:CPU、硬盘、MAC地址与网线状态

发布时间:2026/10/4 4:52:22
纯C实现Windows硬件身份采集:CPU、硬盘、MAC地址与网线状态
做软件授权、做资产盘点、做终端准入绕不开的一件事就是怎么拿到这台电脑的硬件身份。我最近在纯C环境里把这套东西完整跑了一遍——CPU序列号、硬盘序列号、网卡IP、MAC地址、以及网线有没有插上全部在Windows用户态搞定不装驱动、不依赖第三方库编出来的EXE只有几十KB扔到U盘里在任意一台Windows机器上都能直接跑。这套代码的典型用途是软件防伪绑定和设备登记。但真到了要选“哪个硬件ID当指纹”的时候坑就来了很多人以为CPU序列号是唯一的结果发现同型号CPU跑出来的值一模一样也有人分不清硬盘的卷序列号和物理盘序列号用错之后机器重装一次授权就失效。这篇文章把我实际踩过的坑和最终验证过的取法都写出来代码是基于常见实践的Windows API方案你直接照着搭就能用。如果你只是想查一下自己电脑的配置那命令行几条命令就够但如果你是要把这个逻辑做成可复用的C模块或者想弄清楚每个API背后到底返回什么这篇能帮你省掉不少弯路。1. 为什么偏要用C语言跑这套硬件信息采集1.1 这条路能解决什么问题先明确场景。公司内部工具需要按机器授权或者软件分发出去之后要做防伪校验这时候你不能指望每台目标机器上都装了PowerShell 5.0也不能要求客户先装个.NET运行时。C语言编译出来的原生程序有几个实打实的好处不需要运行时Windows XP到Windows 11都能跑体积小适合塞进安装包、U盘工具、WinPE环境可以直接调用Win32 API和WMI信息粒度可控方便封装成DLL嵌入到其他业务系统里如果只是临时查一次我承认PowerShell更快Get-WmiObject Win32_Processor | Select-Object ProcessorId Get-WmiObject Win32_DiskDrive | Select-Object Model,SerialNumber Get-WmiObject Win32_NetworkAdapter | Select-Object MACAddress,NetConnectionStatus Get-NetAdapter | Select-Object Name,Status,MacAddress但脚本方案的问题是WMI查询慢且容易被策略禁用不同Windows版本之间命令差异大要集成到C/C产品里还得再套一层。所以正经做产品我还是推荐用C直接调API。1.2 开发环境与需要包含的头文件以下代码在Visual Studio和MinGW-w64下都能编译核心依赖是Windows SDK自带的头文件不需要额外安装库。功能主要API头文件CPU信息__cpuid/ WMIintrin.h/Wbemidl.h硬盘序列号DeviceIoControlIOCTL_STORAGE_QUERY_PROPERTYwinioctl.hMAC/IPGetAdaptersAddressesiphlpapi.hwinsock2.h网线状态GetIfEntry2MIB_IF_ROW2netioapi.h编译时注意winsock2.h必须放在windows.h前面否则会出现一堆redefinition的报错。这个顺序问题我几乎每次帮同事排查都能遇到先提一句。2. CPU序列号CPUID指令和WMI两条路的真实差别2.1 CPUID指令直取速度快但有认知误区最直观的取法是用CPUID指令。在MSVC下可以直接用内置函数#include intrin.h #include stdio.h void ReadCpuId(unsigned int cpuInfo[4]) { // 功能号1返回的是CPU型号、步进、特性集不是真正的“序列号” __cpuid((int*)cpuInfo, 1); } int main(void) { unsigned int id[4] {0}; ReadCpuId(id); char hex[64] {0}; sprintf_s(hex, sizeof(hex), %08X%08X%08X%08X, id[0], id[1], id[2], id[3]); printf(CPUID(Leaf 1) %s\n, hex); return 0; }如果你用的是MinGW头文件和函数名不一样改成这样#include cpuid.h unsigned int eax, ebx, ecx, edx; __get_cpuid(1, eax, ebx, ecx, edx);很多教程到这里就结束了说“这就是CPU序列号”。我必须泼盆冷水这根本不是物理意义上的唯一序列号。Intel在Pentium III时代确实搞过一个Processor Serial NumberPSN通过CPUID的Leaf 3读取后来因为隐私问题被砍掉了。现在的CPUID Leaf 1返回的是什么呢EAX里是Stepping / Model / Family / Processor TypeEBX、ECX、EDX大多是特性标志位比如是否支持SSE、AVX、超线程。换句话说同一批次、同一型号的CPU跑出来的这段十六进制字符串完全一样。我实测过几台同型号的办公电脑CPUID返回值一模一样。你要是拿它当唯一授权标识第一批机器就把你坑惨了。2.2 WMI查询拿到的是组合ID而非物理序列号WMI里对应的字段是Win32_Processor.ProcessorId。用命令查是这样的wmic cpu get ProcessorId它在底层其实也是走CPUID但会把Leaf 1的EAX、EBX、ECX、EDX四个寄存器拼成一个32位十六进制字符串。换句话说它依旧不是“每颗CPU唯一”的物理序列号而是“型号特征特性标志”的组合。同型号CPU之间这个值大概率相同。WMI方式在C里写起来要啰嗦一些标准流程是COM初始化后查Win32_Processor。下面这个辅助函数可以通用于后面查硬盘和网卡状态#include windows.h #include comdef.h #include Wbemidl.h #pragma comment(lib, wbemuuid.lib) HRESULT QueryWmiString(const wchar_t* wql, const wchar_t* prop, wchar_t* out, size_t outLen) { HRESULT hr; IWbemLocator* loc NULL; IWbemServices* svc NULL; IEnumWbemClassObject* pEnum NULL; CoInitializeEx(NULL, COINIT_MULTITHREADED); hr CoCreateInstance(CLSID_WbemLocator, NULL, CLSCTX_INPROC_SERVER, IID_IWbemLocator, (void**)loc); if (FAILED(hr) || !loc) return hr; hr loc-ConnectServer(Lroot\\cimv2, NULL, NULL, NULL, 0, NULL, NULL, svc); if (FAILED(hr) || !svc) { loc-Release(); return hr; } hr svc-ExecQuery(LWQL, wql, WBEM_FLAG_FORWARD_ONLY, NULL, pEnum); if (SUCCEEDED(hr) pEnum) { IWbemClassObject* obj NULL; ULONG ret 0; hr pEnum-Next(WBEM_INFINITE, 1, obj, ret); if (SUCCEEDED(hr) ret) { VARIANT var; VariantInit(var); if (SUCCEEDED(obj-Get(prop, 0, var, NULL, NULL))) { wcsncpy_s(out, outLen, var.bstrVal, _TRUNCATE); VariantClear(var); } obj-Release(); } pEnum-Release(); } if (svc) svc-Release(); if (loc) loc-Release(); CoUninitialize(); return hr; }调用方式wchar_t processorId[128] {0}; QueryWmiString( LSELECT ProcessorId FROM Win32_Processor, LProcessorId, processorId, 128); wprintf(LProcessorId %s\n, processorId);注意两个工程级细节第一CoInitializeSecurity一个进程只能调用一次如果程序里多处用COM建议放到入口统一初始化否则第二次调用会返回RPC_E_TOO_LATE第二WMI首次查询有时需要等待Winmgmt服务启动做工具时可以考虑先CreateService或简单重试一次。2.3 小结CPU ID适合做辅助指纹我的结论是CPUID返回值和Win32_Processor.ProcessorId适合作为“机器型号特征”的一部分参与指纹计算但不适合单独做唯一标识。真正能用于唯一授权的建议看下一节的硬盘序列号再配合主板UUID。3. 硬盘序列号物理盘序列号和卷序列号别混用3.1 GetVolumeInformation拿到的只是格式化序列号这是新手最容易翻车的地方。网上查“硬盘序列号怎么查”很多答案告诉你用GetVolumeInformation#include windows.h #include stdio.h int main(void) { DWORD serial 0; DWORD maxLen 0; DWORD flags 0; char volume[64] {0}; if (GetVolumeInformationA(C:\\, volume, 64, serial, maxLen, flags, NULL, 0)) { printf(Volume Serial %08X\n, serial); } return 0; }这个值确实是C盘的“序列号”但它是格式化时随机生成的卷序列号不是硬盘厂商烧录的物理序列号。你重装系统、快速格式化这个值就会变。拿它做软件授权用户只要重装系统授权就失效这体验基本没法用。3.2 IOCTL物理盘直读从DeviceIoControl拿到厂商序列号要拿硬盘出厂序列号标准做法是向物理盘设备发送IOCTL_STORAGE_QUERY_PROPERTY读取STORAGE_DEVICE_DESCRIPTOR。代码我整理过直接可用#include windows.h #include winioctl.h #include stdio.h #include string.h #include ctype.h static char* TrimString(char* s) { char* p s strlen(s); while (p s isspace((unsigned char)p[-1])) *--p 0; return s; } BOOL GetPhysicalDriveSerial(int driveIndex, char* serial, size_t serialLen) { char path[32] {0}; sprintf_s(path, \\\\.\\PhysicalDrive%d, driveIndex); // 注意这里用0访问权限打开即可不需要管理员 HANDLE hDisk CreateFileA(path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDisk INVALID_HANDLE_VALUE) return FALSE; STORAGE_PROPERTY_QUERY query {0}; query.PropertyId StorageDeviceProperty; query.QueryType PropertyStandardQuery; char buffer[4096] {0}; DWORD bytesReturned 0; BOOL ok DeviceIoControl(hDisk, IOCTL_STORAGE_QUERY_PROPERTY, query, sizeof(query), buffer, sizeof(buffer), bytesReturned, NULL); CloseHandle(hDisk); if (!ok) return FALSE; STORAGE_DEVICE_DESCRIPTOR* desc (STORAGE_DEVICE_DESCRIPTOR*)buffer; // 关键点SerialNumberOffset是相对于buffer首地址的偏移 if (desc-SerialNumberOffset ! 0) { char* pSerial buffer desc-SerialNumberOffset; strncpy_s(serial, serialLen, TrimString(pSerial), _TRUNCATE); return TRUE; } return FALSE; }这里最隐蔽的坑是偏移量的基准。STORAGE_DEVICE_DESCRIPTOR里的SerialNumberOffset不是相对于结构体头的而是相对于整个IOCTL返回缓冲区的首地址。如果你写成(char*)desc desc-SerialNumberOffset结果是一样的因为buffer和desc指向同一地址但如果你把desc复制到别处再取偏移就会读到乱码。另外SerialNumber字段里经常带前后空格尤其是希捷的盘所以上面的TrimString不能省。还有USB移动硬盘的情况特殊很多转接芯片返回的是“硬盘盒”的序列号而不是盘体本身的序列号做授权绑定时要小心。3.3 WMI兜底Win32_DiskDrive.SerialNumberIOCTL方案在大多数内置硬盘上没问题但NVMe SSD、部分虚拟磁盘上可能拿不到值。这时候WMI是很好的兜底而且能顺带拿到型号和接口类型wchar_t diskSerial[128] {0}; QueryWmiString( LSELECT SerialNumber FROM Win32_DiskDrive WHERE Index0, LSerialNumber, diskSerial, 128); wchar_t diskModel[128] {0}; QueryWmiString( LSELECT Model FROM Win32_DiskDrive WHERE Index0, LModel, diskModel, 128);Index0对应第一块物理盘一般就是系统盘。WMI拿到的序列号和IOCTL拿到的通常一致但有些老驱动对WMI的兼容性更好。我的建议是先试IOCTL失败或者为空再查WMI两者都失败时返回一个“未知设备”的标记别硬报错。3.4 为什么硬盘号通常比CPU ID更适合做防伪回到很多人关心的那个问题软件防伪到底用CPU序列号还是硬盘号我的答案是首选硬盘物理序列号辅以主板UUIDCPU ID只做参考因子。原因不复杂CPU ID在同型号CPU上高度重复区分度差MAC地址可以被驱动层修改虚拟机上还会漂移硬盘序列号是厂商写入的同型号同批次冲突概率低虽然它不是全球唯一标准但“厂商型号序列号”组合起来区分度足够高硬盘是用户最不容易更换的部件之一换硬盘往往意味着整机升级如果你想要更稳的机器唯一标识强烈建议顺手把主板UUID也查出来。它比硬盘还稳定重装硬盘都不变wchar_t boardUuid[128] {0}; QueryWmiString( LSELECT UUID FROM Win32_ComputerSystemProduct, LUUID, boardUuid, 128);4. 网卡MAC与IP多网卡/虚拟网卡环境下的正确姿势4.1 别再用GetAdaptersInfoGetAdaptersInfo是老教程的常客但它返回的信息已经跟不上时代不支持IPv6也不提供网卡运行状态。我建议直接上GetAdaptersAddressesVista以后的系统都支持XP打了SP1补丁也能用。4.2 GetAdaptersAddresses完整遍历流程#include winsock2.h #include iphlpapi.h #include ws2tcpip.h #pragma comment(lib, iphlpapi.lib) #pragma comment(lib, ws2_32.lib) void EnumerateAdapters(void) { ULONG bufSize 0; // 第一次调用只用来获取缓冲区大小 GetAdaptersAddresses(AF_UNSPEC, 0, NULL, NULL, bufSize); IP_ADAPTER_ADDRESSES* adapters (IP_ADAPTER_ADDRESSES*)malloc(bufSize); if (!adapters) return; DWORD ret GetAdaptersAddresses(AF_UNSPEC, 0, NULL, adapters, bufSize); if (ret ! ERROR_SUCCESS) { free(adapters); return; } for (IP_ADAPTER_ADDRESSES* p adapters; p ! NULL; p p-Next) { // 跳过回环网卡 if (p-IfType IF_TYPE_SOFTWARE_LOOPBACK) continue; // 跳过未连接/未启动的网卡 if (p-OperStatus ! IfOperStatusUp) continue; // MAC地址长度为0的基本都是隧道或虚拟设备 if (p-PhysicalAddressLength 0) continue; printf(Adapter: %ls\n, p-FriendlyName); printf( MAC: ); for (ULONG i 0; i p-PhysicalAddressLength; i) { printf(%s%02X, i ? : : , p-PhysicalAddress[i]); } printf(\n); // 遍历该网卡上的所有单播IP for (IP_ADAPTER_UNICAST_ADDRESS* ua p-FirstUnicastAddress; ua ! NULL; ua ua-Next) { SOCKADDR* sa ua-Address.lpSockaddr; if (sa-sa_family AF_INET) { char ipv4[INET_ADDRSTRLEN] {0}; inet_ntop(AF_INET, ((SOCKADDR_IN*)sa)-sin_addr, ipv4, sizeof(ipv4)); printf( IPv4: %s\n, ipv4); } else if (sa-sa_family AF_INET6) { char ipv6[INET6_ADDRSTRLEN] {0}; inet_ntop(AF_INET6, ((SOCKADDR_IN6*)sa)-sin6_addr, ipv6, sizeof(ipv6)); printf( IPv6: %s\n, ipv6); } } } free(adapters); }几个容易出错的地方第一次GetAdaptersAddresses传NULL和size只是为了拿到需要的缓冲区长度别省这一步。打印宽字符串用%ls这是C99标准写法MSVC 2015之后的版本都支持。别忘链接ws2_32.lib否则inet_ntop会报链接错误。4.3 筛选物理网卡IfType、OperStatus、OUI前缀实际机器上VirtualBox、VMware、Hyper-V、WSL都会装虚拟网卡它们一样有MAC地址、IP地址。如果授权系统里把虚拟网卡的MAC也算进去用户随便开个虚拟机指纹就变了。我的筛选策略分三层先看IfType以太网是6无线是71回环是24隧道类的基本可以过滤掉。再看OperStatus必须等于IfOperStatusUp。这同时也能帮你判断网线/无线连接状态但注意它不等于外网通不通。最后可以按MAC地址OUI前缀识别常见虚拟机实测里命中率很高虚拟化平台MAC OUI前缀VMware00:0C:29 / 00:50:56VirtualBox08:00:27Hyper-V00:15:5DKVM/QEMU52:54:00如果你的授权策略不允许虚拟机运行按前缀过滤是成本最低的方案。当然它防不住手工改MAC所以只能作为辅助判断。4.4 多IP和IPv6的问题一个网卡可能同时绑多个IPv4地址也可能有IPv6地址。如果你只需要IPv4把第一处AF_UNSPEC改成AF_INET那么FirstUnicastAddress里就只会出现IPv4地址不用在循环里判断类型。这个技巧在代码里很实用。另外p-IfIndex接口索引和p-Luid接口LUID两个字段也要记住下一节判断网线插入状态时要用。5. 判断网线是否插入链路状态不是简单查个API5.1 推荐方案GetIfEntry2与MIB_IF_ROW2查询网线是否插入本质上是问“物理链路层是否已经协商建立连接”。Vista之后的系统提供了GetIfEntry2里面有个MediaConnectState字段这个字段直接反映介质连接状态比OperStatus更贴近“网线插没插”。#include netioapi.h #include iphlpapi.h #include stdio.h #pragma comment(lib, netioapi.lib) void QueryLinkState(ULONG ifIndex) { MIB_IF_ROW2 row; memset(row, 0, sizeof(row)); row.InterfaceIndex ifIndex; DWORD err GetIfEntry2(row); if (err ! NO_ERROR) { printf(GetIfEntry2 failed, err%lu\n, err); return; } printf(OperStatus %lu\n, row.OperStatus); printf(MediaConnectState %d\n, row.MediaConnectState); // MediaConnectStateConnected 1 表示介质已连接 if (row.MediaConnectState MediaConnectStateConnected) printf(网线已插入链路已建立\n); else printf(网线未插入介质断开\n); }配合上一节的GetAdaptersAddresses把使用中的网卡IfIndex取出来挨个查一遍就行。在AF_UNSPEC模式下遍历时可以用p-IfIndex作为索引。如果系统比较新也可以优先用p-Luid填写row.InterfaceLuidLUID在Wildcard重命名和索引变化时更稳定。这里有个细节无线网卡没有网线但连接上Wi-Fi之后MediaConnectState也会返回Connected。所以如果你想判断的是“有线网卡是否插了网线”一定要先按IfType IF_TYPE_ETHERNET_CSMACD过滤只对有线网卡查这个状态。5.2 备选方案WMI的NetConnectionStatusXP和早期Server系统没有GetIfEntry2或者你的程序跑在精简版系统上可以用WMI兜底。Win32_NetworkAdapter里的NetConnectionStatus字段有明确含义值含义0已断开1正在连接2已连接7介质已断开8正在认证9认证成功判断“已连接”就是等2。但注意这个属性只在网卡“存在且被系统管理”时才有效有时候网卡被禁用或驱动异常整个属性是空的要做空值判断。wchar_t statusText[32] {0}; QueryWmiString( LSELECT NetConnectionStatus FROM Win32_NetworkAdapter WHERE NetConnectionID以太网, LNetConnectionStatus, statusText, 32);如果网卡名称是中文字符不同系统的本地化名称不一样这个查询条件很容易落空。所以我更建议先把所有Win32_NetworkAdapter都查出来在C代码里按AdapterTypeID或PhysicalAdapter字段过滤而不是在WQL里写死名称。5.3 服务权限与性能注意判断网线状态这个操作在授权逻辑里有个经典用法要求必须插着网线才能激活且网卡必须处于链路Up状态。但别把这个判断放得太频繁GetAdaptersAddresses加上GetIfEntry2一次组合调用的耗时在几毫秒到几十毫秒之间如果每秒钟轮询一次对老机器的CPU占用还是能感知到的。另外如果你把自己的工具注册成Windows服务来跑请记得服务默认没有访问WMI的权限需要在服务配置里指定账户或者改用GetIfEntry2这种纯API方案。这也是我推荐优先用API而不是WMI的原因之一。6. 组装硬件指纹序列化、哈希与容错设计6.1 指纹拼接与哈希硬件信息都拿到之后下一步是组装成授权指纹。一个比较稳妥的做法是把硬盘序列号、主板UUID、CPU ID、物理网卡MAC按固定顺序拼接然后做SHA-1或MD5哈希。这里给出用CryptoAPI计算SHA-1的例子#include windows.h #include wincrypt.h #include stdio.h #pragma comment(lib, advapi32.lib) void HashFingerprint(const char* input, unsigned char digest[20]) { HCRYPTPROV prov 0; HCRYPTHASH hash 0; DWORD len 20; CryptAcquireContext(prov, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT); CryptCreateHash(prov, CALG_SHA1, 0, 0, hash); CryptHashData(hash, (const BYTE*)input, (DWORD)strlen(input), 0); CryptGetHashParam(hash, HP_HASHVAL, digest, len, 0); CryptDestroyHash(hash); CryptReleaseContext(prov, 0); } void PrintHex(const unsigned char* data, size_t len) { for (size_t i 0; i len; i) printf(%02X, data[i]); printf(\n); } int main(void) { char raw[512] {0}; // 从前面各节拿到的字符串按固定顺序拼起来 // 拼接顺序建议固定并在文档里写明否则升级版本后指纹会变 sprintf_s(raw, sizeof(raw), %ls|%ls|%ls|%02X:%02X:%02X:%02X:%02X:%02X, LdiskSerial, LboardUuid, LcpuId, 0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E); unsigned char digest[20] {0}; HashFingerprint(raw, digest); PrintHex(digest, 20); return 0; }为什么用拼接再哈希而不是直接用原始字符串做授权码两个原因第一原始字符串太长了硬盘序列号加主板UUID加上MAC怎么也有七八十字节传输和存储不方便第二授权码不应该暴露原始硬件信息哈希之后即使被截获也没法直接还原出硬盘序列号。6.2 防伪组合建议回答CPU还是硬盘的经典问题把前面所有信息放在一张表里对比结论就很清晰了硬件标识稳定性唯一性易篡改性适合角色CPUID/ProcessorId极高不更换CPU不变差同型号重复不易改辅助因子硬盘物理序列号高重装系统不变较好不易改主指纹卷序列号低格式化就变一般可改不推荐主板UUID极高较好某些主板可改主指纹辅助MAC地址中换网卡/虚拟机会变一般驱动层可改辅助/在线校验做软件防伪我的最终组合是**硬盘物理序列号 主板UUID作为主指纹CPU ID和MAC作为辅助因子参与哈希但不参与最终唯一性判定。**这样用户重装系统、换网卡、加内存指纹都不会变只有换硬盘或者换主板时才会触发重新激活这个频率在可接受范围内。6.3 硬件变更容错N选M再稳的硬件也有可能变更比如用户从机械硬盘换成SSD。如果不做容错一次硬件升级就导致正版用户无法使用投诉率会直线上升。常见的做法是“N选M”采集5个硬件因子只要匹配3个就算相同设备。C语言实现不复杂把每个因子单独哈希授权时逐项比对命中数达到阈值就通过。我建议至少保留一个“自动重新激活”的交互入口让正版用户更换硬件后可以联系后台刷新授权而不是直接锁死。7. 实测踩坑记录虚拟机、权限、字符编码这些细节7.1 虚拟机里的指纹会漂移我在VMware里测试时发现虚拟机网卡的MAC地址默认是随机生成的重装虚拟机或者复制虚拟机以后MAC会变。硬盘序列号在VMware默认配置下也会变成一个固定的“VMware Virtual disk”序列号多台虚拟机可能一样。如果你要在虚拟机里测试软件授权逻辑记得把网卡的MAC设置成“静态”这样至少MAC和CPU ID是稳定的但要注意这只能保证开发环境可复现不能保证物理机的指纹特性。7.2 管理员权限与WMI服务依赖这套代码大多数情况下普通用户权限就能跑查CPU ID、硬盘序列号、MAC地址都不需要管理员。但有两个例外一是对某些服务器的物理盘发IOCTL_STORAGE_QUERY_PROPERTY时如果驱动实现有问题可能需要管理员二是WMI服务Winmgmt被禁用时所有QueryWmiString都会失败代码里要容忍这种失败不能因为WMI挂掉就整个程序退出。我在封装时做了一个约定**IOCTL失败就降级到WMIWMI也失败就返回一个空字符串由上层决定是否继续。**授权模块宁可多给几个候选指纹也不要因为单点故障把所有用户挡在门外。7.3 Unicode/ANSI和32位/64位差异最后说三个容易出问题的小细节。第一WMI的BSTR是UTF-16打印到控制台时建议统一转成UTF-8或者直接用wprintf。如果混用printf和wprintf输出顺序可能会异常因为两种输出流的缓冲机制不同。第二GetAdaptersAddresses在32位和64位编译下结构体布局可能有差异这不是代码错误而是系统API的设计如此。同一个程序建议分别出x86和x64两个版本别指望一个EXE通吃。第三硬盘序列号字符串内部不全是可见字符我遇到过某品牌SSD的序列号末尾有控制字符直接用strlen会截到错误位置。更稳妥的做法是先转成十六进制字符串再拼接避免原始字符干扰哈希结果。我在实际项目里把这些代码封成了三个函数GetCpuFingerprint()、GetDiskFingerprint()、GetNetworkFingerprint()再包一层GetMachineFingerprint()统一组装。每个函数都保证失败时返回空字符串而不是崩掉。这套东西放到生产环境跑了几个月最深的体会是硬件信息采集的难点从来不在API本身而在于你面对的是千奇百怪的真实机器——虚拟机、精简系统、禁用WMI、改过MAC的网卡、带控制字符的序列号。把每一种异常都想好降级策略这个模块才算真正写完。