简单内核实现笔记 part 1

本系列文章主要记录阅读《操作系统真相还原》一书的笔记,主要是记录实现部分,如果您觉得看着很唐突的话很正常,因为我主要是记录代码和实现的过程,如果您能直接看懂的话,那功力是比较深厚的了,不过如果您没看过这本书的话,我还是非常建议您看着这本书和我一起做实验。

很久之前就想要实现一个内核,就算是抄也想要抄一遍。虽然这是一件重复造轮子的事情,但我个人认为这是任何一个想深入理解内核的人都需要走的一步,Windows和Linux在很多方面是类似的,深入了解其底层原理,你会发现不过也就是一个软件罢了。至于为何要写一篇文章来记录这繁琐枯燥的过程,一方面是因为自己喜欢记录一些学习过程,之后不说100%,至少80%可能是会参考到的。另一方面自己很久之前也答应了一些人要写个内核,却迟迟没有开始,说到这我都不好意思了。

关于操作系统实现的书籍我自己的阅读顺序如下,我自己认为先从Linux平台下手再到Windows比较好,当然也有很多其他很好的书籍,像《一个64位操作系统的设计与实现》、《30天自制操作系统》等,我认为选个一两本就足够了,带着目的去读书最重要。Anyway 希望这系列文章能够帮到你 :)

  1. 《操作系统真相还原》

  2. 《x86汇编语言从实模式到保护模式》

环境搭建

实验环境如下

主机 虚拟机(Vmware 15.5.0 build) 实验机(Ubantu中安装)
Windows 10 1903 x64 Ubantu 16.04 x64 Bochs 2.6.2

首先安装一系列依赖

1
2
3
4
5
6
7
sudo apt-get install build-essential

sudo apt-get install xorg-dev

sudo apt-get install bison

sudo apt-get install libgtk2.0-dev

放入网上下载好的bochs 2.6.2版本,解压安装

1
2
3
tar zxvf bochs-2.6.2.tar.gz

cd bochs-2.6.2

设置环境属性

1
2
3
4
5
6
7
8
./configure \
--prefix=/home/guang/soft/bochs-2.6.2 \
--enable-debugger \
--enable-disasm \
--enable-iodebug \
--enable-x86-debugger \
--with-x \
--with-x11

直接sudo make编译正常情况会出现以下错误

1
2
3
[...]
Makefile:179: recipe for target 'bochs' failed
make: *** [bochs] Error 1

找到Makefile文件LIBS =这句最后面添加上-lpthread

1
LIBS =  -lm -lgtk-x11-2.0 -lgdk-x11-2.0 -lpangocairo-1.0 -latk-1.0 -lcairo -lgdk_pixbuf-2.0 -lgio-2.0 -lpangoft2-1.0 -lpango-1.0 -lgobject-2.0 -lglib-2.0 -lfontconfig -lfreetype -lpthread

重新sudo make编译,然后sudo make install安装,在bochs目录下创建一个bochsrc.disk配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Configuration file for Bochs
# 设置Bochs在运行过程中能够使用的内存: 32 MB
megs: 32

# 设置真实机器的BIOS和VGA BIOS
# 修改成你们对应的地址

romimage: file=/home/guang/soft/bochs-2.6.2/share/bochs/BIOS-bochs-latest
vgaromimage: file=/home/guang/soft/bochs-2.6.2/share/bochs/VGABIOS-lgpl-latest

# 设置Bochs所使用的磁盘
# 设置启动盘符
boot: disk

# 设置日志文件的输出
log: bochs.out

# 开启或关闭某些功能,修改成你们对应的地址
mouse: enabled=0
keyboard:keymap=/home/guang/soft/bochs-2.6.2/share/bochs/keymaps/x11-pc-us.map

# 硬盘设置
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14

# 增加gdb支持,这里添加会报错,暂时不需要
# gdbstub: enabled=1, port=1234, text_base=0, data_base=0, bss_base=0

运行即可,路径为/home/guang/soft/bochs-2.6.2/bin,之后的命令能加sudo的都加上,避免不必要的错误

image-20200429125517920

第一次输入直接回车,第二次输入我们的bochsrc.disk即可设置我们初始化文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
You can also start bochs with the -q option to skip these menus.

1. Restore factory default configuration
2. Read options from...
3. Edit options
4. Save options to...
5. Restore the Bochs state from...
6. Begin simulation
7. Quit now

