简单内核实现笔记 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

保护模式进阶

获取物理内存容量

  Linux获取内存容量方法有三种,本质上分别是BIOS中断0x15的3个子功能,BIOS是实模式下的方法,只能在保护模式之前调用。总结如下

利用BIOS中断0x15子功能0xe820获取内存

此方法最灵活,返回的内容也最丰富,内存信息的内容是地址范围描述符来描述的(ARDS),每个字段4字节,一共20字节,调用0x15返回的也就是这个结构。其中Type字段表示内存类型,1表示这段内存可以使用;2表示不可用使用此内存;其它表示未定义,将来会用到

字节偏移量 属性名称 描述
0 BaseAddrLow 基地址的低32位
4 BaseAddrHigh 基地址的高32位
8 LengthLow 内存长度的低32位,以字节为单位
12 LengthHigh 内存长度的高32位,以字节为单位
16 Type 本段内存的类型

用0x15子功能0xe820调用说明和调用步骤如下

  1. 填写好”调用前输入”中列出的寄存器
  2. 执行中断调用 int 0x15
  3. 在CF位为0的情况下,”返回后输出”中对应的寄存器中就有结果

image-20200508082615611

利用BIOS中断0x15子功能0xe801获取内存

此方法最多识别4G的内存,结果存放在两组寄存器中,操作起来要简便一些,调用说明和调用步骤如下

  1. AX寄存器写入0xE801
  2. 执行中断调用 int 0x15
  3. 在CF位为0的情况下,”返回后输出”中对应的寄存器中就有结果

image-20200508083428601

利用BIOS中断0x15子功能0x88获取内存

此方法最多识别64MB内存,操作起来最简单,调用说明和调用步骤如下

  1. AX寄存器写入0x88
  2. 执行中断调用 int 0x15
  3. 在CF位为0的情况下,”返回后输出”中对应的寄存器中就有结果

image-20200508083919353

下面结合这三种方式改进我们的实验代码,下面是loader,我们将结果保存在了total_mem_bytes中,重要的一些地方都有注释,更详细的内容建议参考书中P183

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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
   %include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR

;构建gdt及其内部的描述符
GDT_BASE: dd 0x00000000
dd 0x00000000

CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4

DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4

VIDEO_DESC: dd 0x80000007 ; limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4 ; 此时dpl为0

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留60个描述符的空位(slot)
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 ; 同上

; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE

;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录ards结构体数量

loader_start:

;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------

xor ebx, ebx ;第一次调用时,ebx值要为0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;ards结构缓冲区
.e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
mov ecx, 20 ;ARDS地址范围描述符结构大小是20字节
int 0x15
jc .e820_failed_so_try_e801 ;若cf位为1则有错误发生,尝试0xe801子功能
add di, cx ;使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr] ;记录ARDS数量
cmp ebx, 0 ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
jnz .e820_mem_get_loop

;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf
xor edx, edx ;edx为最大的内存容量,在此先清0
.find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ;base_add_low
add eax, [ebx+8] ;length_low
add ebx, 20 ;指向缓冲区中下一个ARDS结构
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .next_ards
mov edx, eax ;edx为总内存大小
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok

;------ int 15h ax = E801h 获取内存大小,最大支持4G ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法

;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400 ;cx和ax值一样,cx用做乘数
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份

;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax,eax
mov ax,bx
mov ecx, 0x10000 ;0x10000十进制为64KB
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
mov edx,esi ;edx为总内存大小
jmp .mem_get_ok

;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
;int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax,0x0000FFFF

;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
mov cx, 0x400 ;0x400等于1024,将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16 ;把dx移到高16位
or edx, eax ;把积的低16位组合到edx,为32位的积
add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

.mem_get_ok:
mov [total_mem_bytes], edx ;将内存换为byte单位后存入total_mem_bytes处。


;----------------- 准备进入保护模式 -------------------
;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 ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt

[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'

jmp $

mbr.S中也需要修改一处内容,我们跳转的内容要加上0x300,原因是在 loader.S 中loader_start计算如下

(4个段描述符 + 60个段描述符槽位) * 8字节 = total_mem_bytes_offset

(4 + 60) * 8 = 512 = 0x200

total_mem_bytes + gdt_ptr + ards_buf + adrs_nr + total_mem_bytes_offset = loader_start

4 + 6 + 244 + 2 + 0x200 = 0x300

修改片断如下

1
2
3
4
5
6
7
8
9
10
[...] 
mov eax,LOADER_START_SECTOR
mov bx,LOADER_BASE_ADDR
mov cx,4
call rd_disk_m_16

jmp LOADER_BASE_ADDR+0x300 ; 这里

rd_disk_m_16:
[...]

运行结果如下,这里我们用xp 0xb00查看我们的结果,0x02000000换算过来刚好是我们bochsrc.disk中 megs 设置的32MB大小

image-20200508083919353

启动分页机制

  分页机制是当物理内存不足时,或者内存碎片过多无法容纳新进程等情况的一种应对措施。假如说此时未开启分页功能,而物理内存空间又不足,如下图所示,此时线性地址和物理地址一一对应,没有满足进程C的内存大小,可以选择等待进程B或者A执行完获得连续的内存空间,也可以将A3或者B1段换到硬盘上,腾出一部分空间,然而这些IO操作过多会使机器响应速度很慢,用户体验很差。

image-20200508083919353

出现这种情况的本质其实是在分段机制下,线性地址等价于物理地址。那么即使在进程B的下面还有10M的可用空间,但因为两块可用空间并不连续,所以进程C无法使用进程B下面的10M可用空间。

按照这种思路,只需要通过某种映射关系,将线性地址映射到任意的物理地址,就可以解决这种问题了。实现线性地址的连续,而物理地址不需要连续,于是分页机制就诞生了。

一级页表

  在保护模式下寻址依旧是通过段基址:段内偏移组成的线性地址,计算出线性地址后再通过判断分页位是否打开,若打开则开启分页机制进行检索,如下图所示

image-20200508083919353

分页机制的作用有

  • 将线性地址转换成物理地址
  • 用大小相等的页代替大小不等的段

分页机制的作用如下图所示,分页机制来映射的线性地址便是我们经常说的虚拟地址

image-20200508083919353

因为页大小 * 页数量 = 4GB,想要减少页表的大小,只能增加一页的大小。最终通过数学求极限,定下4KB为最佳页大小。页表将线性地址转换成物理地址的过程总结如下图,首先通过计算线性地址高20位索引出页表中的基址,然后加上低12位计算出最终的物理地址,下图中0x9234即是最终的物理地址

image-20200508083919353

二级页表

  无论是几级页表,标准页的尺寸都是4KB。所以4GB的线性地址空间最多有1M个标准页。一级页表是将这1M个标准页放置到一张页表中,二级页表是将这1M个标准页平均放置1K个页表中,每个页表包含有1K个页表项。页表项是4字节大小,页表包含1K个页表项,故页表的大小同样为4KB,刚好为一页。

为了管理页表的物理地址,专门有一个页目录表来存放这些页表。页目录表中存储的页表称为页目录项(PDE),页目录项同样为4KB,且最多有1K个页目录项,所以页目录表也是4KB,如下图所示

image-20200508083919353

二级页表中虚拟地址到物理地址的转换也有很大的变化,具体步骤如下

  • 用虚拟地址的高 10 位乘以 4,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的和,便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。
  • 用虚拟地址的中间 10 位乘以 4,作为页表内的偏移地址,加上在第 1 步中得到的页表物理地址,所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。
  • 虚拟地址的高 10 位和中间 10 位分别是 PDE PIE 的索引值,所以它们需要乘以 4。但低 12 位就不是索引值了,其表示的范围是 0~0xfff,作为页内偏移最合适,所以虚拟地址的低 12 位加上第二步中得到的物理页地址,所得的和便是最终转换的物理地址。

还是用书中的图最直观,下图表示mov ax, [0x1234567]的转换过程,可以发现cr3寄存器其实指向的是页目录表基地址

image-20200508083919353

PDE和PTE的结构如下图所示

image-20200508083919353

从右到左各属性总结如下表

属性位 意义
P 存在位,为1时表示该页在物理内存中,为0表示不在物理内存中
RW 读写位,为1时可读可写,为0是可读不可写
US 特权位,为1时表示处于普通用户,0~3特权级可访问,为0表示超级用户,0~2特权级可访问
PWT 页级通写位,为1表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存
PCD 页级高速缓存禁止位,为1表示该页启用高速缓存
A 访问位,为1表示该页被CPU访问过
D 脏页位,当CPU对一个页面执行写操作,此为被赋1
PAT 页属性表位,能够在页面一级的粒度上设置内存属性
G 全局位,为1表示该页在高速缓存TLB中一直保存
AVL 表示软件,系统可用该位,和CPU无关

总结这些步骤,我们启用分页机制需要做的事情如下

  1. 准备好页目录表及页表
  2. 将页表地址写入控制寄存器cr3
  3. 寄存器cr0的PG位置1

下面是创建页目录及页表的代码

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
; 创建页目录及页表
setup_page:
; 先把页目录占用的空间逐字节清零
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir

; 开始创建页目录项(PDE)
.create_pde: ; 创建PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ; 此时eax为第一个页表的位置及属性
mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址

; 下面将页目录项0和0xc00都存为第一个页表的地址,每个页表表示4MB内存
; 这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 一个页表项占用四字节
; 0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间
; 也就是页表的0xc0000000~0xffffffff这1G属于内核
; 0x0~0xbfffffff这3G属于用户进程
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax ; 使最后一个目录项指向页目录表自己的地址

; 下面创建页表项(PTE)
mov ecx, 256 ; 1M低端内存 / 每页大小 4K = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7
.create_pte: ; 创建PTE
mov [ebx+esi*4], edx ; 此时的edx为0x101000,也就是第一个页表的地址
add edx, 4096
inc esi
loop .create_pte

; 创建内核其他页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 此时eax为第二个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P ; 属性为7
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ; 范围为第769~1022的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret

在boot.inc中添加如下信息

1
2
3
4
5
6
7
8
; loader 和 kernel
PAGE_DIR_TABLE_POS equ 0x100000
; 页表相关属性
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b

进行完第一步的内容,之后的操作相对就简单了,将页表地址写入控制寄存器cr3寄存器和将cr0的PG位置1的操作整合起来的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
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR

;构建gdt及其内部的描述符
GDT_BASE: dd 0x00000000
dd 0x00000000

CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4

DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4

VIDEO_DESC: dd 0x80000007 ; limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4 ; 此时dpl为0

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留60个描述符的空位(slot)
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 ; 同上

; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE

;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录ards结构体数量

loader_start:

;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------

xor ebx, ebx ;第一次调用时,ebx值要为0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;ards结构缓冲区
.e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
mov ecx, 20 ;ARDS地址范围描述符结构大小是20字节
int 0x15
jc .e820_failed_so_try_e801 ;若cf位为1则有错误发生,尝试0xe801子功能
add di, cx ;使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr] ;记录ARDS数量
cmp ebx, 0 ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
jnz .e820_mem_get_loop

;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf
xor edx, edx ;edx为最大的内存容量,在此先清0
.find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ;base_add_low
add eax, [ebx+8] ;length_low
add ebx, 20 ;指向缓冲区中下一个ARDS结构
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .next_ards
mov edx, eax ;edx为总内存大小
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok

;------ int 15h ax = E801h 获取内存大小,最大支持4G ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法

;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400 ;cx和ax值一样,cx用做乘数
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份

;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax,eax
mov ax,bx
mov ecx, 0x10000 ;0x10000十进制为64KB
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
mov edx,esi ;edx为总内存大小
jmp .mem_get_ok

;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
;int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax,0x0000FFFF

;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
mov cx, 0x400 ;0x400等于1024,将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16 ;把dx移到高16位
or edx, eax ;把积的低16位组合到edx,为32位的积
add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

.mem_get_ok:
mov [total_mem_bytes], edx ;将内存换为byte单位后存入total_mem_bytes处。


;----------------- 准备进入保护模式 -------------------
;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 ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt

[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

; 创建页目录及页表并初始化内存位图
call setup_page

; 要将描述符表地址及偏移量写入内存gdt_ptr,一会儿用新地址重新加载
sgdt [gdt_ptr] ; 储存到原来gdt所有位置

; 将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx, [gdt_ptr + 2] ; gdt地址
or dword [ebx + 0x18 + 4], 0xc0000000
; 视频段是第3个段描述符,每个描述符是8字节,故0x18
; 段描述符的高4字节的最高位是段基址的第31~24位

; 将gdt的基址加上0xc0000000使其成为内核所在的高地址
add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000 ; 将栈指针同样映射到内核地址

; 把页目录地址赋给cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax

; 打开cr0的pg位(第31位)
mov eax, cr0
or eax, 0x80000000
mov cr0, eax

; 在开启分页后,用gdt新的地址重新加载
lgdt [gdt_ptr] ; 重新加载

mov byte [gs:160], 'V'
; 视频段段基址已经被更新,用字符V表示virtual addr
jmp $

;------------- 创建页目录及页表 ---------------
; 创建页目录以及页表
setup_page:
; 页目录表占据4KB空间,清零之
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir

; 创建页目录表(PDE)
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
; 0x1000为4KB,加上页目录表起始地址便是第一个页表的地址
add eax, 0x1000
mov ebx, eax

; 设置页目录项属性
or eax, PG_US_U | PG_RW_W | PG_P
; 设置第一个页目录项
mov [PAGE_DIR_TABLE_POS], eax
; 第768(内核空间的第一个)个页目录项,与第一个相同,这样第一个和768个都指向低端4MB空间
mov [PAGE_DIR_TABLE_POS + 0xc00], eax
; 最后一个表项指向自己,用于访问页目录本身
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax

; 创建页表
mov ecx, 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P
.create_pte:
mov [ebx + esi * 4], edx
add edx, 4096
inc esi
loop .create_pte

; 创建内核的其它PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000
or eax, PG_US_U | PG_RW_W | PG_P
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254
mov esi, 769
.create_kernel_pde:
mov [ebx + esi * 4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret

编译运行,其中编译count的参数根据实际大小调整,这里我编译设置的是3,运行结果如下图,其中红框中gdt段基址已经修改为大于0xc0000000,也就是3GB之上的内核地址空间,通过info tab可查看地址映射关系,其中箭头左边是虚拟地址,右边是对应的物理地址

image-20200508083919353

总结虚拟地址获取物理地址的过程:

先要从 CR3 寄存器中获取页目录表物理地址,然后用虚拟地址的高 10 位乘以 4 的积作为在页目录表中的偏移量去寻址目录项 pde ,从 pde 中读出页表物理地址,然后再用虚拟地址的中间 10 位乘以 4 的积作为在该页表中的偏移量去寻址页表项 pte,从该 pte 中读出页框物理地址,用虚拟地址的低 12 位作为该物理页框的偏移量。

快表TLB

  因为从虚拟地址映射到物理地址确实比较麻烦,所以为了提高效率,intel自然想得到用一个缓存装置TLB。结构如下,更新TLB的方法有两种,重新加载CR3和指令invlpg m,其中m表示操作数为虚拟内存地址,如更新虚拟地址0x1234对应的条目指令为invlpg [0x1234]

虚拟地址高20位(虚拟页框号) 属性位 物理地址高20位(物理页框号)

ELF格式浅析

我们下一步的目标是在内核中使用C语言,因为C语言是高级语言,在内核中的C语言用gcc编译需要指定很多参数,避免编译器添加许多不必要的函数。然而在Linux下C语言编译而成的可执行文件格式为ELF,想在我们的内核中运行ELF程序首先需要对其进行解析,下面简单介绍一下ELF文件格式,ELF文件格式分为文件头和文件体部分,文件头存放程序中其他的一些头表信息,文件体则具体的对这些表进行描述。ELF格式的作用体现在链接阶段和运行阶段两个方面,其布局如下图所示

image-20200508083919353

其中elf header的结构如下所示,这里的很多结构都来自Linux源码/usr/include/elf.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 32位elf头 */
struct Elf32_Ehdr
{
unsigned char e_ident[16];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
};

其中的一些数据类型如下

数据类型名称 字节大小 对齐 意义
Elf32_Half 2 2 无符号中等大小的整数
Elf32_Word 4 4 无符号大整数
Elf32_Addr 4 4 无符号程序运行地址
Elf32_Off 4 4 无符号的文件偏移量

下面介绍一些关键成员,其中e_ident[16]数组功能如下,其大小是16字节,存放一些文件属性信息

e_type占用2字节,指定 elf 目标文件的类型

elf目标文件类型 取值 意义
ET_NONE 0 未知目标文件格式
ET_REL 1 可重定位文件
ET_EXEC 2 可执行文件
ET_DYN 3 动态共享目标文件
ET_CORE 4 core文件,即程序崩溃时其内存映像的转储格式
ET_LOPROC 0xff00 特定处理器文件的扩展下边界
ET_HIPROC 0xffff 特定处理器文件的扩展上边界

剩下的一些字段如下,想更具体了解的可以自己百度

字段 大小(字节) 意义
e_machine 2 支持的硬件平台
e_version 4 表示版本信息
e_entry 4 操作系统运行该程序时,将控制权转交到的虚拟地址
e_phoff 4 程序头表在文件内的字节偏移量。如果没有程序头表,该值为0
e_shoff 4 节头表在文件内的字节偏移量。若没有节头表,该值为0
e_flags 4 与处理器相关的标志
e_ehsize 2 指明 elf header 的字节大小
e_phentsize 2 指明程序头表(program header table )中每个条目(entry)的字节大小
e_phnum 2 指明程序头表中条目的数量。实际上就是段的个数
e_shentsize 2 节头表中每个条目的字节大小,即每个用来描述节信息的数据结构的字节大小
e_shnum 2 指明节头表中条目的数量。实际上就是节的个数
e_shstrndx 2 指明 string name table 在节头表中的索引 index

下面再介绍一下Elf32_Phdr结构,此段是指程序中的某个数据或代码的区域段落,例如代码段或数据段,这个段不是内存中的段,此段是磁盘上程序中的一个段,下面是其结构

1
2
3
4
5
6
7
8
9
10
11
struct Elf32_Phdr
{
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
};

各个字段的意义如下表

字段 意义
p_type 段类型
p_offset 本段在文件的偏移量
p_vaddr 本段在内存中起始的虚拟地址
p_paddr 仅用于与物理地址相关的系统中
p_filesz 本段在文件中的大小
p_memsz 本段在内存中的大小
p_flags 本段相关的标志
p_align 本段在文件和内存中的对齐方式

载入内核

  Linux下可以用readelf命令解析ELF文件,下面是我们在kernel目录下新添加的测试代码,因为是64位操作系统,编译命令需要如下修改,我们下一步就是将这个简单的elf文件加载入内核,物理内存中0x900是loader.bin的加载地址,其开始部分是不能覆盖的GDT,预计其大小是小于2000字节,保守起见这里选起始的物理地址为0x1500,所以链接命令指定虚拟起始地址0xc0001500

下面通过dd命令将其写入磁盘,为了不纠结count的赋值,这里直接赋值为200,seek赋值为9,写在第9扇区

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

写完之后我们需要修改loader.S中的内容,分两步完成

  • 加载内核:内核文件加载到内存缓冲区
  • 初始化内核:需要在分页后,将加载进来的elf内核文件安置到相应的虚拟内存地址,然后跳过去执行,从此loader的工作结束

内核的加载地址选取的是0x7e00~0x9fbff范围中的0x70000,添加如下片断

1
2
3
4
5
6
7
8
9
10
; ------------------ 加载内核 ------------------
mov eax, KERNEL_START_SECTOR ; kernel.bin所在的扇区号0x9
mov ebx, KERNEL_BIN_BASE_ADDR ; 0x70000
; 从磁盘读出后,写入到ebx指定的地址
mov ecx, 200 ; 读入的扇区数

call rd_disk_m_32 ; eax,ebx,ecx均为参数,从硬盘上读取数据

; 创建页目录及页表并初始化页内存位图
call setup_page

下一步是初始化内核的工作,我们需要遍历kernel.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
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
   ; -------------------------   加载kernel  ----------------------
[略...]
; 打开cr0的pg位(第31位)
mov eax, cr0
or eax, 0x80000000
mov cr0, eax

; 在开启分页后,用gdt新的地址重新加载
lgdt [gdt_ptr] ; 重新加载

jmp SELECTOR_CODE:enter_kernel ; 强制刷新流水线,更新gdt,不刷新也可以
enter_kernel:
call kernel_init
mov esp, 0xc009f000 ;进入内核之后栈也要修改
jmp KERNEL_ENTRY_POINT ; 用地址0x1500访问测试,结果ok
;----------将kernel.bin中的segment拷贝到编译的地址----------
kernel_init:
xor eax, eax
xor ebx, ebx ; 记录程序头表地址
xor ecx, ecx ; cx记录程序头表中的program header数量
xor edx, edx ; dx记录program header尺寸,即e_phentsize

mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移文件42字节处的属性是e_phentsize,表示program header大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件开始部分28字节的地方是e_phoff,表示第1个program header在文件中的偏移量

add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
.each_segment:
cmp byte [ebx + 0], PT_NULL ; 若p_type等于 PT_NULL,说明此program header未使用。
je .PTNULL

;为函数memcpy压入参数,参数是从右往左依然压入.函数原型类似于 memcpy(dst,src,size)
push dword [ebx + 16] ; program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数:size
mov eax, [ebx + 4] ; 距程序头偏移量为4字节的位置是p_offset
add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax
push dword [ebx + 8] ; 压入函数memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr,这就是目的地址
call mem_cpy ; 调用mem_cpy完成段复制
add esp,12 ; 清理栈中压入的三个参数, 3 * 4 = 12 字节
.PTNULL:
add ebx, edx ; edx为program header大小,即e_phentsize,在此ebx指向下一个program header
loop .each_segment
ret

;---------- 逐字节拷贝 mem_cpy(dst,src,size) ------------
;输入:栈中三个参数(dst,src,size)
;输出:无
;---------------------------------------------------------
mem_cpy:
cld ; 控制重复字符递增方式,也就是edi和esi每复制一次就加一个单位大小,相对的指令为std
push ebp
mov esp, ebp
push ecx ; rep指令用到了ecx,但ecx对于外层段的循环还有用,故先入栈备份
mov edi, [ebp + 8] ; dst
mov esi, [ebp + 12] ; src
mov ecx, [ebp + 16] ; size
rep movsb ; 逐字节拷贝,直到ecx为0

; 恢复环境
pop ecx
pop ebp
ret

最终的一个内存布局如下,参考之前的1MB实模式地址图来对应就明白了

特权管理

  特权级按照权力分为0、1、2、3级,数字越小,级别越高。计算机启动之初就在0级特权运行,MBR则就是0级权限,谈到权限就得提到TSS任务状态段,程序拥有此结构才能运行,相当于一个任务的身份证,结构如下图所示,大小为104字节,其中有很多寄存器信息,而TSS则是由TR寄存器加载的

每个特权级只能有一个栈,特权级在变换的时候需要用到不同特权级下的栈,特权转移分为两类,一类是中断门和调用门实现低权限到高权限,另一类是由调用返回指令从高权限到低权限,这是唯一一种让处理器降低权限的方法。

对于低权限到高权限的情况,处理器需要提前记录目标栈的地方,更新SS和ESP,也就是说我们只需要提前在TSS中记录好高特权级的栈地址即可,也就是说TSS不需要记录3级特权的栈,因为它的权限最低。

对于高权限到低权限的情况,一方面因为处理器不需要在TSS中寻找低特权级目标栈的,也就是说TSS也不需要记录3级特权的栈,另一方面因为低权限的栈地址已经存在了,这是由处理器的向高特权级转移指令(int、call等)实现机制决定的。下面就介绍一下权限相关的一些知识点:

CPL、DPL、RPL

CPL是当前进程的权限级别(Current Privilege Level),是当前正在执行的代码所在的段的特权级,存在于cs寄存器的低两位。

RPL是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的,每个段选择子有自己的RPL,它说明的是进程对段访问的请求权限,有点像函数参数。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样它对该段仍然只有特权为3的访问权限。

DPL存储在段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的DPL固定。当进程访问一个段时,需要进程特权级检查,一般要求DPL >= max {CPL, RPL}

门结构

处理器只有通过门结构才能由低特权级转移到高特权级,也可以通过门结构进行平级跳转,所以门相当于一个跳板,当前特权级首先需要大于门的DPL特权级,然后才能使用门来跳到想去的特权级,处理器就是这样设计的,四种门结构分别是:任务门、中断门、陷阱门、调用门。门描述符和段描述符类似,都是8字节大小的数据结构,用来描述门通向的代码,如下所示

任务门可以放在GDT、LDT、IDT中,调用门位于GDT、LDT中,中断门和陷阱门仅位于IDT中调用方法如下

调用门

call 和 jmp 指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用。 call 指令使用调用门可以实现向高特权代码转移, jmp 指令使用调用门只能实现向平级代码转移。若需要参数传递,则0~4位表示参数个数,然后在权限切换的时候自动在栈中复制参数。关于调用门的过程保护,参考P240

中断门

以 int 指令主动发中断的形式实现从低特权向高特权转移, Linux 系统调用便用此中断门实现。

陷阱门

以 int3 指令主动发中断的形式实现从低特权向高特权转移,这一般是编译器在调试时用。

任务门

任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务门的选择子或任务 TSS 的选择子。

IO特权级

保护模式下,处理器中的”阶级”不仅体现在数据和代码的访问,还体现在以下只有在0特权级下被执行的特权指令

1
hlt、lgdt、ltr、popf等

还有一些IO敏感指令如in、out、cli、sti等访问端口的指令也需要在相应的特权级下操作,如果当前特权级小于 IOPL 时就会产生异常,IOTL 在 eflags 寄存器中,没有特殊的指令设置 eflags 寄存器,只有用 popf 结合 iretd 指令,在栈中修改,当然也只有在0特权下才能操作,eflags 寄存器中的 IOTL 位如下所示