2. 从机器启动到操作系统运行的过程
一:BIOS 启动过程
当计算机加电后,一般不直接执行操作系统,而是执行系统初始化软件完成基本 IO 初始化和引导加载功能。简单地说,系统初始化软件就是在操作系统内核运行之前运行的一段小软件。通过这段小软件,我们可以初始化硬件设备、建立系统的内存空间映射图,从而将系统的软硬件环境带到一个合适的状态,以便为最终调用操作系统内核准备好正确的环境。最终引导加载程序把操作系统内核映像加载到 RAM 中,并将系统控制权传递给它。
对于绝大多数计算机系统而言,操作系统和应用软件是存放在磁盘(硬盘/软盘)、光盘、EPROM、ROM、Flash 等可在掉电后继续保存数据的存储介质上。计算机启动后,CPU 一开始会到一个特定的地址开始执行指令,这个特定的地址存放了系统初始化软件,负责完成计算机基本的 IO 初始化,这是系统加电后运行的第一段软件代码。对于 Intel 80386 的体系结构而言,PC 机中的系统初始化软件由 BIOS (Basic Input Output System,即基本输入/输出系统,其本质是一个固化在主板 Flash/CMOS 上的软件)和位于软盘/硬盘引导扇区中的 OS Boot Loader(在 ucore 中的 bootasm.S 和 bootmain.c)一起组成。BIOS 实际上是被固化在计算机 ROM(只读存储器)芯片上的一个特殊的软件,为上层软件提供最底层的、最直接的硬件控制与支持。更形象地说,BIOS 就是 PC 计算机硬件与上层软件程序之间的一个"桥梁",负责访问和控制硬件。
以 Intel 80386 为例,计算机加电后,CPU 从物理地址 0xFFFFFFF0(由初始化的 CS:EIP 确定,此时 CS 和 IP 的值分别是 0xF000 和 0xFFF0))开始执行。在 0xFFFFFFF0 这里只是存放了一条跳转指令,通过跳转指令跳到 BIOS 例行程序起始点。BIOS 做完计算机硬件自检和初始化后,会选择一个启动设备(例如软盘、硬盘、光盘等),并且读取该设备的第一扇区(即主引导扇区或启动扇区)到内存一个特定的地址 0x7c00 处,然后 CPU 控制权会转移到那个地址继续执行。至此 BIOS 的初始化工作做完了,进一步的工作交给了 ucore 的 bootloader。
补充信息
Intel 的 CPU 具有很好的向后兼容性。在 16 位的 8086 CPU 时代,内存限制在 1MB 范围内,且 BIOS 的代码固化在 EPROM 中。在基于 Intel 的 8086 CPU 的 PC 机中的 EPROM 被编址在 1 M B 内存地址空间的最高 64KB 中。PC 加电后,CS 寄存器初始化为 0xF000,IP 寄存器初始化为 0xFFF0,所以 CPU 要执行的第一条指令的地址为 CS:IP=0xF000:0XFFF0(Segment:Offset 表示)=0xFFFF0(Linear 表示)。这个地址位于被固化 EPROM 中,指令是一个长跳转指令JMP F000:E05B。这样就开启了 BIOS 的执行过程。
到了 32 位的 80386 CPU 时代,内存空间扩大到了 4G,多了段机制和页机制,但 Intel 依然很好地保证了 80386 向后兼容 8086。地址空间的变化导致无法直接采用 8086 的启动约定。如果把 BIOS 启动固件编址在 0xF000 起始的 64KB 内存地址空间内,就会把整个物理内存地址空间隔离成不连续的两段,一段是 0xF000 以前的地址,一段是 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 寄存器和它的 shadow register,即执行`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 初始化控制流,保证了向下兼容。
二:bootloader 启动过程
BIOS 将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行 bootloader。bootloader 完成的工作包括:
- 切换到保护模式,启用分段机制
- 读磁盘中 ELF 执行文件格式的 ucore 操作系统到内存
- 显示字符串信息
- 把控制权交给 ucore 操作系统
对应其工作的实现文件在 lab1 中的 boot 目录下的三个文件 asm.h、bootasm.S 和 bootmain.c。下面从原理上介绍完成上述工作的计算机系统硬件和软件背景知识。
1. 保护模式和分段机制
为何要了解 Intel 80386 的保护模式和分段机制?首先,我们知道 Intel 80386 只有在进入保护模式后,才能充分发挥其强大的功能,提供更好的保护机制和更大的寻址空间,否则仅仅是一个快速的 8086 而已。没有一定的保护机制,任何一个应用软件都可以任意访问所有的计算机资源,这样也就无从谈起操作系统设计了。且 Intel 80386 的分段机制一直存在,无法屏蔽或避免。其次,在我们的 bootloader 设计中,涉及到了从实模式到保护模式的处理,我们的操作系统功能(比如分页机制)是建立在 Intel 80386 的保护模式上来设计的。如果我们不了解保护模式和分段机制,则我们面向 Intel 80386 体系结构的操作系统设计实际上是建立在一个空中楼阁之上。
【注意】虽然大家学习过 X86 汇编,对 X86 硬件架构有一定了解,但对 X86 保护模式和 X86 系统编程可能了解不够。为了能够清楚了解各个实验中汇编代码的含义,我们建议大家阅读如下参考资料:
- 可先回顾一下 lab0-manual 中的“了解处理器硬件”一节的内容。
- 《Intel 80386 Reference Programmers Manual-i386》:第四、六、九、十章。在后续实验中,还可以进一步阅读第五、七、八等章节。
(1) 实模式
在 bootloader 接手 BIOS 的工作后,当前的 PC 系统处于实模式(16 位模式)运行状态,在这种状态下软件可访问的物理内存空间不能超过 1MB,且无法发挥 Intel 80386 以上级别的 32 位 CPU 的 4GB 内存管理能力。
实模式将整个物理内存看成分段的区域,程序代码和数据位于不同区域,操作系统和用户程序并没有区别对待,而且每一个指针都是指向实际的物理地址。这样,用户程序的一个指针如果指向了操作系统区域或其他用户程序区域,并修改了内容,那么其后果就很可能是灾难性的。通过修改 A20 地址线可以完成从实模式到保护模式的转换。有关 A20 的进一步信息可参考附录“关于 A20 Gate”。
(2) 保护模式
只有在保护模式下,80386 的全部 32 根地址线有效,可寻址高达 4G 字节的线性地址空间和物理地址空间,可访问 64TB(有 2^14 个段,每个段最大空间为 2^32 字节)的逻辑地址空间,可采用分段存储管理机制和分页存储管理机制。这不仅为存储共享和保护提供了硬件支持,而且为实现虚拟存储提供了硬件支持。通过提供 4 个特权级和完善的特权检查机制,既能实现资源共享又能保证代码数据的安全及任务的隔离。
【补充】保护模式下,有两个段表:GDT(Global Descriptor Table)和 LDT(Local Descriptor Table),每一张段表可以包含 8192 (2^13)个描述符[1],因而最多可以同时存在 2 * 2^13 = 2^14 个段。虽然保护模式下可以有这么多段,逻辑地址空间看起来很大,但实际上段并不能扩展物理地址空间,很大程度上各个段的地址空间是相互重叠的。目前所谓的 64TB(2^(14+32)=2^46)逻辑地址空间是一个理论值,没有实际意义。在 32 位保护模式下,真正的物理空间仍然只有 2^32 字节那么大。注:在 ucore lab 中只用到了 GDT,没有用 LDT。Reference: [1] 3.5.1 Segment Descriptor Tables, Intel® 64 and IA-32 Architectures Software Developer’s Manual
(3) 分段存储管理机制
只有在保护模式下才能使用分段存储管理机制。分段机制将内存划分成以起始地址和长度限制这两个二维参数表示的内存块,这些内存块就称之为段(Segment)。编译器把源程序编译成执行程序时用到的代码段、数据段、堆和栈等概念在这里可以与段联系起来,二者在含义上是一致的。
分段机涉及 4 个关键内容:逻辑地址、段描述符(描述段的属性)、段描述符表(包含多个段描述符的“数组”)、段选择子(段寄存器,用于定位段描述符表中表项的索引)。转换逻辑地址(Logical Address,应用程序员看到的地址)到物理地址(Physical Address, 实际的物理内存地址)分以下两步:
[1] 分段地址转换:CPU 把逻辑地址(由段选择子 selector 和段偏移 offset 组成)中的段选择子的内容作为段描述符表的索引,找到表中对应的段描述符,然后把段描述符中保存的段基址加上段偏移值,形成线性地址(Linear Address)。如果不启动分页存储管理机制,则线性地址等于物理地址。 [2] 分页地址转换,这一步中把线性地址转换为物理地址。(注意:这一步是可选的,由操作系统决定是否需要。在后续试验中会涉及。
上述转换过程对于应用程序员来说是不可见的。线性地址空间由一维的线性地址构成,线性地址空间和物理地址空间对等。线性地址 32 位长,线性地址空间容量为 4G 字节。分段地址转换的基本过程如下图所示。
图 1 分段地址转换基本过程
分段存储管理机制需要在启动保护模式的前提下建立。从上图可以看出,为了使得分段存储管理机制正常运行,需要建立好段描述符和段描述符表(参看 bootasm.S,mmu.h,pmm.c)。
段描述符
在分段存储管理机制的保护模式下,每个段由如下三个参数进行定义:段基地址(Base Address)、段界限(Limit)和段属性(Attributes)。在 ucore 中的 kern/mm/mmu.h 中的 struct segdesc 数据结构中有具体的定义。
- 段基地址:规定线性地址空间中段的起始地址。在 80386 保护模式下,段基地址长 32 位。因为基地址长度与寻址地址的长度相同,所以任何一个段都可以从 32 位线性地址空间中的任何一个字节开始,而不象实方式下规定的边界必须被 16 整除。
- 段界限:规定段的大小。在 80386 保护模式下,段界限用 20 位表示,而且段界限可以是以字节为单位或以 4K 字节为单位。
- 段属性:确定段的各种性质。 - 段属性中的粒度位(Granularity),用符号 G 标记。G=0 表示段界限以字节位位单位,20 位的界限可表示的范围是 1 字节至 1M 字节,增量为 1 字节;G=1 表示段界限以 4K 字节为单位,于是 20 位的界限可表示的范围是 4K 字节至 4G 字节,增量为 4K 字节。 - 类型(TYPE):用于区别不同类型的描述符。可表示所描述的段是代码段还是数据段,所描述的段是否可读/写/执行,段的扩展方向等。 - 描述符特权级(Descriptor Privilege Level)(DPL):用来实现保护机制。 - 段存在位(Segment-Present bit):如果这一位为 0,则此描述符为非法的,不能被用来实现地址转换。如果一个非法描述符被加载进一个段寄存器,处理器会立即产生异常。图 5-4 显示了当存在位为 0 时,描述符的格式。操作系统可以任意的使用被标识为可用(AVAILABLE)的位。 - 已访问位(Accessed bit):当处理器访问该段(当一个指向该段描述符的选择子被加载进一个段寄存器)时,将自动设置访问位。操作系统可清除该位。
上述参数通过段描述符来表示,段描述符的结构如下图所示:
图 2 段描述符结构
全局描述符表 全局描述符表的是一个保存多个段描述符的“数组”,其起始地址保存在全局描述符表寄存器 GDTR 中。GDTR 长 48 位,其中高 32 位为基地址,低 16 位为段界限。由于 GDT 不能有 GDT 本身之内的描述符进行描述定义,所以处理器采用 GDTR 为 GDT 这一特殊的系统段。注意,全局描述符表中第一个段描述符设定为空段描述符。GDTR 中的段界限以字节为单位。对于含有 N 个描述符的描述符表的段界限通常可设为 8*N-1。在 ucore 中的 boot/bootasm.S 中的 gdt 地址处和 kern/mm/pmm.c 中的全局变量数组 gdt[]分别有基于汇编语言和 C 语言的全局描述符表的具体实现。
选择子
线性地址部分的选择子是用来选择哪个描述符表和在该表中索引一个描述符的。选择子可以做为指针变量的一部分,从而对应用程序员是可见的,但是一般是由连接加载器来设置的。选择子的格式如下图所示:
图 3 段选择子结构
- 索引(Index):在描述符表中从 8192 个描述符中选择一个描述符。处理器自动将这个索引值乘以 8(描述符的长度),再加上描述符表的基址来索引描述符表,从而选出一个合适的描述符。
- 表指示位(Table Indicator,TI):选择应该访问哪一个描述符表。0 代表应该访问全局描述符表(GDT),1 代表应该访问局部描述符表(LDT)。
- 请求特权级(Requested Privilege Level,RPL):保护机制,在后续试验中会进一步讲解。
全局描述符表的第一项是不能被 CPU 使用,所以当一个段选择子的索引(Index)部分和表指示位(Table Indicator)都为 0 的时(即段选择子指向全局描述符表的第一项时),可以当做一个空的选择子(见 mmu.h 中的 SEG_NULL)。当一个段寄存器被加载一个空选择子时,处理器并不会产生一个异常。但是,当用一个空选择子去访问内存时,则会产生异常。
(4) 保护模式下的特权级
在保护模式下,特权级总共有 4 个,编号从 0(最高特权)到 3(最低特权)。有 3 种主要的资源受到保护:内存,I/O 端口以及执行特殊机器指令的能力。在任一时刻,x86 CPU 都是在一个特定的特权级下运行的,从而决定了代码可以做什么,不可以做什么。这些特权级经常被称为为保护环(protection ring),最内的环(ring 0)对应于最高特权 0,最外面的环(ring 3)一般给应用程序使用,对应最低特权 3。在 ucore 中,CPU 只用到其中的 2 个特权级:0(内核态)和 3(用户态)。
有大约 15 条机器指令被 CPU 限制只能在内核态执行,这些机器指令如果被用户模式的程序所使用,就会颠覆保护模式的保护机制并引起混乱,所以它们被保留给操作系统内核使用。如果企图在 ring 0 以外运行这些指令,就会导致一个一般保护异常(general-protection exception)。对内存和 I/O 端口的访问也受类似的特权级限制。
数据段选择子的整个内容可由程序直接加载到各个段寄存器(如 SS 或 DS 等)当中。这些内容里包含了请求特权级(Requested Privilege Level,简称 RPL)字段。然而,代码段寄存器(CS)的内容不能由装载指令(如 MOV)直接设置,而只能被那些会改变程序执行顺序的指令(如 JMP、INT、CALL)间接地设置。而且 CS 拥有一个由 CPU 维护的当前特权级字段(Current Privilege Level,简称 CPL)。二者结构如下图所示:
图 4 DS 和 CS 的结构图
代码段寄存器中的 CPL 字段(2 位)的值总是等于 CPU 的当前特权级,所以只要看一眼 CS 中的 CPL,你就可以知道此刻的特权级了。
CPU 会在两个关键点上保护内存:当一个段选择符被加载时,以及,当通过线性地址访问一个内存页时。因此,保护也反映在内存地址转换的过程之中,既包括分段又包括分页。当一个数据段选择符被加载时,就会发生下述的检测过程:
图 5 内存访问特权级检查过程
因为越高的数值代表越低的特权,上图中的 MAX()用于选择 CPL 和 RPL 中特权最低的一个,并与描述符特权级(Descriptor Privilege Level,简称 DPL)比较。如果 DPL 的值大于等于它,那么这个访问可正常进行了。RPL 背后的设计思想是:允许内核代码加载特权较低的段。比如,你可以使用 RPL=3 的段描述符来确保给定的操作所使用的段可以在用户模式中访问。但堆栈段寄存器是个例外,它要求 CPL,RPL 和 DPL 这 3 个值必须完全一致,才可以被加载。下面再总结一下 CPL、RPL 和 DPL:
- CPL:当前特权级(Current Privilege Level) 保存在 CS 段寄存器(选择子)的最低两位,CPL 就是当前活动代码段的特权级,并且它定义了当前所执行程序的特权级别)
- DPL:描述符特权(Descriptor Privilege Level) 存储在段描述符中的权限位,用于描述对应段所属的特权等级,也就是段本身能被访问的真正特权级。
- RPL:请求特权级 RPL(Request Privilege Level) RPL 保存在选择子的最低两位。RPL 说明的是进程对段访问的请求权限,意思是当前进程想要的请求权限。RPL 的值可自由设置,并不一定要求 RPL>=CPL,但是当 RPL<CPL 时,实际起作用的就是 CPL 了,因为访问时的特权级保护检查要判断:max(RPL,CPL)<=DPL 是否成立。所以 RPL 可以看成是每次访问时的附加限制,RPL=0 时附加限制最小,RPL=3 时附加限制最大。
2. 地址空间
分段机制涉及 5 个关键内容:逻辑地址(Logical Address,应用程序员看到的地址,在操作系统原理上称为虚拟地址,以后提到虚拟地址就是指逻辑地址)、物理地址(Physical Address, 实际的物理内存地址)、段描述符表(包含多个段描述符的“数组”)、段描述符(描述段的属性,及段描述符表这个“数组”中的“数组元素”)、段选择子(即段寄存器中的值,用于定位段描述符表中段描述符表项的索引)
(1) 逻辑地址空间
从应用程序的角度看,逻辑地址空间就是应用程序员编程所用到的地址空间,比如下面的程序片段: int val=100; int * point=&val;
其中指针变量 point 中存储的即是一个逻辑地址。在基于 80386 的计算机系统中,逻辑地址有一个 16 位的段寄存器(也称段选择子,段选择子)和一个 32 位的偏移量构成。
(2) 物理地址空间
从操作系统的角度看,CPU、内存硬件(通常说的“内存条”)和各种外设是它主要管理的硬件资源而内存硬件和外设分布在物理地址空间中。物理地址空间就是一个“大数组”,CPU 通过索引(物理地址)来访问这个“大数组”中的内容。物理地址是指 CPU 提交到内存总线上用于访问计算机内存和外设的最终地址。
物理地址空间的大小取决于 CPU 实现的物理地址位数,在基于 80386 的计算机系统中,CPU 的物理地址空间为 4GB,如果计算机系统实际上有 1GB 物理内存(即我们通常说的内存条),而其他硬件设备的 IO 寄存器映射到起始物理地址为 3GB 的 256MB 大小的地址空间,则该计算机系统的物理地址空间如下所示:
+------------------+ <- 0xFFFFFFFF (4GB) | 无效空间 | | | +------------------+ <- addr:3G+256M | 256MB | | IO外设地址空间 | | | +------------------+ <- 0xC0000000(3GB) | | /\/\/\/\/\/\/\/\/\/\ /\/\/\/\/\/\/\/\/\/\ | 无效空间 | +------------------+ <- 0x40000000(1GB) | | | 实际有效内存 | | | +------------------+ <- 0x00100000 (1MB) | BIOS ROM | +------------------+ <- 0x000F0000 (960KB) | 16-bit devices, | | expansion ROMs | +------------------+ <- 0x000C0000 (768KB) | VGA Display | +------------------+ <- 0x000A0000 (640KB) | | | Low Memory | | | +------------------+ <- 0x00000000
图 6 X86 计算机系统的物理地址空间
(3) 线性地址空间
一台计算机只有一个物理地址空间,但在操作系统的管理下,每个程序都认为自己独占整个计算机的物理地址空间。为了让多个程序能够有效地相互隔离和使用物理地址空间,引入线性地址空间(也称虚拟地址空间)的概念。线性地址空间的大小取决于 CPU 实现的线性地址位数,在基于 80386 的计算机系统中,CPU 的线性地址空间为 4GB。线性地址空间会被映射到某一部分或整个物理地址空间,并通过索引(线性地址)来访问其中的内容。线性地址又称虚拟地址,是进行逻辑地址转换后形成的地址索引,用于寻址线性地址空间。但 CPU 未启动分页机制时,线性地址等于物理地址;当 CPU 启动分页机制时,线性地址还需经过分页地址转换形成物理地址后,CPU 才能访问内存硬件和外设。三种地址的关系如下所示:
- 启动分段机制,未启动分页机制:逻辑地址--> (分段地址转换) -->线性地址==物理地址
- 启动分段和分页机制:逻辑地址--> (分段地址转换) -->线性地址-->分页地址转换) -->物理地址
在操作系统的管理下,采用灵活的内存管理机制,在只有一个物理地址空间的情况下,可以存在多个线性地址空间。一个典型的线性地址空间
3. 硬盘访问概述
bootloader 让 CPU 进入保护模式后,下一步的工作就是从硬盘上加载并运行 OS。考虑到实现的简单性,bootloader 的访问硬盘都是 LBA 模式的 PIO(Program IO)方式,即所有的 IO 操作是通过 CPU 访问硬盘的 IO 地址寄存器完成。
一般主板有 2 个 IDE 通道,每个通道可以接 2 个 IDE 硬盘。访问第一个硬盘的扇区可设置 IO 地址寄存器 0x1f0-0x1f7 实现的,具体参数见下表。一般第一个 IDE 通道通过访问 IO 地址 0x1f0-0x1f7 来实现,第二个 IDE 通道通过访问 0x170-0x17f 实现。每个通道的主从盘的选择通过第 6 个 IO 偏移地址寄存器来设置。
表一 磁盘 IO 地址和对应功能
第6位:为1=LBA模式;0 = CHS模式 第7位和第5位必须为1
IO地址 |
功能 |
0x1f0 |
读数据,当0x1f7不为忙状态时,可以读。 |
0x1f2 |
要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区 |
0x1f3 |
如果是LBA模式,就是LBA参数的0-7位 |
0x1f4 |
如果是LBA模式,就是LBA参数的8-15位 |
0x1f5 |
如果是LBA模式,就是LBA参数的16-23位 |
0x1f6 |
第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘 |
0x1f7 |
状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据 |
当前 硬盘数据是储存到硬盘扇区中,一个扇区大小为 512 字节。读一个扇区的流程(可参看 boot/bootmain.c 中的 readsect 函数实现)大致如下:
- 等待磁盘准备好
- 发出读取扇区的命令
- 等待磁盘准备好
- 把磁盘扇区数据读到指定内存
4. ELF 文件格式概述
ELF(Executable and linking format)文件格式是 Linux 系统下的一种常用目标文件(object file)格式,有三种主要类型:
- 用于执行的可执行文件(executable file),用于提供程序的进程映像,加载的内存执行。 这也是本实验的 OS 文件类型。
- 用于连接的可重定位文件(relocatable file),可与其它目标文件一起创建可执行文件和共享目标文件。
- 共享目标文件(shared object file),连接器可将它与其它可重定位文件和共享目标文件连接成其它的目标文件,动态连接器又可将它与可执行文件和其它共享目标文件结合起来创建一个进程映像。
这里只分析与本实验相关的 ELF 可执行文件类型。ELF header 在文件开始处描述了整个文件的组织。ELF 的文件头包含整个执行文件的控制结构,其定义在 elf.h 中:
struct elfhdr { uint magic; // must equal ELF_MAGIC uchar elf[12]; ushort type; ushort machine; uint version; uint entry; // 程序入口的虚拟地址 uint phoff; // program header 表的位置偏移 uint shoff; uint flags; ushort ehsize; ushort phentsize; ushort phnum; //program header表中的入口数目 ushort shentsize; ushort shnum; ushort shstrndx; };
program header 描述与程序执行直接相关的目标文件结构信息,用来在文件中定位各个段的映像,同时包含其他一些用来为程序创建进程映像所必需的信息。可执行文件的程序头部是一个 program header 结构的数组, 每个结构描述了一个段或者系统准备程序执行所必需的其它信息。目标文件的 “段” 包含一个或者多个 “节区”(section) ,也就是“段内容(Segment Contents)” 。程序头部仅对于可执行文件和共享目标文件有意义。可执行目标文件在 ELF 头部的 e_phentsize 和 e_phnum 成员中给出其自身程序头部的大小。程序头部的数据结构如下表所示:
struct proghdr { uint type; // 段类型 uint offset; // 段相对文件头的偏移值 uint va; // 段的第一个字节将被放到内存中的虚拟地址 uint pa; uint filesz; uint memsz; // 段在内存映像中占用的字节数 uint flags; uint align; };
根据 elfhdr 和 proghdr 的结构描述,bootloader 就可以完成对 ELF 格式的 ucore 操作系统的加载过程(参见 boot/bootmain.c 中的 bootmain 函数)。
[补充材料]
Link addr& Load addr
Link Address 是指编译器指定代码和数据所需要放置的内存地址,由链接器配置。Load Address 是指程序被实际加载到内存的位置(由程序加载器 ld 配置)。一般由可执行文件结构信息和加载器可保证这两个地址相同。Link Addr 和 LoadAddr 不同会导致:
- 直接跳转位置错误
- 直接内存访问(只读数据区或 bss 等直接地址访问)错误
- 堆和栈等的使用不受影响,但是可能会覆盖程序、数据区域 注意:也存在 Link 地址和 Load 地址不一样的情况(例如:动态链接库)。
三:操作系统启动过程
当 bootloader 通过读取硬盘扇区把 ucore 在系统加载到内存后,就转跳到 ucore 操作系统在内存中的入口位置(kern/init.c 中的 kern_init 函数的起始地址),这样 ucore 就接管了整个控制权。当前的 ucore 功能很简单,只完成基本的内存管理和外设中断管理。ucore 主要完成的工作包括:
- 初始化终端;
- 显示字符串;
- 显示堆栈中的多层函数调用关系;
- 切换到保护模式,启用分段机制;
- 初始化中断控制器,设置中断描述符表,初始化时钟中断,使能整个系统的中断机制;
- 执行 while(1)死循环。
以后的实验中会大量涉及各个函数直接的调用关系,以及由于中断处理导致的异步现象,可能对大家实现操作系统和改正其中的错误有很大影响。而理解好函数调用关系的建立机制和中断处理机制,对后续实验会有很大帮助。下面就练习 5 涉及的函数栈调用关系和练习 6 中的中断机制的建立进行阐述。
1. 函数堆栈
栈是一个很重要的编程概念(编译课和程序设计课都讲过相关内容),与编译器和编程语言有紧密的联系。理解调用栈最重要的两点是:栈的结构,EBP 寄存器的作用。一个函数调用动作可分解为:零到多个 PUSH 指令(用于参数入栈),一个 CALL 指令。CALL 指令内部其实还暗含了一个将返回地址(即 CALL 指令下一条指令的地址)压栈的动作(由硬件完成)。几乎所有本地编译器都会在每个函数体之前插入类似如下的汇编指令:
pushl %ebp movl %esp , %ebp
这样在程序执行到一个函数的实际指令前,已经有以下数据顺序入栈:参数、返回地址、ebp 寄存器。由此得到类似如下的栈结构(参数入栈顺序跟调用方式有关,这里以 C 语言默认的 CDECL 为例):
+| 栈底方向 | 高位地址 | ... | | ... | | 参数3 | | 参数2 | | 参数1 | | 返回地址 | | 上一层[ebp] | <-------- [ebp] | 局部变量 | 低位地址
图 7 函数调用栈结构
这两条汇编指令的含义是:首先将 ebp 寄存器入栈,然后将栈顶指针 esp 赋值给 ebp。“mov ebp esp”这条指令表面上看是用 esp 覆盖 ebp 原来的值,其实不然。因为给 ebp 赋值之前,原 ebp 值已经被压栈(位于栈顶),而新的 ebp 又恰恰指向栈顶。此时 ebp 寄存器就已经处于一个非常重要的地位,该寄存器中存储着栈中的一个地址(原 ebp 入栈后的栈顶),从该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数调用时的 ebp 值。
一般而言,ss:[ebp+4]处为返回地址,ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用 4 字节内存),ss:[ebp-4]处为第一个局部变量,ss:[ebp]处为上一层 ebp 值。由于 ebp 中的地址处总是“上一层函数调用时的 ebp 值”,而在每一层函数调用中,都能通过当时的 ebp 值“向上(栈底方向)”能获取返回地址、参数值,“向下(栈顶方向)”能获取函数局部变量值。如此形成递归,直至到达栈底。这就是函数调用栈。
提示:练习 5 的正确实现取决于对这一小节的正确理解和掌握。
2. 中断与异常
操作系统需要对计算机系统中的各种外设进行管理,这就需要 CPU 和外设能够相互通信才行。一般外设的速度远慢于 CPU 的速度。如果让操作系统通过 CPU“主动关心”外设的事件,即采用通常的轮询(polling)机制,则太浪费 CPU 资源了。所以需要操作系统和 CPU 能够一起提供某种机制,让外设在需要操作系统处理外设相关事件的时候,能够“主动通知”操作系统,即打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后在恢复操作系统和应用的正常执行。在操作系统中,这种机制称为中断机制。中断机制给操作系统提供了处理意外情况的能力,同时它也是实现进程/线程抢占式调度的一个重要基石。但中断的引入导致了对操作系统的理解更加困难。
在操作系统中,有三种特殊的中断事件。由 CPU 外部设备引起的外部事件如 I/O 中断、时钟中断、控制台中断等是异步产生的(即产生的时刻不确定),与 CPU 的执行无关,我们称之为异步中断(asynchronous interrupt)也称外部中断,简称中断(interrupt)。而把在 CPU 执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件称作同步中断(synchronous interrupt),也称内部中断,简称异常(exception)。把在程序中使用请求系统服务的系统调用而引发的事件,称作陷入中断(trap interrupt),也称软中断(soft interrupt),系统调用(system call)简称 trap。在后续试验中会进一步讲解系统调用。
本实验只描述保护模式下的处理过程。当 CPU 收到中断(通过 8259A 完成,有关 8259A 的信息请看附录 A)或者异常的事件时,它会暂停执行当前的程序或任务,通过一定的机制跳转到负责处理这个信号的相关处理例程中,在完成对这个事件的处理后再跳回到刚才被打断的程序或任务中。中断向量和中断服务例程的对应关系主要是由 IDT(中断描述符表)负责。操作系统在 IDT 中设置好各种中断向量对应的中断描述符,留待 CPU 在产生中断后查询对应中断服务例程的起始地址。而 IDT 本身的起始地址保存在 idtr 寄存器中。
(1) 中断描述符表(Interrupt Descriptor Table)
中断描述符表把每个中断或异常编号和一个指向中断服务例程的描述符联系起来。同 GDT 一样,IDT 是一个 8 字节的描述符数组,但 IDT 的第一项可以包含一个描述符。CPU 把中断(异常)号乘以 8 做为 IDT 的索引。IDT 可以位于内存的任意位置,CPU 通过 IDT 寄存器(IDTR)的内容来寻址 IDT 的起始地址。指令 LIDT 和 SIDT 用来操作 IDTR。两条指令都有一个显示的操作数:一个 6 字节表示的内存地址。指令的含义如下:
- LIDT(Load IDT Register)指令:使用一个包含线性地址基址和界限的内存操作数来加载 IDT。操作系统创建 IDT 时需要执行它来设定 IDT 的起始地址。这条指令只能在特权级 0 执行。(可参见 libs/x86.h 中的 lidt 函数实现,其实就是一条汇编指令)
- SIDT(Store IDT Register)指令:拷贝 IDTR 的基址和界限部分到一个内存地址。这条指令可以在任意特权级执行。
IDT 和 IDTR 寄存器的结构和关系如下图所示:
图 8 IDT 和 IDTR 寄存器的结构和关系图
在保护模式下,最多会存在 256 个 Interrupt/Exception Vectors。范围[0,31]内的 32 个向量被异常 Exception 和 NMI 使用,但当前并非所有这 32 个向量都已经被使用,有几个当前没有被使用的,请不要擅自使用它们,它们被保留,以备将来可能增加新的 Exception。范围[32,255]内的向量被保留给用户定义的 Interrupts。Intel 没有定义,也没有保留这些 Interrupts。用户可以将它们用作外部 I/O 设备中断(8259A IRQ),或者系统调用(System Call 、Software Interrupts)等。
(2) IDT gate descriptors
Interrupts/Exceptions 应该使用 Interrupt Gate 和 Trap Gate,它们之间的唯一区别就是:当调用 Interrupt Gate 时,Interrupt 会被 CPU 自动禁止;而调用 Trap Gate 时,CPU 则不会去禁止或打开中断,而是保留它原来的样子。
【补充】所谓“自动禁止”,指的是 CPU 跳转到 interrupt gate 里的地址时,在将 EFLAGS 保存到栈上之后,清除 EFLAGS 里的 IF 位,以避免重复触发中断。在中断处理例程里,操作系统可以将 EFLAGS 里的 IF 设上,从而允许嵌套中断。但是必须在此之前做好处理嵌套中断的必要准备,如保存必要的寄存器等。二在 ucore 中访问 Trap Gate 的目的是为了实现系统调用。用户进程在正常执行中是不能禁止中断的,而当它发出系统调用后,将通过 Trap Gate 完成了从用户态(ring 3)的用户进程进了核心态(ring 0)的 OS kernel。如果在到达 OS kernel 后禁止 EFLAGS 里的 IF 位,第一没意义(因为不会出现嵌套系统调用的情况),第二还会导致某些中断得不到及时响应,所以调用 Trap Gate 时,CPU 则不会去禁止中断。总之,interrupt gate 和 trap gate 之间没有优先级之分,仅仅是 CPU 在处理中断时有不同的方法,供操作系统在实现时根据需要进行选择。
在 IDT 中,可以包含如下 3 种类型的 Descriptor:
- Task-gate descriptor (这里没有使用)
- Interrupt-gate descriptor (中断方式用到)
- Trap-gate descriptor(系统调用用到)
下图图显示了 80386 的任务门描述符、中断门描述符、陷阱门描述符的格式:
图 9 X86 的各种门的格式
可参见 kern/mm/mmu.h 中的 struct gatedesc 数据结构对中断描述符的具体定义。
(3) 中断处理中硬件负责完成的工作
中断服务例程包括具体负责处理中断(异常)的代码是操作系统的重要组成部分。需要注意区别的是,有两个过程由硬件完成:
- 硬件中断处理过程 1(起始):从 CPU 收到中断事件后,打断当前程序或任务的执行,根据某种机制跳转到中断服务例程去执行的过程。其具体流程如下: - CPU 在执行完当前程序的每一条指令后,都会去确认在执行刚才的指令过程中中断控制器(如:8259A)是否发送中断请求过来,如果有那么 CPU 就会在相应的时钟脉冲到来时从总线上读取中断请求对应的中断向量; - CPU 根据得到的中断向量(以此为索引)到 IDT 中找到该向量对应的中断描述符,中断描述符里保存着中断服务例程的段选择子; - CPU 使用 IDT 查到的中断服务例程的段选择子从 GDT 中取得相应的段描述符,段描述符里保存了中断服务例程的段基址和属性信息,此时 CPU 就得到了中断服务例程的起始地址,并跳转到该地址; - CPU 会根据 CPL 和中断服务例程的段描述符的 DPL 信息确认是否发生了特权级的转换。比如当前程序正运行在用户态,而中断程序是运行在内核态的,则意味着发生了特权级的转换,这时 CPU 会从当前程序的 TSS 信息(该信息在内存中的起始地址存在 TR 寄存器中)里取得该程序的内核栈地址,即包括内核态的 ss 和 esp 的值,并立即将系统当前使用的栈切换成新的内核栈。这个栈就是即将运行的中断服务程序要使用的栈。紧接着就将当前程序使用的用户态的 ss 和 esp 压到新的内核栈中保存起来; - CPU 需要开始保存当前被打断的程序的现场(即一些寄存器的值),以便于将来恢复被打断的程序继续执行。这需要利用内核栈来保存相关现场信息,即依次压入当前被打断程序使用的 eflags,cs,eip,errorCode(如果是有错误码的异常)信息; - CPU 利用中断服务例程的段描述符将其第一条指令的地址加载到 cs 和 eip 寄存器中,开始执行中断服务例程。这意味着先前的程序被暂停执行,中断服务程序正式开始工作。
- 硬件中断处理过程 2(结束):每个中断服务例程在有中断处理工作完成后需要通过 iret(或 iretd)指令恢复被打断的程序的执行。CPU 执行 IRET 指令的具体过程如下: - 程序执行这条 iret 指令时,首先会从内核栈里弹出先前保存的被打断的程序的现场信息,即 eflags,cs,eip 重新开始执行; - 如果存在特权级转换(从内核态转换到用户态),则还需要从内核栈中弹出用户态栈的 ss 和 esp,这样也意味着栈也被切换回原先使用的用户态的栈了; - 如果此次处理的是带有错误码(errorCode)的异常,CPU 在恢复先前程序的现场时,并不会弹出 errorCode。这一步需要通过软件完成,即要求相关的中断服务例程在调用 iret 返回之前添加出栈代码主动弹出 errorCode。
下图显示了从中断向量到 GDT 中相应中断服务程序起始位置的定位方式:
图 10 中断向量与中断服务例程起始地址的关系
(4) 中断产生后的堆栈栈变化
下图显示了给出相同特权级和不同特权级情况下中断产生后的堆栈栈变化示意图:
图 11 相同特权级和不同特权级情况下中断产生后的堆栈栈变化示意图
(5) 中断处理的特权级转换
中断处理得特权级转换是通过门描述符(gate descriptor)和相关指令来完成的。一个门描述符就是一个系统类型的段描述符,一共有 4 个子类型:调用门描述符(call-gate descriptor),中断门描述符(interrupt-gate descriptor),陷阱门描述符(trap-gate descriptor)和任务门描述符(task-gate descriptor)。与中断处理相关的是中断门描述符和陷阱门描述符。这些门描述符被存储在中断描述符表(Interrupt Descriptor Table,简称 IDT)当中。CPU 把中断向量作为 IDT 表项的索引,用来指出当中断发生时使用哪一个门描述符来处理中断。中断门描述符和陷阱门描述符几乎是一样的。中断发生时实施特权检查的过程如下图所示:
图 12 中断发生时实施特权检查的过程
门中的 DPL 和段选择符一起控制着访问,同时,段选择符结合偏移量(Offset)指出了中断处理例程的入口点。内核一般在门描述符中填入内核代码段的段选择子。产生中断后,CPU 一定不会将运行控制从高特权环转向低特权环,特权级必须要么保持不变(当操作系统内核自己被中断的时候),或被提升(当用户态程序被中断的时候)。无论哪一种情况,作为结果的 CPL 必须等于目的代码段的 DPL。如果 CPL 发生了改变,一个堆栈切换操作(通过 TSS 完成)就会发生。如果中断是被用户态程序中的指令所触发的(比如软件执行 INT n 生产的中断),还会增加一个额外的检查:门的 DPL 必须具有与 CPL 相同或更低的特权。这就防止了用户代码随意触发中断。如果这些检查失败,会产生一个一般保护异常(general-protection exception)。
3. lab1 中对中断的处理实现
(1) 外设基本初始化设置
Lab1 实现了中断初始化和对键盘、串口、时钟外设进行中断处理。串口的初始化函数 serial_init(位于/kern/driver/console.c)中涉及中断初始化工作的很简单:
...... // 使能串口1接收字符后产生中断 outb(COM1 + COM_IER, COM_IER_RDI); ...... // 通过中断控制器使能串口1中断 pic_enable(IRQ_COM1);
键盘的初始化函数 kbd_init(位于 kern/driver/console.c 中)完成了对键盘的中断初始化工作,具体操作更加简单:
...... // 通过中断控制器使能键盘输入中断 pic_enable(IRQ_KBD);
时钟是一种有着特殊作用的外设,其作用并不仅仅是计时。在后续章节中将讲到,正是由于有了规律的时钟中断,才使得无论当前 CPU 运行在哪里,操作系统都可以在预先确定的时间点上获得 CPU 控制权。这样当一个应用程序运行了一定时间后,操作系统会通过时钟中断获得 CPU 控制权,并可把 CPU 资源让给更需要 CPU 的其他应用程序。时钟的初始化函数 clock_init(位于 kern/driver/clock.c 中)完成了对时钟控制器 8253 的初始化:
...... //设置时钟每秒中断100次 outb(IO_TIMER1, TIMER_DIV(100) % 256); outb(IO_TIMER1, TIMER_DIV(100) / 256); // 通过中断控制器使能时钟中断 pic_enable(IRQ_TIMER);
(2) 中断初始化设置
操作系统如果要正确处理各种不同的中断事件,就需要安排应该由哪个中断服务例程负责处理特定的中断事件。系统将所有的中断事件统一进行了编号(0 ~ 255),这个编号称为中断向量。以 ucore 为例,操作系统内核启动以后,会通过 idt_init 函数初始化 idt 表 (参见 trap.c),而其中 vectors 中存储了中断处理程序的入口地址。vectors 定义在 vector.S 文件中,通过一个工具程序 vector.c 生成。其中仅有 System call 中断的权限为用户权限 (DPL_USER),即仅能够使用 int 0x80 指令。此外还有对 tickslock 的初始化,该锁用于处理时钟中断。
vector.S 文件通过 vectors.c 自动生成,其中定义了每个中断的入口程序和入口地址 (保存在 vectors 数组中)。其中,中断可以分成两类:一类是压入错误编码的 (error code),另一类不压入错误编码。对于第二类, vector.S 自动压入一个 0。此外,还会压入相应中断的中断号。在压入两个必要的参数之后,中断处理函数跳转到统一的入口 alltraps 处。
(3) 中断的处理过程
trap 函数(定义在 trap.c 中)是对中断进行处理的过程,所有的中断在经过中断入口函数__alltraps 预处理后 (定义在 trapasm.S 中) ,都会跳转到这里。在处理过程中,根据不同的中断类型,进行相应的处理。在相应的处理过程结束以后,trap 将会返回,被中断的程序会继续运行。整个中断处理流程大致如下:
trapasm.S |
trap.c |
||||||||||||
1)产生中断后,CPU 跳转到相应的中断处理入口 (vectors),并在桟中压入相应的 error_code(是否存在与异常号相关) 以及 trap_no,然后跳转到 alltraps 函数入口: 注意:此处的跳转是 jmp 过程
在栈中保存当前被打断程序的 trapframe 结构(参见过程trapasm.S)。设置 kernel (内核) 的数据段寄存器,最后压入 esp,作为 trap 函数参数(struct trapframe * tf) 并跳转到中断处理函数 trap 处:
注意:此时的跳转是 call 调用,会压入返回地址 eip,注意区分此处eip与trapframe中eip: trapframe的结构为: 进入 trap 函数,对中断进行相应的处理: |
|||||||||||||
2)详细的中断分类以及处理流程如下: 根据中断号对不同的中断进行处理。其中,若中断号是IRQ_OFFSET + IRQ_TIMER 为时钟中断,则把ticks 将增加一。 若中断号是IRQ_OFFSET + IRQ_COM1 为串口中断,则显示收到的字符。 若中断号是IRQ_OFFSET + IRQ_KBD 为键盘中断,则显示收到的字符。 若为其他中断且产生在内核状态,则挂起系统; |
|||||||||||||
3)结束 trap 函数的执行后,通过 ret 指令返回到 alltraps 执行过程。 从栈中恢复所有寄存器的值。 调整 esp 的值:跳过栈中的 trap_no 与 error_code,使esp指向中断返回 eip,通过 iret 调用恢复 cs、eflag以及 eip,继续执行。 |
|||||||||||||
图 13 ucore 中断处理流程
至此,对整个 lab1 中的主要部分的背景知识和实现进行了阐述。请大家能够根据前面的练习要求完成所有的练习。