树莓派OS-#0x01-理解Linux内核的初始化流程

本文概览

源码结构

➜  linux-master tree -L 1
.
├── Kbuild
├── Kconfig
├── Makefile
├── arch
├── drivers
├── fs
├── include
├── init
├── kernel
├── mm

与内核初始化过程最相关的几个源码目录是:

  • arch 包含许多子目录,每个子目录对应的一种处理器架构
  • init 包含start_kernel和其他与内核初始化相关的函数。内核将由与处理器体系对应的代码引导。然后处理器会执行 start_kernel 函数,该函数负责常见的内核初始化工作,这些工作是与处理器体系结构无关的,是内核的起点
  • kernel Linux内核的核心,几乎所有主要的内核子系统都在此实现
  • mm 与内存管理相关的方法和数据结构都定义在此文件夹中
  • drivers 包含所有外设的驱动实现,此文件夹是内核代码中最大的一个
  • fs 包含各种文件系统的实现

编译规则

Linux 也是使用 make 工具去编译内核源码,但是它的 Makefile 比较复杂。同时 Linux 还开发了基于 makekbuild 编译系统。

kbuild 概念

  • 通过使用 kbuild 变量我们可以自定义编译过程。 kbuild 变量定义在 Kconfig 文件中,在 Kconfig 里可以定义变量和它的默认值。kbuild 变量有3种类型,string integer *boolean*。在 Kconfig 里可以定义变量之间的依赖。Kconfig 不是 make 的功能,它是被 Linux 自己实现解析的,在其中定义的变量会暴露给内核代码和 Makefile。变量的值在内核编译的配置阶段可以进行修改。 比如,执行 make menuconfig 可以自定义编译变量的值,然后它们会被存储在 .config 文件中。
  • Linux 采用的是递归编译。每个子目录能够有自己的 Makefile 和 Kconfig 文件,子目录的编译配置会在编译时被递归的编译。大多数子目录的 Makefile 都比较简单,基本都是定义了哪些目标文件需要被编译。

    obj-$(SOME_CONFIG_VARIABLE) += some_file.o
    

上面的 Makefile 配置表示,如果 SOME_CONFIG_VARIABLE 变量被定义,则会将 some_file.c 编译并链接到内核。如果你想不使用 Kconfig 中的变量去做条件编译,那你可以直接使用 obj-y 去添加编译目标:

   obj-y += some_file.o
  • make 只会在 target 依赖的文件发生改变了才去重新构建 target,这种特性能够有效的利用构建缓存,减少构建耗时。但是如果是一个构建命令发生了更改,make 就不能识别到,会导致 make 在重新编译时实际不会执行命令,而是使用之前的编译产物。

比如:

   %.o: %.c
   		gcc $(flag) -o $@ $<

flag 是一个配置变量,就有可能进行了修改,但是由于 make 判断到 %.c 文件没有发生修改,于是在重新编译时,实际不会去执行 gcc $(flag) -o...命令,而是直接使用上一次的 %.o 产物。这种情况就可能与我们的期望不一致了,所以 Linux 引入了if_changed 方法去增加了对命令是否修改的检测。上面的构建配置就可以修改为下面这样:

   cmd_compile = gcc $(flag) -o $@ $<

   %.o: %.c FORCE
	   $(call if_changed, compile)

修改之后的构建配置表示:为每一个.c文件执行if_changed函数(并把compile作为参数传递给它)去生成.c文件对应的.o文件。 if_changed函数会检查compile变量(if_changed会自动添加一个cmd_前缀)的值与上一次编译相比是否发生了修改,如果发生了修改,就会执行compile引用的命令,进而进行重新编译。FORCE 则是一个特别的依赖文件,使用 FORCE 表示强制让 make 在构建时总是执行构建配置下的命令。

于是使用 FORCEif_changed,就能避免*make*忽略了命令的修改而不触发重新编译。

编译内核

内核的编译流程其实很复杂,但是有两个主要的问题只要弄清楚了,大致流程也就清晰了。

  1. 源文件如何精确地编译为目标文件?
  2. 目标文件如何链接到OS映像?

为了便于理解,需要先了解第二个问题,目标文件的链接。

  • 首先运行 make help 能看到内核定义的编译目标

    ➜  linux-master make help
    ...
    Other generic targets:
     all		  - Build all targets marked with [*]
    * vmlinux	  - Build the bare kernel
    * modules	  - Build all modules
     modules_install - Install all modules to INSTALL_MOD_PATH (default: /)
       
    Execute "make" or "make all" to build all targets marked with [*]
    