Please choose one: [2] // 直接回车

What is the configuration file name?
To cancel, type 'none'. [none] bochsrc.disk // 输入我们刚才配置的文件即可
00000000000i[ ] reading configuration from bochsrc.disk

运行之后会中断提示Mouse capture off,这个时候输入c继续运行即可,运行成功如下图,这里会提示没有设置设备信息

image-20200429151727064

设置设备需要运行bximage进行模拟,使用方法如下

1
2
3
4
5
6
7
8
9
Usage: bximage [options] [filename]

Supported options:
-fd create floppy image // 创建软盘
-hd create hard disk image // 创建硬盘
-mode=... image mode (hard disks only) // 创建硬盘类型
-size=... image size in megabytes // 创建大小
-q quiet mode (don't prompt for user input) // 以静默模式创建,不和用户交互
--help display this help and exit

如下方式创建名为hd60M.img的虚拟镜像

image-20200429153853374

在之前的bochsrc.disk配置文件中添加一行ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63,重新指定配置文件运行

1
sudo ./bochs -f bochsrc.disk

再次报错,这次提示的错误和之前的不太一样,意思是这不是一个启动盘,后面我们需要编写具体的启动盘,故完成到这里环境搭建完毕

image-20200429191208677

实模式

BIOS

BIOS即输入输出系统,是按下主机键之后第一个运行的软件,其主要工作有

  • 调用检测、初始化硬件功能
  • 建立中断向量表(IVT)
  • 校验启动盘中位于0盘0道1扇区的内容

实模式下的1MB内存布局如下,其中0~0x9FFFF处是DRAM,即动态随机访问内存,我们所装的物理内存就是DRAM,如DDR、DDR2等。顶部的0xF0000~0xFFFFF,这64KB的内存是ROM。

起始 结束 大小 用途
FFFF0 FFFFF 16B BIOS入口地址,此地址也属于BIOS代码,同样属于顶部的640KB字节。只是为了强调其入口地址才单独贴出来。此处16字节的内容是跳转指令jmp f000:e05b
F0000 FFFEF 64KB-16B 系统BIOS范围是F0000~FFFFF共640KB,为说明入口地址,将最上面的16字节从此处去掉了,所以此处终止地址是0XFFFEF
C8000 EFFFF 160KB 映射硬件适配器的ROM或内存映射式I/O
C0000 C7FFF 32KB 显示适配器BIOS
B8000 BFFFF 32KB 用于文本模式显示适配器
B0000 B7FFF 32KB 用于黑白显示适配器
A0000 AFFFF 64KB 用于彩色显示适配器
9FC00 9FFFF 1KB EBDA(Extended BIOS Data Area)扩展BIOS数据区
7E00 9FBFF 622080B约608KB 可用区域
7C00 7DFF 512B MBR被BIOS加载到此处,共512字节
500 7BFF 30464B约30KB 可用区域
400 4FF 256B BIOS Data Area(BIOS数据区)
000 3FF 1KB Interrupt Vector Table(中断向量表)

BIOS因为是第一个运行的软件,故需要用硬件对其加载到ROM(0xF0000~0xFFFFF)中,其入口点是0xFFFF0(CPU通过段地址+偏移地址即可访问),因为自己还没有加载起来,想要直接定位到0xFFFF0靠自己肯定是不行的,故也需要硬件来操作,使开机的时候强制将CS:IP置为0xF000:0xFFF0,实模式段基址需要乘16(左移四位),故起始地址为0xFFFF0

1
(0xF000 << 4) + 0xFFF0 = 0xFFFF0

这个起始地址距离1MB内存只有16字节大小,所以这里肯定不是真正实现BIOS的地方,这里肯定只是一个类似于函数索引表的跳板,跳转到真正执行BIOS的地方。

BIOS最后的工作就是校验启动盘中位于0盘0道1扇区的内容,这里面其实主要校验的是MBR,如果此扇区末尾两个字节为0x55和0xaa,BIOS即认定这里为MBR,便将其加载到0x7c00处,然后跳转到这个地方继续执行。至于为什么这里是0x7c00书中也有提到,主要是考虑到不能覆盖中断向量表、预留栈空间等,BIOS大致流程也差不多总结到这里。下一步就是做实验。

第一个MBR

这里用NASM实现一个简单的MBR,功能是在屏幕上打印字符串”1 MBR”,背景色黑色,前景色绿色,因为有中文格式问题,复制的时候建议去除所有中文以及注释,当然最好是自己敲一遍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
; mbr.S
; 主引导程序
; ----------------------------------------------------------
SECTION MBR vstart=0x7c00 ; 起始地址为0x7c00
  mov ax,cs ; cs寄存器初始化其他寄存器
  mov ds,ax
  mov es,ax
  mov ss,ax
  mov fs,ax
  mov sp,0x7c00 ; 初始化栈指针

; 下面功能为清屏,清理其他输出信息,保证我们输出的内容可见+

; -----------------------------------------------------------
; INT 0x10  功能号:0x06  功能描述:上卷窗口
; -----------------------------------------------------------
; AH 功能号= 0x06
; AL = 上卷的行数(如果为0,表示全部)
; BH = 上卷行属性
; (CL,CH) = 窗口左上角的(X,Y)位置
; (DL,DH) = 窗口右下角的(X,Y)位置
; 无返回值
  mov   ax, 0x600
  mov   bx, 0x700
  mov   cx, 0 ; 左上角: (0, 0)
  mov   dx, 0x184f ; 右下角: (80,25),
; VGA文本模式中,一行只能容纳80个字符,共25行。
                 ; 下标从0开始,所以0x18=24,0x4f=79
  int   0x10         ; int 0x10

;;;;;;;;;  下面这三行代码获取光标位置  ;;;;;;;;;
;.get_cursor获取当前光标位置,在光标位置处打印字符。
  mov ah, 3          ; 输入: 3号子功能是获取光标位置,需要存入ah寄存器
  mov bh, 0          ; bh寄存器存储的是待获取光标的页号,这里是第0页

  int 0x10           ; 输出: ch=光标开始行,cl=光标结束行
                 ; dh=光标所在行号,dl=光标所在列号

;;;;;;;;;  获取光标位置结束  ;;;;;;;;;

;;;;;;;;;  打印字符串  ;;;;;;;;;
  ;还是用10h中断,不过这次调用13号子功能打印字符串
  mov ax, message
  mov bp, ax          ; es:bp 为串首地址,es此时同cs一致,
                 ; 开头时已经为sreg初始化

; 光标位置要用到dx寄存器中内容,cx中的光标位置可忽略
  mov cx, 5          ; cx 为串长度,不包括结束符0的字符个数
  mov ax, 0x1301       ; 子功能号13显示字符及属性,要存入ah寄存器,
                 ; al设置写字符方式 ah=01: 显示字符串,光标跟随移动
  mov bx, 0x2         ; bh存储要显示的页号,此处是第0页,
                 ; bl中是字符属性,属性黑底绿字(bl = 02h)
  int 0x10           ; 执行BIOS 0x10 号中断
;;;;;;;;;  打字字符串结束  ;;;;;;;;;
; $为eip地址,$$为本section的起始地址
  jmp $            ; 使程序无限循环,相当于jmp eip

  message db "1 MBR" ; 打印的字符串
  times 510-($-$$) db 0 ; 用0填充本扇区空余的字节数,$-$$即为本行到本section的偏移
  ; 510减去是为了腾出2字节存放0x55和0xaa魔数
  ; 也就是覆盖除了最后两字节和上面已经写了的字节
  db 0x55,0xaa

命令sudo nasm -o mbr.bin mbr.S编译生成mbr.bin文件,然后用dd命令将其写入我们镜像中的第0行,512字节大小,也就是写入一开始BIOS执行的MBR

image-20200430104313495

再次运行sudo ./bochs -f bochsrc.disk即可显示出我们写的内容,断下的时候输入c即可运行

image-20200430104739183

完善MBR

这里介绍一些显存相关内容,显存地址分布

起始 结束 大小 用途
C0000 C7FFF 32KB 显示适配器BIOS
B8000 BFFFF 32KB 用于文本模式显示适配器
B0000 B7FFF 32KB 用于黑白显示适配器
A0000 AFFFF 64KB 用于彩色显示适配器

根据上表地址直接操作显卡显示文本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
;主引导程序 
;------------------------------------------------------------
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800 ;参考上表的基址
mov gs,ax

; 清屏
;利用0x06号功能,上卷全部行,则可清屏。
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0600h
mov bx, 0700h
mov cx, 0 ; 左上角: (0, 0)
mov dx, 184fh ; 右下角: (80,25),
; 因为VGA文本模式中,一行只能容纳80个字符,共25行。
; 下标从0开始,所以0x18=24,0x4f=79
int 10h ; int 10h

; 输出背景色绿色,前景色红色,并且跳动的字符串"1 MBR"
mov byte [gs:0x00],'1' ; 一字节为数据,一字节为属性
mov byte [gs:0x01],0xA4 ; A表示绿色背景闪烁,4表示前景色为红色

mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4

mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4

mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4

mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4

jmp $ ; 通过死循环使程序悬停在此

times 510-($-$$) db 0
db 0x55,0xaa

效果如下,红色字体,绿色背景闪烁

image-20200504083137724

上面MBR实际上没做什么事情,只是单纯的实现了和显卡交互,和打印hello world区别不是很大,我们需要不断增加新的有实际用处的功能,MBR只有512字节,无法实现对内核的加载,所以我们下一步需要让其增加读写磁盘的功能,在硬盘中加载loader,然后用loader来加载我们的内核。

MBR在第0扇区(逻辑LBA编号),loader理论上可以在1扇区,这里为了安全起见放在2扇区,预留出1扇区的空位。MBR将二扇区的内容读出来,放入实模式1MB内存分布中的可用区域(参见BIOS处的表格),因为loader中还会加载一些GDT等的描述符表,这些表不能被覆盖,随着内核越来越完整,loader的内核也不断从低地址向高地址发展,所以需要选择一个稍安全的地方,留出一些空位,这里选择0x900,大致步骤如下:

  1. 先选择通道,往该通道的sector count寄存器中写入待操作的扇区数,参考如下表格找到端口

    img

  2. 往该通道上的三个LBA寄存器写入扇区起始地址的低24位。

  3. 往device寄存器中写入LBA地址的24~27位,并置第6位为1,使其为LBA模式,设置第4位,选择操作的硬盘(master硬盘或slave硬盘)。

  4. 往该通道上的command寄存器写入操作命令。

  5. 读取该通道上的status寄存器,判断硬盘工作是否完成。

  6. 如果以上步骤是读硬盘,进入下一个步骤。否则,完工。

  7. 将硬盘数据读出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
;主引导程序 
;------------------------------------------------------------
%include "boot.inc"
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800
mov gs,ax

;清屏
;利用0x06号功能,上卷全部行,则可清屏。
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0600h
mov bx, 0700h
mov cx, 0 ; 左上角: (0, 0)
mov dx, 184fh ; 右下角: (80,25),
; 因为VGA文本模式中,一行只能容纳80个字符,共25行。
; 下标从0开始,所以0x18=24,0x4f=79
int 10h ; int 10h

; 输出字符串:MBR
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4

mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4

mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4 ;A表示绿色背景闪烁,4表示前景色为红色

mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4

mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4
; 寄存器传三个参数
mov eax,LOADER_START_SECTOR ; 起始扇区LBA地址
mov bx,LOADER_BASE_ADDR ; 写入的地址
mov cx,1 ; 待读入的扇区数,这里是简单的loader故一个扇区足够
call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)

