通过前三章的努力,我们成功将控制权转交给了 loader.asm 这个程序。具体说就是 bios 通过加载并跳转到 0x7c00(IMB大叔们定的) 把控制权转交给了我们操作系统的第一个汇编程序 mbr.asm,然后 mbr.asm 里做的事就是通过加载 loader 程序并跳转到 0x900(这个是我们自己定的)把控制权转交给了 loader.asm 程序,目前这个程序里还只是向屏幕输出一行字符串“loader”,今天我们就将扩展它。并且今天我们要做的事,是操作系统中的第一个精彩之处,就是从实模式跨越到保护模式。
一、实模式与保护模式鸟瞰
我这人喜欢直面问题,其实本章只需要搞明白三个主要问题就行了,什么是实模式和保护模式,实模式与保护模式的区别是什么,怎么进入保护模式。我先来简单阐述下这三个问题
什么是实模式和保护模式
Intel 8086 是一个由 Intel 于 1978 年所设计的 16 位微处理器芯片,是 x86 架构的鼻祖。紧接着 Intel 又推出了第一款 32 位的 cpu Intel 80286(很快被淘汰,80386更经典一些),这款 cpu 由于和之前有很多不同的“保护”特性,所以称为保护模式,也是与此同时,之前的 8086 这个 16 位 cpu 才有了实模式的叫法。
所以什么是实模式和保护模式,其实就是 Intel 给自己的处理器特性命的一个名字而已,具体有哪些特性那就是细节问题了,但最起码有一点刚刚已经有所透露,那就是保护模式至少是 32 位的,而实模式是 16 位的(即使一个 32 位的 cpu 也有实模式)
实模式与保护模式的区别是什么
- 实模式 16 位,保护模式 32 位
- 实模式下的地址是段寄存器地址偏移4位+偏移地址得到物理地址。保护模式下段寄存器存入了段选择子,在段描述符表中寻找段基址,再加上偏移地址得到物理地址(开启分页下为逻辑地址)
- 这个我觉得是个 1 的推论,就是实模式寻址空间是 1M,保护模式是 4G
- 这个我觉得是 2 的推论,就是段描述符表记录了段的权限,改变了实模式下可以随意访问所有内存的隐患(这也是保护这两个字的体现)
怎么进入保护模式
进入保护模式有三步:
- 打开 A20
- 加载 gdt
- 将 cr0 的 pe 位置 1
可以看出进入保护模式的操作是很简单的,但提前要做好准备工作,最重要的就是 gdt(Global Descriptor Table 全局描述表)的准备。
二、代码鸟瞰
loader.asm
section loader vstart=0x900
jmp protect_mode
gdt:
;0描述符
dd 0x00000000
dd 0x00000000
;1描述符(4GB代码段描述符)
dd 0x0000ffff
dd 0x00cf9800
;2描述符(4GB数据段描述符)
dd 0x0000ffff
dd 0x00cf9200
;3描述符(28Kb的视频段描述符)
dd 0x80000007
dd 0x00c0920b
lgdt_value:
dw $-gdt-1 ;高16位表示表的最后一个字节的偏移(表的大小-1)
dd gdt ;低32位表示起始位置(GDT的物理地址)
SELECTOR_CODE equ 0x0001<<3
SELECTOR_DATA equ 0x0002<<3
SELECTOR_VIDEO equ 0x0003<<3
protect_mode:
;进入32位
lgdt [lgdt_value]
in al,0x92
or al,0000_0010b
out 0x92,al
cli
mov eax,cr0
or eax,1
mov cr0,eax
jmp dword SELECTOR_CODE:main
[bits 32]
;正式进入32位
main:
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:0xa0],'3'
mov byte [gs:0xa2],'2'
mov byte [gs:0xa4],'m'
mov byte [gs:0xa6],'o'
mov byte [gs:0xa8],'d'
jmp $
这里说说我的心得体会,现在看整段的代码虽不能说每一行让我自己写能写出来,但现在看起来极为清晰。我现在其实已经想不起来当时为什么理解了好久好久就是理解不了,调试了好半天也老是有各种问题。不过这个代码是我去掉了一些可有可无影响理解的部分,只留下了最精华的部分,我不知道如果我一开始接触的是这样的代码是否能够理解到位。
鸟瞰整段代码,大概分为三块。
- 第一块用二进制方式网内存中写了数据(四个段描述符),并定义了三个常量
- 第二块其实仔细观察会发现就是进入保护模式的步骤(打开A20、加载gdt、将cr0的pe位置1)
- 第三块还是一个在屏幕上输出“32mod”字符串,与之前不同的是这是在保护模式下的输出
三、代码第一块解读:全局段描述符表(GDT)
cpu 与操作系统打配合的方式
有件事现在说可能体会不大,写到后面好多地方你会发现,像加载 gdt 这种操作模式好多地方都是通用的,咱先不用管 gdt 是什么,总之 cpu 会有很多与操作系统相互打配合的地方,这个就是其中之一。配合怎么打呢,那就是 cpu 定义好一个数据结构,再给你一个寄存器。操作系统一般负责做三件事情
- 负责在内存中某位置按照这个数据结构写一堆数据(如本讲的段描述符表gdt,以及之后要说的页表)
- 然后再把你写在内存的哪个位置这个信息(起始地址),存在 cpu 给你预留的一个寄存器里,这一般会有一条专门的指令,比如本讲的 lgdt,不会说让你用 mov 操作的
- 操作系统将 cpu 某寄存器中的某位置 1
然后就开启了这个功能,段描述符表如此,页表如此,TSS亦是如此,这个之后讲到会深有体会。我现在已经有所体会了,但还没整理出全部的这种打配合的地方,等我再深入些再给大家整理一份。
先说说什么是段描述符
直接上干货,还记不记得第一节课说的内容
在你开机的一瞬间,CPU 的 PC 寄存器被强制初始化为 0xFFFF0。如果再说具体些,CPU 将段基址寄存器 cs 初始化为 0xF000,将偏移地址寄存器 IP 初始化为 0xFFF0,根据实模式下的最终地址计算规则,将段基址左移 4 位,加上偏移地址,得到最终的物理地址也就是抽象出来的 PC 寄存器地址为 0xFFFF0。
这种段基址左移 4 位,加上偏移地址,得到物理地址的方式,就是实模式下的地址转换方式。
然而保护模式下不一样了
在保护模式下,段基址寄存器中存的数据,被理解为段选择子,根据这个值去我们自己在内存中写好的段描述符表中找,找到对应的段描述符,从中取出段基址。用这个段基址加上偏移地址,最终得到物理地址(逻辑地址和页表的事以后再说,不冲突)。
就这么点区别
那自然就有两个问题,一个是段描述符表长什么样子呀?决定了我们往内存中写的数据结构是什么。另一个就是去哪找段描述符表压,这个就需要告诉 cpu 为我们提前预留好的寄存器,也就是 lgdt 指令。下面我们就分别看着两个问题
段描述符表长什么样子
首先段描述符表是一张表,在内存中也就是个数组,是一个个的段描述符一个个紧挨着的结果。所以我们要了解段描述符长什么样就好了
这里我顺便把选择子和 GDTR 寄存器的结构也列出来了,这些就是全部的需要我们自己写数据的地方了,也是 cpu 和操作系统配合中需要约定的全部事情
;0描述符
dd 0x00000000
dd 0x00000000
;1描述符(4GB代码段描述符)
dd 0x0000ffff
dd 0x00cf9800
;2描述符(4GB数据段描述符)
dd 0x0000ffff
dd 0x00cf9200
;3描述符(28Kb的视频段描述符)
dd 0x80000007
dd 0x00c0920b
我们看看这些直接在内存中写死的常量,就是按照段描述符的数据结构写的
代码段描述符转化为二进制是 00000000_00000000_11111111_11111111_00000000_11001111_10011000_00000000
数据段描述符转为为二进制是 00000000_00000000_11111111_11111111_00000000_11001111_10010010_00000000
视频段描述符转化为二进制是 00000000_11000000_10010010_00001011_10000000_00000000_00000000_00000111
这里我们拿视频段描述符来分析,提取(拼凑)出段基址的数据,00000000_00001011_10000000_00000000,转换为十六进制是 0xb8000。怎么样熟不熟悉,这恰好是显卡黑白模式在内存中的映射的起始地址。可以看下第一章的内容,不过我这里还是把图贴出来。
接下来的几个常量定义,很容易明白它们的意思
lgdt_value:
dw $-gdt-1 ;高16位表示表的最后一个字节的偏移(表的大小-1)
dd gdt ;低32位表示起始位置(GDT的物理地址)
SELECTOR_CODE equ 0x0001<<3
SELECTOR_DATA equ 0x0002<<3
SELECTOR_VIDEO equ 0x0003<<3
lgdt_value 就是按照 lgdt 寄存器规定的数据结构拼凑出来的,下面的三个常量其实就是对应上面定义的三个段描述符的偏移量,由于每个描述符占 64 位,也就是占 8 个地址单元,所以索引下标的计算就是第几个描述符 * 8就好了,相信这个不难理解。
四、代码第二块解读:进入保护模式三步走
代码直接对应上面的三步
加载 gdt
lgdt [lgdt_value]
打开 A20
in al,0x92
or al,0000_0010b
out 0x92,al
cli ;禁止中断,先不用管
将 cr0 的 pe 位置 1
mov eax,cr0
or eax,1
mov cr0,eax
此时已经进入保护模式了,段基址寄存器的意义已经变了,所以跳转指令变成了
jmp dword SELECTOR_CODE:main
五、代码第三块解读:保护模式下的简单代码
前面就是将数据段寄存器赋值给一些段基址寄存器用于访问数据段,然后将栈基址赋值位本次加载到的内存位置,重点是下面几句
mov ax,SELECTOR_VIDEO
mov gs,ax
mov byte [gs:0xa0],'3'
...
这段将我们刚刚写好的常量 SELECTOR_VIDEO 写入了段基址寄存器 gs,并在其后用了这个基址寄存器去进行 mov 操作。通过这个段选择子,在段描述符表里寻找出来的段基址是我们写好的显卡的内存映射的起始地址,所以同前几章在实模式下的输出就一样了。
六、运行代码
我们并没有增加新文件,所以Makefile和上一篇一样,不用变,直接运行看效果,make brun
可以看到,我们的段基址寄存器没有直接写显卡的起始地址,而是通过段选择子索引的,但依然正常输出了 "32mod" 字符串,说明成功了
写在最后:开源项目和课程规划
如果你对自制一个操作系统感兴趣,不妨跟随这个系列课程看下去,甚至加入我们(下方有公众号和小助手微信),一起来开发。
参考书籍
《操作系统真相还原》这本书真的赞!强烈推荐
项目开源
当你看到该文章时,代码可能已经比文章中的又多写了一些部分了。你可以通过提交记录历史来查看历史的代码,我会慢慢梳理提交历史以及项目说明文档,争取给每一课都准备一个可执行的代码。当然文章中的代码也是全的,采用复制粘贴的方式也是完全可以的。
如果你有兴趣加入这个自制操作系统的大军,也可以在留言区留下您的联系方式,或者在 gitee 私信我您的联系方式。
课程规划
本课程打算出系列课程,我写到哪觉得可以写成一篇文章了就写出来分享给大家,最终会完成一个功能全面的操作系统,我觉得这是最好的学习操作系统的方式了。所以中间遇到的各种坎也会写进去,如果你能持续跟进,跟着我一块写,必然会有很好的收货。即使没有,交个朋友也是好的哈哈。
目前的系列包括