Linux 0.11启动过程分析(一)
Linux 0.11 fork 函数(二)
Linux0.11 缺页处理(三)
Linux0.11 根文件系统挂载(四)
Linux0.11 文件打开open函数(五)
Linux0.11 execve函数(六)
Linux0.11 80X86知识(七)
Linux0.11 内核体系结构(八)
虚拟地址 通过分段机制映射到 线性地址(4G 寻址空间),然后是 线性地址 到 物理地址 的变换。
下图列出了 GDT 和 LDT 描述符信息图:
下图显示了分页变换的过程:
对于 Linux 0.11 系统,内核设置全局描述符表 GDT 中的段描述符项数最大为 256,其中 2 项空闲、2 项系统使用,每个进程使用两项。因此,此时系统可以最多容(256-4)/ 2 = 126 个任务,并且虚拟地址范围是 ((256-4)/ 2)* 64MB 约等于 8G。但 0.11 内核中人工定义最大任务数 NR_TASKS = 64 个,每个任务逻辑地址范围是 64M,并且各个任务在线性地址空间中的起始位置是(任务号)* 64MB。因此全部任务所使用的线性地址空间范围是 64MB*64 =4G,见图 5-10 所示。图中示出了当系统具有 4 个任务时的情况。内核代码段和数据段被映射到线性地址空间的开始 16MB 部分,并且代码和数据段都映射到同一个区域,完全互相重叠。而第 1个任务(任务 0)是由内核"人工"启动运行的,其代码和数据包含在内核代码和数据中,因此该任务所占用的线性地址空间范围比较特殊。任务 0 的代码段和数据段的长度是从线性地址 0 开始的 640KB 范围,其代码和数据段也完全重叠,并且与内核代码段和数据段有重叠的部分。实际上,Linux 0.11 中所有任务的指令空间 I(Instruction)和数据空间 D(Data)都合用一块内存,即一个进程的所有代码、数据和堆栈部分都处于同一内存段中,也即是 I&D 不分离的一种使用方式。
任务 1 的线性地址空间范围也只有从 64MB 开始的 640KB 长度。它们之间的详细对应关系见后面说明。任务 2 和任务 3 分别被映射线性地址 128MB 和 192MB 开始的地方,并且它们的逻辑地址范围均是 64MB。由于 4G 地址空间范围正好是 CPU 的线性地址空间范围和可寻址的最大物理地址空间范围,而且在把任务 0 和任务 1 的逻辑地址范围看作 64MB 时,系统中同时可有任务的逻辑地址范围总和也是 4GB,因此在 0.11 内核中比较容易混滑三种地址概念。
如果也按照线性空间中任务的排列顺序排列虚拟空间中的任务,那么我们可以有图 5-11 所示的系统同时可拥有所有任务在虚拟地址空间中的示意图,所占用虚拟空间范围也是 4GB。其中没有考虑内核代码和数据在虚拟空间中所占用的范围。另外,在图中对于进程 2 和进程 3 还分别给出了各自逻辑空间中代码段和数据段(包括数据和堆栈内容)的位置示意图。
请还需注意,进程逻辑地址空间中代码段(Code Section)和数据段(Data Section)的概念与 CPU 分段机制中的代码段和数据段不是同一个概念。CPU 分段机制中段的概念确定了在线性地址空间中一个段的用途以及被执行或访问的约束和限制,每个段可以设置在 4GB 线性地址空间中的任何地方,它们可以相互独立也可以完全重叠或部分重叠。而进程在其逻辑地址空间中的代码段和数据段则是指由编译器在编译程序和操作系统在加载程序时规定的在进程逻辑空间中顺序排列的代码区域、初始化和未初始化的数据区域以及堆栈区域。进程逻辑地址空间中代码段和数据段等结构形式见图所示。有关逻辑地址空间的说明请参见内存管理一章内容。
Intel 80X86 CPU 共分 4 个保护级,0 级具有最高优先级,而 3 级优先级最低。Linux 0.11 操作系统使用了 CPU 的 0 和 3 两个保护级。内核代码本身会由系统中的所有任务共享。而每个任务则都有自己的代码和数据区,这两个区域保存于局部地址空间,因此系统中的其他任务是看不见的(不能访问的)。而内核代码和数据是由所有任务共享的,因此它保存在全局地址空间中。图 5-13 给出了这种结构的示意图。图中同心圆代表 CPU 的保护级别(保护层),这里仅使用了 CPU 的 0 级和 3 级。而径向射线则用来区分系统中的各个任务。每条径向射线指出了各任务的边界。除了每个任务虚拟地址空间的全局地址区域,任务 1 中的地址与任务 2 中相同地址处是无关的。
当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0 级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3 级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。这与处于内核态的进程的状态有些类似。进程的内核态和用户态将在后面有关进程运行状态一节中作更详细的说明。
任务 0 是系统中一个人工启动的第一个任务。它的代码段和数据段长度被设置为 640KB。该任务的代码和数据直接包含在内核代码和数据中,是从线性地址 0 开始的 640KB 内容,因此可以它直接使用内核代码已经设置好的页目录和页表进行分页地址变换。同样,它的代码和数据段在线性地址空间中也是重叠的。对应的任务状态段 TSS0 也是手工预设置好的,并且位于任务 0 数据结构信息中,参见 sched.h 第 113 行开始的数据。TSS0 段位于内核 sched.c 程序的代码中,长度为 104 字节,具体位置可参见图 5-23 中"任务 0 结构信息"一项所示。三个地址空间中的映射对应关系见图 5-15 所示。
由于任务 0 直接被包含在内核代码中,因此不需要为其再另外分配内存页。它运行时所需要的内核态堆栈和用户态堆栈空间也都在内核代码区中,并且由于在内核初始化时(head.s)这些内核页面在页表项中的属性都已经被设置成了 0b111,即对应页面用户可读写并且存在,因此用户堆栈 user_stack[] 空间虽然在内核空间中,但任务 0 仍然能对其进行读写操作。
与任务 0 类似,任务 1 也是一个特殊的任务。它的代码也在内核代码区域中。与任务 0 不同的是在线性地址空间中,系统在使用 fork() 创建任务 1(init 进程)时为存放任务 1 的二级页表而在主内存区申请了一页内存来存放,并复制了父进程(任务 0)的页目录和二级页表项。因此任务 1 有自己的页目录和页表表项,它把任务 1 占用的线性空间范围 64MB –128MB(实际上是 64MB – 64MB+640KB)也同样映射到了物理地址 0–640KB 处。此时任务 1 的长度也是 640KB,并且其代码段和数据段相重叠,只占用一个页目录项和一个二级页表。另外,系统还会为任务 1 在主内存区域中申请一页内存用来存放它的任务数据结构和用作任务 1 的内核堆栈空间。任务数据结构(也称进程控制块 PCB)信息中包括任务 1 的 TSS 段结构信息。见图 5-16 所示。
任务 1 的用户态堆栈空间将直接共享使用处于内核代码和数据区域(线性地址 0–640KB)中任务 0 的用户态堆栈空间 user_stack[](参见 kernel/sched.c,第 67–72 行),因此这个堆栈需要在任务 1 实际使用之前保持"干净",以确保被复制用于任务 1 的堆栈不含有无用数据。在刚开始创建任务 1 时,任务 0 的用户态堆栈 user_stack[]与任务 1 共享使用,但当任务 1 开始运行时,由于任务 1 映射到 user_stack[] 处的页表项被设置成只读,使得任务 1 在执行堆栈操作时将会引起写页面异常,从而由内核另行分配主内存区页面作为堆栈空间使用。
对于被创建的从任务 2 开始的其他任务,它们的父进程都是 init(任务 1)进程。我们已经知道,在 Linux 0.11 系统中共可以有 64 个进程同时存在。下面我们以任务 2 为例来说明其他任何任务对地址空间的使用情况。
从任务 2 开始,如果任务号以 nr 来表示,那么任务 nr 在线性地址空间中的起始位置将被设定在 nr * 64MB 处。例如任务 2 的开始位置 = nr*64MB = 2 * 64MB = 128MB。任务代码段和数据段的最大长度被设置为 64MB,因此任务 2 占有的线性地址空间范围是 128MB–192MB,共占用 64MB/4MB = 16 个页目录项。虚拟空间中任务代码段和数据段都被映射到线性地址空间相同的范围,因此它们也完全重叠。图 5-17 显示出了任务 2 的代码段和数据段在三种地址空间中的对应关系。
在任务 2 被创建出来之后,将在其中运行 execve()函数来执行 shell 程序。当内核通过复制任务 1 刚创建任务 2 时,除了占用线性地址空间范围不同外(128MB–128MB+640KB),此时任务 2 的代码和数据在三种地址空间中的关系与任务 1 的类似。当任务 2 的代码(init())调用 execve()系统调用开始加载并执行 shell 程序时,该系统调用会释放掉从任务 1 复制的页目录和页表表项及相应内存页面,然后为新的执行程序 shell 重新设置相关页目录和页表表项。图 5-17 给出的是任务 2 中开始执行 shell 程序时的情况,即任务 2 原先复制任务 1 的代码和数据被 shell 程序的代码段和数据段替换后的情况。图中显示出已经映射了一页物理内存页面的情况。这里请注意,在执行 execve()函数时,系统虽然在线性地址空间为任务 2 分配了 64MB 的空间范围,但是内核并不会立刻为其分配和映射物理内存页面。只有当任务 2 开始执行时由于发生缺页而引起异常时才会由内存管理程序为其在主内存区中分配并映射一页物理内存到其线性地址空间中。这种分配和映射物理内存页面的方法称为需求加载(Load on demand)。
从 Linux 内核 0.99 版以后,对内存空间的使用方式发生了变化。每个进程可以单独享用整个 4G 的地址空间范围。如果我们能理解本节描述的内存管理概念,那么对于现在所使用的 Linux 2.x 内核中所使用的内存管理原理也能立刻明白。
当用户应用程序使用 C 函数库中的内存分配函数 malloc()申请内存时,这些动态申请的内存容量或大小均由高层次的 C 库函数 malloc()来进行管理,内核本身并不会插手管理。因为内核已经为每个进程(除了任务 0 和 1,它们与内核代码一起常驻内存中)在 CPU 的 4G 线性地址空间中分配了 64MB 的空间,所以只要进程执行时寻址的范围在它的 64MB 范围内,内核也同样会通过内存缺页管理机制自动为寻址对应的页面分配物理内存页面并进行映射操作。但是内核会为进程使用的代码和数据空间维护一个当前位置值 brk,这个值保存在每个进程的数据结构中。它指出了进程代码和数据(包括动态分配的数据空间)在进程地址空间中的末端位置。当 malloc()函数为程序分配内存时,它会通过系统调用 brk()把程序要求新增的空间长度通知内核,内核代码从而可以根据 malloc()所提供的信息来更新 brk 的值,但此时并不为新申请的空间映射物理内存页面。只有当程序寻址到某个不存在对应物理页面的地址时,内核才会进行相关物理内存页面的映射操作。
若进程代码寻址的某个数据所在的页面不存在,并且该页面所处位置属于进程堆范围,即不属于其执行文件映像文件对应的内存范围中,那么 CPU 就会产生一个缺页异常,并在异常处理程序中为指定的页面分配并映射一页物理内存页面。至于用户程序此次申请内存的字节长度数量和在对应物理页面中的具体位置,则均由 C 库中内存分配函数 malloc()负责管理。内核以页面为单位分配和映射物理内存,该函数则具体记录用户程序使用了一页内存的多少字节。剩余的容量将保留给程序再申请内存时使用。
当用户使用内存释放函数 free()动态释放已申请的内存块时,C 库中的内存管理函数就会把所释放的内存块标记为空闲,以备程序再次申请内存时使用。在这个过程中内核为该进程所分配的这个物理页面并不会被释放掉。只有当进程最终结束时内核才会全面收回已分配和映射到该进程地址空间范围的所有物理内存页面。
有关库函数 malloc()和 free()的具体代码实现请参见内核库中的 lib/malloc.c 程序。
CPU 是根据中断号获取中断向量值,即对应中断服务程片的入口地址值。因此为了让 CPU 由中断号查找到对应得中断向量,就需要在在内存中建立一张查询表,即中断向量表(在 32 位保护模式下该表称为中断描述符表,见下面说明)。80X86 微机支持 256 个中断,对应每个中断需要安排一个中断服务程序。在 80X86 实模式运行方式下,每个中断向量由 4 个字节组成。这 4 个字节指明了一个中断服务程序的段值和段内偏移值。因此整个向量表的长度为 1024 字节。当 80X86 微机启动时,ROM BIOS 中的程序会在物理内存开始地址 0x0000:0x0000 处初始化并设置中断向量表,而各中断的默认中断服务程序则在 BIOS 中给出。由于中断向量表中的向量是按中断号顺序排列,因此给定一个中断号 N,那么它对应的中断向量在内存中的位置就是 0x000:N * 4 ,即对应的中断服务程序入口地址保存在物理内存 0x0000:N * 4 位置处。
在 BIOS 执行初始化操作时,它设置了两个 8259A 芯片支持的 16 个硬件中断向量和 BIOS 提供的中断号为 0x10-0x1f 的中断调用功能向量等。对于实际没有使用的向量则填入临时的哑中断服务程序的地址。以后在系统引导加载操作系统时会根据实际需要修改某些中断向量的值。例如,对于 DOS 操作系统,它会重新设置中断 0x20-0x2f 的中断向量值。而对于 Linux 系统,除了在刚开始加载内核时需要用到 BIOS 提供的显示和磁盘读操作中断功能,在内核正常运行之前则会在 setup.s 程序中重新初始化 8259A 芯片并且在 head.s 程序中重新设置一张中断向量表(中断描述符表)。完全抛弃了 BIOS 所提供的中断服务功能。
当 Intel CPU 运行在 32 位保护模式下时,需要使用中断描述符表 IDT(Interrupt Descriptor Table)来管理中断或异常。IDT 是 Intel 8086 – 80186 CPU 中使用的中断向量表的直接替代物。其作用也类似于中断向量表,只是其中每个中断描述符项中除了含有中断服务程序地址以外,还包含有关特权级和描述符类别等信息。Linux 操作系统工作于 80X86 的保护模式下,因此它使用中断描述符表来设置和保存各中断的"向量"信息。
对于 Linux 内核来说,中断信号通常分为两类:硬件中断和软件中断(异常)。每个中断是由 0-255 之间的一个数字来标识。对于中断 int0–int31(0x00–0x1f),每个中断的功能由 Intel 公司固定设定或保留用, 属于软件中断,但 Intel 公司称之为异常。因为这些中断是在 CPU 执行指令时探测到异常情况而引起的。通常还可分为故障(Fault)和陷阱(traps)两类。中断 int32–int255 (0x20–0xff)可以由用户自己设定。所有中断的分类以及执行后 CPU 的动作方式见表 5-1 所示。
在 Linux 系统中,则将 int32–int47(0x20–0x2f)对应于 8259A 中断控制芯片发出的硬件中断请求信号 IRQ0–IRQ15(见表 5-2 所示),并把程序编程发出的系统调用(system call)中断设置为 int128(0x80)。系统调用中断是用户程序使用操作系统资源的唯一界面接口。
在系统初始化时,内核在 head.s 程序中首先使用一个哪中断向量(中断描述符)对中断描述符表(Interrupt Descriptor Table - IDT)中所有 256 个描述符进行了默认设置(boot/head.s,78)。这个碰中断向量指向一个默认的"无中断"处理过程(boot/head.s,150)。当发生了一个中断而又没有重新设置过该中断向量时就会显示信息"未知中断(Unknown interrupt)"。这里对所有 256 项都进行设置可以有效防止出现一般保护性错误(A gerneal protection fault)(异常 13)。否则的话,如果设置的 IDT 少于 256 项,那么在一个要求的中断所指定的描述符项大于设置的最大描述符项时,CPU 就会产生一个一般保护出错(异常 13)。另外,如果硬件出现问题而没有把设备的向量放到数据总线上,此时 CPU 通常会从数据总线上读入全 1(0xff)作为向量,因此会去读取 IDT 表中的第 256 项,因此也会造成一般保护出错。对于系统中需要使用的一些中断,内核会在其继续初始化的处理过程中(init/main.c)重新设置这些中断的中断描述符项,让它们指向对应的实际处理过程。通常,异常中断处理过程(int0 --int 31)都在 traps.c 的初始化函数中进行了重新设置(kernl/traps.c,181),而系统调用中断 int128 则在调度程序初始化函数中进行了重新设置(kernel/sched.c,385)。
另外,在设置中断描述符表 IDT 时 Linux 内核使用了中断门和陷阱门两种描述符。它们之间的区别在于对标志寄存器 EFLAGS 中的中断允许标志 IF 的影响。由中断门描述符执行的中断会复位 IF 标志,因此可以避免其它中断干扰当前中断的处理,随后的中断结束指令 iret 会从堆栈上恢复 IF 标志的原值;而通过陷阱门执行的中断则不会影响 IF 标志。参见第 11 章中对 include/asm/system.h 文件的说明。
为了避免竞争条件和中断对临界代码区的干扰,在 Linux 0.11 内核代码中许多地方使用了 cli 和 sti 指令。cli 指令用来复位 CPU 标志寄存器中的中断标志,使得系统在执行 cli 指令后不会响应外部中断。 sti 指令用来设置标志寄存器中的中断标志,以允许 CPU 能识别并响应外部设备发出的中断。当进入可能引起竞争条件的代码区时,内核中就会使用 cli 指令来关闭对外部中断的响应,而在执行完竞争代码区时内核就会执行 sti 指令以重新允许 CPU 响应外部中断。例如,在修改文件超级块的锁定标志和任务进入/退出等待队列操作时都需要首先使用 cli 指令关闭 CPU 对外部中断的响应,在操作完成之后再使用 sti 指令开启对外部中断的响应。如果不使用 cli、sti 指令对,即在需要修改一个文件超级块时不使用 cli 来关闭对外部中断的响应,那么在修改之前判断出该超级块锁定标志没有置位而想设置这个标志时,若此时正好发生系统时钟中断而切换到其他任务去运行,并且碰巧其他任务也需要修改这个超级块,那么此时这个其他任务会先设置超级块的锁定标志并且对超级块进行修改操作。当系统又切换回原来的任务时,此时该任务不会再去判断锁定标志就会继续执行设置超级块的锁定标志,从而造成两个任务对临界代码区的同时多重操作,引起超级块数据的不一致性,严重时会导致内核系统崩溃。
Linux 系统中创建新进程使用 fork()系统调用。所有进程都是通过复制进程 0 而得到的,都是进程 0 的子进程。
在创建新进程的过程中,系统首先在任务数组中找出一个还没有被任何进程使用的空项(空槽)。如果系统已经有 64 个进程在运行,则 fork()系统调用会因为任务数组表中没有可用空项而出错返回。然后系统为新建进程在主内存区中申请一页内存来存放其任务数据结构信息,并复制当前进程任务数据结构中的所有内容作为新进程任务数据结构的模板。为了防止这个还未处理完成的新建进程被调度函数执行,此时应该立刻将新进程状态置为不可中断的等待状态(TASK_UNINTERRUPTIBLE)。
随后对复制的任务数据结构进行修改。把当前进程设置为新进程的父进程,清除信号位图并复位新进程各统计值,并设置初始运行时间片值为 15 个系统滴答数(150 毫秒)。接着根据当前进程设置任务状态段(TSS)中各寄存器的值。由于创建进程时新进程返回值应为 0,所以需要设置 tss.eax = 0。新建进程内核态堆栈指针 tss.esp0 被设置成新进程任务数据结构所在内存页面的顶端,而堆栈段 tss.ss0 被设置成内核数据段选择符(固定值 0x10)。tss.ldt 被设置为局部表描述符在 GDT 中的索引值。如果当前进程使用了协处理器,则还需要把协处理器的完整状态保存到新进程的 tss.i387 结构中。
此后系统设置新任务的代码和数据段基址、限长,并复制当前进程内存分页管理的页表。注意,此时系统并不为新的进程分配实际的物理内存页面,而是让它共享其父进程的内存页面。只有当父进程或新进程中任意一个有写内存操作时,系统才会为执行写操作的进程分配相关的独自使用的内存页面。这种处理方式称为写时复制(Copy On Write)技术。
随后,如果父进程中有文件是打开的,则应将对应文件的打开次数增 1。接着在 GDT 中设置新任务的 TSS 和 LDT 描述符项,其中基地址信息指向新进程任务结构中的 tss 和 ldt。最后再将新任务设置成可运行状态并返回新进程号。
另外请注意,创建一个新的子进程和加载运行一个执行程序文件是两个不同的概念。当创建子进程时,它完全复制了父进程的代码和数据区,并会在其中执行子进程部分的代码。而执行块设备上的一个程序时,一般是在子进程中运行 exec() 系统调用来操作的。在进入 exec() 后,子进程原来的代码和数据区就会被清掉(释放)。待该子进程开始运行新程序时,由于此时内核还没有从从块设备上加载该程序的代码,CPU 就会立刻产生代码页面不存在的异常(Fault),此时内存管理程序就会从块设备上加载相应的代码页面,然后 CPU 又重新执行引起异常的指令。到此时新程序的代码才真正开始被执行。
内核中的调度程序用于选择系统中下一个要运行的进程。这种选择运行机制是多任务操作系统的基础。调度程序可以看作为在所有处于运行状态的进程之间分配 CPU 运行时间的管理代码。由前面描述可知,Linux 进程是抢占式的,但被抢占的进程仍然处于 TASK_RUNNING 状态,只是暂时没有被 CPU 运行。进程的抢占发生在进程处于用户态执行阶段,在内核态执行时是不能被抢占的。
为了能让进程有效地使用系统资源,又能使进程有较快的响应时间,就需要对进程的切换调度采用一定的调度策略。在 Linux 0.11 中采用了基于优先级排队的调度策略。
schedule()函数首先扫描任务数组。通过比较每个就绪态(TASK_RUNNING)任务的运行时间递减滴答计数 counter 的值来确定当前哪个进程运行的时间最少。哪一个的值大,就表示运行时间还不长,于是就选中该进程,并使用任务切换宏函数切换到该进程运行。
如果此时所有处于 TASK_RUNNING 状态进程的时间片都已经用完,系统就会根据每个进程的优先权值 priority,对系统中所有进程(包括正在睡眠的进程)重新计算每个任务需要运行的时间片值 counter。
计算的公式是:
这样对于正在睡眠的进程当它们被唤醒时就具有较高的时间片 counter 值。然后 schedule()函数重新扫描任务数组中所有处于 TASK_RUNNING 状态的进程,并重复上述过程,直到选择出一个进程为止。最后调用 switch_to()执行实际的进程切换操作。
如果此时没有其他进程可运行,系统就会选择进程 0 运行。对于 Linux 0.11 来说,进程 0 会调用 pause() 把自己置为可中断的睡眠状态并再次调用 schedule()。不过在调度进程运行时,schedule() 并不在意进程 0 处于什么状态。只要系统空闲就调度进程 0 运行。
每当选择出一个新的可运行进程时,schedule()函 数就会调用定义在 include/asm/system.h 中的 switch_to()宏执行实际进程切换操作。该宏会把 CPU 的当前进程状态(上下文)替换成新进程的状态。在进行切换之前,switch_to()首先检查要切换到的进程是否就是当前进程,如果是则什么也不做,直接退出。否则就首先把内核全局变量 current 置为新任务的指针,然后长跳转到新任务的任务状态段 TSS 组成的地址处,造成 CPU 执行任务切换操作。此时 CPU 会把其所有寄存器的状态保存到当前任务寄存器 TR 中 TSS 段选择符所指向的当前进程任务数据结构的 tss 结构中,然后把新任务状态段选择符所指向的新任务数据结构中 tss 结构中的寄存器信息恢复到 CPU 中,系统就正式开始运行新切换的任务了。这个过程可参见图 5-22 所示。
当一个进程结束了运行或在半途中终止了运行,那么内核就需要释放该进程所占用的系统资源。这包括进程运行时打开的文件、申请的内存等。
当一个用户程序调用 exit()系统调用时,就会执行内核函数 do_exit()。该函数会首先释放进程代码段和数据段占用的内存页面,关闭进程打开着的所有文件,对进程使用的当前工作目录、根目录和运行程序的 i 节点进行同步操作。如果进程有子进程,则让 init 进程作为其所有子进程的父进程。如果进程是一个会话头进程并且有控制终端,则释放控制终端,并向属于该会话的所有进程发送挂断信号 SIGHUP,这通常会终止该会话中的所有进程。然后把进程状态置为僵死状态 TASK_ZOMBIE。并向其原父进程发,送 SIGCHLD 信号,通知其某个子进程已经终止。最后 do_exit()调用调度函数去执行其他进程。由此可见在进程被终止时,它的任务数据结构仍然保留着。因为其父进程还需要使用其中的信息。
在子进程在执行期间,父进程通常使用 wait()或 waitpid()函数等待其某个子进程终止。当等待的子进程被终止并处于僵死状态时,父进程就会把子进程运行所使用的时间累加到自己进程中。最终释放已终止子进程任务数据结构所占用的内存页面,并置空子进程在任务数组中占用的指针项。
本节内容概要描述了 Linux 内核从开机引导到系统正常运行过程中对堆栈的使用方式。这部分内容的说明与内核代码关系比较密切,可以先跳过。在开始阅读相应代码时再回来仔细研究。
Linux 0.11 系统中共使用了四种堆栈。一种是系统引导初始化时临时使用的堆栈;一种是进入保护模式之后提供内核程序初始化使用的堆栈,位于内核代码地址空间固定位置处。该堆栈也是后来任务 0 使用的用户态堆栈;另一种是每个任务通过系统调用,执行内核程序时使用的堆栈,我们称之为任务的内核态堆栈。每个任务都有自己独立的内核态堆栈;最后一种是任务在用户态执行的堆栈,位于任务(进程)逻辑地址空间近末端处。
使用多个栈或在不同情况下使用不同栈的主要原因有两个。首先是由于从实模式进入保护模式,使
得 CPU 对内存寻址访问方式发生了变化,因此需要重新调整设置栈区域。另外,为了解决不同 CPU 特
权级共享使用堆栈带来的保护问题,执行 0 级的内核代码和执行 3 级的用户代码需要使用不同的栈。当一个任务进入内核态运行时,就会使用其 TSS 段中给出的特权级 0 的堆栈指针 tss.ss0、tss.esp0,即内核原用户栈指针会被保存在内核栈中。而当从内核态返回用户态时,就会恢复使用用户态的堆栈。下面分别对它们进行说明。
当 bootsect 代码被 ROM BIOS 引导加载到物理内存 0x7c00 处时,并没有设置堆栈段,当然程序也没有使用堆栈。直到 bootsect 被移动到 0x9000:0 处时,才把堆栈段寄存器 SS 设置为 0x9000,堆栈指针 esp 寄存器设置为 0xff00,也即堆栈顶端在 0x9000:0xff00 处,参见 boot/bootsect.s 第 61、62 行。setup.s 程序中也沿用了 bootsect 中设置的堆栈段。这就是系统初始化时临时使用的堆栈。
从 head.s 程序起,系统开始正式在保护模式下运行。此时堆栈段被设置为内核数据段(0x10),堆栈指针 esp 设置成指向 user_stack 数组的顶端(参见 head.s,第 31 行),保留了 1 页内存(4K)作为堆栈使用。user_stack 数组定义在 sched.c 的 67–72 行,共含有 1024 个长字。它在物理内存中的位置示意图可参见下图 5-23 所示。此时该堆栈是内核程序自已使用的堆栈。其中的给出地址是大约值,它们与编译时的实际设置参数有关。这些地址位置是从编译内核时生成的 system.map 文件中查到的。
在 init/main.c 程序中,在执行 move_to_user_mode()代码把控制权移交给任务 0 之前,系统一直使用上述堆栈。而在执行过 move_to_user_mode()之后,main.c 的代码被"切换"成任务 0 中执行。通过执行 fork() 系统调用,main.c 中的 init()将在任务 1 中执行,并使用任务 1 的堆栈。而 main()本身则在被"切换"成为任务 0 后,仍然继续使用上述内核程序自己的堆栈作为任务 0 的用户态堆栈。关于任务 0 所使用堆栈的详细描述见后面说明。
每个任务都有两个堆栈,分别用于用户态和内核态程序的执行,并且分别称为用户态堆栈和内核态堆栈。除了处于不同 CPU 特权级中,这两个堆栈之间的主要区别在于任务的内核态堆栈很小,所保存的数据量最多不能超过(4096 - 任务数据结构块)个字节,大约为 3K 字节。而任务的用户态堆栈却可以在用户的 64MB 空间内延伸。
每个任务(除了任务 0 和任务 1)有自己的 64MB 地址空间。当一个任务(进程)刚被创建时,它的用户态堆栈指针被设置在其地址空间的靠近末端(64MB 顶端)部分。实际上末端部分还要包括执行程序的参数和环境变量,然后才是用户堆栈空间,见图 5-24 所示。应用程序在用户态下运行时就一直使用这个堆栈。堆栈实际使用的物理内存则由 CPU 分页机制确定。由于 Linux 实现了写时复制功能(Copy on Write),因此在进程被创建后,若该进程及其父进程都没有使用堆栈,则两者共享同一堆栈对应的物理内存页面。只有当其中一个进程执行堆栈写操作(例如 push 操作)时内核内存管理程序才会为写操作进程分配新的内存页面。而进程 0 和进程 1 的用户堆栈比较特殊,见后面说明。
每个任务有其自己的内核态堆栈,用于任务在内核代码中执行期间。其所在线性地址中的位置由该任务 TSS 段中 ss0 和 esp0 两个字段指定。ss0 是任务内核态堆栈的段选择符,esp0 是堆栈栈低指针。因此每当任务从用户代码转移进入内核代码中执行时,任务的内核态栈总是空的。任务内核态堆栈被设置在位于其任务数据结构所在页面的末端,即与任务的任务数据结构(task_struct)放在同一页面内。这是在建立新任务时,fork() 程序在任务 tss 段的内核级堆字段(tss.esp0 和 tss.ss0)中设置的,参见 kernel/fork.c,93 行:
p->tss.esp0 = PAGE_SIZE + (long)p;
p->tss.ss0 = 0x10;
其中 p 是新任务的任务数据结构指针,tss 是任务状态段结构。内核为新任务申请内存用作保存其 task_struct 结构数据,而 tss 结构(段)是 task_struct 中的一个字段。该任务的内核堆栈段值 tss.ss0 也被设置成为 0x10(即内核数据段选择符),而 tss.esp0 则指向保存 task_struct 结构页面的末端。见图 5-25 所示。实际上 tss.esp0 被设置成指向该页面(外)上一字节处(图中堆栈底处)。这是因为 Intel CPU 执行堆栈操作时是先递减堆栈指针 esp 值,然后在 esp 指针处保存入栈内容。
为什么从主内存区申请得来的用于保存任务数据结构的一页内存也能被设置成内核数据段中的数据呢,也即 tss.ss0 为什么能被设置成 0x10 呢? 这是因为用户内核态栈仍然属于内核数据空间。我们可以从内核代码段的长度范围来说明。在 head.s 程序的末端,分别设置了内核代码段和数据段的描述符,段长度都被设置成了 16MB。这个长度值是 Linux 0.11 内核所能支持的最大物理内存长度(参见 head.s,110 行开始的注释)。因此,内核代码可以寻址到整个物理内存范围中的任何位置,当然也包括主内存区。每当任务执行内核程序而需要使用其内核栈时,CPU 就会利用 TSS 结构把它的内核态堆栈设置成由 tss.ss0 和 tss.esp0 这两个值构成的值。在任务切换时,老任务的内核栈指针 esp0 不会被保存。对 CPU 来讲,这两个值是只读的。因此每当一个任务进入内核态执行时,其内核态堆栈总是空的。
任务 0(空闲进程 idle)和任务 1(初始化进程 init)的堆栈比较特殊,需要特别予以说明。任务 0 和任务 1 的代码段和数据段相同,限长也都是 640KB,但它们被映射到不同的线性地址范围中。任务 0 的段基地址从线性地址 0 开始,而任务 1 的段基地址从 64MB 开始。但是它们全都映射到物理地址 0–640KB 范围中。这个地址范围也就是内核代码和基本数据所存放的地方。在执行了 move_to_user_mode()之后,任务 0 和任务 1 的内核态堆栈分别位于各自任务数据结构所在页面的末端,而任务 0 的用户态堆栈就是前面进入保护模式后所使用的堆栈,即 sched.c 的 user_stack[]数组的位置。由于任务 1 在创建时复制了任务 0 的用户堆栈,因此刚开始时任务 0 和任务 1 共享使用同一个用户堆栈空间。但是当任务 1 开始运行时,由于任务 1 映射到 user_stack[]处的页表项被设置成只读,使得任务 1 在执行堆栈操作时将会引起写页面异常,从而内核会使用写时复制机制为任务 1 另行分配主内存区页面作为堆栈空间使用。只有到此时,任务 1 才开始使用自己独立的用户堆栈内存页面。因此任务 0 的堆栈需要在任务 1 实际开始使用之前保持"干净",即任务 0 此时不能使用堆栈,以确保复制的堆栈页面中不含有任务 0 的数据。
任务 0 的内核态堆栈是在其人工设置的初始化任务数据结构中指定的,而它的用户态维栈是在执行 move_to_user_mode()时,在模拟 iret 返回之前的堆栈中设置的,参见图 5-21 所示。我们知道,当进行特权级会发生变化的控制权转移时,目的代码会使用新特权级的堆栈,而原特权级代码堆栈指针将保留在新堆栈中。因此这里先把任务 0 用户堆栈指针压入当前处于特权级 0 的堆栈中,同时把代码指针也压入堆栈,然后执行 IRET 指令即可实现把控制权从特权级 0 的代码转移到特权级 3 的任务 0 代码中。在这个人工设置内容的堆栈中,原 esp 值被设置成仍然是 user_stack 中原来的位置值,而原 ss 段选择符被设置成 0x17,即设置成用户态局部表 LDT 中的数据段选择符。然后把任务 0 代码段选择符符 0x0f 压入堆栈作为栈中原 CS 段的选择符,把下一条指令的指针作为原 EIP 压入堆栈。这样,通过执行 IRET 指令即可"返回"到任务 0 的代码中继续执行了。
在 Linux 0.11 系统中,所有中断服务程序都属于内核代码。如果一个中断产生时任务正在用户代码中执行,那么该中断就会引起 CPU 特权级从 3 级到 0 级的变化,此时 CPU 就会进行用户态堆栈到内核态堆栈的切换操作。CPU 会从当前任务的任务状态段 TSS 中取得新堆栈的段选择符和偏移值。因为中断服务程序在内核中,属于 0 级特权级代码,所以 48 比特的内核态堆栈指针会从 TSS 的 ss0 和 esp0 字段中获得。在定位了新堆栈(内核态堆栈)之后,CPU 就会首先把原用户态堆栈指针 ss 和 esp 压入内核态堆栈,随后把标志寄存器 eflags 的内容和返回位置 cs、eip 压入内核态堆栈。
内核的系统调用是一个软件中断,因此任务调用系统调用时就会进入内核并执行内核中的中断服务代码。此时内核代码就会使用该任务的内核态堆栈进行操作。同样,当进入内核程序时,由于特权级别发生了改变(从用户态转到内核态),用户态堆栈的堆栈段和堆栈指针以及 eflags 会被保存在任务的内核态堆栈中。而在执行 iret 退出内核程序返回到用户程序时,将恢复用户态的堆栈和 eflags。这个过程见图 5-26 所示。
如果一个任务正在内核态中运行,那么若 CPU 响应中断就不再需要进行堆栈切换操作,因为此时该任务运行的内核代码已经在使用内核态堆栈,并且不涉及优先级别的变化,所以 CPU 仅把 eflags 和中断返回指针 cs、eip 压入当前内核态堆栈,然后执行中断服务过程。