您的位置:首页 > 编程语言 > C语言/C++

内存管理:算法及其c/c++实现 翻译七

2006-02-28 17:26 507 查看
保护模式下的分页机制

当使用分页机制时,图1.9中的地址解析方案就变得更为复杂了,当你看到图1.15之前,先深呼吸一下,不要紧张。



Figure1.15

基本来说,我们采用图1.9中的地址解析过程,并加上分页机制的薄记工作所需要的几个步骤,在图1.9中由段描述符和偏移量形成的地址不再是物理内存的字节地址,这里用了另外的方法形成一个32位的地址,这个32位的地址由三个不同的偏移地址构成,两位偏移地址是10位长度,最后一个偏移地址是12位的长度。
注意:我将把这个由三部分组成的32位的值称为线性地址,以此与内存字节的物理地址相区别。GDTR寄存器里存放着GDT数据结构的基线性地址,现在你知道我当初为什么强调这个区别了吧。L。运行于使用分页机制的保护模式下的代码交替地使用线性地址,这样的代码使用的是“假的”32位的地址,但对代码来说,这个地址是足够真实,掩藏在其中的具体实现是处理器把假的(或者说线性的)地址解析成实际物理存储地址。
注意:Intel平台上最初使用分页机制是为了用EMM386.exe程序填充系统内存(oxB0000到oxFFFFF)中没有使用的区域
线性地址的最后10位(译者注:原文是The last 10 bytes,此处有误)是页目录里某一项的偏移地址,页目录是一个由32位的项组成的数组,它的基地址存储在CR3控制寄存器里,页目录项里包括页表的基地址等信息。
假定页目录项指定了某个页表的基地址,则线性地址的中间10位作为页表内的索引地址,此索引地址确定了一个32位的页表项,32位的页表项里包括实际的4KB内存页的基地址等信息。
现在该是线性地址的头12位起作用的时候了,这个12位的偏移地址加到页表项的基地址上,其和就是虚拟内存中字节的实际物理地址,这个地址所指的字节或者在DRAM中,或者已经被换页到了磁盘上。你可能注意到了,在这种情况下,12位的偏移量限定了内存页的大小是4096个字节(4KB)。
注意:存储在页表项里的基地址是20位的大小,处理器设定这20位是32位基地址中最有意义的,换句话说就是另外的12位是默认设置的,它们默认都是零。
例如,基地址为:0XCAEEB 实际是 0xCAEEB [0] [0] [0]。
这和实模式有一点相象,在实模式下,用16进制表示的段地址后默认有一个0,这种方式确保了页的基地址总是4KB的倍数。

注意: 大多数操作系统为每一个进程分配了一个属于自己的页目录,以便程序的线性地址空间映射到不同的物理存储部分,这样,只要页结构项不相同,一个进程就不能访问另外一个进程的物理地址。
如果页在物理内存中,我们称为命中了,如果页不在物理内存中,我们称发生了页错误。当发生页错误时,处理器将产生页错误信号,并把此信息用处理异常的数据结构传递给操作系统,操作系统设置这个数据结构。当操作系统收到这个错误信号时,它将把需要的页加载进DRAM。页错误是Intel处理器上实现虚拟存储的核心内容。
现在你明白了当你同时运行多个程序时你的硬盘为什么忙疯了的原因了,计算机正忙着从硬盘上移进移出程序或程序的某些部分呢!当你的计算机加载了许多程序时,计算机停滞不工作的现象发生了,因为计算机忙着管理这些程序都忙不过来,哪有时间去实际运行它们了。
注意:为了简单起见,我将一直使用32位地址线的4KB大小的分页方案,这一点也可以从图1.15中看出来。Intel处理器的分页方案中有页的大小为4MB或2MB的情况,我不知道这种情况有什么更大的好处,也许,这种更大的分页设置是为了帮助内核保存它们自己的映象。有一些高可用性操作系统(像EROS)周期性地保存整个操作系统的映象,从传统意义上讲,使用这两种更大的分页方案唯一有价值的地方是对磁盘I/O硬件上有大量的改进。
让我们好好看看页目录项和页表项里到底是一些什么内容,如图1.16

