3. 内核编译与启动流程:从源码到系统跑起来

这一章,我们来啃一块硬骨头——内核是怎么从一堆 .c 文件变成能跑起来的二进制,又是怎么从第一条指令一路执行到用户空间的。说实话,很多搞嵌入式 Linux 的朋友,编译内核就是敲个 make,启动流程就是看个 log。但真遇到问题,比如内核起不来、设备树配错了、某段汇编跑飞了,你就得深入到底层去看。

我个人习惯,先把整个流程在脑子里画个图,再一步步验证。咱们今天就按这个思路来。

内核编译与启动流程总览 源码 (.c / .S / .dts) 编译工具链 (gcc / ld / dtc) 编译产物 zImage / uImage / dtb Bootloader (U-Boot) 内核解压 + 重定位 head.S (汇编启动) start_kernel (C入口) init 进程 (用户空间) 关键说明 • 编译阶段:make zImage 生成压缩内核,make uImage 加 U-Boot 头 • dtc 将 .dts 编译为 .dtb,U-Boot 加载后传给内核 • head.S 设置页表、使能 MMU、跳转到 start_kernel • start_kernel 初始化子系统,最终启动 init 进程 • 整个流程从汇编到 C,从单核到多核,从内核到用户空间

3.1 内核编译:make zImage 与 make uImage

先说说编译。你敲 make zImage 的时候,到底发生了什么?说白了,Kbuild 系统会递归进入各个子目录,把 .c 文件编译成 .o,然后链接成 vmlinux——一个 ELF 格式的未压缩内核。接着,objcopy 把它转成二进制,再用 gzip 压缩,最后加上解压头,就成了 arch/arm/boot/zImage。

make uImage 呢?它是在 zImage 前面加了一个 U-Boot 的头部(64 字节),包含镜像类型、加载地址、入口地址等信息。U-Boot 看到这个头,就知道怎么加载和跳转。

核心区别:

  • zImage:自解压内核,Bootloader 直接跳转到它的入口即可。
  • uImage:带 U-Boot 头的 zImage,U-Boot 解析头后跳转。
  • Image:未压缩的原始内核二进制,很少直接用。

我记得有一次,客户说内核起不来,log 停在 "Starting kernel ..."。我一看,他用的 uImage,但 U-Boot 的 bootm 命令加载地址和内核链接地址不一致。嗯,这种问题很常见。你想想看,U-Boot 把镜像加载到 0x20000000,但内核链接地址是 0x80008000,跳转过去当然跑飞了。

我的建议:编译前先确认 CONFIG_PAGE_OFFSET 和 TEXT_OFFSET,这两个值决定了内核的虚拟地址和物理地址偏移。ARM 平台上,常用 TEXT_OFFSET=0x00008000。

3.2 设备树编译:dtc 与 .dtb

设备树编译相对简单。你写一个 .dts 文件(文本格式),用 dtc 工具编译成 .dtb(二进制格式)。命令就是:

dtc -I dts -O dtb -o myboard.dtb myboard.dts

但这里有个坑。我曾经在项目中遇到过,.dts 里引用了某个 .dtsi(头文件),但路径写错了,dtc 报错说找不到节点。排查了半天,最后发现是 #include 路径问题。所以,我建议你在 Makefile 里用 DTC_FLAGS 指定搜索路径:

DTC_FLAGS := -i $(srctree)/arch/arm/boot/dts

设备树的结构其实是一棵树。根节点下挂 CPU、内存、外设等。内核启动时,U-Boot 会把 .dtb 的物理地址传给内核(通过 r2 寄存器),内核解析后就知道硬件长什么样了。

注意:设备树中的 reg 属性、interrupts 属性、compatible 属性,必须和驱动里的匹配表一致。否则,驱动 probe 不会执行。我见过有人把 compatible 写成了 "my,device",驱动里却是 "my_device",结果设备没认出来。

3.3 内核启动流程:head.S 到 start_kernel

这是最核心的部分。内核启动,从汇编开始,到 C 语言结束。咱们一步步拆解。

3.3.1 head.S:汇编启动阶段

