☰
深入uboot第一阶段源码:ARM启动流程与内存初始化实战
2026/10/12 13:23:43 网站建设 项目流程

1. 为什么还要啃uboot第一阶段源码

很多人学嵌入式Linux,到了uboot这一关就开始犯怵。网上的教程一抓一大把,但大多数停留在"编译、烧录、能跑就行"的层面,真正翻开源码一行行读下去的少之又少。我当初也是这么过来的——板子能启动,串口有输出,就觉得万事大吉。直到有一次遇到一个奇怪的启动问题:板子上电后串口一片死寂,连第一行打印都没有,这时候才发现,如果不懂uboot第一阶段到底干了什么,你连从哪儿下手排查都不知道。

uboot的启动流程分为两个阶段,这是设计上的刻意为之。第一阶段(Stage 1)通常用汇编编写,代码量不大,但干的都是"性命攸关"的事:设置CPU运行模式、关闭看门狗、初始化时钟和内存、准备C语言运行环境,最后跳转到第二阶段的C入口。这一阶段之所以用汇编,是因为此时内存还没初始化好,栈都没地方放,C语言根本跑不起来。你可以把它理解成盖房子之前打地基——地基没打好,上面盖什么都是白搭。

这篇文章面向的是有一定ARM体系结构基础、正在或者准备深入uboot源码的嵌入式开发者。我会以常见的ARMv7平台(比如Cortex-A系列)为背景,把uboot第一阶段的核心源码逐段拆开来讲,不光告诉你"这行代码干了什么",更要讲清楚"为什么要这么干"以及"不这么干会怎样"。文章里涉及的具体文件路径和函数名,不同版本的uboot会有差异,但核心逻辑是相通的,你对照自己手头的源码看就行。

需要提前说明的是,uboot版本众多,从早期的2010.x到现在的2024.x,目录结构和代码组织方式变化不小。我这里分析的是比较经典的架构,源码主要位于arch/arm/cpu/目录下的启动文件以及arch/arm/lib/中的相关汇编。如果你用的是厂商定制版uboot(比如某些芯片原厂提供的SDK),代码可能被改得面目全非,但主干逻辑不会变。

2. 第一阶段到底在干什么:整体设计思路拆解

2.1 两个阶段的划分逻辑

uboot把启动分成两个阶段,这不是拍脑袋决定的,而是被硬件现实逼出来的。上电那一刻,CPU刚从复位向量跳出来,DRAM控制器还没配置,内存完全不可用。这时候你能用的只有CPU内部的SRAM(通常几十KB到几百KB)和寄存器。C语言函数调用需要栈,全局变量需要可读写的数据段,这些都得有内存支撑。所以在内存初始化完成之前,只能用汇编小心翼翼地操作寄存器和SRAM。

第一阶段的使命很明确:用最少的代码,把系统从"裸机"状态带到"能跑C语言"的状态。具体来说,它要完成这几件事:

  • 进入SVC模式,关中断,让CPU处于一个可控的状态
  • 关闭看门狗,防止在初始化过程中被复位
  • 关闭MMU和Cache(或者做必要的Cache配置),保证内存访问的一致性
  • 配置时钟系统,让CPU和总线跑在合适的频率上
  • 初始化DRAM控制器,把内存"唤醒"
  • 设置栈指针,为C语言准备运行环境
  • 清零BSS段,拷贝代码到内存(如果需要)
  • 跳转到第二阶段的C入口board_init_f

这些步骤的顺序不能乱。比如你必须先关看门狗再去做耗时的内存初始化,否则看门狗超时直接把系统复位了;你必须先配置好时钟再初始化内存,因为DRAM控制器的时序参数依赖于时钟频率。

2.2 链接地址与运行地址的分离设计

第一阶段有一个非常关键的设计概念:链接地址(link address)和运行地址(run address)可能不一致。链接地址是编译时指定的,代码里的标号、跳转指令都基于这个地址生成。运行地址是代码实际被加载到的地方。

