news 2026/9/13 8:09:23

嵌入式Linux内核启动流程源码级跟踪与调试实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
嵌入式Linux内核启动流程源码级跟踪与调试实战

我最早被嵌入式 Linux 内核启动流程劝退,就是因为它链路太长:从 bootloader 跳到内核,再到 init 用户空间,中间隔着几十个关键函数,网上随便搜一张“内核启动流程图”能铺满整个屏幕。但后来真正做项目,拿着源码一行行跟进,反而发现整个过程比你想象中要朴素得多。这篇记录就是我当时做“嵌入式Linux内核启动分析与源码跟踪”这个项目的完整思路,核心做法很简单:拿启动日志里的每一条打印反推源码位置,把内核从汇编入口到 start_kernel 再到 init 的调用链逐段吃透。适合正在入门嵌入式 Linux 的开发者、准备做系统裁剪优化和性能调优的工程师,以及面试前想把启动流程彻底理一遍的人。

内核启动分析这件事,本质上不是背阶段,而是建立一条“从现象到源码”的映射能力。板子跑不起来,日志停在某一行为什么不往下走了?同一个内核换一块板子为什么起不来?这些问题靠死记硬背解决不了,只有亲自跟踪过源码、知道每一行打印是从哪里打出来的,才能在关键时刻一眼定位问题。

1. 项目概述:为什么要把源码跟踪作为主线

1.1 这算是什么性质的项目

这个项目不是开发某个具体功能,而是偏“源码级问题排查”和“系统性能摸底”。我当时的实物环境是一块 ARM64 开发板,内核版本用的 5.10 LTS,交叉编译工具链是 aarch64-linux-gnu-,文件系统用 BusyBox 做了个最小 initramfs。整套环境起来之后,我给自己定的目标有三个:第一,能从汇编入口开始逐行说清启动路径;第二,能把设备树配置在内核启动中的读取和解析流程讲明白;第三,能通过启动日志做耗时分析和裁剪优化。

这类项目在真实工作中很常见。比如公司拿到一块新板子,BSP 由原厂提供,但启动阶段出了问题,原厂支持跟不上,你就得自己啃源码。又比如产品要求冷启动时间控制在 2 秒以内,系统裁剪到底裁哪里?动哪些配置能见效?这些都属于内核启动分析与源码跟踪的范畴。所以它看着不像一个“功能开发”项目,但投入产出比非常高,几乎把嵌入式 Linux 底层最重要的几块——汇编启动、内存初始化、中断初始化、驱动模型、设备树、initcall 机制——全部串起来了。

1.2 动手前需要准备好的几样东西

先说一下我的环境清单,照着准备就好,不用一步到位:

  • 内核源码:从 kernel.org 拉一个长期支持版本,我用的 5.10,新一点的 6.1 也可以,主体流程没有本质变化。
  • 交叉编译工具链:ARM64 用 aarch64-linux-gnu-,32 位 ARM 用 arm-linux-gnueabihf-,直接用发行版包管理器装即可。
  • 开发板或模拟器:没有实体板子完全可以用 QEMU 起步,qemu-system-aarch64 -M virt就是很干净的实验环境,配合-nographic直接看串口输出,调试起来比真板子还方便。
  • 串口日志工具:minicom 或 picocom,保存完整启动日志非常关键,后面所有分析都建立在日志基础上。
  • 符号解析工具:addr2linenmreadelf,后面定位内核崩溃时的 PC 指针全靠它们。

准备工作里最容易忽略的是把CONFIG_PRINTK_TIME=y打开,这会在每条内核日志前加上时间戳,没有它后面做耗时分布统计会非常难受。另一个建议是编译时保留vmlinux这个未压缩的内核文件,调试信息CONFIG_DEBUG_INFO=y也要开,不然源码级跟踪就是空中楼阁。

2. 内核启动流程主干:从汇编到 start_kernel

2.1 Bootloader 的交接棒:镜像格式与启动参数

要跟踪内核启动,得先明白内核是被谁启动的。以 U-Boot 为例,它加载内核镜像到内存某个地址,同时还要加载设备树文件(.dtb)到另一个地址,然后通过寄存器或特定规则把这两个地址传给内核。ARM32 时代用r0/r1/r2传参,r0存 0,r1存机器类型 ID,r2存设备树地址;ARM64 简化了,x0是设备树地址,w1目前给保留。另一种老方式是 ATAG 列表,现在已经基本淘汰,现代项目清一色设备树。

