11 KiB
8.14 Linux 系统的启动流程
从上电到系统启动
一般而言,系统从上电到 Linux 系统启动完毕需要经历以下主要阶段:
硬件系统复位 -> 硬件引导 -> 软件引导阶段 -> 内核阶段 -> initramfs -> rootfs -> initrc -> systemd service
通常,硬件系统具有一组上电自动复位电路,这将引起整个硬件系统的复位,包括将某些寄存器赋予初始值,以及将程序指针(IP/PC指针)指向复位位置。如果该位置具有有效指令,则程序将继续执行,从而进入到引导阶段。
随着系统复杂度的增加以及接口的持续标准化,一些硬件厂商倾向于在系统中嵌入一段引导代码,用于完成一部分初始化工作,这段代码运行在其他软件引导程序之前,可认为是硬件引导程序。
硬件引导完成之后,将跳转到特定程序位置继续执行,软件引导代码接管系统权限。软件引导可以由一个程序完成,也可以分为多个引导程序。
引导程序执行完毕后,再次修改程序指针,使之指向内核程序段首地址,运行内核程序的第一条指令。
从整个上电和引导流程来看,引导技术的关键就是修改程序指针到特定地址去执行下一阶段的程序。而在这之前,又要能够将下一阶段程序加载到内存中,并且准备好其运行环境,比如分配内存空间等。另外,对于引导系统而言就是要提供某些用户功能,比如命令行交互,串口、以太网和外部存储器的的驱动等,甚至是加载文件系统,当然也要准备好对应的中断资源。
引导系统体现的是从实际问题出发,实事求是的解决主要矛盾的核心思想。比如,由于硬件能力有限,最初的 x86 系统,其刚启动时,硬件只会加载磁盘第一个扇区并引导其运行,这一扇区中的程序又要能够继续访问外部存储器,加载后续程序,准备其运行环境,并执行之。这就要求这段程序必须十分简洁,并且整个引导系统都要进行合理的设计。
软件引导阶段
如前文所述,软件引导可以分为多个阶段。对于基于 x86/64 的 Linux 系统,通常由 BIOS/UEFI 引导,然后交由 GRUB 进行后续引导,最后将控制权交给内核。
而对于基于 ARM 的 Linux 系统,通常先由硬件厂商定制的引导程序引导,然后交由 UBoot 等进行第二阶段引导。
第一阶段引导
对于一些 ARM 系统,其引导程序以二进制形式直接烧录于外部的 SPI Flash。加载这种引导程序不需要复杂的外存控制器驱动,也不需要文件系统来支撑,对于资源有限的硬件引导程序来说,这种设计比较实际。
基于 ARM 的 SOC 通常可选多种启动和引导方式,比如 Flash 启动或 SD 卡启动等,因此其第一阶段软件引导程序的形式也比较多样。
UBoot
内核镜像类型
vmlinuz:Linux 内核经过编译后也会生成一个 elf 格式的可执行程序,叫 vmlinux 或 vmlinuz,这个就是原始的未经任何处理加工的原版内核 elf 文件。嵌入式系统部署时烧录的一般不是这个 vmlinuz/vmlinux,而是要用 objcopy 工具去制作成烧录镜像格式(就是 xxx.bin 这种,但是内核没有 .bin 后缀),经过制作加工成烧录镜像的文件就叫做 Image。
原则上 Image 就可以直接烧录到 Flash 上进行启动执行,但是实际上并不是这么简单,实际上 Linux 的作者觉得 Image 还是太大了,所以对 Image 进行了压缩,并且在 Image 压缩后的文件的前端附加了一部分解压缩代码,构成了一个压缩格式的镜像就叫 zImage。
uboot 为了启动 Linux 内核,还发明了一种内核格式叫做 uImage。uImage 是由 zImage 加工得到的,uboot 中有一个工具,可以将 zImage 加工生成 uImage。uboot 中的 mkimage 工具将 zImage 加工生成 uImage,这个加工过程其实就是在 zImage 前面加上 64 字节的 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
引导阶段
- 硬件引导层
- 第一阶段引导
- 第二极端引导等
通常硬件里集成了一组启动程序,硬件启动程序执行完后,将系统控制权交给引导程序。
引导程序可能有一级,也可能有多级。一般在 x86/64 平台上为 GRUB 等,而 ARM 平台上为 UBoot 等。在 ARM 平台上,如果厂商预置了其他引导程序,则通常在 UBoot 之前运行。
GRUB 或 UBoot 最主要的工作是准备内核运行环境,包括设置启动引导参数等。如设置串口输出的波特率,使用的设备树文件,内核或驱动的参数等。
<Linux Kernel Source>/Documentation/arm/Booting
- Setup and initialise RAM
- Detect the machine type
- Setup the device tree
- Load initramfs
- Calling the kernel image
引导系统将内核装载到指定位置并跳转到入口点去执行。在内核内存管理提到,内核对低端内存进行了直接映射,而引导系统加载内核的地址范围,也正是在此段区域。
引导系统将 zImage 加载到对应地址上后,就会执行 zImage 中的自解压程序,将内核释放。
预留 0x8000(32k) 的空间给内核参数。
TODO: 只要不冲突,UBoot 可以将 zImage 加载到任何位置,zImage 自己会将自己释放到正确位置上。
UBoot 引导
TODO:
LINUX_ZIMAGE_MAGIC
- 这个是定义的一个魔数,这个数等于0x016F2818,表示这个镜像是一个zImage。也就是说zImage格式的镜像中,在头部的一个固定位置存放了这个数作为格式标记。如果拿到了一个Image,去那个位置读取4个字节判断它是否等于LINUX_ZIMAGE_MAGIC,则可以知道这个镜像是不是一个zImage。
- 命令bootm 30008000,所以do_bootm的argc=2、argv[0]=boom、argv[1]=0x30008000,但是实际bootm命令还可以不带参数执行。如果不带参数直接bootm,则会从CFG_LOAD_ADDR地址去执行(定义位于x210_sd.h中)。
- zImage头部开始的第37~40字节处存放着zImage标志魔数,从这个位置取出然后对比LINUX_ZIMAGE_MAGIC。
uImage启动
- LEGACY(遗留的),在do_bootm函数中,这种方式指的就是uImage的方式。
- uImage方式是uboot本身发明的支持linux启动的镜像格式,但是后来这种方式被一种新的方式替代,这个新的方式就是设备树方式(在do_bootm中叫FIT)。
- uImage的启动校验主要在boot_get_kernel函数中,主要任务就是校验uImage的头信息,并且得到真正的kernel的起始位置去启动。
镜像的entrypoint
- ep就是entrypoint的缩写,就是程序入口。一个镜像文件的起始执行部分不是在镜像的开头(镜像开头有n个字节的头信息),真正的镜像文件执行时,第一句代码在镜像的中部某个字节处,相当于头是有一定的偏移量的。这个偏移量记录在头信息中。
- 一般执行一个镜像文件的过程是:第一步,先读取头信息,然后在头信息的特定地址找MAGIC_NUM,由此来确定镜像种类;第二步,对镜像进行校验;第三步,再次读取头信息,由头信息的特定地址知道这个镜像的各种信息(镜像长度、镜像种类、入口地址);第四步,去entrypoint处执行镜像。
- theKernel = (void (*)(int, int, uint))ep; —— 将ep赋值给theKernel,则这个函数指针就指向了内存中加载的OS镜像的真正入口地址(就是操作系统的第一句执行的代码)。
传参并启动操作
内核阶段
arch/arm/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);
内核模块的加载顺序
- 由 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 是一个内存文件系统,可以一些必要的驱动程序或工具打包进该文件系统。这个文件系统由 Bootloader 加载进内存,如 GRUB 或 UBoot,内核直接使用即可。
initramfs 不是必须的,如果全部必须的驱动已经 build in 到内核,则可以不使用 initramfs。
rootfs
TODO: 挂载外存驱动和 init 进程谁先谁后?
有了外存驱动以后,内核可以加载对应的外存驱动,执行第一个进程,即 init 程序,并挂载文件系统。但是都需要挂载哪些磁盘分区,哪个磁盘分区才是根文件系统呢?这是由 /etc/fstab 文件决定的。