Projet personnel
Émulateur RISC-V (RV32I) en C
Émulateur RV32I from scratch en C99 strict — boucle fetch-decode-execute, 47 instructions, validé sur la suite officielle riscv-tests.
- C99
- CMake
- Ninja
- Unity
- GCC RISC-V toolchain
- WSL2
Problème
Comprendre intimement comment un CPU exécute du code — pas en lisant un manuel d'architecture, mais en construisant la chose. RV32I est l'ISA idéale pour ça : 47 instructions suffisent à exécuter du C compilé, et la spec est ouverte et claire.
Architecture
Le code est découpé en modules à responsabilité unique : cpu.c (état + boucle fetch-decode-execute), decoder.c (mot 32 bits → struct typée Instruction), executor.c (effet de chaque instruction sur le CPU), memory.c (RAM 1 MB little-endian). Pas de cycles dans les #include, pas de malloc dans le hot path. C99 strict avec -Wall -Wextra -Wpedantic -Wshadow -Wconversion. Build CMake + Ninja, dev sur WSL2 Ubuntu, cross-compilation des programmes de test avec riscv64-unknown-elf-gcc.
Points clés
- 47 instructions RV32I — ALU, loads / stores, branches, jumps, system
- Modules séparés : cpu, decoder, executor, memory, utils
- Décodeur typé : un mot 32 bits → struct Instruction (6 formats)
- Banc de 32 registres + PC, garde x0 = 0 enforcée en lecture ET en écriture
- RAM 1 MB little-endian, accès byte / half / word
- ~70 sous-tests unitaires Unity
- Suite officielle riscv-tests (~40 rv32ui-p-*)
- 6 programmes asm de démo (Fibonacci, factorielle, popcount, etc.)
Décisions techniques
01.Décodeur typé, pas un méga switch dans cpu_step
decode(raw) retourne une struct Instruction { type, rd, rs1, rs2, imm, raw }. Le type est un enum InstrType (INSTR_ADDI, INSTR_BEQ, …) et l'executor fait le switch là-dessus, pas sur l'opcode brut. Résultat : le décodeur est testable en isolation, et chaque cas d'exécution lit comme du pseudo-code.
02.L'executor renvoie un bool 'pc_modified'
Plutôt que de laisser l'executor toucher au PC pour les branches / jumps et un autre code l'incrémenter ailleurs, l'executor retourne pc_modified. cpu_step() incrémente PC de 4 seulement si l'instruction ne l'a pas modifié. Une seule source de vérité pour la logique du PC.
03.x0 hardwired aux deux endroits
cpu_read_reg(0) retourne 0 directement, et cpu_write_reg(0, _) est silencieusement ignoré. Defense-in-depth : un bug dans l'executor qui écrirait sur x0 ne pollue pas l'état. Conforme au comportement matériel.
04.C99 strict avec warnings élevés
Pas d'extensions GCC, pas de feature-flags qui dérivent ailleurs. -Wall -Wextra -Wpedantic -Wshadow -Wconversion. Le code passe sans warning — c'est une discipline qui force à expliciter chaque cast et chaque shadowing.
05.Triple validation
Niveau 1 : ~70 sous-tests Unity, un fichier par module. Niveau 2 : programmes asm faits maison (Fibonacci, factorielle, popcount) avec résultats attendus vérifiés en CI. Niveau 3 : suite officielle riscv-tests (~40 tests de conformité ISA rv32ui-p-*). Si les trois passent, l'émulateur exécute du vrai code.
Extrait de code
// src/cpu.c — cycle fetch-decode-execute (extrait réel)void cpu_step(Cpu *cpu){ if (cpu->halted) return; uint32_t raw = mem_read32(cpu->pc); Instruction instr = decode(raw); bool pc_modified = execute(cpu, &instr); if (!pc_modified && !cpu->halted) { cpu->pc += 4u; }}