【Linux 0.12】Linux内核体系结构——进程控制

  • [ ] 什么是时间嘀嗒??

Linux内核体系结构

进程控制

程序是一个可执行文件,而进程是一个执行的程序实例。

分时技术让操作系统可以同时运行多个进程。原理是将 CPU 运行时间划分为若干规定长度的时间片,让每个进程在一个时间片内运行。因此,对于单个 CPU 的机器来说,任意时刻只能运行一个进程。但由于进程运行的时间片很短,表面上看起来所有进程都在同时运行。

对于 Linux ,除了 0 号进程(任务)是手工创建,其余的进程都是由现有的进程 fork 出来的(1 号进程)。内核程序使用进程标识号来识别每个进程。进程由可执行的指令代码,数据和堆栈区组成。

我们已经知道,进程可以在内核态执行(系统调用陷入内核),也可以在用户态执行(一般执行)。他们使用各自独立的内核态堆栈和用户态堆栈。

  • 用户堆栈用于进程在用户态下临时保存调用函数的参数、局部变量等数据
  • 内核堆栈则含有内核程序执行函数调用时的信息

本书中,默认把内核进程称为任务,将用户态程序称为进程

任务数据结构

内核程序通过进程表对进程进行管理。在 Linux 系统中,进程表的表项是 task_struct 结构体。它又被称为进程控制块(PCB)或者进程描述符(PD)。内部保存着用户控制和管理进程的所有信息。包括进程当前运行的状态信息、信号、进程号、父进程号、运行时间累计值、正在使用的文件和本人无的局部描述符以及任务状态段信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
struct task_struct{
long state; //任务的运行状态
long counter; //时间计数
long priority; //优先级。越大,程序单次运行时间越长。
long signal; //信号位图。
struct sigaction sigaction[32]; //信号执行属性结构
long blocked; //进程信号屏蔽码(对应信号位图)
int exit_code; //任务停止之后对应的退出码。父进程会获得

unsigned long start_code; //进程代码在 CPU 线性地址空间中的起始地址
unsigned long end_code; //进程代码的字节长度
unsigned long end_data; //代码长度+数据长度
unsigned long brk; //总长度
unsigned long start_stack; //堆栈段地址

long pid; //进程标识号
long pgrp; //进程组号
long session; //会话号,即所属会话的进程号
long leader; //会话首进程号。
int groups[NGROUPS]; //进程所属的各个组的组号。一个进程可以属于多个组

task_struct *p_pptr; //指向父进程的 PCB
task_struct *p_cptr; //指向最新子进程任务结构的指针
task_struct *p_ysptr; //指向比自己后创建的相邻进程的指针
task_struct *p_osptr; //指向比自己造创建的相邻进程的指针

unsigned short uid; //拥有该进程的用户id
unsigned short euid; //有效用户标识号,用于指明访问文件的权利
unsigned short suid; //保存的用户标识号(saved)。
unsigned short gid; //用户所属的组标识号,指明拥有该进程的用户组
unsigned short egid; //有效组标识号,指明组用户访问文件的权限
unsigned short sgid; //保存的用户组标识号

long timeout; //内核定时超时值
long alarm; //进程报警定时(滴答数)
long utime; //累计进程在用户态运行的时间(滴答数)
long stime; //累计进程在内核态运行的时间(滴答数)
long cutime; //累计进程的子进程在用户态运行的时间(滴答数)
long cstime; //累计进程的子进程在内核态运行的时间(滴答数)
long start_time; //进程生成并开始运行的时刻

struct rlimit rlim[RLIM_NLIMITS]; //进程资源使用统计数组
unsigned int flags; //进程标志。0.12还没有这个字段
unsigned short used_math; //是否使用了协处理器的标志。
int tty; //进程使用tty终端的子设备号。 -1表示没有使用
unsigned short umask; //进程创建新文件时使用的16位属性屏蔽字

struct m_inode *pwd; //进程的当前工作目录结点,用于解析相对路径
struct m_inode *root; //进程自己的根目录结点,用于解析绝对路径名
struct m_inode *executable; //进程运行的执行文件在内存中的i结点指针

unsigned long close_on_exec; //进程文件描述符的位图。
struct file *filp[NR_OPEN]; //进城使用的所有打开文件的文件结构指针表。(文件描述符就是该结构的索引)
struct desc_struct ldt[3]; //进程局部描述符结构。定义任务在虚拟地址空间的代码段和数据段
struct tss_struct tss; //任务状态段信息
};
  1. long state:可以取若干值。

    • 如果进程正在等待使用 CPU 或者正在运行,值为 TASK_RUNNING (0)。
    • 如果进程因为等待某一事件而处于空闲状态,则值为 TASK_INTERRUPTIBLE(1)或 TASK_UNINTERRUPTIBLE(2)。区别在于TASK_INTERRUPTIBLE 的进程可以被信号唤醒而激活,但 TASK_UNINTERRUPTIBLE 的进程通常在等待硬件条件的满足。
    • TASK_STOPPED(4)一般表示进程处于停止状态。
    • TASK_ZOMBIE (16)表示进程已经被终止,但是任务数据结构还保存在任务结构表中。
  2. long counter:该字段保存进程在被暂停本次运行之前,还能运行的时间滴答数(tick??)。即在正常情况下,经过几个时钟周期才会切换到下一个进程。

  3. long priority:用于给 counter 赋初始值。其单位也是时钟滴答。

  4. long signal:进程当前收到的信号位图。每位都代表一种信号,例如 SIGINT 代表收到了 ctrl+C。

  5. struct sigaction sigaction[32]:结构数组,保存处理各信号所用的操作和属性。每种 signal 对应一个。

  6. long blocked:表示进程当前不处理的信号的阻塞位图。

  7. int exit:父进程可以查询子进程中止时的退出码。

  8. unsigned long start_code:进程代码在 CPU 线性地址空间中的起始地址。在 Linux 0.1x 中是 64MB 的整数倍

  9. unsigned long end_code:进程代码的字节长度

  10. unsigned long end_data:代码长度+数据长度

  11. unsigned long brk: 总长度。包含代码,数据段,以及未初始化的 bss 段。通过修改该指针,可以为进程添加和释放动态分配的内存。

  12. unsigned long start_stack: 堆栈段地址

  13. long alarm:进程的报警定时值。如果进程使用系统调用 alarm() 设置过该字段。内核会把该函数以秒为单位的时间转换为滴答数。此后当系统时间滴答数超过了 alarm 字段,内核就会向进程发送一个 SIGALARM 信号,默认时中止程序执行。

  14. struct m_inode *executable:系统根据该字段判断系统中是否还有另一个进程在运行同一个程序。????

  15. unsigned long close_on_exec:位图,每个位表示一个文件描述符,用于确定在系统调用 execve() 时需要关闭的文件描述符。当一个程序使用 fork() 创建了一个子进程时,通常会在该子进程中开始执行新程序。若一个文件描述符在 close_on_exec 中对应位是置位,则子进程执行 execve() 对应打开着的文件描述符将被关闭。即在新程序中该文件描述符被关闭。

  16. struct tss_struct tss:任务从执行中被切换出去时,tss_struct 保存了当前处理器的所有寄存器值。当任务又被 CPU 重新执行时,就会利用这些值恢复到任务被切换出时的状态。

    当进程在执行时,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换至另一个进程时,就需要保存当前进程的上下文,以便再次执行。Linux 中,这些信息保存在该结构中。

