第5章-保护模式进阶,向内核迈进
这是一个网站有所有小节的代码实现,同时也包含了Bochs等文件
5.1获取物理内存容量
调用 BIOS 中断 0x15 实现的,分别是 BIOS 中断0x15 的 3 个子功能,子功能号要存放到寄存器 EAX 或 AX 中 :
- EAX=0xE820:遍历主机上全部内存。
- AX=0xE801: 分别检测低 15MB和 16MB~4GB 的内存,最大支持 4GB 。
- AH=0x88:最多检测出 64MB 内存,实际内存超过此容量也按照 64MB 返回。
5.1.2利用BIOS 中断 0x15的 0xe820 获取内存
中断获取的内存信息存储在地址范围描述符中,他的结构如下所示,其大小为20字节,其中type为1表示可以用:
此中断的调用步骤如下。 :
- 填写好“调用前输入”中列出的寄存器。
- 执行中断调用 int 0x15 。
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果。
5.1.3利用BIOS 中断 0x15的 0xe801 获取内存
利用 BIOS 中断 0x15子功能 0xe801 获取内存:低于 15MB的内存以 IKB 为单位大小来记录,单位数量在寄存器 AX 和 CX中记录,其中 AX 和CX的值是一样的,16MB~4GB 是以 64KB为单位大小来记录的,单位数量在寄存器 BX 和 DX 中记录,其中 BX 和 DX 的值是一样的 .
此中断的调用步骤如下:
- 将 AX 寄存器写入 0xE801 。
- 执行中断调用 int 0x15 。
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果。
5.1.4利用BIOS 中断 0x15的 0x88 获取内存
该方法使用最简单,但功能也最简单,简单到只能识别最大 64MB的内存。即使内存容量大于 64MB,也只会显示 63MB ,因为此中断只会显示 1MB 之上的内存,不包括这1MB, 咱们在使用的时候记得加上1MB。
此中断的调用步骤如 :
- 将 AX 寄存器写入 0x88 。
- 执行中断调用 int 0x15。
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果。
5.1.5实战内存
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
;构建gdt及其内部的描述符
GDT_BASE:
dd 0x00000000
dd 0x00000000
CODE_DESC:
dd 0x0000FFFF
dd DESC_CODE_HIGH4
DATA_STACK_DESC:
dd 0x0000FFFF
dd DESC_DATA_HIGH4
VIDEO_DESC:
dd 0x80000007
dd DESC_VIDEO_HIGH4
GDT_SIZE equ $-GDT_BASE
GDT_LIMIT equ GDT_SIZE-1
times 60 dq 0 ;预留60个描述符的空位置
;构建段选择子
SELECTOR_CODE equ (0x0001<<3)+TI_GDT+RPL0
SELECTOR_DATA equ (0x0002<<3)+TI_GDT+RPL0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0
;total_mem_bytes 用于保存内存容量,以字节为单位,此位置比较好记
;当前偏移 loader.bin 文件头 Ox200 字节
;loader. bin 的加载地址是 Ox900
;
;故 total_mem_bytes 内存中的地址是 OxbOO
;将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;以下是 gdt 的指针,前 2 字节是 gdt界限,后 4 字节是 gdt 起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes+gdt_ptr6+ards_buf244+adrs_nr2,共256字节,使得loader_start当前偏移 loader.bin 文件头为0x300字节
ards_buf times 244 db 0 ;创建一个名为 ards_buf 的数组或者缓冲区,它包含了 244 个字节的空间,并且所有的字节都被初始化为 0。
ards_nr dw 0 ;创建一个名为 ards_nr 的字变量,并将其初始化为 0,用于记录ARDS结构体数量
loader_start:
;int 15h eax=0000E820h,edx=534D4150h ('SMAP')获取内存布局
xor ebx,ebx ;第一次调用时,ebx值要为0
mov ebx,0x534D4150 ;edx只赋值一次,循环体中不会改变
mov di,ards_buf ;ards结构缓冲区
.e820_mem_get_loop:
mov eax,0x0000e820 ;执行int 0x15,eax值变为0x534D4150,所以每次执行的是要要更新
mov ecx,20 ;ARDS地址范围描述符结构大小为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结构体,循环次数是ARDS的数量
mov ebx,ards_buf
xor edx,edx ;edx为最大的内存容量这里先清零
.find_max_mem_area: ;无需判断tyep是否为1,最大的内存块一定是可以用的
mov eax,[ebx] ;base_dd_low
add eax,[ebx+8] ;length_low
add ebx,20 ;指向下一个缓存区
cmp edx,eax;冒泡排序,找出最大值,edx寄存器始终存放的是最大值
jge .next_ards ;如果前面的比较结果表明源操作数大于或等于目的操作数,则跳转到标记为 .next_ards 的位置执行相应的指令
mov edx,eax;edx为总内存大小
.next_ards:
loop .find_max_mem_area
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 .e820_failed_so_try_88 ;若当前失效则去使用0x88方法
;先计算出低15MB,寄存器中存放的是内存数量,将其转化为以byte为单位
mov cx,0x400; 0x400为1024,将ax中的内存容量换成以byte为单位的
mul cx ;16位乘法,高16为在的dx中低16在ax中
shl edx,16;左移16位
and eax,0x0000FFFF
or edx,eax
add edx,0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存加入esi寄存器备用
;再计算16MB以上数据
xor eax,eax
mov ax,bx
mov ecx,0x10000 ;0x10000十进制为64KB,即为64*1024
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32在edx,低32在eax
add esi,eax ;由于次方法只能测出4GB的内存没所以32位就够了
;edx肯定为0,只加eax便可
mov edx,esi ;eds为总内大小
jmp .mem_get_ok
;--------------------------int 15 ah=0x88获取内存,只能获取64MB内存-----------------------
.e820_failed_so_try_88:
;int 15后,ax存放的是以KB为单位的内存容量
mov ah,0x88
int 0x15
jc .error_hlt
and eax,0x0000FFFF
;16位乘法,被乘数是ax,积为32位,高16在dx中,低16在ax中
mov cx,0x400 ;0x400为1024,将ax中的内存容量换成以byte为单位的
mul cx
shl edx,16 ;把dx移到高16位
or edx,eax ;把积的低16为组合到edx,为32位的积
add edx,0x100000 ;此中断只会显示 1MB 之上的内存,不包括这1MB, 咱们在使用的时候记得加上1MB
.mem_get_ok:
mov [total_mem_bytes],edx
;----------------- 准备进入保护模式 -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1
;----------------- 打开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 ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt
[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 $
5.2启用内存分页机制,畅游虚拟空间
5.2.1内存为什么要分页
内存分页(memory paging)是一种内存管理技术,主要目的是优化内存使用,提高系统性能和增强内存保护。以下是内存分页的几个关键原因:
1. 内存保护
分页可以提供内存保护,防止一个进程访问另一个进程的内存。这是通过虚拟内存实现的,每个进程都有自己独立的虚拟地址空间,映射到物理内存。
2. 内存分配效率
分页使得内存分配和释放变得更加灵活和高效。物理内存被分成固定大小的页(通常是4KB),分配内存时以页为单位,可以减少内存碎片,提高内存利用率。
3. 简化内存管理
分页简化了内存管理,操作系统只需要维护一个页表(page table),跟踪虚拟地址与物理地址之间的映射关系。这样,操作系统可以方便地为进程分配和回收内存。
4. 支持虚拟内存
分页是虚拟内存实现的基础。虚拟内存允许进程使用比物理内存更多的内存,通过将不常用的页面暂时存储到磁盘(交换区),在需要时再加载到物理内存中,从而扩展了内存容量。
5. 地址空间隔离
通过分页,不同进程的虚拟地址空间是隔离的,每个进程认为自己拥有连续的地址空间,而实际上这些地址空间可能映射到不同的物理内存位置。这种隔离提高了系统的稳定性和安全性。
6. 支持共享内存
分页可以实现多个进程共享同一段物理内存。共享内存用于进程间通信,分页机制可以使不同的进程映射到相同的物理内存页,从而实现高效的数据共享。
7. 更灵活的内存分配策略
分页允许更灵活的内存分配策略,比如按需分页(demand paging),即在进程需要访问某个内存页时才将其加载到物理内存中。这种策略可以减少不必要的内存使用,提高系统性能。
5.2.2一级页表
分页机制是建立在分段机制之上的。
CPU 在不打开分页机制的情况下,是按照默认的分段方式进行的,段基址和段内偏移地址经过段部件处理后所输出的线性地址, CPU 就认为是物理地址 。 如果打开了分页机制,段部件输出的线性地址就不再等同于物理地址了,我们称之为虚拟地址,它是逻辑上的,是假的,不应该被送上地址总线。CPU 必须要拿到物理地址才行,此虚拟地址对应的物理地址需要在页表中查找,这项查找工作是由页部件自动完成的。
分页机制原理:
经过段部件处理后,保护模式的寻址空间是 4GB,注意啦,这个寻址空间是指线性地址空间,它在逻
辑上是连续的。分页机制的思想是:通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑
上连续的线性地址其对应的物理地址可以不连续 。
分页机制的作用有两方面:
- 将线性地址转换成物理地址。
- 用大小相等的页代替大小不等的段。
32位地址,其中0-11位表示内存块的大小即4kb,内存块数量则为2的20次方,1M,即1048576个,这也就是一级页表。图 5-11 所示是一级页表模型,由于页大小是4kb,所以页表项中的物理地址都是4K的整数倍,故用十六进制表示的地址,低 3 位都是 0。
虚拟地址的高20位可以用来定位一个物理页,低12位可以用来在该物理页内寻址。
注意:
- 分页机制打开前要将页表地址加载到控制寄存器 cr3 中,这是启用分页机制的先决条件之一。所以,在打开分页机制前加载到寄存器 cr3 中的是页表的物理地址,页表中页表项的地址自然也是物理地址了。
- 虽然内存分页机制的作用是将虚拟地址转换成物理地址,但其转换过程相当于在关闭分页机制下进行,过程中所涉及到的页表及页表项的寻址,它们的地址都被 CPU 当作最终的物理地址(本来也是物理地址)直接送上地址总线,不会被分页机制再次转换(否则会递归转换下去)。
总结地址转换原理:
一个页表项对应一个页,所以,用线性地址的高 20 位作为页表项的索引,每个页表项要占用 4 字节
大小,所以这高 20 位的索引乘以 4 后才是该页表项相对于页表物理地址的字节偏移量。用 cr3 寄存器中
的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线
性地址的低 12 位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。
现在有专门的页部件完成转换,其工作原理:用线性地址的高 20 位在页表中索引页表项,用线性地址的低 12 位与页表项中的物理地址相加,所求的和便是最终线性地址对应的物理地址。
5.2.3二级页表
搞二级页表的原因是:不要一次性地将全部页表项建立好,需要动态创建页表项。
图中最粗的线存放页目录表物理页,稍细一点的线指向的是用来存放页表的物理页,其他最细的线是页表项中分配的物理页。
二级页表地址转换原理是将 32 位虚拟地址拆分成高 10 位、中间 10位、低 12 位三部分,它们的作用是:高 10 位作为页表的索引,用于在页目录表中定位一个页目录项 PDE,页目录项中有页表物理地址,也就是定位到了某个页表。中间 10 位作为物理页的索引,用于在页表内定位到某个页表项 PTE,页表项中有分配的物理页地址,也就是定位到了某个物理页。低 12 位作为页内偏移量用于在已经定位到的物理页内寻址。
转换具体步骤:
- 用虚拟地址的高 10 位乘以 4 ,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的
和,便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。 - 用虚拟地址的中间10位乘以4,作为页表内的偏移地址,加上在第1步中得到的页表物理地址,所得和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。
- 虚拟地址的高 10 位和中间 10 位分别是 PDE 和 PIE 的索引值,所以它们需要乘以4。但低 12 位就不是索引值啦,其表示的范围是,0~0xfff,作为页内偏移最合适,所以虚拟地址的低 12 位加上第2步中得到的物理页地址,所得和便是最终转换得到的物理地址。
乘以4是因为每一个页目录存放的是4字节的页表项物理地址32位为4字节,页表项乘以4也是一样的原理
这种自动转换也是由页部件自动完成的:
按理说 32 位地址应该用 32 位来表示啊,否则不就误差严重了吗。是这样的,因为页目录项和页表项中的都是物理页地址,标准页大小是 4KB,故地址都是 4K 的倍数,也就是地址的低 12位是 0,所以只需要记录物理地址高 20 位就可以啦 。 这样省出来的 12 位(第 0~ 11 位)可以用来添加其他属性,下面对这些属性从低到高逐位介绍 。
P:
存在位。1则存在,0 表示该表不在物理内存中。操作系统的页式虚拟内存管理便是通过 P 位和相应的 pagefault 异常来实现的 。
RW:
意为读写位。若为 1 表示可读可写,若为 0 表示可读不可写。
US:
意为普通用户/超级用户位。若为 1 时,表示处于 User 级,任意级别(0,1,2,3)特权的程序都可以访问该页。若为0,表示处于Supervis级,特权级别为3的程序不允许访问该页。
PWT:
意为页级通写位,也称页级写透位若为 1 表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存。
PCD:
意为页级高速缓存禁止位若为 1 表示该页启用高速缓存,为 0 表示禁止将该页缓存 。 这里咱们将其置为 0。
A:
意为访问位为 1 表示该页被 CPU 访问过啦,所以该位是由 CPU 设置的。
D:
意为脏页位。当 CPU 对一个页面执行写操作时,就会设置对应页表项的D位为1。此项仅针对页表项有效,并不会修改页目录项中的D位。
PAT:
意为页属性表位,能够在页面一节的粒度上设置内存属性
G:
意为全局位。
AVL:
表示可用位。
启用分页机制,我们要按顺序做好三件事:
- 准备好页目录表及页表。
- 将页表地址写入控制寄存器 cr3
- 寄存器 cr0的 PG 位置 1 。
控制寄存器 cr3 用于存储页表物理地址,所以 cr3 寄存器又称为页目录基址寄存器(PDBR)。由于页目录表所在的地址要求在一个自然页内,即页目录的起始地址是 4kb的整数倍,低 12 位地址全是 0。所以,只要在 cr3寄存器的第 31 ~ 12 位中写入物理地址的高 20 位就行了。cr3 寄存器的低 12 位中,除第 3 位的 PWT 位和第 4 位的 PCD 位外,其余位都没用。
5.2.4规划页表之操作系统与用户进程之间的关系
所以我们设计的页表页要满足共享的基本条件,只要操作系统属于用户进程的虚拟地址空间就可以解决这个问题。我们可以把 4GB 虚拟地址空间分成两部分,一部分专门划给操作系统,另一部分就归用户进程使用。我们也学习 Linux 的作法,在用户进程 4GB 虚拟地址空间的高 3GB 以上的部分划分给操作系统, 0~3GB 是用户进程自己的虚拟空间。
5.2.5启用分页机制
代码如下:
;loader.s
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
;构建gdt及其内部的描述符
GDT_BASE:
dd 0x00000000
dd 0x00000000
CODE_DESC:
dd 0x0000FFFF
dd DESC_CODE_HIGH4
DATA_STACK_DESC:
dd 0x0000FFFF
dd DESC_DATA_HIGH4
VIDEO_DESC:
dd 0x80000007
dd DESC_VIDEO_HIGH4
GDT_SIZE equ $-GDT_BASE
GDT_LIMIT equ GDT_SIZE-1
times 60 dq 0 ;预留60个描述符的空位置
;构建段选择子
SELECTOR_CODE equ (0x0001<<3)+TI_GDT+RPL0
SELECTOR_DATA equ (0x0002<<3)+TI_GDT+RPL0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0
;total_mem_bytes 用于保存内存容量,以字节为单位,此位置比较好记
;当前偏移 loader.bin 文件头 Ox200 字节
;loader. bin 的加载地址是 Ox900
;故 total_mem_bytes 内存中的地址是 OxbOO
;将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;以下是 gdt 的指针,前 2 字节是 gdt界限,后 4 字节是 gdt 起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes+gdt_ptr6+ards_buf244+adrs_nr2,共256字节,使得loader_start当前偏移 loader.bin文件头为0x300字节
ards_buf times 244 db 0 ;创建一个名为 ards_buf 的数组或者缓冲区,它包含了 244 个字节的空间,并且所有的字节都被初始化为 0。
ards_nr dw 0 ;创建一个名为 ards_nr 的字变量,并将其初始化为 0,用于记录ARDS结构体数量
loader_start:
;int 15h eax=0000E820h,edx=534D4150h ('SMAP')获取内存布局
xor ebx,ebx ;第一次调用时,ebx值要为0
mov ebx,0x534D4150 ;edx只赋值一次,循环体中不会改变
mov di,ards_buf ;ards结构缓冲区
.e820_mem_get_loop:
mov eax,0x0000e820 ;执行int 0x15,eax值变为0x534D4150,所以每次执行的是要要更新
mov ecx,20 ;ARDS地址范围描述符结构大小为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结构体,循环次数是ARDS的数量
mov ebx,ards_buf
xor edx,edx ;edx为最大的内存容量这里先清零
.find_max_mem_area: ;无需判断tyep是否为1,最大的内存块一定是可以用的
mov eax,[ebx] ;base_dd_low
add eax,[ebx+8] ;length_low
add ebx,20 ;指向下一个缓存区
cmp edx,eax;冒泡排序,找出最大值,edx寄存器始终存放的是最大值
jge .next_ards ;如果前面的比较结果表明源操作数大于或等于目的操作数,则跳转到标记为 .next_ards 的位置执行相应的指令
mov edx,eax;edx为总内存大小
.next_ards:
loop .find_max_mem_area
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 .e820_failed_so_try_88 ;若当前失效则去使用0x88方法
;先计算出低15MB,寄存器中存放的是内存数量,将其转化为以byte为单位
mov cx,0x400; 0x400为1024,将ax中的内存容量换成以byte为单位的
mul cx ;16位乘法,高16为在的dx中低16在ax中
shl edx,16;左移16位
and eax,0x0000FFFF
or edx,eax
add edx,0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存加入esi寄存器备用
;再计算16MB以上数据
xor eax,eax
mov ax,bx
mov ecx,0x10000 ;0x10000十进制为64KB,即为64*1024
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32在edx,低32在eax
add esi,eax ;由于次方法只能测出4GB的内存没所以32位就够了
;edx肯定为0,只加eax便可
mov edx,esi ;eds为总内大小
jmp .mem_get_ok
;--------------------------int 15 ah=0x88获取内存,只能获取64MB内存-----------------------
.e820_failed_so_try_88:
;int 15后,ax存放的是以KB为单位的内存容量
mov ah,0x88
int 0x15
jc .error_hlt
and eax,0x0000FFFF
;16位乘法,被乘数是ax,积为32位,高16在dx中,低16在ax中
mov cx,0x400 ;0x400为1024,将ax中的内存容量换成以byte为单位的
mul cx
shl edx,16 ;把dx移到高16位
or edx,eax ;把积的低16为组合到edx,为32位的积
add edx,0x100000 ;此中断只会显示 1MB 之上的内存,不包括这1MB, 咱们在使用的时候记得加上1MB
.mem_get_ok:
mov [total_mem_bytes],edx
;----------------- 准备进入保护模式 -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1
;----------------- 打开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 ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt
[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 所有的位置
mov ebx, [gdt_ptr+2] ;将 gdt 描述符中视频段描述符中的段基址+OxcOOOOOOO
or dword [ebx+0x18+4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故Ox18,段描述符的高4字节的最高位是段基扯的第31 ~ 24位
add dword [gdt_ptr+2], 0xc0000000 ;将 gdt 的基址加上 OxcOOOOOOO 使其成为内核所在的高地址
add esp, 0xc0000000 ;将栈指针同样映射到内核地址
;把页目录地址赋给 cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax
;打开 crO 的 pg 位(第 31 位)
mov eax, cr0
or eax, 0x80000000
mov cr0,eax
;在开启分页后,用 gdt 新的地址重新加载
lgdt [gdt_ptr]
mov byte [gs:160], 'V' ;视频段段基址已经被更新,用字符 v 表示 virtual addr
jmp $
;-------------创建页目录及页表-------------------
setup_page:
;先把页目录占用大空间逐字节清0
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS+esi],0
inc esi
loop .clear_page_dir
;开创建页目录项PDE
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ;此时eax为第一个页表的位置以及属性
mov ebx, eax ;此处为ebx赋值,是为了.create_pte做准备,ebx为基址
; 下面将页目录项 0 和 OxcOO 都存为第一个页表的地址,每个页表表示 4MB 内存
; 这样 Oxc03ffff 以下的地址和 Ox003fffff 以下的地址都指向相同的页表
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ;页目录项的属性 RW 和 p 位为 1, us 为 1 ,表示用户属性,所有特权级别都可以访问
mov [PAGE_DIR_TABLE_POS+0x0], eax ;第一个目录项
;在页目录表中的第 1 个 目录项写入第一个页表的位量( 0x101000 )及属性(7)
mov [PAGE_DIR_TABLE_POS+0xc00], eax ;一个页表项占用4字节,OxcOO 表示第768个页表占用的目录项, OxcOO以上的目录项用于内核空间
;也就是页表的 OxcOOOOOOO ~ Oxffffffff共计lG属于内核,OxO-Oxbfffffff共计3G属于用户进程
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS+4092],eax ;使最后一个目录项指向页目录表自己的地址
;下面创建页表项PTE
mov ecx, 256 ;1M低端内存/每一页大小4k=256
mov esi, 0
xor edx, edx ;将edx置为0,现在edx指向0地址
mov edx, PG_US_U | PG_RW_W | PG_P ;属性为7
.create_pte:
mov [ebx+esi*4], edx ;此时的 ebx 已经在上茵通过eax赋值为Ox101000 , 也就是第一个页表的地址
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
;boot.inc
..............
KERNEL_START_SECTOR equ 0x9
...........
;----------------------------------页表相关属性-----------------------------
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b
5.2.6用虚拟地址访问页表
获取页目录表物理地址:让虚拟地址的高 20 位为 0xfffff,低 12 位为 0x000,即 0xfffff000,这也是页目录表中第 0 个页目录项自身的物理地址。
- 访问页目录中的页目录项,即获取页表物理地址:要使虚拟地址为0xfffffxxx,其中 xxx 是页目录项的索引乘以4的积.
- 访问页表中的页表项要使虚拟地址高 10 位为0x3ff,目的是获取页目录表物理地址。中间 IO 位为页表的索引,因为是 10 位的索引值,所以这里不用乘以 4。低 12 位为页表内的偏移地址,用来定位页表项,它必须是己经乘以 4 后的值。 公式为
0X3FF<<22+中间10位<<12+低12位
。
5.2.7快表TLB
TLB 中的条目是虚拟地址的高 20 位到物理地址高 20 位的映射结果,实际上就是从虚拟页框到物理页框的映射。有了 TLB,处理器在寻址之前会用虚拟地址的高 20 位作为索引来查找 TLB 中的相关条目,如果命中则返回虚拟地址所映射的物理页框地址,否则会查询内存中的页表,获得页框物理地址后再更新TLB。
TLB需要实时更新,这个任务交给了开发人员,有两种方法:
- 重新加载 CR3,比如将 CR3 寄存器的数据读出来后再写入 CR3 ,这会使整个 TLB 失效。
- 是虚拟地址,其指令格式为
invlpg m
处理器提供了指令invlpg
,它用于在 TLB 中刷新某个虚拟地址对应的条目,处理器是用虚拟地址来检索 TLB 的,因此很自然地也是虚拟地址,其指令格式为invlpg m
。eg:invlpg [0x1234]
5.3加载内核
5.3.1用c语言写内核
首先咱们本身是在写操作系统,而不是用户程序,操作系统不应该再依赖于其他系统的功能,所以不能在咱们的程序(操作系统)中再调用宿主操作系统的系统调用功能。其次同一时刻只能有一个操作系统在运行,咱们即使调用了 0x80 中断,中断描述符表里 0x80 对应的中断处理程序是咱们提供的,再也不存在宿主系统的代码,相当于咱们在调用自己的中断处理程序,而此时我们可能尚未准备好相应的中断处理程序。如果系统调用不能用,就更不能用 C 标准库啦,所以只能用 C 语言原生支持的语法结构。
在Linux在中用于链接的程序是ld,链接有一个好处,可以指定完成最终生成的可执行文件的起始虚拟地址。它是用-Ttext参数来指定的。
5.3.2二进制程序的运行方法
在程序中,程序头(也就是文件头)用来描述程序的布局等信息,它属于信息的信息,也就是元数据。 将这种具有程序头格式的程序文件从外存读入到内存后,从该程序文件的程序头中读出入口地址, 需要直接跳进入口地址执行,跨过程序头才行。
小端(Little Endian)
-
在小端字节序中,最低有效字节(Least Significant Byte,LSB)位于最低地址处,而最高有效字节(Most Significant Byte,MSB)位于最高地址处。
-
举个例子,十六进制数
0x12345678
在小端字节序中的存储方式是:
- 低地址 →
78
- 高地址 →
56
- 高地址+1 →
34
- 高地址+2 →
12
- 低地址 →
-
这个顺序符合我们的阅读习惯,因为我们从右向左读数。
大端(Big Endian)
-
在大端字节序中,最高有效字节(MSB)位于最低地址处,而最低有效字节(LSB)位于最高地址处。
-
举个例子,同样的十六进制数
0x12345678
在大端字节序中的存储方式是:
- 低地址 →
12
- 低地址+1 →
34
- 低地址+2 →
56
- 高地址 →
78
- 低地址 →
-
这种顺序与我们的阅读习惯不同,需要从左向右读数。
示例比较
假设我们有一个 4 字节的整数 0x12345678
:
- 在小端(Little Endian)系统中,存储的顺序是
78 56 34 12
。 - 在大端(Big Endian)系统中,存储的顺序是
12 34 56 78
。
5.3.2elf格式的二进制文件
ELF 指的是 Exeeutable and Linkable Format,可执行链接格式。在 ELF 规范中,把符合 ELF 格式协议的文件统称为“目标文件”或 ELF 文件,这 与我们平时所说的目标文件是不同的。
ELF header 是个用来描述各种“头”的“头飞程序头表和节头表中的元素也是程序头和节头,可见,elf 文件格式的核心思想就是头中嵌头,是种层次化结构的格式。 ELF 文件格式依然分为文件头和文件体两部分,只是该文件头相对稍显复杂,类似层次化结构,先用个 ELF header 从“全局上”给出程序文件的组织结构,概要出程序中其他头表的位置大小等信息,如程序头表的大小及位置、节头表的大小及位置。然后,各个段和节的位置、大小等信息再分别从“具体的”程序头表和节头表中予以说明。
ELF 格式的作用体现在两方面, 一是链接阶段,另一方面是运行阶段,故它们在文件中组织布局咱们也从这两方面展示。
C 语言中的结构体能够很直观地表示物理-内存结构,用结构体的形式展现一个数据结构是最合适不过的啦
e_ident[16
是 16 字节大小的数组,用来表示 elf 字符等信息,开头的 4 个字节是固定不变的,是 elf 文件的魔数,它们分别是0x7f,以及字符串 ELF 的 asc码 :0x45,0x4c,0x46。
e_type
占用 2字节,是用来指定 elf 目标文件的类型
e_machine
占用 2 字节,用来描述 elf 目标文件的体系结构类型
e_version
占用 4 字节,用来表示版本信息。
e_entry
占用 4 字节,用来指明操作系统运行该程序时,将控制权转交到的虚拟地址。
e phoff
占用 4 字节,用来指明程序头表( program header table )在文件内的字节偏移量。如果没有程序头表,该值为 0 。
e_shoff
占用 4 字节,用来指明节头表( section header table )在文件内的字节偏移量。若没有节头表,该值为 0 。
e_flags
占用 4 字节,用来指明与处理器相关的标志,本书用不到那么多的内容,具体取值范围,有兴趣的同学还是要参考/usr/include/elf.h,
e_ehsize
占用 2 字节,用来指明 elf header 的宇节大小。
e_phentsize
占用 2 字节,用来指明程序头表( program header table )中每个条目( entry )的字节大小,即每个用来描述段信息的数据结构的字节大小,该结构是后面要介绍的 struct Elf32_Phdr。
e_phnum
占用 2 字节,用来指明程序头表中条目的数量。实际上就是段的个数。
e_shentsize
占用 2 宇节,用来指明节头表( section header table )中每个条目( en町)的字节大小,即每个用来描述节信息的数据结构的字节大小。
e_shnum
占用 2 字节,用来指明节头表中条目的数量。实际上就是节的个数。
e_shstrndx
占用 2 宇节,用来指明 string name table 在节头表中的索引 index 。
用来描述各个段的信息用的,其结构名为 struct Elf32_Phdr。
p_type 占用 4 字节,用来指明程序中该段的类型。
5.3.4ELF文件实例分析
在 gcc 编译时可以加个参数-v (verbose),这样就会输出冗余的内容,从而展示出编译过程的更多细节。
咱们要想查看转换后的汇编代码,要加个-S 参数,这个参数告诉 gcc:转换成汇编后就停止,不再进行编译链接。
5.3.5将内核载入内存
内核被加载到内存后, loader 还要通过分析其 elf 结构将其展开到新的位置,所以说,内核在内存中有两份拷贝,一份是 elf格式的原文件 kernel.bin,另一份是 loader 解析 elf格式的 kernel.bin 后在内存中生成的内核映像(也就是将程序中的各种段 segment 复制到内存后的程序体),这个映像才是真正运行的内核。
将来内核肯定是越来越大,为了多预留出生长空间,咱们要将加载到地址较高的空间,而内核映像要放置到较低的地址。内核文件经过 loader 解析后就没用啦,这样内核映像将来往高地址处扩展时,也可以覆盖原来的内核文件 kernel.bin。
所以可以在0x7e00~0x9fbff这片区域选择为内核文件 kernel.bin的加载地址,这里选择为0x70000.
内核的入口虚拟地址是 0xc0001500 ,物理内存中 0x900 处是 loader.bin 加载的地址,在 loader.bin 的开始部分是 GDT,它可是必须要保留下来的,可不能覆盖,我们不打算在内核中重新定义它,以后都要指望它了。虽然 loader 的工作结束啦,但 loader 所完成的工作成果咱们还得继续发扬,继续用 。 预计 loader.bin 的大小不会超过 2000 字节。所以咱们可选的起始物理地址是 0x900+2000=0x10d0 (不要把注意力放在这个奇怪的数上,偶然得出的〉。内存很大,但也尽量往低了选,于是凑了个整数,选了 0x1500 作为内核映像的入口地址。 根据咱们的页表,低端1M的虚拟内存与物理内存是一一对应的,所以物理地址是 0x1500,对应的虚拟地址是 0xc0001500
;loader.s
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
;构建gdt及其内部的描述符
GDT_BASE:
dd 0x00000000
dd 0x00000000
CODE_DESC:
dd 0x0000FFFF
dd DESC_CODE_HIGH4
DATA_STACK_DESC:
dd 0x0000FFFF
dd DESC_DATA_HIGH4
VIDEO_DESC:
dd 0x80000007
dd DESC_VIDEO_HIGH4
GDT_SIZE equ $-GDT_BASE
GDT_LIMIT equ GDT_SIZE-1
times 60 dq 0 ;预留60个描述符的空位置
;构建段选择子
SELECTOR_CODE equ (0x0001<<3)+TI_GDT+RPL0
SELECTOR_DATA equ (0x0002<<3)+TI_GDT+RPL0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0
;total_mem_bytes 用于保存内存容量,以字节为单位,此位置比较好记
;当前偏移 loader.bin 文件头 Ox200 字节
;loader. bin 的加载地址是 Ox900
;故 total_mem_bytes 内存中的地址是 OxbOO
;将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;以下是 gdt 的指针,前 2 字节是 gdt界限,后 4 字节是 gdt 起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes+gdt_ptr6+ards_buf244+adrs_nr2,共256字节,使得loader_start当前偏移 loader.bin文件头为0x300字节
ards_buf times 244 db 0 ;创建一个名为 ards_buf 的数组或者缓冲区,它包含了 244 个字节的空间,并且所有的字节都被初始化为 0。
ards_nr dw 0 ;创建一个名为 ards_nr 的字变量,并将其初始化为 0,用于记录ARDS结构体数量
loader_start:
;int 15h eax=0000E820h,edx=534D4150h ('SMAP')获取内存布局
xor ebx,ebx ;第一次调用时,ebx值要为0
mov ebx,0x534D4150 ;edx只赋值一次,循环体中不会改变
mov di,ards_buf ;ards结构缓冲区
.e820_mem_get_loop:
mov eax,0x0000e820 ;执行int 0x15,eax值变为0x534D4150,所以每次执行的是要要更新
mov ecx,20 ;ARDS地址范围描述符结构大小为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结构体,循环次数是ARDS的数量
mov ebx,ards_buf
xor edx,edx ;edx为最大的内存容量这里先清零
.find_max_mem_area: ;无需判断tyep是否为1,最大的内存块一定是可以用的
mov eax,[ebx] ;base_dd_low
add eax,[ebx+8] ;length_low
add ebx,20 ;指向下一个缓存区
cmp edx,eax;冒泡排序,找出最大值,edx寄存器始终存放的是最大值
jge .next_ards ;如果前面的比较结果表明源操作数大于或等于目的操作数,则跳转到标记为 .next_ards 的位置执行相应的指令
mov edx,eax;edx为总内存大小
.next_ards:
loop .find_max_mem_area
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 .e820_failed_so_try_88 ;若当前失效则去使用0x88方法
;先计算出低15MB,寄存器中存放的是内存数量,将其转化为以byte为单位
mov cx,0x400; 0x400为1024,将ax中的内存容量换成以byte为单位的
mul cx ;16位乘法,高16为在的dx中低16在ax中
shl edx,16;左移16位
and eax,0x0000FFFF
or edx,eax
add edx,0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存加入esi寄存器备用
;再计算16MB以上数据
xor eax,eax
mov ax,bx
mov ecx,0x10000 ;0x10000十进制为64KB,即为64*1024
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32在edx,低32在eax
add esi,eax ;由于次方法只能测出4GB的内存没所以32位就够了
;edx肯定为0,只加eax便可
mov edx,esi ;eds为总内大小
jmp .mem_get_ok
;--------------------------int 15 ah=0x88获取内存,只能获取64MB内存-----------------------
.e820_failed_so_try_88:
;int 15后,ax存放的是以KB为单位的内存容量
mov ah,0x88
int 0x15
jc .error_hlt
and eax,0x0000FFFF
;16位乘法,被乘数是ax,积为32位,高16在dx中,低16在ax中
mov cx,0x400 ;0x400为1024,将ax中的内存容量换成以byte为单位的
mul cx
shl edx,16 ;把dx移到高16位
or edx,eax ;把积的低16为组合到edx,为32位的积
add edx,0x100000 ;此中断只会显示 1MB 之上的内存,不包括这1MB, 咱们在使用的时候记得加上1MB
.mem_get_ok:
mov [total_mem_bytes],edx
;----------------- 准备进入保护模式 -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1
;----------------- 打开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 ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt
[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'
;-----------------------------------------------------加载内核------------------------------------------
mov eax, KERNEL_START_SECTOR ;kernel.bin所在区域
mov ebx, KERNEL_BIN_BASE_ADDR ;从磁盘中读初后,写入到ebx指定的地址
mov ecx, 200 ;读入扇区的数量
call rd_disk_m_32
;创建页 目录及页表并初始化页内存位图
call setup_page
;要将描述符表地址及偏移量写入内存 gdt_ptr ,一会儿用新地址重新加载
sgdt [gdt_ptr] ; 存储到原来 gdt 所有的位置
mov ebx, [gdt_ptr+2] ;将 gdt 描述符中视频段描述符中的段基址+OxcOOOOOOO
or dword [ebx+0x18+4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故Ox18,段描述符的高4字节的最高位是段基扯的第31 ~ 24位
add dword [gdt_ptr+2], 0xc0000000 ;将 gdt 的基址加上 OxcOOOOOOO 使其成为内核所在的高地址
add esp, 0xc0000000 ;将栈指针同样映射到内核地址
;把页目录地址赋给 cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax
;打开 crO 的 pg 位(第 31 位)
mov eax, cr0
or eax, 0x80000000
mov cr0,eax
;在开启分页后,用 gdt 新的地址重新加载
lgdt [gdt_ptr]
mov byte [gs:160], 'V' ;视频段段基址已经被更新,用字符 v 表示 virtual addr
jmp SELECTOR_CODE:enter_kernel
enter_kernel:
mov byte [gs:320], 'k' ;视频段段基址已经被更新
mov byte [gs:322], 'e' ;视频段段基址已经被更新
mov byte [gs:324], 'r' ;视频段段基址已经被更新
mov byte [gs:326], 'n' ;视频段段基址已经被更新
mov byte [gs:328], 'e' ;视频段段基址已经被更新
mov byte [gs:330], 'l' ;视频段段基址已经被更新
mov byte [gs:480], 'w' ;视频段段基址已经被更新
mov byte [gs:482], 'h' ;视频段段基址已经被更新
mov byte [gs:484], 'i' ;视频段段基址已经被更新
mov byte [gs:486], 'l' ;视频段段基址已经被更新
mov byte [gs:488], 'e' ;视频段段基址已经被更新
mov byte [gs:490], '(' ;视频段段基址已经被更新
mov byte [gs:492], '1' ;视频段段基址已经被更新
mov byte [gs:494], ')' ;视频段段基址已经被更新
mov byte [gs:496], ';' ;视频段段基址已经被更新
call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT
;------------------------------------------------
;功能:读取硬盘的n个扇区
rd_disk_m_32:
;--------------------------------------------------
;eax=LBA扇区号
;ebx=将数据写入的内存地质
;ecx=读入的扇区数
mov esi,eax ;备份eax
mov di,cx ;备份cx
;读写硬盘
;第一布:设置要读取的扇区数
mov dx,0x1f2
mov al,cl
out dx,al ;读取的扇区数
mov eax,esi ;恢复ax
;第二步:将LBA地址存入0x1f3~0x1f6
;LBA地址7-0位写入端口0x1f3
mov dx,0x1f3
out dx,al
;LBA地址15-8位写入端口0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al
;LBA地址23-16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al
shr eax,cl
and al,0x0f ;lba第24-27位
or al,0xe0 ;设置7-4位为1110,表示lba模式
mov dx,0x1f6
out dx,al
;第3步:向0x1f7端口哦写入读命令,0x20
mov dx,0x1f7
mov al,0x20
out dx,al
;第4步:检测硬盘的状态
.not_ready:
;同一端口,写时表示写入命令字,读时表示读入硬盘状态
nop
in al,dx
and al,0x88 ;第 3 位为 1 表示硬盘控制器已准备好数据传输
;第 7 位为 1 表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备号,继续等待
;第 5 步:从 OxlfO 端口读数据
mov ax,di ;di里是扇区数
mov dx,256
mul dx
mov cx,ax
;di 为要读取的扇区数,一个扇区有 512 字节,每次读入一个字,共需 di*512/2 次,所以 di*256
mov dx,0x1f0
.go_on_read:
in ax,dx ;16位操作数前面有66
mov [ebx],ax
add ebx,2
; 由于在实模式下偏移地址为16位,所以用bx只会访问到0~FFFFh的偏移。
; loader的栈指针为0x900,bx为指向的数据输出缓冲区,且为16位,
; 超过0xffff后,bx部分会从0开始,所以当要读取的扇区数过大,待写入的地址超过bx的范围时,
; 从硬盘上读出的数据会把0x0000~0xffff的覆盖,
; 造成栈被破坏,所以ret返回时,返回地址被破坏了,已经不是之前正确的地址,
; 故程序出会错,不知道会跑到哪里去。
; 所以改为ebx代替bx指向缓冲区,这样生成的机器码前面会有0x66和0x67来反转。
; 0X66用于反转默认的操作数大小! 0X67用于反转默认的寻址方式.
; cpu处于16位模式时,会理所当然的认为操作数和寻址都是16位,处于32位模式时,
; 也会认为要执行的指令是32位.
; 当我们在其中任意模式下用了另外模式的寻址方式或操作数大小(姑且认为16位模式用16位字节操作数,
; 32位模式下用32字节的操作数)时,编译器会在指令前帮我们加上0x66或0x67,
; 临时改变当前cpu模式到另外的模式下.
; 假设当前运行在16位模式,遇到0X66时,操作数大小变为32位.
; 假设当前运行在32位模式,遇到0X66时,操作数大小变为16位.
; 假设当前运行在16位模式,遇到0X67时,寻址方式变为32位寻址
; 假设当前运行在32位模式,遇到0X67时,寻址方式变为16位寻址.
loop .go_on_read
ret
;-------------创建页目录及页表-------------------
setup_page:
;先把页目录占用大空间逐字节清0
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS+esi],0
inc esi
loop .clear_page_dir
;开创建页目录项PDE
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ;此时eax为第一个页表的位置以及属性
mov ebx, eax ;此处为ebx赋值,是为了.create_pte做准备,ebx为基址
; 下面将页目录项 0 和 OxcOO 都存为第一个页表的地址,每个页表表示 4MB 内存
; 这样 Oxc03ffff 以下的地址和 Ox003fffff 以下的地址都指向相同的页表
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ;页目录项的属性 RW 和 p 位为 1, us 为 1 ,表示用户属性,所有特权级别都可以访问
mov [PAGE_DIR_TABLE_POS+0x0], eax ;第一个目录项
;在页目录表中的第 1 个 目录项写入第一个页表的位量( 0x101000 )及属性(7)
mov [PAGE_DIR_TABLE_POS+0xc00], eax ;一个页表项占用4字节,OxcOO 表示第768个页表占用的目录项, OxcOO以上的目录项用于内核空间
;也就是页表的 OxcOOOOOOO ~ Oxffffffff共计lG属于内核,OxO-Oxbfffffff共计3G属于用户进程
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS+4092],eax ;使最后一个目录项指向页目录表自己的地址
;下面创建页表项PTE
mov ecx, 256 ;1M低端内存/每一页大小4k=256
mov esi, 0
xor edx, edx ;将edx置为0,现在edx指向0地址
mov edx, PG_US_U | PG_RW_W | PG_P ;属性为7
.create_pte:
mov [ebx+esi*4], edx ;此时的 ebx 已经在上茵通过eax赋值为Ox101000 , 也就是第一个页表的地址
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
kernel_init:
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,表示program header大小
mov ebx, [KERNEL_BIN_BASE_ADDR+28] ;偏移文件28字节处是e_poff表示第一个program header在文件中的偏移量
add ebx, KERNEL_BIN_BASE_ADDR ;这就是第一个program header的地址
mov cx, [KERNEL_BIN_BASE_ADDR+44] ;偏移文件44字节处是e_phnum表示第一个program header在文件中的数量,就是段个数
.each_segment:
cmp byte [ebx+0], PT_NULL ;若p_type等于PT_NULL,说明program header未使用
je .PTNULL
;为函数memcpy压参数,参数是从右往左依次压入,函数原型类似于 memcpy(dst,src,size)
push dword [ebx+16] ;program header中偏移16字节的地方是p_filesz,压入函数memcoy的第三个参数size
mov eax, [ebx+4] ;距程序头偏移量为4字节的位置是p_offest
add eax, KERNEL_BIN_BASE_ADDR ;加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax ;压入函数memcpy的第二个参数:源地址
push dword [ebx+8] ;压入函数memcoy的第三个参数:目的地址,偏移程序头 8 字节的位置是 p_vaddr ,这就是目的地址
call mem_cpy ;调用mem_cpy完成段复制
add esp, 12 ;清理栈中压入的三个参数
.PTNULL:
add ebx, edx ;edx为program header大小,即e_phentsize,在此ebx指向下一个program header
loop .each_segment
ret
;--------------------逐字的拷贝 mem_cpy(dst,src,size)-----------------------------
;输入:栈中的三个参数(dst,src,size)
;输出:无
;----------------------------------------------------------------------------------
mem_cpy:
cld ;将方向标志位(Direction Flag)清零,确保字符串操作指令的方向是从源到目的地
push ebp ;保存当前函数的基址指针(Base Pointer,BP),为了后面恢复现场
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 ;逐字拷贝 ,重复执行movsb指令,将ECX寄存器指定数量的字节从DS:ESI(源地址)拷贝到ES:EDI(目的地址)。每执行一次movsb,ESI和EDI分别会根据方向标志的设置自动增减
;恢复环境
pop ecx
pop ebp
ret
5.4特权级深入浅出
5.4.1特权级那点事
5.4.2TSS简介
特权级转移分为两类, 一类是由中断门、调用门等手段实现低特权级转向高特权级,另一类则相反,是由调用返回指令从高特权级返回到低特权级,这是唯一种能让处理器降低特权级的情况。
5.4.3CPL和DPL入门
5.4.4门、调用门与RPL序
处理器只有通过“门结构”才能由低特权级转移到高特权级,处理器就是这样设计的,我们必须要遵循它的用法,对处理器来说,操作系统只是它的应用而己。
门结构就是记录一段程序起始地址的描述符。门描述符同短描述符类似,都是8字节大小的数据结构,用来描述门中通向的代码。
任务门描述符可以放在 GDT、 LDT 和 IDT (中断描述符表,后面章节在介绍中断时大伙儿就清楚了)中,调用门可以位于 GDT 、 LDT 中,中断门和陷阱门仅位于 IDT 中。
任务门、调用门都可以用 call 和 jmp 指令直接调用,原因是这两个门描述符都位于描述符表中 ;陷阱门和中断门只存在于 IDT 中,因此不能主动调用,只能由中断信号来触发调用。 调用门的使用形式是:“call 或 jmp 指令+调用门描述符选择子”
1.调用门
call 和 jmp 指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用 。 call 指令使用调用门可以实现向高特权代码转移, jmp 指令使用调用门只能实现向平级代码转移;
2.中断门
以 int 指令主动发中断的形式实现从低特权向高特权转移,Linux 系统调用便用此中断门实现;
3.陷阱门
以 int3 指令主动发中断的形式实现从低特权向高特权转移,这一般是编译器在调试时用;
4.任务门
任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务门的选择子或任务 TSS 的选择子。
在使用门结构之前,处理器要例行公事做特权级检查,参与检查的不只是 CPL 和 DPL,还有 RPL。PRL,即请求特权级。
我们本节始终在说特权级转移,处理器从一个特权级转移到另一个特权级,任意时刻处理器所处的特权级称为当前特权级。当前特权级是对处理器而言 的概念,并不是对代码段而言。
5.4.5调用门的过程保护
调用门只能向平级或更高级转移。
5.4.6RPL的前世今生
在汇编中就是 arpl 指令,此指令用来修改选择子中的 RPL ,其用法是arpl 通用寄存器/16 位内存, 16 位通用寄存器
5.4.7IO特权级
在保护模式下,处理器中的“阶级”不仅体现在数据和代码的访问,还体现在指令中。一方面将指令分级的原因是有些指令的执行对计算机有着严重的影响,它们只有在0特权级下被执行,因此被称为特权指令。另一方面提醒在I/O读写控制上。IO 读写特权是由标志寄存器 eflags 中的 IOPL 位和 TSS 中的 IO 位图决定的,它们用来指定执行 IO 操作的最小特权级。IO 相关的指令只有在当前特权级大于等于 IOPL 时才能执行,所以它们称为 IO 敏感指令。
eflags寄存器:
每个任务(内核进程或用户进程)都有自己的 eflags 寄存器,所以每个任务都有自己的 IOPL. 它表示当前任务要想执行全部 IO 指令的最低特权级,也就是处理器最低的 CPL,只有任务的当前特权级大于等于 IOPL才允许执行全部 IO 指令,即数值上 CPL运IOPL 。
可以通过将战中数据弹出到 eflags 寄存器中来实现修改,可以先用 pushf 指令将 eflags 整体压入拢,然后在栈中修改相应位再用 popf 指令弹出到 eflags 寄存器中。另外一个可利用榜的指令是 iretd,用 iretd 指令从中断返回时,会将枝中相应位置的数据当成 eflags 的内容弹出到 eflags 寄存器中。
I/O 位图中如果相应 bit 被置为 0,表示相应端口可以访问,否则为1,表示该端口禁止访问。I/O 位图只是在数值上CPL>IOPL,即当前特权级比IOPL低时才有效,若当前特权级大于等于IOPL,任何端口都可以直接访问不受限制。
I/O 位图是位于 TSS 中的,它可以存在,也可以不存在,它只是用来设置对某些特定端口的访问,没有它的话便默认为禁止访问所有端口。
TSS结构: