分享

深入理解Linux进程原理及实现机制

 深度Linux 2023-11-14 发布于湖南

普通人的视角,进程就是正在运行着的程序。进程是计算机中正在运行的程序的实例,每个进程都有自己的独立内存空间和执行上下文,包括程序计数器、寄存器、栈等,进程的创建通过操作系统的调度器完成。当一个程序被执行时,操作系统会为它分配一块内存空间,并将程序加载到这块内存中。这样,该程序就成为一个独立的进程了。

进程之间是相互独立运行的,它们不会直接访问其他进程的内存空间。为了实现不同进程之间的通信,操作系统提供了各种机制,如管道、共享内存、消息队列等,每个进程都有自己的优先级和调度策略,操作系统根据这些信息来决定对哪个进程进行调度,以便公平地分配处理器时间,进程还可以拥有子进程,这是通过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)将当前任务控制块的状态标志改为挂起状态

  • 2)将任务就绪表中的对应位置零

  • 3)如果是事件类型,则还会将任务加入到当前的事件等待任务列表中(和就绪表一样的位图)

  • 4)执行任务调度,让出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)进程的创建过程

  1. 分配进程控制块

  2. 初始化机器寄存器

  3. 初始化页表

  4. 将程序代码从磁盘读进内存

  5. 将处理器状态设置为用户态

  6. 跳转到程序的起始地址(设置程序计数器)

里一个最大的问题是,跳转指令是内核态指令,而在第5步时处理器状态已经被设置为用户态。硬件必须将第5步和第6步作为一个步骤一起完成。

进程创建在不同操作系统里方法也不一样:

  • Unix:fork创建一个与自己完全一样的新进程;exec将新进程的地址空间用另一个程序的内容覆盖,然后跳转到新程序的起始地址,从而完成新程序的启动。

  • Windows:使用一个系统调用CreateProcess就可以完成进程创建。把欲执行的程序名称作为参数传过来,创建新的页表,而不需要复制别的进程。

Unix的创建进程要灵活一点,因为我们既可以自我复制,也可以启动新的程序。而自我复制在很多情况下是很有用的。而在Windows下,复制自我就要复杂一些了。而且共享数据只能通过参数传递来实现。

进程管理要处理的问题

进程管理的最大问题是资源分配。除了公平之外,还要考虑效率最优。每个进程分配同样的资源肯定不行,不如让部分人先富起来,给他们使用资源的优先权。

1.4线程

虽然进程已经能提供进程级别的并行,但有时候,我们在一个进程里也有并行的需求。比如我们打开word这个进程,我们希望即使在这个进程中,也能同时处理多件事(比如输入文字和移动窗口是两个独立的并行事件)。这就要求我们在进程层面上进一步实现并发。

线程实现方式:线程管理既可由进程自己来(即用户态线程实现),也可以交给操作系统(即内核态线程实现)。现代操作系统的做法是将这两种方法结合起来:用户态的执行系统负责进程内部线程在非阻塞时的切换;内核态的操作系统负责阻塞线程的切换。

多线程资源共享:两个根本的问题是“通信(沟通)”和“同步(协调)”。

原子操作(atomic operation):指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch。

原语(primitive or atomic action ):是由若干条机器指令组成的,用于完成一定功能的一个过程,具有不可分割性。即原语的执行必须是连续的,在执行过程中不允许被中断。

线程通信

  • 管道:类似文件但不能在文件系统中看到,是个线性字节数组,可设在内存里。

  • 记名管道:在两个不同进程的线程间通信,与文件系统共享一个名字空间,即可以从文件系统看到。

  • 套接字(socket):用于进程间通信,服务方先创建服务区套接字,然后进行监听等待连接请求;客户则创建客户套接字,然后向服务区套接字发送连接请求。服务器套接字接到请求后,在服务器上新建一个客户端套接字与远方的客户套接字对接,从而形成点对点通信。例子:网页浏览。

  • 信号(signal):信号即一个内核数据结构(类似电报)。发送方将该数据结构的内容填好,指定信号的目标进程,发送软件中断。操作系统接收中断后知道是有进程要发信号,于是到特定的内核数据结构里查找信号接收方并进行通知。

  • 信号量(semaphore):即一个整数,可起到锁的作用。一个进程在信号变为1的情况下推进,并且将信号变为0来防止别的进程推进。它既可作通信机制,也可作同步机制。

  • 消息队列:只在内存中实现,新消息放在队尾,从头部读消息。有点类似管道,但不是点对点的,而是多对多的。即同时支持多个进程的读写。

线程同步

同步:同步不是说每个线程在某段时间内一定要执行同样多的指令,而是指所有的线程按一定规则进行。

同步的目的:为了保证不管线程间的执行如何穿插,其运行结果都是正确的(保证结果的确定性)。

同步的手段:对线程间的穿插进行控制。

竞争(race):多个线程争相执行同一段代码(代码竞争)或访问同一资源(数据竞争)的现象。

临界区(critical section):可能造成竞争的共享代码段或资源。

互斥(mutual exclusion):不能有两个进程同时在临界区内,在互斥区外不能阻止另一个进程的运行。

优先级倒挂:高优先级的线程等待低优先级的线程。这会造成一个问题:假如此时CPU在高优先级线程手中,而它此时又需要循环等待(不是阻塞等待)低优先级线程,那么低优先级线程无法顺利获得CPU,进而造成高优先级线程始终处于循环等待阶段无法推进。这就叫高优先级线程被低优先级线程所阻塞

锁:一种同步措施,使得在任何时候只能有一个人进入临界区,以A和B喂鱼的例子来说,为了鱼不被饿死或胀死,那么一个人进入房间时需把门锁住:

A:                                    B:
lock() lock()
if(noFeed){ if(noFeed){
feed fish feed fish
} }
unlock() unlock()

假如程序先执行到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()即可。

typedef int semaphore
semaphore mutex = 1 /* 互斥信号量 */
A: B:
down(&mutex) down(&mutex)
if(noFeed){ if(noFeed){
feed fish feed fish
} }
up(&mutex) up(&mutex)

假如程序先执行到A这边,此时信号量为1(还没有上锁),那么他先把信号量减为0(把门锁住),再进屋喂鱼。若此时B恰好来到,看到信号量为0(门上了锁),那他需要等待(去睡觉),直到A喂完鱼执行up()操作,此时会唤醒等待在信号量mutex上的B。

使用信号量的一个问题是:如果信号量太多,则需要非常小心地设计其操作的先后顺序,否则很容易发生死锁。为了改变这一状况解放人力,发明了所谓的“管程”的机制。

管程(monitor):将需要保护的代码放到begin monitor和end monitor之间,在任何时候只能有一个线程活跃在管程里面。这一点由编译器来完成。管程使用两种同步机制:锁 + 条件变量。条件变量是线程可在其上等待的东西,类似信号量,但没有down和up操作。

管程的中心思想是运行一个在管程里面睡觉的线程,但在睡觉前需要把进入管程的锁或信号量释放,否则在其睡觉后别的线程将无法进入管程(因为无法获得锁),就会造成死锁。

begin monitor
integer i;
condition c;
procedure producer();
...
end;

procedure consumer();
...
end;
end monitor

管程的问题是依赖于编译器,并且只能在单台计算机上发挥作用。为了在多机环境(或网络环境)下进行同步,引入了“消息传递”机制。

消息传递:通过同步双方互相收发消息来实现。有send和receive两个基本操作。同步需要的是阻塞调用:即如果一个线程执行receive操作,则该线程将被挂起,在收到消息后,才能转入就绪。同样使用上面的例子,生产者和消费者通过这样的消息传递进行同步,既不会死锁,也不会繁忙等待。而且,无需使用临界区等机制,还能跨计算机同步。因此,消息传递是目前使用非常广泛的线程同步机制。

其缺点是消息丢失和身份识别,以及效率。

栅栏:主要用于将一组线程强制停下,等栅栏去除后才能继续往前推进

【文章福利】小编推荐自己的Linux内核技术交流群:【865977150】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!

二、进程调度

2.1进程调度的定义

在多进程并发的环境里,虽然从概念上看,有多个进程在同时执行,但在单CPU下,实际上在任何时刻只能由一个进程处理执行状态。

程序使用CPU的模式有3种:

  • 程序大部分时间在CPU上执行,称为CPU导向或计算密集型程序。计算密集型程序通常是科学计算方面的程序。

  • 程序大部分时间在进行输入输出,称为I/O导向或输入输出密集型程序。一般来说,人机交互式程序均属于这类程序。

  • 介于前两种之间,称为平衡型程序。例如,网络浏览或下载、网络视频。

对于不同性质的程序,调度所要达到的目的也不同:

  • CPU导向的程序:周转时间turnaround比较重要

  • I/O导向的程序:响应时间非常重要

  • 平衡型程序:两者之间的平衡

2.2进程调度的目标

CPU调度就是要达到:

  • 极小化平均响应时间:极小化用户发出命令和看到结果之间所花费的时间,即减少做一件工作平均等待的时间

  • 极大化系统吞吐率:在单位时间内完成尽可能多的程序

  • 保持系统各个功能部件均处于繁忙状态:闲置即浪费

  • 提供某种貌似公平的机制

对于不同的系统来说,在调度目标方面也有一些细微的不同:

  • 对于批处理系统,响应时间不太重要,但系统吞吐率、CPU利用率和周转时间则很重要

  • 对于交互式系统来说,响应时间比较重要,但这里要注意适度性。适度性就是响应时间要和期望值匹配,不要超越用户的期望。这是因为,提供超出用户期望的相应会增加系统设计的难度,而又不会提高用户的满意度。

  • 对于实时系统来说,在截止时间前完成所应该完成的任务和提供性能可预测性。

先来先服务调度算法First Come First Serve

类似排队,先来先到的原则。缺点是短的工作由可能因为前面有很长的工作变得很慢。这样就造成用户的交互式体验比较差。

2.3时间片轮转算法

时间片轮转算法是对FCFS算法的一种改进,其主要目的是改善短程序的相应时间,其方法就是周期性地进行进程切换。

系统响应时间依赖于时间片的选择。如果时间片过大,则越来越像FCFS。如果时间片过小,则进程切换所用的系统消耗将太多,从而降低系统效率,并造成浪费。

那如何选择一个合适的时间片呢?我们需要知道进行一次进程切换所用的系统消耗和我们能够承受的整个系统消耗,就可以得出合适的时间片。还需要考虑的一个因素是有多少进程在系统里运行。如果运行的进程多,时间片就需要短一些,不然用户的交互体验会很差。

2.4短任务优先算法Shorted Time to Completion First

这种算法的核心是短任务的优先级比长任务的高。

短任务优先算法有两个变种:

  • 非抢占:让已经在CPU上运行的程序执行到结束或阻塞,然后在所有候选的程序中选择执行时间最短的进程。

  • 抢占:每增加一个新的进程就需要对所有进程(包括正在运行的进程)进行检查,选择执行时间最短的进程。

事实上,在所有非抢占调度算法中,STCF算法的响应时间最优;而在所有抢占调度算法中,抢占式STCF的响应时间最优

缺点:

  • 可能造成长程序无法得到CPU而导致饥饿

  • 如何知道每个进程还需要运转多久?我们可以用一些启发式heuristic方法来进行估算。

2.5优先级调度算法

可以赋予重要的进程以高优先级以确保重要任务能够得到CPU时间。

缺点:

  • 低优先级进程可能会饥饿(可以动态地调节优先级)

  • 响应时间不能保证

2.6混合调度算法

将所有进程分成不同的大类,每个大类为一个优先级。如果两个进程处于同一个大类,则采用时间片轮转来执行。

2.7其他调度算法

  • 保证调度Guaranteed Scheduling:保证每个进程占用CPU的时间完全一样。保证调度不一定要轮转,每次给的时间片不一定要一样。

  • 彩票调度Lottery Scheduling:概率调度算法,给每个进程分发一定数量的彩票,而调度器则从所有彩票里随机抽取一张彩票,中奖的进程就获得CPU。彩票调度的优越性:通过给每个进程至少一张彩票可以防止饥饿;可以用于模拟其他进程调度算法。

  • 用户公平调度Fair Share Scheduling Per User:按照每个用户而不是每个进程来进行公平分配。如果一个用户的进程多,则其所拥有的进程所获得的CPU时间将短。贪婪的用户可以通过启动许多进程来抢占CPU时间,用户公平调度可以解决这个问题。

