ARTICLE DETAIL

资讯详情

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

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

RISC-V裸机启动全流程解析:从复位向量到main函数 1. 从复位向量到main函数RISC-V裸机启动到底经历了什么很多人第一次接触RISC-V裸机开发脑子里冒出来的第一个问题就是芯片一上电第一条指令到底从哪儿取我当初从ARM Cortex-M转过来的时候也习惯性地去找类似Reset_Handler那样的东西结果翻遍文档才发现RISC-V的思路完全不一样。它没有固定的中断向量表地址也没有厂商强制的启动文件模板一切都要你自己在链接脚本里说清楚代码放哪儿、数据放哪儿、栈顶在哪儿、复位入口在哪儿。这种“什么都得自己搭”的感觉刚开始确实让人头大但一旦理清楚了你会发现它比想象中要干净利落得多。这篇文章我想把RISC-V裸机启动这条链路从头到尾捋一遍包括链接脚本怎么写、启动代码里到底该干哪些事、多核场景下从核怎么唤醒、常见的跑飞问题怎么排查。适合已经上手过单片机、想切到RISC-V平台的朋友也适合正在做SoC验证或者写BootROM的同行参考。我不会只贴代码更想把每一步“为什么这么写”讲清楚这样你换一颗芯片也能自己推出来。2. 启动流程的整体设计与核心思路拆解2.1 为什么RISC-V的启动比ARM更“原始”ARM Cortex-M系列在硬件层面就规定好了向量表的前两个字是初始MSP和复位向量芯片出厂就带着这套约定你只要填表就行。RISC-V这边特权规范只规定了一件事复位后hart硬件线程从某个实现定义的地址开始执行这个地址通常叫复位向量。至于这个地址上放什么、后面怎么跳规范不管全交给SoC设计者和软件工程师。这就带来两个直接后果。第一链接脚本变得极其重要因为复位向量地址必须和硬件约定一致你链接的时候就得把这个地址钉死。第二启动代码没有统一模板不同厂商、不同核比如E24、C906、N307给的参考代码风格差异很大你得理解原理才能迁移。我个人的经验是把启动流程拆成三个阶段来看最清晰硬件复位阶段PC指向复位向量、汇编启动阶段设置栈、清BSS、搬数据、设mtvec、C运行阶段跳main。这三个阶段的分界线就是栈可用和C环境就绪。2.2 链接脚本整个启动流程的“地图”链接脚本在裸机开发里的地位怎么强调都不过分。它决定了你的代码段、数据段、BSS段分别落在哪个地址也决定了复位向量是不是落在硬件期望的位置。我见过太多新手编译通过了、烧录也成功了但一上电就跑飞最后发现是链接脚本里FLASH起始地址写错了。一个典型的RISC-V裸机链接脚本核心要描述这几件事MEMORY区域定义FLASH和RAM的起始地址、长度。这个必须和芯片手册里的内存映射完全一致差一个字节都不行。ENTRY声明告诉链接器入口符号是谁通常是_start。段布局.text放代码.rodata放常量.data放已初始化全局变量.bss放未初始化全局变量。栈顶符号通常定义一个_stack_top启动代码里把sp设成它。这里有个容易踩的坑.data段的加载地址LMA在FLASH里运行地址VMA在RAM里启动代码必须把这段数据从FLASH搬到RAM。如果你忘了搬全局变量的初值全是乱的。.bss段则要在RAM里清零不清零的话未初始化变量就是随机值。2.3 启动代码的职责边界很多人写启动代码喜欢把所有初始化都塞进去时钟、串口、DDR全在里面配。我的建议是汇编启动阶段只做C环境必需的事其他外设初始化统统放到C代码里。原因很简单汇编难调试、难维护能少写就少写。汇编阶段必须做的只有四件事设置栈指针sp清零.bss段搬运.data段设置mtvec异常/中断向量基址然后跳main时钟配置、PLL锁定、DDR训练这些放到C里做完全没问题只要保证在用到之前配好就行。这样启动代码能控制在几十行以内可读性大幅提升。3. 核心细节解析与实操要点3.1 复位向量地址怎么确定复位向量地址不是你想设哪儿就设哪儿它由SoC硬件决定。常见的有几种情况固定地址比如很多芯片规定复位后PC0x80000000那你的FLASH或者BootROM就得映射到这个地址。通过strap引脚选择有些芯片用几个引脚的电平决定从哪个存储介质启动不同介质对应不同复位地址。BootROM跳转芯片内部先跑一段固化BootROM再由它根据启动模式跳到用户代码。你拿到一颗新芯片第一件事就是翻手册找“复位向量”或者“Boot Address”这一节。如果手册写得含糊可以用调试器连上去复位后看PC停在哪里这是最直接的办法。3.2 栈指针设置别小看这一行设置sp看起来就是一句la sp, _stack_top但里面有几个细节值得说。第一栈的生长方向。RISC-V的ABI规定栈向低地址生长所以_stack_top应该指向RAM的高地址端栈从高往低用。如果你把栈顶设在RAM起始地址栈一压就跑到RAM外面去了。第二栈对齐。RISC-V的ABI要求栈指针16字节对齐如果你设的栈顶不是16的倍数某些编译器生成的代码比如用到向量指令或者浮点保存可能会出问题。我一般会在链接脚本里把栈顶地址手动对齐到16字节。第三栈大小。裸机环境下栈大小全靠你自己估。中断嵌套、递归调用、大局部数组都会吃栈。我一般给主栈留4KB到8KB如果跑RTOS或者有深度递归得留更多。可以在栈顶附近填一个魔数运行一段时间后检查魔数有没有被覆盖以此判断栈是否溢出。3.3 BSS清零和DATA搬运的实现这两段代码是启动汇编里的重头戏写法有好几种。最直观的是用循环逐字清零/搬运/* 清零BSS */ la a0, _bss_start la a1, _bss_end bgeu a0, a1, bss_done bss_loop: sw zero, 0(a0) addi a0, a0, 4 bltu a0, a1, bss_loop bss_done: /* 搬运DATA */ la a0, _data_lma /* FLASH中的加载地址 */ la a1, _data_start /* RAM中的运行起始 */ la a2, _data_end bgeu a1, a2, data_done data_loop: lw t0, 0(a0) sw t0, 0(a1) addi a0, a0, 4 addi a1, a1, 4 bltu a1, a2, data_loop data_done:这里有几个要点。_data_lma、_data_start、_data_end、_bss_start、_bss_end这些符号都要在链接脚本里定义好。搬运时源地址用LMA目的地址用VMA别搞反了。另外如果数据量很大逐字搬运会比较慢可以考虑用DMA或者更宽的load/store指令加速但裸机启动阶段一般数据量不大逐字就够了。注意BSS清零一定要在DATA搬运之前还是之后其实两者互不干扰顺序无所谓。但如果你有代码依赖某个全局变量的初值那必须在跳main之前两件事都做完。3.4 mtvec设置与异常处理mtvec是机器模式异常向量基址寄存器它决定了发生异常或中断时PC跳到哪里。裸机阶段即使你暂时不用中断也建议把它设好否则一旦跑飞触发异常PC会跳到未定义地址调试起来很痛苦。mtvec有两种模式直接模式和向量模式。直接模式下所有异常都跳到同一个地址向量模式下不同中断跳到基址加4倍中断号的位置。裸机初期用直接模式就够了设一个统一的异常处理函数里面打印或者点灯方便定位问题。la t0, trap_handler csrw mtvec, t0trap_handler里要保存现场、读取mcause和mepc判断是哪种异常。最常见的启动期异常是非法指令和加载/存储访问错误前者通常是代码没搬对后者多半是地址映射或者栈没设好。3.5 多核启动主从核的配合多核RISC-V芯片的启动比单核复杂。复位后通常只有一个核叫主核或者hart 0从复位向量开始跑其他核处于暂停或者等待状态。主核负责初始化关键资源时钟、DDR、外设然后通过某种机制唤醒从核。唤醒机制各家不同常见的有写特定寄存器主核往一个控制寄存器写从核的启动地址然后置位“唤醒”位。核间中断IPI主核给从核发一个软件中断从核的中断处理里跳转到指定入口。自旋等待从核复位后在一个循环里等一个内存标志主核初始化完把标志置1从核看到后继续往下跑。不管哪种机制核心思路都是主核先跑从核后跑从核的入口地址由主核指定。从核启动后通常也要设置自己的栈每个核要有独立的栈、清自己的BSS如果BSS是每核独立的然后进入自己的任务循环。这里有个大坑如果多个核共用同一段BSS或者DATA从核再去清一遍就会把主核的数据破坏掉。所以多核场景下链接脚本里往往要给每个核划分独立的栈和私有数据段共享数据段则只由主核初始化一次。4. 实操过程与核心环节实现4.1 一个最小可跑的链接脚本下面这个链接脚本是我在几颗RISC-V核上验证过的模板你可以根据自己芯片的内存映射改地址OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x80000000, LENGTH 512K RAM (rwx) : ORIGIN 0x80080000, LENGTH 128K } SECTIONS { .text : { *(.text.entry) *(.text*) *(.rodata*) } FLASH .data : { _data_start .; *(.data*) _data_end .; } RAM AT FLASH _data_lma LOADADDR(.data); .bss : { _bss_start .; *(.bss*) *(COMMON) _bss_end .; } RAM .stack : { . ALIGN(16); _stack_top . 0x2000; } RAM }几个关键点解释一下。*(.text.entry)放在最前面保证启动代码落在FLASH起始处也就是复位向量指向的位置。.data段用AT FLASH指定加载地址在FLASH运行地址在RAMLOADADDR取出加载地址给启动代码用。.stack段里_stack_top设在当前地址加8KB的位置并且前面做了16字节对齐。4.2 启动汇编的完整实现把前面说的几件事串起来一个完整的启动汇编大概长这样.section .text.entry .globl _start _start: /* 设置栈指针 */ la sp, _stack_top /* 清零BSS */ la a0, _bss_start la a1, _bss_end bgeu a0, a1, 2f 1: sw zero, 0(a0) addi a0, a0, 4 bltu a0, a1, 1b 2: /* 搬运DATA */ la a0, _data_lma la a1, _data_start la a2, _data_end bgeu a1, a2, 4f 3: lw t0, 0(a0) sw t0, 0(a1) addi a0, a0, 4 addi a1, a1, 4 bltu a1, a2, 3b 4: /* 设置mtvec */ la t0, trap_handler csrw mtvec, t0 /* 跳main */ call main /* main返回后死循环 */ 5: j 5b这段代码里用了局部标签1b、2f这种写法b表示向前找f表示向后找避免标签名冲突。call main会自动处理返回地址如果main真的返回了就死在最后的循环里。4.3 多核启动的代码框架假设主核是hart 0从核是hart 1到hart N一个常见的框架是这样的/* 主核 */ void main(void) { uint32_t hartid; asm volatile(csrr %0, mhartid : r(hartid)); if (hartid 0) { /* 主核初始化 */ clock_init(); uart_init(); ddr_init(); /* 唤醒从核 */ for (int i 1; i NUM_CORES; i) { wakeup_core(i, (uint32_t)secondary_entry); } /* 主核继续干活 */ primary_task(); } else { /* 从核不应该走到这里应该在等待唤醒 */ while (1); } } /* 从核入口 */ void secondary_entry(void) { uint32_t hartid; asm volatile(csrr %0, mhartid : r(hartid)); /* 从核设置自己的栈 */ set_core_stack(hartid); /* 从核自己的初始化 */ secondary_init(); /* 进入从核任务 */ secondary_task(); }wakeup_core的具体实现取决于芯片可能是写寄存器也可能是发IPI。set_core_stack需要你提前在链接脚本里给每个核划分好栈空间然后根据hartid算出对应的栈顶。提示从核的栈一定要独立千万别共用。共用栈在多核并发时必然出问题而且这种问题往往偶发、难复现调试成本极高。4.4 验证启动流程是否正常代码写完了怎么确认启动流程没问题我一般分几步验证。第一步用调试器连上复位后单步走看PC是不是从复位向量开始看sp设置后是不是指向预期的栈顶。这一步能排除链接脚本和入口地址的问题。第二步在BSS清零和DATA搬运之后设断点检查几个全局变量的初值对不对。如果初值是错的多半是DATA搬运有问题。第三步跳到main之后点个灯或者打条串口信息。这是最直观的“活过来了”的标志。第四步如果有多核让每个核都打印自己的hartid确认所有核都正常启动并且栈没冲突。5. 常见问题与排查技巧实录5.1 上电就跑飞PC不知道飞哪儿去了这是最常见的问题原因通常有三个。一是复位向量地址和链接脚本里的FLASH起始地址不一致代码根本没烧到硬件期望的位置。二是栈没设好第一条压栈指令就访问了非法地址。三是时钟没配CPU跑在一个不稳定的频率上。排查顺序先确认烧录地址和链接脚本一致再用调试器看复位后PC值然后单步看sp设置那一条有没有执行成功。如果sp设置就挂了检查_stack_top符号是不是真的落在RAM范围内。5.2 全局变量初值不对九成是DATA搬运出了问题。检查_data_lma和_data_start是不是分别指向FLASH和RAM检查搬运循环的起止地址有没有写反。还有一种可能是链接脚本里.data段的AT FLASH忘了写导致加载地址和运行地址一样数据根本没存在FLASH里。5.3 中断一来就死先看mtvec设了没有。如果没设中断一来PC就跳到0或者随机地址。设了的话看trap_handler里有没有保存现场特别是mepc和mcause。另外RISC-V的中断默认是关闭的要用中断得先开mstatus里的MIE位再在具体的中断使能寄存器里打开对应中断。5.4 多核启动后从核没反应先确认唤醒机制用对了。写寄存器的检查寄存器地址和位定义发IPI的检查从核的中断使能开了没有。然后确认从核的入口地址是有效的并且从核能访问到那段代码。如果从核和主核的地址映射不同比如从核还在BootROM里那入口地址得用从核视角的地址。5.5 常见问题速查表现象可能原因排查方法上电无反应复位向量地址错查手册确认Boot Address对比链接脚本跑飞栈指针未设置或越界调试器看sp值检查栈顶符号全局变量乱码DATA段未搬运检查LMA/VMA和搬运循环中断死机mtvec未设置读csr确认mtvec值从核不启动唤醒机制错误检查唤醒寄存器和从核入口地址偶发崩溃多核栈冲突确认每核独立栈空间5.6 几个我踩过的坑第一个坑是链接脚本里FLASH长度写小了代码稍微一多就溢出链接器不报错但运行异常。后来养成习惯编译完用size命令看各段大小留足余量。第二个坑是启动汇编里用了call跳main但main被编译器优化成了内联或者改名导致链接找不到符号。解决办法是在C里给main加__attribute__((used))或者直接在汇编里用jal跳一个固定的C函数名。第三个坑是多核场景下从核的栈顶算错了两个核的栈重叠跑一段时间就互相踩。后来我在链接脚本里给每个核单独开一段栈空间用hartid索引再也没出过问题。6. 从启动流程延伸出去的几个实用话题6.1 启动时间优化有些场景对启动时间敏感比如汽车电子要求快速出图。优化启动时间主要从几方面入手减少DATA搬运量把不急着用的数据放到后面再初始化、BSS清零用更宽的指令比如用sd代替sw、时钟配置尽早做但别等PLL完全锁定才继续。我实测过把逐字搬运改成逐双字启动时间能省下百分之十几。6.2 启动代码的可移植性如果你写的启动代码要在多颗芯片上复用建议把芯片相关的部分复位向量地址、内存映射、唤醒机制抽成宏或者配置文件启动流程本身保持不变。这样换芯片时只改配置不动逻辑。6.3 和BootROM的关系很多芯片内部有一段固化的BootROM上电后先跑BootROM由它做最基础的初始化比如时钟、串口然后根据启动模式跳到用户代码。这种情况下用户代码的复位向量地址是BootROM约定的不是你随便定的。写启动代码前一定要搞清楚有没有BootROM、它跳到哪、跳之前做了什么。我个人在实际项目里的体会是RISC-V裸机启动这件事难点不在代码本身而在于把硬件约定、链接脚本、启动代码这三者的关系理清楚。一旦理清了你会发现它比很多封闭架构要透明得多可控性也强得多。后续如果要做安全启动或者双系统切换这套启动流程也是很好的基础。
返回列表