《深入理解Linux内核》-2.5. Linux分页
Linux采用一种可以兼容32位和64位系统的分页模型。正如前一章所说,32位系统上使用两级分页是足够的,但是64位系统上需要更多层级的分页。Linux直到2.6.10,使用三级分页,2.6.11之后使用四级分页。图2-12阐述了这四种页表,它们分别是:
- 全局页目录
- 顶层页目录
- 中层页目录
- 页表
全局页目录包含若干顶层页目录的地址,顶层页目录又包含若干中层页目录的地址,中层页目录又包含一些页表的地址。每个页表节点指向一个页帧。因此,线性地址可以分成五个部分。图2-12没有展示它们的位数,因为它们的长度取决于计算机架构。
对于没有启用PAE的32位系统,两级分页就足够了。Linux实际上会忽略顶层页目录和中层页目录。但是为了同时兼容32位和64位系统,顶层页目录和中层页目录的位置依然保存着,Linux定义它们的节点数为1,并使其映射到全局页目录的某个节点。
图2-12. Linux分页模型
对于开启了扩展分页的32位系统,使用三级分页:Linux的全局页目录对应80x86的页目录指针表,顶层页目录被移除,中层页目录对应80x86的页目录,页表对应80x86的页表。
64位系统使用三级或四级分页。
Linux进程处理严重依赖于分页。实际上,线性地址到物理地址的自动转换是的以下设计目标成为可行:
- 位每个进程分配不同的物理地址空间,保证对寻址错误形成有效的保护。
- 区别页面和页帧。当一个页面被保存到页帧中,然后存储到磁盘上,当其被再次取出时,可以被加载出到不同的页帧中。这个是虚拟内存管理的基本要素(参考第17章)。
本章剩下部分,我们将会具体的讲述80x86处理器的分页原理。
正如第9章所提,每个进程有自己的全局页目录和页表,但进程切换时,Linux把cr3寄存器保存在进程描述符中,然后从下个进程的描述符中加载新的值到cr3中,这样,但新进程恢复运行时,分页单元能指向正确的页表。
线性地址映射到物理地址已经变成了一个机械性的任务,虽然它仍然有些复杂。本章接下来的几段列出了一些非常枯燥的函数和宏,内核用它们来寻址和管理页表。你也许现在就想跳过这些函数,但了解它们的作用和原理非常有必要,因为我们在整本书的讨论中经常会遇见它们。
2.5.1. 线性地址字段
以下宏大大简化了对页表的处理:
-
PAGE_SHIFT
定义了偏移字段的位数。对于80x86处理器,它的值为12。2的12次方等于4KB,正好是一个页面的大小。
PAGE_SIZE
宏使用它来返回页面大小,PAGE_MASK
宏产生值0xfffff000,用来计算出偏移字段。 -
PMD_SHIFT
偏移和表字段的总位数;它等于一个中层页目录节点可以映射的区域大小的对数。
PMD_MASK
宏用来取出偏移和表字段的值。 -
PUD_SHIFT
偏移+表+中层页目录的总位数;也就是一个顶层目录节点可以映射的区域大小的对数。
PUD_SIZE
宏用来计算一个全局页目录节点可以映射的空间大小。PUD_MASK
宏用来取出偏移、表、中层页目录的所有位。80x86处理器上,
PUD_SHIFT
总是等于PMD_SHIFT
和PUD_SIZE
,等于4MB或者2MB。 -
PGDIR_SHIFT
偏移+表+中层目录+顶层目录的总位数;也就是一个全局页目录节点可以映射的空间大小的对数。
PGDIR_SIZE
等于一个顶层目录节点可以映射的空间大小。PGDIR_MASK
宏用来取出偏移、表、中层页目录、顶层页目录的所有位。 -
PTRS_PER_PTE, PTRS_PER_PMD, PTRS_PER_PUD, and PTRS_PER_PGD
分别计算页表、中层页目录、顶层页目录、全局页目录的节点数。80x86上当关闭PAE时,它们分别等于1024,1,1,1024,启用PAE时则为512、512、1、4。
2.5.2. 页表处理
pte_t, pmd_t, pud_t, pgd_t分别描述了页表、中层页目录、顶层页目录、全局页目录的字段。
在PAE启用时,它们是64位的数据类型,否则是32位。
五个类型转换宏:__pte
, __pmd
, __pud
, __pgd
和__pgprot
,它们把一个无符号整数转换成对应的类型。另外五个宏:ptr_val
, pmd_val
, pud_val
, pgd_val
, 和pgprot_val
,它们执行相反的转换,把对应的数据类型转换成无符号整数。
内核还提供了几个宏和函数来读取和修改页表节点:
-
pte_none
,pmd_none
,pud_none
, 和pdg_none
,当对应的节点为0时值为1,否则为0。 -
pte_clear
,pmd_clear
,pud_clear
, 和pgd_clear
,清除对应的页表节点,以此来禁止进程使用该节点对应的线性地址。ptep_get_and_clear()
清除页表节点并返回之前的值。 -
set_pte
,set_pmd
,set_pud
, 和set_pgd
设置页表节点的值;set_pte_atomic
等价于set_pte
,但是当启用PAE时,它还保证64位值被原子写入。 -
pte_same(a, b)
,当两个页表节点a,b指向相同页面且具有相同的访问权限时返回1,否则返回0。 -
pmd_large(e)
,如果中层页目录节点e指向大页面(2MB或4MB),则返回1,否则返回0。
pmd_bad
宏用来检测中层页表节点,返回1表示指向一个非法的页表,一般有以下几种情况:
- 对应的页面不在内存中(Present标志被清除)
- 页面只读(Read/Write标志被清除)
- Accessed或者Dirty标志被清除(Linux为每个存在的页表强制地设置为1)。
pud_bad
和pgd_bad
宏总是返回0。没有定义pte_bad
宏,因为对于页表节点来说,无论它指向的页面不在内存中、不可写或者不可访问,都是合法的。
pte_present
宏在页表节点的Present标志或者Page Size标志等于1时返回1,否则返回0。前一章有提到,Page Size标志对于页表节点没有意义,但是内核会在当页面在内存中但是不具有读写和执行权限时设置Present为0,Page Size为1,如此,任何对于这类页面的访问都会产生一个页面错误异常(Present为0),而且内核可以通过Page Size的值判断该异常不是由于缺页引起的。
pmd_present
宏在Present标志为1时返回1,也就是对应的页面或者页表已被加载至内存中。pud_present
和pgd_present
宏总是返回1。
表2-5中列出的函数用来读取页表节点中的标志位。除了pte_file()
,这些函数能正常工作的前提是pte_present
返回1。
表2-5. 页表读取函数
表2-6中列出的函数用来设置页表节点中的标志位。
表2-6. 页面标志设置函数
表2-7列出的宏用来把页面地址和一组保护标志组合到页表节点中,或者相反的从页表节点中提取处页面地址。注意,其中一些宏通过“页描述符”的线性地址来引用一个页面(参考第8章“页描述符”),而不是直接通过页面的线性地址。
表2-7. 页表节点操作宏
表中最后几个函数用来简化页表节点的创建和删除。
当使用两级页表时,中间页目录的创建和删除有点麻烦。正如之前所说,中间页目录包含单个指向子页表的节点,而中间页目录又是全局页目录的节点中的节点。对于页表来说,创建节点更加复杂,因为这个节点指向的页表可能并不存在,这时候就需要分配一个新的页帧,填充0,并添加节点。
当开启PAE时,内核使用三级页表。当内核创建一个新的全局页目录时,它还创建四个对应的中间页目录,它们仅在全局页目录释放的时候才被释放。
对于两级或者三级页表,顶层页目录节点总是对应全局页目录的单个节点。
80x86架构通常有以下函数:
表2-8. 页分配函数
2.5.3. 物理内存布局
内核在初始化阶段会建立一个物理地址映射表,它定义了那些物理地址空间可以被内核使用,那些不能(这些内存用于硬件设备I/O共享内存或者因为相应的页帧包含BIOS数据)。
内核预留以下页帧:
- 落在不可用的物理地址区间中的地址
- 包含内核代码和初始化数据结构的页面
预留页帧中的页面不能被动态分配或者换出到磁盘。
一般情况下,内核被安装在RAM中以0x00100000为起始地址的区域,即从第二个兆字节开始。总共需要的也帧数取决于内核配置,典型的配置使小于3MB。
为什么内核不从RAM第一个兆字节加载?因为PC架构必须要考虑几种特殊情况,比如:
- 第0个页帧被BIOS用来存储硬件配置信息,这些信息在启动自测阶段(Power-On Self-Test)被检测;很多笔记本电脑甚至在系统初始化之后还想这些页帧写入数据。
- 0x000a0000到0x000fffff之间的物理地址预留用作BIOS例程,和映射内存到ISA显卡。
- 第一兆B内存的一些额外页帧被用在特殊型号的电脑。比如IBM ThinkPad把0xa页帧映射到0xa9。
系统启动的早期阶段(参考附录A),内核通过查询BIOS来知道物理内存的大小。最新的计算机中,内核还调用一个BIOS例程来建立一串物理地址和它们对应的内存类型。
然后,内核通过执行machine_specific_memory_setup()
函数来建立物理地址映射(参考表2-9)。当然,内核建立这张表的依据是上面说的那个BIOS列表,如果BIOS列表不可用,内核用默认的规则来建立这张表;0x9f(LOWMEMSIZE())到0x100(HIGH_MEMORY)之间的页帧都被预留。
表2-9. BIOS物理地址映射表举例
表2-9展示了一个典型的128MB内存计算机的内存配置。0x07ff0000到0x07ff2fff之间的地址用来存储系统硬件信息,这些硬件信息在POST阶段被BIOS写入;在系统初始化阶段,内核把这些信息拷贝到相应的内核数据结构,之后标记这些页帧为可用状态。而0x07ff3000到0x07ffffff之间的地址则被映射到ROM芯片上。0xffff0000开始的物理地址被预留用作BIOS的ROM芯片的地址映射(参考附录A)。BIOS有时候不会为某些物理地址提供信息(如表中,0x000a0000到0x000effff之间的地址)。为了安全起见,Linux认为这些地址是不可用的。
BIOS公布的地址对内核并不是全部可见:比如,当开启PAE时,内核也只能寻址4GB空间,即使实际上有更多的物理内存可以使用。setup_memory()
函数在machine_specific_memory_setup()
之后被调用:它通过分析物理地址表来初始化一些变量用来描述内核物理内存布局。这些变量见表2-10:
表2-10. 内核描述物理内存布局的变量
为了是内核被加载到连续的页帧,Linux跳过RAM的第一个1MB空间。显然,那些未被内核预留的页帧将被用作存储动态分配的页面。
图2-13展示了Linux的前3MB内存分布。在此,我们假设内核需要的内存小于3MB。
_text对应物理地址0x00100000,表示内核代码的第一个字节。内核代码的最后一个字节用与之相似的符号_etext表示内核数据分成两部分:已初始化和为初始化的。初始化的数据从_etext开始到_edata结束。接着是未初始化数据,以_end结束。
图片中出现的这些符号并没有在Linux源码中定义,它们在内核编译阶段生成。
图2-13. Linux2.6的前768个页帧(3MB)
2.5.4. 进程页表
进程的线性地址空间分成两部分:
- 从0x00000000到0xbfffffff的线性地址可在内核态和用户态访问
- 从0xc0000000到0xffffffff的线性地址只能在内核态访问
进程允许在用户态时,它提交的地址小于0xc0000000;当它运行在内核态时,它执行内核代码并且提交的地址大于等于0xc0000000。但是在某些情况下,内核必须访问用户态地址来获取或者存储数据。
PAE_OFFSET
宏产生值0xc0000000;这是进程的内核空间的起始地址。本书中,我们经常直接使用0xc0000000来代替PAE_OFFSET
。
全局页表的第一个节点映射到小于0xc0000000的线性地址(禁用PAE时对应前768个节点,启用PAE时对应前三个节点),它的内容由进程自己决定。而剩下的节点对所有进程来说都是相同的,并且等于主内核全局页目录的相应节点(参看下面一段)。
2.5.5. 内核页表
内核维护一套页表供自己使用,它们的根叫做主全局页目录。系统初始化之后,这些页表从不被任何进程或者内核线程使用;但是,它的最高节点被系统中的任意普通进程用来引用全局页目录。
我们会在第8章(非连续内存空间的线性地址)介绍内核是如何在改变主内核全局页目录的时候同步到进程的全局页目录的。
我们这里只讲述内核是如何初始化它自己的页表的。这分为两个阶段。事实上,在内核镜像刚好加载到内存后,CPU仍然运行在实时状态,此时分页还未启用。
第一阶段,内核创建一个有限的地址空间,它包含内核代码和数据段、初始页表、和一些128KB的动态数据结构。这个最小的地址空间刚好够安装内核和它的核心数据结构。
第二阶段,内核利用所有已存的RAM并建立页表。