2023-06-11
原文作者:奇小葩 原文地址:https://blog.csdn.net/u012489236/category_10946851.html

在学习Linux进程管理的知识之前,我们来从操作系统的发展说起,在每个阶段的发展历程中,每一个新的技术都是解决当前的技术瓶颈。

  • 操作系统的进程管理的发展和解决什么问题
  • 早期分时系统的内核实现,以linux0.11源码为主

1. 我从哪里来

人工控制时代

在20世纪50年代中期,出现了人们历史上的第一代计算机,那个时候还需要程序员亲自手工操作穿孔的纸带卡片,当时的计算机一切数据和操作指令都是通过穿孔纸带输入的。 穿孔纸带利用一排孔表示一个字符,用穿孔或不穿孔表示1和0,来将指令和数据导入内存。

202306111303321121.png

当时,所有的程序都挨着排好队,等待管理员将其取出,换上下一个开始执行,久而久之,程序员也开始抱怨,排队十分钟,执行三秒钟,管理员切换也太慢了,时间都用在排队和切换上呢?

单道批处理

能不能让计算机自动完成程序切换,不需要手动切换呢?

202306111303366162.png

人工操作的速度比起计算机实在是慢太多了, 人机矛盾 日益凸显,人类决定对机器重新进行设计,并且开发了一个 控制程序 ,在它的指挥下,可以批量执行程序,自动实现切换,不用再需要人工介入了,效率提高了不少。这就是早期的单道批处理系统诞生了,提高了系统资源利用率和吞吐量。

多道批处理

所有进程一个一个排队执行,等待执行的队伍越来越长,但是突然其中一个A阻塞了,因为在内存中仅有一道程序,所以程序在运行中发出I/O请求后,CPU就在那一直等。

大家就觉得,你看这个程序一直在做输入输出,CPU一直空着,一时半会也用不到,这不是浪费吗?要不换一个程序上去执行一会?

202306111303381293.png

但是A程序执行的数据都在内存中存放着,如果切换到B程序,目前只有一个CPU,如何保证内存现场,待A输入输出回来后也能正常运行呢?

因为计算机资源的增加,此时有了同时将多个程序载入内存的能力,此时操作系统主要解决

  • 操作系统需要决定将哪些程序载入内存
  • 在多个隔离的程序之间进行切换
  • 操作系统中多了“进程”对象和进程管理API

所以 20世纪60年代中期,引入了多道程序的设计技术,形成多道批处理系统,进一步提高资源利用率和系统吞吐量,使程序更好运行系统软件。

202306111303394354.png

分时系统

但是没过多久,随着技术和需求的发展,此时要求支持多任务、多用户、多终端,新的问题有出现了。

有一天,所有的程序都在排队,等待操作系统调度,但是此时操作系统发现此时运行的程序有一个死循环,一直占用CPU,无法切换出去

对于这个问题,就产生了时间片的概念,把计算机的系统资源(尤其是 CPU时间)进行时间上的分割,每个时间段称为一个时间片,每个用户依次轮流使用时间片。

202306111303404095.png

2. 分时系统

主机以很短的时间片为单位,把CPU轮流分配给每个终端使用;直到所有作业被运行完。若某个作业在分配给它的时间片内未完成计算,则该作业暂停运行,把处理器让给其他作业使用,等待下一轮再继续使用。若终端数量不多,每个终端很快就能重新获得CPU,使得每个终端得到及时响应。

2.1 时间中断

说到时间片,那么就必须用到计算机的定时器功能,这个定时器,每当定时时间到了,就会向CPU发送一个中断信号。

202306111303423516.png

在linux0.11中,这个定时间隔被设置为10ms,也就是100Hz,其代码在include\sched.h

    #define NR_TASKS 64		// 系统中同时最多任务(进程)数。
    #define HZ 100			// 定义系统时钟滴答频率(1 百赫兹,每个滴答10ms)

也就是说每10ms硬件上通过定时器产生一次时钟中断,如果没有操作系统,这个10ms的一次时钟中断就没有任何的意义,CPU收到这个中断也不会做出任何的反映。但是,此时有了操作系统,操作系统提前设置好了中断向量表,在kernel/sched.c中有如下配置

    void sched_init (void)
    {
    	int i;
    	struct desc_struct *p;	// 描述符表结构指针。
       ...
      // 设置时钟中断处理程序句柄(设置时钟中断门)。
    	set_intr_gate (0x20, &timer_interrupt);
      // 修改中断控制器屏蔽码,允许时钟中断。
    	outb (inb_p (0x21) & ~0x01, 0x21);
      // 设置系统调用中断门。
    	set_system_gate (0x80, &system_call);
       ...
    }

