
1. 为什么嵌入式开发总要啃U-Boot启动流程做嵌入式Linux开发的人几乎都绕不开U-Boot。它不是操作系统却决定了你的内核能不能跑起来、DDR初始化对不对、启动参数传没传对。很多新手卡在U-Boot到底是怎么跑起来的这个问题上有经验的工程师也常常说把U-Boot启动流程理解了整个嵌入式底层开发就算入门了。这句话我是认同的因为U-Boot的启动链路几乎涵盖了嵌入式底层开发的全部核心技能异常向量、ARM处理器模式切换、MMU与Cache控制、DDR初始化、栈建立、BSS清零、重定位、C语言运行时环境构建。说白了它就是一份活生生的ARM启动代码教材。我当年刚接触这块时也干过直接copy板卡配置、然后对着编译错误发呆的事。真正让我把整个体系串起来的还是静下心来逐行读_start那段汇编代码。这篇文章我就按照自己当初的阅读路径从_start汇编入口带你一步步走进U-Boot的C语言世界。你不需要预先掌握多少汇编知识只要有基本的ARM概念跟着走一遍启动流程的大框架就能立起来。2. _start入口复位向量与异常向量表的底层逻辑2.1 为什么一切从_start开始U-Boot编译链接之后生成的u-boot.bin文件有一个链接地址例如很多ARM平台把它放在0x87800000或者0xC0000000。CPU上电或者复位之后第一条取指地址由硬件决定通常是0x00000000或者SoC内部ROM指定的启动地址。要保证第一条指令落在U-Boot的代码里就要通过链接脚本把入口点指到_start标号。你可以在u-boot根目录下的u-boot.lds或者arch/arm/cpu/armv7/u-boot.lds等具体路径因平台而异中看到类似这样的定义OUTPUT_FORMAT(elf32-littlearm, elf32-littlearm, elf32-littlearm) OUTPUT_ARCH(arm) ENTRY(_start) SECTIONS { . 0x00000000; . ALIGN(4); .text : { *(.__image_copy_start) *(.vectors) CPUDIR/start.o (.text*) *(.text*) } }关键就是这个ENTRY(_start)它告诉链接器U-Boot的入口是_start标号。而*(.vectors)把异常向量表也放到了最前面。ARM处理器复位后会直接跳转到0地址或映射地址读取第一条指令执行所以_start就必须是一个可以正确初始化处理器的入口而不是任意的普通函数。2.2 异常向量表一张跳转扑克牌ARM架构要求内存最前面的32字节在armv7中是4字节乘8个异常向量放一张异常向量表。U-Boot在start.S里直接用汇编代码写出来了比如armv7的start.S_start: #ifdef CONFIG_USE_IRQ ldr pc, _undefined_instruction ldr pc, _software_interrupt ldr pc, _prefetch_abort ldr pc, _data_abort ldr pc, _not_used ldr pc, _irq ldr pc, _fiq #else b reset ldr pc, _undefined_instruction ldr pc, _software_interrupt ldr pc, _prefetch_abort ldr pc, _data_abort ldr pc, _not_used ldr pc, _irq ldr pc, _fiq #endif这段代码做了几件事如果配置了中断CONFIG_USE_IRQ每个向量位置用ldr pc, 标号直接加载跳转地址如果没有配置中断第一个向量位置用b reset后面的异常向量依然加载地址。为什么第一个入口用b reset而不是ldr pc, _reset因为b是相对跳转指令跳转范围受限但位置无关在最早期可以不用依赖绝对地址比较保险。而ldr pc, 某个地址则需要把目标地址存放在某个位置如果那段内存还没准备好就会出问题。2.3 reset入口的设置CPU模式SVC模式的讲究从_start跳转到reset标号之后Linux平台和很多其它平台的U-Boot都会在reset代码里做同样的事切换CPU模式、关中断、关MMU。reset: /* * set the cpu to SVC32 mode */ mrs r0, cpsr bic r0, r0, #0x1f orr r0, r0, #0xd3 msr cpsr, r0这行代码的意图是把CPSR的模式位改成SVC模式同时屏蔽IRQ和FIQ中断。0xd3对应的二进制是1101 0011其中bit[4:0]10011是SVC模式bit71表示禁止IRQbit61表示禁止FIQ。还有一路是清掉I bit、F bit但这里的0xd3明显是把中断关了。我用一个不太严谨但很好记的比喻ARM处理器模式就像你在公司里的不同角色用户模式是普通员工SVC模式是高权限的管理员。U-Boot在启动早期就必须切到SVC模式因为后期要配置MMU、访问协处理器这些操作在用户模式下是特权指令根本执行不了。这里的经验是不同平台可能用不同的模式切换方式但目的一样。如果你做的是Cortex-M系列可能看不到这段代码而是直接进Handler模式。所以读_start时要先确认自己的ARM产品家族。3. 汇编阶段的硬件大扫除关MMU、清TLB、关Cache3.1 为什么一上电就要关MMU和Cache很多刚接触的人会问MMU不是用来做虚拟内存映射的吗功能这么好为什么要关掉原因很简单上电复位那一刻MMU和Cache的状态是不确定的。尤其在被引导加载程序接管之前我们根本不知道上一段代码把MMU和页表配置成什么样子。DDR可能还没初始化页表可能存放在一个无效地址上此时开着MMU就相当于蒙着眼睛走路。U-Boot的处理方式是先统一把MMU关掉把指令Cache和数据Cache也关掉等后面DDR初始化完成、页表建好、进入C语言重定位流程之后再在合适时机开启MMU和Cache。整个过程是可控的、一步一步来的。在start.S里常见的代码片段如下armv7/* * disable MMU and caches */ mrc p15, 0, r0, c1, c0, 0 bic r0, r0, #0x0005 mcr p15, 0, r0, c1, c0, 0这里是对协处理器CP15的SCTLR寄存器System Control Register系统控制寄存器操作。bit0是MMU使能位bit2是数据Cache使能位bic掉0x0005就同时关闭了MMU和D-Cache。还可能加上指令Cache、分支预测等位。这里不同ARM版本位定义略有不同但是大思路一样。3.2 清理TLB别让旧地址映射拖后腿TLBTranslation Lookaside Buffer快表是MMU用来缓存最近使用的地址转换结果的一块硬件。如果MMU之前被用过TLB里可能残留一些地址转换条目。直接关掉MMU再开或者重新开MMU的时候这些残留条目会让新的映射表失效。稳妥的做法是使用CP15的TLBIALL指令无效化整个TLBmcr p15, 0, r0, c8, c7, 0 invalidate TLB在U-Boot早期汇编阶段清理TLB这个动作不是必须重复多次但在从ROM代码跳转过来的场景里强烈建议做一次。我之前遇到过一块板子启动时偶尔出现莫名其妙的地址错乱最后发现是TSPTightly-Coupled Memory紧耦合内存引导ROM代码留下的TLB缓存干扰。后来在_start里强制刷新TLB问题消失了。3.3 关中断的另一个隐藏原因异常向量表还没准备好除了模式切换时关中断这里还有一个隐含原因异常向量表的重定向问题。默认情况下ARM在复位后把异常向量表放在0x00000000位置或特定平台的固定位置。但U-Boot在链接时可能把自己的镜像放在DDR高位地址。也就是说向量表要么还没拷贝到DDR要么还没设置VBARVector Base Address Register向量基地址寄存器指向新位置。在这个过渡期任何中断或异常进来CPU都会去找一个位置不对的向量表然后执行错误的处理流程。所以必须关掉所有中断直到后面代码安全地建立了新的向量表和中断处理机制。4. 从CPU到板级低级初始化里那些必须提前干完的事4.1 时钟初始化为什么不能拖很多人误以为时钟初始化是在C语言阶段的board_init_f里做的。其实在部分平台中U-Boot在汇编阶段就会调用一个叫lowlevel_init或者cpu_init_cp15、s_init等的函数做最基础的CPU初始化。时钟初始化也可能在这里完成取决于厂商的移植方式。时钟为什么要这么早做因为DDR控制器需要时钟才能工作UART外设需要时钟才能输出打印信息定时器需要时钟才能计算延时。整个系统都依靠PLLPhase-Locked Loop锁相环把外部低频晶振倍频到高频总线频率。如果时钟没配好后面DDR参数配置就无从谈起。常见的路径在arch/arm/cpu/armv7/start.S中是这样的bl lowlevel_initlowlevel_init在板级目录里比如board/xxx/xxx/lowlevel_init.S里面通常会设置PLL、初始化DDR控制器以及其它必须提前就绪的硬件。4.2 DDR初始化是通往C世界的先决条件这一点值得单独强调大部分U-Boot流程里DDR初始化发生在汇编阶段或者在进入C后board_init_f早期完成。为什么不能等到C语言阶段再慢慢配置因为C语言运行需要栈指针SP、需要局部变量放在内存里、需要全局变量放在内存里。而一开始SP的值可能是未知的或者说只适配内部SRAM如果DDR没有初始化程序规模一大就无处安放数据。因此在跳转C之前必须保证DDR可用。在armv7平台上低级初始化流程大致是根据SoC的PLL配置寄存器设置CPU频率、AXI/AHB/APB总线频率根据DDR芯片的规格容量、位宽、时序参数配置DDR控制器执行DDR控制器的训练/校准流程等待DDR就绪后把栈指针设置到DDR空间的安全位置。这里最容易踩的坑是DDR时序参数填错。比如把CAS延迟Column Address Strobe latency配大了系统能跑但是性能低配小了数据不稳定会出现随机死机、内存校验失败。我在调试一块四层板的时候遇到DDR初始化偶尔失败后来发现是板子走线长度差异大需要调整DDR控制器里的时序补偿寄存器而不是单纯改主时钟。4.3 串口初始化为什么有些平台会提前开有一个比较有意思的细节早期U-Boot在汇编阶段可能就已经把UART初始化好了甚至在lowlevel_init里打印一个字符。这是为了方便调试——如果DDR初始化失败、代码卡死至少能通过串口输出判断卡在哪一步。如果UART没有提前初始化那么后续一旦DDR代码出了问题你只能干瞪眼用示波器看波形、用JTAG调试器去查PC指针效率低很多。所以如果你的板卡支持我建议在lowlevel_init阶段就初始化UART并且打印一个U-Boot starting...之类的字符。这样启动失败时你至少能知道自己走到了哪一层。这不算是标准流程的必须但却是实际调试里非常管用的一个技巧。5. 栈的建立与BSS段清零踏进C世界之前的两张入场券5.1 栈不是想设哪就设哪要避开镜像和DDR空洞跳进C语言世界最核心的不是调用一个函数而是这个函数执行时能有自己的局部变量。局部变量放在栈上。ARM的ATPCS规则ARM-Thumb Procedure Call StandardARM过程调用标准规定栈是向下生长的SP寄存器指向栈顶地址push时减小SP、pop时增大SP。在start.S中你会看到类似这样的代码ldr r0, _TEXT_BASE ldr sp, _TEXT_BASE sub sp, sp, #CONFIG_SYS_MALLOC_F_LEN这里的_TEXT_BASE是U-Boot链接是的代码基地址比如0x87800000。把SP设置到代码基地址上再减去一段预留空间CONFIG_SYS_MALLOC_F_LEN是早期malloc区域长度就得到了可行的栈起点。为什么要减一段空间因为栈向下生长预留出的这一小段空间可以放早期malloc数据不会和函数调用栈冲突。一个关键点是栈地址必须避免与代码段、数据段重叠。如果U-Boot镜像在0x87800000DDR实际空间到0x8FFFFFFF那么栈放在0x87800000之上、镜像末尾之下都行。但如果你不小心把栈设到了镜像中间镜像被覆盖程序会随机跑飞。我建议初学者在分析时把自己板卡的DDR地址范围画出来再把U-Boot镜像的加载地址和链接地址标上然后核对栈的初值是否落在合理区间。这一步虽然基础却可以帮你避免后面很多奇怪的疑难杂症。5.2 BSS段清0C语言全局变量默认值是0的前提C语言里未初始化或者初始化为0的全局变量被放在BSS段。在C语言标准里它们的初始值是0。但这个语义对硬件来说不是天然成立的——SRAM或DDR上电后的内容是随机的。所以U-Boot必须在跳进C之前手动把BSS段清零。start.S里有一段经典写法ldr r0, _bss_start ldr r1, _bss_end mov r2, #0 clear_bss: cmp r0, r1 bhs clear_bss_done str r2, [r0], #4 b clear_bss clear_bss_done:_bss_start和_bss_end这两个符号来自链接脚本分别表示BSS段的起始和结束地址。代码逐字地把这段内存写成0。有些平台会用bss_start和bss_end名字大同小异功能一样。为什么这是一张入场券因为一旦你跳过BSS清零直接进C所有未初始化全局变量都是随机值。后续比如某个中断处理函数检查g_irq_counter它可能是一个巨大的随机数直接导致逻辑错误。这类问题一旦出现定位起来非常痛苦因为它不是必现的和你操作板的时序有关。所以一定不要省略这个步骤。5.3 可选的early stack与大栈的切换U-Boot在早期阶段会使用一个小栈通常建立在内部SRAM或者DDR前段的安全区域。这个栈足够支撑board_init_f等早期C函数使用。等完整重定位之后栈会切到DDR上的最终栈地址。有的平台在start.S里会先设置一个临时SP然后进C代码之后再通过relocate_code等函数更新SP。我当年在读代码时就困惑过既然最终都要用DDR内存当栈为什么不在_start里一次配好现在理解了因为部分平台在早期连DDR都还没初始化只能用SRAM做栈而DDR初始化完成后栈再迁移过去。不同平台的做法反映了其硬件初始化的不同时序约束。6. 从汇编到C的临界点start_armboot与board_init_f的调用背后6.1 汇编函数跳转C函数的ABI规则r0到r3传递参数在start.S的尾部有一段典型的跳转代码bl board_init_f这条指令执行之前你可以看到通常会有对r0、r1参数的赋值比如传递DDR起始地址或重定位标志。ARM的ABI规定前4个参数分别由r0、r1、r2、r3传递超过4个的部分用栈传递。所以bl board_init_f之前必须确保r0-r3中的值符合函数签名要求。跳转指令bl做了两件事先把下一条指令的地址保存到链接寄存器r14LR然后修改PC跳转到目标函数。当C函数要返回时它会把LR里的值恢复给PC。这也是汇编和C交汇时需要特别注意的地方。如果汇编阶段没有维护好LR或者中间调用了子函数导致LR被覆盖那C函数的返回就会跑飞到不知道哪里去。所以U-Boot在初始化阶段也有一句mov lr, #0它会先把LR清零避免随机值干扰。但因为board_init_f一般不返回这个清零操作更多是无害的保险。6.2 board_init_f究竟做了什么启动初期的一站式调度进入C世界后第一个重量级函数就是board_init_f。它通常在common/board_f.c名字里的f代表init_f意思是在重定位之前的初始化阶段。board_init_f的主要任务是把U-Boot镜像里需要用到的内存布局计算出来初始化早期串口打印信息初始化DRAM如果在汇编阶段没有做完整设置栈顶、堆、malloc区域、全局数据区gd等为后续重定位做准备计算U-Boot镜像要拷贝到哪个地址。在common/board_f.c里有一个init_sequence_f数组它把board_init_f内部要执行的一长串初始化函数按顺序排列static init_fnc_t init_sequence_f[] { setup_machine, reserve_global_data, ... initf_dram, ... setup_dest_addr, reserve_uboot, reserve_malloc, reserve_board, setup_machine, ... init_func_ram, ... initf_console_record, ... announce_early_init, ... };这个数组在U-Boot的不同版本里略有不同但思路完全一致用一个函数指针数组表达初始化步骤板级适配可以通过重载或条件编译来裁剪。其中gdglobal_data结构体非常关键它是U-Boot早期所有全局信息的载体——DDR起始地址与大小、栈位置、重定位地址、串口配置、环境变量位置等。setup_global_data会把这个结构体放在一个早期安全的内存位置。6.3 gd结构体启动早期阶段的全局笔记本如果你读代码看到到处都是gd-xxx不用慌它就是一个精简的全局信息结构。它在include/asm-generic/global_data.h里定义。为什么要用gd而不是普通的全局变量因为早期代码运行在位置未定或者RAM未全部初始化的状态下普通全局变量在BSS段可能不可用而gd可以放在一个固定的、提前确定的地址上。gd的地址通常通过寄存器传递。在ARM平台U-Boot会把gd地址放在r9寄存器里。所以你会看到很多汇编或内联汇编里专门维护r9不被其它代码随意使用。跳进C后约定俗成地使用gd来实现对它成员的访问。如果你在自己开发的汇编代码里碰了r9就会突然出现gd数据错乱的问题这是新手比较容易踩的一个暗坑。6.4 重定位把自己搬到内存高处再继续跑U-Boot早期可能从NOR Flash、SD卡或内部SRAM启动这些地方的代码执行效率低或者空间有限。启动流程会在初始化完DDR、准备好内存布局之后把自己整个镜像拷贝到DDR的更高地址运行。这个动作就是重定位relocation。在board_init_f的最后通常计算好了需要拷贝的地址然后调用汇编函数relocate_code完成搬运。搬运之后还要更新栈指针、更新gd地址、跳转到新地址继续执行board_init_r。这一步骤为什么重要因为U-Boot的一个核心能力是把自己变成一个可以被内核覆盖的引导加载程序。它不能一直占着DDR的底部否则Linux内核加载时无处落脚。所以U-Boot主动把自己放到内存的顶端区域通常是一个比较高的地址把底端空出来给内核和设备树。重定位代码通常也在start.S里比如典型的relocate_code: ldr r1, _TEXT_BASE ... copy_loop: ldmia r0!, {r3-r10} stmia r1!, {r3-r10} ...它的核心就是一段高效的循环拷贝同时考虑了cache的flush确保指令和数据的可见性正确。7. 常见启动异常排查我实际遇到过的三个卡住不说的问题7.1 卡在U-Boot SPL ...就不动了SPLSecondary Program Loader二级程序加载器是U-Boot的一个特殊构建目标用于一些对大小有严格限制的启动场景。它把DDR初始化提前然后再加载完整的U-Boot。如果你的板子打印了SPL头信息后就卡住多半是在SPL阶段做DDR初始化时出了问题。我调试过一块板子SPL能输出但之后串口无任何信息。用示波器测DDR时钟和数据线发现DQS信号质量很差。排查后发现板子Layout上DDR走线绕了两个过孔stub过长导致信号反射。修改PCB之后问题解决。想说的是遇到DDR相关的启动异常光堆代码没用往往要回到硬件层面做信号完整性分析。7.2 跳入C后立刻跑飞栈地址不对的经典症状如果你看到U-Boot打印了极少信息后崩溃而且PC指针跳到一个怪异的地址大概率是栈指针设置不当。我遇到过一种情况板卡DDR实际大小是512MB但配置成1GB而U-Boot把栈设置到了DDR不存在的地址。程序访问到空洞内存数据丢失后跑飞。这类问题排查方法是先确认__DDR_SIZE__或者CONFIG_NR_DRAM_BANKS、CONFIG_SYS_SDRAM_BASE等配置是否和板卡实际内存匹配。可以在board_init_f里临时加打印把gd-ram_base和ram_size打出来验证。7.3 从SD卡启动和从EMMC启动行为不一致常有道友问为什么我同一个U-BootSD卡启动正常、EMMC启动卡住这个问题往往不是代码逻辑问题而是EMMC初始化时序和SD不同或者EMMC的boot分区配置与U-Boot的读取逻辑不匹配。在这种场景下我会建议先把启动介质差异隔离出来。用JTAG调试器直接查看SPL阶段是否成功读取了后续代码再检查EMMC的RST_n信号、CLK频率和电压切换顺序。很多时候EMMC无法启动是因为没有正确切换电压信号3.3V切到1.8V这是硬件和驱动配合的经典问题。8. 我读start.S多年后总结的几条实用经验如果非要给这份启动源码分析做个经验提炼我觉得比较值得记住的有这几点。第一读汇编不要逐行死抠先把大致的路标找出来。每一段汇编都对应一个明确的硬件目标设模式、关MMU、清BSS、设SP、调函数。把路标之间的关系理顺了再钻细节。第二利用U-Boot的打印信息作为你的定位工具。U-Boot启动过程里有很多可供开启的调试宏比如CONFIG_DEBUG_UART、CONFIG_DEBUG_UART_BASE等。在汇编阶段也可以直接读写UART寄存器输出字符这是最朴素的调试手段。第三善用JTAG调试器看PC指针和SP的值。汇编阶段的寄存器状态没法用printk看但调试器可以直接读。遇到卡死、跑飞先看PC跑到哪了再反推前面的代码因为什么原因没有正确跳转。第四维护好r9gd指针和lr链接寄存器。在你自己写的汇编代码里一定要避免破坏这两个寄存器。否则就会出现底层代码改一行上层全乱的灵异问题。第五有条件的话多对比几个平台的start.S。ARMv7-A、ARMv8-A、Cortex-M这些家族的启动汇编各有差异。比如ARMv8使用el3_entry、el2_entry等把异常级别切换讲得更细。但底层逻辑是共通的——初始化CPU、初始化内存、建立运行时环境、转型给C代码。很多人会在看完启动流程后感慨就这么几百行汇编背后的硬件门道数十万字都写不完。这话不假但也不必被吓退。从一开始跟着_start的每一行指令确认它的意图再到把它跟硬件手册里对应的寄存器位对应起来这个过程本身就会让你的硬件功底上一个台阶。我到现在调试底层问题时还是习惯性地打开start.S从头过一遍。它就像一张地图每次走都能发现点之前没注意的细节。也希望这篇文章能成为你手里的第一版地图带着你从汇编稳稳地踏进那个属于U-Boot的C语言世界。下次你再听到同事说卡死在board_init_f至少心里能立刻有个方向了。