• Author Information: Deep Observer · Weapon Jun | Account:
• Copyright Notice: Please contact the author for commercial use
This is a great way to deeply understand the RISC-V architecture by implementing a small RISC-V simulator in C language on MinGW (Windows environment), focusing on simulating the core instruction set and runtime environment of RISC-V. The core components of a RISC-V simulator require the following parts: 1. CPU State ◦ 32 General-purpose Registers (x0-x31) ◦ Program Counter (pc) ◦ Memory (for storing instructions and data) 2. Instruction Cycle ◦ Fetch (read instruction from memory) ◦ Decode (parse the opcode and operands of the instruction) ◦ Execute (perform the operation corresponding to the instruction) ◦ Write Back (write the result back to registers or memory) 3. Instruction Set Support ◦ First implement the basic integer instruction set (RV32I) ◦ Including arithmetic operations, logical operations, load/store, branch/jump, etc.
Now, let’s build the simplest RISC-V instruction set simulator that can be compiled and run in the MinGW environment. This example will include the following core functionalities: 1. CPU State Simulation: Implement 32 general-purpose registers from x0 to x31, as well as the program counter (pc). 2. Memory Simulation: Use a simple byte array to simulate memory space. 3. Instruction Execution Loop: Implement a “fetch-decode-execute” loop. 4. Basic Instruction Support: Focus on implementing the most core and commonly used instructions in the RV32I instruction set, such as add, addi, lw, sw, beq, jal. 5. Test Program: We will directly “inject” a simple RISC-V assembly code machine code into memory for the simulator to execute, to verify functionality.
Final Code (riscv_sim.c)
#include #include #include #include // =============================================================================// 1. Define RISC-V CPU and Memory Structure// =============================================================================// Define RISC-V CPU State typedef struct { uint32_t reg[32]; // 32 32-bit general-purpose registers x0-x31 uint32_t pc; // Program Counter uint8_t *mem; // Pointer to memory uint32_t mem_size; // Total memory size (bytes)} riscv_cpu_t;// Global CPU Instance riscv_cpu_t cpu;// =============================================================================// 2. Helper Functions: Memory Read/Write// =============================================================================// Read a 32-bit word from memory (big-endian) uint32_t mem_read32(uint32_t addr) { // Simple out-of-bounds check if (addr + 4 > cpu.mem_size) { printf(“Memory access out of bounds at 0x%08x\n”, addr); exit(1); } // RISC-V is big-endian, but our memory is stored in little-endian, so conversion is needed return (cpu.mem[addr] << 24) | (cpu.mem[addr + 1] << 16) | (cpu.mem[addr + 2] << 8) | cpu.mem[addr + 3];}// Write a 32-bit word to memory (big-endian) void mem_write32(uint32_t addr, uint32_t data) { if (addr + 4 > cpu.mem_size) { printf(“Memory access out of bounds at 0x%08x\n”, addr); exit(1); } cpu.mem[addr] = (data >> 24) & 0xFF; cpu.mem[addr + 1] = (data >> 16) & 0xFF; cpu.mem[addr + 2] = (data >> 8) & 0xFF; cpu.mem[addr + 3] = data & 0xFF;}// =============================================================================// 3. Instruction Execution Functions// =============================================================================// Execute R-type instructions (e.g., add, sub) void exec_r_type(uint32_t instr) { uint8_t rd = (instr >> 7) & 0x1F; // Destination register uint8_t funct3 = (instr >> 12) & 0x7; // Function code 3 uint8_t rs1 = (instr >> 15) & 0x1F; // Source register 1 uint8_t rs2 = (instr >> 20) & 0x1F; // Source register 2 uint8_t funct7 = (instr >> 25) & 0x7F; // Function code 7 (void)funct3; // Unused, to prevent compiler warning if (funct7 == 0x00) { // add rd, rs1, rs2 cpu.reg[rd] = cpu.reg[rs1] + cpu.reg[rs2]; printf(“add x%d, x%d, x%d\t// x%d = %u + %u = %u\n”, rd, rs1, rs2, rd, cpu.reg[rs1], cpu.reg[rs2], cpu.reg[rd]); } else if (funct7 == 0x20) { // sub rd, rs1, rs2 cpu.reg[rd] = cpu.reg[rs1] – cpu.reg[rs2]; printf(“sub x%d, x%d, x%d\t// x%d = %u – %u = %u\n”, rd, rs1, rs2, rd, cpu.reg[rs1], cpu.reg[rs2], cpu.reg[rd]); } else { printf(“Unknown R-type instruction: funct7=0x%x\n”, funct7); exit(1); }}// Execute I-type instructions (e.g., addi, lw) void exec_i_type(uint32_t instr) { uint8_t opcode = instr & 0x7F; uint8_t rd = (instr >> 7) & 0x1F; uint8_t funct3 = (instr >> 12) & 0x7; uint8_t rs1 = (instr >> 15) & 0x1F; int32_t imm = (int32_t)(instr >> 20); // Sign extension if (opcode == 0x13) { // Immediate arithmetic instruction if (funct3 == 0x0) { // addi rd, rs1, imm cpu.reg[rd] = cpu.reg[rs1] + imm; printf(“addi x%d, x%d, %d\t// x%d = %u + %d = %u\n”, rd, rs1, imm, rd, cpu.reg[rs1], imm, cpu.reg[rd]); } else { printf(“Unknown I-type (opcode=0x13) instruction: funct3=0x%x\n”, funct3); exit(1); } } else if (opcode == 0x03) { // Load instruction if (funct3 == 0x2) { // lw rd, offset(rs1) uint32_t addr = cpu.reg[rs1] + imm; cpu.reg[rd] = mem_read32(addr); printf(“lw x%d, %d(x%d)\t// x%d = mem[0x%08x] = %u\n”, rd, imm, rs1, rd, addr, cpu.reg[rd]); } else { printf(“Unknown I-type (opcode=0x03) instruction: funct3=0x%x\n”, funct3); exit(1); } }}// Execute S-type instructions (e.g., sw) void exec_s_type(uint32_t instr) { uint8_t funct3 = (instr >> 12) & 0x7; uint8_t rs1 = (instr >> 15) & 0x1F; uint8_t rs2 = (instr >> 20) & 0x1F; int32_t imm = ((int32_t)(instr >> 25) << 5) | ((instr >> 7) & 0x1F); // Immediate concatenation (void)funct3; if (funct3 == 0x2) { // sw rs2, offset(rs1) uint32_t addr = cpu.reg[rs1] + imm; mem_write32(addr, cpu.reg[rs2]); printf(“sw x%d, %d(x%d)\t// mem[0x%08x] = %u\n”, rs2, imm, rs1, addr, cpu.reg[rs2]); } else { printf(“Unknown S-type instruction: funct3=0x%x\n”, funct3); exit(1); }}// Execute B-type instructions (e.g., beq) void exec_b_type(uint32_t instr) { uint8_t funct3 = (instr >> 12) & 0x7; uint8_t rs1 = (instr >> 15) & 0x1F; uint8_t rs2 = (instr >> 20) & 0x1F; // Immediate concatenation and sign extension int32_t imm = ((int32_t)(instr >> 12) & 0x1) << 11 | ((int32_t)(instr >> 25) << 4) | ((int32_t)(instr >> 7) & 0xF) << 1; if ((instr >> 31) & 0x1) { // Sign bit imm |= 0xFFFFF000; // Extend sign bit } if (funct3 == 0x0) { // beq rs1, rs2, offset printf(“beq x%d, x%d, %d\t// %u == %u ? “, rs1, rs2, imm, cpu.reg[rs1], cpu.reg[rs2]); if (cpu.reg[rs1] == cpu.reg[rs2]) { printf(“Yes, jumping to 0x%08x\n”, cpu.pc + imm); cpu.pc += imm; // Jump } else { printf(“No, pc remains 0x%08x\n”, cpu.pc); } } else { printf(“Unknown B-type instruction: funct3=0x%x\n”, funct3); exit(1); }}// Execute J-type instructions (e.g., jal) void exec_j_type(uint32_t instr) { uint8_t rd = (instr >> 7) & 0x1F; // Immediate concatenation and sign extension int32_t imm = ((int32_t)(instr >> 12) & 0xFF) << 12 | ((int32_t)(instr >> 20) & 0x1) << 11 | ((int32_t)(instr >> 21) & 0x3FF) << 1; if ((instr >> 31) & 0x1) { imm |= 0xFFF00000; } // jal rd, offset cpu.reg[rd] = cpu.pc; // Write return address to rd printf(“jal x%d, %d\t// x%d = pc(0x%08x), jumping to 0x%08x\n”, rd, imm, rd, cpu.pc, cpu.pc + imm); cpu.pc += imm; // Jump}// Instruction decode and execute void execute_instruction(uint32_t instr) { uint8_t opcode = instr & 0x7F; printf(“Executing instr: 0x%08x (pc: 0x%08x) Opcode: 0x%x\n”, instr, cpu.pc – 4, opcode); switch (opcode) { case 0x33: // R-type exec_r_type(instr); break; case 0x13: // I-type (addi) case 0x03: // I-type (lw) exec_i_type(instr); break; case 0x23: // S-type (sw) exec_s_type(instr); break; case 0x63: // B-type (beq) exec_b_type(instr); break; case 0x6F: // J-type (jal) exec_j_type(instr); break; case 0x00: // No operation (nop) printf(“nop\n”); break; default: printf(“Unknown opcode: 0x%x\n”, opcode); exit(1); }}// =============================================================================// 4. Initialization and Main Loop// =============================================================================// Initialize CPU and Memory void cpu_init(uint32_t mem_size) { // Initialize registers, x0 is always 0 memset(cpu.reg, 0, sizeof(cpu.reg)); // Set program counter to default starting address cpu.pc = 0x80000000; // Initialize memory cpu.mem_size = mem_size; cpu.mem = (uint8_t *)malloc(mem_size); if (!cpu.mem) { perror(“Failed to allocate memory”); exit(1); } memset(cpu.mem, 0, mem_size); printf(“CPU initialized. PC: 0x%08x, Mem Size: %u bytes\n”, cpu.pc, mem_size);}// Load test program into memory void load_test_program() { // We will directly write a simple assembly program machine code into memory // Program starting address is 0x80000000 // Assembly code is as follows: // 1. addi x1, x0, 10 # x1 = 10 (loop count) // 2. addi x2, x0, 0 # x2 = 0 (accumulator) // 3. addi x3, x0, 1 # x3 = 1 (step) // 4. loop: // 5. add x2, x2, x3 # x2 = x2 + x3 // 6. addi x3, x3, 1 # x3 = x3 + 1 // 7. beq x3, x1, exit # If x3 == x1, jump to exit // 8. jal x0, loop # Unconditionally jump back to loop // 9. exit: // 10. sw x2, 0x100(x0) # Write result to memory address 0x80000100 uint32_t program[] = { 0x00a00093, // addi x1, x0, 10 0x00000113, // addi x2, x0, 0 0x00100193, // addi x3, x0, 1 0x003101b3, // add x2, x2, x3 0x00118193, // addi x3, x3, 1 0xfe511ce3, // beq x3, x1, -20 (jump to exit) 0xff5ff06f, // jal x0, -24 (jump back to loop) 0x00202423 // sw x2, 256(x0) }; // Write program into memory starting address for (int i = 0; i < sizeof(program) / sizeof(program[0]); i++) { mem_write32(cpu.pc + i * 4, program[i]); } printf(“Test program loaded into memory.\n”);}// Print CPU state void print_cpu_state() { printf(“\n=== CPU State ===\n”); printf(“PC: 0x%08x\n”, cpu.pc); printf(“Registers:\n”); for (int i = 0; i < 32; i++) { printf(“x%02d: 0x%08x (%10u) %s\n”, i, cpu.reg[i], cpu.reg[i], (i == 0) ? “(zero)” : (i == 1) ? “(ra)” : (i == 2) ? “(sp)” : “”); } printf(“=================\n\n”);} int main() { // Initialize CPU, allocate 64KB memory cpu_init(64 * 1024); // Load test program load_test_program(); // Print initial state print_cpu_state(); // Main execution loop, execute 10 instructions then exit (enough to complete our test program) for (int i = 0; i < 10; i++) { printf(“\n— Instruction Cycle %d —\n”, i + 1); // 1. Fetch uint32_t instr = mem_read32(cpu.pc); // 2. Update PC (RISC-V instructions are 4-byte aligned) cpu.pc += 4; // 3. Execute execute_instruction(instr); } // Print final state print_cpu_state(); // Check execution result uint32_t result = mem_read32(0x80000100); printf(“Result stored in memory (0x80000100): %u\n”, result); printf(“Expected result (sum 1+2+…+10): 55\n”); if (result == 55) { printf(“✅ Test passed!\n”); } else { printf(“❌ Test failed!\n”); } // Free memory free(cpu.mem); return 0;}
How to Compile and Run on MinGW 1. Install MinGW-w64: ◦ If you haven’t installed it yet, download and install it from the MinGW-w64 official website. ◦ During installation, make sure to select an architecture (e.g., x86_64). 2. Open MinGW-w64 Terminal: ◦ After installation, open the “MinGW-w64 Win64 Shell” or a similar terminal. 3. Compile the code: ◦ Use the gcc command to compile the riscv_sim.c file. gcc -o riscv_sim.exe riscv_sim.c 4. Run the simulator: ◦ Execute the generated riscv_sim.exe file. ./riscv_sim.exe Expected Output When you run the program, you will see a series of detailed execution logs, and it should ultimately output: …Result stored in memory (0x80000100): 55 Expected result (sum 1+2+…+10): 55
✅ Test passed!
This indicates that our simulator successfully executed a RISC-V program that calculates the sum from 1 to 10 and obtained the correct result of 55. Summary and Next Steps This example provides you with a solid starting point. It demonstrates how to simulate the core workings of a processor using C language. You can start expanding from here: 1. Implement more instructions: such as subi, and, or, sll, srl, bne, blt, etc. 2. Add exception handling: handle illegal instructions, memory access errors, etc. 3. Support loading ELF files: Currently, we are hardcoding machine code; the next step can be learning how to parse standard RISC-V ELF executable files and load them into memory. 4. Implement pipelining: To improve performance, you can simulate the CPU’s pipeline structure (fetch, decode, execute, memory access, write back). I hope this detailed example helps you better understand the RISC-V architecture and computer organization principles!
If you like me, follow me.
Click the bottom right corner to shareWelcome to leave comments for discussion!