这样,当时钟中断,也就是 0x20 号中断来临时,CPU 会查找中断向量表中 0x20 处的函数地址,这个函数地址即中断处理函数,并跳转过去执行。

    _timer_interrupt:
    	push ds ;// save ds,es and put kernel data space
    	push es ;// into them. %fs is used by _system_call
    	push fs
    	push edx ;// we save %eax,%ecx,%edx as gcc doesn't
    	push ecx ;// save those across function calls. %ebx
    	push ebx ;// is saved as we use that in ret_sys_call
    	push eax
    	mov eax,10h ;// ds,es 置为指向内核数据段。
    	mov ds,ax
    	mov es,ax
    	mov eax,17h ;// fs 置为指向局部数据段(出错程序的数据段)。
    	mov fs,ax
    	inc dword ptr _jiffies // 增加系统滴答数
    ;// 由于初始化中断控制芯片时没有采用自动EOI,所以这里需要发指令结束该硬件中断。
    	mov al,20h ;// EOI to interrupt controller ;//1
    	out 20h,al ;// 操作命令字OCW2 送0x20 端口。
    ;// 下面3 句从选择符中取出当前特权级别(0 或3)并压入堆栈,作为do_timer 的参数。
    	mov eax,dword ptr [R_CS+esp]
    	and eax,3 ;// %eax is CPL (0 or 3, 0=supervisor)
    	push eax
        do_timer(CPL)  ;//执行任务切换、计时等工作,在kernel/shched.c,305 行实现。
    	call _do_timer ;// 'do_timer(long CPL)' does everything from
    	add esp,4 ;// task switching to accounting ...
    	jmp ret_from_sys_call

这个函数主要做了以下几件事情,我们重点关注任务切换的函数

  • 保持中断前的现场,主要包括内核数据段等
  • 将系统滴答数这个变量_jiffies加1,
  • 调用do_timer,执行任务切换,计时等工作
  • ret_from_sys_call控制任务的切换与返回
     时钟中断C 函数处理程序,在kernel/system_call.s 中的_timer_interrupt(176 行)被调用。
    // 参数cpl 是当前特权级0 或3,0 表示内核代码在执行。
    // 对于一个进程由于执行时间片用完时,则进行任务切换。并执行一个计时更新工作。
    void do_timer (long cpl)
    {
    	extern int beepcount;		// 扬声器发声时间滴答数(kernel/chr_drv/console.c,697)
    	extern void sysbeepstop (void);	// 关闭扬声器(kernel/chr_drv/console.c,691)
    
      // 如果发声计数次数到,则关闭发声。(向0x61 口发送命令,复位位0 和1。位0 控制8253
      // 计数器2 的工作,位1 控制扬声器)。
    	if (beepcount)
    		if (!--beepcount)
    			sysbeepstop ();
    
      // 如果当前特权级(cpl)为0(最高,表示是内核程序在工作),则将超级用户运行时间stime 递增;
      // 如果cpl > 0,则表示是一般用户程序在工作,增加utime。
    	if (cpl)
    		current->utime++;
    	else
    		current->stime++;
    
    // 如果有用户的定时器存在,则将链表第1 个定时器的值减1。如果已等于0,则调用相应的处理
    // 程序,并将该处理程序指针置为空。然后去掉该项定时器。
    	if (next_timer)
    	{				// next_timer 是定时器链表的头指针(见270 行)。
    		next_timer->jiffies--;
    		while (next_timer && next_timer->jiffies <= 0)
    		{
    			void (*fn) ();	// 这里插入了一个函数指针定义!!!??
    
    			fn = next_timer->fn;
    			next_timer->fn = NULL;
    			next_timer = next_timer->next;
    			(fn) ();		// 调用处理函数。
    		}
    	}
    // 如果当前软盘控制器FDC 的数字输出寄存器中马达启动位有置位的,则执行软盘定时程序(245 行)。
    	if (current_DOR & 0xf0)
    		do_floppy_timer ();
    	if ((--current->counter) > 0)
    		return;			// 如果进程运行时间还没完,则退出。
    	current->counter = 0;
    	if (!cpl)
    		return;			// 对于超级用户程序,不依赖counter 值进行调度。
    	schedule ();
    }

这代码,非常简单,主要是完成以下两个内容

  • 如果时间片仍然大于零,则什么都不做直接返回,该程序继续执行
  • 如果时间片已经为0,则调用schedule,这就是进行进程调度的主要函数

2.2 进程调度

