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

发布时间:2026/9/13 8:09:26
嵌入式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保存完整启动日志非常关键后面所有分析都建立在日志基础上。符号解析工具addr2line、nm、readelf后面定位内核崩溃时的 PC 指针全靠它们。准备工作里最容易忽略的是把CONFIG_PRINTK_TIMEy打开这会在每条内核日志前加上时间戳没有它后面做耗时分布统计会非常难受。另一个建议是编译时保留vmlinux这个未压缩的内核文件调试信息CONFIG_DEBUG_INFOy也要开不然源码级跟踪就是空中楼阁。2. 内核启动流程主干从汇编到 start_kernel2.1 Bootloader 的交接棒镜像格式与启动参数要跟踪内核启动得先明白内核是被谁启动的。以 U-Boot 为例它加载内核镜像到内存某个地址同时还要加载设备树文件.dtb到另一个地址然后通过寄存器或特定规则把这两个地址传给内核。ARM32 时代用r0/r1/r2传参r0存 0r1存机器类型 IDr2存设备树地址ARM64 简化了x0是设备树地址w1目前给保留。另一种老方式是 ATAG 列表现在已经基本淘汰现代项目清一色设备树。这里很多人会踩一个坑编译出来的ImageARM64 未压缩镜像和zImageARM32 压缩镜像是不同的启动入口。zImage 开头有一段自解压汇编代码先把自身解压到合适位置再跳转到真正的内核入口而 ARM64 的Image没有自解压阶段U-Boot 直接把镜像拷贝到内存地址通过booti命令跳转执行。分析源码时如果看到反汇编里带decompress相关符号多半是压缩镜像或开启了内核自解压功能别误以为是内核主入口。对应的源码位置在arch/arm64/kernel/head.S。入口符号是stextU-Boot 跳转到这里时MMU 还没开CPU 还处于物理地址直接访问的状态所以stext里第一件事情是建立早期页表为开启 MMU 做准备。2.2 汇编阶段的入口从 stext 到开启 MMUhead.S这一阶段是新手最容易放弃的地方其实不用每个宏都看懂抓住重点就行。ARM64 的启动流程大致是stext记录 CPU ID设置异常向量表选择当前 CPU 并保存 bootloader 传入的设备树地址。__create_page_tables用汇编手动建立初始页表。这些页表只映射了内核镜像所在区域和 DTB 所在区域用的还是 2MB 或 4KB 粒度的静态映射。__enable_mmu配置sctlr_el1寄存器打开 MMU。这一步之后代码里的地址访问就从物理地址切换成了虚拟地址。跳转到__primary_switch最终通过br x8之类的间接跳转进入 C 语言世界。我用一个不恰当的比喻帮你理解这阶段汇编启动就像你早上刚睁开眼眼睛还没完全对焦先在床头摸到眼镜再把房间主要区域看清楚然后才下床正常行动。初始页表就是那副眼镜只保证你能看清当前必要的区域剩下的内存映射等setup_arch和paging_init去做。看书时对照一下 Cortex-M 的启动流程会有很直观的差异Cortex-M 上电后直接从向量表跳到Reset_Handler然后初始化栈指针、调用SystemInit最后进main全程没有 MMU不需要转页表。而 Cortex-A 上跑 Linux汇编启动阶段做的事情多得多因为它要管理虚拟内存、多核启动、缓存一致性这些复杂问题。搞清楚这个区别你就不难理解为什么嵌入式 Linux 启动分析比单片机开发更强调源码跟踪了。2.3 start_kernel整个初始化流程的“总控室”汇编阶段完成任务后内核跳入位于init/main.c的start_kernel()。可以说从这一刻开始内核就进入了一个巨大的“顺序执行流程”。它的初始化顺序非常讲究前后依赖很强不是随便排的setup_arch()解析设备树初始化内存布局建立最终的内核页表。mm_init()初始化内存管理子系统包括 buddy 分配器和 slab 分配器。init_IRQ()初始化中断控制器和中断描述符表。time_init()初始化时钟源和时钟事件设备。没有时钟后面的调度器根本跑不起来。console_init()初始化控制台。此时串口打印正式可用了但你之前看到的日志可能是earlycon或bootconsole后面会讲到区别。rest_init()创建两个内核线程——kernel_init和kthreadd。前者最终负责挂载根文件系统、执行init程序后者是所有内核工作线程的父线程。我最开始源码跟踪时特别喜欢在start_kernel里按函数顺序打桩加打印后来发现内核本身提供了更优雅的机制——printk 层级和 initcall_debug完全不需要自己改源码。先记住start_kernel这条主干后面每个子模块的初始化细节随时可以顺着源码再挖。3. 源码级跟踪实操从日志反推调用链3.1 用 earlycon 和 printk 获取最早的启动信息跟踪启动过程必须要有输出最简单可靠的方法是串口。但要注意串口初始化也有先后内核在console_init之前是没有标准控制台可用的如果你需要看最早期汇编阶段的打印得靠CONFIG_DEBUG_LL配合earlyprintk或earlycon。ARM64 上推荐用设备树里chosen节点的stdout-path加内核参数earlycon例如qemu-system-aarch64 -machine virt -cpu cortex-a57 \ -kernel arch/arm64/boot/Image \ -append consolettyAMA0 earlycon root/dev/ram0 \ -initrd rootfs.cpio.gz \ -nographicearlycon注册的是一个“早期控制台”它只做最基础的寄存器轮询输出不依赖完整的终端子系统。所以你能在打印里看到带bootconsole标识的日志比如printk: bootconsole [ns16550a0] enabled到了console_init之后正式控制台console [ttyAMA0] enabled出现这时早期控制台会被自动禁用。如果你在日志里看到这一行说明标准控制台已经接管输出在这之后做的打印调试才适合用普通printk。有个细节值得注意consolettyAMA0和earlycon的 stdout-path 必须匹配实际串口否则你会连一行日志都看不到。我之前在 AM335x 上调试板子把调试串口放在 UART3而内核默认 console 是 UART0结果就是启动过程静悄悄怎么看都像是内核没加载。排查了半天才发现是设备树chosen节点没改。3.2 通过 initcall_debug 精确追踪每个初始化调用启动过程中大量驱动和子系统的初始化是通过“initcall 机制”完成的。简单说内核把各个初始化函数按优先级放在不同的链接段里启动时依次执行。这些优先级从高到低大致是core_initcallpostcore_initcallarch_initcallsubsys_initcallfs_initcalldevice_initcalllate_initcall对应的源码定义在include/linux/init.h执行的入口在init/main.c的do_initcalls()。如果你想看每个 initcall 函数的调用顺序和耗时不用自己改代码在内核命令行加一个参数就行initcall_debug加上之后日志里会多出类似这样的内容initcall io_apic_init_ops0x0/0x20 returned 0 after 0 usecs initcall acpi_pci_root_init0x0/0x14 returned 0 after 59 usecs别小看这行日志它包含了函数名、模块基址偏移、返回值、耗时。遇到启动卡死只要找到最后一个返回非 0 或者干脆没返回的 initcall再对比源码问题点基本就锁定了。我在实际项目里就靠着initcall_debug抓过一个 Wi-Fi 模组驱动初始化时固件加载超时的问题日志停在了某个wlan_probe相关的 initcall然后顺着代码找发现是模组复位 GPIO 没拉高驱动一直在等固件 ready 信号。如果你还想看更细的函数调用链可以用内核自带的 ftrace。内核启动早期用ftracefunction_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_info到platform_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或设备树 compatibleARM64 跳转后完全无输出设备树地址传错或 DTB 损坏检查 booti 命令地址和$fdtcontroladdrearlycon 未匹配console 参数与串口驱动不匹配核对设备树 stdout-path 和实际串口Kernel panic - not syncing找不到根文件系统检查 root 和 initramfs 加载地址我调试过一个 i.MX6ULL 板子U-Boot 总是正常打印Starting kernel ...内核就是一点反应都没有。后来仔细查 bootcmd发现loadaddr和fdtaddr重叠内核镜像加载时直接把设备树覆盖了等于拿损坏的 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_INFOy必要时候再开CONFIG_KALLSYMS_ALLy这样Call trace里的符号信息更全不会出现一堆[anonymous]。一旦确认某个驱动是罪魁祸首短期先通过设备树把这个节点status disabled跳过保证系统能起再慢慢查驱动司题。这在项目联调阶段非常管用。4.3 与 Cortex-M 启动流程的对比和认知误区很多人是从单片机转过来的最常犯的认知误区是“内核启动和单片机启动一样从头跑到尾”。实际上Linux 内核启动充满异步和并行。比如request_irq之后中断随时可能触发workqueue里的延迟工作在后台跑kthreadd会去唤醒各色内核线程。启动流程不是一条直线而是“主线 支线”交织的网络。Cortex-M 上你只要保证main()里的初始化顺序不犯错程序就能稳定跑而 Linux 内核启动期间很多设备驱动的 probe 并不阻塞主线甚至会有意地把耗时操作放到probe之后的async_schedule或工作队列里让系统尽快进入用户态。所以做启动时序分析时不能只盯着start_kernel到init的直线还要看initcall_debug里哪些驱动悄悄在后台干活。我记得第一次在项目里看/sys/kernel/debug/devices_deferred时发现一批设备的 probe 都被推迟了原因是依赖的 regulator 还没准备好。如果没跟踪过源码看到“设备树里加了节点但设备没出现”就会一头雾水。这正是理解“Linux 启动是协作式调度 异步资源依赖”之后才能解释的现象。5. 从启动分析走向性能优化5.1 时间戳跟踪与启动耗时分布前边提到打开CONFIG_PRINTK_TIMEy这一步在做性能调优时尤其重要。启动日志每条前面都有毫秒级时间戳我用脚本扫一遍就能画出启动阶段耗时分布。以我手上一块 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 启动提速的通用思路有以下几类按收益从高到低排减去不必要的内核配置从defconfig里去掉用不到的子系统比如蓝牙、Wi-Fi、音频、GPU 等。配置减少之后不仅仅编译时间减少启动时 probe 的驱动数量也直接下降。精简设备树节点只保留当前硬件真正用到的外设节点其他全部status disabled。一个多余节点的代价不只是内存而是驱动的加载和初始化时间。使用最小化 initramfs 或裁剪 rootfs把启动依赖的文件、库压到最少BusyBox 静态编译很多场景下能把 init 启动时间降低一半。调整内核参数比如quiet可以减少打印开销loglevel3能在保留可观测性的同时减少串口输出等待。对确定性高的外设把驱动编译进内核而不是模块避免模块加载和依赖解析的额外开销。在做启动优化时务必要“改一步、测一步”每次只改一个变量。我曾经一次性裁剪了几十个内核配置启动时间确实缩短了但某个 SPI 设备驱动被顺手关掉了导致产品功能直接缺失后来在 git diff 里找了半天才定位到。5.3 一个小技巧用 init/bin/sh 快速验证启动系统最后一个很实用的技巧如果你启动卡在挂载根文件系统或者 systemd 服务不要急着改内核可以先在内核命令行加init/bin/sh。这样内核会跳过完整的用户态初始化直接丢给你一个 shell。它能快速验证“内核本身能不能起来、设备驱动是否正常”把问题隔离在用户态之外。我之前做一个工具型产品rootfs 换用全新 buildroot 之后启动到一半就黑屏。先用init/bin/sh确认内核和设备节点没问题再手动逐步执行/etc/init.d/rcS几行脚本下去就找到是某个服务依赖了网络但网卡固件还没加载完。这个排查思路比反复刷机高效得多。内核启动源码跟踪这个事做到最后你会发现它不只是一个“读懂源码”的过程更像是在为主板建立一个完整的“认知地图”知道每个子系统什么时候初始化、依赖什么资源、失败时表现是什么。有了这张地图以后无论遇到内核崩溃、驱动加载失败还是冷启动超时你都能用同一套方法论去拆解。我在实际项目里最大的体会是别指望一次完整啃完所有代码跟着一次真实的启动日志反复来回跳转今天理解一段明天补充一段很快就通透起来了。