head.S 是内核的第一条指令入口。它主要做几件事:

  1. 设置异常向量表:把 CPU 的异常向量表搬到内核地址空间。
  2. 获取 CPU ID:多核处理器,每个核有自己的 ID,主核继续启动,从核等待。
  3. 创建页表:临时页表,把内核镜像映射到虚拟地址空间。
  4. 使能 MMU:开启虚拟地址映射,从此 CPU 看到的是虚拟地址。
  5. 跳转到 start_kernel:通过 b start_kernel 或 bl start_kernel 进入 C 世界。

你可能会问,为什么要先开 MMU 再跳转?因为内核代码是链接到虚拟地址的,不开 MMU,PC 指针根本取不到正确的指令。嗯,这里要注意,head.S 里有一段代码叫 __turn_mmu_on,它开启 MMU 后,紧接着就跳转到虚拟地址的 start_kernel。这个跳转是位置无关的,但目标地址是虚拟地址。

关键代码片段(ARM 架构):

ENTRY(stext)
    @ 设置异常向量表
    mrc p15, 0, r0, c0, c0, 5   @ 读取 CPU ID
    @ ... 省略中间步骤
    @ 创建页表
    ldr r4, =swapper_pg_dir
    @ 使能 MMU
    ldr r0, =__turn_mmu_on
    mov pc, r0
__turn_mmu_on:
    mcr p15, 0, r0, c1, c0, 0   @ 使能 MMU
    nop; nop; nop; nop          @ 等待流水线清空
    ldr pc, =start_kernel       @ 跳转到 C 入口
ENDPROC(stext)

我记得第一次看这段代码时,对 ldr pc, =start_kernel 这个伪指令印象很深。它加载的是 start_kernel 的虚拟地址,而 MMU 已经开了,所以 CPU 能正确跳转。如果 MMU 没开,这个地址就是无效的物理地址,直接跑飞。

3.3.2 start_kernel:C 语言初始化

进入 start_kernel 后,内核开始全面初始化。这个函数在 init/main.c 里,大概有几百行。它按顺序调用各个子系统的初始化函数:

顺序 函数 作用
1 setup_arch 解析设备树,初始化内存、中断、时钟等架构相关部分
2 trap_init 设置异常处理函数(中断、系统调用等)
3 mm_init 初始化内存管理子系统(伙伴系统、slab 分配器等)
4 sched_init 初始化调度器,创建 idle 进程
5 init_IRQ 初始化中断控制器,注册中断处理函数
6 console_init 初始化控制台,从此 printk 能输出到串口
7 do_basic_setup 调用所有驱动和子系统的 initcall
8 rest_init 创建 kernel_init 进程(即 init 进程),进入用户空间

这里有个细节,console_init 之前,printk 的输出是存到内核缓冲区里的,你看不到。只有 console 初始化后,才能从串口看到 log。所以,如果内核在 console_init 之前就挂了,你只能看到一片空白。我曾经调试过一个板子,就是死在 setup_arch 里,因为设备树里内存节点配错了,内核无法建立内存映射。

调试技巧:如果内核在早期启动阶段就挂了,可以打开 CONFIG_DEBUG_LL 和 CONFIG_EARLY_PRINTK。这样,在 console_init 之前,就能通过串口输出调试信息。不过要注意,这需要你正确配置调试串口的物理地址。

3.3.3 rest_init 与 init 进程

rest_init 是 start_kernel 的最后一步。它创建两个内核线程:

  • kernel_init:最终执行 /sbin/init 或 /bin/sh,进入用户空间。
  • kthreadd:内核线程的守护进程,负责创建其他内核线程。

然后,当前进程(即 idle 进程)进入无限循环,CPU 空闲时执行 idle 指令。从此,内核启动完成,用户空间接管。

你想想看,从第一条汇编指令到用户空间的 shell,中间经历了多少步骤?编译、链接、压缩、加载、解压、页表、MMU、中断、调度、内存管理……任何一个环节出问题,系统都起不来。所以,理解这个流程,是调试内核的基础。

常见故障点:

  • 编译时:链接地址错误、设备树语法错误、驱动未编译进内核。
  • 启动时:U-Boot 加载地址不对、设备树与内核不匹配、内存配置错误。
  • 运行时:init 进程找不到、根文件系统挂载失败、驱动 probe 失败。