jmp LOADER_BASE_ADDR

;-------------------------------------------------------------------------------
;功能:读取硬盘n个扇区
rd_disk_m_16:
;-------------------------------------------------------------------------------
; eax=LBA扇区号
; ebx=将数据写入的内存地址
; ecx=读入的扇区数
mov esi,eax ;备份eax
mov di,cx ;备份cx
;读写硬盘:
;第1步:选择通道,往该通道的sector count寄存器中写入待操作的扇区数
;因为bochs配置文件中虚拟硬盘属于ata0,是Primary通道,所以sector count寄存器由0x1f2端口访问
mov dx,0x1f2
mov al,cl
out dx,al ;读取的扇区数
;out 往端口中写数据
;in 从端口中读数据

mov eax,esi ;恢复ax

;第2步:将LBA地址写入三个LBA寄存器和device寄存器的低4位

;LBA地址7~0位写入端口0x1f3
mov dx,0x1f3
out dx,al

;LBA地址15~8位写入端口0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al

;LBA地址23~16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al

shr eax,cl
and al,0x0f ; lba第24~27位
or al,0xe0 ; 设置7~4位为1110,表示lba模式
mov dx,0x1f6
out dx,al

;第3步:向command寄存器写入读命令,0x20
mov dx,0x1f7 ;要写入的端口
mov al,0x20 ;要写入的数据
out dx,al

