C++编写操作系统(1):基于 EFI 的 Bootloader

时间:2024-01-16 20:40:20

很久以前就对操作系统很好奇,用了这么多年Windows,对他的运作机理也不是很清楚,所以一直想自己动手写一个,研究一下操作系统究竟是怎么实现的。后来在网上也找到过一些教程(比如:《自己动手写操作系统》),大都是先要用汇编写活动分区的第一个扇区(MBR)。13年4月左右我也曾经跟着教程尝试过,用汇编调用BIOS中断读扇区、加载Bootstrap。不得不说用汇编很容易出错,可读性也不好,所以这次我就想能不能完全不用汇编写操作系统。

UEFI

经过一番搜索,我找到了一个叫UEFI的东西,下面是它的简单介绍:

统一可扩展固件接口Unified Extensible Firmware Interface, UEFI)是一种个人电脑系统规格,用来定义操作系统与系统固件之间的软件界面,作为BIOS的替代方案。可扩展固件接口负责加电自检(POST)、连系操作系统以及提供连接操作系统与硬件的接口。

——摘自*

简而言之,(U)EFI就是一个用来替代传统BIOS的规范,OS启动阶段我们可以不再和麻烦的BIOS打交道了。而且因为UEFI完全使用C风格的编程接口,意味着我们可以只用C、C++来引导我们的OS。开发UEFI可以使用EDK,然而进过一番比较,intel的EFI Toolkit虽然已经不再更新,但使用简单,对于我们开发Bootloader来说已经足够了,因此我选择了使用EFI Toolkit来开发EFI程序。

1. 编译 EFI Toolkit

下载好EFI Toolkit以后,我们把他解压到方便找到的目录里:

C++编写操作系统(1):基于 EFI 的 Bootloader

由于我是开发运行于intel 64 架构的EFI程序,所以进入build\em64t目录,打开sdk.env并修改配置:

C++编写操作系统(1):基于 EFI 的 Bootloader

将选中部分修改为VC AMD64编译器的目录("XXX\Microsoft Visual Studio 14.0\VC\bin\amd64",记得要加双引号)。

然后打开VS 2015(其他版本也可以)x64本地工具命令提示符,切换到EFI Toolkit目录,执行build em64t,然后执行nmake。

一段时间后EFI Toolkit就编译完成了。

2.编写一个Bootloader

BootLoader是系统加电启运行的第一段软件代码,回忆一下PC的体系结构我们可以知道,PC机中的引导加载程序由BIOS(其本质就是一段固件程序)和位于硬盘MBR中的引导程序一起组成。BIOS在完成硬件检测和资源分配后,将硬盘MBR中的引导程序读到系统的RAM中,然后将控制权交给引导程序。引导程序的主要运行任务就是将内核映象从硬盘上读到RAM中 然后跳转到内核的入口点去运行,也即开始启动操作系统。

——摘自互动百科

2.1配置项目

EFI程序有很多种类型,我们写的Bootloader属于EFI Application中的OSLoader。EFI在启动OS时会寻找启动盘EFI\Boot目录下的bootx64.efi文件,而这个文件实际上是一个PE32+格式的应用程序,同时VC也提供了编译这种程序的支持,所以我们可以直接使用VS来编写Bootloader。创建项目以后为了方便管理我们可以设置输出目录和输出文件名。

C++编写操作系统(1):基于 EFI 的 Bootloader

这样在部署OS的时候我们只需要将整个输出目录复制到启动分区上。

另外还要设置链接选项,将子系统设置为 EFI Application(重要):

C++编写操作系统(1):基于 EFI 的 Bootloader

另外要设置以下编译选项:

  • 关闭C++异常
  • 设置基本运行时检查为 Default
  • 关闭安全检查(/GS-)

设置以下链接选项:

  • 忽略默认库(/NODEFAULTLIB)
  • 添加额外库:libefi.lib
  • 关闭UAC支持(/MANIFESTUAC:NO)
  • 关闭随机基址(/DYNAMICBASE:NO)
  • 关闭DEP支持(/NXCOMPAT:NO)
  • 设置入口点(比如:efi_main)

同时设置VC++目录,添加以下目录到Include目录中:

  • EFI_Toolkit_2.0\include\efi
  • EFI_Toolkit_2.0\include\efi\em64t

添加一下目录到Lib目录中:

  • EFI_Toolkit_2.0\build\em64t\output\lib\libefi

一大堆东西。。终于弄好了之后就可以编写我们的代码了。

2.2编写代码

EFI程序的入口定义如下:

EFI_STATUS __cdecl efi_main(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable)

其中efi_main的名字随便起,记得在链接选项中设置入口点就好。另外有两个参数:

  • ImageHandle 就是我们Bootloader被LoadImage加载后的句柄,从中我们可以得到一些信息,之后会用到。
  • SystemTable 包含了EFI提供给我们的所有服务,我们和硬件打交道就靠他了,里面包含了各种能用到的 API。

