本篇对应书籍第五章5.1--5.2的内容
本篇介绍并实践获取物理内存容量的方法,以及开启分页机制
本篇难点:
- 分页机制原理
获取物理内存容量的方法
上一篇章中,我们从CPU的实模式进入到了保护模式,首先要处理的第一个问题就是内存地址的访问,保护模式下,内存地址使用虚拟地址到物理地址映射访问,在那之前,要先获取物理内存的大小
可以通过调用 BIOS中断 0x15 实现,BIOS中断 0x15 有3个子功能:
- EAX = 0xE820:遍历主机上全部内存
- AX = 0xE801:分别检测低 15MB 和 16MB ~ 4GB 的内存,最大支持 4GB
- AH = 0x88:最多检测出 64MB 内存,实际内存容量超过此容量也按照 64MB 返回
利用 BIOS 中断 0x15 子功能 0xe820 获取内存
0xe820能够获取系统的内存布局,每次BIOS只返回一种类型的内存信息,直到全部返回完成;
内存信息被存在一个结构中--地址范围描述符 ARDS:
一共5个字段,每个字段4个字节
Type字段含义:
为什么 BIOS 要按类型来返回呢?
因为内存可能处于多种状态,按类型好区分
BIOS 中断 0x15 子功能 0xe820 说明:
此中断的调用步骤:
- 填写好“调用前输入”中列出的寄存器
- 执行中断调用 int 0x15
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果
利用 BIOS 中断 0x15 子功能 0xe801 获取内存
这种方法最大识别内存为 4GB
BIOS 中断 0x15 子功能 0xe801 说明:
此中断的调用步骤:
- 将 AX 寄存器写入 0xE801
- 执行中断调用 int 0x15
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果
利用 BIOS 中断 0x15 子功能 0x0e88 获取内存
只能识别到 64MB内存
此中断的调用步骤:
- 将 AX 寄存器写入 0X88
- 执行中断调用 int 0x15
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果
实现获取物理内存容量
本节主要用于演示 BIOS 0x15 中断的用法,可跳过
MBR5_1.S
将:
jmp LOADER_BASE_ADDR
修改成:
jmp LOADER_BASE_ADDR + 0x300
loader5_1.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)/4k = 7
dd DESC_VIDEO_HIGH4;此时dpl为0
GDT_SIZE equ $ - GDT_BASE ;获取 GDT 大小
GDT_LIMIT equ GDT_SIZE - 1 ;获取 段界限
times 60 dq 0 ;预留60个空位,为以后填入中断描述符表和任务状态段TSS描述符留空间
;times 60 表示后面的内容循环60次,是nasm提供的伪指令
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文件头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地址线
in al, 0x92
or al, 00000010B
out 0x92, al
;2.加载GDT
lgdt [gdt_ptr]
;3.将CR0的PE位置1
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线
.error_hlt: ;出错则挂起
hlt
[bits 32] ;编译成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 $
编译&写入硬盘
nasm -I include/ -o mbr5_1.bin mbr5_1.S
nasm -I include/ -o loader5_1.bin loader5_1.S
cd ..
dd if=boot/mbr5_1.bin of=hd60M.img bs=512 count=1 conv=notrunc
dd if=boot/loader5_1.bin of=hd60M.img bs=512 count=4 seek=2 conv=notrunc
运行Bochs
Bochsrc.disk 配置文件的内存设置:
megs: 32
Bochs运行起来后,使用xp 0xb00
查看获取结果:
0x2000000 换成十进制正是 32 MB
分页机制
段基址 + 段内偏移地址是线性地址,是连续的,是唯一的,只能属于某一个进程
有时候内存中剩余的连续的地址空间无法装下一个新的进程,内存空间利用率低下,为了能让可用地址空间不是连续的也能用,于是有了分页机制
一级页表
分页机制建立在分段机制之上:
- 没打开分页机制,就按分段机制直接访问物理内存地址
- 打开分页机制后,线性地址变成虚拟地址,需要经过地址转换才能进行访问
分页机制的思想
是:通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续。
分页机制的作用
:
- 将线性地址转换成物理地址
- 将大小相等的页代替大小不等的段
经过段部件输出的线性地址也叫虚拟地址,地址先进行逻辑上的分段,然后将线性地址的段拆分成以页为单位的大小相同的小内存块,也就是图片中间那部分
CPU 采用的页大小是 4KB ,4GB 地址空间被划分出 1M 个页,只要是 4KB 的地址空间都可以称为一页,线性地址的一页都对应物理地址的一页,这就是一级页表
页表是一个线性表,就像数组一样,可以用下标进行索引,页表的表项只有一个字段,那就是这个页所对应的真实的物理地址
分页机制打开前要将页表地址加载到控制寄存器CR3中,这个地址是实际的物理地址,不会被分页进行转换
一级页表转换原理
:
知道页表的物理地址后,用虚拟地址的高20位用来索引页表的表项,因为页表表项大小为4字节,所以将高20位乘4即是虚拟地址所对应的表项的地址,内容就是对应的物理地址
然后再加上低12位的偏移,即是真正的物理地址,如下图所示:
二级页表
二级页表是另一种页表形式,为了减少页表的空间占用,所以需要二级页表,二级页表占用内存示意图如下:
每个页表的物理地址在页目录表中都以页目录项(Page Directory Entry,PDE)的形式存储。
原来一级页表是1M个表项,二级页表则是将1M个表项分成1K个页表,每个页表有1K个表项的形式,页表的地址装载页目录表中页目录表也有1024个项
二级页表转换原理
:
- 高10位作为页目录表的索引,获取页表的物理地址
- 中间10位作为物理页表项的索引,从页表中获取物理页地址
- 将获取到的物理页地址加上后12位页内偏移,获取真实的物理地址
10位二进制位刚好能表示0~1023这1024个表项或者页表
把4G内存分成了1024份,每份的大小是4M,每份又分成了1024小份,每小份大小是4K
地址转换则是先按4M来分,找到属于哪一个4M,然后再看属于哪一个4K,然后再以此为基址加上偏移地址找到真实地址
示意图如下图:
每个进程都有自己的页表
页目录项和页表项
-
P, Present,存在位。若为 1 表示该页存在于物理内存中,若为 0 表示不在物理内存中。
- 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 表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存。
-
PCD,Page-level Cache Disable,页级高速缓存禁止位。若为 1 表示该页启用高速缓存,为 0 表示禁止将该页缓存。
-
A,Accessed,访问位。若为 1 表示该页被 CPU 访问过了。操作系统定期将该位清 0,统计一段时间内变成 1 的次数可计算内存页的使用频率。
-
D,Dirty,脏页位。当 CPU 对一个页面执行写操作时,就会设置对应页表项的 D 位为 1。此项仅针对页表项有效,并不会修改页目录项中的 D 位。
-
PAT,Page Attribute Table,页属性表位。
-
G,Global,全局位。1 表示是全局页,0 表示不是全局页。若为全局页,该页将在高速缓存 TLB 中一直保存。
-
AVL,Available 位。
启用分页机制的准备
启用分页机制要做三件事:
- 准备好页目录表及页表
- 将页表地址写入控制寄存器 cr3
- 寄存器 cr0 的 PG 位置 1
存储段地址有专门的寄存器--段描述符缓冲寄存器,存储页表地址也有专门的寄存器:CR3,也叫页目录基址寄存器:
只需要把页目录无敌离职的高20位写入CR3寄存器即可
设计内存布局
设计页表也就是设计内存布局
用户代码加上需要用的操作系统中的部分代码才是完整的程序,用户进程需要与操作系统共同配合
所以用户进程都需要共享操作系统,只要让虚拟内存空间的0~3GB分给用户进程,3~4GB分给操作系统即可实现共享,让所有进程的虚拟空间的3~4GB部分都指向同一个物理地址
启用分页机制
boot.inc
新增如下内容:
PAGE_DIR_TABLE_POS equ 0x100000
;--------------页表 属性---------------------
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:p_mode_start
这里首先将gdt里面的显存 0xb8000 修改到了内核内存部分中 0xc00b8000
然后将 gdt 的地址修改到了 内核部分中,这里主要是为了以后不重复加载
由于修改了页表,现在访问虚拟内存0xc0000000开始1M的地址,都会map到0-1M内存的位置
最后通过操作显存,
p_mode_start内容有变动:
其中setup_page函数在后文单独列出
p_mode_start:
;初始化为32位的段寄存器
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov ss, ax
mov esp, LOADER_STACK_TOP
;显示“P”到屏幕上
mov ax, SELECTOR_VIDEO
mov gs, ax
mov byte [gs:160],'P'
;创建页目录和页表并初始化页内存位图
call setup_page
;gdt需要放在内核里
;将描述符表地址&偏移量写入内存gdt_ptr,一会用新的地址加载
sgdt [gdt_ptr] ;取出GDT地址和偏移信息,存放在gdt_ptr这个内存位置上
;视频段需要放在内核里与用户进程进行共享
;将gdt描述符中视频段的段基址+0xc0000000
mov ebx, [gdt_ptr + 2] ;这里gdt_ptr前2字节是偏移量,后4字节是GDT基址,先选中GDT
or dword [ebx + 0x18 + 4], 0xc0000000 ;一个描述符8字节,0x18处是第3个段描述符也就是视频段,修改段基址最高位为C,+4进入高4字节,用or修改即可
;将gdt的基址加上 0xc0000000 成为内核所在的地址
add dword [gdt_ptr + 2 ], 0xc0000000
add esp, 0xc0000000 ;将栈指针同样map到内核地址,???
;页目录赋值给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]
;这里书上存在问题,寄存器没刷新直接写入,就没往虚拟内存里写入了,这里需要重新初始化一下gs寄存器
mov ax, SELECTOR_VIDEO
mov gs, ax
mov byte [gs:320], 'V'
;停住
jmp $
坑点:最后打印V的时候,这里书上存在问题,寄存器没刷新直接写入,这样写入基址的还是0xb8000,这里需要重新初始化一下gs寄存器,才能向0xc00b8000基址写入内容
loader.S:setup_page
因为系统需要被共享,让所有用户进程在自己的虚拟空间都能访问到系统,所以系统所在的虚拟地址对应的页目录,应该指向系统真实存在的页目录,指向同一个页表
;-------- 创建页目录和页表 --------
setup_page:
;把页目录所占空间清0
mov ecx, 4096
xor esi, esi
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
;开始创建页目录项(Page Directory Entry)
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ;第一个页表的位置(仅次于页目录表,页目录表大小4KB)
mov ebx ,eax ;0x00101 000
;下面将页目录项0和OxcOO都存为第一个页表的地址 ,每个页表表示4MB内存
;这样Oxc03fffff(3G-3G04M)以下的地址和Ox003fffff(0-4M)以下的地址都 指向相同的页表
;这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ;用户特权级,可读可写,存在内存
mov [PAGE_DIR_TABLE_POS + 0x0] , eax ;第一个目录项,0x00101 007
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;第0xc00高10位0x300=768个页表占用的目录项,0xc00以上属于kernel空间
;这里是把第768个目录页和第1个目录页指向同一个页表的物理地址:0x101000
;系统实际位于0~0x100000内存地址中,将系统虚拟地址0xc00000000映射到这低1M的空间内,只需要让0xc0000000的地址指向和低1M相同的页表即可
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 0x1000], eax ;使最后一个目录项指向页目录表自己的位置
;创建页表项(Page Table Entry)
mov ecx, 256 ;1M低端内存/每页大小4K = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ;地址为0x0,属性为7,111b
;这个页表项提供map地址的范围是0x0~0x100000,也就是低端1M
.create_pte:
mov [ebx+esi*4], edx
add edx, 0x1000
inc esi
loop .create_pte ;低端1M内存中,物理地址=虚拟地址,这里创建了1M空间的页表项
;创建内核其他页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;第二个页表
or eax, PG_US_U | PG_RW_W | PG_P ;111b
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ;768~1022的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret
运行Bochs
编译,写入硬盘。
运行,显示出了V:
gs寄存器的基地址,还有GDT的基地址,和视频段的基址都是虚拟地址:
用虚拟地址访问页表
- 用虚拟地址获取页表中各数据类型的方法:
- 获取页目录表物理地址:让虚拟地址的高 20 位为 0xfffff,低 12 位为 0x000,即 0xfffff000,这也是页目录表中第 0 个页目录项自身的物理地址
- 访问页目录中的页目录项,即获取页表物理地址:要使虚拟地址为 0xfffffxxx,其中 xxx 是页目录项的索引乘以 4 的积访问页表中的页表项:要使虚拟地址高 10 位为 0x3ff,目的是获取页目录表物理地址。中间 10 位为页表的索引,因为是 10 位的索引值,所以这里不用乘以 4。低 12 位为页表内的偏移地址,用来定位页表项,它必须是已经乘以 4 后的值
快表简介
为了减少CPU频繁访问内存导致效率下降,于是发明了存储页信息的调整缓存,就是TLB,快表。
快表 TLB (Translation Lookaside Buffer) 中的条目是虚拟地址的高 20 位到物理地址高 20 位的映射结果。除此之外还有一些属性位,比如页表项的 RW 属性。
修改了页表之后,需要对快表进行更新
TLB对开发人员不可见,但又两种方法可以更新其中的条目:
- 重新加载CR3
- TLB 更新指令:invlpg m,其中 m 表示操作数为虚拟内存地址。