计算机启动过程简介:

主引导记录为硬盘的第一个扇区,共512字节。计算机启动时,bios会检查主引导记录的最后两个字节(0x1FE:0x55,0x1FF:0xaa)来确定是否为引导代码。
在boot/cmake中start.S被写入到0x7c00处,然后将生成的二进制文件通过dd命令写入磁盘,再将写好的磁盘作为参数传递给qemu进行启动。如果bios检查主引导记录没有错误,程序运行的时候就会跳转到初始代码,也就是start.S处。进入到start.S(boot),也就代表着我们从bios手中接管了电脑的控制权。
boot的功能:跳转到loader。
根据上文所述,boot的大小只有512字节,所以更多的功能要放在loader中完成。
如何跳转到loader?
上文所述,boot的加载是由bios完成的,bios将boot处代码从磁盘加载到内存0x7c00处执行。所以要跳转到loader,我们也应该将loader的代码从磁盘上加载到内存中。loader在cmake中被链接到了0x8000地址处。
1 | mov $0x8000, %bx // 读取到的内存地址 |
关于利用中断方式读取磁盘,可以参考BIOS int 13H中断介绍-CSDN博客,这样我们就将loader代码加载到了内存中,接下来如何做呢?
1 | jmp boot_entry//跳转到加载loader的入口 |
boot_entry函数定义在boot.c中,所以我们要在start.S文件开头中加入
1 | .extern boot_entry |
来指示boot_entry是一个外部函数。这样我们就可以跳转到这个函数中运行。接下来的问题是,我们知道了loader在内存中的起始位置,如何跳转到这个指定好的位置呢?代码如下:
1 |
|
void (*)(void) 表示一个返回类型为 void 且无参数的函数指针类型,这样可以将指定的地址处强制转换为一个函数,并运行。这样我们就可以跳转到0x8000地址处运行了~
loader与保护模式
进入loader后,会首先执行*loader_entry()*,在这个函数中会执行两个功能:①检测内存。②切换到保护模式。
检测内存方法可以参考Detecting Memory (x86) - OSDev Wiki。因为这不是主要功能(相比于切换到保护模式来说重要性小了很多),所以不用特别在意。
保护模式
什么是保护模式?
在之前的代码中,cpu工作在实模式中,实模式的代码只有16位,内存地址空间也只有1MB,且功能十分简单。在接下来我们要切换到32位的保护模式,保护模式有着更大的地址空间(可访问4GB),更复杂、强大的功能(比如实模式不支持的特权级、内存分页等)。更多信息可以参考CPU的实模式和保护模式(一) - 知乎。
如何切换到保护模式?
进入保护模式我们有五个步骤要做:
①关中断
②打开A20地址线
③加载GDT表
④CR0->PE置1
⑤远跳转到32位环境
①关中断:处理器在模式切换过程中需要一个稳定的状态,而中断可能会打断切换流程,导致系统无法正常运行。直接用cli关中断即可。
②打开A20地址线:实模式共20根地址线(0~19),保护模式有32根,打开A20地址线即可启动剩余的地址线。开启方法参考A20 Line - OSDev Wiki。
③加载GDT表:关于GDT表之后再论述。将设置好的gdt表通过lgdt指令加载到gdtr寄存器即可。
1 | uint16_t gdt_table[][4] = { |
先按照这样设置即可。
④CR0->PE置1:
可以看到PE位是CR0寄存器的第一位,注意CR0无法直接读写,必须先将值读取到某个中间寄存器,修改值后,再将值回写到CR0中。
⑤远跳转到32位环境:使用ljmpl指令跳转到protect_mode_entry函数入口,在这个函数中首先要重设所有数据段描述符。在保护模式下,处理器使用段描述符来定义内存段的属性,如段的基地址、长度、访问权限等。当从实模式切换到保护模式时,处理器需要重新加载所有的数据段描述符,以确保在保护模式下正确地访问内存。设置好后就可以跳转到跳转到32位环境load_kernel
所以代码为:
1 | static void enter_protect_mode() |
关于8的含义以后再填坑,*cli(),inb()*等指令都是内联汇编函数而不是直接可以调用的指令。关于gcc内联汇编以后会专门写一篇blog来讲。
从保护模式切换到内核
现在我们已经成功进入32位的保护模式了,但是在进入内核之前还有一些检查工作要做。
第一点是要切换到使用LBA模式读取磁盘。进入保护模式后我们无法再用bios提供的中断进行磁盘的读取,所以要使用LBA模式。LBA48将硬盘上所有的扇区看成线性排列,没有磁盘、柱面等概念,因此访问起来更加简单,扇区序号从0开始。可以参考ATA PIO 模式 - OSDev Wiki。
第二点是读取内核在内存中的位置作为内核的入口。内核文件在编译时会被编译成ELF文件并放置在1M处(ELF(文件格式)_百度百科),在ELF文件中有许多段,我们要从中找到内核函数的入口。
在elf文件中,有两个重要的结构体:
1 | typedef struct |
这两个段的每个项的详细功能,可以man 5 elf 查看。我们要对elf文件进行一些处理。
①检查是否为ELF格式
②遍历程序头表,找到所有p_type为 PT_LOAD 的段,这些段需要被加载到内存中。
③加载段到内存,将 PT_LOAD 段的内容从文件中读取并复制到目标物理地址 p_paddr。如果段的内存大小大于文件大小(p_memsz > p_filesz),将多出来的部分用零填充(用于bss段的设置)。
④返回 ELF 文件头中的 e_entry。
1 | static uint32_t reload_elf_file(uint8_t *file_buffer) |
这时elf文件便做完了必要的设置,我们可以使用返回的入口值进入内核了。
恭喜!我们成功让计算机进入了自己的内核!
下一篇博客就开始从内核说起了。不知道会拖更多久🤯
注:本博客参考了李述铜老师的os课程,是个人的学习笔记。
有些问题李老师没讲清楚,本人加了一些自己的理解与思考。如果您发现我的笔记有问题,请给我发邮件批评指正。
本人邮箱:myslqyr@qq.com
一补
关于Cmake中的 -Ttext=0x7c00 的作用,这个选项指定了链接器(Linker)在生成可执行文件时将代码段(Code Segment)的起始地址设置为 0x7c00。但是bios默认会加载这段代码到0x7c00处,这样感觉像多此一举。但是如果不设置这个选项,代码段设置的默认起始地址值可能就会跟我们想要的0x7c00不一样,所以要设置(本人粗略的分析,不一定对)。