2.2.1 加载内核文件

Bootloader要做的最重要事之一就是加载内核,而第一步我们需要从硬盘或者其他存储设备读取内核文件到内存,EFI给我们提供了很方便的手段来进行这个过程。

我们假定内核文件和Bootloader在同一个分区,我们可以获取这个分区的句柄。

在入口函数加载 efilib

InitializeLib(ImageHandle, SystemTable);

获取Bootloader所在的卷的句柄:

void KernelFile::LoadKernelFile()
{
EFI_LOADED_IMAGE* loadedImage;
EFI_FILE_IO_INTERFACE* volume;
// 获取 Loader 所在的卷
BS->HandleProtocol(imageHandle, &LoadedImageProtocol, (void**)&loadedImage);
BS->HandleProtocol(loadedImage->DeviceHandle, &FileSystemProtocol, (void**)&volume);

所谓Protocol就类似与接口的概念,一个句柄就相当于一个类的实例,我们利用BootServices提供的HandleProtocol函数可以获取这个类的一个接口——第一个参数是句柄,第二个参数是Protocol的GUID,第三个参数是Protocol的指针。看到这种用法不知道有没有人想起COM ←_←。

接下来是LoadKernelFile方法的剩余部分:

     EFI_FILE_HANDLE rootFS, fileHandle;
volume->OpenVolume(volume, &rootFS);
// 读取文件
EXIT_IF_NOT_SUCCESS(rootFS->Open(rootFS, &fileHandle, (CHAR16*)KernelFilePath, EFI_FILE_MODE_READ, 0),
imageHandle, L"Cannot Open Tomato Kernel File.\r\n"); UINT8* kernelBuffer;
EXIT_IF_NOT_SUCCESS(BS->AllocatePool(EfiLoaderData, KernelPoolSize, (void**)&kernelBuffer),
imageHandle, L"Cannot Allocate Tomato Kernel Buffer.\r\n");
EXIT_IF_NOT_SUCCESS(fileHandle->Read(fileHandle, &KernelPoolSize, kernelBuffer),
imageHandle, L"Cannot Read Tomato Kernel File.\r\n");
fileHandle->Close(fileHandle); kernelFileBuffer = kernelBuffer;
}

这段代码中我们从上面获取的分区接口得到一个根目录的接口,又利用这个根目录接口得到我们内核文件的接口,其中第三个参数是文件的路径:

static const wchar_t KernelFilePath[] = LR"(Tomato\System\OSKernel.exe)";

之后我们利用BootServices提供的内存管理功能分配一个KernelPoolSize大小的内存,然后利用刚刚获取的内核文件接口将文件内容读取到内存中。

2.2.2 解析内核文件

内核文件已经加载到内存了,由于内核文件实际上是一个PE格式的应用程序,我们需要像Windows一样解析他,并将需要的内容读取出来放到内存该放的地方。

PE文件的头部在 pe.h 中有定义。

首先我们验证PE文件的有效性:

bool KernelFile::ValidateKernel()
{
IMAGE_DOS_HEADER* dosHeader = (IMAGE_DOS_HEADER*)kernelFileBuffer;
if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE)
return FALSE;
IMAGE_NT_HEADERS* ntHeaders = (IMAGE_NT_HEADERS*)(kernelFileBuffer + dosHeader->e_lfanew);
if (ntHeaders->Signature != IMAGE_NT_SIGNATURE)
return FALSE;
return TRUE;
}

具体算法是验证DOS头的MZ标志和PE头的PE00标志。

如果文件是有效的PE映像,我们接下来需要解析包含的每一个节,并复制到另一块内存里:

