首页 > 代码库 > 深入理解 Linux 内存管理

深入理解 Linux 内存管理

1. 内存地址


以Intel的中央处理器为例,Linux 32位的系统中,物理内存的基本单位是字节(Byte),1个字节有8个二进制位。每个内存地址指向一个字节,内存地址加1后得到下一个字节的地址。这里用以表示物理内存实际位置的地址,就是通常所说的物理地址(Physical Address)。CPU正在执行的进程代码、进程数据和栈区数据等,都临时保存在物理内存中。

线性地址(Linear Address,亦即虚拟地址 Virtual Address)是出于以下考虑

(1) 隔离不同进程使用的内存地址空间;
(2) 提高内存的使用率;
(3) 确定程序运行时的地址;

(4) 扩展内存,即运行所需内层大于物理内存的程序

而在物理地址和程序之间增加的中间层。虚拟地址范围对应CPU的寻址能力,32位的CPU的虚拟地址范围为 0x00000000 ~ 0xFFFFFFFF,即最大虚拟内存为2^32 Bytes = 4GB;相应的64位CPU最大虚拟内存为 2^64 Bytes,然而实际上目前大部分操作系统和应用程序都不需要这样大的虚拟地址空间,并且64位长的地址会增加系统的复杂性和地址转换成本,因此目前的x86-64架构只使用虚拟地址低位48位(0 ~ 47)作为虚拟地址,并用第47位的值填充48 ~ 63高位,因此64位CPU的最大虚拟内存为2^48 = 256TB。一般地,物理地址空间只是虚拟地址空间的一个子集。

为了提高内存管理效率,发挥虚拟空间的作用,可设定CPU的 CR0 寄存器的最高位(PG,分页标志位),启用分页机制将虚拟空间等分成若干页,然后按页帧管理和使用虚拟空间。物理内存规定的页大小有4096 Bytes,8192 Bytes,2MB, 4MB等,因为虚拟空间页中存储的内容实际上是要放到物理内存上的,所以虚拟空间也采用上述大小进行分页。普通的分页大小采用4KB的标准。

现代计算机系统中,一般不需要程序员直接操作物理地址,而是由操作系统按页帧为进程分配执行用的虚拟地址。每个页帧可以被映射到任何可用的物理内存页。CPU 在执行程序进程时,CPU发出对相应的虚拟地址进行读或写操作,硬件设备(MMU,内存管理单元,一般都集成在CPU芯片上)分析虚拟地址后查询页表并计算,将该虚拟地址映射为物理地址,然后通过北桥芯片(北桥芯片主要功能就是负责CPU和物理内存之间的通信)连接内存总线,从而CPU能够访问到物理内存中进程代码和数据。

逻辑地址(Linear Address)指的是程序内部的地址偏移量。该地址以操作系统为程序分配的程序入口地址为基准,指定程序中操作数或指令的地址。逻辑地址是程序员直接操纵的地址,例如在 C 语言编程中,定义一个 int  变量,然后使用取地址运算符(&var)得到的地址就是逻辑地址。

逻辑地址由两部分构成,分别是段选择符(Segment Selector)和段内偏移量(Offset),段选择符是一个16-bit (2字节)无符号数,段偏移量则是一个 32-bit 的无符号数。段选择符的内容如下图所示。

 

Figure 1 Segment Selector Fields


2. 内存管理之分段机制


段(Segmentation)是在段式内存管理的概念下形成的术语。段式管理的基本思想是把程序内容或过程关系分成段,例如代码段、数据段等,操作系统按段为进程分配虚拟地址空间,这样不同进程段空间不同,实现了进程隔离和内存保护。每个段都有自己的描述符(Segment Descriptor, 8-byte long),这些描述符被保存在全局描述符表(Global Descriptor Table, GDT)或局部描述符表(Local Descriptor Table, LDT)中。段描述符的内容如图2所示。

 

Figure 2 Segment Descriptor


