17 KiB
8.14 Linux 系统的启动流程
从上电到系统启动
一般而言,系统从上电到 Linux 系统启动完毕需要经历以下主要阶段:
硬件系统复位 -> 硬件引导 -> 软件引导阶段 -> 内核阶段 -> initramfs -> rootfs -> initrc -> systemd service
通常,硬件系统具有一组上电自动复位电路,这将引起整个硬件系统的复位,包括将某些寄存器赋予初始值,以及将程序指针(IP/PC指针)指向复位位置。如果该位置具有有效指令,则程序将继续执行,从而进入到引导阶段。
随着系统复杂度的增加以及接口的持续标准化,一些硬件厂商倾向于在系统 ROM 中嵌入一段引导代码,用于完成一部分初始化工作,这段代码运行在其他软件引导程序之前,可认为是硬件引导程序,比如说 BIOS 系统。
以 ARM 平台为例,硬件引导阶段通常不具备外部 DRR 的驱动能力,只使用内部的 s-RAM 进行工作。硬件引导在完成一系列准备工作后,将加载第一阶段软件引导程序到 s-RAM 中的指定位置,并跳转到该程序位置继续执行,此时软件引导代码接管系统权限。
软件引导可以由一个程序完成,也可以分为多个引导程序,一般而言,第一级软件引导程序执行完毕后系统就具备的访问 DDR 的能力,具备简单的外部存储器的驱动能力,但不具备文件系统功能,因此无法从文件系统中加载程序,只能装载外存中 Boot 区的内容。外存 Boot 区存有二级引导程序,当二级引导程序运行完毕后,系统就具备了文件系统的访问能力。引导程序执行完毕后,将再次修改程序指针,使之指向内核程序段首地址,运行内核程序的第一条指令。
从整个上电和引导流程来看,引导程序的关键就是逐步的为下一级程序创造合适的运行环境,然后通过程序跳转等方式修改程序指针到下一级程序的首地址,直至系统具备全部的硬件访问能力,并能够从外存的文件系统中加载程序和文件,完成内核的引导和文件系统的装载工作。另外,对于二级软件引导系统而言,很可能需要提供一些额外的用户功能,比如命令行交互,串口、以太网和外部存储器的的驱动等,甚至是加载文件系统,当然也要准备好对应的中断资源。
引导系统体现的是从实际问题出发,实事求是的解决主要矛盾的核心思想。比如,由于硬件能力有限,最初的 x86 系统,其刚启动时,硬件只会加载磁盘第一个扇区并引导其运行,这一扇区中的程序又要能够继续访问外部存储器,加载后续程序,准备其运行环境,并执行之。这就要求这段程序必须十分简洁,有针对性的去解决实际问题,并且整个引导系统都要进行合理的设计。
软件引导阶段
如前文所述,软件引导可以分为多个阶段。对于基于 x86/64 的 Linux 系统,通常由 BIOS 完成硬件引导,然后交由 GRUB 进行后续引导,最后将控制权交给内核。
而对于基于 ARM 的 Linux 系统,通常先由硬件厂商定制的引导程序引导,然后交由 UBoot 等进行第二阶段引导,装载内核并启动系统。
第一阶段引导
对于一些 ARM 系统,其引导程序以二进制形式直接烧录于外部的 SPI Flash。加载这种引导程序不需要复杂的外存控制器驱动,也不需要文件系统来支撑,对于资源有限的硬件引导程序来说,这种设计比较实际。
第一阶段引导程序的后续主要工作就是装载第二阶段引导系统,第二阶段引导系统可能位于外部 Flash、eMMC 甚至是 SD 卡中,为此,一阶段引导程序就必须具备相应设备的驱动能力,有的第一阶段引导程序只支持从其中一种存储器中装载程序,此时需要注意烧录镜像是否正确,或者在支持多存储器的情况下,注意拨码开关的设置。
一旦能成功访问到第二阶段引导程序,其将被装载到一个特定的内存地址中,第一阶段引导程序完成全部工作后,就跳转到这个地址,第二阶段引导程序开始执行。
值得注意的是,第一阶段引导程序也有可能划分为多个子阶段或子程序,但从功能上来看,它们都在从事第一阶段的引导工作。
第二阶段引导和 UBoot
第二阶段引导程序的主要工作是准备内核运行环境,包括设置启动引导参数等,决定使用的设备树文件,内核或驱动的参数等,例如:Linux 内核文档中对于 ARM 平台引导阶段所需要做的事情给出了以下清单:
- 设置并初始化 RAM
- 探知硬件平台类型
- 设置设备树
- 装载 initramfs
- 调用系统内核
最为常见的二级引导程序有:GRUB、UBoot 和 Systemd-Boot 等,其中 UBoot 是在嵌入式系统中使用最广泛的二级引导程序,其特点是功能强大,移植容易。它能够引导 zImage、uImage 等类型的内核镜像、装载 ramfs 等。
zImage 是 Linux 定义的内核镜像格式,其特点是头部第 37~40 字节处为由 LINUX_ZIMAGE_MAGIC 宏定义的魔数:0x016F2818。zImage 的实质是从 vmlinuz/vmlinux(elf 格式) 通过 objcopy 得来的二进制程序文件,之后该程序文件又经过了压缩和增加头部信息等处理,进一步的减少了文件体积。而为了能正确解压该镜像文件,其中又嵌入了一段解压代码。
UBoot 等二级引导程序将 zImage 加载到系统的内存中,确切的说是内核内存管理中提到的直接映射区。之后引导程序跳转到内核首地址去执行,这个地址被称为内核的入口点(EP)。需要注意的是,内核入口点并不一定位于内核镜像文件的零偏移处。
一旦统权限由 UBoot 让度给 Linux 系统内核,解压算法首先被执行,将真正的内核镜像释放到正确的内存位置上。
在加载内核时,UBoot 还需要预留 0x8000(32k) 的空间用于存储内核参数,例如对 console、init、isolcpus 的设定等都会存储在这个区域中。
为了加载 zImage,就需要提供一组内核加载信息,如:内核加载地址,内核入口地址等。为方便加载内核镜像,UBoot 增加了一种新的内核镜像格式——Legacy uImage。Legacy uImage 在 zImage 基础上增加了一个 64 字节的头,打包了魔数、镜像长度、内核加载地址、内核入口地址等加载内核时所需的参数。
UBoot 提供了 mkimage 工具用于制作 Legacy uImage 文件,该命令基本格式如下:
# -A ==> set architecture to 'arch'(“alpha”,”arm”,”x86″,”ia64″,”m6k8″,”microblaze”,”mips”,”mips64″,”nios”,”nios2″,”ppc”,”s390″,”sh”,”sparc”,”sparc64″,“blackfin”,”avr32″)
# -O ==> set operating system to 'os'(“4_4bsd”,”artos”,”esix”,”freebsd”,”irix”,”linux”,”lynxos”,”ncr”,”netbsd”,”openbsd”,”psos”,”qnx”,”rtems”,”sco”,”sloaris”,“u-boot”,vxworks”)
# -T ==> set image type to 'type'(“filesystem”,”firmware”,”firmware”,”kernel”,”multi”,”ramdisk”,”script”,”standalone”,”flat_dt”)
# -C ==> set compression type 'comp'(“none”,”bzip2″,”gzip”)
# -a ==> set load address to 'addr' (hex)
# -e ==> set entry point to 'ep' (hex)(一般是-a参数指定的值加上0x40。(因为前面有个mkimage添加的0x40个字节的头))
# -n ==> set image name to 'name'
# -d ==> use image data from 'datafile'
# -x ==> set XIP (execute in place)
mkimage -n 'linux-2.6.32' -A arm -O linux -T kernel -C none -a 0x30008000 -e 0x30008040 -d zImage uImage
除了 Legacy uImage,UBoot 还扩展了一种 FIT uImage 格式。FIT 运用了与 Device Tree 类似的语法和打包格式。dtc 和 mkimage 工具在 Image Source File(.its) 文件的指导下,将镜像文件打包成一体。FIT uImage 不仅可以包含内核,还可以包含设备树文件等,从而实现 Unify Kernel 的愿景。
Image Source File 的语法和 Device Tree Source File 完全一样,只不过自定义了一些特有的节点,包括:
- Images 节点:指定所要包含的二进制文件,可以指定多种类型的多个文件。
- Configurations 节点:可以将不同类型的二进制文件,根据不同的场景,组合起来,形成一个个的配置项,u-boot在boot的时候,以配置项为单位加载、执行,这样就可以根据不同的场景,方便的选择不同的配置。
示例如下:
/*
* U-Boot uImage source file with multiple kernels, ramdisks and FDT blobs
*/
/dts-v1/;
/ {
description = "Various kernels, ramdisks and FDT blobs";
#address-cells = <1>;
images {
kernel@1 {
description = "vanilla-2.6.23";
data = /incbin/("./vmlinux.bin.gz");
type = "kernel";
arch = "ppc";
os = "linux";
compression = "gzip";
load = <00000000>;
entry = <00000000>;
hash@1 {
algo = "md5";
};
hash@2 {
algo = "sha1";
};
};
kernel@2 {
description = "2.6.23-denx";
data = /incbin/("./2.6.23-denx.bin.gz");
type = "kernel";
arch = "ppc";
os = "linux";
compression = "gzip";
load = <00000000>;
entry = <00000000>;
hash@1 {
algo = "sha1";
};
};
kernel@3 {
description = "2.4.25-denx";
data = /incbin/("./2.4.25-denx.bin.gz");
type = "kernel";
arch = "ppc";
os = "linux";
compression = "gzip";
load = <00000000>;
entry = <00000000>;
hash@1 {
algo = "md5";
};
};
ramdisk@1 {
description = "eldk-4.2-ramdisk";
data = /incbin/("./eldk-4.2-ramdisk");
type = "ramdisk";
arch = "ppc";
os = "linux";
compression = "gzip";
load = <00000000>;
entry = <00000000>;
hash@1 {
algo = "sha1";
};
};
ramdisk@2 {
description = "eldk-3.1-ramdisk";
data = /incbin/("./eldk-3.1-ramdisk");
type = "ramdisk";
arch = "ppc";
os = "linux";
compression = "gzip";
load = <00000000>;
entry = <00000000>;
hash@1 {
algo = "crc32";
};
};
fdt@1 {
description = "tqm5200-fdt";
data = /incbin/("./tqm5200.dtb");
type = "flat_dt";
arch = "ppc";
compression = "none";
hash@1 {
algo = "crc32";
};
};
fdt@2 {
description = "tqm5200s-fdt";
data = /incbin/("./tqm5200s.dtb");
type = "flat_dt";
arch = "ppc";
compression = "none";
load = <00700000>;
hash@1 {
algo = "sha1";
};
};
};
configurations {
default = "config@1";
config@1 {
description = "tqm5200 vanilla-2.6.23 configuration";
kernel = "kernel@1";
ramdisk = "ramdisk@1";
fdt = "fdt@1";
};
config@2 {
description = "tqm5200s denx-2.6.23 configuration";
kernel = "kernel@2";
ramdisk = "ramdisk@1";
fdt = "fdt@2";
};
config@3 {
description = "tqm5200s denx-2.4.25 configuration";
kernel = "kernel@3";
ramdisk = "ramdisk@2";
};
};
};
它包含了 3 种配置,每种配置使用了不同的 kernel、ramdisk 和 fdt,默认配置项由“default”指定,当然也可以在运行时指定。
使用 mkimage 制作 FIT uImage 的命令如下:
# 制作镜像
mkimage -f <Image Source File>.its <FIT uImage File>.itb
# 查看信息
mkimage -l <FIT uImage File>.itb
FIT uImage 还能够支持签名,以实现 SecureBoot 功能。
在调试时,经常使用到 UBoot 命令行,在该命令行下,使用 boot 系列命令来引导内核,区别如下:
- bootm:启动 ARM uImage。
- booti:启动 ARM64 uImage。
- bootz:启动 zImage。
- bootefi:启动 EFI 分区中的内核镜像。
- bootp:从网络加载并启动内核镜像。
- nboot:启动 Nand Flash 中的内核镜像。
UBoot 启动内核镜像时,将使用一些参数对内核进行设定,这些参数以 UBoot 环境变量的形式来使用。这些环境变量通常被保存在外部存储器中,以实现数据的持久化。
内核阶段
一旦第二阶段引导程序将控制权交给内核后,内核就会初始化总线控制器、外设等。当完成全部初始化工作后,系统就具备了完全的执行能力。
内核阶段代码是从 arch/<ARCH>/kernel/head.S 开始的,其大体流程如下:
arch/\<ARCH>/kernel/head.S // 内核的启动汇编
|
+---__create_page_tables // 创建内核运行时所需的 rodata、data、bss 段
|
+---secondary_start_kernel // 跳转到 C 语言的入口函数
|
----init/main.c —> void __init start_kernel(void) // C 语言的程序入口
|
+---arch/<arch>/kernel/setup.c -> setup_arch(&command_line); // 体系结构初始化
| |
| +---setup_machine(machine_arch_type); //配置当前的机器类型
| |
| +----xxxxx_init_machine();
|
+---setup_command_line(command_line);
head.S 将创建一个内核进程,称为 0 号进程,0 号进程是系统中全部其他进程的父进程。
内核完成自身初始化后,将会按一定顺序加载内核模块。这个顺序首先由 init 宏决定,其次由内核 Makefile 文件决定:
- 由 init 宏决定了优先级:
- pure_initcall()
- core_initcall()/core_initcall_sync()
- postcore_initcall()/postcore_initcall_sync()
- arch_initcall()/arch_initcall_sync()
- subsys_initcall()/subsys_initcall_sync()
- fs_initcall()/fs_initcall_sync()
- rootfs_initcall()
- device_initcall()/moudle_init()/device_initcall_sync()
- late_initcall()/late_initcall_sync()
- 同一优先级的模块由 Makefile 决定,排在 Makefile 前面的模块先加载。
initramfs
内核启动后就要挂载 rootfs,但是 rootfs 在外部存储器中,因此需要加载外存的驱动程序,而外存的驱动很可能也在 rootfs 中,必须先加载 rootfs 才能加载驱动。此时就陷入先有鸡还是先有蛋的问题中。Linux 系统解决此问题的办法就是使用 initramfs。
initramfs 是一个内存文件系统,可以将一些必要的驱动程序或工具打包进该文件系统。这个文件系统由二级引导程序加载进内存,如 UBoot,内核直接使用即可。mkimage 能够将 initramfs 的镜像文件一起打包到 uImage 中,initramfs 镜像文件也称作 ramdisk 文件。
initramfs 不是必须的,如果全部必须的驱动已经 build in 到内核,则可以不使用 initramfs。
挂载文件系统
当内核和驱动全部加载并运行后,内核将挂载根文件系统。根文件系统可由内核的 root 参数进行指定,也能够通过 rootfstype、rootflags 等对根文件系统参数进行设定。
根文件系统也可以通过 /etc/fstab 文件来指定,该文件同时可以指定其他要挂载的文件系统,比如交换分区等:
# /etc/fstab: static file system information.
#
# Use 'blkid' to print the universally unique identifier for a device; this may
# be used with UUID= as a more robust way to name devices that works even if
# disks are added and removed. See fstab(5).
#
# <file system> <mount point> <type> <options> <dump> <pass>
UUID=XXXX-XXXX /boot/efi vfat umask=0077 0 2
UUID=XXXX-XXXX-XXXX-XXXX-XXXX / ext4 defaults,noatime 0 1
/swapfile swap swap defaults,noatime 0 0
tmpfs
以上 fstab 文件使用了磁盘分区的 UUID,当然也可以直接指定分区设备,如:/dev/sda1 等,但使用 UUID 的好处是,即便磁盘加载的顺序变了,挂载程序也能将该分区挂载到正确的挂载点上。
init 程序
挂载完根文件系统后,内核将搜索并执行根文件系统中的 init 程序,init 是由 0 号进程创建的,称为 1 号进程,同时也是用户态的第一个进程,是用户态其他进程的父进程。
关于 1 号进程,比较有趣的一点是,1 号进程诞生时还处于内核态,然后逐步的切换到用户态去执行。init 程序进入到用户态后就会调用 init 脚本去启动一些用户态服务程序。
Systemd Service
init 程序的设计非常简洁,因此一些复杂的系统服务无法通过 init 程序来实现。比如服务的启动、监控、日志记录等。在 init 脚本中,通常会启动其他系统服务管理器,Systemd 便是最常用的一种。
Systemd 服务由 /etc/systemd/system 下的文件进行配置,同时能够决定哪些服务将在启动时加载。当全部服务都被启动后,系统 shell 就会被建立起来,并最终为用户提供一个登陆会话。