文章目录
- 前言
- 一、内存寻址
- 1、内存地址
- 2、硬件中的分段
- (1)段选择符
- 3、Linux 中的分段
- (1)Linux GDT
- (2)Linux LDT
- 4、硬件中的分页
- 5、Linux 中的分页
- (1)进程页表
- (2)内核页表
- (3)临时内核页表
- (4)当 RAM 小于 896MB时的最终内核页表
- (5)当 RAM 大小在 896MB 和 4096MB 之间时的最终内核页表
- (6)当 RAM 大于 4096MB 时的最终内核页表
- (7)固定映射的线性地址
- (8)处理硬件高速缓存和 TLB
前言
本文主要用来摘录《深入理解 Linux 内核》一书中学习知识点,本书基于 Linux 2.6.11 版本,源代码摘录基于 Linux 2.6.34 ,两者之间可能有些出入。
一、内存寻址
1、内存地址
可参考 ⇒ 1、内存寻址
2、硬件中的分段
可参考 ⇒ 五、分段机制
(1)段选择符
80x86 中有 6 个段寄存器,分别为 cs,ss,ds,es,fs 和 gs。 6 个寄存器中 3 个有专门的用途:可参考 ⇒ 3、段选择符
- cs 代码段寄存器,指向包含程序指令的段。
- ss 栈段寄存器,指向包含当前程序栈的段。
- ds 数据段寄存器,指向包含静态数据或者全局数据段。
其它 3 个段寄存器做一般用途,可以指向任意的数据段。
3、Linux 中的分段
分段可以给每一个进程分配不同的线性地址空间,而分页可以把同一线性地址空间映射到不同的物理空间。与分段相比,Linux 更喜欢使用分页方式,因为:
- 当所有进程使用相同的段寄存器值时,内存管理变得更简单,也就是说它们能共享同样的一组线性地址。
- Linux 设计目标之一是可以把它移植到绝大多数流行的处理器平台上。然而,如 RISC 体系结构对分段的支持很有限。
2.6 版的 Linux 只有在 80x86 结构下才需要使用分段。
运行在用户态的所有 Linux 进程都使用一对相同的段来对指令和数据寻址。这两个段就是所谓的用户代码段和用户数据段。类似地,运行在内核态的所有 Linux 进程都使用一对相同的段对指令和数据寻址:它们分别叫做内核代码段和内核数据段。下图显示了这四个重要段的段描述符字段的值。可参考 ⇒ 4、段描述符 一文。
相应的段选择符由宏 __USER_CS,__USER_DS,__KERNEL_CS 和 __KERNEL_DS 分别定义。例如,为了对内核代码段寻址,内核只需要把 __KERNEL_CS 宏产生的值装进 cs 段寄存器即可。
注意,与段相关的线性地址从 0 开始,达到 232 - 1 的寻址限长。这就意味着在用户态或内核态下的所有进程可以使用相同的逻辑地址。
所有段都从 0x00000000 开始,这可以得出另一个重要结论,那就是在 Linux 下逻辑地址与线性地址是一致的,即逻辑地址的偏移字段的值与相应的线性地址的值总是一致的。
如前所述,CPU 的当前特权级(CPL)反映了进程是在用户态还是内核态,并由存放在 CS 寄存器中的段选择符的 RPL 字段指定。只要当前特权级被改变,一些段寄存器必须相应地更新。例如,当 CPL=3 时(用户态),ds 寄存器必须含有用户数据段的段选择符,而当 CPL=0 时,ds 寄存器必须含有内核数据段的段选择符。
类似的情况也出现在 ss 寄存器中。当 CPL 为 3 时,它必须指向一个用户数据段中的用户栈,而当 CPL 为 0 时,它必须指向内核数据段中的一个内核栈。当从用户态切换到内核态时,Linux 总是确保 ss 寄存器装有内核数据段的段选择符。
当对指向指令或者数据结构的指针进行保存时,内核根本不需要为其设置逻辑地址的段选择符,因为 cs 寄存器就含有当前的段选择等。例如,当内核调用一个函数时,它执行一条 call 汇编语言指令,该指令仅指定其逻辑地址的偏移量部分,而段选择符不用设置,它已经隐含在 cs 寄存器中了。因为"在内核态执行"的段只有一种,叫做代码段,由宏 __KERNEL_CS 定义,所以只要当 CPU 切换到内核态时将 __KERNEL_CS 装载进 cs 就足够了。同样的道理也适用于指向内核数据结构的指针(隐含地使用 ds 寄存器)以及指向用户数据结构的指针(内核显式地使用 es 寄存器)。
除了刚才描述的 4 个段以外,Linux 还使用了其他几个专门的段。我们将在下一节讲述 Linux GDT 的时候介绍它们。
(1)Linux GDT
在单处理器系统中只有一个 GDT,而在多处理器系统中每个 CPU 对应一个 GDT。 所有的 GDT 都存放在 cpu_gdt_table 数组中,而所有 GDT 的地址和它们的大小(当初始化 gdtr 寄存器时使用)被存放在 cpu_gdt_descr 数组中。如果你到源代码索引中查看,可以看到这些符号都在文件 arch/i386/kernel/head.S 中被定义。本书中的每一个宏、函数和其他符号都被列在源代码索引中,所以能在源代码中很方便地找到它们。
图 2-6 是 GDT 的布局示意图。每个 GDT 包含 18 个段描述符和 14 个空的,未使用的,或保留的项。插入未使用的项的目的是为了使经常一起访问的描述符能够处于同一个 32 字节的硬件高速缓存行中(参见本章 后面"硬件高速缓存"一节)。
每一个 GDT 中包含的 18 个段描述符指向下列的段:
- 用户态和内核态下的代码段和数据段共 4 个(参见前面一节)。
- 任务状态段(TSS),每个处理器有 1 个。每个 TSS 相应的线性地址空间都是内核数据段相应线性地址空间的一个小子集。所有的任务状态段都顺序地存放在 init_tss 数组中,值得特别说明的是,第 n 个 CPU 的 TSS 描述符的 Base 字段指向 init_tss 数组的第 n 个元素。G(粒度)标志被清 0 ,而 Limit 字段置为 0xeb,因为 TSS 段是 236 字节长。Type 字段置为 9 或 11(可用的 32 位 TSS),且 DPL 置为 0,因为不允许用户态下的进程访问 TSS 段。在第三章"任务状态段"一节你可以找到 Linux 是如何使用 TSS 的细节。参考 ==> 3.1 任务状态段
- 1 个包括缺省局部描述符表的段,这个段通常是被所有进程共享的段 (参见下一节)。
- 3 个局部线程存储(Thread-Local Storage,TLS) 段:这种机制允许多线程应用程序使用最多 3 个局部于线程的数据段。系统调用 set_thread_area() 和 get_thread_area() 分别为正在执行的进程创建和撤消一个 TLS 段。
- 与高级电源管理(AMP)相关的 3 个段:由于 BIOS 代码使用段,所以当 Linux APM 驱动程序调用 BIOS 函数来获取或者设置 APM 设备的状态时,就可以使用自定义的代码段和数据段。
- 与支持即插即用(PnP)功能的 BIOS 服务程序相关的 5 个段:在前一种情况下,就像前述与 AMP 相关的 3 个段的情况一样,由于 BIOS 例程使用段,所以当 Linux 的 PnP 设备驱动程序调用 BIOS 函数来检测 PnP 设备使用的资源时,就可以使用自定义的代码段和数据段。
- 被内核用来处理"双重错误"(译注 1)异常的特殊 TSS 段(参见第四章的"异常"一节)。
如前所述,系统中每个处理器都有一个 GDT 副本。除少数几种情况以外,所有 GDT 的副本都存放相同的表项。首先,每个处理器都有它自己的 TSS 段,因此其对应的 GDT 项不同。其次,GDT 中只有少数项可能依赖于 CPU 正在执行的进程(LDT 和 TLS 段描述符)。最后,在某些情况下,处理器可能临时修改 GDT 副本里的某个项,例如,当调用 APM 的 BIOS 例程时就会发生这种情况。
(2)Linux LDT
大多数用户态下的 Linux 程序不使用局部描述符表,这样内核就定义了一个缺省的 LDT 供大多数进程共享。缺省的局部描述符表存放在 default_ldt 数组中。它包含 5 个项,但内核仅仅有效地使用了其中的两个项:用于 iBCS 执行文件的调用门和 Solaris/x86 可执行文件的调用门(参见第二十章的"执行域"一节)。调用门是 80x86 微处理器提供的一种机制,用于在调用预定义函数时改变 CPU 的特权级,由于我们不会再更深入地讨论它们,所以请参考 Intel 文档以获取更多详情。
在某些情况下,进程仍然需要创建自己的局部描述符表。这对有些应用程序很有用,像 Wine 那样的程序,它们执行面向段的微软 Windows 应用程序。modify_ldt() 系统调用允许进程创建自己的局部描述符表。
任何被 modify_ldt() 创建的自定义局部描述符表仍然需要它自己的段。当处理器开始执行拥有自定义局部描述符表的进程时,该 CPU 的 GDT 副本中的 LDT 表项相应地就被修改了。
用户态下的程序同样也利用 modify_ldt() 来分配新的段,但内核却从不使用这些段,它也不需要了解相应的段描述符,因为这些段描述符被包含在进程自定义的局部描述符表中了。
4、硬件中的分页
参考 ⇒ 六、分页机制
5、Linux 中的分页
Linux 采用了一种同时适用于 32 位和 64 位系统的普通分页模型。正像前面 “64 位系统中的分页” 一节所解释的那样,两级页表对 32 位系统来说已经足够了,但 64 位系统需要更多数量的分页级别。直到 2.6.10 版本,Linux 采用三级分页的模型。从 2.6.11 版本开始,采用了四级分页模型(注 5)。图 2-12 中展示的 4 种页表分别被为:
- 页全局目录(Page Global Directory)
- 页上级目录(Page Upper Directory)
- 页中间目录(Page Middle Directory)
- 页表(Page Table)
页全局目录包含若干页上级目录的地址,页上级目录又依次包含若干页中间目录的地址,而页中间目录又包含若干页表的地址。每一个页表项指向一个页框。线性地址因此被分成五个部分。图 2-12 没有显示位数,因为每一部分的大小与具体的计算机体系结构有关。
对于没有启用物理地址扩展的 32 位系统,两级页表已经足够了。Linux 通过使 “页上级目录” 位和 “页中间目录” 位全为 0,从根本上取消了页上级目录和页中间目录字段。不过,页上级目录和页中间目录在指针序列中的位置被保留,以便同样的代码在 32 位系统和 64 位系统下都能使用。内核为页上级目录和页中间目录保留了一个位置,这是通过把它们的页目录项数设置为 1,并把这两个目录项映射到页全局目录的一个适当的目录项而实现的。
启用了物理地址扩展的 32 位系统使用了三级页表。Linux 的页全局目录对应 80x86 的页目录指针表(PDPT),取消了页上级目录,页中间目录对应 80x86 的页目录,Linux 的页表对应 80x86 的页表。
最后,64 位系统使用三级还是四级分页取决于硬件对线性地址的位的划分(见表 2-4)。
Linux 的进程处理很大程度上依赖于分页。事实上,线性地址到物理地址的自动转换使下面的设计目标变得可行:
- 给每一个进程分配一块不同的物理地址空间,这确保了可以有效地防止寻址错误。
- 区别页(即一组数据)和页框(即主存中的物理地址)之不同。这就允许存放在某个页框中的一个页,然后保存到磁盘上,以后重新装入这同一页时又可以被装在不同的页框中。这就是虚拟内存机制的基本要素(参见第十七章)。
在本章剩余的部分,为了具体起见,我们将涉及 80x86 处理器使用的分页机制。
我们将在第九章看到,每一个进程有它自己的页全局目录和自己的页表集。当发生进程切换时(参见第三章 “进程切换” 一节),Linux 把 CR3 控制寄存器的内容保存在前一个执行进程的描述符中,然后把下一个要执行进程的描述符的值装入 CR3 寄存器中。因此,当新进程重新开始在 CPU 上执行时,分页单元指向一组正确的页表。
(1)进程页表
进程的线性地址空间分成两部分:
- 从 0x00000000 到 0xbfffffff 的线性地址,无论进程运行在用户态还是内核态都可以寻址。
- 从 0xc0000000 到 0xffffffff 的线性地址,只有内核态的进程才能寻址。
当进程运行在用户态时,它产生的线性地址小于 0xc0000000 ;当进程运行在内核态时,它执行内核代码,所产生的地址大于等于 0xc0000000 。但是,在某些情况下,内核为了检索或存放数据必须访问用户态线性地址空间。