什么是保护模式?
原先的CPU 是不需要要保护模式就能访问的,但这样 程序 之间 随便访问,没有任何安全可言,要是一个程序崩溃了或者因为某些BUG跳转到了其他程序的底盘,直接就调用别的程序 的功能多牛逼 哈哈 某些Hack 也是利用这种思路,通过栈溢出直接就跳转到一指定地址执行一些不安全的代码。 而 保护模式在兼容实模式的情况下,CPU 可以通过读取 GDT 表 知道 哪一段范围是放程序,哪一段范围是放操作系统的,同时设置可读 可写等属性,ring0 ~ring3 权限等属性,如果 普通程序 是没法 直接操作 系统内存,直接改写 硬件内容的,这样大大保护了 系统的安全。
并且处理器在 执行代码的时候 也会有 不同的处理等级(Ring0、Ring1、Ring2、 Ring3 从大到小 Ring0 为最高等级),一般操作系统 跑在Ring0 特权级下(特权级低的不能访问特权级高的程序),普通应用程序 一般 跑在Ring3 特权级下。Linux内核目前只使用了 Ring0 和Ring3级。而等级1和等级2 介于内核程序与应用程序之间,它们通常作为系统服务程序来使用。保护模式不仅引入了权限,还引入了分页的功能。我们都知道内存的最小单位是字节,如果要执行一段程序 就要申请 N个字节,但是如果不定大小的向操作系统申请字节,在管理上比较麻烦。所以 就采用分页 规定多少多少字节为一个分页,这样 我要执行程序,只要计算出要多少分页,然后向操作系统申请就好了。
实模式下SS,CS,ES,SP和 保护模式下 的区别?
其实 保护模式下的选择子 和 实模式下 的 段寄存器 是一样的 , 增加了访问内存的能力,和 段之间访问的保护,在实模式下 段:偏移 能访问到计算机内存的全部1M空间 但在保护模式 通过一张表 来限定分配 实现了 不同程序 有单独隔离可访问的空间,原来好比 集体宿舍 大家在里面 打牌的 玩电脑的 xxx的 互相共用,现在 变成了单独隔间,共用一些基础设施。 而GDT 像是一张登记表 记录了 将 单人间 1 给张三 总统套房 给李四 这样 每一个房间 就是一个选择子,我要找张三 先找到张三的房间(选择子) 然后跟宿舍阿姨说(段寄存器) 阿姨就能帮你找到在房间里 XXX的张三了,选择子 还能限定 权限 住在 破烂单间 的 没法访问 住在总统套房的 。
所以GDT(Global Descriptor Table)全局描述符表 相当于一张登记表,上面写了 哪些 地址范围 用来干什么,拥有怎样的权限。
全局描述符表,占 8个字节,同时CPU 内部为了 能直接跟踪GDT ,所以内置了一个48位的寄存器,称为全局描述符表寄存器(GDTR)。
由2部分组成
1. 32位的线性地址
2. 16位边界
- 32 位线性地址 可以访问到 2 32 2^{32} 232 也就是0x00000000 到0xFFFFFFFF 的 4GB 的内存,也只是GDT存放的其实地址。
- 16 位保存的是全局描述符表的边界(界限),就是表示的 保存了多少个GDT表 一个G DT 占 用 8个字节 ,16 位的 可以表示的范围 为
2
16
2^{16}
216 字节= 64(KB) 64KB/8字节 =8192 个字节的范围
上面说的 都是概念 让我们画张图 来便于更好的理解吧。
为什么GDT在 1M 以下空间呢(1M以下未被使用的空间),实际上 GDT 是可以在4G 内存空间中的任意位置的,但是由于 我们在加载GDT表时还处于1M的实模式下,所以被加载时内存在1M 以下空间。
理论上 你可以在加载后重新定位GDT表。
GDT表栈8个字节 64位
GDT 表 格式:
Base:表示段基地址 (16~ 31 bit + 32~39 bit + 56 ~ 63 bit) = 16 + 8 + 8 = 32bit
Limit:表示断界限 (0~ 15 bit + 48 ~51 bit) =16 + 4 = 20bit
Flags:为 0时 段界限每一位表示 单位为字节,范围为
2
20
∗
1
2^{20} *1
220∗1Byte = 1M(表示范围:1B ~1M ),为1 时 段界限 以 4KB为单位 范围为
2
20
∗
4
2^{20} *4
220∗4 4Byte = 4G(此时范围为4KB~4G)。
AccessByte:由8个Bit 组成了各种权限的组合 S 为0 时 表示 是一个系统段,为1是表示代码段或者数据段。DPL 表示特权级 包含0~3 个特权级,0级为最高等级,刚进入保护模式是特权级为0. P段存在位,P指定所指定的段内存是否存在,如果你GDT指定了4个G的内存,但是实际并没有4G内存是 应该置为0,会触发一个中断异常使操作系统能把该段从硬盘加载到内存。并将P置为1,是虚拟内存使用的机制。
通过TYPE 可以设置数据段 还是代码段,可以限定 数据是否能被访问等。
上面的GDT表,定义的是不是很奇怪,Base Limit 都划分在不同的字节里了,他们之间不是连续的,之所以这么定义 是Intel处于 兼容 80826 CPU个是 留下历史的包袱。毕竟 你出的CPU要是不能兼容上一代那前面开发的程序就全部不能用了,这样 会丢失一定的市场占有率。
下面提供一个 我写了个脚本 来计算 段描述符 的表示:
a =0x4F9AFFFFFFFFFF # 段描述符 64位
binstr ='{:064b}'.format(a)
pre31bit = binstr[:32]
next31bit = binstr[32:]
_G = {"0":"1Byte","1":"4KB"}
_P = {"0":"当前段不在内存中存在","1":"当前短实际存在内存中"}
_DPL = {"00":"当前段特权级为 0(最高 可以访问其他所有特权级)","01":"当前段特权级为 1(可以访问 1 2 3 特权级)"
,"10":"当前段特权级为 2 (可以访问 2 3 特权级)","11":"当前段特权级为 3 只可以访问 3特权级"}
_TYPED = {"E":{"0":"向上拓展","1":"向下拓展(栈)"},"W":{"0":"只读","1":"可读/写"},"A":{"0":"这个段描述符没有被访问过(没有被选择子加载过)","1":"已访问过"}}
_TYPEC = {"C":{"0":"非一致代码段(Ring3 不能调用 Ring0)","1":"一致代码段(Ring3 能调用Ring0)"},"R":{"0":"仅执行","1":"执行/可读"},"A":{"0":"这个段描述符没有被访问过(没有被选择子加载过)","1":"已访问过"}}
_S = {"0":"数据段","1":"代码段"}
_DB = {"0":"16位的代码和数据段","1":"32位的代码和数据段"}
base3 = pre31bit[0:8]
G = pre31bit[8]
DB = pre31bit[9]
L = pre31bit[10]
AVL = pre31bit[11]
P = pre31bit[16]
DPL = pre31bit[17:19]
S = pre31bit[19]
TYPE = pre31bit[20:24]
base2 = pre31bit[24:]
seglen2 = pre31bit[12:16]
base1 = next31bit[0:16]
segl1= next31bit[16:]
print("段界限:",hex(int(segl1 + seglen2,2)))
print("段基地址: ",hex(int(base3 +base2+base1,2)))
print("G 粒度:",_G[G])
print("L:",L)
print("S:",_S[S])
print("P(标志指出该段当前是否在内存中):",_P[P])
print("DB:",_DB[DB])
print("AVL:",AVL)
print("DPL(该段的特权级):",_DPL[DPL])
if S == "0":
print("TYPE:", _TYPED["E"][TYPE[0]], _TYPED["W"][TYPE[1]], _TYPED["A"][TYPE[2]])
else:
print("TYPE:", _TYPEC["C"][TYPE[0]], _TYPEC["R"][TYPE[1]], _TYPEC["A"][TYPE[2]])
假设段描述符 是 0x4F9AFF~FFFFFFFF 通过代入计算得到:
开启保护模式前的准备
原先8086处理器能访问1M 的空间,超过1M空间很多程序员为了炫技就充分利用这个特性,但随着CPU出现保护模式能力的提升 使得能访问超过1M以上的空间 ,但为了向下兼容以前的程序,为了解决这个问题。就出现了利用一个当时8042键盘的一个空闲的端口引脚,可以开启或关闭1M以上的空间。如果设置了A20引脚为低电平0,那么能访问的就只有20位(1M)的有效地址。但键盘访问太麻烦后来把这个端口集成到了CPU的A20(快速门)端口 直接控制CPU而不是访问键盘控制器,使用0x92端口 将第二位置 1 就可以开启。
开机时这个引脚是关闭的,
;段描述符 结构定义
;GDT_Descriptor Base,LIMIT,ATTR
;下面的 结构 通过声明 3 个 属性 然后
;CPU 会将 我们定义的 结构 转换成 GDTR寄存器格式
%macro GDT_Descriptor 3
dw %2 & 0xffff ;段界限1 2字节
dw %1 & 0xffff ;段基址 2字节
db (%1 >> 16 ) & 0ff;段基址 1字节
dw ((%2 >> 8) & 0xf00) | (%3 & 0xf0ff);属性 1 + 段界限2 + 属性2 2字节
db (%1 >> 24) & 0xff ;段基址 3 1字节
%endmacro
CODE_32 equ 0x4000
CODE_32_G equ 0x8000
CODE_SEGMENT_32 EQU 0x9A
DATA_SEGMENT_32 EQU 0x92
开启保护模式前的黎明
开启保护模式前 还要对CR0操作,CR0是一个标志位,包含 一系列用于操控CPU运行模式和运行状态的标志位。 他的第一位,是保护模式允许位,要置为0 才能开启保护模式。
总结 开启保护模式 需要 3步:
- 定义GDT表 的属性 计算出 GDT表的位置指针 长度及选择子
; 描述符 基地址 段界限 段属性
LABEL_GDT: GDT_Descriptor 0, 0, 0 ; 空描述符,必须存在,不然CPU无法识别GDT
LABEL_DESC_CODE: GDT_Descriptor 0, 0xfffff, DA_32 | DA_CR | DA_LIMIT_4K ; 0~4G,32位可读代码段,粒度为4KB
LABEL_DESC_DATA: GDT_Descriptor 0, 0xfffff, DA_32 | DA_DRW | DA_LIMIT_4K; 0~4G,32位可读写数据段,粒度为4KB
LABEL_DESC_VIDEO: GDT_Descriptor 0xb8000, 0xfffff, DA_DRW | DA_DPL3 ; 视频段,特权级3(用户特权级)
; GDT全局描述符表 -------------------------------------------------------------
GDTLen equ $ - LABEL_GDT ; GDT的长度
GDTPtr dw GDTLen - 1 ; GDT指针.段界限
dd LOADER_PHY_ADDR + LABEL_GDT ; GDT指针.基地址 Loader程序被加载的 地址 0x9000:0000 + LABEL_GDT 开始偏移
; GDT选择子 ------------------------------------------------------------------
SelectorCode equ LABEL_DESC_CODE - LABEL_GDT ; 代码段选择子
SelectorData equ LABEL_DESC_DATA - LABEL_GDT ; 数据段选择子
SelectorVideo equ LABEL_DESC_VIDEO - LABEL_GDT | SA_RPL3 ; 视频段选择子,特权级3(用户特权级)
; GDT选择子 ------------------------------------------------------------------
上面 其实 是初始化一张GDT表的 固定格式,上面 定义了 代码段 数据段 和 显卡内存访问的段 除了显卡缓存段 是Ring3 权限其他都是Ring0,并且 我们将代码段 和 数据段 都定义为了 从 0x00000 ~ 0xFFFFF 粒度 是 DA_LIMIT_4K (4KB) ,意味着 内存从0x00000 ~ 0xFFFFF * 4K / 1024 /1024 =3.999 4G 左右的空间。
GDTLen :计算的是你定义的 GDT表 的长度 通过当前地址 - GDT表起始地址 ($ - LABEL_GDT)计算 这是一种固定格式
GDTPtr: 定义了GDT的 长度和 起始地址。
上面代码 基本上是固定格式,除了GDT表定义那边.
CPU 加载 GDT 通过 lgdt 这条命令
lgdt [GDTPtr];获取GDT表的位置 并加载
- 开启A20快速门
这里通过 0x92 端口,将 开启CPU的 第快速门,实现 开启保护模式的其中一步。
;开启A20 快速门
in al,0x92
or al, 00000010b
out 92h, al
- 设置CR0 第0 为 1
mov eax,cr0
or eax,0x1
mov cr0,eax
通过上面 3步 就可以 开启了 保护模式
其实还差一步:
jmp 代码段选择子:(要跳转到的程序 + 程序加载其实地址)
选择子 就是 我们 前面定义的 LABEL_DESC_DATA 然后 跳转到那段代码 如下 就是PM_Start
[SECTION .code32]
[BITS 32]
PM_Start:
mov ax, SelectorData
mov ds, ax
mov es, ax
xxxxx....省略
如果 你的 Loader 程序 被加载 到了 0x1000:0x0000 那么 就加上 0x1000<< 4 + 0x0000
最后 就是 这样 :jmp SelectorCode:(PM_Start: + 0x10000)
在跳转前 还需要关闭 磁盘驱动让他停止读取,如果不关闭就一直会发出声音,虽然在我们的BOCHS下 关不关 是没什么区别的:
;关闭驱动器
KillMotor:
push dx
mov dx,03F2h
mov al,0
out dx,al
pop dx
ret
以上步骤 就是 开启保护模式 必须要干的事了啦。
loder_begin:
mov ax,cs
mov es,ax
mov ds,ax
mov ss,ax
mov sp,BaseOfStack
;加载GDT
lgdt [GDTPtr]
;关闭外部中断
cli
;开启A20 快速门
in al,0x92
or al, 00000010b
out 92h, al
;设置cr0 开启保护模式
mov eax,cr0
or eax,0x1
mov cr0,eax
jmp dword SelectorCode:LOADER_PHY_ADDR + segment32
jmp $
;关闭驱动器
KillMotor:
push dx
mov dx,03F2h
mov al,0
out dx,al
pop dx
ret
LoaderESAddress dw 0x7000
Sectoroffset dw 00
Fatcluster dd 0x00
NoLoaderMessage: db "Can't find you kernal!"
SectorNo db 0x00
LoaderFileName: db "KERNEL BIN",0;寻找的 文件名 loader.bin
msgProgress db ".", 0x00
StartBootMessage: db "Start Loading Kernel.bin Please Waiting"
absoluteSector db 0x00;S
absoluteHead db 0x00;H
absoluteTrack db 0x00;C
datasector dw 0x0000
[SECTION .code32]
[BITS 32]
align 32
segment32:
mov ax, SelectorData
mov ds, ax
mov es, ax
mov fs, ax
mov ss, ax ; ds = es = fs = ss = 数据段
mov esp, TopOfStack ; 设置栈顶
mov ax, SelectorVideo
mov gs, ax ; gs = 视频段
mov ebx,0x10;列偏移多少个字符
mov ecx,2;一个字符 2个字节 所以乘以2
mov esi,LoadMseeage
call showMSG
jmp $
showMSG:
mov edi,(80*11);屏幕中间位置
add edi,ebx;自带符偏移
mov eax,edi
mul ecx ;每个字符 2个字节 所以乘以2
mov edi,eax
mov ah,0xc
mov al,[esi];位置
cmp al,0
je fin
inc ebx
inc si
mov [gs:edi],ax ;写入屏幕
jmp showMSG
fin:
HLT
jmp fin
[SECTION .data32]
[BITS 32]
align 32
DATA32:
_LoadMseeage: DB "Welcome To 32Bits Protect Model!(#^.^#)",0
BottomOfStack times 0x100 db 0
TopOfStack equ $ + LOADER_PHY_ADDR
;--------------------------------------
LoadMseeage equ LOADER_PHY_ADDR + _LoadMseeage
打印 几个字符来表示能成功加载成功:
好了 到这里 我们 已经 进入了32 为的保护模式啦 。_
完整代码,请见GITHUB。