前一篇博客讲到了 bootloader ,本文将继续了解内核镜像是如何被 Bootloader 加载进内存,并最终启动的。
原文地址: manybutfinite.com/post/how-co…
注:文章发布于 2008 年,部分内容可能已经过时
为使阅读顺畅,在翻译的过程中进行了一些删改和补充。修改较小处不单独标出,只有较大量的补充会进行标注
在本文中会给出很多到 LXR / The Linux Cross Reference 中 2.6.25.6 版本内核代码链接,只要你熟悉类 C 语法的语言,读起这些内核源码就应该不会太困难——即使无法理解某些细节,也不影响大致了解主要逻辑。我也会尽量讲出链接中代码相关的上下文信息(比如要在什么时候、为什么运行等),让你读起来更容易一些。出于简洁方面的考量,本文只能将很多有趣的东西(比如中断和内存)一带而过。本文最后还会简要介绍 Windows 的启动过程。
接上篇:从用户按下启动按钮的那一刻到现在,我们的 x86 处理器还仍然工作在实模式下,只能寻址 1MB 的内存——而加载完成后的现代 Linux 系统的内存是下面这样的,显然需要大于 1MB 的内存:
boot loader 运行完成后的内存
内核镜像会被 bootloader 通过 BIOS 磁盘 I/O 服务加载进内存,该镜像就是硬盘上包含镜像的文件的一份拷贝(如 /boot/vmlinuz-2.6.22-14-server
)。镜像会被分为两部分:较小的、包含实模式代码的那部分被加载到前 640K 内存之内,较大的、运行于保护模式的主要部分则被加载到内存的第 1 MB 之后。
内核的启动过程,从上图中的实模式内核头( real-mode kernel header )开始。这一区域的内存被用来实现 bootloader 和内核之间的 Linux 启动协议 。该内存中的某些值是由 bootloader 设置的,其中有一些易用性相关的信息,比如以人类可读字符串形式存储的内核版本等。也有像实模式部分内核代码大小这样的关键信息。bootloader 还会向这个区域中写入一些别的值,比如用户在启动菜单中输入的命令行参数等。在 boot loader 运行完成后,其就已经准备好了内核头所需的所有参数,接下来就可以跳转到内核入口点了。
下图结合源码目录、文件以及代码所在行,展示了内核初始化的代码序列:
架构相关的 Linux 内核初始化过程
Intel 架构的早期阶段内核启动代码定义在 arch/x86/boot/header.S 中。该代码由汇编语言编写,汇编语言在启动阶段的代码中非常常见,而在这之后的主要部分中则用得很少。该文件最开始包含了引导扇区的代码——这些代码是从 Linux 可以不依赖引导程序启动的那些日子里遗留下来的。如果尝试执行现在的启动扇区代码,它就只会在为用户打印一行 "bugger_off_msg" 之后重启。因此,现代 bootloader 都忽略了这段经典代码。
在启动扇区后,是实模式内核头的前 15 bytes 。这两部分加起来一共有 512 bytes ,这是 Intel 硬件中一个典型的磁盘扇区的大小。
在这 512 bytes 之后的偏移量 0x200 处,是 Linux 内核的第一条指令:实模式入口点。代码位于 header.S:110 ,这是一个 2 byte 的跳转指令,直接以机器码的形式写作 0x3aeb 。你可以通过对自己的 Linux 内核镜像 hexdump 一把,来看看这个位置的东西。boot loader 运行完成后会跳转到这个位置,然后跳转到 header.S:229 处名为 start_of_setup
的汇编程序。这一小段程序会创建一个栈,将实模式内核的 bss 段置零(该段存放静态变量,初始值为 0 )并跳转到 arch/x86/boot/main.c:122 的 C 代码。
main()
函数会检测内存布局,设置显示模式等,最后调用 go_to_protected_mode() 函数。
在 CPU 可以被设置为保护模式前,仍然有些必备的准备工作。这些工作主要包括两部分:中断和内存。
- 在实模式下,处理器的 中断向量表 的内存地址总是内存地址 0 ,而在保护模式下,中断向量表的地址则会保存在 CPU 的 IDTR 寄存器中。
- 在实模式和保护模式下,逻辑内存地址(软件所使用的地址值)到线性内存地址(从 0 到内存最高地址)的转换也是不同的。保护模式下需要在 GDTR 寄存器中保存 全局描述附表(Global Descriptor Table,GDT)的内存地址。
因此,go_to_protected_mode()
函数需要调用 setup_idt() 建立临时中断描述符表,再调用 setup_gdt() 建立全局描述符表。
译注:
bss(Block Starting Symbol)是对象文件、可执行文件或汇编代码中,包含【未初始化的静态分配变量】的内存部分。通常情况下,对象文件的这部分都只保存了长度而没有实际的数据,其占用的内存会在加载器加载程序时再分配。
中断描述符表(Interrupt Descriptor Table, IDT):中断描述符表是 x86 架构下的 "中断向量表"。其保存每个异常 / 中断与它们相应的处理程序的关联。
全局描述附表(Global Descritpor Table, GDT)是 Intel x86 系列处理器用于定义程序运行时不同内存区域功能的数据结构,其中包括各个区域的基址、大小、访问权限(如是否可执行、可写等)—— Intel 将这些内存区域称为 "段"( segment )
现在终于可以进入保护模式了,模式转换通过另一段汇编程序 protected_mode_jump 进行。这段代码通过设置 CPU 中的 CR0 寄存器的 PE 位来启动保护模式。此时还没有启用分页(paging) —— 分页是处理器的一个可选特性(在保护模式下同样可选),目前还不需要启动该特性。到这一步,就不再受 640K 内存屏障的限制,可以寻址 4GB 的内存了。该汇编程序接下来会调用 32 位压缩内核的入口点 —— startup_32 ——这段程序会初始化一些寄存器,然后调用 C 函数 decompress_kernel() 解压内核。
decompress_kernel()
将打印一条类似于 "Decompressing Linux..." 之类的信息。内核解压是在原地进行的,一旦完成就会覆盖上面第一张图片中的压缩内核——也就是说,解压后的内容也是从 1MB 的内存地址开始的,解压完成后会打印一行 done.
,继而打印 "Booting the kernel."。 "Booting" 意味着跳转到最终的 Linux 入口点——这就是保护模式的内核入口点,位于 RAM 的第 2MB (0x100000)。
位于此处的是另一个 startup_32 汇编程序,其中包含着 32 位模式的初始化代码。该程序会初始化保护模式内核(直到关机前都会一直运行的真正内核)的 bss 段内存、设置最终的内存全局描述附表、构建页表、启动分页、初始化栈、创建最终的中断描述符表,最后跳转到与架构无关的内核启动函数 start_kernel() 。下面的图展示了启动最后阶段的代码流程
架构无关 Linux 内核初始化过程
start_kernel()
看起来就更像是普通的内核代码了——基本全部由 C 语言编写,与具体机器架构无关。该函数进行了一系列的调用,来初始化各种内核子系统和数据结构。包括调度器、内存区、时间管理等。在完成绝大部分的初始化工作后, start_kernel()
将调用 rest_init() 。rest_init()
会创建一个内核线程并传入 kernel_init() 函数。然后 rest_init()
会再调用 schedule() 启动任务调度,并通过调用 cpu_idle() 函数进入睡眠状态——cpu_idle()
是内核的空闲线程,该线程由 0 号进程管理,并和该进程一起一直运行。只要有可运行进程出现时,0 号进程就会继续运行,直到无事可做时再返回。
idle 循环是我们从上电后 CPU 执行的第一个 jump 开始,一直跟踪的代码的最后一步。从重置向量,到 BIOS、MBR、bootloader、实模式内核再到保护模式内核,一步步跳转到 boot 处理器的 cpu_idle() 。接下来是全新的开始。
至此,前面创建的内核线程就已经就绪了,其将取代 0 号进程和它的空闲线程。内核线程在 kerenl_init()
开始运行: kernel_init() 需要初始化系统中剩余的 CPU (这些 CPU 之前一直处于待机状态)—— 到目前为止,我们看过的所有代码都是由一个被称为 "启动处理器 boot processor" 的 CPU 执行的。其余被称为 "应用 CPU " 的处理器也是从实模式开始运行的,经过几次初始化后才算启动完成。该过程中很多的逻辑都是相同的(参阅代码 startup_32 ),只在一些小分支不太一样。最终,kernel_init()
会调用 init_post() ,该段代码会尝试按下面的顺序启动一个用户模式进程:/sbin/init
、/etc/init
、/bin/init
和 /bin/sh
,如果全都启动失败了,内核就会 panic 。但幸运的是,启动通常都会成功。成功启动的进程将作为 PID 1 开始运行,其会检查自己的配置文件来确定启动哪些进程——例如 X11 Windows、控制台的登录程序、网络守护进程等。至此,启动完成。
在相同的架构下, Windows 的启动过程和 Linux 有很多相似之处——需要面对很多相同的问题,并做一系列必须完成的初始化。启动阶段最大的区别在于,Windows 将所有实模式内核代码和部分最初的保护模式代码都打包放在了 boot loader 中(C:\NTLDR
)。因此,与 Linux 将一个内核镜像分到了内存中的两个区域不同,Windows 直接提供了两个二进制镜像。另一个不同点是 Linux 实现了 bootloader 和内核的完全分离。下图中展示了 Windows 内核启动的主要部分:
Windows 内核初始化
Windows 用户模式的启动与 Linux 非常不同——Windows 没有 /sbin/init
,而是通过 Csrss.exe
和 Winlogin.exe
启动。Winlogin 会启动 Services.exe ,其会启动所有的 Windows 服务以及 Lsass.exe —— 本地安全身份认证子系统。经典的 Windows 登录对话窗口就运行在 Winlogin 的上下文中。
系统启动系列就此完结。感谢大家的阅读和反馈。下面给出一些有用的资源:
- 最有用的资源永远是内核源码。Linux 的和各种 BSD 的都好;
- Intel 出版了非常棒的 软件开发者手册 ,可以免费下载;
- 理解 Linux 内核 是一本很好的书,它能够带你了解很多内核源码。虽然有点老了,但依旧值得推荐。Linux 设备驱动 更有趣一些,但覆盖范围较小。评论区推荐的 Linux 内核开发 也是一本广受好评的好书,应该值得一看;
- 目前最好的 Windows 参考书是 David Solomon 和 Mark Russinovich 的 Windows Internals ,后者是 Sysinternals 的大佬。这是一本好书,只可惜没有系统源码。
补充内容:评论区中的 "Nix" 对初始根文件系统( initial root file system, initrfs )进行了补充。为了简便,本文没有提到这部分内容。
译注,补充翻译如下
最初的根文件系统,默认是从内核代码树的 usr/
子文件夹中的内容组合而来的(体现为一个 cpio 压缩包),并链接到内核镜像中(也可以将这个压缩的文件系统镜像作为一个单独的文件使用)。启动过程(定义在 init/main.c:do_basic_setup()
)的一部分会执行 initcalls
—— 这些调用会以函数指针数组的形式保存,并在启动时调用,该数组由链接器构造。
这些 initcalls 中的一个就是 init/initramfs.c:populate_rootfs()
,该函数会初始化不可换页的内存文件系统,该文件系统挂载在 /
("真正的"根文件系统是挂载在这个根之上的,稍后会提到)。在系统成功启动后, rootfs 也不会被卸载,而是会一直挂在 /proc/mounts
。上面的 init/initramfs.c:populate_rootfs()
函数会解压这个压缩的文件系统,并在该文件系统上执行 /init
。一旦找到真正的根文件系统,就会立即 chroot 过去并执行真正的 init ,这样 "早期用户空间" 的启动就完成了。因此——寻找根文件系统的过程是完全可自定义的:可以从 RAID 阵列进行组合,也可以从网络拉取一部分组件(我已经这么做了,这样可以用于极端情况下的系统恢复)。
但如果没能找到,系统就还是没有一个包含 /sbin/init
的文件系统。在调用 init_post()
之前,系统可以调用 init/do_mounts.c
中的 prepare_namespace()
函数,该函数可以尝试通过多种方式来找到一个根文件系统:比如暂停一段时间,等待保存在低速硬件中的根文件系统就绪(例如 SCSI 磁盘或 USB 盘),又比如自动检测 RAID (这么做其实有点危险,因为此时无法确定此时组合阵列的方式是否正确:推荐的 RAID 启动方式是使用可自定义的启动进程,并使用 mdadm 工具来进行组合),再比如通过内核命令行的 root= 来挂载指定的块设备。甚至可以直接要求用户插入一张包含根文件系统的软盘。
评论记录:
回复评论: