目录
进程
程序是一个可执行文件,而进程是一个执行中的程序实例,所以可以认为
进程 = 进程资源 + 执行序列
利用分时技术,在操作系统上同时可以运行多个进程。分时技术的基本原理是把CPU的运行时间划分成一个个规定长度的时间片,让每个进程在一个时间片内运行。当进程的时间片用完时,系统就利用调度程序切换到另一个进程去运行。那么有几个问题需要探讨一下:1、进程到底是什么;2、怎么知道时间片用完的。
1、进程到底是什么
内核程序通过进程表对进程进行管理,每个进程在进程表中占用一项。任务表项是一个task_struct任务结构指针,也被称作任务控制块PCB,保存着控制和管理进程的所有信息,如下:
2、怎么知道时间片用完的
这里其实就是进程调度的概念了。我们从上一节的PCB中可以看到,有一个字段叫count(滴答数),滴答数会随着时间不断递减,那是怎么做到的呢?
这就不得不说计算机中的一个设备——定时器。这个定时器每隔一段时间就会向 CPU 发起一个中断信号。定时器的信号是由晶振分频产生的。在 linux-0.11 中,定时器的间隔时间被设置为 10 ms,也就是 100 Hz。发起的中断叫时钟中断,其中断向量号被设置为了 0x20。一切的源头,就源于这个每 10ms 产生的一次时钟中断。
操作系统在启动的时候,会创建一个中断向量表,当时钟中断0x20产生时,CPU会查找中断向量表中 0x20 处的函数地址,这个函数地址即中断处理函数,并跳转过去执行。
_timer_interrupt:
...
// 增加系统滴答数
incl _jiffies
...
// 调用函数 do_timer
call _do_timer
...
该函数主要做了两件事:1、将系统滴答数这个变量 jiffies 加一;2、调用了另一个函数 do_timer。
jiffies是内核的全局变量,记录的是计算机启动以来时钟中断的数量。在启动的时候,该变量为0,每来一次时钟中断,该值加1。HZ代表1秒会产生多少次时钟中断,所以计算机启动的时间就可以通过jiffies/HZ获得。
do_timer函数首先将当前进程的时间片 -1,然后判断如果时间片仍然大于零,则什么都不做直接返回。如果时间片已经为零,则调用 schedule(),即进程调度。
void do_timer(long cpl) {
...
// 当前线程还有剩余时间片,直接返回
if ((--current->counter)>0) return;
// 若没有剩余时间片,调度
schedule();
}
这里可能会有一个疑问:如果一个counter值等于0之后,会调度其他进程,那么总会达到一个结果,就是所有的进程的counter都为0。如果所有进程的counter值都为0,那么就会对所有进程的counter重新赋值。
3、进程切换
在PCB数据结构中,我们看到有一个任务状态段字段tss,这里用于存储当前进程所有寄存器值。
当进行进程调度的时候,底层主要经历了如下过程:
1. 通过 ljmp 跳转指令跳转到新进程的偏移地址处。
2. 将当前各个寄存器的值保存在当前进程的 TSS 中,并将新进程的 TSS 信息加载到各个寄存器。(这部分是执行 ljmp 指令的副作用,并且是由硬件实现的)
4、进程初始化
创建进程,实际上就是创建了一个PCB,并分配了一段内存用于存储该进程的代码程序。创建进程的过程也是复制的过程,牵涉到进程数据结构中信息的设置
- 系统首先为新进程在内存中申请一页内存来存放其任务数据结构信息,并复制当前进程任务数据结构中所有内容作为新进程任务数据结构的模板
- 对已复制的任务数据结构内容进行修改,把当前进程设置为新进程的父进程
- 根据当前进程环境设置新进程任务状态段tss
- 新建进程内核态堆栈指针tss.esp0被设置成新进程任务数据结构所在内存页面的顶端(见下图)
- 设置新进程代码段、数据段的基址和段限长,复制当前进程内存分页管理的页目录项和页表项。
- 在GDT中设置新进程的tss和LDT描述符项,其中基地址信息指向新进程的tss和ldt
- 最后将新进程设置成可运行状态,并向当前进程返回新进程号
所有涉及”页“的概念,下一章进行介绍。
fork()的执行过程中,内核并不会立刻为新进程分配代码和数据内存页。只有当以后执行过程中如果其中有一个进程以写的方式访问内存时,被访问的内存页面才会在写操作前被复制到新申请的内存页面中。
子进程虽然复制了父进程所有的地址空间中的内容,但是二者具有独立的地址空间,执行时互不干扰。
为了说明这些不同,首先看一看 UNIX 操作系统。在 UNIX 中,正如以前所述,每个进程都用一个唯一的整型进程标识符来标识。通过系统调用 fork(),可创建新进程。新进程的地址空间复制了原来进程的地址空间。这种机制允许父进程与子进程轻松通信。这两个进程(父和子)都继续执行处于系统调用 fork() 之后的指令,但有一点不同:对于新(子)进程,系统调用 fork() 的返回值为 0;而对于父进程,返回值为子进程的进程标识符(非零)。
通常,在系统调用 fork() 之后,有个进程使用系统调用 exec(),以用新程序来取代进程的内存空间。系统调用 exec() 加载二进制文件到内存中(破坏了包含系统调用 exec() 的原来程序的内存内容),并开始执行。采用这种方式,这两个进程能相互通信,并能按各自方法运行。父进程能够创建更多子进程,或者如果在子进程运行时没有什么可做,那么它采用系统调用 wait() 把自己移出就绪队列,直到子进程终止。因为调用 exec() 用新程序覆盖了进程的地址空间,所以调用 exec() 除非出现错误,不会返回控制。
从上面的解释也可以看出来,创建一个新的子进程和加载运行一个执行程序文件是两个不同的概念:当创建子进程时,它完全复制了父进程的代码和数据区,并会在其中执行子进程部分的代码,一般在子进程中运行exec()系统调用会执行块设备上的程序,在进入exec()后,子进程原来的代码和数据区就会被清掉,CPU就会立刻产生代码页面不存在的异常,此时内存管理程序就会从块设备上加载相应的代码页面,然后CPU重新执行引起异常的指令,到此时新程序的代码才真正开始被执行。
5、任务堆栈
为了解决不同CPU特权级共享使用堆栈带来的保护问题,执行0级的内核代码和执行3级的用户代码需要使用不同的栈,所以每个任务都有两个堆栈:用户态堆栈和内核态堆栈。除了处于不同CPU特权级中,这两个堆栈之间的主要区别在于任务的内核态堆栈很小。
每个任务有自己的64M地址空间,当一个任务刚被创建时,它的用户态堆栈指针被设置在其地址空间的靠近末端的部分。堆栈实际使用的物理内存则是由CPU分页机制确定。
每个任务都有自己的内核态堆栈,用于任务在内核代码中执行期间。其所在线性地址中的位置由该任务TSS段中ss0和esp0两个字段指定。ss0事实上任务内核态堆栈的段选择符,esp0是堆栈栈底指针。因此每当任务从用户代码转移进入内核代码中执行时,任务的内核态总是空的。
每个段定义了内存中的某个区域以及访问的优先级等级等信息。
6、系统调用
系统调用接口是linux内核与上层应用程序进行交互通信的唯一接口。用户程序通过直接或间接调用中断int 0x80,并在eax寄存器中指定系统调用功能号,即可使用内核资源。
如果一个中断产生时任务正在用户态中执行,那么该中断就会引起CPU特权级从3级变化到0级,此时CPU就会进行用户态堆栈到内核态堆栈的切换。CPU会从当前任务的任务状态段tss中取得新堆栈的段选择符和偏移值,即ss0和esp0。在定位了内核态堆栈之后,CPU就会首先把原用户态堆栈指针ss和esp压入内核态堆栈,随后把标志寄存器eflags的内容和返回位置cs、eip也压入内核堆栈。
执行iret退出内核程序返回到用户程序时,将恢复用户态堆栈和eflags
上面讲的是系统调用时,任务堆栈的切换过程。如果任务在用户态时,CPU收到了中断响应,那么就需要先讲任务切换到内核态,再响应中断。如果一个任务正在内核态中运行,那么若CPU响应中断就不再需要进行堆栈切换操作,因为此时该任务运行的内核代码已经在使用内核态堆栈,所以CPU仅把eflags和中断返回指针cs、eip压入当前内核态堆栈,然后执行中断服务过程。
参考:
《linux内核完全剖析》