进程的运行状态

进程的状态及转换关系

进程初始化

引导程序首先将内核从磁盘加载到内存,并让系统进入保护模式运行,之后就开始执行系统初始化程序 init/main.c 。该程序首先确定如何分配使用系统物理内存,然后调用内核各部分的初始化函数,分别对内存管理,中断操作,块设备和字符设备,进程管理以及硬盘和软盘硬件进行初始化处理。在完成上述操作后,系统已经处于可运行状态。此后,程序把自己“手工”移动到任务 0 运行,并使用 fork() 创建出进程 1 。在进程 1 中,程序将继续进行应用环境的初始化,并执行 shell 登陆程序。而进程 0 则会在系统空闲时被调度执行,此时任务 0 仅执行 pause() 系统调用。

“将自己移动到任务 0 执行” 这个过程由宏定义 move_to_user_mode(include/asm/system.h)完成。它会将 main.c 程序执行流从内核态转移到用户态的任务 0 中继续运行。在移动之前,系统在对调度程序(sched_init())的初始化过程中, 首先对任务 0 的运行环境进行了设置。这包括人工预先设置好任务 0 数据结构各字段的值,在全局描述符表添加任务 0 的任务状态段描述符,和局部描述符表的段描述符,并把他们加载到任务寄存器 tr 和局部描述符表 ldtr中。

需要强调的是,内核初始化是一个特殊的过程,内核初始化代码也是任务 0 的代码。从任务 0 的数据结构中设置的初始值可知,任务 0 的代码段和数据段的基地址是 0 ,段限长是 640KB。而内核代码段和数据段的基地址是0,段限长是 16MB 。因此任务 0 的代码和数据段包含在内核代码数据段之中。内核初始化程序 main.c 就是任务 0 中的代码。知识在移动到任务 0 之前系统正以内核态运行 main.c 。宏定义 move_to_user_mode 的功能就是把特权级由 0 转移到 3,但仍然执行原来的代码流程。