调度异常之优先级倒挂(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()函数大致回顾下现在已经建立好的东西:

void mem_init(){
//检查内存还有多少
i386_detect_memory();

//分配一个物理页作为页目录表
//boot_alloc只是暂时的页分配器,等我们建立好页目录后,将使用page_alloc函数来分配物理页(返回对应PageInfo结构体指针)
kern_pgdir = (pde_t *)boot_alloc(PGSIZE);

//添加第一个页目录表项
kern_pgdir[PDX(UVPT)] = PADDR(kern_pgdir) | PTE_U | PTE_P;

//分配一块内存用于存放struct PageInfo数组,用于追踪所有内存物理页的使用情况
pages = (struct PageInfo *)boot_alloc(npages*sizeof(struct PageInfo));

//分配一块内存用于存放struct Env数组,用于追踪所有进程情况
envs = (struct Env *)boot_alloc(NENV*sizeof(struct Env));

//初始化pages数组,初始化pages_free_list链表
page_init();

//下面构建虚拟地址空间
//函数原型:boot_map_region(pde_t* pgdir, uintptr_t va, size_t size, phyaddr_t pa, int perm)
//功能是:把虚拟地址空间范围[va, va+size]映射到物理空间[pa, pa+size]的映射关系加入到页表pgdir中
//注:也可以用page_insert()实现同样的功能
boot_map_region(kern_pgdir, UPAGES, PTSIZE, PADDR(pages), PTE_U);
boot_map_region(kern_pgdir, UENVS, PTSIZE, PADDR(envs), PTE_U);
boot_map_region(kern_pgdir, KSTACKTOP-KSTKSIZE, KSTKSIZE, PADDR(bootstack), PTE_W);
boot_map_region(kern_pgdir, KERNBASE, 0xffffffff-KERNBASE, 0, PTE_W);

lcr3(PADDR(kern_pgdir));
}

可见,我们现在已经做好的是:建立起了虚拟地址空间(及其到物理地址的映射),在物理内存中有一个envs数组,可用来记录进程情况。 那么下面我们就要利用这个envs数组来构建进程管理系统了。

JOS的进程(环境)管理:

JOS内核使用Env结构体来追踪用户进程(又称用户环境environment)。

一个Env结构体实际上是记录了某个进程运行时的上下文,它记录了进程类型,进程状态,该进程的虚拟空间的映射,切换进程时需记录的寄存器值(尤其是该进程的执行入口地址值)等。

在内核中定义了如下三个指针用于追踪并调控进程:

struct Env* envs = NULL;              //指向所有进程的链表的指针
struct Env* curenv = NULL; //指向当前进程
static struct Env* env_free_list; //空闲进程结构链表

而struct Env的定义为:

struct Env {
struct Trapframe env_tf; // 当进程停止运行时用于保存寄存器的值,比如当发生中断切换到内核环境运行了或者
// 切换到另一个进程运行的时候需要保存当前进程的寄存器值以便后续该进程继续执行
struct Env *env_link; // 指向空闲进程链表env_free_list中的下一个Env结构
envid_t env_id; // 进程ID。因为进程ID是正数,所以符号位是0,而中间的21位是标识符,标识在
// 不同的时间创建但是却共享同一个进程索引号的进程,最后10位是进程的索引号,
// 要用envs索引进程管理结构Env就要用ENVX(env_id)
envid_t env_parent_id; // 进程的父进程ID
enum EnvType env_type; // 进程类型,通常是 ENV_TYPE_USER,后面实验中可能会用到其他类型
unsigned env_status; // 进程状态,进程可能处于下面几种状态:
// ENV_FREE:标识该进程结构处于不活跃状态,存在于env_free_list链表。
// ENV_RUNNABLE: 标识该进程处于等待运行的状态。
// ENV_RUNNING: 标识该进程是当前正在运行的进程。
// ENV_NOT_RUNNABLE: 标识该进程是当前运行的进程,但是处于不活跃的状态,
// 比如在等待另一个进程的IPC。
// ENV_DYING: 该状态用于标识僵尸进程。在实验4才会用到这个状态,实验3不用。
// Address space
pde_t *env_pgdir; // 用于保存进程页目录的虚拟地址
};

构建用户进程的流程是这样的:

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) 函数开始执行该进程。

上述流程对应的代码调用过程如下(运行用户进程前的调用过程):

start (kern/entry.S)
i386_init (kern/init.c)
cons_init
mem_init
env_init
trap_init (still incomplete at this point)
//获取一个新进程(设置struct Env中的状态信息),根据用户elf文件开辟物理空间并建立映射,映射关系写入e->env_pgdir,
//再复制用户elf到对应虚地址。本质上是env_create(_binary_obj_user_hello_start, ENV_TYPE_USER),
//这里的参数_binary_obj_user_hello_start是由链接器生成的符号
env_create
//从env_free_list上获取一个进程指针,指向一个没内存上被使用的struct Env,并初始化这个新进程的状态信息
env_alloc
env_setup_vm //为新创建的进程分配一个页目录,用于构建进程虚地址到物理地址的映射
load_icode //用户elf指定了运行虚地址,在这里就把用户代码拷贝到
region_alloc //新开辟的物理地址,地址映射记录到进程e->env_pgdir中
env_run
env_pop_tf

在上面,系统运行到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)。

