第五章 保护模式进阶,向内核迈进
本文是对《操作系统真象还原》第五章学习的笔记,欢迎大家一起交流。
a 获取物理内存
知识部分
为了在后期做好内存管理工作,咱们先得知道自己有多少物理内存才行。所以现在的工作是为了获取物理内存,一共介绍三种方法,都是利用的 BIOS 0x15 中断,三种方式从优先级来说第一种 > 第二种 > 第三种,所以我们先利用第一种方式,失败时再去用第二种,以此类推。
利用 BIOS 中断 0x15 子功能 0xe820 获取内存
BIOS 中断 0x15 的子功能 0xE820 能够获取系统的内存布局,由于系统内存各部分的类型属性不同,BIOS 就按照类型属性来划分这片系统内存,所以这种查询呈迭代式,每次 BIOS 只返回一种类型的内存信息,直到将所有内存类型返回完毕。子功能 0xE820 的强大之处是返回的内存信息较丰富,包括多个属性字段,所以需要一种格式结构来组织这些数据。内存信息的内容是用地址范围描述符来描述的,用于存储这种描述符的结构称之为地址范围描述符(Address Range Descriptor Structure,ARDS),下面是 ARDS 的格式:
由于我们是在 32 位下编程,所以只需要用到 BaseAddrLow、LengthLow 和 Type 字段,其他的不需要关注,从上表中,我们也可以看出一个 ARDS 长 20 字节,下面是 Type 字段解释:
BIOS 中断 0x15 子功能 0xe820 的具体使用方法如下:
此中断的调用步骤如下。
- 填写好“调用前输入”中列出的寄存器。
- 执行中断调用 int 0x15。
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果。
利用 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 中记录,其中 BX 和 DX 的值是一样的,所以 16MB 以上空间的内存实际大小=BX64*1024
利用 BIOS 中断 0x15 子功能 0x88 获取内存
最后一个获取内存的方法也同样是 BIOS 0x15 中断,子功能号是 0x88。该方法使用最简单,但功能也最简单,简单到只能识别最大 64MB 的内存。即使内存容量大于 64MB,也只会显示 63MB,大家可以自己在 bochs 中试验下。为什么只显示到 63MB 呢?因为此中断只会显示 1MB 之上的内存,不包括这 1MB,咱们在使用的时候记得加上 1MB。
代码部分
代码如下:
%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 ; limit=(0xbffff-0xb8000+1)/4k-1=0x7
dd DESC_VIDEO_HIGH4 ; 此时dpl为0
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留60个描述符的空位(slot)
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_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes 4字节+gdt_ptr6 字节+ards_buf 244字节+ards_nr 2,共256字节
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录ards结构体数量
loader_start:
;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------
xor ebx, ebx ;第一次调用时,ebx值要为0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;ards结构缓冲区
.e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
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为最大的内存容量,在此先清0
.find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ;base_add_low
add eax, [ebx+8] ;length_low
add ebx, 20 ;指向缓冲区中下一个ARDS结构
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .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寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法
;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400 ;cx和ax值一样,cx用做乘数
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份
;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax,eax
mov ax,bx
mov ecx, 0x10000 ;0x10000十进制为64KB
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
mov edx,esi ;edx为总内存大小
jmp .mem_get_ok
;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
;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 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB
.mem_get_ok:
mov [total_mem_bytes], edx ;将内存换为byte单位后存入total_mem_bytes处。
;----------------- 准备进入保护模式 -------------------
;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 $
在第 28 行,我们定义了一个四字节的 total_mem_bytes,用于指示最大的内存容量,另外我们可以计算一下该值的地址=LOADER_BASE_ADDR+(4+60)*8=0xb00
,这个地址我们在后面还会用到。
35-38 行,我们定义接受 ARDS 的缓冲区,按理说缓冲区的大小等于 ARDS 结构的大小就行了,但为了编程省事,处理的思路是将所有 ARDS 都得到后再统一遍历,所以我们就申请个大一些的缓冲区。已知一个 ARDS 结构是 20 字节,具体多大取决于到底会有多少个 ARDS 结构。所以先估计个数吧,不够了再加(在本机实际测试中共返回了 6 个 ARDS 结构)。在这里先为其分配 244 字节,哈哈,有点奇怪是不,为什么有零有整的?这是为了手工对齐下面 39 行的标签 loader_start,使其在文件内的偏移地址为 0x300。我们可以看到,从 total_mem_bytes 开始算,0x200+total_mem_bytes 4字节+gdt_ptr 6字节+ards_buf 244字节+ards_nr 2字节=0x300
仅此,没其他特殊含义。
另外,我们原来是通过 mbr.s 中 jmp+loader.s 里的 jmp 才到达 loader_start,现在我们直接在 mbr.s 中 jmp jmp LOADER_BASE_ADDR + 0x300
到 loader_start,这是因为 jmp 指令占三个字节,不太方便对齐。
41-113 行就是利用三种方法计算了,注释很详细
后面的代码和之前一样,不再多说,下面看效果,利用 xp 0xb00
查看内存,结果是 0x0200,0000
,即 32MB
b 启用内存分页
知识部分
概述
在分段模式下,我们的 cpu 会受到各种各样的限制,出现各种内存不足的问题
- 在目前只分段的机制下,CPU 认为线性地址等于物理地址。
- 线性地址是由编译器编译出来的,它本身是连续的,所以物理地址也必须要连续才行,但我们可用的物理地址可能不连续。
于是乎,提出了分页机制,分页机制的思想是:通过映射,使连续的线性地址与任意物理内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续。
分页机制的作用有两方面:
- 将线性地址转换成物理地址
- 用大小相等的页代替大小不等的段。
由于有了线性地址到真实物理地址的这层映射,经过段部件输出的线性地址便有了另外一个名字,虚拟地址,因此,分页机制是建立在分段机制上的。
如何分页
- 页是地址空间的计量单位,并不是专属物理地址或线性地址,只要是 4KB 的地址空间都可以称为页,所以线性地址的一页也要对应物理地址的一页。
- 一页大小为 4KB,所以 4GB 地址空间被划分成 4GB/4KB=1M 个页,也就是 4GB 空间中可以容纳 1048576 个页,页表中自然也要有 1048576 个页表项。这就是我们要说的一级页表。
地址转换
cr3 寄存器中存放着的页表的物理地址,线性地址的高 20 位作为页表项的索引,加上 cr3 中的物理地址,得到对应页表项的物理地址,再从该物理地址中得到线性地址对应的物理地址的高 20 位,再加上线性地址的低 12 位,得到最终结果。
二级页表
二级页表的作用不要一次性地将全部页表项建好,需要时动态创建页表项,他包括一个页目录表和 1024 个页表,即页目录表中每一个页表项都对应一个页表。此时的 cr3 保存的就是页目录表的地址。
-
高 10 位是页目录的索引
-
中间 10 位是页表的索引
-
低 12 位则作为页内偏移来用
一个图总结一下:
代码部分
分页机制启动步骤
- 准备页目录表和页表
- 将页表地址写入控制寄存器 cr3
- 寄存器 cr0 的 PG 位置为 1
其中 cr3 格式如下:
cr0 格式如下,我们之前用过它的 PE 位来打开保护模式:
页表项结构
无论是页表项还是页目录表项,他们的结构都是一样的:
-
P,表示存在位,1 表示该页位于物理内存中
-
RW:读写位,1 表示可读可写,0 表示可读不可写
-
US:表示普通用户/系统用户
- 1,处于 user 级别,任意级别(1,2,3,4)特权的程序都可以访问该页
- 0,处于 supervisor 级别,只允许特权级位(1,2,3)的程序可以访问
-
PWT:页级通写位,1 表示此项采用通写方式表示该页不仅是普通内存,还是高速缓存。此项和高速缓存有关,“通写”是高速缓存的一种工作方式本位用来间接决定是否用此方式改善该页的访问效率。
-
PCD:页级高速缓存禁止位,1 表示该页启用高速缓存
-
A:访问位,1 表示该页已经被 CPU 访问过了
-
D:脏页位,当 CPU 对一个页面进行写操作时,就会将对应页表项 D 位置为 1
-
PAT:页属性表位
-
G,全局位,1 表示该页为全局页,也就是该页将在 TLB 高速缓存中一直存在
-
AVL:可用位,操作系统是否可用
内存规划
先了解一下内存规划,以便于后面更好的理解代码。
我们仿照 linux 系统,3-4GB 放操作系统,0-3GB 放用户进程。如下图:
我们页目录表的位置:0x100000 ( 1MB ),然后让页表紧挨着它,第一张页表的位置即 0x100000 + 4KB = 0x101000
,此时布局如下图所示:
以下是内核需要的页目录表在物理内存中的存放位置,以及虚拟空间 3GB ~ 4GB 需要的页表在物理内存中的位置(下图的内核低 1MB 即内核 loader):
此外我们主要到内核 loader 在低 1MB,loader 在分段和分页下都要正常运行,即跨越分段和分页,所以除了 3-4GB 的低 1MB 要映射到物理内存的低 1MB,页目录表的 0 号页表也应该指向页表第一个页表,使得低 1MB 能够跨越分段和分页。
另外页目录表最后一项 1023 号项指向自己,为的是将来动态操作页表做准备(后面章节会详细讲到)。
总体设计如下图所示,图是参考的其他博主:
(下图的二级页表应该是页目录表,一级页表即页表)
代码实现
创建页表和页目录表
代码设计
我们先看创建页表和页目录表的函数
- 先在 boot.inc 中定义页目录表的起始位置(放在内存 1MB 开始的位置),与定义模块化的页目录项与页表项字段;在上一版所写的 loader.S 中加入我们的创建页表函数。
- 在页目录表的位置初始化 0。
- 页目录表 0 号项与 768 号项均指向第一个页表——0 号项指向第一个页表(loader 这个程序会运行在 0-4M 空间内,且跨越了段机制与页机制,顺序映射(第一个页表映射 0 开始 4MB,第二个页表映射紧挨着下一个 4MB 空间)可以保证之前段机制下的线性地址和分页后的虚拟地址对应的物理地址一致,在这 4M 空间内,分段下的线性地址=分页后的虚拟地址=物理地址),768 号项指向第一个页表是为了让分页机制下 3GB 开始的 4MB 虚拟地址空间(虚拟地址 0-3G 空间用户用,3-4G 空间操作系统用)对应到了 0-4M 实际物理空间,因为这里放着我们的操作系统。这是为了所有进程共享操作系统做准备。页目录表最后一项 1023 号项指向自己——为的是将来动态操作页表做准备(后面章节会详细讲到)。
- 初始化第一个页表(非页目录表)——因为这个页表管理着 0—4M 的物理地址空间,我们的操作系统就在这个空间内。
- 初始化页目录表 769 号-1022 号项,769 号项指向第二个页表(此页表紧挨着上面的第一个页表),770 号指向第三个,以此类推,——为将来用户进程做准备,使所有用户进程共享内核空间(从 768 号项—1022 号项的页目录表项会被拷贝到所有用户进程的页目录表项中);
代码
boot.inc 新增代码如下:
PAGE_DIR_TABLE_POS equ 0x100000 ;页目录表所在地址
;---------------- 页表相关属性 --------------
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b
loader.s 中新增 setup_page 函数,用于创建页目录及页表
;------------- 创建页目录及页表 ---------------
setup_page:
;先把页目录占用的空间逐字节清0
mov ecx, 4096 ; 页目录表大小4kB 即 4096字节
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 ; PAGE_DIR_TABLE_POS + 4k,此时eax为第一个页表的位置
mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址。
; 下面将页目录项0和0xc00都存为第一个页表的地址,
; 一个页表可表示4MB内存,
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
mov [PAGE_DIR_TABLE_POS], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)
mov [PAGE_DIR_TABLE_POS + 0xc00 ], eax ; 一个页表项占用4字节,0xc00 = 768*4表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
; 将页目录表的最后一个页目录项写入页目录表的物理地址
sub eax, 0x1000 ; 此时eax位页目录表及地址+属性
mov [PAGE_DIR_TABLE_POS + 4092], eax
; 下面创建页表项(PTE)
; 初始化第一个页表,因为我们的操作系统不会超过1M,所以只用初始化256项
mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1
.create_pte: ; 创建Page Table Entry
mov [ebx+esi*4],edx ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
add edx,4096 ; edx指向下一个4kb空间,且已经设定好了属性,故edx中是一个完整指向下一个4kb物理空间的页表表项
inc esi
loop .create_pte
;创建内核其它页表的PDE 初始化页目录表769号-1022号项,769号项指向第二个页表的地址(此页表紧挨着上面的第一个页表),770号指向第三个,以此类推
;虽然现在还没有数据,但要先全部初始化,方便多个进程共享
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 此时eax为第二个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性US,RW和P位都为1
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
4-9 行将页目录表全部清 0
12-22 行将第一个页表写到页目录的第 1 个和第 768 个页目录项,具体原因上面解释过了
25-27 行将页目录表最后一项 1023 号项指向页目录表第一项
31-39 行创建页表,由于要保证我们内核 loader 跨越段模式和分页模式,并且内核 loader 是在 1MB 以内的,用 256 个页表即可,所以在前 256 个页表,直接映射到物理内存低端 1M
44-55 行创建其他内核页目录表,初始化页目录表 769 号-1022 号项,769 号项指向第二个页表的地址(此页表紧挨着上面的第一个页表),770 号指向第三个,以此类推
开启分页机制
代码设计
- 调用之前写好的 setup_page 完成页目录表与页表的创建
- GDT 中的视频段描述符表地址 +3G 、GDT 表基地址 +3G、栈指针地址 +3G
- 将页目录表地址写入控制寄存器 CR3,将 CR0 寄存器的 PG 位置为 1
- 重新加载 GDT 地址
- 操作显存段显示字符
代码实现
代码如下:
; 创建页目录及页表并初始化页内存位图
; 页目录表起始地址 PAGE_DIR_TABLE_POS = 0x100000
call setup_page
;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
sgdt [gdt_ptr] ; 存储到原来gdt所有的位置
;将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx, [gdt_ptr + 2] ;gdt_ptr+2表示GDT_BASE,也就是GDT的起始地址
add dword [ebx + 0x18 + 4], 0xc0000000 ;显存段是第3个段描述符,每个描述符是8字节,故0x18。
;段描述符的高4字节的最高位是段基址的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], 'V' ;视频段段基址已经被更新,用字符v表示virtual addr
jmp $
第 3 行调用我们上面写的函数
第 6 行备存原来的 gdt_ptr,但是实测不需要这句代码也可以,sgdt 一下更保险
9-10 行找到显存段的段描述符,给显存段的段基址 31-24 位加上 C0,,这样就进入到分页模式了
13 行给 gdt 基址也加上 0xC000000
14 行给栈指针也映射到内核空间
后面的代码很简单了,看注释就行
完整的 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 ; limit=(0xbffff-0xb8000+1)/4k-1=0x7
dd DESC_VIDEO_HIGH4 ; 此时dpl为0
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留60个描述符的空位(slot)
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_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes 4字节+gdt_ptr 6字节+ards_buf 244字节+ards_nr 2字节,共256字节
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录ards结构体数量
loader_start:
;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------
xor ebx, ebx ;第一次调用时,ebx值要为0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;ards结构缓冲区
.e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
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为最大的内存容量,在此先清0
.find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ;base_add_low
add eax, [ebx+8] ;length_low
add ebx, 20 ;指向缓冲区中下一个ARDS结构
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .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寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法
;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400 ;cx和ax值一样,cx用做乘数
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份
;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax,eax
mov ax,bx
mov ecx, 0x10000 ;0x10000十进制为64KB
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
mov edx,esi ;edx为总内存大小
jmp .mem_get_ok
;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
;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 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB
.mem_get_ok:
mov [total_mem_bytes], edx ;将内存换为byte单位后存入total_mem_bytes处。
;----------------- 准备进入保护模式 -------------------
;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
; 创建页目录及页表并初始化页内存位图
; 页目录表起始地址 PAGE_DIR_TABLE_POS = 0x100000
call setup_page
;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
; sgdt [gdt_ptr] ; 存储到原来gdt所有的位置
;将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx, [gdt_ptr + 2] ;gdt_ptr+2表示GDT_BASE,也就是GDT的起始地址
add dword [ebx + 0x18 + 4], 0xc0000000 ;显存段是第3个段描述符,每个描述符是8字节,故0x18。
;段描述符的高4字节的最高位是段基址的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], 'V' ;视频段段基址已经被更新,用字符v表示virtual addr
jmp $
;------------- 创建页目录及页表 ---------------
setup_page:
;先把页目录占用的空间逐字节清0
mov ecx, 4096 ; 页目录表大小4kB 即 4096字节
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 ; PAGE_DIR_TABLE_POS + 4k,此时eax为第一个页表的位置
mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址。
; 下面将页目录项0和0xc00都存为第一个页表的地址,
; 一个页表可表示4MB内存,
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
mov [PAGE_DIR_TABLE_POS], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)
mov [PAGE_DIR_TABLE_POS + 0xc00 ], eax ; 一个页表项占用4字节,0xc00 = 768*4表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
; 将页目录表的最后一个页目录项写入页目录表的物理地址
sub eax, 0x1000 ; 此时eax位页目录表及地址+属性
mov [PAGE_DIR_TABLE_POS + 4092], eax
; 下面创建页表项(PTE)
; 初始化第一个页表,因为我们的操作系统不会超过1M,所以只用初始化256项
mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1
.create_pte: ; 创建Page Table Entry
mov [ebx+esi*4],edx ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
add edx,4096 ; edx指向下一个4kb空间,且已经设定好了属性,故edx中是一个完整指向下一个4kb物理空间的页表表项
inc esi
loop .create_pte
;创建内核其它页表的PDE 初始化页目录表769号-1022号项,769号项指向第二个页表的地址(此页表紧挨着上面的第一个页表),770号指向第三个,以此类推
;虽然现在还没有数据,但要先全部初始化,方便多个进程共享
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 此时eax为第二个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性US,RW和P位都为1
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
运行结果
编译运行,结果如下:
再看一下 gdt 信息,可以看到 GDT 基址以及显存段都变成了虚拟地址:
再看一下现在的映射关系,书 P204-205 有详细分析
c 加载内核
首先要更改gcc版本至4.4,参考文章:ubuntu 16.04 gcc高低版本切换_高版本降级到gcc4.4.7-CSDN博客
我们这部分的目标是用c写一个简单的内核。然后加载内核
知识部分
由于任务是加载内核,所以我们需要了解elf文件格式,并且用汇编实现一个内核loader
看书上P218-219即可,这里不再多说
代码部分
代码设计
我们要把kernel.bin写到硬盘中,写到哪里呢,作者帮我们选的9号扇区(1开始算的话是10号扇区)。
我们在加载内核需要两部分的内存空间,一部分是读kernel.bin到内存中,另一部分是kernel.bin的内核映像,内核映像是按照elf格式展开之后的,是真正运行的内核。所以我们要为这两部分分配空间,看一下可用空间:
MBR在现阶段已经没用了,是所以打对号的是可用区域,我们的想法是映像在低地址,kernel.bin在高地址,这样方便内核映像向高地址扩展,即使覆盖了kernel.bin也没事,这里作者帮我们选好了,kernel.bin放在0x70000,内核映像越低越好,这里作者选择的0x1500,对应虚拟地址即0xc0001500,此时内存布局如下所示:
此外,忘了说,我们kernel的堆栈指针也要重新安排,作者安排到了0x9f000
代码实现
首先是kernel/main.c,很简单:
int main(void) {
while(1);
return 0;
}
第二处改动在loader.s里面,在创建页表之前先把kernel.bin读进来
; ------------------------- 加载kernel ----------------------
mov eax, KERNEL_START_SECTOR ;kernel的lba扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ;kernel.bin源文件的内存地址
mov ecx,200 ;读入的扇区数 一劳永逸
call rd_disk_m_32
;-------------------------------------------------------------------------------
;功能:读取硬盘n个扇区
;eax =lba起始地址
;bx = 写入的内存地址
;cx = 读入的扇区数
rd_disk_m_32:
;1 写入待操作数
;2 写入LBA 低24位寄存器 确认扇区
;3 device 寄存器 第4位主次盘 第6位LBA模式 改为1
;4 command 写指令
;5 读取status状态寄存器 判断是否完成工作
;6 完成工作 取出数据
mov esi, eax
mov di, cx ;做备份
;读写硬盘:
;第1步:设置要读取的扇区数
mov dx, 0x1F2
mov al, cl
out dx, al
mov eax,esi ;恢复ax
;第2步:将LBA地址存入0x1f3 ~ 0x1f6
;7-0位
mov dx, 0x1F3
out dx, al
;15-8位
mov dx, 0x1F4
mov cl,8
shr eax, cl ;左移8位 获得15-8位的地址
out dx, al
;23-16位
mov dx, 0x1F5
shr eax, cl ;继续左移
out dx, al
shr eax, cl ;继续左移,获得24-27位
and al, 0x0f
or al, 0xe0 ;这两行使得24-27位得以保留,并且7-4位设置为0110,表示lba模式
mov dx, 0x1f6
out dx, al
;第3步:向0x1f7端口写入读命令,0x20
mov dx, 0x1f7
mov al, 0x20
out dx, al
;第4步:检测硬盘状态
.not_ready:
nop ;相当于 sleep 一下
in al,dx
and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备好,继续等。
;第5步:从0x1f0端口读数据
mov ax, di
mov dx,256
mul dx ;乘积在ax
mov cx, ax
mov dx, 0x1f0
.go_on_read:
in ax, dx ;读到ax
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
其实核心就是rd_disk_m_32函数,与我们之前实模式下实现的rd_disk_m_16十分类似,只是有几处寄存器用到了e,不再多说
下面是加载内核的代码,代码逻辑如下:
-
从elf头中读取程序头表的信息
- 程序头表的初始偏移
- 程序头表中条目(记录段的信息)的数量
- 程序头表中每个条目的大小
-
读取到程序头表的信息后,我们就可以像遍历数组一样遍历程序头表,取出程序头表中的每个程序头(也就是段头)的信息
- 本段在文件内的大小
- 本段在文件内的起始偏移
- 本段在内存中的起始虚拟地址
-
将段复制到内存指定的虚拟地址处
代码如下:
;;;;;;;;;;;;;;;;;;;;;;;;;;;; 此时不刷新流水线也没问题 ;;;;;;;;;;;;;;;;;;;;;;;;
;由于一直处在32位下,原则上不需要强制刷新,经过实际测试没有以下这两句也没问题.
;但以防万一,还是加上啦,免得将来出来莫句奇妙的问题.
jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新gdt
enter_kernel:
call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT ; 用地址0x1500访问测试,结果ok
;----------------- 将kernel.bin中的segment拷贝到编译的地址 -----------
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_phoff,表示第1 个program header在文件中的偏移量
; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值
add ebx, KERNEL_BIN_BASE_ADDR
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,压入函数memcpy的第三个参数:size
mov eax, [ebx + 4] ; 距程序头偏移量为4字节的位置是p_offset
add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax ; 压入函数memcpy的第二个参数:源地址
push dword [ebx + 8] ; 压入函数memcpy的第一个参数:目的地址,偏移程序头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
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 ; 逐字节拷贝
;恢复环境
pop ecx
pop ebp
ret
其中先调用kernel_init函数
19-30行是读取program header相关信息,然后调用自己定义的mem_cpy函数实现复制操作
关于mem_cpy函数,可以看书P226,非常详细
第八行就是改变堆栈指针,然后下一行跳转到内核执行
编译命令
我们的最新编译脚本如下:
nasm -I ./boot/include/ -o ./boot/mbr.bin ./boot/mbr.s
dd if=./boot/mbr.bin of=../../hd60M.img bs=512 count=1 conv=notrunc
nasm -I ./boot/include/ -o ./boot/loader.bin ./boot/loader.s
dd if=./boot/loader.bin of=../../hd60M.img bs=512 count=4 seek=2 conv=notrunc
gcc-4.4 ./kernel/main.c -o ./kernel/main.o -c -m32
ld ./kernel/main.o -Ttext 0xc0001500 -e main -o ./kernel/kernel.bin -m elf_i386
dd if=./kernel/kernel.bin of=../../hd60M.img bs=512 count=200 seek=9 conv=notrunc
效果:
我们看一下eip指针的值,确保进入内核:
确实是我们之前给内核安排的虚拟内存,没毛病。
标签:保护模式,进阶,内存,mov,eax,地址,页表,目录,内核 From: https://www.cnblogs.com/fdxsec/p/18664411