计算机启动过程简介:

启动过程

主引导记录为硬盘的第一个扇区,共512字节。计算机启动时,bios会检查主引导记录的最后两个字节(0x1FE:0x55,0x1FF:0xaa)来确定是否为引导代码。
boot/cmakestart.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
2
3
4
5
6
7
8
mov $0x8000, %bx	// 读取到的内存地址
mov $0x2, %cx
mov $0x2, %ah //读磁盘命令
mov $64, %al // 读取的扇区数量
mov $0x0080, %dx // dh: 磁头号,dl驱动器号0x80(磁盘1)
int $0x13
jc read_loader
jmp boot_entry

关于利用中断方式读取磁盘,可以参考BIOS int 13H中断介绍-CSDN博客,这样我们就将loader代码加载到了内存中,接下来如何做呢?

1
jmp boot_entry//跳转到加载loader的入口

boot_entry函数定义在boot.c中,所以我们要在start.S文件开头中加入

1
.extern boot_entry

来指示boot_entry是一个外部函数。这样我们就可以跳转到这个函数中运行。接下来的问题是,我们知道了loader在内存中的起始位置,如何跳转到这个指定好的位置呢?代码如下:

1
2
3
4
5
6
#define LOADER_START_ADDR 0x8000 // loader的地址

void boot_entry(void)
{
((void (*)(void))LOADER_START_ADDR)();
}

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
2
3
4
5
uint16_t gdt_table[][4] = {
{0, 0, 0, 0}, // 空描述符
{0xFFFF, 0x0000, 0x9A00, 0x00CF}, // 代码段描述符
{0xFFFF, 0x0000, 0x9200, 0x00CF}, // 数据段描述符
};

先按照这样设置即可。
④CR0->PE置1:cr0寄存器结构
可以看到PE位是CR0寄存器的第一位,注意CR0无法直接读写,必须先将值读取到某个中间寄存器,修改值后,再将值回写到CR0中。
⑤远跳转到32位环境:使用ljmpl指令跳转到protect_mode_entry函数入口,在这个函数中首先要重设所有数据段描述符。在保护模式下,处理器使用段描述符来定义内存段的属性,如段的基地址、长度、访问权限等。当从实模式切换到保护模式时,处理器需要重新加载所有的数据段描述符,以确保在保护模式下正确地访问内存。设置好后就可以跳转到跳转到32位环境load_kernel

所以代码为:

1
2
3
4
5
6
7
8
9
10
static void enter_protect_mode()
{
cli();
uint8_t a20 = inb(0x92);
outb(0x92, a20 | 0x2);
lgdt((uint32_t)gdt_table, sizeof(gdt_table));
uint32_t cr0 = read_cr0();
write_cr0(cr0 | (1 << 0));
far_jump(8, (uint32_t)protect_mode_entry);
}

关于8的含义以后再填坑,*cli()inb()*等指令都是内联汇编函数而不是直接可以调用的指令。关于gcc内联汇编以后会专门写一篇blog来讲。

从保护模式切换到内核

现在我们已经成功进入32位的保护模式了,但是在进入内核之前还有一些检查工作要做。
第一点是要切换到使用LBA模式读取磁盘。进入保护模式后我们无法再用bios提供的中断进行磁盘的读取,所以要使用LBA模式。LBA48将硬盘上所有的扇区看成线性排列,没有磁盘、柱面等概念,因此访问起来更加简单,扇区序号从0开始。可以参考ATA PIO 模式 - OSDev Wiki
第二点是读取内核在内存中的位置作为内核的入口。内核文件在编译时会被编译成ELF文件并放置在1M处(ELF(文件格式)_百度百科),在ELF文件中有许多段,我们要从中找到内核函数的入口。
ELF文件格式
在elf文件中,有两个重要的结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
typedef struct
{
char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr; //描述 ELF 文件整体结构
typedef struct
{
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr; //描述每个段的加载信息

这两个段的每个项的详细功能,可以man 5 elf 查看。我们要对elf文件进行一些处理。
①检查是否为ELF格式
可以参考这个进行设置

②遍历程序头表,找到所有p_type为 PT_LOAD 的段,这些段需要被加载到内存中。
③加载段到内存,将 PT_LOAD 段的内容从文件中读取并复制到目标物理地址 p_paddr。如果段的内存大小大于文件大小(p_memsz > p_filesz),将多出来的部分用零填充(用于bss段的设置)。
④返回 ELF 文件头中的 e_entry。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static uint32_t reload_elf_file(uint8_t *file_buffer)
{
Elf32_Ehdr *elf_hdr = (Elf32_Ehdr *)file_buffer;
if ((elf_hdr->e_ident[0] != ELF_MAGIC) || (elf_hdr->e_ident[1] != 'E') || (elf_hdr->e_ident[2] != 'L') || (elf_hdr->e_ident[3] != 'F'))
{
return 0;
}
for (int i = 0; i < elf_hdr->e_phnum; i++)
{
Elf32_Phdr *phdr = (Elf32_Phdr *)(file_buffer + elf_hdr->e_phoff) + i;
if (phdr->p_type != PT_LOAD)
{
continue;
}
uint8_t *src = file_buffer + phdr->p_offset;
uint8_t *dest = (uint8_t *)phdr->p_paddr;
for (int j = 0; j < phdr->p_filesz; j++)
{
*dest++ = *src++;
}
dest = (uint8_t *)phdr->p_paddr + phdr->p_filesz;
for (int j = 0; j < phdr->p_memsz - phdr->p_filesz; j++)
{
*dest++ = 0; //有些时候memsz会大于filesz但是实际存储的大小为memsz
}
}

return elf_hdr->e_entry;
}

这时elf文件便做完了必要的设置,我们可以使用返回的入口值进入内核了。

恭喜!我们成功让计算机进入了自己的内核!

下一篇博客就开始从内核说起了。不知道会拖更多久🤯
注:本博客参考了李述铜老师的os课程,是个人的学习笔记。
有些问题李老师没讲清楚,本人加了一些自己的理解与思考。如果您发现我的笔记有问题,请给我发邮件批评指正。
本人邮箱:myslqyr@qq.com

一补

关于Cmake中的 -Ttext=0x7c00 的作用,这个选项指定了链接器(Linker)在生成可执行文件时将代码段(Code Segment)的起始地址设置为 0x7c00。但是bios默认会加载这段代码到0x7c00处,这样感觉像多此一举。但是如果不设置这个选项,代码段设置的默认起始地址值可能就会跟我们想要的0x7c00不一样,所以要设置(本人粗略的分析,不一定对)。