第二章 内核,启动! - 从零开始开发UEFI引导的64位操作系统内核

声明:此文章为本人在知乎首发的原创文章


  • 与本文同时编写的内核项目现已开源至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寄存器的值已经是在链接脚本中设置好的地址了可以看到eip寄存器的值已经是在链接脚本中设置好的地址了。


二、模式切换

有关x86架构的模式切换,不得不提到英特尔白皮书第三卷中的处理器模式切换示意图:处理器模式切换图当我们的内核被引导时,正处于Protected Mode下,即通常说的32位模式。
虽然UEFI和grub都是64位的,在引导的过程中也确实处于64位模式(即图中的IA-32e模式),但是作为一个内核,我们首先要在32位模式下设置好内存的分段和分页数据,然后切换到64位模式。
根据从保护模式切换到ia32e模式的注解,在白皮书第三卷9.8.5章节有详细的描述:保护模式到ia32e模式切换流程经过图中所描述的操作后,处理器的状态还没有更新,只要经过一个远跳转即可将设置好的状态更新,进入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

可以看到反编译结果(截取gdtgdt_ptr的部分,由于gdt_endgdt_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查看寄存器: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的运行结果正确。
各个段寄存器中储存的段选择子以及段限长也被正确地设置。
gdtrcr3寄存器的值也是正确的。


经过本章内容,我们的内核终于进入了64位模式,进入64位模式后,我们依旧需要设置许多CPU数据结构;下一章,我们会跳过这部分内容,设置好multiboot2头之后直接进入内核主程序。
而跳过的这部分内容将会在恰当的时机补回来。


与本文同时编写的内核项目现已开源至github


参考:

  1. 会产生疑问的吧?后文会解释的
  2. 即它的起始地址必须是4096的整数倍
  • 18
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

指向BIOS的野指针

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值