MIT 6.828 Operating System Engineering
当x86 PC启动时, 它将开始执行名为BIOS的程序,该程序存储在主板上的非易失性内存中。BIOS 的工作是准备硬件,然后将控制权转移到操作系统。具体来说,它将控制权转移到从引导扇区加载的代码,即引导磁盘的第一个512字节扇区。引导扇区包含引导加载程序,即将内核加载到内存中的指令。BIOS在内存地址0x7c00处加载引导扇区, 然后跳转(设置处理器的%ip
)到该地址。当引导加载程序开始执行时,处理器正在模拟英特尔8088,加载程序的工作是将处理器置于更现代的操作模式,将xv6内核从磁盘加载到内存中,然后将控制权转移到内核。xv6引导加载程序由两个源文件组成,一个由16位和32位x86汇编语言编写,另一个由C语言编写。
Assembly bootstrap
1 | // 译者引入 |
引导加载程序中的第一个指令是cli
(8912),它禁用处理器中断。中断是硬件设备调用称为中断处理程序的操作系统功能的一种方式。BIOS是一个很小的操作系统,它可能已经设置了自己的中断处理程序,作为初始化硬件的一部分。但是BIOS不再运行(轮到引导加载程序运行),因此处理来自硬件设备的中断不再合适或安全。当xv6准备就绪时(在第3章中),它将重新启用中断。
处理器处于实模式(real mode),在该模式下模拟英特尔8088。在实模式下,有8个16位通用寄存器,但处理器向内存发送20位地址。段寄存器%cs
% ds
%es
和%ss
提供了从16位寄存器生成20位内存地址所需的额外位。当程序引用内存地址时,处理器会自动添加一个段寄存器的值的16倍(即左移4位,译者注),这些寄存器是16位宽。因此,内存引用中其实隐含地使用了段寄存器的值:指令提取使用%cs
(code segment,译注),数据读取和写入使用%ds
(data segment,译注), 堆栈读取和写入使用%ss
(stack segment,译注)。
Xv6假装x86指令对其内存操作使用虚拟地址,但x86指令实际上使用逻辑地址(参见图B-1)。逻辑地址由段选择器(segment selector)和偏移量(offset)组成,有时将其写成segment:offset
。更常见的情况是,会忽略segment,程序直接操作offset。Segmentation hardware执行上述转换以生成线性地址(linear address)。如果启用分页硬件(请参阅第2章),则会将线性地址转换为物理地址;否则处理器使用线性地址作为物理地址。
引导加载程序不启用分页硬件;它使用的逻辑地址由Segmentation hardware转换为线性地址,然后直接用作物理地址。Xv6将Segmentation hardware配置为从逻辑地址转换为线性地址时不进行更改,以便它们始终相等。由于历史原因,我们使用了“虚拟地址”(virtual address)一词来指程序操纵的地址;xv6的虚拟地址就是x86上的逻辑地址,也就是Segmentation hardware映射到的得到的线性地址。启用分页后,系统中唯一有趣的地址映射就是从线性地址到物理地址。
BIOS不保证%ds
%es
%ss
的内容,因此禁用中断后的第一步是将%ax
设置为零,然后将零复制到%ds
%es
%ss
(8915-8918)中。
虚拟地址segment:offset
可以产生21位物理地址,但英特尔8088只能寻址20位内存,因此它丢弃了顶部:0xffff0+0xffff = 0x10ffef,但在Intel 8088上,虚拟地址0xffff:0xffff指的是物理地址0x0ffef。一些早期的软件依赖于硬件去忽略21地址位,因此,当英特尔引入具有20位以上的物理地址的处理器时,IBM提供了一个兼容的技巧用来匹配兼容的硬件。如果键盘控制器的输出端口的第二位是低电平,则始终清除第21位物理地址,否则就会保留第21位。Boot loader使用键盘控制器上的0x64和0x60两个I/O端口来启用第21个地址位(第8920-8936行)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// 译者引入
# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# 1MB wrap around to zero by default. This code undoes this.
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
实模式的16位通用和分段寄存器使程序使用超过65536字节的内存变得很尴尬,并且不可能使用超过兆字节的内存。自80286以来,x86处理器具有保护模式(protected mode),允许物理地址具有更多的位。并且自80386以来,“32位”模式会导致寄存器、虚拟地址和大多数整数算法使用32位而不是16位执行。xv6启用保护模式和32位模式的顺序如下所示。
在保护模式下,段寄存器是段描述符表(segment descriptor table)索引(见图 B-2)。每个表条目表明了一个基本物理地址,一个称为limit的最大虚拟地址和段的权限位。这些权限位就是保护模式下的保护措施,因为内核使用它们去保证程序只能使用属于它自己的内存。
xv6几乎不使用段,而是使用第2章所描述的分页硬件。引导加载程序设置段描述符表gdt
(第8982-8985行),以便所有段的基址为零和可能的最大限制(4GB)。该表有一个空条目,一个可执行代码的条目,和一个数据条目。代码段描述符具有一个标志集, 指示代码应在32位模式(0660)下运行。使用此设置,当引导加载程序进入保护模式时,逻辑地址将一对一映射到物理地址。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 译者引入
// 第8982-8985行
# Bootstrap GDT
.p2align 2
gdt:
SEG_NULL
SEG(STA_X|STA_R, 0x0, 0xffffffff)
SEG(STA_W, 0x0, 0xffffffff)
// 660行
// The 0xC0 means the limit is in 4096−byte units
// and (for executable segments) 32−bit mode.
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff);
.byte (((base) >> 16) & 0xff), (0x90 | (type)),
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
引导加载程序执行 lgdt
指令(8941),将处理器的全局描述符表(GDT)寄存器加载到值gdtdesc
(8987-8989),该值指向表gdt
。
1 | // 译者引入 |
一旦加载GDT寄存器后,引导加载程序通过在寄存器%cr0
(8942-8944)中设置1位(CR0_PE)来启用保护模式。启用保护模式不会立即更改处理器将逻辑转换为物理地址的方式;只有当将一个新值加载到段寄存器中时,处理器才会读取GDT并更改其内部段设置。但是因为不能直接修改%cs
,代码通过执行ljmp
(far jump)指令(8953)的方式,来指定代码段选择器。跳转将在下一行(8956)继续执行,但在此过程中设置%cs
以引用gdt
中的代码描述符项。该描述符描述了32位代码段,因此处理器切换到32位模式。引导加载程序随着处理器从8088到80286到80386的演变而不断改变。
引导加载程序在32位模式下的第一个操作是使用SEG _ KDATA(8958-8961)初始化数据段寄存器。逻辑地址现在直接映射到物理地址。在执行C代码之前剩下的唯一步骤是在未使用的内存区域中设置堆栈。0xa0000到0x100000的内存通常散落在设备内存区域中,xv6内核预计将被放置在0x100000。引导加载程序本身位于0x7c00到0x7c00。从本质上讲,内存的任何其他部分都是堆栈的良好位置。引导加载程序选择0x7c00(在此文件中称为$start)作为堆栈的顶部;堆栈将从那里向0x0000增长,远离引导加载程序
最后,引导加载程序调用C函数bootmain(8968)。Bootmain的工作是加载和运行内核。只有在出了问题的情况下,它才会回来。在这种情况下, 代码会在端口0x8a00(8970-8976)上发送几个输出字。在实际硬件上, 没有设备连接到该端口,因此此代码不执行任何操作。如果引导加载程序在PC模拟器内运行,则端口0x8a00将连接到模拟器本身,并可以将控制权转移回模拟器。如果没有模拟器,代码然后执行无限循环(8977-8978)。真正的引导加载程序可能会尝试先打印错误消息。
1 | .code32 # Tell assembler to generate 32−bit code now. |
C bootstrap
引导加载程序的C部分(boot. c(9000))希望在第二个扇区开始的磁盘上找到可执行内核的副本。正如我们在第2章中所看到的,内核是一个ELF格式的二进制文件。为了获取ELF标头,bootmain加载ELF二进制文件的前4096字节(9014),并放在内存地址0x10000。
下一步是快速检查这是ELF二进制文件,而不是未初始化的磁盘。Bootmain从ELF标头off字节后开始读取节的内容,并从地址paddr
开始写入内存。Bootmain调用readseg
从磁盘加载数据(9038)并调用stosb
为段的其余部分(9040)为零。Stosb(0492)使用x86指令代表stosb
初始化内存块的每个字节。
内核已经编译和链接,因此它希望从虚拟地址0x80100000开始找到自己。因此,函数调用指令必须看起来像0x801xxxxx的目标地址;你可以在kernel.asm中看到例子。此地址是在kernel.ld中配置的。0x80100000是一个相对较高的地址,接近32位地址空间的末尾,第2章解释了这一选择的原因。在如此高的地址可能没有任何物理内存。一旦内核开始执行,它将设置分页硬件,将从0x80100000开始的虚拟地址映射到0x00100000开始的物理地址;内核假定在这个较低的地址有物理内存。但是,在引导过程中的这一点上,未启用分页。相反,kernel.ld指定ELF paddr从0x00100000开始,这将导致引导加载程序将内核复制到分页硬件最终将指向的低物理地址。
引导加载程序的最后一步是调用内核的入口点,这是内核希望开始执行的指令。对于xv6,入口地址为 0x10000c:1
2
3
4
5
6$ objdump -f obj/kern/kernel
kernel: file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c
按照惯例,_start
符号指定了ELF入口点,该值定义在文件entry.S(1036)。由于xv6尚未设置虚拟内存,因此xv6的入口点是entry(1040)的物理地址。1
2
3
4
5
6
7// 译者引入
# '_start' specifies the ELF entry point. Since we haven't set up
.globl _start
_start = RELOC(entry)
Real world
本附录中描述的引导加载程序将编译为大约470字节的机器代码,具体取决于编译C代码时使用的优化。为了适应这少量的空间,xv6引导加载程序做出了一个主要的简化假设,即内核已连续从扇区1开始写入引导磁盘。更常见的情况是,内核存储在普通文件系统中,在这些文件系统中,内核可能不是连续的,或者是通过网络加载的。这些复杂情况要求引导加载程序能够驱动各种磁盘和网络控制器,并了解各种文件系统和网络协议。换句话说,引导加载程序本身必须是一个小型操作系统。由于这种复杂的引导加载程序肯定不止512字节,大多数PC操作系统启动过程分为两步。首先,像本附录中的一个简单的引导加载程序从已知的磁盘位置加载一个功能齐全的引导加载程序,通常依赖于空间限制较少的BIOS进行磁盘访问,而不是试图驱动磁盘本身。然后,完全加载程序,摆脱512字节的限制,可以实现定位、加载和执行所需内核所需的复杂性。也许更现代的设计会让BIOS直接从磁盘读取更大的引导加载程序 (并在受保护和32位模式下启动它)。
编写本附录时,似乎在开机和执行引导加载程序之间发生的唯一事情是BIOS加载引导扇区。事实上,BIOS做了大量的初始化,以使现代计算机的复杂硬件看起来像一个传统的标准PC。
Exercises
由于扇区粒度,文本中对
readseg
的调用等效于readseg((uchar*)0x100000、0xb500、0x1000)
。实际上, 这种马虎的行为原来不是问题。为什么粗粒度的readsect不会引起问题?有关BIOS持续较长+安全问题的内容
假设您希望bootmain()以0x200000而不是0x100000去加载内核,并且您是通过修改bootmain()将0x200000添加到每个ELF节的va部分的来完成的。这是会出事的。为什么?
引导加载程序将ELF标头复制到任意位置0x10000的内存似乎具有潜在的危险。为什么不叫malloc来获得它所需要的内存呢?