移植第一步:启动代码与链接脚本

说实话,ARMv8 的移植工作里,启动代码和链接脚本是最容易让人栽跟头的部分。我见过不少团队,应用层写得飞起,结果卡在板子一上电就跑飞了——连个打印都看不到。说白了,CPU 复位后第一脚踩在哪里,决定了整个系统的生死。

这一章我们就把启动流程拆开揉碎。你跟着我走一遍,心里就有底了。

4.1 向量表重定位:CPU 的第一张地图

ARMv8 的异常向量表,说白了就是一张“中断跳转地图”。CPU 遇到异常(比如 IRQ、FIQ、同步异常),会直接查这张表,跳到对应的入口地址。

默认情况下,向量表基地址是 0x00000000。但实际项目中,我们通常把代码放在别的地址,比如 0x80000000。这时候就需要重定位向量表。

核心寄存器:VBAR_ELx
VBAR_EL1、VBAR_EL2、VBAR_EL3 分别对应不同异常等级。
写入新基地址后,CPU 就会从新地址查表。

我在项目中遇到过一个问题:板子启动后,一触发中断就死机。查了半天,发现是 VBAR_EL1 没设置,CPU 还在读 ROM 里的旧向量表。嗯,这种坑踩一次就记住了。

重定位的典型步骤:

  1. 在链接脚本里给向量表分配一个独立段,比如 .vectors
  2. 启动代码里用 ldr x0, =__vectors_start 拿到基地址
  3. 用 msr VBAR_EL1, x0 写入
  4. 用 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 字节
注意:ARMv8 的向量表每个入口必须 128 字节对齐(.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,启动代码里要循环清零
我曾经踩过的坑:有一次我把 .data 段的加载地址(LMA)和运行地址(VMA)搞混了。结果全局变量在 RAM 里全是乱码,系统跑起来各种诡异。后来我养成了一个习惯:在链接脚本里显式标注 AT>ROM,确保加载地址明确。

4.4 栈指针初始化:给 C 代码一个落脚点

在跳转到 C 语言的 main() 之前,必须把栈指针 SP 设置好。ARMv8 的栈是满递减的,也就是说 SP 指向栈顶,压栈时 SP 先减再存。

初始化步骤:

  1. 在链接脚本里定义栈顶地址,比如 __stack_top = 0x90000000;
  2. 启动代码里用 ldr x0, =__stack_top 加载地址
  3. 用 mov sp, x0 设置栈指针
  4. 如果使用多个异常等级,每个 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
建议:栈大小要预留足够。我一般给每个异常等级分配 4KB 栈空间,调试阶段可以加大到 16KB。如果栈溢出,系统会踩到相邻内存,这种 bug 极难排查。

4.5 整体启动流程:一张图说清楚

下面这张图把整个启动流程串起来了。你对照着看,心里会更清楚。

ARMv8 启动流程与内存布局 ARMv8 启动流程与内存布局 CPU 复位 设置 VBAR_ELx 配置异常向量表 初始化 SP_ELx 清零 .bss / 拷贝 .data 跳转 main() 内存布局示意 0x80000000 .vectors 0x80000200 .text 0x88000000 .data 0x8F000000 .bss 0x90000000 栈顶

你看,整个流程其实就六步。每一步都依赖上一步的结果。如果向量表没设对,后面的异常处理全是空中楼阁。如果栈指针没初始化,C 代码连局部变量都存不了。

核心原则:启动代码是硬件和软件之间的桥梁。桥没搭好,后面的应用跑得再快也没用。

我个人习惯在启动代码里加一个“看门狗喂食”的步骤。如果某一步卡住了,看门狗超时复位,至少能知道系统没跑起来。调试阶段特别有用。

嗯,这一章的内容就到这里。你把这些基础打牢了,后面的内存管理、外设驱动移植才能顺风顺水。