Skip to content

微机原理课程设计 RISC-V指令集模拟器CakeMu-RV

zhaocake, 2024/12/19


该项目是微机原理课程设计项目

开源地址:https://github.com/ZhaoCake/cakemu_rv


设计目标

设计一个支持RV32I指令集的RISC-V指令集模拟器,从而学习理解现代指令集处理器的工作方式,理解与8086的区别与联系。

  1. 借助课程上学习到的计算机硬件组成部分的理解,查阅资料,完成一个RISC-V指令集模拟器。
  2. 通过该处理器模拟器设计外设部分,从而学习现代指令集常见的统一编址与8086分开编址的区别。
  3. 编写该模拟器所需的C语言开发环境,从而学习裸机开发的过程。

现状

处理器模拟器在现代计算机系统的开发和研究中扮演着重要角色。当前,一些主要的处理器模拟器如QEMU和NEMU在功能和生态系统方面的发展情况如下:

  1. QEMU
  2. 功能和作用 :QEMU是一个普遍使用的开源模拟器,支持多种指令集架构,包括x86、ARM、MIPS以及RISC-V。它能够提供全系统模拟和用户级模拟,帮助开发者在不同架构下运行和测试软件。
  3. 性能优化 :通过动态翻译技术,QEMU能够有效地提升模拟速度,并最大化地缩小与真实硬件环境间的性能差距。
  4. 用户生态 :QEMU拥有广泛的社区支持,并且集成于多个开发工具链中,如用于发行版测试的CI/CD管道。这种可扩展性和丰富的资源库使得开发和调试过程更加便利。
  5. NEMU
  6. 功能和作用 :NEMU是一个特别针对RISC-V及相关指令集进行优化的模拟器。它主要用于学术研究和教学,提供了一个简洁且高效的环境来理解处理器架构的底层原理。
  7. 设计简化 :NEMU的设计目标是提供简约的代码结构,使得易于被学生和研究人员扩展和修改,这在教育场景中尤为重要。
  8. 用户生态 :较QEMU而言,NEMU的用户群体更加专注于教育和学术研究领域。然而,它在这个领域内具备良好的声誉和稳定的用户基础。

我们小组所设计的指令集模拟器的思路主要来源于NEMU,所使用主要语言为Rust(主要为避免使用C造成的大量抄写NEMU)。

设计进展

1 现有功能特性

  • 支持 RV32I 基本指令集
  • 完整的外设模拟系统:
  • UART:支持字符和字符串输出
  • Timer:可编程定时器,支持中断
  • Wave Generator:波形发生器,支持多种波形输出
    • 正弦波
    • 方波(可调占空比)
    • 三角波
    • 锯齿波
  • 提供 C 语言开发环境
  • 支持调试输出控制
  • 波形数据可视化工具
  • 轻量级二进制程序构建工具

2 关键代码展示

2.1 CPU结构体

// cpu.rs
pub struct Cpu {
    registers: RegisterFile,
    pc: u32,
    memory: Memory,
    debugger: Debugger,
}

上面代码展示了Cpu结构体的内容,其中关键部分为register、PC、Memory。(Debugger是在运行过程中用于输出调试信息的模块)

该结构体体现出处理器的关键是寄存器与内存,换言之,处理器运行的过程实际上就是根据状态转换规则所发生的寄存器和内存状态的改变。

2.2 内存读写

// memory.rs
pub struct Memory {
    data: Vec<u8>,
    devices: Devices,
}

// devices/mod.rs
pub struct Devices {
    uart: Uart,
    timer: Timer,
    wave: Wave,
}

// device/uart.rs
pub struct Uart {
    data: u8,        // 数据寄存器
    status: u8,      // 状态寄存器
    control: u8,     // 控制寄存器
}

这几个结构表现了外设寄存器与用于内存对于处理器而言没有本质区别,因此完全可以使用统一的读写逻辑进行读写,在模拟器中体现为地址的转换,在硬件上体现为现代SoC常见的片内总线,如AHB、AXI等。

// memory.rs
// 将虚拟地址转换为内存索引
    fn translate_address(&self, addr: usize) -> Result<usize, &'static str> {
        match addr {
            // 代码段:0x80000000-0x80FFFFFF -> 0x00000000-0x00FFFFFF
            addr if addr >= 0x80000000 && addr < 0x81000000 => {
                Ok(addr - 0x80000000)
            }
            // 数据段:0x01000000-0x01FFFFFF -> 保持原地址
            addr if addr >= 0x01000000 && addr < 0x02000000 => {
                Ok(addr)
            }
            // 外设段:0x02000000-0x02FFFFFF -> 转发到设备
            addr if addr >= 0x02000000 && addr < 0x03000000 => {
                Err("Device address")  // 特殊错误,表示这是设备地址
            }
            _ => {
                println!("Invalid memory access at address 0x{:08x}", addr);
                Err("Invalid memory access: address out of valid ranges")
            }
        }
    }

这个代码片段体现了模拟器中进行地址映射的过程,可以看作是输入一个虚拟地址,输出一个“物理地址”。在外设设计部分,有更多关于控制寄存器映射到内存空间的例子。

在硬件上,也会采用分级总线结构,如APB桥接到AXI、AHB总线上,这主要是由于从机实际需求的频率较低以及主机的扇出能力所导致的。在模拟器中,并不需要模拟这种分级总线结构。

2.3 外设读写

在上面一节,已经粗略地提及了外设与内存的读写形式是一致的,在模拟器中通过地址范围的判断来决定是读写外设还是读写内存,同样的,在外设部分,可以再此通过对请求地址的判断来确定读写的是哪个外设:

pub fn read(&mut self, addr: usize, size: usize) -> Result<u32, &'static str> {
        match addr {
            0x02000000..=0x0200000F => self.uart.read(addr & 0xF, size),
            0x02000100..=0x0200010F => self.gpio.read(addr & 0xF, size),
            0x02000200..=0x0200020F => self.timer.read(addr & 0xF, size),
            0x02000300..=0x0200031F => self.wave.read(addr & 0x1F, size),
            _ => Err("Invalid device address"),
        }
    }

    pub fn write(&mut self, addr: usize, value: u32, size: usize) -> Result<(), &'static str> {
        match addr {
            0x02000000..=0x0200000F => self.uart.write(addr & 0xF, value, size),
            0x02000100..=0x0200010F => self.gpio.write(addr & 0xF, value, size),
            0x02000200..=0x0200020F => self.timer.write(addr & 0xF, value, size),
            0x02000300..=0x0200031F => self.wave.write(addr & 0x1F, value, size),
            _ => Err("Invalid device address"),
        }
    }

补充:在硬件实现上,例如AHB总线,是通过一个集中式的地址解码器来金额点主设备发出的地址是属于那一个从设备的,根据地址范围判定之后生成选通信号来选择相应的从设备

3 在模拟器上运行程序

3.0 环境准备

对于裸机开发的C程序,首先需要通过一段汇编程序初始化资源以及调用主函数。

// start.S
...
_start:
    # 设置栈指针到数据段末尾,确保16字节对齐
    li sp, 0x01ffffe0

    # 设置帧指针
    add s0, sp, zero

    # 为main函数创建栈帧
    addi sp, sp, -16
    sw ra, 12(sp)
    sw s0, 8(sp)

    # 调用主函数
    call main

    # 恢复栈帧
    lw ra, 12(sp)
    lw s0, 8(sp)
    addi sp, sp, 16

    # 程序结束,使用 ebreak
    ebreak
...

然后需要通过linker.ld链接脚本设置程序的起始地址、入口函数、段空间设置等:OUTPUT_FORMAT("elf32-littleriscv")ENTRY(_start)

然后就可以进行主程序的编写

3.1 UART

在device.rs中,已经实现了UART外设,而要在C程序中使用他,本质上是对其寄存器的读写。因此先在uart.h中定义其控制寄存器地址:

// uart.h
// UART 寄存器基地址
#define UART_BASE   0x02000000

// UART 寄存器
#define UART_DATA    (UART_BASE + 0x0)
#define UART_STATUS  (UART_BASE + 0x4)
#define UART_CONTROL (UART_BASE + 0x8)

// 函数声明
void uart_putc(char c);
void uart_puts(const char *str);

只需要在C代码中对几个寄存器进行读写就可以控制uart外设的行为。在模拟器中,我们对这一过程进行了简化,选择直接使用内联汇编的方法来加载当前字符(应该采用读写上面宏定义的寄存器更加合理)。

// uart.c
void uart_putc(char c) {
    // 使用内联汇编直接写入 UART 寄存器
    asm volatile(
        "li t0, 0x2000000\n\t"  // UART 基地址
        "sb %0, 0(t0)"          // 写入字符
        :                       // 无输出操作数
        : "r"(c)               // 输入操作数
        : "t0"                 // 破坏的寄存器
    );
}
// uart_puts 略

3.1 波形发生器

在uart外设的读写中,主要体现的是写入寄存器作为uart的数据缓冲被打印出来;在波形发生器中,主要展示通过寄存器配置外设工作状态从而输出不同波形。

// wave.h
// 波形发生器寄存器基地址
#define WAVE_BASE   0x02000300

// 波形发生器寄存器
#define WAVE_CONTROL    (WAVE_BASE + 0x0)  // 控制寄存器
#define WAVE_FREQUENCY  (WAVE_BASE + 0x4)  // 频率寄存器 (Hz)
#define WAVE_AMPLITUDE  (WAVE_BASE + 0x8)  // 幅度寄存器 (0-255)
#define WAVE_PHASE      (WAVE_BASE + 0xC)  // 相位寄存器 (0-359度)
#define WAVE_DUTY       (WAVE_BASE + 0x10) // 占空比寄存器 (0-100%)

// 波形控制寄存器位
#define WAVE_ENABLE     (1 << 0)  // 使能位
#define WAVE_TYPE_MASK  (0x7 << 1)  // 波形类型掩码
#define WAVE_TYPE_SHIFT 1