Base Address一共 32 bits,它指向当前段第一个字节的线性地址。Limit部分一共 20 bits,它指明本段虚拟空间最后一个字节相对第一个字节的偏移量,因此它也能表示段的长度。与页不同(长度固定为4KB等),段的长度根据程序相应内容变化。另外,如果标志位 G设定为0,那么偏移量每增加 1,地址值增加 1 byte,那么这时段的最大长度为 1 byte * 2^20 = 1MB;如果标志位 G设定为1,那么偏移量加1,地址值增加 4 KB,相应的这时段的最大长度为 4KB * 2^20 = 4GB。

在进程的执行过程中,当遇到需要访问内存的指令时,首先根据逻辑地址得到相应的线性地址,然后再根据线性地址得到物理地址。根据逻辑地址得到线性地址的过程如图3所示。

 

Figure 3 Translating a Logical Address


CPU提供CS 寄存器临时保存正在执行的进程代码段的段选择符,DS 寄存器临时保存进程数据段的段选择符,以及SS寄存器临时保存栈区分段的段选择符。这样,在转换逻辑地址的时候,CPU根据当前保存在CS中的段选择器(参见图1),其中 TI 标志确定段描述符位于GDT还是LDT,Index部分确定段描述符在表(GDT或LDT)中的位置,从而可以找到逻辑地址对应的段描述符;根据段描述符中的Base Address 找到段的起始线性地址,使用起始地址加上指令逻辑地址中的偏移量,就能得到指令所指向的实际线性地址。

由于分段机制和Intel处理器相关联,在其它的硬件系统上,可能并不支持分段式内存管理,因此在 Linux 中,操作系统倾向与使用分页的方式管理内存。在用户模式(User Mode)下,所有的进程共用用户代码段和用户数据段。用户模式下,所有进程使用代码段的段描述符的Base Address部分都指向线性地址0x00000000,同时数据段的段描述符的 Base Address部分也指向线性地址0x00000000;在内核模式(Kernel Mode)下,所有的进程共用内核代码段和内核数据段。内核所有进程使用代码段的段描述符的Base Address部分都指向线性地址0x00000000,同时数据段的段描述符的 Base Address部分也指向线性地址0x00000000。上述的段描述符的G位都设定为1,段对应的虚拟空间从0到2^32,对应整个32位CPU的最大虚拟空间。

上述办法解决了其它硬件平台不支持段式管理的情况,大大简化了地址转换操作,但是由于理论上每个进程的可用线性空间范围都是4G,即进程共用段表,使用段界限隔离进程内存的目的就不能实现了。因此,在Linux中,为每个进程分配独立的页表,纯粹依靠分页机制提供内存保护和进程隔离。接下来,针对分页机制进行详细的说明。


3. 内存管理之分页机制


分页机制将整个线性地址空间及整个物理内存看成由许多大小相同的存储块组成的,并把这些块作为页(虚拟空间分页后每个单位称为页)或页帧(物理内存分页后每个单位称为页帧)进行管理。不考虑内存访问权限时,线性地址空间的任何一页,理论上可以映射为物理地址空间中的任何一个页帧。最常见的分页方式是以 4KB 单位划分页,并且保证页地址边界对齐,即每一页的起始地址都应被4K整除。在4KB的页单位下,32位机的整个虚拟空间就被划分成了 2^20 个页。因为虚拟地址是按页全部被映射到相同大小的页帧,并且页面边界对齐,因此虚拟地址的后12位可以直接作为物理地址的低12位使用。

为了节省储存页表所需的内存空间(2^20 * 4B = 4M),32位操作系统常使用两级页表结构记载虚拟地址空间分页现状。因此每个虚拟地址就由三部分组成,高10位是页目录(Page Directory)中内容的索引,中间10位是页表索引,低12位则作为对应物理地址在页帧中的偏移量。

 

Figure 4 Paging Mechanism


页目录保存在CR3寄存器中,可以直接访问。访问时以线性地址高10位作为索引,直接检索并得到对应索引的 32 位页目录项。32位页目录项的结构如图4中Page Directory部分所示,目录项的高20位用以给出该目录项对应的页表在内存中的物理地址的高 20 位,1024个目录项刚好能给出1024个页表的入口地址。目录项的低12位是一些标志位,其中P标志指明当前目录项对应的页表是否在内存中;U标志指明当前目录项对应的页的访问权限;S标志指明页的大小是4KB或4MB,等等。另外,由于每个页目录项的长度为32位,即4个字节,页目录中共有1024个页目录项,所以页目录的总大小为 4KB。

