分类: LINUX
2013-06-27 18:48:07
姓名:韩康,学号:SA***183
一、 操作系统工作的基础
1. 存储程序计算机
存储程序计算机最早是由著名数学家冯·诺伊曼等人在1946年总结并明确提出来的,因此又被称为冯·诺伊曼计算机。
存储程序计算机在体系结构上主要特点有:
- 以运算单元为中心
- 采用存储程序原理
- 存储器是按地址访问、线性编址的空间
- 控制流由指令流产生
- 指令由操作码和地址码组成
- 数据以二进制编码
下面是存储程序计算机的组成框图:
图1 存储程序计算机组成框图
CPU解释并执行计算机指令。
Memory用来存储数据和程序。
2. 堆栈(函数调用堆栈)机制
C程序代码(进程)在“存储程序计算机”上执行是通过一种叫做“函数调用堆栈机制”实现的。我们引入函数调用堆栈机制之前,先来分析一下Linux下进程地址空间的布局以及堆栈帧的结构。
任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的 虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈。图2所示为 Linux下进程的地址空间布局:
进程用户空间的最高位置是用来存放程序运行时的命令行参数及环境变量的,在这段地址空间的下方和bss段的上方还留有一个很大的空洞,而作为进程动态运行环境的堆栈和堆就栖身其中,其中堆栈向下伸展,堆向上伸展。知道了堆栈在进程地址空间中的位置,我们再来看一看堆栈中都存放了什么。相信读者对C语言中的函数这样的概念都已经很熟悉了,实际上堆栈中存放的就是与每个函数对应的堆栈帧。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆栈帧从堆栈中弹出。典型的堆栈帧结构如图3所示:
堆栈帧的顶部为函数的实参,下面是函数的返回地址以及前一个堆栈帧的指针,最下面是分配给函数的局部变量使用的空间。一个堆栈帧通常都有两个指针,其中一个称为堆栈帧指针,另一个称为栈顶指针。前者所指向的位置是固定的,而后者所指向的位置在函数的运行过程中可变。因此,在函数中访问实 参和局部变量时都是以堆栈帧指针为基址,再加上一个偏移。对照图3可知,实参的偏移为正,局部变量的偏移为负。
介绍了linux进程空间以及堆栈帧的结构,我们可以明白C代码在存储程序计算上执行就可以实现了。
3. 中断机制
引入中断机制使内核可以处理硬件外设I/O。中断来源有I/O请求、时钟以及系统调用。
什么是中断,简单地说就是CPU在忙着作自己的事情,这时候硬件(比如说键盘按了一下)触发了一个电信号,这个信号通过中断线到达中断控制器 8259A,8259A接受到这个信号,向CPU发送INT信号申请CPU来执行刚才的硬件操作,并且将中断类型号也发给CPU,此时CPU就丢下自己正 在做的事情,但不是随便丢到旁边而是保存了当前正在做的事情的相关资料,然后去处理这个申请,根据中断类型号找到它的中断向量(也就是中断程序在内存中的 地址),然后去执行这段程序(这段程序是已经写好的,在内存中),执行完后再向8259A发送一个INTA信号表示我已经处理完你刚才的申请。这个时候 CPU就可以继续做它刚才被打断做的事情了,这个时候刚才保存的相关信息就帮助CPU接着执行下面的程序,而不至于忘记自己刚才正在做什么。
至此,我们构造了一个操作系统所运行的必要的环境,如图4所示:
图4 操作系统运行最小环境
二、操作系统(内核)是如何工作的
Linux将内核程序和基于之上的用户程序分开处理,分别运行在用户态和内核态。以32位X86架构为例,虚拟空间4G,高地址1G为系统程序运行的内核态,低地址的3G空间为用户程序运行的用户态。
当一个程序在用户态执行时,它不能直接访问内核数据结构或内核的程序。然而,当应用程序在内核态下运行时,这些限制就不再有效。在一个程序执行时,大部分时间都处在用户态下,只有需要内核所提供的服务时才切换到内核态。当内核满足了用户程序的请求后,它让程序又回到用户台下。
进程是动态的实体,内核是进程的管理者。在单处理系统中,任何时候只有一个进程在运行,它要么处于用户态,要么处于内核态。稍后我们会分析用户态与内核态之间的转换。
然而在linux内核是可重入的(reentrant),这意味着若干个进程可以同时在内核态下执行。当然,在单处理系统上只有一个进程在真正运行,但是有许多进程可能在等待CPU或某一I/O操作完成时在内核态下被阻塞。例如,当内核代表某一进程发出一个读磁盘请求后,就让磁盘控制器处理这个请求并且恢复执行其它进程,这里面牵涉到了进程间切换,稍后我们会分析进程之间的切换。当设备满足了读请求时,有一个中断就会通知内核,从而恢复以前的进程继续执行。
1. 用户态和内核态切换机制
1)用户态切换到内核态的3种方式
a) 系统调用
这是用户态进程主动要求切换到内核态的一种方式。
如果一个用户程序需要调用底层的系统接口,比如printf、malloc等等诸如libc里面的系统调用函数,那么就需要牵涉到用户态与内核态的两个栈切换问题。所有的系统调用函数都是运行在内核态。在系统调用时,由于用户态和内核态是运行在两个独立栈上面,所以我们不能仅仅简单的传递函数指针,因为对于内核态空间用户态是不可见的,所以系统调用函数指针对于用户态不可见;另外一个问题是参数传递,由于两个栈之前独立运行的,所以不能用普通的压栈出栈的形式进行参数传递。
b)异常
当CPU在执行运行在用户态下的程序时,发生了某些事先不可预知的异常,这时会触发当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
c)外围设备的中断
当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。
2)具体的切换操作
从触发方式上看,可以认为存在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一致的,没有任何区别,都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本上也是一致的,关于它们的具体区别这里不再赘述。关于中断处理机制的细节和步骤这里也不做过多分析,涉及到由用户态切换到内核态的步骤主要包括:
- 从当前进程的描述符(TSS)中提取内核的栈段和栈指针的正确值转载进ss和esp寄存器。
- 在新的内核栈中保存ss和esp以前的值,这些值定义了与用户态相关的栈的逻辑地址。
- 如果故障发生,将引起异常的指令地址装载cs和eip寄存器,从而使得这些指令能在此被执行。
- 在内核栈中保存eflags、cs及eip的内容。
- 装载cs和eip寄存器,其值分别是中断描述符表中第i项门描述符的段选择符合偏移量字段。这些值给出了中断或者异常处理程序的第一条指令的逻辑地址。
控制单元所执行的最后一步就是跳转到中断或异常处理程序。换句话说,处理完中断信号后,控制单元所执行的指令就是被选中处理程序的第一条指令。
中断或异常被处理完成后,相应的处理程序必须产生一条iret指令,把控制权转交给被中断的进程,这将迫使控制单元:
具体的执行流程如下:
- 用保存在栈中的值装载cs、eip或eflags寄存器。如果一个硬件出错码曾被压入栈中,并且在eip内容的上面,那么,执行iret指令前必须先弹出这个硬件出错码。
- 检查处理程序的CPL是否等于cs中最低两位的值(这意味着被中断的进程与处理程序运行在同一特权级)。如果是,iret终止执行;否则,转入下一步。
- 从栈中装载ss和esp寄存器,因此,返回到与旧特权级相关的栈
2. 进程切换机制
进程切换由宏 switch_to实现。首先简单提一下这个宏和函数的被调用关系:
schedule() à context_switch()à switch_to à __switch_to()
当schedule()需要暂停A进程的执行而继续B进程的执行时,就发生了进程之间的切换。进程切换主要有两部分:1、切换全局页表项;2、切换内核堆栈和硬件上下文。这个切换工作由context_switch()完成。其中switch_to和__switch_to()主要完成第二部分。更详细的,__switch_to()主要完成硬件上下文切换,switch_to主要完成内核堆栈切换。
switch_to是一个宏,不是函数,它的参数prev, next, last不是值拷贝,而是它的调用者context_switch()的局部变量。局部变量是通过%ebp寄存器来索引的,也就是通过n(%ebp),n是编译时决定的,在不同的进程的同一段代码中,同一局部变量的n是相同的。在switch_to中,发生了堆栈的切换,即ebp发生了改变,所以要格外留意在任一时刻的局部变量属于哪一个进程。关于__switch_to()这 个函数的调用,并不是通过普通的call来实现,而是直接jmp,函数参数也并不是通过堆栈来传递,而是通过寄存器来传递。
switch_to切换主要有以下三部分:
- 进程切换,即esp的切换,从esp可以找到进程的描述符
- 硬件上下文切换,即__switch_to()
- 堆栈的切换,即ebp的切换,ebp是栈底指针,它确定了当前变量空间属于哪个进程
swithc_to源代码如下:
点击(此处)折叠或打开
其中%0和%1都在内存中,分别为prev->thread.esp和prev->thread.eip,而%2则与寄存器EBX结合,对应于参数中的last。其中%3和%4在内存中,分别为next->thread.esp和next->thread.eip,%5、%6和%7分别与寄存器EAX、EDX和EBX结合,分别对应于prev,next,prev.
如图所示,A为此时正运行的进程(prev),B为待切换的进程(next)。切换过程一共分为四步:
第一步:即movl %%esp,%0也就是将寄存器esp中的值保存在进程A的thread.esp中。
第二步:即movl %3,%%esp也就是将进程B的thread.esp的值赋给寄存器esp。(实际上这个值就是上一次从B中切换走的时候执行的第一步的结果。为了要返回,必须为以后考虑周全。)
第三步:即movl $1f,%1其中1f就是说程序后面标号为1的地方,将标号为1的地方的代码的地址保存到A的thread.eip中。
第四步:即pushl %4,将进程B的thread.eip的值压栈,此时的esp指向已是进程B的堆栈。(实际上此时的thread.eip就是上一次从B中切换走的时候第三步执行的结果,即标号一得位置。所以任何进程恢复运行,首先肯定是执行的标号1的代码。)
这里要说明的是,pushl %4后面的一句代码是调转jmp __switch_to 而__switch_to是个函数,他执行完成以后会有一个ret的操作,即将栈中的第一个地址作为函数返回的地址,所以就会跳到标号1的地方去执行代码了。
由于__switch_to的代码在schedule()中,而shedule()函数又在其他系统调用函数中,比如sys_exit()中,所以先返回到调用B进程上次切换走时的schedule()中,然后返回到调用schedule()的系统调用函数中,最后系统调用又是在用户空间调用的,所有返回到系统调用的那个地方,接着执行用户空间的代码。这样就彻底的回到了B进程。注意由于此时的返回路径是根据B堆栈中保存的返回地址来返回的,所以肯定会返回到B进程中。
三、 实验总结
进程是动态执行的实体,内核是进程的管理者。进程不但包括程序的指令和数据,而且包括程序计数器和CPU的所有寄存器以及存储临时数据的进程堆栈。所以,正在执行的进程包括处理器当前的一切活动。进程既可以在用户态下运行,也能在内核下运行,只是内核提供了一些用户态没有的核心服务,因此进程在访问这些服务时会产生中断,必须进行用户态与内核态的切换。
Linux是一个多进程的操作系统,所以,其他的进程必须等到正在运行的进程空闲CPU后才能运行。当正在运行的进程等待其他的系统资源时,Linux内核将取得CPU的控制权,并将CPU分配给其他正在等待的进程,这就是进程切换。内核中的调度算法决定将CPU分配给哪一个进程。
Linux系统有一个进程控制表(process table),一个进程就是其中的一项。进程控制表中的每一项都是一个task_struct结构,在task_struct结构中存储各种低级和高级的信息,包括从一些硬件设备的寄存器拷贝到进程的工作目录的链接点。进程控制表既是一个数组,又是一个双向链表,同时又是一个树,其物理实现是一个包括多个指针的静态数组。系统启动后,内核通常作为某一个进程的代表。一个指向task_struct的全局指针变量current用来记录正在运行的进程。变量current只能由kernel /sched.c中的进程调度改变。
参考Linux下函数调用堆栈帧的详细解释
http://blog.chinaunix.net/uid-21237130-id-159883.html
Linux中断一:初看Linux中断
http://blog.csdn.net/xuexingyang/article/details/7350420
Linux用户态和内核态
http://blog.chinaunix.net/uid-1829236-id-3182279.html
深入理解Linux下用户态与内核态切换
深入理解Linux内核
linux 内核原理——关于进程的简介
http://essayteam.blogbus.com/logs/3870611.html