RK3568 Linux内核启动流程与调试实践:从U-Boot到用户空间 从事嵌入式Linux开发的人应该都有过这种体验拿到一块全新板卡第一件事往往不是写业务代码而是看它能不能稳定启动。我这段时间一直在折腾基于RK3568的板卡被问得最多的问题是内核到底是从哪个点开始跑的启动流程里为什么有些驱动先初始化有些后初始化如果系统卡死了怎么通过日志快速定位是死在引导阶段、内核阶段还是用户态阶段这篇文章就把RK3568平台上的Linux内核启动流程从头到尾拆一遍从U-Boot跳转到内核入口开始讲到设备树解析、核心子系统初始化、驱动挂载再到用户空间交接。最后会补充一些实际调试中积累的排障经验比如earlycon怎么配、initcall阶段怎么看、启动卡死怎么定位。内容偏向实操适合正在做RK3568平台移植、驱动开发或者系统启动优化的朋友。1. 启动链路总览从RomCode到内核入口1.1 RK3568这颗SoC的启动拓扑先理清一个基本概念RK3568是一个四核Cortex-A55的SoC但它并不是上电后就直接跳到Linux内核的。整个启动链路是分段接力完成的我习惯把它拆成四个阶段BootROM阶段芯片内部的只读固件负责最基础的时钟和存储初始化然后从选定的启动介质读取下一级引导。引导程序阶段常见的是U-Boot负责DDR初始化、外设时钟、引脚复用把内核镜像和设备树从存储介质搬运到内存。内核早期阶段从入口汇编代码开始建立异常向量、早期页表、开启MMU逐步切换到C环境。用户空间阶段内核完成驱动和设备初始化后挂载根文件系统执行init程序。这四个阶段里客户能改的通常是后面三个阶段但很多启动问题恰恰出在前两个阶段的交接处。比如U-Boot传给内核的内存布局不对或者设备树物理地址传错内核会直接在早期阶段卡死日志只有几个字符甚至完全没有输出。RK3568的BootROM还有一个特点它支持多种启动介质协商包括SD卡、eMMC、SPI NOR Flash等。实际开发中最常用的是eMMC和SD卡从SD卡启动时BootROM会把SD卡特定偏移位置的引导代码加载到SRAM里执行。这个偏移位置和U-Boot的编译配置必须对应很多朋友刚接触时会在uboot阶段就卡住其实只是镜像烧录位置不对。1.2 U-Boot与内核的交接现场内核不会自己主动找个入口就开始执行它需要一个引导者把它叫醒这个引导者通常就是U-Boot。U-Boot在标准启动流程里会依次执行几个环境变量定义的命令常见顺序是setenv bootargs consolettyS0,1500000n8 root/dev/mmcblk0p5 rootwait load mmc 0:1 0x10000000 Image load mmc 0:1 0x11000000 rk3568-evb.dtb booti 0x10000000 - 0x11000000这里有几个关键点值得展开说。booti表示启动的是一个64位内核镜像对应ARM64架构不能用bootm去启动未经U-Boot封装的Image文件。第二个参数是-表示没有独立的ramdisk这种情况下根文件系统依靠bootargs里的root参数去挂载。0x10000000和0x11000000这两个地址不是随意取的。RK3568平台DDR通常从0x0开始映射但不同版本的内核镜像对加载地址有对齐和空间约束。一般建议内核镜像放在一个2MB对齐的地址设备树放在独立的1MB以上对齐的内存区域避免被解压过程覆盖。实际的加载地址通常由板级配置或bootcmd环境变量决定我见过不少启动异常就是加载地址与U-Boot配置不一致导致的。还有一个很多新手会忽略的点U-Boot会把自己维护的DDR信息传给内核具体实现是通过设备树节点里的reg属性以及U-Boot在启动内核前修正的内存节点。这就是为什么换了DDR容量后有时候内核无法识别全部内存需要同时更新设备树和U-Boot配置不能只改一边。2. 汇编入口阶段stext之前的那些事2.1 入口如何被找到内核镜像格式与解压当U-Boot执行booti后CPU会跳转到内核镜像头部指定的入口地址。ARM64内核的镜像开头是一个固定的64字节头部里面包含了magic、code0、code1和_text_offset字段。U-Boot启动booti时会读取这部分信息确认这是一个合法的ARM64内核镜像然后按头部指示跳转。如果你用的是压缩镜像Image.gz流程里会多一个自解压环节。ARM64的Image.gz自带解压代码入口处会先执行一段解压逻辑把真正的内核释放到合适的内存位置再跳转过去。这个过程会临时占用一段内存所以镜像加载地址和实际运行地址之间的空间规划很重要。RK3568平台实际开发中绝大多数情况直接使用未压缩的Image镜像原因有两条一是eMMC和SD卡的读取带宽足够高镜像体积从20MB增加到30MB对加载时间影响不明显二是解压过程会引入无法精确控制的时间开销对启动耗时敏感的场合不友好。如果是在NAND或者SPI Flash这类小容量介质上启动才优先考虑压缩镜像。2.2 早期页表与异常向量初始化入口汇编代码位于arch/arm64/kernel/head.S这一段代码执行的环境非常原始MMU还没有开启DDR可能还在自刷新状态C语言的运行环境完全没有准备好。汇编代码要做的事情可以概括为三件清扫BSS段。创建早期页表开启MMU和缓存。设置异常向量表切换到异常向量表所在的虚拟地址空间。为什么要这么早开MMU很多人以为MMU是高级功能启动早期应该关闭。实际上在ARM64上开启MMU不仅仅是内存保护的问题更重要的是CPU对指令预取的效率和缓存行为的控制。如果不开启MMUCPU访问内存只能使用物理地址由于线性映射还没有建立每次访问都需要经过非常原始的转换流程性能会非常差而且某些外设在DDR未初始化完成前还不能随意访问。head.S里有一大段针对不同内存布局的页表构建逻辑它根据KERNEL_START、KERNEL_END、TEXT_OFFSET等宏计算所需的页表项。这段代码踩过的坑不少最常见的原生问题之一就是TEXT_OFFSET配置不当导致页表映射出的地址和实际链接地址不一致内核启动后立刻触发异常。关于异常向量表需要知道ARM64一共有四组异常向量分别对应不同异常来源和运行级别。内核在启动早期就把vectors地址写入VBAR_EL1寄存器并设置SCTLR寄存器中的I/D cache使能位。这里如果配置出错通常表现是内核完全没有日志输出用JTAG挂钩时发现CPU停留在异常处理入口。2.3 earlycon早期串口的启用方法启动早期常规printk还没有初始化但这不意味着你什么都看不到。ARM64内核支持earlycon机制它能在极早期就把调试信息从串口打出来。这对RK3568平台调试非常重要因为RK3568的UART控制器在芯片内部有非常确定的寄存器地址完全可以提前初始化。启用earlycon有两种常见方式在U-Boot的bootargs里追加earlyconuart8250,mmio32,0xFE660000。在设备树的chosen节点配置stdout-path和bootargs。我个人的习惯是两种同时加因为在U-Boot阶段内核可能还没读到设备树这时bootargs里的earlycon参数更可靠。等内核进入设备树解析阶段后stdout-path会接管控制台输出。需要特别注意的是串口的寄存器位宽。RK3568的UART寄存器是32位访问模式earlyconuart8250,mmio32,0xFE660000里mmio32三个字必须写对。如果写成mmio内核会按8位模式访问寄存器读出来的状态位永远是错的表现就是完全没有输出甚至卡死。另外波特率建议固定为1500000这是RK3568平台调试串口最常见的配置U-Boot和设备树中也需要保持一致。还有一个更隐蔽的问题earlycon使用的物理地址是固定映射它的有效性依赖于early_fixmap_init完成。如果你的内核版本经过了裁剪或改过内存布局earlycon可能无法在预期地址工作。这时候可以退而求其次用汇编阶段准备好的putc调试函数但这需要你自己在平台汇编代码里加钩子复杂度高一些实际使用中并不太推荐。3. start_kernel之前的关键跳变设备树的三次转换3.1 从FDT到device_node当汇编代码完成基础初始化后会跳转到C语言的入口start_kernel。但在那之前还有一个重要的前置函数叫setup_arch它是架构相关的初始化入口负责处理从U-Boot传过来的设备树二进制数据。设备树在内核里要经历三次形态转换FDT原始二进制数据由U-Boot加载到内存物理地址通过寄存器x0传递给内核。struct device_node内核把FDT解析成一系列树状结构节点每个节点对应一个device_node结构体。struct platform_device驱动匹配阶段部分节点会被转换成平台设备挂到总线上等待驱动注册。这三次转换不是一次性完成的。setup_arch早期只做第一次解析把设备树映射到内核虚拟地址空间。第二次转换在unflatten_device_tree函数中完成它会遍历FDT生成一棵完整的device_node树并为每个节点分配内存。第三次转换则发生在驱动模型建立时期通过of_platform_default_populate把根节点下的子节点逐一注册为平台设备。很多问题的根源在于设备树二进制本身并不合法。我遇到过一块板卡U-Boot加载设备树时没检查校验和内核解析到某个节点就崩溃了。这种情况下加earlycon能看到解析中断位置但更有效的办法是在U-Boot阶段先执行fdt print /查看设备树是否能正常遍历。3.2 setup_arch与RK3568的平台适配setup_arch是ARM64内核初始化中最重要的架构函数之一。它做的事情包括解析设备树中的内存节点建立物理内存映射表。初始化固定映射区域为后续的vmalloc区、模块区准备空间。处理被标记为保留内存的区域比如CMA、ramdisk、secure monitor等。调用init_machine相关回调为板级驱动初始化做准备。在RK3568平台setup_arch阶段还会涉及一些SoC特有的初始化比如片内SRAM的映射、IOMMU和SMMU的早期配置等。这些内容通常由芯片厂商提供的内核补丁实现如果你使用的是纯上游主线内核可能需要在设备树中增加额外的reserved-memory节点把某些硬件需要的保留内存区域声明出来。我建议大家在研究RK3568启动流程时一定要查看内核日志里Memory:这一行及其后面的输出。它会列出内核识别到的物理内存范围、高位内存、保留区域和CMA大小。如果发现内存范围不对多半是设备树reg属性或U-Boot memreserve处理出了问题。3.3 中断、时钟、pinctrl的初始化顺序为什么这么排在start_kernel之后内核会执行一系列核心子系统初始化其中与平台关系最紧密的是中断控制器、时间系统和时钟框架。RK3568使用GICv3作为中断控制器GIC的初始化必须在任何驱动请求中断之前完成否则申请中断时找不到合法的irqdomain。时钟框架的初始化同样前置。原因是平台上几乎所有外设的正常工作都依赖某个时钟源而设备树中的clocks属性引用必须在时钟框架建立之后才能解析。如果你把某个驱动的probe时间提前而它的父时钟还没注册就会报出failed to get clock的错误。pinctrl的初始化优先级比具体外设驱动高但比时钟和中断控制器低因为它本身也依赖寄存器映射和某些时钟。RK3568的引脚复用和GPIO控制器由pinctrl驱动统一管理初始化时机太晚会造成大量外设驱动的probe直接失败。这个顺序不是随便定的它反映的是依赖关系的层级中断是事件传递的基础时钟是信号工作的基础引脚是物理连接的基础然后才是I2C、SPI、UART这些具体外设。4. 内核核心初始化与驱动initcall机制4.1 核心子系统的初始化顺序start_kernel会调用一系列*_init函数这些函数完成内核最核心的初始化包括调度器、内存管理、VFS、网络协议栈等。它们的调用顺序在内核源码中是固定的不能随意调整。这里列几个关键阶段setup_arch架构相关初始化完成内存映射和设备树解析。mm_init内存管理子系统初始化建立伙伴系统、slab分配器。sched_init调度器初始化创建各CPU的运行队列。init_IRQ中断子系统初始化。time_init定时器与时钟源初始化。kmem_cache_init、vmalloc_init等内存管理细化初始化。console_init早期控制台正式注册printk可以完整工作。initcalls这是驱动初始化的总入口按不同优先级依次执行。看到这里你会发现一个有意思的地方很多驱动需要在操作硬件但CPU、内存、中断、时钟等基础设施必须先就位。这就解释了为什么你在驱动里不能无脑在module_init里访问所有硬件资源必须等待probe时机。4.2 initcall的级别与调用细节内核引导阶段的驱动加载并不是一次性完成的而是分成多个级别顺序执行。ARM64和通用内核一致定义了以下几级initcall#define __define_initcall(fn, id) \ static initcall_t __initcall_##fn##id \ __used __attribute__((__section__(.initcall #id .init))) fn按照链接脚本将它们放在不同的段中内核启动时按initcall_levels数组顺序依次调用。层次从early到fs再到device具体包括pure_initcall最早期很少使用。core_initcall核心子系统如中断控制器、时钟控制器、定时器。postcore_initcall平台总线建立相关。arch_initcall架构相关设备如DMA、IOMMU。subsys_initcall子系统级通常是总线框架自身。fs_initcall文件系统相关。device_initcall普通设备驱动。late_initcall依赖大部分设备就绪后的初始化。对RK3568开发者来说最常见的驱动入口module_init对应的就是device_initcall级别。这意味着你的驱动会在中断、时钟、引脚、总线框架之后被probe依赖关系基本都能满足。如果你非要提前驱动初始化可以使用arch_initcall或者subsys_initcall但必须确认依赖项已经就绪否则会得到一堆让人摸不着头脑的probe失败日志。一个实用技巧想知道某个节点在哪个initcall级别被注册可以看内核日志里有没有initcall字段。开启CONFIG_DEBUG_DRIVER或使用initcall_debug内核参数后内核会打印每个initcall函数的启动和耗时。这个参数对于定位哪个驱动卡住是神器。4.3 RK3568中关键驱动的挂载时机把理论放到RK3568平台上看几个典型驱动。第一个是串口驱动。由于控制台依赖串口串口驱动的probe通常要足够早。RK3568的uart驱动基于8250框架注册时机往往在subsys_initcall级别这样控制台才能在后续启动过程中持续输出。如果你发现启动日志在Console: colour之后突然没有更多串口信息了可以先怀疑串口驱动太晚注册或者被probe失败。第二个是DMA控制器。RK3568有多个DMA控制器服务于不同外设。它们的初始化必须早于UART、SPI等需要使用DMA的设备但晚于时钟与中断。实际日志里如果出现DMA channel request failed多半不是控制器本身没初始化而是依赖的时钟没打开。第三个是MMC驱动。eMMC和SD卡的驱动挂载直接影响根文件系统能否挂载成功。RK3568平台通常使用dw_mmc驱动它的probe需要等待自身的时钟和PHY初始化完成。一旦MMC驱动初始化失败日志会在挂载根文件系统时报错提示VFS: Unable to mount root fs。值得记住的一点initcall的执行顺序和驱动在Makefile里的排序或设备树节点的顺序无关它完全由initcall级别决定。设备树里的节点顺序只影响同一级别下的匹配顺序不影响级别。5. 用户空间交接与启动耗时测量5.1 kernel_init如何选择init进程驱动和核心子系统初始化完成后内核会创建第一个用户空间进程。这个进程的本质是kernel_init线程它会先尝试执行根文件系统下的init程序路径查找顺序固定为先/sbin/init再/etc/init最后/bin/init。如果这些路径都不存在内核会尝试进入ramfs执行/init。如果所有这些init程序都无法找到或无法执行会怎么样内核会打印严重错误信息然后直接panic。这就是你常看到的Kernel panic - not syncing: No working init found。还有一个相关知识点是root和rootwait。U-Boot传递给内核的bootargs里指定根设备rootwait表示内核需要等待根设备出现后再尝试挂载。RK3568平台从eMMC启动时通常需要rootwait因为MMC设备驱动和分区扫描需要时间如果没有这个参数内核可能在MMC设备还没完全注册时就尝试挂载结果失败。用户空间第一个进程执行后内核会释放掉提前配置的initramfs临时内存彻底切换到用户态环境。从这一刻开始内核启动流程就结束了。5.2 用ftrace和printk实测启动耗时做启动优化前必须有数据支撑不然只能靠猜。这里分享我用过的两种测量方式。方式一在内核命令行加上initcall_debug。它能打印每个initcall函数的名字和执行耗时。举一个实际输出的例子[ 0.112033] initcall platform_register0x0/0x30 returned 0 after 468 usecs如果只关心总耗时可以用time命令测量从上电到用户空间代码执行的时间或者在用户空间里加一个时间戳打印配合内核的banner时间戳差值估算。方式二使用ftrace对内核启动过程做函数级跟踪。将ftracefunction_graph传入内核启动完成后收集trace日志。这种方式能精确定位某个驱动里哪一步耗时最长但缺点是trace日志非常大对启动时间本身有影响适合在调试阶段使用不适合做最终启动性能验证。在RK3568上做实测时我习惯把启动过程分成三段U-Boot阶段、内核早期阶段、驱动初始化阶段。前两段时间通常比较固定真正可优化空间最大的是驱动初始化阶段很多平台的项目里驱动probe全部执行完需要几百毫秒而U-Boot只需要几十到一百多毫秒。降低initcall数量、把不需要的驱动编译成模块、精简设备树节点都能显著减少这段耗时。5.3 优化启动时间的方向启动优化的核心思路是减少必须要做的事情。具体到RK3568平台我总结为五个方向裁剪内核配置关闭不需要的驱动和子系统减少initcall数量。精简设备树移除不用的外设节点比如板子上没接的PCIe、USB、HDMI等。使用模块化驱动把非关键驱动编译成可加载模块推迟到用户空间再加载。优化存储访问eMMC的读写模式、频率、DDR带宽分配都会影响镜像加载时间。控制台降频调试完成后降低串口控制台波特率或直接关闭也能省下少量时间。其中最常见的误区是开启内核并行初始化。内核的async_probe机制可以让部分设备同步probe但如果设备之间存在依赖关系异步化反而会导致启动失败。我建议只在确认无依赖的节点上使用例如两个独立的I2C触摸控制器。比较激进的做法是把整个device级别的initcall异步化但风险确实很高实际项目中我一般不会这样做除非对目标平台的内核和硬件已经非常熟悉。6. 常见故障与定位手段6.1 日志断点对照表调试RK3568启动问题我最依赖的就是日志输出。日志断在哪里问题通常就在哪里。下面这张表是根据我的实际排障经验整理的适用于绝大多数ARM64 Linux平台RK3568也不例外。日志特征故障方向常见原因完全没有输出早期汇编阶段或console配置earlycon未启用、串口引脚复用不对、启动介质未找到停在Starting kernel ...U-Boot跳转阶段内核镜像损坏、加载地址不对、DTS传给内核的地址错误打印少量早期汇编信息后止步MMU或设备树早期阶段内核TEXT_OFFSET问题、设备树非法、内存保留区重叠Unable to handle kernel NULL pointer设备树解析或driver probe设备树节点引用空指针、节点属性缺失Failed to get clock时钟框架时钟节点重复、父子时钟注册顺序错误、时钟名不匹配停在Waiting for root device根文件系统分区表不对、MMC驱动未注册、root参数错误Kernel panic - not syncing: No working init foundinitramfs或init程序文件系统损坏、init路径缺失、init程序权限不对这张表只能作为切入点真正定位还是需要看完整日志。我遇到过不止一次问题其实出在前一个阶段但报错在后一个阶段。比如设备树里的一个错误引脚配置看起来是pinctrl子系统的问题实际是设备树根节点里某些属性写错了。6.2 一次earlycon下的卡死定位举个具体例子。有一次RK3568板卡启动时日志停在设备树解析阶段没有任何panic信息CPU像是被冻住了。我立刻在U-Boot阶段把内核命令行加上earlyconuart8250,mmio32,0xFE660000和initcall_debug重新启动后多打出了几行日志但还是在某个initcall里停住。于是我再次确认设备树中该节点的寄存器地址映射。这时发现设备树里声明的某个外设寄存器区域与RK3568的保留内存区域存在重叠。内核在映射该驱动所需的寄存器地址时会覆盖掉固件占用的SRAM区域导致后续执行卡死。解决这个问题的办法是检查设备树里的reserved-memory节点为固件区域增加保护同时调整外设驱动在设备树中的reg属性使其不与保留区重叠。经过修改后内核可以顺利通过该initcall阶段。这类问题在平台开发初期出现的频率很高特别是当你不清楚SoC的固件占用布局时很容易踩到。这个例子说明启动问题不一定只发生在代码逻辑上硬件资源布局同样会影响启动流程。越是底层的问题越需要同时从设备树、内核配置和内存布局几个角度综合分析。做RK3568平台开发这段时间我最深刻的体会是启动流程不是一个你可以囫囵吞枣带过的知识点它决定着你后续调试驱动的效率。内核把启动过程划分成清晰的阶段每个阶段都有明确的日志特征和依赖关系只要把这条链路摸熟遇到问题就不会两眼一抹黑。建议新手先在板卡上实际跑一遍打开initcall_debug对照源码一行行看启动日志每个阶段都弄清楚在做些什么这样积累的经验比单纯看文档牢固得多。后面如果有机会我可以继续聊聊RK3568的U-Boot定制和根文件系统制作那又是一个新话题了。