可以看到 vmlinux 被 * 号标记了,所以它会默认的被编译。

  • vmlinux 编译目标的定义如下:

    cmd_link-vmlinux =                                                 \
        $(CONFIG_SHELL) $< $(LD) $(KBUILD_LDFLAGS) $(LDFLAGS_vmlinux) ;    \
        $(if $(ARCH_POSTLINK), $(MAKE) -f $(ARCH_POSTLINK) $@, true)
    
    vmlinux: scripts/link-vmlinux.sh autoksyms_recursive $(vmlinux-deps) FORCE
        +$(call if_changed,link-vmlinux)
    

去除 if_changed 的干扰,替换 $<$@ 之后,意思就是 vmlinux 的构建会执行 cmd_link_vmlinux 命令。 cmd_link_linux 对应的命令就是执行 scripts/link-vmlinux.sh,然后再执行处理器架构对应的 *ARCH_POSTLINK*。

  • link-vmlinux.sh 执行时,假设所有依赖的目标文件都已经编译出来了。这些依赖的目标文件位置存放在 $(KBUILD_VMLINUX_INIT)$(KBUILD_VMLINUX_MAIN)$(KBUILD_VMLINUX_LIBS) 中(来自 link-vmlinux.sh的注释)。
  • link-vmlinux.sh 脚本首先会将所有可用的目标文件一起编译为一个 thin archive(archive_builtih方法)。thin archive 是一个特别的目标文件,它包含了一系列目标文件的引用和目标文件们的符号表的合并。生成的 thin archive 作为 build-in.o 文件存放,并且 build-in.o 文件的格式能够被 linker 识别,所以它的使用方法和普通的目标文件一样。(thin archivearchive_build 函数利用 ar 工具生成的。)
  • 接着会调用 modpost_link 方法。这个方法调用 linker 去生成 vmlinux.o 文件,这个文件会被用于执行 Section missmatch analysis,该分析由modpost 程序执行,并在 link-vmlinux.sh#L260 触发。
  • 接着会生成内核符号表。它会包含所有函数和全局变量,以及它们在 vmlinux 二进制文件中的位置信息。主要的工作在 kallsyms 函数中完成。它首先使用 nmvmlinux.o 中导出所有符号。然后使用 scripts/kallsyms 生成一个包含所有符号信息,且按照一种能被内核理解的特定格式编码的汇编文件(symbols.S)。接下来 symbols.S 被编译,并和原始的 vmlinux 文件链接在一起。来自内核符号表的信息用于在运行时生成 /proc/kallsyms 文件。
  • 最终,vmlinux 文件生成,System.map 也会被生成。System.map 文件包含的信息和 /proc/kallsyms 一样,区别在与 System.map 是编译期生成的,用来在内核出现错误 Crash 时(linux kernel oops),根据内存地址查找对应的符号信息。

/proc/kallsyms 虚拟文件:

kallsyms: Extract all kernel symbols for debugging

   ffffffff8140c3b0 T vsnprintf
   ffffffff8140c8e0 T vscnprintf
   ffffffff8140c910 T vsprintf
   ffffffff8140c930 T snprintf
   ffffffff8140c990 T scnprintf
   ffffffff8140ca20 T sprintf
   ffffffff8140ca90 T bstr_printf
   ffffffff8140ce50 T num_to_str
   ffffffff8140cef0 T clear_page
   ...

build stage

  • 首先看下源码文件是如何被编译为目标文件的,在上面 link stage 部分,能看到 vmlinux 构建目标有一个依赖项是 $(vmlinux-deps) 变量。vmlinux-deps 变量定义在 Linux 源码根目录的 Makefile 中:

    init-y        := init/
    drivers-y     := drivers/ sound/ firmware/
    net-y         := net/
    libs-y        := lib/
    core-y        := usr/
    core-y        += kernel/ certs/ mm/ fs/ ipc/ security/ crypto/ block/
       
    init-y        := $(patsubst %/, %/built-in.o, $(init-y))
    core-y        := $(patsubst %/, %/built-in.o, $(core-y))
    drivers-y     := $(patsubst %/, %/built-in.o, $(drivers-y))
    net-y         := $(patsubst %/, %/built-in.o, $(net-y))
       
    export KBUILD_VMLINUX_INIT := $(head-y) $(init-y)
    export KBUILD_VMLINUX_MAIN := $(core-y) $(libs-y2) $(drivers-y) $(net-y) $(virt-y)
    export KBUILD_VMLINUX_LIBS := $(libs-y1)
    export KBUILD_LDS          := arch/$(SRCARCH)/kernel/vmlinux.lds
       
    vmlinux-deps := $(KBUILD_LDS) $(KBUILD_VMLINUX_INIT) $(KBUILD_VMLINUX_MAIN) $(KBUILD_VMLINUX_LIBS)
    

开始定义的 *init-y*,drivers-y 等变量包含了所有需要编译到内核中的源码文件目录路径,然后经过 patsubst 函数处理后,这些变量会变为 init/build-in.o 这样的路径。

