ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AMI BIOS源代码拆解与固件定制:从UEFI工程到编译实践的落地方案

AMI BIOS源代码拆解与固件定制:从UEFI工程到编译实践的落地方案

简介:这份源码包收录了AMI BIOS关键实现,面向BIOS研发、固件调试及系统底层开发工程师,可用于理解BIOS启动流程、硬件初始化逻辑,以及开展定制化改造。压缩包内共1294个文件,包含519个obj目标文件、201个asm汇编源码、68个inc头文件、112个asl描述文件,以及OEM定制模块、bin固件镜像、mak编译脚本等,整体约5.61MB,适合作为《BIOS研发技术剖析》配套学习材料。目前已有1077人学习下载。源码核心覆盖POST自检、中断服务例程、硬件初始化、CMOS读写、引导加载器识别、PCI设备配置、ACPI电源管理等模块,并涉及传统BIOS向UEFI过渡的代码片段,可帮助读者掌握底层硬件驱动编写思路与系统级编程技巧。通过逐行分析这些汇编级代码,能够直观理解计算机启动每一环节的软硬件交互,以及中断向量表、I/O端口操作等关键机制,对从事固件开发或系统级软件研发具有很高参考价值。

1. AMI BIOS源代码:它不是一份公开代码,而是一套能拆解重组的固件工程

搜索“AMI BIOS源代码”的人,多半已经在网上碰过一鼻子灰:不是链接失效,就是打包下载下来的东西解压后根本编译不起来。原因很简单,AMI BIOS源代码从来不是像Linux内核那样对外开源的仓库,而是一套基于EDK2框架、由AMI封装成Aptio V形态的UEFI固件工程,它跟着授权和保密协议走。真正能落地的价值在于:你可以拿OEM厂商提供的源码包,也能从主板BIOS二进制里反向还原出可分析的模块,然后去改启动Logo、默认串口重定向、PCIe配置策略,甚至塞进一个自己的DXE驱动。这套东西做顺了,固件定制的工作效率会翻倍;做不顺,就是在黑匣子里反复试错。这篇直接用可复现的拆解和编译步骤,把从拿到二进制到完成定制的完整链路讲清楚,适合做板卡固件调试、工控定制和UEFI驱动开发的人,也适合想评估“要不要接固件定制活”的从业者。

2. 从固件二进制到源代码:先搞清你手上拿到的是什么

2.1 三种形态:开放源码包、OEM修改包、反编译中间代码

AMI BIOS源代码不是只有一种存在形式。很多人拿到手里的第一份东西,是主板厂商流入出来的完整源码包,里面是AMI Aptio V的标准化工程,包含EDK2的基础包、AMI的CSP封装包、平台板卡包、编译脚本和一批XML配置模板。这种包一般体积在1到2 GB左右,解压后你看到的是一堆.c、.h、.dsc、.inf、.vfr文件和Python脚本,构建系统是标准的EDK2build命令加上AMI自己的前端脚本。这种形态最理想,因为你能直接编译、直接调试。

第二种形态是OEM修改包。很多ODM厂商拿到的不是完整源码,而是从上游拿一个“基础版本”再加自己的差异化文件。常见做法是给一份 DIFF 目录,里面只放板卡相关的BoardPkg、ChipsetPkg和几个配置头文件。这种包没法独立编译,必须叠加到某个基准版本上。识别方法是看目录下有没有.dsc和build.sh,没有的话大概率是增量包。

第三种最麻烦,也是不少工程师实际面临的局面:你只有一块板子、一个.bin或.fd的BIOS固件文件,没有源码包,想做点定制就必须反编译。这时你“看到”的源代码不是真正的C语言,而是由UEFITool、IFRExtractor这类工具从固件卷(FV)里解出的EFI字节码,再交给IDA或Ghidra反编译出伪代码。它和真实源码之间的差距很大,因为编译阶段的优化已经把结构打散,变量名全部丢失,只能靠UEFI协议名称和GUID去猜逻辑。但用于替换模块、定位某个字符串、分析启动顺序,足够用了。

