本章是操作系统从 0 到 1 系列的第四篇文章,主要讲解了 loader 需要完成的任务以及其中涉及到的知识点。本章先总体介绍 loader 的工作,然后逐步实现部分 loader 的工作(虚拟内存之前)并讲解相关知识点,由于虚拟内存相关的知识庞大且重要,我们会在下一章进行详细探讨。

Concepts you may want to Google beforehand: GDT, protected mode, segment register, interrupts, pipeline

# Loader 的工作

总的来说,loader 的工作为以下几项(也是 loader 到 kernel 的步骤):

  1. 禁用中断
  2. 使能 A20 地址线(现在的设备都是默认使能的,所以可以跳过)
  3. 建立 GDT
  4. 进入保护模式并刷新 cpu 管道、初始化段寄存器和栈
  5. 建立 kernel 页目录( page directory )和页表( page tables )
  6. 打开虚拟内存( virtual memory ),进入 paging 模式
  7. 加载 kernel 镜像到内存
  8. 执行 kernel 代码,控制权转交给 kernel

# Loader 编写

# 禁用中断

禁用中断通过设置 IF flag 来实现,当 IF flag 被设置为 0 时,系统会屏蔽中断命令;禁用中断非常简单,只需要一行命令即可:

cli

如果你看了很多教程的话,可能会发现,有的教程中有这行代码,而有的没有,甚至在自己写的 loader 中不禁用中断可能也不会有什么问题,但是,这里是 “可能”,如果中断发生在实模式进入保护模式之前和之后,系统都没有问题,但在模式切换进行时发生中断就会发生异常。这个情况概率比较低,少量的验证是没法复现此问题的,所以我们最好还是加上 cli 命令。

# 使能 A20 地址线

由于现在设备会在启动时自动使能 A20 地址线,所以这一步可以跳过,不过我们的教程为了完整性,还是稍微讲一下。

「A20 其实是指第 21 根地址线,因为地址线是从 0 开始计数的。」

需要该步骤也是历史原因导致的,我们之前讲过,在 CPU 发展早期(8086 时代),CPU 地址线只有 20 位,此时寻址还需要还需要经过转换。经过 CPU 的不断发展(80286 时代),地址线从 20 根扩展到了 24 根,能访问的内存达到了 224M,但是在 CPU 设计时是向下兼容的,所以,80286 CPU 在实模式时表现应该和 8086 CPU 一致,但其实 80286 芯片却存在一个 Bug:因为 80286 有了 A20 地址线,如果程序员访问 100000H ~ 10FFEFH 之间的内存,系统将实际访问这块内存,而不是像 8086/8088 一样从 0 开始。

所以为了解决上述兼容问题,IBM 使用实模式下本应无用的 A20 地址线来进行设置,当 A20 被使能时,程序员给出 100000H ~ 10FFEFH 之间的地址的时候,系统将真正访问这块内存区域;若 A20 为被使能,则会按照实模式下对地址取模进行访问。

使能 A20 的代码非常简单,只需要记住即可

in al, 92h ; 2. enable A20
or al, 00000010b
out 92h, al

# 建立 GDT

什么是GDT?

GDT( Global Descriptor Table)即全局描述符表,又叫段描述符表,本质其实就是一种专用于 IA-32 和 x86-64 体系结构的二进制数据结构,该表位于内存中,具体位置保存在 GDTR 寄存器中,其条目描述并规定了不同内存分区的各种特征,包括基地址、大小和访问特权如可执行、可写等。 在 Intel 的术语中,这些内存区域被称为 segment 。

我们在上一章中提到过,CPU 通过 段基地址 + 段内偏移 的方式来扩展寻址空间,类似的,在保护模式下,CPU 通过 GDT 中记录的基地址和偏移地址实现寻址,因此可以通过 GDT 中记录的段的信息来进行内存的保护(对比实模式下,所有程序都可以访问任意内存)。

# GDTR

根据手册,GDT 的地址应存储在 GDTR 寄存器中,这个过程通过汇编指令 LGDT 进行加载,参数指向 GDT Descriptor 结构:

GDT Descriptor

  • Size:表示 GDT 的大小,以字节为单位,实际大小值为 Size+1 ,这是因为 Size 的最大值是 65535,但是 GDT 的最大值为 65536(8192 条数据);同时,GDT 的 Size 不能为 0
  • Offest:GDT 的线性地址(并不是物理地址,而是分页地址)

同时,上图中我们也可以看出 LGDT 命令在 32 位和 64 位下采用不同的方式载入数据, Offest 在 32 位下只有 4 字节长度,而在 64 位下有 8 字节。

# GDT

GDT 中的条目占 8 字节,在内存中的形式如下:

