本系列文章只做个人学习记录使用
参考资料:
《操作系统真象还原》
从0到-1写一个操作系统
获取物理内存容量
计算机要想被使用,就必须先管理,我们想和物理内存打交道,就必须先知道物理内存有多大
linux获取内存的方法
在linux 2.6内核中,使用detect_memory
函数来获取内存容量的,其函数本质上是通过调用BIOS中断0x15实现的,分别是BIOS中断0x15的3个子功能,子功能号需要存放到寄存器EAX或AX中,以下是这三种模式的介绍:
- EAX=0xE820:遍历主机上全部内存
- AX=0xE801:分别检测低15MB和16MB~4GB内存,最大支持4GB
- AH=0x88:最多检测64MB内存,实际内存超过此容量也按照64MB返回
1. 利用BIOS中断0x15子功能0xe820获取内存
这是最灵活的内存获取方式,说他比较灵活是因为他返回的信息比较丰富,而返回丰富的信息就表示我们需要用一种格式结构来组织这些数据。而内存信息的内容是用地址范围描述符来描述的,用于存储这种描述符的结构称之为地址范围描述符,格式如下:
上述的字段从偏移也可以看出每个占4字节,其中含义大家可以有表得知,这里详细介绍其中的TYPE字段,具体意义如下:
而BIOS按照上述类型来返回内存信息是因为这段内存可能为一下几种情况:
- 系统的ROM
- ROM用到了这部分内存
- 设备内存映射到了这部分内存
- 由于某种原因,这段内存不适合标准设备使用
而由于我们是在32位环境下,所以我么只需要用到低32位属性属性,也就是BaseAddrLow 和 LengthLow就可以了,当然我们在调用BIOS中断不仅仅使得EAX或AX里面有相应的功能号,我们还需要通过其他寄存器传入一系列参数,下面给出具体示例:
这里值得注意的参数寄存器有ECX和ES:DI,其中ECX是指缓冲区大小,ES:DI是指缓冲区指针,被调用函数将所写内容写入该缓冲区,然后记录写入内容大小然后记录在缓冲区大小寄存器中。注意这里调用者是传入的期待BIOS写入大小,而被调用者则是往ECX写入实际大小。此中断的调用步骤如下:- 填写好调用前函数寄存器
- 执行中断调用int 0x15
- 在CF为0的情况下,“返回后输出”的寄存器便会有相应的结果
2. 利用BIOS中断0x15子功能0xe801获取内存
简单但不强大,最大识别4GB的内存,但对32位地址线足够,但其检测的内存是分别存放在两组寄存器中的。
低于15MB的内存以1KB为单位来记录,单位数量在AX和CX中记录,其中AX和CX的值是一样的,所以在15MB空间以下的实际内存容量=AX×1024.AX,CX最大值为0x3c00,即0x3c00**** 1024 =15MB.
16MB~4GB是以64KB为单位大小来记录的,单位数量在BX、DX中存储,其中这俩内容一样,跟上述AX、CX类似
使用方法如图:
这里我们大多数人都会意识到这两个问题:
- 为什么要分“前15MB”和“16MB~4GB”。
- 为什么要设两个内容相同的单位量寄存器,就是说AX=CX,BX=DX.
看表头发现实际物理内存和检测到的内存大小总是相差1MB
这是由于在80286版本由于有24位地址线,即可表示16MB的内存空间,其中低15MB用来正常作为内存使用,而高1MB是留给一些ISA设备作为缓冲区使用,到了现在由于为了向前兼容,所以这1MB还是被空了出来,造成一种内存空洞的现象。
所以说但我们检查内存大小大于等于16MB时,其中AX×1024必然小于等于15MB,而BX×64K必然大于0,所以我们在这种情况下是可以检查出这个历史遗留的1MB的内存空洞,但若是我们检查内存小于16MB的时候,我们所检查的内容范围就会小于实际内存1MB。
为什么要用两个内容相同的问题?
我们在上面的输入寄存器的图片中可以看到,AX与CX,BX与DX这两组寄存器中,都是一个充当Extended和Configured.
这里再给出第二个方法的调用步骤:
- 将AX寄存器写入0xE801
- 执行中断调用
- 在CF为0的情况下,“返回后输出”的寄存器便会有相应的结果
3. 利用BIOS中断0x15子功能0x88获取内存
只能识别到最大64MB的内存,即使内存容量大于64MB,也只会显示63MB,这里为啥又少了1MB呢,这是因为此中断只能显示1MB之上的内存,所以我们在检测之后需要加上1MB,现在懂了为啥说第一种灵活了吧,这第二种第三种都有点毛病,这里像之前一样给出传递参数
调用步骤如下:
- 将AH寄存器写入0x88
- 执行中断调用 int 0x15
- 在CF为0的情况下,“返回后输出”的寄存器便会有相应的结果
代码实现
loader.s
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR ;起始地址按照之前约定一样
LOADER_STACK_TOP equ LOADER_BASE_ADDR ;loader在实模式下的栈指针地址
;这里注意删掉了之前的jmp loader_start,转而对mbr.S进行了修改
;构建gdt及其内部描述符
GDT_BASE: dd 0x00000000 ;低4字节
dd 0x00000000 ;高4字节,无效描述符,防止选择子未初始化
CODE_DESC: dd 0x0000FFFF ;dd是伪指令,表示define double-word,也就是定义双字变量,这里的0xFFFF表示段界限
dd DESC_CODE_HIGH4 ;代码段描述符
DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4 ;栈段描述符,也就是数据段描述符,这俩共用一个段是因为方便,至于为什么这里栈的P位为什么不是1,也就是向下扩展,这是因为段描述符是由CPU检查的,而CPU并不知道这个段的作用,程序员若要实现栈向下扩展只需要使得其esp在push时减小即可
VIDEO_DESC: dd 0x80000007;limit=(0xbffff-0xb8000)/4k=0x7,这里的0xb8000~0xbffff是实模式下文本模式显存适配器的内存地址,因此段界限即为上述方程
dd DESC_VIDEO_HIGH4 ;此时dpl为0,此乃显存段描述符
;-------- 以上共填充了3个段描述符 + 1个首段无效描述符-------------
GDT_SIZE equ $ - GDT_BASE ;计算当前GDT已经填充的大小
GDT_LIMIT equ GDT_SIZE - 1 ;
times 60 dq 0 ;此处预留60个描述符空位,这里跟上面一致,表示define quad-word ,也就是定义60个以0填充的段描述符,这里的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 ;同上类似
;total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记
;当前偏移loader.bin 文件头0x200个字节
;loader.bin的加载地址是0x900
;故total_mem_byte内存中的地址是0xb00
;将来在内核中我们会引用到这个地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;-------------- 以下定义gdt的指针,前2字节是gdt的界限,后4字节是gdt的起始地址 ---------
gdt_ptr dw GDT_LIMIT ;define word
dd GDT_BASE
;人工对齐:total_mem_bytes4+gdt_ptr6+ards_buf244+ards_nr2,共256字节,0x100
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录ARDS结构体的数量
loader_start:
;------ int 15H eax = 0000E820,edx = 534D4150('SMAP') 获取内存布局-------
xor ebx, ebx ;第一次调用时,ebx置0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;ards结构缓冲区,这里由于es我们在mbr.S中已经初始化,为0,所以这里我们不需要修改es,只需要对di赋值即可
.e820_mem_get_loop:
mov eax, 0x0000e820 ;每次执行int 0x15之后,eax会变成0x534d4150,所以每次执行int之前都要更新为子功能号
mov ecx, 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结构提,循环次数cx就是ards的数量
mov ebx, ards_buf ;将ebx中放入我们构造的缓冲区地址
xor edx, edx ;edx为最大的内存容量,在此先清0
.find_max_mem_area: ;这里不需要判断type是否为1,最大的内存块一定是可被使用的
mov eax, [ebx]
add eax, [ebx+8] ;这里ebx和ebx+8代表了BaseAddrLow 和 LengthLow
add ebx, 20 ;ebx指向下一个ards结构体
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .next_ards ;大于或等于
mov edx, eax ;edx为总内存大小
.next_ards:
loop .find_max_mem_area ;循环,以cx为循环次数
jmp .mem_get_ok
;------ int 15H ax=E801h , 获取内存大小,最大支持4G------
;返回后,ax cx值一样,以KB为单位, bx dx 一样,以64KB为单位
;在ax和cx寄存器中为低16MB,在bx与dx寄存器中为16MB到4GB
.e820_failed_so_try_e801:
mov ax, 0xe801
int 0x15
jc .e801_failed_so_try88 ;若cf位为1则说明有错误发生,尝试下一个88方法
;1 先算出低15MB的内存
; ax和cx中是以KB为单位的内存数量,因此我们将其转换为以byte为单位
mov cx,0x400 ;这里由于cx和ax一样,所以我们将cx用作乘数,0x400即为1024
mul cx ;由于处于实模式,所以我们mul指令的含义是ax × cx,注意mul指令是16位乘法,生成乘数应该是32位,高16位在dx中,低16位存于ax中
shl edx, 16 ;左移16位,这里也就是将dx保存的高16位转移到edx的高16位上
and eax, 0x0000FFFF ;将eax高16位清0
or edx, eax ;或后得出乘积,保存至edx中
add edx, 0x100000 ;最后将差的那1MB加上
mov esi, edx ;这里保存一下edx的值,因为在之后的计算过程中他会被破坏
;2 再将16MB以上的内存转换为byte为单位
xor eax, eax
mov ax, bx
mov ecx, 0x10000 ;0x10000为16进制的64K
mul ecx ;32位乘法,其高32位和低32位存放在edx和eax中
add esi, eax ;由于这里只能最大测出4GB,edx的值肯定为0,所以咱们只需要eax就可以了
mov edx, esi ;其中edx为总内存大小
jmp .mem_get_ok
;----- int 15h ah=0x88 获取内存大小,只能获取64MB之内 -------
.e801_failed_so_try88:
;int 15h后,ax存入的是以KB为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax, 0x0000FFFF
;16位乘法
mov cx, 0x400
mul cx
shl edx, 16
or edx, eax
add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,所以最终我们还需要加上1MB
.error_hlt:
jmp $
.mem_get_ok:
mov [total_mem_bytes], edx ;将内存换为bytes为单位然后存入total_mem_bytes中
;---------------- 准备进入保护模式 ------------------
;1 打开A20
;2 加载gdt
;3.将CR0的PE位置0,
;------------=- 打开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 ;刷新流水线
[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
因为我们在loader.S
上去掉了jmp loader_start
(占3字节),而loader_start
在loader
中的偏移为我们精心准备好的0x300
,所以我们在mbr
跳转到loader_start
时就要加上0x300
;--------------------------------
jmp LOADER_BASE_ADDR +0x300
;--------------------------------
os.sh
脚本
在编译运行时可以写一个脚本执行
#!/bin/sh
nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S
sudo dd if=./loader.bin of=/usr/local/hd60M.img bs=512 count=2 seek=2 conv=notrunc
sudo dd if=./mbr.bin of=/usr/local/hd60M.img bs=512 count=1 conv=notrunc
执行代码
chmod +x ./os.sh
./os.sh
结果
查看配置中内存大小为32M,而下面方框中框选的0x02000000
正好是32MB
内存分页
内存为什么要分页?
进程在使用内存时,它们占据的空间不是紧密相连的,而是随机分配,就导致内存中明明还有地方放,但都是一些小段,导致程序无法放入内存,那这些没有被使用的内存就被浪费了,计算机给这些内存起了一个名字叫做“内存碎片”
所以计算机想了一种方法,将不常用的程序段从内存换到硬盘,这样就解决了内存碎片,但段的大小不一,我们很难判断到底换出多大的段才能让新进程进去。
简单来说就是在只分段的情况下,CPU认为线性地址就是物理地址,但线性地址是连续的,物理地址不连续,所以就导致
一级页表
在我们没有使用分页机制的时候,我们采用的仍然是系统自带的分段方式,也就是依靠段地址:段内偏移来进行地址选择,且该地址仍然是物理地址,寻址过程如图:
而我们开启分页机制之后,我们程序员所使用的地址就变为了虚拟地址,然后我们的寻址过程就变成如下图:
我们使用4GB虚拟内存,首先会将其分为大小一致的一堆页,而这个页面大小一般定为4KB,也就是说在32位地址中,高20位为页地址,而低12位为页内地址.
4GB的物理地址是操作系统共享的,但4GB虚拟地址空间是每个进程独享的,我们利用分页机制让每个进程以为自己可以独享4GB的空间。
在我们本来的程序中是进行了分段的操作,但是载入物理内存的过程中就会进行分页而打乱顺序,此时就需要用到页表,也表中保存的也就是一个个映射,保证你按顺序访问虚拟地址,他会给出想对应的物理地址。
需要一个页表来建立这层映射关系,也表中每个页表项就保存着一个真实物理地址。但是光有页表还不行,我们还需要找得到他,所以我们还需要一个额外的寄存器来保存这个页表在物理地址中的位置。这个寄存器就是控制寄存器CR3.
寻址过程:
- 首先我们拥有想要访问的虚拟地址
- 此时我们取虚拟地址的高20位,这就是页表相对偏移
- 我们找到cr3寄存器中的页表首地址,然后加上我们刚刚取到的偏移再乘上4,(这是因为一个页表项占4字节),我们访问该物理地址就会得到另一个物理地址
- 刚刚从页表当中得到的物理地址是我们真正想要访问的页地址,此时我们再加上虚拟地址的低12位,也就是页内地址,这样我们就得到了我们真正想访问的地址了。
二级页表
但单级页表会产生问题,页表太大了,而且每个进程都需要一个页表,导致开销很大
于是产生了二级页表
二级页表同一级页表类似,就是中间又加了一层而已,这里提出二级页表的原因是由于最高级页表必须在内存,但是我们若只采用一级的话,常驻内存的页表会十分巨大,所以我们需要再加上一级页表(这里应该被叫做页目录)用来减少内存消耗,我们在一级页表是采用了高20位来表示页表项的偏移,这里我们二级页表将其对半分开,高10位用作页目录偏移,剩下的10位用作页表偏移。分配情况如下图所示:
我们看下面的图来分析
- 低12位是页大小,正好是4K
- 中间10位是一级页表,存储1K个数据,每个数据是4字节(正好是页表项的大小,后面有结构图),所以是4M大小
- 高10位是页目录项
可以看到一级页表要表示12M大小需要4M(正好是一整个一级页表),而二级页表仅仅需要一个二级页表和三个一级页表,总共16K大小
下面是mov ax, [0x1234567]
的寻址过程
其中页目录项之于页目录,页表项之于页表,就如同段描述符之于全局描述表一样,下面给出这俩的具体结构:
这里我们可以看到并不是说表项全是地址,他还有很多别的标志位,其中表项保存地址只用了20位,但为什么不是32位呢,因为咱们只需要高20位,也就是页的首地址,而页都是以0x1000为单位的,所以低12位肯定为0,就不需要保存啦,接下来介绍每个标志位的含义:
- P位,Present,类似段描述符,表示是否存在,为1表示存在于物理内存
- RW, Read/Write,读写位,为1则表示可读写
- US, User/Supervisor,普通/超级用户位,若为1则表示处于用户级,任意级别(0,1,2,3)都可以使用此页,当为0的时候表示超级用户位,特权级别3不可访问,而(0,1,2)可以访问此页
- PWT, Page-Level Write-Through,意为页级通写位,若为1表示采用通写方式,表示该页不仅在内存,还存在在高速缓存。我们在这里默认置0
- PCD, Page-Level Cache-disable,意为页级高速缓存禁用位,1表示该页启用高速缓存,0为禁用,我们这里默认置0
- A, Access,意为访问位,若为1则表示该页已经被CPU访问过了,这里是由CPU赋值的
- D, Dirty,脏位,表示该页已经被修改。此项仅对于页表项有效,对目录项不发生改变
- PAT, Page Attribute Table, 意为页属性表位,这位比较复杂,我们不涉及,直接置0
- G, Global,全局位,用来指定该位是否为全局页,为1表示是,为0表示不是。若为全局页,则该页会在TLB中一直保存(快表)。顺便这里加个知识点:清空TLB有两种方式,一种是invlpg指令针对单独虚拟地址条目进行清理,还有一种是修改CR3寄存器,这将直接清空TLB
- AVL, Available,可用位,这里咱们不需要管
若要启用分页机制,我们需要执行以下步骤:
- 准备好页目录表和页表
- 将页表地址写入CR3控制寄存器
- 将CR0的PG位置1
其中第二步我们需要了解一个CR3寄存器的结构,
CR3寄存器被用来存放页表的首地址,所以他还有个更响亮的名字:页目录基址寄存器(Page Directory Base Register,PDBR)
页表和进程的关系
每个进程都有自己的页表,在进行进程切换时,页表也要跟着切换,页表是动态增长的
操作系统与用户进程的关系(页表)
用户进程和操作系统的关系是基于用户进程共享操作系统的(比如一些系统调用,这些功能只能由操作系统来实现),所以页表也要共享。
操作系统属于用户进程的虚拟地址空间----->实现共享
让用户3GB~4GB的地址空间都指向同一个操作系统,也就是所有进程的虚拟地址该部分都是指向同一片物理页地址,这样就实现了共享
代码实现
boot.inc
添加页表相关属性
;---------- loader 和 kernel ------------
LOADER_BASE_ADDR equ 0x900 ;内存首址
LOADER_START_SECTOR equ 0x2 ;硬盘扇区
PAGE_DIR_TABLE_POS equ 0x100000 ;页目录存放首址
;---------- gdt描述符属性 ---------------
DESC_G_4K equ 1_00000000000000000000000b ;G位,表示粒度
DESC_D_32 equ 1_0000000000000000000000b ;D位,表示为32位
DESC_L equ 0_000000000000000000000b ;64位代码标记,此处为0即可
DESC_AVL equ 0_00000000000000000000b ;CPU不用此位,此位为0
DESC_LIMIT_CODE2 equ 1111_0000000000000000b ;表示代码段的段界限值第二段
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2 ;表示数据段的段界限值第二段
DESC_LIMIT_VIDEO2 equ 0000_0000000000000000b
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_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_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 ;这里书上有误,应为0x0b
;----------- 选择子属性 --------------
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b
;------------ 页表以及相关属性 -------------
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_U equ 000b
PG_US_S equ 100b
loader.S
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR ;起始地址按照之前约定一样
LOADER_STACK_TOP equ LOADER_BASE_ADDR ;loader在实模式下的栈指针地址
;这里注意删掉了之前的jmp loader_start,转而对mbr.S进行了修改
;构建gdt及其内部描述符
GDT_BASE: dd 0x00000000 ;低4字节
dd 0x00000000 ;高4字节,无效描述符,防止选择子未初始化
CODE_DESC: dd 0x0000FFFF ;dd是伪指令,表示define double-word,也就是定义双字变量,这里的0xFFFF表示段界限
dd DESC_CODE_HIGH4 ;代码段描述符
DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4 ;栈段描述符,也就是数据段描述符,这俩共用一个段是因为方便,至于为什么这里栈的P位为什么不是1,也就是向下扩展,这是因为段描述符是由CPU检查的,而CPU并不知道这个段的作用,程序员若要实现栈向下扩展只需要使得其esp在push时减小即可
VIDEO_DESC: dd 0x80000007;limit=(0xbffff-0xb8000)/4k=0x7,这里的0xb8000~0xbffff是实模式下文本模式显存适配器的内存地址,因此段界限即为上述方程
dd DESC_VIDEO_HIGH4 ;此时dpl为0,此乃显存段描述符
;-------- 以上共填充了3个段描述符 + 1个首段无效描述符-------------
GDT_SIZE equ $ - GDT_BASE ;计算当前GDT已经填充的大小
GDT_LIMIT equ GDT_SIZE - 1 ;
times 60 dq 0 ;此处预留60个描述符空位,这里跟上面一致,表示define quad-word ,也就是定义60个以0填充的段描述符,这里的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 ;同上类似
;total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记
;当前偏移loader.bin 文件头0x200个字节
;loader.bin的加载地址是0x900
;故total_mem_byte内存中的地址是0xb00
;将来在内核中我们会引用到这个地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;-------------- 以下定义gdt的指针,前2字节是gdt的界限,后4字节是gdt的起始地址 ---------
gdt_ptr dw GDT_LIMIT ;define word
dd GDT_BASE
;人工对齐:total_mem_bytes4+gdt_ptr6+ards_buf244+ards_nr2,共256字节,0x100
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录ARDS结构体的数量
loader_start:
;------ int 15H eax = 0000E820,edx = 534D4150('SMAP') 获取内存布局-------
xor ebx, ebx ;第一次调用时,ebx置0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;ards结构缓冲区,这里由于es我们在mbr.S中已经初始化,为0,所以这里我们不需要修改es,只需要对di赋值即可
.e820_mem_get_loop:
mov eax, 0x0000e820 ;每次执行int 0x15之后,eax会变成0x534d4150,所以每次执行int之前都要更新为子功能号
mov ecx, 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结构提,循环次数cx就是ards的数量
mov ebx, ards_buf ;将ebx中放入我们构造的缓冲区地址
xor edx, edx ;edx为最大的内存容量,在此先清0
.find_max_mem_area: ;这里不需要判断type是否为1,最大的内存块一定是可被使用的
mov eax, [ebx]
add eax, [ebx+8] ;这里ebx和ebx+8代表了BaseAddrLow 和 LengthLow
add ebx, 20 ;ebx指向下一个ards结构体
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .next_ards ;大于或等于
mov edx, eax ;edx为总内存大小
.next_ards:
loop .find_max_mem_area ;循环,以cx为循环次数
jmp .mem_get_ok
;------ int 15H ax=E801h , 获取内存大小,最大支持4G------
;返回后,ax cx值一样,以KB为单位, bx dx 一样,以64KB为单位
;在ax和cx寄存器中为低16MB,在bx与dx寄存器中为16MB到4GB
.e820_failed_so_try_e801:
mov ax, 0xe801
int 0x15
jc .e801_failed_so_try88 ;若cf位为1则说明有错误发生,尝试下一个88方法
;1 先算出低15MB的内存
; ax和cx中是以KB为单位的内存数量,因此我们将其转换为以byte为单位
mov cx,0x400 ;这里由于cx和ax一样,所以我们将cx用作乘数,0x400即为1024
mul cx ;由于处于实模式,所以我们mul指令的含义是ax × cx,注意mul指令是16位乘法,生成乘数应该是32位,高16位在dx中,低16位存于ax中
shl edx, 16 ;左移16位,这里也就是将dx保存的高16位转移到edx的高16位上
and eax, 0x0000FFFF ;将eax高16位清0
or edx, eax ;或后得出乘积,保存至edx中
add edx, 0x100000 ;最后将差的那1MB加上
mov esi, edx ;这里保存一下edx的值,因为在之后的计算过程中他会被破坏
;2 再将16MB以上的内存转换为byte为单位
xor eax, eax
mov ax, bx
mov ecx, 0x10000 ;0x10000为16进制的64K
mul ecx ;32位乘法,其高32位和低32位存放在edx和eax中
add esi, eax ;由于这里只能最大测出4GB,edx的值肯定为0,所以咱们只需要eax就可以了
mov edx, esi ;其中edx为总内存大小
jmp .mem_get_ok
;----- int 15h ah=0x88 获取内存大小,只能获取64MB之内 -------
.e801_failed_so_try88:
;int 15h后,ax存入的是以KB为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax, 0x0000FFFF
;16位乘法
mov cx, 0x400
mul cx
shl edx, 16
or edx, eax
add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,所以最终我们还需要加上1MB
.error_hlt:
jmp $
.mem_get_ok:
mov [total_mem_bytes], edx ;将内存换为bytes为单位然后存入total_mem_bytes中
;---------------- 准备进入保护模式 ------------------
;1 打开A20
;2 加载gdt
;3.将CR0的PE位置0,
;------------=- 打开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 ;刷新流水线
[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'
;创建页目录及页表并初始化页内存位图
call setup_page
;要将描述符表地址及偏移量写入内存gdt_ptr,一会儿用新地址重新加载
sgdt [gdt_ptr] ;存储到原来gdt所有的位置
;将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx, [gdt_ptr + 2] ;加上2是因为gdt_ptr的低2字节是偏移量,高四字节才是GDT地址
or dword [ebx + 0x18 + 4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故为0x18,这里再加上4是因为咱们要的是高4字节,这里或的含义就类似与加,因为目前最高位肯定为0
;段描述符高四字节的最高位是段基址的第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], 'L' ;视频段段基址已经被更新,用字符V表示vitual addr
mov byte [gs:162], 'I'
mov byte [gs:164], 'U'
mov byte [gs:166], 'T'
mov byte [gs:168], 'I'
mov byte [gs:170], 'A'
mov byte [gs:172], 'N'
mov byte [gs:178], 'I'
jmp $
;------------- 创建页目录以及页表 ------------
setup_page:
;先把页目录占用的空间逐字清0
mov ecx, 4096 ;表示4K
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
;开始创建页目录项(PDE)
.create_pde: ;创建Page Directory Entry
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 ;第一个目录项
;在页目录表中的地一个目录项写入第一个页表的位置(0x101000)及属性
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;一个页表项占用4字节
;0xc00表示第768个页表占用的页表项,0xc00以上的目录项用于内核空间,768用16进制表示为0x300,这个值再加就是刚好属于内核进程了
;也就是页表的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 ;该页表用来分配0x0~0x3fffff的物理页,也就是虚拟地址0x0~0x3fffff和虚拟地址0xc0000000~0xc03fffff对应的物理页,我们现在只用了低1MB,所以此时虚拟地址是等于物理地址的
mov edx, PG_US_U | PG_RW_W | PG_P ;同上面类似
.create_pte: ;创建Page Table Entry
mov [ebx + esi*4], edx ;此时ebx为第一个页表的首地址,这在上面咱们已经赋值了
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 ;同上
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
页目录项及页表项
而由于距离页目录偏移0xc00的地方之后,就属于了高1G,这里可以通过简单的计算得出来,0 + 0xc00/4 * 2^22 = 0xc0000000,这里刚好可以得出咱们内核存放的最低地址。
所以说我们首先将页目录偏移0,以及偏移0xc00的地方填入我们的地一个页表地址,由于咱们页目录和第i一个页表挨着存放,所以第0个页表地址应该为0x101000,如下图:
易错点
sudo dd if=./loader.bin of=/usr/local/hd60M.img bs=512 count=3 seek=2 conv=notrunc
注意这条指令的count是3,而不是2.这个问题困扰了我将近一天。
使用bochs调试发现一直无法开启分页功能,明明代码看起来没有出现错误。
使用ls命令可以看到loader.bin大于1024,因此参数count至少为3,否则就会访问到.img的野空间一直报错。
结果分析
最终运行的结果如下
用虚拟地址访问页表
让虚拟地址与物理地址乱序映射就可以更好的使用虚拟地址
查看虚拟地址映射情况:
-
cr3寄存器显示的是页目录表的物理地址
PAGE_DIR_TABLE_POS equ 0x100000 ;页目录存放首址
-
0x00000000-0x000fffff
虚拟空间低端1M内存,其物理地址是0x000000000000-0x0000000fffff
,正好是mov ecx, 256 ;1M低端内存/每页大小4K = 256
这行代码未256个页表的作用 -
0xc0000000-0xc00fffff
是768个页表的作用,由于第0个页页目录项和第768个页目录项指向的是同一个页表,所以映射的物理地址还是0x000000000000-0x0000000fffff
-
mov [PAGE_DIR_TABLE_POS + 4092], eax
导致后面三个映射的产生0xffc00000-0xffc00fff -> 0x000000101000-0x000000101fff
最后一个目录项是第1023个,虚拟地址的高10位用来访问页目录表中的目录项,高10位都为1,1111111111b=0x3ff=1023
,正好访问的是最后一个页目录项,其页目录表物理地址就是0x100000
但这个页目录项地址正好是页表的地址,说明页目录表被当成了页表
中间10位检索到第0个页表项,所以第0个页表项就是页目录项的第0个页目录项,记录的是第一个页表的地址,根据add eax, 0x1000 ;此时eax为第一个页表的位置以及属性
得到物理地址是0x101000
低12位是000对应的就是0
虚拟地址获取页表中各数据类型的方法
- 获取页目录表物理地址:让虚拟地址的高 20 位为 0xfffff,低 12 位为 0x000,即 0xfffff000,这也是页目录表中第 0 个页目录项自身的物理地址
- 访问页目录中的页目录项,即获取页表物理地址:要使虚拟地址为 0xfffffxxx,其中 xxx 是页目录项的索引乘以 4 的积
- 访问页表中的页表项:要使虚拟地址高 10 位为 0x3ff,目的是获取页目录表物理地址。中间 10 位为页表的索引,因为是 10 位的索引值,所以这里不用乘以 4。低 12 位为页表内的偏移地址,用来定位页表项,它必须是已经乘以 4 后的值
加载内核
二进制程序的运行方法
前面所写的程序让我们知道了,要想从mbr跳转到loader,必须要将地址固定起来,但这会给编程带来很多问题,所以我们要让程序的加载地址不是那么固定
最简单的就是在程序文件中专门腾出空间来写入这些程序的入口地址,主调程序在该程序的相应空间中将该程序的入口信息读出来;
在开头记录这部分信息,最终就形成了一种文件格式,文件头用来描述程序的布局信息
ELF文件结构
我们拿到一个文件,我们该从哪儿知道这个文件是什么格式,有多大,什么类型等各种信息呢,可能有的同学会说我们使用检测文件的工具即可,就比如我们上一篇中所说的linux自带的工具file,但是问题是这个file工具又是怎么知道这个文件的格式然后反馈给我们用户的呢,实际上每个文件都会存在有一个文件头,这个文件头里面存放着包含这个文件的各种信息,当然ELF文件也不例外
目标文件既会参与程序链接又会参与程序执行。出于方便性和效率考虑,根据过程的不同,目标文件格式提供了其内容的两种并行视图,如下:
我们首先来介绍ELF header部分
上面是介绍了一些关于elf header的数据类型,下面便是具体的数据结构
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT];
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_Ehdr;
-
文件头中的e__ident数组,下面给出表
-
e_type:占2字节,指示elf目标文件类型,类型如下:
-
e_machine:占2字节,指示目标文件需要在哪个机器上才能运行
-
e_version:占4字节,表示版本信息
-
e_entry:占4字节,表示程序入口地址
-
e_phoff:指明程序头表在文件中的偏移
-
e_shoff:指明文件节头表在文件中的偏移
-
e_flags:关于处理器的一些标志,这里不做具体介绍
-
e_ehsize:指明文件头大小
-
e_phentsize:指明程序头表中每个条目的大小
-
e_phnum:指明程序头表中有多少条目,也就是多少个段
-
e_shentsie:指明节头表中每个条目的大小
-
e_shnum:指明节头表中有多少个条目,也就是多少个节
-
e_shstrndx:用来指明字符串表对应条目在节头表上的索引
以上就是elf header各字段的解释,下面我们来介绍一下程序头表,注意这里严格意义上来说是介绍程序头表中的一个条目,就类似段描述表中介绍段描述符一样,一个程序头表中有着很多下面结构的元素:
typedef struct {
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;
} Elf32_Phdr;
- p_type:表示该段的类型,类型如下
- p_offset:表示本段在文件中的偏移地址
- p_vaddr:表示本段在虚拟内存中的起始地址
- p_paddr:仅用于与物理地址相关的系统中,因为 System V忽略用户程序中所有的物理地址,所以此项暂且保留,未设定。
- p_filesz:表示本段在文件中的大小
- p_memsz:表示本段子内存中的大小
- p_flags:指明本段的标志类型,如下:
- p_align:对齐方式
将内核载入内存
载入前我们必须知道那些空间已经被使用了,我们不能把之前的东西覆盖掉
在0号磁盘放入MBR,然后在2号磁盘写入了loader。
物理硬盘在低1MB中除了可用的空间,我们在0x7c00放入的是MBR,但是这里其实可以覆盖他了,因为他没用了已经,0x900开始存放的loader,然后我们在0x100000后存放的是页目录以及页表,而由于我们内核将会只存放在低端1MB,所以这里之后就不用管了,这里给出低1MB图片,打勾的都是可用区域。
内核被加载到内存后,loader还要将elf文件解析并放到新的位置。
所以就是内核在内存中有两份拷贝,一份是一份是 elf 格式的原文件 kernel.bin ,另一份是loader解析elf格式的 kernel.bin 后在内存中生成的内核映像(也就是将程序中的各种段segment复制到内存后的程序体),这个映像才是真正运行的内核。
为了以后loader扩展的可能性,我们的kernel.bin放的距离他远一点,我们放在磁盘的9号扇区
dd if=./kernel.bin of=../bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc
这里选择写入200块是因为为了防止以后每次修改,这里如果少于200块的话写入会自动停止,大家不需要担心
而内存中我们的内核以后会越来越大,所以我们将内核kernel.bin文件尽量放到比较高的地址,而真正重要的内核映像就放比较前面,所以我们在0x70000这儿放内核文件
所以我们接下来的工作主要有两步:
- 加载内核:把内核文件加载到内核缓冲区
- 初始化内核:需要在分页后,将加载进来的 elf 内核文件安置到相应的虚拟内存地址,然后跳过去
执行,从此 loader 的工作结束。
首先我们修改boot.inc里面的内容,将内核的代码地址添加上去,如下:
KERNEL_START_SECTOR equ 0x9 ;Kernel存放硬盘扇区
KERNEL_BIN_BASE_ADDR equ 0x70000 ;Kernel存放内存首址
KERNEL_ENTRY_POINT equ 0xc0001500 ;Kernel程序入口地址
;----------- ELF文件相关 -----------------
PT_NULL equ 0x0
然后我们在加载页表之前来加载内核,代码如下:
;------------ 加载kernel ---------------------
mov eax, KERNEL_START_SECTOR ;kernel.bin所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ;从磁盘读出后,写入到ebx指定的地址
mov ecx, 200 ;读入的扇区数
call rd_disk_m_32 ;上述类似与传递参数,这里类似于mbr.S中的rd_disk_m_32
初始化内核的代码
;------------ 将kernel.bin中的segment拷贝到编译的地址 ----------------
kernel_init: ;0xd45
xor eax, eax
xor ebx, 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
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ;e_phoff
add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ;e_phnum
.each_segment:
cmp byte [ebx + 0], PT_NULL ;若p_type等于PT_NULL,说明此program未使用
je .PTNULL
;为函数memcpyu压入参数,参数从右往左依次压入
;函数原型类似于memcpy(dst, src, size)
push dword [ebx + 16] ;program header中偏移16字节的地方是p_filesz,传入size参数
mov eax, [ebx + 4] ;p_offset
add eax, KERNEL_BIN_BASE_ADDR ;此时eax就是该段的物理地址
push eax ;压入memcpy的第二个参数,源地址
push dword [ebx + 8] ;呀如函数memcpy的第一个参数,目的地址,p_vaddr
call mem_cpy
add esp, 12 ;清理栈中压入的三个参数
.PTNULL:
add ebx, edx ;edx为program header的尺寸,这里就是跳入下一个描述符
loop .each_segment
ret
;----------- 逐字节拷贝 mem_cpy(dst, src, size)-------------
;输入:栈中三个参数
;输出:无
;-----------------------------------------------------------
mem_cpy:
cld ;控制eflags寄存器中的方向标志位,将其置0
push ebp
mov ebp, esp ;构造栈帧
push ecx ;rep指令用到了ecx,但ecx对于外层段的循环还有用,所以入栈备份
mov edi, [ebp + 8] ;dst
mov esi, [ebp + 12] ;src
mov ecx, [ebp + 16] ;size
rep movsb ;逐字节拷贝,其中movs代表move string,其中源地址保存在esi,目的地址保存在edi中,其中edi和esi肯定会一直增加,而这个增加的功能由cld指令实现
;这里的rep指令是repeat的意思,就是重复执行movsb,循环次数保存在ecx中
;恢复环境
pop ecx ;因为外层ecx保存的是程序段数量,这里又要用作size,所以进行恢复
pop ebp
ret
上面代码也就是逐字拷贝,逻辑比较简单
所以我们此时再到loader主体里面进行调用,代码如下,注意这段代码是在开启页表后进行的
;;;;;;;;;;;;;;;;;;;;;;;;; 此时可不用刷新流水线;;;;;;;;;;;;;;;;;;;;;;;;;
;这里是因为一直处于32位之下,但是为了以防万一所以还是加上一个流水线刷新
jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新gdt
enter_kernel:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
call kernel_init
mov esp, 0xc009f000 ;这里选用0xc009f000对应物理地址为0x9f000是一个尽量靠近可用区域边界且为整的地址,并不是必须得是这个,但这个地址确实不错
jmp KERNEL_ENTRY_POINT ;用地址0x1500访问测试,这里相当与jmp $了
最后这里注意一点那就是内核函数main.c的编译,此时我们需要指定编译版本,(我的gcc版本和书上一样,都是gcc 4.4 ,所以没有错误,建议大家都将版本改成4.4)
gcc -m32 -c main.c -o main.o
进行链接
ld -m elf_i386 main.o -T link.script -Ttext 0xc0001500 -e main -o ./kernel.bin
直接打入9号扇区
dd if=./kernel.bin of=../bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc
内存示意图:
特权级
计算机的一切操作都可以被认为是某个访问受访者,为了保证安全,就产生了特权级,就像你是学生,你没有权限看到高考卷子一样
而我们目前特权级一般有4中情况,分别是0,1,2,3,我们从计算机启动到mbr再到loader以及内核都是处于0级特权级,下面就是各特权级的使命:
(1)TSS
TSS.也就是Task State Segment,任务状态段,它是每个任务都有的结构(任务也就是进程),这里面保存了一些特权级的栈地址等,总共占104字节,下面给出具体结构:
可以看到其有esp0,esp1,esp2,这个是表示三个栈顶地址
有三个地址的原因是,我们在切换特权级的时候,我们的栈要切换到对应的特权栈。
只有三个特权栈的原因是,我们最差就是三号特权,咱们切换特权级只能切0,1,2,而3号特权栈实际上就是用户栈,这个栈的切换是通过保存上下文来进行的
TSS就是在处理器进入不同特权级的过程中,由硬件到TSS中寻找同特权级的栈,而这个寻找过程不需要咱们知道,因为这是系统级的
特权级的转移分为两类:
- 通过中断门,调用门实现低特权阶级转向高特权级
- 通过调用返回指令从高特权级返回低特权级
不是说每个任务都有4个栈,一个任务栈的数量取决于自身特权级,就比如说我们用户级任务,特权级为3,所以我们有4个栈,分别是用户栈,特权2,1,0栈,可是对于特权级为2的任务,他就只有3个栈,也就是特权2,1,0栈。
TSS就如同GDT一样也是个数据结构,所以为了知道怎么找到他,我们需要类似GDTR一样的东西来保存TSS的地址,这个就是TR寄存器。
(2)CPL和DPL
PL就是是Privilege Level的意思,也就是CPU若想知道谁的特权高谁的特权低,就得需要一种标识类的东西来记录那个人的特权级,不然在CPU眼里万物都是一样的。
选择子的图片:
这里的RPL记录的是请求特权级,也就是访问者的特权级。
访问者就是执行的指令,只有指令才有能力访问其他资源,所以CS.RPL记录的就是当前执行指令的处理器的特权级
CPL,Current Privilege Level,也就是当前特权级。
在CPU运行的是指令,运行的指令肯定会属于某个段,该代码段的特权级就是代码段描述符中的DPL=当前CPU所处的特权级,这个特权级称为当前特权级也就是CPL,他表示处理器正在执行的代码的特权级别。
首先段描述符有个DPL,表示当前段的特权级,而选择子的RPL指的是我们访问者的特权级,就是CPL所指代的特权级。
其中RPL和CPL不同的地方在于RPL是为了表示访问者请求的特权而存在的,而CPL则是一个动态的概念,他作为一种标识我们当前指令特权的存在,即使我们不访问资源他也是存在的。
访问资源的两种情况
- 受访者为数据段,此时我们只能使用高特权级或平级访问
- 受访者为代码段,此时只能平级访问
数据是要保护的,访问就需要高特权级
代码段处于高特权级,我想要访问低特权级的代码,因为低特权级的代码能访问的我高特权级代码肯定也能访问,所以我这里不需要专门降级。
代码段处于低特权级若我们想访问高特权级代码,此时又会存在一系列风险,因为我们代码处于高特权级的时候程序想干啥就干啥。所以我们这里也避免了低特权代码访问高特权代码,因此这里受访者为代码段的时候,只能平级访问。
一致性代码,低特权级下的指令是真的需要使用高特权级指令
而一致性代码还有个名字叫做依从代码段,他是指如果自己是转以后的目标段,自己的特权级一定要大于等于转以前的特权级,且在转以后的当前特权级(CPL)并不会改变,还是之前那个低特权级,这样我们就实现了在低特权下运行高特权的代码。
但是我们总不可能一直这样运行,因为有的代码他不会标识为一致性代码,所以我们就需要某种机制使得我们向高特权级转化,接下来我们就来讲述此法。
(3)门,调用门与RPL序
门结构是使得处理器从低特权及转移到高特权级的唯一途径
门结构是记录一段程序起始地址的描述符,用来描述一段程序,只要进入门,处理器就能转移到更高的特权级上
门描述符和段描述符类似,都是八字节大小的数据结构,下面给出几种不同的门描述符结构:
任务门同其他的门有些许差别,其他三门是对应有一段函数,所以这三门函数中需要有选择子和偏移,这样才能找到对应段的某段函数了。
而任务门描述符可以直接存放在GDT、LDT、IDT(中断描述表,以后的内容)中,调用门可以位于GDT、LDT中,中断门和陷阱门仅位于IDT中
其中任务门、调用门都可以用call,jmp指令直接诶用,原因是其都直接位于描述符表中,而陷阱门和中断门之存在与IDT中,只能由中断信号触发。
任务门比较特殊,它使用TSS的描述符选择子来描述一个任务,除他之外,其他门都通过选择子和偏移来指定一段程序,虽然说他们的作用都是实现从低特权级向高特权级转移,但是他们的适用范围是不同的,下面分别来解释:
- 调用门
call
和jmp
指令后接调用门选择子为参数实现系统调用,call
指令使用调用门可以实现向高特权级代码转移,jmp
使用调用门只能实现平级代码转移 - 中断门
以int
指令主动发中断的形式实现低到高,linux系统调用就是用其实现的 - 陷阱门
以int3
主动发中断的形式实现低到高,一般是编译器调试时使用 - 任务门
以TSS为单位用来实现任务切换,可以借助中断或指令发起,当中断发生时若对应的中断向量号是任务门,则会发生任务切换,当然也可以像调用门那样通过call
和jmp
发起
门的特权级是一定要低于我们访问者的特权级的,这样才能保证我们能过调用门,而受访者的特权级一定得高于访问者
当我们进门之后,处理器将以目标代码段DPL为当前特权级CPL,因此进门之后我们就顺利提高了特权级了。
这里我们来介绍一下调用门的内部执行流程,先上个图
首先通过call调用门选择子,该选择子是指向GDT或者LDT中的某个门描述符
假设是GDT,我们找到了门描述符时,再次通过门描述符中的选择子对GDT再次进行寻找,找到一个段描述符,再通过门描述符中的偏移来找到对应内核例程的地址(相当于我们去表里找个地址,在通过这个地址找到表内另一个地址)
(4)调用门的过程保护
用户进程通过call指令调用"调用门"的完整过程
-
首先假设我们要调用某个调用门需要两个参数,也就是说该门描述符的参数值为2,此时我们处于特权级3栈,要到特权级0,所以栈也会替换到特权级0栈,但在调用门前还需要两个参数,现在将两个参数压入特权级3栈中
-
然后确定新栈,根据门描述符中所寻找的选择子来确定目的代码段的DPL值,作为后面的CPL值存在,同时通过TSS确定相对应DPL的栈地址,也就是栈段选择子SS和栈指针ESP,记做SS_NEW和ESP_NEW
-
如果转移后代码段特权级上升,需要切换到新栈,此时旧段选择子我们记为SS_OLD 和 ESP_OLD,我们需要将这两值保存到新栈中,方便
retf
指令进行返回后恢复旧栈,将SS_OLD和ESP_OLD放到某个地方进行保存,例如其他的一些寄存器,然后将SS_NEW 和 ESP_NEW载入到SS和ESP寄存器后,咱们再将他俩压入新栈就行了,如下图:
-
然后我们再将用户栈中保存的参数压入新栈
-
调用门描述符中记录的是某个段选择子和偏移,此时的CS寄存器需要用这个选择子重新加载,所以要像上次一样先将旧的CS和EIP保存到栈上,然后重新加载两个寄存器,如下:
-
之后就是按照CS:EIP指示来运行内核例程从而实现特权级从3到0啦
当我们在高特权级游玩一段时间后,我们总归是要回到我们那一亩三分地的,这里就涉及到高特权级到低特权级,这里有且仅有一种方法,那就是retf
指令,下面是执行过程
- 首先进行检查,检查之前栈中保存的旧CS选择子,判断其中的RPL,来决定是否需要进行权限变换
- 然后弹出CS_OLD 和EIP_OLD,目前为止ESP就会指向最后压的那个参数
- 此时我们需要跳过参数,所以得将ESP_NEW的值加上一定偏移,使得他刚好指向ESP_OLD
- 若第一步中确定需要进行权限变换,此时再次pop两次,这样就恢复了之前的SS和ESP了
在返回时如果需要进行权限变换,将检查数据段寄存器 DS,ES,FS,GS中的内容, 如果在它们之中,某个寄存器中选择子所指向的数据段描述符的DPL权限比返回后的CPL(CS.RPL)高,就是数值上返回后的CPL > 数据段描述符的DPL,处理器将把数值0填充到相应的段寄存器
我们在进入内核态时也要访问内核态的数据,这些段寄存器的选择子也会修改成对应内核态的特权级,但在retf
指令中并没有管这些寄存器,(只管理了升级但没有降级)
问题是我们返回了,处理器特权级降下来了,但段寄存器我们只返回了SS和CS,其他在内核中的段寄存器并没有作出改变,这就导致我们在用户态依然可以访问内核态的数据
将这些段寄存器都一股脑像之前CS,SS一样都保存在栈上,等retf时候再返回,或者说类似于linux一样不使用调用门,而使用中断门来进行系统调用。
而上面填充0也是一种处理器自己提供的办法,我们之前写过GDT,我们在第0个段描述符上填的是全0,若我们将段寄存器里的选择子清0会发生什么,对就是报错,从而引出处理器异常再来初始化这些段寄存器。
(5)RPL
RPL, Request Privilege Level ,请求特权级,
RPL是代表真正资源需求者的CPL,以后在请求某特权级为DPL级别的资源时,参与特权级检查的不只是CPL,还要加上RPL,CPL、RPL的特权必须同时 >= 受访者的特权DPL,即数值上:DPL>=RPL,DPL>=CPL
RPL 位于选择子中的,所以,要看当前运行的程序在访问数据或代码时用的是谁提供的选择子
如果用的是自己提供的选择子,那肯定 CPL RPL 都出自同 个程序
如果选择子是别人提供的,那就有可能 RPL和CPL 出自两段程序。
(6)IO特权级
在保护模式中,“阶级”不仅体现在数据和代码的访问之间,也体现在指令之间
一方面将指令分级是因为部分指令会对计算机产生巨大的影响所以得小心使用,其中就比如lgdt等
另一方面体现在IO读写控制上,IO读写特权是由标志寄存器eflags中的IOPL位和TSS中的IO位图决定的,他们用来指定执行IO操作的最小特权级。
这里我们来看看eflags寄存器的结构,从中我们可以看到IOPL位:
IOPL
,I/O Privilege Level
,即IO特权级,除了限制当前任务进行IO敏感指令的最低特权级外,还用来决定任务是否允许操作所有的IO端口。每个任务都有自己的eflags寄存器,所以每个任务都有自己的IOPL,他表示当前任务要想执行全部IO指令的最低特权级。
IOPL设置:
这里只有通过pushf指令将eflags整体入栈然后修改栈中的数据再弹出。另一个可利用栈的指令是iretd,用iretd从中断返回时,会将栈中相应位置的数据当作eflags的内容弹到eflags寄存器中,这就有点类似与PWN的技巧了。所以可以设置IOPL的指令有popf 和 iretd。
上面说了IOPL打开就能访问所有端口,但如果其关上的话,也就是说CPL的特权级是低于IOPL特权级的,那么我们可以通过IO位图来设置部分端口的访问权限。而这样设计的目的是使得我们在低特权级时依然能访问我们所设计需要的硬件资源,从而免去了我们进行系统调用提升权限保护上下文的消耗,说白了也就是提速而已。
(7)IO位图
即bit map,建立的是某种关系,这里感觉就类似表示磁盘空间的位图一样,也就是1个bit代表着一个端口,总共有65536个端口,所以我们共需要65536/8=8192个字节来表示IO位图。若某位bit为0则表示可以访问,若为1则表示禁止访问
IO位图位于TSS中,这里TSS不包括位图的时候就只有104字节大小。至于IO位图的一些其他设置在这里我们并不需要,所以就不过多详述,这里最后给出一张TSS+位图方位的图片作为结束。
(8)区分RPL,DPL,CPL
-
CPL:
当内核任务执行时,CPL为0。
当用户任务执行时,CPL为3。
这表示当前正在执行的代码的特权级别。 -
DPL:
假设有一个代码段描述符,其中DPL被设置为0,这意味着只有特权级别为0的代码可以访问这个段。
另一段数据描述符的DPL被设置为3,这表示特权级别为0、1、2、3的代码都可以访问这个段。
通过DPL,可以为不同的段设置不同的特权级别要求。 -
RPL:
当用户任务希望调用内核任务的某个服务例程时,它需要使用一个称为门的结构,其中包含了段选择子和RPL字段。
如果用户任务希望调用内核任务的代码,它会将RPL字段设置为0,因为内核运行在Ring 0。
当加载这个段选择子时,处理器会检查RPL,如果RPL不等于CPL,会发生特权级别的转换,使得任务可以调用更高特权级别的代码。