《操作系统真像还原》操作系统实现——进入保护模式

Posted on May 15, 2021

之前看完王爽老师的《汇编语言》后本来准备看一下《X86汇编语言:从实模式到保护模式》,但是那本书不是很好读,再加上自己也选择的是 PWN 这个方向,对汇编的要求没有那么高,所以就没读下去,对保护模式也只有一点粗浅的认知。本书看完了 4.3 节,虽然也不敢说有多深的理解,但是还是有学到新知识的,这里简单记录一下。

8086 的工作模式为各进程平等,每个进程都可以访问任意的地址空间(实模式),这会导致很多问题,而且很多时候都是致命的。在 Intel 后来推出的 80286 处理器中新增了保护模式,增加了 GDTR 寄存器来设置段的属性并提供虚拟内存支持。进入保护模式后,程序内部的地址都变为虚拟地址,物理地址都需要通过硬件(地址转换部件)和软件(操作系统提供的 GDT 表等)协作的方式来计算。

保护模式的好处什么的我就不多说了。

GDTR

32 位下,GDTR 是一个 48 位寄存器,低 12 位设置 GDT 的大小上限,高 32 位设置 GDT 的基址。所以通过 GDTR 我们可以对 GDT 进行索引寻址,并且获取 GDT 可以存储多少个表项。

段选择子

上面说了我们可以通过 GDTR 来对 GDT 进行索引寻址,那么用什么来做索引呢,那就是段选择子了。8086 有多个段寄存器,这些寄存器当时是为了在 16 位下突破 64KB 寻址上限而存在的,而 32 位下,有 32 条地址总线,可以寻址到 4GB,在当时已经足够用了,就不需要段寄存器来做基址了。但是为了前向兼容,这些寄存器又必须存在,正好我们缺一个索引 GDT 的东西,就用这些段寄存器来索引了,称他们为段选择子。

编写 loader!

为了兼容性,x86 架构的芯片在刚通电时仍然是运行在实模式下的,实模式只能访问 1M 的内存,这么一点点空间可能是不够的,所以在 loader 的开始,我们要先进入保护模式。分三步

  1. 打开 A20 地址总线
  2. 设置 GDT 表项并设置 GDT 寄存器
  3. 设置 CR0,进入保护模式

打开 A20

在 8086 时期,地址总线只有 20 条,如果要要寻的址超过了 2^20,那么高位就会被丢弃,只留下低 20 位,这样就起到了地址回滚的效果。而部分开发者都利用了这种特性来开发实模式程序,为了兼容这种程序,即使有更多的地址总线的 32 位或 64 位 CPU 在实模式下也需要支持地址回滚。要实现也很容易,只要把第 21 条地址总线也就是 A20 关掉就可以了。所以 x86 芯片刚通电时 A20 都是默认关闭的。进入保护模式的第一步就是打开这条线。要打开很容易,只要把 0x92 端口的第一位置 1 就可以了。

; open A20
in al,0x92              ;
or al,0000_0010B        ; save existed status
out 0x92,al

设置 GDT 表项

GDT,Global Description Table,全局描述符表,是一种段描述符,描述一个内存段的属性。没有必要去记每位是什么,用到的时候查就行了。这里就简单列举一下。

低 32 位:

所处的位字段名字段意义
0 ~ 15段界限段界限的低 16 位
16 ~ 31段基址段基址的低 16 位

高 32 位:

所处的位字段名字段意义
0 ~ 7段基址段基址的 16 ~ 23 位
8 ~ 11TYPE该段的子类型(对操作系统而言)
12S该段的类型(对 CPU 而言,硬件运行需要的才是代码,此段 S 为 0)
13 ~ 14DPL该段的特权级(0 ~ 3)
15Ppresent 位,为 1 表示当前段在内存中
16 ~ 19段界限段界限的高 4 字节
20AVLCPU 不使用,可保留给操作系统使用
21L64 位代码标识
22D/B指定操作数大小和段基址类型(1 表示为 32/64 位)
23G段内存粒度(1 表示为 4k,0 表示为 1 字节)
24 ~ 31段基址段基址的 24 ~ 31 位

这次实现的 loader 中,我们需要设置代码段、数据段和显示器数据段三个段描述符。

设置 CR0,进入保护模式

CPU 的控制寄存器之一 CR0 的最低 1 位用来表示是否处于保护模式,我们只要把该位置为 1 CPU 就会以保护模式工作了。设置方法和打开 A20 总线类似