GDT

注意,GDT 中的第一个条目 (Entry 0) 永远为空,所以段数据应从第二个条目开始存储;表中的项由段选择器(Segment Selectors)存取,段选择器通过汇编指令或中断等硬件函数加载到段寄存器中。

GDT 中每一项都有一个名为 Segment Descriptor 复杂的结构,该结构长度为 8 Bytes,示意图如下:

System Segment Descriptor

  • Base:一个 32 位值(不连续的黄色部分),记录了该段的起始地址「图中 Base address 后的括号说明了改部分在整个 Base 中的位数」
  • Limit:一个 20 位值(不连续的粉红色部分),记录最大可寻址单元,以 1 字节为单位(最大寻址 1M),或以 1 页(4 KB)为单位;因此,在 32 位下,如果选择以页为单位且 limit 设为 0xFFFFF ,那么该段将覆盖整个 4 GB(4KB*1M )空间「图中括号内数字含义同 Base」
64位下的GDT

上述图片展示的是 32 位模式下的 GDT,而在 64 位模式下,段保护机制基本被废弃了,更多的是使用页机制,因此,服务于段机制的 GDT 表项就少很多;又由于 64 位下引入的 IA-32e 指令集不再兼容老旧的 8086 模式,仅有代码段 CS,数据段 DS 仍和 32 位一样使用 64 位,即 8 字节的描述符,其他的段寄存器例如:DS、ES、SS 这样的段寄存器已经不再使用了,GDT 项存在的意义就更小了。

另一个导致 GDT 在 64 位下不受宠的原因是,其实从 win XP 系统开始,系统进入保护模式后 GDT 的项就没用了,一直就那么空在内存中,甚至还有从系统启动到结束就没用过的表项,不但占地方还给了黑客可趁之机,很多大神看上了这一大片有可读写可执行属性的内存,就把 Shellcode 或者全局数据区布置在这了。

  • Access Byte:一个 8 位值(40-47),代表了该段的访问权限字段,字段参数及含义如下:

    • P: 段是否存在,存在 = 1

    • DPL: 描述符权限级别字段(2 位), 即该段的 CPU 权级. 0 = 最高权限(内核), 3 = 最低权限(用户应用).

    • S: 描述符类型字段。如果设置为 0 ,则定义了一个系统段 (例如 TSS「 Task State Segment 」、LDT「 Local Descriptor Table 」);如果设置为 1 ,则定义一个代码段或数据段。这两种描述符的 Type 字段结构有所不同,接下来分别来说:

      S=0 时(System Segment Descriptor)

      • Type:定义系统段的类型(32 位模式)
        • 0x1: 16-bit TSS (Available)
        • 0x2: LDT
        • 0x3: 16-bit TSS (Busy)
        • 0x9: 32-bit TSS (Available)
        • 0xB: 32-bit TSS (Busy)
      • Type:定义系统段的类型(Long Mode)
        • 0x2: LDT
        • 0x9: 64-bit TSS (Available)
        • 0xB: 64-bit TSS (Busy)

      S=1 时(Code/Data Segment),Type 字段会被拆分为以下字段

      Access Byte

      • E:第 43 位,是否可执行位, 0 代表该段为数据段,不可执行; 1 代表代码段,可执行
      • DC:第 42 位,方向位 / 符合位
        • 对于数据段:表示方向位。 0 代表段向高地址增长; 1 代表段向低地址生长
        • 对于代码段:表示符合位。 0 代表只有 DPL 字段中指定的权限可以执行该段代码; 1 表示该段代码的执行权限可以小于等于 DPL
      • RW:第 41 位,可读 / 可写位
        • 对于代码段:表示可读位。 0 代表该段不允许读, 1 代表可读;代码段永远没有写权限。
        • 对于数据段:表示可写位。 0 代表该段不可写, 1 代表可写;数据段永远有读权限。
      • A:第 40 位,访问位,最好设置为 0,CPU 会在该段被访问时自动设置它。
  • Flags:一个 4 位值(52-55 位)

    • G:粒度位,指示 Limit 的单位。 0 表示 Limit 单位为 1 字节 (字节粒度)。如果设置为 1 ,则 Limit 单位为 4KB 块 (页面粒度)。
    • DB:大小位, 0 代表定义了一个 16 位保护模式段, 1 表示定义了一个 32 位保护模式段;一个 GDT 可以同时有 16 位和 32 位的位选择器
    • L:长模式代码标志, 1 表示该段定义为 64 位代码段,此时 DB 应该设置位 0 ; 0 表示为其他类型段
    • A:保留位,设为 0 即可

# asm