为什么会有这个差异?因为uboot可能从NOR Flash、NAND Flash、SD卡、SPI Flash等不同介质启动。有些介质支持XIP(就地执行),代码可以直接在Flash里跑;有些介质不支持,必须先把代码搬到RAM里。而RAM的地址和Flash的地址显然不一样。

uboot用CONFIG_SYS_TEXT_BASE来定义链接地址,用位置无关代码(PIC)或者重定位机制来处理这个差异。在ARM平台上,第一阶段早期通常使用相对跳转(b、bl指令)而不是绝对跳转(ldr pc, =label),因为相对跳转的偏移量是固定的,不管代码在哪里跑都没问题。等到需要访问绝对地址的数据时,才通过adr、ldr等指令配合_TEXT_BASE来计算实际地址。

这个设计的好处是灵活性极高,同一份uboot代码可以适配多种启动介质。代价是代码可读性下降,初学者看到ldr r0, _TEXT_BASE这种写法容易懵。我的建议是,读源码时先在脑子里明确"当前代码在哪里运行",很多困惑就迎刃而解了。

2.3 汇编与C的分界线

第一阶段的终点是_main函数(在arch/arm/lib/crt0.S中),这个函数虽然还是汇编,但它已经开始为C语言做最后的准备了。_main执行完毕后,会调用board_init_f,这才是第二阶段的C入口。

这条分界线不是随便画的。在_main之前,栈还没设置好,你没法调用C函数;在_main之后,栈有了,BSS清零了,C语言可以正常跑了。所以_main是一个"过渡层",它自己用汇编写,但为C语言铺好了路。

理解这条分界线很重要,因为它决定了你调试时的策略。如果问题出在_main之前,你只能用汇编级调试手段(比如点灯、JTAG单步);如果问题出在_main之后,你可以用printf、用GDB,排查效率高得多。

3. 核心源码逐段拆解与实操要点

3.1 复位向量与异常向量表

CPU上电后,第一条指令从哪里取?这取决于芯片的启动模式。ARM处理器通常有一个固定的复位向量地址,比如0x00000000或者0xFFFF0000,具体由芯片设计决定。uboot的入口点通过链接脚本控制,在arch/arm/cpu/目录下的u-boot.lds中,你会看到类似这样的定义:

ENTRY(_start) SECTIONS { . = 0x00000000; . = ALIGN(4); .text : { *(.__image_copy_start) *(.vectors) arch/arm/cpu/armv7/start.o (.text*) *(.text*) } }

注意*(.vectors)这一行,它把异常向量表放在了最前面。异常向量表是ARM架构的硬性要求,复位、未定义指令、软中断、预取中止、数据中止、IRQ、FIQ这七种异常各有固定的入口地址,间隔通常是4字节(ARM状态)或4字(Thumb状态)。

uboot的向量表定义在arch/arm/lib/vectors.S中,大致长这样:

_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

第一行b reset就是复位后执行的第一条指令,跳转到reset标号。这里用b而不是ldr pc, =reset,是因为b是相对跳转,不依赖绝对地址,在重定位之前也能正确执行。

注意:有些芯片的复位向量不在0x00000000,比如有些芯片上电后从0xFFFF0000取指令。这种情况下,uboot的链接脚本和向量表位置都要相应调整。如果你移植uboot时发现第一条指令就跑飞了,先确认芯片手册里的复位向量地址。

3.2 reset标号:进入SVC模式与关中断

reset标号在arch/arm/cpu/armv7/start.S中,这是第一阶段真正开始干活的地方。开头几行通常是这样的:

reset: /* 允许访问CP15协处理器 */ mrc p15, 0, r0, c1, c0, 0 bic r0, r0, #0x00002000 /* 清除V位,异常向量表基址设为0x00000000 */ bic r0, r0, #0x00000007 /* 清除I位和F位,关闭IRQ和FIQ */ mcr p15, 0, r0, c1, c0, 0 /* 进入SVC模式 */ mrs r0, cpsr bic r0, r0, #0x1f orr r0, r0, #0x13 msr cpsr, r0

