☰
内核启动流程源码级分析:从head.S到start_kernel的调试实战
2026/10/9 3:13:12 网站建设 项目流程

搞内核的人应该都遇到过这样的场景:系统起不来,串口上的输出停在一句看不懂的汇编级信息,而手边除了一份内核源码,没有任何帮助文档。内核启动流程源码级分析,就是在这种时候能真正救命的基本功。它解决的并不是“看懂启动日志”这一件事,而是把 bootloader 跳转、汇编入口、镜像解压、start_kernel 初始化、根文件系统挂载这一整条链路串起来,让你在问题出现的第一时间就判断出故障发生在哪一段、应该到哪个文件里去翻代码。这篇文章不走教科书式的逐句注释路线,而是沿着一条实际可操作的源码阅读路径展开,适合刚接触内核的新人,也适合那些已经写过不少驱动、却一直没把启动链路彻底捋顺的开发者。

1. 先看清链路:内核启动流程的整体设计

1.1 这条链路上的几个关键节点

通常我们说的“内核启动流程”,范围是从 bootloader 跳入内核镜像开始,一直到内核完成自身初始化、拉起第一个用户态进程为止。中间大致可以切成四段:

  1. 汇编入口阶段:bootloader 把控制权交给内核,CPU 执行 head.S 里最早的那几条指令,完成栈准备、异常级别确认、早期内存信息保存。
  2. 镜像解压阶段:如果加载的是压缩镜像,内核自带解压程序处理解压和重定位,随后跳进真正的内核代码。
  3. C 语言初始化阶段:内核进入 start_kernel(),依次完成内存管理、调度器、中断、时间子系统、RCU 等基础模块的初始化。
  4. 用户态启动阶段:创建第一个内核线程并转换为 PID 1 进程,挂载根文件系统,执行 init 程序。

这四段在源码里的位置非常清楚。前两段主要在 arch 目录下的汇编文件和压缩目录里,第三段的核心在 init/main.c,第四段则散落在 kernel/、init/ 以及驱动 probe 过程中。理解了这四段,再看启动日志时,你就能把每一行输出对应到某个阶段的某段代码,而不是雾里看花。

1.2 镜像格式、符号表与源码对应关系

源码级分析绕不开“符号”这个概念。编译内核时会生成 vmlinux、System.map、arch/arm64/boot/Image 等产物。vmlinux 是带完整调试信息的 ELF 文件,适合 GDB;Image 是去掉了调试信息的裸镜像,适合直接加载到内存;System.map 则是符号地址对照表。

实际操作中,我建议把 vmlinux 保留好,同时打开 System.map。用 grep 搜一个函数名,就能立刻确认它在内存里的链接地址。比如:

grep " start_kernel$" System.map

输出通常是:

ffff800010c00000 T start_kernel

这里的关键是理解“链接地址”和“实际运行地址”可能不一致。如果内核开了 KASLR,实际运行地址会在链接地址基础上加上一个随机偏移。所以调试时第一件事就是把 KASLR 关掉,或者在启动参数里明确加 nokaslr,否则 GDB 断点很难命中。这一步看起来简单,但我见过太多人在 QEMU 里折腾半天打不上断点,最后发现是随机化在捣乱。

1.3 为什么要做源码级而不是只看日志

启动日志当然重要,但日志是结果,不是过程。日志打印突然中断时,你只能知道“停在这里”,却不知道它是主动停的、被动卡住的,还是已经掉进异常处理流程。源码级分析能让你在关键节点打断点,观察寄存器、内存、栈的变化,确认代码到底走的是哪条分支。

用生活里的例子来说,看日志像是看监控录像,你看到一个人走到走廊尽头消失了,但不知道他进了哪扇门;源码级调试则像是跟着他走进走廊,每到一个岔路都停下确认方向。后者要慢一些,但信息密度完全不一样。尤其在 bring-up 新板卡、裁剪启动时间、或者定位某个驱动 probe 卡死的问题时,这种能力几乎无可替代。

2. 汇编入口与解压阶段:从 head.S 到跳入 C 语言

2.1 bootloader 交权后发生了什么

以 arm64 为例,bootloader(U-Boot、UEFI 或者其他方案)会把内核镜像加载到约定的物理内存位置,然后跳转过去。内核真正执行的第一段代码在 arch/arm64/kernel/head.S 里。入口前的准备工作很简单:保证镜像按 2MB 对齐,MMU 可以关闭,也可以处于一个能执行当前指令的轻量映射状态。