以上就是所有有关 GDT 的理论知识啦,现在让我们动手写一下我们的操作系统的 GDT 吧!梳理一下:

  1. GDT 的第一项必须为空项
  2. 至少定义两个段:代码段、数据段( base 和 length 先一样即可)
  3. 获取 GDT 的地址
  4. 创建 GDT Descriptor 结构用于后续填充 GDTR
32bit-gdt.asm
gdt_start: ; don't remove the labels, they're needed to compute sizes and jumps
    ; the GDT starts with a null 8-byte
    dd 0x0 ; 4 byte
    dd 0x0 ; 4 byte
    
; GDT for code segment. base = 0x00000000, length = 0xfffff
; for flags, refer to https://gality.cn/os/03-loader/#gdt
; again,  1 bytes = 8 bits | 1 word = 2 bytes = 16 bits
gdt_code:
    dw 0xffff    ; segment length, bits 0-15
    dw 0x0       ; segment base, bits 0-15
    db 0x0       ; segment base, bits 16-23
    db 10011010b ; flags (8 bits), little-endian, so compare the doc. from P to A (high to low)
    db 11001111b ; flags (4 bits) + segment length, bits 16-19 | length=0xfffff
    db 0x0       ; segment base, bits 24-31
; GDT for data segment. base and length identical to code segment
; some flags changed, again, https://gality.cn/os/03-loader/#gdt
gdt_data:
    dw 0xffff
    dw 0x0
    db 0x0
    db 10010010b
    db 11001111b
    db 0x0
gdt_end: ; don't remove this label
; GDT Descriptor
gdt_descriptor:
    dw gdt_end - gdt_start - 1 ; size (16 bit), always one less of its true size
    dd gdt_start ; address (32 bit)
;define some constants for later use
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start

上面的代码中其实还隐藏了一个知识点,仔细想想也确实会有一个并不是很合逻辑的地方。我们知道在高级语言,例如 C语言 中,一个一个的创建变量并不能保证变量在内存中是连续存储的,除非用数组;但是我们的代码里面并没有任何的特殊数据结构的形式,那么 GDT 的每一项在内存中是一定在一起的吗?

如果是,那么这代表什么?如果不是,那么实际又是怎么实现的呢?

答案:一定是在一起。这是因为汇编源代码里的指令和数据部分是可以自由混杂排布的,而且最终编译出来的二进制中,它们排布顺序完全遵循源代码的排布。所以你可以任意安排你的指令和数据所处的位置,只需记得要使用各种跳转指令来控制代码运行即可。

但是,整个 loader 的起始位置,即入口代码必须在 0x9000 处,因为这是和 mbr 约定好的跳转地址(详见上一章),至于后面全部可以自由发挥和排布。

其实,这个特性又是另外一个问题的答案,不知道有没有读者在看前面的代码时有这么一个疑问,为什么都要在代码结束加上 jmp $ ,一定要加吗?

当然是的,这个问题的答案同样是来源于上述特性,如果不加上 jmp $ 来使代码进入死循环,那么代码将继续向后执行,将随机乱码理解成代码去执行,这样会导致未知错误。

# 保护模式

# 设置 GDTR

上一步中我们已经定义了 GDT 和 GDT Descriptor 结构,只需要使用 lgdt [gdt_descriptor] 命令来将 GDT Descriptor 的地址加载到 GDTR 寄存器中即可。

# 进入保护模式

设置完 GDT 后我们就可以使 CPU 进入保护模式了,进入保护模式非常简单,只需要设置 CPU 的 cr0 寄存器即可。 cr0 寄存器是一个控制寄存器,共 32 位,每一位都对应着不同的控制能力,我们这里无需管其他位的能力,只需要关注第 0 位 PE(Protected Enable)位即可,当 PE=0 时,启动保护模式; PE=1 时,则在实模式运行。

我们在对 cr0 寄存器进行操作时还必须注意需要保留其他控制位值不变,想到了什么操作?没错,就是 OR ,该操作可以仅将第 0 位改变成 1 ,并保持其他位不变。

还有一点需要注意的是, OR 操作不能直接对 cr0 寄存器进行操作,所以我们需要借助 eax 寄存器来作为桥梁,完成对 cr0 寄存器的赋值,代码是不是已经呼之欲出了?

进入保护模式
mov eax, cr0
or eax, 0x1 ; set 32-bit mode bit in cr0
mov cr0, eax

此时,我们就正式进入保护模式了。

# 刷新 CPU 管道