在移动到任务 0 的过程中,宏 move_to_user_mode 使用了中断返回指令造成特权级的改变。这种方法进行控制权转移是由 CPU 保护机制造成的。CPU 允许低级别的代码通过调用门或中断、陷阱门来跳转到高级别的代码中运行,但是反之则不可以。因此内核采用了模拟 IRET 返回低级别代码的方法。该方法的主要思想是在堆栈中构建中断返回指令需要的内容,把返回地址的段选择符设置成任务 0 的代码段选择符,即可跳转。(有一种栈溢出覆盖返回地址劫持程序流的感觉。)

创建新进程

Linux 系统中创建新进程使用 fork() 系统调用。所有进程都是通过复制进程 0 得到的,都是进程 0 的子进程。

在创建新进程的过程中,系统首先在任务数组中找到一个还没有被任何进程使用的空项,然后系统为新建进程在主存区申请一页内存来存放任务数据结构信息(PCB),并复制当前进程任务数据结构中的所有内容作为新进程任务数据结构的模版。为了防止这个还未处理完成的新建进程被调度函数执行,此时应该立刻将新进程设置为不可中断的等待状态。(TASK_UNINTERRUPTIBLE

随后对复制的任务数据结构进行修改,把当前进程设置为新进程的父进程。清除信号位图并复位新进程的各统计值(资源占用等),设置初始运行时间片为 15tick。接着根据当前进程设置任务状态段 TSS 的各个寄存器的值。由于创建进程时新进程返回值应该为 0 ,所以需要设置 tss.eax = 0。新进程内核态堆栈指针 tss.esp0 将被设置为 LDT 在 GDT 的索引。

此后系统设置新任务的代码和数据段地址和限长,并复制当前进程内存分页管理的页表。此时用到 写时复制技术(copy on write),系统并不立刻位新进程分配实际的物理内存页面,而是和父进程共享内存页面。只有当父进程或新进程有写内存操作时,才会位执行写操作的进程分配相关的肚子使用的内存。

随后,如果父进程有相关文件是打开的,则对应文件的打开次数增加 1 ,接着在 GDT 中设置新任务的 TSS 和 LDT,其中基地址信息指向新进程 PCB 的 tss 和 ldt。最后再将新进程设置为可运行状态并返回新进程号。

注意!!创建新的子进程和加载运行一个可执行文件是两个不同的概念!!!

创建子进程,会完全复制父进程的代码和数据。执行块设备上的一个程序时,一般是子进程运行 exec() 系统调用来操作的。在进入 exec() 后,子进程原来的代码和数据区就会被清除,子进程开始运行新程序时,由于此时还没有从块设备加载程序的代码,CPU 引起缺页中断,内存就会从块设备加载代码页面。

进程调度

内核的进程调度程序用于选择系统中下一个要运行的进程。

Linux 进程是抢占式,抢占发生在用户态执行阶段,内核态执行程序是不能被抢占的。

为了让进程有效地使用系统资源,又能使进程有较快的响应时间, Linux 采用基于优先级排队的调度策略。

1. 调度程序

schedule() 函数扫描任务数组,在就绪态任务中,选择一个运行时间最少的进程。(即选择时间计数器 counter 最大的一个进程)

如果此时所有处于就绪态的进程时间片都用完,系统会根据进程的 priority 对所有进程计算每个任务需要运行的时间片 counter。(正在睡眠的进程也会计算)

$$
counter = \frac{counter}{2} + priority
$$

这样,由于睡眠的进程 counter 本身不是 0 ,所以能获得更高的时间片 counter 值。然后再在处于 Task_RUNNING 状态的进程中选出一个,调用 switch_to() 执行实际的切换操作。

此时,如果还是没有其他进程可以运行,系统就会选择进程 0 运行。(对于 0.12,进程 0 会调用 pause() 将自己设置为可中断的睡眠,然后再次调用 schedule() 函数。但其实 schedule() 并不在意进程 0 是什么状态,只要系统空闲就调度进程 0 运行。)

2. 进程切换

每当选出一个新的可运行进程时,schedule() 函数就会调用定义在 include/asm/system.h 中的 switch_to() 宏定义进行实际的切换操作。

3. 中止进程

当用户调用了 exit() 系统调用时,就会执行内核函数 do_exit()

  1. 释放进程代码段和数据段占用的内存页面
  2. 关闭进程打开的所有文件
  3. 如果进程有子进程,则让 init 进程作为其所有子进程的父进程
  4. 制空当前进程和子进程在任务数组中占用的指针