;第4步:检测硬盘状态,读取该通道上的status寄存器,判断硬盘工作是否完成
.not_ready:
;同一端口,写时表示写入命令字,读时表示读入硬盘状态
nop
in al,dx
and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备好,继续等。

;第5步:从0x1f0端口读数据
mov ax, di
mov dx, 256
mul dx
mov cx, ax ; di为要读取的扇区数,一个扇区有512字节,每次读入一个字,
; 共需di*512/2次,所以di*256
mov dx, 0x1f0
.go_on_read: ; 循环写入bx指向的内存
in ax,dx
mov [bx],ax
add bx,2
loop .go_on_read
ret

times 510-($-$$) db 0
db 0x55,0xaa

我们需要在boot.inc中指定两句头文件参数,如下所示

1
2
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2

这里编译需要加一个-I参数,这里我将boot.inc放在include目录下

image-20200505092951970

编译成功之后,发现我们还没有写loader,这会导致CPU跳转到0x900处的地方,所以下一步我们就需要实现一个简单的loader,至少保证能简单运行下去。复习一下现在位置我们所知道的开机流程:BIOS -> MBR -> Loader

loader中的内容我们用之前MBR的即可,这里编译也是需要sudo nasm -I include/ -o loader.bin loader.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR

mov byte [gs:0x00],'2'
mov byte [gs:0x01],0xA4

mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4

mov byte [gs:0x04],'L'
mov byte [gs:0x05],0xA4

mov byte [gs:0x06],'O'
mov byte [gs:0x07],0xA4

mov byte [gs:0x08],'A'
mov byte [gs:0x09],0xA4

mov byte [gs:0x0a],'D'
mov byte [gs:0x0b],0xA4

mov byte [gs:0x0c],'E'
mov byte [gs:0x0d],0xA4

mov byte [gs:0x0e],'R'
mov byte [gs:0x0f],0xA4

jmp $

dd命令指定seek参数将其放入第二个扇区

1
sudo dd if=./loader.bin of=/home/guang/soft/bochs-2.6.2/bin/hd60M.img bs=512 count=1 seek=2 conv=notrunc

最后的运行效果如下

image-20200505130009352

实模式的安全缺陷总结:

  1. 操作系统和用户属于同一特权级
  2. 用户程序引用的地址都是指向真实的物理地址
  3. 用户程序可以自由修改段基址,自由访问所有内存

保护模式初探

32位CPU在16位模式下运行的状态为实模式,当CPU发展到32位的时候出现保护模式,保护模式下CPU变成了32根地址总线,32根地址总线足够访问4GB的空间,为了满足4GB空间寻址,寄存器宽度也增加了一倍,从原来的2字节变为4字节32位。除了段寄存器仍然使用16位,其余通用寄存器都提升到32位。
寄存器要保持向下兼容,不会重新构造原来的基础设备而是在原有的寄存器基础上进行了拓展。经过拓展后的寄存器在原有名字上加了个e,如图所示

段描述符

保护模式中的段基址不再是像实模式那样直接存放物理地址,段寄存器中要记录32位地址的数据段基址,16位肯定是装不下的,所以段基址都存储在一个数据结构中——全局描述符表。其中每个表项称为段描述符,其大小为64字节,用来描述各个内存段的起始地址、大小、权限等信息。而这里段寄存器中存放的是段选择子 selector 。如果把全局描述符表当作数组来看的话,段选择子就是数组的下标,用来索引段描述符。该全局描述符表很大,所以放在内存中,由GDTR寄存器指向它。

Tip:因为段描述符是在内存中,CPU访问较慢,效率不高,故在80286的保护模式中增加了一个段描述符缓冲寄存器用来提高效率。CPU每次将获取到的内存信息整理之后存入此寄存器,之后每次访问相同的段时,直接读取对应的段描述符缓冲寄存器即可。

因为80286始终是16位CPU,通用寄存器还是16位宽,寻址空间为2的24次方也就是16MB,单个寄存器依旧无法访问到全部内存空间,这就有了80386的登场,参数总结如下

版本 CPU位数 寄存器宽 地址线宽 寻址空间
8086 16 16 20 2^20 = 1MB
80286 16 16 24 2^24 = 16MB
80386 32 32 32 2^32 = 4GB

实模式和保护模式的内存寻址方式如下图所示

32位CPU既支持实模式有支持保护模式,为了区分当前指令到底是哪个模式下运行的,编译器提供了伪指令bits

