ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

uboot启动流程:从_start汇编入口到第一个C函数

uboot启动流程:从_start汇编入口到第一个C函数 干嵌入式这一行的谁没被 uboot 劝退过几次说句实话我当年第一次接触 uboot拿着编译好的固件烧进板子串口能蹦出版本信息我还挺得意结果一琢磨“这玩意儿到底是怎么跑起来的”整个人是懵的上电之后第一条指令在哪Flash 里的代码怎么到内存里的汇编那段天书一样的.S文件好端端怎么突然就跳到 C 函数了估计你也一样卡从_start到board_init_f这段就再也走不动了。这篇文章我就干一件事把 uboot 启动流程这条线彻底捋清楚重点放在最容易被劝退的一段——从_start汇编入口开始一直到真正踏入 C 世界的那一步。我会以最常用的 ARM 平台为例把向量表、模式切换、栈指针、重定位、BSS 清零这些“凶神恶煞”的概念一个一个掰开揉碎讲给你听。适合正在学嵌入式 Linux、被 bootloader 折磨的初学者也适合刷了路由器固件比如 wr703n想知道底层做了什么的折腾党。我会顺带附上我实际调试时踩过的坑和排查方法你照着思路去跟代码会轻松非常多。1. 先把整体流程装进脑子从 ROM Code 到 main 函数之间发生了什么很多人看 uboot 源码打开arch/arm/cpu/armv7/start.S就头皮发麻几百行汇编密密麻麻看两行就想关掉。我的建议是先别看细节先搞清楚“这一段代码在整个启动链条里到底是干什么的、它凭什么叫第一段代码”。你脑子里装着全貌图再回头啃汇编顺序就顺了。1.1 启动全貌谁把 uboot 喊醒的CPU 上电那一刻RAM 里是空的DDR 颗粒连初始化都没做那它是怎么知道自己该执行哪条指令的靠的是芯片内部固化的 ROM Code也叫 BootROM。芯片出厂时硅片里就写好了一段只读代码上电后 CPU 从固定地址比如 ARM 的 0x00000000或者 SoC 自定义的 0xFFFF0000取第一条指令这就是 BootROM 的入口。这段 BootROM 会干一件非常关键的事探测启动介质。它会按照芯片手册规定的顺序去尝试读取 SD 卡、EMMC、NAND、NOR Flash 之类的介质把你烧在里面的 uboot 二进制搬运到片内 SRAM也叫 iRAM、ocram然后跳过去执行。到了这一步真正的 uboot 代码才开始跑入口就是我们常说的_start。所以记住这个链条CPU 上电 → BootROM 代码初始化时钟和介质 → 加载 uboot 到 SRAM → 跳转_start→ uboot 汇编初始化环境 → 调用 C 函数 → 进入 uboot 的 main 流程 → 加载内核。很多平台全志、瑞芯微、NXP 多数 SoC在 uboot 前面还加了一个 SPL/TPL 阶段本质就是“袖珍版 uboot”专门负责初始化 DDR 和加载完整版 uboot用的材料和完整版一样是那套代码只是配置裁剪过。你理解了完整版SPL 自然也会了。1.2 为什么必须有那段汇编C 语言跑不起来的三座大山有人可能会问既然最终都要进 C 世界uboot 干嘛非得用汇编写一大串直接用 C 写启动不好吗问这个问题的多半是被 C 语言“惯坏”了忘了 C 语言能跑起来是有前提条件的。第一个前提是栈。C 语言的函数调用、局部变量、返回地址全靠栈栈的本质是内存里的一块区域。可 CPU 刚上电DDR 控制器没初始化板子上的大内存颗粒根本没法用你让 C 代码往哪放栈所以必须有人在汇编阶段按照当前可用的内存通常是片内 SRAM把栈指针 SP 给设置好。第二个前提是中断环境要干净。启动阶段代码都是裸奔的如果突然来个中断中断向量表、中断服务程序都没准备好程序立刻跑飞。所以汇编阶段必须把中断关掉让 CPU 在一个“与世隔绝”的安静环境里做准备工作。第三个前提是全局变量和静态变量要被清零或初始化。C 代码里写了int flag;C 标准说它的初值是 0但这个 0 不是天上掉下来的必须由代码负责把对应内存区域清零。这个动作在 BSS 段清零环节完成也必须在 C 函数跑起来之前做完。你可以把汇编阶段想象成搬家前的准备工作先派两个人去新房子接好水电、打扫干净、把柜子放好然后才让大队人马C 代码进场。没有前面这些脏活累活C 代码一进去就是死路一条。2._start入口深挖看懂这段汇编你就懂了一半好现在打开arch/arm/lib/vectors.S或者start.S咱们从头看起。不同厂商的 uboot 文件路径略有差异但套路几乎一致先是向量表然后复位入口随后一串 CPU 初始化。2.1 向量表不是“摆设”复位入口是怎么被找到的真正第一段代码长这样以 ARMv7 为例.section .vectors, ax _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这段叫异常向量表。CPU 规定每种异常复位、未定义指令、软中断、预取指中止、数据中止、IRQ、FIQ对应一个固定入口地址向量表从基地址开始每 4 字节放一条指令。复位是第 0 项所以 CPU 一上来命中的就是第一条b reset跳转到 reset 标号去执行真正复位逻辑。很多人不理解为什么第一行要用b跳转后面几行却用ldr pc, xxx因为b是相对跳转范围 ±32MB而且这条指令必须紧贴向量表入口保证 CPU 刚启动就能用而后面的异常入口通常不是b因为有些异常入口地址离处理函数太远b跳不到干脆用ldr pc, [pc, #offset]从内存加载一个绝对地址到 PC。严格来说ARM 的向量表 8 项每项都是 4 字节的指令用ldr pc方式能直接跳 32 位绝对地址更灵活。需要特别注意系统里可能不止一份 uboot 代码。SPL 阶段也存在自己的向量表完整版 uboot 在重定位后内存里也有一份。搜索热词里有人提到“wr703n刷uboot”其实 AR9331 这类芯片的启动流程也一样只是 BootROM 加载的介质和 SRAM 大小不同思想完全相同——都是向量表开路。2.2 切换 SVC 模式 关中断uboot 为什么要“与世隔绝”接着往下看reset 标号里最常见的第一段操作就是设置 CPU 模式并关中断reset: mrs r0, cpsr bic r0, r0, #0x1f /* 清除模式位 */ orr r0, r0, #0xd3 /* 设置为 SVC 模式同时屏蔽 IRQ/FIQ */ msr cpsr, r0ARM 的 CPSR 寄存器低 5 位是模式位10011 是 SVC、10010 是 IRQ、10001 是 FIQ、10111 是 ABT、11011 是 UND。按位操作后CPU 就切到了 SVC超级管理员模式同时0xd3的高六位11010011里 I 位和 F 位都被置 1IRQ 和 FIQ 全局关断。为什么要 SVC 模式因为它拥有最高的特权等级能访问全部寄存器、协处理器、内存映射uboot 启动阶段要干的事情都属于“硬件敏感操作”必须用特权模式。为什么不选其他特权模式因为 SVC 模式的 SP 栈在后续初始化中最方便而且 GDB 调试和 Linux 内核的启动习惯也默认 SVC 模式。关中断的原因前面说了启动环境极度脆弱一个中断进来连跳到哪都不知道直接关掉最稳妥。这里有个很容易忽略的细节关中断必须尽早因为有些外设上电默认开了中断万一中断触发PC 会跳向向量表如果对应项还是ldr pc, _irq这种加载指令那_irq的值往往还是 0 或者无效地址整个启动就废了。2.3 设置栈指针开工前先给 C 语言攒个窝汇编里接下来必然有一句类似这样的ldr sp, CONFIG_SYS_INIT_SP_ADDR bic sp, sp, #7 /* 8 字节对齐满足 AAPCS 调用约定 */这句话干的事就是设置栈指针。CONFIG_SYS_INIT_SP_ADDR这个宏在 include/configs/ 下对应的头文件里定义它通常指向片内 SRAM 的某个地址。为什么要用 SRAM因为此时 DDR 还没初始化只有片上那点小内存能用。栈是向下生长的所以这块 SRAM 要被合理地划出顶部地址作为初始 SP 起点。栈顶往上可能还放着 uboot 的代码与全局数据如果栈设置太高一压栈把代码区压穿了程序分分钟跑飞设置太低栈空间不够用函数一递归就爆。我在实际移植中见过最典型的 bug 就是某个平台 SRAM 特别小比如只有 4KBSP 设置和代码区重叠现象是 uboot 打印几行信息后随机死机排查大半天才发现是栈顶越界。你还得注意对齐ARM 的 AAPCS 要求 SP 在函数调用边界是 8 字节对齐的所以汇编里用bic sp, sp, #7把低 3 位清零。别小看这个对齐如果忽略它某些编译器生成的ldrd/strd双字加载/存储指令执行时会触发对齐异常症状诡异到你根本想不到是这里的问题。3. 从汇编到 C 的“惊险一跳”重定位与 BSS 清零栈有了模式对了中断关了那是不是马上就bl board_init_f慢着在很多人批号里学到的 uboot 旧版本比如 2016 之前的经典流程里跳 C 之前还得干两件事关闭 MMU/Cache、重定位代码到 DRAM、清零 BSS 段。搞懂这三步你对“汇编是怎么给 C 铺路”的理解才算完整。3.1 关闭 MMU 和 Cache为什么要先“裸奔”刚开始启动地址映射是最原始的状态实地址访问。uboot 会通过 CP15 协处理器指令把 SCTLR 寄存器System Control Register里的 MMU 位、D-Cache 位、I-Cache 位统统清零mrc p15, 0, r0, c1, c0, 0 /* 读 SCTLR */ bic r0, r0, #0x1 /* 关 MMU */ bic r0, r0, #0xC /* 关 D-Cache 和 I-Cache */ mcr p15, 0, r0, c1, c0, 0 /* 写回 */为什么要关因为 uboot 在重定位之前代码是“借宿”在 SRAM 里的虚拟地址和物理地址的关系还没建立。如果开了 MMU你明明把代码从 Flash 拷到内存地址 ACPU 却按虚拟地址 B 去取指那不乱套才怪。Cache 同理如果开了 D-CacheDMA 搬运数据和 CPU 访问数据的缓存一致性没人维护拷贝完代码发现读回来的全是脏数据调试会痛不欲生。另外关闭 MMU 后所有的 ldr/str 都是直接操作物理地址这对早期访问 GPIO 寄存器、UART 寄存器特别友好——你想写哪个物理寄存器就写哪个不需要任何映射表。你可以对比一下 Linux 内核启动后访问外设要经过 ioremap就知道启动阶段“裸奔”有多省事了。3.2 重定位把代码从 Flash 请进内存uboot 代码运行的地方和存放的地方往往不是同一个地方存放的地方是 Flash/EMMC运行的地方是 RAM。重定位就是把代码从前者拷贝到后者然后跳过去继续执行。典型的拷贝循环长这样/* r0 src, r1 dest, r2 end */ _copy_loop: ldmia r0!, {r9-r10} stmia r1!, {r9-r10} cmp r0, r2 blo _copy_loop为什么要重定位有几个非常现实的理由第一个理由是速度。Flash 上执行的效率比内存差一个数量级代码量大了必须有内存来跑。第二个理由是可写性uboot 运行时要修改全局变量、构建环境变量、管理堆内存Flash 的写入又慢又有寿命限制不可能在那里做这些操作。第三个理由更深刻uboot 的链接地址link address默认是按 RAM 地址编的比如 0x80800000如果直接在 Flash 里跑PC 永远对不上链接地址所有绝对跳转都会崩。所以重定位是保证 PC 和链接地址一致的关键手段。重定位之后的跳转有一个巨大的坑必须保证 PC 是相对地址跳转。代码被搬到新位置后如果继续用绝对地址跳转跳到的还是旧位置的地址程序瞬间跑飞。解决办法要么用b相对跳转指令要么在重定位后用 PC 相对计算新的地址。uboot 源码里有类似ldr pc, __relocate_code的写法它会先把标号的地址计算成新位置再跳。这个坑我在第 5 节还会细说。3.3 BSS 段清零C 语言的舞台必须干净重定位完之后有一句很容易被忽略但极其重要的汇编ldr r0, __bss_start ldr r1, __bss_end mov r2, #0 clbss_l: str r2, [r0] add r0, r0, #4 cmp r0, r1 bne clbss_l__bss_start和__bss_end是链接脚本.lds 文件里导出的符号标出 BSS 段的起始和结束地址。这段代码把这整块内存都写成 0。什么是 BSS 段它专门放“未初始化的全局变量”和“未初始化的静态变量”。C 语言标准规定这些变量初值必须为 0但硬件不会好心帮你清零编译器也只在链接脚本里划出这块区域真正的清零工作必须由启动代码完成。如果不清 BSS后果可能是某个全局标志位初始是 1、某块缓冲区全是垃圾数据代码逻辑完全错乱而且这种错乱是随机的——因为 SRAM 或 DRAM 上电后的残留值每次可能都不同。再强调一遍这段清 BSS 的汇编必须在调用任何依赖全局变量的 C 函数之前执行。假如你把 board_init_f 的调用放在了清 BSS 前面而 board_init_f 内部用了全局数组那数据就是坨垃圾bug 极难定位。4. 第一个 C 函数 board_init_f分阶段设计的智慧万事俱备之后汇编终于要交出接力棒了。经典的 uboot 代码里会有这么一句ldr r0, 0x00000000 /* 参数某些平台传 boot flags */ bl board_init_fCPU 执行bl指令时硬件自动把下一指令地址存入 LRr14然后 PC 跳到 board_init_f。等 C 函数执行完毕通过mov pc, lr或bx lr返回CPU 又乖乖回到汇编继续跑。正是这一条bl让你从汇编世界正式踏入 C 世界。4.1 BL 指令与参数传递汇编怎么“喊”C 函数ARM 的 AAPCS 调用规则规定C 函数的前 4 个参数通过 r0~r3 传递返回值放在 r0。所以汇编里ldr r0, 0x0就是在给 board_init_f 传第一个参数。再看下面的 uboot 源码示例void board_init_f(ulong boot_flags) { ... gd (struct global_data *)CONFIG_SYS_INIT_GD_ADDR; ... relocaddr CONFIG_SYS_TEXT_BASE; ... }你会发现 board_init_f 一进来就做了一个极其关键的动作给gdglobal_data指针赋值。gd是 uboot 的一个全局结构体里面记着重定位地址、堆栈地址、串口波特率、环境变量起始地址等等。而gd自己也住在 SRAM 里预先划好的一块区域CONFIG_SYS_INIT_GD_ADDR这是程序员在配置头文件里手工指定死的不能等 C 代码用 malloc 分配——因为堆管理器还没初始化。从这里你就能感受到 uboot 启动阶段的管理逻辑在 C 语言能“正常生活”之前所有重要的位置都是汇编和配置宏提前划好的C 代码只是在这个棋盘上做指挥官而不是自己搭棋盘。4.2 为什么是 board_init_f 和 board_init_r 两段式uboot 把板级初始化拆成了两个阶段board_init_ff before relocation重定位前和board_init_rr after relocation重定位后。这是我当年最迷糊的地方为什么不能一个board_init干完全部原因就是一个经典的“鸡生蛋”问题要运行完整 uboot需要内存足够大要让内存DDR能用又必须先运行代码去初始化 DDR。而初始化 DDR 的逻辑不复杂但也没简单到纯汇编能轻松表达它有很多时序参数要算用 C 写更合适。可是 C 代码需要栈DDR 又用不了怎么办答案是先在片内 SRAM 搭一个小舞台。board_init_f阶段使用 SRAM 作栈完成两件事一是初始化 DDR二是规划 uboot 重定位后的内存布局gd 里的 relocaddr、堆栈指针等。然后返回到汇编汇编执行真正的重定位拷贝并且把 SP 切到 DDR 上的新栈最后调用board_init_r。board_init_r里uboot 终于可以大展拳脚了初始化串口、定时器、各种驱动、设置环境变量、进入 main loop 等待命令行或者直接 boot 内核。这也就解释了为什么我在搜索热词里看到很多人刷 wr703n 时疑惑“为什么 uboot 先跑一段然后又重新加载自己”就是两次拷贝、两个阶段在互相配合。下面这张表帮你快速对比两个阶段阶段运行位置栈内存主要任务依赖board_init_fSRAMSRAM初始化 DDR、规划内存布局、重定位准备极简 UART/GPIO 驱动board_init_rDDRDDR完整驱动初始化、环境变量、命令行、加载内核完整驱动框架、堆管理4.3 重定位后那些坑链接地址和加载地址很多人学到这以为万事大吉结果自己改 uboot 时一改配置就启动失败。最常见的坑集中在链接地址Link Address与加载地址Load Address不一致的问题上。链接地址是编译时链接器假设代码运行的地址在u-boot.lds和配置宏CONFIG_SYS_TEXT_BASE里指定比如经典的0x80800000。加载地址是代码实际烧录并开始执行的地址比如 SPI Flash 里可能从偏移0x0开始。BootROM 加载 uboot 到 SRAM 后CPU 最开始执行的地址是 SRAM 里的加载地址而不是链接地址。所以在最早期PC 和链接地址是不一致的这就是为什么启动代码里访问全局变量、调用函数必须小心编译器生成的绝对地址访问指令比如ldr r0, var默认按链接地址来早期没有重定位时直接用就会访问错误的位置。正确的做法就是前面反复强调的流程**先用相对跳转 / 位置无关代码完成必要初始化把代码完整拷贝到链接地址对应的 DDR 区域清 BSS再把 PC 切过去。**拷贝完成之后 PC 和链接地址终于“团圆”之后 C 代码里所有全局变量、函数指针才敢用绝对地址。我之前遇到一个很让人抓狂的 bug某个平台换了 DDR 颗粒后uboot 一重定位跑飞加打印发现是 DDR 初始化完但内存不稳定拷贝过去的数据在 5 秒内自己翻转了。排查方法是用 JTAG 读数据对比源和目的地址发现几百毫秒后数据开始出错最终定位到是 DDR 的tWTR时序参数配得太紧导致写后读不稳定。这类问题光看代码是永远看不出来的得借助硬件调试手段。5. 启动过程中的典型故障与排查方法理解得再透彻不如亲手踩一次坑记得牢。下面几个问题是这几年我见过和自己倒霉碰上的最常见的启动故障按经验整理了排查路线。5.1 串口没打印或打印乱码板子上电后串口毫无反应最让人绝望。这是启动流程最早暴露问题的环节原因往往很靠前第一BootROM 根本没跑到 uboot 入口。检查启动介质是否被正确识别boot 拨码开关、EMMC 里有没有数据、SD 卡分区表是否被破坏。热词里提到 “wr703n刷uboot变砖”多半就是这一层出了问题解决办法是用编程器重新烧写 SPI Flash。第二串口初始化时机太晚。uboot 在board_init_f里会调用init_serial如果你的平台串口初始化被放在了重定位之后而重定位之前代码就崩了你什么都看不到。排查手段是拉 GPIO 点个灯直接看程序有没有跑到某个位置。第三打印乱码。这基本就是波特率或时钟问题。串口波特率除频是基于外设时钟算的如果 PLL 配置错误实际波特率会偏差很大。我之前遇到过晶振从 24MHz 换成 25MHz 后打印满屏乱码改配置宏里的晶振频率就好了。还有一个容易被忽略的点如果初始化串口之前 CPU 已经被 PPL 超频UART 的时钟源也会变乱码也在所难免。5.2 进 C 函数就挂prefetch abort 怎么定位如果你能看到 uboot 打印了一部分信息但只要进入某个 C 函数就死机大概率是启动环境没搭好。最常见的是BSS 段没清零。症状是一进 C 函数判断某个全局变量走错分支。这种 bug 非常恶心因为每次跑的可能都不一样本质上取决于内存上电残留值。排查时先在clbss_l之后、调用第一个 C 函数之前设断点检查__bss_start到__bss_end是否整片为 0只要看到任何一个非零值就可以确定问题在这。还有一种是VBAR 没设好。ARMv7 及以上处理器异常向量表基地址由 VBAR 寄存器决定。uboot 里有一段代码用mcr p15, 0, r0, c12, c0, 0把_start地址写入 VBAR。如果这条命令没执行或者重定位后又把代码搬走了却没更新 VBAR一旦发生中断CPU 就去旧地址取向量表旧地址处要么是垃圾要么全是 0必然 prefetch abort。所以看到 abort 信息先查 VBAR再查向量表是否在新位置有效。5.3 重定位后跑飞链接地址与加载地址的“爱恨情仇”uboot 打印了 U-Boot 2022.04 ... 之后眼看着就要进命令行突然重启或者死机基本是重定位后的代码出了问题。排查办法分三步走第一步确认拷贝长度对不对。源码里重定位拷贝的 end 地址是从链接脚本算出来的如果链接脚本__image_copy_end符号对应的区域变大比如增加了驱动而某处还拿着旧的长度宏就会拷贝不完整跳到新地址后发现后面的代码缺失。你可以比较 Flash 里镜像文件和 DDR 里目标区域的文件 MD5不一致就是拷贝环节错了。第二步确认重定位后的 SP 指向有效内存。重定位后汇编会把 SP 切到 DDR 上的新栈如果新栈地址和代码区重叠函数调用一深就撞代码表现为死机位置和函数调用深度强相关。我建议把栈地址设到 DDR 的最高端附近远离镜像区域。第三步确认绝对跳转没问题。代码里大量存在ldr pc, function这样加载绝对地址的指令。一旦重定位完成所有符号地址都按链接地址算而你必须保证编译时链接地址等于你真正搬运的目的地址。在 include/configs/ 里检查CONFIG_SYS_TEXT_BASE是否和链接脚本、DDR 映射一致一个数字错位就能让你调一整天。6. 从_start到 C 世界我的体会和学习路线建议当初带我入门的老工程师跟我说过一句话我一直记到现在“uboot 的启动代码不是给人读的是给 CPU 读的。你读不懂它是因为你把自己当成人但它在做的事全是按 CPU 的脾气来的。”现在回头看理解启动流程最有效的方式不是盯着一行行汇编抠而是要把“CPU 必须要什么条件才能跑 C”这个问题时刻放在心里栈、中断向量、内存、全局变量零初始化缺一样都不行。我自己带过几个新人每次都会让他们用 GDB 开发板把整个启动流程单步走一遍在_start处设置断点用info registers观察 CPSR、SP、PC在跳转board_init_f的那条bl指令前后对比寄存器变化。说句实话只要完整跑过一遍单步80% 的人对 uboot 启动流程的恐惧都会烟消云散剩下的 20% 是没跑明白回头再看一遍就行。因为你会发现汇编再可怕最终都是为了服务那个你熟得不能再熟的东西——C 语言。理解了这一点你和嵌入式 Linux 这条路的距离又近了一大截。再分享一个小技巧调启动流程的时候不要舍不得在汇编里加打印。很多平台没有仿真器时你可以在reset后、board_init_f前、重定位后三处各加一句 GPIO 点灯或串口打印用最笨的办法确认程序走到哪里。有了方向再往细节里钻效率高得多。等你哪一天突然能自己看明白start.S里每一步为什么这么写了恭喜你uboot 这关就算真正迈过去了。
返回列表