深入EFI了解背后的运行

EFI介绍

EFI全名Extensible Firmware Interface,是一种引导系统的固件接口,作为BIOS之后新发展的PC扩展接口升级。先说为什么会有EFI这个东西:传统BIOS跑在16位实模式下,能直接访问的内存只有1MB,开机自检和初始化硬件的代码基本是写死的,扩展性很差,硬盘分区也只能用MBR,超过2TB的硬盘就用不明白了。随着机器越来越复杂,这套老框架顶不住了,Intel就牵头做了EFI,后来由UEFI论坛接管,统一成现在的UEFI(Unified Extensible Firmware Interface),不过大家平时还是习惯叫EFI。

EFI可以理解成固件里的一套小程序运行环境,它工作在32位或者64位模式下,能从FAT格式的分区里加载可执行文件,也就是我们说的efi文件,这个文件其实就是一个PE格式的可执行程序。在Windows系统安装中,会在硬盘分出一片区域,名字就是efi,正式叫法是ESP(EFI System Partition),必须格式化成FAT或FAT32,用于引导系统的启动,一般分个几百兆就够用了,里面放的就是引导程序和驱动(之前我卸载过这个分区,印象很深)。

对于更多EFI启动的流程可以看下面的图片,里面包含了一套完整的EFI周期

图里那套流程大致分成SEC、PEI、DXE、BDS这几个阶段,前面几个阶段做的是硬件初始化和自检,到BDS(Boot Device Selection)阶段固件才会去各个启动项里找能引导的设备,找到efi文件就加载执行,跑完再把控制权交还固件或者交给操作系统。我们写的应用程序运行时其实已经在DXE之后的阶段了,能用的服务都由固件提前准备好,所以底层开发得跟着固件的规矩来。

更多的EFI介绍可以参考百度,现在不关心历史只关心用处🤣

安装并解释EDK

EDK也就是EFI Development Kit,类似JDK一样,EDK将会是开发efi文件的库,我们在ubuntu下操作,安装EDK2。EDK2是tianocore社区维护的开源实现,装它主要就是拿三样东西:基础库、编译工具和现成的示例代码,后面自己写的代码都要挂在这套框架上。

1
git clone https://github.com/tianocore/edk2.git

一些依赖库文件的下载指令放在下面,当然是ubuntu的apt,如果你是centos就改成yum。这些包各有各的用处:build-essential是gcc、make这一套编译环境,uuid-dev提供UUID相关的头文件,iasl是ACPI编译器,nasm是汇编器,python3-distutils则是BuildTools里的python脚本要用的。命令里写的是gcc-5,具体版本按自己系统来,换成gcc一般也行。

1
apt install build-essential uuid-dev iasl git gcc-5 nasm python3-distutils

文件指针切入edk2目录下,导入一些子项目。edk2的仓库里很多功能是拆成子模块放的,比如加解密相关的openssl,所以克隆完还要执行这一句把子模块拉全,不然后面编译会缺文件。

1
git submodule update --init

安装完成会出现一大堆文件,这些文件夹的共性就是都以Pkg结尾,这是edk2的包管理习惯,每个Pkg都是一个独立的功能包。其中关注两个文件夹MdePkg和BaseTools,前者是库而后者就是工具:MdePkg里放着最基础的数据类型、协议定义和库,地位差不多相当于普通开发里的标准库;BaseTools是编译工具,edk2的整个编译流程都要靠它里面生成的一堆工具程序。另外MdeModulePkg里有各种通用模块和示例,我们马上要看的HelloWorld就在里面。因为我们要自己写Efi,所以我们新建一个文件夹,就叫MinPkg,名字尾部带Pkg是edk2的命名习惯,方便编译器识别。

1
2
3
4
5
6
7
8
9
root@minloha:~/LearnEFI/code/edk2# ls
ArmPkg CryptoPkg FatPkg Maintainers.txt pip-requirements.txt SourceLevelDebugPkg
ArmPlatformPkg DynamicTablesPkg FmpDevicePkg MdeModulePkg ReadMe.rst StandaloneMmPkg
ArmVirtPkg edksetup.bat IntelFsp2Pkg MdePkg RedfishPkg UefiCpuPkg
BaseTools edksetup.sh IntelFsp2WrapperPkg NetworkPkg SecurityPkg UefiPayloadPkg
Conf EmbeddedPkg License-History.txt OvmfPkg ShellPkg UnitTestFrameworkPkg
CONTRIBUTING.md EmulatorPkg License.txt PcAtChipsetPkg SignedCapsulePkg
root@minloha:~/LearnEFI/code/edk2# mkdir MinPkg
root@minloha:~/LearnEFI/code/edk2#