接着,在 export 部分,不同目录下的 build-in.o 被分类到了 KBUILDVMLINUX*** 中。

最后,所有 build-in.o 文件被聚合到 vmlinux-deps 变量中。这也解释了为什么 vmlinux 最终其实是依赖了所有子目录的 build-in.o 文件。

patsubst 是 make 的函数,用来替换文本。比如 init-y 的初始值是 *init/*,那么经过 patsubst 处理之后:

   init-y   := $(patsubst %/, %/built-in.o, $(init-y))

init-y 就会变为 *init/build-in.o*。

  • 那么所有 build-in.o 文件是如何生成的呢?下面是相关的 Makefile:

    vmlinux-dirs   := $(patsubst %/,%,$(filter %/, $(init-y) $(init-m) \
    		     $(core-y) $(core-m) $(drivers-y) $(drivers-m) \
    		     $(net-y) $(net-m) $(libs-y) $(libs-m) $(virt-y)))
    
    $(sort $(vmlinux-deps)): $(vmlinux-dirs) ;
       
    $(vmlinux-dirs): prepare scripts
    	$(Q)$(MAKE) $(build)=$@
    

build 变量定义在 Kbuild.include 中:

   ###
   # Shorthand for $(Q)$(MAKE) -f scripts/Makefile.build obj=
   # Usage:
   # $(Q)$(MAKE) $(build)=dir
   build := -f $(srctree)/scripts/Makefile.build obj

所以会调用 Makefile.build 脚本,并将各个 build-in.o 文件作为 obj 参数传递。

启动流程

要理解启动流程,需要先找到内核启动之后,执行的入口方法。这就涉及了内核镜像文件的文件布局。而决定一个 ELF 文件布局的程序是 ld ( Linker 链接器),ld 又是根据 linker script 来执行链接操作的。

Linker Script

Linker Script: 是被 ld 程序使用的配置脚本,它描述了输入文件应该按照怎样的布局储存到输出文件中。

下面是一段简单的 linker script:

SECTIONS
{
  . = 0x1000000;
  .text : { *(.text) }
  . = 0x8000000;
  .data : { *(.data) }
  .bss : { *(.bss) }
}

这段脚本描述了在 ELF 文件中, text 域将会在 0x1000000内存地址开始存放, data 域在 0x8000000 开始存放,bss 域则紧跟 data 域之后。

Linker Script 脚本中的每行代表一个 *Output Section*,每行开头的.号是 *Location Counter*,表示当前行的开始内存地址。Location Counter 会随着 Output Section 占用的内存增加。

这里的第二行定义了 Section *.text*。冒号是必需的语法。在 Output Section 名称后面的花括号中,放置在此 Output Section 中的 Input Section 的名称。*是与任何文件名匹配的通配符。表达式 *(.text) 表示所有输入文件中的 .text input section 都会被放置在这个区域。

详细可参考Simple Linker Script Example

Linux Linker Script

Linux arm64 架构对应的 link script(vmlinux.lds.S) 是一个模版文件,该模板文件利用一些宏去替换其实际值,来构建实际的 linker script,这样就能让在不同体系的处理器之间读取和移植能够变得更加容易。

SECTIONS
{
	. = KIMAGE_VADDR + TEXT_OFFSET;

	.head.text : {
		_text = .;
		HEAD_TEXT
	}
	...
}

上面是 vmlinux.lds.S 的相关部分,内核代码的入口应该放在 .head.text Section 中。通过在内核代码中搜索能发现,在 include/linux/int.h中定义了一个宏 _HEAD,这个宏的值是 .section ".head.text","ax"。在 arm64/kernel/head.S 中会用到这个宏去定义 linker 规则。这个规则中用到了ENTRY去定义了程序执行的第一个指令。

ENTRY(stext)

ENTRY(symbol) 是 Linker Script 设置 entry point 的命令,symbol 就是需要执行的方法符号。

说明机器在通电启动之后,经过 bootloader 加载之后,执行的入口就是 stext

ENTRY(stext)
	bl	preserve_boot_args
	bl	el2_setup			// Drop to EL1, w0=cpu_boot_mode
	adrp	x23, __PHYS_OFFSET
	and	x23, x23, MIN_KIMG_ALIGN - 1	// KASLR offset, defaults to 0
	bl	set_cpu_boot_mode_flag
	bl	__create_page_tables
	/*
	 * The following calls CPU setup code, see arch/arm64/mm/proc.S for
	 * details.
	 * On return, the CPU will be ready for the MMU to be turned on and
	 * the TCR will have been set.
	 */
	bl	__cpu_setup			// initialise processor
	b	__primary_switch
ENDPROC(stext)

preserve_boot_args 方法用来存储 bootloader 传递给内核的参数。详细可参考preserve_boot_args

el2_setup 设置处理器的异常级别在 EL1

参考