void Bootloader::PrepareKernel(KernelFile& file)
{
if (file.ValidateKernel())
{
auto kernelImageBase = file.GetKernelFileBuffer();
IMAGE_DOS_HEADER* dosHeader = (IMAGE_DOS_HEADER*)kernelImageBase;
IMAGE_NT_HEADERS* ntHeaders = (IMAGE_NT_HEADERS*)(kernelImageBase + dosHeader->e_lfanew); sectionStart = (IMAGE_SECTION_HEADER*)(((UINT8*)ntHeaders) + sizeof(IMAGE_NT_HEADERS)
- sizeof(IMAGE_OPTIONAL_HEADER) + ntHeaders->FileHeader.SizeOfOptionalHeader);
sectionCount = ntHeaders->FileHeader.NumberOfSections;
UINTN sectionAlign = ntHeaders->OptionalHeader.SectionAlignment;
UINTN fileAlign = ntHeaders->OptionalHeader.FileAlignment; UINTN memAllocPages = GetAllSectionsMemoryPages(sectionStart, sectionCount);
EXIT_IF_NOT_SUCCESS(BS->AllocatePages(AllocateAnyPages, KernelPoolType, memAllocPages,
&kernelMemBuffer), imageHandle, L"Cannot allocate Kernel Memory.\r\n");

上面这段函数中我们通过GetAllSectionsMemoryPages函数计算得到PE文件中所有节的总页数,然后利用BootServices的AllocatePages函数分配内存页,至于为什么要按页的大小对齐,是因为我们之后做内存分页的要求。

          IMAGE_SECTION_HEADER* section = sectionStart;
UINT8* memBuffer = (UINT8*)kernelMemBuffer;
for (UINTN i = 0; i < sectionCount; i++, section++)
{
BOOLEAN dataInFile = section->PointerToRawData != 0;
UINT8* sectionData = kernelImageBase + section->PointerToRawData;
UINTN memAllocSize = AlignSize(section->SizeOfRawData, EFI_PAGE_SIZE); if (memAllocSize)
{
if (dataInFile)
CopyMem(memBuffer, sectionData, section->SizeOfRawData);
memBuffer += memAllocSize;
}
}

接下来我们将每一节的内容复制到刚才分配的内存中去。

2.2.3 分页

我们到目前为止一直使用的是内存的物理地址,这样虽然简单但有一个问题:如果内核的基址很大,超出了物理内存范围那么我们将没有办法执行内核。为了解决这个问题我们需要引入虚拟地址。关于分页intel的手册上有详尽的说明,在这里我是用IA32e分页模式,这种模式工作在64位模式下,我们可以使用48位虚拟地址,管理256TB的内存(物理或虚拟)。

然而如果对整个地址空间进行分页,内存会被极大地浪费,甚至会装不下,因此IA32e分页模式可以使用4级页表。这样我们就可以针对其中的一段地址空间将页表保存在物理内存中,其他地址空间我们可以将其页表的Present位设为0,以表示不在物理内存中,大大减少内存的占用。

我的内核基址是0x140000000,也就是5GB的位置。

void MappingKernelAddress(EFI_HANDLE ImageHandle, EFI_PHYSICAL_ADDRESS kernelMemBuffer,
IMAGE_SECTION_HEADER* section, UINTN sectionCount, PDPTable& pdpTable)
{
enum : uint64_t {
KernelPML4EIndex = KernelImageBase / PML4EntryManageSize,
KernelPML4ERest = KernelImageBase % PML4EntryManageSize,
KernelPDPEIndex = KernelPML4ERest / PDPEntryManageSize,
KernelPDPERest = KernelPML4ERest % PDPEntryManageSize,
KernelPDEIndex = KernelPDPERest / PDEntryManageSize,
KernelPDERest = KernelPDPERest % PDEntryManageSize,
KernelPTEIndex = KernelPDERest / PTEntryManageSize,
KernelPTERest = KernelPDERest % PTEntryManageSize
}; auto& kernelPageDir = *AllocatePageDirectory(ImageHandle);
auto& kernelPageDirRef = pdpTable[KernelPDPEIndex];
kernelPageDirRef.Present = TRUE;
kernelPageDirRef.ReadWrite = TRUE;
kernelPageDirRef.SetPTEntryAddress(kernelPageDir);

我们先分配一个Page Directory(页目录,映射 1 GB),将其Present设为TRUE,表示在物理内存中,并将其挂在到上一级Page Directory Pointer Table (映射 512 GB)上。然后按内核的每一个节的虚拟地址填写对应的页表和页表项,并映射到物理地址:

	uint8_t* physicalAddr = (uint8_t*)kernelMemBuffer;
for (size_t i = 0; i < sectionCount; i++)
{
auto& curSection = section[i];
if (curSection.SizeOfRawData)
{
auto dataSize = AlignSize(curSection.SizeOfRawData, EFI_PAGE_SIZE); // 起始 Page Table Index
auto curPTIndex = curSection.VirtualAddress / PDEntryManageSize;
auto restToMap = dataSize;
uint8_t* startVirtualAddress = (uint8_t*)(KernelPDPEIndex * PDPEntryManageSize +
curPTIndex * PDEntryManageSize); for (; restToMap; curPTIndex++)
{
auto& pageTableRef = kernelPageDir[curPTIndex];
// 如果未分配则分配页表
if (!pageTableRef.Present)
{
pageTableRef.SetPageTableAddress(*AllocatePageTable(ImageHandle));
pageTableRef.Present = TRUE;
pageTableRef.ReadWrite = TRUE;
}
PageTable& pageTable = pageTableRef.GetPageTableAddress();
auto curPEIndex = (curSection.VirtualAddress % PDEntryManageSize)
/ PTEntryManageSize;
auto curVirtualAddress = startVirtualAddress + curPEIndex * PTEntryManageSize; for (size_t j = curPEIndex; j < __crt_countof(pageTable); j++)
{
auto& ptEntry = pageTable[j];
ptEntry.SetPhysicalAddress(physicalAddr);
ptEntry.Present = TRUE;
ptEntry.ReadWrite = TRUE; physicalAddr += EFI_PAGE_SIZE;
curVirtualAddress += PTEntryManageSize;
restToMap -= PTEntryManageSize;
if (!restToMap)break;
}
}
}
}
}

接下来用类似的方法映射内存的前 1 GB(EFI的Runtime Services会用到),之后启用分页:

// 启用分页
void Bootloader::EnablePaging()
{
// 分配 PML4Table
auto& pml4Table = *AllocatePML4Table(imageHandle);
// 分配 PDPTable
auto& pdpTable = *AllocatePDPTable(imageHandle);
// 映射前 1 GB
MappingLow1GB(imageHandle, pdpTable);
// 映射内核所在的 1 GB
MappingKernelAddress(imageHandle, kernelMemBuffer, sectionStart, sectionCount, pdpTable); // 映射前 512 GB
auto& pdpTableRef = pml4Table[0];
pdpTableRef.SetPDPTableAddress(pdpTable);
pdpTableRef.Present = TRUE;
pdpTableRef.ReadWrite = TRUE; EnableIA32ePaging(pml4Table);
}

启用IA32e分页需要设置一系列寄存器:

inline void EnableIA32ePaging(const PML4Table& pml4Table)
{
const PML4Entry* addr = pml4Table;
uint64_t cr3 = __readcr3();
cr3 &= ~CR3_PML4_MASK;
cr3 |= ((uint64_t)addr) & CR3_PML4_MASK;
// 将页表存入 cr3
__writecr3(cr3); // 启用分页
tagCR0 cr0 = __readcr0();
cr0.PG = 1;
__writecr0(cr0.value); // 启用 PAE
tagCR4 cr4 = __readcr4();
cr4.PAE = 1;
__writecr4(cr4.value); // 启用 IA32e 分页
tagMSR_IA32_EFER ia32Efer = __readmsr(MSR_IA32_EFER);
ia32Efer.LME = 1;
__writemsr(MSR_IA32_EFER, ia32Efer.value);
}

至此分页完成。

2.2.4 配置 EFI Runtime Services

由于我们进入了分页模式,使用了虚拟地址,我们需要通知EFI更改他内部的指针,以适应这个变化。不过由于我做的前1GB分页是1:1分页,虚拟地址=物理地址,所以只需要简单的赋值:

void Bootloader::PrepareVirtualMemoryMapping()
{
UINTN entries, mapKey, descriptorSize;
UINT32 descriptorVersion;
EFI_MEMORY_DESCRIPTOR* descriptor = LibMemoryMap(&entries, &mapKey, &descriptorSize, &descriptorVersion); BS->ExitBootServices(imageHandle, mapKey); EFI_MEMORY_DESCRIPTOR* memoryMapEntry = descriptor;
for (UINTN i = 0; i < entries; i++)
{
if (memoryMapEntry->Attribute & EFI_MEMORY_RUNTIME)
{
memoryMapEntry->VirtualStart = memoryMapEntry->PhysicalStart;
}
memoryMapEntry = NextMemoryDescriptor(memoryMapEntry, descriptorSize);
} EFI_STATUS status = RT->SetVirtualAddressMap(entries * descriptorSize, descriptorSize,
EFI_MEMORY_DESCRIPTOR_VERSION, descriptor);
if (EFI_ERROR(status))
RT->ResetSystem(EfiResetWarm, EFI_LOAD_ERROR, 62, (CHAR16*)L"Setting Memory mapping failed."); params.MemoryDescriptor = descriptor;
params.MemoryDescriptorSize = descriptorSize;
params.MemoryDescriptorEntryCount = entries;
}

先利用LibMemoryMap获取当前的内存分布图,并针对属性带有EFI_MEMORY_RUNTIME的每一项设置他的VirtualStart(本例中=物理地址),最后调用Runtime Services的SetVirtualAddressMap函数通知EFI更改指针。

2.2.5 启动内核

内核加载了,分页也做了,EFI也配置过了,终于我们要进入新的世界了(←_←

从内核文件中读出入口点,调用,over~

void Bootloader::RunKernel(KernelEntryPoint entryPoint)
{
entryPoint(params);
}

后记

第一次写博客,可能代码堆得多了点,今后会努力改进。另外由于EFI开发的资料很少,我也是第一次接触这个,肯定有很多错误理解的地方,还请各位园友不吝赐教。

最近对开发操作系统很有兴趣,在学习过程中也希望和大家深入交流,谢谢 :)