Nuclei UX900FD RISC-V IP 裸机启动流程
最近公司在调研芯来IP,借此机会,分析一下RISC-V的裸机启动流程
基于 Nuclei SDK + evalsoc 平台 + ILM 下载模式,从复位到 main() 流程分析
1. 概述:启动流程全景图
上电/复位后,CPU 从 _start 到 main() 的完整流程:
1 | 上电/复位 |
2. 内存布局:ILM 链接脚本分析
2.1 物理内存映射
来自 evalsoc.memory:
| 区域 | 基址 | 大小 | 用途 |
|---|---|---|---|
| FLASH | 0x20000000 |
8 MB | 非易失存储,存放烧录镜像 |
| ILM | 0x80000000 |
64 KB | Instruction Local Memory——指令紧耦合存储器 |
| DLM | 0x90000000 |
64 KB | Data Local Memory——数据紧耦合存储器 |
| SRAM/DDR | 0xA0000000 |
128 KB / 256 MB | 外部 RAM,视配置选择 |
ILM 和 DLM 是 Nuclei 核内部的紧耦合存储器,通过专用总线直连 CPU,访问延迟远低于外部 DDR/SRAM,且与 I-Cache/D-Cache 独立——这是实现确定实时性的关键硬件机制。
2.2 链接脚本 ILM 模式的内存分区
1 | /* gcc_evalsoc_ilm.ld */ |
| 链接器区域 | 物理对应 | 权限 | 存放内容 |
|---|---|---|---|
| ROM 别名 = ILM | ILM (0x80000000) | R+X | .init(向量表+启动代码)、.text(程序代码)、.riscv.jvt(Zcmt 跳转表) |
| RAM 别名 = DLM | DLM (0x90000000) | R+W+X | .data(初始化数据+只读常数)、.tdata(TLS 数据)、.tbss/.bss(未初始化数据)、.heap、.stack |
2.3 关键符号定义
链接脚本定义了一系列符号,这些符号在启动汇编中被直接引用:
1 | /* .text 段符号——用于代码搬运和 GP 初始化 */ |
__global_pointer$被放置在.sdata(短数据段)起始位置偏移0x800处,这使得编译器能以gp ± 2KB范围覆盖整个.sdata+.sbss区域。
2.4 ILM 下载模式 vs 其他模式
| 模式 | .text 加载地址(LMA) | .text 运行地址(VMA) | 是否需要搬运 |
|---|---|---|---|
| ILM | ILM (0x80000000) | ILM (0x80000000) | 不需要(LMA = VMA) |
| DDR | DDR (0xA0000000) | DDR (0xA0000000) | 不需要 |
| Flash | Flash (0x20000000) | ILM (0x80000000) | 需要——启动时从 Flash 拷贝到 ILM |
| FlashXIP | Flash (0x20000000) | Flash (0x20000000) | 不需要(直接就地执行) |
| SRAM | SRAM (0xA0000000) | SRAM (0xA0000000) | 不需要 |
ILM 模式:烧录器直接将可执行文件下载到 ILM 和 DLM 中,__init_common 检测到 _text_lma == _text,就会跳过 .text 搬运步骤。但 .data 段仍然需要从加载地址搬运到运行地址(DLM)。
3. Stage 1:复位向量 _start——CPU 的第一条指令
3.1 入口点
1 | .section .text.init /* 放入 .init 段——链接脚本中排在第一 */ |
链接脚本通过 ENTRY(_start) 指定入口点,CPU 复位后第一条指令就是 _start 的第一条汇编指令。
3.2 关中断
1 | csrc CSR_MSTATUS, MSTATUS_MIE /* 清零 mstatus.MIE 位,全局关中断 */ |
RISC-V 的 mstatus.MIE(Machine Interrupt Enable,bit 3)是机器模式中断总开关。启动阶段中断系统尚未初始化,任何中断到达都会导致不可预测行为。
3.3 启动核(Boot Hart)选择
1 | #ifndef SMP_CPU_CNT /* 单核模式下进入此分支 */ |
在多核(SMP)SoC 中,所有核同时从 _start 开始执行。但只需一个核(通常为 Hart 0)负责初始化——其他核应立即进入低功耗等待状态:
1 | __amp_wait: |
从核被 __amp_wait 卡住,直到主核完成初始化后在 __sync_harts() 中通过写 MSIP 寄存器唤醒它们。
3.4 初始化 GP、TP 和 Zcmt 跳转表
1 | .option push |
GP(Global Pointer,x3)
__global_pointer$ 位于 .sdata 段中间偏移 0x800 处。对在 gp ± 2KB 范围内的变量,编译器生成单条 ld rd, offset(gp) 指令代替常规的 lui + addi + ld 三指令序列。这是一种代码尺寸 + 性能的双重优化,在嵌入式领域非常关键。
对比:
1 | ; 普通寻址(2条指令 + 1条访存) |
.option norelax 防止汇编器将 la 伪指令优化为 GP-relative 形式——因为此时 GP 本身尚未初始化,必须先做绝对加载。
TP(Thread Pointer,x4)
__tls_base 指向 TLS(Thread Local Storage)段基址。TLS 用于支持 RTOS 下的线程私有变量(如 errno)。在裸机单任务场景下 TP 虽暂时不用,但初始化它保证进入 RTOS 后上下文切换机制正确——这是一种前瞻性设计。
Zcmt(Compressed Jump Table for Multi-call)
Zcmt 是 RISC-V Zc* 代码密度扩展族的一员。它引入 cm.jt / cm.jalt 两条 16 位指令,通过查询 CSR_JVT 指向的跳转表完成间接函数调用。
1 | ; 无 Zcmt:间接调用需要 ~4 字节(c.jalr)或 8 字节(jalr) |
3.5 初始化栈指针
1 | /* 单核:直接设置 sp = _sp */ |
_sp 在链接脚本中被定义为 DLM 顶部地址。
3.6 配置 NMI 共享与 Zc 硬件使能
1 | /* 配置 NMI 基址与 mtvec 共享 */ |
CSR_MMISC_CTL 是 Nuclei 自定义的机器模式杂项控制寄存器。NMI 共享设计让 mnvec 与 mtvec 共享同一入口值,启动阶段不需要维护两套入口。后续 _premain_init() 中的 Exception_Init() 会重新设置完整的异常处理体系。
3.7 设置早期异常入口
1 | la t0, early_exc_entry |
1 | .align 6 |
early_exc_entry 是一个占位异常处理器——任何启动阶段的异常都会导致 CPU 进入 WFI 暂停 + 自旋死循环。正常启动过程不应触发任何异常,如果停在这里说明有严重的早期初始化错误(如 FPU 未使能就执行浮点指令)。
4. Stage 2:FPU、Vector、Cache 与计数器使能
4.1 使能浮点单元(FPU)
1 | #if defined(__riscv_flen) && __riscv_flen > 0 |
RISC-V mstatus.FS 域控制浮点单元的状态,有四种取值:
| FS 值 | 名称 | 含义 |
|---|---|---|
0b00 |
Off | FPU 关闭——执行浮点指令触发非法指令异常 |
0b01 |
Initial | FPU 使能,但寄存器内容为初始值(未被修改) |
0b10 |
Clean | 使能,寄存器内容与内存保存副本一致 |
0b11 |
Dirty | 使能,且寄存器已被修改 |
先清后设的安全序列确保无论复位值是什么,FS 最终精确等于 0b01(Initial)。使用 Initial 而非 Dirty 有一个关键优势:在 RTOS 上下文切换时,处于 Initial 状态的线程不需要保存/恢复 32 个浮点寄存器(256 字节),减少了切换开销。
4.2 使能向量扩展(V 扩展)
1 | #if defined(__riscv_vector) |
完全对称于 FPU 使能逻辑,控制的是向量单元。
4.3 使能 I/D Cache
1 | #ifdef CFG_HAS_ICACHE |
Cache 使能通过 Nuclei 自定义 CSR CSR_MCACHE_CTL 控制。Stage 2 的 Cache 使能是早期粗粒度使能,仅限单核模式。SMP 模式下的 Cache 使能被推迟到 _premain_init() 中。
4.4 启用性能计数器
1 | csrci CSR_MCOUNTINHIBIT, 0x5 /* 清除 MHPM_CTL bit 0 和 bit 2 */ |
清零 bit 0 使能 cycle 计数,清零 bit 2 使能指令计数——这是后续 get_cpu_freq() 测量 CPU 频率的基础。
5. Stage 3:__init_common——搬 .text、搬 .data、清 .bss
这个函数做的是所有 C 程序运行前最基本的内存初始化。它不是通过 call 调用的,而是 _start 执行完毕后通过代码顺序执行自然落入的(_start 和 __init_common 处于同一个 .text.init 段中)。
搬 .text(代码段)
1 | __init_common: |
RISC-V 的 I-Cache 和 D-Cache 是分离的。sw 指令通过数据通路写入了内存,但 CPU 的取指单元可能仍然在 I-Cache 中缓存了旧数据。fence.i 保证:所有之前的数据写入对指令取指可见,且 I-Cache 被刷新。
不执行
fence.i的后果:CPU 可能从旧地址取指,执行到垃圾代码。
使用 4 字节传输而非 8 字节,是为了同时兼容 RV32 和 RV64——当 __riscv_xlen == 32 时,ld/sd 指令非法。
搬 .data(已初始化数据段)
1 | /* ===== Step 2: 搬 .data(已初始化数据段) ===== */ |
搬运 .data 段是因为 C 语言要求在 main() 之前所有已初始化全局变量的值必须可用。在嵌入式系统中,这些初始值存储在非易失介质(Flash)中,变量本身位于可写 RAM 中:
1 | Flash 0x20000000: [代码][.data 初始值: x=42, y=100, ...] |
清 .bss(未初始化数据段)
1 | /* ===== Step 3: 清 .bss(未初始化数据段) ===== */ |
C 标准要求所有未显式初始化的全局变量在 main() 前被清零。
内存操作全景
以 ILM 下载模式为例,三个阶段完成后的内存状态:
1 | ILM (0x80000000): |
6. Stage 4:_start_premain——SystemInit 与 C++ 构造
1 | _start_premain: |
6.1 SystemInit()
1 | void SystemInit(void) |
当前是一个空壳实现,应在此填充 PLL 配置、外部时钟源初始化、Flash 控制器时序配置等。由于 __libc_init_array 可能在内部重写 .data/.bss 段,SystemInit() 中只能做纯寄存器操作。
6.2 __libc_init_array()
此函数来自 GCC 工具链的 libc_nano.a。它依次执行:_init() → 遍历 .init_array 段(C++ 全局对象构造发生在这里) → 设置 atexit 处理链。
7. Stage 5:__skip_init——多核同步与 _premain_init
1 | __skip_init: |
7.1 __sync_harts()——多核同步屏障
通过 CLINT 的 MSIP(Machine Software Interrupt Pending)寄存器实现核间同步:
1 | 时间线: |
主核负责所有的内存初始化,从核在这些共享资源准备好之前不能进入 _premain_init。
7.2 _premain_init()——真正的板级初始化核心
主核执行路径:
- 配置 ILM/DLM(使能/禁用 + ECC 开关)
- 配置 I-Cache / D-Cache(运行时检测 + ECC + 使能)
- 探测 CPU IRegion 基址,使能全局预取
- 配置 L2 Cache / BPU
get_system_clock()测量/读取 CPU 频率uart_init()串口初始化Exception_Init()初始化异常处理表Interrupt_Init()初始化中断控制器(ECLIC/PLIC/CLINT)
从核执行路径:
- 自旋等待主核设置好
CpuIRegionBase - 仅调用
Interrupt_Init()
get_system_clock()——频率测量:
1 | static uint32_t get_system_clock(void) |
Exception_Init()——异常处理框架:初始化长度为 27 的异常处理函数指针表,全部指向 system_default_exception_handler。同时设置 CSR_MSCRATCH 为栈顶地址。
Interrupt_Init()——中断控制器初始化:根据 SoC 配置选择 ECLIC/PLIC/CLINT。默认使用 ECLIC(Enhanced Core Local Interrupt Controller):
1 | ECLIC 模式的异常/中断入口切换: |
exc_entry | 0x3 中最低两位表示使用 CLIC 向量中断模式——CPU 硬件会根据中断号自动从 mtvt 指向的向量表中加载中断服务例程,无需软件分发。
8. Stage 6:main——用户应用程序
1 | int main(void) |
到 main() 执行时,以下环境已经就绪:
| 资源 | 状态 |
|---|---|
| 栈指针 SP | 指向 DLM 顶部,可用 |
| 全局指针 GP | .sdata GP-relative 寻址可用 |
| 线程指针 TP | TLS 基址已设(RTOS 准备) |
| I-Cache / D-Cache | 已使能 |
| FPU / Vector | 已使能,状态为 Initial |
.text / .data / .bss |
已搬运初始化 |
| C++ 全局对象 | 已构造 |
| 异常处理表 | 所有异常有默认 handler |
| 中断控制器 | ECLIC/PLIC/CLINT 已配置 |
| UART 串口 | 已初始化(115200 bps) |
| 系统时钟 | SystemCoreClock 已设置 |
| L2 Cache / BPU | 已使能 |
| 多核同步 | 所有核已到达同步点 |
9. Stage 7:_postmain_fini——善后与仿真退出
1 | void _postmain_fini(int status) |
当 main() 返回时执行 _postmain_fini()。在仿真场景中通过 UART 写特定值通知仿真器终止;在真实硬件上,此函数应做适当的系统关断处理。
10. 完整调用链
1 | _start (startup_evalsoc.S:157) |
如果觉得本文有帮助,欢迎关注公众号「菠菜的碎碎念」,获取更多技术干货。