在 head.S 的早期代码里,CPU 要做的第一件事是保存 bootloader 传过来的参数。arm64 的约定是 x0 寄存器保存 DTB 物理地址,x1-x3 可能携带其他信息。紧接着设置栈指针,因为在进入 C 语言之前,任何函数调用都需要栈。这个阶段的栈空间很小,往往只是在镜像段内部划出一小块,所以早期汇编代码里不能随便开大数组、不能递归。

第一段汇编还会读取 CurrentEL 寄存器,判断当前 CPU 处于 EL1 还是 EL2。如果从 EL2 进入,说明可能有虚拟机监视器或者 secure monitor 在场,内核需要额外做虚拟化相关的初始化。这段逻辑虽然不长,但分支很细,初学者最容易在这里绕晕。

2.2 异常级别与早期启动上下文

异常级别是理解启动早期代码的一把钥匙。在 arm64 里,EL0 是用户态,EL1 是内核态,EL2 是虚拟化层,EL3 是安全世界。bootloader 通常在 EL2 或 EL3 运行,跳进内核时可能处于 EL2,也可能已经切到 EL1。

内核源码里有对应的分支处理:如果当前是 EL2,需要配置系统寄存器、建立相应的页表映射,再降级到 EL1 继续执行。如果当前已经是 EL1,就跳过这些操作。调试时你会发现,切换到 EL1 之前,某些寄存器访问权限是受限的,一旦降级后,再访问 EL2 专有寄存器会触发异常。这个细节在静态阅读源码时很容易忽略,只有在单步调试踩到异常后才印象深刻。

早期汇编里另一个常见动作是建立临时页表。由于真正的内存布局信息要到解析 DTB 之后才能确定,启动早期只能先做一个粗粒度的恒等映射,保证当前执行地址能被访问。这在 GDB 里表现为:MMU 开启前,你读某段内存地址得到的数据可能与预期不符,其实不是内存坏了,而是地址映射还没生效。遇到这种情况先检查 SCTLR_EL1 的 M 位,别急着怀疑硬件。

2.3 自解压机制:压缩镜像的过渡逻辑

arm64 的 Image.gz 是压缩过的内核镜像,内核自带解压程序。解压目录在 arch/arm64/boot/compressed/,里面有一套不依赖完整 C 运行环境的简化代码。所谓“简化”,意味着你不能随便调用 malloc、不能依赖全局变量初始化,栈是临时准备的,运行环境非常局促。

自解压的基本流程是:先执行一段汇编初始化栈,然后调用解压函数,把压缩数据解到目标内存地址。这里有一个经典问题:如果解压目标地址和当前正在执行的解压程序区域重叠,就必须把解压程序先搬到安全位置,或者把解压目标往后挪。源码中通过计算当前 PC 与目标地址的关系来判断是否需要重定位。

实际开发中我遇到过一次很典型的故障:内核解压完成后跳进主镜像,但在初始化内存时反复崩溃。后来查设备树才发现,解压目标地址被固件的一块 reserved-memory 占用了。解决办法不是改内核代码,而是调整加载地址或者设备树里的内存预留区域。这个案例说明,早期启动问题不一定是内核自己的逻辑错,有时是镜像放置位置和内存布局冲突,排查时要同时看 bootloader 加载脚本和 DTB 的 memory/reserved-memory 节点。

2.4 链接脚本与早期代码布局的注意事项

汇编启动代码之所以能出现在镜像的最前面,靠的是链接脚本。arm64 的 vmlinux.lds.S 里,把 .head.text 段放在最前,入口代码必须放在这个段里,才能保证 bootloader 一跳就能执行到。如果你想把自定义的早期初始化函数加进去,也要放到 .head.text 或紧随其后的段里,否则它可能被链接器排到远离入口的位置,导致流程错乱。

我个人的经验是,不要轻易改动链接脚本的段顺序。虽然有时候为了性能优化,有人会把某些热代码搬到特定段里,但这种改动对启动早期的影响很隐蔽,出了问题很难排查。启动早期代码应该保持最小化:能不用栈就不用栈,能不用全局变量就不用全局变量,能让链接脚本保持原样就保持原样。这不是保守,而是这段代码的运行前提实在太苛刻了。

3. start_kernel:真正的中枢初始化流程

3.1 初始化依赖的先后顺序

解压完成后,内核会进入真正的 C 代码入口 start_kernel()。这个函数在 init/main.c 里,是一长串 init_xxx 调用的集合。初看像流水账,但它的顺序其实代表了内核子系统的依赖关系,不能随意调整。