所以动手之前先回答一个问题:你要做的事是“改配置”还是“改逻辑”?改配置,比如默认串口波特率、BIOS设置里的隐藏项、启动顺序,第三种形态就够了。改逻辑,比如自己写一个Init程序、替换某个DXE驱动,那你必须尽量去要第一或第二形态。我一般会先打开固件文件确认版本字符串,如果里面有Aptio V字样,再按上面的分类判断该走哪条路。

2.2 识别AMI Aptio V的FV结构和模块工具链

拿到一个BIOS文件后,第一件事不是急着反编译,而是确认它到底是不是AMI的,以及分区布局是什么样的。用一个十六进制工具或者直接在命令行走一趟:

strings bios.bin | grep -i -E "AMI|Aptio|Insyde|Phoenix"

如果看到AMI Aptio V或者大量AmiTse、AmiCsp这类模块名,基本可以确定是AMI的代码。接下来用UEFITool打开镜像,你会看到一个层级列表:最外层是多个固件卷(Firmware Volume),每个FV里又分成一个个文件(File),文件里有PE32或TE格式的可执行段。

为什么这一步重要?因为AMI BIOS的镜像文件头部通常还有非FFS区域,比如描述器(Descriptor)和嵌入式固件接口表(FIT),这两块不能随意改动。如果你用UEFITool“保存整个镜像”,它只会保存它解析出的FFS区域,有可能丢掉MBR式分区结构,导致刷写后无法启动。正确的做法是:用UEFITool仅提取或替换某个模块,再用闪存编程器能识别的完整镜像去做回写。

工具链上,我常用的是开源组合:UEFITool负责解析和提取,FFS Extractor负责把文件按类型拆开,IFR Extractor负责把BIOS设置界面(HII IFR)反编译成可读的.txt,BinToH之类的脚本负责把二进制转成C数组。这套组合不需要额外买License,常年维护,坑少。如果你拿到的BIOS有BIOS Guard或已开启安全启动依赖,提取出来的模块会带签名,这属于后话,放在编译阶段讲。

2.3 把二进制拆开:用Python脚本提取特定模块

当你想定位某个功能模块时,靠UEFITool手动找很慢,我一般会先写一个几十行的Python脚本,直接扫描整个镜像里的GUID和特征字符串。比如你想找Logo模块,可以搜PNG头或者某个已知的GUID前缀。

import sys import re def scan_mod(data): # 常见AMI DXE驱动GUID前两个字节,实际使用时可自行扩展 known_guids = { "AmiLogo": [0x7B, 0x85], "Bds": [0xB4, 0x5F] } for name, prefix in known_guids.items(): pattern = bytes(prefix) + b'\x00' * 6 # 简化匹配,真实GUID要按16字节对齐 for m in re.finditer(re.escape(pattern), data): start = m.start() guid_str = data[start:start+16].hex() print(f"{name} -> offset 0x{start:X}, GUID {guid_str}") with open(sys.argv[1], 'rb') as f: data = f.read() scan_mod(data)

这段脚本的思路是:把已知的GUID或特征字节当成pattern,在完整镜像里扫描,输出命中偏移和GUID。参数说明:start表示偏移量,guid_str是16字节GUID的小端序展示;实际工程中应该用uuid.UUID(bytes_le=data[start:start+16])来转标准格式。你可以把已知模块名和GUID写死在字典里,比如从AmiCspPkg里挑几个重点模块,等UEFITool确认后再比较。注意,真正的GUID匹配往往要求先做Firmware Volume的对齐解析,简单扫描会漏掉那些被压缩过的模块,所以这段脚本只适合做快速初筛,最终提取还是得靠UEFITool。

这一步做完,你已经把“固件”拆成“可看的部分”了。下一步是理解这些模块在AMI源码包里的位置,也就是目录结构的门道。

3. AMI BIOS源码目录结构:哪些文件才是你真正要碰的

3.1 核心框架:EDK2与Aptio V的源码分层

如果你打开过一份完整的AMI Aptio V源码包,第一反应多半是“怎么这么多包名”。实际上从BaseTools、MdePkg、MdeModulePkg到IntelSiliconPkg、AmiCspPkg、AmiBoardPkg,一层套一层。这套结构和Standard EDK2的区别在于,AMI在EDK2核心之上加了两层:AmiCspPkg(Chipset Support Package的AMI封装)和AmiBoardPkg(平台板卡级策略)。MdePkg是EFI规范的基础C库,定义EFI_STATUS、EFI_GUID这些类型;MdeModulePkg是通用的UEFI模块,比如GraphicsConsole、SetupBrowser;IntelSiliconPkg是芯片组初始化库;AmiCspPkg则把芯片组库封装成AMI式的表格驱动代码,很多默认值都藏在它的Pei、Dxe目录里。

理解这个分层最大的好处是:你改错了地方,编译器不一定报错,但运行时会出诡异问题。比如你想改DDR频率训练参数,应该去碰板卡包里的内存参考代码和MemoryInit的预编译配置文件,而不是改MdeModulePkg。反过来,你想改UEFI Shell的默认启动顺序,就要去Bds模块的策略文件,而不是在Setup的VFR里硬改。

源码包里的命名也有规律。一个典型模块文件夹通常包括.inf(构建描述文件)、.c/.h(源码或头文件)、.vfr(Setup界面的可视化表单)、.uni(字符串UNI文件)。当你看到.uni文件里有文本"Boot Configuration",就知道这是设置界面相关模块。最直接的做法是:在源码目录里全局搜索你想改的字符串或GUID,命中哪个包就在哪个包动手,这比从零学整个树结构快得多。

3.2 平台初始化代码路径与定制入口

平台初始化是从上电到进入系统前最敏感的环节。在AMI源码中,这段逻辑分布在几个大的阶段:SEC、PEI、DXE、BDS。SEC代码负责CPU早期初始化,一般由SecCore和AmiTsePkg承载;PEI阶段做的事情是内存初始化、芯片组早期配置,对应的代码通常在AmiBoardPkg下的PeiPlatformHook或ChipsetInit;DXE阶段的代码则是所有驱动的主战场,比如ACPI表生成、SMM管理、USB控制。

定制入口大致分成三类。第一类是“固定的HOOK点”,AMI在代码里留了一批空实现函数,比如PlatformEarlyInit()、PlatformBdsInit()、SetupDefaultVariable(),你只需要在板卡包里实现这些函数,就会在对应阶段被调用。这是最安全的定制方式,因为不动原有的初始化流程,只是附加执行。第二类是“替换整个驱动”,比如你想替换自带的启动Logo驱动,那要保证新的DXE驱动能处理好Firmware里压缩过的Logo数据。第三类是“修改配置表”,包括Setup.vfr里显示的项和NVRAM里的默认变量。第一类是最常用的,我会优先找AmiBoardPkg/BoardInit/下的空函数补实现。

举个例子,如果你想在BDS阶段开始前干一件事,比如检测一个GPIO状态,那么就去AmiBoardPkg里找PlatformBdsInit()函数,在注释标记的空块位置加入自己的代码。注意不要试图在PEI阶段去访问文件系统,因为那时驱动的栈还没建好。这是初学者最容易踩的坑。

3.3 设置界面(Setup)的配置口和默认值修改

AMI BIOS的“高级设置”界面不是硬编码的,而是由VFR文件和UNI文件在编译时生成。VFR定义了界面结构:哪些是FORM、哪些是ONE_OF下拉列表、哪些是NUMERIC数值框;UNI文件定义了这些控件显示的文字。你可以在源码包中搜索一个你常见的字符串,比如"Quiet Boot",找到它所在的.uni文件,再通过.vfr文件里的varstore找到对应的变量名。

修改默认值最典型的做法是改Setup模块里的默认变量列表。在AMI包里,通常会有一个SetupDefault.c或s_database.c,里面是一张表,每一行对应一个设置项。拿串口重定向(Serial Redirection)举例,表里会有一列是Default,一列是该变量的GUID和Offset。你如果把波特率默认值从115200改成9600,编译烧录后什么都不用动,进BIOS就会看到默认变成9600。

但这里有个容易误解的点:默认值并不是单纯写在这个表里就一定能生效。BIOS在运行时会根据硬件状态、上一次用户配置、以及某个变量的“上一次修改时间戳”来决定是否覆盖默认值。AM I里这个机制跟EDK2的VarCheck和NVRAM清理逻辑挂钩。如果你改了默认值,但不清空NVRAM,或者固件里的BmpRefresh逻辑认为上次配置比默认值“新”,那你看到的还是旧值。这个踩坑点放在后面专门讲。修改VFR时也要注意varstore的Buffer尺寸不能改,否则会导致变量存取越界,哪怕编译过了,运行时也会崩溃。