//分配一个新的进程并初始化。若成功,新进程被保存在*newenv_store中(*newenv_store指向新进程的struct Env),返回0
//这里必须使用参数struct Env**。若试图以struct Env*作函数形参,而以外部的struct Env*作实参传入,
//那么由于形参是临时变量,将无法通过它来给实参赋值。
int env_alloc(struct Env** newenv_store, envid_t parent_id){
int32_t generation;
int r;
struct Env* e;

if(!(e = env_free_list))
return -E_NO_FREE_ENV;
//为这个进程分配和设置页目录
if((r = env_setup_vm(e)) < 0)
return r;
//为这个进程生成一个env_id。这部分代码不太理解,待有空了慢慢研究
//ENVGENSHIFT==12(>=LOGNENV)
generation = (e->env_id + (1 << ENVGENSHIFT)) & ~(NENV-1);
if(generation <= 0)
generation = 1 << ENVGENSHIFT;
e->env_id = generation | (e-envs);

//设置基本状态变量
e->env_parent_id = parent_id;
e->env_type = ENV_TYPE_USER;
e->env_status = ENV_RUNNABLE;
e->env_runs = 0;

//把所有保存的寄存器状态清零,以防止上一个进程遗留的寄存器值影响当前进程
memset(&e->env_tf, 0, sizeof(e->env_tf));

//为段寄存器设置合适的初值。GD_UD是GDT中用户数据段选择子,GD_UT是用户代码段选择子。
//每个段寄存器的低2位包含了请求优先级(RPL);3表示用户模式。当我们切换优先级时,硬件进行各种
//检查,包括RPL以及本身保存在描述符中的描述符优先级(DPL)
e->env_tf.tf_ds = GD_UD | 3;
e->env_tf.tf_es = GD_UD | 3;
e->env_tf.tf_ss = GD_UD | 3;
e->env_tf.tf_esp = USTACKTOP;
e->env_tf.tf_cs = GD_UT | 3;
//我们在之后再设置e->env_tf.tf_eip

//完成分配
env_free_list = e->env_link;
*newenv_store = e;

return 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中:

inc/trap.h中:

struct Trapframe {
struct PushRegs tf_regs; //其中有8个通用寄存器的值
uint16_t tf_es;
uint16_t tf_padding1; //这应该是用于填充内存使对齐用的
uint16_t tf_ds;
uint16_t tf_padding2;
uint32_t tf_trapno;
/* below here defined by x86 hardware */
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;
} __attribute__((packed));

其中的PushRegs是使用命令pusha时压入的寄存器值构成的结构体,对应8个通用寄存器:
struct PushRegs {
uint32_t reg_edi;
uint32_t reg_esi;
uint32_t reg_ebp;
uint32_t reg_oesp; /* useless */
uint32_t reg_ebx;
uint32_t reg_edx;
uint32_t reg_ecx;
uint32_t reg_eax;
} __attribute__((packed));

为一个新创建的进程初始化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()中完成的:

kern/env.c中:
//调用方式:env_pop_tf(&curenv->env_tf),这是进入用户态前的最后一个函数
void env_pop_tf(struct Trapframe *tf)
{
asm volatile(
"\t movl %0, %%esp\n" /* %0对应后面的tf,这里是将tf这个地址值赋给%esp,
从而可以把当前进程的env_tf结构当作一段栈空间来操作 */
"\t popal\n" /* 弹出tf_regs到所有的通用寄存器中,
按顺序 DI,SI,BP,SP,BX,DX,CX,AX 弹出 */
"\t popl %%es\n" /* 弹出值到%es */
"\t popl %%ds\n" /* 弹出值到%ds */
"\t addl $0x8, %%esp\n" /* 跳过tf_trapno 和 tf_errcode */
"\t iret\n" /* 从中断返回,将栈中存储数据弹出到eip, cs, eflags寄存器中 */
: : "g"(tf) : "memory"); /* “g”表示将输入变量tf放入eax,ebx,ecx,edx之一,或作为内存变量, */
/* "memory"告诉编译器在执行期间会发生内存变动,以防止错误的代码优化 */
panic("iret failed");
}

此,已经把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字段来指明这个内核堆栈的位置,大小。

在这里首先总述一下中断控制流程:

  • 1. trap_init()先将所有中断处理函数的起始地址放到中断向量表IDT中;

  • 2. 当中断发生时,无论是外部中断还是内部中断,CPU捕捉到该中断,进入内核态,根据中断向量去查询中断向量表,找到对应项;

  • 3. 保存被中断程序的上下文到内核堆栈中,调用这个表项中指明的中断处理函数;

  • 4. 执行中断处理函数;

  • 5. 执行完成后,恢复被中断进程的上下文,返回用户态,继续运行这个进程。

从用户态进入内核态(第一部分:中断指令int T_SYSCALL之前):

在理解中断和系统调用这节时,要从用户态进入,追踪并理解在系统调用时中断和进程切换实现的机制。所有这些机制实现的背景在trap_init()和env_create()时就已经准备好了,它实际发生的阶段其实是位于进程运行阶段: env_run(&envs[0])。这个函数是不会返回的,它一直运行:从内核态进入用户态-执行用户代码-发生系统调用-中断发生-返回-进程终止。我们使用cprintf()这个例子来进行说明,首先追踪一下函数调用过程,看看从用户态程序是怎么产生系统调用的。

cprintf()的调用过程:

在用户程序中常见如下的函数调用过程:

void umain()
{
cprintf("hello, world\n");
}

上述的cprintf函数是在lib/printf.c中定义的:

在lib/printf.c中依次定义并发生了如下函数调用:

/*
* va_list型变量是指向参数的指针。用va_start宏可以初始化一个va_list变量,该宏有两个参数,
* 第一个是va_list变量本身,第二个是可变的参数的前面那个参数,是个固定的参数。
* 利用va_list,把可变个参数的信息归结到一个参数ap上,便于后续函数调用
* 这些参数依次被放在用户堆栈区,最高地址处为可变参数n,fmt的值放在栈顶
*/
int cprintf(const char *fmt, ...) //注:在kern/printf.c中也有一个一模一样的cprintf函数
{
va_list ap; //va_list是指向可变参数的指针
int cnt;

va_start(ap, fmt); //初始化ap,之后ap就指向fmt后面的可变参数(可变参数1)
cnt = vcprintf(fmt, ap); //对参数指针初始化之后,调用vcprintf函数
va_end(ap); //结束对可变参数的获取

return cnt;
}

/*
* 此时在栈区存放着所需参数,fmt是显示字符串的指针,ap是可变参数指针。函数参数归结为了两个。
* 在其中又调用vprintfmt函数,vprintfmt函数的第一个参数是个函数指针putch
* putch的功能是输出一个字符在屏幕上
*/
int vcprintf(const char *fmt, va_list ap)
{
struct printbuf b;
b.idx = 0;
b.cnt = 0;
vprintfmt((void*)putch, &b, fmt, ap); //putch函数被当成了一个参数
sys_cputs(b.buf, b.idx);
return b.cnt;
}

/*
* 这个函数的功能是输出一个指定字符到屏幕
* 参数ch代表要输出的字符,一个int的低八位就是字符的ascii编码
* printbuf结构体:struct printbuf{
* int idx; //当前缓冲索引
* int cnt; //已经打印的字节数
* char buf[256];
* }
* 这个结构体的作用是最多收集256个字符到缓冲区并进行一次系统调用把它们全部打印到屏幕
*/
static void putch(int ch, struct printbuf *b)
{
b->buf[b->idx++] = ch;
if(b->idx == 256-1){
sys_cputs(b->buf, b->idx);
b->idx = 0;
}
b->cnt++;
}

在vcprintf中调用了vprintfmt函数,vprintfmt以putch作为它的一个参数(函数指针),putch定义在lib/printf.c中;vprintfmt是在lib/printfmt.c中定义的:

/*
* 第一个参数是函数指针,用于把一个指定字符输出到屏幕————
* 而这个函数有两个参数:第一个参数用于指定被输出字符;第二个参数指定输出位置。
* 第二个参数putdat是指向输出位置地址的指针,即与putch()中第二个参数同义。
* 第三个和第四个参数就是用于指定输出格式和内容的参数
*/
void vprintfmt(void (*putch)(int, void*), void *putdat, const char *fmt, va_list ap)
{
...
}

这个函数的主体部分过长,我没有写出来,用下面一个流程图来表示其逻辑:

总之,在这个函数中需要打印时就调用putch函数(在lib/printf.c中),而putch就进行系统调用:调用(lib/syscall.c中的)sys_cputs()-->syscall()。

//在lib/syscall.c中发生如下调用:

/*
* 用户态下的sys_cputs函数,正是它调用了int 0x30中断
* 指针s指向一个buf[256]
* 这里SYS_cputs是定义在inc/syscall.h中的系统调用号(值为0)。
*/
void sys_cputs(const char *s, size_t len)
{
syscall(SYS_cputs, 0, (uint32_t)s, len, 0, 0, 0); //系统调用
}

/*
* 在JOS中所有系统调用通过syscall这个函数进行:执行int T_SYSCALL,把函数参数存入若干指定的寄存器
* 并指定函数返回值返回到寄存器ax中
* 用第一个参数num来确定到底是哪个系统调用
* 参数num == SYS_cputs == 0,check == 0,a1 == b->buf, a2 == b->idx,剩下a3、a4、a5都为0
*/
static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5){
int32_t ret;
asm volatile("int %1\n" //汇编指令模板,%1是占位符,对应后面的T_SYSCALL
: "=a" (ret) //=表示在汇编里只能改变该C变量的值,而不能取它的值
//ret值与%ax相联系,即指令执行完后ax的值存入变量ret
: "i" (T_SYSCALL), //中断向量T_SYSCALL,是立即数
"a" (num), //输入参数num(值为0),指令执行前先将num变量的值存入%ax
"d" (a1), //输入参数a1(b->buf),指令执行前先将a1变量的值存入%dx
"c" (a2), //输入参数a2(b->idx)
"b" (a3), //之后三个参数都为0
"D" (a4),
"S" (a5)
: "cc", "memory");
if(check && ret > 0)
panic("syscall %d returned %d (>0)", num, ret);
return ret;
}