页表保存在内存中。页表项的长度是32位,每个页表中有1024个页表项,可得出每个页表的大小是 4 KB。页表在内存中存放时,与物理分页的大小(4KB)对齐,所以每个页表所在的物理内存的起始物理地址的后12位都是0。而该物理地址的高20位又由页表对应的页目录项中的高20位指定,这样就可以得到找到物理内存中的页表了。找到页表后,以线性地址的中间10位为索引,检索到该索引对应的32位页表项。和页目录项类似,页表项的高20用以给出其对应页帧的起始物理地址的高20位。页表项的低12位是关于页的标志位。

页帧对应物理内存。根据前面的两步找得到页帧的起始物理地址的高20位后,由于物理内存按4KB大小划分成页帧,所以页帧的起始物理地址的低12位都是0。这样高20位加低12位,得到页帧的起始物理地址。找到页帧后,使用线性地址的低10位作为偏移量,加上页帧的起始物理地址后能找到线性地址对应的物理地址了。需要注意的是,页帧和页表项的对应关系并不是确定的,页表项指向的页首先是虚拟页,然后该虚拟页的内容被储存在任何合适的页帧中。

操作系统按页为每个进程分配虚拟地址范围,理论上根据程序需要最大可使用4G的虚拟内存。但由于操作系统需要保护内核进程内存,所以将内核进程虚拟内存和用户进程虚拟内存分离,前者可用空间为1G虚拟内存,后者为3G虚拟内存。进程执行时,操作系统为其分配的页的页目录会被加载到CR3寄存器,页表会被加载到物理内存。分页单元将线性地址转换为物理地址的过程中,会检查当前进程是否有访问该分页的权限,以及线性地址对应的页数据是否在物理内存中,如果上述检查条件未被通过,分页单元将会生成页错误异常,进而中止进程或将相应分页数据加载到物理内存。


4. 物理地址扩展


物理地址扩展(Physical Address Extension)是Intel 32位CPU上独有的一种虚拟地址分页方式。理论上,32位CPU有32条内存寻址线,最多能访问4G的物理内存;实际上在Linux系统中,用户模式程序需要线性地址空间,因此内核最多只能直接访问的物理内存为1G。但是,随着计算机软件的发展,一台32位计算机上可能同时运行许多进程,而这些同时运行的进程所需内存量会大于4G,因此Intel为其32位CPU增加了4条内存寻址线,共36条,这样CPU支持的物理内存增大到2^36,即64GB。扩展物理内存的同时,保持虚拟地址空间范围为4G不变。从而使32位的应用程序继续使用32位的地址,每个进程可使用的最大虚拟内存仍是4GB。

64GB的物理内存在4KB分页下,被分成2^24个页帧,每个页帧的起始物理地址后12位仍然为0,但前24位则需要页表提供。而我们知道,常规分页中,页表项为32位,其中只能提供20位作为其指向页帧的高20位物理地址,不能满足36位系统的寻址需要。可以同通过增加页表项的总长度来解决这个问题,为了保证以4KB的边界对齐,我们将页表项的长度增加为64位,8字节(而不能是刚好满足需要的36位),页表大小保持4KB,那么一个页表中只有512个页表项(2^12 / 8)。

相应地,页目录也要适应36位的物理内存寻址能力,每条页目录项长度也变成64位,页目录大小保持4KB,一个页目录中只有512个页目录项。这样一个页目录总共可检索 512 × 512=2^18个页,而虚拟地址空间共有 2^20个页,所以总共需要4个页目录。

一个新的分层被加入到CR3控制器和页目录之间,这个新的分层是页目录指针表(Page Directory Pointer Table)。页目录指针表中有四个长度为64位的指针,分别指向前述的4个页目录。页目录指针表被加载到64GB内存的第一个4GB上(物理地址0x00000000 ~ 0xFFFFFFFF),CR3中则保存的是该页目录指针表的起始物理地址。

开启物理扩展寻址方式后,将线性地址转换为物理地址的方式和之前有较大不同,具体过程如图5所示。
 

