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程序,所以进入build\em64t目录,打开sdk.env并修改配置:
将选中部分修改为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。创建项目以后为了方便管理我们可以设置输出目录和输出文件名。
这样在部署OS的时候我们只需要将整个输出目录复制到启动分区上。
另外还要设置链接选项,将子系统设置为 EFI Application(重要):
另外要设置以下编译选项:
- 关闭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开发的资料很少,我也是第一次接触这个,肯定有很多错误理解的地方,还请各位园友不吝赐教。
最近对开发操作系统很有兴趣,在学习过程中也希望和大家深入交流,谢谢 :)
C++编写操作系统(1):基于 EFI 的 Bootloader的更多相关文章
- 基于UDS的BootLoader
bootloader程序架构略有简化的bootloader图 这张图和恒润教程中的BootLoader流程大体是一致的. 疑问点 Q:图中的烧写顺序是34-36-34-36-34-36-37,但另一些 ...
- spark学习之路1--用IDEA编写第一个基于java的程序打包,放standalone集群,client和cluster模式上运行
1,首先确保hadoop和spark已经运行.(如果是基于yarn,hdfs的需要启动hadoop,否则hadoop不需要启动). 2.打开idea,创建maven工程.编辑pom.xml文件.增加d ...
- 《HBase in Action》 第三章节的学习总结 ---- 如何编写和运行基于HBase的MapReduce程序
HBase之所以与Hadoop是最好的伙伴,我理解就因为两点:1.HADOOP的HDFS,为HBase提供了分布式的存储方式:2.HADOOP的MR为HBase提供的分布式的计算方法.u 其中第一点, ...
- C#编写了一个基于Lucene.Net的搜索引擎查询通用工具类:SearchEngineUtil
最近由于工作原因,一直忙于公司的各种项目(大部份都是基于spring cloud的微服务项目),故有一段时间没有与大家分享总结最近的技术研究成果的,其实最近我一直在不断的深入研究学习Spring.Sp ...
- 趣谈linux操作系统笔记-从BIOS到bootloader
BIOS 在主板上,有一个东西叫ROM(Read Only Memory,只读存储器).这和咱们平常说的内存RAM(Read Access Memory,随机存取存储器)不同. 而 ROM 是只读的, ...
- VM 操作系统实例化(基于 KVM 的虚拟化研究及应用--崔泽永(2011))的论文笔记
一.VM操作系统实例化 1.建立虚拟磁盘镜像 虚拟磁盘镜像在逻辑上是提供给虚拟机使用的硬盘, 在物理上可以是 L inux系 统内一普通镜像文件, 也可以是真实的物理磁盘或分区. 本方案设计中将虚拟机 ...
- 《30天自制操作系统》笔记4 --- (Day2 下节)了解如何用汇编写操作系统中的HelloWorld
关于上一节,我测试了发现3e.4c.4e都OK ,4b 4d 4f都进不去系统还把qemu卡死了. 50不会输出HelloWorld,可能需要hex偶数且在0x3e~4f区间吧.上节复制并运行命令如下 ...
- 从零开始编写操作系统——bochs
一.生成boot.bin boot sector代码: loop: jmp loop times -($-$$) db dw 0xaa55 重点就是最后的0xaa55 nasm boot.asm -f ...
- UEFI,BIOS,MBR,
UEFI启动是一种新的主板引导项,正被看做是有近20多年历史的BIOS 的继任者.顾名思义,快速启动是可以提高开机后操作系统的启动速度.由于开机过程中UEFI的介入 第一:安全性更强 UEFI启动需要 ...
随机推荐
- 主流存储引擎详解:Innodb,Tokudb、Memory、MYISAM、Federated
主流存储引擎: Innodb:推荐使用,主力引擎,使用99%以上的场景 Tokudb:高速写入使用,日用量大量写入eg:500G可压缩为50G.适用于访问日志的写入,相对MYISAM有事务性,相对于I ...
- posix thread API列表
互斥量: pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;int pthread_mutex_init(pthread_mutex_t *mutex ...
- 远程通信Socket
网络通信高性能的三个主题: 1) 传输:用什么样的通道将数据发送给对方,BIO.NIO或者AIO,IO模型在很大程度上决定了框架的性能: 2) 协议:采用什么样的通信协议,HTTP或者内部私有协议.协 ...
- java算法小知识练习
偶尔翻开了以前的练习题,不自觉又想随手敲一遍,虽然有些思想依然是那么老套,但毕竟也算是对知识的巩固 了. 一.题目:有1.2.3.4四个数字,能组成多少个互不相同且无重复数字的三位数?都是多少? 具体 ...
- 初步接触html心得
接触HTML大概有七天,做一下小总结,过过记忆. html大致可分为三部分:Dtd头.Head.Body三大部分. Dtd头:是用于浏览器编辑的,也就是俗话说的给电脑看的的东西. Head:内细分下大 ...
- ASP.NET网络硬盘(文件上传,文件下载)
文件上传: 界面: 前台代码: <body style="text-align: center; background-image: url(Images/bg6.bmp);" ...
- angularjs开发总结
使用AngularJS有差不多一年时间了,前前后后也用了不少库和指令,整理了一下,分成四大类列出.有demo地址的,就直接连接到demo地址,其它的直接链到github托管库中. 图片视频类 angu ...
- Java _Map接口的使用(转载)
转载自:http://blog.csdn.net/tomholmes7/article/details/2663379.转载请注明原作者地址 Map Map以按键/数值对的形式存储数据,和数组非常相似 ...
- UISearchController的使用。(iOS8+)
这种方法早就发现了,不过一致没用,今天拿过来用,发现了一些问题. 1.这个东西和表视图结合使用很方便,首先,创建新的工程,将表视图控制器作为工程的根视图,并且添加一个导航(当然,你可以不这样做,但是你 ...
- UITableViewCell和UITableViewHeaderFooterView的重用
不管是系统自带的还是自定义的UITableViewCell,对于它们合理的使用都是决定一个UITableView的性能的关键因素.应该确保以下三条: UITableViewCell的重复利用:首先对象 ...