经过以上一番折腾,系统进入中断(即执行汇编命令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()时就已经准备好了。

start (kern/entry.S)
i386_init (kern/init.c)
cons_init
mem_init
env_init
trap_init //中断的初始化
SETGATE(idt[T_SYSCALL], 0, GD_KT, t_syscall, 3);
trap_init_percpu
env_create
env_alloc
env_setup_vm
load_icode
region_alloc
env_run
env_pop_tf //从这里之后便从内核进入用户进程,
//如果之后用户进程又产生中断,则利用我们在trap_init中设置好的切换机制
//陷入内核并跳往中断处理函数,处理完之后再切出内核回到用户空间

下面我们再详细进行一下说明:

在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()函数:

trap_init_percpu
{
//
ts.ts_esp0 = KSTACKTOP
ts.ts_ss0 = GD_KT;
ts.ts_iomb = sizeof(struct Taskstate);
//
gdt[GD_TSS0 >> 3] = SEG16(STS_T32A, (uint32_t)(&ts), sizeof(struct Taskstate)-1, 0);
gdt[GD_TSS0 >> 3].sd_s = 0;
//
ltr(GD_TSS0);
lidt(&idt_pd);
}

在kern/trapentry.S中,使用宏TRAPHANDLER_NOEC(name, num)定义好了全局可见的trap处理函数。每个中断向量都有自己的一个处理函数,但它们都先执行一些统一的操作:包括把中断向量入栈,再跳往_alltraps处。

在执行完中断指令int 0x30后,执行的第一条指令就是在trapentry.S中用TRAPHANDLER_NOEC定义的指令:push 0x30。然后执行jmp _alltraps。

_alltraps处也是统一的一些操作:主要是把寄存器按照Trapframe结构的顺序入栈。

所以从用户态陷入内核后,中断和系统调用过程为:

你所实现的 _alltraps 应该:

  • 1. 把值压入堆栈使堆栈看起来像一个结构体Trapframe。注:pushal指令:依次push ax, bx, cx, dx, sp, bp, si, di

  • 2. 加载 GD_KD (对应内核代码段的全局描述符) 的值到 %ds, %es寄存器中

  • 3. 把%esp的值压入,并且传递一个指向Trapframe的指针到trap()函数中

  • 4. 调用trap

Trapframe用于保存当前进程的寄存器值。无论是新建一个进程还是进程中断陷入内核时,都会设置当前进程的env_tf结构体,以便记录进程的当时状态。

struct Trapframe {
struct PushRegs tf_regs;
uint16_t tf_es;
uint16_t tf_padding1;
uint16_t tf_ds;
uint16_t tf_padding2;
uint32_t tf_trapno;
/* below here defined by x86 hardware */
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;
} __attribute__((packed));

我们在之前讲过,从内核态进入用户态分两种情况: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处。执行:

//实参是int 0x30后首先压入内核堆栈的通用寄存器的值,它以trapframe中tf_regs结构的顺序存放在内核栈上
syscall(tf->tf_regs.reg_eax,
tf->tf_regs.reg_edx,
tf->tf_regs.reg_ecx,
tf->tf_regs.reg_ebx,
tf->tf_regs.reg_edi,
tf->tf_regs.reg_esi);

实际上,用户代码是在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),如下:

int32_t syscall(uint32_t syscallno, uint32_t a1, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5){
switch(syscallno){
case (SYS_cputs):
sys_cputs((const char*)a1, a2);
return 0;
case (SYS_cgets):
return sys_cgetc();
case (SYS_getenvid):
return sys_getenvid();
case (SYS_env_destroy):
return sys_env_destroy(a1);
}
}

