启动 中断 异常 系统调用
我们一般打开电脑从启动电源开始,等待开机后再进行具体的操作,运行特定的程序。具体计算机是怎么加载程序并开始运行的呢?在操作系统内核运行之前需要先执行系统初始化软件,完成基本的I/O初始化和引导加载功能,为操作系统内核运行构建环境。之后,操作系统通过中断、异常、系统调用来响应用户的一系列操作。
启动
计算机启动时内存和磁盘布局如下,具体分布情况参考Memory Map。
CPU初始化
CS寄存器分为可见的选择子selector
区域(16位)和不可见的基址base address
区域(32位)。 计算机加电后,CPU从物理地址0xFFFFFFF0
(由初始化的CS:EIP
确定,此时CS和IP的值分别是0xF000
和0xFFF0
)开始执行。在0xFFFFFFF0
这里只是存放了一条跳转指令,通过跳转指令跳到BIOS例行程序起始点。
在16位的8086时代,内存限制在1MB范围内,此时,BIOS的代码固化在EPROM中,且EPROM被编址在1MB内存地址空间的最高64KB中。计算机加电后,CS寄存器初始化为0xF000
,IP寄存器初始化为0xFFF0
,所以CPU要执行的第一条指令的地址为CS:IP=0xF000:0XFFF0
( Segment:Offset
表示) =0xFFFF0
( Linear表示) 。这个地址位于被固化的EPROM中,该地址存储了一条指令,它是一个长跳转指令JMP F000:E05B
。这样就开启了BIOS的执行过程。
到了32位的80386 CPU时代,内存空间扩大到了4G,多了段机制和页机制。如果仍然把BIOS启动固件编址在0xF0000
起始的64KB内存地址空间内,就会把整个物理内存地址空间隔离成不连续的两段,一段是0xF0000
以前的地址,一段是1MB以后的地址,这很不协调。为此,intel采用了一个折中的方案:默认将执行BIOS ROM编址在32位内存地址空间的最高端,即位于4GB地址的最后一个64KB内。在PC系统开机复位时,CPU进入实模式,并将CS寄存器设置成0xF000
,将shadow register
初始化设置为0xFFFF0000
,EIP寄存器初始化设置为0x0000FFF0
。所以机器执行的第一条指令的物理地址是0xFFFFFFF0
。80386的BIOS代码也要和以前8086的BIOS代码兼容,故地址0xFFFFFFF0
处的指令还是一条长跳转指令jmp F000:E05B
。这个长跳转指令会更新CS寄存器和shadow register
,即执行jmp F000:E05B
后,CS将被更新成0xF000
。表面上看CS其实没有变化,但CS的shadow register
被更新为另外一个值了,它的Base域被更新成0x000F0000
,此时 PC = 16*CS + IP
,形成的物理地址为0x000FE05B
系统地址空间只有20位(1MB) ,这就是CPU执行的第二条指令的地址。此时这条指令的地址已经是1M以内了,且此地址不再位于BIOS ROM中,而是位于RAM空间中。由于Intel设计了一种映射机制,将内存高端的BIOS ROM映射到1MB以内的RAM空间里,并且可以使这一段被映射的RAM空间具有与ROM类似的只读属性。所以计算机启动时将开启这种映射机制,让4GB地址空间的最高一个64KB的内容等同于1MB地址空间的最高一个64K的内容,从而使得执行了长跳转指令后,其实是回到了早期的8086CPU初始化控制流,保证了向下兼容。
BIOS初始化
- 在实模式下提供基本输入输出方法
- 通过中断调用实现
- 只能在实模式下使用,操作系统不能使用
- 系统设置信息
- 开机后自检
- 硬件自检POST
- 检测系统中内存或显卡等关键部位的存在和工作状态
- 查找并执行显卡等接口的初始化程序
- 系统初始化
- 检测配置即插即用设备
- 更新 ESCD 扩展系统配置数据
- 硬件自检POST
- 系统自启动程序等
- 用户选择引导设备(从什么介质启动)
- 将
bootloader
从磁盘的引导扇区加载到内存中0x7c00
开始的位置 - 跳转到
bootloader
的位置:CS:IP=0000:7c00
之后,控制权就交给了bootloader
。
bootloader
加载后内存布局如下:
最后,加载程序(bootloader)把控制权交给了操作系统。
BIOS如何读取bootloader
BIOS先读取主引导扇区(又称主引导记录)代码,主引导扇区代码再读取活动分区的引导扇区代码,再由引导扇区代码读取文件系统的加载程序。
BIOS为什么没有直接从磁盘读入操作系统内核映像?
BIOS完成硬件初始化和自检后,会根据CMOS中设置的启动顺序启动相应的设备,这里假定按顺序系统要启动硬盘。但此时,文件系统并没有建立,BIOS也不知道硬盘里存放的是什么,所以BIOS无法直接启动操作系统。另外一个硬盘可以有多个分区,每个分区都有可能包括一个不同的操作系统,BIOS也无从判断应该从哪个分区启动,所以对待硬盘,所有的BIOS都是读取硬盘的0磁头、0柱面、1扇区的内容,然后把控制权交给这里面的MBR (Main Boot Record)。
BIOS读取主引导记录的过程
- BIOS加电自检。BIOS执行内存地址为
FFFF:0000H
处的跳转指令,跳转到固化在ROM中的自检程序处,对系统硬件(包括内存)进行检查。 - 读取主引导记录(MBR)。当BIOS检查到硬件正常并与CMOS中的设置相符后,按照CMOS中对启动设备的设置顺序检测可用的启动设备。BIOS将相应启动设备的第一个扇区(也就是MBR扇区)读入内存地址为
0000:7C00H
处。 - 检查
0000:7DFEH-0000:7DFFH
(MBR的结束标志位)是否等于55AAH
,若不等于则转去尝试其他启动设备,如果没有启动设备满足要求则显示”NO ROM BASIC”然后死机。 - 当检测到有启动设备满足要求后,BIOS将控制权交给相应启动设备。启动设备的MBR将自己复制到
0000:0600H
处,然后继续执行。 - 根据MBR中的引导代码启动引导程序。
事实上,BIOS不仅检查0000:7DFEH-0000:7DFFH
(MBR的结束标志位)是否等于55AAH
,往往还对磁盘是否有写保护、主引导扇区中是否存在活动分区等进行检查。如果发现磁盘有写保护,则显示磁盘写保护出错信息;如果发现磁盘中不存在活动分区,则显示类似如下的信息“Remove disk or other media Press any key to restart”。
标准MBR结构如下:
地址(十进制) | 描述 | 长度(字节) |
---|---|---|
0 | 代码区 | 440(最大446) |
440 | 选用磁盘标志 | 4 |
444 | 一般为空值; 0x0000 | 2 |
446 | 标准MBR分区表规划(四个16 byte的主分区表入口) | 64 |
510 | MBR有效标志:0x55AA | 2 |
系统启动规范
BIOS 固化到计算机主板上的程序(系统设置、自检程序和系统自启动程序)
- BIOS-MBR 主引导记录最多支持4个分区,一个分区占用 16字节,四个分区占用 64字节
- BIOS-GPT 全局唯一标识分区表,不受4个分区的限制
- PXE 网络启动标准,通过服务器下载内核镜像来加载
UEFI 统一可扩展固件接口 目标是在所有平台上一致的操作系统启动服务 会对引导记录进行可信性检查 只有通过可信性检查的才能运行
UEFI和BIOS的区别
统一可扩展固件接口 (Unified Extensible Firmware Interface, UEFI) 是一种个人电脑系统规格,用来定义操作系统与系统固件之间的软件界面,作为BIOS的替代方案。
UEFI启动对比BIOS启动的优势有:
- 安全性更强:UEFI启动需要一个独立的分区,它将系统启动文件和操作系统本身隔离,可以更好的保护系统的启动;
- 启动配置更灵活:EFI启动和GRUB启动类似,在启动的时候可以调用EFIShell,在此可以加载指定硬件驱动,选择启动文件。比如默认启动失败,在EFIShell加载U盘上的启动文件继续启动系统;
- 支持容量更大:传统的BIOS启动由于MBR的限制,默认是无法引导超过2TB以上的硬盘的。随着硬盘价格的不断走低,2TB以上的硬盘会逐渐普及,因此UEFI启动也是今后主流的启动方式。
中断 异常 系统调用
系统调用(system call):应用程序主动向操作系统发出的服务请求
异常(exception):非法指令或其他原因导致当前指令执行失败(如:内存出错)后的处理请求
中断(hardware interrupt):来自硬件设备的处理请求
无论是发生异常、中断,还是系统调用,都需要由硬件保存现场和中断号,转到内核态,进入中断向量表,查找对应的设备驱动程序地址(异常)、异常服务例程地址(异常),或找到系统调用表,并在其中查找对应的系统调用实现的起始地址。处理完毕之后,再进行现场的切换,回到用户态继续执行程序(如果可以继续的话)。
区别
来源 | 响应方式 | 处理机制 | |
---|---|---|---|
中断 | 外部中断 | 异步 | 持续,对用户应用程序是透明的 |
异常 | 应用程序或内核意想不到的行为 | 同步 | 杀死或者重新执行意想不到的应用程序指令 |
系统调用 | 应用程序请求操作系统提供服务 | 同步或异步 | 等待和持续 |
相比于用户态的函数调用,中断和异常的开销是比较大的,因为它们需要进行:
- 特权级的切换
- 建立内核堆栈
- 验证参数的合法性(防止对内核的恶意攻击)
- 内核态需要映射到用户态的地址空间(因为需要访问用户程序的一些内容),因此需要更新页面映射权限
- 内核态也拥有独立的地址空间,因此TLB会失效
中断和异常处理机制
中断和异常处理需要涉及软件和硬件操作
硬件:在CPU初始化时设置中断使能标志
- 依据内部标志或外部中断事件设置中断标志位
- 依据中断向量表调用相应中断服务例程
软件:
- 现场保存(CPU+编译器)
- 中断服务处理(服务例程)
- 清楚中断标记(服务例程)
- 现场恢复(CPU+编译器)
中断嵌套
硬件中断服务例程可被打断
- 不同硬件中断源可能在硬件中断处理时出现
- 硬件中断服务例程中需要临时禁止中断服务例程
- 中断请求会保持到CPU做出响应
异常服务例程中可被打断
- 异常服务例程执行时可能出现硬件中断
异常服务例程可嵌套
- 异常服务例程可能出现缺页
在intel 手册中,intel架构总共可用的中断号有256个(从0到255),其中前32个(从0到31)中断号保留给Intel架构内部使用,也就是说这些中断都有其特定的含义而不可被用户更改,而剩余的224(从32到255)个中断则可由用户自定义和实现其具体功能。其中Double Fault即为中断嵌套,对应中断情况如下所示:
First Exception | Second Exception | Second Exception | Second Exception |
---|---|---|---|
Benign | Contributory | Page Fault | |
Benign | x | x | x |
Contributory | x | Double Fault | x |
Page Fault | x | Double Fault | Double Fault |
从上表中我们可以看到一些嵌套的情况,比如硬件中断“Device Not Available”和系统调用都属于Benign类,因此它们可以和任何其他中断/异常进行嵌套;而Page Fault就不可嵌套。
系统调用
系统调用的特点
- 系统调用是操作系统服务的编程接口
- 通常由高级语言编写(C或C++)
- 程序访问系统调用通常是通过高层次的API接口而不是直接进行系统调用
- 3种最常用的应用程序编程接口(API):
- Win32 API:Windows
- POSIX API:UNIX、LINUX、Mac OS X
- Java API:用于JAVA虚拟机(JVM),是对实际系统调用的进一步抽象
系统调用的实现
- 每个系统调用对应一个系统调用号
- 系统调用接口根据系统调用号来维护表的索引
- 系统调用接口调用内核态中的系统调用功能实现,并返回系统调用的状态和结果
- 用户不需要知道系统调用的实现
- 需要设置调用参数和获取返回结果
- 操作系统接口的细节大部分都隐藏在应用编程接口后
- 通过运行程序支持的库来管理
系统调用过程中堆栈的切换
由tss(task state segment)切换不同特权级堆栈
系统调用与函数调用的区别
- 汇编指令的区别
- 系统调用:使用INT和IRET指令
- 函数调用:使用CALL和RET指令
- 安全性的区别
- 系统调用有堆栈和特权级的转换过程,函数调用没有这样的过程,系统调用相对更为安全
- 性能的区别
- 时间角度:系统调用比函数调用要做更多和特权级切换的工作,所以需要更多的时间开销
- 空间角度:在一些情况下,如果函数调用采用静态编译,往往需要大量的空间开销,此时系统调用更具有
ucore系统调用分析
在 ucore 中,执行系统调用前,需要将系统调用的参数储存在寄存器中。eax
表示系统调用类型,其余参数依次存在 edx
, ecx
, ebx
, edi
, esi
中。
1 | ... |
以下为用户态的系统调用syscall
函数:
1 | syscall(int num, ...) { |
其中num
参数为系统调用号,该函数将参数准备好后,通过 SYSCALL
汇编指令进行系统调用,进入内核态,返回值放在 eax
寄存器,传入参数通过 eax
~ esi
依次传递进去。
在内核态中,首先进入 trap()
函数,然后调用 trap_dispatch()
进入中断分发,当系统得知该中断为系统调用后,OS调用如下的 syscall
函数:
1 | void |
该函数得到系统调用号 num = tf->tf_regs.reg_eax;
,通过计算快速跳转到相应的 sys_
开头的函数,最终在内核态中完成系统调用所需要的功能。