selph
selph
Published on 2021-01-26 / 1,222 Visits
0
1

操作系统真象还原 学习笔记05--内存分页机制

本篇对应书籍第五章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 说明:

img

此中断的调用步骤:

  • 填写好“调用前输入”中列出的寄存器
  • 执行中断调用 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查看获取结果:

image-20210125022610043

0x2000000 换成十进制正是 32 MB

分页机制

段基址 + 段内偏移地址是线性地址,是连续的,是唯一的,只能属于某一个进程

有时候内存中剩余的连续的地址空间无法装下一个新的进程,内存空间利用率低下,为了能让可用地址空间不是连续的也能用,于是有了分页机制

一级页表

分页机制建立在分段机制之上:

  • 没打开分页机制,就按分段机制直接访问物理内存地址
  • 打开分页机制后,线性地址变成虚拟地址,需要经过地址转换才能进行访问

在这里插入图片描述

分页机制的思想是:通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续。

分页机制的作用:

  • 将线性地址转换成物理地址
  • 将大小相等的页代替大小不等的段

在这里插入图片描述

经过段部件输出的线性地址也叫虚拟地址,地址先进行逻辑上的分段,然后将线性地址的段拆分成以页为单位的大小相同的小内存块,也就是图片中间那部分

CPU 采用的页大小是 4KB ,4GB 地址空间被划分出 1M 个页,只要是 4KB 的地址空间都可以称为一页,线性地址的一页都对应物理地址的一页,这就是一级页表


页表是一个线性表,就像数组一样,可以用下标进行索引,页表的表项只有一个字段,那就是这个页所对应的真实的物理地址

分页机制打开前要将页表地址加载到控制寄存器CR3中,这个地址是实际的物理地址,不会被分页进行转换

一级页表转换原理

知道页表的物理地址后,用虚拟地址的高20位用来索引页表的表项,因为页表表项大小为4字节,所以将高20位乘4即是虚拟地址所对应的表项的地址,内容就是对应的物理地址

然后再加上低12位的偏移,即是真正的物理地址,如下图所示:

image-20210125114950694

二级页表

二级页表是另一种页表形式,为了减少页表的空间占用,所以需要二级页表,二级页表占用内存示意图如下:

在这里插入图片描述

每个页表的物理地址在页目录表中都以页目录项(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 位。

启用分页机制的准备

启用分页机制要做三件事:

  1. 准备好页目录表及页表
  2. 将页表地址写入控制寄存器 cr3
  3. 寄存器 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:

image-20210126131353942

gs寄存器的基地址,还有GDT的基地址,和视频段的基址都是虚拟地址:

image-20210126132826100

用虚拟地址访问页表

  • 用虚拟地址获取页表中各数据类型的方法:
  • 获取页目录表物理地址:让虚拟地址的高 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 表示操作数为虚拟内存地址。

Comment