再执行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进程调度的过程

  1. 因时序或外部中断或进程挂起而导致操作系统获得CPU控制权

  2. 操作系统在所有就绪的进程中按照某种算法遴选进程

  3. 如果选中的是非当前进程,则操作系统将当前进程状态予以保护

  4. 将选中的进程环境布置好(设置寄存器、栈指针、状态字等)

  5. 跳转到选中的进程

四、进程通信

进程之间的交互称为进程间通信(Inter-Process Communication,IPC)。

4.1进程对白:管道、记名管道、套接字

进程对白就是一个进程发出某种数据信息, 另外一方接收数据信息,而这些数据信息通过一片共享的存储空间进行传递。

管道

在这种方式下,一个进程向这片存储空间的一端写入信息,另一个进程从存储空间的另外一端读取信息。这看上去就像管道。管道所占的空间既可以是内存,也可以是磁盘。要创建一个管道,一个进程只需调用管道创建的系统调用即可。该系统调用所做的事情就是在某种存储介质上划出一片空间,赋给其中一个进程写的权利,另一个进程读的权利即可。

从根本上说,管道是一个线性字节数组,类似文件,可以使用文件读写的方式进行访问。但却不是文件。因为通过文件系统看不到管道的存在。管道可以设在内存里,而文件很少设在内存里(当然,有研究人员在研发基于内存的文件系统,但这个还不是主流)。

创建管道在壳命令行下和在程序里是不同的。

  • 壳(shell)命令行下,只需要使用符号“|”即可。例如,在UNIX壳下,我们可以键入如下命令:
    $sort<file1|grep zou

  • 在程序里面,创建管道需要使用系统调用popen()或者pipe()。

    • popen()需要提供一个目标进程作为参数,然后在调用该函数的进程和给出的目标进程之间创建一个管道。创建时还需要提供一个参数表明管道类型:读管道或者写管道。

    • 而pipe()调用将返回两个文件描述符(文件描述符是用来识别一个文件流的一个整数,与句柄不同),其中一个用于从管道进行读操作,一个用于写入管道。也就是说,pipe()将两个文件描述符连接起来,使得一端可以读,另一端可以写。通常情况下, 在使用pipe()调用创建管道后,再使用fork产生两个进程,这两个进程使用pipe()返回的两个文件描述符进行通信。

管道的一个重要特点是使用管道的两个进程之间必须存在某种关系。

记名管道

如果要在两个不相关的进程(如两个不同进程里面的进程)之间进行管道通信,则需要使用记名管道。顾名思义,命名管道是一个有名字的通信管道。记名管道与文件系统共享一个名字空间,即我们可以从文件系统中看到记名管道。也就是说,记名管道的名字不能与文件系统里的任何文件名重名。

一个进程创建一个记名管道后,另外一个进程可使用open来打开这个管道(无名管道则不能使用open操作),从而与另外一端进行交流。

记名管道的名称由两部分组成:计算机名和管道名,例如\\[主机名]\管道\[管道名]\。对于同一主机来讲,允许有多个同一命名管道的实例并且可以由不同的进程打开,但是不同的管道都有属于自己的管道缓冲区而且有自己的通信环境,互不影响。命名管道可以支持多个客户端连接一个服务器端。命名管道客户端
不但可以与本机上的服务器通信也可以同其他主机上的服务器通信。

管道和记名管道虽然具有简单、无需特殊设计(指应用程序方面)就可以和另外一个进程进行通信的优点, 但其缺点也很明显。首先是管道和记名管道并不是所有操作系统都支持。主要支持管道通信方式的是UNIX和类UNIX(如Linux)的操作系统。这样,如果需要在其他操作系统上进行通信,管道机制就多半会力不从心了。其次,管道通信需要在相关的进程间进行(无名管道),或者需要知道按名字来打开(记名管道), 而这在某些时候会十分不便。

虫洞:套接字

套接字(socket)是另外一种可以用于进程间通信的机制。套接字首先在BSD操作系统中出现,随后几乎渗透到所有主流操作系统中。套接字的功能非常强大,可以支持不同层面、不同应用、跨网络的通信。使用套接字进行通信需要双方均创建一个套接字,其中一方作为服务器方,另外一方作为客户方。服务器方必须先创建一个服务区套接字,然后在该套接字上进行监听,等待远方的连接请求。欲与服务器通信的客户则创建 一个客户套接字,然后向服务区套接字发送连接请求。服务器套接字在收到连接请求后,将在服务器方机器上创建一个客户套接字,与远方的客户机上的客户套接字形成点到点的通信通道。之后,客户方和服务器方就可以通过send和recv命令在这个创建的套接字通道上进行交流了。

使用套接字进行通信稍微有点复杂,我们下面以一个网页浏览的例子对套接字这种通信方式予以说明。对于 一个网站来说,要想提供正常的网页浏览服务,其网站服务器需要首先创建一个服务区套接字,作为外界与本服务器的通信信道。为了使该信道为外人所知,我们通常将该服务区套接字与某公共主机的一个众所周知
的端口进行绑定。

#创建一个INET的流套接字
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM);
#将套接字与某公共主机的一个众所周知的端口绑定
serversocket.bind(socket.gethostname(),80);

进行套接字和端口绑定的语句里的socket.gethostname()用来将套接字向外界公开。如果将该语句的使用socket.gethostname()改为''或者'localhost或者某个具体的IP地址(如120.121.4.1),则该服务区套接字将被限制在本地机器上使用。

在创建了服务区套接字并将其向外界公开后,网站服务器就可以在该套接字上进行监听。

serversocket.listen(5); // 将套接字变为一个服务区套接字

将端口上的等待队列长度限制为5,即超过5个的请求将被拒绝。

到这里,服务器方的设置就宣告结束。

对于客户方来说,如要访问上述网站,则需要点击该网站的网址。在点击网址后(我们这里假定该网站网址为http://www.sjtu.edu.cn),客户机上的网络浏览器进行若干步操作:

#创建一个INET的流套接字
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM);
#将套接字与某公共主机的一个众所周知的端口绑定
s.connect("www.sjtu.edu.cn",80);