指令格式:[bits 16]或[bits 32],分别对应16位和32位

如下面的例子

1
2
3
4
5
6
7
[bits 16]
mov ax, 0x1234
mov dx, 0x1234

[bits 32]
mov eax, 0x1234
mov edx, 0x1234

模式之间可以相互使用对方环境下的资源。比如,16位实模式下可以使用32位保护模式下的寄存器。如果要用另一模式下的操作数大小,需要在指令前添加指令前缀0x66,将当前指令模式临时转变为另一种模式。这就是反转的意义,不管当前模式是什么,总是转变成相反的运行模式。这个转换是临时的,只有在当前指令才有效。如下图
比如,在指令中添加了0x66反转前缀后:
假如当前运行模式是16位实模式,操作数大小变为32位。
假设当前运行模式是32位保护模式,操作数大小变为16位。

操作数可以在模式间相互转换,那么寻址方式一样可以,只需要在它的指令前加上0x67反转前缀即可。如下图

下面总结一下,保护模式首先是必须向前兼容的,故其访问内存依然是段基址:段内偏移的方式,结合前面总结过实模式的一些安全问题,想要解决这些问题就得既保证向前兼容,又保证安全性。CPU工程师想到的方法就是增加更多的安全属性位,下图即是段描述符格式:

其实对于各个字段的解释,我更倾向于用的时候去查,因为随着CPU的更新换代,如今的一些位可能有变化,要参考当然是参考最新的比较好,比如参考intel手册之类的权威资料,无非就是保存一些段的属性(可读、可写、是否存在等),权限(Ring0-Ring3),基址,界限范围等信息。其访问内存的形式如下图所示

全局描述符

全局描述符表GDT相当于是一个描述符的数组,数组每一个元素都是8个字节的描述符,而选择子则是提供下标在GDT中索引描述符。假设 A[10] 数组即为GDT表,则

  • GDT表相当于数组A
  • 数组中每个数据A[0]~A[10]相当于描述符
  • A[0]~A[10]中的0~10索引下标则是选择子

全局描述符表是公用的,GDTR这个专门的寄存器则存放GDT表的内存地址和大小,是一个48位的寄存器,对这个寄存器操作无法用mov等指令,这里用的是lgdt指令初始化,指令格式是:lgdt 48位内存数据

其中前16位是GDT以字节为单位的界限值,相当于GDT字节大小减1。后32位是GDT的起始地址。由于GDT的大小是16位二进制,表示范围是2^16 = 65536字节。每个描述符大小是8字节,故GDT中最多可容纳的描述符数量是65536/8 = 8198,也就是可以容纳8192个段或门。

局部描述符表

按照CPU的设想,一个任务对应一个局部描述符表LDT,切换任务的时候也会切换LDT,LDT也存放在内存中,由LDTR寄存器指向,加载的指令为lldt。对于操作系统来说,每个系统必须定义一个GDT,用于系统中的所有任务和程序。可选择性定义若干个LDT。LDT本身是一个段,而GDT不是。这种表在这里并不常用所以就不继续展开了,感兴趣的小伙伴可以自行百度。

段选择子

首先复习一下段寄存器CS、DS、ES、FS、GS、SS,保护模式下段寄存器中存放的即是段选择子,结构如下,其中0-1位表示特权级,2位TI表示选择子是在GDT中,还是在LDT中索引描述符,剩下的13位就是索引部分,2^13 = 8192,这也刚好和GDT最多容纳的段或门的数量相符。

举个访问内存的例子,例如选择子是 0x8,将其加载到 ds 寄存器后,访问 ds: 0x9 这样的内存,其过程是首先拆分 0x8 为二进制 0000 0000 0000 1000 然后得到 0x8 的低 2 位是RPL,其值为 00。第 2 是 TI ,其值 0,表示是在 GOT 中索引段描述符。用 0x8 的高 13 位 0x1 在 GOT 中索引,也就是 GOT 中的第 1 个段描述符(GDT 中第 0 个段描述符不可用)。假设第 1 个段描述符中的 3个段基址部分,其值为 0xl234oCPU 将 0xl234 作为段基址,与段内偏移地址 0x9 相加, 0x1234 + 0x9 = 0x123d。用所得的和 0x123d 作为访存地址。

Tip:GDT中第0个段描述符不可用是为了防止未初始化段选择子,如果未初始化段选择子就会访问到第0个段描述符从而抛出异常。

