ARTICLE DETAIL

资讯详情

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

RISC-V模拟器Spike源码解析:从指令执行到多核调度与调试

RISC-V模拟器Spike源码解析:从指令执行到多核调度与调试 简介Spike 是 RISC-V 架构中应用广泛的指令级模拟器这份《20200617-Spike 代码框架及具体实现分析》PDF从代码框架切入帮助关注模拟器实现原理的开发者、学生或 RISC-V 入门者快速建立整体认知。内容围绕 Spike 的模块划分展开覆盖 fdt、fesvr、riscv 与 spike_main 等核心部分并结合指令译码、访存、寄存器维护、调试模式讲解 CPU 的三阶段执行流程同时分析了多核轮流执行机制、Duff’s Device 循环展开优化以及 Spike 的 cache 模型与真实 CPU 的差异并归类对比 Functional、Trace-accurate、Cycle-accurate 模拟器的性能与适用场景。资源包含 1 个 PDF 文件压缩包约 503KB体量精简适合对照源码局部精读或作为课程分享材料。目前已有 980 人学习/下载可作为 RISC-V 模拟器学习、源码分析或二次开发的参考资料。1. Spike 不只是模拟器更是一份可读的 RISC-V 参考实现如果你只把 Spike 当成一个跑 RISC-V 程序的工具那你可能错过了它最有价值的部分。Spike 的定位是 Trace-accurate 模拟器它不追求每秒钟执行多少条指令而是忠实地模拟指令执行过程中的软硬件行为——每条指令取了什么、译码成什么、访问了哪块内存、寄存器怎么变化这些都能在源码里找到一一对应的实现。更关键的是Spike 的代码结构非常清晰指令行为写在riscv/insns/下的一个个.h文件里指令编码定义在riscv/encoding.h中CPU 的执行循环、MMU 的内存访问、多核的调度策略都是分开的模块。这意味着你不只可以用它来跑程序还可以把它当成一份可执行的 RISC-V 架构文档来读。对于做编译器后端、处理器验证、操作系统移植的工程师来说Spike 源码里藏着的往往是规格书里不会直接告诉你的实现细节。这篇文章从一个一线工程师的视角把 Spike 的代码框架拆开讲清楚指令从取指到执行完成经过了哪些环节、cache 模型和真实 CPU 差在哪、多核是怎么用单线程模拟出来的以及怎么通过 SPIKE 提供的接口做调试和扩展。2. 三种模拟器类型的差异与 Spike 的架构定位2.1 RISC-V 模拟器的性能分层在进入 Spike 源码之前先明确一个坐标系RISC-V 生态里的模拟器大致分三类性能和精度是互为代价的。类型代表性能精度FunctionalQEMU10^8 - 10^9 条指令/秒只保证执行结果正确Trace-accurateSpike10^7 - 10^8 条指令/秒指令级行为可见Cycle-accurateRocket-chip10^4 - 10^5 条指令/秒周期级精确模拟QEMU 用二进制翻译把 RISC-V 机器码翻译成宿主机的机器码再执行所以快但它不关心你执行了多少条指令、cache 命中率是多少。Rocket-chip 这类 Cycle-accurate 模拟器则能精确到每个时钟周期发生了什么但代价是慢到只能跑小规模测试。Spike 卡在中间它逐条指令模拟但每条指令的行为是用 C 函数描述的不做二进制翻译所以比 QEMU 慢一到两个数量级但比周期精确模拟快得多。选择 Spike 的理由很直接当你需要验证一条指令的行为、调试一个操作系统的启动流程、或者测量一段代码的指令数时Spike 的精度恰好够用速度又不会让人等得失去耐心。2.2 Spike 源码目录结构与职责边界Spike 的顶层目录划分非常清晰每个子目录对应一个独立的功能模块fdt生成 device tree模拟硬件拓扑信息让 target 侧的操作系统能发现机器上有什么设备fesvrFront-End Server提供 target 与主机 host 交互的接口通常借助 proxy kernelpk实现系统调用代理softfloat软浮点库为 F/D 扩展指令提供浮点运算支持riscv核心目录实现了 RISC-V 机器码的翻译执行、MMU、cache 模型、多核调度spike_main程序的入口点负责解析命令行参数、初始化模拟器、启动执行这个分层的设计有一个值得注意的地方fesvr把 target 侧的系统调用转发到 host 侧执行这意味着你可以在 Spike 上跑一个未修改的 Linux 二进制而它调用的read、write等系统调用实际上由 host 内核完成。这也是 Spike 能快速启动一个 RISC-V Linux 的原因之一——不需要全系统模拟只要把系统调用透传出去就行。2.3 二进制执行的主流程Spike 执行一个 binary 的主流程可以概括为六个环节decode the instructions、memory load and store、maintain the registers、debug mode像一个小型 gdb、execute the instructions、others。这里的顺序有一个微妙之处decode 和 execute 实际上是交织的execute_insn是执行的核心函数它先做译码然后根据译码结果分发到对应的指令行为函数。memory load and store 并不只是发生在执行阶段取指本身也是一次内存访问这一步由 MMU 的 icache 机制处理。debug mode 则是 Spike 内置的一个简化版调试器可以单步、查看寄存器、查看内存虽然不如 gdb 强大但对于快速定位指令执行异常已经够用。3. 指令的编码定义与执行机制的源码级拆解3.1 从 encoding.h 到 insns 目录一条指令的两段式定义在 Spike 中定义一条指令需要两个部分配合。第一部分是指令的编码定义写在riscv/encoding.h中格式如下#define MATCH_LW 0x2003 #define MASK_LW 0x707f DECLARE_INSN(LW, MATCH_LW, MASK_LW)MATCH是指令的机器码模板MASK是指令中哪些位是固定的、哪些位是可变字段。DECLARE_INSN把这二者绑定在一起并声明一个指令对象。实际的匹配逻辑是(insn MASK) MATCH只有满足这个条件的 32 位机器码才会被识别为 LW 指令。这个方法在验证新指令时很实用你只需要在 encoding.h 里加入自定义的MATCH和MASK再在riscv/insns/下写一个同名的.h文件定义行为Spike 就能识别并执行这条指令。riscv/encoding.h本身可以从 RISC-V 官方的riscv-opcodes仓库生成不用手写。当你需要为一个新的扩展指令集添加支持时通常的做法是先修改 opcodes 的 CSV 描述文件再运行生成脚本得到 encoding.h避免手工维护容易出错。3.2 指令行为其实就是一段 C 函数第二部分是指令的行为定义写在riscv/insns/下的.h文件中。以lw.h为例WRITE_RD(MMU.load_int32(RS1 insn.i_imm()));这行代码里发生了三件事RS1 insn.i_imm()计算源地址即寄存器 rs1 的值加上立即数偏移MMU.load_int32从这个地址读取一个 32 位整数WRITE_RD把读取到的值写入目标寄存器 rd。这里最核心的设计是模拟一条指令就是写一个 C 函数函数的输入来自译码得到的寄存器和立即数输出是对寄存器的写回和对内存的访问。这套机制让 Spike 的指令实现极其简洁——每条指令对应的.h文件通常只有几行到十几行代码可读性远高于一个几千行的 switch-case。riscv/decode.h中定义了一系列宏用于在指令行为函数中操作寄存器和内存#define RS1 (xstate.XPR[insn.rs1()]) #define RS2 (xstate.XPR[insn.rs2()]) #define WRITE_RD(v) (xstate.XPR[insn.rd()] (v)) #define MMU (*p-get_mmu())这些宏把insn.rs1()、insn.rd()这类译码结果直接映射到处理器状态xstate.XPR数组上。也就是说你在写一条新指令的行为时不需要关心译码的细节只需要知道RS1表示第一个源操作数寄存器WRITE_RD表示写目标寄存器。这种抽象层让扩展指令的实现成本降到最低。3.3 一个完整的执行流程从 pc 到指令效果CPU 的执行循环可以概括为以下步骤从 CPU 的 pc程序计数器指针处读取指令编码通过译码确定指令的类型和操作数根据译码结果查表得到对应的行为函数执行行为函数期间可能触发 MMU 读写、寄存器更新pc 更新为下一条指令的地址继续循环// Spike 核心执行循环的简化逻辑 while (!stopped) { insn_fetch_t fetch mmu-fetch_insn(pc); // 取指 pc execute_insn(this, pc, fetch); // 执行并返回下一条 pc state.pc pc; }这里的execute_insn接收当前 pc 和取到的指令编码内部完成译码和分发。返回的新 pc 可能等于pc 4顺序执行也可能是跳转目标分支或跳转指令修改的结果。如果指令触发了异常比如非对齐访问、非法指令执行流程会跳转到异常处理入口state.pc也会被设置为异常向量地址。这就是 Spike 模拟异常处理的基本机制——异常不是中断执行而是改变执行流的正常跳转。3.4 译码分发机制如何从机器码定位到行为函数Spike 的译码分发核心是一个查表过程。riscv/decode.h中有一个insn_t类负责解析 32 位指令编码提取 opcode、rd、rs1、rs2、立即数字段。分发逻辑则是通过DECLARE_INSN注册的指令表用(insn MASK) MATCH逐一比对找到匹配的指令。这个过程看起来是线性的但因为指令数量有限几百条线性查找的代价可以接受。值得注意的细节是Spike 的译码结果不是直接跳转到行为函数而是先创建一个insn_t对象其中包含了提取好的各个字段。行为函数中通过insn.rs1()、insn.i_imm()等方法获取操作数而不是再次解析指令编码。这种设计避免了在每条指令的执行过程中重复做位运算让译码只发生一次执行只做数据操作。4. 多核调度机制与 Duffs Device 执行优化4.1 INTERLEAVE5000单线程怎么模拟多核Spike 对多核的模拟策略在riscv/sim.cc中定义核心参数是一个常量static const size_t INTERLEAVE 5000;模拟器维护一组处理器核心每个核心都是一个processor_t对象。执行调度时Spike 让每个核心连续执行INTERLEAVE条指令然后切换到下一个核心如此轮流。这本质上是一个时间片轮转调度只是时间片的单位不是毫秒而是指令条数。// 多核轮转调度的简化逻辑 for (size_t i 0; i num_cores; i) { processor_t* p get_core(i); p-run(INTERLEAVE); // 每个核心跑 5000 条指令 }这里的INTERLEAVE大小直接影响模拟的公平性和 cache 行为。如果值太大某个核心会长时间独占执行让其他核心在等待如果太小核心切换的开销会让总执行时间膨胀。5000 是一个经验值在大多数场景下能平衡切换开销和模拟公平性。4.2 Duffs Device看似奇技淫巧实则提升近一倍Spike 执行指令时用到了一个经典的 C 语言技巧——Duffs Device。它的本质是把 switch 语句嵌入到循环体中实现循环展开。在 Spike 的场景里指令缓存icache的每个 entry 对应一个 case 分支CPU 顺序执行指令时可以直接从一个 case 落入下一个 case不需要每次跳回循环头。size_t idx _mmu-icache_index(pc); auto ic_entry _mmu-access_icache(pc); #define ICACHE_ACCESS(i) { \ insn_fetch_t fetch ic_entry-data; \ pc execute_insn(this, pc, fetch); \ ic_entry ic_entry-next; \ if (i mmu_t::ICACHE_ENTRIES-1) break; \ if (unlikely(ic_entry-tag ! pc)) break; \ if (unlikely(instret1 n)) break; \ instret; \ state.pc pc; \ } switch (idx) { // icache.h 由 gen_icache 脚本生成 #include icache.h }这个实现有两个实质收益。第一每个 cache entry 有独立的 case分支预测的准确度更高因为每个 case 的跳转目标是确定的。第二顺序执行的指令不需要每次循环都做条件判断和分支跳转直接从当前 case 落入下一个 case减少了分支开销。Spike 的作者 Andrew Waterman 回忆这个优化带来了大约 2 倍的性能提升。对于模拟器这种分支密集型程序来说接近一倍的性能提升是相当可观的。4.3 这个优化能照搬到其他项目里吗Duffs Device 在现代编译器面前的效果因人而异。GCC 和 Clang 的循环展开优化已经很强有时编译器自己做展开比手写 switch-case 更高效。但 Spike 用它的理由仍然成立这种行为本质上是从 icache 按 entry 索引分发到处理逻辑用 switch-case 让分发逻辑和执行逻辑融合在一起代码上反而比拆成两个函数更清晰。如果你想在自己项目里复刻这个技巧关键在于你的访问模式是否也具备顺序遍历 entry 且每个 entry 有独立处理逻辑的特征。如果只是普通的循环展开建议先用编译器的#pragma unroll试试不要直接抄 Duffs Device——可读性的代价需要实打实的性能收益来补偿。5. 存储模型、cache 实现差异与 Spike 的调试扩展接口5.1 分页存储与 MMU 的职责边界Spike 的存储模型采用分页方式页面大小固定为 4KB每个 CPU 核心有一个 MMU 处理内存请求。但这个 MMU 和真实 CPU 中的 MMU 有一个重要区别它并不负责虚拟地址VA到物理地址PA的完整翻译。看sim_t::addr_to_mem的实现char* sim_t::addr_to_mem(reg_t addr) { if (!paddr_ok(addr)) return NULL; auto desc bus.find_device(addr); if (auto mem dynamic_castmem_t*(desc.second)) if (addr - desc.first mem-size()) return mem-contents() (addr - desc.first); return NULL; }这个函数做的事情很简单检查地址是否落在某个内存设备的区间内是则返回该地址对应的宿主机内存指针。也就是说Spike 的物理内存就是宿主机上的一块连续 bufferaddr_to_mem返回的是这一块 buffer 中的偏移位置。paddr_ok做的是合法性检查防止访问超出模拟内存范围的地址。真正的地址翻译如果有的话发生在软件层面——比如运行 Linux 时页表由内核维护TLB 的行为由 Spike 模拟但页表遍历和权限检查的细节比真实 CPU 简化得多。5.2 cache 模型更像 tracer 而不是真实的缓存Spike 的 cache 模型可能是它与真实 CPU 差异最大的地方。真实的 L1 cache 是每个核心私有的L2 是共享的Spike 里的所有核心共享同一组 L1 cache而且不存在 cache 一致性协议。更关键的是cache 中存储的只是地址的索引实际的读写操作仍然直接访问物理内存cache 并不承担缓存数据的职责。void cache_sim_t::access(uint64_t addr, size_t bytes, bool store) { store ? write_accesses : read_accesses; (store ? bytes_written : bytes_read) bytes; uint64_t* hit_way check_tag(addr); if (likely(hit_way ! NULL)) { if (store) *hit_way | DIRTY; return; } store ? write_misses : read_misses; uint64_t victim victimize(addr); if ((victim (VALID | DIRTY)) (VALID | DIRTY)) { uint64_t dirty_addr (victim ~(VALID | DIRTY)) idx_shift; if (miss_handler) miss_handler-access(dirty_addr, linesz, true); writebacks; } if (miss_handler) miss_handler-access(addr ~(linesz-1), linesz, false); if (store) *check_tag(addr) | DIRTY; }重点看这个函数的逻辑每次访问都会更新read_accesses或write_accesses计数器命中则更新 DIRTY 位并返回未命中则更新read_misses或write_misses然后通过victimize选择一个牺牲行。如果牺牲行是脏的会触发一次写回——但这个写回只是调用miss_handler的access方法本质上是在更新计数器的计数而不是真的把数据写回内存。原因很简单数据根本没有放进 cache 里所有的 load/store 都是直接对物理内存操作的。这也解释了为什么 Spike 的 cache 更像一个 tracer它记录的是命中率、缺失率、写回次数等指标。当你在 Spike 上跑程序时它输出的 cache 统计信息能反映程序的访存模式但不能精确反映真实 CPU 上的 cache 行为——因为在真实 CPU 上cache miss 会造成 stall而在 Spike 上cache miss 只是计数器加一不影响执行速度。做性能分析时要记住这个边界Spike 的 cache 统计适合做相对比较不适合预测绝对性能。5.3 预留 opcode 扩展接口自定义指令从哪下手RISC-V 在设计时预留了 4 个 opcode 用于自定义扩展指令Spike 为这 4 个 opcode 提供了直接的接口。这就意味着你不需要修改 Spike 的核心译码逻辑只需要定义MATCH和MASK并在riscv/insns/下添加自定义行为文件。// 自定义指令的编码定义示例 #define MATCH_CUSTOM0 0x0b #define MASK_CUSTOM0 0x707f DECLARE_INSN(CUSTOM0, MATCH_CUSTOM0, MASK_CUSTOM0)对应的行为定义写在riscv/insns/custom0.h中格式和标准指令完全一致。配合 5.1 节提到的接口一条自定义指令从定义到使用只需要三个步骤改 encoding.h、写行为文件、在目标程序中使用对应的编码。所以 Spike 也是自定义指令开发的理想测试平台先在这个模拟器里验证指令的行为逻辑再把它落地到真实的 RTL 实现中。5.4 调试接口的三种用法Spike 提供的调试接口可以从外部获取处理器状态。这部分的典型用法如下// 获取 0 号核心的处理器对象 processor_t* p sim_t::get_core(0); // 读取寄存器 x10对应 RISC-V 的 a0 reg_t a0 p-get_state()-XPR[10]; // 读取当前 pc reg_t pc p-get_state()-pc; // 获取 MMU 指针直接读取内存 mmu_t* mmu p-get_mmu(); reg_t val mmu-load_uint64(0x00000000);这里有个实用的验证 Trick在测试程序里把关键变量的地址放在某个寄存器中然后暂停模拟器通过get_state()-XPR[n]直接读出该变量的值再用mmu-load_uint64读出内存内容对比程序打印的结果是否一致。这个方法不需要修改测试程序也不需要打断点非常适合快速验证程序逻辑。如果需要在执行过程中实时观察寄存器变化Spike 还支持通过 OpenOCD 连接到 GDB 进行远程调试。这个模式下的调试体验和真实的 RISC-V 开发板几乎一致——你可以设置断点、单步执行、查看寄存器比 Spike 自带的 debug mode 功能完整得多。配置方式通常是在 GDB 中target remote localhost:3333连接 OpenOCD而 OpenOCD 则通过 Spike 提供的调试接口控制模拟器。对于需要运行到特定位置再检查状态的场景这套组合比在 Spike 源码里加 printf 要高效得多——不需要重新编译模拟器只需要在 GDB 侧操作。5.5 验证 Spike 的 cache 统计输出一个快速的验证方法是用 Spike 运行一个简单的循环程序然后观察它的 cache 统计输出。spike --ic16:64:1 --dc16:64:1 your_program.elf--ic和--dc分别是指令 cache 和数据 cache 的配置参数格式为size:associativity:line_size。执行结束后Spike 会输出 cache 的访问次数、命中率、缺失率等统计信息。你可以尝试调整 cache 大小观察命中率的变化是否符合预期——如果增大 cache 后命中率没有明显提升可能是程序的访存局部性太差或者你的测试程序的访存模式本身就不是 cache 友好的。这种 调整参数、观察统计、验证假设 的循环才是 Spike 的 cache 模型真正的用武之地。本文还有配套的精品资源点击获取
返回列表