ARTICLE DETAIL

资讯详情

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

RISC-V裸机启动全流程:从复位向量到main函数的深度解析

RISC-V裸机启动全流程:从复位向量到main函数的深度解析 1. 从复位向量到main函数RISC-V裸机启动到底经历了什么很多人第一次接触RISC-V裸机开发脑子里会有一个巨大的问号一块芯片上电之后没有任何操作系统帮忙第一条指令到底从哪里来栈指针是谁设置的全局变量什么时候被初始化如果你之前玩过STM32之类的ARM Cortex-M芯片你可能习惯了启动文件里那段汇编帮你把一切安排妥当但换到RISC-V平台上这套流程的细节和ARM有相似之处也有不少自己的脾气。这篇文章要聊的就是RISC-V从复位到进入main函数之间发生的所有事情。我会把链接脚本的写法、启动汇编的逐行逻辑、多核启动的处理策略、以及实际调试中容易踩的坑都拆开讲清楚。不管你是刚拿到一块RISC-V开发板的新手还是从ARM平台迁移过来想搞清楚底层差异的老手这篇内容都能帮你把上电之后到底发生了什么这件事彻底搞明白。整篇内容会围绕几个核心问题展开复位向量怎么定位、链接脚本怎么描述内存布局、启动代码怎么完成C运行时环境搭建、多核场景下从核怎么被唤醒。每个环节我都会给出可复现的代码和配置并且解释为什么这么做而不是只丢一段汇编让你自己悟。2. 复位向量与第一条指令CPU上电后去哪了2.1 硬件层面的复位行为RISC-V规范本身并没有强制规定复位向量的地址这一点和ARM也不太一样。具体跳到哪里完全由芯片厂商决定。大多数RISC-V MCU和SoC会把复位向量放在地址0x00000000或者某个固定的ROM区域比如0x80000000常见于SiFive系列或者0x20000000。芯片手册里通常会有一个Boot ROM或者Reset Vector的章节告诉你这个地址。上电或者复位之后CPU做的事情非常有限PC被设置为复位向量地址其他寄存器基本处于未定义状态有些实现会清零有些不会不要依赖这个行为。此时没有栈、没有堆、没有初始化过的数据段你面对的就是一块裸的硬件。这里有一个容易忽略的点复位之后mstatus寄存器里的MIE位通常是0也就是全局中断关闭的。但个别实现可能有不同的默认值所以启动代码里最好显式地关闭中断不要假设硬件帮你做了。2.2 第一条指令应该做什么复位向量处放的第一条指令通常是一条跳转指令跳到真正的启动代码入口。为什么不直接把启动代码放在复位向量处因为复位向量附近的空间往往很有限比如只有几十个字节放不下完整的启动逻辑。所以典型做法是在复位向量处放一条j _start或者j reset_handler。_start这个符号名是约定俗成的链接器默认会把它当作程序入口。你也可以在链接脚本里用ENTRY()指定别的符号名但用_start是最省事的做法工具链和调试器都认这个名字。启动代码的第一件事通常是设置栈指针。在RISC-V里栈指针是sp寄存器也就是x2。设置栈指针的方式取决于你的内存布局一般是从链接脚本里导出的栈顶符号加载la sp, _stack_top这条指令把_stack_top的地址加载到sp里。_stack_top是链接脚本里定义的一个符号通常指向RAM区域的最高地址因为栈是向下增长的。2.3 为什么栈要放在RAM最高地址栈向下增长是RISC-V以及大多数架构的约定。把栈顶设在RAM最高地址的好处是栈的空间和堆的空间可以相向增长最大化利用可用的RAM。如果你的RAM是64KB从0x80010000到0x80020000那栈顶就设在0x80020000栈从高地址往低地址长堆从低地址往高地址长中间的空闲区域就是它们共享的空间。当然在裸机环境里你未必需要堆malloc但栈是必须的。没有栈你连C函数都调不了因为函数调用需要保存返回地址和局部变量。3. 链接脚本把代码和数据安排到正确的位置3.1 链接脚本的基本结构链接脚本linker script是裸机开发中最关键也最容易被忽视的文件。它决定了你的代码段、数据段、BSS段分别放在哪个地址栈顶在哪里入口点是什么。一个典型的RISC-V裸机链接脚本长这样OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x80000000, LENGTH 512K RAM (rwx) : ORIGIN 0x80080000, LENGTH 64K } SECTIONS { .text : { *(.text.entry) *(.text .text.*) } FLASH .rodata : { *(.rodata .rodata.*) } FLASH .data : { _data_start .; *(.data .data.*) _data_end .; } RAM AT FLASH .bss : { _bss_start .; *(.bss .bss.*) *(COMMON) _bss_end .; } RAM _stack_top ORIGIN(RAM) LENGTH(RAM); }这段脚本里几个关键点值得展开说。ENTRY(_start)告诉链接器程序入口是_start符号。MEMORY块定义了FLASH和RAM的起始地址和大小这些值必须和你的芯片手册一致写错了程序跑不起来。.text段放在FLASH里因为代码是只读的。注意我把*(.text.entry)放在最前面这是为了确保启动代码排在FLASH的最开头也就是复位向量指向的位置。如果你的复位向量地址和FLASH起始地址不一致还需要额外处理。3.2 .data段的LMA和VMA问题.data段的处理是链接脚本里最容易出错的地方。.data段存放的是有初始值的全局变量和静态变量这些变量的初始值存储在FLASH里因为掉电不丢失但运行时它们必须在RAM里因为变量需要可写。这就涉及两个地址概念加载地址LMALoad Memory Address和虚拟地址VMAVirtual Memory Address。 RAM AT FLASH这个写法就是告诉链接器.data段的VMA在RAM里但LMA在FLASH里。启动代码需要负责把.data段从FLASH拷贝到RAM否则全局变量的初始值是错的。同理.bss段存放的是未初始化或者初始化为0的全局变量它不需要在FLASH里占空间但运行时需要在RAM里占空间并且启动代码要把它清零。3.3 链接脚本里导出的符号你可能注意到脚本里定义了_data_start、_data_end、_bss_start、_bss_end、_stack_top这些符号。这些符号在C代码和汇编代码里都可以直接引用链接器会把它们替换成实际的地址值。在汇编里引用这些符号用la指令load address在C代码里引用需要声明为externextern char _data_start[]; extern char _data_end[]; extern char _bss_start[]; extern char _bss_end[];注意这里声明为数组类型而不是普通变量因为符号代表的是地址不是值。用_data_start和直接用_data_start在C里含义不同前者是符号的地址后者是符号的值而符号本身没有值。这个细节坑过不少人。4. 启动汇编的逐行拆解从复位到C环境就绪4.1 完整的启动代码示例下面是一段典型的RISC-V裸机启动汇编我会逐段解释每条指令的意图.section .text.entry .globl _start _start: # 1. 关闭全局中断 csrw mie, zero csrw mip, zero # 2. 设置栈指针 la sp, _stack_top # 3. 拷贝.data段从FLASH到RAM la a0, _data_start la a1, _data_end la a2, _data_load copy_data: bge a0, a1, copy_data_done lw t0, 0(a2) sw t0, 0(a0) addi a0, a0, 4 addi a2, a2, 4 j copy_data copy_data_done: # 4. 清零.bss段 la a0, _bss_start la a1, _bss_end clear_bss: bge a0, a1, clear_bss_done sw zero, 0(a0) addi a0, a0, 4 j clear_bss clear_bss_done: # 5. 跳转到main call main # 6. main返回后进入死循环 loop: j loop4.2 为什么先关中断第1步关闭中断看起来简单但非常重要。复位之后虽然大多数实现默认关闭中断但显式地写csrw mie, zero和csrw mip, zero可以确保在初始化过程中不会被任何中断打断。想象一下你正在拷贝.data段突然来了一个中断中断处理程序访问了还没初始化完的全局变量结果就是不可预期的行为。mie是中断使能寄存器mip是中断挂起寄存器。把这两个都清零等于把所有中断源都关掉了。等C环境初始化完毕需要用到中断的时候再在main函数里重新配置。4.3 栈指针设置的位置很关键第2步设置栈指针必须放在调用任何C函数之前。因为C函数调用会使用栈来保存返回地址、局部变量和保存寄存器。如果栈指针没设置好第一次函数调用就会写到非法地址触发异常或者直接跑飞。la sp, _stack_top这条指令把_stack_top的地址加载到sp。la是load address的伪指令实际会被展开成auipc加addi的组合能够加载任意32位地址。4.4 .data段拷贝的地址计算第3步拷贝.data段时源地址是.data段在FLASH里的加载地址目标地址是它在RAM里的运行地址。链接脚本里 RAM AT FLASH会自动处理VMA和LMA的差异但你需要知道FLASH里的加载地址是多少。一种做法是在链接脚本里额外定义一个符号.data : AT(_data_load) { _data_start .; *(.data .data.*) _data_end .; } RAM _data_load LOADADDR(.data);这样_data_load就是.data段在FLASH里的起始地址。启动代码里用la a2, _data_load加载这个地址然后逐字拷贝。拷贝循环用bge判断是否到达末尾每次拷贝4个字节一个word。这里假设.data段的大小是4字节对齐的链接器默认会保证这一点。如果你的数据段里有非对齐的情况需要改成按字节拷贝。4.5 .bss段清零的注意事项第4步清零.bss段相对简单就是把从_bss_start到_bss_end之间的内存全部写0。同样用sw zero, 0(a0)每次写4个字节。这里有一个潜在问题如果.bss段的大小不是4的倍数最后一次写会越界。链接器通常会把段大小对齐到4字节但为了保险你可以在链接脚本里显式对齐或者在启动代码里处理剩余字节。实际项目中我一般依赖链接器的对齐但会在链接脚本里加ALIGN(4)确保安全。4.6 跳转到main之后第5步call main跳转到C代码的入口。call是伪指令实际展开为auipc加jalr能够跳转到任意32位地址范围内的目标。main函数正常情况不应该返回。如果返回了第6步的死循环会接住它。在裸机环境里main返回意味着程序逻辑结束了但硬件还在运行所以必须有个地方让CPU停下来。死循环是最简单的处理方式你也可以在这里加一个低功耗指令比如wfiwait for interrupt让CPU进入休眠状态。5. 多核启动从核怎么被唤醒5.1 多核RISC-V芯片的启动模型很多RISC-V SoC是多核的比如双核或者四核。上电之后所有核心都会从复位向量开始执行吗不一定。常见的模型有两种第一种是单核启动模型只有一个核心通常叫hart 0hart是hardware thread的缩写从复位向量开始执行其他核心处于停止状态需要被显式唤醒。这种模型下hart 0负责所有的初始化工作然后通过某种机制比如写一个特定的内存地址或者寄存器唤醒其他核心。第二种是全部启动模型所有核心都从复位向量开始执行但你需要通过读取mhartid寄存器来区分自己是哪个核心然后决定做什么。通常hart 0负责初始化其他核心在初始化完成后跳转到各自的入口。5.2 用mhartid区分核心mhartid是一个M模式的只读寄存器存放当前核心的ID。在启动代码的最开始读取这个值就能知道自己是哪个核心csrr a0, mhartid bnez a0, secondary_core_loop如果mhartid不为0说明是从核跳转到从核的处理逻辑。从核通常不参与初始化而是等待主核完成初始化后通过一个共享变量或者IPI核间中断来通知它们开始工作。从核的等待循环可以这样写secondary_core_loop: la t0, boot_flag wait_loop: lw t1, 0(t0) beqz t1, wait_loop # boot_flag非0跳转到从核入口 la t0, secondary_entry jr t0boot_flag是一个放在.bss段或者特定内存地址的变量主核完成初始化后把它设为非0从核看到这个值变化后就跳出等待循环。5.3 多核启动的内存一致性问题多核场景下内存一致性是需要特别注意的。主核写boot_flag之后从核可能因为缓存或者乱序执行的原因不能立即看到这个写操作。在RISC-V里你需要使用内存屏障指令来保证可见性。主核在写boot_flag之后应该执行一条fence指令li t0, 1 la t1, boot_flag sw t0, 0(t1) fence w, w从核在读取boot_flag之前也应该执行fencewait_loop: fence r, r lw t1, 0(t0) beqz t1, wait_loopfence w, w保证之前的写操作对其他核心可见fence r, r保证之后的读操作能看到其他核心的写。具体用哪种fence组合取决于你的内存模型和硬件实现保守的做法是用全屏障fence等同于fence iorw, iorw。5.4 从核的栈和入口每个从核都需要自己的栈空间。如果所有核心共用一个栈会立刻出现数据竞争和崩溃。在链接脚本里为每个核心分配独立的栈区域_stack_top_hart0 ORIGIN(RAM) LENGTH(RAM); _stack_top_hart1 ORIGIN(RAM) LENGTH(RAM) - 4K; _stack_top_hart2 ORIGIN(RAM) LENGTH(RAM) - 8K;从核启动时根据自己的mhartid选择对应的栈顶csrr a0, mhartid la t0, _stack_top_hart0 slli a1, a0, 12 sub t0, t0, a1 mv sp, t0这段代码假设每个核心的栈大小是4KB2的12次方通过左移12位计算偏移量然后从hart 0的栈顶减去偏移得到当前核心的栈顶。6. 实际调试中容易踩的坑6.1 链接脚本地址写错导致程序跑飞这是最常见的问题。FLASH和RAM的起始地址必须和芯片手册完全一致。有些芯片的FLASH映射在0x80000000有些在0x20000000有些在0x00000000。如果你用的是SiFive的HiFive1FLASH在0x20000000如果是GD32V或者ESP32-C3地址又不一样。调试方法用riscv64-unknown-elf-objdump -h查看生成的可执行文件的段布局确认每个段的地址和大小是否符合预期。如果.text段的地址不是FLASH起始地址说明链接脚本有问题。6.2 .data段拷贝遗漏导致全局变量初值错误如果你发现全局变量的初始值不对比如定义int x 42;但运行时读到的是0或者随机值大概率是.data段没有正确拷贝。检查启动代码里是否有拷贝逻辑以及链接脚本里是否正确定义了_data_load符号。一个快速验证方法在main函数开头打印几个全局变量的值和预期对比。如果全是0可能是.bss清零把.data也覆盖了段地址重叠如果是随机值可能是拷贝逻辑没执行。6.3 栈溢出悄无声息地破坏数据裸机环境没有操作系统帮你检测栈溢出。如果栈设得太小或者递归太深栈会向下增长到其他数据区域悄无声息地破坏全局变量或者堆数据。症状往往是程序运行一段时间后莫名其妙崩溃或者某个变量突然变了值。排查方法在链接脚本里把栈区域和其他数据区域之间留一段空白guard region并在启动时把这段空白填上特定的魔数比如0xDEADBEEF。程序运行一段时间后检查这些魔数是否被改写如果被改写了说明栈溢出。6.4 多核启动时从核没被正确唤醒多核调试的难点在于你无法确定从核到底有没有在运行。如果从核的等待循环写错了它可能一直在等待一个永远不会被设置的标志或者直接跑飞到非法地址。一个实用的调试技巧让从核在启动时往一个特定的内存地址写一个魔数主核在初始化完成后检查这个地址的值。如果魔数不对说明从核没有正确启动。另外用调试器连接多核系统时注意选择正确的hart进行调试很多调试器默认只连接hart 0。6.5 中断向量表没有正确对齐RISC-V的中断向量表基址寄存器mtvec的低两位决定了模式0表示直接模式所有异常跳转到同一个地址1表示向量模式不同异常跳转到不同偏移。如果你用向量模式向量表的基址必须按4字节对齐实际上要求更严格通常是64字节或者128字节对齐取决于实现。如果mtvec设置不对中断发生时CPU可能跳转到错误的地址导致程序崩溃。在启动代码里设置mtvec时确保基址对齐并且模式位设置正确。7. 从裸机到实际项目一些经验性的建议7.1 启动代码尽量用C写虽然启动代码通常用汇编写但除了最开始的几条指令设置栈指针、关中断后面的.data拷贝和.bss清零完全可以用C函数来实现。这样可读性更好也更容易维护。只需要在汇编里设置好栈指针然后调用一个C函数system_init()在里面完成所有初始化工作。_start: csrw mie, zero la sp, _stack_top call system_init call main loop: j loopsystem_init()函数里做.data拷贝、.bss清零、时钟初始化、外设初始化等工作。这样启动汇编只需要十几行逻辑清晰修改方便。7.2 链接脚本要加ASSERT检查链接脚本支持ASSERT语句可以在链接时检查条件是否满足。比如检查.data段和.bss段是否超出RAM范围ASSERT(_bss_end _stack_top, BSS overflow!) ASSERT(_data_end _bss_start, DATA and BSS overlap!)如果条件不满足链接器会报错而不是生成一个运行时才崩溃的可执行文件。这个技巧在项目变大、内存布局变复杂之后特别有用。7.3 保留一个安全的启动模式在实际产品中我习惯保留一个安全启动模式如果某个GPIO被拉低或者某个特定的内存地址有特定的值启动代码就跳转到一个最小的固件更新或者恢复模式而不是直接进入main。这个模式可以用来修复变砖的设备或者在不拆机的情况下更新固件。实现方式很简单在启动代码的最开始检查条件如果满足就跳转到恢复模式的入口否则继续正常启动流程。恢复模式的代码可以放在FLASH的另一个区域链接脚本里单独划分。7.4 用QEMU验证启动流程在没有硬件的情况下QEMU是验证RISC-V启动流程的好工具。QEMU支持多种RISC-V机器类型比如virt机器可以模拟一个多核RISC-V系统。你可以用-bios参数指定启动固件用-kernel参数指定内核镜像然后用GDB连接调试。QEMU的好处是你可以清楚地看到每条指令的执行情况设置断点检查寄存器和内存。对于理解启动流程来说QEMU比真实硬件更方便因为真实硬件上电后的第一条指令你很难直接观察到。7.5 启动时间优化在一些对启动时间敏感的场景比如汽车电子或者工业控制启动代码的执行时间需要优化。.data拷贝和.bss清零是启动阶段最耗时的部分尤其是当数据段很大的时候。优化方法把不急着用的.data段延迟初始化或者把一些常量数据直接放在FLASH里用const修饰避免拷贝到RAM。另外.bss清零可以用DMA来做释放CPU去执行其他初始化任务。不过DMA初始化本身也需要时间需要权衡。我在一个实际项目里把启动时间从200ms优化到了50ms主要手段就是把.data段里那些只读的初始化数据改成const减少了拷贝量同时把.bss清零改成按块用DMA处理。这些优化需要对链接脚本和启动代码有比较深的理解但效果很直接。7.6 关于调试工具的选择RISC-V的调试工具链这几年成熟了很多。OpenOCD加上FTDI或者J-Link的调试器基本能覆盖大部分场景。如果你用的是SiFive的板子他们的OpenOCD分支对自家芯片支持最好。如果是其他厂商的芯片可能需要用厂商提供的定制版OpenOCD。调试启动代码时一个很有用的技巧是在复位向量处设置硬件断点。这样芯片一上电就会停在第一条指令你可以单步跟踪整个启动流程。不过有些芯片的复位向量在ROM里硬件断点可能不支持这时候只能用软件断点或者LED闪烁来调试了。启动流程这个东西看再多文档不如自己动手写一遍。找一块RISC-V开发板从链接脚本开始一行一行地把启动代码写出来遇到问题就查手册、用调试器跟。走完一遍之后你对RISC-V的理解会完全不一样。
返回列表