赞
踩
Linux 内核设计与实现
深入理解 Linux 内核
深入理解 Linux 内核(二)
Linux 设备驱动程序
Linux设备驱动开发详解
本文主要用来摘录《深入理解 Linux 内核》一书中学习知识点,本书基于 Linux 2.6.11 版本,源代码摘录基于 Linux 2.6.34 ,两者之间可能有些出入。
可参考 ⇒ 1、内存寻址
可参考 ⇒ 五、分段机制
80x86 中有 6 个段寄存器,分别为 cs,ss,ds,es,fs 和 gs。 6 个寄存器中 3 个有专门的用途:可参考 ⇒ 3、段选择符
其它 3 个段寄存器做一般用途,可以指向任意的数据段。
分段可以给每一个进程分配不同的线性地址空间,而分页可以把同一线性地址空间映射到不同的物理空间。与分段相比,Linux 更喜欢使用分页方式,因为:
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 的时候介绍它们。
在单处理器系统中只有一个 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 个段描述符指向下列的段:
如前所述,系统中每个处理器都有一个 GDT 副本。除少数几种情况以外,所有 GDT 的副本都存放相同的表项。首先,每个处理器都有它自己的 TSS 段,因此其对应的 GDT 项不同。其次,GDT 中只有少数项可能依赖于 CPU 正在执行的进程(LDT 和 TLS 段描述符)。最后,在某些情况下,处理器可能临时修改 GDT 副本里的某个项,例如,当调用 APM 的 BIOS 例程时就会发生这种情况。
大多数用户态下的 Linux 程序不使用局部描述符表,这样内核就定义了一个缺省的 LDT 供大多数进程共享。缺省的局部描述符表存放在 default_ldt 数组中。它包含 5 个项,但内核仅仅有效地使用了其中的两个项:用于 iBCS 执行文件的调用门和 Solaris/x86 可执行文件的调用门(参见第二十章的"执行域"一节)。调用门是 80x86 微处理器提供的一种机制,用于在调用预定义函数时改变 CPU 的特权级,由于我们不会再更深入地讨论它们,所以请参考 Intel 文档以获取更多详情。
在某些情况下,进程仍然需要创建自己的局部描述符表。这对有些应用程序很有用,像 Wine 那样的程序,它们执行面向段的微软 Windows 应用程序。modify_ldt() 系统调用允许进程创建自己的局部描述符表。
任何被 modify_ldt() 创建的自定义局部描述符表仍然需要它自己的段。当处理器开始执行拥有自定义局部描述符表的进程时,该 CPU 的 GDT 副本中的 LDT 表项相应地就被修改了。
用户态下的程序同样也利用 modify_ldt() 来分配新的段,但内核却从不使用这些段,它也不需要了解相应的段描述符,因为这些段描述符被包含在进程自定义的局部描述符表中了。
参考 ⇒ 六、分页机制
Linux 采用了一种同时适用于 32 位和 64 位系统的普通分页模型。正像前面 “64 位系统中的分页” 一节所解释的那样,两级页表对 32 位系统来说已经足够了,但 64 位系统需要更多数量的分页级别。直到 2.6.10 版本,Linux 采用三级分页的模型。从 2.6.11 版本开始,采用了四级分页模型(注 5)。图 2-12 中展示的 4 种页表分别被为:
页全局目录包含若干页上级目录的地址,页上级目录又依次包含若干页中间目录的地址,而页中间目录又包含若干页表的地址。每一个页表项指向一个页框。线性地址因此被分成五个部分。图 2-12 没有显示位数,因为每一部分的大小与具体的计算机体系结构有关。
对于没有启用物理地址扩展的 32 位系统,两级页表已经足够了。Linux 通过使 “页上级目录” 位和 “页中间目录” 位全为 0,从根本上取消了页上级目录和页中间目录字段。不过,页上级目录和页中间目录在指针序列中的位置被保留,以便同样的代码在 32 位系统和 64 位系统下都能使用。内核为页上级目录和页中间目录保留了一个位置,这是通过把它们的页目录项数设置为 1,并把这两个目录项映射到页全局目录的一个适当的目录项而实现的。
启用了物理地址扩展的 32 位系统使用了三级页表。Linux 的页全局目录对应 80x86 的页目录指针表(PDPT),取消了页上级目录,页中间目录对应 80x86 的页目录,Linux 的页表对应 80x86 的页表。
最后,64 位系统使用三级还是四级分页取决于硬件对线性地址的位的划分(见表 2-4)。
Linux 的进程处理很大程度上依赖于分页。事实上,线性地址到物理地址的自动转换使下面的设计目标变得可行:
在本章剩余的部分,为了具体起见,我们将涉及 80x86 处理器使用的分页机制。
我们将在第九章看到,每一个进程有它自己的页全局目录和自己的页表集。当发生进程切换时(参见第三章 “进程切换” 一节),Linux 把 CR3 控制寄存器的内容保存在前一个执行进程的描述符中,然后把下一个要执行进程的描述符的值装入 CR3 寄存器中。因此,当新进程重新开始在 CPU 上执行时,分页单元指向一组正确的页表。
进程的线性地址空间分成两部分:
当进程运行在用户态时,它产生的线性地址小于 0xc0000000 ;当进程运行在内核态时,它执行内核代码,所产生的地址大于等于 0xc0000000 。但是,在某些情况下,内核为了检索或存放数据必须访问用户态线性地址空间。
内核维持着一组自己使用的页表,驻留在所谓的主内核页全局目录(master kernel Page Global Directory)中。系统初始化后,这组页表还从未被任何进程或任何内核线程直接使用;更确切地说,主内核页全局目录的最高目录项部分作为参考模型,为系统中每个普通进程对应的页全局目录项提供参考模型。
我们在第八章 “非连续内存区的线性地址” 一节将会解释,内核如何确保对主内核页全局目录的修改能传递到由进程实际使用的页全局目录中。
我们现在描述内核如何初始化自己的页表。这个过程分为两个阶段。事实上,内核映像刚刚被装入内存后,CPU 仍然运行于实模式,所以分页功能没有被启用。
第一个阶段,内核创建一个有限的地址空间,包括内核的代码段和数据段、初始页表和用于存放动态数据结构的共 128KB 大小的空间。这个最小限度的地址空间仅够将内核装入 RAM 和对其初始化的核心数据结构。
第二个阶段,内核充分利用剩余的 RAM 并适当地建立分页表。
临时页全局目录是在内核编译过程中静态地初始化的,而临时页表是由 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)位 * /
由内核页表所提供的最终映射必须把从 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() 执行的操作既依赖于现有 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 。
在这种情况下,并不把 RAM 全部映射到内核地址空间。Linux 在初始化阶段可以做的最好的事是把一个具有 896MB 的 RAM 窗口(window)映射到内核线性地址空间。如果一个程序需要对现有 RAM 的其余部分寻址,那就必须把某些其他的线性地址间隔映射到所需的 RAM。这意味着修改某些页表项的值。我们将在第八章讨论这种动态重映射是如何进行的。
内核使用与前一种情况相同的代码来初始化页全局目录。
现在让我们考虑 RAM 大于 4GB 计算机的内核页表初始化,更确切地说,我们处理以下发生的情况:
尽管 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() 函数来清除对应的页表项,正如先前的情况一样。
我们看到内核线性地址第四个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<<PAGE_SHIFT),并用常量线性地址 0xffffc000 代替 fix_to_virt() 函数调用。
为了把一个物理地址与固定映射的线性地址关联起来,内核使用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)); }
内存寻址的最后一个主题是关于内核如何使用硬件高速缓存来达到最佳效果。硬件高速缓存和转换后援缓冲器(TLB)在提高现代计算机体系结构的性能上扮演着重要角色。
内核开发者采用一些技术来减少高速缓存和 TLB 的未命中次数。
当一个进程创建时,它几乎与父进程相同。它接受父进程地址空间的一个(逻辑)拷贝,并从进程创建系统调用的下一条指令开始执行与父进程相同的代码。尽管父子进程可以共享含有程序代码(正文)的页,但是它们各自有独立的数据拷贝(栈和堆),因此子进程对一个内存单元的修改对父进程是不可见的(反之亦然)。
尽管早期 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() 这样的一些系统调用,它像一个组织,起整体的作用。
// 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 的进程描述符。
一般来说,能被独立调度的每个执行上下文都必须拥有它自己的进程描述符:因此,即使共享内核大部分数据结构的轻量级进程,也有它们自己的 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 表示所操作的进程。
进程是动态实体,其生命周期范围从几毫秒到几个月。因此,内核必须能够同时处理很多进程,并把进程描述符存放在动态内存中,而不是放在永久分配给内核的内存区(译注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 结构和内核栈的内存区。
从效率的观点来看,刚才所讲的 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 。
进程切换可能只发生在精心定义的点:schedule() 函数。从本质上说,每个进程切换由两步组成:
// 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)
进程切换的第二步由 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 局部变量的值。
movl prev, %eax
movl next, %edx
pushfl
pushl %ebp
movl %esp,484(%eax)
484(%eax) 操作数表示内存单元的地址为 eax 内容加上 484。
movl 484(%edx), %esp
movl $1f, 480(%eax)
pushl 480(%edx)
jmp __switch_to
1:
popl %ebp
popfl
注意这些 pop 指令是怎样引用 prev 进程的内核栈的。当进程调度程序选择了 prev 作为新进程在 CPU 上运行时,将执行这些指令。于是,以 prev 作为第二个参数调用 switch_to。因此,esp 寄存器指向 prev 的内核栈。
movl %eax, last
正如先前讨论的,eax 寄存器指向刚被替换的进程的描述符(注 6)。
// arch/x86/kernel/process_64.c struct task_struct * __switch_to(struct task_struct *prev_p, struct task_struct *next_p) { struct thread_struct *prev = &prev_p->thread; struct thread_struct *next = &next_p->thread; int cpu = smp_processor_id(); struct tss_struct *tss = &per_cpu(init_tss, cpu); unsigned fsindex, gsindex; bool preload_fpu; /* * If the task has used fpu the last 5 timeslices, just do a full * restore of the math state immediately to avoid the trap; the * chances of needing FPU soon are obviously high now */ preload_fpu = tsk_used_math(next_p) && next_p->fpu_counter > 5; /* we're going to use this soon, after a few expensive things */ if (preload_fpu) prefetch(next->xstate); /* * Reload esp0, LDT and the page table pointer: */ load_sp0(tss, next); /* * Switch DS and ES. * This won't pick up thread selector changes, but I guess that is ok. */ savesegment(es, prev->es); if (unlikely(next->es | prev->es)) loadsegment(es, next->es); savesegment(ds, prev->ds); if (unlikely(next->ds | prev->ds)) loadsegment(ds, next->ds); /* We must save %fs and %gs before load_TLS() because * %fs and %gs may be cleared by load_TLS(). * * (e.g. xen_load_tls()) */ savesegment(fs, fsindex); savesegment(gs, gsindex); load_TLS(next, cpu); /* Must be after DS reload */ unlazy_fpu(prev_p); /* Make sure cpu is ready for new context */ if (preload_fpu) clts(); /* * Leave lazy mode, flushing any hypercalls made here. * This must be done before restoring TLS segments so * the GDT and LDT are properly updated, and must be * done before math_state_restore, so the TS bit is up * to date. */ arch_end_context_switch(next_p); /* * Switch FS and GS. * * Segment register != 0 always requires a reload. Also * reload when it has changed. When prev process used 64bit * base always reload to avoid an information leak. */ if (unlikely(fsindex | next->fsindex | prev->fs)) { loadsegment(fs, next->fsindex); /* * Check if the user used a selector != 0; if yes * clear 64bit base, since overloaded base is always * mapped to the Null selector */ if (fsindex) prev->fs = 0; } /* when next process has a 64bit base use it */ if (next->fs) wrmsrl(MSR_FS_BASE, next->fs); prev->fsindex = fsindex; if (unlikely(gsindex | next->gsindex | prev->gs)) { load_gs_index(next->gsindex); if (gsindex) prev->gs = 0; } if (next->gs) wrmsrl(MSR_KERNEL_GS_BASE, next->gs); prev->gsindex = gsindex; /* * Switch the PDA and FPU contexts. */ prev->usersp = percpu_read(old_rsp); percpu_write(old_rsp, next->usersp); percpu_write(current_task, next_p); percpu_write(kernel_stack, (unsigned long)task_stack_page(next_p) + THREAD_SIZE - KERNEL_STACK_OFFSET); /* * Now maybe reload the debug registers and handle I/O bitmaps */ if (unlikely(task_thread_info(next_p)->flags & _TIF_WORK_CTXSW_NEXT || task_thread_info(prev_p)->flags & _TIF_WORK_CTXSW_PREV)) __switch_to_xtra(prev_p, next_p, tss); /* * Preload the FPU context, now that we've determined that the * task is likely to be using it. */ if (preload_fpu) __math_state_restore(); return prev_p; }
_switch_to() 函数执行大多数开始于 switch_to() 宏的进程切换。这个函数作用于 prev_p 和 next_p 参数,这两个参数表示前一个进程和新进程。这个函数的调用不同于一般函数的调用,因为 _switch_to() 从 eax 和 edx 取参数 prev_p 和 next_p(我们在前面已看到这些参数就是保存在那里),而不像大多数函数一样从栈中取参数。为了强迫函数从寄存器取它的参数,内核利用 __attribute__ 和 regparm 关键字,这两个关键字是 C 语言非标准的扩展名,由 gcc 编译程序实现。在 include/asm-i386/system.h 头文件中,__switch_to() 函数的声明如下:
__switch_to(struct task_struct *prev_p,
struct task_struct *next_p)
_attribute__(regparm(3));
函数执行的步骤如下:
__unlazy_fpu(prev_p);
init_tss[cpu].esp0 = next_p->thread.esp0;
cpu_gdt_table[cpu][6] = next_p->thread.tls_array[0];
cpu_gdt_table[cpu][7] = next_p->thread.tls_array[1];
cpu_gdt_table[cpu][8] = next_p->thread.tls_array[2];
movl %fs, 40(%esi)
movl %gs, 44(%esi)
esi 寄存器指向 prev_p->thread 结构。
movl 40(%ebx),%fs
movl 44(%ebx),%gs
ebx 寄存器指向 next_p->thread 结构。代码实际上更复杂,因为当它检测到一个无效的段寄存器值时,CPU 可能产生一个异常。代码采用一种 “修正(fix-up)” 途径来考虑这种可能性(参见第十章"动态地址检查:修正代码" 一节)。
if (next_p->thread.debugreg[7]) {
loaddebug(&next_p->thread, 0);
loaddebug(&next_p->thread, 1);
loaddebug(&next_p->thread, 2);
loaddebug(&next_p->thread, 3);
/* 没有 4 和 5*/
loaddebug(&next_p->thread, 6);
loaddebug(&next_p->thread, 7);
}
if (prev_p->thread.io_bitmap_ptr || next_p->thread.io_bitmap_ptr)
handle_io_bitmap(&next_p->thread, &init_tss[cpu]);
因为进程很少修改 I/O 权限位图,所以该位图在"懒"模式中被处理:当且仅当一个进程在当前时间片内实际访问 I/O 端口时,真实位图才被拷贝到本地 CPU 的 TSS中。进程的定制 I/O 权限位图被保存在 thread_info 结构的 io_bitmap_ptr 字段指向的缓冲区中。 handle_io_bitmap() 函数为 next_p 进程设置本地 CPU 使用的 TSS 的 io_bitmap 字段如下:
TSS 的 io_bitmap 字段应当包含一个在 TSS 中的偏移量,其中存放实际位图。无论何时用户态进程试图访问一个 I/O 端口,0x8000 和 0x9000 指向 TSS 界限之外并将因此引起 “General protection” 异常(参见第四章的 “异常” 一节)。do_general_protection() 异常处理程序将检查保存在 io_bitmap 字段的值;如果是 0x8000,函数发送一个 SIGSEGV 信号给用户态进程; 如果是 0x9000,函数把进程位图(由 thread_info 结构中的 io_bitmap_ptr 字段指示)拷贝到本地 CPU 的 TSS 中,把 io_bitmap 字段设为实际位图的偏移(104),并强制再一次执行有缺陷的汇编语言指令。
return prev_p;
由编译器产生的相应汇编语言指令是:
movl %edi,%eax
ret
prev_p 参数 (现在在 edi 中) 被拷贝到 eax,因为缺省情况下任何 C 函数的返回值被传递给 eax 寄存器。注意 eax 的值因此在调用 __switch_to() 的过程中被保护起来;这非常重要,因为调用 switch_to 宏时会假定 eax 总是用来存放将被替换的进程描述符的地址。
汇编语言指令 ret 把栈顶保存的返回地址装入eip 程序计数器。不过,通过简单地跳转到 __switch_to() 函数来调用该函数。因此,ret 汇编指令在栈中找到标号为 1 的指令的地址,其中标号为 1 的地址是由 switch_to() 宏推入栈中的。如果因为 next_p 第一次执行而以前从未被挂起,__switch_to() 就找到 ret_from_fork() 函数的起始地址。
// kernel/fork.c long do_fork(unsigned long clone_flags, unsigned long stack_start, struct pt_regs *regs, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr) { struct task_struct *p; int trace = 0; long nr; /* * Do some preliminary argument and permissions checking before we * actually start allocating stuff */ if (clone_flags & CLONE_NEWUSER) { if (clone_flags & CLONE_THREAD) return -EINVAL; /* hopefully this check will go away when userns support is * complete */ if (!capable(CAP_SYS_ADMIN) || !capable(CAP_SETUID) || !capable(CAP_SETGID)) return -EPERM; } /* * We hope to recycle these flags after 2.6.26 */ if (unlikely(clone_flags & CLONE_STOPPED)) { static int __read_mostly count = 100; if (count > 0 && printk_ratelimit()) { char comm[TASK_COMM_LEN]; count--; printk(KERN_INFO "fork(): process `%s' used deprecated " "clone flags 0x%lx\n", get_task_comm(comm, current), clone_flags & CLONE_STOPPED); } } /* * When called from kernel_thread, don't do user tracing stuff. */ if (likely(user_mode(regs))) trace = tracehook_prepare_clone(clone_flags); p = copy_process(clone_flags, stack_start, regs, stack_size, child_tidptr, NULL, trace); /* * Do this prior waking up the new thread - the thread pointer * might get invalid after that point, if the thread exits quickly. */ if (!IS_ERR(p)) { struct completion vfork; trace_sched_process_fork(current, p); nr = task_pid_vnr(p); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, parent_tidptr); if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); } audit_finish_fork(p); tracehook_report_clone(regs, clone_flags, nr, p); /* * We set PF_STARTING at creation in case tracing wants to * use this to distinguish a fully live task from one that * hasn't gotten to tracehook_report_clone() yet. Now we * clear it and set the child going. */ p->flags &= ~PF_STARTING; if (unlikely(clone_flags & CLONE_STOPPED)) { /* * We'll start up with an immediate SIGSTOP. */ sigaddset(&p->pending.signal, SIGSTOP); set_tsk_thread_flag(p, TIF_SIGPENDING); __set_task_state(p, TASK_STOPPED); } else { wake_up_new_task(p, clone_flags); } tracehook_report_clone_complete(trace, regs, clone_flags, nr, p); if (clone_flags & CLONE_VFORK) { freezer_do_not_count(); wait_for_completion(&vfork); freezer_count(); tracehook_report_vfork_done(p, nr); } } else { nr = PTR_ERR(p); } return nr; }
// kernel/fork.c static struct task_struct *copy_process(unsigned long clone_flags, unsigned long stack_start, struct pt_regs *regs, unsigned long stack_size, int __user *child_tidptr, struct pid *pid, int trace) { int retval; struct task_struct *p; int cgroup_callbacks_done = 0; if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS)) return ERR_PTR(-EINVAL); /* * Thread groups must share signals as well, and detached threads * can only be started up within the thread group. */ if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND)) return ERR_PTR(-EINVAL); /* * Shared signal handlers imply shared VM. By way of the above, * thread groups also imply shared VM. Blocking this case allows * for various simplifications in other code. */ if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM)) return ERR_PTR(-EINVAL); /* * Siblings of global init remain as zombies on exit since they are * not reaped by their parent (swapper). To solve this and to avoid * multi-rooted process trees, prevent global and container-inits * from creating siblings. */ if ((clone_flags & CLONE_PARENT) && current->signal->flags & SIGNAL_UNKILLABLE) return ERR_PTR(-EINVAL); retval = security_task_create(clone_flags); if (retval) goto fork_out; retval = -ENOMEM; p = dup_task_struct(current); if (!p) goto fork_out; ftrace_graph_init_task(p); rt_mutex_init_task(p); #ifdef CONFIG_PROVE_LOCKING DEBUG_LOCKS_WARN_ON(!p->hardirqs_enabled); DEBUG_LOCKS_WARN_ON(!p->softirqs_enabled); #endif retval = -EAGAIN; if (atomic_read(&p->real_cred->user->processes) >= task_rlimit(p, RLIMIT_NPROC)) { if (!capable(CAP_SYS_ADMIN) && !capable(CAP_SYS_RESOURCE) && p->real_cred->user != INIT_USER) goto bad_fork_free; } retval = copy_creds(p, clone_flags); if (retval < 0) goto bad_fork_free; /* * If multiple threads are within copy_process(), then this check * triggers too late. This doesn't hurt, the check is only there * to stop root fork bombs. */ retval = -EAGAIN; if (nr_threads >= max_threads) goto bad_fork_cleanup_count; if (!try_module_get(task_thread_info(p)->exec_domain->module)) goto bad_fork_cleanup_count; p->did_exec = 0; delayacct_tsk_init(p); /* Must remain after dup_task_struct() */ copy_flags(clone_flags, p); INIT_LIST_HEAD(&p->children); INIT_LIST_HEAD(&p->sibling); rcu_copy_process(p); p->vfork_done = NULL; spin_lock_init(&p->alloc_lock); init_sigpending(&p->pending); p->utime = cputime_zero; p->stime = cputime_zero; p->gtime = cputime_zero; p->utimescaled = cputime_zero; p->stimescaled = cputime_zero; #ifndef CONFIG_VIRT_CPU_ACCOUNTING p->prev_utime = cputime_zero; p->prev_stime = cputime_zero; #endif #if defined(SPLIT_RSS_COUNTING) memset(&p->rss_stat, 0, sizeof(p->rss_stat)); #endif p->default_timer_slack_ns = current->timer_slack_ns; task_io_accounting_init(&p->ioac); acct_clear_integrals(p); posix_cpu_timers_init(p); p->lock_depth = -1; /* -1 = no lock */ do_posix_clock_monotonic_gettime(&p->start_time); p->real_start_time = p->start_time; monotonic_to_bootbased(&p->real_start_time); p->io_context = NULL; p->audit_context = NULL; cgroup_fork(p); #ifdef CONFIG_NUMA p->mempolicy = mpol_dup(p->mempolicy); if (IS_ERR(p->mempolicy)) { retval = PTR_ERR(p->mempolicy); p->mempolicy = NULL; goto bad_fork_cleanup_cgroup; } mpol_fix_fork_child_flag(p); #endif #ifdef CONFIG_TRACE_IRQFLAGS p->irq_events = 0; #ifdef __ARCH_WANT_INTERRUPTS_ON_CTXSW p->hardirqs_enabled = 1; #else p->hardirqs_enabled = 0; #endif p->hardirq_enable_ip = 0; p->hardirq_enable_event = 0; p->hardirq_disable_ip = _THIS_IP_; p->hardirq_disable_event = 0; p->softirqs_enabled = 1; p->softirq_enable_ip = _THIS_IP_; p->softirq_enable_event = 0; p->softirq_disable_ip = 0; p->softirq_disable_event = 0; p->hardirq_context = 0; p->softirq_context = 0; #endif #ifdef CONFIG_LOCKDEP p->lockdep_depth = 0; /* no locks held yet */ p->curr_chain_key = 0; p->lockdep_recursion = 0; #endif #ifdef CONFIG_DEBUG_MUTEXES p->blocked_on = NULL; /* not blocked yet */ #endif #ifdef CONFIG_CGROUP_MEM_RES_CTLR p->memcg_batch.do_batch = 0; p->memcg_batch.memcg = NULL; #endif p->bts = NULL; /* Perform scheduler related setup. Assign this task to a CPU. */ sched_fork(p, clone_flags); retval = perf_event_init_task(p); if (retval) goto bad_fork_cleanup_policy; if ((retval = audit_alloc(p))) goto bad_fork_cleanup_policy; /* copy all the process information */ if ((retval = copy_semundo(clone_flags, p))) goto bad_fork_cleanup_audit; if ((retval = copy_files(clone_flags, p))) goto bad_fork_cleanup_semundo; if ((retval = copy_fs(clone_flags, p))) goto bad_fork_cleanup_files; if ((retval = copy_sighand(clone_flags, p))) goto bad_fork_cleanup_fs; if ((retval = copy_signal(clone_flags, p))) goto bad_fork_cleanup_sighand; if ((retval = copy_mm(clone_flags, p))) goto bad_fork_cleanup_signal; if ((retval = copy_namespaces(clone_flags, p))) goto bad_fork_cleanup_mm; if ((retval = copy_io(clone_flags, p))) goto bad_fork_cleanup_namespaces; retval = copy_thread(clone_flags, stack_start, stack_size, p, regs); if (retval) goto bad_fork_cleanup_io; if (pid != &init_struct_pid) { retval = -ENOMEM; pid = alloc_pid(p->nsproxy->pid_ns); if (!pid) goto bad_fork_cleanup_io; if (clone_flags & CLONE_NEWPID) { retval = pid_ns_prepare_proc(p->nsproxy->pid_ns); if (retval < 0) goto bad_fork_free_pid; } } p->pid = pid_nr(pid); p->tgid = p->pid; if (clone_flags & CLONE_THREAD) p->tgid = current->tgid; if (current->nsproxy != p->nsproxy) { retval = ns_cgroup_clone(p, pid); if (retval) goto bad_fork_free_pid; } p->set_child_tid = (clone_flags & CLONE_CHILD_SETTID) ? child_tidptr : NULL; /* * Clear TID on mm_release()? */ p->clear_child_tid = (clone_flags & CLONE_CHILD_CLEARTID) ? child_tidptr: NULL; #ifdef CONFIG_FUTEX p->robust_list = NULL; #ifdef CONFIG_COMPAT p->compat_robust_list = NULL; #endif INIT_LIST_HEAD(&p->pi_state_list); p->pi_state_cache = NULL; #endif /* * sigaltstack should be cleared when sharing the same VM */ if ((clone_flags & (CLONE_VM|CLONE_VFORK)) == CLONE_VM) p->sas_ss_sp = p->sas_ss_size = 0; /* * Syscall tracing and stepping should be turned off in the * child regardless of CLONE_PTRACE. */ user_disable_single_step(p); clear_tsk_thread_flag(p, TIF_SYSCALL_TRACE); #ifdef TIF_SYSCALL_EMU clear_tsk_thread_flag(p, TIF_SYSCALL_EMU); #endif clear_all_latency_tracing(p); /* ok, now we should be set up.. */ p->exit_signal = (clone_flags & CLONE_THREAD) ? -1 : (clone_flags & CSIGNAL); p->pdeath_signal = 0; p->exit_state = 0; /* * Ok, make it visible to the rest of the system. * We dont wake it up yet. */ p->group_leader = p; INIT_LIST_HEAD(&p->thread_group); /* Now that the task is set up, run cgroup callbacks if * necessary. We need to run them before the task is visible * on the tasklist. */ cgroup_fork_callbacks(p); cgroup_callbacks_done = 1; /* Need tasklist lock for parent etc handling! */ write_lock_irq(&tasklist_lock); /* CLONE_PARENT re-uses the old parent */ if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) { p->real_parent = current->real_parent; p->parent_exec_id = current->parent_exec_id; } else { p->real_parent = current; p->parent_exec_id = current->self_exec_id; } spin_lock(¤t->sighand->siglock); /* * Process group and session signals need to be delivered to just the * parent before the fork or both the parent and the child after the * fork. Restart if a signal comes in before we add the new process to * it's process group. * A fatal signal pending means that current will exit, so the new * thread can't slip out of an OOM kill (or normal SIGKILL). */ recalc_sigpending(); if (signal_pending(current)) { spin_unlock(¤t->sighand->siglock); write_unlock_irq(&tasklist_lock); retval = -ERESTARTNOINTR; goto bad_fork_free_pid; } if (clone_flags & CLONE_THREAD) { atomic_inc(¤t->signal->count); atomic_inc(¤t->signal->live); p->group_leader = current->group_leader; list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group); } if (likely(p->pid)) { tracehook_finish_clone(p, clone_flags, trace); if (thread_group_leader(p)) { if (clone_flags & CLONE_NEWPID) p->nsproxy->pid_ns->child_reaper = p; p->signal->leader_pid = pid; tty_kref_put(p->signal->tty); p->signal->tty = tty_kref_get(current->signal->tty); attach_pid(p, PIDTYPE_PGID, task_pgrp(current)); attach_pid(p, PIDTYPE_SID, task_session(current)); list_add_tail(&p->sibling, &p->real_parent->children); list_add_tail_rcu(&p->tasks, &init_task.tasks); __get_cpu_var(process_counts)++; } attach_pid(p, PIDTYPE_PID, pid); nr_threads++; } total_forks++; spin_unlock(¤t->sighand->siglock); write_unlock_irq(&tasklist_lock); proc_fork_connector(p); cgroup_post_fork(p); perf_event_fork(p); return p; bad_fork_free_pid: if (pid != &init_struct_pid) free_pid(pid); bad_fork_cleanup_io: if (p->io_context) exit_io_context(p); bad_fork_cleanup_namespaces: exit_task_namespaces(p); bad_fork_cleanup_mm: if (p->mm) mmput(p->mm); bad_fork_cleanup_signal: if (!(clone_flags & CLONE_THREAD)) __cleanup_signal(p->signal); bad_fork_cleanup_sighand: __cleanup_sighand(p->sighand); bad_fork_cleanup_fs: exit_fs(p); /* blocking */ bad_fork_cleanup_files: exit_files(p); /* blocking */ bad_fork_cleanup_semundo: exit_sem(p); bad_fork_cleanup_audit: audit_free(p); bad_fork_cleanup_policy: perf_event_free_task(p); #ifdef CONFIG_NUMA mpol_put(p->mempolicy); bad_fork_cleanup_cgroup: #endif cgroup_exit(p, cgroup_callbacks_done); delayacct_tsk_free(p); module_put(task_thread_info(p)->exec_domain->module); bad_fork_cleanup_count: atomic_dec(&p->cred->user->processes); exit_creds(p); bad_fork_free: free_task(p); fork_out: return ERR_PTR(retval); }
copy_process() 创建进程描述符以及子进程执行所需要的所有其他数据结构。它的参数与 do_fork() 的参数相同,外加子进程的 PID。下面描述 copy process() 的最重要的步骤:
检查参数 clone_flags 所传递标志的一致性。尤其是,在下列情况下,它返回错误代号:
通过调用 security_task_create() 以及稍后调用的 security_task_alloc() 执行所有附加的安全检查。Linux 2.6 提供扩展安全性的钩子函数,与传统 Unix 相比,它具有更加强壮的安全模型。详情参见第二十章。
调用 dup_task_struct() 为子进程获取进程描述符。该函数执行如下操作:
如果需要,则在当前进程中调用 __unlazy_fpu(),把 FPU、MMX 和 SSE/SSE2 寄存器的内容保存到父进程的 thread_info 结构中。稍后,dup_task_struct() 将把这些值复制到子进程的 thread_info 结构中。
执行 alloc_task_struct() 宏,为新进程获取进程描述符(task_struct 结构),并将描述符地址保存在 tsk 局部变量中。
执行 alloc_thread_info 宏以获取一块空闲内存区,用来存放新进程的 thread_info 结构和内核栈,并将这块内存区字段的地址存在局部变量 ti 中。正如在本章前面 “标识一个进程” 一节中所述:这块内存区字段的大小是 8KB 或 4KB 。
将 current 进程描述符的内容复制到 tsk 所指向的 task_struct 结构中,然后把 tsk->thread_info 置为 ti 。
把 current 进程的 thread_info 描述符的内容复制到 ti 所指向的结构中,然后把 ti->task 置为 tsk。
把新进程描述符的使用计数器(tsk->usage)置为 2,用来表示进程描述符正在被使用而且其相应的进程处于活动状态(进程状态即不是 EXIT_ZOMBIE,也不是 EXIT_DEAD)。
返回新进程的进程描述符指针(tsk)。
检查存放在 current->signal->rlim[RLIMIT_NPROC].rlim_cur 变量中的值是否小于或等于用户所拥有的进程数。如果是,则返回错误码,除非进程没有 root 权限。该函数从每用户数据结构 user_struct 中获取用户所拥有的进程数。通过进程描述符 user 字段的指针可以找到这个数据结构。
递增 user_struct 结构的使用计数器 (tsk->user->__count 字段)和用户所拥有的进程的计数器(tsk->user->processes)。
检查系统中的进程数量(存放在 nr_threads 变量中)是否超过 max_threads 变量的值。这个变量的缺省值取决于系统内存容量的大小。总的原则是:所有 thread_info 描述符和内核栈所占用的空间不能超过物理内存大小的 1/8。不过,系统管理员可以通过写 /proc/sys/kernel/threads-max 文件来改变这个值。
如果实现新进程的执行域和可执行格式的内核函数(参见第二十章)都包含在内核模块中,则递增它们的使用计数器(参见附录二)。
设置与进程状态相关的几个关键字段:
把新进程的 PID 存入 tsk->pid 字段。
如果 clone_flags 参数中的 CLONE_PARENT_SETTID 标志被设置,就把子进程的 PID 复制到参数 parent_tidptr 指向的用户态变量中。
初始化子进程描述符中的 list_head 数据结构和自旋锁,并为与挂起信号、定时器及时间统计表相关的几个字段赋初值。
调用 copy_semundo(), copy_files(),copy_fs(),copy_sighand(),copy_signa1(), copy_mm() 和 copy_namespace() 来创建新的数据结构,并把父进程相应数据结构的值复制到新数据结构中,除非 clone_flags 参数指出它们有不同的值。
调用 copy_thread(),用发出 clone() 系统调用时 CPU 寄存器的值(正如第十章所述,这些值已经被保存在父进程的内核栈中)来初始化子进程的内核栈。不过,copy_thread() 把 eax 寄存器对应字段的值 [这是 fork() 和 clone() 系统调用在子进程中的返回值] 字段强行置为 0。子进程描述符的 thread.esp 字段初始化为子进程内核栈的基地址,汇编语言函数(ret_from_fork())的地址存放在 thread.eip 字段中。如果父进程使用 I/O 权限位图,则子进程获取该位图的一个拷贝。最后,如果 CLONE_SETTLS 标志被设置,则子进程获取由 clone() 系统调用的参数 tls 指向的用户态数据结构所表示的 TLS 段(注 9)。
如果 clone_flags 参数的值被置为 CLONE_CHILD_SETTID 或CLONE_CHILD_CLEARTID,就把 child_tidptr 参数的值分别复制到 tsk->set_chid_tid 或 tsk->clear_child_tid 字段。这些标志说明:必须改变子进程用户态地址空间的 child_tidptr 所指向的变量的值,不过实际的写操作要稍后再执行。
清除子进程 thread_info 结构的 TIF_SYSCALL_TRACE 标志,以使 ret_from_fork() 函数不会把系统调用结束的消息通知给调试进程(参见第十章 “进入和退出系统调用” 一节)。(为对子进程的跟踪是由 tsk->ptrace 中的 PTRACE_SYSCALL 标志来控制的,所以子进程的系统调用跟踪不会被禁用。)
用 clone_flags 参数低位的信号数字编码初始化 tsk->exit_signal 字段,如果 CLONE_THREAD 标志被置位,就把 tsk->exit_signal 字段初始化为 -1。正如我们将在本章稍后 “进程终止” 一节所看见的,只有当线程组的最后一个成员(通常是线程组的领头)“死亡”,才会产生一个信号,以通知线程组的领头进程的父进程。
调用 sched_fork() 完成对新进程调度程序数据结构的初始化。该函数把新进程的状态设置为 TASK_RUNNING,并把 thread_info 结构的 preempt_count 字段设置为 1,从而禁止内核抢占(参见第五章 “内核抢占” 一节)。此外,为了保证公平的进程调度,该函数在父子进程之间共享父进程的时间片(参见第七章 “scheduler_tick() 数” 一节)。
把新进程的 thread_info 结构的 cpu 字段设置为由 smp_processor_id() 所返回的本地 CPU 号。
初始化表示亲子关系的字段。尤其是,如果 CLONE_PARENT 或 CLONE_THREAD ,被设置,就用 current->real_parent 的值初始化 tsk->real_parent 和 tsk->parent,因此,子进程的父进程似乎是当前进程的父进程。否则,把 tsk->real_parent 和 tsk->parent 置为当前进程。
如果不需要跟踪子进程(没有设置 CLONE_PTRAC 标志),就把 tsk->ptrace 字段设置为 0 。tsk->ptrace 字段会存放一些标志,而这些标志是在一个进程被另外一个进程跟踪时才会用到的。采用这种方式,即使当前进程被跟踪,子进程也不会被跟踪。
执行 SET_LINKS 宏,把新进程描述符插入进程链表。
如果子进程必须被跟踪(tsk->ptrace 字段的 PT_PTRACED 标志被设置),就把 current->parent 赋给 tsk->parent,并将子进程插入调试程序的跟踪链表中。
调用 attach_pid() 把新进程插述符的 PID 插入 pidhash[PIDTYPE_PID] 散列表。
如果子进程是线程组的领头进程(CLONE_THREAD 标志被清 0):
否则,如果子进程属于它的父进程的线程组(CLONE_THREAD 标志被设置):
现在,新进程已经被加入进程集合:递增 nr_threads 变量的值。
递增 total_forks 变量以记录被创建的进程的数量。
终止并返回子进程描述符指针(tsk)。
让我们回头看看在 do_fork() 结束之后都发生了什么。现在,我们有了处于可运行状态的完整的子进程。但是,它还没有实际运行,调度程序要决定何时把 CPU 交给这个子进程。在以后的进程切换中,调度程序继续完善子进程:把子进程描述符 thread 字段的值装入几个 CPU 寄存器。特别是把 thread.esp(即把子进程内核态堆栈的地址)装入 esp 寄存器,把函数 ret_from_fork() 的地址装入eip 寄存器。这个汇编语言函数调用 schedule_tail() 函数(它依次调用 finish_task_switch() 来完成进程切换,参见第七章 “schedule() 函数” 一节),用存放在栈中的值再装载所有的寄存器,并强迫 CPU 返回到用户态。然后,在 fork()、vfork() 或 clone() 系统调用结束时,新进程将开始执行系统调用的返回值放在 eax 寄存器中:返回给子进程的值是 0,返回给父进程的值是子进程的 PID。回顾 copy_thread() 对子进程的 eax 寄存器所执行的操作(copy_process() 的第 13 步),就能理解这是如何实现的。
除非 fork 系统调用返回 0,否则,子进程将与父进程执行相同的代码(参见 copy_process() 的第 13 步)。应用程序的开发者可以按照 Unix 编程者熟悉的方式利用这一事实,在基于 PID 值的程序中插入一个条件语句使子进程与父进程有不同的行为。
传统的 Unix 系统把一些重要的任务委托给周期性执行的进程,这些任务包括刷新磁盘高速缓存,交换出不用的页框,维护网络连接等等。事实上,以严格线性的方式执行这些任务的确效率不高,如果把它们放在后台调度,不管是对它们的函数还是对终端用户进程都能得到较好的响应。因为一些系统进程只运行在内核态,所以现代操作系统把它们的函数委托给内核线程(kernel thread),内核线程不受不必要的用户态上下文的拖累。在 Linux 中,内核线程在以下几方面不同于普通进程:
// arch/x86/kernel/process.c /* * Create a kernel thread */ int kernel_thread(int (*fn)(void *), void *arg, unsigned long flags) { struct pt_regs regs; memset(®s, 0, sizeof(regs)); regs.si = (unsigned long) fn; regs.di = (unsigned long) arg; #ifdef CONFIG_X86_32 regs.ds = __USER_DS; regs.es = __USER_DS; regs.fs = __KERNEL_PERCPU; regs.gs = __KERNEL_STACK_CANARY; #else regs.ss = __KERNEL_DS; #endif regs.orig_ax = -1; regs.ip = (unsigned long) kernel_thread_helper; regs.cs = __KERNEL_CS | get_kernel_rpl(); regs.flags = X86_EFLAGS_IF | 0x2; /* Ok, create the new process.. */ return do_fork(flags | CLONE_VM | CLONE_UNTRACED, 0, ®s, 0, NULL, NULL); } EXPORT_SYMBOL(kernel_thread);
kernel_thread() 函数创建一个新的内核线程,它接受的参数有:所要执行的内核函数的地址(fn)、要传递给函数的参数(arg)、一组 clone 标志(flags)。 该函数本质上以下面的方式调用 do_fork() :
do_fork(flags | CLONE_VM | CLONE_UNTRACED, 0, ®s, 0, NULL, NULL);
CLONE_VM 标志避免复制调用进程的页表:由于新内核线程无论如何都不会访问用户态地址空间,所以这种复制无疑会造成时间和空间的浪费。CLONE_UNTRACED 标志保证不会有任何进程跟踪新内核线程,即使调用进程被跟踪。
传递给 do_fork() 的参数 regs 表示内核栈的地址,copy_thread() 函数将从这里找到为新线程初始化 CPU 寄存器的值。kernel_thread() 函数在这个栈中保留寄存器值的目的是:
movl %edx, %eax
pushl %edx
call *%ebx
pushl %eax
call do_exit
因此,新的内核线程开始执行 fn(arg) 函数,如果该函数结束,内核线程执行系统调用 _exit(),并把 fn() 的返回值传递给它(参见本章稍后 “撤消进程” 一节)。
// arch/x86/kernel/init_task.c /* * Initial thread structure. * * We need to make sure that this is THREAD_SIZE aligned due to the * way process stacks are handled. This is done by having a special * "init_task" linker map entry.. */ union thread_union init_thread_union __init_task_data = { INIT_THREAD_INFO(init_task) }; /* * Initial task structure. * * All other task structs will be allocated on slabs in fork.c */ struct task_struct init_task = INIT_TASK(init_task); EXPORT_SYMBOL(init_task); // include/linux/init_task.h #define INIT_TASK(tsk) \ { \ .state = 0, \ .stack = &init_thread_info, \ .usage = ATOMIC_INIT(2), \ .flags = PF_KTHREAD, \ .lock_depth = -1, \ .prio = MAX_PRIO-20, \ .static_prio = MAX_PRIO-20, \ .normal_prio = MAX_PRIO-20, \ .policy = SCHED_NORMAL, \ .cpus_allowed = CPU_MASK_ALL, \ .mm = NULL, \ .active_mm = &init_mm, \ .se = { \ .group_node = LIST_HEAD_INIT(tsk.se.group_node), \ }, \ .rt = { \ .run_list = LIST_HEAD_INIT(tsk.rt.run_list), \ .time_slice = HZ, \ .nr_cpus_allowed = NR_CPUS, \ }, \ .tasks = LIST_HEAD_INIT(tsk.tasks), \ .pushable_tasks = PLIST_NODE_INIT(tsk.pushable_tasks, MAX_PRIO), \ .ptraced = LIST_HEAD_INIT(tsk.ptraced), \ .ptrace_entry = LIST_HEAD_INIT(tsk.ptrace_entry), \ .real_parent = &tsk, \ .parent = &tsk, \ .children = LIST_HEAD_INIT(tsk.children), \ .sibling = LIST_HEAD_INIT(tsk.sibling), \ .group_leader = &tsk, \ .real_cred = &init_cred, \ .cred = &init_cred, \ .cred_guard_mutex = \ __MUTEX_INITIALIZER(tsk.cred_guard_mutex), \ .comm = "swapper", \ .thread = INIT_THREAD, \ .fs = &init_fs, \ .files = &init_files, \ .signal = &init_signals, \ .sighand = &init_sighand, \ .nsproxy = &init_nsproxy, \ .pending = { \ .list = LIST_HEAD_INIT(tsk.pending.list), \ .signal = {{0}}}, \ .blocked = {{0}}, \ .alloc_lock = __SPIN_LOCK_UNLOCKED(tsk.alloc_lock), \ .journal_info = NULL, \ .cpu_timers = INIT_CPU_TIMERS(tsk.cpu_timers), \ .fs_excl = ATOMIC_INIT(0), \ .pi_lock = __RAW_SPIN_LOCK_UNLOCKED(tsk.pi_lock), \ .timer_slack_ns = 50000, /* 50 usec default slack */ \ .pids = { \ [PIDTYPE_PID] = INIT_PID_LINK(PIDTYPE_PID), \ [PIDTYPE_PGID] = INIT_PID_LINK(PIDTYPE_PGID), \ [PIDTYPE_SID] = INIT_PID_LINK(PIDTYPE_SID), \ }, \ .dirties = INIT_PROP_LOCAL_SINGLE(dirties), \ INIT_IDS \ INIT_PERF_EVENTS(tsk) \ INIT_TRACE_IRQFLAGS \ INIT_LOCKDEP \ INIT_FTRACE_GRAPH \ INIT_TRACE_RECURSION \ INIT_TASK_RCU_PREEMPT(tsk) \ } #define INIT_CPU_TIMERS(cpu_timers) \ { \ LIST_HEAD_INIT(cpu_timers[0]), \ LIST_HEAD_INIT(cpu_timers[1]), \ LIST_HEAD_INIT(cpu_timers[2]), \ }
所有进程的祖先叫做进程 0,idle 进程或因为历史的原因叫做 swapper 进程,它是在 Linux 的初始化阶段从无到有创建的一个内核线程(参见附录一)。这个祖先进程使用下列静态分配的数据结构(所有其他进程的数据结构都是动态分配的):
存放在 init_task 变量中的进程描述符,由 INIT_TASK 宏完成对它的初始化。
存放在 init_thread_union 变量中的 thread_info 描述符和内核堆栈,由 INIT_THREAD_INFO 宏完成对它们的初始化。
由进程描述符指向的下列表:
主内核页全局目录存放在 swapper_pg_dir 中(参见第二章"内核页表"一节)。 start_kernel() 函数初始化内核需要的所有数据结构,激活中断,创建另一个叫进程 1 的内核线程(一般叫做 init 进程):
kernel_thread(init, NULL, CLONE_FS | CLONE_SIGHAND);
新创建内核线程的 PID 为 1,并与进程 0 共享每进程所有的内核数据结构。此外,当调度程序选择到它时,init 进程开始执行 init() 函数。
创建 init 进程后,进程 0 执行 cpu_idle() 函数,该函数本质上是在开中断的情况下重复执行 hlt 汇编语言指令(参见第四章)。只有当没有其他进程处于 TASK_RUNNING 状态时,调度程序才选择进程 0 。
在多处理器系统中,每个 CPU 都有一个进程 0 。只要打开机器电源,计算机的 BIOS 就启动某一个 CPU,同时禁用其他 CPU。运行在 CPU 0 上的 swapper 进程初始化内核数据结构,然后激活其他的 CPU,并通过 copy_process() 函数创建另外的 swapper 进程,把 0 传递给新创建的 swapper 进程作为它们的新 PID。此外,内核把适当的 CPU 索引赋给内核所创建的每个进程的 thread_info 描述符的 cpu 字段。
由进程 0 创建的内核线程执行 init() 函数,init() 依次完成内核初始化。init() 调用 execve() 系统调用装入可执行程序 init。结果,init 内核线程变为一个普通进程,且拥有自己的每进程(per-process)内核数据结构(参见第二十章)。在系统关闭之前,init 进程一直存活,因为它创建和监控在操作系统外层执行的所有进程的活动。
Linux 使用很多其他内核线程。其中一些在初始化阶段创建,一直运行到系统关闭:而其他一些在内核必须执行一个任务时 “按需” 创建,这种任务在内核的执行上下文中得到很好的执行。
一些内核线程的例子(除了进程 0 和进程 1)是:
keventd(也被称为事件)
执行 keventd_wq 工作队列(参见第四章)中的函数。
kapmd
处理与高级电源管理(APM)相关的事件。
kswapd
执行内存回收,在第十七章"周期回收"一节将进行描述。
pdflush
刷新 “脏” 缓冲区中的内容到磁盘以回收内存,在第十五章 “pdflush内核线程” 一节将进行描述。
kblockd
执行 kblockd_workqueue 工作队列中的函数。实质上,它周期性地激活块设备驱动程序,将在第十四章 “激活块设备驱动程序” 一节给予描述。
ksoftirqd
运行 tasklet(参看第四章 “软中断及 tasklet” 一节):系统中每个 CPU 都有这样一个内核线程。
很多进程终止了它们本该执行的代码,从这种意义上说,这些进程"死"了。当这种情况发生时,必须通知内核以便内核释放进程所拥有的资源,包括内存、打开文件及其他我们在本书中讲到的零碎东西,如信号量。
进程终止的一般方式是调用 exit() 库函数,该函数释放 C 函数库所分配的资源,执行编程者所注册的每个函数,并结束从系统回收进程的那个系统调用。exit() 函数可能由编程者显式地插入。另外,C 编译程序总是把 exit() 函数插入到 main() 函数的最后一条语句之后。
内核可以有选择地强迫整个线程组死掉。这发生在以下两种典型情况下:当进程接收到一个不能处理或忽视的信号时(参见十一章),或者当内核正在代表进程运行时在内核态产生一个不可恢复的 CPU 异常时(参见第四章)。
在 Linux 2.6 中有两个终止用户态应用的系统调用:
exit_group() 系统调用,它终止整个线程组,即整个基于多线程的应用。do_group_exit() 是实现这个系统调用的主要内核函数。这是 C 库函数 exit() 应该调用的系统调用。
exit() 系统调用,它终止某一个线程,而不管该线程所属线程组中的所有其他进程。do_exit() 是实现这个系统调用的主要内核函数。这是被诸如 pthread_exit() 的 Linux 线程库的函数所调用的系统调用。
应用层调用:
#include <linux/unistd.h>
void exit_group(int status);
内核层响应函数:
// kernel/exit.c void do_group_exit(int exit_code) { struct signal_struct *sig = current->signal; BUG_ON(exit_code & 0x80); /* core dumps don't get here */ if (signal_group_exit(sig)) exit_code = sig->group_exit_code; else if (!thread_group_empty(current)) { struct sighand_struct *const sighand = current->sighand; spin_lock_irq(&sighand->siglock); if (signal_group_exit(sig)) /* Another thread got here before we took the lock. */ exit_code = sig->group_exit_code; else { sig->group_exit_code = exit_code; sig->flags = SIGNAL_GROUP_EXIT; zap_other_threads(current); } spin_unlock_irq(&sighand->siglock); } do_exit(exit_code); /* NOTREACHED */ }
do_group_exit() 函数杀死属于 current 线程组的所有进程。它接受进程终止代号作为参数,进程终止代号可能是系统调用 exit_group()(正常结束)指定的一个值,也可能是内核提供的一个错误代号(异常结束)。该函数执行下述操作:
// kernel/exit.c void do_exit(long code) { struct task_struct *tsk = current; int group_dead; profile_task_exit(tsk); WARN_ON(atomic_read(&tsk->fs_excl)); if (unlikely(in_interrupt())) panic("Aiee, killing interrupt handler!"); if (unlikely(!tsk->pid)) panic("Attempted to kill the idle task!"); tracehook_report_exit(&code); validate_creds_for_do_exit(tsk); /* * We're taking recursive faults here in do_exit. Safest is to just * leave this task alone and wait for reboot. */ if (unlikely(tsk->flags & PF_EXITING)) { printk(KERN_ALERT "Fixing recursive fault but reboot is needed!\n"); /* * We can do this unlocked here. The futex code uses * this flag just to verify whether the pi state * cleanup has been done or not. In the worst case it * loops once more. We pretend that the cleanup was * done as there is no way to return. Either the * OWNER_DIED bit is set by now or we push the blocked * task into the wait for ever nirwana as well. */ tsk->flags |= PF_EXITPIDONE; set_current_state(TASK_UNINTERRUPTIBLE); schedule(); } exit_irq_thread(); exit_signals(tsk); /* sets PF_EXITING */ /* * tsk->flags are checked in the futex code to protect against * an exiting task cleaning up the robust pi futexes. */ smp_mb(); raw_spin_unlock_wait(&tsk->pi_lock); if (unlikely(in_atomic())) printk(KERN_INFO "note: %s[%d] exited with preempt_count %d\n", current->comm, task_pid_nr(current), preempt_count()); acct_update_integrals(tsk); /* sync mm's RSS info before statistics gathering */ if (tsk->mm) sync_mm_rss(tsk, tsk->mm); group_dead = atomic_dec_and_test(&tsk->signal->live); if (group_dead) { hrtimer_cancel(&tsk->signal->real_timer); exit_itimers(tsk->signal); if (tsk->mm) setmax_mm_hiwater_rss(&tsk->signal->maxrss, tsk->mm); } acct_collect(code, group_dead); if (group_dead) tty_audit_exit(); if (unlikely(tsk->audit_context)) audit_free(tsk); tsk->exit_code = code; taskstats_exit(tsk, group_dead); exit_mm(tsk); if (group_dead) acct_process(); trace_sched_process_exit(tsk); exit_sem(tsk); exit_files(tsk); exit_fs(tsk); check_stack_usage(); exit_thread(); cgroup_exit(tsk, 1); if (group_dead) disassociate_ctty(1); module_put(task_thread_info(tsk)->exec_domain->module); proc_exit_connector(tsk); /* * FIXME: do that only when needed, using sched_exit tracepoint */ flush_ptrace_hw_breakpoint(tsk); /* * Flush inherited counters to the parent - before the parent * gets woken up by child-exit notifications. */ perf_event_exit_task(tsk); exit_notify(tsk, group_dead); #ifdef CONFIG_NUMA mpol_put(tsk->mempolicy); tsk->mempolicy = NULL; #endif #ifdef CONFIG_FUTEX if (unlikely(current->pi_state_cache)) kfree(current->pi_state_cache); #endif /* * Make sure we are holding no locks: */ debug_check_no_locks_held(tsk); /* * We can do this unlocked here. The futex code uses this flag * just to verify whether the pi state cleanup has been done * or not. In the worst case it loops once more. */ tsk->flags |= PF_EXITPIDONE; if (tsk->io_context) exit_io_context(tsk); if (tsk->splice_pipe) __free_pipe_info(tsk->splice_pipe); validate_creds_for_do_exit(tsk); preempt_disable(); exit_rcu(); /* causes final put_task_struct in finish_task_switch(). */ tsk->state = TASK_DEAD; schedule(); BUG(); /* Avoid "noreturn function does return". */ for (;;) cpu_relax(); /* For when BUG is null */ } EXPORT_SYMBOL_GPL(do_exit);
所有进程的终止都是由 do_exit() 函数来处理的,这个函数从内核数据结构中删除对终止进程的大部分引用。do_exit() 函数接受进程的终止代号作为参数并执行下列操作:
把进程描述符的 flag 字段设置为 PF_EXITING 标志,以表示进程正在被删除。
如果需要,通过函数 del_timer_sync()(参见第六章)从动态定时器队列中删除进程描述符。
分别调用 exit_mm()、exit_sem()、__exit_files()、__exit_fs()、exit_namespace() 和 exit_thread() 函数从进程描述符中分离出与分页、信号量、文件系统、打开文件描述符、命名空间以及 I/O 权限位图相关的数据结构。如果没有其他进程共享这些数据结构,那么这些函数还删除所有这些数据结构中。
如果实现了被杀死进程的执行域和可执行格式(参见第二十章)的内核函数包含在内核模块中,则函数递减它们的使用计数器。
把进程描述符的 exit_code 字段设置成进程的终止代号,这个值要么是 _exit() 或 exit_group() 系统调用参数(正常终止),要么是由内核提供的一个错误代号(异常终止)。
调用 exit_notify() 函数执行下面的操作:
更新父进程和子进程的亲属关系。如果同一线程组中有正在运行的进程,就让终止进程所创建的所有子进程都变成同一线程组中另外一个进程的子进程,否则让它们成为 init 的子进程。
检查被终止进程其进程描述符的 exit_signal 字段是否不等于 -1,并检查进程是否是其所属进程组的最后一个成员(注意:正常进程都会具有这些条件,参见前面 “clone()、fork() 和 vfork() 系统调用” 一节中对 copy_process() 的描述,第 16 步)。在这种情况下,函数通过给 正被终止进程的父进程发送一个信号(通常是 SIGCHLD),以通知父进程子进程死亡。
否则,也就是 exit_signal 字段等于 -1,或者线程组中还有其他进程,那么只要进程正在被跟踪,就向父进程发送一个 SIGCHLD 信号(在这种情况下,父进程是调试程序,因而,向它报告轻量级进程死亡的信息)。
如果进程描述符的 exit_signal 字段等于 -1,而且进程没有被跟踪,就把进程描述符的 exit_state 字段置为 EXIT_DEAD,然后调用 release_task() 回收进程的其他数据结构占用的内存,并递减进程描述符的使用计数器(见下一节)。使用记数变为为 1(参见 copy_process() 函数的第 3f 步),以使进程描述符本身正好不会被释放。
否则,如果进程描述符的 exit_signal 字段不等于 -1,或进程正在被跟踪,就把 exit_state 字段置为 EXIT_ZOMBIE。在下一节我们将看到如何处理僵死进程。
把进程描述符的 flags 字段设置为 PF_DEAD 标志(参见第七章 " schedule() 函数" 一节)。
调用 schedule() 函数(参见第七章)选择一个新进程运行。调度程序忽略处于 EXIT_ZOMBIE 状态的进程,所以这种进程正好在 schedule() 中的宏 switch_to 被调用之后停止执行。正如在第七章我们将看到的:调度程序将检查被替换的僵死进程描述符的 PF_DEAD 标志并递减使用计数器,从而说明进程不再存活的事实。
Unix 允许进程查询内核以获得其父进程的 PID,或者其任何子进程的执行状态。例如,进程可以创建一个子进程来执行特定的任务,然后调用诸如 wait() 这样的一些库函数检查子进程是否终止。如果子进程已经终止,那么,它的终止代号将告诉父进程这个任务是否已成功地完成。
为了遵循这些设计选择,不允许 Unix 内核在进程一终止后就丢弃包含在进程描述符字段中的数据。只有父进程发出了与被终止的进程相关的 wait() 类系统调用之后,才允许这样做。这就是引入僵死状态的原因:尽管从技术上来说进程已死,但必须保存它的描述符,直到父进程得到通知。
如果父进程在子进程结束之前结束会发生什么情况呢? 在这种情况下,系统中会到处是僵死的进程,而且它们的进程描述符永久占据着 RAM。如前所述,必须强迫所有的孤儿进程成为 init 进程的子进程来解决这个问题。这样,init 进程在用 wait() 类系统调用检查其合法的子进程终止时,就会撤消僵死的进程。
release_task() 函数从僵死进程的描述符中分离出最后的数据结构,对僵死进程的处理有两种可能的方式:如果父进程不需要接收来自子进程的信号,就调用 do_exit() ;如果已经给父进程发送了一个信号,就调用 wait4() 或 waitpid() 系统调用。在后一种情况下,函数还将回收进程描述符所占用的内存空间,而在前一种情况下,内存的回收将由进程调度程序来完成(参见第七章)。该函数执行下述步骤:
中断处理是由内核执行的最敏感的任务之一,因为它必须满足下列约束:
Intel 文档把中断和异常分为以下几类:
每个中断和异常是由 0 ~ 255 之间的一个数来标识。因为一些未知的原因,Intel 把这个 8 位的无符号整数叫做一个向量(vector)。非屏蔽中断的向量和异常的向量是固定的,而可屏蔽中断的向量可以通过对中断控制器的编程来改变(参见下一节)。
每个能够发出中断请求的硬件设备控制器都有一条名为 IRQ(Interrupt ReQuest)的输出线(注1)。所有现有的 IRQ 线(IRQ line)都与一个名为可编程中断控制器(Programmable Interrpt Controuer PIC)的硬件电路的输入引脚相连。可编程中断控制器执行下列动作:
IRQ 线是从 0 开始顺序编号的,因此,第一条 IRQ 线通常表示成 IRQ0。与 IRQn 关联的 Intel 的缺省向量是 n+32。如前所述,通过向中断控制器端口发布合适的指令,就可以修改 IRQ 和向量之间的映射。
可以有选择地禁止每条 IRQ 线。因此,可以对 PIC 编程从而禁止 IRQ,也就是说,可以告诉 PIC 停止对给定的 IRQ 线发布中断,或者激话它们。禁止的中断是丢失不了的,它们一旦被激活,PIC 就又把它们发送到 CPU。这个特点被大多数中断处理程序使用,因为这允许中断处理程序逐次地处理同一类型的 IRQ。
有选择地激活/禁止 IRQ 线不同于可屏蔽中断的全局屏蔽 /非屏蔽。当 eflags 寄存器的 IF 标志被清 0 时,由 PIC 发布的每个可屏蔽中断都由 CPU 暂时忽略。cli 和 sti 汇编指令分别清除和设置该标志。可参考 ==> 1、标志寄存器
传统的 PIC 是由两片 8259A 风格的外部芯片以 “级联” 的方式连接在一起的。每个芯片可以处理多达 8 个不同的 IRQ 输入线。因为从 PIC 的 INT 输出线连接到主 PIC 的 IRQ2 引脚,因此,可用 IRQ 线的个数限制为 15。
以前的描述仅涉及为单处理器系统设计的 PIC。如果系统只有一个单独的 CPU,那么主 PIC 的输出线以直接了当的方式连接到 CPU 的 INTR 引脚。然而,如果系统中包含两个或多个 CPU,那么这种方式不再有效,因而需要更复杂的 PIC。
为了充分发挥 SMP 体系结构的并行性,能够把中断传递给系统中的每个 CPU 至关重要。基于此理由,Intel 从 Pentiun III 开始引入了一种名为 I/O 高级可编程控制器(I/O Advanced Programmable Interrupt Controller,I/O APIC)的新组件,用以代替老式的 8259A 可编程中断控制器。新近的主板为了支持以前的操作系统都包括两种芯片。此外,80x86 微处理器当前所有的 CPU 都含有一个本地 APIC。每个本地 APIC 都有 32 位的寄存器、一个内部时钟、一个本地定时设备及为本地 APIC 中断保留的两条额外的 IRQ 线 LINT0 和 LINT1。所有本地 APIC 都连接到一个外部 I/O APIC,形成一个多 APIC 的系统。
图 4-1 以示意图的方式显示了一个多 APIC 系统的结构。一条 APIC 总线把 “前端” I/O APIC 连接到本地 APIC。来自设备的 IRQ 线连接到 I/O APIC,因此,相对于本地 APIC,I/O APIC 起路由器的作用。在 Pentium III 和早期处理器的母板上,APIC 总线是一个串行三线总线;从 Pentium 4 开始,APIC 总线通过系统总线来实现。不过,因为 APIC 总线及其信息对软件是不可见的,因此,我们不做进一步的详细讨论。
I/O APIC 的组成为:一组 24 条 IRQ 线、一张 24 项的中断重定向表(Interrupt Redirection Table)、可编程寄存器,以及通过 APIC 总线发送和接收 APIC 信息的一个信息单元。与 8259A 的 IRQ 引脚不同,中断优先级并不与引脚号相关联:中断重定向表中的每一项都可以被单独编程以指明中断向量和优先级、目标处理器及选择处理器的方式。重定向表中的信息用于把每个外部 IRQ 信号转换为一条消息,然后,通过 APIC 总线把消息发送给一个或多个本地 APIC 单元。
来自外部硬件设备的中断请求以两种方式在可用 CPU 之间分发:
静态分发
IRQ 信号传递给重定向表相应项中所列出的本地 APIC。中断立即传递给一个特定的 CPU,或一组 CPU,或所有 CPU(广播方式)。
动态分发
如果处理器正在执行最低优先级的进程,IRQ 信号就传递给这种处理器的本地 APIC。每个本地 APIC 都有一个可编程任务优先级寄存器(task priority register,TPR), TPR 用来计算当前运行进程的优先级。Intel 希望在操作系统内核中通过每次进程切换对这个寄存器存器进行修改。
如果两个或多个 CPU 共享最低优先级,就利用仲裁(arbitration)技术在这些 CPU 之间分配负荷。在本地 APIC 的仲裁优先级寄存器中,给每个 CPU 都分配一个 0(最低)~ 15(最高) 范围内的值。
每当中断传递给一个 CPU 时、其相应的仲裁优先级就自动置为 0,而其他每个 CPU 的仲裁优先级都增加 1 。当仲裁优先级寄存器大于 15 时,就把它置为获胜 CPU 的前一个仲裁优先级加 1。因此,中断以轮转方式在 CPU 之间分发,且具有相同的任务优先级(注 2)。
除了在处理器之间分发中断外,多 APIC 系统还允许 CPU 产生处理器间中断(interprocessor interrupt)。当一个 CPU 希望把中断发给另一个 CPU 时,它就在自己本地 APIC 的中断指令寄存器 (Interrupt Command Register ,ICR)中存放这个中断向量和目标本地 APIC 的标识符。然后,通过 APIC 总线向目标本地 APIC 发送一条消息,从而向自己的 CPU 发出一个相应的中断。
处理器间中断(简称 IPI)是 SMP 体系结构至关重要的組成部分,并由 Linux 有效地用来在 CPU 之间交换信息(参见本章后面)。
目前大部分单处理器系统都包含一个 I/O APIC 芯片,可以用以下两种方式对这种芯片进行配置:
80x86 微处理器发布了大约 20 种不同的异常(注3)。内核必须为每种异常提供一个专门的异常处理程序。对于某些异常,CPU 控制单元在开始执行异常处理程序前会产生一个硬件出错码(hardware error code),并且压入内核态堆栈。
下面的列表给出了在 80x86 处理器中可以找到的异常的向量、名字、类型及其简单描述。更多的信息可以在 Intel 的技术文挡中找到。
0 —— “Divide error”(故障)
当一个程序试图执行整数被 0 除操作时产生。
1 —— “Debug”(陷阱或故障)
产生于:(1)设置 eflags 的 TF 标志时(对于实现调试程序的单步执行是相当有用的),(2)一条指令或操作数的地址落在一个活动 debug 寄存器的范围之内(参见第三章的 “硬件上下文” 一节)。
2 —— 未用
为非屏蔽中断保留(利用 NMI 引脚的那些中断)。
3 —— “Breakpoint”(陷阱)
由 int3 (断点)指令(通常由 debugger 插入)引起。
4 —— “Overflow”(陷阱)
当 eflags 的 OF (overflow)标志被设置时,into(检查溢出)指令被执行。
5 —— “Bounds check”(故障)
对于有效地址范围之外的操作数,bound(检查地址边界)指令被执行。
6 ——“Invalid opcode” (故障)
CPU 执行单元检测到一个无效的操作码(决定执行操作的机器指令部分)。
7 —— “Device not available”(故障)
随着 cr0 的 TS 标志被设置,ESCAPE、MMX 或 XMM 指令被执行(参见第三章的"保存和加载 FPU、MMX 及 XMM 寄存器"一节)。
8 —— “Double fault”(异常中止)
正常情况下,当 CPU 正试图为前一个异常调用处理程序时,同时又检测到一个异常,两个异常能被串行地处理。然而,在少数情况下,处理器不能串行地处理它们,因而产生这种异常。
9 —— “Coprocessor segment overrun”(异常中止)
因外部的数学协处理器引起的问题(仅用于 80386 微处理器)。
10 —— “Invalid TSS”(故障)
CPU 试图让一个上下文切换到有无效的 TSS 的进程。
11 ——“Segment not present”(故障)
引用一个不存在的内存段(段描述符的 Segment -Presert 标志被清 0)。
12 —— “Stack segment fault”(故障)
试图超过栈段界限的指令,或者由 ss 标识的段不在内存。
13 —— "General protection" (故障)
违反了 80x86 保护模式下的保护规则之一。
14 —— “Page fault” (故障)
寻址的页不在内存,相应的页表项为空,或者违反了一种分页保护机制。
15 —— 由 Intel 保留
16 —— “Floating point error” (故障)
集成到 CPU 芯片中的浮点单元用信号通知一个错误情形,如数字溢出,或被 0 除(注4)。
17 —— “Alignment check”(故障)
操作数的地址没有被正确地对齐(例如,一个长整数的地址不是 4 的倍数)。
18 —— “Machine check”(异常中止)
机器检查机制检测到一个 CPU 错误或总线错误。
19 —— “SIMD floating point exception”(故障)
集成到 CPU 芯片中的 SSE 或 SSE2 单元对浮点操作用信号通知一个错误情形。
20~31 这些值由 Intel 留作将来开发。如表 4-1 所示,每个异常都由专门的异常处理程序来处理(参见本章后面的"异常处理"一节),它们通常把一个 Unix 信号发送到引起异常的进程。
可参考 ⇒ 5、中断描述符表
Linux 利用中断门处理中断,利用陷阱门处理异常。
我们现在描述 CPU 控制单元如何处理中断和异常。我们假定内核已被初始化,因此,CPU 在保护模式下运行。
当执行了一条指令后,cs 和 eip 这对寄存器包含下一条将要执行的指令的逻辑地址。在处理那条指令之前,控制单元会检查在运行前一条指令时是否已经发生了一个中断或异常。如果发生了一个中断或异常,那么控制单元执行下列操作:
确定与中断或异常关联的向量 i(0 < i < 255)。
读由 idtr 寄存器指向的 IDT 表中的第 i 项(在下面的描述中,我们假定 IDT 表项中包含的是一个中断门或一个陷阱门)。
从 gdtr 寄存器获得 GDT 的基地址 并在 GDT 中查找,以读取 IDT 表项中的选择符所标识的段描述符。这个描述符指定中断或异常处理程序所在段的基地址。
确信中断是由授权的(中断)发生源发出的。首先将当前特权级 CPL(存放在 cs 寄存器的低两位)与段描述符(存放在 GDT 中)的描述符特权级 DPL 比较,如果 CPL 小于 DPL,就产生一个 “General protection” 异常,因为中断处理程序的特权不能低于引起中断的程序的特权。对于编程异常,则做进一步的安全检查:比较 CPL 与处于 IDT 中的门描述符的 DPL,如果 DPL 小于 CPL ,就产生一个 “General protection” 异常。这最后一个检查可以避免用户应用程序访问特殊的陷阱门或中断门。
检查是否发生了特权级的变化,也就是说,CPL 是否不同于所选择的段描述符的 DPL。如果是,控制单元必须开始使用与新的特权级相关的栈。通过执行以下步骤来做到这点:
如果故障已发生,用引起异常的指令地址装载 cs 和 eip 寄存器,从而使得这条指令能再次被执行。
在栈中保存 eflags、cs 及 eip 的内容。
如果异常产生了一个硬件出错码,则将它保存在栈中。
装载 cs 和 eip 寄存器,其值分别是 IDT 表中第 i 项门描述符的段选择符和偏移量字段。这些值给出了中断或者异常处理程序的第一条指令的逻辑地址。
控制单元所执行的最后一步就是跳转到中断或者异常处理程序。换句话说,处理完中断信号后,控制单元所执行的指令就是被选中处理程序的第一条指令。
中断或异常被处理完后,相应的处理程序必须产生一条 iret 指令,把控制权转交给被中断的进程,这将迫使控制单元:
每个中断或异常都会引起一个内核控制路径,或者说代表当前进程在内核态执行单独的指令序列。内核控制路径可以任意嵌套。一个中断处理程序可以被另一个中断处理程序 “中断” 。
允许内核控制路径嵌套执行必须付出代价,那就是中断处理程序必须永不阻塞,换句话说,中断处理程序运行期间不能发生进程切换。
假定内核没有 bug,那么大多数异常就只在 CPU 处于用户态时发生。事实上,异常要么是由编程错误引起,要么是由调试程序触发。然而,“Page Fault(缺页)” 异常发生在内核态。这发生在当进程试图对属于其地址空间的页进行寻址,而该页现在不在 RAM 中时。当处理这样的一个异常时,内核可以挂起当前进程,并用另一个进程代替它,直到请求的页可以使用为止。只要被挂起的进程又获得处理器,处理缺页异常的内核控制路径就恢复执行。
因为 “Page Fault” 异常处理程序从不进一步引起异常,所以与异常相关的至多两个内核控制路径(第一个由系统调用引起,第二个由缺页引起)会堆叠在一起,一个在另一个之上。
与异常形成对照的是,尽管处理中断的内核控制路径代表当前进程运行,但由 I/O 设备产生的中断并不引用当前进程的专有数据结构。事实上,当一个给定的中断发生时,要预测哪个进程将会运行是不可能的。
一个中断处理程序既可以抢占其他的中断处理程序,也可以抢占异常处理程序。相反,异常处理程序从不抢占中断处理程序。
在内核态能触发的唯一异常就是刚刚描述的缺页异常。但是,中断处理程序从不执行可以导致缺页(因此意味着进程切换)的操作。
基于以下两个主要原因,Linux 交错执行内核控制路径:
为了提高可编程中断控制器和设备控制器的吞吐量。假定设备控制器在一条 IRQ 线上产生了一个信号,PIC 把这个信号转换成一个外部中断,然后 PIC 和设备控制器保持阻塞,一直到 PIC 从 CPU 处接收到一条应答信息。由于内核控制路径的交错执行,内核即使正在处理前一个中断,也能发送应答。
为了实现一种没有优先级的中断模型。因为每个中断处理程序都可以被另一个中断处理程序延缓,因此,在硬件设备之间没必要建立预定义优先级。这就简化了内核代码,提高了内核的可移植性。
在多处理器系统上,几个内核控制路径可以并发执行。此外,与异常相关的内核控制路径可以开始在一个 CPU 上执行,并且由于进程切换而移往另一个 CPU 上执行。
与在前面 “中断描述符表” 中所提到的一样,Intel 提供了三种类型的中断描述符:任务门、中断门及陷阱门描述符。Linux 使用与 Intel 稍有不同的细目分类和术语,把它们如下进行分类:
中断门 (interrupt gate)
用户态的进程不能访问的一个 Intel 中断门(门的 DPL 字段为 0)。所有的 Linux 中断处理程序都通过中断门激活,并全部限制在内核态。
系统门 (system gate)
用户态的进程可以访问的一个 Intel 陷阱门(门的 DPL 字段为 3)。通过系统门来激活三个 Linux 异常处理程序,它们的向量是 4,5 及 128,因此,在用户态下,可以发布 into、bound 及 int $0x80 三条汇编语言指令。
系统中断门 (system interrupt gate)
能够被用户态进程访问的 Intel 中断门(门的 DPL 字段为 3)。与向量 3 相关的异常处理程序是由系统中断门激活的,因此,在用户态可以使用汇编话言指令 int3。
陷阱门 (trap gate)
用户态的进程不能访问的一个 Intel 陷阱门(门的 DPL 字段为 0)。大部分 Linux 异常处理程序都通过陷阱门来激活。
任务门 (task gate)
不能被用户态进程访问的 Intel 任务门(门的 DPL 字段为 0)。Linux 对 “Double fault” 异常的处理程序是由任务门激活的。
下列体系结构相关的函数用来在 IDT 中插入门:
CPU 产生的大部分异常都由 Linux 解释为出错条件。当其中一个异常发生时,内核就向引起异常的进程发送一个信号向它通知一个反常条件。例如,如果进程执行了一个被 0 除的操作,CPU 就产生一个 “Divide error” 异常,并由相应的异常处理程序向当前进程发送一个 SIGFPE 信号,这个进程将采取若干必要的步骤来(从出错中)恢复或者中止运行(如果没有为这个信号设置处理程序的话)。
但是,在两种情况下,Linux 利用 CPU 异常更有效地管理硬件资源。第一种情况已经在第三章 “保存和加载 FPU、MMX 及 XMM 寄存器” 一节描述过,“Device not availeble” 异常与 cr0 寄存器的 TS 标志一起用来把新值装入浮点寄存器。第二种情况指的是 “Page Fault” 异常,该异常推迟给进程分配新的页框,直到不能再推迟为止。相应的处理程序比较复杂,因为异常可能表示一个错误条件,也可能不表示一个错误条件(参见第九章 “缺页异常处理程序” 一节)。
异常处理程序有一个标准的结构,由以下三部分组成:
为了利用异常,必须对 IDT 进行适当的初始化,使得每个被确认的异常都有一个异常处理程序。trap_init() 函数的工作是将一些最终值(即处理异常的函数)插入到 IDT 的非屏蔽中断及异常表项中。这是由函数 set_trap_gate()、set_intr_gate()、set_system_gate()、set_system_intr_gate() 和 set_task_gate() 来完成的。
// arch/x86/kernel/traps.c void __init trap_init(void) { int i; #ifdef CONFIG_EISA void __iomem *p = early_ioremap(0x0FFFD9, 4); if (readl(p) == 'E' + ('I'<<8) + ('S'<<16) + ('A'<<24)) EISA_bus = 1; early_iounmap(p, 4); #endif set_intr_gate(0, ÷_error); set_intr_gate_ist(1, &debug, DEBUG_STACK); set_intr_gate_ist(2, &nmi, NMI_STACK); /* int3 can be called from all */ set_system_intr_gate_ist(3, &int3, DEBUG_STACK); /* int4 can be called from all */ set_system_intr_gate(4, &overflow); set_intr_gate(5, &bounds); set_intr_gate(6, &invalid_op); set_intr_gate(7, &device_not_available); #ifdef CONFIG_X86_32 set_task_gate(8, GDT_ENTRY_DOUBLEFAULT_TSS); #else set_intr_gate_ist(8, &double_fault, DOUBLEFAULT_STACK); #endif set_intr_gate(9, &coprocessor_segment_overrun); set_intr_gate(10, &invalid_TSS); set_intr_gate(11, &segment_not_present); set_intr_gate_ist(12, &stack_segment, STACKFAULT_STACK); set_intr_gate(13, &general_protection); set_intr_gate(14, &page_fault); set_intr_gate(15, &spurious_interrupt_bug); set_intr_gate(16, &coprocessor_error); set_intr_gate(17, &alignment_check); #ifdef CONFIG_X86_MCE set_intr_gate_ist(18, &machine_check, MCE_STACK); #endif set_intr_gate(19, &simd_coprocessor_error); /* Reserve all the builtin and the syscall vector: */ for (i = 0; i < FIRST_EXTERNAL_VECTOR; i++) set_bit(i, used_vectors); #ifdef CONFIG_IA32_EMULATION set_system_intr_gate(IA32_SYSCALL_VECTOR, ia32_syscall); set_bit(IA32_SYSCALL_VECTOR, used_vectors); #endif #ifdef CONFIG_X86_32 if (cpu_has_fxsr) { printk(KERN_INFO "Enabling fast FPU save and restore... "); set_in_cr4(X86_CR4_OSFXSR); printk("done.\n"); } if (cpu_has_xmm) { printk(KERN_INFO "Enabling unmasked SIMD FPU exception support... "); set_in_cr4(X86_CR4_OSXMMEXCPT); printk("done.\n"); } set_system_trap_gate(SYSCALL_VECTOR, &system_call); set_bit(SYSCALL_VECTOR, used_vectors); #endif /* * Should be a barrier for any external CPU state: */ cpu_init(); x86_init.irqs.trap_init(); }
正如前面解释的那样,内核只要给引起异常的进程发送一个 Unix 信号就能处理大多数异常。因此,要采取的行动被延迟,直到进程接收到这个信号。所以,内核能很快地处理异常。
这种方法并不适合中断,因为经常会出现一个进程(例如,一个请求数据传输的进程)被挂起好久后中断才到达的情况,因此,一个完全无关的进程可能正在运行。所以,给当前进程发送一个 Unix 信号是毫无意义的。
中断处理依赖于中断类型。就我们的目的而言,我们将讨论三种主要的中断类型:
一般而言,I/O 中断处理程序必须足够灵活以给多个设备同时提供服务。例如在 PCI 总线的体系结构中,几个设备可以共享同一个 IRQ 线。这就意味着仅仅中断向量不能说明所有问题。在表 4-3 所示的例子中,同一个向量 43 既分配给 USB 端口,也分配给声卡。然而,在老式 PC 体系结构(像 ISA)中发现的一些硬件设备,当它们的 IRQ 与其他设备共享时,就不能可靠地运转。
中断处理程序的灵活性是以两种不同的方式实现的,讨论如下:
IRQ 共享
中断处理句柄执行多个中断服务例程(interrupt service routine, ISR)。每个 ISR 是一个与单独设备(共享 IRQ 线)相关的函数。因为不可能预先知道哪个特定的设备产生 IRQ,因此,每个 ISR 都被执行,以验证它的设备是否需要关注:如果是,当设备产生中断时,就执行需要执行的所有操作。
IRQ 动态分配
一条 IRQ 线在可能的最后时刻才与一个设备驱动程序相关联:例如,软盘设备的 IRQ 线只有在用户访问软盘设备时才被分配。这样,即使几个硬件设备并不共享 IRQ 线、同一个 IRQ 向量也可以由这几个设备在不同时刻使用(见本节最后一部分的讨论)。
当一个中断发生时,并不是所有的操作都具有相同的急迫性。事实上,把所有的操作都放进中断处理程序本身并不合适。需要时间长的、非重要的操作应该推后,因为当一个中断处理程序正在运行时,相应的 IRQ 线上发出的信号就被暂时忽略。更重要的是,中断处理程序是代表进程执行的,它所代表的进程必须总处于 TASK_RUNNING 状态,否则,就可能出现系统僵死情形。因此,中断处理程序不能执行任何阻塞过程,如磁盘 I/O 操作。因此,Linux 把紧随中断要执行的操作分为三类:
紧急的(Critical)
这样的操作诸如:对 PIC 应答中断,对 PIC 或设备控制器重编程,或者修改由设备和处理器同时访问的数据结构。这些都能被很快地执行,而之所以说它们是紧急的是因为它们必须被尽快地执行。紧急操作要在一个中断处理程序内立即执行,而且是在禁止可屏蔽中断的情况下。
非紧急的 (Noncritical)
这样的操作诸如:修改那些只有处理器才会访问的数据结构(例如,按下一个键后读扫描码)。这些操作也要很快地完成,因此,它们在开中断的情况下,由中断处理程序立即执行。
非紧急可延迟的(Noncritical deferrable)
这样的操作诸如:把缓冲区的内容拷贝到某个进程的地址空间(例如,把键盘行缓冲区的内容发送到终端处理程序进程)。这些操作可能被延迟较长的时间间隔而不影响内核操作,有兴趣的进程将会等待数据。非紧急可延迟的操作由独立的函数来执行,我们将在 “软中断及 tasklet” 一节讨论。
不管引起中断的电路种类如何,所有的 I/O 中断处理程序都执行四个相同的基本操作:
当中断发生时,需要用几个描述符来表示 IRQ 线的状态和需要执行的通数。图 4-4 以示意图的方式展示了处理一个中断的硬件电路和软件函数。下面几节会讨论这些函数。
每个中断向量都有它自己的 irq_desc_t 描述符,所有的这些描述符组织在一起形成 irq_desc 数组。
// include/linux/irq.h /** * struct irq_desc - interrupt descriptor * @irq: interrupt number for this descriptor * @timer_rand_state: pointer to timer rand state struct * @kstat_irqs: irq stats per cpu * @irq_2_iommu: iommu with this irq * @handle_irq: highlevel irq-events handler [if NULL, __do_IRQ()] * @chip: low level interrupt hardware access * @msi_desc: MSI descriptor * @handler_data: per-IRQ data for the irq_chip methods * @chip_data: platform-specific per-chip private data for the chip * methods, to allow shared chip implementations * @action: the irq action chain * @status: status information * @depth: disable-depth, for nested irq_disable() calls * @wake_depth: enable depth, for multiple set_irq_wake() callers * @irq_count: stats field to detect stalled irqs * @last_unhandled: aging timer for unhandled count * @irqs_unhandled: stats field for spurious unhandled interrupts * @lock: locking for SMP * @affinity: IRQ affinity on SMP * @node: node index useful for balancing * @pending_mask: pending rebalanced interrupts * @threads_active: number of irqaction threads currently running * @wait_for_threads: wait queue for sync_irq to wait for threaded handlers * @dir: /proc/irq/ procfs entry * @name: flow handler name for /proc/interrupts output */ struct irq_desc { unsigned int irq; struct timer_rand_state *timer_rand_state; unsigned int *kstat_irqs; #ifdef CONFIG_INTR_REMAP struct irq_2_iommu *irq_2_iommu; #endif irq_flow_handler_t handle_irq; struct irq_chip *chip; struct msi_desc *msi_desc; void *handler_data; void *chip_data; struct irqaction *action; /* IRQ action list */ unsigned int status; /* IRQ status */ unsigned int depth; /* nested irq disables */ unsigned int wake_depth; /* nested wake enables */ unsigned int irq_count; /* For detecting broken IRQs */ unsigned long last_unhandled; /* Aging timer for unhandled count */ unsigned int irqs_unhandled; raw_spinlock_t lock; #ifdef CONFIG_SMP cpumask_var_t affinity; unsigned int node; #ifdef CONFIG_GENERIC_PENDING_IRQ cpumask_var_t pending_mask; #endif #endif atomic_t threads_active; wait_queue_head_t wait_for_threads; #ifdef CONFIG_PROC_FS struct proc_dir_entry *dir; #endif const char *name; } ____cacheline_internodealigned_in_smp;
当使用优化的编译器时,你千万不要认为指令会严格按它们在源代码中出现的顺序执行。例如,编译器可能重新安排汇编语言指令以使寄存器以最优的方式使用。此外,现代 CPU 通常并行地执行若干条指令,且可能重新安排内存访问。这种重新排序可以极大地加速程序的执行。
然而,当处理同步时,必须避免指令重新排序。如果放在同步原语之后的一条指令在同步原语本身之前执行,事情很快就会变得失控。事实上,所有的同步原语起优化和内存屏障的作用。
优化屏障(optimization barrier)原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语操作之后的汇编语言指令,这些汇编语言指令在C中都有对应的语句。在 Linux 中,优化屏障就是 barrier() 宏 。
// include/linux/compiler.h
# define barrier() __memory_barrier()
// 展开为:
asm volatile("":::"memory")
指令 asm 告诉编译程序要插入汇编语言片段(这种情况下为空)。volatile 关键字禁止编译器把 asm 指令与程序中的其他指令重新组合。memory 关键字强制编译器假定 RAM 中的所有内存单元已经被汇编语言指令修改了。因此,编译器不能使用存放在 CPU 寄存器中的内存单元的值来优化 asm 指令前的代码。
注意,优化屏障并不保证不使当前 CPU 把汇编语言指令混在一起执行——这是内存屏障的工作。
内存屏障(memory barrier)原语确保,在原语之后的操作开始执行之前,原语之前的操作已经完成。因此,内存屏障类似于防火墙,让任何汇编话言指令都不能通过。
在 80x86 处理器中,下列种类的汇编语言指令是 “串行的” ,因为它们起内存屏障的作用:
Linux 使用六个内存屏障原语,如表 5-6 所示。这些原语也被当作优化屏障,因为我们必须保证编译程序不在屏障前后移动汇编语言指令。“读内存屏障” 仅仅作用于从内存读的指令,而 “写内存屏障” 仅仅作用于写内存的指令。内存屏障既用于多处理器系统,也用于单处理器系统。当内存屏障应该防止仅出现于多处理器系统上的竞争条件时,就使用 smp_xxx() 原语;在单处理器系统上,它们什么也不做。其他的内存屏障防止出现在单处理器和多处理器系统上的竞争条件。
内存屏障原语的实现依赖于系统的体系结构。在 80x86 微处理器上,
#ifdef NN
// 把 0 加到栈顶的内存单元;这条指令本身没有价值,
// 但是,lock 前缀使得这条指令成为 CPU 的一个内存屏障。
#define rmb() asm volatile("lock; addl $0,0(%%esp)":::"memory")
#else
// 如果 CPU 支持 lfence 汇编语言指令,展开成如下形式
#define rmb() asm volatile("lfence":::"memory")
#endif
asm 指令告诉编译器插入一些汇编语言指令并起优化屏障的作用。Intel 上的 wmbb() 宏实际上更简单,因为它展开为 barrier()。这是因为 Intel 处理器从不对写内存访问重新排序,因此,没有必要在代码中插入一条串行化汇编指令。不过,这个宏禁止编译器重新组合指令。
注意,在多处理器系统上,在前一节 “原子操作” 中描述的所有原子操作都起内存屏障的作用,因为它们使用了 lock 字节。
可参考 ==> 2、自旋锁
// include/linux/spinlock_types.h typedef struct spinlock { union { struct raw_spinlock rlock; #ifdef CONFIG_DEBUG_LOCK_ALLOC # define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map)) struct { u8 __padding[LOCK_PADSIZE]; struct lockdep_map dep_map; }; #endif }; } spinlock_t; typedef struct raw_spinlock { arch_spinlock_t raw_lock; #ifdef CONFIG_GENERIC_LOCKBREAK unsigned int break_lock; #endif #ifdef CONFIG_DEBUG_SPINLOCK unsigned int magic, owner_cpu; void *owner; #endif #ifdef CONFIG_DEBUG_LOCK_ALLOC struct lockdep_map dep_map; #endif } raw_spinlock_t; // arch/x86/include/asm/spinlock_types.h typedef struct arch_spinlock { unsigned int slock; } arch_spinlock_t;
让我们来详细讨论用于请求自旋锁的 spin_lock 宏。下面的描述都是针对支持 SMP 系统的抢占式内核的。该宏获取自旋琐的地址 slp 作为它的参数,并执行下面的操作:
movb $0, %al
xchgb %al, slp->slock
汇编语言指令 xchg 原子性地交换 8 位寄存器 %al(存 0)和 slp->slock 指示的内存单元的内容。随后,如果存放在自旋锁中的旧值(在 xchg 指令执行之后存放在 %al 中)是正数,函数就返回 1,否则返回 0。
注 2: 具有识刺意味的是,自旋锁是全局的、因此对它本身必须进行保护以防止并发访问。
while (spin_is_locked(slp) && slp->break_lock)
cpu_relax();
宏 cpu_relax() 简化为一条 pause 汇编语言指令。在 Pentium 4 模型中引入了这条指令以优化自旋锁循环的执行。通过引入一个很短的延迟,加快了紧跟在锁后面的代码的执行并减少了能源消耗。pause 与早先的 80x86 微处理器模型是向后兼容的,因为它对应 rep; nop 指令,也就是对应空操作。
7. 跳转回到第 1 步,再次试图获取自旋锁。
如果在内核编译时没有选择内核抢占选项,spin_lock 宏就与前面描述的 spin_lock 宏有很大的区别。在这种情况下,宏生成一个汇编语言程序片段,它本质上等价于下面紧凑的忙等待(注 3):
1: lock; decb slp->slock
jns 3f
2: pause
cmpb $0, slp->slock
jle 2b
jmp 1b
3:
汇编语言指令 decb 进减自旋锁的值,该指令是原子的,因为它带有 lock 字节前缀。随后检测符号标志,如果它被清 0,说明自旋锁被设置为 1(未锁),因此从标记 3 处继续正常执行(后缀 f 表示标签是 “向前的” ,它在程序的后面出现)。否则,在标签 2 处(后缀 b 表示 “向后的” 标签)执行紧凑循环直到自旋锁出现正值。然后从标签 1 处开始重新执行,因为不检查其他的处理器是否抢占了锁就继续执行是不安全的。
spin_unlock 宏释放以前获得的自旋锁,它本质上执行下列汇编语言指令:
movb $1, slp->slock
并在随后调用 preempt_enable()(如果不支持内核抢占,preempt_enable() 什么都不做)。注意,因为现在的 80x86 微处理器总是原子地执行内存中的只写访问,所以不使用 lock 字节。
// include/linux/seqlock.h typedef struct { unsigned sequence; spinlock_t lock; } seqlock_t; // include/linux/seqlock.h static __always_inline unsigned read_seqbegin(const seqlock_t *sl) { unsigned ret; repeat: ret = sl->sequence; smp_rmb(); if (unlikely(ret & 1)) { cpu_relax(); goto repeat; } return ret; }
当使用读 / 写自旋锁时,内核控制路径发出的执行 read_lock 或 write_lock 操作的请求具有相同的优先权:读者必须等待,直到写操作完成。同样地,写者也必须等待,直到读操作完成。
Linux 2.6 中引入了顺序锁(seqlock),它与读 / 写自旋锁非常相似,只是它为写者赋予了较高的优先级:事实上,即使在读者正在读的时候也允许写者继续运行。这种策略的好处是写者永远不会等待(除非另外一个写者正在写),缺点是有些时候读者不得不反复多次读相同的数据直到它获得有效的副本。
每个顺序锁都是包括两个字段的 seqlock_t 结构:一个类型为 spinlock_t 的 lock 字段和一个整型的 sequence 字段,第二个字段是一个顺序计数器。每个读者都必须在读数据前后两次读顺序计数器,并检查两次读到的值是否相同,如果不相同,说明新的写者已经开始写并增加了顺序计数器, 因此暗示读者刚读到的数据是无效的。
通过把 SEQLOCK_UNLOCKED 赋给变量 seqlock_t 或执行 seqlock_init 宏,把 seqlock_t 变量初始化为 “未上锁” 。写者通过调用 write_seqlock() 和 write_sequnlock() 获取和释放顺序锁。第一个函数获取 seqlock_t 数据结构中的自旋锁,然后使顺序计数器加 1;第二个函数再次增加顺序计数器,然后释放自旋锁。这样可以保证写者在写的过程中,计数器的值是奇数,并且当没有写者在改变数据的时候,计数器的值是偶数。
读-拷贝-更新(RCU)是为了保护在多数情况下被多个 CPU 读的数据结构而设计的另一种同步技术。RCU 允许多个读者和写者并发执行(相对于只允许一个写者执行的顺序锁有了改进)。而且,RCU 是不使用锁的,就是说,它不使用被所有 CPU 共享的锁或计数器,在这一点上与读 / 写自旋锁和顺序锁(由于高速缓存行窃用和失效而有很高的开销)相比,RCU 具有更大的优势。
RCU 是如何不使用共享数据结构而令人惊讶地实现多个 CPU 同步呢? 其关键的思想包括限制 RCU 的范围,如下所述:
当内核控制路径要读取被 RCU 保护的数据结构时,执行宏 rcu_read_lock(),它等同于 preempt_disable() 。接下来,读者间接引用该数据结构指针所对应的内存单元并开始读这个数据结构。正如在前面所强调的,读者在完成对数据结构的读操作之前,是不能睡眠的。用等同于 preempt_enable() 的宏 rcu_read_unlock() 标记临界区的结束。
我们可以想象,由于读者几乎不做任何事情来防止竞争条件的出现,所以写者不得不做得更多一些。事实上,当写者要更新数据结构时,它间接引用指针并生成整个数据结构的副本。接下来,写者修改这个副本。一旦修改完毕,写者改变指向数据结构的指针,以使它指向被修改后的副本。由于修改指针值的操作是一个原子操作,所以旧副本和新副本对每个读者或写者都是可见的,在数据结构中不会出现数据奔溃。尽管如此,还需要内存屏障来保证:只有在数据结构被修改之后,已更新的指针对其他 CPU 才是可见的。如果把自旋锁与 RCU 结合起来以禁止写者的并发执行,就隐含地引入了这样的内存屏障。
然而,使用 RCU 技术的真正困难在于:写者修改指针时不能立即释放数据结构的旧副本。实际上,写者开始修改时,正在访问数据结构的读者可能还在读旧副本。只有在 CPU 上的所有(潜在的)读者都执行完宏 rcu_read_unlock() 之后,才可以释放旧副本。内核要求每个潜在的读者在下面的操作之前执行 rcu_read_unlock() 宏:
对上述每种情况,我们说 CPU 已经经过了静止状态(quiescent state)。写者调用函数 call_rcu() 来释放数据结构的旧副本。当所有的 CPU 都通过静止状态之后,call_rcu() 接受 rcu_head 描述符(通常嵌在要被释放的数据结构中)的地址和将要调用的回调函数的地址作为参数。一旦回调函数被执行,它通常释放数据结构的旧副本。
函数 call_rcu() 把回调函数和其参数的地址存放在 rcu_head 描述符中,然后把描述符插入回调函数的每 CPU(per-CPU)链表中。内核每经过一个时钟滴答(参见第六章 “更新本地 CPU 统计数” 一节)就周期性地检查本地 CPU 是否经过了一个静止状态。如果所有CPU 都经过了静止状态,本地 tasklet(它的描述符存放在每 CPU 变量 rcu_tasklet 中)就执行链表中的所有回调函数。
RCU 是 Linux 2.6 中新加的功能,用在网络层和虚拟文件系统中。
Linux 提供两种信号量:
可参考 ==> 4、信号量
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。