为了让段基址:段内偏移策略继续可用,CPU采取的做法是将超过1MB的部分自动绕回到0地址,继续从0地址开始映射。相当于把地址对1MB求模。超过1MB多余出来的内存被称为高端内存区HMA。

这种地址绕回的做法需要通过两种情况分别讨论:

  • 对于只有20位地址线的CPU,不需要任何操作便能自动实现地址绕回

  • 当其他有更多地址总线的时候,因为CPU可以访问更多的内存,所以不会产生地址回滚。这种情况下的解决方案就是对第21根地址线进行操作。开启A20则直接访问物理地址即可,关闭A20则使用回绕方式访问。

打开A20的操作方法有以下三个步骤,主要是将0x92端口第一位置一即可

1
2
3
in al, 0x92
or al, 0000_0010B
out 0x92, al

CR0寄存器

CRx系列寄存器属于控制寄存器一类,这里主要介绍CR0寄存器,这个寄存器如下图所示,其中第0位PE位表示是否开启保护模式

其他位如下图所示,这里暂时不深入讨论

对CR0的PE位操作如下所示

1
2
3
mov eax,cr0
or eax,0x00000001
mov cr0,eax

进入保护模式

现在基础知识总结的差不多了,进入下一个实验阶段,更新我们的mbr和loader,因为我们的loader.bin会超过512字节,所以要把mbr.S中加载loader.bin的读入扇区数增大,目前是1扇区,这里直接改为4扇区

1
2
3
4
...
52 mov cx, 4 ; 带读入的扇区数
53 call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)
...

如下图所示,cx 寄存器中存放的这个参数非常重要,代表读入扇区数,如果loader.bin的大小超过mbr读入的扇区数,就需要对这个参数进行修改

image-20200506225157139

接下来就是更新boot.inc,里面存放的是loader.S的一些符号信息,相当于头文件,比之前主要多定义了GDT描述符的属性和选择子的属性。Linux使用的是平坦模型,整个内存都在一个段里,这里平坦模型在我们定义的描述符中,段基址是0,段界限 * 粒度 = 4G 粒度选的是4k,故段界限是 0xFFFFF

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
;--------------------- loader 和 kernel---------------------

LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2

;-------------------- gdt 描述符属性 ----------------------
DESC_G_4K equ 1_00000000000000000000000b ;描述符的G位为4k粒度,以二进制表示,下划线可去掉
DESC_D_32 equ 1_0000000000000000000000b
DESC_L equ 0_000000000000000000000b ;64位代码标记,此处标记为0便可
DESC_AVL equ 0_00000000000000000000b ;CPU不用此位,暂置为0
DESC_LIMIT_CODE2 equ 1111_0000000000000000b ;段界限,需要设置为0xFFFFF
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2 equ 0000_000000000000000b
DESC_P equ 1_000000000000000b
DESC_DPL_0 equ 00_0000000000000b
DESC_DPL_1 equ 01_0000000000000b
DESC_DPL_2 equ 10_0000000000000b
DESC_DPL_3 equ 11_0000000000000b
DESC_S_CODE equ 1_000000000000b
DESC_S_DATA equ DESC_S_CODE
DESC_S_sys equ 0_000000000000b
DESC_TYPE_CODE equ 1000_00000000b ;x=1,c=0,r=0,a=0 代码段是可执行的,非一致性,不可读,已访问位a清0.
DESC_TYPE_DATA equ 0010_00000000b ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写,已访问位a清0.

DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00 ;定义代码段的高四字节,(0x00 << 24)表示"段基址的24~31"字段,该字段位于段描述符高四字节24~31位,平坦模式段基址为0,所以这里用0填充,最后的0x00也是
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b

;-------------- 选择子属性 ---------------
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b

下面修改 loader.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR ;初始化的栈顶
jmp loader_start