页目录项

AVL = 操作系统使用,
PS = 页大小(如果为0,则页大小为4KB)
O = 总是0
G = 全局标志(可忽略)
A = 是否被访问的标志(如果这个页最近被方位过,则设为1)
CD = 是否使用了缓存(如果要阻止页表进入缓存,则设为1)
WT = 写通(当使用缓存、并且缓存被增强时使用这一位)
US = 用户或监管(如果此位为0,则页表有监管特权)
RW = 读或写(如果此位为0,则页表属性为只读)
P = 如果为1,则被指向的页表目前在物理内存之中



figure1.16

一个页目录项里有许多与缓存相关的特定目的的标志,不过,在此我将不会深入探讨缓存的有关内容,页目录项里真正重要的字段是页表的基地址和页尺寸大小的标志(PS)。存在标志P由操作系统使用,用以指示页表目前是否在内存中,如果不在,页错误机制将使此页加载进内存,以便地址解析能够顺利进行下去。然而,大多数操作系统是很聪明的,它们会把极其重要的数据结构留在内存中。
页表项的布局如图1.17。正如你所见,它的结构和页目录项极其相似,不同点在于页目录项里的字段涉及到的是页表,而页表项里的字段涉及到的是4KB的内存页。
页表项

AVL = 操作系统使用,
G = 全局标志(可忽略)
O = 总是0
D = 是否是脏页(当设为1时,页已经被写过)
A = 是否被访问的标志(如果这个页最近被方位过,则设为1)
CD = 是否使用了缓存(如果要阻止页表进入缓存,则设为1)
WT = 写通(当使用缓存、并且缓存被增强时使用这一位)
US = 用户或监管(如果此位为0,则页表有监管特权)
RW = 读或写(如果此位为0,则页表属性为只读)
P = 如果为1,则4Kb的页目前在物理内存之中



figure 1.17

在页表项中你可能注意到有一个称为脏位(Dirty Bit)的位,用它可以显示正在被使用的页是否最近被写过。设置存在标志位P和脏位D有助于操作系统管理分页的过程。当页被初次加载进DRAM时,操作系统通常清除脏位标志,当页初次被更新时,处理器将设置脏位标志。
我们已经知道了CR0里的PE标志可以使处理器进入保护模式,处理器在使用分页功能时甚至需要更多的控制寄存器的参与。图1.18对一些重要的寄存器做了一个小小的总结,灰色部门的保留区不能被修改。



Figure 1.18

CR0用作控制处理器的模式和状态。在上一部分,我们探讨了PE标志,它负责使处理器进入保护模式。就分页而言,CR0中另一个重要的标志是位于其最高位的PG,当PG标志被设置时,处理器将使用分页机制,当PG位被清除时,线性地址就被当作物理地址。
CR1被保留,用一种更好的方法说就是它没有用于任何目的,所以在图1.18中我没有把它画出来,在以后的讲解中可以忽略CR1,我猜测CR1可能是为以后使用的。
注意:在你渴之前挖一口井不是一个坏注意。任何称职的架构师都会为他设计的物品留下将来修正和扩充功能的余地。如果你用过Win32 API,毫无疑问,你将注意到许多以void *为类型的函数参数被保留下来作为将来使用。
CR2寄存器用来存储发生页错误的线性地址。还好,这个寄存器整个都用来存放地址。
CR3寄存器在分页机制里物理地址的解析过程中扮演着重要的角色,这个寄存器存放着页目录的基地址。如果你回过头再看看图1.15,你就明白了为什么说CR3在分页机制里物理地址的解析过程中扮演着重要的角色。如果CR3里的值被破坏了,那么你就得与你的内存管理器吻别了。这个寄存器里的另外两个标志PCD和PWT与缓存有关,我们不将单独讨论它们。
CR4寄存器在一些高级机制中使用,例如,设置PAE标志可以使另外四根地址线被使用,这样,地址线将达到36根。要注意的是为了使PAE生效,CR0里的PG标志也必须被设置。如果标志位PSE被设置位0,则页大小为4KB,如果PSE被设为1,则页大小为4MB,如果PAE和PSE都设置为1,则页大小为2MB。