强调两个文件类型.dsc和.dec,前者是对当前Pkg的描述,类似一份总的编译清单,写清楚这个平台由哪些包组成、支持哪些架构;后者是用于声明公开接口的描述,比如这个包对外提供哪些头文件、GUID和PCD,别的包要用就直接引用它。找到最经典的Hello world代码存放位置,里面保存了.inf和源码.c文件,这里注意,因为是底层所以没有标准库,也就意味着所有的输出函数和变量定义都必须重新写,也就是无法再像以往写代码一样去定义变量了。具体来说,stdio、stdlib这些库压根没有,打印要用UefiLib里的Print(),字符串是宽字符,写的时候要加L前缀,比如Print(L"Hello EFI!\n");数据类型也换成了UINTN、CHAR16、EFI_STATUS这些固件自己定义的;还有个容易忘的坑,EDK2沿用C89风格,局部变量声明得放在函数最前面,不能在中间随用随定义,不然编译直接报错。

1
2
3
4
root@minloha:~/LearnEFI/code# cd edk2/MdeModulePkg/Application/HelloWorld
root@minloha:~/LearnEFI/code/edk2/MdeModulePkg/Application/HelloWorld# ls
HelloWorld.c HelloWorldExtra.uni HelloWorld.inf HelloWorldStr.uni HelloWorld.uni
root@minloha:~/LearnEFI/code/edk2/MdeModulePkg/Application/HelloWorld#

打开.inf进行解释,内容如下,[Defines]定义了程序的版本、程序名、文件GUID、版本以及程序入口,其中GUID要保证全局唯一,相当于这个模块的身份证,ENTRY_POINT = UefiMain说明程序从UefiMain这个函数开始跑,MODULE_TYPE = UEFI_APPLICATION表示这是个应用程序,如果是驱动就要换成UEFI_DRIVER之类的类型。[Sources]写出了程序所需要的资源文件,即源码和配置。[Packages]定义了所需要EDK的包,里面列的dec文件就是本模块引用的包声明,[LibraryClasses]列举了使用的接口,这里每写一个名字,编译时就会去链接对应的库实现。[Pcd]规定了一些常量字符串,引用了.dec文件中所写的内容,PCD全称Platform Configuration Database,就是一份跟着平台走的配置数据,同一份代码在不同平台上编译能拿到不同的值,[FeaturePcd]里的则是个开关,用来控制代码编不编进去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
## @file
# Sample UEFI Application Reference EDKII Module.
#
# This is a sample shell application that will print "UEFI Hello World!" to the
# UEFI Console based on PCD setting.
#
# It demos how to use EDKII PCD mechanism to make code more flexible.
#
# Copyright (c) 2008 - 2018, Intel Corporation. All rights reserved.<BR>
#
# SPDX-License-Identifier: BSD-2-Clause-Patent
#
#
##

[Defines]
INF_VERSION = 0x00010005
BASE_NAME = HelloWorld
MODULE_UNI_FILE = HelloWorld.uni
FILE_GUID = 6987936E-ED34-44db-AE97-1FA5E4ED2116
MODULE_TYPE = UEFI_APPLICATION
VERSION_STRING = 1.0
ENTRY_POINT = UefiMain

#
# This flag specifies whether HII resource section is generated into PE image.
#
UEFI_HII_RESOURCE_SECTION = TRUE

#
# The following information is for reference only and not required by the build tools.
#
# VALID_ARCHITECTURES = IA32 X64 EBC
#
[Sources]
HelloWorld.c
HelloWorldStr.uni

[Packages]
MdePkg/MdePkg.dec
MdeModulePkg/MdeModulePkg.dec

[LibraryClasses]
UefiApplicationEntryPoint
UefiLib
PcdLib

