程序是一个可执行的文件,而进程(process)是一个执行中的程序实例。
利用分时技术,在Linux操作系统上同时可以运行多个进程。
分时技术的基本原理:
把CPU的运行时间划分成一个个规定长度的时间片(time slice),
- 让每个进程在一个时间片内运行。
- 当进程的时间片用完时系统就利用调度程序切换到另一个进程去运行。
因此实际上对于具有单个CPU的机器来说某一时刻只能运行一个进程。但由于每个进程运行的时间片很短(例如15个系统滴答=150ms),所以表面看来好像所有进程在同时运行着。
对于Linux 0.11内核来讲,系统最多可有64个进程同时存在。
除了第一个进程是“手工”建立以外,其余的都是进程使用系统调用 fork
创建的新进程,
被创建的进程称为子进程(child process),创建者则称为父进程(parent process)。
内核程序使用进程标识号(process ID,pid)来标识每个进程。
进程的组成:
- 可执行的指令代码、
- 数据
- 堆栈区
进程中的代码和数据部分分别对应一个执行文件中的代码段、数据段。
在采用段式内存管理的架构中,数据段(data segment)通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
每个进程只能执行自己的代码和访问自己的数据及堆栈区。
进程之间的通信需要通过系统调用来进行。
对于只有一个CPU的系统,在某一时刻只能有一个进程在运行。内核通过调度程序分时调度执行运行各个进程。
Linux系统中,一个进程可以在内核态(kernel mode)或用户态(user mode)下执行,因此,Linux内核堆栈和用户堆栈是分开的。用户堆栈用于进程在用户态下临时保存调用函数的参数、局部变量等数据。内核堆栈则含有内核程序执行函数调用时的信息。
1. 任务数据结构
任务数据结构
内核程序通过进程表对进程进行管理,每个进程在进程表中占有一项。在Linux系统中,进程表项是一个 task_struct
任务结构指针。任务数据结构定义在头文件 sched.h
中。
有些书上称其为进程控制块(Process Control Block,PCB)或进程描述符(Process Descriptor,PD)。
其中保存着用于控制和管理进程的所有信息。主要包括:
- 进程当前运行的状态信息
- 信号
- 进程号
- 父进程号
- 运行时间累计值
- 正在使用的文件
- 本任务的局部描述符
- 任务状态段信息。
该结构每个字段的具体含义参见头文件 sched.h
。
当一个进程在执行时,
- CPU的所有寄存器中的值
- 进程的状态
- 堆栈中的内容
被称为该进程的上下文。
当内核需要切换(switch)至另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够恢复到切换时的状态执行下去。
在Linux中,当前进程上下文均保存在进程的任务数据结构中。
在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中断服务结束时能恢复被中断进程的执行。
上面那段话的意思应该就是中断发生时,内核在调用中断服务时会同时保存被中断进程的上下文,便于中断服务结束时让被中断进程继续执行下去。
2. 进程运行状态
一个进程在其生存期内,可处于一组不同的状态下,称为进程状态,如图2-6所示。
进程状态保存在进程任务结构的 state
字段中。
当进程正在等待系统中的资源而处于等待状态时,则称其处于睡眠等待状态。在Linux系统中,睡眠等待状态被分为可中断的和不可中断的等待状态。
图2-6 进程状态及转换关系
- 运行状态(TASK_RUNNING):当进程正在被CPU执行,或已经准备就绪随时可由调度程序执行,则称该进程为处于运行状态(running)。进程可以在内核态运行,也可以在用户态运行。当系统资源已经可用时,进程就被唤醒而进入准备运行状态,该状态称为就绪态。这些状态(图中中间一列)在内核中表示方法相同,都被称为处于 TASK_RUNNING 状态。
- 也就是说运行状态实际上包含了:
- 用户运行态
- 内核运行态
- 就绪态
- 也就是说运行状态实际上包含了:
- 可中断睡眠状态(TASK_INTERRUPTIBLE):当进程处于可中断睡眠状态时,系统不会调度该进程执行。当系统产生一个中断或者释放了进程正在等待的资源,或者进程收到一个信号,都可以唤醒进程,转换到就绪状态(运行状态)。
- 不可中断睡眠状态(TASK_UNINTERRUPTIBLE):与可中断睡眠状态类似。但处于该状态的进程只有被
wake_up()
函数明确唤醒时才能转换到可运行的就绪状态。
wake_up
函数的实现原理主要包括两个方面:管理等待队列和发送信号。
首先,wake_up
函数会遍历等待队列中的每个等待者节点。对于每个节点,它会检查等待者的状态以确定是否需要唤醒。如果等待者处于等待状态,则会从等待队列中移除该节点,并将其添加到活动队列中。这样,被唤醒的等待者将有机会被调度执行。
其次,wake_up
函数会向唤醒的等待者发送信号。这是通过调用内核中的__wake_up_common
函数来实现的。该函数会检查等待者的处理函数,并根据处理函数设置的信号类型发送信号。在唤醒过程中,等待者可能会被添加到活动队列中,然后通过调度器进行再次调度。
-
暂停状态(TASK_STOPPED):当进程收到信号 SIGSTOP、SIGTSTP、SIGTTIN 或 SIGTTOU 时就会进入暂停状态。可向其发送 SIGCONT 信号让进程转换到可运行状态。在Linux 0.11中,还未实现对该状态的转换处理。处于该状态的进程将被作为进程终止来处理。
-
僵死状态(TASK_ZOMBIE):当进程已停止运行,但其父进程还没有询问其状态时,称该进程处于僵死状态。
当一个进程的运行时间片用完,系统就会使用调度程序强制切换到其他的进程去执行。另外,如果进程在内核态执行时需要等待系统的某个资源,此时该进程就会调用 sleep_on()
或 interruptible_sleep_on()
自愿地放弃CPU的使用权,而让调度程序去执行其他进程。进程则进入睡眠状态(TASK_UNINTERRUPTIBLE或TASK_INTERRUPTIBLE)。
只有当进程从“内核运行态”转移到“睡眠状态”时,内核才会进行进程切换操作。在内核态下运行的进程不能被其他进程抢占,而且一个进程不能改变另一个进程的状态。为了避免进程切换时造成内核数据错误,内核在执行临界区代码时会禁止一切中断。
3. 进程初始化
当boot/目录中的引导程序把内核从磁盘上加载到内存中,并让系统进入保护模式下运行后,就开始执行系统初始化程序 init/main.c。
该程序首先确定如何分配使用系统物理内存,
然后调用内核各部分的初始化函数分别对:
- 内存管理、
- 中断处理、
- 块设备和字符设备、
- 进程管理以及
- 硬盘和软盘硬件
进行初始化处理。
在完成了这些操作之后,系统各部分已经处于可运行状态。此后程序把自己“手工”移动到任务0(进程0)中运行,并使用 fork()
调用首次创建出进程1。在进程1中程序将继续进行应用环境的初始化并执行 shell 登录程序。而原进程0则会在系统空闲时被调度执行,此时任务0仅执行 pause()
系统调用,并会再调用调度函数。
pause()
定义
使调用进程(或线程)进入休眠状态,直到传递信号为止,要么终止进程,要么调用信号捕获函数。
说明
pause() 会令目前的进程暂停(进入睡眠状态), 直到被信号(signal)所中断。
移动到任务0中执行”这个过程由宏 move_to_user_mode
(include/asm/system.h)完成。它把 main.c 程序执行流从内核态(特权级0)移动到了用户态(特权级3)的任务0中继续运行。在移动之前,系统在对调度程序的初始化过程(sched_init()
)中,首先对任务0的运行环境进行了设置。
这包括人工预先设置好任务0数据结构各字段的值(include/linux/sched.h)、在全局描述符表中添入任务0的任务状态段(TSS)描述符和局部描述符表(LDT)的段描述符,并把它们分别加载到任务寄存器 tr
和局部描述符表寄存器 ldtr
中。
需要强调的是,内核初始化是一个特殊过程,内核初始化代码也即是任务0的代码。从任务0数据结构中设置的初始数据可知,任务0的代码段和数据段基址是0,段限长是640KB。而内核代码段和数据段的基址是0,段限长是16MB,因此任务0的代码段和数据段分别包含在内核代码段和数据段中。内核初始化程序 main.c 就是任务0中的代码,只是在移动到任务0之前系统正以内核态特权级0运行着main.c程序。
宏 move_to_user_mode
的功能就是把运行特权级从内核态的0级变换到用户态的3级,但是仍然继续执行原来的代码指令流。
在移动到任务0的过程中,宏 move_to_user_mode
使用了中断返回指令造成特权级改变的方法。
该方法的主要思想是在堆栈中构筑中断返回指令需要的内容,把返回地址的段选择符设置成任务0代码段选择符,其特权级为3。此后执行中断返回指令 iret
时将导致系统CPU从特权级0跳转到外层的特权级3上运行。图2-7是特权级发生变化时中断返回堆栈结构示意图。
图2-7 特权级发生变化时中断返回堆栈结构示意图
宏 move_to_user_mode
首先往内核堆栈中压入任务0数据段选择符和内核堆栈指针。然后压入标志寄存器内容。最后压入任务0代码段选择符和执行中断返回后需要执行的下一条指令的偏移位置。该偏移位置是 iret
后的一条指令处。
当执行 iret
指令时,CPU把返回地址送入 CS:EIP
中,同时弹出堆栈中标志寄存器内容。
CS(Code Segment):代码段寄存器
EIP(Instruction Pointer):指令指针寄存器
由于CPU判断出目的代码段的特权级是3,与当前内核态的0级不同。于是CPU会把堆栈中的堆栈段选择符和堆栈指针弹出到 SS:ESP
中。
SS 和 ESP 栈相关寄存器,SS:ESP 构成栈顶的地址
堆栈段寄存器SS(Stack Segment)
ESP(Extended Stack Pointer)为扩展栈指针寄存器
由于特权级发生了变化,段寄存器 DS、ES、FS 和 GS 的值变得无效,此时CPU会把这些段寄存器清零。因此在执行了 iret
指令后需要重新加载这些段寄存器。
此后,系统就开始以特权级3运行在任务0的代码上。所使用的用户态堆栈还是原来在移动之前使用的堆栈。而其内核态堆栈则被指定为其任务数据结构所在页面的顶端开始(PAGE_SIZE+(long)&init_task
)。由于以后在创建新进程时,需要复制任务0的任务数据结构,包括其用户堆栈指针,因此要求任务0的用户态堆栈在创建任务1(进程1)之前保持“干净”状态。
4. 创建新进程
Linux系统中创建新进程使用 fork()
系统调用。所有进程都是通过复制 进程0 而得到的,都是 进程0 的子进程。
在创建新进程的过程中,
- 系统首先在任务数组中找出一个还没有被任何进程使用的空项(空槽)。
- 如果系统已经有64个进程在运行,则 fork() 系统调用会因为任务数组表中没有可用空项而出错返回。
- 然后系统为新建进程在主内存区中申请一页内存来存放其任务数据结构信息,并复制当前进程任务数据结构中的所有内容作为新进程任务数据结构的模板。
- 为了防止这个还未处理完成的新建进程被调度函数执行,此时应该立刻将新进程状态置为不可中断的睡眠状态(TASK_UNINTERRUPTIBLE)。
随后对复制的任务数据结构进行修改。
- 把当前进程设置为新进程的父进程,清除信号位图并复位新进程各统计值,并设置初始运行时间片值为15个系统滴答数(150ms)。
- 接着根据当前进程设置任务状态段(TSS)中各寄存器的值。
- 由于创建进程时新进程返回值应为0,所以需要设置
tss.eax=0
。 - 新建进程内核态堆栈指针
tss.esp0
被设置成新进程任务数据结构所在内存页面的顶端, - 而堆栈段
tss.ss0
被设置成内核数据段选择符。 tss.ldt
被设置为局部表描述符在GDT中的索引值。- 如果当前进程使用了协处理器,则还需要把协处理器的完整状态保存到新进程的
tss.i387
结构中。
- 由于创建进程时新进程返回值应为0,所以需要设置
此后系统设置新任务的代码和数据段基址、限长并复制当前进程内存分页管理的页表。如果父进程中有文件是打开的,则应将对应文件的打开次数增1。接着在GDT中设置新任务的TSS和LDT描述符项,其中基地址信息指向新进程任务结构中的 tss
和 ldt
。最后再将新任务设置成可运行状态并返回新进程号。
5. 进程调度
由前面描述可知,Linux进程是抢占式的。被抢占的进程仍然处于TASK_RUNNING状态,只是暂时没有被CPU运行。进程的抢占发生在进程处于用户态执行阶段,在内核态执行时是不能被抢占的。
为了能让进程有效地使用系统资源,又能使进程有较快的响应时间,就需要对进程的切换调度采用一定的调度策略。在Linux 0.11中采用了基于优先级排队的调度策略。
5.1 调度程序
schedule() 函数首先扫描任务数组。通过比较每个就绪态(TASK RUNNING)任务的运行时间递减滴答计数 counter
的值来确定当前哪个进程运行的时间最少。哪一个的值大,就表示运行时间还不长,于是就选中该进程,并使用任务切换宏函数切换到该进程运行。
如果此时所有处于 TASK_RUNNING 状态进程的时间片都已经用完,系统就会根据每个进程的优先权值 priority,对系统中所有进程(包括正在睡眠的进程)重新计算每个任务需要运行的时间片值 counter
。
计算的公式是:
然后 schdeule()
函数重新扫描任务数组中所有处于 TASK_RUNNING 状态的进程,重复上述过程,直到选择出一个进程为止。最后调用 switch_to()
执行实际的进程切换操作。
如果此时没有其他进程可运行,系统就会选择进程0运行。对于Linux 0.11来说,进程0会调用 pause()
把自己置为可中断的睡眠状态并再次调用 schedule()
。不过在调度进程运行时,schedule()
并不在意进程0处于什么状态。只要系统空闲就调度进程0运行。
5.2 进程切换
执行实际进程切换的任务由 switch_to()
宏定义的一段汇编代码完成。
- 在进行切换之前,
switch_to()
首先检查要切换到的进程是否为当前进程,- 如果是则什么也不做,直接退出。
- 否则就首先把内核全局变量
current
置为新任务的指针,- 然后长跳转到新任务的任务状态段TSS组成的地址处,造成CPU执行任务切换操作。
- 此时CPU会把其所有寄存器的状态保存到当前任务寄存器TR中TSS段选择符所指向的当前进程任务数据结构的tss结构中,
- 然后把新任务状态段选择符所指向的新任务数据结构中tss结构中的寄存器信息恢复到CPU中,系统就正式开始运行新切换的任务了。
这个过程如图2-8所示。
图2-8 任务切换操作示意图
6. 终止进程
当一个进程结束了运行或在半途终止了运行,那么内核就需要释放该进程所占用的系统资源。这包括进程运行时打开的文件、申请的内存等。
当一个用户程序调用 exit()
系统调用时,就会执行内核函数 do_exit()
。
- 该函数会首先释放进程代码段和数据段占用的内存页面,关闭进程打开着的所有文件,对进程使用的当前工作目录、根目录和运行程序的 i 节点进行同步操作。
- 如果进程有子进程,则让
init
进程作为其所有子进程的父进程。 - 如果进程是一个会话头进程并且有控制终端,则释放控制终端,并向属于该会话的所有进程发送挂断信号 SIGHUP,这通常会终止该会话中的所有进程。
- 然后把进程状态置为僵死状态(TASK_ZOMBIE)。并向其原父进程发送 SIGCHLD 信号,通知其某个子进程已经终止。
- 最后
do_exit()
调用调度函数去执行其他进程。
由此可见在进程被终止时,它的任务数据结构仍然保留着。因为其父进程还需要使用其中的信息。
在子进程在执行期间,父进程通常使用 wait()
或 waitpid()
函数等待其某个子进程终止。当等待的子进程被终止并处于僵死状态时,父进程就会把子进程运行所使用的时间累加到自己进程中。最终释放已终止子进程任务数据结构所占用的内存页面,并置空子进程在任务数组中占用的指针项。
这一节读起来很痛苦各种前置没学过或忘记了,总是要停下来查资料导致拖了很久才看完。说实话还是有地方没有完全理解。
标签:状态,堆栈,任务,内核,Linux,进程,运行,2.4 From: https://www.cnblogs.com/Larcvz/p/18207092