分页机制的保护模式

一般来说,分页机制用作人为地扩充处理器能够访问的内存的数量,然而,Intel结构的处理器在分页机制中加入了很多功能,这些功能就足以使分页机制能单独实现内存保护。事实上你完全可以关闭基于段的保护功能而只是使用分页机制去保护内存。当然,这种情况很少使用,因为这样做还需要很多其它的条件必须得到满足。
首先得产生一个单板内存模式(flat memory model),在单板内存模式中,有一个很大的段,它是可能的最简单的内存保护模式。所有的事情共享一个内存段,所有由分段提供的保护功能都被扔到了九霄云外。通过创建有三个组成项的GDT可以实现一个单板内存模式,GDT中的第一项由于一些原因没有被使用,所以,在某种意义上它只是一个占位符,第二和第三项用作代码段和数据段,此处的技巧是使两者的描述符都指向同一个段,这个段地址是0x00000000,大小是4GB。大小被限制到最大的可能值(0xFFFFFFFF)是为了阻止抛出“超出范围”的例外。
如果在汇编指令里使用诸如DS、ES或者CS段寄存器,必须使用一个段选择器来索引找出GDT中两个有效的描述符中的其中一个。记住有三个可能的段选择器,其中的两个段选择器指向代码和数据段描述符,一个段选择器指向空的段描述符,此选择器就称为空选择器。如果使用了0特权,那么这三个选择器将如下所示:
段描述符 段选择器
0 0(空选择器)
1 0x08
2 0x10
单板内存模式结构如图1.19所示:



figure 1.19

注意:不要忘了GDT里第一个段描述符是空的,这一点颇为蹊跷,Intel文档里好像在提到它时是忽略过去的。
如果使用单板内存模式,传统的分段检查没有一个生效。如果使用了分页机制,那么我们就有两种方法去保护内存区域。这两种基于分页的机制依赖于页表项里的信息。
如果你再看看图1.17,你会发现有一个读写标志位(第一位)和一个用户/监管标志位(第二位)。用户/监管标志位可以用来构造两圈的安全方案。例如,把操作系统占用的页放在监管模式,把用户程序占用的页放在用户模式,这样,内核能受到保护而不会被恶意的程序所侵害。如果应用程序试图访问监管模式下的页,处理器可以产生页错误信息。
读写标志可以用来加强用户/监管模式的分离。当处理器在监管页里执行代码时,它能任意的读写,换句话说,内存页的只读标志被忽略了。然而,如果处理器在用户页里执行代码,那么它只能读写到其它的用户级别的页,如果它竭力访问监管状态的页,那么,处理器将产生一个页错误。用户页间的访问依赖于页的读写标志,用户级别的代码必须遵守其它用户页是否允许读写的规则。

地址:逻辑地址,线性地址和物理地址

在这一章我已经对地址类型做了一些区别,的确很容易混淆它们。我将在这部分再次区别地址类型之间的差别,在Intel硬件上,有三类地址:

物理地址(Physical)
逻辑地址(Logical)
线性地址(Linear)

图1.20显示了使用分段机制和分页机制时整个地址的解析过程



figure 1.20

物理地址是DRAM里的字节的地址,它是处理器放置在地址线上的值,此值用来访问芯片上的内存。
逻辑地址是由段寄存器和通用寄存器指定的地址,只有在实模式下逻辑地址才与物理地址一一对应,这是因为实模式没有使用分段机制或分页机制,图1.19描述的方法也没有被使用。你可能要记住的是逻辑地址中的偏移部分不必被存储在通用寄存器里,我只是为了表述的方便才说保存在通用寄存器里。
线性地址是一个32位的值,它是由段描述符里的基地址和通用寄存器里的偏移值指定的。如果没有使用分页机制的话,线性地址就与实际的物理地址一一对应。
如果使用了分页机制,那么,线性地址将被分解为三个部分,并且,为了得到物理地址,必须进行页目录和页表的相关操作。

页框和页

