Linux内核启动源码跟踪:从stext到start_kernel的ARM实战排查 1. 一次启动卡死逼着我老老实实读源码做嵌入式Linux这几年我遇到过最让人抓狂的问题不是驱动写不出来而是内核启动到一半串口上啥输出都没有了。那种感觉就像你寄了一个快递物流显示运输中然后就永远卡在运输中。我印象最深的一次是在一块imx6ull板子上移植内核U-Boot打印完Starting kernel ...整个串口就像被人拔了麦克风一样一片死寂。一开始我以为是U-Boot的bootargs写错了或者是zImage拷贝的内存地址不对反反复复对着文档查了快两天网上能搜到的相关帖子都翻遍了没找到一条有效的思路。后来同事提醒了一句你为什么不看看内核源码在启动最开始到底干了什么这一句话点醒了我。之前我总是把内核当成一个黑盒出了问题第一反应是去查配置、查参数、查网络上的零散资料却很少主动去读启动路径上的源码。实际上Linux内核启动是一个完全可控、完全确定的过程从汇编入口stext到start_kernel再到第一个用户态进程init中间每一步都有对应的源码和符号。只要你掌握了源码跟踪的方法所谓的未知卡死本质上就是某个函数没有返回、某个依赖没有就绪仅此而已。后来我花了大概两周时间把ARM平台的启动路径从头到尾过了一遍配合反汇编、System.map和串口日志把那次问题定位到设备树解析阶段的一个节点配置错误上。从那以后凡是在启动阶段遇到疑难杂症我都习惯先打开源码从stext开始顺着执行流走一遍。这篇文章就是把这些经验整理出来写给正在跟内核引导较劲的同行。无论你是刚接触嵌入式Linux的新手还是已经在做驱动移植但不太敢碰启动代码的人按着这条线去读源码你会发现自己对内核的理解会上一个台阶。2. 跟踪内核启动的第一步搞清镜像、符号表和启动分段2.1 你手里拿的到底是什么镜像开始读启动代码之前得先搞清楚你编译出来的镜像长什么样。很多新手会混淆这几个文件vmlinux未经压缩的ELF格式内核带完整的调试信息和符号表是源码跟踪时最常用的文件但体积大不能直接烧录进板子。Imagevmlinux经objcopy去掉ELF头后得到的裸二进制镜像通常也不用于直接启动。zImageImage经过自解压程序压缩后的ARM传统镜像头部有一段解压代码运行时先把自己解压到内存某处再跳转执行。uImage在zImage前面加上一个U-Boot专用的64字节头部包含镜像加载地址、入口地址、CRC校验等信息。U-Boot识别的是这种格式。dtb设备树二进制文件存放硬件拓扑信息会被U-Boot加载到内存并传给内核。源码跟踪时我几乎只盯着vmlinux和System.map。vmlinux里能直接用objdump反汇编查看某条指令的上下文System.map里记录了所有符号的虚拟地址帮助你把PC指针的值翻译成一个函数名。2.2 System.map和反汇编把数字翻译成人话系统启动过程中你拿到的往往是串口日志、JTAG读出的PC寄存器值、或者一个oops信息里的函数地址。想把这些数字翻译成具体的函数名靠的就是System.map和addr2line。举个例子假设oops信息里写着PC is at 0x803054a8。你可以在Linux源码根目录下执行arm-linux-gnueabihf-addr2line -e vmlinux 0x803054a8输出通常像这样arch/arm/kernel/setup.c:245这就告诉你PC指针卡在setup.c第245行附近。然后再配合objdump反汇编这一小段代码往往就能看出是在哪个循环里死转或者哪个空指针解引用了。arm-linux-gnueabihf-objdump -d vmlinux --start-address0x80305400 --stop-address0x803054c02.3 启动流程的整体分段在逐个函数深入之前先在脑子里建立一张整体地图。ARM Linux内核从开机到用户态进程大致分为五个阶段阶段执行环境关键入口/文件主要职责Bootloader引导实地址/物理地址U-Boot加载zImage和dtb到内存自解压物理地址arch/arm/boot/compressed/head.S解压zImage为vmlinux汇编入口物理地址→虚拟地址arch/arm/kernel/head.S 的 stext设置CPU模式、页表、MMUC语言初始化虚拟地址init/main.c 的 start_kernel完成内核核心子系统初始化init进程启动用户态虚拟地址kernel_init()挂载根文件系统执行/sbin/init实际跟踪过程中最让人头疼的是第3和第4阶段之间的一次穿越MMU开启前代码运行在物理地址MMU开启后代码跳转运行在虚拟地址。很多刚接触源码的人会在这一步看晕因为同一个地址在开MMU前后含义完全不同。3. 汇编阶段源码跟踪stext入口到MMU开启的每一步3.1 起点arch/arm/kernel/head.S如果你用的是ARM32平台比如imx6ull的Cortex-A7内核真正的入口是head.S里的stext标号。U-Boot跳转到内核入口后CPU处于SVC模式、中断未屏蔽、MMU关闭运行在物理地址。打开head.S第一眼会看到一段很紧凑的汇编ENTRY(stext) ARM_BE8(setend be) THUMB( badr r9, 1f ) THUMB( .thumb ) THUMB(1: ) setmode PSR_F_BIT | PSR_I_BIT | SVC_MODE, r9第一件事是切换到SVC模式并关闭FIQ和IRQ中断。原因很简单在页表、异常向量表、中断控制器都没准备好的情况下任何中断进来都会导致不可预测的行为。这就是为什么启动阶段日志里偶尔能看到Unexpected IRQ之类的炸栈提示。接着是关键的两步bl __lookup_processor_type bl __lookup_machine_type bl __vet_atags3.2 __lookup_processor_type为什么要先验CPU身份证这段代码通过CPU的MIDR寄存器Main ID Register读取处理器型号然后在内核编译时生成的一个proc_info_list表里查找匹配项。表里记录了这个处理器支持的MMU类型、cache类型、CPU指令等关键差异。如果找不到匹配项内核会直接panic因为你正在用一个不支持的CPU跑内核——在排查问题的时候如果日志在启动最早期就出现Error: unrecognized processor type十有八九是内核config选的CPU类型与你板子上的SoC不符比如imx6ull是Cortex-A7你却在config里选了Cortex-A9。这一步是整个Linux内核的自我认知阶段。源码里通过__proc_info_begin和__proc_info_end两个符号圈定查找范围本质上就是在遍历一个链表。3.3 __create_page_tables开启MMU前的脚手架ARM Linux并不是一直跑在物理地址而是尽早开启MMU因为这样可以启用虚拟内存管理、cache和DMA一致性等功能。开启MMU之前必须先把一级页表准备好。这段代码挂在__create_page_tables标号下做的事情是把当前物理内存起始地址到内核镜像尾部映射到虚拟地址3G以上内核空间同时把设备树DTB所在的内存区域也映射进来保证后续访问DTB时不会缺页。这里有个常见的坑页表缓冲区必须在物理内存的低端而且不能覆盖正在执行的kernel镜像和解压后的DTB区域。如果你在移植时发现内核一开MMU就挂先检查你是不是改动过内核加载地址或者CONFIG_PHYS_OFFSET配置。3.4 __enable_mmu和__mmap_switched一次换视角的跳跃MMU开启后PC仍然指向物理地址但紧接着的__mmap_switched会通过一次绝对跳转而切换到虚拟地址空间继续执行。这一跳非常关键如果虚拟地址映射不对跳转过去的瞬间就取指失败现象就是完全无输出、板子像死了一样。从__mmap_switched开始代码正式进入C语言世界。它会保存处理器ID、机器ID、DTB指针到指定寄存器然后调用b start_kernel3.5 这一阶段怎么排查问题汇编阶段几乎没有任何现成的打印机制因为串口驱动还没初始化。常用的手段有两个CONFIG_DEBUG_LL打开这个配置后内核会使用一套极简的、不依赖完整驱动框架的底层调试打印函数printascii()专门用于汇编和极早期启动打印。我在imx6ull上用它来输出当前PC值定位过多次死循环。JTAG读取PC寄存器用OpenOCD连接开发板的JTAG接口复位内核后让它停在异常处直接读$pc再用System.map和addr2line翻译成C代码行号。这个方法对编译优化带来的跳转尤为有效因为你能看到真实的指令流。如果你发现日志完全停留在U-Boot的Starting kernel...之后优先打开CONFIG_DEBUG_LL确认内核是否真的进入了stext。很多时候问题发生得更早——比如U-Boot跳转的地址根本不是合法的内核入口或者镜像在拷贝时尾部没有被正确搬运。4. start_kernel主线C语言初始化的调用顺序为什么这么排4.1 一个函数撑起整个内核从汇编跳进来之后start_kernel位于init/main.c几乎是整个内核唯一的总导演。它的代码很长但主线调用顺序极其清晰asmlinkage __visible void __init start_kernel(void) { char *command_line; char *after_dashes; set_task_stack_end_magic(init_task); smp_setup_processor_id(); debug_objects_early_init(); cgroup_init_early(); local_irq_disable(); early_boot_irqs_disabled true; /* * Interrupts are still disabled. Do necessary setups, then * enable them. */ boot_cpu_init(); page_address_init(); pr_notice(%s, linux_banner); setup_arch(command_line); ... mm_init(); sched_init(); ... console_init(); ... rest_init(); }4.2 setup_arch架构相关的户型图解析setup_arch是架构相关代码的入口对ARM平台来说它主要做三件事解析DTB通过setup_machine_fdt找到DTB在内存中的位置解析出machine描述符板级型号、内存大小、内核启动参数。初始化memblock创建一个物理内存分配器用来管理哪些内存区域可用于内核哪些被保留给硬件。日志里的Memory: 512MB available就是这一步的输出。paging_init建立完整的页表映射把内核镜像、vmalloc区域、DMA区域等全部规划好。这里我栽过一个跟头某块板子的芯片本身有512MB内存但内核启动日志只显示256MB。查了很久发现设备树里的memory节点写成reg 0x80000000 0x100000000x10000000正是256MB。DTS里内存大小和芯片实际封装不一致内核是不会自动探测的。4.3 从内存、调度器到控制台为什么console_init这么靠后很多刚接触启动流程的人不理解为什么启动日志里内核版本号打印得那么早而console_init调用却那么晚原因是控制台需要依赖大量的子系统串口驱动可能需要设备树解析、字符设备框架、甚至中断子系统。start_kernel选择了最短依赖路径的初始化顺序先初始化基础的CPU和内存管理没有内存就没有一切再初始化调度器没有sched_init就没有多任务能力然后初始化中断为串口收发的后续驱动做准备最后才轮到console。这也就解释了为什么在控制台就绪前的那些日志实际上是存在一个早期环形缓冲区里等console_init之后才一次性冲出来。如果你在设备树里配置了earlycon这部分日志就能立即显示。4.4 rest_init创建1号进程和2号进程start_kernel的最后一步是rest_init()它创建两个核心内核线程kernel_init1号进程负责后续的设备驱动初始化、挂载根文件系统最终退化为用户态的init进程。kthreadd2号进程所有内核线程的母亲负责内核态线程的创建和管理。你可以在源码里看到static noinline void __ref rest_init(void) { rcu_sched_grace_period(); rcu_sched_after_fork(); pid kernel_thread(kernel_init, NULL, CLONE_FS | CLONE_SIGHAND); ... pid kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES); ... }日志里Freeing unused kernel memory: 332K打印完根文件系统开始挂载系统进入用户态。4.5 用initcall_debug观察驱动初始化顺序kernel_init会调用do_basic_setup最终通过do_initcalls拉起所有编译进内核的驱动模块。这一过程遵循链接脚本vmlinux.lds.S里定义的initcall段顺序分为early、core、postcore、arch、subsys、fs、device、late八个优先级。想观察每个驱动在什么时候初始化、是否成功可以在kernel cmdline里加上initcall_debug启动日志会打印类似initcall fec_probe0x0/0x4a8 returned 0 after 32882 usecs这对定位某个驱动probe卡死极其有效——你能精确到是哪个initcall返回慢、返回失败还是根本不返回。5. 设备树在内核启动中的消费链路从DTB到platform设备5.1 DTB从哪里来又去了哪里新版内核3.x之后几乎完全依赖设备树描述硬件。U-Boot在引导内核时会把DTB加载到内存中并把其物理地址存放在r2寄存器传给内核。内核拿到DTB后经历一条完整的消费链路步骤关键函数作用校验合法__vet_atags / __fixup_smp检查DTB魔数、大小、结构设置machinesetup_machine_fdt通过root节点的compatible匹配machine_desc扫描内存early_init_dt_scan_nodes解析chosen、memory、cmdline展开树unflatten_device_tree将扁平DTB转成device_node结构树注册设备of_platform_populate把总线下的节点转成platform_device匹配驱动platform_match通过compatible / of_device_id匹配probe很多人没意识到的是unflatten_device_tree这一步会一次性把所有DTB节点转换成内核内部的struct device_node它是一个单链表parent/child指针的树状结构。从那以后DTB本身那块内存就可以释放了。5.2 of_platform_populate设备与驱动的相亲会对于嵌入式的SoC来说大部分外设控制器比如I2C、SPI、UART、Ethernet都是内核起设备树的of_platform_populate后自动变成platform_device的。驱动侧什么时候被probe取决于设备树节点的compatible属性与驱动中of_device_id表的匹配。这个机制看着简单实际上埋了很多坑。我见过最多的一类问题fec1 { status disabled; };可能原来是调试时临时禁用了网卡改完dts忘了编译烧录新dtb结果上电以后eth0就是不出现。更隐蔽的是你在板级dts里写了一个节点i2c2 { status okay; };但SoC级dtsi里的对应节点使用的是compatible fsl,imx6ull-i2c而你拿到手的某个驱动补丁只匹配fsl,imx6q-i2c哪怕是同一家厂商的IP核驱动匹配不上设备节点就始终处于孤儿状态。5.3 怎么确认DTB节点是否被内核正确解析排查设备树问题最直接的办法是在启动早期查看设备树展开是否完成。在cmdline里加上log_buf_len1M earlycon同时在源码里临时加打印也是个土办法。但我更推荐一个不折腾源码的方式在内核进入用户态后用debugfs或sysfs检查。比如想确认fec1网卡节点是否被识别为platform_devicels /sys/devices/platform/ | grep fec如果节点存在再检查driver字段ls -l /sys/devices/platform/2000000.ethernet/driver如果显示No such file or directory说明没有匹配到驱动。这一招在跑板子时几乎百试百灵能快速区分设备树节点没生成和驱动匹配失败两种不同的问题。5.4 chosen节点和stdout-path没有它串口可能一片空白设备树中chosen节点承担着传递早期参数的职责至少包括bootargs和stdout-path。很多开发板调试时串口没有任何输出不是内核没跑而是stdout-path没指向正确的串口别名。比如imx6ull有名的ttymxc0chosen { stdout-path uart1; };如果这个属性缺失内核在console_init之前可能完全不知道把日志往哪里送。对于没有显示器和输入设备的嵌入式板子这就是睁眼瞎状态。所以我在移植新板子时拿到的第一份dts先看三个节点chosen、memory、以及根节点的compatible。这三个节点解析成功内核才能看见内存、console和板级型号。6. imx6ull启动日志逐段拆解网卡驱动的失踪是怎么查出来的6.1 从U-Boot到内核日志的分水岭看启动日志要习惯分段。U-Boot段和内核段之间有一个明显的分水岭就是Starting kernel ...这一行。U-Boot打印完这行后会关闭中断、把参数准备好然后跳转内核。正常的内核日志以Booting Linux on physical CPU 0x0开始。如果到了这一步说明汇编阶段的CPU识别和MMU初始化已经成功因为这条日志是start_kernel里通过pr_notice打印出来的。以一块imx6ull板子为例一段典型的启动日志开头是Booting Linux on physical CPU 0x0 Linux version 4.1.15 (buildubuntu) (gcc version 5.4.0 ...) #1 SMP Wed ... CPU: ARMv7 Processor [410fc075] revision 5 (ARMv7), cr10c5387d CPU: PIPT / VIPT nonaliasing data cache, VIPT aliasing instruction cache Machine model: Freescale i.MX6 ULL 14x14 EVK BoardMachine model这行非常关键它是由设备树根节点的compatible属性决定的。如果这行打印的板子名字和你实际板子不一致说明U-Boot加载的DTB不对。6.2 日志中隐藏的初始化地图继续往下看Kernel command line: consolettymxc0,115200 root/dev/mmcblk1p2 rootwait Memory: 366848K/524288K available (8092K kernel code, 454K rwdata, 2160K rodata, 1816K init, 276K bss, 153844K reserved, 0K cma-reserved)Memory一行的366848K/524288K available意味着总内存约512MB其中约150MB被保留。保留内存通常用于CMA、GPU、VPU等硬件专用区域。如果你发现可用内存异常少就可以顺着reserved保留区去查dts里的reserved-memory节点。再往下CPU: All CPU(s) started on SMP单核SoC一般只有一行。接着是大量的驱动初始化噪音直到Freeing unused kernel memory: 332K (80504000 - 80559000)这行在文件系统加载前出现表示内核把所有仅启动时使用的init段内存释放了。这个位置往往意味着内核初始化完成了马上要跳转到根文件系统的init程序。6.3 网卡消失的定位过程有一块工业控制板bootargs、内核、根文件系统都正常但启动后死活找不到以太网接口。我的排查过程如下第一步确认内核是否编译进fec驱动。检查内核配置grep -i fec .config第二步确认设备树节点是否存在。启动后查看platform设备ls /sys/devices/platform/ | grep fec结果有2188000.ethernet这个设备目录说明设备树节点解析出来并被注册了但目录下没有driver链接。第三步确认驱动probe是否被调用。在fec驱动源码drivers/net/ethernet/freescale/fec_main.c的probe函数入口加一行dev_info重新编译内核启动。发现根本没有打印。这说明设备树中的节点虽然注册了platform_device但驱动侧的compatible匹配失败。第四步对比两边compatible。再看设备树fec1 { compatible fsl,imx6q-fec; // 设备树里的 };而驱动of_device_id表里可能只有static const struct of_device_id fec_dt_ids[] { { .compatible fsl,imx6ul-fec, .data fec_data[0] }, { .compatible fsl,imx6ull-fec, .data fec_data[1] }, };两边对不上。实际原因是同一个SoC型号有时会对应多份dts、多份内核补丁改动过程容易引入compatible不一致。这种无声无息的问题日志里没有任何error只能靠链路排查法一层层切下去。6.4 中断控制器引用错误启动panic案例还有一类隐蔽问题是用错中断控制器引用。设备树节点里uart1 { interrupt-parent intc; interrupts 0 26 4; };如果interrupt-parent写成了gpc或者数字填写错误驱动probe时申请中断会失败启动日志里可能出现irq: no irq domain found for /soc/aips-bus02000000/spba-bus02000000/serial02020000 !这种日志定位起来也不难关键是要有中断域意识。设备树中的interrupt-parent和interrupts-extended必须指向一个能解析该节点中断的interrupt controller。7. 陌生内核快速定位卡死我的排查链路和工具清单7.1 先判断卡死在哪一层拿到一个陌生内核或者别人移交的BSP启动卡死时我不会盲目去翻源码而是先按下面这张表快速判断当前所处阶段日志现象大致所处阶段优先排查方向U-Boot正常Starting kernel...后无任何输出汇编早期或DTB问题CONFIG_DEBUG_LL、r2寄存器里的DTB地址打印了Linux version但卡住在某一行start_kernel早期内存配置、CPU相关config能看到部分驱动初始化日志但不到根文件系统do_initcalls阶段initcall_debug定位卡住的initcall内核日志完整但卡在挂载根文件系统kernel_init阶段root设备节点、文件系统驱动是否编译进内核文件系统挂载成功但login/init进程异常用户态问题init程序路径、rcS脚本、动态库依赖这一步能帮你节省最多时间不要一开始就钻进海量的启动源码里而是先定位大头。7.2 earlycon和printk.level这两个救命配置在完全没有日志的情况下优先尝试两个配置第一个是earlycon。在bootargs里加上earlycon如果内核编译时配置了CONFIG_SERIAL_EARLYCON且设备树里chosen节点的stdout-path正确你会看到从汇编阶段之后最早期的日志。这个手段比CONFIG_DEBUG_LL更现代也更简单不需要改源码。第二个是调整printk默认级别。有些日志默认级别较低串口上根本显示不出来。启动参数加loglevel8会打印包括debug级别的所有消息。很多驱动卡死前其实打印过WARNING或DEBUG信息调高日志级别后线索直接送上门。7.3 initcall_debug把驱动初始化变成进度条上一节提过initcall_debug这里再展开讲它的具体用法。在内核cmdline里加上initcall_debug loglevel8启动后你会看到类似initcall i2c_init0x0/0x28 returned 0 after 463 usecs initcall 0x80008d60 returned -19 after 11 usecsreturned -19对应-ENODEV说明没有匹配到设备这通常不代表错误只是驱动找不到硬件。但如果你发现某个initcall长时间不返回比如日志卡在initcall xxx那一行不再有下一行基本能断定就是这个驱动的init或probe函数死循环或阻塞了。这时候再去读对应驱动源码配合addr2line算出卡住代码行定位精度就非常高了。7.4 我常用的启动调试工具清单条条大路通罗马但工具选得好效率差好几倍。下面是我在实际工作中验证过的一套组合工具/手段场景备注System.map addr2line崩溃地址转函数名几乎每次oops排查必备objdump -d看反汇编确认优化后的真实执行流编译器优化会改变代码顺序源码层面看不出分支OpenOCD GDB读取PC、寄存器、内存设置硬件断点卡死无日志时最可靠ftrace跟踪函数调用关系适合定位进入某个函数后再也没出来的深层问题initcall_debug观察驱动初始化的起点和耗时启动优化和卡死定位都能用printk friends临时插桩打印最原始但最直接串口逻辑分析仪确认物理层真的没数据别小看这一步我遇到过USB转串口线有毛刺导致日志中间丢一段7.5 最后一个土办法二分法关闭内核feature代码实在不好定位时我常用一个土办法二分法裁剪。比如怀疑是某个子系统初始化导致卡死就打开menuconfig把camera、GPU、VPU等不相关驱动先摘掉编译启动。如果问题消失再把驱动一批批加回来逐步缩小范围。这招在调试启动时间优化时也很好用先关掉所有非必要驱动得到基线启动时间然后逐个开启并测量耗时增量。不要靠猜来一次开一堆驱动那样出了问题根本不知道是谁导致的。我在实际使用中发现读内核启动代码最大的价值不是把每个函数都背下来而是建立起一种预期。你知道这一段该打印什么、那一段该做什么一旦现实和预期出现偏差你就能敏锐地察觉到异常点。Linux内核的启动路径其实不长花点时间把init/main.c、setup.c、head.S这几十个关键函数过一遍后续再排查任何启动问题都会从容很多。最后再分享一个小技巧把你手上平台的正常启动日志完整保存一份每次调试ebugg前先diff一遍变化的地方往往就是问题所在。