[FeaturePcd]
gEfiMdeModulePkgTokenSpaceGuid.PcdHelloWorldPrintEnable ## CONSUMES

[Pcd]
gEfiMdeModulePkgTokenSpaceGuid.PcdHelloWorldPrintString ## SOMETIMES_CONSUMES
gEfiMdeModulePkgTokenSpaceGuid.PcdHelloWorldPrintTimes ## SOMETIMES_CONSUMES

[UserExtensions.TianoCore."ExtraFiles"]
HelloWorldExtra.uni

EDK的编译是通过make进行的,我们需要切换到EDK2的目录下的Conf文件夹修改target.txt来改变编译输出。target.txt里几个关键项:ACTIVE_PLATFORM指定要编译的平台dsc文件,不写清楚编译器都不知道该编谁;TARGET_ARCH是目标架构,一般填X64;TOOL_CHAIN_TAG是工具链,gcc环境对应GCC5;TARGET决定输出DEBUG还是RELEASE版本;MAX_CONCURRENT_THREAD_NUMBER是并行编译的线程数,机器核多就调大一点,编译快不少。

C的指针很强大

指针是C语言的核心,在深入EFI底层前,必须要先复习一下C指针的含义。原因很简单,固件里访问硬件寄存器靠的就是固定地址,调用固件服务也是一个装了一堆函数指针的结构体,指针玩不明白,后面的代码基本无从下手,随便写两句代码:

1
2
3
4
5
6
7
8
9
#include "stdio.h"

int main(){
int age = 20;
int *p_age = &age;
printf("age=%d,address=%p\n",age,&age);
printf("p_age=%d,address=%p,paddress=%p\n",*p_age,p_age,&p_age);
return 0;
}

利用gcc编译并运行,可以看到age与p_age的内存地址address是同一个,但是paddress却和address不同。这里&age和&p_age都是用取地址符拿到变量的地址,*p_age则是解引用,顺着指针找到它指的那块内存,把里面的值取出来。

p_age作为指向age内存的指针,内部必然存储了age变量的内存地址,对p_age变量而言,输出的address就是保存age的内存地址,而paddress输出的是指针在内存的位置,通俗地说就是一个是复制别人的,一个是自己保存的。再看两个地址的差值,0x7ffee75e2bb0减0x7ffee75e2bac正好是4,因为int占4个字节,这两个变量在栈上正好挨着放;而指针变量本身在64位系统里是占8个字节的,这两个别记混了。

1
2
3
4
5
root@minloha:~/LearnEFI/C/pointer# gcc main.c
root@minloha:~/LearnEFI/C/pointer# ./a.out
age=20,address=0x7ffee75e2bac
p_age=20,address=0x7ffee75e2bac,paddress=0x7ffee75e2bb0
root@minloha:~/LearnEFI/C/pointer#

pointer

指针就是保存地址的变量,拿到地址就可以顺着它去读写那块内存。比如:

1
2
3
int age = 20;
int *p_age = &age;
*p_age = 30;

这一段执行完,age就变成30了,因为*p_age = 30改的就是age所在的那块内存,相当于绕过了变量名,直接对内存下手。在底层操作中,直接操作变量是无意义的,因为无法从根本上修改变量内容,所以利用指针进行操作内存,直接修改数据是非常高效的。不过指针也是最容易出事的地方,野指针、悬空指针解引用轻则程序跑飞,重则机器直接重启,固件里尤其要小心,用指针之前脑子里必须清楚它指向哪块内存、那块内存是不是合法。

另外EFI里的指针还有一层特殊意义:固件启动时会传给程序一个SystemTable,里面是一个个装满函数指针的服务表,我们调用的打印、内存分配这些功能,其实都是顺着函数指针跳过去执行固件里的实现,这也算是把指针用到极致了。

编译出EFI

在EDK2目录中使用指令,可以编译出.efi文件。不过第一步得先把BaseTools编译出来,它是edk2自己的构建系统,里面有一大堆工具程序,没有它后面的编译流程根本走不动,-C就是切到对应目录执行make,所以命令长这样:

1
make -C BaseTools

