3. 内核编译与启动流程:从源码到系统跑起来
这一章,我们来啃一块硬骨头——内核是怎么从一堆 .c 文件变成能跑起来的二进制,又是怎么从第一条指令一路执行到用户空间的。说实话,很多搞嵌入式 Linux 的朋友,编译内核就是敲个 make,启动流程就是看个 log。但真遇到问题,比如内核起不来、设备树配错了、某段汇编跑飞了,你就得深入到底层去看。
我个人习惯,先把整个流程在脑子里画个图,再一步步验证。咱们今天就按这个思路来。
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 是内核的第一条指令入口。它主要做几件事:
- 设置异常向量表:把 CPU 的异常向量表搬到内核地址空间。
- 获取 CPU ID:多核处理器,每个核有自己的 ID,主核继续启动,从核等待。
- 创建页表:临时页表,把内核镜像映射到虚拟地址空间。
- 使能 MMU:开启虚拟地址映射,从此 CPU 看到的是虚拟地址。
- 跳转到 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 失败。