清华大学MOOC《操作系统》第3讲:“启动、中断、异常和系统调用”总结(转)

课程内容概述

这节课的主要内容包括:

  • 系统启动过程
    • BIOS的原理
    • BIOS的一些具体工作
    • 系统启动规范
  • 中断,异常和系统调用
    • 中断
    • 系统调用

系统启动过程

BIOS的基本功能

计算机刚刚启动时的内存布局如图:

地址用途
(4GB - 64KB) ~ 4GB实际BIOS ROM
1MB ~ (4GB - 64KB)空闲空间
640KB ~ 1MB视频内存,BIOS启动固件(映射)
0 ~ 640KB空闲空间

(这是一个非常简略的示意图,具体请见Memory Map (x86)

这一复杂的映射机制是为了保证向后兼容而设计的。在8086时代,内存只有1MB大小,此时,BIOS的代码固化在EPROM中,且EPROM被编址在1MB内存地址空间的最高64KB中。PC加电后,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的Base值初始化设置为0xFFFF0000,EIP寄存器初始化设置为0x0000FFF0。所以机器执行的第一条指令的物理地址是0xFFFFFFF0。80386的BIOS代码也要和以前8086的BIOS代码兼容,故地址0xFFFFFFF0处的指令还是一条长跳转指令jmp F000:E05B。注意,这个长跳转指令会更新CS寄存器和它的shadowregister,即执行jmp F000:E05B后,CS将被更新成0xF000。表面上看CS其实没有变化,但CS的shadow register被更新为另外一个值了,它的Base域被更新成0x000F0000,此时形成的物理地址为Base+EIP=0x000FE05B,这就是CPU执行的第二条指令的地址。此时这条指令的地址已经是1M以内了,且此地址不再位于BIOS ROM中,而是位于RAM空间中。由于Intel设计了一种映射机制,将内存高端的BIOS ROM映射到1MB以内的RAM空间里,并且可以使这一段被映射的RAM空间具有与ROM类似的只读属性。所以PC机启动时将开启这种映射机制,让4GB地址空间的最高一个64KB的内容等同于1MB地址空间的最高一个64K的内容,从而使得执行了长跳转指令后,其实是回到了早期的8086 CPU初始化控制流,保证了向下兼容。

上述说明指出,在CPU启动之后,它处于实模式之下,执行的第一条指令是jmp F000:E05B,跳转到BIOS程序中。此时,PC = 16*CS + IP,系统地址空间只有20位(1MB)。

• 20位地址空间:1MB

这之后BIOS会进行以下工作:

  • 在实模式下提供基本输入输出方法
    • 通过中断调用实现
    • 只能在实模式下使用,操作系统不能使用
  • 运行自检程序
  • 用户选择引导设备(从什么介质启动)
  • 将bootloader从磁盘的引导扇区加载到内存中0x7c00开始的位置
  • 跳转到bootloader的位置:CS:IP=0000:7c00
    • 系统设置信息:
    • 开机后自检程序
    • 系统自启动程序等

这之后,控制权就交给bootloader:

  • 切换到保护模式
  • 将操作系统的代码和数据从硬盘加载到内存中(因为BIOS无法处理硬盘的文件系统)
  • 跳转到操作系统的起始地址

加载之后的内存布局如下表:

地址用途
(4GB - 64KB) ~ 4GB实际BIOS ROM
? ~ (4GB - 64KB)空闲空间
1MB ~ ?操作系统
640KB ~ 1MB视频内存,BIOS启动固件(映射)
? ~ 640KB空闲空间
0x7c00 ~ ?bootloader
0 ~ 0x7c00BIOS数据

最后,bootloader把控制权转交给操作系统。

BIOS的一些具体工作

上面这些听起来都很不错,但是课程中还涉及了很多烦人的细节。姑且摘录如下:

BIOS本身的初始化内容

包括:

  • 硬件自检POST
  • 检测系统中内存和显卡等关键部件的存在和工作状态
  • 查找并执行显卡等接口卡BIOS,进行设备初始化
  • 执行系统BIOS,进行系统检测:检测和配置系统中安装的即插即用设备
  • 更新CMOS中的扩展系统配置数据ESCD
  • 按指定启动顺序从硬盘、软盘等设备启动

BIOS如何读取bootloader

Wiki上是这么说的:

  • 系统开机或者重启。
    • BIOS加电(台湾用语:开机)自检(Power On Self Test -- POST)。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”。

我觉得这个wiki讲得十分清楚,所以就不摘录课件内容了。

标准MBR的结构如下:

地址(十进制)描述长度(字节)
0代码区440(最大446)
440选用磁盘标志4
444一般为空值; 0x00002
446标准MBR分区表规划(四个16 byte的主分区表入口)64
510MBR有效标志:0x55AA2

系统启动规范

课程中还讲到了BIOS-MBR、BIOS-GPT、PXE和UEFI等系统启动规范,其中UEFI似乎还更重要一点。这似乎是通用的现代BIOS标准。

中断、异常和系统调用

首先下个定义:

  • 系统调用(system call):应用程序主动向操作系统发出的服务请求
  • 异常(exception):非法指令或其他原因导致当前指令执行失败(如:内存出错)后的处理请求
  • 中断(hardware interrupt):来自硬件设备的处理请求

它们的相同之处是,采用的处理方式大致相同。无论是发生异常、中断,还是系统调用,都需要由硬件保存现场和中断号,转到内核态,进入中断向量表,查找对应的设备驱动程序地址(异常)、异常服务例程地址(异常),或找到系统调用表,并在其中查找对应的系统调用实现的起始地址。处理完毕之后,再进行现场的切换,回到用户态继续执行程序(如果可以继续的话)。

中断、异常和系统调用处理流程中断、异常和系统调用处理流程

它们的区别如下表:

 源头响应方式处理机制
中断外设异步持续,对用户应用程序是透明的
异常应用程序或内核意想不到的行为同步杀死或重新执行意想不到的应用程序指令
系统调用应用程序请求操作提供服务异步或同步等待和持续

这三者的处理有时可以嵌套,有时不可以。Intel手册中把中断、异常和系统调用分为Benign、Contributry和Page Fault三类:
中断和异常分类中断和异常分类

其中8号异常没有列在分类中,因为它是就是Double Fault的中断号。当嵌套中断/异常发生时,根据种类的不同,处理方式也有所不同:
可能产生Double Fault的情况可能产生Double Fault的情况

从中可以了解很多有趣的事实。比如,硬件中断“Device Not Available”和系统调用都属于Benign类,因此它们可以和任何其他中断/异常进行嵌套;而Page Fault就不可嵌套(很有道理;如果连缺页服务例程自己都缺页了,那根本就没法解决)。

(以上内容也参考了Double Fault & Triple Fault一文)

相比于用户态的函数调用,中断和异常的开销是比较大的,因为它们需要进行:

  • 特权级的切换
  • 建立内核堆栈
  • 验证参数的合法性(防止对内核的恶意攻击)
  • 内核态需要映射到用户态的地址空间(因为需要访问用户程序的一些内容),因此需要更新页面映射权限
  • 内核态也拥有独立的地址空间,因此TLB会失效

中断的具体处理机制

中断处理的过程需要软件和硬件的配合(虽然系统调用和异常也是……)
硬件处理内容包括:

  • 在CPU初始化时设置中断使能标志
  • 依据内部或外部事件设置中断标志
  • 依据中断向量调用对应的中断服务例程
    软件处理内容包括:
  • 现场保存(编译器)
  • 中断服务处理(服务例程)
  • 清除中断标记(服务例程)(系统调用只占用一个中断向量,另有系统调用表)
  • 现场恢复(编译器)

系统调用

系统调用的特点

  • 系统调用是操作系统服务的编程接口
  • 通常由高级语言编写(C或C++)
  • 程序访问系统调用通常是通过高层次的API接口(比如封装到标准C库—)而不是直接进行系统调用
  • 3种最常用的应用程序编程接口(API):
    • Win32 API:Windows
    • POSIX API:UNIX、LINUX、Mac OS X
    • Java API:用于JAVA虚拟机(JVM),是对实际系统调用的进一步抽象

系统调用的实现

  • 每个系统调用对应一个系统调用号
    • 系统调用接口根据系统调用号来维护表的索引
  • 系统调用接口调用内核态中的系统调用功能实现,并返回系统调用的状态和结果
  • 用户不需要知道系统调用的实现
    • 需要设置调用参数和获取返回结果
    • 操作系统接口的细节大部分都隐藏在应用编程接口后
  • 通过运行程序支持的库来管理

注意,系统调用时,堆栈需要切换(内核和用户程序使用的是不同的堆栈),特权级需要进行转换

uCore中系统调用的具体实现

read(fd, buffer, length)的实现为例:

  1. vectors.S中的内容填充IDT表
  2. 发生系统调用时,硬件访问IDT表后,跳转到中断服务例程,对中断进行具体处理
  3. kern/trap/trapentry.S:alltraps():中断服务例程
  4. kern/trap/trap.c:trap():针对中断类型进行具体处理
    1. tf->trapno == T_SYSCALL:中断类型为系统调用,调用具体的系统调用处理函数
  5. kern/syscall/syscall.c:syscall():进行系统调用
    1. tf->tf_regs.reg_eax == SYS_read:读eax(系统调用编号),得到具体是哪个系统调用
  6. kern/syscall/syscall.c:sys_read():处理SYS_READ系统调用,从tf->sp获取堆栈中的参数(fdbuflength
  7. kern/fs/sysfile.c:sysfile_read()SYS_READ系统调用的更具体实现,读取文件,直接操纵驱动程序
  8. kern/trap/trapentry.S:trapret():中断处理返回,将所需内容返回给用户态

习题

来自lec 3 SPOC Discussion

简答题

BIOS从磁盘读入的第一个扇区是是什么内容?为什么没有直接读入操作系统内核映像?

BIOS完成硬件初始化和自检后,会根据CMOS中设置的启动顺序启动相应的设备,这里假定按顺序系统要启动硬盘。但此时,文件系统并没有建立,BIOS也不知道硬盘里存放的是什么,所以BIOS是无法直接启动操作系统。另外一个硬盘可以有多个分区,每个分区都有可能包括一个不同的操作系统,BIOS也无从判断应该从哪个分区启动,所以对待硬盘,所有的BIOS都是读取硬盘的0磁头、0柱面、1扇区的内容,然后把控制权交给这里面的MBR (Main Boot Record)。

我认为上述答案并不十分确切。比如,在uCore中,虽然BIOS没有建立文件系统,bootloader也没有建立文件系统啊。但是,加载操作系统是个很复杂的过程:就比如uCore,我们需要完成对ELF文件格式的解析和文件本身的读入。BIOS工作在实模式,本身访存范围只有1MB(能使用的数据只有0 ~ 0x7c00的范围),而且代码长度被限制在64KB。为了将OS读入到高地址的内存中,需要BIOS进行模式的切换。但是,如果BIOS进行了实模式到保护模式的切换,就不能实现向后兼容了。而且不同的OS的文件格式和处理方法也有差异,这会导致BIOS十分复杂。因此,让OS提供自己的启动程序是最好的选择。


比较UEFI和BIOS的区别。

统一可扩展固件接口 (Unified Extensible Firmware Interface, UEFI) 是一种个人电脑系统规格,用来定义操作系统与系统固件之间的软件界面,作为BIOS的替代方案。

UEFI启动对比BIOS启动的优势有三点:

  • 安全性更强:UEFI启动需要一个独立的分区,它将系统启动文件和操作系统本身隔离,可以更好的保护系统的启动;
  • 启动配置更灵活:EFI启动和GRUB启动类似,在启动的时候可以调用EFIShell,在此可以加载指定硬件驱动,选择启动文件。比如默认启动失败,在EFIShell加载U盘上的启动文件继续启动系统;
  • 支持容量更大:传统的BIOS启动由于MBR的限制,默认是无法引导超过2TB以上的硬盘的。随着硬盘价格的不断走低,2TB以上的硬盘会逐渐普及,因此UEFI启动也是今后主流的启动方式。

分区引导扇区的结束标志是什么?

0X55AA。当然,上面也说到了,BIOS除此之外还会检查别的内容。


在UEFI中的可信启动有什么作用?

通过启动前的数字签名检查来保证启动介质的安全性。


什么是中断、异常和系统调用?

  • 中断:外部意外的响应;
  • 异常:指令执行意外的响应;
  • 系统调用:系统调用指令的响应。

这个回答真是十分简洁明了。


中断、异常和系统调用的处理流程有什么异同?

  • 相同点:都会进入异常服务例程,切换为内核态。
  • 不同点:
    • 源头不同,中断源是外部设备,异常和系统调用源是应用程序;
    • 响应方式不同,中断是异步的,异常是同步的,系统调用异步和同步都可以。
    • 处理机制不同,中断对用户程序是透明的,异常会重新执行用户指令或杀死用户进程,系统调用一般是用户程序调用的

以ucore lab8的answer为例,uCore的系统调用有哪些?大致的功能分类有哪些?

打开kern/syscall/syscall.c,其中定义了如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int (*syscalls[])(uint32_t arg[]) = {
    [SYS_exit]              sys_exit,
    [SYS_fork]              sys_fork,
    [SYS_wait]              sys_wait,
    [SYS_exec]              sys_exec,
    [SYS_yield]             sys_yield,
    [SYS_kill]              sys_kill,
    [SYS_getpid]            sys_getpid,
    [SYS_putc]              sys_putc,
    [SYS_pgdir]             sys_pgdir,
    [SYS_gettime]           sys_gettime,
    [SYS_lab6_set_priority] sys_lab6_set_priority,
    [SYS_sleep]             sys_sleep,
    [SYS_open]              sys_open,
    [SYS_close]             sys_close,
    [SYS_read]              sys_read,
    [SYS_write]             sys_write,
    [SYS_seek]              sys_seek,
    [SYS_fstat]             sys_fstat,
    [SYS_fsync]             sys_fsync,
    [SYS_getcwd]            sys_getcwd,
    [SYS_getdirentry]       sys_getdirentry,
    [SYS_dup]               sys_dup,
};

大致可以分为如下4类:

  • 进程管理:包括 fork/exit/wait/exec/yield/kill/getpid/sleep
  • 文件操作:包括 open/close/read/write/seek/fstat/fsync/getcwd/getdirentry/dup
  • 内存管理:pgdir命令
  • 外设输出:putc命令

通过分析lab1_ex0了解Linux应用的系统调用编写和含义。(仅实践,不用回答)

按说是应该做一下的,但现在没有时间了……


通过调试lab1_ex1了解Linux应用的系统调用执行过程。(仅实践,不用回答)

按说是应该做一下的,但现在没有时间了……+1


基于实验八的代码分析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;
...

说实话,我不知道这个题目在问什么。姑且把lab5 report中的说法粘贴过来:

fork/wait/exit系统调用的执行过程,以fork为例:

  • 应用态进程调用user/libs/ulib.c:fork函数
  • user/libs/ulib.c:fork调用user/libs/syscall.c:sys_fork函数
  • user/libs/syscall.c:sys_fork函数调用user/libs/syscall.c:syscall函数,保存好相应的中断号和参数,引发系统调用中断
  • vectors.S:vector128:系统调用对应的中断向量
  • trapentry.S:__alltraps:进入中断处理例程
  • trap.c:trap
  • trap.c:trap_dispatch:进入系统中断的具体处理例程
  • kern/syscall/syscall.c:syscall:根据%eax寄存器中保存的值,进入sys_fork函数处理SYS_fork这一具体系统调用
  • kern/syscall/syscall.c:sys_fork:调用do_fork函数
  • proc.c:do_fork

以ucore lab8的answer为例,分析ucore应用的系统调用编写和含义。

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;
}

这段代码是用户态的syscall函数,其中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_开头的函数,最终在内核态中,完成系统调用所需要的功能。


以ucore lab8的answer为例,尝试修改并运行ucore OS kernel代码,使其具有类似Linux应用工具strace的功能,即能够显示出应用程序发出的系统调用,从而可以分析ucore应用的系统调用执行过程。

利用trap.ctrap_in_kernel()函数判断是否是用户态的系统调用,调用syscall()时传入此参数

1
2
3
case T_SYSCALL:
    syscall(trap_in_kernel(tf));
    break;

更改syscall()的函数原型为void syscall(bool);

之后在syscall(bool)中加入输出即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;

    if (!in_kernel) {
    	cprintf("SYSCALL: %d\n", num);
    }
        tf->tf_regs.reg_eax = syscalls[num](arg);
        return ;
    }
}

下面是qemu运行的输出结果片段,可以看出在用户程序输出前调用了SYS_open,输出sh is running的过程中调用了SYS_write

1
2
3
4
5
6
Iter 1, No.0 philosopher_sema is thinking
kernel_execve: pid = 2, name = "sh".
SYSCALL: 100
SYSCALL: 100
SYSCALL: 103
....

系统调用与函数调用的区别是什么?

  • 汇编指令的区别
    • 系统调用:使用INT和IRET指令
    • 函数调用:使用CALL和RET指令
  • 安全性的区别
    • 系统调用有堆栈和特权级的转换过程,函数调用没有这样的过程,系统调用相对更为安全
  • 性能的区别
    • 时间角度:系统调用比函数调用要做更多和特权级切换的工作,所以需要更多的时间开销
    • 空间角度:在一些情况下,如果函数调用采用静态编译,往往需要大量的空间开销,此时系统调用更具有优势

通过分析int、iret、call和ret的指令准确功能和调用代码,比较函数调用与系统调用的堆栈操作有什么不同?

int指令压栈的内容请参考Interrupt and Exception Handling on the x86第10页。

实践练习

看不完了==

  • 2
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值