为什么要刷新 CPU 管道(pipeline)呢?是因为 CPU 是以 pipeline 的方式工作的,需要执行的指令都在 pipeline 中,若两个不同指令依赖同一块数据,同时修改这块数据就可能导致危险,这种情况下,就需要刷新 CPU 管道来防止这种危险发生(这里查到的资料是这样的,但是感觉稍微有一点点问题,有熟悉的师傅欢迎评论区留言呀~)。

刷新 CPU 管道也非常简单,可以通过一个远跳命令实现:

jmp CODE_SEG:init_pm ; far jump by using a different segment to refresh pipeline

# 初始化段寄存器

上一章中我们已经介绍了寄存器的默认用法及含义,这里详细介绍下段寄存器各自的用途

在保护模式下,CPU 提供了段寄存器(16 位)。段寄存器存放的并不是段的基地址,而是段选择符(Segment Descriptor)的相对于 GDT 地址的偏移地址(这也是为什么我们在上一节的代码中要定义 CODE_SEG 和 DATA_SEG 的原因),这些段寄存器包括 cs 、 ss 、 ds 、 es 、 fs 、 gs 。前三个寄存器有特定用途:

  • cs:代码段寄存器,指向包含程序指令的段
  • ss:栈段寄存器,指向包含当前程序栈的段
  • ds:数据段寄存器,指向包含静态数据或全局数据的数据段

后三个段寄存器是通用的。

cs 寄存器直接由 CPU 设置(想想上一章中讲的)我们无需初始化,所以我们就需要初始化其他的寄存器,至于用什么初始化,当然是我们提前定义好的 DATA_SEG 变量啦,有一点需要注意,段寄存器同样也不能直接进行立即数的赋值,我们需要借助 ax (16 位)来帮助赋值。

[bits 32]
init_pm: ; we are now using 32-bit instructions
    mov ax, DATA_SEG ; update the segment registers
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

# 初始化栈

mbr工作阶段内存布局

还记得上面这个图吗,我们将 loader 的地址设置在了 0x8000 后 0x1000 的空间内,此时 0x8000 前后的空间和 mbr 前的空间都是空着的,我们用 mbr 前面的空间来布置栈,就像示意图中标识的那样,将栈底设置为 0x7B00 (栈向低地址增长),代码同样非常简单。

mov ebp, 0x7B00
mov esp, ebp

将以上代码合并起来,就形成了从实模式到保护模式的代码

[bits 16] ; real mode
switch_to_pm:
    cli ; 1. disable interrupts
    in al, 92h ; 2. enable A20
    or al, 00000010b
    out 92h, al
    lgdt [gdt_descriptor] ; 3. load the GDT descriptor
    mov eax, cr0
    or eax, 0x1 ; 4. set 32-bit mode bit in cr0
    mov cr0, eax
    jmp CODE_SEG:init_pm ; 5. far jump by using a different segment to refresh cpu pipeline
[bits 32] ; protected mode
init_pm: ; we are now using 32-bit instructions
    mov ax, DATA_SEG ; 6. update the segment registers
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ebp, 0x7B00 ; 7. update the stack right at the top of the free space
    mov esp, ebp
    call BEGIN_PM ; 8. Call a well-known label with useful code

此时,我们不妨写一个 main 函数来调用上述过程,然后测试一下程序:

[org 0x7c00] ; bootloader offset
    mov bp, 0x9000 ; set the stack
    mov sp, bp
    mov bx, MSG_REAL_MODE
    call print ; This will be written after the BIOS messages
    call switch_to_pm
    mov bx, MSG_ERROR ; this will actually never be executed
    call print 
    jmp $
%include "../02-mbr/boot-print.asm" ; must be the first included
%include "32bit-gdt.asm"
%include "32bit-switch.asm"
[bits 32]
BEGIN_PM: ; after the switch we will get here
    ; need patch
    jmp $
MSG_REAL_MODE db "Started in 16-bit real mode", 0
MSG_ERROR db "Loaded 32-bit protected mode ERROR", 0
; bootsector
times 510-($-$$) db 0
dw 0xaa55

然后编译执行就可以了

nasm -fbin 32bit-main.asm -o loader.bin
qemu-system-i386 -fda loader.bin

实际测试发现 boot-print.asm 必须第一个 include,否则 nasm 在编译的时候会把 [bx] 转换成 [edi] 不知道是 bug 还是我不知道的什么原因,总之,首先 include 就不会有问题了。


至此,CPU 已经进入了保护模式(32 位),接下来我们需要完成虚拟内存到物理内存的映射,为 kernel 的工作打下基础,由于这部分内容体量相对庞大且至关重要,所以我们放在下下一章详细去讲(下一章先讲点轻松的~)。


更新于 阅读次数 次

请我喝[茶]~( ̄▽ ̄)~*

Gality 微信支付

微信支付

Gality 支付宝

支付宝