唐凌 发表于 2020-2-28 13:02:11

【UEFI】【傻瓜式教程】写一个EFI的Hello World

“以后只能在垃圾站里找只能MBR启动的机器了”
这话我信,所以引出了本文,写一个在uefi上跑的hello world。

运行efi的环境不必多说,直接上vmware就行了,不过一定要14版及以上的,否则没有UEFI。本人使用的是15.5.1版。
创建虚拟机的时候,一定要在选项里记得把固件换成UEFI。不过如果真机支持UEFI请一定要在真机上试试。

接下来是编译器,这里选择LLVM。不选GCC是因为MinGW似乎对我有仇,在我的机器上总是安装失败,于是放弃了。
LLVM可以在GitHub的LLVM发布页下载:https://github.com/llvm/llvm-project/releases
鉴于本文是选择在Win64上开发,因此下载时选择win64的LLVM安装包,然后安装到默认目录里。
编写EFI时可以使用开源的开发包EDK II。可以在TianoCore的GitHub官网上下载release版:https://github.com/tianocore/edk2/releases
注意我们要下载它的源码包,同时注意它的开源条例是BSD-2-Clause-Patent。
本人下载的是2019年11月稳定版,压缩包里有一个文件夹,提取出来放进C盘,本人将其重命名为"UefiDKII"。
随地创建一个空目录作为工程目录,在里面创建一个目录"compllvm_uefix64",这个目录名的含义是用llvm编译到x64环境的uefi中。再在这个目录里创建一个Intermediate目录,用于存放中间文件。
在工程目录里,创建一个批处理文件,名曰"compllvm_uefix64.bat"。这个批处理文件将是我们编译代码的批处理文件,双击开始编译。
创建一个源码文件,名曰efimain.c。这个C源码文件将包含我们写hello world的源码。

正式开始编码前我们先讲明白几件事:
在AMD64架构中,UEFI中调用约定使用的是微软规定的AMD64下fastcall调用约定,因此用GCC写的话就需要在意一下如何使用微软的调用约定了。
EFI提供了很多函数,使用高级语言进行开发时效率可以极快。但如果要写操作系统,这些函数一个都别出现在操作系统内核里。比如读文件读磁盘的函数出现在加载器里用来加载内核及相关驱动就行了。
由于刚启动,所以机器是单核运行的(代码运行在BSP上)。UEFI虽然没有多线程,不过却有函数可以调用其他的核心(AP),不需要自己构造IPI了。
更多详情请查阅UEFI的文档:https://uefi.org/specifications

既然我们使用EDK,自然要包含它的头文件,这个头文件叫Uefi.h,因此我们写上:
#include <Uefi.h>
这里似乎真的在把读者当成傻瓜了呢,毕竟是傻瓜式教程。不过我要强调一下,LLVM的编译器会纠结头文件名的大小写,大小写有误就会警告,而警告就是个麻烦事,所以我们要规避它。
一个C程序必然会有入口函数,EFI当然也是如此,它的函数原型如下:
typedef EFI_STATUS (EFIAPI *EFI_IMAGE_ENTRY_POINT) (IN EFI_HANDLE ImageHandle,IN EFI_SYSTEM_TABLE *SystemTable);
因此入口函数可以这么写:
EFI_STATUS EfiMain(IN EFI_HANDLE ImageHandle,IN EFI_SYSTEM_TABLE *SystemTable)
参数SystemTable将是最常用的,EFI_SYSTEM_TABLE结构体如下:
///
/// EFI System Table
///
typedef struct {
///
/// The table header for the EFI System Table.
///
EFI_TABLE_HEADER                  Hdr;
///
/// A pointer to a null terminated string that identifies the vendor
/// that produces the system firmware for the platform.
///
CHAR16                            *FirmwareVendor;
///
/// A firmware vendor specific value that identifies the revision
/// of the system firmware for the platform.
///
UINT32                            FirmwareRevision;
///
/// The handle for the active console input device. This handle must support
/// EFI_SIMPLE_TEXT_INPUT_PROTOCOL and EFI_SIMPLE_TEXT_INPUT_EX_PROTOCOL.
///
EFI_HANDLE                        ConsoleInHandle;
///
/// A pointer to the EFI_SIMPLE_TEXT_INPUT_PROTOCOL interface that is
/// associated with ConsoleInHandle.
///
EFI_SIMPLE_TEXT_INPUT_PROTOCOL    *ConIn;
///
/// The handle for the active console output device.
///
EFI_HANDLE                        ConsoleOutHandle;
///
/// A pointer to the EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL interface
/// that is associated with ConsoleOutHandle.
///
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL   *ConOut;
///
/// The handle for the active standard error console device.
/// This handle must support the EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL.
///
EFI_HANDLE                        StandardErrorHandle;
///
/// A pointer to the EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL interface
/// that is associated with StandardErrorHandle.
///
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL   *StdErr;
///
/// A pointer to the EFI Runtime Services Table.
///
EFI_RUNTIME_SERVICES            *RuntimeServices;
///
/// A pointer to the EFI Boot Services Table.
///
EFI_BOOT_SERVICES               *BootServices;
///
/// The number of system configuration tables in the buffer ConfigurationTable.
///
UINTN                           NumberOfTableEntries;
///
/// A pointer to the system configuration tables.
/// The number of entries in the table is NumberOfTableEntries.
///
EFI_CONFIGURATION_TABLE         *ConfigurationTable;
} EFI_SYSTEM_TABLE;
代码摘自EDK的头文件,所以带了很多注释。我们可以明显注意到几个参数ConIn,ConOut,StdErr,这不就相当于stdin,stdout,stderr么。
因此输出文本的时候我们自然选择ConOut。这个结构成员也是指向一个结构体EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL。这里我们重点关心的是它的函数OutputString。
那么我们输出hello world的时就调用这个函数就行。不过值得注意的是,在UEFI环境里,换行是CRLF,因此一定要\r\n。那么代码就这么写:
SystemTable->ConOut->OutputString(SystemTable->ConOut,L"Hello UEFI World!\r\n");
第一个参数直接放ConOut,第二个参数放文本。注意,这个函数不支持格式化,不能当fprintf用。
返回时直接返回一个EFI_SUCCESS即可。那么总的来说,我们efimain.c的代码如下:
#include <Uefi.h>

