现在的 Linux 启动过程一般分成了两步,也就是首先调用 GRUB 作为通用的启动服务,然后可以选择 Windows 或者 Linux 加载。
接下来,看看 Linux 的加载过程。
启动加载
Bootloader 执行完之后,将内核加载到物理地址 0x100000(1MB)
处,并将执行权限交给了内核。然后执行的是 setup.bin
的入口函数 _start()
( arch/x86/boot/header.S
,链接文件为 setup.lds
) 。
第一条是一个调转指令,调转到 start_of_setup
(start_of_setup-1f
是setup头部的长度),在这之间的代码是一个庞大的数据结构,与 bootparam.h
中的 struct setup_header
一一对应。这个数据结构定义了启动时所需的默认参数,其中一些参数可以通过命令选项 overwrite。很多值是在形成 bzImage 时,由 arch/x86/boot/tools/build
程序赋值。
接着实际是设置 C 语言环境,主要是设置堆栈,然后调转到 main()@arch/x86/boot/main.c
,现在仍然运行在实模式。setup.bin
主要作用就是完成一些系统检测和环境准备的工作,其函数调用顺序为:
_start()@arch/x86/boot/header.S
|-start_of_setup()@arch/x86/boot/header.S
|-main()@arch/x86/boot/main.c
|-copy_boot_params() // 将进行参数复制
|-detect_memory() // 探测物理内存,第一次与内存相关
|-go_to_protect_mode
|-realmode_switch_hook() // 如果boot_params.hdr.realmode_swtch有非空的hook函数地址则执行之
|-enable_a20()
|-reset_coprecessor() // 重启协处理器
|-protected_mode_jump()@arch/x86/boot/pmjump.S // 进入 32-bit 模式
main.c 主要功能为检测系统参数如: Detect memory layout
,set video mode
等,后面会详细分析,最后调用 goto_protect_mode
,设置 32-bit 或 64-bit 保护段式寻址模式,注意: main.o
编译为16位实模式程序。
内核的日志信息可以通过 printk() 打印,不过只有信息等级小于 console loglevel 的值时,这些信息才会被打印到 console 上,可以通过如下方法修改日志等级。
- 修改内核启动时的参数选项,loglevel=level。
- 运行时执行如下命令 dmesg -n level 或者 echo $level > /proc/sys/kernel/printk 。
- 写程序使用 syslog 系统调用。
goto_protect_modea()
则会调用 protected_mode_jump()
,进入 32-bit 的兼容模式,也就是 IA32-e 模式,在最后会执行 jmpl *%eax
调转到 32-bits 的入口,也就是 0x100000
处。
startup_32()
可以看到 arch/x86/boot/compressed/head_64.S
的入口函数 startup_32
,该函数会解压缩 Kernel、设置内存地址转换、进入 64bit 模式。
关于转换的具体步骤,可以参考 《Intel 64 and IA-32 Architectures Software Developer’s Manual》
中的 9.8.5 Initializing IA-32e Mode
部分 。
然后会调转到 arch/x86/kernel/head_64.S
的 startup_64
。
准备环境
这个函数主要是为第一个 Linux 进程 (进程0) 建立执行环境,该函数主要执行以下操作:
最先执行的是 arch/x86/boot/header.S
(由最早的bootsect.S和setup.S修改而来),一般是汇编语言。最早的时候 Linux 可以自己启动,因此包含了 Boot Sector 的代码,但是现在如果执行的话只会输出 "bugger_off_msg"
,并重启,该段为 .bstext
。
现在的 Bootloader 会忽略这段代码。上述无效的 Boot Sector 之后还有 15Bytes 的 Real-Mode Kernel Header ,这两部分总计 512Bytes 。
512 字节之后,也就是在偏移 0x200
处,是 Linux Kernel 实模式的入口,也即 _start
。第一条指令是直接用机器码写的跳转指令 (0xeb+[start_of_setup-1])
,因此会直接跳转到 start_of_setup
。这个函数主要是设置堆栈、清零 bss 段,然后跳转到 arch/x86/boot/main.c:main()
。
main()
函数主要完成一些必要的清理工作,例如探测内存的分布、设置 Video Mode 等,最后调用 go_to_protected_ mode()@arch/x86/boot/pm.c
。
初始化
在进入保护模式之前,还需要一些初始化操作,最主要的两项是 中断 和 内存。
中断
在实模式中,中断向量表保存在地址 0 处,而在保护模式中,中断向量表的地址保存在寄存器 IDTR 中。内存
实模式和保护模式的从逻辑地址到实地址的转换不同。在保护模式中,需要通过 GDTR 定位全局描述符表(Global Descriptor Table) 。
因此,进入保护模式之前,在 go_to_protected_mode()
中会通过调用 setup_idt()
和 setup_gdt()
创建临时的中断描述符表和全局描述符表。最后通过 protected_mode_jump()
(同样为汇编语言) 进入保护模式。
protected_mode_jump()
会设置 CR0 寄存器的 PE 位。此时,不需要分页功能,因此分页功能是关闭的。最主要的是现在不会受 640K 的限制, RAM 的访问空间达到了 4GB 。
然后会调用 32-bit 的内核入口,即 startup_32 。该函数会初始化寄存器,并通过 decompress_kernel() 解压内核。
decompress_kernel()
输出 "Decompressing Linux..."
,然后 in-place 解压,解压后的内核镜像会覆盖上图中的压缩镜像。因此,解压后的内容也是从 1MB 开始。解压完成后将会输出 "done."
,然后输出 "Booting the kernel."
。
此时将会跳转到保护模式下的入口处 (0x100000)
,该入口同样为 startup_32
,但与上面的在不同的目录下。
第二个 startup_32
同样为汇编语言,包含了 32-bit 的初始化函数。包括清零保护模式下的 Kernel bss 段、建立全局描述符、建立中断描述符表,然后跳转到目标相关的启动入口,start_kernel()
,过程如下所示。
正式启动
start_kernel()@init/main.c
的大部分代码为 C ,而且与平台相关。该函数会初始化内核的各个子系统和数据结构,包括了调度器(Scheduler)、内存、时钟等。
然后 start_kernel()
会调用 rest_init()
,该函数将会创建一个内核线程,并将 kernel_init()
作为一个入口传入。
dmesg 中的信息是从 start_kernel()
之后记录的。
像 USB、ACPI、PCI 这样的系统,会通过 subsys_initcall() 定义一个入口,当然还有一些其它的类似入口,可以参考 include/linux/init.h ,实际上时在内核镜像中定义了 .initcall.init 字段 (连接脚本见vmlinux.lds)。
这些函数会在 do_initcalls() 中调用。
rest_init()
随后会调用 schedule()
启动任务调度器,并通过 cpu_idle()
进入睡眠,这是 Linux 中的空闲线程。当没有可运行的进程时,该线程会调用,否则运行可运行的线程。
此时,之前启动的线程将会替代进程0(process 0) 即空闲线程。kernel_init()
将会初始化其他的 CPUs ,在此之前,这些 CPUs 并没有运行。负责初始化的 CPU 被称为 boot processor ,此时启动的 CPUs 被称为 application processors ,这些 CPUs 同样从实模式启动,因此也需要类似的初始化。
最后 kernel_init()
调用 init_post()
,该函数将会尝试执行一个用户进程,执行顺序如下 /sbin/init
,/etc/init
,/bin/init
和 /bin/sh
。如果所有的都失败了,那么内核将会停止。
此时执行的进程 PID 为 1 ,它将会检查配置文件,然后运行相应的进程,如 X11 Windows、登录程序、网络服务等。
至此,全部启动过程完成。
详细流程
从代码角度,介绍启动时的调用流程。
start_kernel()
|-smp_setup_processor_id() ← 返回启动时的CPU号
|-local_irq_disable() ← 关闭当前CPU的中断
|-setup_arch() ← 完成与体系结构相关的初始化工作
| |-setup_memory_map() ← 建立内存图
| |-e820_end_of_ram_pfn() ← 找出最大的可用页帧号
| |-init_mem_mapping() ← 初始化内存映射机制
| |-initmem_init() ← 初始化内存分配器
| |-x86_init.paging.pagetable_init() ← 建立完整的页表
|-parse_early_param()
| |-parse_args() ← 调用两次parse_args()处理bootloader传递的参数
|-parse_args()
|-init_IRQ() ← 硬件中断初始化
|-softirq_init() ← 软中断初始化
|
|-vfs_caches_init_early()
|-vfs_caches_init() ← 根据参数计算可以作为缓存的页面数,并建立一个存放文件名称的slab缓存
| |-kmem_cache_create() ← 创建slab缓存
| |-dcache_init() ← 建立dentry和dentry_hashtable的缓存
| |-inode_init() ← 建立inode和inode_hashtable的缓存
| |-files_init() ← 建立filp的slab缓存,设置内核可打开的最大文件数
| |-mnt_init() ← 完成sysfs和rootfs的注册和挂载
| |-kernfs_init()
| |-sysfs_init() ← 注册挂载sysfs
| | |-kmem_cache_create() ← 创建缓存
| | |-register_filesystem()
| |-kobject_create_and_add() ← 创建fs目录
| |-init_rootfs() ← 注册rootfs文件系统
| |-init_mount_tree() ← 建立目录树,将init_task的命名空间与之联系起来
| |-vfs_kern_mount() ← 挂载已经注册的rootfs文件系统
| | |-alloc_vfsmnt()
| |-create_mnt_ns() ← 创建命名空间
| |-set_fs_pwd() ← 设置init的当前目录
| |-set_fs_root() ← 以及根目录
|
|-rest_init()
|-kernel_init() ← 通过kernel_thread()创建独立内核线程
| |-kernel_init_freeable()
| | |-do_basic_setup()
| | | |-do_initcalls() ← 调用子模块的初始化
| | | |-do_initcall_level()
| | | |-do_one_initcall() ← 调用一系列初始化函数
| | | |-populate_rootfs()
| | | |-unpack_to_rootfs()
| | |
| | |-prepare_namespace()
| | |-wait_for_device_probe()
| | |-md_run_setup()
| | |-initrd_load() ← 加载initrd
| | | |-create_dev()
| | | |-rd_load_image()
| | | |-identify_ramdisk_image() ← 检查映像文件的magic确定格式,minux、ext2等;并返回解压方法
| | | | |-decompress_method()
| | | |-crd_load() ← 解压
| | | |-deco()
| | |
| | |-mount_root()
| |
| |-run_init_process() ← 执行init,会依次查看ramdisk、命令行指定、/sbin/init等
|
|-kthreadd() ← 同样通过kernel_thread()创建独立内核线程
初始化
在初始化时通常可以分为两种:A) 一种是关键而其必须按照特定顺序来完成,通常在 start_kernel()
中直接调用;B) 以子系统、模块实现,通过 do_initcalls()
完成。
在 do_initcalls()
中调用时,会按照等级,从 level0 ~ level7 来初始化,其宏定义在 include/linux/init.h
中实现,简单分为了两类,内核以及模块的实现。
下面以 inet_init 的初始化为例,末行为最后的展开格式。
fs_initcall(inet_init); // net/ipv4/af_inet.c
#define fs_initcall(fn) __define_initcall(fn, 5) // include/linux/init.h
#define __define_initcall(fn, id) \ // 同上
static initcall_t __initcall_##fn##id __used \
__attribute__((__section__(".initcall" #id ".init"))) = fn
static initcall_t __initcall_inet_init5 __used __attribute__((__section__(".initcall5.init"))) = inet_init;
do_initcalls()
是从特定的内存区域取出初始化函数的指针,然后调用该函数,通过 "vmlinux.lds.h"
定义的宏。
参考
- 阮一峰的网络日志中有介绍启动流程,可以查看 Linux 的启动流程 。
- 另外,一个不错的介绍可以查看 Gustavo Duarte Blog ,Motherboard Chipsets Mmemory Map 主要介绍主板的内存;How Computers Boot Up 介绍计算机如何启动;Kernel Boot Process 介绍内核启动的过程,含有 Linux 和 Windows 的启动过程 。