Figure 5 Linear Address Translation with PAE


首先由CR3得到页面指针表的物理地址,然后以线性地址的30 ~ 31位作为索引得到页目录。接下来的21 ~39位(共9 bits,恰好提供全部512个页目录项的索引)可以帮助找到线性地址对应的页表,12 ~  20(共9 bits,恰好提供全部512个页表项的物理地址)可以帮助找到线性地址对应的页帧的物理地址。


5. 64位机的分页机制


64位机的寻址能力为 2^64 Bytes,但实际中用不到这么多的虚拟内存,使用64位寻址方式还会造成寻址时间增加、内存空间浪费等不利因素,因此在实际应用中,对64位机使用48位的寻址方式(最大支持256TB物理内存)。同样的,将物理内存分为4KB大小的页帧,那么就需要 48-12=36位物理地址高位来确定页帧位置。为了减小储存页表所需的物理内存,实现内存权限访问,可以通过增加两个页目录层来分散页表。在Linux中,采用4层分页的方式来实现该目的。

 

Figure 6 Paging in 64-bit Linux


从64位线性地址(只有48位作为地址用)转换位物理地址的过程类似32位线性地址的转换。

为了避免在多级页表解析过程中多次查表而导致性能下降的问题,Intel x86 处理器缓存了地址转译信息,即从虚拟地址到物理地址的映射关系。这样,当处理器重复访问同一个地址时无须再进行转译。此缓存是一种位于处理器内部的关联存储单元阵列,也被称为地址转译快查缓冲区(TLB,TranslationLook-aside Buffer)。TLB 中包含了最近使用过的页面的内存映射信息,处理器提供了专门的电路来并发地读取并比较TLB中的页面映射项。因此,对于频繁使用的虚拟地址,它们很可能在TLB中有对应的映射项,因而处理器可以绝对快速地将虚拟地址转译成物理地址;反之,如果一个虚拟地址没有出现在TLB中,那么处理器必须采用以上介绍的两次查表过程(意味着要两次访问内存)才能完成地址转译。在这种情况下,这一次内存访问会慢一些,但是,经过这次访问以后,此虚拟页面与对应物理页面之间的映射关系将被记录到TLB中,所以,下次再访问此虚拟页面时,处理器就可以从TLB 中实现快速转译,除非此映射项已经被 TLB 移除了。研究表明,由于计算机程序的内存访问有一定的局部性,因此,即使处理器只维护一个相对较小的TLB,程序的运行也能获得较显著的性能提升。


6. 进程的建立和执行


执行程序时,操作系统会创建一个执行该程序的进程,然后装载程序或程序片段等,然后开始顺序执行代码段。在这个过程中,操作系统总的来说做三件事情:

(1) 为进程创建一个独立的虚拟地址空间(范围)

例如在32位系统常规分页状态下,操作系统发现待执行程序的指令和数据总和为32KB,那么操作系统会为进程分配8个页的虚拟内存空间,并分配页目录和页表,把页目录装入CR3,把进程用到的页表加载到内存。但并不把指令和数据加载到内存。

(2) 读取程序可执行文件文件头,并且建立虚拟空间与可执行文件中的代码段、数据段的逻辑地址的映射关系

这一步将程序指令和数据映射到虚拟内存空间中。

(3) 将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行

执行程序过程时,如果当前指令或数据之在虚拟地址空间中,而实际上并不在物理内存中(前两步都没有将指令或数据加载到物理内存),将发生页错误,这时操作系统再从物理内存分配一个空闲的物理页帧,并将虚拟地址页对应的数据从磁盘拷贝加载到物理页帧中,并建立页表项和页帧的映射关系。随着进程的执行,页错误也会不断产生,操作系统也会响应每个页错误并为进程分配物理内存页帧。但物理内存是有限的,为一个进程可分配的物理内存也有限。全部可用物理内存都分配给进程后,如果进程继续抛出页错误请求更多物理内存,这时候操作系统根据自身的页置换操作算法,在保证进程正常运行的前提下,将先前为进程分配的物理内存页帧收回,重新分给该进程。

深入理解 Linux 内存管理