启动 中断 异常 系统调用

我们一般打开电脑从启动电源开始,等待开机后再进行具体的操作,运行特定的程序。具体计算机是怎么加载程序并开始运行的呢?在操作系统内核运行之前需要先执行系统初始化软件,完成基本的I/O初始化和引导加载功能,为操作系统内核运行构建环境。之后,操作系统通过中断、异常、系统调用来响应用户的一系列操作。

启动

计算机启动时内存和磁盘布局如下,具体分布情况参考Memory Map

CPU初始化

CS寄存器分为可见的选择子selector区域(16位)和不可见的基址base address区域(32位)。 计算机加电后,CPU从物理地址0xFFFFFFF0(由初始化的CS:EIP确定,此时CS和IP的值分别是0xF0000xFFF0)开始执行。在0xFFFFFFF0这里只是存放了一条跳转指令,通过跳转指令跳到BIOS例行程序起始点。

在16位的8086时代,内存限制在1MB范围内,此时,BIOS的代码固化在EPROM中,且EPROM被编址在1MB内存地址空间的最高64KB中。计算机加电后,CS寄存器初始化为0xF000,IP寄存器初始化为0xFFF0,所以CPU要执行的第一条指令的地址为CS:IP=0xF000:0XFFF0Segment: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初始化

  • 在实模式下提供基本输入输出方法
    • 通过中断调用实现
    • 只能在实模式下使用,操作系统不能使用
  • 系统设置信息
  • 开机后自检
    1. 硬件自检POST
      • 检测系统中内存或显卡等关键部位的存在和工作状态
      • 查找并执行显卡等接口的初始化程序
    2. 系统初始化
      • 检测配置即插即用设备
      • 更新 ESCD 扩展系统配置数据
  • 系统自启动程序等
  • 用户选择引导设备(从什么介质启动)
  • 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读取主引导记录的过程

  1. BIOS加电自检。BIOS执行内存地址为FFFF:0000H处的跳转指令,跳转到固化在ROM中的自检程序处,对系统硬件(包括内存)进行检查。
  2. 读取主引导记录(MBR)。当BIOS检查到硬件正常并与CMOS中的设置相符后,按照CMOS中对启动设备的设置顺序检测可用的启动设备。BIOS将相应启动设备的第一个扇区(也就是MBR扇区)读入内存地址为0000:7C00H处。
  3. 检查0000:7DFEH-0000:7DFFH(MBR的结束标志位)是否等于55AAH,若不等于则转去尝试其他启动设备,如果没有启动设备满足要求则显示”NO ROM BASIC”然后死机。
  4. 当检测到有启动设备满足要求后,BIOS将控制权交给相应启动设备。启动设备的MBR将自己复制到0000:0600H处,然后继续执行。
  5. 根据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启动的优势有:

  1. 安全性更强:UEFI启动需要一个独立的分区,它将系统启动文件和操作系统本身隔离,可以更好的保护系统的启动;
  2. 启动配置更灵活:EFI启动和GRUB启动类似,在启动的时候可以调用EFIShell,在此可以加载指定硬件驱动,选择启动文件。比如默认启动失败,在EFIShell加载U盘上的启动文件继续启动系统;
  3. 支持容量更大:传统的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
2
3
4
5
6
7
8
9
...
int num = tf->tf_regs.reg_eax;
...
arg[0] = tf->tf_regs.reg_edx;
arg[1] = tf->tf_regs.reg_ecx;
arg[2] = tf->tf_regs.reg_ebx;
arg[3] = tf->tf_regs.reg_edi;
arg[4] = tf->tf_regs.reg_esi;
...

以下为用户态的系统调用syscall函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
syscall(int num, ...) {
va_list ap;
va_start(ap, num);
uint32_t a[MAX_ARGS];
int i, ret;
for (i = 0; i < MAX_ARGS; i ++) {
a[i] = va_arg(ap, uint32_t);
}
va_end(ap);

asm volatile (
"int %1;"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a[0]),
"c" (a[1]),
"b" (a[2]),
"D" (a[3]),
"S" (a[4])
: "cc", "memory");
return ret;
}

其中num参数为系统调用号,该函数将参数准备好后,通过 SYSCALL 汇编指令进行系统调用,进入内核态,返回值放在 eax 寄存器,传入参数通过 eax ~ esi 依次传递进去。
在内核态中,首先进入 trap() 函数,然后调用 trap_dispatch()进入中断分发,当系统得知该中断为系统调用后,OS调用如下的 syscall 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void
syscall(void) {
struct trapframe *tf = current->tf;
uint32_t arg[5];
int num = tf->tf_regs.reg_eax;
if (num >= 0 && num < NUM_SYSCALLS) {
if (syscalls[num] != NULL) {
arg[0] = tf->tf_regs.reg_edx;
arg[1] = tf->tf_regs.reg_ecx;
arg[2] = tf->tf_regs.reg_ebx;
arg[3] = tf->tf_regs.reg_edi;
arg[4] = tf->tf_regs.reg_esi;
tf->tf_regs.reg_eax = syscalls[num](arg);
return ;
}
}
print_trapframe(tf);
panic("undefined syscall %d, pid = %d, name = %s.\n",
num, current->pid, current->name);
}

该函数得到系统调用号 num = tf->tf_regs.reg_eax;,通过计算快速跳转到相应的 sys_ 开头的函数,最终在内核态中完成系统调用所需要的功能。