前言
本文主要用来摘录《深入理解 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 。但是,在某些情况下,内核为了检索或存放数据必须访问用户态线性地址空间。
(2)内核页表
内核维持着一组自己使用的页表,驻留在所谓的主内核页全局目录(master kernel Page Global Directory)中。系统初始化后,这组页表还从未被任何进程或任何内核线程直接使用;更确切地说,主内核页全局目录的最高目录项部分作为参考模型,为系统中每个普通进程对应的页全局目录项提供参考模型。
我们在第八章 “非连续内存区的线性地址” 一节将会解释,内核如何确保对主内核页全局目录的修改能传递到由进程实际使用的页全局目录中。
我们现在描述内核如何初始化自己的页表。这个过程分为两个阶段。事实上,内核映像刚刚被装入内存后,CPU 仍然运行于实模式,所以分页功能没有被启用。
第一个阶段,内核创建一个有限的地址空间,包括内核的代码段和数据段、初始页表和用于存放动态数据结构的共 128KB 大小的空间。这个最小限度的地址空间仅够将内核装入 RAM 和对其初始化的核心数据结构。
第二个阶段,内核充分利用剩余的 RAM 并适当地建立分页表。
(3)临时内核页表
临时页全局目录是在内核编译过程中静态地初始化的,而临时页表是由 startup_32() 汇编语言函数(定义于 arch/i386/kernel/head.S)初始化的。我们不再过多提及页上级目录和页中间目录,因为它们相当于页全局目录项。在这个阶段 PAE 支持并未激活。
临时页全局目录放在 swapper_pg_dir 变量中。临时页表在 pg0 变量处开始存放,紧接在内核未初始化的数据段(图 2-13 中的 _end 符号)后面。为简单起见,我们假设内核使用的段、临时页表和 128KB 的内存范围能容纳于 RAM 前 8MB 空间里。为了映射 RAM 前 8MB 的空间,需要用到两个页表。
分页第一个阶段的目标是允许在实模式下和保护模式下都能很容易地对这 8MB 寻址。因此,内核必须创建一个映射,把从 0x00000000 到 0x007fffff 的线性地址和从 0xc0000000 到 0xc07fffff 的线性地址映射到从 0x00000000 到 0x007fffff 的物理地址。换句话说,内核在初始化的第一阶段,可以通过与物理地址相同的线性地址或者通过从 0xc0000000 开始的 8MB 线性地址对 RAM 的前 8MB 进行寻址。
内核通过把 swapper_pg_dir 所有项都填充为 0 来创建期望的映射,不过,0,1,0x300(十进制 768)和 0x301(十进制 769)这四项除外:后两项包含了从 0xc0000000 到 0xc07fffff 间的所有线性地址。0、1、0x300 和 0x301 按以下方式初始化:
0 项和 0x300 项的地址字段置为 pg0 的物理地址,而 1 项和 0x301 项的地址字段置为紧随 pg0 后的页框的物理地址。
把这四个项中的 Present、Read/Write 和 User/Supervisor 标志置位。
把这四个项中的 Accessed、Dirty、PCD、PWD 和 Page Size 标志清 0。
汇编语言函数 startup_32() 也启用分页单元,通过向 cr3 控制寄存器装入 swapper_pg_dir 的地址及设置 cr0 控制寄存器的 PG 标志来达到这一目的。下面是等价的代码片段:
movl $swapper_pg_dir-0xc0000000, %eax movl %eax, %cr3 # /* 设置页表指针*****/ movl %cr0, %eax orl $0x80000000, %eax movl %eax, %cr0 # /*-----设置分页(PG)位 * /
(4)当 RAM 小于 896MB时的最终内核页表
由内核页表所提供的最终映射必须把从 0xc0000000 开始的线性地址转化为从 0 开始的物理地址。
// arch/x86/include/asm/page.h #define __pa(x) __phys_addr((unsigned long)(x)) #define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET)) // arch/x86/mm/physaddr.c 32位系统下的实现 unsigned long __phys_addr(unsigned long x) { /* VMALLOC_* aren't constants */ VIRTUAL_BUG_ON(x < PAGE_OFFSET); VIRTUAL_BUG_ON(__vmalloc_start_set && is_vmalloc_addr((void *) x)); return x - PAGE_OFFSET; } EXPORT_SYMBOL(__phys_addr);
宏 __pa 用于把从 PAGE_OFFSET 开始的线性地址转换成相应的物理地址,而去 __va 做相反的转化。
主内核页全局目录仍然保存在 swapper_pg_dir 变量中。它由 paging_init() 函数初始化。该函数进行如下操作:
- 调用 pagetable_init() 适当地建立页表项。
- 把 swapper_pg_dir 的物理地址写入 cr3 控制寄存器中。
- 如果 CPU 支持 PAE 并且如果内核编译时支持 PAE,则将 cr4 控制寄存器的 PAE 标志置位。
- 调用 __flush_tlb_all() 使 TLB 的所有项无效。
pagetable_init() 执行的操作既依赖于现有 RAM 的容量,也依赖于 CPU 模型。让我们从最简单的情况开始。我们的计算机有小于 896MB(注 7)的 RAM,32 位物理地址足以对所有可用 RAM 进行寻址,因而没有必要激活 PAE 机制 [参见前面 “物理地址扩展(PAE)分页机制” 一节]。
swapper_pg_dir 页全局目录由如下等价的循环重新初始化:
pgd = swapper_pg_dir + pgd_index(PAGE_OFFSET); /* 768 */ phys_addr = 0x00000000; while (phys_addr < (max_low_pfn * PAGE_SIZE)) { pmd = one_md_table_init(pgd); /* 返回 pgd */ set_pmd(pmd, __pmd(phys_addr | pgprot_val(__pgprot(0xle3)))); /* Ox1e3 == Present, Accessed, Dirty, Read/Write, Page Size, Global */ phys_addr += PTRS_PER_PTE * PAGE_SIZE; /* 0x400000 */ ++pgd; }
我们假定 CPU 是支持 4MB 页和 “全局(global)” TLB 表项的最新 80x86 微处理器。注意如果页全局目录项对应的是 0xc0000000 之上的线性地址,则把所有这些项的 User/Supervisor 标志清 0,由此拒绝用户态进程访问内核地址空间。还要注意 Page Size 被置位使得内核可以通过使用大型页来对 RAM 进行寻址(参见本章先前的 “扩展分页” 一节)。
由 startup_32() 函数创建的物理内存前 8MB 的恒等映射用来完成内核的初始化阶段。当这种映射不再必要时,内核调用 zap_low_mappings() 函数清除对应的页表项。
实际上,这种描述并未说明全部事实。我们将在后面 “固定映射的线性地址” 一节看到,内核也调整与 “固定映射的线性地址” 对应的页表项。
注 7:线性地址的最高 128MB 留给几种映射去用(参见本章后面 “固定映射的线性地址” 一节和第八章 “非连续内存区的线性地址” 一节)。因此此映射 RAM 所剩空间为 1GB - 128MB= 896MB 。
(5)当 RAM 大小在 896MB 和 4096MB 之间时的最终内核页表
在这种情况下,并不把 RAM 全部映射到内核地址空间。Linux 在初始化阶段可以做的最好的事是把一个具有 896MB 的 RAM 窗口(window)映射到内核线性地址空间。如果一个程序需要对现有 RAM 的其余部分寻址,那就必须把某些其他的线性地址间隔映射到所需的 RAM。这意味着修改某些页表项的值。我们将在第八章讨论这种动态重映射是如何进行的。
内核使用与前一种情况相同的代码来初始化页全局目录。
(6)当 RAM 大于 4096MB 时的最终内核页表
现在让我们考虑 RAM 大于 4GB 计算机的内核页表初始化,更确切地说,我们处理以下发生的情况:
- CPU 模型支持物理地址扩展(PAE)
- RAM 容量大于 4GB
- 内核以 PAE 支持来编译
尽管 PAE 处理 36 位物理地址,但是线性地址依然是 32 位地址。如前所述,Linux 映射一个 896MB 的 RAM 窗口到内核线性地址空间;剩余 RAM 留着不映射,并由动态重映射来处理,第八章将对此进行描述。与前一种情况的主要差异是使用三级分页模型,因此页全局目录按以下循环代码来初始化:
pgd_idx = pgd_index(PAGE_OFFSET); /* 3 */ for (i=0; i<pgd_idx; i++) set_pgd(swapper_pg_dir + i, __pgd(__pa(empty_zero_page) + 0x001)); /* 0x001 == Present. */ pgd = swapper_pg_dir + pgd_idx; phys_addr = 0x00000000; for (; i<PTRS_PER_PGD; ++i, ++pgd) { pmd = (pmd_t *) alloc_bootmem_low_pages(PAGE_SIZE); set_pgd(pgd, __pgd(__pa(pmd) | 0x001)): /* 0x001 == Present */ if (phys_addr < max_low_pfn * PAGE_SIZE) { for (j=0; j < PTRS_PER_PMD /* 512 */ && phys_addr < max_low_pfn*PAGE_SIZE; ++j) { set_pmd(pmd, __pmd(phys_addr | pgprot_val(__pgprot(0x1e3)))); /* 0x1e3 == Present, Accessed, Dirty, Read/Write, Page Size, Global */ phys_addr += PTRS_PER_PTE * PAGE_SIZE; /* 0x200000 */ } } } swapper_pg_dir[0] = swapper_pg_dir[pgd_idx];
页全局目录中的前三项与用户线性地址空间相对应,内核用一个空页(empty_zero_page)的地址对这三项进行初始化。第四项用页中间目录(pmd)的地址初始化,该页中间目录是通过调用 alloc_bootmem_low_pages() 分配的。页中间目录中的前 448 项(有 512 项,但后 64 项留给非连续内存分配;参见第八章的 “非连续内存区管理” 一节)用 RAM 前 896MB 的物理地址填充。
注意,支持 PAE 的所有 CPU 模型也支持大型 2MB 页和全局页。正如前一种情况一样,只要可能,Linux 使用大型页来减少页表数。
然后页全局目录的第四项被拷贝到第一项中,这样好为线性地址空间的前 896MB 中的低物理内存映射作镜像。为了完成对 SMP 系统的初始化,这个映射是必需的:当这个映射不再必要时,内核通过调用 zap_low_mappings() 函数来清除对应的页表项,正如先前的情况一样。
(7)固定映射的线性地址
我们看到内核线性地址第四个GB 的初始部分映射系统的物理内存。但是,至少 128MB 的线性地址总是留作他用,因为内核使用这些线性地址实现非连续内存分配和固定映射的线性地址。
非连续内存分配仅仅是动态分配和释放内存页的一种特殊方式,将在第八章 “非连续内存区的线性地址” 一节描述。本节我们集中讨论固定映射的线性地址。
固定映射的线性地址(fix-mapped linear address)基本上是一种类似于 0xffffc000(4G - 16K) 这样的常量线性地址,其对应的物理地址不必等于线性地址减去 0xc000000,而是可以以任意方式建立。因此,每个固定映射的线性地址都映射一个物理内存的页框。我们将会在后面的章节看到,内核使用固定映射的线性地址来代替指针变量,因为这些指针变量的值从不改变。
固定映射的线性地址概念上类似于对 RAM 前 896MB 映射的线性地址。不过,固定映射的线性地址可以映射任何物理地址,而由第 4GB 初始部分的线性地址所建立的映射是线性的(线性地址 X 映射物理地址 X - PAGE_OFFSET)。
就指针变量而言,固定映射的线性地址更有效。事实上,间接引用一个指针变量比间接引用一个立即常量地址要多一次内存访问。此外,在间接引用一个指针变量之前对其值进行检查是一个良好的编程习惯;相反,对一个常量线性地址的检查则是没有必要的。
每个固定映射的线性地址都由定义于 enum fixed_addresses 数据结构中的整型索引来表示:
enum fixed_addresses { FIX_HOLE, FIX_VSYSCALL, FIX_APIC_BASE, FIX_IO_APIC_BASE_0, [...] __end_of_fixed_addresses }
每个固定映射的线性地址都存放在线性地址第四个 GB 的末端。fix_to_virt() 函数计算从给定索引开始的常量线性地址:
inline unsigned long fix_to_virt(const unsigned int idx) { _if (idx >= __end_of_fixed_addresses) __this_fixmap_does_not_exist(); return (0xfffff000UL - (idx << PAGE_SHIFT)); }
让我们假定某个内核函数调用 fix_to_virt(FIX_IO_APIC_BASE_0) 。因为该函数声明为 “inline”,所以C 编译程序不调用 fix_to_virt(),而是仅仅把它的代码插入到调用函数中。此外,运行时从不对这个索引值执行检查。事实上,FIX_IO_APIC_BASE_0 是个等于 3 的常量,因此编译程序可以去掉 if 语句,因为它的条件在编译时为假。相反,如果条件为真,或者 fix_to_virt() 的参数不是一个常量,则编译程序在连接阶段产生一个错误,因为符号 _this_fixmap_does_not_exist 在别处没有定义。最后,编译程序计算 0xfffff000-(3<
为了把一个物理地址与固定映射的线性地址关联起来,内核使用set_fixmap(idx, phys) 和 set_fixmap_nocache(idx,phys) 去。这两个函数都把 fix_to_virt(idx) 线性地址对应的一个页表项初始化为物理地址 phys;不过,第二个函数也把页表项的 PCD 标志置位,因此,当访问这个页框中的数据时禁用硬件高速缓存(参见本章前面 “硬件高速缓存” 一节)。反过来,clear_fixmap(idx) 用来撤销固定映射线性地址 idx 和物理地址之间的连接。
// arch/x86/include/asm/fixmap.h #define set_fixmap(idx, phys) \ __set_fixmap(idx, phys, PAGE_KERNEL) #define set_fixmap_nocache(idx, phys) \ __set_fixmap(idx, phys, PAGE_KERNEL_NOCACHE) static inline void __set_fixmap(enum fixed_addresses idx, phys_addr_t phys, pgprot_t flags) { native_set_fixmap(idx, phys, flags); } // arch/x86/mm/pgtable.c void native_set_fixmap(enum fixed_addresses idx, phys_addr_t phys, pgprot_t flags) { __native_set_fixmap(idx, pfn_pte(phys >> PAGE_SHIFT, flags)); }
(8)处理硬件高速缓存和 TLB
内存寻址的最后一个主题是关于内核如何使用硬件高速缓存来达到最佳效果。硬件高速缓存和转换后援缓冲器(TLB)在提高现代计算机体系结构的性能上扮演着重要角色。
内核开发者采用一些技术来减少高速缓存和 TLB 的未命中次数。
- 处理硬件高速缓存
- 处理 TLB
三、进程
1、进程、轻量级进程和线程
当一个进程创建时,它几乎与父进程相同。它接受父进程地址空间的一个(逻辑)拷贝,并从进程创建系统调用的下一条指令开始执行与父进程相同的代码。尽管父子进程可以共享含有程序代码(正文)的页,但是它们各自有独立的数据拷贝(栈和堆),因此子进程对一个内存单元的修改对父进程是不可见的(反之亦然)。
尽管早期 Unix 内核使用了这种简单模式,但是,现代 Unix 系统并没有如此使用。它们支持多线程应用程序 —— 拥有很多相对独立执行流的用户程序共享应用程序的大部分数据结构。在这样的系统中,一个进程由几个用户线程(或简单地说,线程)组成,每个线程都代表进程的一个执行流。现在,大部分多线程应用程序都是用 pthread(POSIX thread)库的标准库函数集编写的。
Linux 内核的早期版本没有提供多线程应用的支持。从内核观点看,多线程应用程序仅仅是一个普通进程。多线程应用程序多个执行流的创建、处理、调度整个都是在用户态进行的(通常使用 POSIX 兼容的 pthread 库)。
Linux 使用轻量级进程(lightweight process)对多线程应用程序提供更好的支持。两个轻量级进程基本上可以共享一些资源,诸如地址空间、打开的文件等等。只要其中一个修改共享资源,另一个就立即查看这种修改。当然,当两个线程访问共享资源时就必须同步它们自己。
实现多线程应用程序的一个简单方式就是把轻量级进程与每个线程关联起来。这样,线程之间就可以通过简单地共享同一内存地址空间、同一打开文件集等来访问相同的应用程序数据结构集,同时,每个线程都可以由内核独立调度,以便一个睡眠的同时另一个仍然是可运行的。POSIX 兼容的 pthread 库使用 Linux 轻量级进程有 3 个例子,它们是 LinuxThreads、 Native Posix Thread Library(NPTL) 和 IBM 的下一代 Posix 线程包 NGPT(Next Generation Posix Threading Package)。
POSIX 兼容的多线程应用程序由支持 “线程组” 的内核来处理最好不过。在 Linux 中,一个线程组基本上就是实现了多线程应用的一组轻量级进程,对于像 getpid(),kill(),和 _exit() 这样的一些系统调用,它像一个组织,起整体的作用。
2、进程描述符
// include/linux/sched.h struct task_struct { volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */ void *stack; atomic_t usage; unsigned int flags; /* per process flags, defined below */ unsigned int ptrace; int lock_depth; /* BKL lock depth */ #ifdef CONFIG_SMP #ifdef __ARCH_WANT_UNLOCKED_CTXSW int oncpu; #endif #endif int prio, static_prio, normal_prio; unsigned int rt_priority; const struct sched_class *sched_class; struct sched_entity se; struct sched_rt_entity rt; #ifdef CONFIG_PREEMPT_NOTIFIERS /* list of struct preempt_notifier: */ struct hlist_head preempt_notifiers; #endif /* * fpu_counter contains the number of consecutive context switches * that the FPU is used. If this is over a threshold, the lazy fpu * saving becomes unlazy to save the trap. This is an unsigned char * so that after 256 times the counter wraps and the behavior turns * lazy again; this to deal with bursty apps that only use FPU for * a short time */ unsigned char fpu_counter; #ifdef CONFIG_BLK_DEV_IO_TRACE unsigned int btrace_seq; #endif unsigned int policy; cpumask_t cpus_allowed; #ifdef CONFIG_TREE_PREEMPT_RCU int rcu_read_lock_nesting; char rcu_read_unlock_special; struct rcu_node *rcu_blocked_node; struct list_head rcu_node_entry; #endif /* #ifdef CONFIG_TREE_PREEMPT_RCU */ #if defined(CONFIG_SCHEDSTATS) || defined(CONFIG_TASK_DELAY_ACCT) struct sched_info sched_info; #endif struct list_head tasks; struct plist_node pushable_tasks; struct mm_struct *mm, *active_mm; #if defined(SPLIT_RSS_COUNTING) struct task_rss_stat rss_stat; #endif /* task state */ int exit_state; int exit_code, exit_signal; int pdeath_signal; /* The signal sent when the parent dies */ /* ??? */ unsigned int personality; unsigned did_exec:1; unsigned in_execve:1; /* Tell the LSMs that the process is doing an * execve */ unsigned in_iowait:1; /* Revert to default priority/policy when forking */ unsigned sched_reset_on_fork:1; pid_t pid; pid_t tgid; #ifdef CONFIG_CC_STACKPROTECTOR /* Canary value for the -fstack-protector gcc feature */ unsigned long stack_canary; #endif /* * pointers to (original) parent process, youngest child, younger sibling, * older sibling, respectively. (p->father can be replaced with * p->real_parent->pid) */ struct task_struct *real_parent; /* real parent process */ struct task_struct *parent; /* recipient of SIGCHLD, wait4() reports */ /* * children/sibling forms the list of my natural children */ struct list_head children; /* list of my children */ struct list_head sibling; /* linkage in my parent's children list */ struct task_struct *group_leader; /* threadgroup leader */ /* * ptraced is the list of tasks this task is using ptrace on. * This includes both natural children and PTRACE_ATTACH targets. * p->ptrace_entry is p's link on the p->parent->ptraced list. */ struct list_head ptraced; struct list_head ptrace_entry; /* * This is the tracer handle for the ptrace BTS extension. * This field actually belongs to the ptracer task. */ struct bts_context *bts; /* PID/PID hash table linkage. */ struct pid_link pids[PIDTYPE_MAX]; struct list_head thread_group; struct completion *vfork_done; /* for vfork() */ int __user *set_child_tid; /* CLONE_CHILD_SETTID */ int __user *clear_child_tid; /* CLONE_CHILD_CLEARTID */ cputime_t utime, stime, utimescaled, stimescaled; cputime_t gtime; #ifndef CONFIG_VIRT_CPU_ACCOUNTING cputime_t prev_utime, prev_stime; #endif unsigned long nvcsw, nivcsw; /* context switch counts */ struct timespec start_time; /* monotonic time */ struct timespec real_start_time; /* boot based time */ /* mm fault and swap info: this can arguably be seen as either mm-specific or thread-specific */ unsigned long min_flt, maj_flt; struct task_cputime cputime_expires; struct list_head cpu_timers[3]; /* process credentials */ const struct cred *real_cred; /* objective and real subjective task * credentials (COW) */ const struct cred *cred; /* effective (overridable) subjective task * credentials (COW) */ struct mutex cred_guard_mutex; /* guard against foreign influences on * credential calculations * (notably. ptrace) */ struct cred *replacement_session_keyring; /* for KEYCTL_SESSION_TO_PARENT */ char comm[TASK_COMM_LEN]; /* executable name excluding path - access with [gs]et_task_comm (which lock it with task_lock()) - initialized normally by setup_new_exec */ /* file system info */ int link_count, total_link_count; #ifdef CONFIG_SYSVIPC /* ipc stuff */ struct sysv_sem sysvsem; #endif #ifdef CONFIG_DETECT_HUNG_TASK /* hung task detection */ unsigned long last_switch_count; #endif /* CPU-specific state of this task */ struct thread_struct thread; /* filesystem information */ struct fs_struct *fs; /* open file information */ struct files_struct *files; /* namespaces */ struct nsproxy *nsproxy; /* signal handlers */ struct signal_struct *signal; struct sighand_struct *sighand; sigset_t blocked, real_blocked; sigset_t saved_sigmask; /* restored if set_restore_sigmask() was used */ struct sigpending pending; unsigned long sas_ss_sp; size_t sas_ss_size; int (*notifier)(void *priv); void *notifier_data; sigset_t *notifier_mask; struct audit_context *audit_context; #ifdef CONFIG_AUDITSYSCALL uid_t loginuid; unsigned int sessionid; #endif seccomp_t seccomp; /* Thread group tracking */ u32 parent_exec_id; u32 self_exec_id; /* Protection of (de-)allocation: mm, files, fs, tty, keyrings, mems_allowed, * mempolicy */ spinlock_t alloc_lock; #ifdef CONFIG_GENERIC_HARDIRQS /* IRQ handler threads */ struct irqaction *irqaction; #endif /* Protection of the PI data structures: */ raw_spinlock_t pi_lock; #ifdef CONFIG_RT_MUTEXES /* PI waiters blocked on a rt_mutex held by this task */ struct plist_head pi_waiters; /* Deadlock detection and priority inheritance handling */ struct rt_mutex_waiter *pi_blocked_on; #endif #ifdef CONFIG_DEBUG_MUTEXES /* mutex deadlock detection */ struct mutex_waiter *blocked_on; #endif #ifdef CONFIG_TRACE_IRQFLAGS unsigned int irq_events; unsigned long hardirq_enable_ip; unsigned long hardirq_disable_ip; unsigned int hardirq_enable_event; unsigned int hardirq_disable_event; int hardirqs_enabled; int hardirq_context; unsigned long softirq_disable_ip; unsigned long softirq_enable_ip; unsigned int softirq_disable_event; unsigned int softirq_enable_event; int softirqs_enabled; int softirq_context; #endif #ifdef CONFIG_LOCKDEP # define MAX_LOCK_DEPTH 48UL u64 curr_chain_key; int lockdep_depth; unsigned int lockdep_recursion; struct held_lock held_locks[MAX_LOCK_DEPTH]; gfp_t lockdep_reclaim_gfp; #endif /* journalling filesystem info */ void *journal_info; /* stacked block device info */ struct bio_list *bio_list; /* VM state */ struct reclaim_state *reclaim_state; struct backing_dev_info *backing_dev_info; struct io_context *io_context; unsigned long ptrace_message; siginfo_t *last_siginfo; /* For ptrace use. */ struct task_io_accounting ioac; #if defined(CONFIG_TASK_XACCT) u64 acct_rss_mem1; /* accumulated rss usage */ u64 acct_vm_mem1; /* accumulated virtual memory usage */ cputime_t acct_timexpd; /* stime + utime since last update */ #endif #ifdef CONFIG_CPUSETS nodemask_t mems_allowed; /* Protected by alloc_lock */ int cpuset_mem_spread_rotor; #endif #ifdef CONFIG_CGROUPS /* Control Group info protected by css_set_lock */ struct css_set *cgroups; /* cg_list protected by css_set_lock and tsk->alloc_lock */ struct list_head cg_list; #endif #ifdef CONFIG_FUTEX struct robust_list_head __user *robust_list; #ifdef CONFIG_COMPAT struct compat_robust_list_head __user *compat_robust_list; #endif struct list_head pi_state_list; struct futex_pi_state *pi_state_cache; #endif #ifdef CONFIG_PERF_EVENTS struct perf_event_context *perf_event_ctxp; struct mutex perf_event_mutex; struct list_head perf_event_list; #endif #ifdef CONFIG_NUMA struct mempolicy *mempolicy; /* Protected by alloc_lock */ short il_next; #endif atomic_t fs_excl; /* holding fs exclusive resources */ struct rcu_head rcu; /* * cache last used pipe for splice */ struct pipe_inode_info *splice_pipe; #ifdef CONFIG_TASK_DELAY_ACCT struct task_delay_info *delays; #endif #ifdef CONFIG_FAULT_INJECTION int make_it_fail; #endif struct prop_local_single dirties; #ifdef CONFIG_LATENCYTOP int latency_record_count; struct latency_record latency_record[LT_SAVECOUNT]; #endif /* * time slack values; these are used to round up poll() and * select() etc timeout values. These are in nanoseconds. */ unsigned long timer_slack_ns; unsigned long default_timer_slack_ns; struct list_head *scm_work_list; #ifdef CONFIG_FUNCTION_GRAPH_TRACER /* Index of current stored address in ret_stack */ int curr_ret_stack; /* Stack of return addresses for return function tracing */ struct ftrace_ret_stack *ret_stack; /* time stamp for last schedule */ unsigned long long ftrace_timestamp; /* * Number of functions that haven't been traced * because of depth overrun. */ atomic_t trace_overrun; /* Pause for the tracing */ atomic_t tracing_graph_pause; #endif #ifdef CONFIG_TRACING /* state flags for use by tracers */ unsigned long trace; /* bitmask of trace recursion */ unsigned long trace_recursion; #endif /* CONFIG_TRACING */ #ifdef CONFIG_CGROUP_MEM_RES_CTLR /* memcg uses this to do batch job */ struct memcg_batch_info { int do_batch; /* incremented when batch uncharge started */ struct mem_cgroup *memcg; /* target memcg of uncharge */ unsigned long bytes; /* uncharged usage */ unsigned long memsw_bytes; /* uncharged mem+swap usage */ } memcg_batch; #endif };
下图示意性地描述了 Linux 的进程描述符。
(1)标识一个进程
一般来说,能被独立调度的每个执行上下文都必须拥有它自己的进程描述符:因此,即使共享内核大部分数据结构的轻量级进程,也有它们自己的 task_struct 结构。
进程和进程描述符之间有非常严格的一一对应关系,这使得用 32 位进程描述符地址(注 3)标识进程成为一种方便的方式。进程描述符指针指向这些地址,内核对进程的大部分引用是通过进程描述符指针进行的。
另一方面,类 Unix 操作系统允许用户使用一个叫做进程标识符 process ID(或 PID)的数来标识进程,PID 存放在进程描述符的 pid 字段中。PID 被顺序编号,新创建进程的 PID 通常是前一个进程的 PID 加 1。不过,PID 的值有一个上限,当内核使用的 PID 达到这个上限值的时候就必须开始循环使用已闲置的小 PID 号。在缺省情况下,最大的 PID 号是 32767(PID_MAX_DEFAULT-1):系统管理员可以通过往 /proc/sys/kernel/pid_max 这个文件中写入一个更小的值来减小 PID 的上限值,使 PID 的上限小于 32767。( /proc 是一个特殊文件系统的安装点,参看第十二章"特殊文件系统"一节。)在 64 位体系结构中,系统管理员可以把 PID 的上限扩大到 4194303 。
由于循环使用 PID 编号,内核必须通过管理一个 pidmap-array 位图来表示当前已分配的 PID 号和闲置的 PID 号。因为一个页框包含 32768 个位,所以在 32 位体系结构中 pidmap-array 位图存放在一个单独的页中。然而,在 64 位体系结构中,当内核分配了超过当前位图大小的 PID 号时,需要为 PID 位图增加更多的页。系统会一直保存这些页不被释放。
Linux 把不同的 PID 与系统中每个进程或轻量级进程相关联(本章后面我们会看到,在多处理器系统上稍有例外)。这种方式能提供最大的灵活性,因为系统中每个执行上下文都可以被唯一地识别。
另一方面,Unix 程序员希望同一组中的线程有共同的 PID。例如,把指定 PID 的信号发给组中的所有线程。事实上,POSIX 1003.1c 标准规定一个多线程应用程序中的所有线程都必须有相同的 PID。
遵照这个标准,Linux 引入线程组的表示。一个线程组中的所有线程使用和该线程组的领头线程(thread group leader)相同的 PID,也就是该组中第一个轻量级进程的 PID,它被存入进程描述符的 tgid 字段中。getpid() 系统调用返回当前进程的 tgid 值而不是 pid 的值,因此,一个多线程应用的所有线程共享相同的 PID。绝大多数进程都属于一个线程组,包含单一的成员线程组的领头线程其 tgid 的值与 pid 的值相同,因而 getpid() 系统调用对这类进程所起的作用和一般进程是一样的。
下面,我们将向你说明如何从进程的 PID 中有效地导出它的描述符指针。效率至关重要,因为像 kill() 这样的很多系统调用使用 PID 表示所操作的进程。
(2)进程描述符处理
进程是动态实体,其生命周期范围从几毫秒到几个月。因此,内核必须能够同时处理很多进程,并把进程描述符存放在动态内存中,而不是放在永久分配给内核的内存区(译注1)。对每个进程来说,Linux 都把两个不同的数据结构紧凑地存放在一个单独为进程分配的存储区域内:一个是内核态的进程堆栈,另一个是紧挨进程描述符的小数据结构 thread_info,叫做线程描述符,这块存储区域的大小通常为 8192 个字节(两个页框)。考虑到效率的因素,内核让这 8K 空间占据连续的两个页框并让第一个页框的起始地址是 213 的倍数。当几乎没有可用的动态内存空间时,就会很难找到这样的两个连续页框,因为空闲空间可能存在大量碎片(见第八章 “伙伴系统算法” 一节)。因此,在 80x86 体系结构中,在编译时可以进行设置,以使内核栈和线程描述符跨越一个单独的页框(4096 个字节)。
在第二章 “Linux 中的分段” 一节中我们已经知道,内核态的进程访问处于内核数据段的栈,这个栈不同于用户态的进程所用的栈。因为内核控制路径使用很少的栈,因此只需要几千个字节的内核态堆栈。所以,对栈和 thread_info 结构来说,8KB 足够了。不过,当使用一个页框(一页内存,4K)存放内核态堆栈和 thread_info 结构时,内核要采用一些额外的栈以防止中断和异常的深度嵌套而引起的溢出(见第四章)。
图 3-2 显示了在 2 页(8KB)内存区中存放两种数据结构的方式。线程描述符驻留于这个内存区的开始,而栈从末端向下增长。该图还显示了分别通过 task 和 thread_info 字段使 thread_info 结构与 task_struct 结构互相关联。
esp 寄存器是 CPU 栈指针,用来存放栈顶单元的地址。在 80x86 系统中,栈起始于末端,并朝这个内存区开始的方向增长。从用户态刚切换到内核态以后,进程的内核栈总是空的,因此,esp 寄存器指向这个栈的顶端。
一旦数据写入堆栈,esp 的值就递减。因为 thread_info 结构是 52 个字节长,因此内核栈能扩展到 8140 个字节。
C 语言使用下列的联合结构方便地表示一个进程的线程描述符和内核栈:
union thread_union { struct thread_info thread_info; unsigned long stack[2048]; /* 对 4K 的核数组下标是 1024 */ };
如图 3-2 所示,thread_info 结构从 0x015fa000 地址处开始存放,而栈从 0x015fc000 地址处开始存放。esp 寄存器的值指向地址为 0x015fa878 的当前栈顶。
内核使用 alloc_thread_info 和 free_thread_info 宏分配和释放存储 thread_info 结构和内核栈的内存区。
(3)标识当前进程
从效率的观点来看,刚才所讲的 thread_info 结构与内核态堆栈之间的紧密结合提供的主要好处是:内核很容易从 esp 寄存器的值获得当前在 CPU 上正在运行进程的 thread_info 结构的地址。事实上,如果 thread_union 结构长度是 8K(213 字节),则内核屏蔽掉 esp 的低 13 位有效位就可以获得 thread_info 结构的基地址; 而如果 thread_union 结构长度是 4K,内核需要屏蔽掉 esp 的低 12 位有效位。这项工作由 current_thread_info() 函数来完成,它产生如下一些汇编指令:
movl $0xffffe000, %ecx # /* 或者是用于 4K 堆栈的 0xfffff000 */ andl %esp, %ecx movl %ecх, p
这三条指令执行以后,p 就包含在执行指令的 CPU 上运行的进程的 thread_info 结构的指针。
进程最常用的是进程描述符的地址而不是 thread_info 结构的地址。为了获得当前在 CPU 上运行进程的描述符指针,内核要调用 current 宏,该宏本质上等价于 current_thread_info()->task ,它产生如下汇编语言指令:
movl $0xffffe000, %ecx # /* 或者是用于 4K堆栈的 0xfffff000 */ andl %esp, %ecx movl (%ecx), p
因为 task 字段在 thread_info 结构中的偏移量为 0,所以执行完这三条指令之后,p 就包含在 CPU 上运行进程的描述符指针。
current 宏经常作为进程描述符字段的前缀出现在内核代码中,例如,current->pid 返回在 CPU 上正在执行的进程的 PID。
用栈存放进程描述符的另一个优点体现在多处理器系统上:如前所述,对于每个硬件处理器,仅通过检查栈就可以获得当前正确的进程。早先的 Linux 版本没有把内核栈与进程描述符存放在一起,而是强制引入全局静态变量 current 来标识正在运行进程的描述符。在多处理器系统上,有必要把 current 定义为一个数组,每一个元素对应一个可用 CPU 。
3、进程切换
进程切换可能只发生在精心定义的点:schedule() 函数。从本质上说,每个进程切换由两步组成:
- 切换页全局目录以安装一个新的地址空间;
- 切换内核态堆栈和硬件上下文,因为硬件上下文提供了内核执行新进程所需要的所有信息,包含 CPU 寄存器。
(1)switch_to 宏
// arch/x86/include/asm/system.h #define switch_to(prev, next, last) \ asm volatile(SAVE_CONTEXT \ "movq %%rsp,%P[threadrsp](%[prev])\n\t" /* save RSP */ \ "movq %P[threadrsp](%[next]),%%rsp\n\t" /* restore RSP */ \ "call __switch_to\n\t" \ "movq "__percpu_arg([current_task])",%%rsi\n\t" \ __switch_canary \ "movq %P[thread_info](%%rsi),%%r8\n\t" \ "movq %%rax,%%rdi\n\t" \ "testl %[_tif_fork],%P[ti_flags](%%r8)\n\t" \ "jnz ret_from_fork\n\t" \ RESTORE_CONTEXT \ : "=a" (last) \ __switch_canary_oparam \ : [next] "S" (next), [prev] "D" (prev), \ [threadrsp] "i" (offsetof(struct task_struct, thread.sp)), \ [ti_flags] "i" (offsetof(struct thread_info, flags)), \ [_tif_fork] "i" (_TIF_FORK), \ [thread_info] "i" (offsetof(struct task_struct, stack)), \ [current_task] "m" (current_task) \ __switch_canary_iparam \ : "memory", "cc" __EXTRA_CLOBBER)
(a)分析
进程切换的第二步由 switch_to 宏执行。它是内核中与硬件关系最密切的例程之一,要理解它到底做了些什么我们必须下些功夫。
首先,该宏有三个参数,它们是 prev,next 和 last 。你可能很容易猜到 prev 和 next 的作用:它们仅是局部变量 prev 和 next 的占位符,即它们是输入参数,分别表示被替换进程和新进程描述符的地址在内存中的位置。
那第三个参数 last 呢 ?在任何进程切换中,涉及到三个进程而不是两个。假设内核决定暂停进程 A 而激活进程 B。在 schedule() 函数中,prev 指向 A 的描述符而 next 指向 B 的描述符。switch_to 宏一但使 A 暂停,A 的执行流就冻结。
随后,当内核想再次此激活 A,就必须暂停另一个进程 C(这通常不同于 B),于是就要用 prev 指向 C 而 next 指向 A 来执行另一个 switch_to 宏。当 A 恢复它的执行流时,就会找到它原来的内核栈,于是 prev 局部变量还是指向 A 的描述符而 next 指向 B 的描述符。此时,代表进程 A 执行的内核就失去了对 C 的任何引用。但是,事实表明这个引用对于完成进程切换是很有用的(更多细节参见第七章)。
switch_to 宏的最后一个参数是输出参数,它表示宏把进程 C 的描述符地址写在内存的什么位置了(当然,这是在 A 恢复执行之后完成的)。在进程切换之前,宏把第一个输入参数 prev(即在 A 的内核堆栈中分配的 prev 局部变量)表示的变量的内容存入 CPU 的 eax 寄存器。在完成进程切换,A 已经恢复执行时,宏把 CPU 的 eax 寄存器的内容写入由第三个输出参数 —— last 所指示的 A 在内存中的位置。因为 CPU 寄存器不会在切换点发生变化,所以 C 的描述符地址也存在内存的这个位置。在 schedule() 执行过程中,参数 last 指向 A 的局部变量 prev,所以 prev 被 C 的地址覆盖。
图 3-7 显示了进程 A,B,C 内核堆栈的内容以及 eax 寄存器的内容。必须注意的是:图中显示的是在被 eax 寄存器的内容覆盖以前的 prev 局部变量的值。
- 在 eax 和 edx 寄存器中分别保存 prev 和 next 的值:
movl prev, %eax movl next, %edx
- 把 eflags 和 ebp 寄存器的内容保存在 prev 内核栈中。必须保存它们的原因是编译器认为在 switch_to 结束之前它们的值应当保持不变。
pushfl pushl %ebp
- 把 esp 的内容保存到 prev->thread.esp 中以使该字段指向 prev 内核栈的栈顶:
movl %esp,484(%eax)
484(%eax) 操作数表示内存单元的地址为 eax 内容加上 484。
4。把 next->thread.esp 装入 esp。此时,内核开始在 next 的内核栈上操作,因此这条指令实际上完成了从 prev 到 next 的切换。由于进程描述符的地址和内核栈的地址紧挨着(就像我们在本章前面"标识一个进程"一节所解释的),所以改变内核栈意味着改变当前进程。
- 把标记为 1 的地地址(本节后面所示)存入 prev->thread.eip。当被替换的进程重新恢复执行时,进程执行被标记为 1 的那条指令:
movl $1f, 480(%eax)
- 宏把 next->thread.eip 的值(绝大多数情况下是一个被标记为 1 的地址)压入 next 的内核栈:
pushl 480(%edx)
- 跳到 __switch_to() C 函数(见下面):
jmp __switch_to
- 这里被进程 B 替换的进程 A 再次获得 CPU:它执行一些保存 eflags 和 ebp 寄存器内容的指令,这两条指令的第一条指令被标记为 1 。
1: popl %ebp popfl
注意这些 pop 指令是怎样引用 prev 进程的内核栈的。当进程调度程序选择了 prev 作为新进程在 CPU 上运行时,将执行这些指令。于是,以 prev 作为第二个参数调用 switch_to。因此,esp 寄存器指向 prev 的内核栈。
- 拷贝 eax 寄存器(上面步骤 1 中被装载)的内容到 switch_to 宏的第三个参数 last 标识的内存区域中:
movl %eax, last
正如先前讨论的,eax 寄存器指向刚被替换的进程的描述符(注 6)。
深入理解 Linux 内核2:https://developer.aliyun.com/article/1597358