序言
启动计算机一般不是一件难事:按下电源键,稍等片刻,你还能看见一个登入界面,再输入正确的密码,就可以开启三天的网上滑水之旅了。
但时常这件事没这么顺利,有时侯迎接你的不是熟悉的登入界面,而是一个令人生畏的命令提示符界面,一闪一闪的提示符告诉你:“你遇到麻烦了”。于是你对着错误提示查找解决方式,根据网页上的步骤,你对着提示符输入并执行了几条你完全不理解的命令,计算机又能正常启动了,但同时你发觉你那存有学院同事糗照的硬碟分区被清空了。
为了避免这样的惨剧发生,了解一下计算机的启动流程是十分有必要的,这能帮助你上次再见到计算机启动问题时不至于手忙脚乱,而误把硬碟分区低格掉。
下边开始题外话,我会以一个Linux使用者(而不是专业的UEFI开发工程师)的视角来表述一下UEFI固件计算机的启动流程。其实BIOS看上去早已成为历史,但它实际上还运行在许多存量设备上——我数年前供职的第一家公司其生产的基于Linux的设备就仍在使用BIOS固件,因而也会对其进行介绍。
BIOS+MBR
在BIOS+MBR时代,对于固件来说,并没有文件的概念,甚至没有分区的概念,它只认识磁道。在把对CPU的控制权交给储存于硬碟MBR中的bootloader代码后,固件就完成了它在启动过程中的大部份工作,后续的流程由bootloader来负责。
标准MBR结构:
MBR分区表项格式:
而MBR空间十分有限,最多只有446个字节,这并不足以容纳全部的启动流程,因而一般bootloader会把启动流程界定为多个阶段,每位阶段的逻辑存贮在不同的地方。
以以前广泛使用的GRUBLegacy为例,它的启动流程分为三个阶段:stage1、stage1.5和stage2,其中stage1.5是可选的。stage1只是一个拿来加载stage1.5或则stage2的入口;stage1.5提供了文件系统驱动,假如存在stage1.5,旁边的stage2的代码就可以通过文件路径来加载,否则还是要通过磁道列表来加载;stage2才是最终启动内核的地方,依据配置,它可以加载指定路径下的内核镜像,也可以加载安装在其他分区PBR中的bootloader,由另外的bootloader完成系统的启动。
UEFI+GPT
UEFI和BIOS很不一样,依照标准,UEFI固件需实现对FAT文件系统的支持,而有了对文件系统的支持之后,许多事情都显得恍然活泼了:bootloader再也不用争抢MBR这块弹丸之地,也不须要切割逻辑,把代码见缝插针地安置在c盘分区之间的缝隙中,bootloader如今完全可以作为文件系统中的合法公民,不再是游离在外的幽灵。
GPT
UEFI其实也可以以CSM模式从MBR硬碟中启动,但现在大多数情况都是配合GPT硬碟使用的,因而了解一下GPT分区表是很有必要的。本文后续也都基于UEFI+GPT的组合,但是不考虑U盘和光碟等联通储存设备。
GPT分区表的结构:
出于兼容性的考虑,在GPT硬碟的第0个LBA依然保存有一份MBR格式的分区表,但这个分区表将硬碟余下的所有区域标记为一个受保护的分区:
GPT硬碟的第1个LBA才是真正的GPT分区表头,其格式如下:
GPT分区表头旁边跟随着分区表项,每位分区表项大小为128字节,其格式如下:
须要注意的是,这儿的“分区类型GUID”里面的“分区类型”并不是按照FAT32、NTFS和ext4等具体格式界定的,而是按照用途界定的。下边是gnome-disk-utility在编辑分区时支持的部份分区类型GUID:
既然GPT分区表项中并没有表示分区格式的数组,这么c盘管理工具是怎样获取分区格式的呢?以libblkid为例,它实现了一系列的probe_*函数,通过读取分区腹部的superblock中的数据来侦测分区格式:
probe_vfat
probe_ext4
probe_ext3
probe_ext2
probe_ntfs
须要注意的是,在lsblk等工具的输出中,有一列UUID,但这个UUID并不是GPT分区表项中的分区类型GUID或则分区GUID,而是文件系统UUID,它不是GPT标准的一部份。文件系统的UUID一般储存于superblock中,但是因为没有统一的标准,其储存位置和大小也不尽相同国内linux主机,ext系列文件系统的UUID宽度为16个字节,FAT系列文件系统的UUID厚度为4个字节linux内核启动流程图,NTFS文件系统的UUID宽度为8个字节:
struct ext2_super_block {
// *
unsigned char s_uuid[16];
// *
};
struct msdos_super_block {
// *
/* 27*/ unsigned char ms_serno[4];
// *
};
struct vfat_super_block {
// *
/* 43*/ unsigned char vs_serno[4];
// *
};
struct ntfs_super_block {
// *
uint64_t volume_serial;
// *
};
在lsblk中,分区类型GUID和分区GUID数组名分别为PARTTYPE和PARTUUID:
$ lsblk /dev/sda --fs -o +PARTTYPE,PARTUUID
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS PARTTYPE PARTUUID
sda
├─sda1
│ vfat FAT32 81DE-2849 504.9M 1% /boot/efi c12a7328-f81f-11d2-ba4b-00a0c93ec93b 3c1a61a2-5829-7598-8673-16494a668884
└─sda2
ext4 1.0 5ae3cdb0-e1d3-372b-82d9-253144874891 395.5G 5% / 0fc63daf-8483-4772-8e79-3d69d8477de4 01c4ade9-2f93-e266-b517-858b6af37179
EFI系统分区(ESP)
在UEFI+GPT时代,bootloader不再储存于MBR,而是作为普通文件储存在EFI系统分区(ESP)。ESP实际上就是一个FAT格式的分区(一般是FAT32),其分区类型GUID为为c12a7328-f81f-11d2-ba4b-00a0c93ec93b,其GPT分区表项中属性数组的bit1被设置为0,除此之外,它与普通的FAT格式分区并没有哪些不同。
ESP在Windows上默认是隐藏的,在Linux上可以作为普通的块设备正常挂载。在Ubuntu上,ESP被默认挂载于/boot/efi目录,/etc/fstab中有这样一个配置项:
# /boot/efi was on /dev/sda1 during installation
UUID=81DE-2849 /boot/efi vfat umask=0077 0 1
这个配置项的一个细节是被挂载的分区并没有使用/dev/sd1这样的设备路径,而是使用了UUID=81DE-2849来指定,这样可以避免因为硬件配置变动造成错误的分区被挂载到/boot/efi目录。
bootloader也不是随便地置于ESP中,UEFI标准还对ESP的目录结构做了规定。根据标准,ESP的根目录中需包含一个名为EFI的目录,所有的bootloader均需放置在EFI目录的子目录下:
EFI
…
BOOT
BOOT{machine type short name}.EFI
其中BOOT目录是一个缺省目录,缺省目录中又有一个缺省bootloader,当没有其他可加载的bootloader时,固件都会尝试加载它。这个缺省bootloader文件名和处理器构架相关,在x86_64机器上linux漏洞扫描,它的名子是BOOTX64.EFI。
我本机ESP目录结构如下:
/boot/efi
└── EFI
├── BOOT
│ ├── BOOTX64.EFI
│ ├── fbx64.efi
│ └── mmx64.efi
└── ubuntu
├── BOOTX64.CSV
├── grub.cfg
├── grubx64.efi
├── mmx64.efi
└── shimx64.efi
UEFI镜像
其实我后面仍然在说bootloader,但UEFI固件就能加载的不仅仅是bootloader,任何符合格式的文件都可以被加载,它们被合称为UEFI镜像,其后缀名为efi。UEFI镜像可分为两类:UEFI应用和UEFI驱动,bootloader是UEFI应用的一个子类,它实现了加载操作系统的功能。不仅bootloader之外还有其他类型的UEFI应用,比如提供了命令行交互插口的UEFIshell,才能以菜单方式选择不同bootloader加载的bootmanager,甚至连python都被移植成了UEFI应用。
UEFI目前使用的镜像格式为PE32+,这是一种和Windows上的exe可执行文件承德小异的格式,感兴趣的可以自己查找相关资料。在Linux下使用file命令辨识它们,输出如下:
$ file BOOTX64.EFI
BOOTX64.EFI: PE32+ executable (EFI application) x86-64 (stripped to external PDB), for MS Windows
objdump也认识它们:
$ objdump -f BOOTX64.EFI
BOOTX64.EFI: file format pei-x86-64
architecture: i386:x86-64, flags 0x00000133:
HAS_RELOC, EXEC_P, HAS_SYMS, HAS_LOCALS, D_PAGED
start address 0x0000000000023000
NVRAM变量
后面提及了UEFI标准对ESP目录结构有规定,bootloader须要放置在EFI目录的子目录下linux内核启动流程图,然而这还不够,想要才能被UEFI固件直接加载,还要配合NVRAM变量。
UEFI使用NVRAM来保存配置,这种配置在机器断电重启后也仍然还能保持,其中有一些与启动过程息息相关。
名为Boot####的NVRAM变量定义了启动项条目,保存了bootloader的路径,其中####是一个16补码的序号。当机器启动后,我们自动选择启动项时,实际上就是在选择不同的Boot####变量中保存的bootloader路径进行加载。因而想要添加一个新的启动项时,不但要把bootloader置于符合UEFI标准的路径下,还要创建一个指向这个bootloader的Boot####变量。
名为BootOrder的NVRAM变量定义了启动项次序,当我们没有自动选择启动项时,UEFI固件都会根据这个变量中配置的次序依次加载bootloader,直至有bootloader被成功加载为止。
在Linux上可以使用efibootmgr工具来管理它们:
$ efibootmgr -v
BootCurrent: 0000
Timeout: 0 seconds
BootOrder: 0002,0004,2001,2002,2003
Boot0000* USB HDD: KingstonDataTraveler 3.0 PciRoot(0x0)/Pci(0x14,0x0)/USB(17,0)/HD(1,GPT,a3382272-59c0-2911-3af9-29919cc5c581,0x800,0x1ce77df)
Boot0002* ubuntu HD(1,GPT,3c1a61a2-5829-7598-8673-16494a668884,0x800,0x100000)/File(EFIubuntushimx64.efi)
Boot0004* Windows Boot Manager HD(1,GPT,44b82d72-7651-803f-9a23-2cf241e4c839,0x800,0x32000)/File(EFIMicrosoftBootbootmgfw.efi)
Boot2001* EFI USB Device RC
Boot2002* EFI DVD/CDROM RC
Boot2003* EFI Network RC
至于怎样增删启动项,可以自行查阅efibootmgr指南。
假如由于某种缘由NVRAM中数据被清空了,这么计算机该怎么启动呢?一种方法是上面提及过的缺省bootloader,你可以把一个GRUB2的UEFI镜像重命名为BOOTX64.EFI放在BOOT目录出来引导机器启动,也可以使用fbx64.efi这些才能重建NVRAM启动项的UEFI应拿来修补启动项,甚至把UEFIshell作为缺省启动项,自动加载要启动的bootloader;另一种形式是依赖UEFI固件的一些非标准逻辑——你机器上的UEFI固件很可能无论怎样就会查看ESP中的EFI/Microsoft/Boot/bootmgfw.efi文件是否存在并尝试加载它,而无视NVRAM中的配置。
GRUB2
后面是任何使用UEFI固件的计算机启动时就会涉及的流程,而当bootloader被成功加载后,操作系统怎样被加载,就不在UEFI标准之中了。下边以GRUB2为例,看一下Linux内核是怎样被加载的。
GRUB2安装在ESP中的bootloader镜像在x86_64机器上名子是grubx64.efi,虽然在UEFI时代,GRUB2也没有把全部的代码和数据统统塞入ESP中,主配置和数据一直保存在普通数据分区中。GRUB2在ESP中有一份简单的配置文件grub.cfg,它的作用就是告诉grubx64.efi应该去那里加载主配置文件,下边是我机器上ESP分区中grub.cfg的内容:
search.fs_uuid 5ae3cdb0-e1d3-372b-82d9-253144874891 root hd0,gpt2
set prefix=($root)'/boot/grub'
configfile $prefix/grub.cfg
grubx64.efi可以按照这份配置文件的内容,首先根据文件系统UUID找到储存主配置文件的分区,之后再依据路径找到主配置文件加载。
而主配置文件的内容就比较长了,此处列举其中三个启动项的配置:
第一个是Ubuntu启动项,在以正常形式启动Ubuntu时,这个启动项会被GRUB2加载。这个启动项被加载时,GRUB2首先会进行必要的配置,使用insmod命令加载必要的模块,之后按照文件系统UUID找到储存内核镜像的分区,找到内核镜像后,使用linux命令向其传递启动参数并加载,使用initrd命令加载initrd,最后会执行蕴涵的boot命令,启动内核:
menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-5ae3cdb0-e1d3-372b-82d9-253144874891' {
recordfail
load_video
gfxmode $linux_gfx_mode
insmod gzio
if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi
insmod part_gpt
insmod ext2
set root='hd0,gpt2'
if [ x$feature_platform_search_hint = xy ]; then
search --no-floppy --fs-uuid --set=root --hint-bios=hd0,gpt2 --hint-efi=hd0,gpt2 --hint-baremetal=ahci0,gpt2 5ae3cdb0-e1d3-372b-82d9-253144874891
else
search --no-floppy --fs-uuid --set=root 5ae3cdb0-e1d3-372b-82d9-253144874891
fi
linux /boot/vmlinuz-5.19.0-50-generic root=UUID=5ae3cdb0-e1d3-372b-82d9-253144874891 ro quiet splash $vt_handoff
initrd /boot/initrd.img-5.19.0-50-generic
}
第二个是WindowsBootManager启动项,GRUB2并不单纯是一个bootloader,它还可以使用chainloader命令加载其他UEFI镜像,在这个启动项中,它就加载了Windows的bootloader:
menuentry 'Windows Boot Manager (on /dev/nvme0n1p1)' --class windows --class os $menuentry_id_option 'osprober-efi-9493-2BA0' {
insmod part_gpt
insmod fat
search --no-floppy --fs-uuid --set=root 9493-2BA0
chainloader /efi/Microsoft/Boot/bootmgfw.efi
}
第三个是UEFIFirmwareSettings,GRUB2还可以舍弃加载内核或则其他bootloader,调用fwsetup重启机器,并步入UEFI固件配置界面:
menuentry 'UEFI Firmware Settings' $menuentry_id_option 'uefi-firmware' {
fwsetup
}
总结
总结一下,使用UEFI固件的计算机从开机到Linux内核启动的典型流程如下:
UEFI固件初始化UEFI固件按照NVRAM配置,或则自动选择,在ESP中加载bootloader。假如想要启动Linux,这个bootloader大部份情况是GRUB2GRUB2加载ESP分区的配置文件,找到主配置文件路径加载主配置文件,展示GRUB2启动项选择菜单自动或则默认选择Linux启动项通过一系列的insmod、linux、initrd和boot命令加载并启动Linux内核
其实了,UEFI固件的实现并不一定会完全依照标准来,Linux也并不只是有GRUB2这一个bootloader可用,所以每台计算机实际的启动流程并不一定和这儿完全相符。并且万变不离其宗,在正常启动的情况下,CPU的控制权总还是会依照UEFI固件->UEFI应用->Linux内核的次序流转的。
%E4%B8%BB%E5%BC%95%E5%AF%BC%E8%AE%B0%E5%BD%95
#file-system-format
%E7%A3%81%E7%A2%9F%E5%88%86%E5%89%B2%E8%A1%A8
#protective-mbr
#partition-discovery
#directory-structure
#globally-defined-variables
#boot