这里很多人会踩一个坑:编译出来的Image(ARM64 未压缩镜像)和zImage(ARM32 压缩镜像)是不同的启动入口。zImage 开头有一段自解压汇编代码,先把自身解压到合适位置再跳转到真正的内核入口;而 ARM64 的Image没有自解压阶段,U-Boot 直接把镜像拷贝到内存地址,通过booti命令跳转执行。分析源码时,如果看到反汇编里带decompress相关符号,多半是压缩镜像或开启了内核自解压功能,别误以为是内核主入口。

对应的源码位置在arch/arm64/kernel/head.S。入口符号是stext,U-Boot 跳转到这里时,MMU 还没开,CPU 还处于物理地址直接访问的状态,所以stext里第一件事情是建立早期页表,为开启 MMU 做准备。

2.2 汇编阶段的入口:从 stext 到开启 MMU

head.S这一阶段是新手最容易放弃的地方,其实不用每个宏都看懂,抓住重点就行。ARM64 的启动流程大致是:

  1. stext:记录 CPU ID,设置异常向量表,选择当前 CPU 并保存 bootloader 传入的设备树地址。
  2. __create_page_tables:用汇编手动建立初始页表。这些页表只映射了内核镜像所在区域和 DTB 所在区域,用的还是 2MB 或 4KB 粒度的静态映射。
  3. __enable_mmu:配置sctlr_el1寄存器,打开 MMU。这一步之后,代码里的地址访问就从物理地址切换成了虚拟地址。
  4. 跳转到__primary_switch,最终通过br x8之类的间接跳转进入 C 语言世界。

我用一个不恰当的比喻帮你理解这阶段:汇编启动就像你早上刚睁开眼,眼睛还没完全对焦,先在床头摸到眼镜,再把房间主要区域看清楚,然后才下床正常行动。初始页表就是那副眼镜,只保证你能看清当前必要的区域,剩下的内存映射等setup_archpaging_init去做。

看书时对照一下 Cortex-M 的启动流程会有很直观的差异:Cortex-M 上电后直接从向量表跳到Reset_Handler,然后初始化栈指针、调用SystemInit,最后进main,全程没有 MMU,不需要转页表。而 Cortex-A 上跑 Linux,汇编启动阶段做的事情多得多,因为它要管理虚拟内存、多核启动、缓存一致性这些复杂问题。搞清楚这个区别,你就不难理解为什么嵌入式 Linux 启动分析比单片机开发更强调源码跟踪了。

2.3 start_kernel:整个初始化流程的“总控室”

汇编阶段完成任务后,内核跳入位于init/main.cstart_kernel()。可以说,从这一刻开始,内核就进入了一个巨大的“顺序执行流程”。它的初始化顺序非常讲究,前后依赖很强,不是随便排的:

  • setup_arch():解析设备树,初始化内存布局,建立最终的内核页表。
  • mm_init():初始化内存管理子系统,包括 buddy 分配器和 slab 分配器。
  • init_IRQ():初始化中断控制器和中断描述符表。
  • time_init():初始化时钟源和时钟事件设备。没有时钟,后面的调度器根本跑不起来。
  • console_init():初始化控制台。此时串口打印正式可用了,但你之前看到的日志可能是earlyconbootconsole,后面会讲到区别。
  • rest_init():创建两个内核线程——kernel_initkthreadd。前者最终负责挂载根文件系统、执行init程序;后者是所有内核工作线程的父线程。

我最开始源码跟踪时,特别喜欢在start_kernel里按函数顺序打桩加打印,后来发现内核本身提供了更优雅的机制——printk 层级和 initcall_debug,完全不需要自己改源码。先记住start_kernel这条主干,后面每个子模块的初始化细节随时可以顺着源码再挖。

3. 源码级跟踪实操:从日志反推调用链

3.1 用 earlycon 和 printk 获取最早的启动信息

跟踪启动过程必须要有输出,最简单可靠的方法是串口。但要注意串口初始化也有先后:内核在console_init之前是没有标准控制台可用的,如果你需要看最早期汇编阶段的打印,得靠CONFIG_DEBUG_LL配合earlyprintkearlycon。ARM64 上推荐用设备树里chosen节点的stdout-path加内核参数earlycon,例如:

qemu-system-aarch64 -machine virt -cpu cortex-a57 \ -kernel arch/arm64/boot/Image \ -append "console=ttyAMA0 earlycon root=/dev/ram0" \ -initrd rootfs.cpio.gz \ -nographic

