从按下电源键那一刻开始,整个系统其实就已经进入了一场精密的接力赛。对于用讯为4412开发板做嵌入式学习的同学来说,uboot启动过程是绝对不能绕开的一环——它既是硬件初始化的总指挥,又是内核加载的引路人。很多人卡在这块不是因为看不懂代码,而是不知道这段启动流程到底在解决什么问题、每一步为什么要这么干。这篇文章我就以4412平台为背景,把uboot从复位向量到跳转内核的完整过程拆开揉碎,结合我实际调试中踩过的坑,给正在学uboot的朋友一条相对顺畅的上手路径。
uboot本身是个庞然大物,如果一头扎进源码很容易迷路。我带的思路是先站在系统的高度看全景,再钻进具体的代码和寄存器操作,最后回到开发板上盯着串口输出验证结论。4412作为一颗非常经典的ARM Cortex-A9四核芯片,它的启动流程在嵌入式领域相当有代表性,把这块吃透,再去移植别的板子会轻松很多。
适合看这篇文章的人有两类:一类是刚接触uboot、想搞清楚移植和启动流程的学生,另一类是工作中要维护uboot、经常被启动问题折腾的工程师。我会努力把每个关键节点的原理讲明白,同时把我在实际操作中的经验穿插进去,尽量减少你走弯路的时间。
1. 从启动介质到入口代码:uboot的第一口气
1.1 4412的启动方式选择逻辑
Exynos 4412这代芯片支持从多种介质启动,常见的包括eMMC、SD卡、USB、串口等。实际开发中最常用的是SD卡启动和eMMC启动,因为这两种方式不需要额外的烧录设备,一张卡或者板载存储就能搞定。
芯片上电后,内部固化在ROM里的一段代码会先执行,这段代码叫iROM,也叫BL0。它做的事情其实很有限:初始化最基础的内存环境,然后根据芯片检测引脚的状态,也就是OM引脚的电平组合,决定从哪个外部介质去加载下一段代码。OM引脚在原理图上一般会标注,不同电平组合对应不同的启动介质,这个逻辑在芯片手册里有专门的表格可查。
很多初学者不理解为什么uboot要分好几段加载,而不是一次全部读进来。核心原因有两个:第一,芯片内部的SRAM容量非常有限,4412的iRAM只有几百KB级别,根本装不下完整的uboot;第二,外部存储介质比如eMMC或SD卡在裸机阶段还没有驱动,需要先有一段轻量代码把基本控制器初始化好,才能去读更多数据。所以启动过程天然就是分层的,BL0读到BL1,BL1再初始化内存并加载BL2,最终才把完整的uboot或内核交给DDR运行。
1.2 BL0、BL1和BL2的职责划分
如果把整条启动链看作一个公司,BL0相当于行政前台,只负责开门认路;BL1是部门主管,启动关键外设后把空间腾出来;BL2就是正式员工,承担主业务。以4412的SD卡启动为例,iROM会把SD卡固定位置的数据拷贝到iRAM里,这个拷贝区域和大小是芯片设计时就定死的,uboot源码里的配置项必须严格匹配。
BL1阶段做的事情包括继续初始化DRAM控制器、设置栈指针、搬运代码到DDR等。在4412的uboot中,这个阶段通常由一段汇编代码加上极简的C函数完成。需要注意的是,BL1阶段代码运行在SRAM里,链接地址必须使用SRAM对应的地址段,而BL2阶段运行在DDR里,链接地址也完全不同,这两者的链接脚本是分开的。
有朋友可能会问:既然最终uboot都要跑到DDR里,为什么不能一开始就把全部代码加载到DDR?答案很简单——DDR控制器上电后根本还没配置,芯片无法访问DDR的地址空间,必须先运行在SRAM里把控制器初始化好,才能把数据搬过去。这就好比你要搬进新房子,但门锁还没换,你得先站在门口用旧钥匙开锁,进去之后才能装新锁。
2. 第一阶段启动:那段让人头疼的汇编代码
2.1 复位向量与异常向量表的放置
uboot的入口一般是从链接脚本指定的入口符号开始的,在4412的uboot里通常是_start。这段代码被放在启动介质的最前端,硬件复位后从固定地址取第一条指令,然后进入异常向量表。异常向量表是ARM架构的规矩,每个表项对应一种异常类型,比如复位、未定义指令、软中断、IRQ等。
在实际的uboot源码中,异常向量表往往只是一串跳转指令,比如b reset、b undef_instr等。之所以要放跳转指令而不是直接处理逻辑,是因为向量表地址空间有限,每条向量只有4字节,放不下复杂的处理代码。跳转则可以把实际逻辑放到后面任意位置,灵活度更高。
我在调试早期阶段时,经常遇到程序跑飞的情况,用J-Link或者仿真器一看PC指针跑到0xdead0000之类的区域,基本就是异常向量没配对或者DRAM初始化参数有问题。这时候不要急着查应用逻辑,先把向量表和栈指针确认了再说。
2.2 关中断、设栈与CPU模式切换
_start之后的一段代码,不同平台大同小异,核心就是三件事:设置当前CPU的工作模式、关闭中断、设置栈指针。4412是Cortex-A9内核,有几种工作模式,比如SVC模式、IRQ模式、FIQ模式等。uboot早期阶段通常先把CPU切到SVC模式,因为SVC模式是特权模式,访问系统寄存器不受限制。
关中断用的是cpsr寄存器中的中断屏蔽位,直接给cpsr赋值即可。这一步很有必要——在这段启动代码里,中断向量表和中断控制器还没初始化,任何一个中断触发都可能导致不可预料的跳转。
设置栈指针也是个容易被忽视的细节。在汇编阶段,栈指针sp必须指向一段合法的可读写内存。如果指向了无效地址,后续调用C函数时一旦压栈就会触发数据异常。我见到过多次因为栈地址写错,uboot打印完几行日志就死掉的情况,最后查下来全是SP设置问题。
2.3 低层硬件初始化:时钟、DDR、串口
第一阶段的C函数入口通常叫board_init_f之类的名字,不同版本的uboot叫法可能不同。这个函数负责初始化板级相关的底层硬件,包括时钟PLL、DDR控制器、串口控制器等。4412的时钟初始化尤其关键,因为后续所有外设的时钟源都依赖PLL配置,一旦PLL设置错误,串口波特率会完全对不上,打印出来全是乱码。
DDR初始化是另一个重头戏。4412的DDR控制器参数非常多,包括时序参数、频率、驱动强度、地址映射等。uboot中一般会根据具体的内存颗粒型号配置一组参数,参数是否准确直接影响系统稳定性。很多人启动失败,表现就是uboot能跑起来但kernel起来后随机死机,十有八九是DDR时序参数余量不足。
串口初始化在这个阶段的目的只有一个:输出调试信息。我们会看到屏幕上打印的“U-Boot ...”,这个输出看似简单,却意味着CPU时钟、DDR、串口这些最基础的部分已经正常工作了。如果卡在更早的地方,串口连一个字符都打印不出来,此时就得靠仿真器或者LED点灯法来定位问题。
3. 第二阶段启动:重定位是理解uboot的钥匙
3.1 代码搬移与链接地址的关系
当DDR初始化完成后,uboot需要把自身从SRAM或者启动介质中搬运到DDR的高地址处运行。这个动作叫重定位,是第二阶段启动的核心。为什么要重定位?因为uboot在编译时链接到了一个较高的内存地址,比如0x43e00000,这是它规划中的最终运行地址。但上电后在DDR可用之前,代码只能从SRAM开始执行,这两者的地址不同,就需要一段位置无关代码完成跳转。
具体来说,uboot会把自身镜像从当前运行位置拷贝到链接地址对应的内存区域,然后通过一条长跳转指令跳到DDR中的新地址继续执行。这个过程在代码里通常表现为从relocate_code开始,一直到board_init_r函数被调用。
理解重定位,最直观的方法是查看uboot编译时的System.map映射文件。这个文件包含了每个符号的链接地址,启动后如果你发现函数指针和System.map对不上,说明重定位没有正确执行。调试中我经常用打印函数地址的方式来验证搬移是否成功。
3.2 为何要准备一份全局数据表
uboot的全局数据保存在一个gd结构体里,这个结构体里存放了内存分配信息、启动介质信息、环境变量地址等内容。在重定位之后,gd结构体也需要被搬到新的内存区域,并且要重新计算内部的指针。这个设计初看会觉得绕,但它的意义在于:uboot在重定位前后要访问同一套全局信息,如果gd还指向旧地址,一旦代码跳到DDR运行,访问的数据可能已经在SRAM中被覆盖。
我在学这个阶段的时候,一度对gd和bd(板级数据结构)之间的关系很糊涂。后来用了一个笨办法:在仿真器里把gd结构体的地址打印出来,再把关键字段和实际内存内容做对照,很快就理解了这套设计的存在意义。简单说,gd是全局的“管家”,bd是板级的“户口本”,两者配合起来才能让uboot在不同板卡之间移植时做到最大程度的代码复用。
3.3 栈空间与堆空间划分
重定位之后,uboot的栈和堆空间也需要重新规划。栈是从高地址向下生长的,堆是从低地址向上生长的,两者绝对不能重叠。uboot的配置头文件里会有CONFIG_SYS_MALLOC_LEN和CONFIG_SYS_STACK_SIZE之类的定义,调整这些值时要留足余量,尤其malloc空间,因为环境变量、设备树、驱动模型都要从中分配内存。
我之前遇到过一个问题:uboot运行中执行某个命令时突然挂掉,查了很久才发现是malloc空间不够导致分配失败,而uboot没有很好的错误提示。后来我把malloc区域加大,问题就消失了。这提醒我,在评估内存布局时,宁可多分一点给堆空间,也不要为了一点内存省出稳定性的隐患。
4. 驱动模型与设备初始化
4.1 从board_init_r看初始化顺序
board_init_r是uboot进入C语言总控阶段的入口,从这里开始,它的工作不再是把硬件勉强调通,而是要为加载内核准备好一个完整的软件环境。初始化顺序并不是随意的,比如时钟要早于串口,串口要早于flash和网络接口,因为后面的步骤往往依赖前面的输出或者存储能力。
这个过程在源码里是一张初始化函数表,表中的每一项对应一个初始化函数。uboot会按顺序遍历这张表,每一项执行结束后还可以加入调试打印,观察执行到哪里卡住。如果你移植的板卡在内核引导前某个环节失败,首先要把这张表当成排查地图,而不是漫无目的地加打印信息。
我在实际调试中习惯在关键初始化函数入口处加一个短打印,比如=> init done。这样一旦出现卡死,串口上的最后一行输出就能直接告诉你卡在了哪一项,排查效率会提高很多。调试没问题后再把这些临时打印删掉就行。
4.2 设备树与驱动模型的引入
新版本的uboot引入了设备树技术,所谓设备树,就是把板子的硬件描述信息从C代码中分离出来,存放到一个dtb文件中。uboot启动时把这个dtb文件读进内存,解析节点,再根据节点信息去匹配对应的驱动。好处很直观:换一块板子时,只要换dtb文件,不一定需要重新改驱动代码。
4412的uboot版本相对较早,可能还是以板级代码为主,但这并不妨碍我们理解设备树的思想。在调试时,dtb文件在内存中的加载地址、是否被正确传递到内核,都是需要关注的点。常见的错误是:dtb加载地址和内核解压地址重叠,导致内核启动后访问了被覆盖的设备树,系统直接崩溃。遇到这种问题,调整加载地址或者让uboot把dtb放置到安全区域,基本就能解决。
4.3 命令系统的工作原理
uboot给人的第一印象是一堆交互命令,比如printenv、setenv、saveenv、mmc等。这些命令之所以能识别,是因为uboot内部维护着一张命令表,每个命令项包括命令名、帮助信息、处理函数指针。当你在串口终端输入一行字符,uboot会解析这段输入,在命令表中查找匹配项,找到后就调用对应的处理函数,并把参数传过去。
理解命令系统对调试启动流程非常有用。比如你想在uboot启动时自动执行一串命令,可以设置环境变量bootcmd,uboot在启动阶段检测到bootcmd存在,就会按其中的内容顺序执行命令。这种方式比起每次都手动输入,要省事得多。我在调试中经常临时修改bootcmd,让它先mmc read再bootz,快速验证镜像加载链路是否正常。
5. 环境变量与启动内核的最后一棒
5.1 环境变量的存储与读取
环境变量是uboot里的一批键值对,它们默认有编译时内置的默认值,同时可以从外部存储介质比如eMMC的特定分区或SD卡上加载。用户通过setenv修改后,执行saveenv会将这些内容写回存储介质。这个过程看似简单,但有个细节值得注意:环境变量的存储位置是固定的,通常是一个独立的扇区或分区,如果和镜像、文件系统分区重叠,会导致数据互相覆盖。
为了避免这种问题,4412开发板在烧写镜像时通常会规划一个专门的环境变量分区。我建议刚上手的朋友,拿到开发板后第一时间把环境变量用saveenv保存一次,然后用printenv对比看看,确认读写链路是否正常。这样后面改配置时,心里有底,不会出现存了等于没存的情况。
5.2 内核镜像格式与加载命令
4412的32位内核镜像,常见的有uImage格式和zImage配合设备树两种方式。老的方式是uboot直接加载uImage,并在其中读取内核入口地址和大小信息;新方式则更多是加载zImage和设备树,然后用bootz命令启动。两者之间最大的区别在于:uImage自带头部信息,uboot可以直接解析;zImage需要另外加载dtb,所以uboot还必须知道dtb的内存放位置。
在确认启动命令时,有几个地址必须记录清楚:内核加载地址、dtb加载地址、ramdisk加载地址。这些地址的选择要避开uboot自身占用的区域,也要避免和内核解压地址冲突。4412平台常见的内存起始地址是0x40000000,不同的发行版可能略有差异,务必按实际内存映射来配置。
5.3 bootcmd到bootargs的传递
uboot启动内核前的最后一步,是把内核启动参数传给内核。这些参数通过环境变量bootargs来传递,常见的包括console串口设备号、波特率、根文件系统位置、内存大小等。内核在启动早期就会解析这些参数,如果console设置不对,或者根文件系统路径写错,内核可能直接挂在启动过程中。
做系统启动调试时,我一般会让bootargs里加上init=/bin/sh,这样内核启动后不会跑完整的init进程,而是直接进入shell,非常适合排查根文件系统问题。等确认文件系统没问题后,再去掉这个参数,恢复正常启动流程。这个技巧不复杂,但能省下大量反复烧写根文件系统的等待时间。
6. 启动过程常见问题与排查经验
6.1 串口无输出的第一反应
串口完全没有输出是新手最怕的问题,但其实这种问题往往原因比较集中。我遇到的情况无非那么几种:串口线松动或接错、电源不稳定导致复位反复、OM引脚的启动介质配置和实际烧写介质不匹配、串口波特率设置错误。
排查顺序我建议这样:先拿万用表确认板卡供电正常,再用示波器或逻辑分析仪看串口TX引脚是否有波形。如果TX有波形但电脑显示乱码,检查波特率是否匹配;如果完全没有波形,把启动介质换到另一个测试一遍,也可以观察板载LED是否有生命迹象。我见过一个很典型的误判:板子其实已经在跑了,只是某条虚拟串口线在电脑里占用了错误端口号,导致完全看不到输出。
6.2 DDR初始化失败的典型症状
DDR初始化是一个隐蔽性极强的问题。最常见的情况是uboot偶尔能跑起来,偶尔又卡死,而且卡死的时机完全随机。这种症状很可能来自DDR时序参数设置临界、供电纹波过大,或者内存颗粒本身存在瑕疵。
我在调试一块类似4412的开发板时,遇到uboot启动阶段打印出部分信息后死机,用示波器抓DDR供电,发现纹波明显偏大。后来在供电电路上增加了滤波电容后,问题不再复现。在实验室环境下,如果手头没有示波器,可以用重复读写内存的uboot命令,比如md、mw配合大范围扫描,也可以暴露出内存的不稳定性。
6.3 启动卡在内核引导阶段的分析思路
如果uboot已经正常进入内核引导,打印了Starting kernel...之后屏幕就不再更新,问题范围就从uboot切换到了内核。此时需要确认是否传入了正确的设备树和bootargs,还需要确认内核镜像是否完整、是否和板卡硬件匹配。
一个常见的坑是:文件系统和内核模块是针对不同内核版本编译的,启动后模块加载失败,或者是设备树里的中断号和驱动代码不匹配。这种问题要仔细分清到底是内核该打印却没打印,还是打印了但串口终端没刷新。
6.4 我的一点调试建议
最后分享一个我自己的习惯:调试启动问题时,永远从最简单的反馈开始确认。先看电源灯亮不亮,再看串口有没有输出,再看回车键能不能唤醒uboot命令提示符,然后一步步往前推。如果这一步没确认,后面所有的高级手段都会变成猜谜。
再有就是,改uboot源码时不要一次改一大堆,最好改动一小块、编译一次、烧录一次,验证一次。哪怕损失一些编译时间,也远比出问题后对着diff文件反复揣摩要节省时间。我前几年学的时候,经常一次性改了时钟、DDR、串口三处,结果出问题完全分不清是哪个改动引起的,吃了不少苦头。
启动流程的每一步都有它的必然性,只要你对照硬件手册和源码,按顺序核对,绝大多数问题都能找到根因。这也是嵌入式学习最有意思的地方——看起来千奇百怪的现象,背后往往是一两个看似不起眼的配置项在起作用。