This page looks best with JavaScript enabled

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

 ·  ☕ 8 min read

本文概览

源码结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
➜  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 都比较简单,基本都是定义了哪些目标文件需要被编译。

    1
    
    obj-$(SOME_CONFIG_VARIABLE) += some_file.o
    

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

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

    比如:

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

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

    1
    2
    3
    4
    
    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 能看到内核定义的编译目标

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    ➜  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 编译目标的定义如下:

    1
    2
    3
    4
    5
    6
    
    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 中:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    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-ydrivers-y 等变量包含了所有需要编译到内核中的源码文件目录路径,然后经过 patsubst 函数处理后,这些变量会变为 init/build-in.o 这样的路径。

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

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

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

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    
    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 中:

    1
    2
    3
    4
    5
    
    ###
    # 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

参考

Support the author with
alipay QR Code
wechat QR Code

Yang
WRITTEN BY
Yang
Developer