Linux0.11 内核体系结构(八)(上):https://developer.aliyun.com/article/1597294
二、Linux 系统的中断机制
1、中断向量表
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 的保护模式下,因此它使用中断描述符表来设置和保存各中断的"向量"信息。
2、Linux 内核的中断处理
对于 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 文件的说明。
3、标志寄存器的中断标志
为了避免竞争条件和中断对临界代码区的干扰,在 Linux 0.11 内核代码中许多地方使用了 cli 和 sti 指令。cli 指令用来复位 CPU 标志寄存器中的中断标志,使得系统在执行 cli 指令后不会响应外部中断。 sti 指令用来设置标志寄存器中的中断标志,以允许 CPU 能识别并响应外部设备发出的中断。当进入可能引起竞争条件的代码区时,内核中就会使用 cli 指令来关闭对外部中断的响应,而在执行完竞争代码区时内核就会执行 sti 指令以重新允许 CPU 响应外部中断。例如,在修改文件超级块的锁定标志和任务进入/退出等待队列操作时都需要首先使用 cli 指令关闭 CPU 对外部中断的响应,在操作完成之后再使用 sti 指令开启对外部中断的响应。如果不使用 cli、sti 指令对,即在需要修改一个文件超级块时不使用 cli 来关闭对外部中断的响应,那么在修改之前判断出该超级块锁定标志没有置位而想设置这个标志时,若此时正好发生系统时钟中断而切换到其他任务去运行,并且碰巧其他任务也需要修改这个超级块,那么此时这个其他任务会先设置超级块的锁定标志并且对超级块进行修改操作。当系统又切换回原来的任务时,此时该任务不会再去判断锁定标志就会继续执行设置超级块的锁定标志,从而造成两个任务对临界代码区的同时多重操作,引起超级块数据的不一致性,严重时会导致内核系统崩溃。
三、Linux 进程控制
1、创建新进程
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 又重新执行引起异常的指令。到此时新程序的代码才真正开始被执行。
2、进程调度
内核中的调度程序用于选择系统中下一个要运行的进程。这种选择运行机制是多任务操作系统的基础。调度程序可以看作为在所有处于运行状态的进程之间分配 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()函数等待其某个子进程终止。当等待的子进程被终止并处于僵死状态时,父进程就会把子进程运行所使用的时间累加到自己进程中。最终释放已终止子进程任务数据结构所占用的内存页面,并置空子进程在任务数组中占用的指针项。
3、Linux 系统中堆栈的使用方法
本节内容概要描述了 Linux 内核从开机引导到系统正常运行过程中对堆栈的使用方式。这部分内容的说明与内核代码关系比较密切,可以先跳过。在开始阅读相应代码时再回来仔细研究。
Linux 0.11 系统中共使用了四种堆栈。一种是系统引导初始化时临时使用的堆栈;一种是进入保护模式之后提供内核程序初始化使用的堆栈,位于内核代码地址空间固定位置处。该堆栈也是后来任务 0 使用的用户态堆栈;另一种是每个任务通过系统调用,执行内核程序时使用的堆栈,我们称之为任务的内核态堆栈。每个任务都有自己独立的内核态堆栈;最后一种是任务在用户态执行的堆栈,位于任务(进程)逻辑地址空间近末端处。
使用多个栈或在不同情况下使用不同栈的主要原因有两个。首先是由于从实模式进入保护模式,使
得 CPU 对内存寻址访问方式发生了变化,因此需要重新调整设置栈区域。另外,为了解决不同 CPU 特
权级共享使用堆栈带来的保护问题,执行 0 级的内核代码和执行 3 级的用户代码需要使用不同的栈。当一个任务进入内核态运行时,就会使用其 TSS 段中给出的特权级 0 的堆栈指针 tss.ss0、tss.esp0,即内核原用户栈指针会被保存在内核栈中。而当从内核态返回用户态时,就会恢复使用用户态的堆栈。下面分别对它们进行说明。
3.1 初始化阶段
开机初始化时(bootsect.s,setup.s)
当 bootsect 代码被 ROM BIOS 引导加载到物理内存 0x7c00 处时,并没有设置堆栈段,当然程序也没有使用堆栈。直到 bootsect 被移动到 0x9000:0 处时,才把堆栈段寄存器 SS 设置为 0x9000,堆栈指针 esp 寄存器设置为 0xff00,也即堆栈顶端在 0x9000:0xff00 处,参见 boot/bootsect.s 第 61、62 行。setup.s 程序中也沿用了 bootsect 中设置的堆栈段。这就是系统初始化时临时使用的堆栈。
进入保护模式时(head.s)
从 head.s 程序起,系统开始正式在保护模式下运行。此时堆栈段被设置为内核数据段(0x10),堆栈指针 esp 设置成指向 user_stack 数组的顶端(参见 head.s,第 31 行),保留了 1 页内存(4K)作为堆栈使用。user_stack 数组定义在 sched.c 的 67–72 行,共含有 1024 个长字。它在物理内存中的位置示意图可参见下图 5-23 所示。此时该堆栈是内核程序自已使用的堆栈。其中的给出地址是大约值,它们与编译时的实际设置参数有关。这些地址位置是从编译内核时生成的 system.map 文件中查到的。
初始化时(main.c)
在 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 所使用堆栈的详细描述见后面说明。
3.2 任务的堆栈
每个任务都有两个堆栈,分别用于用户态和内核态程序的执行,并且分别称为用户态堆栈和内核态堆栈。除了处于不同 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 和任务 1 的堆栈
任务 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 的代码中继续执行了。
3.3 任务内核态堆栈与用户态堆栈之间的切换
在 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 压入当前内核态堆栈,然后执行中断服务过程。