我还记得当我第一次研究内存管理时对页和页框的概念混淆不清,我想很多人也对此混淆不清。页框和页的概念是不一样的,当使用分页机制时,物理内存(DRAM)被分为4KB的大小,我们把它称为页框。可以用像框做类比,在把相片放在像框之前,像框是空的,而页框指定的是一些特定的物理内存块。另一方方面,页只是一块块4096字节的空间,它们或者位于磁盘,或者被加载到了物理内存的页框里面。如果数据页存放在磁盘,而程序试图去访问那一页,那么,这样将产生页错误,操作系统负责捕获错误,并把那一页加载进可以使用的页框中,操作系统也将在页相对应的页表项里设置存在标志位P。
页和页框的关系可以用图1.21描述如下:



figure1.21

案例分析:如何转换到保护模式下

你已经跋涉了理论的千山万水,现在是实际研究保护模式的时候了。在这一部分里,我将带领你从实模式跳到保护模式,由于这一跳很困难,为了避免我被别人标以“虐待狂”的称号,我将坚持只用分段机制,而不用分页机制来讲解,这样难度要小一些。
注意:以下代码使用的是Intel8086汇编器。如果你不熟悉Intel汇编代码,你应该挑一本Barry Brey的书看看,这本书在本章的引用中提到了。
以下六个步骤可以转换到保护模式下:
1. 创建GDT
2. 关中断
3. 使A20地址线可以使用
4. 加载GDTR寄存器
5. 设计CR0寄存器里的PE标志位
6. 执行远程跳转

转换到保护模式是一件极其危险的事情,在转换过程中必须关闭中断,以使处理器心无旁骛地执行转换工作,不然,当有中断发生时,处理器将撂下手头的活去处理中断,这种情况极其危险,因为当处理器去运行中断处理函数时,运行的指令可能改变你的机器所处的状态,这样将会破坏你为了转换模式而做出的努力。
注意:关闭中断是许多处理器使用旗语作为信号量来实现原子操作的方法。通过关闭中断,你可以停掉计算机上任何其它无关紧要的活动(比如:任务转换),这样,代码片断就可以以原子的方式运行(译者注:在化学的历史上,曾认为原子是最小的微粒,不能在把它分开了,此处借用此意)。我翻阅了七、八本关于操作系统的书才跌跌幢幢地理解了Tanenbaum写的关于MINIX的书中的这个概念。
大家可能对第三步会感到比较陌生,即使对Intel汇编语言了解得比较深刻的人也会有这种感受,的确,A20地址线让人感到有一点可怕!J。当8086作为产品发布时,它有20根地址线(从A0到A19),这样,8086可以管理1MB的地址空间,任何对超过1MB地址空间的访问都会被圆整到地址0上,奔腾处理器通常使用32位地址线,然而,奔腾处理器启动时运行在实模式下,它是通过在上电时不使用A20这根地址线来模拟8086的。
有一个称为A20的逻辑与的门电路,地址线A20必须通过此门电路把信号传到外部。就像C语言里的按位与操作一样,与门电路需要两个输入,它的另一个输入与键盘控制器的8042相连。大多数与PC相连的外设有自己专门的微控制器。可以使用OUT汇编指令来对8042进行编程,通过发送1到A20门可以使A20地址线生效。
注意:A20数据线的使用方式基本是为了兼容8086和80286,我认为使用键盘控制器上的引脚解决内存问题不是一个完美的解决方案,也不是一个快速的解决方案!
一旦通过LDTR指令加载了GDTR寄存器,并且在CR0中设置了PE标志位,FAR远程跳转就必须被执行。在Intel的汇编器中,FAR跳转指令被用作实现段间跳转,这导致新的值被加载到代码段寄存器CS和指令指针寄存器IP中,执行FAR远程跳转的目的是在CS中加入段选择器的值。
FAR远程跳转中有窍门的地方是它必须以二进制进行编码。在跳转到保护模式前我们还在实模式下,这意味着汇编器把指令看作传统的16位的方式,如果我们竭力在汇编语言中使用32位的FAR远程跳转来编码,那么,汇编器将或者发出错误或者错误的执行跳转指令。这样,困难的活就留给我们去做了。
下面是汇编源代码:

.486P

; -- pmode.asm --

