7.2 C 編譯器、組譯器與指令集模擬器#
對應原始碼:
riscv/rvtools/src/cc.rs、riscv/rvtools/src/asm.rs、riscv/rvtools/src/iss.rs、riscv/rvtools/src/prelude.c上一節:7.1 RISC-V 指令集與 SoC 結構 下一節:7.3 Linux 開機
7.2.1 為什麼需要自己的 C 編譯器#
GCC 與 Clang 都能交叉編譯 RISC-V。但自己寫一個的理由是:
- 教學價值:一個 1700 行的 C 編譯器可以完整讀完;
- 完全控制:可以針對這個簡單的 SoC 產生最有效率的碼;
- 零相依:整個 workspace 不需要 LLVM。
限制是明確的(模組頭註明):
- 沒有結構体/聯合体/列舉/typedef;
- 沒有浮點;
- 沒有函式指標;
- 最多 8 個參數(加上
...); char是無號的(依 RISC-V ABI)。
7.2.2 編譯器的四個階段#
flowchart TD
A["C 原始碼"] --> B["預處理器<br/>Pre::run"]
B --> C["詞法分析<br/>lex_line"]
C --> D["語法分析<br/>遞迴下降"]
D --> E["程式碼生成<br/>Gen:堆疊機器"]
E --> F["RV32I / RV64 組合語言"]
F --> G["組譯器<br/>asm::assemble_with"]
G --> H["機器碼 + 符號 + listing"]7.2.2.1 預處理器#
Pre::run 的功能:
strip_comments(保留換行,所以行號不會跑掉;處理\續行);#define(含參數與__VA_ARGS__);#undef;#ifdef/#ifndef/#if/#else/#endif;#include/#pragma被忽略(執行期庫永遠存在)。
巨集展開是遞迴的,用一個 hide 清單防止無限遞迴。
7.2.2.2 詞法分析#
enum Tok { Id(String), Num(i64, bool /*unsigned*/), Str(Vec<u8>), P(&'static str) }
47 個標點 token(最長優先)。數字支援 u/U/l/L 後綴、0x/0b/八進位/字元常數、相鄰字串常數的串接。
7.2.2.3 型別#
pub enum Ty {
Void,
Char(bool), // 有號?(char 是無號的,依 RISC-V ABI)
Short(bool),
Int(bool),
Ptr(Box<Ty>),
Arr(Box<Ty>, Option<usize>),
Func(Box<Ty>, Vec<Ty>, bool), // 回傳型別、參數、可變參數
}
size():void 1、char 1、short 2、int/ptr/func 4、array = 元素 × 個數。 align() = size 上限 4。
7.2.2.4 程式碼生成:堆疊機器#
模組頭的說明:
程式碼刻意做得簡單且可預期:每個運算式的結果都放在 a0,中間值推入堆疊,區域變數放在 frame 裡(s0 = frame pointer)。每個陳述句前面都有一個帶 C 原始碼行號的註解,所以組合語言可以跟著程式一起讀。
這是一個重要的教學設計:把 C 與組合語言的對應關係「印」在輸出裡。
關鍵常數:
/// 參數:8 個字在 s0 - 40 .. s0 - 12(遞增,所以 `...` 參數接在具名參數之後)
const ARGS: i32 = 40;
frame 大小 16 位元組對齊:
let frame = (ARGS + self.max_frame + 15) / 16 * 16;
序言:addi sp, sp, -frame / sw ra, frame-4(sp) / sw s0, frame-8(sp) / addi s0, sp, frame。frame ≥ 2048 時用 t0/t1(因為 12 位元立即數不夠)。
7.2.2.5 序言/結語的關鍵細節#
結語絕不讀取 sp 以下的位置:
v.push(" addi sp, s0, -8".into());
v.push(" lw ra, 4(sp)".into());
v.push(" lw s0, 0(sp)".into());
v.push(" addi sp, sp, 8".into());
v.push(" ret".into());
為什麼? 因為中斷處理程式會把它自己的 frame 推到 sp 以下。如果結語讀取 sp-4,而中斷剛好發生在結語期間,它會讀到被中斷推掉的內容。
這個細節在真實系統裡是個經典的 bug 來源。 把它寫在註解裡說明,是好程式碼的特徵。
7.2.2.6 立即數形式的最佳化#
binop_imm 挑最短的指令:
| 運算 | 選擇 |
|---|---|
x + c | addi(12 位元立即數) |
x * 2^k | slli |
x / 2^k(無號) | srli |
x % 2^k | andi |
x == 0 | seqz |
x != 0 | snez |
x < c | slti / sltiu |
7.2.2.7 常數折疊#
fn constant(e: &E) -> Option<(i64, Ty)> {
let v = const_eval(e)?;
let u = has_unsigned(e);
if u && v < 0 { return None; } // 無號運算變負時不折疊
Some((v, Ty::Int(!u)))
}
「無號運算變負時不折疊」 是正確性關鍵:無號的負值在 C 裡是實作定義的(實際上是模運算),折疊成一個負的 i64 會產生不同的結果。
7.2.2.8 分支折疊#
cond_jump 處理:
!→ 反轉條件;&&/||→ 短路跳轉(用分支實作,不做布林運算);- 與零比較 →
beqz/bnez; - 常數條件 → 恆真或恆假。
用分支實作 && / || 是重要的:它避免了建立一個 0/1 的值,也讓分支預測器的工作方式自然。
7.2.2.9 型別推導#
// type_of 是用「乾跑」模式執行運算式產生器並丟棄輸出來計算的
這是一個簡單但有效的技巧:既然程式碼生成器已經知道每個運算式的型別,就用它自己來算。
7.2.2.10 裸函式#
// 主體只由 asm(...) 陳述句組成的函式,不產生序言/結語
這是寫中斷處理程式與啟動碼的標準技巧。
7.2.2.11 可達性剪枝#
// 產生器記錄每個函式/全局變數引用的符號集合;
// 只有從 main 連通可達的函式(Linux 模式還有 exit,
// 加上頂層組合語言引用的符號)才會被輸出
函式層級的死碼消除。C 編譯器不做全域最佳化,但這個簡單的剪枝已經省下很多。
7.2.2.12 Linux 模式:x32 資料模型#
pub const LINUX_ORIGIN: u32 = 0x10000;
const LINUX_STACK: u32 = 256 * 1024;
RV64 的 Linux 模式用同一個 32 位元資料模型(int、long、指標都是 4 位元組,x32 風格),但用 *W 指令產生 RV64 程式碼。
轉換是一個函式:
/// 計算 32 位元整數(有號延伸)的 RV64 形式之指令
fn w_form(s: &str) -> String {
let (op, rest) = s.split_once(' ').unwrap_or((s, ""));
match op {
"add" | "sub" | "mul" | "div" | "divu" | "rem" | "remu" | "sll" | "srl" | "sra" |
"addi" | "slli" | "srli" | "srai" | "neg" => format!("{}w {}", op, rest),
_ => s.to_string(),
}
}
「W 指令讓暫存器永遠持有 32 位元值(符號延伸)」,這正是 x32 資料模型需要的語意。
為什麼用 x32 而不是完整的 LP64? 因為 Linux 核心預設假設 64 位元的 long 與指標。用 x32 會讓核心無法啟動。除非核心也用 x32 編譯。
7.2.2.13 輸出的佈局#
.text
_start:
li sp, <mem_size> # 裸機:堆疊在 RAM 的頂端
la t0, __bss_start
la t1, __bss_end
.Lbss: bgeu t0, t1, .Lmain
sw zero, 0(t0); addi t0, t0, 4; j .Lbss
.Lmain: call main
_halt: j _halt
Linux 模式:
.text
_start:
la sp, __stack_top
call main
call exit
外加 .rodata(字串常數 .LS0、.LS1…)、.data / .bss(全域變數,.balign 對齊)。
bss 清零是一個必備步驟——C 語言保證未初始化的全域變數是 0。
7.2.3 執行期庫#
riscv/rvtools/src/prelude.c(652 行)永遠被編譯進去(檔案索引 0),使用者檔案是索引 1。
7.2.3.1 周邊的巨集#
#define LED (*(volatile unsigned *)0x10000000)
#define BUTTONS (*(volatile unsigned *)0x10000004 & 7)
#define SWITCHES ((*(volatile unsigned *)0x10000004 >> 8) & 255)
#define UART_DATA (*(volatile unsigned *)0x10000008)
#define UART_STATUS (*(volatile unsigned *)0x1000000c)
#define TIMER (*(volatile unsigned *)0x10000010)
#define MTIMECMP (*(volatile unsigned *)0x10000018)
#define VGA_TEXT ((volatile unsigned *)0x10002000)
#define VGA_FONT ((volatile unsigned *)0x10004000)
#define VGA_COLOR ((volatile unsigned *)0x10006000)
volatile 是必要的:沒有它,編譯器可能把對 MMIO 的讀取迴圈最佳化掉(讀一次就好)。
7.2.3.2 軟體乘除#
// __mulsi3、__udivmod、__udivsi3、__umodsi3、__divsi3、__modsi3
這樣編譯器就不需要 M 擴充——RV32 目標沒有 M 擴充時也能跑 int * int。
7.2.3.3 printf#
// __emit、__pnum(含基底、寬度、零填充)、putchar、getchar、puts、kbhit、
// delay_ms、millis、rand
一個完整的 printf 實作只有一百多行,卻讓 C 教學變得完整。
7.2.3.4 Linux 系統呼叫#
// __syscall:透過 ecall
// open/read/write/close/lseek/unlink/getpid/exit
// malloc/free:brk 風格的配置器
7.2.4 組譯器#
riscv/rvtools/src/asm.rs 是一個兩趟的 RV32I 組譯器。
7.2.4.1 輸出結構#
pub struct Program {
pub bytes: Vec<u8>, // 從位址 0 起的映像(不含 bss)
pub symbols: Vec<(String, u32)>,
pub listing: Vec<Line>,
pub end: u32, // 所有 section 的結束(含 bss)
pub origin: u32,
}
pub struct Line { pub addr: u32, pub bytes: Vec<u8>, pub line: usize, pub text: String }
pub struct Opts { pub origin: u32, pub rv64: bool }
listing 是「位址 + 位元組 + 行號 + 原始文字」,可以用來產生可讀的 listing 檔。
7.2.4.2 Section 佈局#
const SECTIONS: [&str; 4] = [".text", ".rodata", ".data", ".bss"];
let mut base = [opts.origin; 4];
for s in 1..4 { base[s] = (base[s - 1] + off[s - 1] + 3) & !3; }
四個 section 依序排在同一個平坦映像**裡,從位址 0(或 origin)開始,每個 word 對齊。
平坦映像是對的,因為 SoC 把整個映像載入 RAM。
7.2.4.3 語法#
GNU 風格:
- 標籤;
#與//註解;off(reg)操作元;%hi(...)/%lo(...);;分隔陳述句;.equ/.set、.align/.p2align/.balign;.word/.half/.byte;.ascii/.asciz/.string;.space/.zero/.skip。
7.2.4.4 重定位運算#
Part::Hi => ((v + 0x800) >> 12) & 0xfffff,
Part::Lo => ((v & 0xfff) ^ 0x800) - 0x800, // 符號延伸的低 12 位
+ 0x800 是「四捨五入」而不是「截斷」:這樣 %hi 與 %lo 的組合在任何位址都正確產生一個能被 addi 接受的 12 位元立即數。
((v & 0xfff) ^ 0x800) - 0x800 是符號延伸的技巧:把低 12 位元的最高位翻轉後減去 0x800,等價於把無號的 12 位元轉成有號的。
7.2.4.5 偽指令#
nop, li, la, mv, not, neg, seqz, snez, sltz, sgtz, beqz, bnez, blez, bgez,
bltz, bgtz, bgt, ble, bgtu, bleu, j, jr, ret, call, tail
fence → 0x0ff0_000f,fence.i → 0x0000_100f。
li 的大小:12 位元內或 4096 對齊時用一條指令,否則兩條(lui + addi);RV64 上 lui + addiw。
7.2.4.6 RV64 模式#
Opts.rv64 加入:
*W指令;ld/sd/lwu;- 64 位元移位;
- 32 位元值的
li用addiw。
7.2.4.7 elf_linux#
// 最小靜態 ELF64:0x1000 位元組標頭,e_type = ET_EXEC (2),
// e_machine = EM_RISCV (243),e_flags = 0(無 C,軟浮點),
// 沒有 section header,一個 PT_LOAD segment(flags R|W|X)
// 在檔案偏移 0x1000,p_vaddr = p.origin,entry = _start
「沒有 section header」 是靜態可執行檔的常見簡化——核心只需要 PT_LOAD 段落。
7.2.5 指令集模擬器(ISS)#
riscv/rvtools/src/iss.rs 是 RV32IMA 的「週期精確」參考模型。
它像 RvCore 一樣精確地數時脈週期——每個指令 2 個,載入 / LR / AMO 3 個,乘除 35 個,陷入 3 個,
wfi直到有中斷待處理,重置後 1 個。
「週期精確」是關鍵:它不只是檢查功能正確,而是檢查時序模型也正確。這讓它可以驗證硬體的流水線深度。
7.2.5.1 狀態#
pub struct Iss {
pub pc: u32,
pub x: [u32; 32],
pub mem: Vec<u8>,
pub led: u32,
/// bit 2..0 = BTN3..BTN1,bit 15..8 = 開關
pub btn: u32,
pub tx: Vec<u8>,
pub rx: VecDeque<u8>,
pub cycles: u64,
pub instret: u64,
pub tx_cycles: u64,
tx_busy_until: u64,
pub vram: Vec<u8>, // 40 × 30 字元(2048 個項目)
pub font: Vec<u8>, // 128 × 8 列
pub colors: u32, // 前景 | 背景 << 16
pub mie_bit: bool, pub mpie: bool, pub mie: u32,
pub mtvec: u32, pub mscratch: u32, pub mepc: u32, pub mcause: u32, pub minstret: u32,
resv: Option<u32>,
mtime_off: u32, pub mtimecmp: u32, pub msip: bool, pub wfi: bool,
}
VGA 文字記憶體是 2048 個字元(40 × 30),字型是 128 個字元 × 8 列。
7.2.5.2 週期會計#
let mut cyc = 2; // 預設
// 載入、LR/SC、AMO:cyc = 3
// M 擴充的乘除:cyc = 35
// 陷入:self.cycles += 3; // 處理器的 Decode, Exec, Fetch
「陷入花 3 個週期」 是硬體的典型值(處理器把 PC 送到 trap handler 需要一個管線排空)。
7.2.5.3 計時器#
// mtime = cycle + mtime_off
// 寫入計時器暫存器會設定偏移量,讓新值從「下一個週期」開始生效
self.mtime_off = v.wrapping_sub((t + 1) as u32);
7.2.5.4 UART#
一個位元組每 tx_cycles;狀態暫存器 bit 0 = 忙碌,bit 1 = 收到一個位元組。
7.2.5.5 AMO 的保留集#
// LR/SC 的保留是一個 resv: Option<u32>
// AMO 的寫入發生在 Mem 週期
self.store(addr, new, 15, e + 1);
7.2.5.6 陷阱的優先順序#
RvCore 的順序:
- 中斷(代碼 11/3/7 → MEIP/MSIP/MTIP)
- 非法指令(
trap(2)) ecall(trap(11))ebreak(trap(3))- 載入未對齊(4)
- 存儲未對齊(6)
- 錯誤的跳轉目標(0)
中斷優先於所有例外——這與 RISC-V 規格一致。
7.2.5.7 執行迴圈#
// run(max) 在 pc 不再前進且沒有中斷可以喚醒時回傳 true("j ." 的停止迴圈)
「j . 是一個自我跳轉的無限迴圈」 是嵌入式軟體常見的結束方式。
7.2.6 CLI#
reda cc PROG.c [-o PROG.bin] [-S PROG.s] [-L PROG.lst] [--run [N]] [--mem BYTES] [--linux]
.s/.S輸入直接送給組譯器;--run N在Iss上執行映像並印出 UART 輸出與 LED 的值;--mem BYTES設定 RAM 大小。
7.2.7 三層驗證#
flowchart TD
A["C 程式"] --> B["reda cc → 機器碼"]
B --> C["Iss(RV32 指令集模擬器,週期精確)"]
B --> D["RvCore(Veryl 寫的硬體)"]
C --> E["比對:結果 + 週期數"]
D --> EISS 的「週期精確」讓它不只是功能參考,也是效能參考。
這是硬體驗證的黃金模式:先寫一個可執行的規格(ISS),再用它來驗硬體。