普通人的视角,进程就是正在运行着的程序。进程是计算机中正在运行的程序的实例,每个进程都有自己的独立内存空间和执行上下文,包括程序计数器、寄存器、栈等,进程的创建通过操作系统的调度器完成。当一个程序被执行时,操作系统会为它分配一块内存空间,并将程序加载到这块内存中。这样,该程序就成为一个独立的进程了。 进程之间是相互独立运行的,它们不会直接访问其他进程的内存空间。为了实现不同进程之间的通信,操作系统提供了各种机制,如管道、共享内存、消息队列等,每个进程都有自己的优先级和调度策略,操作系统根据这些信息来决定对哪个进程进行调度,以便公平地分配处理器时间,进程还可以拥有子进程,这是通过fork()函数实现的。子进程在创建后会复制父进程的代码和数据,并开始独立运行。 从程序员的视角来看,认知要复杂得多。进程是程序正在运行的一个实例。它由程序指令,和从文件、其它程序中读取的数据或系统用户的输入组成。它也是应用层运行、占据着内存、与内核时常交互的动态运行实体。 进程是由内核定义的抽象的实体,内核为进程分配用来执行程序的各项系统资源。 从内核 的层面来看,进程由用户内存空间和一系列内核数据结构组成。其中,用户内存空间包含了程序代码和代码使用的变量,内核数据结构用于维护进程的状态信息。这些记录在内核数据结构的信息有:进程标识号IDs、虚拟内存表、打开文件描述符表、信号传递及处理的相关信息、进程资源使用和限制、当前工作目录、环境变量、命令行等等大量的相关信息。 一、Linux进程1.1进程概论进程是执行中的程序,一个程序加载到内存中后就变为进程。进程=程序+执行。为了提高CPU利用率,人们想起将多个程序同时加载到计算机里,并发执行。进程让每个用户感觉自己独占CPU。 (1)进程模型 从物理内存的分配来看,每个进程占用一片内存空间。 在物理层面上,所有进程共用一个程序计数器。 从逻辑层面上看,每个进程有着自己的计数器,记录其下一条指令所在的位置。 从时间上看,每个进程都必须往前推进。 进程不一定必须终结。事实上,许多系统进程是不会终结的,除非强制终止或计算机关机。 对于操作系统来说,进程是其提供的一种抽象,目的是通过并发来提高系统利用率,同时还能缩短系统响应时间。 (2)多道编程的好处 人们发明进程是为了支持多道编程,而进行多道编程的目的则是提高计算机CPU的效率,或者说系统的吞吐量。 除了提高CPU利用率外,多道编程更大的好处是改善系统响应时间,即用户等待时间。多道编程带来的好处到底有多少与每个程序的性质、多道编程的度数、进程切换消耗等均有关系。但一般来说,只要度数适当,多道编程总是利大于弊。 (3)进程的产生与消亡 造成进程产生的主要事件有:
造成进程消亡的事件:
1.2进程的层次结构一个进程在执行过程中可以通过系统调用创建新的进程,这个新进程就称为子进程,而创建子进程的进程称为父进程。子进程又可以创建子进程,这样子子孙孙创建下去就形成了所谓的进程树。 Unix称这个进程树里所有的进程为一个进程组,进程组里面的进程分布在不同的层次上,从而形成一个层次架构。Windows没有进程组的概念,而是所有进程均地位平等。 (1)进程的状态 进程可以在CPU上执行,也可以处于挂起状态。挂起的原因有:
操作系统在进程调度时就只需要查看第二种类型的挂起,而非第一类自身原因造成的挂起。 进程分为3种状态:执行、阻塞和就绪。 进程:一个程序加载到内存后就变为进程,即进程=程序+执行。每个进程占用一片内存空间。因为一个进程的执行过程中只占用其生命周期的一部分来使用CPU,因而可以实现多道编程,从而提高CPU的效率。随进程数增加,也就是随着多道编程的度的增加,CPU利用率会逐步提升,直到某个临界点为止。 阻塞:阻塞操作即读写磁盘、收发数据包等,此时进程访问的数据尚未就绪,是直接返回还是等待就绪。 阻塞等待:进程进入阻塞态后,会交出CPU 繁忙(循环)等待:进程虽然没做什么有意义的事,但一直占用着CPU 挂起:将任务挂起的实质就是:
就绪:将任务改为就绪状态的实质就是将对应任务就绪表对应位设置为1并将任务控制块状态标志改为就绪状态 阻塞与挂起的区别:挂起是主动的,需要调用挂起函数来进行,若没有resume操作则此任务一直不会ready;而阻塞是因为资源被其他任务抢占而处于休眠态。两者的表现方式都是从就绪态里“清掉”,即对应标志位清零,只不过实现方式不一样。 进程的状态:进程既可以在CPU上执行,也可以处于挂起状态。挂起的原因分两种:1)由于执行到阻塞操作(如读写磁盘),此时需要等待结果后方能继续执行,故这种是由于自身原因的挂起;2)由于执行时间过长,为了公平,操作系统将其挂起。由此,进程可分为三种状态:执行态——阻塞态——就绪态 多道编程:在内存中同时存放几道相互独立的程序,使它们在管理程序控制下,相互穿插运行,两个或两个以上程序在计算机系统中同处于开始到结束之间的状态, 这些程序共享计算机系统资源。在多道程序环境下,当某个程序等待I/O操作时,CPU可以执行其他程序,从而大大提高CPU的利用率。对于一个单CPU系统来说,程序同时处于运行状态只是一种宏观上的概念,他们虽然都已经开始运行,但就微观而言,任意时刻,CPU上运行的程序只有一个。 进程管理的核心问题:公平和效率。 状态转换: ![]() 为何没有以下状态转换 阻塞->执行:阻塞进程即使给予CPU,也无法执行,因此操作系统在调度时并不会从阻塞队列里挑选。 就绪->阻塞:处于就绪状态的进程并没有执行,自然无法进入阻塞状态。 这里阐述的进程的3种典型状态并不是唯一的分类方式。事实上,许多商业操作系统的进程状态不止3个。不管是多少个,其目的都是便于操作系统管理进程。 (2)进程与地址空间 进程与地址空间演技丶主要内容是如何让多个进程空间共享一个物理内存。具体来说,就是高效、安全地让所有进程共享这片物理内存。 1.3进程管理(1)进程管理所需要的手段 当一个进程产生时,操作系统需要为其创建记录。操作系统用于维护进程记录的结构就是进程表或进程控制块(Process Control Block)。显然,不同的操作系统维护的进程记录不同。但一般来说,应当包括寄存器、程序计数器、状态字、栈指针、优先级、进程ID、信号、创建时间、所耗CPU时间、当前持有的各种句柄等。而采纳的数据结构主要是线性表、链表和结构体,当然也可能使用树和图结构。 (2)进程的创建过程
里一个最大的问题是,跳转指令是内核态指令,而在第5步时处理器状态已经被设置为用户态。硬件必须将第5步和第6步作为一个步骤一起完成。 进程创建在不同操作系统里方法也不一样:
Unix的创建进程要灵活一点,因为我们既可以自我复制,也可以启动新的程序。而自我复制在很多情况下是很有用的。而在Windows下,复制自我就要复杂一些了。而且共享数据只能通过参数传递来实现。 进程管理要处理的问题 进程管理的最大问题是资源分配。除了公平之外,还要考虑效率最优。每个进程分配同样的资源肯定不行,不如让部分人先富起来,给他们使用资源的优先权。 1.4线程虽然进程已经能提供进程级别的并行,但有时候,我们在一个进程里也有并行的需求。比如我们打开word这个进程,我们希望即使在这个进程中,也能同时处理多件事(比如输入文字和移动窗口是两个独立的并行事件)。这就要求我们在进程层面上进一步实现并发。 线程实现方式:线程管理既可由进程自己来(即用户态线程实现),也可以交给操作系统(即内核态线程实现)。现代操作系统的做法是将这两种方法结合起来:用户态的执行系统负责进程内部线程在非阻塞时的切换;内核态的操作系统负责阻塞线程的切换。 多线程资源共享:两个根本的问题是“通信(沟通)”和“同步(协调)”。 原子操作(atomic operation):指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch。 原语(primitive or atomic action ):是由若干条机器指令组成的,用于完成一定功能的一个过程,具有不可分割性。即原语的执行必须是连续的,在执行过程中不允许被中断。 线程通信
线程同步 同步:同步不是说每个线程在某段时间内一定要执行同样多的指令,而是指所有的线程按一定规则进行。 同步的目的:为了保证不管线程间的执行如何穿插,其运行结果都是正确的(保证结果的确定性)。 同步的手段:对线程间的穿插进行控制。 竞争(race):多个线程争相执行同一段代码(代码竞争)或访问同一资源(数据竞争)的现象。 临界区(critical section):可能造成竞争的共享代码段或资源。 互斥(mutual exclusion):不能有两个进程同时在临界区内,在互斥区外不能阻止另一个进程的运行。 优先级倒挂:高优先级的线程等待低优先级的线程。这会造成一个问题:假如此时CPU在高优先级线程手中,而它此时又需要循环等待(不是阻塞等待)低优先级线程,那么低优先级线程无法顺利获得CPU,进而造成高优先级线程始终处于循环等待阶段无法推进。这就叫高优先级线程被低优先级线程所阻塞 锁:一种同步措施,使得在任何时候只能有一个人进入临界区,以A和B喂鱼的例子来说,为了鱼不被饿死或胀死,那么一个人进入房间时需把门锁住:
假如程序先执行到A这边,此时还没有上锁,那么他先把门锁住,再进屋喂鱼。若此时B恰好来到,看到门上了锁,那他需要循环等待,直到锁变为打开状态,然后他再闭锁、进屋。 睡觉和叫醒(sleep&wakeup):在上面一个线程运行到lock()的位置时,假如一把锁已经被别人锁上,则必须一直繁忙等待直到锁打开才能继续执行。这就很低效。为此,我们采取另外的机制:睡觉和叫醒。就是如果锁被对方持有,你不用等待锁变为打开状态,而是睡觉去(释放CPU),锁打开后再来把你叫醒。 死锁:单纯采用睡觉和叫醒机制,可能产生死锁。以生产者-消费者例子为例,假定consumer先来,此时count==0,于是去睡觉,但假如在执行sleep语句前发生CPU切换,生产者开始运行,它生产一件商品后,给count加1,发现count为1,因此发出叫醒消费者的信号。但此时consumer还没有睡觉,故该信号没有任何效果,浪费了。而生产者一直运行到缓冲区满了之后也去睡觉了,此时CPU切换回消费者,而消费者执行的第一个操作就是睡觉。至此,两者都进入睡觉状态,系统死锁发生。 上述死锁发生的原因在于唤醒信号丢失,为此,我们希望用某种方法将发出的信号积累起来而不是丢掉。由此,产生了所谓的“信号量”的机制。 信号量(semaphore):既是通信原语,又是同步原语,还可作锁。本质上就是计数器,取值为当前累积的信号数量。它有两个基本操作: 1)down减法:减少信号量或等待(判断信号量取值是否≥1,如果是的话就减一;0的话即减无可减的时候就去睡觉) 2)up加法:增加信号量并唤醒(唤醒等待在该信号量上的线程) 二元信号量(binary semaphore):信号量取值限制为0或1,规则同上,此时其作用就是一把结合了睡觉与叫醒操作的锁。down操作就是获得锁:闭锁(值变为0),或等待开锁(锁在别人手里处于闭锁状态);up操作就是释放锁(值变为1)并唤醒该信号量上等待的线程。它比锁更灵活的地方在于:等在信号量上的线程不是繁忙等待,而是去睡觉,等另一个线程执行up操作来叫醒。 同样使用上面喂鱼的例子,将lock()换为down(),将unlock()换为up()即可。
假如程序先执行到A这边,此时信号量为1(还没有上锁),那么他先把信号量减为0(把门锁住),再进屋喂鱼。若此时B恰好来到,看到信号量为0(门上了锁),那他需要等待(去睡觉),直到A喂完鱼执行up()操作,此时会唤醒等待在信号量mutex上的B。 使用信号量的一个问题是:如果信号量太多,则需要非常小心地设计其操作的先后顺序,否则很容易发生死锁。为了改变这一状况解放人力,发明了所谓的“管程”的机制。 管程(monitor):将需要保护的代码放到begin monitor和end monitor之间,在任何时候只能有一个线程活跃在管程里面。这一点由编译器来完成。管程使用两种同步机制:锁 + 条件变量。条件变量是线程可在其上等待的东西,类似信号量,但没有down和up操作。 管程的中心思想是运行一个在管程里面睡觉的线程,但在睡觉前需要把进入管程的锁或信号量释放,否则在其睡觉后别的线程将无法进入管程(因为无法获得锁),就会造成死锁。
管程的问题是依赖于编译器,并且只能在单台计算机上发挥作用。为了在多机环境(或网络环境)下进行同步,引入了“消息传递”机制。 消息传递:通过同步双方互相收发消息来实现。有send和receive两个基本操作。同步需要的是阻塞调用:即如果一个线程执行receive操作,则该线程将被挂起,在收到消息后,才能转入就绪。同样使用上面的例子,生产者和消费者通过这样的消息传递进行同步,既不会死锁,也不会繁忙等待。而且,无需使用临界区等机制,还能跨计算机同步。因此,消息传递是目前使用非常广泛的线程同步机制。 其缺点是消息丢失和身份识别,以及效率。 栅栏:主要用于将一组线程强制停下,等栅栏去除后才能继续往前推进 【文章福利】小编推荐自己的Linux内核技术交流群:【865977150】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!! ![]() 二、进程调度2.1进程调度的定义在多进程并发的环境里,虽然从概念上看,有多个进程在同时执行,但在单CPU下,实际上在任何时刻只能由一个进程处理执行状态。 程序使用CPU的模式有3种:
对于不同性质的程序,调度所要达到的目的也不同:
2.2进程调度的目标CPU调度就是要达到:
对于不同的系统来说,在调度目标方面也有一些细微的不同:
先来先服务调度算法First Come First Serve 类似排队,先来先到的原则。缺点是短的工作由可能因为前面有很长的工作变得很慢。这样就造成用户的交互式体验比较差。 2.3时间片轮转算法时间片轮转算法是对FCFS算法的一种改进,其主要目的是改善短程序的相应时间,其方法就是周期性地进行进程切换。 系统响应时间依赖于时间片的选择。如果时间片过大,则越来越像FCFS。如果时间片过小,则进程切换所用的系统消耗将太多,从而降低系统效率,并造成浪费。 那如何选择一个合适的时间片呢?我们需要知道进行一次进程切换所用的系统消耗和我们能够承受的整个系统消耗,就可以得出合适的时间片。还需要考虑的一个因素是有多少进程在系统里运行。如果运行的进程多,时间片就需要短一些,不然用户的交互体验会很差。 2.4短任务优先算法Shorted Time to Completion First这种算法的核心是短任务的优先级比长任务的高。 短任务优先算法有两个变种:
事实上,在所有非抢占调度算法中,STCF算法的响应时间最优;而在所有抢占调度算法中,抢占式STCF的响应时间最优 缺点:
2.5优先级调度算法可以赋予重要的进程以高优先级以确保重要任务能够得到CPU时间。 缺点:
2.6混合调度算法将所有进程分成不同的大类,每个大类为一个优先级。如果两个进程处于同一个大类,则采用时间片轮转来执行。 2.7其他调度算法
调度异常之优先级倒挂(priority inversion):低优先级任务持有一个被高优先级认为所需要的共享资源,这样高优先级任务因资源缺乏处于受阻状态,一直到低优先级任务释放资源为止。如果高优先级进程在等待时不是阻塞等待而是循环等待,则低优先级进程无法争夺到CPU造成无法释放资源。进而高优先级进程无法获得资源而推进。 2.8倒挂的解决方案锁的实现 在线程同步中提到的各种同步机制虽然各不相同,但有个共同特点:即同步原语均是原子操作(即不会被打断)。任何同步原语在微指令级别上都有多个步骤,如何保证这些同步原语的原子性呢?其根本原因在于在硬件层面上已经提供了一些原子操作:中断禁止和启用(interrupt enable/disable),内存加载和存入(load/store),测试与设置(test&set)指令。
死锁应对 在Lab2中完成了内存布局,最重要的是mem_init()函数的功能。这个函数在内核刚开始运行时就被调用,对整个操作系统的内存管理系统进行一些初始化设置:创建开辟一个页表大小的空间作为页目录kern_pgdir,开辟一片内存空间用于保存npages个struct PageInfo,开辟一片内存空间用于保存NENV个struct Env,然后构建虚拟内存空间:将虚拟地址映射到物理地址(映射关系记录到kern_pgdir中)。我们用mem_init()函数大致回顾下现在已经建立好的东西:
可见,我们现在已经做好的是:建立起了虚拟地址空间(及其到物理地址的映射),在物理内存中有一个envs数组,可用来记录进程情况。 那么下面我们就要利用这个envs数组来构建进程管理系统了。 JOS的进程(环境)管理: JOS内核使用Env结构体来追踪用户进程(又称用户环境environment)。 一个Env结构体实际上是记录了某个进程运行时的上下文,它记录了进程类型,进程状态,该进程的虚拟空间的映射,切换进程时需记录的寄存器值(尤其是该进程的执行入口地址值)等。 在内核中定义了如下三个指针用于追踪并调控进程:
而struct Env的定义为:
构建用户进程的流程是这样的: 1)在内存中开辟空间创建envs(在mem_init()中已经完成)和env_free_list(在静态区),然后初始化; 2)然后调用env_create()创建一个新进程:从list上取下一个Env结构体用于代表新的进程(env_alloc()函数),设置这个进程的env_id、type、env_tf等值,然后为该用户进程分配页目录表并调用load_icode()函数加载程序代码到内存中(包括开辟物理内存并加载代码); 注: 在load_icode()中为进程分配页目录(page_alloc()一页空间用于构建进程页目录并初始化)是用于构建地址映射。进程虚拟地址是由进程代码自身携带的:在elf的ph->p_va中;但具体映射加载到哪段物理地址由操作系统确定。此外,还需要将e->env_tf.tf_eip设为elfh->e_entry,即设置该进程的执行入口地址。 3)加载完程序代码后,万事俱备,调用 env_run(e) 函数开始执行该进程。 上述流程对应的代码调用过程如下(运行用户进程前的调用过程):
在上面,系统运行到env_run()之后,用户进程会遇到int $0x30系统调用(即T_SYSCALL中断向量,这个系统调用是输出一个字符到命令台),试图开始中断之旅。 具体地说,系统在执行env_pop_tf()后进入用户态,并在用户程序user_hello中调用了 cprintf 打印输出,而cprintf最终要通过系统调用来实现也就是需要回到内核态,而此时系统还没有建立起从用户态到内核态的转变机制。 当CPU发现它无法处理这一系统调用中断时,就会抛出一个保护异常-->然而还是不知道如何处理保护异常,就进一步抛出“double fault exception”-->还是不知如何处理,终于屈服了,抛出一个“triple fault”。一般如果在实际中遇到这种异常,会导致系统重启。 注: kern和 lib目录下面都有 printf.c和syscall.c文件,kern目录下面的是内核专用的。而用户要cprintf输出,就要使用lib目录下面的printf.c中的函数,最后经由lib/syscall.c的sys_cputs(),最终通过该文件中的syscall()来实现输出。 看一下env_alloc()做了什么: 主要做了两件事:1)向进程表申请空闲的进程;2)对新申请的进程的struct Env(进程描述符)进行初始化,主要是段寄存器初始化。在env_create()中调用:env_alloc(&e, 0)。
从内核态进入用户态: 总的来说,在内核态与用户态间发生切换时,总需要恢复或保存进程的运行环境(即全部的寄存器值),这一信息是使用env的Trapframe结构来保存的。 每个进程env对应了一个成员是env_tf,这是一个struct Trapframe,用于保护CPU现场(在发生中断或进程切换时保存当前进程的寄存器值)。两种使用情景: 1)在创建新的进程时(上面的env_alloc()和load_icode()中),会为这个新创建的进程初始化设置对应的Trapframe结构体。等到执行env_run()时,会将当前进程的env_tf值pop到寄存器中,从而准备好执行用户进程。 2)在发生中断陷入内核时(int 0x30后),会先将当前进程的寄存器值按trapframe的结构压入内核堆栈,并将其拷贝到当前进程的env_tf中,那么之后恢复该进程时就能继续从之前中断的状态开始执行了。 结构体struct Trapframe定义在inc/trap.h中:
为一个新创建的进程初始化struct Trapeframe的主要工作是在env_alloc()中: 1. 将当前e->env_tf结构体清零(防止之前可能的历史进程留下的信息污染当前进程信息) 2. 为段寄存器设置恰当的初值,即设置e->env_tf.tf_ds、tf_es、tf_ss为GD_UD(即GDT中的user data segment selector);env_tf.tf_esp为USTACKTOP;env_tf.tf_cs为GD_UT(即user text segment selector) 然后在load_icode()中设置好进程的执行入口地址e->env_tf.tf_eip = elfh->e_entry。 ![]() 初始化好了进程的struct Trapframe,可以使我们在运行env_run()、试图从内核进入用户态时(无论是不是第一次进入),恰当地为用户进程布置好对应于这个进程的寄存器值(即恢复Trapeframe到寄存器中去)。注:如果是第一次从内核进入用户态的话,实际上我们的代码是人为制造假象,让系统以为第一个进程是原来就有的,它通过中断进入了内核,在内核处理完了相应的操作之后,才返回用户态的。 从即将要运行的env的env_tf中恢复其保存的寄存器值到寄存器中是在函数env_pop_tf()中完成的:
此,已经把env_tf中保存的值弹出到寄存器中去,从而做好了运行进程代码的准备。 在执行完iret指令后,回到用户态执行进程程序。一个新创建的进程的入口是定义在lib/entry.S中的。在该文件中,首先会进行一些设置,然后就会调用lib/libmain.c中的libmain()函数,使它能够初始化全局指针thisenv,让它指向当前用户环境的Env结构体。实际上lib/entry.S、libmain.c、umain.c被编译到同一个ELF文件中的.text代码段。 JOS的中断处理(概念简介): “保护性”控制转移包括如下两种方式: 中断(interrupt):由处理器外部事件导致,例如外设的I/O请求 异常(exception):由正在运行的进程自身导致,例如0作除数或非法的内存访问 所谓保护性控制转移,其实质就是:在实现处理器从用户态切换到内核态时,不让用户进程有任何机会影响到内核区代码或其他进程代码,从而保护内核。 其实现方法是这样的:当需要从用户态进入内核态时,不能由用户代码来决定能否进入内核代码区以及去到哪,而必须按照我们事先约定的规则,从若干个指定位置进入内核并执行相应的处理代码。通过这种方式,限制了用户代码的权力。 为使处理器能受保护地从用户态回到内核态,需用到如下两种机制:IDT (中断描述符表) 和 TSS (任务状态段)。 IDT(中断描述符表:interrupt descriptor table): IDT提供了进入内核的若干入口(x86上总共可有256个中断或异常入口点) IDTR+中断向量(interrupt vector) -->中断门(位于IDT中) -->中断处理程序。简言之,IDT就是用于寻址中断处理程序的。 但我们能否直接跳到中断处理程序入口呢?答案是不能,因为我们希望在处理完中断异常后,还能返回用户进程继续之前的运行进程。那么,这就引出了第二个关键工具——TSS,我们需要利用它来保留进程信息(其实就是CPU状态)。 TSS(任务状态段:task state segment): 用于存储一个任务的所有状态信息。cpu在处理进程切换时需要TSS来保存旧的处理器状态,以便在中断返回时恢复以前的进程。本质上是存放在静态区的一个结构体变量,在jos中用到的唯一功能是用于指定内核栈地址。 在用户模式下遇到异常/中断,会陷入内核并调用中断处理函数。但在进入内核后,不是立即进入处理函数,而是先布好退路:1)处理器将用户进程信息(用户进程的CS,EIP,SS,ESP,EFLAGS,以及一个可选错误码)压入内核栈,内核栈地址由TSS中的SS0,ESP0来指定; 2)处理器设置CS,EIP寄存器使其指向中断处理程序,并且设置ESP,SS寄存器指向新的堆栈。 这样,当执行完内核异常处理程序后,就可以从内核栈保存的这些信息中恢复用户进程。 注:尽管TSS非常大,并且还有很多其他的功能,但是JOS仅仅使用它来定义处理器从用户态转向内核态所采用的内核堆栈,由于JOS中的内核态指的就是特权级0,所以处理器用TSS中的ESP0,SS0字段来指明这个内核堆栈的位置,大小。 在这里首先总述一下中断控制流程:
从用户态进入内核态(第一部分:中断指令int T_SYSCALL之前): 在理解中断和系统调用这节时,要从用户态进入,追踪并理解在系统调用时中断和进程切换实现的机制。所有这些机制实现的背景在trap_init()和env_create()时就已经准备好了,它实际发生的阶段其实是位于进程运行阶段: env_run(&envs[0])。这个函数是不会返回的,它一直运行:从内核态进入用户态-执行用户代码-发生系统调用-中断发生-返回-进程终止。我们使用cprintf()这个例子来进行说明,首先追踪一下函数调用过程,看看从用户态程序是怎么产生系统调用的。 cprintf()的调用过程: ![]() 在用户程序中常见如下的函数调用过程:
上述的cprintf函数是在lib/printf.c中定义的:
在vcprintf中调用了vprintfmt函数,vprintfmt以putch作为它的一个参数(函数指针),putch定义在lib/printf.c中;vprintfmt是在lib/printfmt.c中定义的:
这个函数的主体部分过长,我没有写出来,用下面一个流程图来表示其逻辑: ![]() 总之,在这个函数中需要打印时就调用putch函数(在lib/printf.c中),而putch就进行系统调用:调用(lib/syscall.c中的)sys_cputs()-->syscall()。
经过以上一番折腾,系统进入中断(即执行汇编命令int T_SYSCALL)。OS 的中断处理过程开始执行。它根据中断号和参数调用内核的处理函数,也是sys_cputs来处理。 在int 0x30处打断点并查看当前寄存器的值,如下图: ![]() 在syscall()中,应用程序会把系统调用号以及系统调用的参数放到寄存器中,在切换到内核态之后再将它们放到内核堆栈上。系统调用号存放到 %eax 中,参数则存放在 %edx, %ecx, %ebx, %edi, 和 %esi 中。内核会把返回值送到 %eax中。问题:要是参数特别多,寄存器不够放怎么办呢? 这个系统调用对所有的应用程序都是一样的,区别仅在于传进去的中断向量值以及需要传递的应用程序的函数参数。 从用户态进入内核态(第二部分:中断指令int T_SYSCALL之后): 在上面的systcall()后,CPU执行int 0x30中断指令,根据在IDT中找到的中断函数地址,就直接跳往该中断函数。 由前述我们知道,IDTR+中断向量(interrupt vector) -->中断门(位于IDT中) -->中断处理程序。大致过程如下图所示: ![]() 我们前面提到过,中断和进程切换机制实现的背景在trap_init()和env_create()时就已经准备好了。
下面我们再详细进行一下说明: 在kern/trap.c中的函数void trap_init(){ }中,使用宏SETGATE(idt[T_SYSCALL], 0, GD_KT, t_syscall, 3)将中断向量T_SYSCALL与中断处理函数t_syscall的地址联系起来了。0表示这个是一个中断门;GD_KT是对应内核代码段的全局描述符号;3表示中断描述符优先级(用户级)。而t_syscall函数是已经在trapentry.S中定义好了的函数,那些函数会直到发生了中断才被运行(尽管它们是作为内核代码的一部分,早就被编译好了处在内核中)。 在kern/trap.c中trap_init()中调用了一个trap_init_percpu()函数:
在kern/trapentry.S中,使用宏TRAPHANDLER_NOEC(name, num)定义好了全局可见的trap处理函数。每个中断向量都有自己的一个处理函数,但它们都先执行一些统一的操作:包括把中断向量入栈,再跳往_alltraps处。 在执行完中断指令int 0x30后,执行的第一条指令就是在trapentry.S中用TRAPHANDLER_NOEC定义的指令:push 0x30。然后执行jmp _alltraps。 _alltraps处也是统一的一些操作:主要是把寄存器按照Trapframe结构的顺序入栈。 所以从用户态陷入内核后,中断和系统调用过程为: ![]() 你所实现的 _alltraps 应该:
Trapframe用于保存当前进程的寄存器值。无论是新建一个进程还是进程中断陷入内核时,都会设置当前进程的env_tf结构体,以便记录进程的当时状态。
我们在之前讲过,从内核态进入用户态分两种情况:1)第一种是启动系统后第一次进入用户态;2)第二种是某个进程发生中断,进入内核态执行中断处理程序后,又从内核态恢复到用户态。在这两种情况中,Trapframe的作用都是用于从内核进入用户态时为进程布置好寄存器。在情况1)中是做一些初始化工作,初始化第一个要运行进程的Trapeframe;而在情况2)中则是需要在发生中断时先保留好该进程的Trapeframe。 对于第一种情况:主要是在函数env_alloc()中对新建的进程的e->env_tf进行了初始化工作,在之后的env_pop_tf()函数中把该内存区域当作栈来使用; 对于第二张情况:主要是在t_syscall和_alltraps中将用户进程的寄存器值压入内核栈,注意在陷入内核之前已经由TSS中的SS0,ESP0来指定内核堆栈区。此时,curenv->env_tf还依然是之前的初始化值,之后,在trap()中需要将内核栈上的Trap_frame拷贝给curenv->env_tf从而记录陷入内核前进程的上下文。 回到我们的cprintf的例子,发生中断后,内核根据中断向量分发到对应的中断处理程序syscall处。执行:
实际上,用户代码是在lib/sys_cputs()中调用:lib/syscall(SYS_cputs, 0, (uint32_t)s, len, 0, 0, 0); ,然后在用户态的syscall()中发起中断,经一系列陷入内核操作指令后跳往执行kern/syscall(uint32_t syscallno, uint32_t a1, a2, a3, a4, a5),如下:
再执行sys_cputs(),而它又调用kern/cprintf()。 注:这里还有点混乱,有空再看一下 三、实时调度算法实时系统是一种必须提供时序可预测性的系统。实时系统必须考虑每个具体任务的相应时间必须符合要求,即每个任务在什么时间之前完成,而无须考虑如何降低整个系统的响应时间或吞吐率。 3.1EDF调度算法Earlist Deadline First最早截止的任务先做。动态地计算每个任务的截止时间并动态调节优先级。如果需要,还会对当前进程进行抢占。 EDF调度算法就是STCF算法变化来的。如果将STCF算法的任务所需执行时间变为截止时间,则抢占式STCF算法就是EDF调度算法。 虽然EDF算法在理论上是最优的,但动态计算截止时间和动态抢占CPU均要消耗系统资源。 3.2RMS调度算法Rate Monotonic Scheduling在进行调度前先计算出所有任务的优先级,然后按照计算出来的优先级进行调度,任务执行中间既不接收新的进程,也不进行优先级的调整或进行CPU抢占。 缺点是不灵活。 对于RMS算法来说,一个重要的任务是判断一个任务组能否调度。而这个判断并不是容易做的。具体来说,一个系统里所有任务的截止时间如果想都得到满足,则这些任务必须满足下面的条件: ![]() n为任务是数量,ci为第i个任务的执行时间,pi为第i个任务的释放周期。当n趋于无穷时,U=ln2。 根据上述公式,如果CPU利用率在ln2以下时,所有任务的截止时间均可满足。因为此时系统还剩下约30%的CPU时间。这个时间可以用来处理一些非实时任务。 RMS算法为静态最优算法。即如果任何静态优先级算法可以满足一组任务的截止时间,则RMS算法也必能满足。 3.3进程调度的过程
四、进程通信进程之间的交互称为进程间通信(Inter-Process Communication,IPC)。 4.1进程对白:管道、记名管道、套接字进程对白就是一个进程发出某种数据信息, 另外一方接收数据信息,而这些数据信息通过一片共享的存储空间进行传递。 管道 在这种方式下,一个进程向这片存储空间的一端写入信息,另一个进程从存储空间的另外一端读取信息。这看上去就像管道。管道所占的空间既可以是内存,也可以是磁盘。要创建一个管道,一个进程只需调用管道创建的系统调用即可。该系统调用所做的事情就是在某种存储介质上划出一片空间,赋给其中一个进程写的权利,另一个进程读的权利即可。 从根本上说,管道是一个线性字节数组,类似文件,可以使用文件读写的方式进行访问。但却不是文件。因为通过文件系统看不到管道的存在。管道可以设在内存里,而文件很少设在内存里(当然,有研究人员在研发基于内存的文件系统,但这个还不是主流)。 创建管道在壳命令行下和在程序里是不同的。
管道的一个重要特点是使用管道的两个进程之间必须存在某种关系。 记名管道 如果要在两个不相关的进程(如两个不同进程里面的进程)之间进行管道通信,则需要使用记名管道。顾名思义,命名管道是一个有名字的通信管道。记名管道与文件系统共享一个名字空间,即我们可以从文件系统中看到记名管道。也就是说,记名管道的名字不能与文件系统里的任何文件名重名。 一个进程创建一个记名管道后,另外一个进程可使用open来打开这个管道(无名管道则不能使用open操作),从而与另外一端进行交流。 记名管道的名称由两部分组成:计算机名和管道名,例如\\[主机名]\管道\[管道名]\。对于同一主机来讲,允许有多个同一命名管道的实例并且可以由不同的进程打开,但是不同的管道都有属于自己的管道缓冲区而且有自己的通信环境,互不影响。命名管道可以支持多个客户端连接一个服务器端。命名管道客户端 管道和记名管道虽然具有简单、无需特殊设计(指应用程序方面)就可以和另外一个进程进行通信的优点, 但其缺点也很明显。首先是管道和记名管道并不是所有操作系统都支持。主要支持管道通信方式的是UNIX和类UNIX(如Linux)的操作系统。这样,如果需要在其他操作系统上进行通信,管道机制就多半会力不从心了。其次,管道通信需要在相关的进程间进行(无名管道),或者需要知道按名字来打开(记名管道), 而这在某些时候会十分不便。 虫洞:套接字 套接字(socket)是另外一种可以用于进程间通信的机制。套接字首先在BSD操作系统中出现,随后几乎渗透到所有主流操作系统中。套接字的功能非常强大,可以支持不同层面、不同应用、跨网络的通信。使用套接字进行通信需要双方均创建一个套接字,其中一方作为服务器方,另外一方作为客户方。服务器方必须先创建一个服务区套接字,然后在该套接字上进行监听,等待远方的连接请求。欲与服务器通信的客户则创建 一个客户套接字,然后向服务区套接字发送连接请求。服务器套接字在收到连接请求后,将在服务器方机器上创建一个客户套接字,与远方的客户机上的客户套接字形成点到点的通信通道。之后,客户方和服务器方就可以通过send和recv命令在这个创建的套接字通道上进行交流了。 使用套接字进行通信稍微有点复杂,我们下面以一个网页浏览的例子对套接字这种通信方式予以说明。对于 一个网站来说,要想提供正常的网页浏览服务,其网站服务器需要首先创建一个服务区套接字,作为外界与本服务器的通信信道。为了使该信道为外人所知,我们通常将该服务区套接字与某公共主机的一个众所周知
进行套接字和端口绑定的语句里的socket.gethostname()用来将套接字向外界公开。如果将该语句的使用socket.gethostname()改为''或者'localhost或者某个具体的IP地址(如120.121.4.1),则该服务区套接字将被限制在本地机器上使用。 在创建了服务区套接字并将其向外界公开后,网站服务器就可以在该套接字上进行监听。
将端口上的等待队列长度限制为5,即超过5个的请求将被拒绝。 到这里,服务器方的设置就宣告结束。 对于客户方来说,如要访问上述网站,则需要点击该网站的网址。在点击网址后(我们这里假定该网站网址为http://www.sjtu.edu.cn),客户机上的网络浏览器进行若干步操作:
s.connect命令将向服务器http://www.sjtu.edu.cn在端口80打开的服务器套接字发送连接请求。而服务器端在接收到该连接请求后,将生成一个新的客户端套接字与该客户端套接字对接,从而建立一个套接字通信信道。
至此,套接字通信信道成功创建。客户端程序可以使用套接字s来发送请求、索取网页,而服务器端则使用套接字clientsocket进行发送和接收消息。 这里需要指出的是服务区套接字既不发送数据,也不接收数据(指不接收正常的用户数据,而不是连接请求数据),而仅仅生产出“客户”套接字。当其他(远方)的客户套接字发出一个连接请求时,我们就创建一个客户套接字。一旦创建客户套接字clientsocket,与客户的通信任务就交给了这个刚刚创建的客户套接字。而 套接字由于其功能强大而获得了很大发展,并出现了许多种类。不同的操作系统均支持或实现了某种套接字功能。例如按照传输媒介是否为本地,套接字可以分为本地(UNIX域)套接字和网域套接字。而网域套接字又按照其提供的数据传输特性分为几个大类,分别是:
套接字从某种程度上来说非常繁杂,各种操作系统对其处理并不完全一样。因此,如要了解某个特定套接字实现,读者需要查阅关于该套接字实现的具体手册或相关文档。 4.2进程电报:信号管道和套接字的缺点:
那么信号是什么呢?在计算机里,信号就是一个内核对象,或者说是一个内核数据结构。发送方将该数据结构的内容填好,并指明该信号的目标进程后,发出特定的软件中断。操作系统接收到特定的中断请求后,知道是有进程要发送信号,于是到特定的内核数据结构里查找信号接收方,并进行通知。接到通知的进程则对信号进行相应处理。 4.3进程旗语:信号量在计算机里,信号量实际上就是一个简单整数。一个进程在信号变为0或者1的情况下推进,并且将信号变为1或0来防止别的进程推进。当进程完成任务后,则将信号再改变为0或1,从而允许其他进程执行。需要注意的是,信号量不只是一种通信机制,更是一种同步机制。 4.4进程拥抱:共享内存共享内存就是两个进程共同拥有同一片内存。对于这片内存中的任何内容,二者均可以访问。要使用共享内存进行通信,一个进程首先需要创建一片内存空间专门作为通信用,而其他进程则将该片内存映射到自己的(虚拟)地址空间。这样,读写自己地址空间中对应共享内存的区域时,就是在和其他进程进行通信。 与管道的区别:
缺点:
这里需要注意的是,使用全局变量在同一个进程的进程间实现通信不称为共享内存。 4.5信件发送:消息队列与管道的区别:
4.6其他通信机制除了上面介绍的主流通信方式外,有些操作系统还提供了一些其所特有的通信机制,例如Windows支持的进程通信方式就有所谓的剪贴板(clipboard)、COM/DCOM、动态数据交换(DDE)、邮箱(mailslots);而Solaris则有所谓的Solaris门机制,让客户通过轻量级(16KB)系统调用使用服务器的服务。 虽然进程之间的通信机制繁多,且每种机制都有着自己的特性,但归根结底都来源于AT&T的UNIX V系统。该系统在1983年加入了对共享内存、信号量和消息队列的支持。而这三者就是众所周知的System VIPC(POSIX IPC也是源于该系统并成为当前IPC的标准)。因此,虽然不同操作系统的IPC机制可能不尽相同,但其基本原理则并无大的区别。如果需要了解具体操作系统的IPC机制的实现,读者可以阅读相关的操作系统内核教程。 五、进程实现原理(基于linux0.11内核)Linux内核是如何初始化操作系统,并开始运行第一个程序呢? ![]() 我们都知道,系统启动过程为:bootsect.s —>setup.s —>head.s。姑且不去讨论这些汇编源程序的功能,假设操作系统的pc指正已经运行到了head.s 处的部分代码,这里做下仔细的研究。 目标代码如下(linux/boot/head.s):
忽略其他细节问题,当head程序执行到第49行时,将跳转到135行代码出运行。在第135行代码处,便是head.s调用init中main函数的核心。回顾c函数与汇编之间相互调用的知识可知,内核栈中存在: ![]() 当代码继续跳转后,会进入198行代码,直到程序运行至ret,调用返回。此时,内核栈中指向main函数将弹栈,pc指正自动指向main函数地址入口出,从而开始执行main函数。其次,传入main函数中的三个参数均为0(初始化main函数,无需传参,因此这里默认为0)。 目标代码如下(linux/init/main.c):
main函数是系统创建的第一个进程,作为第一个进程,它有自己的堆栈段,数据段,代码段等。在进入用户模式下后,便调用了fork函数来创建新的子进程,创建完进程后,立马进入暂停态。而第二个进程将在init()函数下继续运行。init()函数中,便是用来调用linux的shell脚本程序,从而可以与用户进行交互。那么问题来了,fork是如何做到进程的创建的呢?系统如何让两个进程或多个进程并发的执行呢?进程实现的原理是什么? 进程实现的原理 一个最简单的想法,或者说是最初的想法便是让pc指针分时的进行地址跳转,这的确是最核心最正确的解决办法,可具体该怎样实现?在用户态下,用户进行函数调用,是需要把函数参数,函数地址,局部变量等进行压栈的。而函数调用其实本事就是个让pc指针变化的事。请看图: ![]() A函数运行一段时间后,必然会转入B函数,且调用Yield函数,在进行地址跳转之前,先将该程序下两个地址进行压栈。而一旦调Yield函数,做的操作便是,将A程序的用户栈和将被调用的B程序的用户栈进行切换。Yield的具体实现也正是这么做的,交换了两个线程控制块。由于现在esp指向了C函数的入口地址,操作系统便开始执行C函数了,同样一旦运行到D函数,再次调用Yield,进行用户栈切换,这时,当前esp=1000,当Yield函数调用结束,即ret返回,自动弹栈。PC=204,返回A程序执行。至此,两个线程便能交替的执行任务了。 ![]() 注意:Liunx内核中,并没有实现线程的机制,但为了阐述进程的实现原理,理解线程的实现原理是必要的。也正如我们在操作系统学到的那样: 线程是独立调度的基本单位,用户级线程没有进入系统内核,调用计算机资源,仅仅在用户态下运行即可。 进程之所以是资源分配的基本单位,除了要完成用户态栈的切换,还需要内核栈的切换,其次进程的创建fork函数是系统调用,它将通过中断进入系统,调度计算机的资源。 所以,通过上述总结,进程调度的实现原理就是在线程调度的基础上,加上了内核栈与用户栈的关联步骤,并且在内核态进行两个栈的切换,即PCB的切换。来分析代码咯! 目标代码(linux/kernel/sys_call.s):
在系统调用这章,详细讨论了sys_call_table的作用。今天我们便要研究透这段代码(这里插段自己学习的经验总结,遇到源码看不懂的问题,没关系,遇到知识瓶颈再正常不过了,不要花太多时间硬啃,可以放一放。随着你每天日积月累的总结,当时看不懂不代表现在看不懂,当再次回顾代码时,可以把新学的内容套用上,便能慢慢研究透源代码了)。 代码4-9行,进行了一系列的压栈操作,这便是所谓的对当前操作系统状态进行一个保存,记录当前进程运行的信息。内核栈如下: ![]() 小伙伴可能要问了,4-9行明明没有ss:sp、EFLAGS、ret的内容啊,怎么会出现在内核栈呢。还记得前文所述的进程与线程的区别嘛?进程是通过中断来完成创建的,因此在调用fork函数时,便自动产生软中断,一旦中断发生,便由硬件自动返程ss:sp、EFLAGS、ret的压栈。 ![]() 仔细看上图,ss:sp指向的便是进程在用户态下的用户栈地址。这也就实现了进程的内核栈与用户栈的关联。 注意:
切换到内核态后,便可以执行sys_fork函数了。同样在该目标代码中:
首先调用find-empty-process函数,具体代码不再阐述了,可以翻看linux内核剖析。具体位置在linux/kernel/fork.c下,该函数的主要作用为:last_pid与当前系统内的进程号进行比较,如果当前进程号=last_pid且当前任务处于运行状态,则last_pid++;直到找到一个可用的进程号,然后返回,此时eax存放的内容便是last_pid。 函数返回可能会发生两种情况:
目标代码(linux/kernel/fork.c):
代码请注意:p->tss.eax=0,作用是什么? copy_process函数所传入的参数对应为当前系统内核栈的参数,即nr=(%eax),ebp=(%ebp)….依此类推ss=(%ss)。该函数的主要作用是: 创建新的PCB,并且给该进程控制块分配新的物理内存 初始化PCB的内容,并且将父进程的内核栈信息全部复制给子进程的内核栈。 函数成功调用后,最终返回系统进程调用号。至此,子进程如果不出意外便算创建成功了。函数返回后,便又回到了sys_call的代码段,我们继续分析接下来的代码。 需要了解的是,子进程虽然被创建了,但目前处于就绪状态,它只是存在于内存的某处,并没有开始执行调度。因此,此时copy-process返回的便是子进程的pid,显然eax=pid且 !0。值得注意的是,现在这个状态还是父进程的运行态。它将执行一系列符合条件的进程调度操作,如果幸运的话,可能也不会执行调度,直接弹栈,中断返回。 目标代码如下(linux/init/main.c):
还记得操作系统初始化的这段代码嘛,这里便有一个fork调用,接着上面的分析,调用fork后,父进程的pid!=0,所以将跳过if语句往下执行,这里让父进程通过sys_pause强制暂停了。再次进入中断后,操作系统开始寻找内核里存在的就绪进程,这时候发现有一个新的就绪态的子进程在默默的等待,因此通过jne reschedule执行函数的调度。reschedule将接着继续调用schedule。 目标代码(linux/kernel/sched.c):
![]() 进入调度函数后,先忽略前述的某些优先级调度算法,直接转入switch_to函数,你会发现它是通过建立映象来完成内核栈的切换的。而内核栈切换是进程调度的核心的核心,switch_to代码的工作原理是通过TR寄存器来找到当前的tss段,当寄存器存放的内容发生改变时,便会导致TR指向的tss的内容写入CPU的所有寄存器当中,而将原来的寄存器的内容保存在原来的tss中。 代码分析:
执行长跳转ljmp,ljmp可分为两步:
switch_to(n)一旦完成切换,内核栈的内容便是子进程的内容了。还记得fork出p->tss.eax为什么等于0了嘛,此时切换的子进程tss.eax=0,那就意味着swicth_to后,cpu寄存器的eax=0,那么等中断返回后,!fork()条件就变成了真!还记得子进程是复制了父进程内核栈的信息了嘛,所以当子进程中断返回后,不就是返回到if后面一条语句执行吗!so,子进程便能在操作系统开始它自己的工作了。。。o(∩_∩)o |
|