; set cr0, let's roll!
mov eax,cr0
or eax,0x00000001       ; save existed status
mov cr0,eax             ; enter Protection mode

实现代码

书上给出的代码在对视频数据段的段描述符的高 32 位中的段基址初始化时有勘误,应该初始化为 0x0B,我基本上对着抄了一遍,做了一点小微调

loader.S:

%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR

jmp LoaderStart

; set up GOT and descriptor
GDT_BASE: dd 0x00000000   
          dd 0x00000000   

CODE_DESC: dd 0x0000FFFF                ; low 32 bits
           dd DESC_CODE_HIGH4           ; high 32 bits

DATA_STACK_DESC: dd 0x0000FFFF          ; used by stack and data seg
            dd DESC_DATA_HIGH4

; text-mode display
; limit = (0xBFFFF - 0xB8000) / 4K = 0x7
VIDEO_DESC: dd 0x80000007 
            dd DESC_VIDEO_HIGH4

GDT_SIZE  equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1

times 60 dq 0                           ; reserve 60 GDTs

SELECTOR_CODE equ ((CODE_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0
SELECTOR_DATA equ ((DATA_STACK_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0
SELECTOR_VIDEO equ ((VIDEO_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0

; pointer point to GDT
gdt_ptr: dw GDT_LIMIT    ; low 16 bits of GDT reg
         dd GDT_BASE     ; high 32 bits of GDT reg
; end of GDT setup


loader_msg db '2 loader in real mode.'

LoaderStart:
mov sp,LOADER_BASE_ADDR
mov bp,loader_msg
mov cx,22               ; len
mov ax,0x1301           ; 0x13: int mode, 0x01: display mode
mov bx,0x001F           ; page num 0, color: 0x1F
mov dx,0x1800           ; place to display
int 0x10                ; call int

; ---------- ready to enter Proctection mode ----------
; 1 open A20 address line
; 2 load GDT reg
; 3 set pe of cr0 to 1

; open A20
in al,0x92              ;
or al,0000_0010B        ; save existed status
out 0x92,al
; load GDT reg
lgdt [gdt_ptr]
; set cr0, let's roll!
mov eax,cr0
or eax,0x00000001       ; save existed status
mov cr0,eax             ; enter Protection mode

jmp dword SELECTOR_CODE:ProctectionModeStart    ; reflesh assembly line

[bits 32]
ProctectionModeStart:
    mov ax,SELECTOR_DATA
    mov ds,ax
    mov es,ax
    mov ss,ax
    mov esp,LOADER_STACK_TOP
    mov ax,SELECTOR_VIDEO
    mov gs,ax

    mov byte [gs:160],'P'

    jmp $

boot.inc:

;---------- loader and kernel ----------
LOADER_BASE_ADDR equ 0x600                  ; 0x500 ~ 0x7BFF
LOADER_START_SECTOR equ 0x2

;---------- gdt related ----------
; G, D, L, AVL sign
DESC_G_4K equ 1_00000000000000000000000b    ; set grid 4K
DESC_D_32 equ  1_0000000000000000000000b    ; set 32 bit text mode
DESC_L    equ   0_000000000000000000000b    ; turn off 64 bit text mode
DESC_AVL  equ    0_00000000000000000000b    ; unused by CPU

; segment limit high 4 bits
DESC_LIMIT_CODEH equ 1111_0000000000000000b ; LIMIT 0xF(FFFF)
DESC_LIMIT_DATAH equ DESC_LIMIT_CODEH       ; LIMIT 0xF(FFFF)
DESC_LIMIT_VIDEOH equ 0000_0000000000000000b 

; Present sign
DESC_P_IN equ 1_000000000000000b            ; this segment is in RAM

; Descriptor Privilege Level (DPL sign)
DESC_DPL_RING_0 equ 00_0000000000000b       ; set RING 0 
DESC_DPL_RING_1 equ 01_0000000000000b       ; set RING 1 
DESC_DPL_RING_2 equ 10_0000000000000b       ; set RING 2 
DESC_DPL_RING_3 equ 11_0000000000000b       ; set RING 3 

; CPU segment status (S sign)
DESC_S_CODE equ 1_000000000000b             ; code segment
DESC_S_DATA equ DESC_S_CODE                 ; data segment
DESC_S_SYS  equ 0_000000000000b             ; sys segment (to cpu)

; OS segment status (type sign)
DESC_TYPE_CODE equ 1000_00000000b           ; code segment (r-x)
DESC_TYPE_DATA equ 0010_00000000b           ; data segment (rw-) 

; normalized Descriptor
DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_L + \
DESC_D_32 + DESC_AVL + DESC_P_IN + DESC_LIMIT_CODEH + \
DESC_DPL_RING_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00

DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_L + \
DESC_D_32 + DESC_AVL + DESC_P_IN + DESC_LIMIT_DATAH + \
DESC_DPL_RING_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00

DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_L + \
DESC_D_32 + DESC_AVL + DESC_P_IN + DESC_LIMIT_VIDEOH + \
DESC_DPL_RING_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0B

;---------- selector status ----------
; Request Privilege Level
RPL0 equ 00b                                ; Ring 0
RPL1 equ 01b                                ; Ring 1
RPL2 equ 10b                                ; Ring 2
RPL3 equ 11b                                ; Ring 3

; Table Indicator
TI_GDT equ 000b                                 ; set GDT selector 
TI_LDT equ 100b                                 ; set LDT selector

由于新的 loader 的大小超过了 512 字节,所以读取的时候要读超过一个扇区,需要对 mbr.S 和 dd 写入磁盘时的扇区数进行更新。

最后的效果:

关于 loader.S 中的 jmp dword 指令

jmp dword SELECTOR_CODE:ProctectionModeStart    ; reflesh assembly line

书上详细说了一下这条指令存在的意义,确实很有必要,让我知道了我不知道的东西。原因有二:

刷新描述符缓冲寄存器

由于从 GDT 表中取出表项并计算出需要的一些数据是非常费时间的,所以 80286 之后的 CPU 中都有描述符缓冲寄存器来缓存段的信息。虽然这个缓冲器虽然是为了保护模式才出现的,但并不代表 CPU 在实模式下执行时不会使用到这个缓冲器。

这里就需要多说两句了,8086 是没有 GDT 这些东西的,自然不会有描述符缓冲寄存器,但是能够进入保护模式的 CPU 在实模式下执行的时候,并不是自废武功,退化到 8086,处处受限,而是“模拟”实模式起到兼容的效果,这些 CPU 在实模式下仍然会用到该缓冲器,并通过 GDT 中的 D 位等来实现 8086 的特性。

我在写这个 jmp 的时候感到十分疑惑,为什么进入了保护模式,还不用指定 [bits 32] 来让这个 jmp 指令变为 32 位指令。现在才明白,其实 32/64 位模式和保护模式并不是对等的,两者甚至可以说没有关心,代码、数据是否是 32/64 位完全是由 GDT 来指定的,在保护模式下仍然可以以 16 位的模式执行,只是我们在保护模式下常常使用 32/64 位而已。

那么在进入了保护模式,即将 CR0 的最低一位设置成了 1 之后,段描述符缓冲寄存器并没有被更新,此时的代码仍然是在 16 位模式下执行的,所以不应该让汇编器编译成 32 位代码。

既然我们进入了保护模式,那自然是要与 8086 说再见了,肯定不想在 16 位下工作了,就需要刷新这个段描述符缓冲器,也就是更新段选择子。代码段的段选择子是 CS,而 CS 是不能直接 mov 的,要改变他的值只能通过一些远跳转指令 call,jmp,retf 来实现。用 retf 和 call 都没意义,所以用 jmp 来实现。

这就是这个跳转指令存在的意义之一了。

刷新流水线

这个和 CPU 的实现关系比较紧密,我只简单说一下,可能会很不严谨。

CPU 要执行一个指令,需要做

  • 取指令
  • 翻译指令
  • 执行

这三个是由不同的部件执行,他们可以并行执行,既然如此就可以在执行当前指令的同时翻译下一条指令、取下下条指令。如果代码都是顺序执行下去的话,其实这样工作是“可行”的,不会出错。但是我们的代码中会有各种各样的跳转,如果翻译好的指令是下一条指令,实际应该执行的指令是被跳转的指令,但是流水线上流(译码器)传来了下一条指令,就会出现指令执行错误,这是不可接受的。所以在碰到无条件跳转的时候,一定会刷新流水线(如果是条件跳转,则会有分支预测之类的,这里按下不表),我们通过 jmp 指令就可以实现刷新流水线了。

但是好像还是没有解释问题,我们之后要执行的代码就是这个跳转指令的下一条指令啊,完全可以先取指令译码啊。好像很有道理,但是实际上不对,之后的代码都是 32 位的,而 jmp 前 CPU 还工作在 16 位下,提前译码就会出错,所以我们必须刷新掉流水线。

基于以上两个原因,我们需要使用这个 jmp 语句来进入 32 位模式。