声明:本文为博主学习记录。如有问题,欢迎指正。
在保护模式下,处理器使用地址转换的两个阶段来获得物理地址:逻辑地址转换和线性地址空间分页。
保护模式下,CPU把程序编译链接后形成的虚拟地址送给MMU,MMU将此虚拟地址转换成物理地址送给存储器,操作系统配合MMU把虚拟地址转换为物理地址。此过程中可分为两个阶段,分别引入了分段机制和分页机制,第一阶段是用分段机制把虚拟地址转换为线性地址,第二阶段是分页机制是把线性地址转换为物理地址,此处所说的线性地址是一段连续的,不分段的,32位处理器范围为0-4GB的地址空间,64位为0-256TB。
一、分段机制
虚拟地址到线性地址的转换方法:线性地址=段的起始地址+偏移量
1.段
现在所说的实模式可以认为是为了和保护模式区分而产生的说法,用来指8088/8086工作的模式,是80286后为了兼容以前的CPU而产生的概念。X86各模式产生及CPU代表顺序如下:
8086 实模式。
80286 两种寻址方式:实模式(兼容8086)和保护模式。
80386 具有三种工作模式:实模式、保护模式、虚拟86模式。
8086 的原理
上古时期,8088/8086(还有80186)处理器只有20位地址总线(即存储空间最大为1MiB),为了暂存数据,8086 处理器内部有 8 个 16 位的通用寄存器,分别是 AX、BX、CX、DX、SP、BP、SI、DI。这些寄存器主要用于在计算过程中暂存数据。其中 AX、BX、CX、DX 可以分成两个 8 位的寄存器来使用,分别是AH、AL、BH、BL、CH、CL、DH、DL,其中 H 就是 High(高位),L 就是 Low(低位)。
IP 寄存器就是指令指针寄存器(Instruction Pointer Register),指向代码段中下一条指令的位置。CPU 会根据它来不断地将指令从内存的代码段中,加载到 CPU 的指令队列中,然后交给运算单元去执行。
如果需要切换进程呢?每个进程都分代码段和数据段,为了指向不同进程的地址空间,有四个 16 位的段寄存器,分别是 CS、DS、SS、ES。
- CS 代码段寄存器(Code Segment Register),通过它可以找到代码在内存中的位置;
- DS 是数据段的寄存器,通过它可以找到数据在内存中的位置。
- SS 是栈寄存器(Stack Register)。push 就是入栈,pop 就是出栈,凡是与函数调用相关的操作,都与栈紧密相关。
如果运算中需要加载内存中的数据,需要通过 DS 找到内存中的数据,加载到通用寄存器中,应该如何加载呢?对于一个段,有一个起始的地址,而段内的具体位置,称为偏移量(Offset)。在 CS 和 DS 中都存放着一个段的起始地址。代码段的偏移量在 IP 寄存器中,数据段的偏移量会放在通用寄存器中。
这时候问题来了,CS 和 DS 都是 16 位的,也就是说,起始地址都是 16 位的,IP 寄存器和通用寄存器都是 16 位的,偏移量也是 16 位的,但是 8086 的地址总线地址是 20 位。怎么凑够这 20 位呢?方法就是“*起始地址 16+ 偏移量”,也就是把 CS 和 DS 中的值左移 4 位,变成20 位的,加上 16 位的偏移量,这样就可以得到最终 20 位的数据地址。
后来计算机的发展日新月异,内存越来越大,总线也越来越宽。在 32 位处理器中,有32 根地址总线,可以访问 2^32=4G 的内存。使用原来的模式肯定不行了,但是又不能完全抛弃原来的模式,因为这个架构是开放的。一定要和原来的架构兼容,而且要一直兼容,这样大家才愿意跟着你这个开放平台一直玩下去。如果你朝令夕改,那其他厂商就惨了。
80386使用了8个32位通用寄存器(均为8086通用寄存器扩展而来),数据总线与地址总线均为32位(为了与16位寄存器区分,32位寄存器名称前均加了一个E,如AX变为EAX,SP变为ESP)。另外,80386使用了6个段寄存器(均为16位),与之匹配的,有6个段描述符寄存器(32位,用来存放从段描述符表中取出的数据)。
(资料来源于网络)
段是实现虚拟地址到线性地址转换机制的基础。8086处理器里段起始地址就是保存在段寄存器里,而80386开始改动比较大。在保护方式下,段的特征有以下三个:段基址,段限长,段属性。
这三个特征存储在段描述符(segment descriptor)之中,用以实现从逻辑地址到线性地址的转换。
段描述符存储在段描述符表之中,段描述符表放在内存的某个地方。表格中的一项一项是段描述符(Segment Descriptor)。段描述符里面才是真正的段的起始地址。而段寄存器里面保存的是在这个表格中的哪一项,称为选择子(Selector)。
要将逻辑地址转换为线性地址,处理器执行以下操作:
要在保护模式下获取物理地址,需要执行以下步骤:
- 先用段选择器(段寄存器)找到表格中的一项,从该项中一项中拿到段描述符。然后将描述符加载到段寄存器的隐藏部分中。(当然为了快速拿到段起始地址,段寄存器会从内存中拿到 CPU 的描述符高速缓存器中,所以仅当将新的段选择器加载到段寄存器中时才需要执行此步骤。)
- 检查段描述符,以检查段的访问权限和范围,以确保段可访问且偏移量在段的限制内。
- 段描述符中的段的基地址加上偏移量以形成线性地址。
(如果不使用分页,则处理器会将线性地址直接映射到物理地址。 如果线性地址空间分页了,第二步将线性地址转换为物理地址。)
段描述符表有三种,GDT、LDT和IDT。结构如下:
-
全局描述符表GDT(Gloabal Descriptor Table)。整个系统中只有GDT(一个处理器对应一个)总共有8192个表项,可以存放在内存的任何位置,GDT不属于任何段,那么如何获得GDT的入口地址呢? Intel使用一个48位的寄存器,
GDTR
存储GDT的入口地址。GDTR
内容包含两个部分:- 全局描述表的size(16bit)
- 全局描述符表的地址(32bit)
使用
LGDT xxx
将GDT的入口地址装入GDTR
,之后CPU根据GSTR
的内容访问GDT表,GDT描述系统段,包括操作系统本身。每个进程都要在全局描述表GDT中占用两个表项,GDT的第2项和第3项分别用于内核的代码段和数据段。 -
局部描述符表LDT(Local Descriptor Table)。LDT属于程序,LDT描述每个程序的段,包括其代码、数据、堆栈等,相应的有寄存器
LDTR
,与GDTR
相同的功能,LDTR
存放的LDT表的入口地址。linux内核中基本不使用局部段描述表LDT。 -
中断描述符表IDT(Interrupt Descriptor Table)。只有一个,保存着X86异常向量0-255共256个异常处理程序地址(X86异常处理章节描述)。相应的 Intel使用一个48位的寄存器
IDTR
保存IDT表的入口地址。操作IDTR寄存的指令为LIDR
。
2. 段选择子(Segment Selectors)
段选择子是段的一个标识符(16bit),也就是段寄存器的可见部分,它不直接指向段,指向段描述符,一个段选择子包含以下信息:
Index (Bit3-15),从GDT或LDT描述表8192个中选择一项。
TI (table indicator) flag(Bit 2),指示在哪里搜索描述符,0表示选择全局描述符表(GDT)中搜索描述符,否则,则在全局描述符表(GDT)中搜索描述符
Requested Privilege Level (RPL),(Bits 0 -1)表示特权等级0-4。
GDT与LDT的关系:
LDT和GDT从本质上说是相同的,只是LDT嵌套在GDT之中。LDTR记录局部描述符表的起始位置,与GDTR不同的是LDTR的内容是一个段选择子。由于LDT本身同样是一段内存,也是一个段,所以它也有个描述符描述它,这个描述符就存储在GDT中,对应这个表述符也会有一个选择子,LDTR装载的就是这样一个选择子。LDTR可以在程序中随时改变,通过使用lldt
指令。
3. 段寄存器(Segment Registers)
为了减少地址转换时间和编码复杂度,处理器提供了6个段选择器的寄存器。 这些段寄存器中的每一个都支持一种特定类型的内存引用(代码,堆栈或数据)。
- CS 代码段寄存器
- DS 数据段寄存器
- SS 栈寄存器
- ES 、FS、GS扩展段寄存器,用于使附加数据段可用于当前正在执行的程序(或任务)
为了使程序访问段,段的段选择子必须已加载到段寄存器之一中。 因此,尽管系统可以定义数千个段,但只有6个段可立即使用。 通过在程序执行期间将其他段的段选择子加载到这些寄存器中,可以使其他段可用。
每个段寄存器都有一个“可见”部分和一个“隐藏”部分。可见部分就是上面的段选择子 ,(隐藏部分称为"段描述符缓冲器")当将段选择子加载到段寄存器的可见部分时,处理器还将使用 段选择器指向的段描述符的基址、段限制和访问控制信息。 这部分内容程序不能访问,由处理器自动使用。
所以段寄存器中保存的不在是段地址,而是段选择子,真正的段地址位于段寄存器的描述符高速缓冲中。
段寄存器加载指令:
1.直接加载指令,例如MOV
,POP
,LDS
,LES
,LSS
,LGS
和LFS
指令。 这些指令明确了加载的段寄存器。
2.隐式加载指令,例如CALL
,JMP
和RET
指令的远指针版本,SYSENTER
和SYSEXIT
指令以及IRET
,INT n
,INTO
,INT3
和INT1
指令。 这些指令将CS寄存器(有时是其他段寄存器)的内容作为其操作的附带部分进行更改。
4. 段描述符(Segment Descriptors)
4.1描述符结构
到此还没有说GDT或LDT中的段描述符长啥样。段描述符为处理器提供段的大小、位置、访问控制和状态等信息。段描述符通常由编译器,链接器,加载器或操作系统或执行程序创建,而不由应用程序创建。如图。
GDT的作用是用来提供段式存储机制,这种机制是段寄存器和GDT中的描述符共同提供的。每个描述符在GDT中占8字节,也就是 2 个双字(64 bit)。图中,下面是低32位,上面是高32位。
段描述符中的标志和字段如下:
-
段限制Limit[20 bit]位于0-15和48-51之间,它定义了length_of_segment-1。每个段的大小取决于G位(55bit):
- 如果
G
=0,并且段限制为0,则段的大小为1字节。 - 如果
G
=1,并且段限制为0,则段的大小为4096字节。 - 如果
G
=0,并且段限制为0xfffff,则段的大小为1M字节。 - 如果
G
=1,并且段限制为0xfffff,则段的大小为4G字节。
所以,这意味着
如果G
为0,则将Limit解释为1 Byte,该段的最大大小可以为1 MB。
如果G
为1,则限制解释为4096字节= 4K字节= 1页,该段的最大大小可以为4吉字节。 实际上,当G
为1时,Limit的值向左移动12位。 因此,20位+ 12位= 32位 且 2^32= 4 GB。 - 如果
-
Base [32bit]分布在位16-31、32-39和56-63。 它定义了段起始位置的物理地址。
-
DPL 表示描述符的特权级( Descriptor Privilege Level, DPL)。这两位用于指定段的特权级。共有 4 种处理器支持的特权级别,分别是 0、 1、 2、 3,其中 0 是最高特权级别, 3 是最低特权级别。刚进入保护模式时执行的代码具有最高特权级 0(可以看成是从处理器那里继承来的),这些代码通常都是操作系统代码,因此它的特权级别最高。每当操作系统加载一个用户程序时,它通常都会指定一个稍低的特权级,比如 3 特权级。不同特权级别的程序是互相隔离的,其互访是严格限制的,而且有些处理器指令(特权指令)只能由 0 特权级的程序来执行,为的就是安全。这里再次点明了为何叫保护模式。
-
P 是段存在位( Segment Present)。 P 位用于指示描述符所对应的段是否存在于内存中。一般来说,描述符所指示的段都位于内存中。但是,当内存空间紧张时,有可能只是建立了描述符,对应的内存空间并不存在,这时,就应当把描述符的 P 位清零,表示段并不存在。P 位是由处理器负责检查的。每当通过描述符访问内存中的段时,如果 P 位是“ 0”,处理器就会产生一个异常中断,并且处理器将拒绝从该段读取。
-
D/B 位是“默认的操作数大小”( Default Operation Size)或者“默认的堆栈指针大小”,又或者“上部边界”标志。设立该标志位,主要是为了能够在 32 位处理器上兼容运行 16 位保护模式的程序。D=0 表示指令中的偏移地址或者操作数是 16 位的; D=1,指示 32 位的偏移地址或者操作数。
举个例子来说, 如果代码段描述符的 D 位是 0,那么,当处理器在这个段上执行时,将使用 16位的指令指针寄存器 IP 来取指令,否则使用 32 位的 EIP。
对于堆栈段来说,该位被叫做“ B”位,用于在进行隐式的堆栈操作时,是使用 SP 寄存器还是ESP 寄存器。
-
AVL标志(bit52)-可用和保留位。 在Linux中被忽略。
-
L标志(bit 53)指示代码段是否包含本机64位代码。 如果已设置,则代码段以64位模式执行。
4.2段类型
- type/Attribute[5bit]由位40-44表示。 它定义了段的类型以及访问。
- S 位用于指定描述符的类型( Descriptor Type)。当该位是**“ 0”时,表示是一个系统段**;为“ 1”时,表示是一个代码段或者数据段(堆栈段也是特殊的数据段)。要确定该段是代码段还是数据段,可以检查其Ex(bit 43)属性。 如果为0,则该段为数据段,否则为代码段。
段可以是以下类型之一:
第一位(位43)对于数据段为0,对于代码段为1。 接下来的三位(40、41、42)是EWA(扩展可写可访问)或CRA(符合可读可访问)。
X
表示是否可以执行( eXecutable)。数据段总是不可执行的,X
=0;代码段总是可以执行的,因此,X
=1。- 对于数据段来说,
E
位指示段的扩展方向。E
=0 是向上扩展的,也就是向高地址方向扩展的,是普通的数据段;E
=1 是向下扩展的,也就是向低地址方向扩展的,通常是堆栈段。 W
位指示段的读写属性,或者说段是否可写,W
=0 的段是不允许写入的,否则会引发处理器异常中断;W
=1的段是可以正常写入的。- 对于代码段来说,
C
位指示段是否为特权级依从的( Conforming)。C
=0 表示非依从的代码段,这样的代码段可以从与它特权级相同的代码段调用,或者通过门调用; C=1 表示允许从低特权级的程序转移到该段执行。 - R 位指示代码段是否允许读出。代码段总是可以执行的,但是,为了防止程序被破坏,它是不能写入的。至于是否有读出的可能,由 R 位指定。 R=0 表示不能读出,如果企图去读一个 R=0 的代码段,会引发处理器异常中断;如果 R=1,则代码段是可以读出的,即可以把这个段的内容当成 ROM 一样使用。
也许有人会问,既然代码段是不可读的,那处理器怎么从里面取指令执行呢?事实上,这里的R属性并非用来限制处理器, 而是用来限制程序和指令的行为。
数据段和代码段的 A 位是已访问位,用于指示它所指向的段最近是否被访问过。在描述符创建的时候,应该清零。之后,每当该段被访问时,处理器自动将该位置“ 1”。
对于系统段处理器识别以下类型的系统描述符:
-
本地描述符表(LDT)段描述符。
-
任务状态段(TSS)描述符。
-
呼叫门描述符。
-
中断门描述符。
-
陷阱门描述符。
-
任务门描述符。
这些描述符类型分为两类:系统段描述符和门描述符。 系统段描述符指向系统段(LDT和TSS段)。 门描述符本身就是“门”,其中包含指向代码段中的过程入口的指针(调用,中断和陷阱门),或者包含TSS的段选择器(任务门)。
5.段总结
6.Linux 段初始化代码
在 Linux 里面,段表全称段描述符表(segment descriptors),放在全局描述符表GDT(Global Descriptor Table)里面,会有下面的宏来初始化段描述符表里面的表项(注:GDT第一个表项是强制性的空描述符)。
#define GDT_ENTRY_INIT(flags, base, limit) \
{ \
.limit0 = (u16) (limit), \
.limit1 = ((limit) >> 16) & 0x0F, \
.base0 = (u16) (base), \
.base1 = ((base) >> 16) & 0xFF, \
.base2 = ((base) >> 24) & 0xFF, \
.type = (flags & 0x0f), \
.s = (flags >> 4) & 0x01, \
.dpl = (flags >> 5) & 0x03, \
.p = (flags >> 7) & 0x01, \
.avl = (flags >> 12) & 0x01, \
.l = (flags >> 13) & 0x01, \
.d = (flags >> 14) & 0x01, \
.g = (flags >> 15) & 0x01, \
}
一个段表项由段基地址 base、段界限 limit,还有一些标识符组成。
//arch\x86\kernel\cpu\common.c
DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
#ifdef CONFIG_X86_64
/*
* We need valid kernel segments for data and code in long mode too
* IRET will check the segment types kkeil 2000/10/28
* Also sysret mandates a special GDT layout
*
* TLS descriptors are currently at a different place compared to i386.
* Hopefully nobody expects them at a fixed place (Wine?)
*/
[GDT_ENTRY_KERNEL32_CS] = GDT_ENTRY_INIT(0xc09b, 0, 0xfffff),
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xa09b, 0, 0xfffff),
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc093, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER32_CS] = GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff),
#else
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff),
/*
* Segments used for calling PnP BIOS have byte granularity.
* They code segments and data segments have fixed 64k limits,
* the transfer segment sizes are set at run time.
*/
/* 32-bit code */
[GDT_ENTRY_PNPBIOS_CS32] = GDT_ENTRY_INIT(0x409a, 0, 0xffff),
/* 16-bit code */
[GDT_ENTRY_PNPBIOS_CS16] = GDT_ENTRY_INIT(0x009a, 0, 0xffff),
/* 16-bit data */
[GDT_ENTRY_PNPBIOS_DS] = GDT_ENTRY_INIT(0x0092, 0, 0xffff),
/* 16-bit data */
[GDT_ENTRY_PNPBIOS_TS1] = GDT_ENTRY_INIT(0x0092, 0, 0),
/* 16-bit data */
[GDT_ENTRY_PNPBIOS_TS2] = GDT_ENTRY_INIT(0x0092, 0, 0),
/*
* The APM segments have byte granularity and their bases
* are set at run time. All have 64k limits.
*/
/* 32-bit code */
[GDT_ENTRY_APMBIOS_BASE] = GDT_ENTRY_INIT(0x409a, 0, 0xffff),
/* 16-bit code */
[GDT_ENTRY_APMBIOS_BASE+1] = GDT_ENTRY_INIT(0x009a, 0, 0xffff),
/* data */
[GDT_ENTRY_APMBIOS_BASE+2] = GDT_ENTRY_INIT(0x4092, 0, 0xffff),
[GDT_ENTRY_ESPFIX_SS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
[GDT_ENTRY_PERCPU] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
GDT_STACK_CANARY_INIT
#endif
} };
这里面对于 64 位的和 32 位的,都定义了内核代码段、内核数据段、用户代码段和用户数据段。
另外,还会定义下面四个段选择子,指向上面的段描述符表项。启动第一个用户态的进程,就是将这四个值赋值给段寄存器。
//arch\x86\include\asm\segment.h
#define __KERNEL_CS (GDT_ENTRY_KERNEL_CS*8)
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS*8)
#define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS*8 + 3)
#define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS*8 + 3)
通过分析发现,所有的段的起始地址都是一样的,都是 0。这算哪门子分段嘛!intel的意图是将进程的映像分成代码段、数据段和堆栈段,linux内核却不买这个账,所以,在 Linux 操作系统中,并没有使用到全部的分段功能。那分段是不是完全没有用处呢?分段可以做权限审核,例如用户态 DPL 是 3,内核态 DPL 是 0。当用户态试图访问内核态的时候,会因为权限不足而报错。
Linux的设计人员让段的基地址为0,而段的界限为64K,这时任意给出一个偏移量,等式”线性地址=段的起始地址+偏移量“,就变成”线性地址=偏移量“,另外段机制偏移量位宽64bit(或32bit),这恰好是线性地址空间范围,也就是说虚拟地址直接映射到了线性地址,看来,Linux在没有回避段机制的情况下巧妙地把段机制给绕过去了。
所以 Linux 倾向于另外一种从虚拟地址到物理地址的转换方式,称为分页(Paging)。
这是因为以下原因:
- Linux巧妙地绕过了段机制(线性地址=偏移量)
- Linux设计目标之一就是具有可移植性,但很多CPU并不支持段。
- 对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存
页面长时间不用了,可以暂时写到硬盘上,称为换出。一旦需要的时候,再加载进来,叫作
换出。这样可以扩大可用物理内存的大小,提高物理内存的利用率。
二、分页机制
虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。
1.X86三种分页模式
CR0.PG是页映射的总开关。如果CR0.PG = 0,则不使用分页。逻辑处理器将所有线性地址视为物理地址。
如果CR0.PG = 1,则启用分页。只有启用保护(CR0.PE = 1),才能启用分页。启用分页后使用三种分页模式之一。 使用哪种分页模式由CR4.PAE和IA32_EFER.LME的值确定:
- 如果CR0.PG = 1且CR4.PAE = 0,则使用32位分页。
- 如果CR0.PG = 1,CR4.PAE = 1,并且IA32_EFER.LME = 0,则使用PAE(物理地址扩展)分页。
- 如果CR0.PG = 1,CR4.PAE = 1,并且IA32_EFER.LME = 1,则使用4级分页模型。 4级分页仅在支持Intel 64架构的处理器上可用。
三种分页模式在以下细节方面有所不同:
- 线性地址宽度。可以转换的线性地址的大小。
- 物理地址宽度。分页产生的物理地址的大小。
- 页面大小。线性地址转换的粒度。将同一页上的线性地址转换为同一页上的相应物理地址。
- 支持禁用执行权限。在某些分页模式下,可以防止软件从其他可读的页面中获取指令。
- 支持PCID。通过4级分页,软件可以启用一种功能,逻辑处理器可通过该功能为多个线性地址空间缓存信息。当软件在不同的线性地址空间之间切换时,处理器可以保留缓存的信息。
- 支持保护key。通过4级分页,软件可以启用一种功能,通过该功能,每个线性地址都与保护key相关联。软件可以使用新的控制寄存器为每个保护密钥确定软件如何访问与该保护密钥相关联的线性地址。
表4-1说明了这三种寻呼模式之间的主要区别。
2. 2级分页模式
对于常见的x86架构,如果系统是32位,二级分页模型就可满足系统需求;如果32位系统采用PAE(物理地址扩展)模式,Linux使用三级分页模型;如果是64位系统,Linux使用四级分页模型。
32 位环境下,虚拟地址空间共 4GB。如果分成 4KB 一个页,那就是 1M 个页。每个页表项需要 4 个字节来存储,那么整个 4GB 空间的映射就需要 4MB 的内存来存储映射表。如果每个进程都有自己的映射表,100 个进程就需要 400MB 的内存。对于内核来讲,有点大了 。
页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址的页号找到对应的页表项了。
那怎么办呢?可以试着将页表再分页,4G 的空间需要 4M 的页表来存储映射。把这 4M 分成 1K(1024)个 4K,每个 4K 又能放在一个页里面,这样 1K 个 4K 就是 1K 个页,这 1K 个页也需要一个表进行管理,称为页目录表,这个页目录表里面有 1K 项,每项4 个字节,页目录表大小也是 4K。
页目录有 1K 项,用 10 位就可以表示访问页目录录的哪一项。这一项其实对应的是一整页的页表项,也即 4K 的页表项。每个页表项也是 4 个字节,因而一整页的页表项是 1K 个。再用 10 位就可以表示访问页表项的哪一项,页表项中的一项对应的就是一个页,是存放数据的页,这个页的大小是 4K,用 12 位可以定位这个页内的任何一个位置。
这样加起来正好32位,也就是用前10位定位到页表中的一项,将这一项对应的页表取出来共1K项,再用中间10位定位到页表的一项,将这一项对应的存放数据的页取出来,用低12位做页内偏移定位到最终的物理位置。
如果一个进程只需要分配一个数据页,如果只使用页表,就需要分配整整1M个页表项共4M的内存,但现在使用了页目录,1K个页目录需要全部分配占用4K内存,但里面只有一项使用。到了页表项,只需分配能够管理那个数据页的页表项页4K,共8K内存,与4M相比大大节省了内存。
2. 4级分页模式
4级分页模式用于X86_64处理器。配置CR0.PG = 1,CR4.PAE = 1,并且IA32_EFER.LME = 1,则逻辑处理器将使用4级分页。
4级分页将48位线性地址转换为52位物理地址。尽管52bit对应于寻址空间未4P Byte,但线性地址只有48位。 在任何给定时间最多可以访问256 TB的线性地址空间。
CR3寄存器用于查找第一个分页结构PML4表。 4级分页可以将线性地址映射到4 KB页面,2 MB页面或1 GB页面。Linux常用4K页,还可配置为大页(hupage)2 MB或1GB页面。
四级分页只是在中间又加了二层索引,4个索引分别如下:
- 全局页目录项PGD(Page Global Directory)
- 上层页目录项PUD(Page Up Directory)
- 中间页目录项PMD(Page Middle Directory)
- 页表项PTE(Page Table Entry)
Long mode
长模式是x86_64处理器的native模式。 x86_64和x86区别如下:
64位模式提供以下功能:
从r8到r15的8个新通用寄存器
现在所有通用寄存器均为64位
64位指令指针-RIP
一种新的操作模式-长模式;
64位地址和操作数;
RIP相对寻址。
长模式是传统保护模式的扩展。 它包含两个子模式:
- 64位模式
- 兼容模式。
要切换到64位模式,我们需要执行以下操作:
- 启用PAE;
- 建立页表并将顶层页表的地址加载到cr3寄存器中;
- 启用EFER.LME;
- 启用分页。
- 通过设置cr4控制寄存器中的PAE位。
在 x86 体系结构中,提供了一种以硬件的方式进行进程切换的模式,对于每个进程,x86 希望在内存里面维护一个 TSS(Task State Segment,任务状态段)结构。用LTR STR
两条指令操作TSS寄存器。这里面有所有的寄存器。
另外,还有一个特殊的寄存器 TR(Task Register,任务寄存器),指向某个进程的 TSS。更改TR 的值,将会触发硬件保存 CPU 所有寄存器的值到当前进程的 TSS 中,然后从新进程的TSS 中读出所有寄存器值,加载到 CPU 对应的寄存器中。
参考
英特尔® 64 位和 IA-32 架构软件开发人员手册第 3 卷 :系统编程指南
极客时间专栏-趣谈Linux操作系统
《linux内核源代码情景分析》