移植第一步:启动代码与链接脚本
说实话,ARMv8 的移植工作里,启动代码和链接脚本是最容易让人栽跟头的部分。我见过不少团队,应用层写得飞起,结果卡在板子一上电就跑飞了——连个打印都看不到。说白了,CPU 复位后第一脚踩在哪里,决定了整个系统的生死。
这一章我们就把启动流程拆开揉碎。你跟着我走一遍,心里就有底了。
4.1 向量表重定位:CPU 的第一张地图
ARMv8 的异常向量表,说白了就是一张“中断跳转地图”。CPU 遇到异常(比如 IRQ、FIQ、同步异常),会直接查这张表,跳到对应的入口地址。
默认情况下,向量表基地址是 0x00000000。但实际项目中,我们通常把代码放在别的地址,比如 0x80000000。这时候就需要重定位向量表。
VBAR_EL1、VBAR_EL2、VBAR_EL3 分别对应不同异常等级。
写入新基地址后,CPU 就会从新地址查表。
我在项目中遇到过一个问题:板子启动后,一触发中断就死机。查了半天,发现是 VBAR_EL1 没设置,CPU 还在读 ROM 里的旧向量表。嗯,这种坑踩一次就记住了。
重定位的典型步骤:
- 在链接脚本里给向量表分配一个独立段,比如
.vectors - 启动代码里用
ldr x0, =__vectors_start拿到基地址 - 用
msr VBAR_EL1, x0写入 - 用
isb指令同步,确保生效
// 汇编示例:重定位向量表
.section .vectors, "ax"
.globl __vectors_start
__vectors_start:
// 同步异常(EL1t)
.align 7
b _start
// IRQ(EL1t)
.align 7
b irq_handler
// FIQ(EL1t)
.align 7
b fiq_handler
// SError(EL1t)
.align 7
b serror_handler
// ... 后续 12 个入口,每个对齐 0x80 字节
.align 7)。如果对齐不对,CPU 会跳到错误地址。我见过有人手写偏移量算错了,结果整个中断系统瘫痪。
4.2 异常向量表配置:别让中断裸奔
向量表重定位只是第一步。你想想看,表是放对了,但表里的每个入口指向哪里?如果入口里啥都没有,中断来了还是死。
ARMv8 的异常向量表分四大类,每类又有四个子类型:
| 异常类型 | 触发条件 | 典型处理 |
|---|---|---|
| 同步异常 | 指令执行错误、系统调用 | 保存现场、打印错误信息 |
| IRQ | 普通中断 | 跳转中断控制器、查中断号 |
| FIQ | 快速中断 | 直接处理高优先级事件 |
| SError | 系统错误(总线错误等) | 记录错误、尝试恢复或复位 |
每个异常类型下,又根据当前异常等级(EL)和栈指针选择(SP_ELx 还是 SP_EL0)分成四个入口。所以整个表一共 16 个入口,每个 128 字节。
我个人习惯在启动阶段把所有未使用的异常入口都指向一个 panic_handler。这样一旦出现意外异常,能立刻定位,而不是让系统静默崩溃。
// 未使用异常的统一处理
panic_handler:
// 保存所有寄存器
stp x0, x1, [sp, #-16]!
// 打印异常类型(通过 ESR_EL1 获取)
mrs x0, ESR_EL1
bl print_esr
// 死循环等待调试器
1: wfi
b 1b
brk 指令。这样调试器一碰就停,不用反复烧写固件。
4.3 链接脚本适配:把代码安排明白
链接脚本决定了你的代码、数据、BSS 段放在哪里。移植到新平台时,最常改的就是 MEMORY 和 SECTIONS 命令。
一个典型的 ARMv8 链接脚本结构:
MEMORY
{
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 0x10000000
}
SECTIONS
{
. = 0x80000000;
.text : {
*(.vectors)
*(.text*)
} > RAM
.rodata : {
*(.rodata*)
} > RAM
.data : {
*(.data*)
} > RAM
.bss : {
__bss_start = .;
*(.bss*)
__bss_end = .;
} > RAM
}
这里有几个关键点:
- 向量表必须放在最前面:CPU 复位后从
0x80000000开始执行,第一条指令就是向量表里的b _start - .data 段要初始化:全局变量需要从 ROM 拷贝到 RAM,启动代码里要处理
- .bss 段要清零:未初始化的全局变量默认是 0,启动代码里要循环清零
AT>ROM,确保加载地址明确。
4.4 栈指针初始化:给 C 代码一个落脚点
在跳转到 C 语言的 main() 之前,必须把栈指针 SP 设置好。ARMv8 的栈是满递减的,也就是说 SP 指向栈顶,压栈时 SP 先减再存。
初始化步骤:
- 在链接脚本里定义栈顶地址,比如
__stack_top = 0x90000000; - 启动代码里用
ldr x0, =__stack_top加载地址 - 用
mov sp, x0设置栈指针 - 如果使用多个异常等级,每个 EL 都要单独设置 SP
// 设置 EL1 的栈指针
.section .text.startup
.globl _start
_start:
// 设置栈指针
ldr x0, =__stack_top
mov sp, x0
// 清零 BSS 段
ldr x0, =__bss_start
ldr x1, =__bss_end
bl clear_bss
// 跳转到 C 语言 main
bl main
// 死循环
1: wfi
b 1b
4.5 整体启动流程:一张图说清楚
下面这张图把整个启动流程串起来了。你对照着看,心里会更清楚。
你看,整个流程其实就六步。每一步都依赖上一步的结果。如果向量表没设对,后面的异常处理全是空中楼阁。如果栈指针没初始化,C 代码连局部变量都存不了。
我个人习惯在启动代码里加一个“看门狗喂食”的步骤。如果某一步卡住了,看门狗超时复位,至少能知道系统没跑起来。调试阶段特别有用。
嗯,这一章的内容就到这里。你把这些基础打牢了,后面的内存管理、外设驱动移植才能顺风顺水。