这几行代码干了三件事:设置异常向量表基址、关闭中断、切换到SVC模式。

先说异常向量表基址。CP15的c1寄存器(系统控制寄存器)有一个V位(bit 13),它决定异常向量表是在0x00000000还是0xFFFF0000。uboot通常把它清0,让向量表位于低地址。这个设置要和链接脚本里的地址对应,否则中断来了会跳到错误的地方。

再说关中断。CPSR的I位(bit 7)控制IRQ,F位(bit 6)控制FIQ。启动阶段关中断是必须的,因为此时中断向量表可能还没完全准备好,中断服务程序也不存在,一旦来中断就是死路一条。等第二阶段初始化完中断控制器,再按需打开。

最后是模式切换。ARM有七种运行模式,上电后默认是什么模式取决于芯片设计,但uboot需要SVC模式(管理模式),因为只有SVC模式能访问所有特权寄存器。CPSR的低5位(M[4:0])定义当前模式,0x13就是SVC模式。

实操心得:如果你在调试时发现uboot跑着跑着就进了未定义指令异常,先检查CPSR的模式位对不对。有些芯片上电后默认在Hyp模式或者Monitor模式,不切换到SVC模式的话,某些特权指令执行会出问题。

3.3 关闭看门狗与时钟早期配置

关看门狗这件事,不同芯片的实现差异很大。有些芯片的看门狗在复位后是默认关闭的,有些则是默认开启的。uboot通常会在reset之后不久就处理这件事,代码可能在start.S里,也可能在board_init_f的早期阶段。

以某款常见的ARM SoC为例,看门狗控制寄存器可能长这样:

/* 关闭看门狗 */ ldr r0, =WTCON ldr r1, =0x0 str r1, [r0]

简单粗暴,直接把控制寄存器写0。但有些芯片的看门狗需要先解锁再操作,或者有多个看门狗(比如安全域一个、非安全域一个),那就得逐个处理。

时钟配置也是类似的情况。有些芯片上电后跑在内部低速时钟(比如24MHz晶振或者内部RC振荡器),需要切换到PLL输出的高频时钟。时钟配置涉及PLL锁定时间、分频系数等参数,算错了轻则跑得慢,重则直接跑飞。

/* 配置PLL */ ldr r0, =PLL_CON0 ldr r1, =PLL_ENABLE | PLL_LOCKTIME | PLL_MDIV | PLL_PDIV | PLL_SDIV str r1, [r0] /* 等待PLL锁定 */ wait_pll_lock: ldr r0, =PLL_CON0 ldr r1, [r0] tst r1, #PLL_LOCKED beq wait_pll_lock /* 切换时钟源 */ ldr r0, =CLK_SRC ldr r1, =CLK_SRC_APLL str r1, [r0]

这段代码的逻辑是:先配置PLL参数并使能,然后轮询等待PLL锁定,最后把系统时钟源切换到PLL。PLL锁定时间通常在几百微秒到几毫秒之间,这个等待循环是必须的,跳过的话系统会不稳定。

注意:时钟配置的参数计算通常有公式,比如Fout = Fin * MDIV / (PDIV * 2^SDIV)。这些参数不是随便填的,要参考芯片手册里的推荐值和你的晶振频率来算。我见过有人直接把参考板的参数抄过来,结果晶振频率不一样,跑出来的时钟差了十万八千里。

3.4 内存初始化:DRAM控制器的配置

内存初始化是第一阶段的"重头戏",也是最容易出问题的环节。DRAM控制器的配置涉及大量时序参数,这些参数取决于你用的DRAM颗粒型号、PCB走线长度、工作频率等因素。

以DDR3为例,需要配置的参数包括:

参数含义典型值来源
tRCD行激活到列命令延迟DRAM手册
tRP预充电到行激活延迟DRAM手册
tRAS行激活到预充电最小时间DRAM手册
tRFC刷新周期时间DRAM手册
CLCAS延迟DRAM手册
tWR写恢复时间DRAM手册

这些参数通常以时钟周期为单位,需要根据你的DRAM工作频率换算。比如tRCD在DRAM手册里标的是13.75ns,你的DDR时钟是400MHz(周期2.5ns),那tRCD就是13.75/2.5=5.5,向上取整为6个周期。

uboot里内存初始化的代码通常在arch/arm/cpu/armv7/下的某个ddr.c或者sdram.c文件中,也可能在board目录下。代码结构一般是:

void dram_init(void) { /* 配置DDR控制器寄存器 */ writel(DDR_PHY_CON0, ...); writel(DDR_PHY_CON1, ...); /* ... 几十个寄存器 ... */ /* 执行DDR初始化序列 */ writel(DDR_CMD, DDR_CMD_INIT); while (readl(DDR_STATUS) & DDR_STATUS_BUSY); /* 检查初始化是否成功 */ if (readl(DDR_STATUS) & DDR_STATUS_ERROR) { /* 初始化失败 */ } }

这段代码看起来简单,但每个寄存器的值都是精心计算出来的。我见过一个案例,某块板子的DDR初始化代码是从参考板抄来的,但参考板用的是三星颗粒,实际板子用的是海力士颗粒,tRFC参数不一样,结果系统跑一会儿就死机。后来把tRFC从160ns改成260ns,问题解决。

实操心得:调试DDR初始化时,如果手头有示波器,可以量一下DDR时钟和命令线的波形,确认频率和时序对不对。如果没有示波器,可以在初始化前后分别读写一段内存,看数据能不能正确写入和读出。最简单的办法是写一个已知模式(比如0xAA55AA55)到内存,再读回来比较。

3.5 设置栈指针与BSS段清零

内存初始化完成后,就可以设置栈指针了。栈的位置通常选在内存的高地址区域,向下增长。uboot里一般这样设置:

/* 设置栈指针 */ ldr r0, =CONFIG_SYS_INIT_SP_ADDR mov sp, r0

CONFIG_SYS_INIT_SP_ADDR是一个配置宏,通常指向SRAM的顶部或者DRAM的某个位置。在重定位之前,栈一般放在SRAM里,因为此时DRAM虽然初始化了,但uboot代码可能还在Flash里跑,用DRAM做栈需要确保DRAM稳定。

栈设置好之后,就可以调用C函数了。但还有一个问题:BSS段需要清零。BSS段存放的是未初始化的全局变量和静态变量,C语言标准要求它们初始值为0。在裸机环境下,这个清零工作得自己来做。

/* 清零BSS段 */ ldr r0, =__bss_start ldr r1, =__bss_end mov r2, #0 clear_bss: cmp r0, r1 strlt r2, [r0], #4 blt clear_bss

__bss_start和__bss_end是链接脚本里定义的符号,标记了BSS段的起止地址。这段循环把BSS段每个字节都写成0。

注意:BSS清零必须在C代码使用任何全局变量之前完成。如果顺序搞反了,全局变量的初始值就是内存里的随机数据,程序行为完全不可预测。我遇到过一个问题,某块板子上电后某个全局标志位偶尔是1偶尔是0,查了半天发现是BSS清零代码被优化掉了,编译器认为那段内存"没被使用"。

3.6 重定位:把代码搬到该去的地方

重定位(relocation)是第一阶段的最后一个大动作。前面说过,uboot可能从Flash启动,但最终要跑到DRAM里。重定位就是把代码从加载地址搬到链接地址的过程。

重定位的代码通常在arch/arm/lib/relocate.S中,核心逻辑是:

relocate_code: /* r0 = 新的代码起始地址 */ /* r1 = 旧的代码起始地址 */ /* r2 = 代码长度 */ /* 拷贝代码 */ copy_loop: ldr r3, [r1], #4 str r3, [r0], #4 cmp r1, r2 blt copy_loop /* 跳转到新地址继续执行 */ ldr pc, =_main

实际的重定位代码比这复杂得多,因为还要处理重定位偏移量、修正绝对地址引用等问题。uboot使用了一个叫做GOT(全局偏移表)的机制来记录哪些地方需要修正。

重定位完成后,程序计数器跳转到DRAM里的_main函数,第一阶段就算正式结束了。

实操心得:重定位是uboot调试中最容易出问题的环节之一。常见的问题包括:重定位后的代码跑飞、全局变量访问错误、函数指针失效等。排查时可以在重定位前后分别打印一些关键地址,确认代码确实被搬到了正确的位置。另外,重定位的代码长度要算对,少拷贝了或者多拷贝了都会出问题。

4. 常见问题与排查技巧实录

4.1 串口无输出:从哪开始查

串口无输出是uboot第一阶段最典型的问题。遇到这种情况,不要慌,按下面的顺序排查:

第一步,确认硬件。量一下串口TX引脚有没有波形,晶振有没有起振,电源电压对不对。硬件问题占这类故障的一半以上。

第二步,确认启动介质。芯片的启动模式引脚设置对不对?是从SD卡启动还是从SPI Flash启动?如果启动介质选错了,芯片根本不会去读uboot。

第三步,确认向量表。用JTAG或者调试器读一下复位向量地址的内容,看看是不是b reset指令。如果不是,说明uboot没被正确加载到启动地址。

第四步,确认时钟和串口配置。如果向量表对了,但串口还是没输出,可能是时钟配置错了导致波特率不对,或者串口引脚复用没配置。这时候可以用示波器量TX引脚,看有没有数据波形,有波形但乱码就是波特率问题。

第五步,确认栈和BSS。如果串口能输出一部分然后卡死,多半是栈溢出或者BSS没清零导致全局变量异常。

4.2 常见问题速查表

现象可能原因排查方法
上电无任何输出启动介质错误、时钟未配置、串口引脚未复用检查启动引脚、量晶振、查引脚复用寄存器
输出乱码波特率不对、时钟频率错误量TX波形算波特率、检查PLL配置
输出几行后卡死栈溢出、BSS未清零、内存初始化不稳定增大栈空间、检查BSS清零代码、降频测试
重定位后跑飞重定位地址错误、代码长度算错、GOT表未修正打印重定位前后地址、检查链接脚本
偶尔启动失败电源不稳定、DDR时序余量不足、看门狗未关量电源纹波、放宽DDR时序、确认看门狗寄存器

4.3 几个容易踩的坑

坑一:看门狗没关干净。有些芯片有多个看门狗,或者看门狗需要先解锁才能关闭。我见过一个案例,开发者只关了非安全域的看门狗,安全域的看门狗还在跑,结果系统跑几秒就复位。排查这种问题,要仔细看芯片手册里看门狗章节,确认所有看门狗都被处理了。

坑二:Cache没关导致内存访问异常。第一阶段早期,MMU和Cache通常是关闭的。但有些芯片上电后Cache可能是开启状态,或者你在代码里不小心打开了Cache。Cache开启后,写内存可能只是写到Cache里,没有真正写到DRAM,导致后续读取数据不一致。排查时可以在关键位置插入Cache刷新操作,或者确认CP15的Cache控制位。

坑三:栈指针设置到了未初始化的内存。栈指针指向的内存必须是可读写的。如果栈指针指向了还没初始化的DRAM区域,压栈操作会失败。我建议在设置栈指针之前,先往栈顶地址写一个测试值再读回来,确认内存可用。

坑四:重定位时忘了修正函数指针。uboot里有很多函数指针,比如中断向量表、驱动操作集等。重定位后,这些指针还指向旧地址,调用就会跑飞。uboot的GOT机制就是用来处理这个问题的,但如果你自己添加了函数指针,记得也要加入GOT表。

5. 调试手段与工具选型

5.1 点灯大法:最原始但最有效

在没有串口输出、没有JTAG的情况下,点灯是最可靠的调试手段。在代码的关键位置插入GPIO操作,点亮或熄灭LED,通过LED的状态判断程序执行到了哪里。