4. 编译与验证:把修改后的源码变成可刷新的固件

4.1 构建环境搭建的注意点

拿到源代码包后,第一步不是看代码,而是搭构建环境。我见过太多人在这里浪费两天,最后发现是路径里有中文或者Python版本不对。AMI Aptio V的构建依赖三样东西:一个支持x64的Windows或Linux环境、EDK2支持的编译器(Windows下常用VS2019或VS2022,Linux下常用GCC5或GCC49)、以及Python 2.7或Python 3.8+的BaseTools。

环境变量是个大坑。EDK2的构建系统强烈依赖WORKSPACE、PACKAGES_PATH、EDK_TOOLS_PATH。常见做法是在源码根目录建一个setup_env.bat(Windows)或source env.sh(Linux),把路径配好。有个细节:PACKAGES_PATH里多个包的路径分隔符,Windows用分号,Linux用冒号,搞反了一个都找不到包。另外,AMI自带的build.py有时会启动一个后台的“Module Fingerprint”校验,如果源码包是从OEM那里二次分发的手工拷贝,这个校验会失败。解决办法是找到构建脚本里对应的-nofp或-skipfingerprint参数,关闭校验。

编译本身不需要买昂贵的硬件。即使你没有那块目标主板,也可以编译出固件,再拿到模拟环境或真实板子上验证。不过要注意,Aptio V的源码包往往带有板卡增强性的改动,例如某个板卡的GPI/O表、内存配置文件,你必须确认自己的构建对象是哪个Board。如果板卡名与你的硬件不匹配,编译出来的固件刷进去大概率直接黑屏。所以环境搭建的实际工作是“让构建系统认识你的板卡”,而不只是装编译器。

4.2 编译命令与生成文件说明

在Linux下,如果你拿到的是标准Aptio V工程,常见的构建步骤是这样:

cd /opt/ami/AptioV source ./edksetup.sh build -p AmiBoardPkg/AmiBoardPkg.dsc -b RELEASE -t GCC5 -n 8

参数说明:-p指向平台DSC文件,AmiBoardPkg.dsc是顶层构建入口;-b RELEASE表示构建Release版本,对应的还有DEBUG版本,后者会保留大量日志打印,体积更大,适合调试早期启动,但绝不要用来交付;-t GCC5指定工具链,如果编译报gcc: error: unrecognized command line option,多半是工具链标签和实际编译器版本不匹配;-n 8是并行编译线程数,按CPU核数和内存大小调整,我一般用8到16。

构建完成后,产物通常放在Build/AmiBoardPkg/RELEASE_GCC5/FV/目录下,里面有.fd文件、.rom文件,和一份MAP文件。.fd是最终固件镜像,可以直接用于刷写;.rom有时和.fd相同,有时只是原始输出。重点要看Platform.map文件,它记录了每个模块在固件里的物理地址和大小;当你怀疑自己的改动没编进去时,打开这个文件搜索你新增的模块名,能快速确认。

如果构建时输出大量error LKB2003:或者GenFw: Error,常见原因是某个.inf文件里声明的入口函数与你实际写的函数名不一致。这时不要急着改代码,先检查该模块的EntryPoint声明。另一个常见问题是VfrCompile报错,说明你的.vfr文件语法有错,而且错误消息往往指向某个name属性重复。这两个错误在源码开发里几乎必遇,遇到先深呼吸,然后去查对应的INF和VFR文件。

4.3 验证方式:模拟器、二进制对比和刷写回滚

编译出来不等于能启动。最快的验证方式是先做二进制层面的对比,再做模拟器启动。对比也很简单:构建两次,一次不修改,一次修改,然后用diff或md5sum比较生成的.fd文件差异。只看差异区域的偏移,能快速判断你是否动到了不该动的固件头部。如果差异出现在文件末尾或某个模块的内部,说明改动符合预期。

