protected mode
保护模式简介
实模式的缺陷
实模式下操作系统与用户程序属于听一个特权级,容易引起系统崩溃。
程序引用的地址指向真实的物理地址,即逻辑地址等于物理地址,不利于内存分片管理,容易造成内存碎片化。
用户程序可以随意访问任意内存。
一个短只能访问64KB地址,太小了,操作不方便。
一次只能运行一个程序。
只有20根地址线,只能寻址1M的空间,太小啦。
为了克服实模式低劣的内存管理方式,开发出了保护模式。
保护模式的特点
- 寄存器扩展。
- 寻址空间扩展为4GB。
- 对内存分段并进行描述。全局描述符表,全局描述符表寄存器,段描述符缓冲寄存器。
进入保护模式
进入保护模式的步骤为:
- 打开A20
- 加载gdt
- 将cr0的pe位置1
- jmp 刷新流水线
1 | ;----------------- 打开A20 ---------------- |
内存分段
段描述符的结构
位 | 作用 |
---|---|
高32位 | |
31-24 | 段基址31-24 |
23 | G |
22 | D/B |
21 | L |
20 | AVL |
19-16 | 段界限19-16 |
15 | P |
14-13 | DPL |
12 | S |
11-8 | TYPE |
7-0 | 段基址23-16 |
低32位 | |
31-16 | 段基址15-0 |
15-0 | 段界限15-0 |
- 实际的段界限值=(描述符中的段界限值+1)*(段界限的粒度,1B 或者 4KB) - 1
- G=0,粒度为1B,G=1,粒度为4KB.
3、E=0,向上扩展,常用于代码段与数据段。E=1,向下扩展,常用于栈段。
全局描述符表 GDT
全局描述符表相当于描述符的数组,每一个元素是8字节的描述符,使用选择子进行索引。GDT中的第0个描述符是不可用的。
全局描述符表寄存器 GDTR
GDT存在于内存中,GDTR是一个专门指向全局描述符表的寄存器。结构如下:
47-15 | 15-0 |
---|---|
GDT初始地址 | GDT界限 |
在实模式先使用lgdt加载描述符表,但是由于实模式的限制,此时的GTD只能处于1M地址空间以内,所以到了保护模式可以再次进行lgdt更改GDT的位置。
lgdt [48位内存数据]
按小端字节序,前16位用于舒适化GDT界限,后32位,用于指定GDT的初始地址。
选择子
在实模式下,段寄存器选择的是段基址,在开启内存分段后,段寄存器存储的是对应段描述符表的索引和相关数据。选择子的结构如下:
15-4 | 3 | 2-0 |
---|---|---|
描述符索引值 | TI | RPL |
描述符索引值正好为12位,刚好和GDTR界限所确定的最大描述符数量相匹配。
获得内存信息
eg:
1 | ;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 ------- |
内存分页
分段是分页的前提
内存分段模式下的问题
- 物理内存不足时怎么把?
- 内存碎片化问题怎么解决。
在保护模式下,段描述符是内存段的身份证,cpu根据一个段描述符来引用一个段。很多时候,段描述符对应的段并不在内存中。如果一个描述符的P位为1,则表示该段在内存中存在。访问过一个段后,cpu将该描述符中的A为置1。如果P位为0,则说明内存中不存在这个段,则cpu抛出np异常,操作系统将对应的段加载到内存中。
分页机制的思想是,通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑上连续的额线性地址在物理上可以不连续。分页机制将大小不不等的段分解为大小相等的页,再将页映射到物理页。分页机制的作用有:将线性地址转化为物理地址,用大小相等的页代替大小不等的段。有了页表的映射关系,经过段部件的处理输出的则为虚拟地址。在分页机制下,每个进程都认为自己独享整个4GB空间。即程序以为自己身处于段式存储下并拥有4GB空间,但是他的地址经由页部件处理之后才是真正的物理地址。
一级页表
页表用于存储线性地址与物理地址之间的映射。页表中的每一项为大小为4字节的页表项,用来记录4GB空间的物理地址。当访问一个线性地址的时候,实际上就是在访问页表项中对应的物理地址。
每个页大小为4KB,一个页表可以存储1M个页表项,加起来一个页表可以表示整个4GB物理空间。
一级页表的地址转换过程为:用线性地址的高20位作为页表项的索引,每个页表项占用4字节的大小,索引值乘上4就可以得到该页表项在页表中的偏移量。用cr3寄存器中的页表物理地址加上此偏移量就可以得到该页表项的物理地址,从该页表项中得到映射的物理地址,再与低12位的线性地址相加就可以得到最终要访问的物理地址。
假设咱们是在平坦模型下工作,不管段选择子值是多少,其所指向的段基址都是 0,指令 mov ax,
[0x1234]中的 0x1234 称为有效地址,它作为“段基址:段内偏移地址”中的段内偏移地址。这样段基址
为 0,段内偏移地址为 0x1234,经过段部件处理后,输出的线性地址是 0x1234。由于咱们是演示分页机制,必须假定系统已经打开了分页机制,所以线性地址 0x1234 被送入了页部件。页部件分析 0x1234 的高20 位,用十六进制表示高 20 位是 0x00001。将此项作为页表项索引,再将该索引乘以 4 后加上 cr3 寄存器中页表的物理地址,这样便得到索引所指代的页表项的物理地址,从该物理地址处(页表项中)读取所映射的物理页地址:0x9000。线性地址的低 12 位是 0x234,它作为物理页的页内偏移地址与物理页地址0x9000 相加,和为 0x9234,这就是线性地址 0x1234 最终转换成的物理地址。
二级页表
二级页表将4GB空间按每一个标准页大小4KB分为1M个页,将这1M个页分1K*1K个页,每1K个页表项又正好可以组成一个新的页(1K*4B=4KB),将这个新产生的页记录为一个页表项,则总共会产生1K个新的页表项,再将这1K个页表项组成一个标准页,则最后产生的这个标准页为页目录表,其中每一项为页目录项PDE。
二级页表地址转换原理是将 32 位虚拟地址拆分成高 10 位、中间 10 位、低 12 位三部分,它们
的作用是:高 10 位作为页表的索引,用于在页目录表中定位一个页目录项 PDE,页目录项中有页表物理地址,也就是定位到了某个页表。中间 10 位作为物理页的索引,用于在页表内定位到某个页表项 PTE,页表项中有分配的物理页地址,也就是定位到了某个物理页。低 12 位作为页内偏移量用于在已经定位到的物理页内寻址。
转换过程背后的具体步骤如下。
(1)用虚拟地址的高 10 位乘以 4,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的
和,便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。
(2)用虚拟地址的中间 10 位乘以 4,作为页表内的偏移地址,加上在第 1 步中得到的页表物理地址,
所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。
(3)虚拟地址的高 10 位和中间 10 位分别是 PDE 和 PTE 的索引值,所以它们需要乘以 4。但低 12 位就不是索引值啦,其表示的范围是 0~0xfff,作为页内偏移最合适,所以虚拟地址的低 12 位加上第 2 步中得到的物理页地址,所得的和便是最终转换的物理地址。
页目录项和页表项的结构
页目录项
31-12 | 11-9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|
页表物理页地址31-12位 | AVL | G | 0 | D | A | PCD | PWT | US | RW | P |
页表项
31-12 | 11-9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|
物理页地址31-12位 | AVL | G | PAT | D | A | PCD | PWT | US | RW | P |
页表目录项和页表项中的都是物理页地址,标准页大小就是4KB,所以地址都是4K的倍数,即低12位全为零,所以只需要记录高20位就可以了。省下来的12位可以用于其他属性。
P,Present,意为存在位。若为 1 表示该页存在于物理内存中,若为 0 表示该表不在物理内存中。操
作系统的页式虚拟内存管理便是通过 P 位和相应的 pagefault 异常来实现的。
RW,Read/Write,意为读写位。若为 1 表示可读可写,若为 0 表示可读不可写。
US,User/Supervisor,意为普通用户/超级用户位。若为 1 时,表示处于 User 级,任意级别(0、1、2、 3)特权的程序都可以访问该页。若为 0,表示处于 Supervisor 级,特权级别为 3 的程序不允许访问该页,该页只允许特权级别为 0、1、2 的程序可以访问。
PWT,Page-level Write-Through,意为页级通写位,也称页级写透位。若为 1 表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存。此项和高速缓存有关,“通写”是高速缓存的一种工作方式,本位用来间接决定是否用此方式改善该页的访问效率。这里咱们直接置为 0 就可以啦。
PCD,Page-level Cache Disable,意为页级高速缓存禁止位。若为 1 表示该页启用高速缓存,为 0 表示禁止将该页缓存。这里咱们将其置为 0。 A,Accessed,意为访问位。若为 1 表示该页被 CPU 访问过啦,所以该位是由 CPU 设置的。还记得段描述符中的 A 位和 P 位吗?这两位在一起可以实现段式虚拟内存管理。和它们一样,这里页目录项和页表项中的 A 位也可以用来记录某一内存页的使用频率(操作系统定期将该位清 0,统计一段时间内变成 1 的次数),从而当内存不足时,可以将使用频率较低的页面换出到外存(如硬盘),同时将页目录项或页表项的 P位置 0,下次访问该页引起 pagefault 异常时,中断处理程序将硬盘上的页再次换入,同时将 P 位置 1。 D,Dirty,意为脏页位。当 CPU 对一个页面执行写操作时,就会设置对应页表项的 D 位为 1。此项仅针对页表项有效,并不会修改页目录项中的 D 位。
PAT,Page Attribute Table,意为页属性表位,能够在页面一级的粒度上设置内存属性。比较复杂,将此位置 0 即可。
G,Global,意为全局位。由于内存地址转换也是颇费周折,先得拆分虚拟地址,然后又要查页目录,又要查页表的,所以为了提高获取物理地址的速度,将虚拟地址与物理地址转换结果存储在 TLB(Translation Lookaside Buffer)中,TLB 以后咱们会细说。在此先知道 TLB 是用来缓存地址转换结果的高速缓存就 ok 啦。此 G 位用来指定该页是否为全局页,为 1 表示是全局页,为 0 表示不是全局页。若为全局页,该页将在高速缓存 TLB 中一直保存,给出虚拟地址直接就出物理地址啦,无需那三步骤转换。由于 TLB 容量比较小(一般速度较快的存储设备容量都比较小),所以这里面就存放使用频率较高的页面。顺便说一句,清空 TLB 有两种方式,一是用 invlpg 指令针对单独虚拟地址条目清理,或者是重新加载 cr3 寄存器,这将直接清空 TLB。
AVL,意为 Available 位,表示可用,谁可以用?当然是软件,操作系统可用该位,CPU 不理会该位
的值,那咱们也不理会吧。
开启分页
开启分页需要顺序做以下三件事情。
- 准备好页目录表和页表。
- 将页表地址写入控制寄存器cr3。
- 寄存器的PG位置1.
elf (executable and linkable format)
elf中的数据类型
数据类型名称 | 字节大小 | 对齐 | 意义 |
---|---|---|---|
Elf_Half | 2 | 2 | 无符号中等大小的整数 |
Elf_Word | 4 | 4 | 无符号大整数 |
Elf_Addr | 4 | 4 | 无符号程序运行地址 |
Elf_Off | 4 | 4 | 无符号文件偏移量 |
特权级
RPL:请求特权级指令请求访问其他资源的能力成为请求特权级,指令存放在代码段中,所所以使用CS中选择子的RPL位表示代码请求别人资源的能力。
CPL:表示处理器当亲的特权级。指令最终是由处理器执行的,执行到不同特权的代码,处理器的特权级就切换到不同的等级。代码段描述符中的DPL便是当前处理器所处的特权级。
对于数据段来讲,只有访问者的权限大于或等于段描述符中的DPL表示的最低权限时才能够访问。
对于代码段来讲,只有访问者的权限等于段描述符中的DPL才能访问。即只能平级访问。访问一个代码段实质上就是跳转到这个段进行执行。唯一一种处理器从高特权级降到低特权级执行的情况是处理器从中断处理程序中返回到用户态。
一致性代码段:一致性代码段也成为依从代码段,用来实现从低特权级的代码向高特权级代码的转移。一致性代码段是指如果自己是转移后的目标段,则自己的特权级一定能要大于等于转移前的CPL,也就是说一致性代码段的DPL是特权的上限。处理器遇到目标端位一致性代码段的时候并不会将CPL用该段的DPL来替换。代码段可以有一致性与不一致性的段,但是数据段只能有非一致性,即数据段不允许比自己特权级低的代码段访问。
函数调约定
cdecl (c declaration 即c声明)
函数参数从右到左顺序入栈,EAX,ECX,EDX,寄存器由调用者保存,其余的寄存器由被调用者保存。函数的返回值存储在EAX寄存器中。由调用者清理栈空间。
示例:
1 | int subtract(int a, int b); //被调用者 |
C与汇编混合编程
c语言和汇编语言混合编程分为两种:
- 单独的汇编代码文件与单独的c语言文件分别编译成目标文件后,再进行连接成可执行程序。
- 再c语言中嵌入汇编语言,直接编译成生可执行程序。这种也叫做内联汇编。
内联汇编
基本内联汇编
格式:
asm [volatile](“asm code”)
- 指令必须要用双引号括起来
- 一对双引号不可以跨行,如果跨行需要在结尾使用’\‘转义。
- 指令之间使用’;’,’\n’,’\t’分隔开。
- 即使指令分隔在多个双引号中也要使用分隔符。
1 | char* str="hello,world\n"; |
扩展内联汇编
格式:
asm [volatile] (“assembly code”:output : input : clobber/modify)
assembly code:还是用户写入的汇编指令,和基本内联汇编一样。
output:output 用来指定汇编代码的数据如何输出给 C 代码使用。内嵌的汇编指令运行结束后,如果想将运行结果存储到 c 变量中,就用此项指定输出的位置。
input:input 用来指定 C 中数据如何输入给汇编使用。要想让汇编使用 C 中的变量作为参数,就要在此指定。
clobber/modify:汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄
存器或内存数据的破坏,这样 gcc 就知道哪些寄存器或内存需要提前保护起来。
约束
- 寄存器约束
寄存器约束就是要求 gcc 使用哪个寄存器,将 input 或 output 中变量约束在某个寄存器中。常见的寄存器约束有:
a:表示寄存器 eax/ax/al
b:表示寄存器 ebx/bx/bl
c:表示寄存器 ecx/cx/cl
d:表示寄存器 edx/dx/dl
D:表示寄存器 edi/di
S:表示寄存器 esi/si
q:表示任意这 4 个通用寄存器之一:eax/ebx/ecx/edx
r:表示任意这 6 个通用寄存器之一:eax/ebx/ecx/edx/esi/edi
g:表示可以存放到任意地点(寄存器和内存)。相当于除了同 q 一样外,还可以让 gcc 安排在内存中
A:把 eax 和 edx 组合成 64 位整数
f:表示浮点寄存器
t:表示第 1 个浮点寄存器
u:表示第 2 个浮点寄存器
1 |
|
- 内存约束
内存约束是要求 gcc 直接将位于 input 和 output 中的 C 变量的内存地址作为内联汇编代码的操作数,不需要寄存器做中转,直接进行内存读写,也就是汇编代码的操作数是 C 变量的指针。
m:表示操作数可以使用任意一种内存形式。
o:操作数为内存变量,但访问它是通过偏移量的形式访问,即包含 offset_address 的格式。
1 |
|
- 立即数约束
i:表示操作数为整数立即数
F:表示操作数为浮点数立即数
I:表示操作数为 0~31 之间的立即数
J:表示操作数为 0~63 之间的立即数
N:表示操作数为 0~255 之间的立即数
O:表示操作数为 0~32 之间的立即数
X:表示操作数为任何类型立即数
- 通用约束
0~9:此约束只用在 input 部分,但表示可与 output 和 input 中第 n 个操作数用相同的寄存器或内存。
AT&T汇编
intel 与 AT&T 语法风格对比
区别 | intel | AT&T | 说明 |
---|---|---|---|
寄存器 | 寄存器没有钱追你 | 寄存器有前缀% | |
操作数顺序 | 目的操作数在左边,源操作数在右边 | 相反 | |
操作数指定大小 | 有关内存的操作数要加数据类型指定大小,byte:8位,word:16位,dword:32位 | 指令的最后一个字母代表指令操作数大小,b:8位,w:16位,l:32位。 | |
立即数 | 没有前缀 | 有前缀$ | |
远跳转 | jmp far segment:offset | ljmp $segment:$offset | |
远调用 | call far segment:offset | lcall $segment:$offset | |
远返回 | ret far n | lret $n |
内存寻址格式:
segreg(段基址):base_address(offset,index,size)
segreg:base_address+offset+index*size
打印 printf 的实现
打印字符
在printf.h中声明函数
1 | void put_char(uint8_t char_asci) |
在print.s中完成函数的定义
1 | put_char |
打印字符串
在printf.h中声明函数
1 | void put_str(char* message) |
在print.s中完成函数的定义
1 | put_str |
打印整数
在printf.h中声明函数
1 | void put_int(uint32_t num); |
在print.s中完成函数的定义
1 | put_int |
杂项问题
- 对于push指令,处于对齐的要求,操作数要么是16位要么是32位,所以8位操作数会被扩展为运行模式下的默认操作数宽度。实模式为16位,保护模式为32位。
- 使用伪指令 [bits 16] [bits 32] 指定编译器进行模式指定。
- 操作数反转前缀 0x66 寻址方式反转前缀 0x67