/* 点亮LED */ ldr r0, =GPIO_BASE ldr r1, =GPIO_LED_PIN str r1, [r0, #GPIO_SET_OFFSET]

点灯大法的优点是简单直接,不依赖任何外设初始化。缺点是信息量少,只能判断"到了"或"没到",不能看变量值。但在第一阶段早期,这已经够用了。

5.2 JTAG调试:单步跟踪的利器

如果板子有JTAG接口,那调试效率会高很多。你可以设置断点、单步执行、查看寄存器和内存。对于第一阶段这种汇编代码,JTAG几乎是必备工具。

用JTAG调试时,我通常会在reset标号处设一个断点,然后单步执行,观察每一步寄存器的变化。特别是CPSR、SCTLR这些控制寄存器,它们的值直接决定了CPU的行为。

注意:有些芯片的JTAG引脚和GPIO复用,需要在启动时通过引脚配置或者eFuse设置才能启用JTAG。如果你接上JTAG发现连不上,先确认JTAG功能有没有被使能。

5.3 串口打印:第二阶段的主力

到了第二阶段,串口打印就是最主要的调试手段了。printf、debug、puts这些函数可以输出变量值、执行流程等信息。uboot的串口驱动通常在drivers/serial/目录下,初始化代码在board_init_f或者board_init_r中调用。

串口打印的缺点是依赖串口初始化成功,如果串口本身有问题,那就什么都看不到。所以第一阶段早期的问题,还是得靠点灯和JTAG。

5.4 工具选型对比

调试手段适用阶段优点缺点
点灯全阶段简单、不依赖外设信息量少
JTAG全阶段可单步、可看寄存器需要硬件支持、速度慢
串口打印第二阶段信息量大、使用方便依赖串口初始化
逻辑分析仪全阶段可看时序、可抓总线需要硬件、分析复杂

6. 从第一阶段到第二阶段的交接细节

6.1 _main函数的承上启下作用

_main函数位于arch/arm/lib/crt0.S,它是汇编和C语言的交接点。这个函数主要做三件事:设置C运行环境(栈、BSS)、调用board_init_f、处理返回后的重定位。

_main: /* 设置栈 */ ldr sp, =(CONFIG_SYS_INIT_SP_ADDR) /* 清零BSS */ ldr r0, =__bss_start ldr r1, =__bss_end mov r2, #0 bl memset /* 调用board_init_f */ bl board_init_f /* board_init_f返回后,执行重定位 */ bl relocate_code /* 跳转到重定位后的地址 */ ldr pc, =board_init_r

board_init_f是第二阶段的第一个C函数,它负责早期的板级初始化,包括串口、定时器、环境变量等。board_init_f执行完毕后,会返回一个重定位目标地址,然后_main调用relocate_code把uboot搬到DRAM的高端地址,最后跳转到board_init_r。

6.2 全局数据结构gd和bd

uboot使用两个全局数据结构来传递信息:gd(global_data)和bd(bd_info)。gd在board_init_f之前就已经在内存中预留了位置,board_init_f负责填充它。bd通常在board_init_r阶段填充。

gd的定义在include/asm-generic/global_data.h中,包含了大量字段,比如:

typedef struct global_data { bd_t *bd; unsigned long flags; unsigned int baudrate; unsigned long cpu_clk; unsigned long bus_clk; unsigned long relocaddr; phys_size_t ram_size; /* ... 更多字段 ... */ } gd_t;

gd的地址通常存在一个固定的寄存器里(比如ARM的r9),这样在任何地方都能快速访问。这个设计很巧妙,避免了全局变量在重定位后地址失效的问题。

实操心得:如果你在board_init_f里添加了自己的初始化代码,记得把结果存到gd里,而不是用普通的全局变量。因为board_init_f执行时uboot还在Flash里跑,全局变量的地址是链接地址,重定位后这些地址就失效了。gd是专门为跨重定位传递数据设计的。

6.3 重定位后的地址修正

重定位完成后,uboot运行在DRAM里,但代码里很多绝对地址引用还是旧的链接地址。uboot通过relocate_code里的GOT表来修正这些引用。GOT表在链接时生成,记录了所有需要修正的地址位置。

relocate_code: /* r0 = 新地址,r1 = 旧地址,r2 = 长度 */ /* 拷贝代码 */ /* ... */ /* 修正GOT表 */ ldr r3, =__got_start ldr r4, =__got_end fix_got: cmp r3, r4 ldrlt r5, [r3] addlt r5, r5, r0 sublt r5, r5, r1 strlt r5, [r3], #4 blt fix_got /* 跳转到新地址 */ ldr pc, =_main

GOT表的修正逻辑是:对于每个需要修正的地址,加上新地址和旧地址的差值。这样,原来指向旧地址的指针就变成了指向新地址。

这个机制对开发者是透明的,你不需要手动处理。但如果你在汇编里硬编码了绝对地址,那重定位后就会出问题。所以uboot的汇编代码里大量使用相对跳转和adr指令,就是为了避免这个问题。

7. 移植与适配的实战建议

7.1 从参考板到自定义板

uboot移植的第一步通常是找一个最接近的参考板,复制它的配置和代码,然后逐步修改。这个过程中,第一阶段需要改的地方主要有:

  • 时钟配置:根据你的晶振频率和CPU目标频率,重新计算PLL参数
  • 内存配置:根据你的DRAM颗粒型号和PCB设计,调整DDR控制器参数
  • 串口配置:根据你用的串口控制器和波特率,修改串口初始化代码
  • GPIO配置:根据你的LED和按键连接,修改GPIO初始化

这些修改中,内存配置是最容易出问题的。我的建议是先用保守的参数(降低频率、放宽时序)让系统跑起来,然后再逐步优化。

7.2 配置宏的梳理

uboot有大量的配置宏,分布在include/configs/目录下的头文件中。第一阶段相关的宏主要有:

宏名含义典型值
CONFIG_SYS_TEXT_BASE链接地址0x80800000
CONFIG_SYS_INIT_SP_ADDR初始栈指针0x80800000 - 0x1000
CONFIG_SYS_SDRAM_BASEDRAM起始地址0x80000000
CONFIG_SYS_BAUDRATE_TABLE支持的波特率{9600, 19200, 38400, 57600, 115200}
CONFIG_SYS_CPUCPU类型"armv7"

这些宏的值要和你的硬件设计对应。比如CONFIG_SYS_SDRAM_BASE必须和DDR控制器的地址映射一致,否则内存访问会出错。

7.3 调试顺序建议

移植uboot时,我建议按这个顺序调试:

  1. 先让串口有输出。哪怕只是几个乱码,也说明CPU在跑,时钟和串口基本正常。
  2. 再让串口输出正确。调整波特率和时钟配置,直到能看到清晰的打印信息。
  3. 然后确认内存可用。在board_init_f里加一段内存测试代码,读写一段DRAM,确认数据正确。
  4. 最后调试外设。网口、USB、MMC等外设的调试放在最后,因为它们依赖内存和时钟都正常。

这个顺序的核心逻辑是:从最基础的CPU和时钟开始,逐步向上层外设推进。每一步都确认无误后再进行下一步,避免问题叠加导致排查困难。

实操心得:我在移植uboot时,习惯在board_init_f的最开始加一句串口打印,输出当前CPU频率和内存大小。这样每次启动都能看到关键信息,一旦有问题,一眼就能看出是哪个环节出了错。这个习惯帮我省了很多调试时间。

8. 几个值得深挖的细节

8.1 为什么用汇编而不是C

第一阶段用汇编,根本原因是C语言运行需要栈,而栈需要内存,内存还没初始化。这是一个"鸡生蛋蛋生鸡"的问题。汇编可以直接操作寄存器和物理地址,不需要栈,所以能在内存初始化之前运行。

另一个原因是汇编可以精确控制指令的执行顺序和时序。比如DDR初始化对时序要求很严格,某些操作之间需要精确的延迟,用C语言很难保证编译器不会插入额外的指令。

8.2 位置无关代码的实现

位置无关代码(PIC)是指代码不管加载到哪个地址都能正确执行。ARM汇编里实现PIC主要靠相对跳转和adr指令。

/* 相对跳转,不管代码在哪里都能跳到label */ b label /* adr指令获取label的当前地址 */ adr r0, label /* 绝对跳转,依赖链接地址 */ ldr pc, =label

b和bl指令的跳转偏移是相对于当前PC的,所以是位置无关的。adr指令也是基于PC计算地址,同样是位置无关的。而ldr pc, =label会把label的链接地址加载到PC,如果代码不在链接地址运行,就会跳错地方。

uboot在第一阶段早期大量使用b和adr,就是为了在重定位之前也能正确执行。等到重定位完成后,才可以使用绝对跳转。

8.3 异常向量表的两种位置

前面提到过,异常向量表可以在0x00000000或者0xFFFF0000。这个选择由CP15的V位控制。为什么有两个位置?这是ARM架构的历史遗留设计。

0x00000000是传统位置,但有些系统会把0x00000000映射到RAM,这样向量表可以被动态修改。0xFFFF0000是高地址位置,通常映射到ROM或者固定的内存区域,适合那些需要向量表固定不变的场景。

uboot通常使用0x00000000,因为uboot需要动态设置向量表(比如在运行时修改中断处理函数)。但具体用哪个,还要看芯片设计。有些芯片的BootROM会占用0x00000000,那uboot就只能用0xFFFF0000。

8.4 Cache和MMU的早期处理

第一阶段早期,Cache和MMU通常是关闭的。但有些芯片上电后Cache可能是开启的,或者BootROM在跳转到uboot之前开启了Cache。这种情况下,uboot需要在reset之后尽快关闭Cache,避免内存访问不一致。

关闭Cache的代码在CP15的c1寄存器:

mrc p15, 0, r0, c1, c0, 0 bic r0, r0, #0x00000004 /* 关闭D-Cache */ bic r0, r0, #0x00001000 /* 关闭I-Cache */ mcr p15, 0, r0, c1, c0, 0

关闭Cache后,所有内存访问都直接到DRAM,虽然慢但一致性好。等第二阶段需要性能时,再重新开启Cache并配置MMU。

注意:关闭Cache之前,如果Cache里有脏数据,需要先刷新到内存。否则关闭Cache后,这些数据就丢了。uboot通常在关闭Cache之前会执行一次Cache刷新操作。

9. 个人实操体会

读uboot第一阶段源码这件事,我前前后后折腾了好几块板子才算真正搞明白。最开始是照着教程一行行看,看完觉得懂了,但一换板子就懵。后来才意识到,光看代码不够,得结合芯片手册一起看,知道每个寄存器是干什么的,为什么要写那个值。

我踩过的最大的一个坑是DDR初始化。当时从参考板抄了一套参数,板子能启动,但跑memtest偶尔会报错。查了很久才发现是tRFC参数不对,参考板的DRAM颗粒和我的不一样。后来把tRFC从160ns改成260ns,memtest跑了一整夜都没报错。这件事让我明白,uboot移植里没有"抄作业"这回事,每个参数都得自己算、自己验证。

另一个体会是,调试第一阶段的问题,工具比经验重要。有JTAG的话,很多问题单步跟一遍就清楚了;没有JTAG,只能靠点灯和串口,效率差很多。所以如果条件允许,尽量给板子留一个JTAG接口,哪怕只是调试用。

最后说一个实用技巧:在board_init_f里加一段代码,把gd结构体的关键字段打印出来,包括CPU频率、内存大小、重定位地址等。这段打印在每次启动时都会输出,一旦系统出问题,看一眼这些信息就能快速定位是哪个环节的配置不对。这个习惯我保持了好几年,帮我省了无数调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询