本章是操作系统从 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?

GDTGlobal Descriptor Table)即全局描述符表,又叫段描述符表,本质其实就是一种专用于 IA-32x86-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 应该设置位 00 表示为其他类型段
    • A:保留位,设为 0 即可

# asm

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

  1. GDT 的第一项必须为空项
  2. 至少定义两个段:代码段、数据段( baselength 先一样即可)
  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

上一步中我们已经定义了 GDTGDT 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_SEGDATA_SEG 的原因),这些段寄存器包括 csssdsesfsgs 。前三个寄存器有特定用途:

  • 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 的地址设置在了 0x80000x1000 的空间内,此时 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 支付宝

支付宝