以下是第四章的收获:
保护模式
什么是保护模式?直接定义保护模式似乎是件很抽象的事情,我们不妨先看看为什么要有保护模式,且保护模式能为我们做些什么?
保护模式是相对于实模式而言的,且是为了解决实模式的一些问题而提出来的。实模式是8086CPU下的寻址模式、指令用法、寄存器大小等。
那么实模式有什么问题呢?为什么需要保护模式呢?
为什么需要保护模式
①实模式下,用户进程和系统进程属于同一特权级别,平起平坐,系统进程能调用什么,用户进程就能调用什么。
②实模式下,进程访问的地址直接就是物理地址,想修改内存里面的内容十分容易,没有一点限制。
③实模式下,可以随意修改段基址,访问所有内存。
以上三点均为安全问题,也是主要问题。我们可以从保护模式的“保护”中看出,保护模式下运行的程序会更加安全可靠,更具“安全感”,不会被另一个程序随意修改。
④访问内存,需要不断变换段基址,来访问我们想要的内存地址,因为一个段的大小只有64KB。
⑤内存大小只有1M,不够用。
⑥每次只能运行一个程序,浪费CPU的资源。
④到⑥点是使用上的问题,想想如果我们现在的电脑还用着使用着实模式下的CPU的的话,程序的效率会有多么低下,访问内存还得自缚手脚,运行一个程序还要等上一个程序运行完,显然是现代操作系统里难以想象的。
下面就来想如何解决这些问题,第①点的话,我们赋予每个进程不一样的特权等级就好,操作系统显然是最高级别的,用户进程就要低一些,具体内容会在之后章节看到。
第②点,保护模式下采用了分页来访问内存,此时进程访问的地址并不是最终的物理地址,而是虚拟地址。虚拟地址还需要通过分页模式下的一些信息才能转换为最终的物理地址。这也会在后面章节说到。
对于②③点,最直接的做法是对段的读写做一定的限制,CPU及操作系统通过段描述符来达到这一目的。段描述符,就是描述段的结构,其信息包括:段基址、段界限、段类型、段是否可写可读、段的方向(如栈是往低地址延伸的,其它的往高地址延伸)等等。具体的细节后面会说到。
对于④⑤点,在CPU发展到32位后,地址总线和数据总线拓展到32位,通用寄存器大小也拓展到32位,这样子能访问的内存地址空间一下子就变成4G,更方便的是,单纯靠一个通用寄存器也能访问所有内存地址了,甚至无需段基址了,也无需左移4位。当然,为了兼容,CPU在保护模式下还是采用段基址+段内偏移地址来访问最终的物理地址,但我们可以做一点小小的操作,将段基址设为0,单靠段内偏移地址就能访问所有的物理内存,因而这也被称为“平坦模式”。除此之外,CPU还拓展了指令用法,例如基址寻址不再限制只能用bx,sp作为基址寄存器,而是所有通过寄存器都是作为基址寄存器;变址寻址也不再限制于只用si,di作为变址寄存器,而是除了sp以外的所有通用寄存器都能用作变址寄存器。
全局描述符表
一个段描述符描述一个段的信息,一个专门的数据结构保存着多个段描述符,称为“全局描述符表”,其实就是一个保存着段描述符的数组。寄存器显然不可能保存着这个全局描述符表,那么只能保存在大一点的内存里面。但内存相对于寄存器和L1、L2缓存等还是慢了不少,所以CPU专门有一个叫做段描述符缓冲寄存器来提高获取段描述符的效率,通过将用过的段描述符存进寄存器里,再用时再从寄存器中取出来,减少对内存的访问,从而加快内存寻址。
下面来描述一下段描述符结构:
低32位0~15位和高32位的16~19位代表段界限,描述段能达到的边界。具体边界值要结合23位的G来看,G=1时,表示段界限的粒度为4KB,G=0时,表示段界限的粒度为1Byte,实际的段界限=(描述符里的段界限+1)*段界限粒度大小-1。
低32位的16~31位和高32位的0~7位及24~31位共同描述段基址的32位,为什么会分散在三个地方呢?答案很简单,兼容问题,为了将段基址拓展到32位,段界限拓展到20位,也只能接着在后面添加了,所以才会分散在不同的地方。
S代表一个段是系统段还是数据段,在CPU眼里,凡是硬件使用到的东西称为系统,凡是软件使用到的东西称为数据。所以代码段、数据段、栈段等也属于S中所代表的的数据段。
Type指定段的类型,一共四位。只有S决定了,Type才有它的意义。下图是Type在系统段和数据段里不同的意义。
我们主要看一下数据段下Type的意义。当段为代码段时,Type由X、R、C、A组成,分别代表是否可执行、是否可读、是否一致、是否被访问过。当段位数据段时,Type由X、W、E、A组成,分别代表是否可执行、是否可写、扩展方向、是否被访问过。
DPL代表段属于哪一个特权级别。
P代表内存段是否存在,0代表段不存在,1代表段存在。
AVL代表可用的位,操作系统可以随意使用,没有特殊含义。
L代表代码段是64位还是32位。
D/B。对于代码段来说此位是D,用来给代码段指定是使用16位还是32位有效地址和操作数的。对于栈段来说此位是B,用来给栈段指定使用的是sp寄存器还是esp寄存器,sp寄存器的最大寻址范围是0xFFFF,esp寄存器的最大寻址范围是0xFFFFFFFF。
G代表段界限的粒度,是4KB还是1B。
全局描述符表示共用的,多个程序都可以在这个表定义自己的段描述符。我们进入保护模式的其中一个步骤之一就是加载全局描述符表,让CPU知道全局描述符表的位置,在操作内存的时候,CPU就会根据描述符的信息检查这操作是否有效。
A20地址线
在实模式下,A20地址线是默认禁用的,原因是还未进入保护模式之前,地址总线还是要模拟20位的效果,即只保留20位以内的地址,如果地址超过20位,地址就会回绕到0,将地址20位(从0开始算)舍弃,所以要将A20地址线给禁用掉。但进入保护模式后,我们需要恢复地址总线的原貌,即使地址超过20位,地址也不应该回绕到0,所以此时将A20地址线打开,我们就能访问超过20位的地址了。因此,打开A20地址线,是进入保护模式的步骤之一。
CR0的PE位
进入保护模式的最后一个步骤是,打开CR0的PE位,CR0是控制寄存器。控制寄存器是CPU的窗口,它既可以展示CPU的内部状态,也可以控制CPU的运行机制。CR0的第0位,PE位,就是保护模式的开关,我们打开PE位,就是告诉CPU接下来我们要进入保护模式。
进入保护模式
由上面可以知道,进入保护模式的步骤如下:
① 打开A20地址线
② 加载GDT
③ 将CR0的PE位置为1
下面我们开始编写代码:
首先是boot.inc文件的改动,主要定义了描述符的各个位,方便设置整个段描述符。
LOADER_BASE_ADDR equ 0x900 LOADER_START_SECTOR equ 0x2 DESC_G_4K equ 1_000_000_000_000_000_000_000_00b DESC_D_32 equ 1_000_000_000_000_000_000_000_0b DESC_L equ 0_000_000_000_000_000_000_000b DESC_AVL equ 0_000_000_000_000_000_000_00b DESC_LIMIT_CODE2 equ 1111_0000_0000_0000_0000b DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2 DESC_LIMIT_VIDEO2 equ 0000_000_000_000_000_000b DESC_P equ 1_000_000_000_000_000b DESC_DPL_0 equ 00_000_000_000_000_0b DESC_DPL_1 equ 01_000_000_000_000_0b DESC_DPL_2 equ 10_000_000_000_000_0b DESC_DPL_3 equ 11_000_000_000_000_0b DESC_S_CODE equ 1_000_000_000_000b DESC_S_DATA equ DESC_S_CODE DESC_S_sys equ 0_000_000_000_000b DESC_TYPE_CODE equ 1000_0000_0000b DESC_TYPE_DATA equ 0010_0000_0000b 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 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的改动:
%include "boot.inc" section loader vstart=LOADER_BASE_ADDR LOADER_STACK_TOP equ LOADER_BASE_ADDR jmp loader_start ;构建GDT,设置段描述符 ;第一个段描述符不作使用 GDT_BASE: dd 0x0000_0000 dd 0x0000_0000 ;代码段描述符 CODE_DESC: dd 0x0000FFFF dd DESC_CODE_HIGH4 ;数据段、栈段描述符 DATA_STACK_DESC: dd 0x0000FFFF dd DESC_DATA_HIGH4 ;显存段 VIDEO_DESC: dd 0x8000_0007 dd DESC_VIDEO_HIGH4 GDT_SIZE equ $ - GDT_BASE GDT_LIMIT equ GDT_SIZE - 1 ;预留60个描述符 times 60 dq 0 ;设置各个选择子 SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0 SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL0 SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0 ;用于设置GDTR寄存器的值 gdt_ptr dw GDT_LIMIT dd GDT_BASE loadermsg db '2 loader in real.' loader_start: mov sp,LOADER_BASE_ADDR mov bp, loadermsg mov cx, 17 mov ax, 0x1301 mov bx, 0x001f mov dx, 0x1800 int 0x10 in al,0x92 or al,0000_0010b out 0x92,al ;进入保护模式的三个步骤 lgdt [gdt_ptr] mov eax, cr0 or eax, 0x0000_0001 mov cr0, eax ;刷新流水线 jmp dword SELECTOR_CODE:p_mode_start [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 $
像之前一节那样编译及加载程序到硬盘后,执行bochs,第二行会显示字符P,最后一行会显示“2 loader in real",结果如下:
另一个值得注意的指令是:jmp dword SELECTOR_CODE:p_mode_start,这个指令是用来刷新流水线的,因为在进入保护模式之前,p_mode_start后面的指令也会被放上流水线,指令会按照16位译码,其实本来应该按照32位译码才能正常执行,所以我们需要清除流水线上的这些指令,保证这些指令按32位译码,这样才能正常地运行下去。