声明:此文章为本人在知乎首发的原创文章
- 与本文同时编写的内核项目现已开源至github
- 注:本章全部内容仅适用于x86架构,其它架构请读者自行阅读对应的文档。
一、简单启动一下
创建一个汇编代码文件,可以是entry32.s
等文件名。
首先给multiboot2头创建一个.multiboot2
段,方便安排multiboot2头的位置:
section .multiboot2 align=8
MULTIBOOT2_HEADER_MAGIC equ 0xe85250d6
MULTIBOOT2_HEADER_LENGTH equ multiboot2_header_end - multiboot2_header
multiboot2_header:
dd MULTIBOOT2_HEADER_MAGIC
dd 0
dd MULTIBOOT2_HEADER_LENGTH
dd - (MULTIBOOT2_HEADER_MAGIC + MULTIBOOT2_HEADER_LENGTH)
end_tag:
dd 0
dd 8
multiboot2_header_end:
根据上一章所述的multiboot2头定义,前4个双字分别为魔数、架构、头的长度、校验码,接下来是一系列标签,我们暂时先只写一个结束标签。
接着创建一个.entry
段,其中应该有从内核被引导开始到进入内核主程序之前的所有代码。
虽然我们使用的是64位UEFI启动,但是由于内存管理的硬件支持需要在32位模式下配置,所以内核被引导时会切换回32位模式。因此有一部分程序需要使用32位汇编,有一部分需要使用64位汇编;汇编代码也分别在两个源文件中编写。
section .entry
init32:
jmp $
首先写一段死循环代码,按照前一章的链接脚本,这个init32
就是内核程序的入口点。$
代表本指令所在的内存地址。
然后可以使用nasm
汇编器将其汇编成可重定位文件:
nasm -f elf32 -o entry32.o entry32.s
由于内核整体上是64位的,又因为链接脚本指定的是64位的,因此我们需要将这个elf32格式的目标文件直接转换成elf64格式:
objcopy -I elf32-i386 -O elf64-x86-64 entry32.o entry32.o
接着根据上一章所述的链接脚本kernel.lds
将这个可重定位文件链接成elf64可执行文件:
ld -T=kernel.lds -o kernel.elf entry32.o
这样就生成了可以直接被grub加载的内核文件kernel.elf
。
根据上一章的内容,将可引导磁盘镜像挂载,将内核文件移动到指定的位置,卸载磁盘镜像并用qemu运行。
等grub引导了内核后,打开compatmonitor0
使用info registers
查看eip
寄存器:可以看到eip寄存器的值已经是在链接脚本中设置好的地址了。
二、模式切换
有关x86架构的模式切换,不得不提到英特尔白皮书第三卷中的处理器模式切换示意图:当我们的内核被引导时,正处于Protected Mode
下,即通常说的32位模式。
虽然UEFI和grub都是64位的,在引导的过程中也确实处于64位模式(即图中的IA-32e
模式),但是作为一个内核,我们首先要在32位模式下设置好内存的分段和分页数据,然后切换到64位模式。
根据从保护模式切换到ia32e模式的注解,在白皮书第三卷9.8.5章节有详细的描述:经过图中所描述的操作后,处理器的状态还没有更新,只要经过一个远跳转即可将设置好的状态更新,进入64位模式。
在这之前,我们首先需要将分段机制设置好,其次,在远跳转时,就已经使用了64位地址,因此也要把分页机制一并设置。
1. 设置分段机制
程序中的段不是这里所说的段,作为区分,在本节中写作程序段
首先,在程序段.cpumeta
中创建一个GDT
(全局描述符表),其中可以包括多个段描述符(GDT中除了段描述符还可以包括其它描述符)。
section .cpumeta
gdt:
dq 0
dq 0x0020980000000000 ; 内核态代码段
dq 0x0000920000000000 ; 内核态数据段
dq 0x0020f80000000000 ; 用户态代码段
dq 0x0000f20000000000 ; 用户态数据段
gdt_end:
gdt_ptr:
dw gdt_end - gdt - 1
dq gdt
在gdt中,第0项是保留项,不可以作为任何一个描述符,我们分别定义了4个描述符。
根据白皮书的描述,四个段描述符都描述了一个起始地址为0,段限长为0的段;
DPL决定了这个段是用户态(DPL
=3)还是内核态(DPL
=0),
type
决定了段的读写和执行的权限,
其它项的含义就很明确了。
我们可以发现,起始地址和段限长都是0,这是因为amd在开发ia32e模式时,引入了平坦内存模型;
由于现代操作系统都使用分页作为基本的内存管理功能,分段机制非常多余,因此平坦内存模型规定,所有的段都必须将起始地址和段限长设为0,而内存访问时会直接忽略段限长,这样分段机制映射出来的逻辑地址空间就与线性地址空间完全相同。
逻辑地址、线性地址与物理地址:物理地址直接对应了实际上的内存空间,线性地址经过分页机制会转换为物理地址,逻辑地址经过分段机制会转换为线性地址。
接下来的gdt_ptr
定义了一个gdt描述符,将会被加载到gdtr
寄存器中,其内容分别是gdt表的长度和起始地址。
然后开始在init32
中编写加载gdt的代码:
; 设置gdt_ptr
mov eax, 0x10402a ; gdt_ptr + 2
mov dword [eax], 0x104000 ; gdt
; 加载GDTR和段寄存器
db 0x66
lgdt [0x104028] ; gdt_ptr
mov ax, 0x10
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
这段代码首先为gdt_ptr写入正确的值<1>,并用临时64位指令前缀0x66
后加lgdt
指令,将64位GDT加载到gdtr
中。
然后将0x10写入除cs之外的段寄存器中。
在注释中我标出了地址常数对应的符号名,这些常数的值取决于你的链接脚本如何安排这些符号的位置,可以先将它们置为0,并将生成的内核文件反编译,查看这些符号的地址,例如在我的项目中,使用如下命令:
objdump -D src/metaverse.elf
可以看到反编译结果(截取gdt
和gdt_ptr
的部分,由于gdt_end
与gdt_ptr
地址相同,gdt_end
覆盖了gdt_ptr
):
0000000000104000 <gdt>:
...
10400c: 00 98 20 00 00 00 add %bl,0x20(%rax)
104012: 00 00 add %al,(%rax)
104014: 00 92 00 00 00 00 add %dl,0x0(%rdx)
10401a: 00 00 add %al,(%rax)
10401c: 00 f8 add %bh,%al
10401e: 20 00 and %al,(%rax)
104020: 00 00 add %al,(%rax)
104022: 00 00 add %al,(%rax)
104024: 00 f2 add %dh,%dl
...
0000000000104028 <gdt_end>:
104028: 27 (bad)
104029: 00 00 add %al,(%rax)
10402b: 10 10 adc %dl,(%rax)
10402d: 00 00 add %al,(%rax)
10402f: 00 00 add %al,(%rax)
然后可以看到,我们向除了cs
寄存器之外的所有段寄存器写入了0x10
,这就是段选择子: 平坦内存模型要求总是使用GDT
,因此TI
总是为0,而我们在开始创建进程前都处于内核态,所以RPL
为0。
高13位是此段寄存器所使用的段选择子的索引,根据前面段描述符的定义,第1项为代码段,第2项为数据段,应为cs寄存器使用第一项,为其余的段寄存器使用第二项。因此段选择子
在内核态中,总是所选择的段描述符在gdt
中的偏移量。
而cs
寄存器不可以使用指令直接设置,需要通过远跳转指令间接设置,在切换到64位模式的远跳转时趁机设置就好了。
cs
寄存器名为代码段寄存器,在读取指令时,处理器访问代码段寄存器确定应该使用哪个段访问代码,将段起始地址与ip
寄存器(eip
/rip
)相加,作为线性地址,读取这个线性地址处的指令。
其它的段寄存器用于各种不同的用途,但肯定不是读取指令,因此其它的寄存器都使用数据段。
- 坑点
ld链接器会在链接时将以下内容替换:
gdt_ptr:
dw gdt_end - gdt - 1
dq gdt
由于我们使用了objcopy
命令将生成的elf32格式可重定位文件直接转换成elf64位格式,虽然都是elf,但是32位和64位多多少少有些区别,所以链接器会错误地将此处的gdt
替换成另外一个常数而不是gdt
的地址;这就是前文要在写入gdtr
前为gdt_ptr
写入正确的值的原因。后文也有类似的情况发生,将不再作出解释。
2. 设置分页机制
首先罗列英特尔白皮书中有关线性地址、多级页表等有关数据结构的示意图:4级页表下的线性地址,以及线性地址的寻址方式;2MB页、1GB页的寻址方式类似,只是更高的9位也用作Offset而已;
cr3寄存器以及4、5级分页页表项格式;详细内容见白皮书第三卷4.5中的表4-14至4-20。
在4级分页中,cr3储存PML4 table,而PML4 table中的每个表项都指向下一级页表;下一级页表中的表项指向再下一级的页表或页框起始地址;以此类推。
另外,页表项的低12位被设为页表属性的各种标志位,因此所有页表的起始地址必须4096字节对齐<2>,这样,在访问页表时,CPU直接将低12位置为0即可得到下一级页表的起始地址。
首先,在程序段.cpumeta
中写出一套分页的数据结构,写在程序段开头,这样这三个符号就都是4096字节对齐了:
; 分页
PML4:
dq 0x003 + PDPT0
resq 511
PDPT0:
dq 0x003 + PD0
resq 511
PD0:
resq 512
我暂时将物理地址前128MB直接映射为线性地址的前128MB,用64个2MB页来完成,因此只写到了PD
,没有写到PT
。
接下来还是使用汇编指令写入正确内容环节:
; 设置PML4的第0项
mov eax, 0x102000 ; PDPT0
add eax, 0x003
mov edi, 0x101000 ; PML4
mov dword [edi], eax
add edi, 4
mov dword [edi], 0
; 设置PDPT的第0项
mov eax, 0x103000 ; PD0
add eax, 0x003
mov edi, 0x102000 ; PDPT0
mov dword [edi], eax
add edi, 4
mov dword [edi], 0
; 设置PD0中的PDE
mov ecx, 64
mov eax, 0
mov edi, 0x103000 ; PD0
init32_loop0:
mov edx, eax
add edx, 0x183
mov dword [edi], edx
add eax, 0x400000 ; 2MB
add edi, 4
mov dword [edi], 0
add edi, 4
loop init32_loop0
然后将PAE
(物理地址扩展)打开:
; 打开PAE
mov eax, cr4
bts eax, 5
mov cr4, eax
然后将PML4
的地址写入cr3
中:
; 加载cr3
mov eax, 0x101000 ; PML4
mov cr3, eax
3. 切换至ia32e模式并跳转至64位代码
; 切换ia32e模式
mov ecx, 0xc0000080 ; ia32_efer在msr中的地址
rdmsr
bts eax, 8
wrmsr
; 打开保护模式和分页机制
mov eax, cr0
bts eax, 0
bts eax, 31
mov cr0, eax
; 远跳转
jmp 0x8:init64
在远跳转之前,CPU依然处于32位模式,不会出现在64位模式执行32位代码的现象。
在这个远跳转指令中,0x8
将被写入cs
寄存器,即GDT
中第1个段描述符。
接下来创建另一个源文件entry.s:
section .entry align=8
global init64
init64:
endbr64
cli
jmp $
这段代码依然在.entry段中,但是这个源文件会以elf64格式生成可重定位文件:
nasm -f elf64 -o entry.o entry.s
并与转换为elf64格式的entry32.o
一同链接。
在entry32.s
的程序段.entry
的段声明后写一行extern init64
使远跳转命令能使用它的地址。
将链接后的内核放进虚拟磁盘中用qemu运行,打开视图compatmonitor0
,输入命令info registers
查看寄存器:我们可以通过objdump命令反编译内核文件,其中符号init64的代码如下:
00000000001000b8 <init64>:
1000b8: f3 0f 1e fa endbr64
1000bc: fa cli
1000bd: eb fe jmp 1000bd <init64+0x5>
可以看出rip
应该最终处于0x1000bd
,qemu的运行结果正确。
各个段寄存器中储存的段选择子以及段限长也被正确地设置。
gdtr
和cr3
寄存器的值也是正确的。
经过本章内容,我们的内核终于进入了64位模式,进入64位模式后,我们依旧需要设置许多CPU数据结构;下一章,我们会跳过这部分内容,设置好multiboot2头之后直接进入内核主程序。
而跳过的这部分内容将会在恰当的时机补回来。
与本文同时编写的内核项目现已开源至github