; create a single 16-bit segment containing real-mode
instructions
CSEG SEGMENT BYTE USE16 PUBLIC 'CODE'
ASSUME CS:CSEG, DS:CSEG, SS:CSEG
ORG 100H

;start here---------------------------------------------------
here:
JMP _main

;make the jump to protected mode------------------------------
PUBLIC _makeTheJump
_makeTheJump:

; disable interrupts
CLI

; enable A20 address line via keyboard controller
; 60H = status port, 64H = control port on 8042
MOV AL,0D1H
OUT 64H,AL
MOV AL,0DFH
OUT 60H,AL

; contents which we will load into the GDTR via LGDTR need
; to jump over the data to keep it from being executed as code
JMP overRdata
gdtr_stuff:
gdt_limit  DW  0C0H
gdt_base   DD  0H
; copy GDT to 0000[0]:0000 ( linear address is 00000000H )
; makes life easier, so don't have to modify gdt_base
; but it also destroys the real-mode interrupt table (doh!)
; REP MOVSB moves DS:[SI] to ES:[DI] until CX=0
overRdata:
MOV AX,OFFSET CS:nullDescriptor
MOV SI,AX
MOV AX,0
MOV ES,AX
MOV DI,0H
MOV CX,0C0H
REP MOVSB

; load the GDTR
LGDT FWORD PTR gdtr_stuff

; set the PE flag in CR0
smsw    ax    ; get machine status word
or      al,1  ; enable protected mode bit
lmsw    ax    ; now in protected mode

; perform manual far jump
DB 66H
DB 67H
DB 0EAH              ; FAR JMP opcode
DW OFFSET _loadshell
DW 8H                ; 16-bit selector to GDT

;end of line, infinte loop
_loadshell:
NOP
JMP _loadshell
RET

; Global Descriptor Table (GDT) ------------------------------
PUBLIC _GDT
_GDT:
nullDescriptor:
NDlimit0_15      dw  0    ; low 16 bits of segment limit
NDbaseAddr0_15   dw  0    ; low 16 bits of base address
NDbaseAddr16_23  db  0    ; next 8 bits of base address
NDflags          db  0    ; segment type and flags
NDlimit_flags    db  0    ; top 4 bits of limit, more flags
NDbaseAddr24_31  db  0    ; final 8 bits of base address

codeDescriptor:
CDlimit0_15      dw  0FFFFH    ; low 16 bits of segment limit
CDbaseAddr0_15   dw  0    ; low 16 bits of base address
CDbaseAddr16_23  db  0    ; next 8 bits of base address
CDflags          db  9AH  ; segment type and flags
CDlimit_flags    db  0CFH ; top 4 bits of limit, more flags
CDbaseAddr24_31  db  0    ; final 8 bits of base address

dataDescriptor:
DDlimit0_15      dw  0FFFFH    ; low 16 bits of segment limit
DDbaseAddr0_15   dw  0    ; low 16 bits of base address
DDbaseAddr16_23  db  0    ; next 8 bits of base address
DDflags          db  92H  ; segment type and flags
DDlimit_flags    db  0CFH ; top 4 bits of limit, more flags
DDbaseAddr24_31  db  0    ; final 8 bits of base address

;main--------------------------------------------------------
PUBLIC_main
_main:

PUSH BP
MOV BP,SP

; set up temporary stack
MOV AX,CS
MOV SS,AX
MOV AX, OFFSET CSEG:_tstack
ADD AX,80H
MOV SP,AX

CALL _makeTheJump

POP BP
RET

;temp stack--------------------------------------------------
PUBLIC _tstack
_tstack DB 128 DUP(?)

CSEG ENDS
END here