s.connect命令将向服务器http://www.sjtu.edu.cn在端口80打开的服务器套接字发送连接请求。而服务器端在接收到该连接请求后,将生成一个新的客户端套接字与该客户端套接字对接,从而建立一个套接字通信信道。
网站服务器上运行的主循环

while (true)
{
(clientsocket, address) = serversocket.accept(); // 接收外部连接请求
// 对clientsocket进行相关操作,例如创建一个新进程来处理客户请求
newct = client_thread(clientsocket);
newct.run();
}

至此,套接字通信信道成功创建。客户端程序可以使用套接字s来发送请求、索取网页,而服务器端则使用套接字clientsocket进行发送和接收消息。

这里需要指出的是服务区套接字既不发送数据,也不接收数据(指不接收正常的用户数据,而不是连接请求数据),而仅仅生产出“客户”套接字。当其他(远方)的客户套接字发出一个连接请求时,我们就创建一个客户套接字。一旦创建客户套接字clientsocket,与客户的通信任务就交给了这个刚刚创建的客户套接字。而
原本的服务器套接字serversocket则回到其原来的监听操作上。

套接字由于其功能强大而获得了很大发展,并出现了许多种类。不同的操作系统均支持或实现了某种套接字功能。例如按照传输媒介是否为本地,套接字可以分为本地(UNIX域)套接字和网域套接字。而网域套接字又按照其提供的数据传输特性分为几个大类,分别是:

  • 数据流套接字(stream socket):提供双向、有序、可靠、非重复数据通信。

  • 电报流套接字(datagram socket):提供双向消息流。数据不一定按序到达。

  • 序列包套接字(sequential packet):提供双向、有序、可靠连接,包有最大限制。

  • 裸套接字(raw socket):提供对下层通信协议的访问。

套接字从某种程度上来说非常繁杂,各种操作系统对其处理并不完全一样。因此,如要了解某个特定套接字实现,读者需要查阅关于该套接字实现的具体手册或相关文档。

4.2进程电报:信号

管道和套接字的缺点:

  • 如果使用管道和套接字方式来通信,必须事先在通信的进程间建立连接(创建管道或套接字),这需要消耗系统资源。

  • 通信是自愿的。即一方虽然可以随意向管道或套接字发送信息,但对方却可以选择接收的时机。即使对方对此充耳不闻,你也奈何不得。再次,由于建立连接消耗时间,一旦建立,我们就想进行尽可能多的通信。而如果通信的信息量微小,如我们只是想通知一个进程某件事情的发生,则用管道和套接字就有点“杀鸡用牛刀”的味道,效率十分低下。

那么信号是什么呢?在计算机里,信号就是一个内核对象,或者说是一个内核数据结构。发送方将该数据结构的内容填好,并指明该信号的目标进程后,发出特定的软件中断。操作系统接收到特定的中断请求后,知道是有进程要发送信号,于是到特定的内核数据结构里查找信号接收方,并进行通知。接到通知的进程则对信号进行相应处理。

4.3进程旗语:信号量

在计算机里,信号量实际上就是一个简单整数。一个进程在信号变为0或者1的情况下推进,并且将信号变为1或0来防止别的进程推进。当进程完成任务后,则将信号再改变为0或1,从而允许其他进程执行。需要注意的是,信号量不只是一种通信机制,更是一种同步机制。

4.4进程拥抱:共享内存

共享内存就是两个进程共同拥有同一片内存。对于这片内存中的任何内容,二者均可以访问。要使用共享内存进行通信,一个进程首先需要创建一片内存空间专门作为通信用,而其他进程则将该片内存映射到自己的(虚拟)地址空间。这样,读写自己地址空间中对应共享内存的区域时,就是在和其他进程进行通信。

与管道的区别:

  • 使用共享内存机制通信的两个进程必须在同一台物理机器上;

  • 共享内存的访问方式是随机的,而不是只能从一端写,另一端读,因此其灵活性比管道和套接字大很多,能够传递的信息也复杂得多。

缺点:

  • 管理复杂,且两个进程必须在同一台物理机器上才能使用这种通信方式。

  • 安全性脆弱。因为两个进程存在一片共享的内存,如果一个进程染有病毒,很容易就会传给另外 一个进程。就像两个紧密接触的人,一个人的病毒是很容易传染另外一个人的。

这里需要注意的是,使用全局变量在同一个进程的进程间实现通信不称为共享内存。

4.5信件发送:消息队列

与管道的区别:

  • 它无需固定的读写进程,任何进程都可以读写(当然是有权限的进程)。

  • 它可以同时支持多个进程,多个进程可以读写消息队列。即所谓的多对多,而不是管道的点对点。

  • 消息队列只在内存中实现。

  • 它并不是只在UNIX和类UNIX操作系统中实现。几乎所有主流操作系统都支持消息队列。

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):

17:startup_32:
18: movl $0x10,%eax
......
48: call check_x87
49: jmp after_page_tables #跳转到135行
......
135:after_page_tables:
136: pushl $0
137: pushl $0
138: pushl $0
139: pushl $L6
140: pushl $_main
141: jmp setup_paging #跳转到198行
142:L6:
143: jmp L6;
......
198:setup_paging:
199: movl $1024*5,%ecx
......
217: movl %eax,%cr0
218: ret

忽略其他细节问题,当head程序执行到第49行时,将跳转到135行代码出运行。在第135行代码处,便是head.s调用init中main函数的核心。回顾c函数与汇编之间相互调用的知识可知,内核栈中存在:

当代码继续跳转后,会进入198行代码,直到程序运行至ret,调用返回。此时,内核栈中指向main函数将弹栈,pc指正自动指向main函数地址入口出,从而开始执行main函数。其次,传入main函数中的三个参数均为0(初始化main函数,无需传参,因此这里默认为0)。

目标代码如下(linux/init/main.c):

void main(void){
.......
move_to_user_mode();//移到用户模式下执行 什么叫做移动到用户模式下?未解 o(︶︿︶)o
if(!fork()){
init();
}
for(;;)
__asm__("int $0x80"::"a"(__NR_pause):"ax");
}

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):

