目录 Table of Contents
第三讲_启动、中断、异常和系统调用
BIOS
CPU 在加电, 稳定之后它会对里面的寄存器做一个初始化, 到一个指定状态. 这个时候开始去执行第一条指令, 那这第一条指令在哪, 它会在内存里面, 但是我们前面讲内存的时候, 内存是用来存数据的, 里面数据电源一关掉之后就没了.
那么这个时候你去执行第一条指令, 去哪里执行呢 ?
内存会分成 RAM 随机访问存储和一个 ROM 只读存储. 也就是说 ROM 加电之后还会有一些原来写进去的内容, 我们的系统初始化代码就从那里开始执行.
看图, 1MB 下面有一段 BIOS 固件, 这个部分它在加电的时候我们蹦到那里去执行.
这个时候就有一个约定, 计算机系统 CPU 在初始化完成之后, 里面的代码段寄存器和当前指针, 这两个寄存器的值是多少, 因为这个值直接决定了我们从内存中读取数据时候的位置.
CS:IP = 0xf000:fff0
我们的系统 CPU 在初始化之后, 它处于实模式下, 在实模式下它的地址计算把段寄存器左移四位然后加上它的当前指令指针, 这两个加到一起就是我们访问的第一条指令的位置.
还有一个限制就是说, 在加电的时候, 它处于实模式, 这个时候地址总线并不是像我们现在的 32 位, 它只有 20 位的地址可以用. 那在这 20 位的地址里面, 我们用的区域就是 2^10, 这个时候就只有 1M, 所以放的区域就只能放在最底下 1M 里面一小块.
BIOS 里面需要提供
-
基本输入输出程序
-
系统设置信息
-
开机后自检程序
-
系统自启动程序等
这些服务可以保证我们可以访问磁盘设备这些东西.
具体过程我们可以这样看
在 BIOS 里, 它启动起来的时候, 初始化完成之后, 它就会从磁盘上读引导扇区, 这个引导扇区只有 512 字节长, 更长的它没有这个能力放在 BIOS 程序. 读进来之后放到指定的位置 0x7c00.
然后跳转到其中的固定位置 CS:IP = 0000:7c00
然后这个时候我们就把控制权转到从磁盘上读进来的程序, 在这里是我们的加载程序.
这个加载程序可以把操作系统的代码读到内存中, 并且把控制权交给操作系统, 来继续执行操作系统功能.
这个时候有个问题, 就是你既然能从磁盘上读取数据, 那为什么我不是直接从 BIOS 里面直接把操作系统的内核映像读进来呢 ?
首先我们磁盘上是有文件系统的, 文件系统是多种多样的, 我们机器在出厂的时候不能直接限制死只能用某一种文件系统, 然后我的 BIOS 上又放不下能够识别所有文件系统的代码, 为了增加这种灵活性, 我们在这里有一个基本约定.
基本约定就是我不需要认识格式, 我也能从里面读到你的第一块, 读了这一块之后, 这一块加载程序里面, 我们会用加载程序来识别你的磁盘上的文件系统, 我认识文件系统之后, 我就可以读进来我的内核映像, 并且将其加载到内存当中来.
这就是我们这里看到用加载程序读到操作系统来, 有了这个过程之后我们再把相应的控制权转到读进来的操作系统内核代码上, 我们的操作系统就可以开始运行.
当然在这里 BIOS 只能提供一些最基本的输入输出功能, 并且它的使用也受到很大限制, 比如在我们英特尔 CPU 上它有一条限制就是只能在实模式下工作. 那如果我们的操作系统工作在保护模式下, 这些都不能用了
系统启动流程
刚才我们说清了计算机在启动的时候它从什么地方去读第一条指令, 从磁盘上什么地方去读第一块数据, 那么接下来我们会把刚才这个过程进一步细化.
系统启动的流程按照刚才说的就是加电之后读取 BIOS, 然后 BIOS 去读取你的加载程序, 然后加载程序读取内核映像.
这个过程我们可以把它细化
我 BIOS 里头起来, 我们说直接去读 bootloader 加载程序, 但实际上这个过程它并不能直接进行. 因为最早的时候系统里只有一个分区, 上来之后我就直接分区里找文件系统了, 但是对于现在的电脑, 里面都不止一个分区, 每个分区可能会有不同的系统, 那这个时候就在前面加上一个主引导记录, 这个主引导记录是说我要从哪个文件系统里面读我这个加载程序.
有了主引导程序之后我就进到当前哪个分区里面, 分区里面又有一个分区的引导扇区, 这个活动分区的引导扇区要来加载我们刚才说到的加载程序.
我们需要知道这几个部分它的格式是什么样子, 如果你不知道这个格式的话, 写出来的程序最终存到磁盘上, 机器是不能够从里面认识的.
那我们具体说起来有这样几个过程, 首先我们在前面已经说过, CPU 加点完成之后它的初始化到一个确定的状态去读第一条指令 (第一条指令是跳转指令), 我们需要知道 CPU 初始化之后它的 CS 寄存器和 IP 寄存器这两个的内容, 然后计算出来它的第一条指令在内存中的什么地方.
(图-BIOS初始化流程)
有了这个之后, 我们进到 BIOS 里执行, BIOS 是从磁盘上读加载程序, 实际上它还有很多事情要做
首先一个是硬件自检, 我们加电之后有可能内存出错, 那这样后边都没法做了, 这个时候需要加电自检看看最关键的这几个部分是不是在工作.
在自检的时候需要知道, 关键的内存显卡这几个部分有没有, 如果有内存有显卡的话它的工作状态是什么, 然后这些关键性设备接口卡里面也有自己的初始化程序, 这些初始化程序完成之后, 那我就认为关键的设备是可以正常工作的了.
然后我们再来执行系统的初始化, BIOS 的初始化, 这时候它是干什么呢 ?
我们现在的系统很多都是可以即插即用的, 那如果我想从一个 USB 接口的设备里启动, 你怎么启动 ? 这个时候进行系统自检, 检测并配置这些设备. 然后我就知道我现在系统里到底都连接了哪些硬件.
我们在 BIOS 里有一个系统配置表, 这个配置表就是我们这里所说的 ESCD, 就是扩展系统配置数据, 用这个数据我就能知道我当前系统里有些什么样的设备.
做完之后我就把控制权转到我们从外部读进来的代码.
这就是我们在 BIOS 里指定的顺序, 从你指定的设备上读进你的第一块扇区, 读进来之后, 我们有多个分区, 所以我们要看看这个主引导记录.
我们需要知道的内容是它的格式
我们说它只有 512 字节, 但是在这我可以用到的只有 446 字节, 原因是我这里面后面还有多个分区, 这些分区的状态是怎么样的, 也要存放到这 512 字节里面, 所以我们只有 446 字节的内容来执行启动代码.
在这启动代码里我们要知道我这些分区是不是正确的, 如果分区表是错误的, 那我的程序是无法正常加载的. 然后我们要加载并跳转到活动分区的引导记录上面去.
第二个是分区表, 我们对于所有的引导扇区都有一个结束标志, 这个标志是 55AA, 有了这个之后, 它才认为这是一个合法的主引导记录.
然后它就会跳到你活动分区的引导扇区上去, 那在这里仍然是一样的, 它也有一个需要了解的格式
在这里面开始有文件卷的信息, 这个结束标志和刚才那个主引导记录是一样的, 在这基础上它有启动代码, 启动代码就是一条跳转指令, 这个跳转指令和刚才的有些区别, 这个和 CPU 不同有关.
然后空下的其他地方再是我的启动代码. 这个地方启动代码就需要认识你的格式. 我这个加载程序不是存放在 512 字节里面的, 而是存放在别处, 它在哪就要靠你这里的代码来指定. 而这里的代码实际上是我们存放在硬盘软盘上的, 这个时候是可以改动的, 改动完了之后我就可以把我的加载程序放到任意地方, 只要我在这里标识出来就行.
接下来我们说说加载程序的细化
加载程序首先不是直接去加载你的内核, 而是去从文件系统当中, 这时候加载程序是能够认识文件系统格式的, 从里面读取一个启动配置文件, 这个启动配置文件在不同系统是不一样的
. 然后依据这个来选择你启动的参数, 比如是正常启动还是安全模式启动. 这些区别读出来后会导致后来加载出来的内核不一样, 或者说我加载时的参数不一样.
依据配置加载内核.
如果你是想要写这个程序的话, 你还需要看 CPU 手册, 然后看 BIOS 里的规范格式.
我们在实际的工业界, 它制定的一组相应的标准, BIOS 就是我们现在广泛使用的在 PC 机上的启动流程标准
BIOS, UEFI, MBR, Legacy, GPT等概念整理
中断、异常和系统调用比较
背景 为什么需要中断、异常和系统调用
-
在计算机中, 内核是被信任的第三方
-
只有内核可以执行特权指令
-
方便应用程序
可信任的内核, 必须为外界提供一个打交道的通道
背景 中断和异常希望解决的问题
- 当外设连接计算机时, 会出现什么现象 ?
为了能够让系统对外界做出适当的反映, 我们需要中断机制, 就是说外设与系统交互的时候, 我需要怎么来处理
- 当应用程序处理意想不到的行为时, 会出现什么现象 ?
应用程序出了异常的时候, 由操作系统来处理它.
背景 系统调用希望解决的问题
- 用户程序是如何得到系统服务 ?
我们需要通过系统调用来提供一个接口, 让应用程序既方便快捷地使用内核提供的服务, 又不至于让用户的行为对我内核安全产生影响
- 系统调用和功能调用的不同之处是什么 ?
一个是系统调用, 一个是调用函数库
内核的进入和退出
从图中看到, 操作系统内核和外界打交道基本上就是中断异常和系统调用这三个接口
中断、异常和系统调用
- 系统调用 (System call)
系统调用是应用程序主动向操作系统发出的服务请
- 异常 (Exception)
非法指令或者其他原因导致当前的指令执行失败 (如 : 内存出错) 后的处理请求
- 中断 (Hardware interrupt)
来自硬件设备的处理请求
三者的区别
- 源头
中断 : 外设
异常 : 应用程序意想不到的行为
系统调用 : 应用程序请求操作系统提供服务
- 响应方式
中断 : 异步
异步就是我系统调用发出来之后, 你内核在处理的时候, 我切出去干别的了, 然后等到其他条件准备之后, 你这时回来, 已经做好了, 我不会感知到中断的存在
异常 : 同步
而同步, 是你异常是跟你当前指令有关, 是同步的, 也就是说必须处理完这个问题, 我才可以继续下去
系统调用 : 异步或者同步
- 处理机制
当然这中断和异常和系统调用在内核中处理方式也有一些区别, 中断会持续进行, 而系统调用是在用户发出请求之后会处理, 而异常是会处理当前所出现的问题
中断 : 持续, 对用户应用程序是透明的
异常 : 杀死或者重新执行意想不到的应用程序指令
系统调用 : 等待和持续
中断 (这里是三种情况的总称) 处理机制
-
硬件处理
- 在 CPU 初始化时设置中断使能标志
也就是说在许可外界打扰 CPU 的执行之前, CPU 是不会对外界的任何中断请求发出响应的, 那只有我 CPU 把准备工作做完, 外界来一个请求之后我知道怎么处理了, 我才会允许这种处理, 如果我不知道怎么处理的话, 你给我一个请求, 我也不知道怎么办. 所以在这里初始化的时候, 它有一个中断使能, 使能之后我才能够进行中断的处理.
-
依据内部或外部事件设置中断标志
第二个是说, 这个事件产生了, 产生了之后通常是一个电平上升沿, 或者说是一个高电平, 那 CPU 会记录下这件事情, 也就是说我有一个中断标志, 表示出现了一个中断, 然后这时候我需要知道中断到底是由什么产生的, 需要知道中断源的编号, 这一部分工作是由硬件来做的.
-
依据中断向量调用
然后这时候我需要知道中断到底是由什么设备产生的, 需要知道中断源的编号, 这一部分工作是由硬件来做的, 硬件做完之后, 剩下的事情就是内核的软件来做
-
软件
- 现场保存 (编译器)
- 中断服务处理 (服务例程)
-清除中断标记 (服务例程) - 现场恢复 (编译器)
在这里头呢, 也就是说这几个中断进来之后都是到了这个中断向量表
根据这个中断向量表, 如果查到是中断的话, 就直接绕到这边的中断服务例程, 驱动程序里来做出响应; 如果是异常, 直接转到异常服务例程来做处理; 如果是系统调用, 由于系统调用的量很大, 它自己是有一个系统调用表的, 然后就是根据你系统调用表里的选择的功能不同, 我去选择不同的系统调用实现.
那么在这里面, 我为了不影响程序的正常执行, 我前边有一个保护现场和恢复现场, 而要做系统调用的交互, 我还要知道系统调用产生之前, 我准备的上下文信息, 比如说你到底让我干啥, 那这一部分工作是会和你的编译有关系, 然后中间这一部分到底如何实现, 是由操作系统来做的
如果是中断的话, 你在执行过程中, 需要清除这个中断标志, 这个也是由你的中断服务例程来做的
中断嵌套
中断可以满足应用程序, 外部设备或者是程序执行异常的服务请求, 那这时候可能我在处理请求的时候又来一个请求, 这时候该怎么办 ?
- 硬件中断服务例程可被打断
- 不同硬件中断源可能在硬件中断处理时出现
- 硬件中断服务例程中需要临时禁止中断请求
- 中断请求会保持到 CPU 做出响应
在操作系统里面, 硬件的中断, 它是允许被打断的, 也就是说我在处理一个中断的时候, 可以运行你再出现其他的中断, 如果两个中断源不同, 那这时候我可以通过优先级的高低让一个往后面等一等, 或者让一个暂停下来, 那使得我可以同时交替地进行处理这两个中断.
然后在中断服务例程里头, 并不是说我任何一个时刻都可以做任何一个处理, 它会在一定的时间里, 禁止中断请求, 比如说我电源有问题, 那可能其它的问题就不那么重要了, 这时候我在做电源处理的时候, 我就会禁止掉其它的中断, 然后中断服务请求会一直保持到 CPU 做出响应
中断服务请求会一直保持到 CPU 响应, 它会一直在那里等着 CPU
- 异常服务例程可被打断
- 异常服务例程执行时可能出现硬件中断
对于异常, 我也可以被打断.
比如我在程序执行当中出现了异常, 这时候正在做异常的处理, 比如说我在这里头虚拟存储里头, 它访问到的存储单元的数据不存在, 我正在从硬盘上倒数据进来, 倒的过程当中, 它会用到磁盘 I/O, 这时候也会再有磁盘设备的中断, 这时候是允许它做嵌套的
- 异常服务例程可嵌套
- 异常服务例程可能出现缺页
然后对于异常服务的嵌套呢, 这是我们说到的异常服务和缺页, 这两个在异常服务里头还会再出现异常, 也就是说我执行异常处理例程里头, 有一段存储访问是缺页的, 那这时候两个异常也是可以嵌套到一起
系统调用
标准 C 库的例子
- 应用程序调用 printf() 时, 会触发系统调用 write().
把这个图转换一下来看
系统调用_
对于系统调用
-
操作系统服务的编程接口
-
通常用高级语言编写 (C 或者 C++)
-
程序访问通常是通过高层次的 API 接口而不是直接进行系统调用
你在使用的时候, 通常不直接去使用系统调用, 而是把系统调用封装到一个库里面, 比如向我们的标准 C 库, 应用程序是访问这些库里的库函数来实现的.
-
三种最常用的应用程序编程接口 (API)
- Win32 API 用于 Windows
- POSIX API 用于 POSIX-based systems(包括 Unix Linux Mac OS X 的所有版本)
- Java API 用于 Java 虚拟机 (JVM)
上面的是最常用的应用程序编程接口, 也就相当于把系统调用最后封装完了之后, 用户用到的接口是什么样的.
系统调用的实现
-
每一个系统调用都有一个编号
然后依据这个编号不同, 我们来使用不同的功能- 系统调用接口根据系统调用编号来维护表的索引
-
系统调用接口调用内核态中的系统调用功能实现, 并返回系统调用的状态和结果
-
用户不需要知道系统调用的实现
- 需要设置调用参数和获取返回结果
- 操作系统接口的细节大部分都隐藏在应用编程接口后
- 通过运行程序支持的库来管理
函数调用和系统调用的不同之处
-
系统调用
- INT 和 IRET 指令用于系统调用
- 系统调用时, 堆栈切换和特权级的转换
-
函数调用
- CALL 和 RET 用于常规调用
- 常规调用时没有堆栈切换
这四条指令, 指令级是完全不同的, 那么他们的区别体现在什么地方
实际功能区别在于, 函数调用, 我们为了调用一个函数, 我需要把参数压到堆栈里面去, 然后转到相应函数去执行, 执行的时候从堆栈获取我的参数信息, 返回的结果放在堆栈然后返回回来, 这样的话你上面的函数就知道我相关的返回的结果, 然后用这个结果继续往下执行
而对于系统调用, 它由于是受内核保护的, 而应用程序是他自己的区域, 在这里为了实现保护内核, 这个地方内核和用户态的应用程序之间使用不同的堆栈, 所以在这会有一个堆栈的切换. 切换之后, 由于处于内核态, 我就可以使用特权指令, 这些特权指令所导致的结果就是我这个时候可以直接对设备进行控制, 而你在用户态是不可能做到的.
如果这两个用同一个堆栈, 就会出现用户其它代码可以改变你堆栈的信息, 这对于系统来说是不安全的.
我这里只是对这几条指令最主要的区别做一个介绍, 如果你要实现新的系统调用, 你可能需要去查详细的区别, 去查 CPU 指令手册
中断、异常和系统调用的开销
超过函数调用
原因是有一个用户态到内核态的切换, 具体有哪些开销我们在这里列出来
- 引导机制
首先你有一个切换的引导, 这是硬件上需要做的事情
- 建立内核堆栈
再有就是你内核里面有另外一个堆栈, 如果说第一次调用的时候, 这个时候会有内核堆栈的建立
- 验证参数
然后我在这里传参数的时候, 这个参数的有效性合法性是需要做验证的
- 内核态映射到用户态的地址空间
- 更新页面映射权限
切换到内核执行的时候, 由于我访问的代码有切换, 那么在这种情况下, 内核需要访问到用户态的一些信息, 这个时候会做一个地址空间上的映射, 这些映射会导致你的缓存会有变化
- 内核态独立地址空间
- TLB
系统调用实例
今天我们通过一个实例
来介绍系统调用的使用和实现
在这里我们通常从应用程序的写法上来说这个事
我们说我想写一个程序
能够把一个文件的内容复制出来
写到另一个文件里头改一个名字
这个地方是我把这件事情展开之后
我写程序会是什么样的
首先我会在屏幕上输出一个提示
要求用户输入
你要读的那个文件的名字
然后等待键盘输入
接下来 我会提示用户
在屏幕上给出提示说让它输入输出文件的名字
等待并接受键盘的输入
等到这两个信息都有了
然后说我们就试图去打开相应的文件
首先去打开输入文件
如果输入文件在你的文件系统当中有
那这是成立的 如果说文件不存在
这个时候肯定出错了因为我要复制的源没有了
接下来说我去看看创建输出文件
如果说你有这个文件
这个时候我会把原来东西覆盖掉
这是不对的 这个时候如果文件存在
这个时候我出错退出 这几步都做完了之后
那我就确认我的输入文件是存在的
输出文件是没有的
然后开始循环从文件当中读数据写到输出文件
从输入文件当中读数据
从输出文件当中把数据写到输出文件中
那为什么会在这做循环
有可能这个文件很小我一次就搞完了
如果这个文很大我可能会循环若干次
等所有这些都做完
那么这个时候我关闭输出文件和输入文件
然后屏幕上提示我整个事情做完
整个程序正常退出 那这是我想写的程序
对于这个程序来说
我会用到哪些系统调用
我们会有键盘的输入 有屏幕的输出
有文件的输入和输出
实际上在操作系统内核里
它这个实现键盘 屏幕和文件
都视为是文件系统里的
只是说键盘和屏幕作为特殊的文件来使用
那么在这里头涉及到的系统调用是open close还有一个应该是create
然后再有一个write read
有了这几个系统调用之后
那么我这件事情在上边
用我的函数库里内容就可以完成
-
ucore 中库函数 read() 的功能是读文件
- user/libs/file.h:int read(int fd, void * buf, int length)
-
库函数 read() 的参数和返回值
- int fd 文件句柄
- void * buf 数据缓冲区指针
- int length 数据缓冲区长度
- int return_value 返回读出数据长度
-
库函数 read() 使用示例
- in sfs_filetest1.c:ret = read(fd, data, len);
具体怎么来做 我们看到要想在应用程序写, 那应用程序会使用到一个库函数read
这和我们用到其他函数是一样的
在我们ucore有这样一个头文件
告诉你他格式什么样子
然后这里头你需要知道这里的参数是什么意思
你读写的文件 你读出来的数据放在什么地方
缓冲区域头指针
然后这个缓冲区域并不是无限大的
它的最大长度你不能超过这个长度
然后我从里头读数据往里放
那我实际读出来的时候
有可能比这短
因为我实际文件里可能没有这么多数据
如果说我实际数据比这个缓冲区大怎么办
这个时候最多读出来是缓冲区长度
因为在多就缓冲区溢出了
然后读完之后返回值 这个地方有个返回值
返回值是你的长度 你在使用的时候怎么做
你打开文件 把这三个参数填上去
返回来的时候
返回的结果就是你实际的程度
这是你在用的时候
对于我们系统的实现来讲
在编译程序的时候
你的应用程序用到相应的库函数
这个库里头在编译系统调用的内容的时候
它就会前面准备参数
实际上这段都会往堆栈压栈
压占完之后最后有函数调用
而这个函数调用最后都转到我有一个
你说这不是系统调用 是的
这是一个函数调用
这个函数调用所有的系统调用, 它都是通过一个宏展开形成相应的函数
实际上在这里有一段汇编
大家注意这段汇编有可能你现在还不能完全看懂
那你需要注意这个int, 是我们前面说到系统调用的指令
后面 i 实际上是你系统调用的中断向量编号
后面 num 实际就是系统调用 read 系统调用编号
然后后面是相应的参数
这些填完之后
实际上最后你在的执行应用程序
执行到这个地方 到这里来的时候
它就会转成系统调用进到内核里去
ucore 系统调用 read(fd, buffer, length) 的实现
-
kern/trap/trapentry.S:alltraps()
-
kern/trap/trap.c:trap()
tf->trapno == T_SYSCALL -
kern/syscall/syscall.c:syscall()
tf->tf_regs.reg_eax == SYS_read -
kern/syscall/syscall.c:sys_read()
从 tf->sp 获取 fd, buf, length -
kern/sysfile.c:sysfile_read()
读取文件 -
kern/trap/trapentry.S:trapret()
接下来我们操作系统里是如何实现系统调用 read
首先我们刚才说在用户态一个int进到内核里来之后
这实际上是一个软中断
所有这些都会到你最开始一段汇编程序叫 alltraps
在这里会获取到中段所需要的
相关信息组成的数据结构
这个时候实际上是 TF 数据结构
那么在这里头我们注意到其中有一条 (trapno)
是其中的中段向量
那么在这里 (T_SYSCALL) 是系统调用对应中段向量
然后依据这个那么在trap函数里头
它就会转到我们系统调用这个函数 (syscall()
) 里头
在这个函数里头它会读其中的EAX
实际上就是你的系统调用编号
这个系统调用编号会看到它是等于read
等于这个实际上相当于这个时候
我们知道进来是系统调用
并且这个系统调用调用是哪个功能
我们还缺什么
我们还缺它参数
这些参数就会转到相应的系统调用实现里头
这个实现里负责去堆占里头SP
获取相应我们当时填进堆占里头那三个参数
这个文件句柄 缓冲区 头指针
然后缓冲区长度 有了这三个之后
实际上这个时候就相当于
已经从用户态转到内核态
如果说我是一个函数调用的话
这个时候就已经转过来
我在继续做相应函数实现就可以了
这个时候我们就看到最后它到sysfile read
这个函数里头去完成相应的文件读取功能
这个文件读取就是直接操作底下的驱动程序
再往下 等到它最后返回的时候
ok 那这个时候我们到这 trapret
在这里头我们去看这类代码最后会有 ireturn
这个 return 会把相应的返回值的长度
读到内容长度返回给用户态
整个实现就全部完成
那接下来我们会去看看实际的系统里的代码是什么样子的
ucore+ 系统调用代码
-