;构建gdt以及内部的描述符,每个8字节,由两个四字节组成
;第0个描述符不可用,置为0
GDT_BASE: dd 0x00000000 ;低四字节
dd 0x00000000 ;高四字节
;代码段描述符
CODE_DESC: dd 0x0000FFFF ;0xFFFF是段界限的0~15位,0x0000是段基址的0~15位
DESC_CODE_HIGH4 ;boot.inc中定义的高四字节
;数据段和栈段描述符
DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4
;显存段描述符,为了方便显存操作,显存段不用平坦模型
VIDEO_DESC: dd 0x80000007 ;参考1MB实模式内存分布,limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4 ;此时dpl为0
GDT_SIZE equ $ - GDT_BASE ;地址差获得GDT大小
GDT_LIMIT equ GDT_SIZE - 1 ;大小减1获得段界限
times 60 dq 0 ;此处预留60个描述符空位,为以后做准备,times相当于是循环执行命令
;构建代码段、数据段、显存段的选择子
SELECTOR_CODE equ (0x0001<<3)+TI_GDT+RPL0 ;相当于(CODE_DESC-GDT_BASE)/8+TI_GDT+RPL0
SELECTOR_DATA equ (0x0002<<3)+TI_GDT+RPL0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0

;以下是gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
loadermsg db '2 loader in real.'
loader_start:
;---------------------------------------------------------
;INT 0x10 功能号:0x13 功能描述符:打印字符串
;---------------------------------------------------------
;输入:
;AH 子功能号=13H
;BH = 页码
;BL = 属性(若AL=00H或01H)
;CX = 字符串长度
;(DH,DL)=坐标(行,列)
;ES:BP=字符串地址
;AL=显示输出方式
;0——字符串中只含显示字符,其显示属性在BL中。显示后,光标位置不变
;1——字符串中只含显示字符,其显示属性在BL中。显示后,光标位置改变
;2——字符串中只含显示字符和显示属性。显示后,光标位置不变。
;3——字符串中只含显示字符和显示属性。显示后,光标位置改变。
;无返回值
mov sp,LOADER_BASE_ADDR
mov bp,loadermsg ;ES:BP=字符串地址
mov cx,17 ;CX=字符串长度
mov ax,0x1301 ;AH=13,AL=01h
mov bx,0x001f ;页号为0(BH=0)蓝底分红子(BL=1fh)
mov dx,0x1800
int 0x10

;---------------------准备进入保护模式-------------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1

;-----------------------打开A20--------------------------
in al,0x92
or al,0000_0010B
out 0x92,al
;-----------------------加载GDT--------------------------
lgdt [gdt_ptr]

;----------------------cr0 第 0 位置 1-------------------
mov eax,cr0
or eax,0x00000001
mov cr0,eax

jmp dword SELECTOR_CODE:p_mode_start ;下面指令又有16位又有32位,故需要刷新流水线

[bits 32]
p_mode_start:
;选择子初始化段寄存器
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' ;显存第80个字符的位置写一个P

jmp $

同之前的方法编译,注意这里loader.bin编译后为615个字节,需要2个扇区大小,写入磁盘时要给count赋值为2

image-20200507101206860

运行结果如下,其中1 MBR来自实模式下的mbr.S,2 loader in real来自实模式下用BIOS中断0x10实现的,左上角第二行的P是在保护模式下输出的。

image-20200507102103054

查看GDT表中的内容和我们设置的相符,其中第0个不可用。查看寄存器信息PE位设置为1表示已经进入保护模式。

24

保护模式对内存的保护体现在如下几个方面,这里简单总结一下,更详细的内容网上有很多更详细的说明,当然最权威的还是intel手册。

向段寄存器加载段选择子时的保护

当引用一个内存段时,实际上就是往段寄存器中加载个段选择子,为了避免非法引用内存段的情况,会检查选择子是否合理,判断方法就是通过验证索引值是否出现越界,越界则抛出异常。有如下表达式

描述符表基地址+选择子中的索引值*8+7<=描述符表基地址+描述符表界限值

总结如下图

image-20200507174438486

检查完选择子就该检查段描述符中 type 字段,也就是段的类型,如下图所示

image-20200507174438486

检查完类型后检查P位,P位表示该段是否存在,1表示存在,0表示不存在。

代码段和数据段的保护

代码段和数据段主要保护措施是当CPU访问一个地址的时候,判断该地址不能超过所在内存段的范围。简单总结如下图所示,出现这种跨段操作就会出现异常。

image-20200507174438486

栈段的保护

段描述符type中的e位表示扩展方向,栈可以向上扩展和向下扩展,下面就是检查方式

  • 对于向上拓展的段,实际段界限是段内可以访问的最后一个字节
  • 对于向下拓展的段,实际段界限是段内不可以访问的第一个字节

等价于如下表达式

1
实际段界限+1<=esp-操作数大小<=0xFFFFFFFF