此答案的版本具有不错的 TOC 和更多内容。
我将更正报告的任何错误。如果您想进行较大的修改或添加缺失的方面,请根据您自己的答案进行修改以获得当之无愧的代表。可以直接合并小的编辑。
示例代码
最小示例:https ://github.com/cirosantilli/x86-bare-metal-examples/blob/5c672f73884a487414b3e21bd9e579c67cd77621/paging.S
就像编程中的其他一切一样,真正理解这一点的唯一方法是使用最少的示例。
使这成为“困难”主题的原因在于,最小的示例很大,因为您需要制作自己的小型操作系统。
英特尔手册
尽管没有示例就无法理解,但请尝试尽快熟悉手册。
英特尔在英特尔手册第 3 卷系统编程指南 - 325384-056US 2015 年 9 月第 4 章“分页”中描述了分页。
特别有趣的是图 4-4“32 位分页的 CR3 格式和分页结构条目”,它给出了关键数据结构。
MMU
分页由 CPU 的内存管理单元(MMU) 部分完成。与许多其他(例如x87 协处理器、APIC)一样,这在早期是由单独的芯片组成的,后来被集成到 CPU 中。但该术语仍在使用。
普遍事实
逻辑地址是“常规”用户空间代码中使用的内存地址(例如rsi
in的内容mov eax, [rsi]
)。
首先分段将它们转换为线性地址,然后分页将线性地址转换为物理地址。
(logical) ------------------> (linear) ------------> (physical)
segmentation paging
大多数时候,我们可以将物理地址视为实际 RAM 硬件内存单元的索引,但这并不是 100% 正确的,因为:
分页仅在保护模式下可用。在保护模式下使用分页是可选的。PG
如果设置了寄存器的位,则分页打开cr0
。
分页与分段
分页和分段之间的一个主要区别是:
- 分页将 RAM 分成大小相等的块,称为页面
- 分段将内存分成任意大小的块
这是分页的主要优点,因为相同大小的块使事情更易于管理。
分页变得如此流行,以至于在 x86-64 中以 64 位模式(新软件的主要操作模式)中放弃了对分段的支持,它只存在于模仿 IA32 的兼容模式中。
应用
分页用于在现代操作系统上实现进程虚拟地址空间。使用虚拟地址,操作系统可以通过以下方式在单个 RAM 上容纳两个或多个并发进程:
- 两个程序都不需要对另一个程序一无所知
- 两个程序的内存都可以根据需要增长和缩小
- 程序之间的切换非常快
- 一个程序永远无法访问另一个进程的内存
历史上,分页出现在分段之后,并在很大程度上取代了它以实现现代操作系统(如 Linux)中的虚拟内存,因为它更容易管理固定大小的内存块而不是可变长度的段。
硬件实现
就像保护模式下的分段(修改段寄存器会触发 GDT 或 LDT 的加载)一样,分页硬件使用内存中的数据结构来完成其工作(页表、页目录等)。
这些数据结构的格式由硬件固定,但由操作系统正确设置和管理 RAM 上的这些数据结构,并告诉硬件在哪里找到它们(通过cr3
)。
其他一些体系结构几乎完全将分页留在软件手中,因此 TLB 未命中会运行操作系统提供的函数来遍历页表并将新映射插入 TLB。这使得页表格式由操作系统选择,但使得硬件不太可能像 x86 那样将页遍历与其他指令的乱序执行重叠。
示例:简化的单级分页方案
这是一个分页如何在 x86 架构的简化版本上运行以实现虚拟内存空间的示例。
页表
操作系统可以为他们提供以下页表:
操作系统为进程 1 提供的页表:
RAM location physical address present
----------------- ----------------- --------
PT1 + 0 * L 0x00001 1
PT1 + 1 * L 0x00000 1
PT1 + 2 * L 0x00003 1
PT1 + 3 * L 0
... ...
PT1 + 0xFFFFF * L 0x00005 1
操作系统为进程 2 提供的页表:
RAM location physical address present
----------------- ----------------- --------
PT2 + 0 * L 0x0000A 1
PT2 + 1 * L 0x0000B 1
PT2 + 2 * L 0
PT2 + 3 * L 0x00003 1
... ... ...
PT2 + 0xFFFFF * L 0x00004 1
在哪里:
页表位于 RAM 上。例如,它们可以位于:
--------------> 0xFFFFFFFF
--------------> PT1 + 0xFFFFF * L
Page Table 1
--------------> PT1
--------------> PT2 + 0xFFFFF * L
Page Table 2
--------------> PT2
--------------> 0x0
两个页表在 RAM 上的初始位置是任意的,由操作系统控制。由操作系统来确保它们不重叠!
每个进程都不能直接接触任何页表,尽管它可以向操作系统发出导致页表被修改的请求,例如请求更大的堆栈或堆段。
一个页面是一个 4KB(12 位)的块,由于地址有 32 位,因此标识每个页面只需要 20 位(20 + 12 = 32,因此 5 个十六进制字符)。该值由硬件固定。
页表条目
页表是……页表条目的表!
表条目的确切格式由硬件确定。
在这个简化的示例中,页表条目仅包含两个字段:
bits function
----- -----------------------------------------
20 physical address of the start of the page
1 present flag
所以在这个例子中,硬件设计者可以选择L = 21
.
大多数真实的页表条目都有其他字段。
将事物对齐为 21 位是不切实际的,因为内存可以按字节而不是按位寻址。因此,即使在这种情况下只需要 21 位,硬件设计人员也可能会选择L = 32
加快访问速度,而只保留剩余的位以供以后使用。x86 上的实际值L
是 32 位。
单级方案中的地址转换
一旦操作系统设置了页表,线性地址和物理地址之间的地址转换就由硬件完成。
当操作系统想要激活进程 1 时,它会将 设置cr3
为PT1
,即进程 1 的表的开头。
如果进程 1 想要访问线性地址0x00000001
,分页硬件电路会自动为操作系统执行以下操作:
将线性地址分成两部分:
| page (20 bits) | offset (12 bits) |
所以在这种情况下,我们会有:
查看第 1 页,因为cr3
指向它。
看条目0x00000
,因为那是页面部分。
硬件知道该条目位于 RAM 地址PT1 + 0 * L = PT1
。
因为它存在,所以访问是有效的
通过页表,页码的位置0x00000
在0x00001 * 4K = 0x00001000
。
要找到最终的物理地址,我们只需要添加偏移量:
00001 000
+ 00000 001
-----------
00001 001
因为00001
是在表上查找的页的物理地址,001
是偏移量。
顾名思义,偏移量总是简单地添加页面的物理地址。
然后硬件在该物理位置获取内存。
同样,进程 1 会发生以下翻译:
linear physical
--------- ---------
00000 002 00001 002
00000 003 00001 003
00000 FFF 00001 FFF
00001 000 00000 000
00001 001 00000 001
00001 FFF 00000 FFF
00002 000 00002 000
FFFFF 000 00005 000
例如,当访问 address 时00001000
,page 部分是00001
硬件知道它的页表条目位于 RAM 地址:(PT1 + 1 * L
因为1
page 部分),它会在那里寻找它。
当操作系统要切换到进程 2 时,它只需要cr3
指向第 2 页即可。就是这么简单!
现在,进程 2 将发生以下翻译:
linear physical
--------- ---------
00000 002 00001 002
00000 003 00001 003
00000 FFF 00001 FFF
00001 000 00000 000
00001 001 00000 001
00001 FFF 00000 FFF
00003 000 00003 000
FFFFF 000 00004 000
对于不同的进程,相同的线性地址转换为不同的物理地址,这仅取决于内部的值cr3
。
通过这种方式,每个程序都可以期望其数据从 开始0
和结束FFFFFFFF
,而不必担心确切的物理地址。
页面错误
如果进程 1 尝试访问不存在的页面内的地址怎么办?
硬件通过页面错误异常通知软件。
然后通常由操作系统注册一个异常处理程序来决定必须做什么。
访问不在表上的页面可能是编程错误:
int is[1];
is[2] = 1;
但在某些情况下它是可以接受的,例如在 Linux 中:
程序想要增加它的堆栈。
它只是尝试访问给定可能范围内的某个字节,如果操作系统满意,它将将该页面添加到进程地址空间。
该页面已交换到磁盘。
操作系统将需要在进程后面做一些工作才能将页面放回 RAM。
操作系统可以根据页表条目的其余部分的内容来发现这种情况,因为如果当前标志被清除,则页表条目的其他条目完全留给操作系统去做它想要的。
例如,在 Linux 上,当存在 = 0 时:
在任何情况下,操作系统都需要知道哪个地址产生了页面错误才能处理问题。cr2
这就是为什么好的 IA32 开发人员在页面错误发生时将值设置为该地址的原因。然后异常处理程序可以查看cr2
以获取地址。
简化
使这个例子更容易理解的对现实的简化:
所有真正的寻呼电路都使用多级寻呼来节省空间,但这显示了一个简单的单级方案。
页表只包含两个字段:一个 20 位地址和一个 1 位存在标志。
实际页表总共包含 12 个字段,因此省略了其他功能。
示例:多级分页方案
单级分页方案的问题在于它会占用过多的 RAM:每个进程 4G / 4K = 1M 条目。如果每个条目是 4 字节长,那么每个进程4M ,即使对于台式计算机来说也太多了:ps -A | wc -l
说我现在正在运行 244 个进程,所以这将占用我大约 1GB 的 RAM!
出于这个原因,x86 开发人员决定使用减少 RAM 使用的多级方案。
该系统的缺点是访问时间稍长。
在用于没有 PAE 的 32 位处理器的简单 3 级分页方案中,32 个地址位划分如下:
| directory (10 bits) | table (10 bits) | offset (12 bits) |
每个进程必须有一个且只有一个与之关联的页面目录,因此它将至少包含2^10 = 1K
页面目录条目,这比单级方案所需的最小 1M 要好得多。
页表仅根据操作系统的需要进行分配。每个页表都有2^10 = 1K
页目录项
页面目录包含...页面目录条目!页目录项与页表项相同,只是它们指向页表的 RAM 地址而不是表的物理地址。由于这些地址只有 20 位宽,因此页表必须位于 4KB 页的开头。
cr3
现在指向当前进程的页目录在 RAM 上的位置,而不是页表。
页表条目与单级方案完全不同。
页表从单级方案更改,因为:
- 每个进程最多可以有 1K 页表,每页目录条目一个。
- 每个页表正好包含 1K 条目而不是 1M 条目。
在前两个级别上使用 10 位(而不是,比如说,12 | 8 | 12
)的原因是每个页表条目都是 4 个字节长。然后页面目录和页面表的 2^10 个条目将很好地适合 4Kb 页面。这意味着为此目的分配和取消分配页面更快、更简单。
多级方案中的地址转换
操作系统给进程 1 的页面目录:
RAM location physical address present
--------------- ----------------- --------
PD1 + 0 * L 0x10000 1
PD1 + 1 * L 0
PD1 + 2 * L 0x80000 1
PD1 + 3 * L 0
... ...
PD1 + 0x3FF * L 0
PT1 = 0x10000000
操作系统在( 0x10000
* 4K)处为进程 1 提供的页表:
RAM location physical address present
--------------- ----------------- --------
PT1 + 0 * L 0x00001 1
PT1 + 1 * L 0
PT1 + 2 * L 0x0000D 1
... ...
PT1 + 0x3FF * L 0x00005 1
PT2 = 0x80000000
操作系统在( 0x80000
* 4K)处为进程 1 提供的页表:
RAM location physical address present
--------------- ----------------- --------
PT2 + 0 * L 0x0000A 1
PT2 + 1 * L 0x0000C 1
PT2 + 2 * L 0
... ...
PT2 + 0x3FF * L 0x00003 1
在哪里:
PD1
: 进程 1 的页目录在 RAM 上的初始位置。
PT1
和PT2
: RAM 上进程 1 的页表 1 和页表 2 的初始位置。
所以在这个例子中,页面目录和页表可以存储在 RAM 中,如下所示:
----------------> 0xFFFFFFFF
----------------> PT2 + 0x3FF * L
Page Table 1
----------------> PT2
----------------> PD1 + 0x3FF * L
Page Directory 1
----------------> PD1
----------------> PT1 + 0x3FF * L
Page Table 2
----------------> PT1
----------------> 0x0
0x00801004
让我们一步一步翻译线性地址。
我们假设cr3 = PD1
,即它指向刚才描述的页面目录。
在二进制中,线性地址是:
0 0 8 0 1 0 0 4
0000 0000 1000 0000 0001 0000 0000 0100
分组为10 | 10 | 12
:
0000000010 0000000001 000000000100
0x2 0x1 0x4
这使:
- 页面目录条目 = 0x2
- 页表条目 = 0x1
- 偏移量 = 0x4
因此硬件会查找页面目录的条目 2。
页目录表表示页表位于0x80000 * 4K = 0x80000000
. 这是进程的第一次 RAM 访问。
由于页表条目是0x1
,硬件查看页表的条目 1 at 0x80000000
,它告诉它物理页位于地址0x0000C * 4K = 0x0000C000
。这是该进程的第二次 RAM 访问。
最后,分页硬件加上偏移量,最终地址为0x0000C004
。
翻译地址的其他示例是:
linear 10 10 12 split physical
-------- --------------- ----------
00000001 000 000 001 00001001
00001001 000 001 001 page fault
003FF001 000 3FF 001 00005001
00400000 001 000 000 page fault
00800001 002 000 001 0000A001
00801008 002 001 008 0000C008
00802008 002 002 008 page fault
00B00001 003 000 000 page fault
如果页目录项或页表项不存在,则会发生页错误。
如果操作系统想要同时运行另一个进程,它会给第二个进程一个单独的页目录,并将该目录链接到单独的页表。
64 位架构
对于当前的 RAM 大小来说,64 位仍然是太多的地址,因此大多数架构将使用更少的位。
x86_64 使用 48 位 (256 TiB),而传统模式的 PAE 已经允许 52 位地址 (4 PiB)。
这 48 位中的 12 位已经为偏移量保留,剩下 36 位。
如果采用 2 级方法,最好的分割是两个 18 位级别。
但这意味着页面目录将包含2^18 = 256K
条目,这将占用太多 RAM:接近 32 位体系结构的单级分页!
因此,64 位架构会创建更多的页面级别,通常为 3 或 4。
x86_64 在一个9 | 9 | 9 | 12
方案中使用了 4 个级别,因此上层只占用2^9
更高级别的条目。
PAE
物理地址扩展。
使用 32 位,只能寻址 4GB RAM。
这开始成为大型服务器的限制,因此英特尔将 PAE 机制引入 Pentium Pro。
为了缓解这个问题,Intel 增加了 4 条新的地址线,这样 64GB 就可以被寻址了。
如果 PAE 打开,页表结构也会改变。更改它的确切方式取决于 PSE 是打开还是关闭。
PAE
PAE 通过 的位打开和关闭cr4
。
即使总可寻址内存为 64GB,单个进程仍然最多只能使用 4GB。然而,操作系统可以将不同的进程放在不同的 4GB 块上。
PSE
页面大小扩展。
允许页面长度为 4M(如果 PAE 开启,则为 2M)而不是 4K。
PAE
PSE 通过 的位打开和关闭cr4
。
PAE 和 PSE 页表方案
如果 PAE 和 PSE 中的任何一个处于活动状态,则使用不同的寻呼级别方案:
没有 PAE 和没有 PSE:10 | 10 | 12
没有 PAE 和 PSE 10 | 22
:。
22 是 4Mb 页面内的偏移量,因为 22 位地址为 4Mb。
PAE 和无 PSE:2 | 9 | 9 | 12
使用 9 而不是 10 的设计原因是现在条目不能再放入 32 位,这些都被 20 个地址位和 12 个有意义或保留的标志位填充。
原因是 20 位不再足以表示页表的地址:现在需要 24 位,因为向处理器添加了 4 条额外的线。
因此,设计者决定将条目大小增加到 64 位,为了使它们适合单个页表,有必要将条目数减少到 2^9 而不是 2^10。
开头的 2 是一个新的 Page 级别,称为 Page Directory Pointer Table (PDPT),因为它指向页目录并填写 32 位线性地址。PDPT 也是 64 位宽。
cr3
现在指向 PDPT,它必须在前四个 4GB 内存上并在 32 位倍数上对齐以提高寻址效率。这意味着现在cr3
有 27 个有效位而不是 20:32 个倍数的 2^5 * 2^27 来完成前 4GB 的 2^32。
PAE 和 PSE:2 | 9 | 21
设计师决定保留一个 9 位宽的字段以使其适合单个页面。
这留下了 23 位。为 PDPT 留下 2 以保持与没有 PSE 的 PAE 情况一致,留下 21 用于偏移,这意味着页面是 2M 宽而不是 4M。
TLB
Translation Lookahead Buffer (TLB) 是用于分页地址的高速缓存。
由于它是一个缓存,它共享许多 CPU 缓存的设计问题,例如关联性级别。
本节将描述一个具有 4 个单地址条目的简化的全关联 TLB。请注意,与其他缓存一样,真正的 TLB 通常不是完全关联的。
基本操作
在线性地址和物理地址之间发生转换后,它被存储在 TLB 上。例如,一个 4 条目的 TLB 以以下状态开始:
valid linear physical
------ ------- ---------
> 0 00000 00000
0 00000 00000
0 00000 00000
0 00000 00000
>
指示要替换的当前条目。
在页面线性地址00003
转换为物理地址00005
之后,TLB变为:
valid linear physical
------ ------- ---------
1 00003 00005
> 0 00000 00000
0 00000 00000
0 00000 00000
在对其进行第二次翻译后00007
变为00009
:
valid linear physical
------ ------- ---------
1 00003 00005
1 00007 00009
> 0 00000 00000
0 00000 00000
现在如果00003
需要再次翻译,硬件首先查找 TLB 并通过单个 RAM 访问找到它的地址00003 --> 00005
。
当然,00000
它不在 TLB 上,因为没有有效的条目包含00000
作为键。
更换政策
当 TLB 被填满时,旧地址将被覆盖。就像 CPU 缓存一样,替换策略是一个潜在的复杂操作,但一个简单而合理的启发式方法是删除最近最少使用的条目 (LRU)。
使用 LRU,从状态开始:
valid linear physical
------ ------- ---------
> 1 00003 00005
1 00007 00009
1 00009 00001
1 0000B 00003
添加0000D -> 0000A
将给出:
valid linear physical
------ ------- ---------
1 0000D 0000A
> 1 00007 00009
1 00009 00001
1 0000B 00003
凸轮
使用 TLB 可以加快翻译速度,因为初始翻译每个 TLB 级别需要一次访问,这意味着在简单的 32 位方案上为 2,但在 64 位架构上为 3 或 4。
TLB 通常作为一种昂贵的 RAM 实现,称为内容可寻址存储器 (CAM)。CAM 在硬件上实现了关联映射,即一个给定键(线性地址)的结构,检索一个值。
映射也可以在 RAM 地址上实现,但 CAM 映射可能需要比 RAM 映射少得多的条目。
例如,一张地图,其中:
- 键和值都有 20 位(简单分页方案的情况)
- 每次最多需要存储4个值
可以存储在具有 4 个条目的 TLB 中:
linear physical
------- ---------
00000 00001
00001 00010
00010 00011
FFFFF 00000
但是,要使用 RAM 实现这一点,需要 2^20 个地址:
linear physical
------- ---------
00000 00001
00001 00010
00010 00011
... (from 00011 to FFFFE)
FFFFF 00000
这将比使用 TLB 更昂贵。
无效条目
当cr3
更改时,所有 TLB 条目都将失效,因为将使用新进程的新页表,因此任何旧条目都不太可能有任何意义。
x86 还提供了invlpg
显式使单个 TLB 条目无效的指令。其他架构为无效的 TLB 条目提供了更多指令,例如使给定范围内的所有条目无效。
一些 x86 CPU 超出了 x86 规范的要求,并且在修改页表条目和使用它之间提供了比它所保证的更多的一致性,当它尚未缓存在 TLB 中时。显然,Windows 9x 依靠它来确保正确性,但现代 AMD CPU 不提供连贯的页面遍历。英特尔 CPU 会这样做,即使它们必须检测到错误推测才能这样做。利用这一点可能是个坏主意,因为可能不会有太多收获,而且很有可能导致难以调试的微妙时序敏感问题。
Linux内核使用
Linux 内核广泛使用 x86 的分页功能,以允许快速的进程切换和小数据碎片。
在v4.2
中,看下arch/x86/
:
include/asm/pgtable*
include/asm/page*
mm/pgtable*
mm/page*
似乎没有定义结构来表示页面,只有宏:include/asm/page_types.h
特别有趣。摘抄:
#define _PAGE_BIT_PRESENT 0 /* is present */
#define _PAGE_BIT_RW 1 /* writeable */
#define _PAGE_BIT_USER 2 /* userspace addressable */
#define _PAGE_BIT_PWT 3 /* page write through */
arch/x86/include/uapi/asm/processor-flags.h
定义CR0
,特别是PG
位位置:
#define X86_CR0_PG_BIT 31 /* Paging */
参考书目
自由的:
非免费: