PC 硬件
本文介绍供 x86 运行的个人计算机(PC)硬件平台。
PC 是指遵守一定工业标准的计算机,它的目标是使得不同厂家生产的机器都能够运行一定范围内的软件。这些标准随时时间迁移不断变化,因此90年代的 PC 与今日的 PC 看起来已是大不相同。
从外观来看,PC 是一个配置有键盘、屏幕和各种设备的"盒子"。盒子内部则是一块集成电路——主板,上面有 CPU 芯片,内存芯片,显卡芯片,I/O 控制器芯片,以及负责芯片间通信的总线。总线会遵守某种标准(如 PCI 或 USB),从而能够兼容不同厂家的设备。
我们可以把 PC 抽象为三部分:CPU、内存和 I/O 设备。CPU 负责计算,内存用于保存计算用的指令和数据,其他设备用于实现存储、通讯等其他功能。
你可以想象主存以一组导线与 CPU 相连接,其中一些是地址线,一些是数据线,还有一些则是控制线。CPU 要从主存读出一个值,需要向地址线上输出一系列表示0和1的电压,并在规定的时间内在 "读" 线上发出信号1,接下来再从数据线上的高低电压中获取数据。CPU 若要向内存中写入一个值,则向数据线和地址线上写入合适的值,并在规定时间内在 "写" 位上发出信号1.真实的内存接口比这复杂的多,但除非你是在追求高性能,否则你不必考虑这么多的细节。
处理器和内存
CPU(*处理单元,或处理器)其实只是在执行一个非常简单的循环:从一个被称为『程序计数器』的寄存器中获取一个内存地址,从个地址读出机器指令,增加程序计数器的值,执行机器指令,不断反复。某些机器指令如分支和函数调用会改变程序计数器,如果执行机器指令没有改变程序计数器,这个循环就会从程序计数器开始一条一条地执行指令。
如果不能保存和修改程序数据,那么执行指令就是毫无意义的。速度最快的数据存储器是处理器的寄存器组。一个寄存器是处理器内的一个存储单元,能够保存一个字大小的值(按照机器不同,一个字通常会是16,32或者64位)。寄存器内的值能在一个 CPU 周期内被快速地读写。
PC 处理器实现了 x86 指令集,该指令集由 Intel 发布并成为了一种标准,一些产商生产实现了该指令集的处理器。和其他的 PC 标准一样,这个标准也在不断更新,但是新的标准是向前兼容的。由于 PC 处理器启动时都是模拟1981年 IBM PC 上使用的芯片 Intel 8088,所以 boot loader 需要作出改变以应对标准的更新。但是,对于 xv6 的绝大部分内容,你只需要关心现代 x86 指令集。
现代 x86 提供了8个32位通用寄存器--%eax, %ebx, %ecx, %edx, %edi, %esi, %ebp, %esp 和一个程序计数器 %eip(instruction pointer)。前缀e是指扩展的(extended),表示它们是16位寄存器%ax, %bx, %cx, %dx, %di, %si, %bp, %sp 的32位扩展。这两套寄存器其实是相互的别名,例如 %ax 是 %eax 的低位:我们在写 %ax 的时候也会改变 %eax,反之亦然。前四个寄存器的两个低8位还有自己的名字:%al, %ah 分别表示 %ax 的低8位和高8位,%bl, %bh, %cl, %ch, %dl, %dh同理。另外,x86 还有8个80位的浮点寄存器,以及一系列特殊用途的寄存器如控制寄存器 %cr0, %cr2, %cr3, %cr4,调试寄存器 %dr0, %dr1, %dr2, %dr3;段寄存器 %cs, %ds, %es, %fs, %gs, %ss;还有全局和局部描述符表的伪寄存器%gdtr, %ldtr。控制寄存器和段寄存器对于任何操作系统都是非常重要的。浮点寄存器和调试寄存器则没那么有意思,并且也没有在 xv6 中使用。
寄存器非常快但是也非常昂贵。大多数处理器都会提供至多数十个通用寄存器。下一个层次的存储器是随机存储器(RAM)。主存的速度大概比寄存器慢10到100倍,但要便宜得多,所以容量可以更大。主存较慢的一个原因是它不在处理器芯片上。一个 x86 处理器只有十多个寄存器,但今天的 PC 通常有 GB 级的主存。由于寄存器和主存在读写速度和大小上的巨大差异,大多数处理器,包括 x86,都在芯片上的缓存中保存了最近使用的主存数据。缓存是主存和寄存器在速度和大小上的折衷。现在的 x86 处理器通常有二级缓存,第一级较小,读写速率接近处理器的时钟周期,第二级较大,读写速率在第一级缓存和主存之间。下表显示了 Intel Core 2 Duo 系统的实际数据:
Intel Core 2 Duo E7200 at 2.53 GHz 备忘:换上真实数字! |存储器 | 读写时间 | 大小 | |-------|--------|-----| |寄存器|0.6ns|64 字节| |L1缓存|0.5ns|64K 字节| |L2缓存|10ns|4M 字节| |主存|100ns|4G 字节|
通常 x86 对操作系统隐藏了缓存,所以我们只需要考虑寄存器和主存两种存储器,不用担心主存的层次结构引发的差异。
I/O
处理器必须像和主存交互一样同设备交互。x86 处理提供了特殊的 in, out 指令来在设备地址(称为'I/O 端口') 上读写。这两个指令的硬件实现本质上和读写内存是相同的。早期的 x86 处理器有一条附加的地址线:0表示从 I/O 端口读写,1则表示从主存读写。每个硬件设备会处理它所在 I/O 端口所接收到的读写操作。设备的端口使得软件可以配置设备,检查状态,使用设备;例如,软件可以通过对 I/O 端口的读写,使磁盘接口硬件对磁盘扇区进行读写。
很多计算机体系结构都没有单独的设备访问指令,取而代之的是让设备拥有固定的内存地址,然后通过内存读写实现设备读写。实际上现代 x86 体系结构就在大多数高速设备上(如网络、磁盘、显卡控制器)使用了该技术,叫做 内存映射 I/O。但由于向前兼容的原因,in, out 指令仍能使用,而比较老的设备如 xv6 中使用的 IDE 磁盘控制器仍使用两个指令。
引导加载器(boot loader)
当 x86 PC 启动时,它执行的是一个叫 BIOS 的程序。BIOS 存放在非易失存储器中,BIOS 的作用是在启动时进行硬件的准备工作,接着把控制权交给操作系统。具体来说,BIOS 会把控制权交给从引导扇区(用于引导的磁盘的第一个512字节的数据区)加载的代码。引导扇区中包含引导加载器——负责内核加载到内存中。BIOS 会把引导扇区加载到内存 0x7c00 处,接着(通过设置寄存器 %ip)跳转至该地址。引导加载器开始执行后,处理器处于模拟 Intel 8088 处理器的模式下。而接下来的工作就是把处理器设置为现代的操作模式,并从磁盘中把 xv6 内核载入到内存中,然后将控制权交给内核。xv6 引导加载器包括两个源文件,一个由16位和32位汇编混合编写而成(bootasm.S;(8400)),另一个由 C 写成(bootmain.c;(8500))。
代码:汇编引导程序
引导加载器的第一条指令 cli(8412)屏蔽处理器中断。硬件可以通过中断触发中断处理程序,从而调用操作系统的功能。BIOS 作为一个小型操作系统,为了初始化硬件设备,可能设置了自己的中断处理程序。但是现在 BIOS 已经没有了控制权,而是引导加载器正在运行,所以现在还允许中断不合理也不安全。当 xv6 准备好了后(详见第3章),它会重新允许中断。
现在处理器处在模拟 Intel 8088 的实模式下,有8个16位通用寄存器可用,但实际上处理器发送给内存的是20位的地址。这时,多出来的4位其实是由段寄存器%cs, %ds, %es, %ss提供的。当程序用到一个内存地址时,处理器会自动在该地址上加上某个16位段寄存器值的16倍。因此,内存引用中其实隐含地使用了段寄存器的值:取指会用到 %cs,读写数据会用到 %ds,读写栈会用到 %ss。
xv6 假设 x86 指令在做内存操作时使用的是虚拟地址,但实际上 x86 指令使用的是逻辑地址(见表 B-1)。逻辑地址由段选择器和偏移组成,有时又被写作segmemt:offset。更多时候,段是隐含的,所以程序会直接使用偏移。分段硬件会完成上述处理,从而产生一个线性地址。如果允许分页硬件工作(见第2章),分页硬件则会把线性地址翻译为物理地址;否则处理器直接把线性地址看作物理地址。
引导加载器还没有允许分页硬件工作;它通过分段硬件把逻辑地址转化为线性地址,然后直接作为物理地址使用。xv6 会配置分段硬件,使之不对逻辑地址做任何改变,直接得到线性地址,所以线性地址和逻辑地址是相等的。由于历史原因我们用虚拟地址这个术语来指程序操作时用的地址。xv6 的虚拟地址等于 X86 的逻辑地址,同样也等于分段硬件映射的线性地址。等到开启了分页后,系统中值得关心的就只有从线性地址到物理地址的映射。
BIOS 完成工作后,%ds, %es, %ss 的值是未知的,所以在屏蔽中断后,引导加载器的第一个工作就是将 %ax 置零,然后把这个零值拷贝到三个段寄存器中(8415-8418)。
虚拟地址 segment:offset 可能产生21位物理地址,但 Intel 8088 只能向内存传递20位地址,所以它截断了地址的最高位:0xffff0 + 0xffff = 0x10ffef,但在8088上虚拟地址 0xffff:0xffff 则是引用物理地址 0x0ffef。早期的软件依赖硬件来忽略第21位地址位,所以当 Intel 研发出使用超过20位物理地址的处理器时,IBM 就想出了一个技巧来保证兼容性。那就是,如果键盘控制器输出端口的第2位是低位,则物理地址的第21位被清零;否则,第21位可以正常使用。引导加载器用 I/O 指令控制端口 0x64 和 0x60 上的键盘控制器,使其输出端口的第2位为高位,来使第21位地址正常工作(8436)。
对于使用内存超过65536字节的程序而言,实模式的16位寄存器和段寄存器就显得非常困窘了,显然更不可能使用超过 1M 字节的内存。x86系列处理器在80286之后就有了保护模式。保护模式下可以使用更多位的地址,并且(80386之后)有了"32位"模式使得寄存器,虚拟地址和大多数的整型运算都从16位变成了32位。xv6 引导程序依次允许了保护模式和32位模式。
在保护模式下,段寄存器保存着段描述符表的索引(见图表 B-2)。每一个表项都指定了一个基物理地址,最大虚拟地址(称为限制),以及该段的权限位。这些权限位在保护模式下起着保护作用,内核可以根据它们来保证一个程序只使用属于自己的内存。
xv6 几乎没有使用段;取而代之的是第2章讲述的分页。引导加载器将段描述符表 gdt(8482-8485)中的每个段的基址都置零,并让所有段都有相同的内存限制(4G字节)。该表中有一个空指针表项,一个可执行代码的表项,一个数据的表项。代码段描述符的标志位中指示了代码只能在32位模式下执行(0660)。正是由于这样的设置,引导加载器在进入保护模式时,逻辑地址才会直接映射为物理地址。
引导加载器执行 lgdt(8441)指令来把指向 gdt 的指针 gdtdesc(8487-8489)加载到全局描述符表(GDT)寄存器中。
加载完毕后,引导加载器将 %cr0 中的 CR0_PE 位置为1,从而开启保护模式。允许保护模式并不会马上改变处理器把逻辑地址翻译成物理地址的过程;只有当某个段寄存器加载了一个新的值,然后处理器通过这个值读取 GDT 的一项从而改变了内部的段设置。我们没法直接修改 %cs,所以使用了一个 ljmp 指令(8453)。跳转指令会接着在下一行(8456)执行,但这样做实际上将 %cs 指向了 gdt 中的一个代码描述符表项。该描述符描述了一个32位代码段,这样处理器就切换到了32位模式下。就这样,引导加载器让处理器从8088进化到80286,接着进化到了80386。
在32位模式下,引导加载器首先用 SEG_KDATA(8458-8461)初始化了数据段寄存器。逻辑地址现在是直接映射到物理地址的。运行 C 代码之前的最后一个步骤是在空闲内存中建立一个栈。内存 0xa0000 到 0x100000 属于设备区,而 xv6 内核则是放在 0x100000 处。引导加载器自己是在 0x7c00 到 0x7d00。本质上来讲,内存的其他任何部分都能用来存放栈。引导加载器选择了 0x7c00(在该文件中即 $start)作为栈顶;栈从此处向下增长,直到 0x0000,不断远离引导加载器代码。
最后加载器调用 C 函数 bootmain(8468)。bootmain 的工作就是加载并运行内核。只有在出错时该函数才会返回,这时它会向端口 0x8a00(8470-8476)输出几个字。在真实硬件中,并没有设备连接到该端口,所以这段代码相当于什么也没有做。如果引导加载器是在 PC 模拟器上运行,那么端口 0x8a00 则会连接到模拟器并把控制权交还给模拟器本身。无论是否使用模拟器,这段代码接下来都会执行一个死循环(8477-8478)。而一个真正的引导加载器则应该会尝试输出一些调试信息。
代码:C 引导程序
引导加载器的 C 语言部分 bootmain.c(8500)目的是在磁盘的第二个扇区开头找到内核程序。如我们在第2章所见,内核是 ELF 格式的二进制文件。为了读取 ELF 头,bootmain 载入 ELF 文件的前4096字节(8514),并将其拷贝到内存中 0x10000 处。
下一步要通过 ELF 头检查这是否的确是一个 ELF 文件。bootmain 从磁盘中 ELF 头之后 off 字节处读取扇区的内容,并写到内存中地址 paddr 处。bootmain 调用 readseg 将数据从磁盘中载入(8538),并调用 stosb 将段的剩余部分置零(8540)。stosb(0492)使用 x86 指令 rep stosb 来初始化内存块中的每个字节。
在内核编译和链接后,我们应该能在虚拟地址 0x80100000 处找到它。因此,函数调用指令使用的地址都是 0xf01xxxxx 的形式;你可以在 kernel.asm 中找到类似的例子。这个地址是在 kernel.ld 中设置的。0x80100000 是一个比较高的地址,差不多处在32位地址空间的尾部;至于原因,我们在第2章中对此作出了详细解释。当然,实际的物理内存中可能并没有这么高的地址。一旦内核开始运行,它会开启分页硬件来将虚拟地址 0x80100000 映射到物理地址 0x00100000。引导程序运行到现在,分页机制尚未被开启。在kernel.ld中指明了内核的paddr是0x00100000,也就是说,引导加载器将内核拷贝到的低地址正是分页硬件最终会映射的物理地址。
引导加载器的最后一项工作是调用内核的入口指令,即内核第一条指令的执行地址。在 xv6 中入口指令的地址是 0x10000c:
# objdump -f kernel
kernel: file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c
按照惯例,在 entry.S(1036)中定义的 _start 符号即 ELF 入口。由于 xv6 还没有建立虚拟内存,xv6 的入口即 entry(1040)的物理地址。
现实情况
该附录中谈到的引导加载器编译后大概有470字节的机器码,具体大小取决于编译优化。为了放入比较小的空间中,xv6 引导加载器做了一个简单的假设:内核放在引导磁盘中从扇区1开始的连续空间中。通常内核就放在普通的文件系统中,而且可能不是连续的。也有可能内核是通过网络加载的。这种复杂性就使得引导加载器必须要能够驱动各种磁盘和网络控制器,并能够解读不同的文件系统和网络原型。也就是说,引导加载器本身就已经成为了一个小操作系统。显然这样的引导加载器不可能只有512字节,大多数的 PC 操作系统的引导过程分为2步。首先,一个类似于该附录介绍的简单的引导加载器会从一个已知的磁盘位置上把完整的引导加载器加载进来,通常这步会依靠空间权限更大的 BIOS 来操作磁盘。接下来,这个超过512字节的完整加载器就有足够的能力定位、加载并执行内核了。也许在更现代的设计中,会直接用 BIOS 从磁盘中读取完整的引导加载器(并在保护模式和32位模式下启动之)。
本文假设在开机后,引导加载器运行前,唯一发生的事即 BIOS 加载引导扇区。但实际上 BIOS 会做相当多的初始化工作来确保现代计算机中结构复杂的硬件能像传统标准中的 PC 一样工作。