EFI_STATUS EfiMain(IN EFI_HANDLE ImageHandle,IN EFI_SYSTEM_TABLE *SystemTable)
{
        SystemTable->ConOut->OutputString(SystemTable->ConOut,L"Hello UEFI World!\r\n");
        return EFI_SUCCESS;
}
接下来讲如何编译代码。

我们刚才说过本文采用双击批处理执行编译,那么这里开始谈编译。不要忘记前文提到要创建的目录!
熟悉visual c++编译流程的话应该知道visual studio是调用cl.exe编译到中间文件,然后调用link.exe把中间文件链接起来。这里也是同理。
LLVM有向MSVC兼容版的clang-cl.exe和lld-link.exe,因此我们在批处理文件里需要调用它们,并且带上相关的参数。不过这里要强调两点:
1. 编译器要注意用/I参数设置头文件,并且标记/Gr让编译器编译到微软的fastcall调用约定。
2. 链接器要注意设置子系统,我们要将其设置为EFI_APPLICATION,并且注意设置入口函数和处理器架构,此外还要指示链接器不链接任何默认库。
具体的批处理代码如下:
@echo off
set edkpath=C:\UefiDKII
set binpath=.\compllvm_uefix64
set objpath=.\compllvm_uefix64\Intermediate

title Compiling Hello UEFI, LLVM, UEFI (AMD64 Architecture)
echo Project: HelloUefi
echo Platform: Unified Extensible Firmware Interface
echo Preset: LLVM

echo ============Start Compiling============
clang-cl .\efimain.c /I"%edkpath%\MdePkg\Include" /I"%edkpath%\MdePkg\Include\X64" /Zi /nologo /W3 /WX /Od /Oy- /Fa"%objpath%\efimain.cod" /Fo"%objpath%\efimain.obj" /Fd"%objpath%\vc90.pdb" /GS- /Gr /TC /c /errorReport:queue

echo ============Start Linking============
lld-link "%objpath%\efimain.obj" /NODEFAULTLIB /NOLOGO /OUT:"%binpath%\bootx64.efi" /SUBSYSTEM:EFI_APPLICATION /ENTRY:"EfiMain" /Machine:X64 /ERRORREPORT:QUEUE

echo Completed!
pause.
双击后应该能在compllvm_uefix64目录里看到bootx64.efi,看到这个文件就可以说明你编译成功了。

接下来讲运行EFI,为了能直接运行在真机上,这里就不说怎么搞磁盘映像文件了,我们直接搞一个U盘来做。首先找一个空闲U盘,以GPT方式将其格式化为FAT32文件系统。在根目录里创建一个名曰EFI的文件夹,然后在这个文件夹里再创建一个名曰BOOT的文件夹,把我们刚才的bootx64.efi丢进去。也就是说,你的U盘现在有一个文件,路径是\EFI\BOOT\bootx64.efi。如果你的U盘原本是MBR分区的,那可能就需要使用一些小工具了,比如rufus。
启动虚拟机进入UEFI中,然后把U盘直通进虚拟机中,依次选择Enter Setup,Configure boot options,Add boot option。如果你重命名过你的U盘,那么你应该看见一个选项(一般是第一个)写了你U盘的名字,选择它,然后依次EFI\BOOT\bootx64.efi。回车后在Input the description里面填写对它的描述,这个描述会作为这个启动项的名称。如果觉得有必要就在Input optional data里也填一些玩意。填写完毕后选择Commit changes and exit来保存这个启动项的信息。
返回到最开始的页面后,找到你刚创建的启动项,回车!然后。。。
emmmmmmm,你会发现虚拟机屏幕黑了一下又返回了。实际上是因为我们EFI程序执行完之后就直接返回了。为了能看见我们的输出,我们进入EFI Shell里。直接输入bootx64.efi并回车后,就能看见执行结果了!

是不是很激动?或许你觉得还要输入一下文件名才能看到执行结果这一点实在是太不真实了。这里我们就要修正一下代码了,让你能在boot时长时间看到输出。

原理很简单,阻塞程序就行了。这里不使用死循环,毕竟这真的不是个好选择。。。既然有相当于stdin的ConIn,我们就利用它来实现阻塞。ConIn中有一个成员叫WaitForKey,这是个等待事件,当有键盘敲击时就解除阻塞。然后我们就用BootService->WaitForEvent来等待这个成员。
不过我们还是设计为“按回车后返回到UEFI”比较好,那么这样的话,我们创建一个循环,每次按键后判断是不是回车,是回车才退出循环即可。
读取按键用ConIn->ReadKeyStroke函数,它会把按键信息放进EFI_INPUT_KEY结构体中,这个结构体如下:
typedef struct {
UINT16 ScanCode;
CHAR16 UnicodeChar;
} EFI_INPUT_KEY;
判断按键是不是回车就看UnicodeChar的值了,注意这是Unicode!那么我们修改efimain.c,修改后的代码如下:
#include <Uefi.h>

EFI_STATUS EfiMain(IN EFI_HANDLE ImageHandle,IN EFI_SYSTEM_TABLE *SystemTable)
{
        EFI_INPUT_KEY InKey;
        SystemTable->ConOut->OutputString(SystemTable->ConOut,L"Hello UEFI World!\r\n");
        SystemTable->ConOut->OutputString(SystemTable->ConOut,L"Press enter to continue...\r\n");
        do
        {
                UINTN fi=0;
                SystemTable->BootServices->WaitForEvent(1,&SystemTable->ConIn->WaitForKey,&fi);
                SystemTable->ConIn->ReadKeyStroke(SystemTable->ConIn,&InKey);
        }while(InKey.UnicodeChar!=L'\r');
        return EFI_SUCCESS;
}
编译后丢到U盘里,进入固件后选择所创建的启动项,终于可以看到预期的输出了!

按回车后返回到了UEFI固件界面里。即便你调整启动项顺序,使得它第一个启动,按回车后依然会进入UEFI固件。

最后就是在实机上测试啦。做出来的坛友请把实机的显示结果拍屏(只有摄像设备的话)或截屏(有视频信号采集设备的话)发上来吧。

唐凌 发表于 2020-3-8 06:38:24

Ink_Hin_fifteen 发表于 2020-3-2 14:41
有没有可能实现图形画面。

我回头实现了一遍在UEFI里实现图形输出。这里实现了在UEFI里显示一个1280*720分辨率的BMP图片文件。
图片来自B站vtuber新科娘的直播截图。

Golden Blonde 发表于 2020-2-29 06:20:11

这个厉害了,早在2014年我就想搞EFI程序,结果当时这句代码死活编译不过去:EfiSocketInit(ImageHandle,SystemTable);搞了很久都没成功,顿时兴趣索然,就放弃了。

Ink_Hin_fifteen 发表于 2020-3-2 14:39:38

大...大...大...好大的命令行。

Ink_Hin_fifteen 发表于 2020-3-2 14:41:14

Ink_Hin_fifteen 发表于 2020-3-2 14:39
大...大...大...好大的命令行。

有没有可能实现图形画面。

唐凌 发表于 2020-3-3 05:16:32

Ink_Hin_fifteen 发表于 2020-3-2 14:41
有没有可能实现图形画面。

UEFI有图形输出接口,如果只要输出个图片自然是不难的。

元始天尊 发表于 2020-3-3 09:48:54

楼主在底层的道路上一去不返。从驱动搞到微指令,现在搞BIOS了!
页: [1]
查看完整版本: 【UEFI】【傻瓜式教程】写一个EFI的Hello World