模拟器方面,我一般使用两个路径:一是用QEMU配合UEFI固件跑起来看DXE驱动列表;二是用AMI来源的UEFI模拟器(如果有SDK的话)直接在Windows下加载你的DXE模块。对于没有调试硬件的场景,QEMU是最稳定的选择。但注意,QEMU不模拟真实的BIOS设置界面和芯片组初始化,所以它只能验证你的DXE驱动逻辑是否正确,不能验证内存训练、GPIO配置这类硬件相关改动。硬件相关改动必须上真机。

刷写之前,务必备份原始BIOS。最稳的办法不是用主板自带的刷写工具,而是用编程器把当前Flash芯片的内容完整读出来存成一个文件。这样即使你把固件刷挂了,也能用编程器一把救回来。编程器刷写时要确认Flash型号、连接线序和电压,别在开机状态下插拔,否则损失的不只是固件,可能还有芯片本体。我自己的经验是:准备一张可启动的DOS U盘,里面放一个老版本刷写工具和原厂固件副本,万一自定义固件出现“进不了BIOS但能开机”的局面,还能用热刷救回来。

5. 避坑指南:改AMI BIOS源代码时常踩的五个坑

5.1 现象:编译通过但开机黑屏

有一回我给一块工控板改串口重定向,编译流程全部顺利,刷进去直接黑屏,Debug卡完全不跑。原因是我的代码里加了一个调试打印函数,但仓库里没有实现该函数的库引用,链接器没有报错,生成出来的模块运行到那个地址就等于跑飞。解决方法是两类:一是把构建配置改成DEBUG模式,重新编译,用串口输出看引导到了哪个模块;二是用源码包里的BaseDebugLibSerialPort,确保打印函数有实现。黑屏这种问题,十有八九是模块抛了异常,而不是整体崩溃,所以先接线用串口抓日志,不要反复盲刷。

另一个黑屏原因是固件体积超限。AMI的主BIOS区域有固定尺寸,比如8 MB或16 MB,如果你的改动让某个FV膨胀,构建工具有时不会报错,只会生成一个超过实际Flash容量的.fd。你把它刷进8 MB的Flash,后面部分被截断,结果就是花屏或黑屏。解决方法是刷之前用Python检查文件大小是否小于等于你的Flash型号写入允许值,比如os.path.getsize("bios.bin"),然后再看MAP文件里是不是有重叠的模块。

5.2 现象:设置界面找不到新增项

在源码里加了一个VFR表单,编译也过了,进BIOS Setup找半天看不到新增的选项。这个坑的原因通常有三个。第一,VFR文件没有被DSC或INF文件引进来,解决办法是在该模块的.inf配置文件里确认含有.vfr条目,并且UNI文件里有对应的字符串。第二,你新增的form的formsetGUID和原来的设置页GUID不一致,导致 SetupBrowser 把它当成另一个独立的表单集,而不是页签里的一个条目。第三,变量存储的varstore没有绑定到正确的NVRAMnv区域,导致设置项被过滤。排查办法是用IFR Extractor把编译好的模块解出来,看生成的IFR里是否包含新增的页面。如果包含,说明编译正确,问题出在浏览器框架的挂接上;如果不包含,说明VFR没有被编进去。

5.3 现象:刷回失败,分区表错乱

用UEFITool直接“替换模块”后保存整个镜像,再刷回主板,结果写不进去或者启动后找不到引导设备。这个坑我踩过不止一次。原因是UEFITool的“保存镜像”会按它的解析结果重写FFS区域,但它不一定保留AMI在固件头部预留下的非FFS间隙,比如Flash Descriptor、FIT表以及某些厂商自己加的签名区。解决方法是不要用“保存镜像”,而是用“提取模块”和“替换模块”功能,在替换后,用原始的.fd文件做模板,只修改目标模块的字节段,再用Python脚本把模块 diff 合入原镜像。这样的好处是原镜像的头部结构完全不动。更稳妥的方案是使用AMI官方的FMMT工具,它能感知FV结构,在替换时自动处理校验和与对齐填充。

5.4 现象:代码中修改了默认值但实际没生效