// 波形类型
#define WAVE_TYPE_SINE     0  // 正弦波
#define WAVE_TYPE_SQUARE   1  // 方波
#define WAVE_TYPE_TRIANGLE 2  // 三角波
#define WAVE_TYPE_SAWTOOTH 3  // 锯齿波

// 函数声明
void wave_init(void);
void wave_enable(void);
void wave_disable(void);
void wave_set_type(uint32_t type);
void wave_set_frequency(uint32_t freq);
void wave_set_amplitude(uint32_t amp);
void wave_set_phase(uint32_t phase);
void wave_set_duty(uint32_t duty);

在原理上与UART无本质区别,外设的使用都是对其功能寄存器的读写。

在模拟外设的实现上,选择将产生的波形保存到文件wave.txt

同时提供一个python脚本用于对产生的波形进行可视化

4 总结

通过尝试设计一个指令集模拟器,对于计算机的运行过程有了更深的了解;通过对该模拟器编写裸机开发环境,加深了对处理器中程序的执行经过的理解。

此外可以认识到处理器模拟器在实际环境中的作用:能够在SoC流片之前进行早期软件开发从而缩短工作周期;虚拟的内存和寄存器完全透明,有利于调试分析;对于内存布局相似的模拟器和真实设备,不需要对代码做太多修改即可移植;但它不能够用于性能评估。

附录

CakeMu-RV项目README.md:

CakeMu-RV

English | 简体中文

简介

CakeMu-RV 是一个用 Rust 编写的简单 RISC-V 模拟器,支持 RV32I 基本指令集。这是一个用于学习计算机组成原理的个人项目,通过实现基本的 CPU 执行过程和简单的外设系统,帮助理解计算机的基本工作原理。目前实现了指令执行、内存访问和基础的 I/O 操作等基本功能。该模拟器可以作为学习计算机组成原理的一个参考和实践工具。

功能特性

  • 支持 RV32I 基本指令集
  • 完整的外设模拟系统:
  • UART:支持字符和字符串输出
  • Timer:可编程定时器,支持中断
  • Wave Generator:波形发生器,支持多种波形输出
    • 正弦波
    • 方波(可调占空比)
    • 三角波
    • 锯齿波
  • 提供 C 语言开发环境
  • 支持调试输出控制
  • 波形数据可视化工具
  • 轻量级二进制程序构建工具

快速开始

  1. 克隆仓库:
git clone https://github.com/yourusername/cakemu_rv.git
cd cakemu_rv
  1. 编译 C 测试程序:
cd c_sim
make
  1. 运行模拟器:
cargo run --bin riscv-emu build/program.bin
  1. 查看波形数据(如果使用了波形发生器):
python3 tools/plot_wave.py

命令行选项

  • --no-mtrace:禁用内存访问跟踪
  • --no-regtrace:禁用寄存器跟踪
  • --no-itrace:禁用指令跟踪
  • --step:启用单步执行模式

C 语言开发

项目提供了完整的 C 语言开发环境,包含以下外设支持:

UART

  • 基本功能:字符和字符串输出
  • 接口:
  • uart_putc(char c):输出单个字符
  • uart_puts(const char *str):输出字符串

Timer

  • 基本功能:可编程定时器
  • 寄存器映射:0x02000200
  • 主要功能:
  • 可编程计数值
  • 中断支持
  • 自动重载功能
  • 接口:
  • timer_init(uint32_t compare_value):初始化定时器
  • timer_enable():启动定时器
  • timer_disable():停止定时器
  • timer_get_status():获取定时器状态
  • timer_clear_status():清除定时器状态

Wave Generator

  • 基本功能:波形发生器
  • 寄存器映射:0x02000300
  • 支持波形:
  • 正弦波 (WAVE_TYPE_SINE)
  • 方波 (WAVE_TYPE_SQUARE)
  • 三角波 (WAVE_TYPE_TRIANGLE)
  • 锯齿波 (WAVE_TYPE_SAWTOOTH)
  • 可配置参数:
  • 频率 (1Hz-100kHz)
  • 幅度 (0-255)
  • 相位 (0-359度)
  • 占空比 (0-100%, 仅方波)
  • 接口:
  • wave_init():初始化波形发生器
  • wave_enable():启动输出
  • wave_disable():停止输出
  • wave_set_type(uint32_t type):设置波形类型
  • wave_set_frequency(uint32_t freq):设置频率
  • wave_set_amplitude(uint32_t amp):设置幅度
  • wave_set_phase(uint32_t phase):设置相位
  • wave_set_duty(uint32_t duty):设置占空比

波形数据可视化

项目提供了波形数据可视化工具:

  1. 波形数据自动保存到 wave.txt
  2. 使用 Python 脚本可视化:
python3 tools/plot_wave.py

示例程序

项目包含了一个综合测试程序 (c_sim/main.c),演示了所有外设的使用方法:

  • UART 字符和字符串输出测试
  • Timer 定时器测试(短延时和长延时)
  • Wave Generator 所有波形类型测试

许可证

本项目采用 GPL-3.0 许可证。详见 LICENSE 文件。