简介:面向Windows系统编程学习者的C++源码工程包,演示如何在Windows环境下通过系统API与WMI接口读取BIOS硬件信息。资源共17个文件,包含3个.h头文件和2个.cpp源文件,另有Visual Studio解决方案与工程文件(.sln/.vcproj)、资源文件(.rc/.ico)、2个txt说明文件以及一个可直接运行的exe,压缩包整体仅66KB,结构紧凑,便于对照查看。已有510人学习该资源,适合希望在Windows系统中采集底层硬件信息的C/C++开发者参考。源码围绕设备信息集枚举、注册表读取、WMI查询等路径展开,涉及SetupDiGetClassDevs、SetupDiEnumDeviceInfo、RegOpenKeyEx及IWbemservices等系统API的使用,并覆盖错误处理、权限提升与不同BIOS兼容性等要点,能够帮助有一定C++基础的开发者快速理解底层设备交互方法,避开权限不足、API调用失败等常见陷阱。通过阅读工程结构和运行示例,可掌握从设备枚举到信息提取的完整流程,并迁移到其他系统硬件信息采集场景。
1. 为什么Windows读BIOS信息绕不开SMBIOS
很多刚接触C++的同行,以为读BIOS信息就是抓一串注册表键值,或者调个WMI类就完事。真正做过资产盘点、售后排查或者写系统信息工具的人会告诉你:Windows下读到的“BIOS版本”“序列号”“厂商”这些数据,几乎全部来自SMBIOS固件表,而注册表和WMI只是它的转发层。C++要在这条链路上拿到稳定、底层的结果,直接解析SMBIOS是最可控的方式。下面顺着这个思路,从原理到代码,把“用C++在Windows下读取BIOS信息”这件事拆开讲清楚。你会看到为什么GetSystemFirmwareTable是首选,也会看到字符串编码、结构体对齐、版本号比较这些真正消耗开发时间的细节。
2. C++读取BIOS信息的三条主流路径:SMBIOS、WMI与注册表
2.1 先弄清BIOS信息在Windows里长什么样
BIOS(或者更准确地说,UEFI/BIOS)在系统启动阶段会把一坨表结构放到内存里,里面包含厂商、版本、序列号、UUID、内存设备、CPU插座等几十种类型的记录。这套规范由DMTF维护,叫做SMBIOS(System Management BIOS)。Windows本身并不直接暴露SMBIOS原始二进制,而是通过固定的API和接口转发。你在“系统信息”对话框里看到的那些字段,底层都来自SMBIOS的Type 0(BIOS Information)、Type 1(System Information)和Type 2(Baseboard Information)。
我在写C++工具时,第一件事就是把“信息源”区分清楚,否则后面会被各种字段截断和默认值坑到。常见的信息源有三类:注册表、WMI、直接读固件表。三者各有各的适用场景,但也有明显的边界。
2.2 注册表路径与WQL查询的适用边界
注册表是最容易上手的路径,但也是最“脏”的。比如:
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\BIOS这里就有BaseBoardManufacturer、BIOSVendor、BIOSReleaseDate等值。用C++常见的做法是通过RegOpenKeyEx和RegQueryValueEx读出这些字符串。
但注册表有两个问题。第一,它只暴露了SMBIOS字段的子集,很多OEM自定义字段根本不在里面;第二,不同厂商可能在这个目录下补充自定义键值,你没法保证键名一致。更麻烦的是,某些云平台和虚拟化环境会改写这个键,导致你读到的不是物理机器自身的BIOS信息。
WMI是第二条路。查询Win32_BIOS类的代码在PowerShell里是这样:
Get-CimInstance -ClassName Win32_BIOS输出里有Manufacturer、SMBIOSBIOSVersion、SerialNumber、ReleaseDate等字段。在C++里用COM接口调用IWbemServices查询同一组WQL语句,也能拿到这些值。WMI的优点是字段语义清晰,统一了不同厂商的命名;缺点是查询本身比较重,初始化COM连接、设置代理安全级别、处理BSTR字符串,一套模板少说几十行,而且如果用户机器上的WMI服务被精简或损坏,你就得等超时。
2.3 直接调用GetSystemFirmwareTable的理由
我的建议是:如果工具只跑在Windows上,能直接用API拿到原始SMBIOS就尽量直接用。理由有三点:一是GetSystemFirmwareTable是kernel32.dll导出函数,从XP时代就有,不依赖WMI服务是否启动;二是返回的二进制格式与SMBIOS规范完全一致,你可以自己控制解析哪些字段,不会被中间层裁剪;三是对C++来说,内存布局就是结构体和指针的平移,不需要处理COM、BSTR、VARIANT那套复杂类型。下面这张表可以帮你快速做选型。
| 路径 | 数据来源 | 优点 | 缺点 |
|---|---|---|---|
| 注册表 | HARDWARE\DESCRIPTION\System\BIOS | 零依赖、无需COM | 字段少、厂商差异大 |
| WMI | Win32_BIOS 类 | 语义统一、支持远程 | COM初始化重、依赖服务 |
| API | GetSystemFirmwareTable | 原始SMBIOS、稳定 | 需自己解析字节流 |
GetSystemFirmwareTable的函数原型是:
UINT WINAPI GetSystemFirmwareTable( DWORD FirmwareTableProviderSignature, DWORD FirmwareTableID, PVOID pFirmwareTableBuffer, DWORD BufferSize );第一个参数传'RSMB',第二个参数传0,代表获取原始SMBIOS固件表。注意第一个参数是四字符编码,C++里可以直接写成'RSMB',这个字符常量会被整形成DWORD。很多初学者在这里踩坑,以为是字符串"RSMB",传了宽字符指针,导致API返回错误码87。这个函数的使用逻辑是先调用一次获取缓冲区大小,再分配内存调用第二次填充数据。线程安全一般不用担心,因为固件表在系统运行期基本不变。
3. 用C++调用GetSystemFirmwareTable解析SMBIOS的最小可运行代码
3.1 获取固件表数据的两个关键参数
先写一个能跑通的骨架:
#include <windows.h> #include <inttypes.h> #include <stdio.h> #include <vector> struct RawSMBIOSData { BYTE Used20CallingMethod; BYTE SMBIOSMajorVersion; BYTE SMBIOSMinorVersion; BYTE DmiRevision; DWORD Length; BYTE SMBIOSTableData[]; }; int main() { DWORD size = GetSystemFirmwareTable('RSMB', 0, nullptr, 0); if (size == 0) { printf("GetSystemFirmwareTable size call failed, error=%lu\n", GetLastError()); return 1; } std::vector<BYTE> buffer(size); DWORD written = GetSystemFirmwareTable('RSMB', 0, buffer.data(), size); if (written == 0) { printf("GetSystemFirmwareTable data call failed, error=%lu\n", GetLastError()); return 1; } auto* raw = reinterpret_cast<RawSMBIOSData*>(buffer.data()); printf("SMBIOS %u.%u, DMI rev %u, table length %u\n", raw->SMBIOSMajorVersion, raw->SMBIOSMinorVersion, raw->DmiRevision, raw->Length); return 0; }代码逻辑不复杂:第一次传空指针拿需要的字节数,第二次把数据填入vector。注意RawSMBIOSData末尾的SMBIOSTableData[]是零长数组,这里只用来做偏移计算,不能直接当结构体用。实际表数据是紧接着Length字段之后的BYTE流,所以后面解析时要用raw->SMBIOSTableData作为起始指针。
参数说明:'RSMB'固定表示Raw SMBIOS表,如果要读UEFI专属的ACPI RSDP则传'RSDT',但BIOS信息的主表还是RSMB。第二个参数在RSMB场景下传0,文档明确要求必须为0,传其他值由厂商决定,不要依赖。返回值是写入字节数,如果第二次传入的缓冲区比实际需求小,函数返回0并设置ERROR_INSUFFICIENT_BUFFER。还有一点容易被忽略:written可能小于size,但正常情况两者应当相等,写代码时最好用written作为实际长度。
3.2 遍历SMBIOS结构体的指针计算
SMBIOS表数据的格式是连续排列的结构体,每个结构体有固定的头部和可变的字符串区域。头部结构如下:
struct SmbiosHeader { BYTE Type; BYTE Length; BYTE Handle[2]; };遍历算法是:从表数据起始处开始,读Type和Length,然后跳过Length个字节到达字符串区,字符串区以两个连续NUL字节结尾,再往后就是下一个结构体的头。这里必须小心处理“紧凑类型”的Length。有些厂商的实现在Length之外还会带填充字段,但规范要求按Length跳过即可。字符串区结束的判断可以写成:
const BYTE* SkipStringSection(const BYTE* p) { while (p[0] != 0 || p[1] != 0) { while (*p != 0) p++; p++; } return p + 2; }内层的while (*p != 0)负责跳过单个字符串,遇到NUL后停住,再++跳到下一个字符串开头。外层循环判断连续两个NUL时,需要先让内层循环把第一个NUL后的第一个字节暴露出来。这个逻辑很容易写错成只判断单个NUL,然后循环失控。使用前一定要确保p和p+1不越过表数据末尾,否则在损坏表数据时可能直接访问非法内存。
3.3 从Type 0和Type 1中提取厂商、版本与序列号
解析实例如下:
const char* GetStringAt(const BYTE* strStart, int idx) { if (idx == 0) return ""; const BYTE* p = strStart; int current = 1; while (*p != 0) { if (current == idx) return reinterpret_cast<const char*>(p); while (*p != 0) p++; p++; current++; } return ""; } void ParseSmbios(const BYTE* tableData, size_t tableLen) { const BYTE* p = tableData; const BYTE* end = tableData + tableLen; while (p < end) { if (end - p < 4) break; auto* hdr = reinterpret_cast<const SmbiosHeader*>(p); if (hdr->Length < 4) break; const BYTE* strStart = p + hdr->Length; if (hdr->Type == 0 && hdr->Length >= 0x12) { // Type 0: BIOS Information int vendorIdx = p[0x04]; int versionIdx = p[0x05]; int dateIdx = p[0x08]; printf("BIOS Vendor: %s\n", GetStringAt(strStart, vendorIdx)); printf("BIOS Version: %s\n", GetStringAt(strStart, versionIdx)); printf("BIOS Release Date: %s\n", GetStringAt(strStart, dateIdx)); } if (hdr->Type == 1 && hdr->Length >= 0x1F) { // Type 1: System Information int manufacturerIdx = p[0x04]; int productIdx = p[0x05]; int serialIdx = p[0x07]; printf("System Manufacturer: %s\n", GetStringAt(strStart, manufacturerIdx)); printf("System Product: %s\n", GetStringAt(strStart, productIdx)); printf("System Serial: %s\n", GetStringAt(strStart, serialIdx)); } // 跳到字符串区末尾 p = SkipStringSection(strStart); } }GetStringAt实现时注意:字符串区域的索引以1为起点,索引0表示“未使用”,返回空串;idx为1返回第一个字符串,idx为2返回第二个,以此类推。这里实际上是把一个连续内存区域当成字符串数组来处理,这个“C++字符串数组初始化”的概念在SMBIOS解析中换了个形式——不是构造数组,而是切分数组。很多新手以为SMBIOS字符串区域是一个固定长度的数组,实际它是长度未知、以双NUL结尾的连续串,遍历时必须依赖外层边界。
偏移说明:Type 0的偏移0x04是Vendor、0x05是BIOS Version、0x08是Release Date;Type 1的偏移0x04是Manufacturer、0x05是Product、0x07是Serial Number。这些偏移在SMBIOS 2.0到3.5中保持稳定,但厂商自定义结构不要依赖偏移,直接按Type识别即可。上面代码里hdr->Length >= 0x12和>= 0x1F是版本兼容检查,防止老设备的表长度不够导致越界。
4. 解析SMBIOS时最容易翻车的三个字段处理细节
4.1 字符编码与C++的char数组
SMBIOS规范规定字符串区域默认使用ASCII或扩展ASCII(代码页437),而不是UTF-8,也不是UTF-16。但设备制造商经常不守规矩,有的直接塞UTF-8,有的写GBK(中文笔记本尤其常见)。C++里用char*读出来之后,要做两件事:第一,判断是否有字节高位为1(非ASCII),如果有,表示不是标准ASCII,需要根据设备型号决定转换目标;第二,绝对不要把char直接当有符号数做位运算,标准写法是(unsigned char)c,否则字节大于127时会被符号扩展成负数,导致UTF-8解码提前失败。
一个常见的折中做法是保留原始字节,只在UI层显示时用MultiByteToWideChar配合CP_UTF8或CP_ACP转换。如果你要把序列号写进日志或数据库,建议统一转成UTF-8。注意GetStringAt返回的是原始字节指针,直接printf到控制台在中文环境下会出现乱码,因为控制台代码页默认是936。要在程序启动时调用SetConsoleOutputCP(CP_UTF8),并且确保源文件保存为带BOM的UTF-8,否则中文字符串字面量也会是乱的。
4.2 版本号比较不能直接strcmp
BIOS版本号常见形如"1.32.2"、"V2.9"、"F.44",用strcmp做字典序比较会得到错误结论。比如"V2.10"和"V2.9"按字母序"V2.10"小于"V2.9",但实际版本2.10更新。
我的做法是写一个只针对数字段的比较函数:把字符串按非数字字符分割,逐段解析成整数后比较。注意有些版本号带字母前缀,比如"F.44",需要先提取字母后面的数字段。在C++里可以这样写:
#include <vector> #include <string> #include <cctype> #include <algorithm> std::vector<int> SplitVersion(const std::string& v) { std::vector<int> parts; size_t i = 0; while (i < v.size()) { if (isdigit(static_cast<unsigned char>(v[i]))) { int num = 0; while (i < v.size() && isdigit(static_cast<unsigned char>(v[i]))) { num = num * 10 + (v[i] - '0'); i++; } parts.push_back(num); } else { i++; } } return parts; } int CompareVersion(const std::string& a, const std::string& b) { auto pa = SplitVersion(a); auto pb = SplitVersion(b); size_t n = std::max(pa.size(), pb.size()); for (size_t i = 0; i < n; i++) { int xa = i < pa.size() ? pa[i] : 0; int xb = i < pb.size() ? pb[i] : 0; if (xa != xb) return xa < xb ? -1 : 1; } return 0; }这个函数不处理日期型版本(比如"2023/05/01"那种),因为日期型直接用字符串字典序就是对的,没必要混在一起。实际使用中还要处理前后缀,比如"V2.9"和"v2.10"大小写不一致,需要先统一转小写。另外,某些厂商的版本号里包含空格,比如"1.15.5 Rev.A",SplitVersion会把"15"和"5"提取出来,但如果"Rev.A"里的"A"也参与比较则会忽略,这通常是符合预期的。如果你要对一组机器的BIOS版本做排序或二分查找,必须使用这种数值化版本比较,否则排序和查找结果都和用户预期不符。
4.3 双字节结构体对齐与32位/64位差异
SMBIOS结构体在数据传输时是紧凑的,没有对齐填充。但当你把结构体强转成C++ struct时,编译器可能会按默认对齐方式插入padding字节,导致字段偏移错位。比如SmbiosHeader里BYTE Handle[2],如果定义成BYTE Handle[2]就没问题,但如果定义成WORD Handle,在32位和64位下都可能产生额外对齐,因为WORD对齐要求是2,紧跟在两个BYTE后本来也满足,但如果有三个BYTE字段再加WORD,就会插入1字节padding。
解决办法是使用#pragma pack(push, 1)包裹所有SMBIOS相关结构体定义,或者在读取字段时直接用原始指针偏移,不依赖结构体布局。我在生产代码里倾向后者,因为厂商自定义结构体太多,pack pragma只能保护你自己的定义,保护不了厂商的怪癖。还要注意32位和64位进程在解析时没有本质区别,因为GetSystemFirmwareTable返回的是BYTE数组,指针大小不影响字节偏移。唯一有差别的是你在做reinterpret_cast时,保证指针按1字节对齐即可——BYTE数组本来就是1字节对齐的,所以可以安全地把const BYTE*转成含packed结构体的指针。
一个更隐蔽的问题是SMBIOS 3.0之后出现了Entry Point结构,部分设备使用64位入口表,GetSystemFirmwareTable返回的数据在raw->SMBIOSTableData之前是8字节的RawSMBIOSData头,这个头在SMBIOS 3.0下Length字段可能大于64位地址范围,但Windows API已经处理好了,你不需要自解析入口表。按照头+数据的方式处理即可。
5. 用WMI交叉验证:用C++调用IWbemServices确认解析结果
5.1 WMI查询Win32_BIOS的CLSID与IID
如果你不放心自己的SMBIOS解析,最有效的验证手段不是拿另一台机器试,而是用WMI或PowerShell把同一组字段读出来对比。WMI的COM接口需要引入以下头文件:
#include <comdef.h> #include <Wbemidl.h> #pragma comment(lib, "wbemuuid.lib")初始化COM后创建WbemLocator实例:
CoInitializeEx(nullptr, COINIT_MULTITHREADED); CoInitializeSecurity(nullptr, -1, nullptr, nullptr, RPC_C_AUTHN_LEVEL_DEFAULT, RPC_C_IMP_LEVEL_IMPERSONATE, nullptr, EOAC_NONE, nullptr); IWbemLocator* pLoc = nullptr; HRESULT hr = CoCreateInstance(CLSID_WbemLocator, nullptr, CLSCTX_INPROC_SERVER, IID_IWbemLocator, (void**)&pLoc); if (FAILED(hr)) return; IWbemServices* pSvc = nullptr; BSTR resource = SysAllocString(L"ROOT\\CIMV2"); hr = pLoc->ConnectServer(resource, nullptr, nullptr, nullptr, 0, nullptr, nullptr, &pSvc); SysFreeString(resource); if (FAILED(hr)) return; BSTR wql = SysAllocString(L"SELECT Manufacturer, SMBIOSBIOSVersion, SerialNumber, ReleaseDate FROM Win32_BIOS"); BSTR name = SysAllocString(L"WQL"); IEnumWbemClassObject* pEnum = nullptr; hr = pSvc->ExecQuery(name, wql, WBEM_FLAG_FORWARD_ONLY, nullptr, &pEnum); SysFreeString(wql); SysFreeString(name);5.2 在C++工程里引入WMI的链接参数
常规设置是在Visual Studio里给项目添加wbemuuid.lib和ole32.lib。如果你不想改工程文件,可以在代码里用#pragma comment(lib, "wbemuuid.lib"),用CoInitializeEx替代旧的CoInitialize,避免在MFC项目里产生初始化冲突。查询结果用IEnumWbemClassObject遍历,注意返回的VARIANT类型一般是VT_BSTR。比较坑的是ReleaseDate在WMI里是字符串(格式如"20231012000000.000000+000"),而SMBIOS原始表里是纯字符表示"10/12/2023",两者需要自己转换格式后比较。
5.3 一个可下班的校验技巧:用powershell对照输出
最后分享一个我常用的零代码校验技巧:在写C++程序之前,先跑一条PowerShell命令,把目标机器的“标准答案”记录下来。
Get-CimInstance Win32_BIOS | Select-Object Manufacturer, SMBIOSBIOSVersion, SerialNumber | Format-List然后用C++程序输出同样的三个字段,人工对比。如果一致,再进入自动化对比脚本。注意,戴尔和联想这类机器上,SerialNumber里可能有非ASCII字符,PowerShell控制台会以系统UTF-16编码显示,而C++直接把char数组打印到控制台时可能变成乱码,这时要用SetConsoleOutputCP(CP_UTF8),并把vs项目字符集设为“使用Unicode字符集”。这个技巧的另一个用途是调试“BIOS更新后读不到新版本”的情况。有些设备在系统未重启前,固件表里缓存的版本还是旧值,WMI也一样。这时别急着怀疑代码,先确认Windows是否加载了新的固件信息,检查bcdedit /enum {current}里的firmwareboot状态,或者干脆要求重启后再验证。
本文还有配套的精品资源,点击获取