资讯详情

RISC-V裸机启动全流程:从复位向量到main函数的七步实现

📅 2026/10/9 0:01:46 | 华诺云谱 👁 阅读
RISC-V裸机启动全流程:从复位向量到main函数的七步实现
1. 这不是“Hello World”而是芯片睁眼的第一瞥RISC-V bare-metal 启动到底在干啥你手头那块刚焊好的 RISC-V 开发板通电后 LED 不亮、串口没输出、调试器连不上——它不是坏了它只是还没“醒”。而唤醒它的过程就是 bare-metal 启动。这不是操作系统加载的后续环节而是整个系统运转的绝对起点从复位信号拉低那一刻起CPU 内部逻辑清零、PC 指针跳转到固定地址、第一条指令开始取指执行……这一整套由硬件强制定义、软件必须严丝合缝配合的初始动作链就是我们说的“启动流程”。它不依赖任何操作系统、不调用任何库函数、不假设任何内存初始化状态纯粹靠汇编语言直接操控寄存器、配置时钟、搬运代码、建立栈空间——就像给一台刚出厂的发动机手动点火、挂挡、预热直到它能自主稳定运转。核心关键词risc-v、bare-metal、启动、汇编、链接脚本每一个都不是孤立概念risc-v 定义了指令集和复位向量地址0x00000000 或可配置的 ROM 地址bare-metal 意味着你必须自己承担所有底层责任启动是这个责任链条的总开关汇编是唯一能精确控制每一条指令执行顺序的语言而链接脚本则是告诉链接器“这段代码放哪”“这段数据放哪”“栈顶在哪”的地图。我做过不下二十款 RISC-V 芯片的启动适配从 GD32VF103 这类入门级 MCU到 Kendryte K210、StarFive JH7100 这类带双核和 DDR 的 SoC再到 SiFive U74 多核服务器级芯片发现一个铁律启动失败90% 的问题出在链接脚本与实际硬件布局错位而非汇编代码写错。比如你把 .data 段链接到 0x80000000但实际 DDR 初始化后物理地址是从 0x80100000 开始那程序拷贝过去就全乱套再比如 .stack_size 设为 1K但在中断处理里一压几十个寄存器瞬间栈溢出导致 PC 跳飞——这些细节在 Linux 下被内核屏蔽得严严实实但在 bare-metal 里它们就是生死线。这篇文章不讲抽象理论只拆解真实开发板上电后那几百毫秒内发生的一切从 reset vector 到 main() 的完整路径每一步为什么这么写、怎么验证、踩过哪些坑。适合正在调试开发板、想搞懂 bootloader 原理、或准备做 RISC-V 固件开发的工程师哪怕你只写过 Arduino只要愿意看懂寄存器定义就能跟着跑通第一个 blinky。2. 启动流程全景图从硬件复位到 C 函数入口的七步通关bare-metal 启动不是单一线性过程而是一个分层递进、环环相扣的状态跃迁。我把它拆成七个不可跳过的阶段每个阶段都有明确的输入、输出和失败判据。这七步不是教科书上的理想模型而是我在 JH7100 开发板上用逻辑分析仪抓取 reset 信号、用 OpenOCD 实时监控 PC 变化、用串口打印每个阶段标记后总结出的真实路径。2.1 阶段一硬件复位与向量跳转0μs ~ 1μs当开发板按下复位键或上电瞬间SoC 内部复位电路拉低 CPU 的 reset 引脚所有寄存器清零PC程序计数器被强制加载为复位向量地址。RISC-V 标准规定该地址为 0x00000000但实际芯片厂商常通过 OTP 或引脚配置将其重映射到片上 ROM如 0x1000、SPI Flash 映射区如 0x20000000或 SRAM如 0x08000000。以 StarFive JH7100 为例其默认复位向量指向内部 BootROM0x1000该 ROM 会先检测 GPIO 状态决定启动源SD 卡、eMMC、SPI Flash再将二级 bootloader如 FSBL拷贝到 SRAM 执行。而 GD32VF103 这类 MCU则直接从 0x00000000即 Flash 起始取第一条指令。关键点在于你写的 startup.s 必须放在链接脚本指定的入口地址且该地址必须与硬件复位向量一致。如果链接脚本把 _start 放在 0x80000000但硬件复位跳转到 0x00000000那 CPU 就会从 Flash 乱码处开始执行大概率立即触发非法指令异常。2.2 阶段二初始栈设置与 CSR 配置1μs ~ 10μsCPU 从复位向量取到第一条指令通常是la sp, _stack_top立刻建立初始栈。RISC-V 使用sp寄存器作为栈指针且要求栈地址必须 16 字节对齐因sd/ld指令优化。此时 RAM 尚未初始化栈只能设在片上 SRAM如 GD32VF103 的 0x20000000~0x2000FFFF。紧接着必须配置三个关键 CSRControl and Status Registermstatus设置 MIEMachine Interrupt Enable为 0关中断MPIEPrevious Interrupt Enable为 0MPPPrevious Privilege Mode为 0b11Machine Mode这是进入机器模式的标志mtvec设置中断向量基址bare-metal 通常设为la t0, _exception_vector; csrw mtvec, t0指向自定义异常处理表mhartid读取当前 HART ID硬件线程 ID多核场景下用于分支执行如仅 HART 0 执行初始化。提示csrw指令写 CSR 时若目标 CSR 不存在如某些精简版 core 不支持mepc会触发 illegal instruction exception。务必查阅芯片手册确认支持的 CSR 列表。2.3 阶段三RAM 初始化与数据段搬运10μs ~ 500μs这是最容易被忽略却最致命的阶段。.data段已初始化全局变量存储在 Flash 中但运行时需在 RAM 中有一份副本.bss段未初始化全局变量需在 RAM 中清零。搬运逻辑必须用汇编硬编码la t0, _sidata // Flash 中 .data 起始地址 la t1, _sdata // RAM 中 .data 起始地址 la t2, _edata // RAM 中 .data 结束地址 copy_data: beq t1, t2, copy_done lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j copy_data copy_done: la t0, _sbss la t1, _ebss clear_bss: beq t0, t1, clear_done sw zero, 0(t0) addi t0, t0, 4 j clear_bss clear_done:注意_sidata、_sdata等符号由链接脚本生成必须与脚本中 MEMORY 和 SECTIONS 定义严格对应。例如若链接脚本将.data定义在SRAM (RWX) : ORIGIN 0x20000000, LENGTH 128K则_sdata就是 0x20000000_edata是 0x20020000。一旦错位搬运就会覆盖栈空间或代码区。2.4 阶段四时钟与外设使能500μs ~ 5msMCU 的外设UART、GPIO、TIMER默认处于复位禁用状态必须手动开启。以 GD32VF103 为例使能 AHB 总线时钟li t0, 0x1f; csrw 0x800, t00x800 是 AHBCLKEN 寄存器地址使能 GPIOA 时钟li t0, 0x2; csrw 0x804, t00x804 是 APB2CLKEN配置 PA0 为推挽输出li t0, 0x00000001; csrw 0x808, t00x808 是 GPIOA_CTL0配置 UART0 时钟并初始化波特率需计算USART_BAUD寄存器值公式为DIV (APB_CLK / (16 * BAUD))APB_CLK108MHz 时115200 波特率对应 DIV58.59 → 取整 58。实操心得时钟配置错误会导致 UART 输出乱码波特率不准或 GPIO 无响应时钟未使能。建议用示波器测 PA0 翻转波形频率应等于system_clock / (2 * prescaler)这是验证时钟树是否正确的黄金标准。2.5 阶段五C 运行时环境构建5ms ~ 10ms调用main()前GCC 工具链要求建立标准 C 环境设置gpglobal pointer寄存器la gp, __global_pointer$用于访问 GOTGlobal Offset Table设置tpthread pointerli tp, 0单线程 bare-metal 中可设为 0调用__libc_init_array()执行.init_array段中的构造函数如 C 全局对象构造最终跳转jal main。此阶段失败常表现为main不执行或执行后立即死机。原因多为.init_array符号未正确定义、gp值错误导致 GOT 访问越界、或main函数签名不符合 ABI必须为int main(void)或int main(int argc, char *argv[])。2.6 阶段六主循环与异常处理10ms ~ forevermain()进入后典型结构为int main(void) { uart_init(); // 初始化串口 gpio_init(); // 初始化 LED 引脚 timer_init(); // 初始化定时器 uart_puts(Bare-metal boot OK!\r\n); while(1) { gpio_toggle(LED_PIN); delay_ms(500); } }同时必须实现异常处理函数否则任何未处理异常如除零、非法地址访问都会导致系统锁死。RISC-V 异常处理框架要求mtvec指向的向量表中索引 0 为 machine-mode exception索引 1 为 machine-mode interrupt每个异常处理函数末尾必须执行mret返回而非ret关键寄存器mepc,mcause,mtval需在处理前保存避免被覆盖。2.7 阶段七调试锚点与状态验证贯穿全程没有 printf 的裸机调试全靠“锚点”定位故障点在阶段一后插入uart_putc(1)验证复位向量和初始栈在阶段三后插入uart_putc(2)验证数据搬运在阶段四后插入uart_putc(3)验证时钟和外设在main开头插入uart_putc(M)确认 C 环境就绪。 若卡在某字符即可精准定位故障阶段。我曾遇到 JH7100 启动卡在 2最终发现是链接脚本中_sidata地址比 Flash 中实际.data起始地址小 0x100导致搬运时读取了无效 Flash 区域——这种细节只有靠锚点逐段验证才能暴露。3. 链接脚本bare-metal 的灵魂地图写错一行就全盘崩溃链接脚本linker script不是可有可无的配置文件它是 bare-metal 世界的宪法——它定义了代码、数据、栈在物理内存中的绝对位置决定了 CPU 能否正确取指、数据能否正确加载、栈能否安全生长。我见过太多人花三天调试 UART 无输出最后发现只是链接脚本里.text段的ORIGIN写错了 0x100。下面以 GD32VF103128KB Flash 32KB SRAM为例逐行解析一个生产级链接脚本。3.1 MEMORY 块声明物理内存拓扑MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 128K RAM (rwx) : ORIGIN 0x20000000, LENGTH 32K }FLASH (rx)rx表示该区域可读read、可执行execute但不可写write符合 Flash 特性ORIGIN 0x08000000这是 GD32VF103 Flash 的起始物理地址必须与芯片手册一致LENGTH 128K128*1024 131072 字节不能写成128K链接器不识别单位必须是数字RAM (rwx)rwx表示可读、可写、可执行因栈和代码可能放 SRAMORIGIN 0x20000000是 SRAM 起始地址。注意若开发板使用外部 SPI FlashFLASH的ORIGIN应设为映射后的地址如 0x20000000且需确保 BootROM 已将其正确映射。写错 ORIGIN 会导致整个程序加载到错误位置CPU 取到乱码指令。3.2 SECTIONS 块定义逻辑段布局SECTIONS { . ALIGN(4); _flash_start .; .text : { *(.text.entry) /* 复位向量必须放在最前 */ *(.text .text.*) /* 主要代码段 */ . ALIGN(4); _etext .; } FLASH .rodata : { *(.rodata .rodata.*) . ALIGN(4); _erodata .; } FLASH .data : { _sdata .; *(.data .data.*) . ALIGN(4); _edata .; } RAM AT FLASH /* AT 表示加载地址在 FLASH运行地址在 RAM */ .bss : { _sbss .; *(.bss .bss.*) *(COMMON) . ALIGN(4); _ebss .; } RAM .stack (NOLOAD) : { . ALIGN(16); _stack_start .; . 2048; /* 2KB 栈空间 */ _stack_top .; } RAM }.是当前位置计数器ALIGN(4)确保段起始地址 4 字节对齐RISC-V 指令要求.text.entry专门存放复位向量和启动代码*(.text.entry)确保它排在.text段最前面这样链接器生成的二进制文件开头就是reset_handler FLASH.text段的运行地址VMA和加载地址LMA都在 FLASH RAM AT FLASH.data段的运行地址在 RAM但加载地址即编译后二进制中存储的位置在 FLASH这正是阶段三数据搬运的依据(NOLOAD).stack段不占用二进制文件空间只在运行时分配避免烧录文件过大_stack_top .定义栈顶地址供汇编中la sp, _stack_top使用。3.3 符号定义连接汇编与 C 的桥梁链接脚本末尾必须导出关键符号供 startup.s 引用PROVIDE(_stack_top 0x20008000); /* 若 RAM 从 0x20000000 开始32K0x8000 */ PROVIDE(_sidata LOADADDR(.data)); /* .data 加载地址FLASH 中位置 */ PROVIDE(_sdata ADDR(.data)); /* .data 运行地址RAM 中位置 */ PROVIDE(_edata ADDR(.data) SIZEOF(.data)); PROVIDE(_sbss ADDR(.bss)); PROVIDE(_ebss ADDR(.bss) SIZEOF(.bss));LOADADDR(.data)返回.data段在二进制文件中的偏移即 Flash 中的地址ADDR(.data)返回.data段在内存中的起始地址即 RAM 中的地址SIZEOF(.data)返回.data段大小用于计算结束地址。实操心得用arm-none-eabi-objdump -h your.elf查看各段地址验证链接脚本是否生效。若.data的 VMA 显示为0x20000000而 LMA 为0x08001000说明AT FLASH生效若两者相同则AT未生效数据搬运会失败。3.4 常见陷阱与避坑指南错误类型具体表现排查方法解决方案ORIGIN 错位程序烧录后完全无响应用objdump -d查看_start地址是否等于复位向量核对芯片手册修正 MEMORY 中 ORIGINAT 丢失.data变量始终为 0objdump -t查看_sidata是否等于.dataLMA在.data段后添加AT FLASH栈溢出main执行几条后死机逻辑分析仪抓取sp寄存器变化看是否低于_stack_start增大.stack大小或检查递归/大数组对齐错误ld报section alignment错误objdump -h查看段 flags 是否含Aallocatable在段定义前加ALIGN(4)确保指令对齐4. 汇编启动文件用最原始的语言完成最精密的初始化startup.s 是 bare-metal 的心脏起搏器它用 RISC-V 汇编指令完成从硬件复位到 C 环境就绪的全部工作。下面是一个经过 JH7100 和 GD32VF103 双平台验证的通用模板每一行都附带原理说明和实操注释。4.1 复位向量与异常向量表.section .text.entry, ax .global _start _start: # 设置初始栈指针 la sp, _stack_top # 关中断设为 Machine Mode li t0, 0x80000000 # mstatus: MIE0, MPIE0, MPP0b11 csrw mstatus, t0 # 设置异常向量基址 la t0, _exception_vector csrw mtvec, t0 # 清除 BSS 段若链接脚本未自动处理 la t0, _sbss la t1, _ebss clear_bss_loop: beq t0, t1, bss_clear_done sw zero, 0(t0) addi t0, t0, 4 j clear_bss_loop bss_clear_done: # 搬运 DATA 段 la t0, _sidata # Flash 中 .data 起始 la t1, _sdata # RAM 中 .data 起始 la t2, _edata # RAM 中 .data 结束 copy_data_loop: beq t1, t2, data_copy_done lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j copy_data_loop data_copy_done: # 设置 global pointer la gp, __global_pointer$ # 跳转到 C 入口 jal main .section .text.exception, ax _exception_vector: # 索引 0: Machine Exception j handle_exception # 索引 1: Machine Interrupt j handle_interrupt # 索引 2~31: 保留 .rept 30 j handle_exception .endr.section .text.entry, axax表示该段可分配a、可执行x且必须放在.text段最前la sp, _stack_toplaload address是伪指令展开为auipcaddi高效加载 32 位地址csrw mstatus, t0csrwCSR write直接写控制寄存器0x80000000是mstatus的 bit31MIE清零即关中断j handle_exceptionjjump是无条件跳转比jal少一个寄存器操作更高效。4.2 异常处理函数让系统在错误中存活.section .text.exception, ax handle_exception: # 保存关键寄存器到栈 addi sp, sp, -128 # 分配 128 字节栈空间 sd ra, 0(sp) # 保存返回地址 sd sp, 8(sp) # 保存原栈指针 sd gp, 16(sp) sd tp, 24(sp) sd t0, 32(sp) sd t1, 40(sp) sd t2, 48(sp) # ... 保存所有 callee-saved 寄存器 # 读取异常信息 csrr t0, mcause # 获取异常原因 csrr t1, mepc # 获取异常发生地址 csrr t2, mtval # 获取异常附加信息如非法地址 # 打印调试信息需 UART 驱动已初始化 # call uart_printf(EXCEPTION: mcause%x, mepc%x\r\n, t0, t1) # 恢复寄存器 ld ra, 0(sp) ld sp, 8(sp) ld gp, 16(sp) ld tp, 24(sp) ld t0, 32(sp) ld t1, 40(sp) ld t2, 48(sp) # ... 恢复所有寄存器 addi sp, sp, 128 mret # 从异常返回恢复 mstatus 并跳转到 mepcaddi sp, sp, -128为异常处理分配独立栈空间避免破坏主栈csrrCSR read读取mcause异常原因码值为 0x00000001 表示指令地址错误0x00000002 表示指令访问错误mret是唯一合法的异常返回指令它会从mstatus恢复中断使能状态并跳转到mepc地址。4.3 C 运行时支持让标准库函数可用bare-metal 中printf、malloc等函数需重定向底层 I/O。以printf为例需实现_write系统调用#include sys/stat.h #include unistd.h extern void uart_puts(const char *s); int _write(int fd, char *ptr, int len) { if (fd STDOUT_FILENO || fd STDERR_FILENO) { for (int i 0; i len; i) { if (ptr[i] \n) { uart_puts(\r\n); } else { uart_putc(ptr[i]); } } return len; } return -1; } // 必须实现的空函数否则链接失败 void _exit(int status) { while(1); } void _kill(int pid, int sig) { while(1); } int _getpid(void) { return 1; }STDOUT_FILENO定义在unistd.h中值为 1uart_puts是你实现的串口输出函数需确保其为阻塞式等待发送完成_exit等函数是 newlib 的桩函数bare-metal 中只需无限循环不真正退出。注意若使用printf但未实现_write链接时会报undefined reference to _write。这是新手最常见的链接错误之一。5. 实操全流程从零开始点亮 GD32VF103 的 LED现在我们把前面所有理论落地为一次完整的实操。目标在 GD32VF103 开发板上不依赖任何 SDK纯手写汇编链接脚本C实现 LED 闪烁。整个过程在 Ubuntu 22.04 下完成工具链为riscv64-unknown-elf-gcc。5.1 环境准备与工具链安装# 添加 Ubuntu 源 sudo apt update sudo apt install -y software-properties-common sudo add-apt-repository -y ppa:team-gcc-arm-embedded/ppa sudo apt update # 安装 RISC-V 工具链 sudo apt install -y gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf \ gdb-riscv64-unknown-elf openocd # 验证安装 riscv64-unknown-elf-gcc --version # 输出应为 riscv64-unknown-elf-gcc (GNU Toolchain for RISC-V) 12.2.05.2 创建项目目录结构mkdir -p gd32-baremetal/{src,include,scripts} cd gd32-baremetal目录结构gd32-baremetal/ ├── Makefile ├── startup.s # 汇编启动文件 ├── link.ld # 链接脚本 ├── main.c # C 主程序 ├── uart.c # 串口驱动 └── gpio.c # GPIO 驱动5.3 编写链接脚本 link.ld/* link.ld */ MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 128K RAM (rwx) : ORIGIN 0x20000000, LENGTH 32K } SECTIONS { . ALIGN(4); _flash_start .; .text : { *(.text.entry) *(.text .text.*) . ALIGN(4); _etext .; } FLASH .rodata : { *(.rodata .rodata.*) . ALIGN(4); _erodata .; } FLASH .data : { _sdata .; *(.data .data.*) . ALIGN(4); _edata .; } RAM AT FLASH .bss : { _sbss .; *(.bss .bss.*) *(COMMON) . ALIGN(4); _ebss .; } RAM .stack (NOLOAD) : { . ALIGN(16); _stack_start .; . 2048; _stack_top .; } RAM PROVIDE(_stack_top 0x20000800); PROVIDE(_sidata LOADADDR(.data)); PROVIDE(_sdata ADDR(.data)); PROVIDE(_edata ADDR(.data) SIZEOF(.data)); PROVIDE(_sbss ADDR(.bss)); PROVIDE(_ebss ADDR(.bss) SIZEOF(.bss)); }5.4 编写汇编启动文件 startup.s/* startup.s */ .section .text.entry, ax .global _start _start: la sp, _stack_top # 关中断设为 Machine Mode li t0, 0x80000000 csrw mstatus, t0 # 设置异常向量 la t0, _exception_vector csrw mtvec, t0 # 清 BSS la t0, _sbss la t1, _ebss clear_bss_loop: beq t0, t1, bss_clear_done sw zero, 0(t0) addi t0, t0, 4 j clear_bss_loop bss_clear_done: # 搬 DATA la t0, _sidata la t1, _sdata la t2, _edata copy_data_loop: beq t1, t2, data_copy_done lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j copy_data_loop data_copy_done: # 设置 GP la gp, __global_pointer$ # 跳转 main jal main .section .text.exception, ax _exception_vector: j handle_exception j handle_interrupt .rept 30 j handle_exception .endr handle_exception: addi sp, sp, -128 sd ra, 0(sp) sd sp, 8(sp) sd gp, 16(sp) sd tp, 24(sp) sd t0, 32(sp) sd t1, 40(sp) sd t2, 48(sp) csrr t0, mcause csrr t1, mepc csrr t2, mtval # 此处可加调试输出 ld ra, 0(sp) ld sp, 8(sp) ld gp, 16(sp) ld tp, 24(sp) ld t0, 32(sp) ld t1, 40(sp) ld t2, 48(sp) addi sp, sp, 128 mret handle_interrupt: # 简单处理清除中断标志后返回 mret5.5 编写 C 主程序 main.c/* main.c */ #include gpio.h #include uart.h void delay_ms(unsigned int ms) { volatile unsigned int i, j; for (i 0; i ms; i) { for (j 0; j 1000; j); } } int main(void) { uart_init(); gpio_init(); uart_puts(GD32VF103 Bare-metal Boot OK!\r\n); while(1) { gpio_set(LED_PIN); delay_ms(500); gpio_reset(LED_PIN); delay_ms(500); } return 0; }5.6 编写 GPIO 驱动 gpio.c/* gpio.c */ #include gpio.h #define RCU_BASE 0x50000000 #define GPIOA_BASE 0x20000000 #define RCU_APB2EN *(volatile unsigned int*)(RCU_BASE 0x18) #define GPIOA_CTL0 *(volatile unsigned int*)(GPIOA_BASE 0x00) #define LED_PIN 0 void gpio_init(void) { // 使能 GPIOA 时钟 RCU_APB2EN | (1 0); // bit0 GPIOAEN // 配置 PA0 为推挽输出 GPIOA_CTL0 ~(0xf (LED_PIN * 4)); // 清除原配置 GPIOA_CTL0 | (0x1 (LED_PIN * 4)); // MODE 0b01 (输出) }
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