earlycon注册的是一个“早期控制台”,它只做最基础的寄存器轮询输出,不依赖完整的终端子系统。所以你能在打印里看到带bootconsole标识的日志,比如:

printk: bootconsole [ns16550a0] enabled

到了console_init之后,正式控制台console [ttyAMA0] enabled出现,这时早期控制台会被自动禁用。如果你在日志里看到这一行,说明标准控制台已经接管输出,在这之后做的打印调试才适合用普通printk

有个细节值得注意:console=ttyAMA0earlycon的 stdout-path 必须匹配实际串口,否则你会连一行日志都看不到。我之前在 AM335x 上调试,板子把调试串口放在 UART3,而内核默认 console 是 UART0,结果就是启动过程静悄悄,怎么看都像是内核没加载。排查了半天才发现是设备树chosen节点没改。

3.2 通过 initcall_debug 精确追踪每个初始化调用

启动过程中,大量驱动和子系统的初始化是通过“initcall 机制”完成的。简单说,内核把各个初始化函数按优先级放在不同的链接段里,启动时依次执行。这些优先级从高到低大致是:

  • core_initcall
  • postcore_initcall
  • arch_initcall
  • subsys_initcall
  • fs_initcall
  • device_initcall
  • late_initcall

对应的源码定义在include/linux/init.h,执行的入口在init/main.cdo_initcalls()。如果你想看每个 initcall 函数的调用顺序和耗时,不用自己改代码,在内核命令行加一个参数就行:

initcall_debug

加上之后,日志里会多出类似这样的内容:

initcall io_apic_init_ops+0x0/0x20 returned 0 after 0 usecs initcall acpi_pci_root_init+0x0/0x14 returned 0 after 59 usecs

别小看这行日志,它包含了函数名、模块基址偏移、返回值、耗时。遇到启动卡死,只要找到最后一个返回非 0 或者干脆没返回的 initcall,再对比源码,问题点基本就锁定了。我在实际项目里就靠着initcall_debug抓过一个 Wi-Fi 模组驱动初始化时固件加载超时的问题:日志停在了某个wlan_probe相关的 initcall,然后顺着代码找,发现是模组复位 GPIO 没拉高,驱动一直在等固件 ready 信号。

如果你还想看更细的函数调用链,可以用内核自带的 ftrace。内核启动早期用ftrace=function_graph参数,或者启动后在 debugfs 里手动设置。不过对普通源码跟踪来说,先学会读initcall_debug已经能解决 80% 的启动顺序问题。

3.3 设备树在内核启动源码中的解析路径

设备树配置是嵌入式 Linux 绕不开的环节。很多人的困惑是:我在.dts里加了一个节点,内核到底怎么知道要加载哪个驱动?这块通过源码跟踪可以完全看清楚。

关键路径在setup_arch()里调用的unflatten_device_tree(),它把扁平设备树(.dtb)转换成树状结构,存到全局的of_root树上。之后of_platform_default_populate()会遍历这棵树,为每个compatible属性匹配到合适驱动的节点创建platform_device。再往后,总线注册时触发platform_match(),它通过driver_match_device()把设备树节点里的compatible和驱动of_device_id表里的字符串一一比对。

所以你在驱动里看到这段代码,它就是在声明“我能匹配哪个设备”:

static const struct of_device_id my_driver_of_match[] = { { .compatible = "vendor,my-device" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_driver_of_match); static struct platform_driver my_driver = { .probe = my_driver_probe, .driver = { .name = "my_device", .of_match_table = my_driver_of_match, }, }; module_platform_driver(my_driver);

设备树解析失败或compatible字符串写错,最直接的现象就是probe函数不执行,设备节点在/sys/devices/platform/下也看不到。排查时我会先在源码里加一个pr_infoplatform_match,或者在驱动里放一个probe入口打印,确认是否走到匹配流程。很多“设备没反应”的问题,追到最后都是dts里少了status = "okay",或者compatible大小写不对,非常基础,但啃源码时非常值得花时间验证一遍。

4. 常见启动异常与排查技巧实录

4.1 日志停在 Uncompressing Linux 或 Starting kernel

这是嵌入式 Linux 调试里最经典的异常之一。32 位 ARM 常见日志:Uncompressing Linux... done, booting the kernel,然后就没有然后了。ARM64 常见的是 U-Boot 打印Starting kernel ...之后静默。

这种情况绝大多数不是内核源码的逻辑问题,而是跳转和参数传递没对上。按出现频率排序,我见过这些原因:

现象大概率原因排查方向
32 位内核解压后无输出机器类型 ID 不匹配检查 U-Boot 的MACHINE_START或设备树 compatible
ARM64 跳转后完全无输出设备树地址传错或 DTB 损坏检查 booti 命令地址和$fdtcontroladdr
earlycon 未匹配console 参数与串口驱动不匹配核对设备树 stdout-path 和实际串口
Kernel panic - not syncing找不到根文件系统检查 root= 和 initramfs 加载地址

我调试过一个 i.MX6ULL 板子,U-Boot 总是正常打印Starting kernel ...,内核就是一点反应都没有。后来仔细查 bootcmd,发现loadaddrfdtaddr重叠,内核镜像加载时直接把设备树覆盖了,等于拿损坏的 DTB 去启动。这种问题不改内核源码,光靠看日志很难发现,必须回看 bootloader 完整输出和地址分配。

4.2 内核崩溃时的 Oops 信息如何快速定位

启动过程中驱动 probe 出错,没处理好就会触发内核 Oops,甚至 panic。看到一屏寄存器和调用栈时不要懵,核心只需要抓住几个信息:

  • PC指针:发生异常时的指令地址。
  • Call trace:从异常点向上回溯的函数调用链。
  • Code列:出错指令附近的机器码。

拿到PC地址后,用交叉工具链里的addr2line转成源码位置:

aarch64-linux-gnu-addr2line -e vmlinux ffffff8008082f04

如果还有行号信息,它会输出类似drivers/clk/clk-fixed-factor.c:120的结果。这一步能把崩溃从“某个地址”变成“某一行代码”,后续基本就是看那个函数为什么空指针、为什么越界。

我自己的习惯是启动调试阶段保持CONFIG_DEBUG_INFO=y,必要时候再开CONFIG_KALLSYMS_ALL=y,这样Call trace里的符号信息更全,不会出现一堆[<anonymous>]。一旦确认某个驱动是罪魁祸首,短期先通过设备树把这个节点status = "disabled"跳过,保证系统能起,再慢慢查驱动司题。这在项目联调阶段非常管用。

4.3 与 Cortex-M 启动流程的对比和认知误区

很多人是从单片机转过来的,最常犯的认知误区是“内核启动和单片机启动一样,从头跑到尾”。实际上,Linux 内核启动充满异步和并行。比如request_irq之后中断随时可能触发,workqueue里的延迟工作在后台跑,kthreadd会去唤醒各色内核线程。启动流程不是一条直线,而是“主线 + 支线”交织的网络。

Cortex-M 上你只要保证main()里的初始化顺序不犯错,程序就能稳定跑;而 Linux 内核启动期间,很多设备驱动的 probe 并不阻塞主线,甚至会有意地把耗时操作放到probe之后的async_schedule或工作队列里,让系统尽快进入用户态。所以做启动时序分析时,不能只盯着start_kernelinit的直线,还要看initcall_debug里哪些驱动悄悄在后台干活。

我记得第一次在项目里看/sys/kernel/debug/devices_deferred时,发现一批设备的 probe 都被推迟了,原因是依赖的 regulator 还没准备好。如果没跟踪过源码,看到“设备树里加了节点但设备没出现”就会一头雾水。这正是理解“Linux 启动是协作式调度 + 异步资源依赖”之后才能解释的现象。

5. 从启动分析走向性能优化

5.1 时间戳跟踪与启动耗时分布

前边提到打开CONFIG_PRINTK_TIME=y,这一步在做性能调优时尤其重要。启动日志每条前面都有毫秒级时间戳,我用脚本扫一遍,就能画出启动阶段耗时分布。以我手上一块 ARM64 板子为例,开启前后对比非常明显:

  • U-Boot 阶段:约 400ms,其中主因是环境变量保存和网络重试。
  • 内核镜像加载:约 300ms,主要受 SD 卡读速限制。
  • 解压和汇编启动:约 150ms,这部分一般是固定开销。
  • start_kernel到 initcall 完成:约 700ms,其中大量时间花在 USB、网络、显示等驱动的 probe 上。
  • initramfs 解包和 init 启动:约 200ms。

内核日志里可以用dmesg -T确认时间基准,也可以直接看打印中的[ 0.123456]前缀。如果某个区间耗时不正常,再用initcall_debug把一个一个 initcall 时间拉出来看,基本能定位到分钟级甚至毫秒级的热点。

5.2 裁剪和优化建议

拿到耗时分布,下一步就是系统裁剪。嵌入式 Linux 启动提速的通用思路有以下几类,按收益从高到低排:

  1. 减去不必要的内核配置:从defconfig里去掉用不到的子系统,比如蓝牙、Wi-Fi、音频、GPU 等。配置减少之后,不仅仅编译时间减少,启动时 probe 的驱动数量也直接下降。
  2. 精简设备树节点:只保留当前硬件真正用到的外设节点,其他全部status = "disabled"。一个多余节点的代价不只是内存,而是驱动的加载和初始化时间。
  3. 使用最小化 initramfs 或裁剪 rootfs:把启动依赖的文件、库压到最少,BusyBox 静态编译,很多场景下能把 init 启动时间降低一半。
  4. 调整内核参数:比如quiet可以减少打印开销,loglevel=3能在保留可观测性的同时减少串口输出等待。
  5. 对确定性高的外设,把驱动编译进内核而不是模块,避免模块加载和依赖解析的额外开销。

在做启动优化时,务必要“改一步、测一步”,每次只改一个变量。我曾经一次性裁剪了几十个内核配置,启动时间确实缩短了,但某个 SPI 设备驱动被顺手关掉了,导致产品功能直接缺失,后来在 git diff 里找了半天才定位到。

5.3 一个小技巧:用 init=/bin/sh 快速验证启动系统

最后一个很实用的技巧:如果你启动卡在挂载根文件系统或者 systemd 服务,不要急着改内核,可以先在内核命令行加init=/bin/sh。这样内核会跳过完整的用户态初始化,直接丢给你一个 shell。它能快速验证“内核本身能不能起来、设备驱动是否正常”,把问题隔离在用户态之外。

我之前做一个工具型产品,rootfs 换用全新 buildroot 之后,启动到一半就黑屏。先用init=/bin/sh确认内核和设备节点没问题,再手动逐步执行/etc/init.d/rcS,几行脚本下去就找到是某个服务依赖了网络但网卡固件还没加载完。这个排查思路比反复刷机高效得多。

内核启动源码跟踪这个事,做到最后你会发现,它不只是一个“读懂源码”的过程,更像是在为主板建立一个完整的“认知地图”:知道每个子系统什么时候初始化、依赖什么资源、失败时表现是什么。有了这张地图,以后无论遇到内核崩溃、驱动加载失败还是冷启动超时,你都能用同一套方法论去拆解。我在实际项目里最大的体会是,别指望一次完整啃完所有代码,跟着一次真实的启动日志,反复来回跳转,今天理解一段,明天补充一段,很快就通透起来了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 8:09:18

前端工程师如何用Redis+BM25构建Agent记忆模块

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 8:09:14

2026年学术论文AI检测与降AI率工具全解析

1. 论文AI率检测的现状与挑战2026年的学术圈正在经历一场前所未有的技术变革风暴。去年某高校爆出研究生论文AI生成率高达99%的新闻&#xff0c;直接导致该生被取消学位资格。这件事像一颗深水炸弹&#xff0c;彻底改变了高校对学术论文的审核标准。现在国内主流高校普遍采用AI…

作者头像 李华
网站建设 2026/9/13 8:08:03

Elasticsearch索引原理:深入理解倒排索引、Lucene架构与段合并机制

Elasticsearch索引原理&#xff1a;深入理解倒排索引、Lucene架构与段合并机制 本文深入解析Elasticsearch核心索引原理&#xff0c;详细阐述倒排索引的工作机制、Lucene的数据结构设计以及段合并策略的实现原理。通过理解这些底层技术&#xff0c;开发者能够优化索引性能&…

作者头像 李华
网站建设 2026/9/13 8:07:26

如何将 MCP Server 的工具接入 AI SDK 并选择 HTTP 或 stdio 传输

如何将 MCP Server 的工具接入 AI SDK 并选择 HTTP 或 stdio 传输 【免费下载链接】ai The AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications and agents 项目地址: https://gitc…

作者头像 李华
网站建设 2026/9/13 8:06:40

如何端到端运行 machine-learning-for-trading 的 ETF 案例研究流水线

如何端到端运行 machine-learning-for-trading 的 ETF 案例研究流水线 【免费下载链接】machine-learning-for-trading Code for Machine Learning for Trading, 3rd edition — from data sourcing to live execution. 项目地址: https://gitcode.com/GitHub_Trending/ma/ma…

作者头像 李华