页表映射实验
- 这个实验是我们后续实现多任务(多进程)的基础
- 前面我们也提到过,操作系统中每个任务都有自己独立使用的页表,当切换任务的时候,也需要要切换页表
- 每一个任务都有自己的虚拟地址空间,那么必然出现多个任务同时使用同一个虚拟地址的情况,那么操作系统就必须实现:将同一个虚拟地址映射到不同的物理地址
- 接下来我们就来做一个实验:给定一个虚拟地址 0x401000, 在页表 0 中将其映射到实际物理地址 0xD01000;在页表 1 中将其映射到实际物理地址 0xE01000。给 0xD01000 这个实际物理地址处写入数据 “ABCD”;给 0xE01000 这个实际物理地址处写入数据 “abcd”。分别查看切换到页表 0 和页表 1 后读取虚拟地址 0x401000 中的数据
- 由于整个实验比较复杂,我们就把它拆分成以下几个部分
- 准备两组页表并实现页表切换功能
- 在保护模式下,修改指定物理内存中的数据:将数据 “ABCD” 写入物理内存 0xD01000 处,将数据 “abcd” 写入物理内存 0xE01000 处
- 修改页表:修改页表 0,将虚拟地址 0x401000 映射到实际物理地址 0xD01000 处;修改页表 1,将虚拟地址 0x401000 映射到实际物理地址 0xE01000 处
- 切换页表,分别读取虚拟地址 0x401000 中的数据
准备两组页表并实现页表切换功能
- 基本的准备工作有:
- 构建两组二级页表
- 切换页表,开启分页机制
- 参照上一章节的代码,复制粘贴后稍加改动即可,改动后完整代码 loader.asm
; 页目录表与页起始地址,注意地址不要踩踏 PAGE_DIR_BASE0 equ 0x100000 PAGE_TAB_BASE0 equ 0x101000 PAGE_DIR_BASE1 equ 0x600000 PAGE_TAB_BASE1 equ 0x601000
; 全局描述符表定义 PAGE_DIR_DESC0 : Descriptor PAGE_DIR_BASE0, 4095, DA_DRW + DA_32 PAGE_TAB_DESC0 : Descriptor PAGE_TAB_BASE0, 1023, DA_DRW + DA_32 + DA_LIMIT_4K PAGE_DIR_DESC1 : Descriptor PAGE_DIR_BASE1, 4095, DA_DRW + DA_32 PAGE_TAB_DESC1 : Descriptor PAGE_TAB_BASE1, 1023, DA_DRW + DA_32 + DA_LIMIT_4K
; 段选择符定义 PAGE_DIR0_SELECTOR equ (0x0005 << 3) + SA_RPL0 + SA_TIG PAGE_TAB0_SELECTOR equ (0x0006 << 3) + SA_RPL0 + SA_TIG PAGE_DIR1_SELECTOR equ (0x0007 << 3) + SA_RPL0 + SA_TIG PAGE_TAB1_SELECTOR equ (0x0008 << 3) + SA_RPL0 + SA_TIG
- 根据原 setup_page 创建页表并开启内存分页函数,我们实现另一个函数 Init_Page_Table,使其可以根据传参的不同从而创建不同的页表;切换页表的功能我们也实现成一个函数 Switch_Page_Table
- 查看 setup_page 函数中页表创建部分,发现其中有三处是用宏定义固定的,它们分别是 PAGE_DIR_SELECTOR(页目录选择子)、PAGE_TAB_SELECTOR(页表选择子) 和 PAGE_TAB_BASE(页表基地址),把这 3 处变为参数输入即可实现不同页表的的创建
- 我们先规划一下函数参数
; eax --> 页目录选择子 ; ebx --> 页表选择子 ; ecx --> 页表基地址 Init_Page_Table: ... ret
- 接下来 Init_Page_Table 函数的具体实现见源码吧
- Switch_Page_Table 函数的实现:先将寄存器 cr0 的 PG 位清 0,暂时先关闭内存分页功能,再将页目录表首地址写入控制寄存器 cr3,最后再将寄存器 cr0 的 PG 位置 1,开启内存分页
; 寄存器 cr0 的 PG 位清 0,暂时先关闭内存分页功能 mov eax, cr0 and eax, 0x7FFFFFFF ; and 按位与 mov cr0, eax ; 将页目录表首地址写入控制寄存器 cr3 mov eax, [esp] ; [esp] = Switch_Page_Table 运行前 eax mov cr3, eax ; 寄存器 cr0 的 PG 位置 1,开启内存分页 mov eax, cr0 or eax, 0x80000000 mov cr0, eax
在保护模式下,修改指定物理内存中的数据
- 我们想把数据 “ABCD” 写入实际物理内存 0xD01000 中以及把数据 “abcd” 写入实际物理内存 0xE01000 中,然而这种指定实际物理地址的内存访问方式不是实模式下才有的吗。那么我们如何在保护模式下实现访问指定的物理内存呢
- 保护模式下想要实现访问指定的物理内存,就需要用到内存平坦模型,而想要理清楚内存平坦模型,就需要了解 x86 芯片的发展历史
- 早期的 x86 芯片寄存器是 16 位的,但地址却是 20 位的(1M的内存空间),一个 16 位的寄存器最多能访问 64K 的内存空间,想要访问 1M 的内存空间,于是就不得不使用两个寄存器组合的方式才能实现,这种方式(段基址:段内偏移)我们称为段式内存管理。
- 随着硬件的发展,出现了 32 位寄存器,于是我们可以用一个 32 位寄存器直接访问 4G 的内存空间了,也就不需要两个寄存器了。这时候就引出了平坦内存模型,即将所有的 4G 内存看成一整个段,段基址为 0,段大小就是整个 4G 物理内存容量。此时段内偏移不就可以看成是实际物理地址了吗
- 用代码实现内存平坦模型其实非常简单,就是将整个内存当成一个段,定义其描述符和选择符
; 全局描述符表定义 FLAT_MODE_DESC : Descriptor 0, 0xFFFFF, DA_DRW + DA_32 + DA_LIMIT_4K ; 段选择符定义 FLAT_MODE_SELECTOR equ (0x0009 << 3) + SA_RPL0 + SA_TIG
- 如果我们想访问实际物理地址 0x00FF00FF,实现方式如下(注意:想访问实际物理地址是不能开启内存分页机制的)
mov eax, FLAT_MODE_SELECTOR mov es, eax mov ecx, [es:0x00FF00FF]
- 好了,让我们继续实验,先看完整代码:loader.asm
- 首先,在 DATA_SEGMENT 数据段中添加数据 “ABCD” 和 “abcd”
TEST_DATA_ABCD db "ABCD" ; 数据 "ABCD" ; "ABCD" 在数据段 DATA_SEGMENT 中的偏移量 TEST_ABCD_OFFSET equ TEST_DATA_ABCD - DATA_SEGMENT TEST_DATA_abcd db "abcd" ; 数据 "abcd" ; "abcd" 在数据段 DATA_SEGMENT 中的偏移量 TEST_abcd_OFFSET equ TEST_DATA_abcd - DATA_SEGMENT
- 下面我们来实现一个函数 MemCpy32 用于把数据 "ABCD" 和 "abcd" 拷贝到指定的指定的物理地址处
; ds:esi --> 源 ; es:edi --> 目标 ; ecx --> 长度 MemCpy32: ; 入栈... .s: mov al, [ds:esi] ; [ds:esi] : 源地址 mov [es:edi], al ; [es:edi] : 目标地址 inc edi ; edi = edi + 1 inc esi ; esi = esi +1 loop .s ; 循环次数由 ecx 决定 ; 出栈... ret
- 最后调用 MemCpy32,将数据 “ABCD” 拷贝到实际物理地址 0xD01000 处,将数据 “abcd” 拷贝到实际物理地址 0xE01000 处。注意要在 Switch_Page_Table 函数执行前完成拷贝
mov esi, TEST_ABCD_OFFSET ; 源 mov edi, 0xD01000 ; 目标 mov ecx, 4 ; 循环次数 call MemCpy32 mov esi, TEST_abcd_OFFSET ; 源 mov edi, 0xE01000 ; 目标 mov ecx, 4 ; 循环次数 call MemCpy32
- make 后反汇编
ndisasm -o 0x900 loader.bin > loader.txt
- 找到程序最后 “jmp $” 的地址为 0xA92,在 0xA92 处打断点后查看内存地址 0xD01000 和 0xE01000 处的数据
<bochs:1> b 0xa92 <bochs:2> c <bochs:3> xp 0xD01000 [bochs]: 0x00d01000 <bogus+ 0>: 0x44434241 <bochs:4> xp 0xE01000 [bochs]: 0x00e01000 <bogus+ 0>: 0x64636261
- 通过查 ASCII 表,0x44434241 即数据 "ABCD", 0x64636261 即数据 "abcd"
修改页表
- 修改页表和下面的切换页表,分别读取虚拟地址 0x401000 中的数据实现代码:loader.asm
- 我们创建的两个页表其中的内容是一模一样的,现在我们想要使得同一个虚拟地址 0x401000 映射到不同的物理地址 0xD01000 和 0xE01000
- 先把虚拟地址 0x401000 转换成二进制 01 0000000001 000000000000,高 10 位值为 1,中 10 位值为 1,低 12 位值为 0
- 因为是做实验,所以我们手动找到对应的内存处改一下。
- 还是利用之前的程序 loader.asm,在 0xA92 处打断点,根据虚拟地址高中低 3 个值找到子页表项,将其修改后使其映射到我们想要的实际物理地址 0xD01000 和 0xE01000
<bochs:1> b 0xa92 <bochs:2> c <bochs:3> xp 0x100004 [bochs]: 0x00100004 <bogus+ 0>: 0x00102007 <bochs:4> xp 0x102004 [bochs]: 0x00102004 <bogus+ 0>: 0x00401007 <bochs:5> xp 0x600004 [bochs]: 0x00600004 <bogus+ 0>: 0x00602007 <bochs:6> xp 0x602004 [bochs]: 0x00602004 <bogus+ 0>: 0x00401007
- 通过查看断点调试的方式我们得到,虚拟地址 0x401000 在页表 0 中对应的子页表项所处的实际物理地址是 0x102004;在页表 1 中对应的子页表项所处的实际物理地址是 0x602004,而他们的子页表项的值都为 0x00401007。那么接下来我们要做的就是把实际物理地址 0x102004 中的值改为 0xD01007,把实际物理地址 0x602004 中的值改为 0xE01007。其中低 12 位 0x007 是属性
mov eax, 0xD01007 mov [es:0x102004], eax ; 将数值 0xD01007 写入 0x102004 地址处 mov eax, 0xE01007 mov [es:0x106004], eax ; 将数值 0xE01007 写入 0x602004 地址处
- 要想显得更高大上一点,我们也可以实现一个映射函数 MapAddress,用于修改指定的页表,将指定的虚拟地址与指定的物理地址映射起来。MapAddress 函数主体实现见源码
切换页表,分别读取虚拟地址 0x401000 中的数据
- 我们可以利用 print_str_32 函数将 0x401000 地址处的字符串字印出来,print_str_32 函数使用如下:
mov ax, FLAT_MODE_SELECTOR mov ds, ax mov ebp, 0x401000 mov bl, 0x0F ; 打印属性,黑底白字 ; 坐标 (0, 3) mov dl, 0x00 mov dh, 0x03 call print_str_32
- 页表映射实验结束,来欣赏一下最终效果吧
页表映射实验升级之函数
- 前面的实验中,虚拟地址 0x401000 映射的物理地址 0xD01000 和 0xE01000 中都是数据。如果映射的物理地址中是一个函数入口,那么是否会发生函数调用呢?
- 答案是:会。做个实验验证一下呗,实验代码:loader.asm
- 在做实验之前,我们先了解一下函数的本质
- 函数在内存中表现形式为一段数据
- 这段数据遵循一定的规则,能够被处理器识别
- 这段时间具有可执行属性,并且只读,不可修改
- 如何在指定的地址 0xD01000 和 0xE01000 处创建函数呢?
- 我们可以先在写好函数,然后利用先前实现的 MemCpy32 函数将目标函数拷贝到指定的物理地址
- 开始实验,在前面实验代码基础上改动,先添加两个函数,函数没啥实际意义,就是给 eax 寄存器赋值。注意:由于函数被复制到其它地方,已不在本身所在的段内,所以应该用 retf
test_func1: mov eax, 8 retf test_func1_len equ $ - test_func1 test_func2: mov eax, 9 retf test_func2_len equ $ - test_func2
- 删掉代码中数据 "ABCD" 和 "abcd" 的拷贝改成拷贝刚添加的测试函数 test_func1 和 test_func2
mov esi, test_func1 ; 源 mov edi, 0xD01000 ; 目标 mov ecx, test_func1_len ; 循环次数 call MemCpy32 mov esi, test_func2 ; 源 mov edi, 0xE01000 ; 目标 mov ecx, test_func2_len ; 循环次数 call MemCpy32
- 然后删掉代码中的数据 "ABCD" 和 "abcd" 的打印调用,改为:
call FLAT_MODE_SELECTOR : 0x401000
- 编译代码,成功,运行一下,崩了。提示关键信息如下:
read_virtual_byte_32(): segment limit violation
- 根据提示信息,我们查找到段描述符 FLAT_MODE_DESC 的段属性是可读写的,但不可执行,可是我们要其具有可执行权限,怎么办?
- 解决办法:再来一个平坦模型数据段描述符,具有可执行权限
; 段描述符 FLAT_MODE_C_DESC : Descriptor 0, 0xFFFFF, DA_C + DA_32 + DA_LIMIT_4K ; 段选择符 FLAT_MODE_C_SELECTOR equ (0x000A << 3) + SA_RPL0 + SA_TIG
- 调用处也改为:
; call FLAT_MODE_SELECTOR : 0x401000 call FLAT_MODE_C_SELECTOR : 0x401000
- 再次编译运行,发现又崩了,崩溃原因跟上次一样,这次又是啥原因呢?
- 经查找后发现,MemCpy32 函数输入参数 ds:esi --> 源,我们程序中ds 初始化成了数据段,我们要拷贝的函数不在数据段中
mov ax, DATA_SELECTOR mov ds, ax
- 因为是做实验,所以我们临时把 ds 初始化为平坦模型段(可读写)
mov ax, FLAT_MODE_SELECTOR mov ds, ax ; ... call MemCpy32 mov ax, DATA_SELECTOR mov ds, ax
- OK,这次程序运行不会崩溃了。但是由于测试函数仅给 eax 寄存器赋值,所以只能通过反汇编后断点调试来验证
- 找到 call FLAT_MODE_C_SELECTOR : 0x401000 这条指令地址为 0xAC4 和 0xAD5,于是在这两个地址处打好断点,运行到断点处后再单步执行
(0) [0xfffffff0] f000:fff0 (unk. ctxt): jmp far f000:e05b ; ea5be000f0 <bochs:1> b 0xac4 <bochs:2> b 0xad5 <bochs:3> c ... (0) [0x00000ac4] 0008:000000be (unk. ctxt): call far 0050:00401000 ; 9a001040005000 <bochs:4> s Next at t=23065731 (0) [0x00d01000] 0050:00401000 (unk. ctxt): mov eax, 0x00000008 ; b808000000 <bochs:5> c (0) Breakpoint 2, 0x00000ad5 in ?? () Next at t=23065746 (0) [0x00000ad5] 0008:000000cf (unk. ctxt): call far 0050:00401000 ; 9a001040005000 <bochs:6> s Next at t=23065747 (0) [0x00e01000] 0050:00401000 (unk. ctxt): mov eax, 0x00000009 ; b809000000
- 从 bochs 打印信息中我们可以看出 test_func1 和 test_func2 函数被成功执行
分页相关的其它操作
- 页表属性中的 P 位可作为标志位,判断是否需要进行页请求
- 当需要页请求/页交换时,需要修改页目录或二级页表
- 平坦内存模型可用于加载/移除物理页
- 关于这些功能,后面章节中我们再做详细介绍