对于进程调度的函数,主要是通过schedule来完成,而对于linux0.11的进程调度算法也比较简单,其代码实现

    void schedule (void)
    {
    	int i, next, c;
    	struct task_struct **p;	// 任务结构指针的指针。
    
    /* 检测alarm(进程的报警定时值),唤醒任何已得到信号的可中断任务 */
    
    // 从任务数组中最后一个任务开始检测alarm。
    	for (p = &LAST_TASK; p > &FIRST_TASK; --p)
    		if (*p)
    		{
    // 如果任务的alarm 时间已经过期(alarm<jiffies),则在信号位图中置SIGALRM 信号,然后清alarm。
    // jiffies 是系统从开机开始算起的滴答数(10ms/滴答)。定义在sched.h 第139 行。
    			if ((*p)->alarm && (*p)->alarm < jiffies)
    			{
    				(*p)->signal |= (1 << (SIGALRM - 1));
    				(*p)->alarm = 0;
    			}
    // 如果信号位图中除被阻塞的信号外还有其它信号,并且任务处于可中断状态,则置任务为就绪状态。
    // 其中'~(_BLOCKABLE & (*p)->blocked)'用于忽略被阻塞的信号,但SIGKILL 和SIGSTOP 不能被阻塞。
    			if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
    					(*p)->state == TASK_INTERRUPTIBLE)
    				(*p)->state = TASK_RUNNING;	//置为就绪(可执行)状态。
    		}
    
      /* 这里是调度程序的主要部分 */
    
    	while (1)
    	{
    		c = -1;
    		next = 0;
    		i = NR_TASKS;
    		p = &task[NR_TASKS];
    // 这段代码也是从任务数组的最后一个任务开始循环处理,并跳过不含任务的数组槽。比较每个就绪
    // 状态任务的counter(任务运行时间的递减滴答计数)值,哪一个值大,运行时间还不长,next 就
    // 指向哪个的任务号。
    		while (--i)
    		{
    			if (!*--p)
    				continue;
    			if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
    				c = (*p)->counter, next = i;
    		}
          // 如果比较得出有counter 值大于0 的结果,则退出124 行开始的循环,执行任务切换(141 行)。
    		if (c)
    			break;
          // 否则就根据每个任务的优先权值,更新每一个任务的counter 值,然后回到125 行重新比较。
          // counter 值的计算方式为counter = counter /2 + priority。[右边counter=0??]
    		for (p = &LAST_TASK; p > &FIRST_TASK; --p)
    			if (*p)
    				(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
    	}
    	switch_to (next);		// 切换到任务号为next 的任务,并运行之。
    }

该接口主要包括两部分,我们重点关注调度的部分

  • 检测alarm(进程的报警定时值),唤醒任何已得到信号的可中断任务
  • 调度程序部分,其流程如下

202306111303436827.png

2.3 进程切换

进程切换主要是通过switch_to接口来实现的,其主要是干两件事

  • 跳转到新切换的进程进行执行
  • 保持当前进程的TSS信息,并将新进程的TSS加载到各个寄存器中

简单的说,即保持当前进程上下文,恢复下一个进程的上下文,其主要流程如下图,源于Linux内核完全注释V5.0

202306111303444628.png

至此,我们通过linux0.11完成了进程切换的整个链路的梳理,其过程如下

202306111303459829.png

3. 数据结构

对于内核,进程相关的调度都是通过struct task_struct来实现的,一个容量只有 64 大小的数组,数组中的元素是 task_struct 结构。

    struct task_struct *task[NR_TASKS]
    struct task_struct
    {
    /* these are hardcoded - don't touch */
    	long state;			/* -1 unrunnable, 0 runnable, >0 stopped */
    	long counter;
    	long priority;
    	long signal;
    	struct sigaction sigaction[32];
    	long blocked;			/* bitmap of masked signals */
    /* various fields */
    	int exit_code;
    	unsigned long start_code, end_code, end_data, brk, start_stack;
    	long pid, father, pgrp, session, leader;
    	unsigned short uid, euid, suid;
    	unsigned short gid, egid, sgid;
    	long alarm;
    	long utime, stime, cutime, cstime, start_time;
    	unsigned short used_math;
    /* file system info */
    	int tty;			/* -1 if no tty, so it must be signed */
    	unsigned short umask;
    	struct m_inode *pwd;
    	struct m_inode *root;
    	struct m_inode *executable;
    	unsigned long close_on_exec;
    	struct file *filp[NR_OPEN];
    /* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
    	struct desc_struct ldt[3];
    /* tss for this task */
    	struct tss_struct tss;
    };

4. 进程启动流程

我们就从这 main.c 开启我们的旅程,当然,我们只关注进程相关的部分

    void main(void) {
        ...
        // 第一步:进程调度初始化
        sched_init();
        ...
        // 第二步:创建一个新进程并做些事
        if (!fork()) {
            init();
        }
        // 第三步:死循环,操作系统正式启动完毕
        for(;;) pause();
    }
  • 第一步 是 sched_init 进程调度初始化,上面章节已经介绍过
  • 主要是最终执行到 shell 程序等待用户输入
  • 操作系统就是一个中断驱动的死循环代码

5. 总结

本文主要学习了操作系统进程管理的演变过程,以最早的linux0.11为例,从数据结构,时间片,来讲解了进程调度的一些细节。同时在操作系统的不断演变过程中,进程管理的细节也不断优化和演变,但是整个骨架和流程都是一样的。本章只是从总体的视角,来学习了进程管理的框架。

6. 参考资料

很久很久以前,有一台神奇的机器

操作系统就是一个“死循环”!

阅读全文