figure1.22
注意:Borland还在卖附带有16位DOS编译器的Turbo C++套件,Microsoft还在卖Visual C++1.52,它也能产生16位的代码。Internet上也有许多免费的编译器可以创建16位的可执行文件。我比较喜欢Dave Dunfield的MICRO-C编译器。
当计算机接通电源后,BIOS (它被烧进ROM中)开始行动起来,它寻找一个可以启动的设备。记住PC是以实模式启动的。为了简化,我们假设BIOS从驱动器A中加载可以启动的磁盘(译者注:软盘)。BIOS将把磁盘(译者注:软盘)的启动扇区加载到DRAM的0000[0]:7c00地址(也就是物理地址0x07c00),这是第一步操作。
一旦BIOS把启动扇区加载进了DRAM,它就把控制权交给了位于DRAM中地址为0000[0]:7c00的指令。然而,还有许多BIOS服务可以通过中断访问,这是BIOS扮演重要角色的地方,因为还有一些简单的磁盘I/O中断是用来加载内核的。
磁盘扇区的大小为512个字节,这样大小的代码只够把内核从磁盘(译者注:硬盘)加载到内存中(第二步操作)并且转换到保护模式。在转换到保护模式快结束时,16位的启动扇区代码执行一个我们编写好的FAR远程跳转,跳转到内核的入口点(第三步操作)。内核接管了以后的事情,这样,系统现就运行在保护模式下,并且执行的是32位的指令(第四步操作)。
注意:你不得不在DOS下运行此程序,当我这样说时我是说必须运行在像DOS6.22这样的环境下,不要在Windows2000的虚拟DOS平台下运行此程序,即使在DOS下运行此程序,你还要确保别的内存管理软件(例如:HIMEM.SYS、EMM386等)没有被安装。不幸的是,你将需要重启你的机器,看看我是怎样覆盖实模式下位于底端内存的中断向量表吧。
如果你已经创建了这样的一个启动扇区,并且对深入讨论下去比较感兴,我能给你一些建议。首先,为了节省空间,你应该使用汇编代码写启动扇区,其次,你的启动代码应该只使用BIOS中断与硬件打交道,你不应该调用DOS中断与硬件打交道,启动时DOS还不知道在哪呢!
另外,你应该编译和连接启动扇区的代码成为16位的.COM二进制文件形式。PC启动时处在实模式下,并且它只能运行16位的指令集。启动扇区里必须是纯粹的代码,这意味着在最后编译好的可执行文件中不能有额外的数据格式存在,这就是为什么我们要编译成.COM的文件形式,因为此文件中是纯粹的二进制代码,没有其它的信息在里面。
为了把这些代码放到磁盘(译者注:软盘)的启动扇区,你需要使用Microsoft的Debug工具,下面的Debug命令可以实现这个任务。
C : / code / boot > debug boot.com
- l
- w cs : 0100 0 0 1
- q
C : / code / boot >

-1命令把boot.com文件加载到内存,默认情况下boot.com文件被debug命令加载到地址CS:0x0100。下一条命令把那个地址开始的指令写到驱动器A(也就是驱动去0)的逻辑扇区0上,一个512字节的扇区被写入。下面是W命令的通用格式,这样更容易理解:
w startAddr driverLetter startSector nSectors
做完上面的事后,剩下的事情就是写内核,并把内核调到磁盘(译者注:硬盘)上了。你需要一个32位的编译器来写内核,你应该意识到编译器将把你的内核封装在一些可执行文件的格式里(例如:Windows的PE文件格式,Portable Executable),不过,我听说GCC有编译选项可以让你创建纯粹的32位的二进制代码。
处理这种额外的打包的方法是跳过那些额外的部分,也就是说,启动扇区的代码应该考虑额外的格式化的头的因素,应该跳过它们。你需要反汇编你的内核文件,从中找到格式化的头在哪里结束,内核代码在哪里开始的信息。
你能使用debug把你的内核写入磁盘(译者注:硬盘),方法与把启动扇区写入软盘一样。

本章最后的思考

现在,你明白了内存管理需要操作系统和处理器紧密结合起来工作。然而,毫无疑问,硬件和操作系统所扮演的角色是互补的。
处理器定义了访问内存、保护内存、模拟内存的一些机制。硬件期望诸如GDT和页目录这样的一些特定的数据在需要它的地方,但是这些只是处理器提供的一些机制的扩充。
操作系统的责任是利用处理器提供的服务和实现使用这些服务的一些必须具备的策略。在下一章里,我们将看到不同的操作系统是怎样实现管理内存的主题。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: