C++读取Windows BIOS信息:WMI与SMBIOS固件表解析实战
简介这套面向Windows系统编程的C示例工程专为解决开发者从系统底层获取BIOS版本、厂商与序列号信息的需求而设计适合需要枚举硬件设备、理解系统API调用与WMI查询的进阶学习者也可用于硬件检测工具或系统信息软件的开发参考。压缩包共17个文件以h头文件与cpp源码为核心辅以vcproj/sln工程配置、rc资源脚本、ico图标、txt说明以及可直接运行的exe程序整体仅66KB结构精简。代码覆盖设备接口类识别、SetupDiGetClassDevs创建设备信息集、SetupDiEnumDeviceInfo遍历设备、设备信息集与设备信息数据解析、注册表HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\BIOS键值读取以及通过COM组件访问WMI服务等多条实现路径同时包含错误处理、管理员权限提醒和不同BIOS供应商的兼容性适配思路方便直接编译学习或移植到实际项目中。已有510人学习下载可作为C系统编程与硬件信息获取的工程参考。1. 为什么要在 C 里读 Windows 的 BIOS 信息同型号的一批终端驱动包按 BIOS 版本分流资产管理系统要采集固件版本号判断是否低于安全基线装机工具要根据系统序列号回填资产台账。这些场景都得在 Windows 里把 BIOS 信息读出来可 Windows 并没有一个开箱即用的 “GetBiosInfo” API网上搜“BIOS”又全是进 BIOS 设置界面调 U 盘的图解。真正能落到代码里的只有两条路WMI 查询 Win32_BIOS以及直接用 GetSystemFirmwareTable 读 SMBIOS 原始固件表。前者封装好了字段后者能拿到 Type 0、Type 1 等全部结构连内存条、TPM 设备都能读出来。这篇文章把两条路的最小可编译实现、参数设置和上线前要避开的坑一次讲完适合写系统工具、做运维平台和装机助手的人5 年以上经验的工程师也能在固件表解析和版本比较部分看到新东西。2. 三条读取路径怎么选注册表、WMI 与 SMBIOS 固件表2.1 注册表、WMI、固件表各自能拿到什么先看开发者最常走的注册表。HKLM\HARDWARE\DESCRIPTION\System\BIOS 下有几个键BIOSVersion、BIOSReleaseDate、SystemManufacturer、SystemProductName遍历一下就能显示代码量很小。问题是这些键是 Windows 启动时由硬件抽象层写入的静态快照字段数量少而且没有系统 UUID、BIOS 里嵌的序列号。部分 OEM 机型这些键还是空的拿它做资产台账不合适做“读出来看一眼”的小工具倒可以。WMI 是生产环境里用得最多的路径。Win32_BIOS 类提供 Manufacturer、SMBIOSBIOSVersion、ReleaseDate、SerialNumber、SMBIOSMajorVersion、SMBIOSMinorVersion、BIOSCharacteristics 等字段另一个类 Win32_ComputerSystemProduct 能拿 UUID。WMI 优势是字段语义已经被 CIM 提供程序整理过日期、版本号都格式化好了不用自己拼结构体。缺点是依赖 winmgmt 服务第一次调用有几百毫秒延迟而且它暴露的只是固件表中的一部分字段。SMBIOS 固件表是最底层的路径。DMTF 标准将 BIOS 信息组织成一张表每条记录叫一个 Structure按 Type 区分Type 0 是 BIOS 信息Type 1 是系统信息Type 2 是主板Type 3 是机箱Type 17 是内存设备Type 43 是 TPM 设备。Windows 提供 GetSystemFirmwareTable(RSMB, ...) 返回这段内存拷贝拿到之后自己解析。这条路信息最全、最原始但要自己处理结构体对齐和字符串区适合做深度采集和带内资产管理。路径数据来源覆盖范围主要坑注册表HKLM\HARDWARE\DESCRIPTION\System\BIOS少量键值缺 UUID 和序列号静态快照OEM 字段可能为空WMIWin32_BIOS / Win32_ComputerSystemProduct常用字段齐全、语义清晰依赖 winmgmt 服务COM 初始化繁琐SMBIOS 固件表GetSystemFirmwareTable(RSMB)全部 Type原始未过滤需自行解析结构、字符串区、对齐2.2 我一般怎么选先 WMI缺字段再回退固件表如果是写一个“给运维用的小工具”我第一版直接走 WMI因为它能在 30 行内拿到 BIOS 厂商、版本、发布日期、系统序列号而且不同厂商的数据一致性由提供程序兜底不用处理奇奇怪怪的字节序。但等到要采集内存条序列号、确认主板型号、判断 TPM 版本时WMI 就没有对应属性了这时候才值得解析 SMBIOS 固件表。实际项目里我用的组合是默认走 Win32_BIOS 和 Win32_ComputerSystemProduct发现 SerialNumber 或 UUID 字段为空时自动降级调用固件表解析把缺失字段补上。这样既保住 WMI 的便利又不丢底层数据。这条组合策略在带外管理的场景下还有个好处SMBIOS 数据不依赖操作系统网络栈杀毒软件也不会拦截它只要进程有权限读固件表就能取到。而 WMI 查询在某些精简版系统上可能因为提供程序被裁掉而直接失败。因此工具类命令行我倾向直接暴露一个“强制读取固件表”的开关方便在 WMI 异常时手动兜底。2.3 前置准备在 vscode 配置 C/C 环境之外要确认的工程属性这篇的代码在 Visual Studio 2019/2022 的 MSVC 工程里验证过。网上讲 vscode 配置 C/C 环境的教程很多用 VS Code 写也可以但两个地方容易踩空一是 WMI 部分需要 Wbemidl.h 和 wbemuuid.libMinGW 的头文件路径未必带全二是结构体对齐必须显式声明。我的建议是 WMI 例子用 MSVC纯固件表解析可以拿 g 编反正只依赖 Windows SDK 的 GetSystemFirmwareTable。建立项目时手动确认三件事C 语言标准选 C17字符集不用改我们显式转 UTF-8链接器额外依赖里加 wbemuuid.lib。COM 接口还要在代码里调用 CoInitializeEx 和 CoInitializeSecurity这两个不是编译器配置能替代的少一个就会出现 0x80070005 之类看不懂的返回码。环境弄好后先跑一个空壳 main 验证 COM 能初始化再加 WMI 查询代码定位问题会容易很多。3. 用 C 走 WMI 查询 Win32_BIOS 的完整步骤3.1 初始化 COM 与 WMI 连接WMI 的 COM 调用链路是CoInitializeEx 初始化线程 COM 环境CoInitializeSecurity 设置安全级别CoCreateInstance 创建 WbemLocator再调用 ConnectServer 连接 ROOT\CIMV2 命名空间。下面是能直接放进 main 函数的基础代码。#include windows.h #include comdef.h #include Wbemidl.h #include iostream #pragma comment(lib, wbemuuid.lib) static std::string ToUtf8(const wchar_t* wstr) { int size WideCharToMultiByte(CP_UTF8, 0, wstr, -1, nullptr, 0, nullptr, nullptr); std::string out(size - 1, 0); WideCharToMultiByte(CP_UTF8, 0, wstr, -1, out.data(), size, nullptr, nullptr); return out; } int main() { HRESULT hr CoInitializeEx(nullptr, COINIT_MULTITHREADED); if (FAILED(hr)) return 1; hr CoInitializeSecurity(nullptr, -1, nullptr, nullptr, RPC_C_AUTHN_LEVEL_DEFAULT, RPC_C_IMP_LEVEL_IMPERSONATE, nullptr, EOAC_NONE, nullptr); IWbemLocator* pLoc nullptr; hr CoCreateInstance(CLSID_WbemLocator, nullptr, CLSCTX_INPROC_SERVER, IID_IWbemLocator, (void**)pLoc); IWbemServices* pSvc nullptr; hr pLoc-ConnectServer(_bstr_t(LROOT\\CIMV2), nullptr, nullptr, nullptr, 0, nullptr, nullptr, pSvc); if (FAILED(hr)) { std::cerr ConnectServer failed: 0x std::hex hr std::endl; return 1; } // 后续查询代码依次往下加 pSvc-Release(); pLoc-Release(); CoUninitialize(); return 0; }这段代码里最容易被忽略的是 CoInitializeSecurity 的 IMP_LEVEL_IMPERSONATE。WMI 查询本机数据时这个模拟级别允许 RPC 以当前进程身份访问远程对象改成 IDENTIFY 或省略不调会在 ExecQuery 阶段收到 0x80070005。另外 COINIT_MULTITHREADED 适合我们自己控制线程的进程如果你这段代码要嵌进别人的 DLLCOM 初始化最好交给调用线程避免和已有线程模型冲突。项目只跑本机查询时CLSCTX_INPROC_SERVER 就够了不需要远程 DCOM。3.2 执行查询并解析 VARIANT连上命名空间后用 IWbemServices::ExecQuery 执行 WQL 语句拿到结果枚举器 IEnumWbemClassObject然后循环取每条 IWbemClassObject。读取字段时通过对象上的 Get 方法放入 VARIANT再统一转换成 VT_BSTR 输出。这里直接给一个可用的字段打印函数和主循环。static void PrintField(IWbemClassObject* obj, const wchar_t* name) { VARIANT vt; VariantInit(vt); if (SUCCEEDED(obj-Get(name, 0, vt, nullptr, nullptr))) { VARIANT conv; VariantInit(conv); if (SUCCEEDED(VariantChangeType(conv, vt, 0, VT_BSTR))) { printf(%s: %s\n, ToUtf8(name).c_str(), ToUtf8(conv.bstrVal).c_str()); VariantClear(conv); } VariantClear(vt); } } IEnumWbemClassObject* pEnum nullptr; hr pSvc-ExecQuery(_bstr_t(LWQL), _bstr_t(LSELECT * FROM Win32_BIOS), WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY, nullptr, pEnum); IWbemClassObject* pObj nullptr; ULONG count 0; while (SUCCEEDED(pEnum-Next(WBEM_INFINITE, 1, pObj, count)) count) { PrintField(pObj, LManufacturer); PrintField(pObj, LSMBIOSBIOSVersion); PrintField(pObj, LReleaseDate); PrintField(pObj, LSerialNumber); pObj-Release(); pObj nullptr; }这段有几个参数值得单独说。ExecQuery 的第一个参数固定传 WQL第二个是查询语句这里用SELECT *把整行都取回来字段少时也可以只写需要的列名性能差别不大。WBEM_FLAG_FORWARD_ONLY 表示结果集只能向前遍历内存占用最低WBEM_FLAG_RETURN_IMMEDIATELY 则让查询异步返回不阻塞调用线程配合后面的 WBEM_INFINITE 等待枚举器取数据。VariantChangeType 统一转成 BSTR 是省事做法ReleaseDate 实际返回的是字符串不做转换直接拿 bstrVal 也可以但统一转换能避免 VARIANT 里存了 uint32 类型的字段输出乱码。这个查询只会返回一条记录Win32_BIOS 是单例类。SerialNumber 字段在部分 OEM 机型上为空这不是程序问题是厂家没往 SMBIOS 里写。遇到这种情况就得走下一章固件表解析从 Type 1 里直接取原始字段。3.3 WMI 常见返回码与排查顺序WMI 查询失败时返回的 HRESULT 能省一半排查时间。我把项目里真正遇到过的汇总成表按出现频率排序。错误码含义排查方向0x80041002WBEM_E_NOT_FOUND类名或命名空间写错确认 ROOT\CIMV20x80041003WBEM_E_ACCESS_DENIED权限不足程序是否以管理员身份运行0x80070005E_ACCESSDENIEDDCOM 安全配置问题检查 CoInitializeSecurity0x80041010WBEM_E_PROVIDER_NOT_FOUND提供程序损坏执行 winmgmt /verifyrepository0x80040154CLASS_E_CLASSNOTAVAILABLEWbemLocator 组件未注册常见于精简系统看到 0x80041010 不要急着重装系统先跑一句winmgmt /verifyrepository看 WMI 仓库状态一般winmgmt /salvagerepository能恢复。进程权限问题则要区分“当前用户是管理员”和“进程以管理员权限运行”UAC 弹窗没点是拿不到 0x80041003 排除资格的。COM 初始化导致的 0x80070005 最隐蔽因为它提示的是访问被拒实际是安全设置顺序问题必须确保 CoInitializeSecurity 在第一次远程调用之前执行。4. 绕过 WMI用 GetSystemFirmwareTable 解析 SMBIOS Type 0 与 Type 14.1 RAWSMBIOSData 结构与内存布局GetSystemFirmwareTable 是 Windows 提供的直接读取固件表的内核接口签名参数传 RSMB第二个参数对 SMBIOS 必须固定传 0。第一次传空缓冲区拿到长度分配好内存后再调一次拿到数据。数据结构最前面是版本信息和一个 Length 字段真正的 SMBIOS 记录紧跟在后面。#include windows.h #include vector #include cstring #include cstdio struct RawSMBIOSData { BYTE Used20CallingMethod; BYTE SMBIOSMajorVersion; BYTE SMBIOSMinorVersion; BYTE DmiRevision; DWORD Length; // SMBIOS 表数据总长度 BYTE SMBIOSTableData[]; // 结构体数组从这里开始 }; DWORD ReadSMBIOS(std::vectorBYTE out) { DWORD size GetSystemFirmwareTable(RSMB, 0, nullptr, 0); if (size 0) return GetLastError(); out.resize(size); DWORD written GetSystemFirmwareTable(RSMB, 0, out.data(), size); if (written ! size) { out.clear(); return ERROR_INVALID_DATA; // 两次读取长度不一致重试一次 } return ERROR_SUCCESS; }注意 Length 字段记录的是从 SMBIOSTableData 开始到表结束的字节数不包含前面那 4 个头部结构体。因此遍历时要以>#pragma pack(push, 1) struct SMBIOSHeader { BYTE Type; BYTE Length; WORD Handle; }; #pragma pack(pop) std::string GetString(const BYTE* area, size_t maxLen, BYTE index) { if (index 0) return {}; const BYTE* p area; size_t remain maxLen; BYTE current 1; while (remain 0 current index) { while (remain 0 *p ! 0) { p; --remain; } if (remain 0) return {}; p; --remain; current; } return std::string(reinterpret_castconst char*(p)); } const BYTE* SkipStringArea(const BYTE* p, size_t remain, size_t consumed) { size_t offset 0; while (offset remain) { const char* str reinterpret_castconst char*(p offset); size_t len strlen(str); offset len 1; if (offset remain) break; if (*(p offset) 0) { offset 1; break; } } consumed offset; return p offset; }GetString 里的 index 对应结构体数据区里的字符串序号序号从 1 开始0 表示该字段为空。字符串区本身没有长度字段只能靠连续两个 \0 判断结束这也是解析 SMBIOS 最容易崩的地方——碰上固件厂商格式不规范strlen 可能越界。因此所有字符串操作都要带上 maxLen 做边界保护。结构体头部必须用 #pragma pack(1) 取消默认对齐否则 sizeof(SMBIOSHeader) 会变成 8 而不是 4整个遍历就会错位。主循环遍历时用p hdr-Length定位字符串区用 SkipStringArea 返回的下一条记录地址更新 p直到 p 超过终点或遇到 Type 127 表结束标记。RawSMBIOSData* data reinterpret_castRawSMBIOSData*(buffer.data()); const BYTE* p >if (hdr-Type 0) { const BYTE* info p sizeof(SMBIOSHeader); std::string vendor GetString(p hdr-Length, end - (p hdr-Length), info[0]); std::string version GetString(p hdr-Length, end - (p hdr-Length), info[1]); std::string date GetString(p hdr-Length, end - (p hdr-Length), info[4]); BYTE romSize info[5]; // 单位 64KB0xFF 表示未填 printf(BIOS Vendor: %s\n, vendor.c_str()); printf(BIOS Version: %s\n, version.c_str()); printf(Release Date: %s\n, date.c_str()); printf(ROM Size: %u MB\n, (romSize 0xFF) ? 0 : romSize * 64 / 1024); }Type 1 记录系统信息包括厂家、产品名、序列号和 UUID。序列号在数据区第 4 个字节也就是 info[3]UUID 从数据区第 5 个字节开始占 16 字节。UUID 的字节序在不同固件实现里不一样前三个字段小端存储后两个字段大端输出时需要手动调换字节序否则同一台机器在 Linux 和 Windows 下看到的 UUID 不一致。if (hdr-Type 1) { const BYTE* sinfo p sizeof(SMBIOSHeader); const BYTE* stringArea p hdr-Length; size_t remain end - stringArea; std::string manufacturer GetString(stringArea, remain, sinfo[0]); std::string product GetString(stringArea, remain, sinfo[1]); std::string serial GetString(stringArea, remain, sinfo[3]); const BYTE* u sinfo 4; char uuid[37]; snprintf(uuid, sizeof(uuid), %02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X, u[3], u[2], u[1], u[0], u[5], u[4], u[7], u[6], u[8], u[9], u[10], u[11], u[12], u[13], u[14], u[15]); printf(System Manufacturer: %s\n, manufacturer.c_str()); printf(Product Name: %s\n, product.c_str()); printf(Serial Number: %s\n, serial.c_str()); printf(UUID: %s\n, uuid); }Secure Boot 的开启状态不在这两个 Type 里Type 0 的 BIOS Characteristics 只描述传统 BIOS 特性Secure Boot 属于 UEFI 运行时变量。要查它得用 GetFirmwareEnvironmentVariable 或直接调 OPMI别指望 SMBIOS 能给你这个答案。UUID 虽然规范定义了字节序但戴尔和联想的笔记本都曾出现过差异作为资产唯一标识前要多台设备交叉验证。4.4 验证解析结果与 WMI 和 PowerShell 双端对照固件表解析最容易出现“代码没报错但字段错位”的问题我的习惯是把 WMI 的结果作为参照物做双端对照。下面这张表列出了 SMBIOS 字段和 WMI 属性的对应关系任何一项对不上都要回头查偏移量。SMBIOS 字段WMI 对应属性验证命令Type 0 VendorWin32_BIOS.Manufacturerwmic bios get ManufacturerType 0 VersionWin32_BIOS.SMBIOSBIOSVersionwmic bios get SMBIOSBIOSVersionType 0 Release DateWin32_BIOS.ReleaseDatewmic bios get ReleaseDateType 1 Serial NumberWin32_BIOS.SerialNumberwmic bios get SerialNumberType 1 UUIDWin32_ComputerSystemProduct.UUIDwmic csproduct get UUIDwmic 在 Windows 11 里已经被弃用新系统改用 PowerShell 的Get-CimInstance Win32_BIOS也一样。对照时注意 ReleaseDate 两边的格式WMI 返回的是 YYYYMMDD 的字符串固件表里可能是带斜杠的原生日期做系统间同步时要先统一格式。UUID 的字节序问题在这个阶段最容易暴露两边不一致且采用上面混合序输出仍然对不上就把原始 16 字节用 hexdump 打出来和 Type 1 标准对照看厂商用的是全小端还是全大端。5. BIOS 版本号比较的四个边界处理5.1 厂商版本号不是数字不能直接 strcmp拿到 BIOS 版本不是为了存库而是要和厂商支持矩阵、漏洞公告里的“最低修复版本”做比较。麻烦的是厂商的版本命名五花八门联想常见N22ET25W (1.10)戴尔是1.14.0还有直接在版本字段写日期的2023/09/12。直接 memcmp 会把1.9.0排到1.10.0后面判断基线时误判层出不穷。我在项目里的处理办法是先做“主版本字段提取”遇到括号取括号内第一段遇到日期串转成 8 位整数再进入自然版本比较。5.2 实现自然版本比较函数自然版本比较的思路是把字符串里的数字段提取出来逐段比较非数字字符当作分隔符跳过。下面这个实现能解决 90% 的厂商格式差异。int CompareVersion(const std::string a, const std::string b) { size_t i 0, j 0; while (i a.size() || j b.size()) { int da 0, db 0; while (i a.size() isdigit(static_castunsigned char(a[i]))) { da da * 10 (a[i] - 0); i; } while (j b.size() isdigit(static_castunsigned char(b[j]))) { db db * 10 (b[j] - 0); j; } if (da ! db) return da db ? -1 : 1; while (i a.size() !isdigit(static_castunsigned char(a[i]))) i; while (j b.size() !isdigit(static_castunsigned char(b[j]))) j; } return 0; }这个函数对1.9和1.10的判断结果是 -1符合自然语义。对1.9A和1.9B它会返回 0因为字母段被跳过了如果固件版本靠字母区分必须先把字母段也纳入比较。边界处理上加一个前处理查表提取括号内版本、去掉开头的 OEM 前缀再传给这个函数。版本样例提取规则与基线比较示例N22ET25W (1.10)取括号内第一段1.10 vs 1.9 → 高1.14.0直接比较1.14 vs 1.9 → 高20230912转成整数 YYYYMMDD20230912 vs 20230101 → 新5.3 给程序加一个自检入口我把上面这套逻辑封装成--check子命令接受一个最低版本参数退出码返回 0 或 1这样就能直接下沉到批量升级脚本里。命令设计成biosinfo --check 1.10 --vendor N22ET25W内部优先用固件表读 Type 0 的 Version再走自然版本比较。上线前我会用三种样本各跑一遍高于基线、等于基线、低于基线确认比较方向没错位。这个自检入口同时解决一个隐藏问题——验证让代码在目标机器上能读到固件表而不是静默返回空字符串。如果输出为空程序要明确报错并提示检查权限或操作系统版本。5.4 上报资产台账前再校验一次编码固件表里的字符串在规范里是 ASCII但个别厂商往字符串区塞了 UTF-8 甚至是 GBK 的中文描述直接入库会乱码。我的处理是在 GetString 返回值里逐字节过滤掉 0x80 以上的高位字符只保留可打印 ASCII宁可丢字段也不让脏数据污染资产库。WMI 路径返回的 BSTR 则统一用 WideCharToMultiByte 转 UTF-8 再入库和固件表数据保持同一套编码。最后对照 Windows 事件日志里的固件更新事件凡是版本号比上次采集还低的记录直接标红避免固件回退被误判成正常变更。这套校验做完BIOS 信息从采集到落库就闭环了不用在每个场景里重复踩编码和字节序的坑。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →