很久以前就对操作系统很好奇,用了这么多年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开发的资料很少,我也是第一次接触这个,肯定有很多错误理解的地方,还请各位园友不吝赐教。
最近对开发操作系统很有兴趣,在学习过程中也希望和大家深入交流,谢谢 :)