我读这段代码的习惯是先画一张依赖草图,哪怕只是用注释写在代码旁边。比如内存管理模块要先初始化,因为 task_struct、栈、内核线程这些对象都需要内存分配;调度器的初始化要能在内存管理器之后进行,因为调度器需要为进程准备 task_struct 和内核栈;中断子系统则需要等异常向量表准备好之后再初始化,否则中断一开就等于把系统暴露在没有处理机制的状态下。先初始化谁、后初始化谁,本质上是问“谁离不开谁”。

3.2 setup_arch 与设备树信息解析

start_kernel 里前几个调用之一就是 setup_arch()。在 arm64 上,它位于 arch/arm64/kernel/setup.c,负责把体系结构相关的信息从 DTB 和启动参数中提取出来,转换成内核通用的数据结构。这里面最关键的一步是解析设备树里的 memory 节点,把物理内存区域登记进 memblock 分配器。

设备树的物理地址是由 bootloader 通过寄存器传给内核的,x0 寄存器保存它的起始地址。如果这个地址无效,或者 DTB 在加载过程中被覆盖,setup_arch 里解析出的内存信息就会缺失或损坏,后续内存初始化会出各种莫名其妙的崩溃。我在实际操作中排查过类似问题:启动日志显示内存大小异常地小,甚至为 0,最后发现是 bootloader 把内核镜像加载位置和 DTB 位置重叠了。这个阶段的坑,很大概率不在内核源码本身,而在 bootloader 的加载脚本和运行环境里。

3.3 内存、调度器、中断与时间子系统的初始化

过了 setup_arch 之后,start_kernel 会继续顺着依赖链往前走。大致会经历以下几个区块:

  • mm_init 系列:包括 buddy 分配器、slab 分配器、内存页表管理等。内核从这里开始具备完整的动态内存分配能力。
  • sched_init:初始化调度器的核心数据结构,为后续创建线程和进程做准备。
  • trap_init 与中断初始化:建立异常向量表,初始化中断控制器,此时系统才具备响应中断和异常的能力。
  • time_init:初始化时钟源和时钟事件设备,让内核拥有时间概念。
  • RCU、workqueue、per-cpu 变量等基础设施也会在中间穿插完成。

这个阶段如果某个子系统初始化得过早或过晚,系统通常不会立刻崩溃,而是会在后续某个操作中表现出诡异的行为。比如中断未初始化就开中断,通常会触发“scheduling while atomic”的告警;时钟未初始化就去读取时间,得到的结果可能是 0 或负值。启动日志里这些告警看起来像是随机出现,但本质上都是初始化顺序问题。

3.4 initcall 机制:驱动初始化的另一条线

start_kernel 负责基础内核模块,而驱动和大量子系统模块的初始化走的是另一条线:initcall 机制。内核把各类初始化函数按优先级分成 early、core、postcore、arch、subsys、fs、device 等层次,编译时链接器把这些函数指针放到不同的段里,启动时按顺序逐个调用。

理解这条线对你排查驱动启动问题特别有用。用 initcall_debug 启动参数可以让内核打印每个 initcall 的返回值,这样就能定位到“最后成功执行的是哪一个 initcall、失败或者卡住的是哪一个”。我做过不少次基于这行日志的排查,基本模式是:先找到卡住的 initcall 对应的驱动,再去看它依赖的设备树节点、时钟、中断、GPIO 是否就绪。大多数驱动 probe 失败,都不是驱动本身写错了,而是它所依赖的资源没有满足。

3.5 rest_init 与 1 号进程的诞生

start_kernel 的最后一段会走到 rest_init()。这里要做的事非常关键:创建 kthreadd 内核线程,创建 kernel_init 内核线程,然后让当前 CPU 进入 idle 流程。

这里有一个值得新手注意的点:第一个用户态进程并不是通过 fork() 出来的,而是 kernel_init 这个内核线程通过 kernel_execve() 执行了 /init 或指定 init 程序之后,转变成了 PID 1 的进程。换句话说,用户态 init 进程的“前身”是一个内核线程,它是在执行了可执行文件之后才变成用户态进程的。

另外,idle 进程也并不是在某个函数里被创建出来的。启动早期 CPU 找到的那条执行路径,在进入调度器之后被赋予了 idle 的身份,所以你会看到每个 CPU 都有一个 idle 进程。读这段源码时不要试图去找创建 idle 进程的 fork 调用,因为它从开机起就一直在 CPU 上跑着,只是到了 rest_init 才被正式登记。

4. 实操:搭建一套可打断点、可复现的启动分析环境

4.1 工具链与源码版本选择

