前面我们已经用代码体验了分段内存的相关操作,现在就来进一步学习分页机制下的内存操作。不管是段还是页,指的都是一块连续的内存空间。一“页”内存在80386中,是固定的4096字节大小,即4KB。
在未打开分页机制时,线性地址等同于物理地址,于是可以认为,逻辑地址通过分段机制直接转换成物理地址。但是,当分页开启时,不再是这样的情况了,分段机制将逻辑地址转换成线性地址后,线性地址需在通过分页机制才能最终转换成为物理地址。该过程如下所示:
从前面的代码中我们也可以体会到,其实分段机制也工作地很好,那么为什么还需要分页机制呢?它的主要目的在于实现虚拟存储器。它可以使得线性地址中任意一个页都能映射到物理地址中的任何一个页,这无疑将使得内存管理变得更加灵活。
分页机制就像是一个函数,它使得如下等式成立:
物理地址 = f(线性地址)
示意图如下:
该转换使用两级页表实现,第一级叫做页目录,大小为4KB,存储在一个物理页中,每个表项4字节长,共有1024个表项。每个表项对应第二级的一个页表,第二级的每一个页表也有1024个表项,每一个表项对应一个物理页。页目录表的表项简称PDE(Page Directory Entry),页表的表项简称PTE(Page Table Entry)。
进行转换时,先是从寄存器cr3指定的页目录中根据线性地址的高10位得到页表地址,然后在页表中根据线性地址的第12到21位得到物理页首地址,将这个首地址加上线性地址低12位就可以得到物理地址。
分页机制是否生效的开关位于cr0的最高位PG位。如果PG= 1,则分页机制生效。所以,当我们准备好了页目录表和页表,并将cr3指向页目录表之后,只需要置PG位,分页机制就开始为我们工作了。
下面我们就用代码来实现分页机制的启动,关键代码如下:
PageDirBase equ 200000h ; 页目录开始地址: 2M
PageTblBase equ 201000h ; 页表开始地址: 2M+4K
……
LABEL_DESC_PAGE_DIR: Descriptor PageDirBase, 4095, DA_DRW;Page Directory
LABEL_DESC_PAGE_TBL: Descriptor PageTblBase, 1023, DA_DRW|DA_LIMIT_4K;Page Tables
……
SelectorPageDir equ LABEL_DESC_PAGE_DIR - LABEL_GDT
SelectorPageTbl equ LABEL_DESC_PAGE_TBL - LABEL_GDT
……
; 启动分页机制 --------------------------------------------------------------
SetupPaging:
; 为简化处理, 所有线性地址对应相等的物理地址.
; 首先初始化页目录
mov ax, SelectorPageDir ; 此段首地址为 PageDirBase
mov es, ax
mov ecx, 1024 ; 共 1K 个表项
xor edi, edi
xor eax, eax
mov eax, PageTblBase | PG_P | PG_USU | PG_RWW
.1:
stosd
add eax, 4096 ; 为了简化, 所有页表在内存中是连续的.
loop .1
; 再初始化所有页表 (1K 个, 4M 内存空间)
mov ax, SelectorPageTbl ; 此段首地址为 PageTblBase
mov es, ax
mov ecx, 1024 * 1024 ; 共 1M 个页表项, 也即有 1M 个页
xor edi, edi
xor eax, eax
mov eax, PG_P | PG_USU | PG_RWW
.2:
stosd
add eax, 4096 ; 每一页指向 4K 的空间
loop .2
mov eax, PageDirBase
mov cr3, eax
mov eax, cr0
or eax, 80000000h ;置PG位
mov cr0, eax
jmp short .3
.3:
nop
ret
; 分页机制启动完毕 ----------------------------------------------------------
其中,PageDirBase和PageTblBase是两个宏,指定了页目录表和页表在内存中的位置。页目录表位于地址2MB处,有1024个表项,占用4KB空间,紧接着页目录表的就是页表,位于地址2MB+4KB处。在这里,我们假定最大的可能,共有1024个页表。由于每个页表占用4096字节,所以这些页表共占用4MB空间。也就是说,本程序至少需要大于6MB的内存。
为了逻辑清晰,上面分别定义了两个段来存放页目录表和页表,大小分别是4KB和4MB。我们的程序将所有的线性地址映射到了相同的物理地址中,使它们满足如下等式:
物理地址 = f(线性地址)= 线性地址
主要解释一下stosd指令的执行过程,相信其它代码都还是比较容易懂的。
它将把EAX中的内容送往ES:DI所指向的内存中,每次传输的数据为2个字的长度;然后DI寄存器将会根据DF的值来进行自增或者自减操作,DF为0时,调用该指令后将自增4(double word)。PG_P | PG_USU | PG_RWW用于表示该页的属性是存在的可读可写的用户级别页表。
最后当页目录表和所有的页表被初始化完毕,就进入了正式开启分页机制的时候。首先,代码让cr3执行页目录表,然后设置cr0的PG位,这样,分页机制就启动完成了。
此时,内存中的分页如下图所示:
其中PDE和PTE的结构如下图所示:
P存在位,表示当前条目所指向的页或者页表是否在物理内存中。P = 0表示页不在内存中,如果处理器试图访问此页,将会产生缺页异常;P = 1表示页在内存中。
R/W指定一个页或者一组页(比如,此条目是指向页表的页目录条目)的读写权限。此位与U/S位和寄存器cr0中的WP位相互作用。R/W = 0表示只读;R/W = 1表示可读写。
U/S指定一个页或者一组页(比如,此条目是指向页表的页目录条目)的特权级。此位与R/W位和寄存器cr0中的WP位相互作用。U/S = 0表示系统级别,如果CPL为0、1或2就是在此级别;U/S = 1表示用户级别,如果CPL为3就是在此级别。
如果cr0中的WP位为0,那么即便用户级页面的R/W=0,系统级程序仍然具备写权限;如果WP为1,那么系统级程序也不能写入用户级只读页。
PWT用于控制对单个页或者页表的缓冲策略。PWT=0时使用Write-back缓冲策略;PWT=1时使用Write-through缓冲策略。当cr0寄存器的CD(Cache-Disable)位被设置时会被忽略。
PDE结构图
PCD用于控制对单个页或者页表的缓冲。PCD = 0时,页或者页表可以被缓冲;PCD = 1时,页或者页表不可以被缓冲。当cr0寄存器的CD位被设置时会被忽略。
A指示页或页表是否被访问。此位往往在页或页表刚刚被加载到物理内存中时被内存管理程序清零,处理器会在第一次访问此页或页面时设置此位。而且,处理器并不会自动清除此位,只有软件能清除它。
D指示页或页表是否被写入。此位往往在页或页表刚刚被加载到物理内存中时被内存管理程序清零,处理器会在第一次写入此页或页面时设置此位。而且,处理器并不会自动清除此位,只有软件能清除它。
A位和D位都是被内存管理程序用来管理页和页表从物理内存中换入换出的。
PS决定页大小。PS = 0时,页大小为4KB,PDE指向页表。
PAT选择PAT(Page Attribute Table)条目。Pentium3以后的CPU开始支持此位。
G指示全局页。如果此位被设置,同时cr4中的PGE位被置位,那么此页的页表或页目录条目不会在TLB中变得无效,即便cr3被加载或者任务切换时也是如此。
处理器会将最近常用的页目录和页表项保存在一个叫做TLB(Translation Lookaside Buffer)的缓冲区中。只有在TLB中找不到被请求页的转换信息时,才会到内存中去寻找。这样就大大加快了访问页目录和页表的时间。
当页目录或页表项被更改时,操作系统应该马上使TLB中对于的条目无效,以便下次用到此条目时让它获得更新。当cr3被加载时,所有TLB都会自动无效,除非页或页表条目的G位被设置。
前面提到的cr3寄存器结构如下:
cr3又叫做PDBR(Page-Directory Base Register)。它的高20位将是页目录表首地址的高20位,页目录表首地址的低12位会是0,也即是说,页目录表会是4KB对齐的。类似地,PDE中的页表基址(Page-Table Base Address)以及PTE中的页基址(Page Base Address)也是用高20位来表示4KB对齐的页表和页。
获得机器内存:
前面虽然完成了分页的功能,但是考虑地并不周全。我们用了4MB的空间来存放页表,并用它映射了4GB的空间,但我们的内存现在显然没有那么大。如果我们的内存只有16MB的话,那么只要4个页表就够了(每个页目录项对应1024个页表项,每个页表项对应一个页,每个物理页4KB)。作为一个操作系统,也有必要知道自己的内存容量,这样才能进行相应的管理。
此处,我们利用中断15h来实现。在调用中断15h之前,需要填充如下寄存器:
EAX:int 15h可以完成许多工作,主要由ax的值决定,我们要获取内存信息时,需要将ax赋值为0E820H。
EBX:放置着“后续值(continuation value)”,第一次调用时EBX必须为0。
ES:DI:指向一个地址范围描述符结构ARDS(Address Range Descriptor Structure),BIOS将会填充次结构。
ECX:es:di所指向的地址范围描述符结构的大小,以字节为单位。无论es:di所指向的结构如何设置,BIOS最多将会填充ECX个字节。不过,通常情况下无论ECX为多大,BIOS值填充20字节,有些BIOS忽略ECX的值,总是填充20字节。
EDX:0534D4150H(‘SMAP’)——BIOS将会使用此标志,对调用者将要请求的系统映像信息进行校验,这些信息会被BIOS放置到es:di所指向的结构中。
中断调用之后,结果存放于下列寄存器之中。
CF:CF = 0表示没有错误,否则存在错误。
EAX:0534D4150H(‘SMAP’)。
ES:DI:返回的地址范围描述符结构指针,和输入值相同。
ECX:BIOS填充在地址范围描述符中的字节数量,被BIOS所返回的最小值是20字节。
EBX:这里放置着为得到下一个地址描述符所需要的后续值,这个值的实际形势依赖于具体的BIOS实现,调用者不必关心它的具体形式,只需要在下次迭代时将其原封不动地放置到EBX中,就可以通过它获取下一个地址范围描述符。如果它的值为0,并且CF没有进位,表示它是最后一个地址范围描述符。即EBX在最开始和最后的值都为0。
其中,地址范围描述符结构(20字节)如下所示:
其中,type的取值和意义如下:
由上面的说明可以看出,ax=0E820H时调用int 15H得到的不仅仅是内存的大小,还包括对不同内存段的一些描述。而且,这些描述都被保存在一个缓冲区中。所以,在调用int 15H之前,先要分配缓冲区。我们把每次得到的内存信息连续写入这块缓冲区,形成一个结构体数组。然后在保护模式下把它们读取出来,显示在屏幕上,并且根据它们来得到内存的容量。
具体代码如下:
_MemChkBuf: times 256 db 0 ;分配缓冲区
……
; 得到内存数
mov ebx, 0
mov di, _MemChkBuf
.loop:
mov eax, 0E820h
mov ecx, 20
mov edx, 0534D4150h
int 15h
jc LABEL_MEM_CHK_FAIL ;当CF=1时,跳转到目标程序处
add di, 20
inc dword [_dwMCRNumber]
cmp ebx, 0
jne .loop ;ebx不为0时跳回.loop
jmp LABEL_MEM_CHK_OK
LABEL_MEM_CHK_FAIL:
mov dword [_dwMCRNumber], 0
LABEL_MEM_CHK_OK:
代码通过循环来填充缓冲区,一旦CF被置为1或EBX为零,则循环将结束。在第一次循环开始之前,EAX为0000E820H,EBX为0,ECX为20,EDX为0534D4150H,ES:DI指向_MemChkBuf的开始处。在每一次循环进行时,寄存器di的值将会递增,每次增加20 字节。另外,EAX、ECX和EDX的值我们不做改变,EBX的值我们无需理会,由BIOS处理。最后,循环的次数,即地址范围描述符结构的个数由_dwMCRNumber变量来保存。
接下来我们需要在保护模式的32位代码中将内存信息显示出来:
DispMemSize:
push esi
push edi
push ecx
mov esi, MemChkBuf
mov ecx, [dwMCRNumber]
;for(int i=0;i<[MCRNumber];i++)//每次得到一个ARDS
.loop: ;{
mov edx, 5 ; for(int j=0;j<5;j++) //每次得到一个ARDS中的成员
mov edi, ARDStruct ;{//依次显示BaseAddrLow, BaseAddrHigh, LengthLow, LengthHigh, Type
.1:
push dword [esi]
call DispInt ; DispInt(MemChkBuf[j*4]); //显示一个成员
pop eax ;
stosd ; ARDStruct[j*4] = MemChkBuf[j*4];
add esi, 4 ;
dec edx ;
cmp edx, 0 ;
jnz .1 ; }
call DispReturn ; printf("\n");
cmp dword [dwType], 1
; if(Type == AddressRangeMemory)
jne .2 ; {
mov eax, [dwBaseAddrLow];
add eax, [dwLengthLow];
cmp eax, [dwMemSize]
; if(BaseAddrLow + LengthLow > MemSize)
jb .2 ;
mov [dwMemSize], eax ; MemSize = BaseAddrLow + LengthLow;
.2: ; }
loop .loop ;}
;
call DispReturn ;printf("\n");
push szRAMSize ;
call DispStr ;printf("RAM size:");
add esp, 4 ;堆栈平衡
;
push dword [dwMemSize] ;
call DispInt ;DispInt(MemSize);
add esp, 4 ;堆栈平衡
pop ecx
pop edi
pop esi
ret
代码的主体部分已经使用C语言来阐释,相信还是比较好理解的。其中循环的次数为地址范围描述符结构的个数,每次循环将会读取一个ARDStruct。首先打印其中每一个成员的各项,然后根据当前结构的类型,得到可以被操作系统使用的内存上限。结构会被存放在变量dwMemSize中,并在此模块的最后打印到屏幕。
为了使用方便,我们在lib.inc中定义了一些功能函数,DispAL、DispReturn的代码如下:
;; 显示一个整形数
DispInt:
mov eax, [esp + 4] ;esp + 4是因为call指令压栈了cs和ip,共计4字节
shr eax, 24
call DispAL
mov eax, [esp + 4]
shr eax, 16
call DispAL
mov eax, [esp + 4]
shr eax, 8
call DispAL
mov eax, [esp + 4]
call DispAL
mov ah, 07h ; 0000b: 黑底 0111b: 灰字
mov al, 'h'
push edi
mov edi, [dwDispPos]
mov [gs:edi], ax
add edi, 4
mov [dwDispPos], edi
pop edi
ret
;; DispInt 结束
;; 显示一个字符串
DispStr:
push ebp
mov ebp, esp ;后面ebp + 8除了call指令的压栈之外还push了ebp
push ebx
push esi
push edi
mov esi, [ebp + 8] ; pszInfo,段地址在SS中。
mov edi, [dwDispPos]
mov ah, 0Fh
.1:
lodsb
test al, al
jz .2
cmp al, 0Ah ; 是回车吗?
jnz .3
push eax
mov eax, edi
mov bl, 160
div bl
and eax, 0FFh
inc eax
mov bl, 160
mul bl
mov edi, eax
pop eax
jmp .1
.3:
mov [gs:edi], ax
add edi, 2
jmp .1
.2:
mov [dwDispPos], edi
pop edi
pop esi
pop ebx
pop ebp
ret
;; DispStr 结束
;; 换行
DispReturn:
push szReturn
call DispStr ;printf("\n");
add esp, 4 ;堆栈平衡
ret
;; DispReturn 结束
DispInt很好理解,它从高到低打印了十六进制整数,并在最后显示了一个灰色的‘h’。DispStr则加入了对回车的处理,采用循环的方式来显示信息,知道遇到\0结束。程序中用dwDispPos保存了当前的显示位置,而不再用EDI寄存器保存,这样我们就可以放心地使用EDI这个寄存器了。
还有就是程序中对应数据段的变量基本都有两个符号,例如:_dwMemSize和dwMemSize。在实模式下使用_dwMemSize,而在保护模式下使用dwMemSize。因为程序是在实模式下编译的,地址只适用于实模式,在保护模式下,数据的地址应该是相对于段基址的偏移。
然后显示内存信息:
push szMemChkTitle
call DispStr
add esp, 4
call DispMemSize ; 显示内存信息
在调用DispMemSize之前,显示了一个字符串作为将要打印的内存信息的表格头。运行结果如下(代码chapter3/g/pmtest7.asm):
由上可见,共有6段内存显示出来。我们知道,能被操作系统使用的内存type字段应为1,由此可见,只有两段内存可以被操作系统使用。它们分别是:
00000000H ~0009EFFF 和 00100000H ~ 01FEFFFFH ;可用的内存共计(30M+636K),共有内存31M。也可以知道,系统中可用的内存并不是连续分布的,所以在使用的时候还得谨慎。
改进页表初始化:
之所以要计算内存容量就是为了节约使用,不再初始化所以PDE和所有页表。现在我们可以根据内存大小计算应初始化多少PDE和多少页表了,我们可以如下修改SetupPaging函数:
SetupPaging:
; 根据内存大小计算应初始化多少PDE以及多少页表
xor edx, edx
mov eax, [dwMemSize]
mov ebx, 400000h ; 400000h = 4M = 4096 * 1024, 一个页表对应的内存大小
div ebx
mov ecx, eax ; 此时 ecx 为页表的个数,也即 PDE 应该的个数
test edx, edx
jz .no_remainder
inc ecx ; 如果余数不为 0 就需增加一个页表
.no_remainder:
push ecx ; 暂存页表个数
……
; 再初始化所有页表
mov ax, SelectorPageTbl ; 此段首地址为 PageTblBase
mov es, ax
pop eax ; 页表个数
mov ebx, 1024 ; 每个页表 1024 个 PTE
mul ebx
mov ecx, eax ; PTE个数 = 页表个数 * 1024
xor edi, edi
……
ret
在函数的开头,我们就用内存大小除以4MB来得到应初始化的PDE的个数(也是页表的个数)。在初始化页表的时候,通过刚才计算出的页表个数乘以1024(每个页表含有1024个PTE)得出要填充的PTE个数,然后通过循环完成对它们的初始化。这样一来程序所需的内存空间就会少很多。
而且,由于分页机制的存在,程序使用的都是线性地址空间,而不再直接是物理地址。好像操作系统为应用程序提供了一个不依赖于硬件(物理内存)的平台,应用程序不必关心实际上有多少物理内存,也不必关心正在使用的是哪一段内存,甚至不必关心某一个地址是在物理内存里还是在硬盘中。