从开机到内核:计算机是怎么启动的
从开机到内核:计算机是怎么启动的
想象一下这个场景:早晨,你走进办公室,坐到工位前,顺手按下电脑电源键。屏幕从一片漆黑逐渐亮起,你熟悉的桌面壁纸出现在眼前,鼠标指针在桌面上自由移动,一切都是那么自然、自然到几乎不需要思考。
但你有没有想过,这短短几十秒的"开机"过程,你的电脑究竟经历了什么?
按下电源键的那一刻,一个沉睡的软件巨人开始苏醒。它需要完成一系列复杂而精密的初始化工作:从最底层的硬件检测,到操作系统核心的加载,再到各种系统服务逐一就位,最终才能为你呈现那个熟悉的登录界面。这个过程涉及BIOS、Bootloader、内核、init进程等多个关键角色的通力合作,每一步都不可或缺。
理解计算机的启动流程,不仅仅是满足好奇心那么简单。它能帮助我们理解操作系统在内核层面究竟扮演什么角色,知道系统出了问题应该从哪个环节排查,甚至为后续深入学习Linux内核源码打下坚实的基础。就像建造一栋摩天大楼,你必须先理解地基是怎么打的,才能真正理解整栋建筑的结构。
让我们从最原始的那一刻——按下电源键——开始,完整地走一遍计算机启动的整个流程。
BIOS与UEFI:固件层面的初始化
当你按下电源键的瞬间,电流开始流动,但此时CPU并不知道自己该做什么。CPU需要有人告诉它第一条指令在哪里,而提供这条指令的,就是计算机的固件系统——BIOS或UEFI。
从BIOS到UEFI的演进
BIOS(Basic Input/Output System,基本输入输出系统)是IBM PC时代延续下来的传统固件标准。它的核心任务是完成硬件初始化,并将控制权转交给操作系统的引导程序。BIOS存在于主板的一块只读存储器(ROM)中,即使断电也不会丢失数据。
BIOS的工作方式有几个显著特点:首先,它运行在16位实模式$^{[1]}$下,这意味着它只能访问1MB内存,且代码风格带有浓浓的上世纪80年代气息;其次,BIOS按照固定的顺序(通常是软盘→硬盘→光盘→USB设备)查找可启动设备,这种机械的方式在现代计算机看来略显笨拙;最后,BIOS的界面大家都见过——那种蓝底白字、充满怀旧感的设置界面。
然而,随着时间推移,BIOS的局限性越来越明显。32位/64位CPU的普及、2TB以上硬盘的支持需求、更安全的启动验证机制——这些新需求推动着新标准的诞生,UEFI(Unified Extensible Firmware Interface,统一可扩展固件接口)应运而生。
UEFI可以看作是对BIOS的全面升级。它运行在32位或64位保护模式下,拥有更现代的架构和更丰富的功能。UEFI不再使用笨拙的16位实模式代码,而是采用模块化、C语言风格的可扩展固件架构。最明显的改变是开机界面——不再有那个蓝底白字的老旧BIOS界面,取而代之的是品牌logo动画或简洁的图形界面。
POST自检:硬件的"健康体检"
无论你使用的是BIOS还是UEFI,启动流程的第一步都是执行POST(Power-On Self Test,上电自检)。你可以把POST理解为计算机的"开机自检"——系统通电后,固件对各个硬件组件进行基础功能检测。
POST会检查哪些东西呢?主要包括以下几个方面:
首先是CPU检测。固件会验证CPU的基本功能是否正常,包括寄存器是否可用、指令是否能正确执行等。这是最基础的检查,如果CPU本身有问题,后面的流程都无法进行。
其次是内存检测。固件会检测系统安装了多少内存,并进行基础的读写测试。这个过程有时会让你看到屏幕上显示的内存容量数字。著名的"嘀"一声开机提示音,据说最早就是用来表示内存检测通过的信号——如果内存有问题,电脑会发出不同的蜂鸣声组合来提示故障。
然后是显卡检测。固件需要初始化显卡并使其能够输出画面,否则你将看不到任何启动信息。这个阶段完成后,屏幕才会真正"亮起来",你才能看到BIOS/UEFI的设置界面或者启动选项。
此外,键盘、鼠标、硬盘、光驱等外设也会被依次检测。固件会检测这些设备是否存在、是否能够正常响应基本操作。
POST的速度取决于硬件配置和BIOS/UEFI的优化程度。在一些老旧的BIOS系统上,POST可能需要数十秒;而现代UEFI系统通常能在几秒内完成这些检测。值得庆幸的是,POST过程通常不会显示详细信息,除非按住某个特定按键(通常是Delete、F2或Esc)进入固件设置界面。
启动设备的选择与引导加载
POST完成后,固件面临一个关键问题:操作系统在哪里?它需要找到可以启动的设备,并从该设备上加载操作系统。
在传统BIOS模式下,固件会按照用户设定的顺序,依次尝试从各个设备启动。启动的判断依据是设备的第一个扇区(512字节)最后两个字节是否为`0x55AA`$^{[2]}$——这是一个约定俗成的"可启动"标记。如果某个设备的第一个扇区以这两个字节结尾,固件就认为找到了启动设备,并将这个扇区的内容加载到内存的`0x7C00`位置,然后跳转到那里执行。
从这一刻起,固件的使命基本完成。接下来的工作交给了一个专门的程序——Bootloader(引导加载程序)。Bootloader是连接固件和操作系统的桥梁,它负责更复杂的启动逻辑:识别文件系统、加载操作系统内核、传递启动参数等。
在UEFI环境下,情况则优雅得多。UEFI本身就内置了文件系统驱动,能够直接读取FAT格式的分区。它不再依赖那个512字节的引导扇区,而是直接在EFI系统分区中查找`.efi`格式的可执行文件。UEFI规范定义了标准化的启动流程:固件查找ESP(EFI System Partition)中的启动文件,按照预定的路径规则(通常是`/EFI/BOOT/BOOTX64.EFI`)加载并执行。这个设计更加清晰,也更容易实现安全启动等高级功能。
探索dmesg:查看内核日志的利器
在我们继续深入之前,让我们先认识一个实用的命令——`dmesg`。这个命令用于显示内核环缓冲区(kernel ring buffer)中的消息,是排查系统启动问题的瑞士军刀。
内核在启动过程中会将各种信息打印到环形缓冲区中,这些信息包括硬件检测结果、驱动程序加载状态、内核初始化进度等。即使系统已经完全启动,你仍然可以通过`dmesg`查看这些"历史记录"。
```bash
查看完整的内核日志
dmesg
实时监控新产生的内核消息(类似tail -f)
dmesg -w
只显示最近20行日志
dmesg | tail -20
以人类可读的时间戳显示
dmesg -T ```
让我们实际运行一下,看看能发现什么:
```bash $ dmesg | head -50 [ 0.000000] Linux version 5.15.0-generic (buildd@lcy02-amd64-047) [ 0.000000] Command line: BOOT_IMAGE=/boot/vmlinuz-5.15.0-generic root=UUID=xxxx ro quiet splash [ 0.000000] KERNEL supported cpus: [ 0.000000] Intel GenuineIntel [ 0.000000] AMD AuthenticAMD [ 0.000000] Linux minimal OS [ 0.001000] Memory: 16383928K/16676352K available (12345K kernel code, 2345K rwdata, 4567K rodata) [ 0.002000] CPU: Physical Processor ID: 0 [ 0.003000] CPU: Processor Core ID: 0 [ 0.004000] Total of 8 processors online. ... ```
每条消息前面的方括号中是时间戳,表示从内核开始运行到该消息产生所经过的秒数和微秒数。这些日志是理解内核初始化过程的重要窗口,在后续学习中我们会反复用到它。
Bootloader:连接固件与内核的桥梁
固件找到启动设备后,需要将控制权交给一个能够加载操作系统的程序。这个程序就是Bootloader。在Linux的世界里,最常用的Bootloader是GRUB(GRand Unified Bootloader)。
GRUB的工作方式
GRUB是目前Linux系统最主流的引导加载程序。它的设计目标很明确:能够从多种文件系统加载内核镜像,并给用户选择操作系统的机会(如果是多系统启动)。
GRUB的工作分为两个阶段。第一阶段(Stage 1)非常小,它存储在启动设备的MBR(Master Boot Record,主引导记录)中,仅有446字节。这么小的空间做不了太多事情,它的功能就是加载第二阶段的代码。由于MBR空间极其有限,Stage 1通常只是简单地将GRUB的下一阶段代码从磁盘加载到内存,然后跳转过去执行。
第二阶段(Stage 2)才是GRUB的"本体"。它包含了读取文件系统、解析配置文件、显示启动菜单等完整功能。Stage 2可以读取`/boot/grub/grub.cfg`配置文件,从中获取内核镜像的位置、启动参数等信息。当用户做出选择(或使用默认选项)后,GRUB会执行最后的"魔法":将内核镜像加载到内存,解压(如果是压缩格式),然后跳转执行内核的第一条指令。
在UEFI环境下工作的GRUB 2则采用了不同的策略。它不再依赖MBR,而是作为`.efi`文件直接运行在UEFI环境中。UEFI本身就提供了文件系统支持,所以GRUB可以直接读取`/boot/grub/x86_64-efi/`目录下的模块,组装出完整的引导功能。这种设计让GRUB更加模块化,也更容易适配不同的平台。
理解grub.cfg配置文件
GRUB的配置文件通常位于`/boot/grub/grub.cfg`(有些发行版可能是`menu.lst`,那是旧版本的格式)。这个文件定义了启动菜单的显示内容和加载逻辑。让我们看一个典型的配置片段:
```bash menuentry 'Ubuntu' { recordfail load_video gfxmode $linux_gfx_mode insmod gzio insmod part_gpt insmod ext2 search --no-floppy --fs-uuid --set=root 12345678-1234-1234-1234-123456789012 linux /boot/vmlinuz-5.15.0-generic root=UUID=12345678-1234-1234-1234-123456789012 ro quiet splash initrd /boot/initrd.img-5.15.0-generic } ```
这段配置定义了一个名为"Ubuntu"的启动项。其中,`menuentry`指定菜单显示名称,`insmod`加载必要的文件系统模块,`linux`行指定内核镜像文件路径及其启动参数,`initrd`行指定初始内存文件系统(Initial RAM Disk)的路径。
启动参数(kernel command line)非常重要,因为它们告诉内核重要信息,比如根文件系统在哪里(`root=`)、是否使用图形界面(`quiet splash`)、是否启用SELinux等。内核在启动初期会读取这些参数来决定如何初始化系统。
动手实验:修改GRUB启动参数
理解Bootloader的最佳方式是动手实践。让我带你做一个简单的实验:修改GRUB的启动参数。
实验目标:修改GRUB启动参数,让内核在启动时显示详细的开机信息,而不是默认的图形界面。
步骤一:编辑GRUB配置文件
```bash
首先备份原始配置
sudo cp /etc/default/grub /etc/default/grub.backup
编辑配置
sudo nano /etc/default/grub ```
找到下面这行: ```bash GRUB_CMDLINE_LINUX_DEFAULT="quiet splash" ```
将其改为: ```bash GRUB_CMDLINE_LINUX_DEFAULT="" ```
步骤二:更新GRUB
```bash sudo update-grub
或者在某些系统上
sudo grub-mkconfig -o /boot/grub/grub.cfg ```
步骤三:重启观察效果
重启后,你会发现内核启动过程中所有的详细信息都显示在屏幕上——驱动程序初始化、文件系统检查、服务启动……这些平时被"splash"隐藏的信息全部可见。这个过程能让你对内核的初始化流程有一个直观的感受。
步骤四:恢复默认设置(可选)
```bash
恢复备份
sudo cp /etc/default/grub.backup /etc/default/grub sudo update-grub ```
通过这个实验,我们可以看到GRUB在启动过程中的重要角色:它不仅是内核的搬运工,还负责传递启动参数、控制启动行为。没有GRUB(或类似的Bootloader),我们无法将控制权顺利交给Linux内核。
内核镜像:Linux内核是如何存储的
在GRUB加载内核之前,我们需要先了解Linux内核是如何存储在磁盘上的。这涉及到内核镜像的格式和结构。
vmlinuz:压缩的内核
Linux内核镜像可不是一个简单的可执行文件。它是一个经过特殊处理的压缩镜像,文件名通常叫`vmlinuz`。注意这个后缀:z表示压缩——Linux内核从一开始就被设计为可压缩的,因为在那个年代,存储空间和内存都非常有限。
`vmlinuz`的生成过程是这样的:内核源码编译链接后会产生一个巨大的ELF格式文件`vmlinux`,这个文件包含了内核的全部代码和数据。然后,工具程序会把这个`vmlinux`用gzip或其他压缩算法压缩,并在前面添加一段解压缩代码和重定位代码。这个最终产物就是我们可以直接用于启动的`vmlinuz`。
为什么要压缩?因为一个完整的Linux内核可能达到几十MB甚至更大。在启动的早期阶段,系统内存可能非常紧张,压缩可以显著减少内核占用的空间和解压所需的时间。更重要的是,启动初期只解压需要的部分——当你看到登录界面时,内核可能还没有完全解压。
除了`vmlinuz`,你可能还见过`zImage`和`bzImage`这两个名字。`zImage`是较老的格式,适用于小于512KB的内核;`bzImage`是"big zImage"的缩写,可以处理更大的内核。`vmlinuz`通常就是指向`bzImage`的符号链接。
initrd:临时的根文件系统
仅有内核是不够的。内核在启动时需要挂载根文件系统,但根文件系统可能位于需要特殊驱动程序才能访问的设备上——比如需要专有驱动的RAID卡、SCSI设备,或者位于LVM、LUKS加密层之上。这就是`initrd`(Initial RAM Disk,初始内存盘)存在的意义。
`initrd`是一个压缩的cpio归档文件,包含了一个临时的根文件系统。在内核启动的早期阶段,它会被加载到内存中作为一个临时的根分区。这个临时的文件系统包含必要的工具和内核模块,能够识别真正的根文件系统设备并挂载它。
当你看到GRUB配置中的`initrd /boot/initrd.img-xxx`这一行,那就是指定了initrd镜像的位置。在启动过程中,GRUB会把这个镜像也加载到内存中,供内核在早期使用。
内核镜像的解压过程
当GRUB完成准备工作后,它会跳转到内核镜像的入口点。这时发生的大致流程是:
第一阶段,解压缩程序开始执行。这段代码位于内核镜像的开头,它将CPU从实模式切换到保护模式(或长模式),初始化最基本的内存环境,然后开始解压压缩的内核主体。
第二阶段,内核主体被解压到最终的内存位置。这个过程可能需要几秒钟,对于大内核来说更明显。
第三阶段,解压完成后,内核的执行权从压缩stub转移到真正的内核代码。内核开始执行它的第一条C语言指令——`start_kernel`函数。
整个解压过程不需要任何操作系统的帮助,完全是自包含的。这也意味着Linux可以在非常简陋的环境中启动——只要CPU能执行指令、有足够的内存存放解压后的内核,其他事情可以慢慢来。
start_kernel:内核的第一个C函数
如果把计算机启动比作一场接力赛,start_kernel函数就是接过接力棒的最后一棒选手——它标志着系统初始化从汇编代码时代正式进入C语言时代。
漫长的初始化之旅
`start_kernel`函数定义在Linux内核源码的`init/main.c`文件中,是内核最先执行的C函数。它的函数签名很长,包含许多参数,这些参数正是GRUB通过启动参数传递过来的。
让我用尽可能简单的方式描述`start_kernel`函数做的事情。这个函数会调用一系列初始化函数,完成整个内核的初始化工作。这些调用形成了一个复杂但有序的初始化序列:
```c asmlinkage __visible __ref void __init start_kernel(void) { // 1. 设置早期的日志记录 setup_log_buf(0);
// 2. 初始化PID哈希表 pidhash_init();
// 3. 初始化虚拟内存子系统 vmalloc_init();
// 4. 初始化中断控制器 early_irq_init(); init_IRQ();
// 5. 初始化调度器 sched_init();
// 6. 初始化内存管理 mm_init();
// 7. 初始化RCU(Read-Copy-Update机制) rcu_init();
// 8. 调用各个子系统的初始化函数 ...
// 9. 打开抢占功能 preempt_enable();
// 10. 创建init进程 rest_init(); } ```
这是一个极度简化的版本。真实的`start_kernel`有几百行代码,调用了数十个初始化函数。每个子系统——内存管理、进程调度、文件系统、设备驱动——都在这里完成自己的初始化。
初始化顺序的艺术
为什么初始