做源码级分析,我不会一上来就上开发板,而是在 QEMU 里先把流程跑通。需要准备的东西不多:Linux 内核源码、交叉编译工具链、QEMU、GDB 的 multiarch 版本。

工具链的选择要与目标架构匹配。分析 arm64 用 aarch64-linux-gnu- 系列即可。如果本机是 x86,完全没问题,交叉编译内核镜像即可,不需要真实的 arm64 板子。内核版本建议用当前流行的 LTS 版本,比如 6.6 或者 5.10,这类版本资料多、相对稳定,而且网上能找到大量讨论,适合对照学习。

源码获取建议直接用官网 tar 包或者 git 仓库。有一点要提醒:直接用 IDE 打开内核源码,经常会遇到大量头文件找不到,因为这些头文件是编译过程中自动生成的。所以先把默认配置编译一遍,再让 IDE 扫描,体验会好很多。

4.2 编译最小可用 arm64 内核

以 arm64 为例,编译命令极其简单:

export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu- make defconfig make -j$(nproc) Image

系统架构相关的处理: 它生成 aarch64/boot/Image。要真正启动起来,还需要 rootfs。个人喜欢用 busybox 做 initramfs,或者用 buildroot 一步到位。这里提一下流程:先用 busybox 编译出静态二进制,再打成 cpio.gz。QEMU 直接把它作为 initrd 传给内核。启动命令大致如下:

qemu-system-aarch64 -M virt -cpu cortex-a72 -nographic \ -kernel arch/arm64/boot/Image \ -initrd rootfs.cpio.gz \ -append "console=ttyAMA0 nokaslr rdinit=/init"

这里面 nokaslr 对调试非常重要,它保证运行时地址与符号表一致。rdinit=/init 指向 initramfs 里的 init 程序。

4.3 QEMU+GDB 观察启动过程

把 QEMU 和 GDB 配合起来,是源码级分析的“重武器”。QEMU 启动时加上 -s 和 -S:

  • -s:开启 gdbstub,默认监听 1234 端口。
  • -S:启动后暂停 CPU,等待调试器连接。

然后在另一个终端启动 GDB:

gdb-multiarch vmlinux target remote :1234 hbreak start_kernel continue

如果断点打不中,通常有两个原因。一是 KASLR 没关,内核加载地址和链接地址不一致;二是断点地址所在的内存还没被映射或者还没被解压好。解决方法是先设更早的断点,比如入口 stext,用 info files 查看当前已加载的段,再用 si 单步逐步推进。每到一个关键点,用 info registers 看看 x0 是不是 DTB 地址,用 p/x 访问它验证 DTB 是否有效。这套操作下来,你对启动早期代码的理解会明显比单纯翻源码深。

4.4 用启动参数与 ftrace 定位启动卡点

没有调试器的时候,内核自己也提供了很多观测手段。earlycon 参数可以在极早阶段输出日志,loglevel=8 能看到全量打印,initcall_debug 能显示每个 initcall 的进入和退出。组合起来是这样的:

console=ttyAMA0 loglevel=8 initcall_debug

如果某个 initcall 打印后系统卡住,问题范围基本就锁定到了那个子系统。进一步可以用 ftrace 查看函数调用轨迹。内核配置打开 CONFIG_FUNCTION_TRACER 和 CONFIG_FUNCTION_GRAPH_TRACER,启动后挂载 tracefs,设置函数过滤器,就能看到启动阶段某个函数内部的调用序列。这个方法比反复加 printk 再编译要高效得多。

不过我建议大家在源码分析阶段优先用 GDB,其次才用 ftrace。因为源码级分析的核心是理解代码路径,而 GDB 可以直接观察地址、寄存器和内存,信息更完整。ftrace 适合启动完成后做性能分析,比如定位启动时间开销大的函数。两种手段各有定位,不必偏废。

5. 启动故障排查与避坑经验

5.1 启动早期无输出的排查路径

系统完全无输出,是最让人头疼的故障之一。我的排查顺序是:先确认串口参数和 console= 是否有效。内核编译时要打开 CONFIG_SERIAL_AMBA_PL011 或对应驱动,否则串口根本不工作。其次是 earlycon 参数,它能让内核在标准 console 初始化之前就开始输出。如果加了 earlycon 还是没输出,就得检查 DTB 里串口节点状态,比如 status="disabled" 会导致驱动不 probe 串口,这时候就算内核跑得正常,你也看不到任何信息。

另外,QEMU 环境下还要注意 -nographic 是否把串口重定向到了 stdio,有时候终端配置不同,输出会跑到别的 fd 上去了。这类问题看起来像内核没跑,实际只是输出通道没通。

