C++解析SMBIOS:获取BIOS信息的关键技术 简介面向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维护叫做SMBIOSSystem Management BIOS。Windows本身并不直接暴露SMBIOS原始二进制而是通过固定的API和接口转发。你在“系统信息”对话框里看到的那些字段底层都来自SMBIOS的Type 0BIOS Information、Type 1System Information和Type 2Baseboard 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字段少、厂商差异大WMIWin32_BIOS 类语义统一、支持远程COM初始化重、依赖服务APIGetSystemFirmwareTable原始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::vectorBYTE 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_castRawSMBIOSData*(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和p1不越过表数据末尾否则在损坏表数据时可能直接访问非法内存。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_castconst 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_castconst 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 DateType 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 版本号比较不能直接strcmpBIOS版本号常见形如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::vectorint SplitVersion(const std::string v) { std::vectorint parts; size_t i 0; while (i v.size()) { if (isdigit(static_castunsigned char(v[i]))) { int num 0; while (i v.size() isdigit(static_castunsigned 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.ASplitVersion会把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(LROOT\\CIMV2); hr pLoc-ConnectServer(resource, nullptr, nullptr, nullptr, 0, nullptr, nullptr, pSvc); SysFreeString(resource); if (FAILED(hr)) return; BSTR wql SysAllocString(LSELECT Manufacturer, SMBIOSBIOSVersion, SerialNumber, ReleaseDate FROM Win32_BIOS); BSTR name SysAllocString(LWQL); 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.000000000而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状态或者干脆要求重启后再验证。本文还有配套的精品资源点击获取