在Linux操作系统复杂而精妙的架构体系中,内存管理堪称其中的核心与关键。想象一下,系统如同一个繁忙的大型工厂,内存则是工厂里至关重要的原材料仓库,各个进程就像不同的生产线,它们都对内存资源有着迫切的需求。如何高效地管理这些内存,让每一条生产线都能顺畅运转,避免资源浪费与冲突,成了决定系统整体性能的关键因素。
在早期,分段机制曾在操作系统的内存管理领域占据主导地位。但随着技术的飞速发展,新的挑战不断涌现,内存碎片问题日益凸显,就如同仓库中的原材料被零散放置,难以被高效取用,这严重影响了内存的使用效率与系统性能。为了应对这一难题,分页机制应运而生,它如同一位聪明的仓库管理员,重新规划了内存的存储方式,将内存分割成固定大小的小片,也就是内存页,以此来提升内存的空间利用率,逐渐成为现代操作系统内存管理的主流选择。今天,就让我们一同深入探索 Linux 内存分页机制的奥秘,解锁这一高效内存管理方式背后的关键技术 。
一、内存分页机制
在 Linux 的世界里,内存分页机制就像是一位有条不紊的大管家,精心管理着系统的内存资源。简单来说,内存分页机制就是把物理内存和虚拟内存分割成固定大小的小块,这些小块被称作 “页” ,每个页的大小一般为 4KB 或者 8KB。就好比你有一个巨大的仓库(内存),为了更好地管理里面的货物(数据),你把仓库划分成了一个个大小相同的小隔间(页)。
1.1什么是分页机制
分页机制是 80x86 内存管理机制的第二部分。它在分段机制的基础上完成虚拟地址到物理地址的转换过程。分段机制把逻辑地址转换成线性地址,而分页机制则把线性地址转换成物理地址。分页机制可用于任何一种分段模型。处理器分页机制会把线性地址空间划分成页面,然后这些线性地址空间页面被映射到物理地址空间的页面上。分页机制的几种页面级保护措施,可和分段机制保护措施或用或替代分段机制的保护措施。
1.2分页机制如何启用
在我们进行程序开发的时候,一般情况下,是不需要管理内存的,也不需要操心内存够不够用,其实,这就是分页机制给我们带来的好处。它是实现虚拟存储的关键,位于线性地址与物理地址之间,在使用这种内存分页管理方法时,每个执行中的进程(任务)可以使用比实际内存容量大得多的连续地址空间。而且当系统内存实际上被分成很多凌乱的块时,它可以建立一个大而连续的内存空间的映象,好让程序不用操心和管理这些分散的内存块。分页机制增强了分段机制的性能。页地址变换是建立在段变换基础之上的。因为,段管理机制对于Intel处理器来说是最基本的,任何时候都无法关闭。所以即使启用了页管理功能,分段机制依然是起作用的,段部件也依然工作。
分页只能在保护模式(CR0.PE = 1)下使用。在保护模式下,是否开启分页,由 CR0. PG 位(位 31)决定:
- 当 CR0.PG = 0 时,未开启分页,线性地址等同于物理地址;
- 当 CR0.PG = 1 时,开启分页。
1.3分页机制线性地址到物理地址转换过程
80x86使用 4K 字节固定大小的页面,每个页面均是 4KB,并且对其于 4K 地址边界处。这表示分页机制把 2^32字节(4GB)的线性地址空间划分成 2^20(1M = 1048576)个页面。分页机制通过把线性地址空间中的页面重新定位到物理地址空间中进行操作。由于 4K 大小的页面作为一个单元进行映射,并且对其于 4K 边界,因此线性地址的低 12 位可做为页内偏移地量直接作为物理地址的低 12 位。分页机制执行的重定向功能可以看作是把线性地址的高 20 位转换到对应物理地址的高 20 位。
线性到物理地址转换功能,被扩展成允许一个线性地址被标注为无效的,而非要让其产生一个物理地址。以下两种情况一个页面可以被标注为无效的:
1. 操作系统不支持的线性地址。
2. 对应的虚拟内存系统中的页面在磁盘上而非在物理内存中。
在第一中情况下,产生无效地址的程序必须被终止,在第二种情况下,该无效地址实际上是请求 操作系统虚拟内存管理器 把对应的页面从磁盘加载到物理内存中,以供程序访问。因为无效页面通常与虚拟存储系统相关,因此它们被称为不存在页面,由页表中称为存在的属性来确定。
当使用分页时,处理器会把线性地址空间划分成固定大小的页面(4KB),这些页面可以映射到物理内存中或磁盘存储空间中,当一个程序引用内存中的逻辑地址时,处理器会把该逻辑地址转换成一个线性地址,然后使用分页机制把该线性地址转换成对应的物理地址。
如果包含线性地址的页面不在当前物理内存中,处理器就会产生一个页错误异常。页错误异常处理程序就会让操作系统从磁盘中把相应页面加载到物理内存中(操作过程中可能会把物理内存中不同的页面写到磁盘上)。当页面加载到物理内存之后,从异常处理过程的返回操作会使异常的指令被重新执行。处理器把用于线性地址转换成物理地址和用于产生页错误的信息包含在存储与内存中的页目录与页表中。
1.4分页机制与分段机制的不同
分页与分段的最大的不同之处在于分页使用了固定长度的页面。段的长度通常与存放在其中的代码或数据结构有相同的长度。与段不同,页面有固定的长度。如果仅使用分段地址转换,那么存储在物理内存中的一个数据结构将包含其所有的部分。如果使用了分页,那么一个数据结构就可以一部分存储与物理内存中,而另一部分保存在磁盘中。
为了减少地址转换所要求的总线周期数量,最近访问的页目录和页表会被存放在处理器的一个叫做转换查找缓冲区(TLB)的缓冲器件中。TLB 可以满足大多数读页目录和页表的请求而无需使用总线周期。只有当 TLB 中不包含所要求的页表项是才会出现使用额外的总线周期从内存读取页表项。通常在一个页表项很长时间没有访问过时才会出现这种情况。
二、分页机制类型
2.1四级分页机制
前面我们提到Linux内核仅使用了较少的分段机制,但是却对分页机制的依赖性很强,其使用一种适合32位和64位结构的通用分页模型,该模型使用四级分页机制,即
- 页全局目录(Page Global Directory)
- 页上级目录(Page Upper Directory)
- 页中间目录(Page Middle Directory)
- 页表(Page Table)
页全局目录包含若干页上级目录的地址:
- 页上级目录又依次包含若干页中间目录的地址;
- 而页中间目录又包含若干页表的地址;
- 每一个页表项指向一个页框。
因此线性地址因此被分成五个部分,而每一部分的大小与具体的计算机体系结构有关。
2.2不同架构的分页机制
对于不同的体系结构,Linux采用的四级页表目录的大小有所不同:对于i386而言,仅采用二级页表,即页上层目录和页中层目录长度为0;对于启用PAE的i386,采用了三级页表,即页上层目录长度为0;对于64位体系结构,可以采用三级或四级页表,具体选择由硬件决定。
对于没有启用物理地址扩展的32位系统,两级页表已经足够了。从本质上说Linux通过使“页上级目录”位和“页中间目录”位全为0,彻底取消了页上级目录和页中间目录字段。不过,页上级目录和页中间目录在指针序列中的位置被保留,以便同样的代码在32位系统和64位系统下都能使用。内核为页上级目录和页中间目录保留了一个位置,这是通过把它们的页目录项数设置为1,并把这两个目录项映射到页全局目录的一个合适的目录项而实现的。
启用了物理地址扩展的32 位系统使用了三级页表。Linux 的页全局目录对应80x86 的页目录指针表(PDPT),取消了页上级目录,页中间目录对应80x86的页目录,Linux的页表对应80x86的页表。
最终,64位系统使用三级还是四级分页取决于硬件对线性地址的位的划分。
为什么linux热衷:分页>分段
那么,为什么Linux是如此地热衷使用分页技术而对分段机制表现得那么地冷淡呢,因为Linux的进程处理很大程度上依赖于分页。事实上,线性地址到物理地址的自动转换使下面的设计目标变得可行:
给每一个进程分配一块不同的物理地址空间,这确保了可以有效地防止寻址错误。
区别页(即一组数据)和页框(即主存中的物理地址)之不同。这就允许存放在某个页框中的一个页,然后保存到磁盘上,以后重新装入这同一页时又被装在不同的页框中。这就是虚拟内存机制的基本要素。
每一个进程有它自己的页全局目录和自己的页表集。当发生进程切换时,Linux把cr3控制寄存器的内容保存在前一个执行进程的描述符中,然后把下一个要执行进程的描述符的值装入cr3寄存器中。因此,当新进程重新开始在CPU上执行时,分页单元指向一组正确的页表。
把线性地址映射到物理地址虽然有点复杂,但现在已经成了一种机械式的任务。
三、分页机制的工作原理
3.1分页技术核心思想
分页技术的核心思想,是把虚拟地址空间和物理内存都划分成固定大小的页,然后通过页表来建立虚拟页到物理页框的映射关系。打个比方,你有一本厚厚的字典(虚拟地址空间),为了快速找到某个字(数据),你给字典的每一页(虚拟页)都编了号,同时在另一张纸上(页表)记录了每个编号对应的实际页码(物理页框)。
以 x86_64 架构的 4 级页表结构为例,虚拟地址被分成了多个部分 。其中,[47-39] 位表示页全局目录(PGD),[38-30] 位表示上层页目录(PUD),[29-21] 位表示中间页目录(PMD),[20-12] 位表示页表项(PTE),最后的 [11-0] 位则是页内偏移 。CPU 在访问内存时,会先通过 CR3 寄存器找到页全局目录,然后根据虚拟地址中的 PGD 部分找到对应的上层页目录,依此类推,逐级查询,最终找到物理页框号,再结合页内偏移,就能得到准确的物理地址。这个过程就像是你按照索引在多层书架上找一本书,每一层索引都能帮你缩小查找范围,最终准确找到目标书籍。
3.2多级页表设计动机与优势
在早期的内存管理中,曾采用过单级页表,即通过一个页表直接将虚拟地址映射到物理地址。然而,这种方式存在明显的弊端。随着虚拟地址空间的不断增大,例如在 64 位系统中,若使用单级页表来映射所有虚拟地址,所需的页表空间将变得极为庞大。以 48 位地址空间为例,若每个页表项大小为 8 字节,计算可知单级页表需要 256TB 的内存空间,这在实际应用中几乎是不可行的,不仅会占用大量的内存资源,而且管理和维护如此巨大的页表也会面临诸多困难。
为了解决单级页表的空间占用问题,多级页表应运而生。多级页表的设计采用了稀疏存储的策略,它并不是为整个虚拟地址空间都分配页表,而是仅在需要时才分配实际使用的页表项。这样一来,大大节省了内存空间。
以 Linux 的四级页表为例,当一个进程只使用 1GB 内存时,若采用四级页表,其总大小约为 (1GB/2MB × 4 × 8B = 16KB) 。这是因为在四级页表结构中,每一级页表都起到了筛选和定位的作用。当进程访问某个虚拟地址时,首先通过 CR3 寄存器找到页全局目录(PGD),PGD 根据虚拟地址中的相应部分,找到上层页目录(PUD),若该 PUD 对应的页表项存在且有效,则继续通过 PUD 找到中间页目录(PMD),依此类推。如果在某一级发现对应的页表项不存在,说明该虚拟地址尚未被映射到物理内存,系统会根据需要动态分配页表项,而不是预先分配整个页表。
这种按需分配的方式,使得页表仅占用实际使用的内存区域,避免了为未使用的虚拟地址空间分配页表所造成的内存浪费 。同时,多级页表的结构也使得内存管理更加灵活和高效,能够更好地适应不同进程对内存的需求。
四、linux中页表处理数据结构
分页转换功能由驻留在内存中的表来描述,该表称为页表,存放在物理地址空间中。页表可以看作是简单的 2^20 物理地址数组。线性到物理地址的映射功能可以简单地看作进行数组查找。线性地址的高 20 位构成这个数组的索引值,用于选择对应页面的物理(基)地址。线性地址的低 12 位给出了页面中的偏移量,加上页面的基地址最终形成对应的物理地址。由于页面基地址对齐在 4K 边界上,因此页面基地址的低 12 为肯定是 0 ,这意味着 高 20 位的页面基地址 和 12 位偏移地址连接组合在一起就能得到对应的物理地址。
页表中每个页表项 大小为 32 位,由于只需其中的 20 位来存放页面的物理基地址,因此剩下的 12 位可用于存放诸如页面是否存在等的属性信息。如果线性地址索引的页表被标注为存在,则表示该项有效, 我们可以从中取得页面的物理地址。如果项中表明不存在,那么当当访问对应物理界面时就会产生一个异常。
4.1 页表类型定义
(1)pgd_t、pmd_t、pud_t和pte_t
Linux分别采用pgd_t、pmd_t、pud_t和pte_t四种数据结构来表示页全局目录项、页上级目录项、页中间目录项和页表项。这四种 数据结构本质上都是无符号长整型unsigned long
Linux为了更严格数据类型检查,将无符号长整型unsigned long分别封装成四种不同的页表项。如果不采用这种方法,那么一个无符号长整型数据可以传入任何一个与四种页表相关的函数或宏中,这将大大降低程序的健壮性。
pgprot_t是另一个64位(PAE激活时)或32位(PAE禁用时)的数据类型,它表示与一个单独表项相关的保护标志。首先我们查看一下子这些类型是如何定义的:
①pteval_t,pmdval_t,pudval_t,pgdval_t
参照arch/x86/include/asm/pgtable_64_types.h
②pgd_t、pmd_t、pud_t和pte_t
参照 /arch/x86/include/asm/pgtable_types.h
③xxx_val和__xxx
参照/arch/x86/include/asm/pgtable.h
五个类型转换宏(_ pte、_ pmd、_ pud、_ pgd和__ pgprot)把一个无符号整数转换成所需的类型。
另外的五个类型转换宏(pte_val,pmd_val, pud_val, pgd_val和pgprot_val)执行相反的转换,即把上面提到的四种特殊的类型转换成一个无符号整数。
这里需要区别指向页表项的指针和页表项所代表的数据。以pgd_t类型为例子,如果已知一个pgd_t类型的指针pgd,那么通过pgd_val(*pgd)即可获得该页表项(也就是一个无符号长整型数据),这里利用了面向对象的思想。
4.2页表描述宏
参照arch/x86/include/asm/pgtable_64
linux中使用下列宏简化了页表处理,对于每一级页表都使用有以下三个关键描述宏:
宏字段 | 描述 |
XXX_SHIFT | 指定Offset字段的位数 |
XXX_SIZE | 页的大小 |
XXX_MASK | 用以屏蔽Offset字段的所有位 |
我们的四级页表,对应的宏分别由PAGE,PMD,PUD,PGDIR
宏字段前缀 | 描述 |
PGDIR | 页全局目录(Page Global Directory) |
PUD | 页上级目录(Page Upper Directory) |
PMD | 页中间目录(Page Middle Directory) |
PAGE | 页表(Page Table) |
PAGE宏–页表(Page Table)
字段 | 描述 |
PAGE_MASK | 用以屏蔽Offset字段的所有位 |
PAGE_SHIFT | 指定Offset字段的位数 |
PAGE_SIZE | 页的大小 |
定义如下,在/arch/x86/include/asm/page_types.h
文件中
当用于80x86处理器时,PAGE_SHIFT返回的值为12,由于页内所有地址都必须放在Offset字段, 因此80x86系统的页的大小PAGE_SIZE是2^12=4096
字节,PAGE_MASK宏产生的值为0xfffff000,用以屏蔽Offset字段的所有位。
PMD-Page Middle Directory (页目录)
字段 | 描述 |
PMD_SHIFT | 指定线性地址的Offset和Table字段的总位数;换句话说,是页中间目录项可以映射的区域大小的对数 |
PMD_SIZE | 用于计算由页中间目录的一个单独表项所映射的区域大小,也就是一个页表的大小 |
PMD_MASK | 用于屏蔽Offset字段与Table字段的所有位 |
当PAE 被禁用时,PMD_SHIFT 产生的值为22(来自Offset 的12 位加上来自Table 的10 位),PMD_SIZE 产生的值为222 或 4 MB,PMD_MASK产生的值为 0xffc00000。
相反,当PAE被激活时,PMD_SHIFT 产生的值为21 (来自Offset的12位加上来自Table的9位),PMD_SIZE 产生的值为2^21 或2 MB PMD_MASK产生的值为 0xffe00000。
大型页不使用最后一级页表,所以产生大型页尺寸的LARGE_PAGE_SIZE 宏等于PMD_SIZE(2PMD_SHIFT),而在大型页地址中用于屏蔽Offset字段和Table字段的所有位的LARGE_PAGE_MASK宏,就等于PMD_MASK。
PUD_SHIFT-页上级目录(Page Upper Directory)
字段 | 描述 |
PUD_SHIFT | 确定页上级目录项能映射的区域大小的位数 |
PUD_SIZE | 用于计算页全局目录中的一个单独表项所能映射的区域大小。 |
PUD_MASK | 用于屏蔽Offset字段,Table字段,Middle Air字段和Upper Air字段的所有位 |
在80x86处理器上,PUD_SHIFT总是等价于PMD_SHIFT,而PUD_SIZE则等于4MB或2MB。
PGDIR_SHIFT-页全局目录(Page Global Directory)
字段 | 描述 |
PGDIR_SHIFT | 确定页全局页目录项能映射的区域大小的位数 |
PGDIR_SIZE | 用于计算页全局目录中一个单独表项所能映射区域的大小 |
PGDIR_MASK | 用于屏蔽Offset, Table,Middle Air及Upper Air的所有位 |
当PAE 被禁止时,PGDIR_SHIFT 产生的值为22(与PMD_SHIFT 和PUD_SHIFT 产生的值相同),PGDIR_SIZE 产生的值为 222 或 4 MB,PGDIR_MASK 产生的值为 0xffc00000。
相反,当PAE被激活时,PGDIR_SHIFT 产生的值为30 (12 位Offset 加 9 位Table再加 9位 Middle Air),PGDIR_SIZE 产生的值为230 或 1 GBPGDIR_MASK产生的值为0xc0000000
PTRS_PER_PTE, PTRS_PER_PMD, PTRS_PER_PUD以及PTRS_PER_PGD用于计算页表、页中间目录、页上级目录和页全局目录表中表项的个数。当PAE被禁止时,它们产生的值分别为1024,1,1和1024。当PAE被激活时,产生的值分别为512,512,1和4。
4.3页表处理函数
内核还提供了许多宏和函数用于读或修改页表表项:
- 如果相应的表项值为0,那么,宏pte_none、pmd_none、pud_none和 pgd_none产生的值为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位的值能被原子地写入。
- 如果a和b两个页表项指向同一页并且指定相同访问优先级,pte_same(a,b)返回1,否则返回0。
- 如果页中间目录项指向一个大型页(2MB或4MB),pmd_large(e)返回1,否则返回0。
宏pmd_bad由函数使用并通过输入参数传递来检查页中间目录项。如果目录项指向一个不能使用的页表,也就是说,如果至少出现以下条件中的一个,则这个宏产生的值为1:
- 页不在主存中(Present标志被清除)。
- 页只允许读访问(Read/Write标志被清除)。
- Acessed或者Dirty位被清除(对于每个现有的页表,Linux总是强制设置这些标志)。
pud_bad宏和pgd_bad宏总是产生0。没有定义pte_bad宏,因为页表项引用一个不在主存中的页,一个不可写的页或一个根本无法访问的页都是合法的。
如果一个页表项的Present标志或者Page Size标志等于1,则pte_present宏产生的值为1,否则为0。
前面讲过页表项的Page Size标志对微处理器的分页部件来讲没有意义,然而,对于当前在主存中却又没有读、写或执行权限的页,内核将其Present和Page Size分别标记为0和1。
这样,任何试图对此类页的访问都会引起一个缺页异常,因为页的Present标志被清0,而内核可以通过检查Page Size的值来检测到产生异常并不是因为缺页。
如果相应表项的Present标志等于1,也就是说,如果对应的页或页表被装载入主存,pmd_present宏产生的值为1。pud_present宏和pgd_present宏产生的值总是1。
(1)查询页表项中任意一个标志的当前值
下表中列出的函数用来查询页表项中任意一个标志的当前值;除了pte_file()外,其他函数只有在pte_present返回1的时候,才能正常返回页表项中任意一个标志。
- pte_user( ):读 User/Supervisor 标志
- pte_read( ):读 User/Supervisor 标志(表示 80x86 处理器上的页不受读的保护)
- pte_write( ):读 Read/Write 标志
- pte_exec( ):读 User/Supervisor 标志( 80x86 处理器上的页不受代码执行的保护)
- pte_dirty( ):读 Dirty 标志
- pte_young( ):读 Accessed 标志
- pte_file( ):读 Dirty 标志(当 Present 标志被清除而 Dirty 标志被设置时,页属于一个非线性磁盘文件映射)
(2)设置页表项中各标志的值
下表列出的另一组函数用于设置页表项中各标志的值
- mk_pte_huge( ):设置页表项中的 Page Size 和 Present 标志
- pte_wrprotect( ):清除 Read/Write 标志
- pte_rdprotect( ):清除 User/Supervisor 标志
- pte_exprotect( ):清除 User/Supervisor 标志
- pte_mkwrite( ):设置 Read/Write 标志
- pte_mkread( ):设置 User/Supervisor 标志
- pte_mkexec( ):设置 User/Supervisor 标志
- pte_mkclean( ):清除 Dirty 标志
- pte_mkdirty( ):设置 Dirty 标志
- pte_mkold( ):清除 Accessed 标志(把此页标记为未访问)
- pte_mkyoung( ):设置 Accessed 标志(把此页标记为访问过)
- pte_modify(p,v):把页表项 p 的所有访问权限设置为指定的值
- ptep_set_wrprotect():与 pte_wrprotect( ) 类似,但作用于指向页表项的指针
- ptep_set_access_flags( ):如果 Dirty 标志被设置为 1 则将页的访问权设置为指定的值,并调用flush_tlb_page() 函数ptep_mkdirty()与 pte_mkdirty( ) 类似,但作用于指向页表项的指针。
- ptep_test_and_clear_dirty( ):与 pte_mkclean( ) 类似,但作用于指向页表项的指针并返回 Dirty 标志的旧值
- ptep_test_and_clear_young( ):与 pte_mkold( ) 类似,但作用于指向页表项的指针并返回 Accessed标志的旧值
(3)宏函数-把一个页地址和一组保护标志组合成页表项,或者执行相反的操作
现在,我们来讨论下表中列出的宏,它们把一个页地址和一组保护标志组合成页表项,或者执行相反的操作,从一个页表项中提取出页地址。请注意这其中的一些宏对页的引用是通过 “页描述符”的线性地址,而不是通过该页本身的线性地址。
- pgd_index(addr):找到线性地址 addr 对应的的目录项在页全局目录中的索引(相对位置)
- pgd_offset(mm, addr):接收内存描述符地址 mm 和线性地址 addr 作为参数。这个宏产生地址addr 在页全局目录中相应表项的线性地址;通过内存描述符 mm 内的一个指针可以找到这个页全局目录pgd_offset_k(addr)产生主内核页全局目录中的某个项的线性地址,该项对应于地址
- addrpgd_page(pgd):通过页全局目录项 pgd 产生页上级目录所在页框的页描述符地址。在两级或三级分页系统中,该宏等价于 pud_page() ,后者应用于页上级目录项
- pud_offset(pgd, addr):参数为指向页全局目录项的指针 pgd 和线性地址 addr 。这个宏产生页上级目录中目录项 addr 对应的线性地址。在两级或三级分页系统中,该宏产生 pgd ,即一个页全局目录项的地址
- pud_page(pud):通过页上级目录项 pud 产生相应的页中间目录的线性地址。在两级分页系统中,该宏等价于 pmd_page() ,后者应用于页中间目录项
- pmd_index(addr):产生线性地址 addr 在页中间目录中所对应目录项的索引(相对位置)
- pmd_offset(pud, addr):接收指向页上级目录项的指针 pud 和线性地址 addr 作为参数。这个宏产生目录项 addr 在页中间目录中的偏移地址。在两级或三级分页系统中,它产生 pud ,即页全局目录项的地址
- pmd_page(pmd):通过页中间目录项 pmd 产生相应页表的页描述符地址。在两级或三级分页系统中, pmd 实际上是页全局目录中的一项mk_pte(p,prot)接收页描述符地址 p 和一组访问权限 prot 作为参数,并创建相应的页表项
- pte_index(addr):产生线性地址 addr 对应的表项在页表中的索引(相对位置)
- pte_offset_kernel(dir,addr):线性地址 addr 在页中间目录 dir 中有一个对应的项,该宏就产生这个对应项,即页表的线性地址。另外,该宏只在主内核页表上使用
- pte_offset_map(dir, addr):接收指向一个页中间目录项的指针 dir 和线性地址 addr 作为参数,它产生与线性地址 addr 相对应的页表项的线性地址。如果页表被保存在高端存储器中,那么内核建立一个临时内核映射,并用 pte_unmap 对它进行释放。pte_offset_map_nested 宏和 pte_unmap_nested 宏是相同的,但它们使用不同的临时内核映射
- pte_page( x ):返回页表项 x 所引用页的描述符地址
- pte_to_pgoff( pte ):从一个页表项的 pte 字段内容中提取出文件偏移量,这个偏移量对应着一个非线性文件内存映射所在的页
- pgoff_to_pte(offset ):为非线性文件内存映射所在的页创建对应页表项的内容
(4)简化页表项的创建和撤消
下面我们罗列最后一组函数来简化页表项的创建和撤消。当使用两级页表时,创建或删除一个页中间目录项是不重要的。如本节前部分所述,页中间目录仅含有一个指向下属页表的目录项。所以,页中间目录项只是页全局目录中的一项而已。然而当处理页表时,创建一个页表项可能很复杂,因为包含页表项的那个页表可能就不存在。在这样的情况下,有必要分配一个新页框,把它填写为 0 ,并把这个表项加入。
如果 PAE 被激活,内核使用三级页表。当内核创建一个新的页全局目录时,同时也分配四个相应的页中间目录;只有当父页全局目录被释放时,这四个页中间目录才得以释放。当使用两级或三级分页时,页上级目录项总是被映射为页全局目录中的一个单独项。与以往一样,下表中列出的函数描述是针对 80x86 构架的。
五、线性地址转换
5.1分页模式下的的线性地址转换
线性地址、页表和页表项线性地址不管系统采用多少级分页模型,线性地址本质上都是索引+偏移量的形式,甚至你可以将整个线性地址看作N+1个索引的组合,N是系统采用的分页级数。在四级分页模型下,线性地址被分为5部分,如下图:
在线性地址中,每个页表索引即代表线性地址在对应级别的页表中中关联的页表项。正是这种索引与页表项的对应关系形成了整个页表映射机制。
(1)页表
多个页表项的集合则为页表,一个页表内的所有页表项是连续存放的。页表本质上是一堆数据,因此也是以页为单位存放在主存中的。因此,在虚拟地址转化物理物理地址的过程中,每访问一级页表就会访问一次内存。
(2)页表项
页表项从四种页表项的数据结构可以看出,每个页表项其实就是一个无符号长整型数据。每个页表项分两大类信息:页框基地址和页的属性信息。在x86-32体系结构中,每个页表项的结构图如下:
这个图是一个通用模型,其中页表项的前20位是物理页的基地址。由于32位的系统采用4kb大小的 页,因此每个页表项的后12位均为0。内核将后12位充分利用,每个位都表示对应虚拟页的相关属性。
不管是那一级的页表,它的功能就是建立虚拟地址和物理地址之间的映射关系,一个页和一个页框之间的映射关系体现在页表项中。上图中的物理页基地址是 个抽象的说明,如果当前的页表项位于页全局目录中,这个物理页基址是指页上级目录所在物理页的基地址;如果当前页表项位于页表中,这个物理页基地址是指最 终要访问数据所在物理页的基地址。
(3)地址转换过程
地址转换过程有了上述的基本知识,就很好理解四级页表模式下如何将虚拟地址转化为逻辑地址了。基本过程如下:
从CR3寄存器中读取页目录所在物理页面的基址(即所谓的页目录基址),从线性地址的第一部分获取页目录项的索引,两者相加得到页目录项的物理地址。
第一次读取内存得到pgd_t结构的目录项,从中取出物理页基址取出(具体位数与平台相关,如果是32系统,则为20位),即页上级页目录的物理基地址。
从线性地址的第二部分中取出页上级目录项的索引,与页上级目录基地址相加得到页上级目录项的物理地址。
第二次读取内存得到pud_t结构的目录项,从中取出页中间目录的物理基地址。
从线性地址的第三部分中取出页中间目录项的索引,与页中间目录基址相加得到页中间目录项的物理地址。
第三次读取内存得到pmd_t结构的目录项,从中取出页表的物理基地址。
从线性地址的第四部分中取出页表项的索引,与页表基址相加得到页表项的物理地址。
第四次读取内存得到pte_t结构的目录项,从中取出物理页的基地址。
从线性地址的第五部分中取出物理页内偏移量,与物理页基址相加得到最终的物理地址。
第五次读取内存得到最终要访问的数据。
整个过程是比较机械的,每次转换先获取物理页基地址,再从线性地址中获取索引,合成物理地址后再访问内存。不管是页表还是要访问的数据都是以页为单 位存放在主存中的,因此每次访问内存时都要先获得基址,再通过索引(或偏移)在页内访问数据,因此可以将线性地址看作是若干个索引的集合。
5.2 Linux中通过4级页表访问物理内存
linux中每个进程有它自己的PGD( Page Global Directory),它是一个物理页,并包含一个pgd_t数组。
进程的pgd_t数据见 task_struct -> mm_struct -> pgd_t * pgd;
PTEs, PMDs和PGDs分别由pte_t, pmd_t 和pgd_t来描述。为了存储保护位,pgprot_t被定义,它拥有相关的flags并经常被存储在page table entry低位(lower bits),其具体的存储方式依赖于CPU架构。
前面我们讲了页表处理的大多数函数信息,在上面我们又讲了线性地址如何转换为物理地址,其实就是不断索引的过程。
通过如下几个函数,不断向下索引,就可以从进程的页表中搜索特定地址对应的页面对象:
- pgd_offset根据当前虚拟地址和当前进程的mm_struct获取pgd项
- pud_offset参数为指向页全局目录项的指针 pgd 和线性地址 addr 。这个宏产生页上级目录中目录项 addr 对应的线性地址。在两级或三级分页系统中,该宏产生 pgd ,即一个页全局目录项的地址
- pmd_offset根据通过pgd_offset获取的pgd 项和虚拟地址,获取相关的pmd项(即pte表的起始地址)
- pte_offset根据通过pmd_offset获取的pmd项和虚拟地址,获取相关的pte项(即物理页的起始地址)
根据虚拟地址获取物理页的示例代码详见mm/memory.c中的函数follow_page
不同的版本可能有所不同,早起内核中存在follow_page,而后来的内核中被follow_page_mask替代,目前最新的发布4.4中为查找到此函数
我们从早期的linux-3.8的源代码中, 截取的代码如下
以上代码可以精简为:
六、Linux 分页机制的显著优势
6.1内存保护与隔离
在 Linux 的内存管理体系中,分页机制就像是一位忠诚的卫士,为系统的安全稳定运行保驾护航,而其中页表项权限位则是这位卫士手中的 “秘密武器”,在内存保护和隔离方面发挥着关键作用。
每个进程都拥有属于自己的独立页表,这就如同每个进程都有一个专属的 “房间”,而页表则是这个房间的 “门锁” 和 “管家”。页表项中的权限位,如读(Read)权限位、写(Write)权限位和执行(Execute)权限位等,就像是门锁上的不同 “密码”,严格控制着对内存页的访问权限 。例如,当一个进程试图访问另一个进程的内存空间时,由于其页表中对应的权限位不允许这样的访问,系统就会立即检测到这种违规行为,并触发一个页错误(Page Fault) 。这就好比有人拿着错误的密码试图打开别人房间的门,门锁会立刻发出警报。
操作系统在接收到页错误后,会迅速采取相应措施,比如记录错误信息,终止违规进程,以防止恶意进程的攻击和破坏,确保每个进程的数据都能安全地存放在自己的 “房间” 里,实现了不同进程之间内存的有效隔离 。这种基于页表项权限位的内存保护机制,为 Linux 系统的安全性和稳定性提供了坚实的保障,让各个进程能够在相互隔离的环境中稳定运行,避免了数据泄露和非法访问带来的风险。
6.2高效内存利用
分页机制在提高内存利用率方面,有着出色的表现,堪称内存管理的 “效率大师”。它通过将内存划分为固定大小的页,巧妙地解决了内存碎片这一棘手问题。
在传统的内存分配方式中,由于进程对内存的需求大小不一,频繁的分配和释放内存很容易产生内存碎片。就像你有一块大蛋糕,每次根据不同的需求切下大小不一的块,时间久了,就会剩下很多小块的 “边角料”,这些 “边角料” 因为太小而无法被充分利用,造成了内存的浪费。而分页机制则像是把蛋糕切成了大小相同的小块,无论进程需要多少内存,都可以通过分配相应数量的页来满足需求 。当进程结束后,释放的页又可以被系统重新分配,大大减少了内存碎片的产生,提高了内存的使用效率。
同时,分页机制还支持动态内存分配,能够根据进程的实际需求,灵活地分配和回收内存页。当一个进程需要更多的内存时,系统可以迅速为其分配新的页;当进程不再需要某些内存时,这些页又能及时被回收,重新投入到其他需要的地方。这种动态分配的方式,就像一个智能的资源分配器,能够根据实际情况,合理地调配内存资源,避免了内存的闲置和浪费,使得系统能够在有限的内存条件下,高效地运行多个进程 。
例如,在一个同时运行多个程序的 Linux 系统中,分页机制可以根据每个程序的实时内存需求,动态地分配和回收内存页,确保每个程序都能获得足够的内存资源,同时又不会造成内存的过度占用,极大地提高了内存的利用率,提升了系统的整体性能。
七、分页机制的应用场景
7.1服务器场景
在服务器环境中,通常会同时运行多个不同的进程,这些进程各自承担着不同的任务,比如 Web 服务器进程负责处理网页请求,数据库服务器进程负责数据的存储和读取,邮件服务器进程负责邮件的收发等 。以一个典型的电商网站服务器为例,它可能同时运行着 Web 服务进程、数据库服务进程和缓存服务进程等。Web 服务进程需要处理大量用户的页面访问请求,数据库服务进程则要频繁地进行数据的查询和更新操作,缓存服务进程则负责存储和管理常用的数据,以提高访问速度 。
在这种多进程并发运行的情况下,内存资源的管理变得至关重要。分页机制就像是一位高效的资源调度员,为每个进程分配独立的虚拟内存空间,确保它们在各自的 “小天地” 里运行,互不干扰。当 Web 服务进程收到用户的页面请求时,它会在自己的虚拟内存空间中查找和处理相关数据,而不会影响到数据库服务进程和缓存服务进程的正常运行 。同时,分页机制通过页表的映射,能够快速地将虚拟地址转换为物理地址,提高内存访问的效率。当数据库服务进程需要读取或写入数据时,分页机制可以迅速地定位到物理内存中的数据位置,减少数据访问的延迟,保证数据库操作的高效性 。
而且,分页机制还能根据进程的实际需求,动态地分配和回收内存页。当电商网站在促销活动期间,访问量大幅增加,Web 服务进程需要更多的内存来处理请求时,分页机制可以及时为其分配额外的内存页;当活动结束后,访问量减少,分页机制又能回收多余的内存页,将其重新分配给其他有需要的进程,从而实现内存资源的高效利用,保障服务器在高负载情况下的稳定运行 。
7.2虚拟化场景
在虚拟化技术中,分页机制扮演着不可或缺的角色,它是实现虚拟机内存隔离与分配的关键所在。以 KVM(Kernel-based Virtual Machine)虚拟化技术为例,它基于 Linux 内核,通过将 Linux 内核转变为一个 Hypervisor,能够在同一台物理主机上运行多个虚拟机 。
每个虚拟机都仿佛是一台独立的物理计算机,拥有自己独立的操作系统和应用程序,而分页机制则为这些虚拟机提供了独立的内存空间,就像为每个虚拟机划分了专属的 “内存区域”,确保它们之间的内存相互隔离,不会出现数据泄露或相互干扰的情况。当一个虚拟机中的应用程序访问内存时,分页机制会根据该虚拟机的页表,将虚拟地址准确地映射到对应的物理内存页上,保证虚拟机的正常运行 。
同时,分页机制还能实现内存的高效分配。在物理主机内存有限的情况下,分页机制可以根据各个虚拟机的实际内存需求,动态地分配内存页。比如,当一台虚拟机运行一个轻量级的应用程序时,它可能只需要较少的内存,分页机制就会为其分配适量的内存页;而当另一台虚拟机运行一个大型数据库应用程序时,它需要大量的内存,分页机制会优先满足其需求,为其分配足够的内存页 。
这种灵活的内存分配方式,使得物理主机的内存资源能够得到充分利用,提高了虚拟化环境的整体性能 。此外,分页机制还能支持内存的共享。在一些情况下,多个虚拟机可能会运行相同的操作系统或应用程序,分页机制可以让这些虚拟机共享相同的物理内存页,减少内存的占用,进一步提高内存的利用率 。例如,在一个云计算数据中心中,通过 KVM 虚拟化技术运行着大量的虚拟机,分页机制的存在使得这些虚拟机能够高效地共享物理主机的内存资源,实现了资源的最大化利用,为用户提供了稳定、高效的云计算服务 。