6.2 裸机启动代码逐行拆解


6.2 裸机启动代码逐行拆解

本节摘要:裸机上电后,处理器从固定地址取第一条指令,此时没有栈、没有 C 运行时、没有内存保护——启动代码要在跳进 main 之前亲手搭好这四样:向量表就位、栈指针就位、数据段搬到该在的位置、BSS 段清零。本节把一段典型启动代码逐行拆解,并给出"卡在启动"的排错清单。

操作系统里的程序"生来就有"很多东西:栈是现成的、全局变量已被初始化、内存访问有人兜底。裸机世界把这些全部抽走——上电那一刻,通用寄存器的值是随机的,sp 指向不确定的地方,内存控制器可能还没配好。启动代码就是在这样的蛮荒里开出第一块田。读懂它是嵌入式工程师的核心技能,也是理解"程序从哪里开始"这个哲学问题的技术答案。

上电后的一小段时间里发生了什么

先固定几个事实。处理器上电或复位后,PC 从架构定义的入口地址开始取指——具体地址由芯片的启动配置决定(有的从片上 ROM 的 boot loader 开始,有的直接从闪存零地址)。此时 MMU 与缓存都是关的,你看到的所有地址都是物理地址(第 4 章埋的这个伏笔在这里兑现);栈不存在;C 语言的世界完全不存在。芯片厂商的 boot loader 通常已经完成时钟与内存控制器初始化,把控制权交给你时代码已经能跑——但"能跑"不等于"能写 C",因为还没有栈。启动代码的使命就是把这个世界从"机器码能跑"升级到"C 能跑"。

逐行拆解:四部曲启动代码

下面是一段典型的 AArch64 启动汇编,对应一个能跑 C 的最小环境。逐行拆:

.section .text.boot, "ax" .global _start _start: // 第一部曲:栈指针就位——没有它连函数调用都不可能 ldr x0, =_stack_top // 链接脚本定义的栈顶符号 mov sp, x0 // 栈向下生长,指针放顶 // 第二部曲:向量表就位——任何异常立刻有处可去 ldr x0, =vectors msr vbar_el1, x0 isb // 第三部曲:数据段搬运——已初始化全局变量的初值躺在闪存里,要搬进 RAM ldr x0, =_data_load // 初值在装载映像里的地址(闪存) ldr x1, =_data_start // 运行时该在的地址(RAM) ldr x2, =_data_end .Lcopy: cmp x1, x2 b.hs .Lzero // 无符号高于等于:搬完了,注意用了无符号条件码 ldrb w3, [x0], #1 // 后索引逐字节搬,第 4 章的寻址模式 strb w3, [x1], #1 b .Lcopy // 第四部曲:BSS 清零——未初始化全局变量的 C 语义承诺 .Lzero: ldr x1, =__bss_start ldr x2, =__bss_end mov w3, #0 .Lclr: cmp x1, x2 b.hs .Lgo strb w3, [x1], #1 b .Lclr // 终点:跳进 C 世界 .Lgo: bl main .Lhang: // main 返回即悬停——裸机没有"退出"可去 wfe // 低功耗等待事件 b .Lhang

六段代码各有必答的为什么。第一部曲必须最先——bl 指令要把返回地址写 x30,后续 C 函数要压栈,没有 sp 一切免谈。第二部曲放在搬运之前,理由是防御:万一搬运代码触发异常(对齐失误之类),至少异常处理桩是可用的,不会飞进随机地址。第三部曲的背景是嵌入式特有的"装载地址与运行地址分离"——代码躺在闪存(装载域),已初始化变量的初值也躺在闪存,但 C 语义要求变量在可写的 RAM 里,所以要搬。第四部曲对应 C 标准"未初始化全局变量为零"的承诺,不搬不等于为零,RAM 上电内容是随机的,必须亲手清。终点那句 wfe 是裸机的"退出语义"——没有操作系统接住返回的 main,只能原地省电悬停。

图 6-3:启动时刻的内存映像迁移

图 6-3:启动时刻的内存映像迁移

链接脚本:地图的绘制者

细心的你会发现代码里引用了一串符号——_stack_top_data_start__bss_end——它们全不在任何源文件里定义,而是链接脚本画地图时定下的界碑。链接脚本至少要声明四件事:代码段的装载地址(闪存哪里开始)、数据段的"双地址"(装载在闪存、运行在 RAM,这正是第三部曲搬运两个地址的来源)、BSS 段的起止、栈顶的位置。第 7 章工具链一节会正式写一个完整链接脚本,这里先记住分工:启动汇编是照地图施工的人,链接脚本才是画地图的人,两者对不上就是"卡在启动"类故障的最大来源。

排错清单:卡在启动的三大惯犯

裸机启动失败的表现单调而绝望——灯不亮、串口无输出、调试器里 PC 停在奇怪的地方。三大惯犯按概率排序:其一,向量表或代码段地址与芯片启动配置不符(芯片从零地址取指,你却把代码链到了别处),对策是核对链接脚本首地址与芯片手册的启动地址;其二,sp 没设或指向不存在的内存,第一个函数调用就 faults,对策是调试器直接看 sp 值落在不在有效 RAM 区间;其三,搬运或清零越界把栈区踩了,症状是"跑到某个函数必崩",对策是给边界符号打印数值核对。三招之外还有一个通用手段:用调试器单步跟完启动代码——它总共不到一百条指令,逐条看 PC 与寄存器,任何异常都藏不住。

深挖:boot ROM、二级加载与界碑的实证

真实芯片的启动链条常有两段:芯片内部 boot ROM 先跑,它负责最基础的时钟与存储初始化,然后从闪存或串口把你的镜像搬进 RAM 跳过去——你写的启动代码是链条的第二环。工程含义有二:入口地址不是你随便定的(boot ROM 跳转的落点由芯片手册规定);镜像头部可能需要特定魔数(厂商 bootloader 靠它确认镜像合法)。拿到新板子先读芯片手册的启动配置那一章,比改十行代码都有用。

6.2 正文说链接脚本是地图,这里补一个"验图"技巧:把全部界碑符号在启动早期用最朴素的手段输出(串口或调试器内存窗口),与链接脚本声明的数值逐一对照:

nm image.elf | grep -E "_data|_bss|_stack" 应与脚本声明一致 _data_start = 0x42000000 _data_end = 0x420003a8 (搬运长度即差值 0x3a8) __bss_start = 0x420003a8 __bss_end = 0x42001f50 _stack_top = 0x421ff000

搬运与清零的循环次数直接由这些差值决定——界碑错一位字节,搬的就差一个字。变式:故意把链接脚本里栈顶挪出 RAM 范围一兆,复现一次"第一个函数调用就飞"的故障,再用调试器看 sp 与访问地址对不上号——亲手制造过一次,排错时就有了对照样本。

本节要点回顾

  • 四部曲:栈指针、向量表、数据段搬运、BSS 清零,顺序有讲究,栈最先。
  • 双地址分离:已初始化数据"装载在闪存、运行在 RAM",启动时亲手搬。
  • BSS 必须清:RAM 上电内容随机,C 的零值承诺靠启动代码兑现。
  • 链接脚本定界碑:启动代码引用的全部符号来自链接脚本,两者要对得上。
  • 裸机无退出:main 返回即悬停,wfe 是终点站。

启动这条路打通了,你已具备白手起家的全部技能。下一节看现代芯片加的"护栏":TrustZone 与指针认证。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U