1. 从按下电源键到第一行代码理解U-Boot的“两段式”启动大家好我是老张在嵌入式这行摸爬滚打十几年了从早期的ARM9到现在的Cortex-A系列U-Boot几乎是每个项目的“开胃菜”。很多新手朋友一上来就对着几百K的U-Boot.bin文件发懵烧录进去发现系统没起来或者串口没输出问题往往就出在对启动流程的理解不够透彻。今天我就以经典的S5PV210芯片为例带大家把U-Boot从SRAM“搬家”到SDRAM这个过程掰开了揉碎了讲清楚。你不需要有汇编基础跟着我的思路结合代码保证你能看懂。首先我们得明白一个核心问题为什么U-Boot启动要分两段也就是常说的BL1和BL2这可不是工程师们故意搞复杂。想象一下你刚搬进一个毛坯房芯片上电里面空空如也连放张床运行完整U-Boot的地方都没有。这个毛坯房自带的只有一个小小的储物间片内SRAM比如S5PV210的96KB iRAM。你没法直接把所有家具完整的U-Boot可能几百KB都塞进去。怎么办聪明的办法是先派一个“先遣小队”BL1带着最基础的工具初始化代码进去把储物间收拾一下然后把外面的大客厅外接的SDRAM打扫干净、通上电。等大客厅可以用了再指挥“大部队”BL2也就是我们常编译的U-Boot.bin全部搬进大客厅舒舒服服地展开工作。这个“先遣小队”就是SPLSecondary Program Loader而“大部队”就是完整的U-Boot。所以启动流程本质上是受限于芯片硬件资源SRAM太小而设计的一种精巧的接力赛。2. 芯片上电后的“自动导航”iROM与BL1的加载当我们给S5PV210芯片上电或者按下复位键的瞬间CPU的第一条指令从哪里开始执行呢这并不是从我们写的U-Boot代码开始的。芯片内部有一块特殊的、出厂时就固化的只读存储器叫做iROMInternal ROM。这块iROM里的代码是芯片厂商比如三星预先烧录好的我们无法修改。它的作用就像一个“自动导航系统”负责完成最最基础的硬件初始化比如初始化时钟、看门狗然后根据我们预先设置的启动模式比如是从SD卡、NAND还是USB启动去指定的外部存储设备上寻找“先遣小队”BL1的代码。这个BL1代码通常就是我们编译U-Boot后通过特定工具比如mkimage生成的一个小体积二进制文件大小必须严格控制在芯片SRAM容量以内比如16KB或96KB。iROM程序会把这个BL1的镜像从SD卡等设备搬运到芯片内部的SRAMStatic RAM中。SRAM的特点是速度快不需要复杂的初始化上电就能用但容量小、成本高所以只适合存放启动初期的关键代码。搬运完成后CPU的程序计数器PC就会跳转到SRAM的起始地址正式开始执行我们写的U-Boot启动代码。这个过程是完全由硬件逻辑决定的我们软件工程师要做的就是确保我们生成的BL1镜像格式正确、大小合适并且放在启动设备的正确位置。3. 在“小房间”里干活BL1在SRAM中的关键任务BL1也就是SPL它的全部生命周期都在那片小小的SRAM里。它的任务繁重但目标明确为“大部队”进入“大客厅”扫清一切障碍。我们可以打开U-Boot源码中arch/arm/cpu/armv7/start.S这个文件这里就是一切开始的地方。代码开头是一段异常向量表.globl _start _start: 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这段汇编定义了几个关键的跳转指令。_start是链接器指定的入口地址对于BL1来说它通常被链接到SRAM的起始地址例如0xD002_0000。第一条指令b reset无条件跳转到reset标号这是正常启动的路径。下面几行则是各种异常比如中断、数据访问错误的处理入口。这里有个细节需要注意在CONFIG_SPL_BUILD定义时即编译BL1阶段这些异常向量的地址就是_undefined_instruction这样的标号自身而在编译BL2时它们指向的是undefined_instruction这样的C函数。这是因为BL1阶段内存环境极其简单可能连栈都没有完全设置好无法支持复杂的C函数调用所以异常处理可能只是一个死循环或者简单的复位。而BL2阶段环境已经建立可以用完整的C函数来处理异常。紧接着代码执行到reset标签。这里做的第一件事通常是设置CPU为SVC超级用户模式并关闭中断。为什么因为在启动初期系统状态一片混沌各种硬件寄存器都是默认值此时如果来了一个中断CPU根本不知道跳到哪里去执行处理程序大概率会导致“跑飞”。所以必须先关掉等一切准备就绪再打开。reset: bl save_boot_params mrs r0, cpsr bic r0, r0, #0x1f orr r0, r0, #0x13 orr r0, r0, #0xc0 msr cpsr, r0save_boot_params通常是一个弱符号定义的函数默认是空操作。有些平台会利用它从iROM传递一些启动参数给U-Boot。接下来的几条汇编指令操作的是CPSR当前程序状态寄存器。bic r0, r0, #0x1f清除低5位orr r0, r0, #0x13将其设置为0b10011即SVC模式。orr r0, r0, #0xc0则置位第6和第7位分别禁止FIQ和IRQ中断。3.1 初始化核心与关闭缓存设置完模式后BL1会进行一系列关键的底层初始化主要是通过操作CP15协处理器来完成。CP15是ARM架构中用于系统控制的协处理器管理着缓存Cache、内存管理单元MMU、异常向量表基址等核心功能。bl cpu_init_cp15我们跳转到cpu_init_cp15函数看看通常在同一文件或lowlevel_init.S中ENTRY(cpu_init_cp15) mov r0, #0 mcr p15, 0, r0, c8, c7, 0 使无效TLBs mcr p15, 0, r0, c7, c5, 0 使无效I-Cache mcr p15, 0, r0, c7, c5, 6 使无效分支预测数组 mcr p15, 0, r0, c7, c10, 4 DSB数据同步屏障 mcr p15, 0, r0, c7, c5, 4 ISB指令同步屏障 mrc p15, 0, r0, c1, c0, 0 bic r0, r0, #0x00002000 清除V位向量表在0x00000000 bic r0, r0, #0x00000007 清除CAM位关闭MMU关闭对齐检查关闭D-Cache orr r0, r0, #0x00000002 使能对齐检查 orr r0, r0, #0x00000800 使能分支预测 #ifndef CONFIG_SYS_ICACHE_OFF orr r0, r0, #0x00001000 使能I-Cache #endif mcr p15, 0, r0, c1, c0, 0 mov pc, lr ENDPROC(cpu_init_cp15)这段代码是启动初期的“安全操作”。首先它使无效了TLB转址旁路缓存用于虚拟地址转换和I-Cache指令缓存。为什么要关闭缓存因为在启动初期代码可能被搬运、内存内容会被修改比如我们正在初始化SDRAM控制器。如果缓存开着CPU可能从缓存中读到“过时”的旧指令或数据导致行为不可预测。所以最安全的做法就是先关掉。DSB和ISB是两条同步指令确保前面的操作在所有CPU流水线中都完成。接着它读取系统控制寄存器SCTLR, c1进行一系列配置清除第13位V位确保异常向量表基址在0x00000000清除低3位关闭MMU和D-Cache然后使能对齐检查方便排查一些内存访问错误和分支预测提升性能。最后根据配置决定是否打开I-Cache。在BL1阶段为了简单可靠我通常建议在配置中保留CONFIG_SYS_ICACHE_OFF即关闭I-Cache。3.2 设置临时栈与板级初始化CPU核心初始化完后需要有一个栈Stack才能调用C语言函数。汇编里可以玩“寄存器杂技”但C函数需要栈来保存局部变量、返回地址等。所以BL1要赶紧在SRAM里划出一小块地方作为临时栈。ldr sp, CONFIG_SYS_INIT_SP_ADDR bic sp, sp, #7CONFIG_SYS_INIT_SP_ADDR这个宏通常在板级配置头文件里定义指向SRAM中一段安全可用的地址。bic sp, sp, #7是为了确保栈指针是8字节对齐的这是ARM的ABI应用二进制接口要求不对齐可能会导致访问错误。有了栈BL1就可以愉快地调用C函数了。接下来会执行bl cpu_init_crit或直接调用board_init_f等函数。这些函数是板级相关的由芯片或开发板厂商提供主要完成以下几件大事初始化系统时钟PLLiROM可能只提供了一个很低的运行时钟为了后续高速运行需要配置锁相环将主频提升到几百MHz甚至更高。初始化内存控制器这是BL1阶段最核心、最关键的任务SDRAM如DDR2/DDR3不像SRAM它不是“上电即用”的。它需要一套复杂的初始化序列包括配置时序参数、进行内存训练ZQ校准等。这部分代码通常由厂商提供或者需要我们从芯片手册里一个个寄存器抠出来配置。配置成功后那片巨大的、可读可写的SDRAM空间比如256MB或512MB才对CPU可见。初始化必要的GPIO和串口为了后续调试通常会把串口初始化好这样就能通过串口打印信息告诉我们“我进行到哪一步了”。4. “乾坤大挪移”代码从SRAM重定位到SDRAM当SDRAM初始化成功的那一刻BL1的使命就完成了一大半。接下来它要执行启动流程中最精妙的一步自我复制与跳转也就是把“大部队”BL2从启动设备如SD卡搬运到SDRAM并跳转过去执行。这个过程在U-Boot里叫做“重定位”Relocation。我们来看看它是怎么做的。首先BL1需要知道两件事1. BL2完整U-Boot的镜像在哪里2. 要把它搬到SDRAM的哪个地址第一个问题的答案藏在启动设备的固定偏移处。以SD卡启动为例iROM加载了BL1到SRAM而BL2的镜像就紧接着存放在SD卡的某个块block之后。BL1的代码里会包含读取SD卡、NAND Flash等设备的驱动去把BL2镜像读出来。第二个问题的答案由链接脚本决定。当我们编译U-Boot时链接器ld会根据链接脚本如u-boot.lds将所有代码、数据段组合起来并指定程序的运行地址Load Address和链接地址Link Address。对于BL2它的链接地址通常就是SDRAM的一个高端地址例如0x34800000。这个地址在config.mk或板级配置头文件中通过CONFIG_SYS_TEXT_BASE定义。重定位的代码逻辑大致如下简化概念确定目标地址从链接脚本或配置中获取_start符号的链接地址也就是BL2希望自己运行在SDRAM中的地址记为dest_addr。确定源地址BL2镜像当前被加载到SRAM或其它临时缓冲区的地址记为src_addr。对于直接从存储设备拷贝的情况src_addr可能就是存储设备的缓冲区地址。计算镜像大小通过链接脚本提供的_image_copy_end和_image_copy_start等符号计算出需要拷贝的二进制段的大小。执行拷贝使用一个循环将数据从src_addr逐字word拷贝到dest_addr。这里必须用汇编或简单的C函数实现因为拷贝过程本身不能依赖已经被“搬动”的代码。设置向量表拷贝完成后需要将异常向量表的基址VBAR寄存器重新设置为SDRAM中的新地址确保发生异常时能跳转到正确的位置。长跳转最后通过一条绝对跳转指令如ldr pc, _start将程序计数器PC指向SDRAM中的新入口地址。从此CPU就在宽阔的SDRAM中驰骋了。这里有一个经典的“坑”在拷贝代码时如果拷贝的代码段包含了当前正在执行的那段拷贝循环自身会发生什么这需要非常小心地设计拷贝的方向从后往前拷贝或者确保执行流在拷贝完成前不会经过被覆盖的区域。U-Boot的relocate_code函数会处理好这些细节。5. 在新世界安家BL2在SDRAM中的环境建立成功跳转到SDRAM后BL2也就是我们熟悉的完整U-Boot开始它的表演。此时的它身处一片“广袤”的内存中但还有很多事情要做远没到可以加载内核的地步。首先它会重新初始化一些关键硬件。你可能会问BL1不是初始化过了吗是的但BL1的初始化可能是最简化的、不完整的或者某些设置如栈指针、内存映射是针对SRAM环境的。BL2需要根据最终运行环境进行更完整、更细致的初始化。例如重新设置栈指针到SDRAM中更宽敞的区域初始化更多的外设如网卡、USB、MMC控制器等。其次也是BL2阶段一个非常重要的工作建立完整的内存映射和启用MMU。在BL1阶段我们关闭了MMUCPU访问的是物理地址。但在现代操作系统中使用虚拟地址是标配它可以提供内存保护、进程隔离等好处。U-Boot作为引导loader有时也需要为内核准备一个初步的页表。启用MMU后CPU访问的地址需要经过转换这能提升系统安全性和灵活性。不过对于简单的移植你也可以选择在U-Boot阶段不开启MMU由Linux内核自己去开启。然后U-Boot会进行全局数据gd和板级信息bd结构的初始化。这两个数据结构是U-Boot的“信息中心”里面存放了内存大小、环境变量地址、当前波特率、IP地址等各种全局信息。它们通常被放在SDRAM中最早初始化好的区域。接下来就是重定位自身的数据段。是的你没看错代码搬过来了但一些初始化的数据.data段和未初始化的数据.bss段可能还在错误的位置。.bss段需要被清零.data段可能需要从只读的镜像区域拷贝到可读写的内存区域。这个过程由board_init_r函数中的relocate_vectors、mem_malloc_init等调用完成。最后U-Boot会初始化控制台、打印启动Logo、加载环境变量并进入那个我们最熟悉的界面U-Boot命令行。此时它已经是一个功能完备的“小系统”了可以接受你的命令去读写内存、操作Flash、设置启动参数或者最终执行bootm命令去加载并启动Linux内核。6. 实战避坑指南调试启动失败的常见场景理论讲完了我们来点实战中踩过的“坑”。理解流程的最大价值就是当系统“跑飞”、串口毫无动静时你知道该从哪里下手。场景一上电后毫无反应串口无任何输出。这是最让人头疼的情况。首先检查硬件电源、时钟、复位信号是否正常启动模式引脚Boot Mode设置是否正确如果硬件无误问题很可能出在BL1。我的排查步骤是确认BL1镜像是否正确生成和烧录使用objdump工具反汇编你的SPL即BL1镜像看看入口地址_start是否正确代码段是否在SRAM地址范围内。使用仿真器如J-Link进行单步调试这是最强大的武器。将仿真器连接到芯片的JTAG/SWD接口在_start处设断点看PC能否停住。如果停不住可能是iROM加载BL1就失败了检查镜像头格式和烧录位置。如果能停住单步执行观察在关闭缓存、初始化时钟、初始化SDRAM控制器这几步中是否在某一步之后“跑飞”。通常SDRAM控制器配置错误是最常见的原因一个时序参数设错就可能导致后续所有内存访问失败。点灯大法如果手头没有仿真器就在关键代码位置插入“点灯”操作。比如在reset标签后、在cpu_init_cp15返回后、在SDRAM初始化函数调用前后控制一个GPIO引脚输出高低电平。用示波器或肉眼观察LED的闪烁顺序就能定位程序死在哪里。场景二串口打印出乱码或者打印几行后停止。这说明BL1已经成功运行并且串口初始化基本正确但可能波特率计算有误或者程序在后续阶段崩溃。检查波特率确认你的串口终端软件如SecureCRT、minicom设置的波特率与代码中初始化串口的波特率是否完全一致。计算波特率的时钟源如PLL输出是否已经正确配置。关注最后打印的信息U-Boot的串口输出函数putc在重定位前后可能指向不同的内存地址一个是SRAM中的临时缓冲区一个是SDRAM中的最终地址。如果在切换过程中出错就会打印乱码或停止。检查重定位相关的代码特别是gd结构体中relocaddr等字段的计算是否正确。检查栈溢出在SRAM中栈空间非常有限。如果你在BL1的C函数中定义了很大的局部数组或者递归调用太深很容易导致栈破坏覆盖掉相邻的代码或数据引发不可预知的崩溃。优化代码减少栈的使用。场景三U-Boot能正常进入命令行但bootm加载内核时失败。这通常说明U-Boot自身运行良好但为内核准备的环境出了问题。检查内核加载地址bootm命令后面跟的地址必须是内核镜像在内存中的正确位置。使用fatload或tftp命令加载内核后用md命令查看一下该地址的数据确认镜像头如ARM的zImage头部是否正确。检查设备树DTB现在的内核普遍使用设备树。确保你加载了正确的.dtb文件到正确地址并且通过bootm 内核地址 - 设备树地址这样的格式传递。设备树地址不对内核会无法识别硬件。检查内存传递ATAGs对于旧版内核U-Boot需要通过ATAGs向内核传递内存大小等信息。确认gd-bd-bi_dram数组是否正确填充了SDRAM的起始地址和大小。内核如果拿到错误的内存信息启动时就会访问非法内存而崩溃。启动流程的调试是个细致活需要结合芯片手册、原理图、示波器和调试器。每解决一个问题你对系统的理解就会加深一层。我最开始移植U-Boot到一块新板子时花了整整两周才让串口打出第一个字符那种成就感是无与伦比的。记住耐心和系统性的排查方法是你的最佳伙伴。希望这篇从SRAM到SDRAM的代码级漫游能帮你建立起清晰的U-Boot启动脉络在下次遇到启动问题时能更快地直击要害。