29、不同架构的启动对比:ARM vs RISC-V vs X86

做嵌入式这些年,我接触过不少架构。ARM、RISC-V、X86,这三兄弟的启动流程,说白了就是「从复位到main函数」这条路怎么走。但每条路的走法,差别还挺大。

我记得刚入行那会儿,被一个X86的BIOS问题折腾了两天。后来切到ARM项目,发现人家压根没有BIOS这回事。嗯,这就是架构差异带来的「惊喜」。

启动流程的宏观对比

先看一张总览图,把三个架构的启动路径画出来。你对比着看,心里就有谱了。

ARM vs RISC-V vs X86 启动流程对比 ARM (Cortex-M) 复位 → 取向量表 SP/PC 初始化 跳转 Reset_Handler BSS/Data 初始化 跳转 main() RISC-V (RV32) 复位 → PC=0x80000000 trap_vector 入口 设置栈指针 sp BSS/Data 初始化 跳转 main() X86 (IA-32) 复位 → CS:IP=0xF000:FFF0 BIOS POST 自检 加载引导扇区 Bootloader → 内核 跳转 main()

从图上你能看出来,ARM和RISC-V的启动路径比较「瘦」,X86则多了一层BIOS/UEFI的「脂肪」。这跟它们的历史包袱有关。

ARM 启动:向量表驱动

ARM Cortex-M系列的启动,我个人觉得是最优雅的。它靠一张向量表搞定一切。

芯片上电后,硬件自动做两件事:

  • 从地址 0x00000000 读取栈指针(SP)初始值
  • 从地址 0x00000004 读取复位向量(Reset_Handler 地址)

然后直接跳过去执行。就这么简单。

关键点:ARM Cortex-M 的向量表前两个字必须是 SP 和 PC。这是硬件写死的,改不了。

我在项目中遇到过一个问题:有人把向量表放到了 0x08000000(Flash 起始地址),但芯片复位后默认从 0x00000000 取向量。结果怎么都跑不起来。后来发现是忘了配置 VTOR 寄存器。嗯,这个坑我踩过。

典型的启动代码长这样:

__Vectors       DCD     __initial_sp          ; 栈顶地址
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler           ; NMI
                DCD     HardFault_Handler     ; 硬错误
                ; ... 其他中断向量

Reset_Handler   PROC
                EXPORT  Reset_Handler
                ; 初始化 .bss 段
                LDR     r0, =__bss_start
                LDR     r1, =__bss_end
                MOV     r2, #0
bss_loop        CMP     r0, r1
                ITT     LT
                STR     r2, [r0], #4
                BLT     bss_loop
                
                ; 跳转 main
                BL      main
                ENDP

RISC-V 启动:灵活但费心

RISC-V 的启动,说白了就是「给你自由,但你要自己负责」。它没有 ARM 那种硬件自动加载向量表的机制。

复位后,PC 被设置成一个固定地址(通常是 0x80000000 或 0x00000000,取决于具体实现)。然后从那里开始执行第一条指令。

这里有个细节:RISC-V 的异常向量表(trap vector)需要软件自己设置。通过 csrw mtvec, handler_addr 指令写入 mtvec 寄存器。

我的建议:写 RISC-V 启动代码时,第一件事就是设置栈指针。因为很多 RISC-V 核复位后 sp 是未定义的。我曾经见过有人没设栈就调用函数,结果压栈压到了非法地址,直接跑飞。

一个典型的 RISC-V 启动代码片段:

.section .text.init
.globl _start
_start:
    // 设置栈指针
    la      sp, _stack_top
    
    // 设置 trap 向量
    la      t0, trap_handler
    csrw    mtvec, t0
    
    // 清零 bss
    la      t0, __bss_start
    la      t1, __bss_end
bss_clear:
    sw      zero, 0(t0)
    addi    t0, t0, 4
    blt     t0, t1, bss_clear
    
    // 跳转 main
    jal     main

你想想看,RISC-V 的启动代码其实比 ARM 多了一步:手动设置异常向量。ARM 的向量表是硬件自动识别的,RISC-V 得靠软件写寄存器。这就是「灵活」的代价。

X86 启动:历史包袱最重

X86 的启动流程,嗯,怎么说呢,它背负了太多历史兼容性。从 8086 到现在的酷睿,启动方式一直在变,但核心逻辑没变。

复位后,CPU 处于实模式。CS=0xF000, IP=0xFFF0,所以第一条指令地址是 0xFFFF0。这个地址映射到 BIOS ROM 的最后 16 字节。

BIOS 会做以下几件事:

  1. POST(上电自检):检查内存、CPU、外设
  2. 初始化中断向量表(IVT)
  3. 扫描引导设备,加载 MBR 到 0x7C00
  4. 跳转到 0x7C00 执行引导代码

注意:X86 的启动代码必须处理实模式到保护模式的切换。这涉及到 GDT、LDT、页表等一系列复杂操作。我当年第一次写保护模式切换代码时,被段描述符的权限位折腾得够呛。

一个简化的 X86 启动流程(从 BIOS 到 main):

; 引导扇区代码 (加载到 0x7C00)
[org 0x7C00]
start:
    ; 设置段寄存器
    xor     ax, ax
    mov     ds, ax
    mov     es, ax
    mov     ss, ax
    mov     sp, 0x7C00
    
    ; 加载内核到内存
    mov     ah, 0x02        ; 读扇区
    mov     al, 0x10        ; 16个扇区
    mov     ch, 0           ; 柱面0
    mov     cl, 2           ; 起始扇区2
    mov     dh, 0           ; 磁头0
    mov     bx, 0x1000      ; 加载地址
    int     0x13
    
    ; 跳转到保护模式入口
    jmp     0x1000

你看,X86 的启动代码里还有 int 0x13 这种 BIOS 中断调用。这在 ARM 和 RISC-V 的世界里根本不存在。说白了,X86 的启动依赖 BIOS 这个「中间人」,而 ARM 和 RISC-V 直接跟硬件打交道。

三个架构的核心差异

我把关键差异整理成了一张表,方便你对比:

对比项 ARM (Cortex-M) RISC-V (RV32) X86 (IA-32)
复位后 PC 来源 向量表偏移 0x04 硬件固定地址 CS:IP = 0xF000:FFF0
栈指针初始化 硬件自动加载 软件手动设置 软件手动设置
异常向量设置 硬件自动识别 csrw mtvec IDT 设置
是否需要 BIOS
启动模式 直接运行 直接运行 实模式 → 保护模式
启动代码复杂度

避坑指南

做启动代码开发,有些坑是共通的。我根据自己的经验,给你列几条:

  • 栈指针必须最先设置:不管什么架构,复位后第一件事就是确认栈指针有效。我曾经在 RISC-V 上吃过亏,没设栈就调了函数,结果压栈到 0 地址,直接 HardFault。
  • BSS 清零不能忘:全局变量默认值必须是 0,这是 C 标准要求的。如果启动代码忘了清 BSS,未初始化的全局变量就是随机值。这个 bug 极难排查。
  • 向量表对齐:ARM 要求向量表 512 字节对齐,RISC-V 要求 mtvec 按 4 字节对齐。不对齐的话,硬件可能取错向量地址。
  • X86 的 A20 地址线:如果你在 X86 上做裸机开发,别忘了开启 A20 地址线。否则访问 1MB 以上的内存会回绕。这个坑我当年调了整整一天。

总结一下:ARM 的启动最省心,硬件帮你做了大部分工作。RISC-V 给你自由,但你要自己搭框架。X86 历史包袱最重,启动流程最复杂。选哪个架构,取决于你的项目需求和个人偏好。我个人做小项目喜欢用 ARM,省事;做研究或定制化项目,RISC-V 更灵活。


公众号:蓝海资料掘金营,微信deep3321