裸金属整机自检,UEFI 下手写 21 项测试:从硬启动到一键报告的实现过程
先交代一下背景,省得你误会我写了个什么大工程。我做的是服务器和测试机的硬件排障,日常状态就是:机器送过来,点不亮,或者能亮但进系统就死机、重启、报错。传统排查路径无非是换内存、换盘、换板子,一套组合拳打下来,运气好半小时定位,运气不好折腾一整天。而且有个根本性痛点始终绕不过去——操作系统起不来的时候,你手里那堆 Linux 下的诊断工具全是废的。
裸金属服务器、无头工作站、机房角落里摸了几年灰的测试机,这类场景下最缺的不是跑分工具,而是一个能在系统之前启动、能覆盖绝大多数硬件、能让人一眼看懂哪坏了、能留证据给后续 RMA 的整机自检工具。我把能搜到的开源方案都试了一圈,要么只测内存,要么只测存储,要么是命令行输出让人满屏找光标,要么压根不支持 UEFI 启动。于是我心一横,用 EDK2 写了这个 UEFI 引导的小工具:21 项硬件测试,全程图形化界面,测完一键导出报告。这篇文章把我的完整设计思路、实现细节和踩过的坑都摊开讲清楚,给同样被裸金属故障逼疯的人一个可复现的参考。
这里说清楚,我不是要重新发明 memtest86 或者 OCCT,我要解决的是"工程师带着 U 盘去现场,插上启动,全自动测试,拔下 U 盘带走报告"这个完整闭环。下面从为什么会有这个工具讲起,再到 21 项测试的设计逻辑、可视化的实现路线、报告导出的细节,最后是真实机器上遇到的一堆坑。全程没有一行废话。
1. 裸金属排障的真空地带:操作系统起不来时你还能靠什么
1.1 现有工具的真实能力上限
先说个反直觉的事实:我们在日常运维里最依赖的故障定位手段,在裸金属硬件出问题的那一刻,基本全部失效。
Linux 下的dmesg、smartctl、memtester、stress-ng这些工具很强大,但前提是系统能起来。硬件故障的典型表现恰恰是系统起不来、起来就崩、跑一下就重启。你连 shell 都进不去,上面这些工具就是镜中花。ipmitool这类带外管理工具倒是能绕过操作系统,但它要求机器配了 BMC,而且 BMC 本身的状态、固件版本、网络配置又是一层变量。小机房、实验室、个人工作站,哪来那么多带外管理。
U 盘启动的 memtest86+ 确实经典,但它只测内存。硬件故障可不止内存一种:PCIe 设备枚举失败、NVMe 盘掉盘、SATA 线接触不良、网卡固件异常、RTC 电池没电、USB 控制器数据异常……这些问题 memtest 一个都不管。
再往上看,商用方案有 PC-Doctor 这类企业级诊断工具,功能全、服务好,但按节点授权收费,对小团队和独立工程师来说不是一个可随手带走的工具。
所以现实是:硬件级排障这个场景,卡在一个很尴尬的真空地带。商用工具太贵,开源工具太偏科,系统工具在关键时刻偏偏用不上。我需要的是一个专门的 UEFI 应用——赶在操作系统加载之前,直接把硬件底层按个遍。
1.2 我为什么决定写一个 UEFI 级自检工具
这个想法形成于一次惨痛经历。那天手上有三台同一批次的机器,症状一模一样:启动过程随机死机,有时候 grub 菜单都出不来,有时候能进系统跑十几分钟然后黑屏。由于是偶发问题,常规替换法效率极低。我搬出 memtest86+ 打算先排除内存,跑了一夜,全过。又用两口锅互相换着测,弄了一上午,毫无进展。折腾到傍晚才发现,是其中一台机器的 PCIe 转接卡在高温下间歇性接触不良,而它恰好插着那张引起整个拓扑异常的 GPU。
如果当时有一个工具,能在 UEFI 阶段就遍历总线枚举、对每个 PCIe 设备做读写验证、跑完硬件压力测试后直观给出报告,我根本不需要用半天时间去猜。那次之后我开始认真调研 UEFI 应用开发。EDK2 和 gnu-efi 都是成熟方案,UEFI 规范本身提供了大量的底层协议接口——GOP 负责图形输出,Block I/O 负责读块设备,PCI I/O 负责总线访问,SMI、RTC 等都有对应的协议入口。这意味着,我可以在没有任何操作系统依赖的前提下,直接跟硬件面对面。
工具的核心目标就三条:覆盖广、可视化、可留痕。覆盖广对应 21 项测试,可视化对应图形界面和进度条,可留痕对应一键导出的报告文件。这三条,刚好补上整个排障链路上最缺的那一块。
2. 21 项测试的设计逻辑:不是把能测的测一遍,而是把该测的测全
2.1 测试项的分层与优先级
设计测试清单时,我给自己定了两个原则。第一,每项测试必须能明确指向一类硬件资源,不搞模糊的"整体稳定性测试"。第二,测试要分优先级,快速定位类测试在前,耗时压力类测试在后,避免用户为了排除最简单的故障被迫等待最久的测试。
最终 21 项测试分成三个梯队:
| 梯队 | 测试重点 | 项目数 | 单项目预计耗时 | 定位目标 |
|---|---|---|---|---|
| 基础枚举层 | CPU 信息、内存容量、PCIe 设备拓扑、存储设备识别、USB 控制器枚举 | 6 项 | < 5 秒/项 | 硬件是否被 UEFI 正确识别 |
| 功能验证层 | RTC 读写、串口回环、网卡寄存器读写、视频输出模式切换、风扇测速通道、电源状态读取 | 7 项 | < 30 秒/项 | 硬件是否能完成基本功能操作 |
| 压力可靠性层 | 内存读写压力、存储读写校验、PCIe 带宽、USB 数据回环、CPU 长时间负载、系统复位循环 | 8 项 | 数分钟到数小时 | 是否存在间歇性故障和过热降频 |
先说基础枚举层的定位。这一层的目的不是判断硬件"好不好",而是判断硬件"在不在"。很多故障的第一现场其实是设备缺位——内存松了、转接卡松了、盘掉了。UEFI 固件在启动过程中已经做过一次设备扫描,我们要做的是把这些信息完整地拉出来展示,配合高亮标注,让用户一眼看到哪个槽位缺设备。
功能验证层解决的是"在不一定好"的问题。设备在枚举列表里好好躺着,不代表它工作正常。典型例子是 RTC:设备在,但电池没电,每次断电时间就重置。这类问题系统下看着像文件系统时间错乱,实际根因在主板电池。再比如串口:设备在,但换个波特率就收不到数据,这种偶发问题在系统下很难跟驱动隔离,在 UEFI 阶段直接做回环测试反而一目了然。
压力可靠性层是最耗时的部分,也是这次设计中争议最大的部分。有人会问:内存测试 memtest 不是做得更专业吗?对,所以我只在内存压力这一项上做了有限的自研,重点做双通道对比读写和取反校验,不追求替代 memtest 的覆盖深度。存储和 PCIe 的压力测试反而是自研价值最高的部分——能同时施加多路并发读写,模拟真实负载下的带宽瓶颈,还能在测试中持续监测温度传感器数据。
2.2 关键测试项的检测思路
挑几个关键测试展开说说具体实现思路。
内存读写测试,我用的是一个经典的退化模式。先对整段可用内存做连续写 0x55、读校验,再做连续写 0xAA、读校验,最后做随机地址读写对比。这个策略不追求穷尽所有数据模式(那是 memtest 的活),而是快速暴露数据线短路、地址线开路、颗粒热不稳定三类最常见故障。值得注意的是,UEFI 环境下我们看到的可用内存只是物理内存的子集,UEFI 固件自身和运行时服务会占用一部分,访问被保留的区域会导致处理器异常。所以测试前必须先过 GetMemoryMap 拿到完整的内存布局,对所有EfiConventionalMemory类型做标记,其余区域一律跳过。
存储读写测试,我遇到过一个有意思的边界问题。UEFI 的 Block I/O 协议在读写时绕开了文件系统,直接操作扇区。测试策略是对整块设备做读校验(没有写是因为裸金属服务器有些盘可能存着重要数据),对可安全写入的 U 盘对拷区做写读校验。读校验的重点是触发设备内部重试:连续读同一扇区一百次,每次校验 CRC,如果出现偶发错误,说明盘片或者接口信号链路存在不稳定因素。
PCIe 验证这块,我的做法是三层渐进。第一层通过 PCI I/O 协议遍历总线,读取每个设备的配置空间,检查 Vendor ID/Device ID 是否可读,Class Code 是否与设备类型匹配。第二层对每个桥设备做寄存器读写往返测试,确认配置空间可写。第三层是用 GPT 协议(可不是磁盘分区表那个 GPT,是 UEFI 的 Graphics Output Protocol)触发一次显示设备模式切换,然后在帧缓冲里画满测试图案,再读回对比。这种渐进式检查,可以让 PCIe 故障快速归类到"链路层"还是"设备层"。
2.3 哪些测试看似高明实则鸡肋,砍掉
设计过程中有一份"已砍名单",写出来帮你少走弯路。
首先是 CPU 温度压力测试,砍了。UEFI 环境下没有标准化的温度读取接口,必须依赖主板厂商的 SMM 变量或者 ACPI 表,不同平台行为差异巨大。温度监测放到压力测试的伴随线程里可以做,但作为独立测试项不具备跨平台可移植性。
其次是网络连通性测试,砍了。UEFI 的 network stack 虽然支持 TCP/IP,但需要通过 SNP/MNP 协议栈加载,而且 DHCP 依赖、PXE 环境依赖、交换机策略一堆外部变量,测出的结果跟硬件本身故障混淆在一起,不好归因。
再次是复杂音频测试。裸金属服务器绝大多数没有板载声卡,测试价值太低,对于有声音故障诉求的桌面场景,Windows 下已经有很多专业工具,UEFI 阶段没必要重复造轮子。
砍完这堆以后,剩下的 21 项测试,每一项都能独立产出一个明确结论:"通过 / 失败 / 警告",而不是一坨含糊的"检测可疑"。
3. 不装系统全程可视化的实现路线:EDK2 + GOP 自绘 UI
3.1 UEFI 程序开发环境搭建
开发 UEFI 应用,主流方案有两个:Intel 主导的 EDK2 和社区维护的 gnu-efi。两个我都试过,最终选了 EDK2,理由很简单:EDK2 的封装层次更完整,协议接口不用自己手工定义头文件,标准库函数齐全,调试符号和模拟器支持也更好。
环境搭建其实没想象中复杂。一台 Linux 机器,装好build-essential、nasm、uuid-dev、python3,然后拉 EDK2 源码,配置环境变量,编译 OVMF(这是虚拟机里用的 UEFI 固件,用来在 QEMU 里快速验证),最后交叉编译生成.efi文件放 U 盘启动。整体流程大约一小时就能跑通第一个 Hello World。
核心代码结构是这样的:
#include <Uefi.h> #include <Library/UefiLib.h> #include <Library/UefiBootServicesTableLib.h> #include <Library/UefiRuntimeServicesTableLib.h> #include <Protocol/GraphicsOutput.h> #include <Protocol/LoadedImage.h> #include <Protocol/SimpleFileSystem.h> EFI_STATUS EFIAPI efi_main(EFI_HANDLE image_handle, EFI_SYSTEM_TABLE *system_table) { // 初始化 // 启动测试调度器 // 进入 UI 主循环 return EFI_SUCCESS; }入口函数并不神秘,EDK2 的 Build 系统会自动处理链接和重定位。真正需要认真设计的,是软件的分层架构。我的做法是三层分离:诊断引擎层只负责执行测试逻辑、产生结构化结果,不关心任何界面表现;UI 层只负责从诊断引擎拿状态、画界面、接收按键;报告层在测试完成后统一从诊断引擎拉取所有结果记录,格式化输出。每层通过简单的结构体接口互相通信,后续给引擎加测试项不影响 UI,给 UI 换皮肤也不影响引擎。
3.2 用 GOP framebuffer 手写 UI 渲染
作为 UEFI 应用,我没有任何现成的 GUI 框架可用。EDK2 的 HII 框架(Human Interface Infrastructure)倒是支持表单式界面,但那个是为 BIOS 设置页设计的,画个进度条都费劲,更别提实时更新的测试状态面板。我选择了更底层的方案:直接操作 GOP 暴露的 framebuffer。
GOP 是 UEFI 规范定义的图形输出协议,核心接口是GOP->Mode->FrameBufferBase加一系列像素格式信息。拿到这块线性内存后,一个像素一个像素地往里填值就能"画图"。在实际场景里,我会优先选择 32 位像素格式,这种格式每像素 4 字节,用BGRA排列后缀,可以直接当 32 位整数数组操作,性能最好。
我的 UI 层只实现三个基本图元:填充矩形、位图字体、字符串。整个界面就是若干个矩形区域拼出来的:标题栏、左侧测试列表、右侧日志区、底部进度条。说句实在话,美观是谈不上的,但一个屏幕之内,用户立刻能看出三件事:当前跑到第几项、前面几项是过是挂、有没有红色高亮的失败项。
字体这块我自己处理了一个点阵字库,16x16 像素的 ASCII 和常用汉字,总共不到 200KB。之所以不用 UEFI 内置的字体接口(HiiGetString之类的),是因为那套接口在 GOP 模式下渲染效率太低,而且字体样式不可控。自己维护点阵字库,渲染时只是内存拷贝加掩码运算,即便是老旧的嵌入式平台也能轻松维持 30 帧以上的刷新率。
渲染循环并不复杂,核心是脏矩形刷新技术。我维护一个标记位,只有测试状态变化的那几个矩形区域需要重绘,其余区域保持不动。完整重绘 1920x1080 的帧缓存虽然只是几百毫秒的事,但局部刷新能让 CPU 占用更低,也避免了长时间测试时风扇飙到全速时的噪音干扰。
3.3 可视化交互的边界:启动参数与键盘响应
全程可视化不只是把结果画出来,还要在测试进行中允许人工干预。UEFI 应用在启动后默认会接管所有输入,我实现了三个控制键:方向键上下选择测试模式,回车启动/暂停测试,ESC 中止当前轮次并跳到报告导出阶段。
这里有个交互上的隐藏设计:UEFI 的 ST(System Table)里的ConIn在启动早期可能还没枚举完 USB 键盘。如果我在启动瞬间就进入输入监听循环,有可能会出现按键完全无响应的情况。稳妥的做法是启动后先主动调用Stall延时 2 秒,等 USB 控制器完成设备枚举,再进入输入循环。这 2 秒在用户那里几乎无感知,但对键盘兼容性的提升是决定性的。
除了键盘交互,我还读取了两个启动路径参数。用户可以在 U 盘的startup.nsh脚本里指定tool.efi -mode quick或-mode full,这样同一份 U 盘可以做成"快速排查"和"深度老化"两用。参数解析是手写的,不依赖 EDK2 的 ShellLib,因为我要兼容直接从固件启动菜单选中.efi文件的场景。
4. 一键出报告的完整链路:从测试结果到可交付文件
4.1 报告格式设计(文本 + JSON)
报告是整个工具的最终产品,一份好的报告要满足三类读者的需求:现场工程师需要快速定位(看文本版)、QA 和厂商需要结构化数据(看 JSON 版)、远程支持需要时间线记录(看日志版)。所以我的报告不是一个单一文件,而是一个小型文件集合。
文本报告走的是"给人类看"的风格,头几行是机器完整信息:主板型号、固件版本、CPU 型号和步进、内存总容量、启动时间、测试版本号。接下来按梯队展示每项测试的结果全部状态,失败项打星号并把日志摘录附上,最后是总判定:全部通过、有错误、有警告三种状态。
JSON 报告走的是"给机器看"的风格,顶层是一个包含机器信息、测试开始结束时间、每项测试结构化结果的数组。每项测试的结果包含四个字段:test_id、test_name、status、elapsed_ms、details,其中details是自由格式 KV 列表,可以记录具体参数(比如内存测试的最大带宽、PCIe 设备的 BDF 号、存储设备的名字和扇区数)。这种格式后续可以直接对接监控系统的 API,或者当 debug 素材发给硬件厂商。
4.2 FAT 分区写入实现与路径选择
报告要写到哪,这是个值得认真设计的问题。最直接的方案是写到当前启动的 U 盘里,但这里有兼容性陷阱:U 盘可能是 FAT32、可能是 exFAT,也可能是 GPT 分区表下的 ESP 分区,文件系统驱动不是都内置的。
EDK2 官方自带 FAT 驱动,但默认只编译 FAT16/32 支持(FAT16/32是标配),exFAT 在新版本 EDK2 里也有支持,不过取决于编译选项。为了最大化兼容性,我的处理是这样的:优先找 FAT 分区,找不到就尝试 4KB 扇区设备,还是不行就退到串口输出。同时,我把Shell环境下的文件系统路径抽象出来,用户把 U 盘在 BIOS 里设置为第一启动设备时,路径通常是FS0:,但为了更稳靠,我会扫描所有EFI_SIMPLE_FILE_SYSTEM_PROTOCOL实例,找一个可写且容量最大的设备。
写文件的实现本身不复杂,调用FileHandleOpen、FileHandleWrite、FileHandleFlush三步走。但有个细节值得提醒:UEFI 环境下的文件写入默认不会立即落盘,U 盘颗粒又喜欢做 write buffering,如果不显式调用Flush,拔 U 盘的一瞬间数据可能还没物理写到位。测试完成后,我会先对每个文件调用 Flush,然后延时 3 秒再退出到固件,确保控制器把缓冲数据写完。这个习惯帮我少丢了很多份报告。
4.3 报告可靠性:断电、异常终止的处理
现场排障时,最常见的场景不是"跑完测试拿报告",而是"测试中途机器死机了,我来找原因"。如果整个报告体系只支持正常结束导出,那这台机器的故障现场可能就丢了一半。
我的处理思路是"边测边存"。诊断引擎每完成一项测试,立刻把该结果追加写入一个临时日志文件runtime_log.txt,如果测试因看门狗重启或断电中断,下次启动时扫描到runtime_log.txt,自动将其改名为report_interrupted_时间戳.txt并保留下来。这样一来,即使测试只跑到第 7 项就死机,我至少知道前 6 项都过了、第 7 项是在什么设备上挂的。这比盲人摸象式的重新测试要高效得多。
另外还有个容易被忽略的问题:测试完成后,我要把结果展示在屏幕上并等待用户按键返回,这个阶段 UEFI 的 watchdogs 通常已经不在了,但如果在压力测试阶段,看门狗超时可能会强制重启。这个话题我在下一节展开,因为它是整个工具开发过程中让我印象最深的一个坑。
5. 真实机器上的踩坑与打磨:UEFI 自检远没有看起来简单
5.1 看门狗重启问题
我第一次在真实服务器上跑完整测试时,发现个诡异现象:内存压力测试跑到约 40 分钟的时候,机器突然重启,没有任何征兆,也没有任何日志。重启后我查dmesg,硬件都是好的,整个系统看起来无比正常。当时差点以为是内存过热,还特意开了机箱侧板对着吹风扇验证。
后来翻 UEFI 规范才意识到:UEFI 固件里有一个看门狗定时器(Watchdog Timer),默认超时一般是 5 分钟,部分服务器固件会更长一些。它的设计初衷是防止固件在 POST 阶段卡死,但一旦进入我的应用环境,定时器依然在跑。正常的检测工具都会定期重置(SetTimer+ResetWdt)看门狗,而我恰恰忘了这一茬。
解决办法非常简单,主循环里每次刷新界面时调用一次:
gBS->SetTimer(watchdog_timer, TimerCancel, 0); gBS->SetTimer(watchdog_timer, TimerPeriodic, EFI_TIMER_PERIOD_SECONDS(30));只要每 30 秒重置一次,系统就永远不会被看门狗打断。这个坑让我意识到,UEFI 应用虽然看起来像独立程序,但本质上还是在固件管理的资源池里运行,固件自己的"后台线程"一直在监视你。
5.2 USB 键盘和存储兼容性
第二个坑是在用不同品牌的 U 盘测试时踩到的。我的工具用 GOP 刷出界面后,发现某些 U 盘上的报告写不进去:报"未找到可写设备"。查了一圈,发现这些 U 盘的分区表是 GPT + exFAT,而我的 FAT 驱动只认 MBR + FAT32。U 盘容量越大,厂商出厂格式越可能偏向 exFAT,而 EDK2 的默认 FAT 驱动不加载 exFAT 支持。
解决方案是在编译时额外加入 exFAT 驱动(FatPkg的EXFAT_ENABLE标志),同时在运行时做了降级策略:如果 FAT32 和 exFAT 都找不到,就尝试直接在整块 U 盘上创建一份 FAT32 文件系统(调用 DiskIo + 简单格式化逻辑),再写入报告。这套混合方案跑下来,兼容性从大约八成提到了接近满覆盖,只剩少数加密 U 盘和特殊 CHS 布局的老盘还在手工处理清单里。
USB 键盘的问题之前已经提过,再补充一个易踩的坑:有些主板上,我延时 2 秒后依然收不到 USB 键盘输入。后来定位是这些固件在 UEFI 阶段把 XHCI(USB 3.x 控制器)的所有端口初始化为"仅 EHCI"兼容模式,而我的代码直接查 XHCI 协议,自然就找不到设备。最终的代码里,我在枚举控制器时会同时检查 EHCI 和 XHCI 两个协议,拿到实际可用的那个控制器来注册输入回调。
5.3 长工时测试的进度与容错
压力测试耗时长,UI 界面就不能只是干巴巴的进度条。我在存储测试和内存测试这两个大头里做了三层并发:测试执行线程(由 UEFI 事件驱动)、UI 刷新线程、温度采样线程。温度采样线程每 5 秒读一次平台特定传感器(如果有的话),在界面角落画一个小折线图。这样长时间测试时,用户能直观看到温度有没有异常爬升。
容错方面,我做了一个"单测试失败不终止全局"的策略。传统测试工具跑挂一个测试项就直接报错退出,这在硬件排障上很浪费——用户可能既想测内存又想测存储,结果存储那项挂在第一个坏扇区上,后续测试全都白搭。我的调度器在单测试项失败后会询问用户:立即终止、跳过该项继续、还是重试一次。这让现场工程师可以在非关键项失败时继续完成全部测试,最终报告里完整记录每个失败项,而不是只得到一个"测试未完成"的结论。
还有一种在真实机器上特别常见的假失败场景:某些老服务器在纯 UEFI 模式下启动后,VGA 控制器不初始化,导致 GOP 模式请求失败。这种情况下我做了个降级模式:如果没有可用 GOP,自动切到 ConOut 的文本模式,在ConOut->OutputString里打印测试进度。虽然界面变丑了,但测试链路完全不受影响。这个降级实现帮我兜住了不少老平台的风险。
结尾:这套工具的适用边界与下一步方向
写了这么多,最后说一下我对这套工具的边界理解。它解决的最大痛点,是"系统起不来时硬件状态不可见"的信息断层,把硬件排障的起点提前到了 UEFI 阶段。但我也得诚实说,它不是万能的:它测的是硬件能不能在固件层面正确响应,不代表操作系统下驱动兼容性没问题;它能覆盖绝大多数常见故障,但保不住所有玄学级偶发问题。
我在实际使用中最满意的一点,是它的可复制性。制作一个启动 U 盘只需把一个 FAT32 分区设为可引导,把.efi文件和startup.nsh丢进去。团队里的新手也能操作:插上、开机、看界面、拔盘、提交报告,不需要记任何命令。
后续我准备把它扩展成 PXE 启动版本,这样数据中心几百台机器可以用 iPXE 批量拉取同一个.efi文件,测试结果统一回传 HTTP 服务,等于有了一套私有的裸金属入网检测系统。当然这就是下一个项目的事了。