开源软件中最古老的笑话是“代码是自文档化的”这句话。经验表明,阅读源代码就像听天气预报:明智的人仍然会走到户外看看天空。接下来是一些关于如何通过利用熟悉的调试工具的知识来检查和观察 Linux 系统启动的技巧。分析运行良好的系统的启动过程,可以帮助用户和开发人员应对不可避免的故障。
在某些方面,启动过程出奇地简单。内核在单核上以单线程和同步方式启动,对于可怜的人类大脑来说几乎是可以理解的。但是内核本身是如何启动的呢?initrd
(initial ramdisk) 和引导加载程序执行什么功能?等等,为什么以太网端口上的 LED 总是亮着?
继续阅读以获得这些问题和其他问题的答案;描述的演示和练习的代码也可在 GitHub 上找到。
启动的开始:关闭状态
网络唤醒
关闭状态意味着系统没有电源,对吗?表面上的简单性具有欺骗性。例如,以太网 LED 亮起是因为您的系统上启用了网络唤醒 (WOL)。通过键入以下内容检查是否是这种情况
$# sudo ethtool <interface name>
其中 <interface name>
可能是,例如,eth0
。(ethtool
在同名的 Linux 软件包中可以找到。)如果输出中的“Wake-on”显示 g
,则远程主机可以通过发送 MagicPacket 来启动系统。如果您不打算远程唤醒系统,也不希望其他人这样做,请在系统 BIOS 菜单中或通过以下方式关闭 WOL
$# sudo ethtool -s <interface name> wol d
响应 MagicPacket 的处理器可能是网络接口的一部分,也可能是 基板管理控制器 (BMC)。
Intel Management Engine、平台控制器中心和 Minix
BMC 不是系统名义上关闭时可能正在侦听的唯一微控制器 (MCU)。x86_64 系统还包括 Intel Management Engine (IME) 软件套件,用于远程管理系统。从服务器到笔记本电脑的各种设备都包含这项技术,这实现了诸如 KVM 远程控制和 Intel Capability Licensing Service 等功能。根据 英特尔自己的检测工具,IME 存在未修补的漏洞。坏消息是,很难禁用 IME。Trammell Hudson 创建了一个 me_cleaner 项目,该项目擦除了一些更令人震惊的 IME 组件,例如嵌入式 Web 服务器,但也可能使运行它的系统崩溃。
IME 固件和随后的启动系统管理模式 (SMM) 软件 基于 Minix 操作系统,并在单独的平台控制器中心处理器上运行,而不是主系统 CPU。然后,SMM 在主处理器上启动通用可扩展固件接口 (UEFI) 软件,关于该软件已经 撰写了很多文章。Google 的 Coreboot 团队启动了一个令人叹为观止的雄心勃勃的 非可扩展精简固件 (NERF) 项目,旨在不仅取代 UEFI,而且取代早期的 Linux 用户空间组件,例如 systemd。在我们等待这些新努力的结果的同时,Linux 用户现在可以从 Purism、System76 或 Dell 购买 禁用 IME 的笔记本电脑,此外,我们可以期待 配备 ARM 64 位处理器的笔记本电脑。
引导加载程序
除了启动有缺陷的间谍软件外,早期启动固件还有什么作用?引导加载程序的工作是为新供电的处理器提供运行通用操作系统(如 Linux)所需的资源。在开机时,不仅没有虚拟内存,而且在 DRAM 控制器启动之前也没有 DRAM。然后,引导加载程序打开电源并扫描总线和接口,以便找到内核映像和根文件系统。流行的引导加载程序(如 U-Boot 和 GRUB)支持熟悉的接口,如 USB、PCI 和 NFS,以及更嵌入式特定的设备,如 NOR 和 NAND 闪存。引导加载程序还与硬件安全设备(如 可信平台模块 (TPM))交互,以建立从最早启动开始的信任链。

opensource.com
开源的、广泛使用的 U-Boot 引导加载程序在从 Raspberry Pi 到 Nintendo 设备再到汽车主板再到 Chromebook 的各种系统上都受到支持。没有 syslog,当事情出错时,通常甚至没有任何控制台输出。为了方便调试,U-Boot 团队提供了一个沙箱,可以在构建主机上甚至在夜间持续集成系统中测试补丁。在安装了 Git 和 GNU 编译器集合 (GCC) 等常用开发工具的系统上,使用 U-Boot 的沙箱相对简单
$# git clone git://git.denx.de/u-boot; cd u-boot
$# make ARCH=sandbox defconfig
$# make; ./u-boot
=> printenv
=> help
就是这样:您正在 x86_64 上运行 U-Boot,并且可以测试棘手的功能,例如 模拟存储设备 重新分区、基于 TPM 的密钥操作以及 USB 设备的即插即用。U-Boot 沙箱甚至可以在 GDB 调试器下单步执行。使用沙箱进行开发比通过将引导加载程序重新刷新到主板上进行测试快 10 倍,并且可以通过 Ctrl+C 恢复“崩溃”的沙箱。
启动内核
配置启动内核
完成任务后,引导加载程序将执行跳转到它已加载到主内存中的内核代码,并开始执行,同时传递用户指定的任何命令行选项。内核是什么样的程序?file /boot/vmlinuz
表明它是一个 bzImage,这意味着一个大的压缩映像。Linux 源代码树包含一个 extract-vmlinux 工具,可用于解压缩文件
$# scripts/extract-vmlinux /boot/vmlinuz-$(uname -r) > vmlinux
$# file vmlinux
vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically
linked, stripped
内核是一个 可执行和链接格式 (ELF) 二进制文件,就像 Linux 用户空间程序一样。这意味着我们可以使用 binutils
软件包中的命令(如 readelf
)来检查它。比较例如以下内容的输出
$# readelf -S /bin/date
$# readelf -S vmlinux
二进制文件中的节列表在很大程度上是相同的。
因此,内核必须像其他 Linux ELF 二进制文件一样启动……但是用户空间程序实际上是如何启动的呢?在 main()
函数中,对吗?不完全是。
在 main()
函数可以运行之前,程序需要一个执行上下文,其中包括堆和栈内存以及 stdio
、stdout
和 stderr
的文件描述符。用户空间程序从标准库获取这些资源,标准库在大多数 Linux 系统上是 glibc
。考虑以下情况
$# file /bin/date
/bin/date: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically
linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=14e8563676febeb06d701dbee35d225c5a8e565a,
stripped
ELF 二进制文件有一个解释器,就像 Bash 和 Python 脚本一样,但是解释器不需要像脚本中那样用 #!
指定,因为 ELF 是 Linux 的本机格式。ELF 解释器通过调用 _start()
为二进制文件 配置 所需的资源,_start()
是 glibc
源代码包中可用的函数,可以通过 GDB 进行检查。内核显然没有解释器,必须自行配置,但是如何配置呢?
使用 GDB 检查内核的启动过程可以给出答案。首先安装内核的调试软件包,其中包含未剥离版本的 vmlinux
,例如 apt-get install linux-image-amd64-dbg
,或者从源代码编译并安装您自己的内核,例如,按照优秀的 Debian 内核手册 中的说明进行操作。gdb vmlinux
后跟 info files
显示 ELF 节 init.text
。使用 l *(address)
列出 init.text
中程序执行的开始位置,其中 address
是 init.text
的十六进制起始地址。GDB 将指示 x86_64 内核在内核文件 arch/x86/kernel/head_64.S 中启动,我们在其中找到汇编函数 start_cpu0()
和在调用 x86_64 start_kernel()
函数之前显式创建堆栈并解压缩 zImage 的代码。ARM 32 位内核具有类似的 arch/arm/kernel/head.S。start_kernel()
不是特定于体系结构的,因此该函数位于内核的 init/main.c 中。可以说,start_kernel()
是 Linux 真正的 main()
函数。
从 start_kernel() 到 PID 1
内核的硬件清单:设备树和 ACPI 表
在启动时,内核需要有关硬件的信息,而不仅仅是它已编译的处理器类型。代码中的指令通过单独存储的配置数据进行扩充。存储此数据主要有两种方法:设备树 和 ACPI 表。内核通过读取这些文件来了解它在每次启动时必须运行的硬件。
对于嵌入式设备,设备树是已安装硬件的清单。设备树只是一个文件,它与内核源代码同时编译,通常位于 /boot
中,与 vmlinux
并排。要查看 ARM 设备上的二进制设备树中的内容,只需在文件名与 /boot/*.dtb
匹配的文件上使用 binutils
软件包中的 strings
命令,因为 dtb
指的是设备树二进制文件。显然,只需编辑组成设备树的类似 JSON 的文件并重新运行内核源代码提供的特殊 dtc
编译器,即可修改设备树。虽然设备树是一个静态文件,其文件路径通常由引导加载程序在命令行上传递给内核,但在最近几年中添加了 设备树覆盖 功能,其中内核可以动态加载其他片段以响应启动后的热插拔事件。
x86 系列和许多企业级 ARM64 设备使用替代的高级配置和电源接口 (ACPI) 机制。与设备树相比,ACPI 信息存储在 /sys/firmware/acpi/tables
虚拟文件系统中,该文件系统由内核在启动时通过访问板载 ROM 创建。读取 ACPI 表的简单方法是使用 acpica-tools
软件包中的 acpidump
命令。这是一个例子

opensource.com
是的,您的 Linux 系统已为 Windows 2001 做好准备,如果您愿意安装它的话。ACPI 既有方法又有数据,这与设备树不同,设备树更像是一种硬件描述语言。ACPI 方法在启动后继续处于活动状态。例如,启动命令 acpi_listen
(来自软件包 apcid
)并打开和关闭笔记本电脑的盖子将显示 ACPI 功能一直在运行。虽然可以临时和动态地 覆盖 ACPI 表,但永久更改它们涉及到在启动时与 BIOS 菜单交互或重新刷新 ROM。如果您要付出这么大的努力,也许您应该只是 安装 coreboot,开源固件替代品。
从 start_kernel() 到用户空间
init/main.c 中的代码出奇地易读,而且有趣的是,仍然带有 Linus Torvalds 从 1991 年至 1992 年的原始版权。在新启动的系统上的 dmesg | head
中找到的行主要来自此源文件。第一个 CPU 在系统中注册,全局数据结构被初始化,并且调度器、中断处理程序 (IRQ)、计时器和控制台一个接一个地按严格的顺序联机。在函数 timekeeping_init()
运行之前,所有时间戳都为零。内核初始化的这一部分是同步的,这意味着执行精确地在一个线程中发生,并且在最后一个函数完成并返回之前,没有函数会被执行。因此,dmesg
输出将完全可重现,即使在两个系统之间也是如此,只要它们具有相同的设备树或 ACPI 表。Linux 的行为类似于在 MCU 上运行的 RTOS(实时操作系统)之一,例如 QNX 或 VxWorks。这种情况一直持续到 rest_init()
函数,该函数由 start_kernel()
在其终止时调用。

opensource.com
名称相当谦虚的 rest_init()
产生一个新的线程,该线程运行 kernel_init()
,后者调用 do_initcalls()
。用户可以通过将 initcall_debug
附加到内核命令行来监视正在运行的 initcalls
,这会导致每次 initcall
函数运行时都会生成 dmesg
条目。initcalls
经历七个连续级别:early、core、postcore、arch、subsys、fs、device 和 late。initcalls
最用户可见的部分是所有处理器外围设备的探测和设置:总线、网络、存储、显示器等,以及加载它们的内核模块。rest_init()
还在引导处理器上产生第二个线程,该线程首先运行 cpu_idle()
,同时等待调度程序为其分配工作。
kernel_init()
也 设置对称多处理 (SMP)。对于更新的内核,通过查找“Bringing up secondary CPUs...”在 dmesg
输出中找到这一点。SMP 通过“热插拔”CPU 来进行,这意味着它使用状态机管理它们的生命周期,该状态机在概念上类似于热插拔 USB 闪存驱动器等设备的生命周期。内核的电源管理系统经常使单个内核脱机,然后在需要时唤醒它们,以便在不繁忙的机器上反复调用相同的 CPU 热插拔代码。使用名为 offcputime.py
的 BCC 工具 观察电源管理系统对 CPU 热插拔的调用。
请注意,当 smp_init()
运行时,init/main.c
中的代码几乎已完成执行:引导处理器已完成其他内核无需重复的大部分一次性初始化。尽管如此,每个内核都必须生成每个内核线程,以管理每个内核上的中断 (IRQ)、工作队列、计时器和电源事件。例如,通过 ps -o psr
命令查看为软中断和工作队列提供服务的每个内核线程的运行情况。
$\# ps -o pid,psr,comm $(pgrep ksoftirqd)
PID PSR COMMAND
7 0 ksoftirqd/0
16 1 ksoftirqd/1
22 2 ksoftirqd/2
28 3 ksoftirqd/3
$\# ps -o pid,psr,comm $(pgrep kworker)
PID PSR COMMAND
4 0 kworker/0:0H
18 1 kworker/1:0H
24 2 kworker/2:0H
30 3 kworker/3:0H
[ . . . ]
其中 PSR 字段代表“处理器”。每个内核还必须托管自己的计时器和 cpuhp
热插拔处理程序。
最终,用户空间是如何启动的?在接近尾声时,kernel_init()
查找可以代表它执行 init
进程的 initrd
。如果找不到,内核将直接执行 init
本身。那么,为什么要使用 initrd
呢?
早期用户空间:谁订购了 initrd?
除了设备树之外,在启动时可选地提供给内核的另一个文件路径是 initrd
的路径。initrd
通常与 x86 上的 bzImage 文件 vmlinuz 或 ARM 的类似 uImage 和设备树一起位于 /boot
中。使用 initramfs-tools-core
软件包中的 lsinitramfs
工具列出 initrd
的内容。发行版 initrd
方案包含最小的 /bin
、/sbin
和 /etc
目录以及内核模块,以及 /scripts
中的一些文件。所有这些都应该看起来非常熟悉,因为 initrd
在很大程度上只是一个最小的 Linux 根文件系统。表面上的相似性有点具有欺骗性,因为 ramdisk 内 /bin
和 /sbin
中的几乎所有可执行文件都是指向 BusyBox 二进制文件 的符号链接,从而导致 /bin
和 /sbin
目录比 glibc 的目录小 10 倍。
如果 initrd
所做的只是加载一些模块,然后在常规根文件系统上启动 init
,那么为什么要费心创建 initrd
呢?考虑一个加密的根文件系统。解密可能依赖于加载存储在根文件系统上的 /lib/modules
中的内核模块……并且,不出所料,也存储在 initrd
中。加密模块可以静态编译到内核中,而不是从文件中加载,但是有各种原因不想这样做。例如,使用模块静态编译内核可能会使其太大而无法安装在可用存储空间上,或者静态编译可能会违反软件许可条款。不出所料,存储、网络和人机输入设备 (HID) 驱动程序也可能存在于 initrd
中——基本上是安装根文件系统所需的任何不属于内核本身的代码。initrd
也是用户可以存放自己的 自定义 ACPI 表代码的地方。

opensource.com
initrd
也非常适合测试文件系统和数据存储设备本身。将这些测试工具存放在 initrd
中,并从内存而不是从被测对象运行测试。
最后,当 init
运行时,系统启动了!由于辅助处理器现在正在运行,因此机器已成为我们熟知和喜爱的异步、可抢占、不可预测的高性能生物。实际上,ps -o pid,psr,comm -p 1
很可能表明用户空间的 init
进程不再在引导处理器上运行。
总结
考虑到即使在简单的嵌入式设备上也有许多不同的软件参与,Linux 启动过程听起来令人望而生畏。从另一个角度来看,启动过程相当简单,因为在启动过程中不存在抢占、RCU 和竞争条件等功能造成的令人费解的复杂性。仅关注内核和 PID 1 会忽略引导加载程序和辅助处理器在准备平台以供内核运行方面可能做的大量工作。虽然内核在 Linux 程序中肯定是独一无二的,但通过将用于检查其他 ELF 二进制文件的一些相同工具应用于内核,可以了解其结构的一些见解。研究运行良好的启动过程可以为系统维护人员应对故障做好准备。
要了解更多信息,请参加 Alison Chaiken 在 linux.conf.au 上的演讲 Linux:第一秒,该演讲将于 1 月 22 日至 26 日在悉尼举行。
感谢 Akkana Peck 最初提出这个主题并进行了许多更正。
8 条评论