ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RISC-V启动流程与Bootloader全解析:从复位向量到内核加载

RISC-V启动流程与Bootloader全解析:从复位向量到内核加载 搞嵌入式这些年我一直有个感受RISC-V 的启动流程相关资料其实不少但绝大多数都散落在芯片手册、U-Boot 邮件列表和各种零散的博客里真正把“上电那一刻到内核跑起来”这条链路串成一条线来讲的内容非常少。尤其是很多从 ARM 转过来的朋友拿着 STM32 的启动经验套 RISC-V第一步就懵了——我的代码到底该放哪个地址第一条指令在哪为什么有的芯片上电先进 ROM 有的直接跑 Flash这篇文章就是想解决这个问题把 RISC-V 启动流程与 Bootloader 的完整路径拆开揉碎从复位向量、机器模式、BootROM/SPL/U-Boot 逐级引导再到最终把控制权交给内核MCU 和 SoC 两种场景都会讲到也会用 QEMU 做一次完整的实操验证。不管你是做 RISC-V 板级移植、编译自己的启动固件还是单纯想把 bootloader 和内核加载的原理彻底搞明白这篇内容应该能帮你省下不少翻文档的时间。1. 上电后的第一分钟复位向量与机器模式1.1 复位时 CPU 到底在什么状态很多做 ARM 开发的朋友习惯了一个预设上电后 CPU 从 0x00000000 或者 0x08000000 开始取指向量表里放着栈指针和复位入口。但 RISC-V 不是这么设计的或者说它把“芯片从哪个地址启动”这个决定权留给了芯片厂商。RISC-V 规范里只规定了复位后的特权级是机器模式M 模式、mstatus等 CSR 处于确定的初始值但 PC 到底从哪个地址开始取值完全由具体芯片的复位向量决定。也就是说你拿到一款 RISC-V 芯片第一件事就是去查手册里的“Reset Vector”或者“Boot Address”。这里有个非常典型的例子QEMU 的 virt 平台复位向量在 0x1000芯片内部固化的启动 ROM 会从这里开始执行然后跳转到内存起始地址 0x80000000而 GD32VF103 这类面向 MCU 场景的芯片复位向量指向 Flash 起始地址 0x20000000至于 CH32V 系列直接从 0x00000000 开始取指。同样是 RISC-V启动地址千差万别这是和 ARM 最大的一个思维差异。除了 PC 不确定复位后还有一个容易踩坑的点栈指针sp是未定义的。ARM Cortex-M 上电后会自动从向量表加载 MSP但 RISC-V 没有这个机制你必须在启动汇编里第一条指令就设置sp。很多第一次写 RISC-V 启动代码的人写完跳转指令就call main结果main里第一条压栈指令就把数据写到了地址 0 上自然跑飞。所以在启动代码里设置全局指针gp和栈指针sp永远是最先要做的两件事。1.2 链接脚本与启动汇编如何定义内存布局知道了复位向量下一步就是让编译器和链接器知道代码该放哪。RISC-V 裸机开发中链接脚本的优先级极高它决定了.text、.data、.bss的落位。我一般会这样写一个最小模板OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN 0x80000000, LENGTH 128M } SECTIONS { . 0x80000000; .text : { KEEP(*(.text.start)) *(.text .text.*) *(.rodata .rodata.*) } RAM .data : { __global_pointer$ . 0x800; *(.data .data.*) *(.sdata .sdata.*) } RAM .bss : { __bss_start .; *(.bss .bss.*) __bss_end .; } RAM . ALIGN(16); . . 0x1000; _stack_top .; }这里ENTRY(_start)告诉链接器入口符号是_startKEEP(*(.text.start))确保启动段不会被链接器当作无用代码优化掉。启动汇编对应的代码大概是这个样子.section .text.start .globl _start _start: la gp, __global_pointer$ la sp, _stack_top la t0, __bss_start la t1, __bss_end 1: bgeu t0, t1, 2f sb zero, 0(t0) addi t0, t0, 1 j 1b 2: call main 3: wfi j 3b这段代码的逻辑并不复杂设置gp和sp清空 BSS 段然后调用 C 入口。但有几个细节值得注意。gp是 RISC-V 的全局指针寄存器编译器会用它在gp相对寻址范围内访问小数据效率比绝对寻址高启动时越早设置越好。BSS 清零必须在调用任何 C 函数之前完成因为 C 语言默认未初始化全局变量为 0如果你的启动代码忘了清 BSS静态变量初始值就是随机的排查起来相当隐蔽。提示如果你用的是带压缩指令扩展C 扩展的工具链链接脚本里的段对齐建议至少 2 字节对齐否则可能生成非法指令。后面排查部分我会再详细说。1.3 为什么 RISC-V 没有“向量表”这个概念ARM Cortex-M 上电后第一个字加载到 MSP第二个字作为复位向量跳转向量表还顺带处理中断。RISC-V 不太一样它没有一个“从固定地址读取向量表”的硬件机制。复位向量是芯片固定的但中断和异常入口是通过mtvec这个 CSR 来配置的。也就是说你在启动代码里除了设置sp还需要在进入 main 逻辑前把mtvec指向你自己的 trap 处理函数。如果mtvec没设置一旦发生异常比如非法指令、访问了不存在的地址CPU 会跳到一个默认的或者未定义的地址去执行表现出来的现象就是“程序莫名跑飞”“卡死在某个奇怪的地址”。调试的时候你可能会看到 PC 停在 0xffffffff 之类的地址这时候先查mtvec和 trap 处理函数十有八九能发现问题。提示严格来说复位向量是一个由芯片定义、独立于mtvec的概念mtvec主要服务于运行时异常和中断。很多文章把两者混为一谈理解的时候要区分开。2. 两类启动路径MCU 直接运行与 SoC 分级引导2.1 MCU 场景Flash 起步RT-Thread 启动初始化流程先看 MCU 场景这类芯片的特点是内部集成 Flash 和 SRAM上电后直接从 Flash 的复位向量地址开始执行不需要外部引导程序。比较典型的是 GD32VF103、CH32V 系列、ESP32-C3 这类带 RISC-V 内核的单片机。它们的启动路径很短复位向量 → 启动汇编 → C 入口 → 应用 main 函数。如果用 RT-Thread 做系统启动流程会在这个基础上增加一层系统初始化。RT-Thread 在 RISC-V 上的板级支持包通常是这样组织的汇编启动文件负责设置sp、清零 BSS然后跳转到entry()这个 C 函数。entry()里做的事情很清晰一步步来rt_hw_board_init()完成时钟、串口等硬件初始化rt_show_version()打印版本号接着是系统定时器初始化、堆初始化、应用初始化最后启动调度器。这里我特别想强调rt_hw_board_init()的重要性。很多人在移植 RT-Thread 到新芯片时看到系统起来后没有 log 输出第一反应是串口驱动写错了但实际上更常见的问题是串口时钟没使能或者 UART 的 GPIO 复用没配置。在 RISC-V MCU 上外设时钟门控、引脚复用这些操作和 ARM 一样需要做不要因为内核换了就忽略这些基础部分。MCU 场景还有一个细节如果你做了 Bootloader App 的架构比如把 Bootloader 放在 Flash 起始地址App 放在偏移地址那么 App 的链接地址和中断向量表地址都要相应偏移。RT-Thread 的rt_hw_interrupt_init()里会设置mtvec如果你把 App 搬到了偏移地址别忘了同步更新向量表的位置否则中断一进来就跳错地方。2.2 SoC 场景BootROM → SPL → U-Boot 逐级放大SoC 场景比 MCU 复杂一个量级因为芯片内部没有可直接运行的 Flash固件通常存在 SPI NOR Flash、SD 卡或者 eMMC 上。问题来了CPU 复位后DDR 内存还没初始化芯片只能先把代码放到片内 SRAM 里执行而片内 SRAM 一般只有几十到几百 KB根本放不下一个完整的 U-Boot更别说 Linux 内核了。所以 SoC 的启动必须分级。第一级是 BootROM它是芯片出厂时固化在硅片里的代码上电后 CPU 从 BootROM 执行。BootROM 做的事情很有限初始化最基本的时钟把启动介质SD、SPI Flash、NAND 等上的第二级引导程序读到片内 SRAM然后跳转过去。第二级引导程序通常是 SPL 或者 FSBL它比 BootROM 灵活主要任务是把 DDR 初始化好然后把完整的 U-Boot 从启动介质读到 DDR 里再跳转执行。最后 U-Boot 负责加载内核和设备树启动 Linux。这个过程你可以把它类比成“洋葱式”的逐级引导每一级只做最小必要的事情然后把控制权交给下一级。为什么要这样因为 BootROM 是写死的没法更新SPL 足够小能塞进 SRAM能完成 DDR 初始化这种比较复杂的任务U-Boot 比较大但放到 DDR 里就完全没压力了可以做文件系统、网络、显示等更丰富的功能。一级比一级能干但每一级的能力受到所在存储空间的限制。U-Boot 在 RISC-V 上还有一个特别之处它往往不是跑在 M 模式的。现代 RISC-V SoC 通常会有 OpenSBI 作为 M 模式固件U-Boot 跑在 S 模式通过ecall指令调用 OpenSBI 提供的服务比如定时器、串口、远程中断等。这样做的好处是内核不再直接操作硬件而是通过 SBI 接口访问资源安全性和可移植性都更好。我把 MCU 和 SoC 的启动差异整理成了下面的表格对比项MCU 场景SoC 场景启动介质内部 FlashSPI NOR / SD / eMMC / NAND复位后代码位置芯片固定地址如 0x20000000芯片内部 BootROM是否初始化 DDR一般不需要SPL/FSBL 阶段完成引导层级Bootloader App 可选BootROM → SPL → U-Boot → 内核典型 RTOSRT-Thread / FreeRTOS 直接跑通常配 Linux调试难度较低串口即可高需要调试多级固件这张表未必覆盖所有芯片但能帮你快速建立整体认知。拿到一款新 RISC-V 芯片先判断它属于哪一类启动代码的组织方式就基本清楚了。2.3 STM32 Bootloader 经验迁移哪些能复用哪些要重来搜“STM32 Bootloader”资料的人特别多不少朋友是从 STM32 转到 RISC-V 的。我得说Bootloader 的核心思想是完全通用的上电初始化硬件、校验固件、跳转到 App这个流程在 RISC-V 上一样成立。Cortex-M 和 RISC-V 的裸机 Bootloader 在架构层面最大的差异在于中断向量表的处理方式。STM32 上 App 的中断向量表可以通过修改 VTOR 寄存器来重定位而 RISC-V 里mtvec本身就是软件设置的只要 App 启动代码里有设置mtvec的逻辑Bootloader 跳转过去后中断自然能正常处理。另一个差异是跳转前的状态清理。STM32 跳转前通常要关中断、把外设复位到默认状态RISC-V 也一样而且还要多考虑一件事如果 Bootloader 跑在 M 模式App 也跑在 M 模式那直接用jalr跳转即可但如果 Bootloader 在 M 模式而 App 在 S 模式比如你要跑 RT-Thread 的用户态组件就必须通过mret切换权限级别同时设置好mstatus.MPP。这个细节很多人容易忽略我在后面实操部分会展示一个迷你示例。3. 内核加载的最后一棒设备树、参数传递与权限切换3.1 a0 和 a1RISC-V 给内核传参的约定Bootloader 的最终使命是把内核加载到内存里并跳转过去。在 ARM 上老内核用 r0/r1/r2 传参新内核用 r0 0、r1 machine type、r2 DTB 地址RISC-V 则明确了两个参数寄存器a0传递当前 hart idCPU 核心编号a1传递设备树二进制文件DTB的物理地址。如果有多核启动每个核的a0值不同内核会根据a0判断当前是主核还是从核主核继续初始化从核进入等待。很多从 ARM 转过来的朋友把内核镜像加载好、跳转过去结果内核起来后完全不知道内存大小、串口在哪、中断控制器怎么配问题就出在a1上——设备树没传对。RISC-V 和 ARM64 一样极度依赖设备树来描述硬件信息。U-Boot 里常见的操作是setenv kernel_addr_r 0x82000000 setenv fdt_addr_r 0x88000000 load mmc 0:1 ${kernel_addr_r} /boot/Image load mmc 0:1 ${fdt_addr_r} /boot/riscv-virt.dtb booti ${kernel_addr_r} - ${fdt_addr_r}这里的booti会读取内核镜像头部信息把 DTB 地址放到a1把当前 hart id 放到a0然后跳转。如果你是自己写的裸机引导代码跳转前千万别忘了这两件事。我见过不少人在 QEMU 里手写启动代码跳转 RT-Thread结果 RT-Thread 起来后内存检测不对查了半天最后发现是a1没传 DTB 地址——当然 RT-Thread 不依赖 DTB 也能跑但内核模式下问题就会被放大。3.2 M 模式与 S 模式的切换mret 是最关键的一条指令RISC-V 特权级切换和 ARM 不太一样。ARM 的异常返回用ERET权限级别切换很大程度上依赖 EL 和异常模型RISC-V 则是用mret、sret这类指令实现模式切换。如果要从 M 模式进入 S 模式执行代码步骤是这样的先把目标地址写入mepc把mstatus.MPP设置为 S 模式二进制 01然后执行mretCPU 就会跳到mepc指定的地址同时进入 S 模式。这里有个容易踩的坑直接用jalr跳转到 S 模式代码虽然 PC 会过去但 CPU 还是 M 模式。在 M 模式下访问 S 模式的 CSR比如satp、sscratch会触发非法指令异常就算你暂时不访问这些 CSR后续行为也完全不可预期。正确的做法永远是设置mepc和mstatus.MPP然后用mret切换。OpenSBI 干的事情其实就是这个的工程化版本。它运行在 M 模式提供 SBI 运行时服务然后通过mret把 CPU 交给 S 模式的内核或 U-Boot。内核运行期间遇到需要特权操作比如电源管理、定时器的时候通过ecall指令陷入 M 模式OpenSBI 处理完再用mret返回。理解了这条路径你就明白了为什么 RISC-V 的启动链里 OpenSBI 几乎成了标配。4. 手把手实操用 QEMU 跑通一条最小启动链4.1 从零写一个 30 行的最小 Bootloader理论说再多不如动手跑一遍。我建议初学者先别急着上 U-Boot而是用 QEMU 的 virt 平台写一个最小的裸机引导程序跑通了再层层加码。QEMU virt 平台的关键信息如下内存起始地址0x80000000UART 地址0x10000000NS16550A 兼容中断控制器PLIC 0x0C000000、CLINT 0x02000000先准备一个main.c功能是初始化 UART 然后打印一行字符#define UART_BASE 0x10000000UL #define UART_THR (*(volatile unsigned char *)(UART_BASE 0x00)) #define UART_LSR (*(volatile unsigned char *)(UART_BASE 0x05)) static void uart_init(void) { /* 8 位数据位无校验1 停止位 */ *(volatile unsigned char *)(UART_BASE 0x03) 0x03; } static void uart_putc(char c) { /* 等待发送保持寄存器为空 */ while ((UART_LSR 0x20) 0) ; UART_THR c; } void main(void) { const char *s Hello RISC-V Bootloader!\r\n; uart_init(); while (*s) uart_putc(*s); for (volatile int i 0;; i) ; }配合前面给出的start.S和link.ld用如下命令编译riscv64-unknown-elf-gcc -marchrv64gc -mabilp64d -nostdlib -ffreestanding -T link.ld start.S main.c -o bootloader.elf如果本地没装riscv64-unknown-elf-gcc用发行版的riscv64-linux-gnu-gcc也可以只是链接的时候要加上-nostdlib。然后启动 QEMUqemu-system-riscv64 -M virt -m 128M -nographic -bios none -kernel bootloader.elf看到串口输出Hello RISC-V Bootloader!就说明这条链路已经通了复位向量、启动汇编、链接脚本、UART 初始化每一步都走对了。如果没有任何输出优先检查三件事编译是否用了正确的-march和-mabi、链接脚本里的起始地址是否是 0x80000000、sp是否设置成功。提示不同 QEMU 版本对-kernel参数处理可能略有差异如果无法启动可以改用-device loader,filebootloader.elf,cpu-num0加载 ELF 文件。4.2 用 RT-Thread 验证完整系统启动路径裸机跑通之后再来看带操作系统的场景。RT-Thread 对 RISC-V 的支持已经很成熟仓库里有现成的 QEMU virt 板级支持包直接构建就能跑git clone https://github.com/RT-Thread/rt-thread.git cd rt-thread/bsp/qemu-virt64-riscv scons -j8 qemu-system-riscv64 -M virt -nographic -kernel rtthread.elf不同版本的仓库目录名可能略有差异qemu-virt64-riscv和qemu-riscv-virt64都出现过找不到的话在 bsp 目录下搜一下riscv即可。启动后你会看到 RT-Thread 的版本号、构建时间然后是调度器启动最后进入msh命令行。能走到这一步说明系统初始化链路已经完全没问题了。RT-Thread 在 QEMU 上的启动流程正好和我们前面讲的裸机流程一一对应。我建议你把start.S和entry.c打开对照着看汇编部分做sp/gp设置和 BSS 清零然后进入entry()entry()调用rt_hw_board_init()初始化时钟和串口接着是系统组件初始化最后rt_system_scheduler_start()启动调度器。这套流程和裸机的差异其实只在于 RT-Thread 把硬件初始化封装成了 BSP 接口架构层面的启动顺序是完全一致的。实操中有一个点值得特意看一下RT-Thread 在这个 BSP 里跑的是 M 模式还是 S 模式默认情况下 QEMU virt 的 RT-Thread 直接跑 M 模式不需要 OpenSBI。这意味着-bios参数不会再加载默认的 OpenSBI 固件你的整个系统权限级别都是机器模式。如果后续想跑 Linux再引入 OpenSBI U-Boot 的链路理解上的跨度会小很多。4.3 用 QEMU 日志与 GDB 观察启动现场如果只是看输出还不够过瘾QEMU 提供了很好的调试手段。用-d in_asm可以打印执行的指令流这对排查“第一条指令到底在哪执行”特别有帮助qemu-system-riscv64 -M virt -m 128M -nographic -bios none -kernel bootloader.elf -d in_asm,cpu_reset日志里能看到 CPU 从复位向量开始执行的每一条指令以及 PC 的变化轨迹。我第一次用它观察自己写的 Bootloader 时发现跳转后 PC 跳到了一个完全没预料到的地址马上意识到是链接脚本的段顺序出了问题。这种问题如果只靠串口输出排查可能要折腾大半天。GDB 调试也同样方便qemu-system-riscv64 -M virt -m 128M -nographic -bios none -kernel bootloader.elf -s -S另开一个终端riscv64-unknown-elf-gdb bootloader.elf target remote localhost:1234连接后可以先info registers看一下启动时的寄存器状态然后在main函数打断点用continue继续执行观察a0、a1的值。如果你在调内核加载的链路这种方式比打印日志直观得多尤其是检查参数传递是否正确的时候一行p/x $a1就能确认 DTB 地址。5. 常见启动问题排查与经验速查5.1 上电后完全没输出的四类原因“上电后串口什么都没有”应该是我被问得最多的问题。按我的经验原因基本集中在四类UART 地址不对、时钟没初始化、启动代码没执行到、sp 设置有问题。UART 地址不对是最容易犯的错。RISC-V 不同芯片的串口地址差异极大QEMU virt 是 0x10000000GD32VF103 的 USART0 是 0x40013800全志 D1 的 UART0 又是另一个地址。如果不确定打开芯片手册或者设备树文件确认不要凭经验猜。时钟没初始化在 MCU 上尤其常见有些芯片的 UART 外设默认是关闭的必须先使能时钟门控否则代码怎么写都不会有输出。启动代码没执行到也是高频问题。很多人喜欢在main里加打印忽略了main之前的汇编部分。如果汇编的 BSS 清零逻辑写错了比如bgeu用成了bltu导致main根本没被调用你打印放哪儿都没用。这时候用-d in_asm看指令流或者直接 GDB 打断点比瞎猜高效得多。sp 没初始化的问题前面说过call main之前没设置栈指针程序大概率一进函数就崩崩了自然没输出调试时会被误认为“完全没反应”。5.2 非法指令与权限异常的排查思路另一种常见现象是程序好像跑了但跑到一半报非法指令错误或者 PC 跳到异常地址。这类问题多半是三种原因。第一种是工具链参数不匹配。-marchrv64gc-mabilp64d是标配组合如果你用-marchrv64imac-mabilp64d或者反过来-marchrv64gc-mabilp64生成代码可能包含当前 ABI 不支持的模式跑起来就非法指令。更隐蔽的是编译时开了 C 扩展压缩指令但运行时环境的实现或者链接脚本的对齐不满足要求同样会触发问题。所以先把编译参数统一确认好再怀疑别的。第二种是权限级别不对。M 模式代码访问 S 模式 CSR或者 S 模式代码直接写 M 模式寄存器都会触发异常。这类问题排查时先看mcause的值mcause2表示非法指令mcause1表示指令访问异常mcause5表示加载访问异常。然后读mepc看异常发生的 PC 地址再对照反汇编代码查具体是哪条指令出问题基本能定位到是权限问题还是访问了不存在的地址。第三种是 trap 处理函数没设置或者设置错误。mtvec如果指向了非对齐地址、或者 trap 处理函数里没有保存现场一旦发生异常就会二次异常表现为“死循环”或者“神秘重启”。初学者建议先写一个简单的 trap handler把mcause和mepc打印出来对调试帮助极大。5.3 一张速查表带走最后把我常用的信息和经验整理成一张表方便你排查问题时快速对照问题可能原因排查方向串口无输出UART 地址错误 / 时钟未使能 / sp 未设置查手册确认地址用-d in_asm看 PC跳转后跑飞链接脚本入口错误 / 段顺序不对检查.text.start是否 KEEP确认起始地址Illegal instructionmarch/mabi 不匹配 / C 扩展对齐问题统一编译参数检查链接脚本对齐权限异常M/S 模式切换未用 mret查mstatus.MPP设置检查mepc内核无响应a0 / a1 未传对 / 内核入口地址不对GDB 打断点看$a0$a1中断不进 handlermtvec 未设置或地址错误确认mtvec写入值和 trap handler 对齐5.4 个人经验最容易翻车的往往不是大逻辑说句实在话我做了这么多启动相关的工作发现真正让人卡住好几天的往往不是“BootROM 怎么跳转”“U-Boot 怎么传参”这种大逻辑而是链接脚本里一个符号没定义、启动汇编里一条分支指令用错、或者工具链参数不匹配这种小问题。RISC-V 的启动链路很长每一级都可能出问题但每一级的问题又都有清晰的排查路径先看 PC 在哪再看寄存器状态最后看链接脚本和编译参数。如果你是刚接触 RISC-V我的建议是不要一上来就啃 U-Boot 和 Linux 的完整启动流程先花半天时间把 QEMU 上的最小 Bootloader 跑通再进 RT-Thread 看系统初始化最后再碰 OpenSBI U-Boot 这条重链路。每一层的知识都是下一层的基础踏踏实实走一遍收获比看十篇文档都大。等你把这条链路彻底吃透了再回头去看任何一款 RISC-V 芯片的手册都会觉得豁然开朗——因为启动的骨架就摆在那里剩下的只是具体地址和寄存器序号的差异了。
返回列表