目录
1 进程概念的引入
1.1 使用CPU的直观想法
操作系统的首要任务是管理资源,其中CPU是最核心的资源。而管理CPU的前提,是首先能使用CPU
由于CPU的工作原理就是不断的取值-执行,因此按照如下步骤就可以让CPU工作起来,
1. 在内存中划分一段区域
2. 调用磁盘驱动和文件系统将一个可执行程序加载到已分配好的内存中
3. 使用这段内存区域的首地址(即程序第一条指令的地址)设置CS:IP,之后CPU就可以不断取值-执行,从而运行起来
1.2 直观用法的缺点
1. 如下图所示的程序在有无fprintf语句的情况下在同一台计算机上执行2次
耗时情况如下,
fprintf(IO操作)的耗时 / 计算指令耗时 = (0.859 / 10^3) / (0.015 / 10^7) = 5.7 * 10^5,也就是说IO操作的耗时远超过计算耗时
2. 假设有一个程序,每5.7 * 10^5条计算指令后有一条IO指令,由于CPU要等待IO操作完成,CPU的利用率只有50%
也就是说,直观用法最大的缺点就是CPU利用率太低
说明:批处理系统就是采用直观用法的思路,即必须按顺序执行完一个程序或者程序出错,才加载另一个程序执行
1.3 直观用法的改进
1. 改进的核心思路,就是CPU在执行到IO指令时,不是等待,而是切换到其他程序继续工作,从而使得CPU一直处于忙碌状态
2. 这就引入了并发的概念,即多个程序同时出发、交替执行
1.4 进程的概念
1.4.1 保存程序执行状态
1. CPU在不同程序间切换时,仅修改PC指针是不够的,还需要保存程序切换时的状态
2. 以上图为例,如果只是修改PC指针,那么程序2中也会使用ax和bx寄存器,从而破坏了程序1切换时的状态。当CPU从程序2切换回程序1时,将无法正确执行
3. 因此,在并发执行时,需要一个数据结构,保存程序的执行状态。也就是说,这种程序 + 记录当前指向状态的结构是并发造成的必然结果
1.4.2 进程与PCB
1. 进程就是用来描述一个程序及其执行过程中的信息,即描述一个执行中的程序
2. 更具体地说,进程描述的是程序以及反映程序执行信息的数据结构的总和。而这个数据结构就是上文中提到的保存程序执行状态的数据结构,即进程控制块(Process Control Block,PCB)
说明1:从操作系统实现的角度看,一个PCB就表示了一个进程
说明2:进程概念的引入逻辑
① 操作系统要使用CPU,只要将程序加载到内存,并将PC设置为程序起始地址
② 如果只执行一个程序,CPU利用率太低。为了提高CPU利用率,CPU需要交替执行多个程序
③ 因为要交替执行多个程序,就需要记录程序的执行状态,从而引入了进程的概念
1.5 Linux 0.11 PCB实例
在Linux 0.11中,实现PCB功能的是task_struct结构(定义在include/linux/sched.h),其中保存着用于控制和管理进程的所有信息,其中各字段含义如下,
说明1:进程上下文的概念
① 当一个进程在执行时,CPU中所有寄存器的值、进程的状态以及栈中的内容(只要保存SS和SP寄存器,就实现了栈的保存)被称为该进程的上下文
② 当内核切换进程时,需要保存当前进程的所有状态,也就是保存当前进程的上下文,以便后续能够恢复切换时的状态继续执行
③ 在Linux中,当前进程上下文均保存在进程的任务数据结构中,也就是task_struct结构中
④ 当发生中断时,内核就在被中断进程的上下文中,并且在内核态执行中断服务程序。此时被中断进程的上下文也会被保存(一般保存在进程内核栈上),以便中断服务结束时能恢复被中断的进程
说明2:PCB可以理解为进程状态的快照
① 当前进程的状态体现在CPU内部的各个寄存器中(包括通用寄存器和系统寄存器)
② 当进程被切换走时,保存当前进程的执行状态,相当于给当前CPU内部的各个寄存器拍摄快照,并保存在PCB中
③ 对于被调度执行的进程,将该进程PCB中的快照恢复到CPU内部的各个寄存器中,从而恢复该进程的运行
④ 在Linux 0.11实际实现的task_struct中,除了进程状态快照,还保存了进程的状态与标识信息以及持有的资源信息
说明3:在Linux 0.11中,使用task数组管理进程,最多可以有64个进程
说明4:关于TSS段
① 在task_struct结构中,包含了tss_struct结构,该结构用于描述该进程的TSS段
② IA-32体系结构中定义的TSS段结构如下,可见tss_struct结构各字段与TSS段结构一致
③ IA-32体系结构提供的TSS段可以理解为一种软硬件结合的PCB结构,在进程切换的过程中,CPU可以自动将进程状态保存在对应的TSS段中
tips:Linux 0.11内核使用TSS段机制实现进程切换,后续Linux内核不再使用,而是使用纯软件的方式实现进程切换
2 多进程视图
2.1 用户视角
1. 用户为了解决问题,就需要运行一个程序,而运行程序就是在shell上启动进程
2. 用户可以通过ps命令或任务管理器感知操作系统中运行的进程
2.2 操作系统视角
1. 操作系统通过PCB感知与管理进程,使得进程合理有序地推进
2. 以上图为例,操作系统中共有3个进程,对应3个PCB结构,其中,
① 进程1(PCB1)因时间片耗尽,在执行到PC=53时被切换出去,切换时进程的状态保存在PCB1中
② 进程2(PCB2)是当前正在CPU上运行的进程
③ 进程3(PCB3)因等待磁盘操作而处于阻塞状态,切换时进程的状态保存在PCB3中
2.3 Linux 0.11多进程视图实例
多进程视图是与机器的启动相始终的,下面简要说明Linux 0.11中0号进程与1号进程的启动过程
2.3.1 0号进程
1. 初始化流程最后执行的main函数,最后将作为0号进程常驻运行
2. 在调用move_to_user_mode函数后,main函数的执行进入用户态(CPL = 3),并且在父进程中循环调用pause函数
3. 0号进程是系统中唯一一个静态创建的进程,他对应的PCB在系统中静态定义。而后续的其他进程,都通过fork系统调用生成
说明1:task_union布局
① 进程PCB与进程内核栈处于同一个page中,其中PCB在地址低端
② 0号进程的task_union是静态定义的,其他进程的task_union是动态分配的
说明2:current指针初值
① current指针用于指向当前正在CPU上执行的进程
② current指针的初值指向0号进程的task_struct结构
③ 当在0号进程中调用fork函数创建1号进程时,由于此时current指针指向0号进程,所以1号进程拷贝的模板就是0号进程
说明3:0号进程的内核栈
0号进程的内核栈[SS0 : ESP0]需要设置在TSS段,INIT_TASK中的值如下,
ESP0 = PAGE_SIZE + (long)&init_task,也就是task_union所在page的地址高端
SS0 = 0x10,也就是内核数据段
说明4:0号进程的用户栈
① 由于0号进程目前运行在用户态,因此当前[SS : ESP]指向的就是0号进程的用户栈
② TSS段中也有当前[SS : ESP]的字段,但是此时无需设置。因为当前的进程正在执行,当通过TSS段机制进行进程切换时,CPU会将当前[SS : ESP]的值保存在TSS段中
③ 那么当前[SS : ESP]指向栈在哪里呢?是在system模块的head.s中被设置为如下值,
而stack_start结构如下,
④ 这里需要注意的是,head.s中设置的栈属于内核数据段(DPL = 0),但是在main函数调用move_to_user_mode函数后,SS被切换为指向用户数据段(DPL = 3)。但是此时指向的栈仍然是stack_user,这是因为Linux 0.11中采用平坦模型,GDT中定义内核数据段和用户数据段均指向整个16MB物理地址空间
说明5:pause函数说明
① 首先需要注意的是,0号进程目前运行在用户态,是在用户态调用pause函数
② pause函数在main.c文件中被定义,是一个系统调用wrapper函数
③ pasue系统调用进入内核态后,会调用sys_pause函数,该函数将当前进程设置为TASK_INTERRUPTIBLE状态,之后调用schedule函数发起进程切换
2.3.2 1号进程
1. 1号进程被0号进程启动,在0号进程中会创建子进程,并且在子进程中调用init函数
2. 1号进程执行init函数,在启动会创建2号进程并在2号进程中启动shell
2.3.3 2号进程
1. 2号进程就是/bin/sh对应的shell
2. 用户后续运行程序,都是在shell上创建进程
3 多进程的组织与管理
3.1 进程状态
3.1.1 进程状态概述
进程状态可以用于描述一个进程在其执行过程中的演化过程,即进程的生存周期。操作系统中进程的主要状态包括,
1. 运行态:当前占有CPU,正在执行的进程状态
2. 就绪态:一个进程具备了所有可以执行的条件,只要获得CPU就能开始执行
3. 阻塞态:也称睡眠态或等待态,是指一个进程因为缺少某些条件,即使分配了CPU也无法执行的状态
4. 退出态:进程终止退出执行的状态
3.1.2 Linux 0.11进程状态实例
在include/linux/sched.h文件中定义了如下5种进程状态
1. 运行状态(TASK_RUNNING)
① 进程正在被CPU运行,或已经准备就绪随时可由调度程序运行
② 也就是说,操作系统理论课程中的就绪态和运行态,在Linux中均表示为TASK_RUNNING状态
2. 可中断睡眠状态(TASK_INTERRUPTIBLE)
① 当进程处于TASK_INTERRUPTIBLE状态时,操作系统不会调度该进程执行
② 当操作系统产生一个中断、释放了进程正在等待的资源、收到一个信号时,都可以唤醒该进程,使其进入TASK_RUNNING状态
3. 不可中断睡眠状态(TASK_UNINTERRUPTIBLE)
① 当进程处于TASK_UNINTERRUPTIBLE状态时,操作系统不会调度该进程执行
② 处于该状态的进程只有被wake_up函数明确唤醒时才能转换为TASK_RUNNING状态
4. 暂停状态(TASK_STOPPED)
① 当进程接收到SIGSTOP / SIGTSTP / SIGTTIN / SIGTTOUT信号时会进入暂停状态
② 可向进程发送SIGCONT信号,使其转换为TASK_RUNNING状态
③ 在Linux 0.11中未实现对暂停状态转换的处理,处于该状态的进程被作为进程终止来处理
5. 僵死状态(TASK_ZOMBIE)
当进程已停止运行,但其父进程还没有回收其状态时,则该进程处于僵死状态
说明1:进程状态保存在task_struct结构的state字段
说明2:进程状态切换图
图中区分了进程在用户态运行和在内核态运行
说明3:使进程进入可中断睡眠状态
① 调用sys_pause函数
② 调用interruptible_sleep_on函数
③ sys_waitpid函数等待的子进程未结束时,也会进入可中断睡眠状态
说明4:使进程进入不可中断睡眠状态
① 调用sleep_on函数可以使进程进入不可中断睡眠状态
② 新创建的进程也处于不可中断睡眠状态
说明5:使进程进入僵死状态
当调用exit系统调用终止进程时,会将被终止进程设置为僵死状态
说明6:父进程回收子进程状态
说明7:对暂停状态的处理
Linux 0.11中只有一处对暂停状态的处理,就是在上图的sys_waitpid函数中,会返回0x7f作为状态值(实际不会有进程处于暂停状态)
3.2 多进程的组织
3.2.1 进程队列
1. 操作系统依靠PCB感知和管理进程
2. 根据进程状态,可以将进程的PCB组织在不同的队列,如下图所示
3.2.2 Linux 0.11进程组织实例
3.2.2.1 当前进程
1. 在kernel/sched.c文件中定义了全局变量current,用于指向当前正在CPU上运行的进程
2. 由于Linux 0.11的运行环境为单核CPU,所以可以用全局变量的方式定义current指针。对于多核CPU,Linux中通过当前栈指针找到task_struct结构
因为stask_struct位于内核栈所在page的低端,因此将当前栈指针按8KB向下对齐,即可索引到当前进程的task_struct结构
tips:参考代码为Linux 2.4 + i386体系结构,内核栈为2页,共8KB
3.2.2.2 就绪队列
Linux 0.11中就绪的进程就保存在task数组中,并未单独建立队列。毕竟最多只有64个数组,调度时直接遍历整个task数组
3.2.2.3 阻塞队列(以块设备请求阻塞队列为例)
在kernel/blk_drv/ll_rw_blk.c中,当块设备的request结构耗尽时,会将当前进程阻塞在wait_for_request队列,其中wait_for_request指向阻塞队列中最后加入的进程
这里的重点是sleep_on函数,需要分析阻塞队列是如何组成的
下图很好地展示了slepp_on函数中指针的变化
这里的玄机是等待队列是依靠tmp变量隐式构成的,而不是显式的队列
说明1:wake_up函数分析
说明2:interruptible_sleep_on函数分析
4 多进程需要解决的问题
4.1 进程的调度与切换
4.1.1 进程切换时机
1. 多进程视图工作的核心是多个进程之间的来回切换,所以什么时候切换以及如何切换是多进程需要解决的基本问题
2. 进程切换的时机是CPU出现空闲时,例如当前进程需要等待资源或当前进程调用exit函数终止
4.1.2 Linux 0.11进程切换时机实例
进程切换通过调用schedule函数实现,该函数完成2个任务,
1. 寻址下一个执行的任务,也就是调度
2. 完成进程切换
下面就以schedule函数的调用点,来分析Linux 0.11中进程切换的时机
1. release函数(kernel/exit.c)
release函数主要被sys_waitpid函数调用,在父进程回收子进程状态并释放子进程资源后触发调度
2. do_exit函数(kernel/exit.c)
当进程调用exit函数终止时,触发调度
3. sys_waitpid / sys_pause / sleep_on / interruptible_sleep_on / tty_write
主动放弃CPU或需要等待资源
4. _system_call函数
系统调用返回前,根据当前进程状态和剩余时间片,决定是否需要触发调度
5. do_timer函数
当进程时间片耗尽时,触发调度
4.1.3 进程切换方式
进程切换的主要步骤是,
1. 将当前寄存器值保存在当前任务的PCB中
2. 将下一任务PCB中的状态恢复到寄存器中
实现该功能的伪代码如下,
说明:由于进程切换操作需要精准控制寄存器,所以需要使用汇编语言实现
4.1.4 Linux 0.11进程切换方式实例
Linux 0.11进程切换使用了IA-32体系结构提供的TSS段机制,详情见下文分析
4.2 进程间地址空间隔离
1. 在多进程情况下,会有多个进程同时在内存中,因此需要隔离不同进程的地址空间,以避免相互影响
2. 而实现进程间地址空间隔离的方法就是采用地址映射表(段式或页式),每个进程都持有自己的映射表,因此每个进程的地址空间是独立的
4.3 进程间通信与协作
1. 多个进程同时在内存中需要隔离相互之间的影响,但是有时也需要相互合作,此时就需要一个合适的进程间通信机制与合作机制
2. 典型的进程间协作示例就是"生产者-消费者"模型,进程间的协作才能确保进程按合理的顺序推进
5 实验:打印进程日志
5.1 任务目标
1. 记录进程在整个生命周期中的运行轨迹,记录每个进程的每次状态切换
2. 将记录的信息保存在/var/process.log文件中
3. 记录信息的格式为,
pid 进程状态 系统当前时间(jiffies值)
进程状态约定:
N:进程新建(New)
R:进程就绪(Running)
B:进程阻塞(Block)
E:进程退出(Exit)
5.2 打开日志文件
5.2.1 打开日志文件时机
1. 打开日志文件的时机应该尽可能早,以记录最全面的进程信息
2. 打开日志文件只能在文件系统挂载之后,而挂载文件系统是在1号进程执行的init函数中(调用setup函数)。而且init函数也是在调用setup函数之后打开标准输入 / 标准输出 / 标准错误三个文件
说明:setup函数分析
① setup是一个用户态的系统调用wrapper函数
② 进入内核态后调用的是sys_setup函数,该函数读取硬盘参数(包括分区表信息)并加载虚拟盘(如果存在的话)并安装根文件系统
5.2.2 修改流程
修改文件:init/main.c
在init函数中打开日志文件,此时是在进程1中打开日志文件
重新编译内核后运行虚拟机,可见进程日志文件已经生成
说明:打开/var/process.log文件的文件描述符为3
5.2.3 优化日志文件打开时机
修改文件:init/main.c
将挂载根文件系统以及打开stdio & 日志文件的操作均迁移到main函数(0号进程)通过move_to_user_mode函数切换到用户态之后,这样可以尽早记录系统中的进程状态
5.3 实现内核记录log函数
5.3.1 fprintk函数实现原理
1. 将进程状态写入日志文件,需要进行写入文件操作。在用户态写入文件是调用write系统调用封装函数,在内核态写入文件则是调用sys_write系统调用服务函数。sys_write函数参数如下,
2. 内核中已经有了printk函数,该函数可以将数据打印到串口
3. 内核用于记录log的fprintk函数需要结合printk和sys_write函数实现,其中从printk函数借鉴处理格式化字符串的方法,然后利用sys_write函数实现将数据写入文件
5.3.2 修改文件
1. 新增文件:kernel/fprintk.c
在该文件中实现fprintf文件
2. 修改文件:kernel/Makefile
将新增的fprink.c文件加入编译
3. 修改文件:include/linux/kernel.h
加入fprintk函数的声明
4. 修改文件:init/main.c
在该文件中调用一次fprintk函数,向日志文件中写入数据
注意:这里的操作是错误的,因为init是在用户态执行的进程,无法调用fprintk函数
5. 修改文件:kernel/fork.c
在创建进程前后打印进程状态
5.3.3 上机验证
经过上机验证,此时fprintk函数调用会出错了,且日志文件中,仅能记录4次进程创建信息。而在shell中执行命令,本身就应该创建新的进程,但是并未被记录
我们分析一下sys_write函数,可见在fprintk调用sys_write时存在文件描述符不存在的情况
我们增加打印信息进行调试,可见对于有些进程,可能不存在3号文件描述符,或者3号文件描述符并不指向日志文件的情况
猜测是在这些进程中存在关闭文件的操作,导致3号文件描述符被关闭,之后新建的3号文件描述符并不指向日志文件。我们首先在copy_process函数中增加打印信息,验证新创建进程的文件描述符3指向;同时打印新创建进程的父进程PID
从验证结果可以看出,从新建进程5开始,3号文件描述符要么为空,要么不指向日志文件(日志文件file结构体指针值为0x000226B0)
5.4 问题分析
5.4.1 2号进程的关闭
从上节验证结果分析,2号进程(之前分析为启动shell)会启动3号进程,但是4号进程却是1号进程(init进程)启动的,所以猜测2号进程已经退出。下面在init函数中增加打印信息进行验证
可见2号进程确实退出,后续的shell其实是由4号进程提供的(这也与在shell中执行命令时打印的进程创建关系相符)
5.4.2 3号文件描述符的修改
从上述实验log中可见,
1. 在1号进程创建4号进程时,1号进程的3号文件描述符还指向日志文件(0x00022750)
2. 在4号进程创建5号进程时,4号进程的3号文件描述符已经不指向日志文件(0x00022770)
也就是说,4号进程关闭了3号文件描述符,并且重新打开了另一个文件
我们在sys_close函数和sys_open函数中增加打印信息,进行验证。这里之所以限制打印的PID,是因为打印信息过多会导致虚拟机工作异常
由于打印显示范围所限,不能显示所有调试信息。但是可以看出,4号进程关闭了直到19号的文件描述符,也就是关闭了一个进程所能打开的所有文件描述符。因此日志文件被关闭,后续4号进程创建的3号文件描述符已不再指向日志文件
说明:关闭sys_open函数中添加的打印信息,可以更清晰的看到4号进程关闭了所有文件描述符
5.5 修改内核打印函数
5.5.1 修改方案1
后续在shell中启动的程序,均是由4号进程创建,而4号进程中已经关闭了指向日志文件的文件描述符。所以在fprintk函数中,改为从0号进程获取日志文件结构体
从实验结果看,虽然后续创建的进程可以将信息记录到日志文件中,但是记录的内容是错误的
5.5.2 修改方案2
参考网上的实验代码,使用内嵌汇编的方式调用file_write函数(与修改方案1只有这处区别)
经过验证,该方案是可行的
5.5.3 方案差异分析
上述2个方案的差异就在于调用file_write函数的方式,下面分析这2个方案中fprintk函数的反汇编结果
1. 方案2
2. 方案1
可见二者的核心差异,就是可运行的版本在调用file_write函数之前,将fs寄存器的值压栈,并将ds寄存器的值赋给fs。这步操作是非常关键的,因为logbuf数组在内核态定义,由ds寄存器指向;而file_write函数中通过fs寄存器获取要写入的数据,此时fs寄存器正指向用户态段选择子,因此无法正确打印