计算机是如何启动的 & GRUB
前言
在面试中有一个问题经常出现:从你在浏览器地址栏按下回车,到网页展示,这中间经历了什么过程?这个看似简单的问题背后隐藏着非常多的细节,每当你说了一个步骤,似乎总能以更细的粒度进行追问。
这篇文章讨论的问题类似,看似一个很日常很普通的操作,但我们真的了解这其中的流程吗?
- 当有一天你的Ubuntu无法正常引导,屏幕上冰冷冷的出现了几行你觉得很陌生的错误提示,这时你会发现,我没有能力解释这个错误提示,我甚至不知道该从哪里开始思考...
- 当你看了很多操作系统的理论,甚至做了很多lab,比如在真实的xv6操作系统中实现cow、页表、mmap等功能后,有一天你心血来潮想从头开始创造一个自己的操作系统,我发现我不知道从何开始,我的操作系统该从哪里夺权呢?
- 当你看一些侧重于实践的硬核书籍的时候,比如《操作系统真象还原》,你会发现有很多地方是模糊的,作者默认你了解,你也了解个大概流程,但你还是不知道它究竟做了什么
- 看jyy的课的时候,如果一节课他讲的知识是100%,你只能get到50%,这亏大了!!!
所以本篇文章想要追根溯源,从CPU启动开始一直到操作系统被引导起来,我们想要研究这其中的每一个细节...可能会包含
- CPU执行模型
- BIOS
- Legacy引导和MBR
- GRUB2
- UEFI引导和GPT
- ...
本文以实践为导向,会有大量的实操环节,可能会需要:
- linux环境:我们利用其中的dd、loop设备等等工具和概念来实现我们的想法
- nasm:用于将汇编转换成x86机器代码
- fdisk:linux下的一个磁盘分区工具
- hexedit:Linux下的一个16进制binary文件编辑器
- bochs:一个模拟器
- grub2:一个bootloader
开始
CPU执行模型
CPU的工作过程可以抽象的十分简单:
- 去读PC寄存器中保存的地址
- 解析地址上的内容,比如它发现了 “放数字” ,它便知道要执行的指令是往某一个寄存器中放入一个数字,于是它马上要接着读取后面一个数字,加一个目标寄存器
- 执行对应的操作内容
- 将PC寄存器中的地址递增,加上刚刚指令读取的字节数
- 回到1
当然,PC寄存器是一个惯常的命名,意为程序计数器(Program Counter),不同的CPU可能使用不同的名字,比如IP寄存器等。另外,我们上面描述隐藏了一些细节,比如CPU还可能会做额外的访存操作、多核缓存同步、中断处理等
硬件之间的约定:CPU Reset和BIOS
刚刚说了,CPU只是一个只会读指令执行的机器,它只会读PC计数器中的地址,去对应地址寻找对应指令。那我们就有两个疑问:
- 最初的PC计数器中的地址是谁放进去的呢?
- 那个地址对应位置的程序指令是谁加载进去的?将指令从各种非易失性设备中加载到内存中也需要一些指令呢!这就好像我们忘记了密码需要重置,但你得需要输入正确的原密码才能重置密码。你需要往空无一物的地址空间中放一些指令,但你想要放一些指令,你得先有放指令的指令。
先解决第一个问题,CPU在上电之后,PC寄存器会有一个默认值,对于硬件来说,只需要让其中的每一位有一个默认的电平即可。这个过程称作CPU Reset。在80286上,这个值是0xFFF0。
解决了PC寄存器默认值的问题,那个默认值对应的地址空间位置上得有指令啊,要不然执行个寂寞。
第二个问题通过一个非易失的只读存储器解决,也就是俗称的BIOS,其中已经有了在出厂时就写好的指令,而它就处于地址空间CPU Reset后PC寄存器的位置上。
注意:地址空间和内存地址并不是一个东西,你可以当作地址空间是主板提供的,它很大,内存可能只是其中的一个连续部分,还有很多其他部分,比如BIOS、显示器(在文本模式下,你可以往地址空间对应的位置写字符,就会显示在显示器上)。
所以,我们也可以看出主板和CPU架构是绑定的,因为它们之间有自己约定的规定。
BIOS做什么
我们已经知道了,在计算机启动后,BIOS是最先执行的程序,那它都会做啥呢?
这个...根据计算机种类的不同,也有很大差别:
- 嵌入式计算机:BIOS大小很小,只需要执行基本的硬件自检,初始化
- 早期通用计算机:BIOS大小也很小,但提供了可配置的界面,比如电源管理、启动顺序管理等
- 现代BIOS:大小较大,提供了更加美观的UI以及更丰富的功能,比如鼠标支持、UEFI启动等
但是总的来说,BIOS都需要执行硬件自检,在其生命周期的最后,它需要将执行权交给其它部分(比如操作系统,或者嵌入式设备中的特定代码),我们称为它的上游。有些BIOS还会给它的上游提供一些简单的硬件访问功能,这是通过BIOS中断实现的。
BIOS和软件之间的约定——存储设备中的数据格式
上面我们说了,BIOS在执行的最终需要将控制权交给其上游,但是我们的地址空间上现在还没有其它程序指令,只有一个孤零零的BIOS,所以找到其上游,并将上游的指令加载到内存中肯定也是BIOS的工作。
这就又需要协议来约束上下游的行为了。个人计算机上,BIOS通常可以配置两种可用的启动模式,分别是Legacy和UEFI,你可以看作这是两种上下游之间的约定,Legacy比较古老,人们发现它已经无法支持我们对美好生活日益增长的期待了,所以又定义了一套新的约定——UEFI。
对于通用个人计算机来说,不论是Legacy还是UEFI,其约定最终的目的都是将操作系统引导起来。
对于特殊用途的计算机,或者嵌入式计算机来说,这种约定可能是私有的,你在其产品对应的说明文档中可能会看到,这已经超出了本篇文章的讨论范围。
Legacy模式——主引导扇区
BIOS知道目前连接到计算机的所有外部存储设备,比如磁盘、软盘、光盘、U盘等,这些设备统一被抽象成块设备。
BIOS会逐一的尝试所有的设备(或按照配置中指定的顺序),尝试分析其是否可启动,并加载其中的启动指令到内存中,那么问题又来了:
- BIOS如何判断一个磁盘是否可以启动?
- BIOS如何知道磁盘中的启动指令在哪?
在Legacy模式下,BIOS会读取每一个磁盘的第一个扇区,这个扇区称为主引导扇区(MBR),扇区内部的前466个字节是代码区,放置启动的指令,接下来64字节是分区表信息,再然后的2个字节是魔数55AA。
实操——在裸金属上写Hello World
下面是一段x86的汇编代码,它的作用是调用BIOS中断,在显示屏上输出"Hello, World!"
org 07c00h
mov ax, cs
mov ds, ax
mov es, ax
call DispStr
jmp $
DispStr:
mov ax, BootMessage
mov bp, ax
mov cx, 16
mov ax, 01301h
mov bx, 000ch
mov dl, 0
int 10h
ret
BootMessage: db "Hello, World!"
times 510-($-$$) db 0
dw 0xaa55
使用nasm编译,得到机器指令:
➜ nasm hello.asm -o hello.bin
生成一个假的磁盘文件,boot.img
,其具有204800个扇区,其中的每一个字节都是0x00:
➜ dd bs=512 if=/dev/zero of=boot.img count=204800
将刚刚得到的直接可以在x86上运行的机器代码——hello.bin
,织入到刚刚的假的磁盘文件的前512字节,也就是第一个扇区上:
➜ dd if=hello.bin of=boot.img bs=512 count=1 conv=notrunc
我们得到了一个精心准备的,用来模拟主引导扇区的文件,其开头512字节就是我们准备的代码:
然后利用bochs启动该磁盘,其左上角输出了Hello World:
刚刚我们在无操作系统的baremetal上完成了第一次和硬件的交互。
bochs的配置如下(for windows):
megs:32
# bios和vga
romimage:file=$BXSHARE/BIOS-bochs-latest
vgaromimage:file=$BXSHARE/VGABIOS-lgpl-latest
# 磁盘1定义
ata0: enabled=1,ioaddr1=0x1f0, ioaddr2=0x3f0,irq=14
ata0-master: type=disk,path=tmp/boot.img, cylinders=6400, heads=1, spt=32
# 启动顺序定义
boot: disk,floppy
log:tmp/bochsout.txt
mouse:enabled=0
keyboard: keymap=$BXSHARE/keymaps/x11-pc-de.map
写到这里我不禁好奇,如果一个磁盘主扇区的代码不是可执行的呢?会发生什么?
我们把刚刚的文件代码部分都删除,只保留魔数55aa:
bochs这次什么都没做,只是一直展示Booting from Hard Disk...
如果我们把魔数也删除呢?这次bochs尝试从磁盘启动失败,宣告磁盘不是一个可启动的磁盘;然后它尝试从软盘启动,而我们没准备软盘...最终启动以失败告终
足以见得至少在bochs下的bios是通过魔数来判断设备是否是bootable的,这在不同的bios上可能不尽相同。
MBR中的代码都做了什么?
刚刚我们知道了在Legacy模式下,BIOS会在自检完成后,通过遍历设备的MBR,将启动过程交棒给MBR中的代码。
那MBR中的代码都做了什么呢?
- MBR太小了,只有512字节,留给代码的只有400多字节,能做的事十分有限
- 操作系统相关的内容应该不会在MBR中,因为这里面已经被代码、分区表、魔数占的满满当当的了
- 分区表??...欸!分区表!!!
MBR在不同语境中的含义:当我们说MBR时,有时是指一个块设备的前512字节,有时是指MBR格式的分区表,有时是指512字节中的代码部分。
搞个镜像看看
我们搞了tinycore(一个非常小的linux发行版)的iso镜像,iso是一种CD介质的原始拷贝,其内容就是CD介质上的每一个扇区。
使用16进制编辑器查看这个镜像,可以看到前512字节中最后两个字节的魔数:
由于汇编语言到机器语言只是符号上的替换,所以在网络上可以轻易找到在线的机器代码转汇编代码的工具,我们得到了这个MBR代码的汇编形式。并且由于x86汇编我也看不懂,这里我直接让GPT来去阅读这段代码:
,
看起来,这段代码也许加载了iso中的某些文件到内存中,这也许是操作系统的引导程序(bootloader),我想有可能还初始化了C语言的运行环境然后跳转到bootloader中。当然,这一切都只是我的猜测。
磁盘呢?
ISO和磁盘不一样,基于MBR分区的磁盘可能有多个分区,此时MBR的代码中可能包括:
- 扫描分区表查找活动分区
- 寻找活动分区的起始扇区
- 将活动分区的引导扇区读到内存
- 执行引导扇区的运行代码
实例分析——GRUB
通过上面的学习,我们大概知道了,计算机的启动过程根据计算机的用途、装载的操作系统等都有很大不同,究竟到哪里才算计算机启动完成了呢?从cpu的视角来看,reset后就算完成了;从嵌入式系统的视角来看,可能BIOS执行后直接引导了嵌入式系统的代码,此时已经算启动完成;从通用计算机的视角来看,较为简单的操作系统也许在MBR交出控制权的那一刻就算启动完成了;对于复杂的现代操作系统,可能还有多级的引导程序来逐级的完成引导......
所以,我们再像之前一样不停的追问可能已经会陷入迷茫了,因为选择太多太多了。所以这里,我们直接学习一个实例——GRUB!
GRUB的全称叫做GRand Unified Bootloader,即大统一启动加载器,旨在为所有操作系统提供统一的、用户友好的启动加载器。GRUB还支持MultiBoot协议,简单来说就是可以让用户通过配置,使得多个系统共存于机器上,用户可以在其中进行选择,GRUB负责引导用户选择的操作系统。
用户视角的GRUB
对于普通用户来说,它们可能使用GRUB来管理多操作系统共存,或者只是简单的作为操作系统的bootloader。目前,大部分Linux发行版默认使用GRUB作为bootloader,所以你可能无形中已经用过它了。
下面,我们使用一个例子来说明GRUB如何使用。
我们利用Linux的loop设备来准备虚拟磁盘:
➜ dd bs=512 if=/dev/zero of=diskfile count=20480 # 创建10MB的空白文件
➜ sudo losetup /dev/loop0 diskfile # 使用linux的loop设备,将该文件作为虚拟设备 /dev/loop0
➜ sudo fdisk /dev/loop0 # 使用fdisk分区,使用MBR分区表,从2048字节开始到最后一个扇区都是唯一一个主分区
➜ sudo partprobe /dev/loop0 # 刷新磁盘分区变更,通知系统
➜ ls /dev/loop0* # 查看/dev/loop0开头的设备,我们已经发现分区1已经出现
/dev/loop0 /dev/loop0p1
➜ sudo mkfs.ext4 /dev/loop0p1 # 给分区1 ext4 文件系统
Writing superblocks and filesystem accounting information: done
我们将刚刚的分区1挂载到磁盘挂载到一个目录下:
➜ mkdir ./mp
➜ sudo mount /dev/loop0p1 ./mp
向磁盘/dev/loop0
安装GRUB,根据官方文档的说法,我们实际上是将GRUB安装到了该磁盘的MBR上,并且,我们将grub的启动目录安装到刚刚的./mp/boot
下,相当于安装在其第一个分区下的/boot
目录中:
➜ sudo grub-install --force --target=i386-pc --boot-directory=./mp/boot/ /dev/loop0
Installing for i386-pc platform.
Installation finished. No error reported.
查看我们的diskfile文件,其MBR已经被grub写入成它自己的内容:
如果你足够细心的话,你会发现第二个扇区也被写入内容了,后面很多扇区都被写入内容了。这...不会破坏我们的第一个分区中的文件系统吗?其实刚刚我们分区的时候选择了分区起始位置是第2049个扇区,现代的分区工具都会在MBR和第一个分区的第一个扇区之间留一定长度的间隙,供bootloader使用,称为post-MBR gap。bootloader可以在这其中保存很多代码用于提供丰富的功能,否则,光靠MBR的400多字节,bootloader能做的事很少。
那么/boot
目录里都是啥呢?可以看到有一个unicode字体文件用于显示unicode字符(否则只能显示ascii),有平台相关的一些mod文件,比如afs.mod
也许用于支持afs
文件系统,由此也可以看出grub是模块化的,通过mod文件可以扩展grub的功能。同时,我们也看到了这里面有两个不太一样的文件,boot.img
和core.img
,它们是grub启动的关键,不过我们本节不关心。
我们使用bochs启动该镜像文件(其实也可以用qemu,不过我的linux没安装图形界面):
可以看到grub已经启动成功,并且给了我们一个shell,可以看到它识别出了一块硬盘hd0
,以及它的主分区msdos1
。
下面我们编写grub的配置文件,告诉grub我们有哪些系统,以及如何引导:
➜ sudo vim mp/boot/grub/grub.cfg
set timeout=5
set default=0
menuentry "OS1" {
# OS1启动项,这里告诉grub如何启动OS1
root=(hd0,msdos1)
linux /bzImage init=/bin/sh
initrd /initrd
}
这里我们通过编译linux内核打包了一个bzImage,并通过busybox打包了一个rootfs出来做initrd,使用qemu(或bochs)加载该磁盘文件:
GRUB的启动过程
GRUB软件的核心被打包在一些img
文件中
boot.img
boot.img
被打包到MBR中,所以它只有512KB。由于大小限制,它不能理解任何文件系统相关的语义,它的任务就是加载core.img
的第一个扇区到内存中。core.img
的第一个扇区位置被硬编码到boot.img
中。
core.img
这个是grub的核心镜像,它是由kernel.img
和任意的模块列表动态构建而来的,使用grub-mkimg
可以制作该镜像。
它其中包含需要的文件系统模块以加载/boot/grub
,并且其它任何工作都由它负责,比如菜单、加载目标操作系统。
core.img
寄生在post-MBR gap中,模块化的设计可以使它保持小的体积(因为可以按需动态打包)。
kernel.img
grub的核心功能镜像,除了mod扩展之外的核心功能写在此镜像中,不过它是最终被打包到core.img的