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


    很久以前就对操作系统很好奇,用了这么多年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以后,我们把他解压到方便找到的目录里:

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

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

    然后打开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时会寻找启动盘EFIBoot目录下的bootx64.efi文件,而这个文件实际上是一个PE32+格式的应用程序,同时VC也提供了编译这种程序的支持,所以我们可以直接使用VS来编写Bootloader。创建项目以后为了方便管理我们可以设置输出目录和输出文件名。

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

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

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

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

    设置以下链接选项:

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

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

    • EFI_Toolkit_2.0includeefi
    • EFI_Toolkit_2.0includeefiem64t

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

    • EFI_Toolkit_2.0uildem64toutputliblibefi

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

    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.
    ");
    
    	UINT8* kernelBuffer;
    	EXIT_IF_NOT_SUCCESS(BS->AllocatePool(EfiLoaderData, KernelPoolSize, (void**)&kernelBuffer),
    		imageHandle, L"Cannot Allocate Tomato Kernel Buffer.
    ");
    	EXIT_IF_NOT_SUCCESS(fileHandle->Read(fileHandle, &KernelPoolSize, kernelBuffer),
    		imageHandle, L"Cannot Read Tomato Kernel File.
    ");
    	fileHandle->Close(fileHandle);
    
    	kernelFileBuffer = kernelBuffer;
    }
    

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

    static const wchar_t KernelFilePath[] = LR"(TomatoSystemOSKernel.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.
    ");
    

    上面这段函数中我们通过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开发的资料很少,我也是第一次接触这个,肯定有很多错误理解的地方,还请各位园友不吝赐教。

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

  • 相关阅读:
    LOG4J介绍
    基于AspectJ的XML方式进行AOP开发
    tsdb import 相关
    xming + putty remote GUI
    html5 编辑
    swift container server 莫名stuck
    rsyslog trouble shooting
    文件处理 字符串处理
    list去掉重复元素
    找到字符串中最长的回文
  • 原文地址:https://www.cnblogs.com/sunnycase/p/write-os-in-cpp-1-efi-based-bootloader.html
Copyright © 2020-2023  润新知