_system_call:  
cmpl $nr_system_calls-1,%eax # 调用号如果超出范围的话就在eax 中置-1 并退出。
ja bad_sys_call
push %ds # 保存原段寄存器值。
push %es
push %fs
pushl %edx # ebx,ecx,edx 中放着系统调用相应的C 语言函数的调用参数。
pushl %ecx # push %ebx,%ecx,%edx as parameters
pushl %ebx # to the system call
movl $0x10,%edx # set up ds,es to kernel space
mov %dx,%ds # ds,es 指向内核数据段(全局描述符表中数据段描述符)。
mov %dx,%es
movl $0x17,%edx # fs points to local data space
mov %dx,%fs # fs 指向局部数据段(局部描述符表中数据段描述符)。
# 下面这句操作数的含义是:调用地址 = _sys_call_table + %eax * 4。参见列表后的说明。
# 对应的C 程序中的sys_call_table 在include/linux/sys.h 中,其中定义了一个包括72 个
# 系统调用C 处理函数的地址数组表。
call _sys_call_table(,%eax,4)
pushl %eax # 把系统调用号入栈。
movl _current,%eax # 取当前任务(进程)数据结构地址??eax。
# 下面97-100 行查看当前任务的运行状态。如果不在就绪状态(state 不等于0)就去执行调度程序。
# 如果该任务在就绪状态但counter[??]值等于0,则也去执行调度程序。
cmpl $0,state(%eax) # state
jne reschedule
cmpl $0,counter(%eax) # counter
je reschedule

在系统调用这章,详细讨论了sys_call_table的作用。今天我们便要研究透这段代码(这里插段自己学习的经验总结,遇到源码看不懂的问题,没关系,遇到知识瓶颈再正常不过了,不要花太多时间硬啃,可以放一放。随着你每天日积月累的总结,当时看不懂不代表现在看不懂,当再次回顾代码时,可以把新学的内容套用上,便能慢慢研究透源代码了)。

代码4-9行,进行了一系列的压栈操作,这便是所谓的对当前操作系统状态进行一个保存,记录当前进程运行的信息。内核栈如下:

小伙伴可能要问了,4-9行明明没有ss:sp、EFLAGS、ret的内容啊,怎么会出现在内核栈呢。还记得前文所述的进程与线程的区别嘛?进程是通过中断来完成创建的,因此在调用fork函数时,便自动产生软中断,一旦中断发生,便由硬件自动返程ss:sp、EFLAGS、ret的压栈。

仔细看上图,ss:sp指向的便是进程在用户态下的用户栈地址。这也就实现了进程的内核栈与用户栈的关联。

注意:

  • ds,es,fs为当前进程的数据段。

  • edx,ecx,ebx是系统调用规定的必传参数,ebx为系统调用的第一个参数,以此类推,edx为第三个参数。

  • 代码10-14进行内核态的切换,寄存器指向内核数据段

切换到内核态后,便可以执行sys_fork函数了。同样在该目标代码中:

.align 2
_sys_fork:
call _find_empty_process
testl %eax,%eax
js lf
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process
addl $20,%esp
l: ret

首先调用find-empty-process函数,具体代码不再阐述了,可以翻看linux内核剖析。具体位置在linux/kernel/fork.c下,该函数的主要作用为:last_pid与当前系统内的进程号进行比较,如果当前进程号=last_pid且当前任务处于运行状态,则last_pid++;直到找到一个可用的进程号,然后返回,此时eax存放的内容便是last_pid。

函数返回可能会发生两种情况:

  • 当eax 为负数时,直接执行ret函数,sys_fork函数调用返回

  • 当eax为正数时,把寄存器gs、esi、edi、ebp、eax的内容压栈,并调用copy_process函数。

目标代码(linux/kernel/fork.c):

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss)
{
struct task_struct *p;
int i;
struct file *f;
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
p->state = TASK_UNINTERRUPTIBLE; //进程在建立过程中为防止被调度,设置为不可中断等待。
p->pid = last_pid; //进程的id
p->father = current->pid; //进程的父进程id
p->counter = p->priority; //进程的优先级,这个只能通过sys_nice来修改。
......
p->tss.eax=0 //请思考作用
......
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING; /* do this last, just in case 到此进程准备完毕,可以运行,则状态修改为就绪 */

return last_pid; //返回新建子进程的ID
}

代码请注意: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):

void main(void){
.......
move_to_user_mode();//移到用户模式下执行 什么叫做移动到用户模式下?未解 o(︶︿︶)o
if(!fork()){
init();
}
for(;;)
__asm__("int $0x80"::"a"(__NR_pause):"ax");
}

还记得操作系统初始化的这段代码嘛,这里便有一个fork调用,接着上面的分析,调用fork后,父进程的pid!=0,所以将跳过if语句往下执行,这里让父进程通过sys_pause强制暂停了。再次进入中断后,操作系统开始寻找内核里存在的就绪进程,这时候发现有一个新的就绪态的子进程在默默的等待,因此通过jne reschedule执行函数的调度。reschedule将接着继续调用schedule。

目标代码(linux/kernel/sched.c):

void schedule(void){
int i,next,c;
.......
switch_to(next);
}

进入调度函数后,先忽略前述的某些优先级调度算法,直接转入switch_to函数,你会发现它是通过建立映象来完成内核栈的切换的。而内核栈切换是进程调度的核心的核心,switch_to代码的工作原理是通过TR寄存器来找到当前的tss段,当寄存器存放的内容发生改变时,便会导致TR指向的tss的内容写入CPU的所有寄存器当中,而将原来的寄存器的内容保存在原来的tss中。

代码分析:

  • 将任务n的tss描述赋值给edx寄存器

  • 将edx寄存器的低16位内容,传给临时变量tmp.b

执行长跳转ljmp,ljmp可分为两步:

  • 将寄存器的内容写入当前进程的tss当中去,并且把原tss的描述符传给临时变量转给tmp.a,即给cpu寄存器拍了个照,留存下来。

  • 将tmp.b指向的新tss的内容全部映射到寄存器中,从而完成切换。

switch_to(n)一旦完成切换,内核栈的内容便是子进程的内容了。还记得fork出p->tss.eax为什么等于0了嘛,此时切换的子进程tss.eax=0,那就意味着swicth_to后,cpu寄存器的eax=0,那么等中断返回后,!fork()条件就变成了真!还记得子进程是复制了父进程内核栈的信息了嘛,所以当子进程中断返回后,不就是返回到if后面一条语句执行吗!so,子进程便能在操作系统开始它自己的工作了。。。o(∩_∩)o

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多