第一次编译会出现问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
make[2]: Leaving directory '/root/LearnEFI/code/edk2/BaseTools/Source/C/DevicePath'
Finished building BaseTools C Tools with HOST_ARCH=X64
make[1]: Leaving directory '/root/LearnEFI/code/edk2/BaseTools/Source/C'
make -C Source/Python
make[1]: Entering directory '/root/LearnEFI/code/edk2/BaseTools/Source/Python'
make[1]: Nothing to be done for 'all'.
make[1]: Leaving directory '/root/LearnEFI/code/edk2/BaseTools/Source/Python'
make -C Tests
make[1]: Entering directory '/root/LearnEFI/code/edk2/BaseTools/Tests'
/bin/sh: 1: python: not found
make[1]: *** [GNUmakefile:11: test] Error 127
make[1]: Leaving directory '/root/LearnEFI/code/edk2/BaseTools/Tests'
make: *** [GNUmakefile:19: Tests] Error 2
make: Leaving directory '/root/LearnEFI/code/edk2/BaseTools'
root@minloha:~/LearnEFI/code/edk2#

没错,python: not found,原因是python版本问题:脚本里写的是python这个名字,而系统里只有python3,两者不是一回事,解决方法就是先找到python位置,然后就要把python3.8与python连接在一起。whereis列出来的路径里能看到系统装了2.7和3.8两个版本,软链接的时候一定要连3.8,别手滑连到python2.7上,不然脚本语法对不上,报的错会更奇怪。

1
2
3
4
5
6
root@minloha:~/LearnEFI/code/edk2# whereis python
python: /usr/bin/python3.8-config /usr/bin/python3.8 /usr/bin/python2.7 /usr/lib/python3.8 /usr/lib/python2.7 /usr/lib/python3.9 /etc/python3.8 /etc/python2.7 /usr/local/lib/python3.8 /usr/local/lib/python2.7 /usr/include/python3.8
root@minloha:~/LearnEFI/code/edk2# sudo ln -s /usr/bin/python3.8 /usr/bin/python
root@minloha:~/LearnEFI/code/edk2# python --version
Python 3.8.10
root@minloha:~/LearnEFI/code/edk2#

然后重新make一下就可以了。这里补充一句,BaseTools编译好只是把工具链备齐了,真正编译efi文件还得改好target.txt里的平台和架构,执行source edksetup.sh把WORKSPACE、EDK_TOOLS_PATH这些环境变量导进来,然后在edk2目录下跑build,编译产物才会出现在Build目录里。

为了运行.efi我们要一个软件qemu,安装一下吧。qemu本身只是个虚拟机,默认不带UEFI固件,所以还得配一份OVMF(Open Virtual Machine Firmware),这是edk2体系编译出来的X64固件,qemu加载它之后才会有UEFI界面和UEFI Shell:

1
apt install qemu-system-x86 ovmf

把编译出来的efi文件放进一个目录,qemu可以把这个目录当作一块FAT硬盘挂进去,大概这么用:

1
qemu-system-x86_64 -bios /usr/share/ovmf/OVMF.fd -drive format=raw,file=fat:rw:./esp

-bios指定固件路径,不同发行版放的位置不一样,路径不对的话用dpkg -L ovmf找一下就行;fat:rw:后面接的目录会被qemu模拟成FAT格式的硬盘,rw表示可读写。开机后在UEFI Shell里用fs0:切到那块盘,直接敲efi文件名就能运行,和双击exe差不多。

总结

这次把EFI是什么、edk2怎么装、C指针怎么理解、efi文件怎么编译出来整个过了一遍,中间踩的坑主要是python没软链导致BaseTools编译报错,qemu 和 OVMF 的用法也在上面记了。指针那节虽然是复习,但在固件里到处都是函数指针表和固定地址,绕不过去,建议多写两遍加深印象。感兴趣的可以把自己编的efi文件扔进qemu跑跑看,亲手写的Hello World从固件里打出来还是挺有成就感的。在了解这么多关于EFI的知识和C语言的操作后,相信你一定可以写出一个自己的EFI系统了,年三十晚的一篇文字。

新年快乐哟!


深入EFI了解背后的运行
https://blog.minloha.cn/posts/003100a90a5a042022013100.html
作者
Minloha
发布于
2022年1月31日
更新于
2022年1月31日
许可协议