修改了Setup默认值表里的某个变量,刷进机器后进BIOS看到的还是旧默认值,把NVRAM清空了才变化。原因在于AMI的默认值存在多个层面。第一个层面是编译时的AutoDefault表;第二个层面是运行时芯片组代码会再次写入“推荐值”,这两个值如果不一样,运行时写入的会覆盖前一个。还有一个原因是NVRAM里已经存储了用户配置,它们的“Last Modified”时间戳比默认值新,固件认为必须保留用户选项。解决方法是:先在调试模式下确认程序是否执行到了默认值写入代码,如果执行到了,再看是不是被后续的ChipsetSetupCallback覆盖。有些板卡会在PlatformBdsInit里做二次默认值注入,你要么改那一处,要么用清除NVRAM的调试命令,不要只改表上的一个数字。

5.5 现象:反编译的“源代码”和实际源码对不上

当你用Ghidra或IDA打开从固件里提取的PE32模块,反编译出的C代码里函数名全是FUN_00012345,变量名全是local_40,和你在网上搜到的某个AMD/Intel参考代码长得完全不一样。这不是你的工具不行,而是反编译本来就是“有损的”。优化过的机器码丢失了类型信息,逻辑结构也会被复杂化。我之前尝试用文本比对区分两个BIOS版本差异,结果95%的差异都来自编译器版本和优化等级,真正的逻辑变更就一两行,淹没在噪音里。后来的习惯是:先对比GUID和依赖表(EFI Dependency Section),锁定新增或删除的模块;再对被替换的模块做函数级别的二进制 diff,用类似BinDiff的工具生成匹配结果;最后只针对不匹配的函数看反编译结果。这样效率最高,也最不容易上头。

6. 让源码改动真正落地:用HOOK函数写一段自己的DXE启动打印

前面讲了那么多框架和方法,最后落一个最实际的技巧:在源码包的一个HOOK点里加入自己的逻辑。我们以PlatformBdsInit()为例,这是AMI在BDS阶段前调用板卡钩子函数,几乎每个Aptio V工程里都有。你只需要在板卡包里找到并修改这个函数,然后重新编译。

#include <PiDxe.h> #include <Library/UefiBootServicesTableLib.h> #include <Library/DebugLib.h> EFI_STATUS EFIAPI PlatformBdsInit ( IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable ) { // 原有代码不动,在下方追加自己的逻辑 DEBUG ((DEBUG_INFO, "PlatformBdsInit: custom hook enter\n")); // 示例:在DXE阶段读取硬件状态并打印 UINTN GpioValue = 0; // 假设这里是从某个寄存器或GPIO驱动读值 DEBUG ((DEBUG_INFO, "custom GPIO value = %x\n", GpioValue)); return EFI_SUCCESS; }

这段代码的入口参数必须严格保持EFI标准定义,因为.inf文件里的ENTRY_POINT会直接引用这个函数名。DEBUG宏是在DEBUG构建里生效的,RELEASE构建会被编译器丢弃,所以如果你只是想临时打印定位问题,务必用DEBUG模式编译。如果想要RELEASE构建也输出信息,就得把DebugLib换成SerialIoLib,然后自己调gST->ConOut或直接访问串口寄存器,但这会破坏兼容性,不建议作为最终交付代码。

把这个函数编译进固件后,怎么验证它真的跑了?在UEFI Shell里执行dmpstore -all看变量,或者直接看串口输出。如果串口输出在进入Boot Device Selection之前看到你的打印,说明HOOK生效。如果你发现打印没有出现,先确认模块是否真的在FV里,用UEFITool打开生成的.fd,搜索你的函数对应的GUID或字符串,找不到就回去查INF文件。要注意的是,HOOK函数里的代码不应申请大块内存,也不要加载额外的驱动,因为BDS阶段的环境并不完整。我的经验是:临时逻辑可以随便写,但正式产品里只放必要的硬件保护和状态上报,越短越好。

最早我拿到AMI BIOS源代码时,总觉得必须把整个EDK2框架读完才能动手,结果硬啃了两周。后来改走“搜字符串定位模块、改默认值验证全流程”的路线,三天就能完成一块板卡的定制需求。这不是玄学,只是因为AMI的源码工程本身就是一个高度模块化的框架,你不需要懂所有细节,只需要遵守它留好的边界。希望这篇能帮你在自己的固件定制路上少走几步弯路,也建议你从改一个Default值开始练手,先跑通全流程,再碰底层初始化。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进