操作系统学习笔记(5)——进程管理

Posted by 皮皮潘 on 05-14,2022

进程结构

操作系统中用task_struct去描述一个进程管理并进行管理,这里需要注意,OS仅仅负责进程的管理,而管理是指创建、调度、销毁等操作,进程的具体执行和OS其实没什么关系,进程的执行是交由CPU负责的,CPU按照指令流一条条顺序执行指令即完成了进程的执行

简单的来看,每个进程核心的属性并不多,主要包括:表示状态的 state,表示剩余时间片的 counter,表示优先级的 priority,和表示上下文信息的 tss,tss中存储了进程的上下文信息(主要是各个寄存器的值),而指向tss的指针同时存在了GDT对应的进程Entry中,另外进程Entry中还存储了进程对应的LDT的指针

进程调度

Linux的进程调度是抢占式调度, CPU抢占式调度本质就是给每个进程一个时间片,当进程执行完时间片后就由OS强行停止当前进程执行进而执行下一个进程,而非抢占式调度则是由进程本身去决定是否让出CPU资源,非抢占式调度现在已经很少见了

至于OS如何发现进程已经执行完时间片的呢,其实是通过时钟中断这一硬件中断去实现的,在之前的博客中我们提到过,程序的执行除了按照PC中的指令顺序执行之外,还可以通过中断切换到其他指令流,因此通过时钟中断就可以将指令切换到时钟中断处理函数,并进而检查当前进程是否已经执行完时间片了(应用程序进程可以通过屏蔽所有中断的方式来达到长时间占有CPU资源的目的)

那时钟中断又是怎么产生的呢,其实时钟中断本质是由时间硬件(系统定时器,一种可编程硬件)产生,它在sched_init中被开,除了实现CPU抢占式调度之外,通过时钟中断还可以来完成更新系统时间、执行周期性任务、实现定时器等功能

总的来讲,进程调度的简单实现如下:

  1. 开启系统定时器之后会每隔一端时间触发一次的定时器滴答,从而给 CPU 产生一个时钟中断信号。
  2. 中断信号会使 CPU 查找中断向量表,找到操作系统写好的一个时钟中断处理函数 do_timer。
  3. do_timer 会首先将当前进程的 counter 变量 -1,如果 counter 此时仍然大于 0,则就此结束。
  4. 如果 counter = 0 了,就开始进行进程的调度。
  5. 进程调度就是找到所有处于 RUNNABLE 状态的进程,并找到一个 counter 值最大的进程,把它丢进 switch_to 函数的入参里。
  6. switch_to 这个终极函数会调用ljmp指令,CPU 规定,如果 ljmp 指令后面跟的是一个 tss 段,那么,会由硬件将当前各个寄存器的值保存在当前进程的 tss 中,并将新进程的 tss 信息加载到各个寄存器(GDT中会存储每个进程的TSS指针以及LDT指针),简单地讲就是它会保存当前进程上下文,恢复要跳转到的这个进程的上下文,同时使得 CPU 跳转到这个进程的指令偏移地址处。
  7. 最后,新的进程就运行了起来,等待着下一次时钟中断的来临。

其中,ljmp的具体过程如下:
1939_1.png

进程创建——fork

fork方法是一个对于系统调用的封装,其底层是系统调用sys_fork

操作系统通过系统调用将一些敏感功能交由用户态程序调用,所有提供给用户态可用的功能都暴露在 sys_call_table 里了。 系统调用统一通过 int 0x80 中断来进入,具体调用这个表里的哪个功能函数,则是由 eax 寄存器传过来,这里的值是对应数组索引的下标,通过这个下标就可以找到在 sys_call_table 这个数组里的具体函数,触发了中断后,CPU 会自动帮我们做如下压栈操作并切换CPU权限级:
1965_1.png
而从中断处理函数返回时,会调用与普通程序返回ret不同的iret指令,iret会在ret的基础上切换CPU特权级回到用户态,在OS的初始化中就是通过中断返回实现了特权级的翻转,也就是从内核态变为了用户态,顺便设置了栈段、代码段和数据段的基地址

言归正传,sys_fork系统调用实现了新进程的创建,其核心步骤如下:

  1. 在task_struct数组中寻找一个适当的空位置索引并递增寻找一个PID作为进程的Id
  2. 创建一个新的进程结构对象,设置新的进程专有的属性:PID、Status、Counter、Priority等
  3. 把父进程的的上下文信息,主要是TSS复制到子进程的TSS中,因此父子进程一开始共享同一个上下文,不过这里需要注意的是:为了使得父进程和子进程的返回值不同,子进程的TSS的eax会被设置成0,因此在子进程中fork的返回值为0,而父进程的返回值为子进程的PID
  4. 配置进程的页表以及IDT(这里会将父进程的页表直接复制一份,然后将源页表和新页表均置为只读,从而在写时触发缺页中断时才会进行内存内容的复制,并将对应内存的引用减一,这样当另外一个进程写该内存时,由于内存引用为一因此只要简单地将对应的读写位改为可读写就行了,这也就是所谓的写时复制)
  5. 将进程的状态设置为TASK_RUNNABLE从而可以被调度到CPU上执行

进程0

所有的进程中,唯一一个不是通过fork创建的进程就是进程0了,那么进程0又是谁呢?进程0其实就是操作系统一开始执行的那些代码,也就是在篇博文中提到的那一堆操作系统初始化的的东东

而一路看过来的代码能够被自信地称作进程 0 的确切时刻,就是在 sched_init 里为当前执行流添加了一个进程管理结构到 task 数组里,同时开启了定时器以及时钟中断的那一个时刻,因为此时时钟中断到来之后,就可以执行到对应的进程调度程序,进程调度程序才会去这个 task 数组里挑选合适的进程进行切换。所以此时,当时执行的代码,才真正有了一个进程的身份,才勉强得到了一个可以被称为进程 0 的资格,毕竟还没有其他进程参与竞争。

参考

  1. 《闪客——你管这破玩意叫操作系统源码》