5.2 根文件系统挂载失败的典型原因

“VFS: Unable to mount root fs”是启动日志里最有辨识度的错误之一。看到它不要慌,这说明内核本身已经初始化到挂载根文件系统的阶段,问题集中在根设备或文件系统类型上。我通常按这个顺序排查:

  1. 确认 root= 参数指向的设备和实际设备名一致。用 /dev/sda1、/dev/mmcblk0p1 这类名字前,先确认内核里对应总线驱动已经初始化完成。
  2. 确认内核打开了 initramfs 支持,即 CONFIG_BLK_DEV_INITRD。如果 rootfs 是 cpio 格式,这个配置必须打开。
  3. 确认文件系统驱动的 CONFIG 被编译进内核,而不是模块。因为挂载根文件系统的阶段,模块本身还要从根文件系统读取,形成鸡生蛋问题,所以根文件系统驱动通常要内建。
  4. 检查镜像本身的文件系统类型和内核支持是否匹配,有时 mkfs 选择的格式与内核配置不一致,会导致能识别块设备却认不出文件系统。

这类问题 90% 不是内核源码逻辑错误,而是配置或参数问题,排查时要冷静。

5.3 启动卡死但无 panic 的定位方法

有些故障表现为启动到某个阶段后完全卡死,没有 panic 输出,没有死锁告警。这种问题最难查,因为系统在哪里停、为什么停,都没有明显提示。做法是先加 initcall_debug 跑一遍,确认最后一个成功返回的 initcall。如果连 initcall_debug 都没输出,说明卡在更早的阶段,此时要用 GDB 打断当前执行。

用 GDB 查看 PC 时,如果 PC 停在某个地址不动,基本是在自旋锁或忙等待;如果 PC 在几个地址之间反复跳,可能是中断风暴或者死循环。再结合函数名和源码就能很快定位。我遇到过一次典型的卡死,是设备树里中断控制器配置与驱动预期不一致,导致驱动在申请中断时永久等待。这种问题看日志往往无解,但配合 GDB 和源码阅读,半小时内就能锁定范围。

5.4 常见问题速查表

现象常见原因首选排查手段
启动完全无输出串口参数错误、earlycon 未启用、DTB 串口被禁用检查启动参数与 DTB 串口节点状态
解压完成后崩溃解压目标地址与固件保留内存冲突查看 DTB memory/reserved-memory 节点
start_kernel断点打不上KASLR 开启,地址不一致启动参数加 nokaslr
内存大小异常为 0DTB 地址无效或被覆盖检查 x0 寄存器与 bootloader 加载脚本
VFS 无法挂载根文件系统root= 参数错误、文件系统驱动未内建核对启动参数、CONFIG_BLK_DEV_INITRD
initcall 返回 -19设备树节点与驱动不匹配检查 DTB 中对应节点的 status/compatible
启动后死锁无 panic自旋锁等待、资源依赖不满足GDB 查看 PC,结合源码判断等待点

这张表是我在追踪启动流程时最常用的检查清单,每次遇到新问题都会先对照一遍,再决定要不要深入读源码。

5.5 我反复踩过的几个小坑

最后分享几个我在源码级分析过程中踩过多次的坑。

第一,不要在启动早期乱动链接脚本。我试过为了“优化”把某些初始化代码挪到指定 section,结果 bootloader 跳转后直接跑飞。这种问题只在特定配置下复现,排查成本极高。

第二,内核源码里有很多文件会在编译时自动生成,包括 include/generated/autoconf.h。用 IDE 阅读时,如果 IDE 缓存了旧的生成文件,你会看到某些宏定义是空的或者完全对不上号。所以每次切换内核版本或者修改配置后,记得重新生成并刷新索引。

第三,QEMU + GDB 单步时,如果发现代码跳转“不按常理”,先检查当前是否还在 MMU 关闭状态。启动早期访问未映射内存,读出来的数据可能是垃圾值,但这不代表真的出了问题。不要被跳变的地址吓到,确认映射状态才是关键。

内核启动流程的源码级分析,说到底就是一次从“看日志”到“读代码”再到“看寄存器与内存变化”的习惯升级。我并不要求自己把每个启动函数都背下来,只要求自己在遇到问题时能快速定位到与之对应的代码段,理清它依赖什么、会被谁调用、失败时会停在什么地方。有了这套思路,以后不管是给新板卡 bring-up、裁剪启动时间,还是排查启动卡死问题,心里都会有一张清晰的路线图。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询