用 Rust 打造本地 LLM 工作站:llama.cpp 原生整合與 Hugging Face 模型管理
本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
上一篇我們聊了怎麼用 Rust 手寫 GGUF 解析器,把模型的二進位結構拆解得清清楚楚。但光會「讀」模型檔還不夠——你還要能「搜」模型、「下載」模型、最後「跑」模型,這才是一個完整的工作流程。
這次我做了 llm-local-studio,一個學習導向的本地 LLM app shell。它的目標很明確:不用 Ollama 或 LM Studio 那種外部服務,直接在 Rust 裡呼叫 llama.cpp 做推理,同時把 Hugging Face 的模型搜尋和下載流程也串起來。
這篇文章來聊聊幾個關鍵的設計決定:怎麼架構 CLI、怎麼跟 Hugging Face API 溝通、怎麼安全地下載模型、以及最重要的——怎麼讓 llama.cpp 變成你 Rust 程式的一部分,而不是一個外掛的子進程。
整體架構:四層分離
這個專案的核心精神是邊界清楚。整個 app 分成四層,每層有明確的職責:
graph TD
AppShell[App Shell
CLI — 未來 Desktop/Web UI]
AppServices[Application Services
Model Registry · HF Catalog · Settings]
InferenceInterface[Inference Engine Interface
load · unload · chat · health]
LlamaCppAdapter[llama.cpp Native Adapter
Rust FFI → llama.cpp / ggml]
AppShell --> AppServices
AppServices --> InferenceInterface
InferenceInterface --> LlamaCppAdapter
為什麼要分這麼多層?因為依賴方向必須單向。App 層只仰賴「推理引擎介面」,不直接碰 llama.cpp 的 C API 符號。未來如果要換成別的推理引擎(例如 ONNX Runtime 或 candle),只要 implement 同一個 trait 就好,上面的程式碼一行都不用改。
CLI 設計:用 clap Derive 模式打造四個指令
CLI 是 Phase 1 的主要入口,用 clap 的 derive 模式定義:
#[derive(Debug, Subcommand)]
enum Command {
/// 掃描本地資料夾裡的 GGUF 檔案
Scan { dir: PathBuf },
/// 搜尋 Hugging Face 上的 GGUF 模型
HfSearch { query: String, limit: usize },
/// 從 Hugging Face 下載 GGUF 模型
HfDownload { name: String, filename: Option<String>, ... },
/// 用 llama.cpp 跑本地模型推理
Run { model: String, prompt: String, ... },
}
四個指令對應四個核心功能:掃描、搜尋、下載、推理。每個指令都是一個獨立的函式,從 Command::run() 分派出去:
impl Command {
fn run(self) -> Result<()> {
match self {
Self::Scan { dir } => scan_models(dir),
Self::HfSearch { query, limit } => search_hugging_face(query, limit),
Self::HfDownload { .. } => download_hugging_face_model(..),
Self::Run { .. } => run_model(..),
}
}
}
為什麼用 derive 而不是 builder?因為我們的指令結構很穩定,不需要動態組合。derive 模式讓每個欄位的 /// 註解自動變成 --help 說明文字,省了寫文件的重工。
而 Run 指令特別有趣的地方在於 model 參數——它不只接受檔案路徑,還能接受模型名稱。背後的 resolve_model_path() 會先檢查是不是直接路徑,如果不是就去 registry 裡模糊搜尋:
fn resolve_model_path(model: &str, dir: PathBuf) -> Result<PathBuf> {
let direct_path = PathBuf::from(model);
if direct_path.try_exists()? {
return Ok(direct_path);
}
let registry = ModelRegistry::scan_dir(&dir)?;
Ok(registry.find(model)?.path.clone())
}
所以你可以這樣用:
# 直接給路徑
llm-local-studio run models/Q4_K_M.gguf -p "Hello"
# 或者只給檔名的一部分
llm-local-studio run tinyllama -p "Hello"
Model Registry:遞迴掃描 GGUF 模型
ModelRegistry 的工作很單純:遞迴掃描一個資料夾,找出所有 .gguf 檔案,建構出 ModelRecord 清單:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRecord {
pub id: String,
pub path: PathBuf,
pub size_bytes: u64,
pub source: ModelSource,
pub status: ModelLoadStatus,
}
掃描的核心是 scan_dir_recursive():
fn scan_dir_recursive(dir: &Path, models: &mut Vec<ModelRecord>) -> Result<()> {
if !dir.try_exists()? {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let metadata = entry.metadata()?;
if metadata.is_dir() {
scan_dir_recursive(&path, models)?;
} else if is_gguf_file(&path) {
models.push(ModelRecord { ... });
}
}
Ok(())
}
這裡有個值得提的小細節:try_exists() 而不是 exists()。差別在哪?exists() 在遇到權限錯誤時會回傳 false(靜默吞掉錯誤),但 try_exists() 會把真正的錯誤(例如 permission denied)往外拋。對於一個 CLI 工具來說,不該靜默吞掉任何錯誤。
模型 ID 是從檔名推導出來的——model.gguf → "model",簡單直接。而 find() 方法支援精確比對和模糊比對的雙層搜尋:
pub fn find(&self, query: &str) -> Result<&ModelRecord> {
// 第一層:精確比對(不分大小寫)
let mut matches = self.models.iter().filter(|model| {
model.id.eq_ignore_ascii_case(query)
|| model.path.file_name()...eq_ignore_ascii_case(query)
});
if let Some(model) = matches.next() {
return Ok(model);
}
// 第二層:部分比對(contains)
let partial_matches = self.models.iter().filter(|model| {
model.id.to_lowercase().contains(&query_lower)
|| model.path...to_lowercase().contains(&query_lower)
}).collect::<Vec<_>>();
match partial_matches.as_slice() {
[model] => Ok(model), // 唯一命中
[] => bail!("no local model matched {query:?}"),
matches => bail!("model name {query:?} is ambiguous; matches: {ids}"),
}
}
模糊比對的歧義處理很貼心——如果多個模型都符合,它不是隨便挑一個,而是列出所有命中的模型 ID 讓你自己選。這避免了「我明明說 A 你卻跑了 B」的詭異情況。
Hugging Face 整合:從搜尋到下載的完整流程
三步下載流程
下載一個 Hugging Face 模型不是簡單的「給 URL 就抓」,而是經過規劃→確認→執行三個步驟:
graph LR
A[解析模型名稱
resolve_model] --> B[列出 GGUF 檔案
list_gguf_files]
B --> C[選擇檔案
choose_gguf_file]
C --> D[計算本地路徑
local_model_path]
D --> E[執行下載
download_gguf]
這個設計的關鍵在於 plan_download()——它把所有決策都做完了,但不會真的下載。你可以先用 --print-url 看看 URL 對不對:
# 先看下載 URL,不下載
llm-local-studio hf-download TheBloke/TinyLlama-1.1B-GGUF --print-url
# 確認無誤後再下載
llm-local-studio hf-download TheBloke/TinyLlama-1.1B-GGUF
智慧量化選擇
Hugging Face 上同一個模型通常有很多種量化版本(Q4_K_M、Q5_K_S、Q8_0……),到底該下哪個?choose_gguf_file() 用了一個偏好清單來自動判斷:
const PREFERRED_QUANTS: &[&str] = &["Q4_K_M", "Q4_K_S", "Q5_K_M", "Q8_0", "Q4_0"];
pub fn choose_gguf_file<'a>(
&self,
files: &'a [HuggingFaceFile],
requested: Option<&str>,
) -> Result<&'a HuggingFaceFile> {
if let Some(requested) = requested {
return files.iter()
.find(|file| file.rfilename == requested)
.with_context(|| ...);
}
for preferred in PREFERRED_QUANTS {
if let Some(file) = files.iter().find(|file| file.rfilename.contains(preferred)) {
return Ok(file);
}
}
files.first().context("repository does not contain GGUF files")
}
Q4_K_M 排第一位——這是社群公認 C/P 值最高的量化等級,兼顧模型品質和檔案大小。如果你有特別偏好,也可以用 --filename 手動指定。
安全下載:原子寫入
下載大檔案最怕什麼?寫到一半斷了,留下一個損壞的檔案。download_gguf() 用了一個簡單但有效的策略——先寫暫存檔,完成後才改名:
let temp_path = plan.destination.with_extension("gguf.part");
let mut output = fs::File::create(&temp_path)?;
let bytes_written = io::copy(&mut response, &mut output)?;
drop(output); // 確保 flush
fs::rename(&temp_path, &plan.destination)?;
.gguf.part → .gguf 的 rename 在同一個檔案系統上是原子操作——要嘛成功看到完整檔案,要嘛看到暫存檔(下次重跑會自動跳過)。不會出現「寫一半的爛檔案」。
防護不只這一個。路徑安全也很重要——local_model_path() 會檢查檔名裡有沒有 .. 路徑穿越攻擊:
fn local_model_path(models_dir: &Path, model: &HuggingFaceModelRef, filename: &str) -> Result<PathBuf> {
if filename.is_empty() || filename.contains("..") {
anyhow::bail!("invalid Hugging Face filename {filename:?}");
}
for component in filename.split('/') {
if component.is_empty() || component == "." || component == ".." {
anyhow::bail!("invalid Hugging Face filename {filename:?}");
}
path.push(component);
}
Ok(path)
}
你不會想讓一個惡意的 Hugging Face 上傳者用 ../../etc/passwd 這種檔名把東西寫到系統目錄裡吧?
下載後的目錄結構也很有系統——按照 models/huggingface/{owner}/{repo}/{filename}.gguf 組織,一目了然:
models/
huggingface/
TheBloke/
TinyLlama-1.1B-GGUF/
tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf
llama.cpp 原生整合:不走子進程,直接 FFI
這是整個專案最核心也最有趣的部分。
大多數 Rust 專案要用 llama.cpp 時,都是用 Command::new("llama-server") 啟一個子進程,然後透過 HTTP API 溝通。但我們的做法完全不同——直接在 Rust 進程裡載入 llama.cpp 的 shared library,用純 Rust API 做推理。
這要歸功於 llama-cpp-2 這個 crate——它把 llama.cpp 的 C API 封裝成了安全的 Rust 介面。
InferenceEngine Trait:定義未來的邊界
我們先定義了應用層的推理介面:
pub trait InferenceEngine {
fn load_model(&mut self, request: LoadModelRequest) -> Result<ModelHandle>;
fn unload_model(&mut self, model_id: &str) -> Result<()>;
fn health(&self) -> EngineHealth;
}
以及對應的狀態型態:
pub enum EngineHealth {
NotConfigured,
Ready,
Loaded { model_id: String },
Error { message: String },
}
今天 LlamaCppEngine 實作了這個 trait,明天如果要接 ONNX Runtime 或 candle,只要新寫一個 impl——上面的 CLI 和 registry 完全不用改。
LlamaCppRunner:一行一行吐出回應
真正做推理的是 LlamaCppRunner。它的 run() 方法完整展示了用 llama.cpp 跑一次 text completion 的全流程:
impl LlamaCppRunner {
pub fn run(&self, request: &RunModelRequest) -> Result<RunModelOutput> {
suppress_llama_logs();
// 1. 初始化 llama.cpp 後端
let backend = LlamaBackend::init()
.context("failed to initialize llama.cpp backend")?;
// 2. 載入 GGUF 模型
let model = LlamaModel::load_from_file(
&backend, &request.model_path, &LlamaModelParams::default()
)?;
// 3. 建立 context(設定 context window 大小)
let context_size = NonZeroU32::new(request.context_size)?;
let context_params = LlamaContextParams::default()
.with_n_ctx(Some(context_size));
let mut context = model.new_context(&backend, context_params)?;
// 4. Tokenize prompt
let prompt_tokens = model.str_to_token(&request.prompt, AddBos::Always)?;
// 5. 檢查 context 容量
let requested_tokens = prompt_tokens.len() + request.max_tokens as usize;
if requested_tokens > context.n_ctx() as usize {
anyhow::bail!("prompt plus generated tokens require {requested_tokens} ...");
}
// 6. 處理 prompt(一次性送入 KV cache)
let mut batch = LlamaBatch::new(512, 1);
for (position, token) in (0_i32..).zip(prompt_tokens.into_iter()) {
batch.add(token, position, &[0], position == last_prompt_index)?;
}
context.decode(&mut batch)?;
// 7. 逐 token 生成(streaming)
let mut sampler = LlamaSampler::chain_simple([
LlamaSampler::dist(1234),
LlamaSampler::greedy(),
]);
while generated_tokens < request.max_tokens {
let token = sampler.sample(&context, batch.n_tokens() - 1);
sampler.accept(token);
if model.is_eog_token(token) { break; }
let piece = model.token_to_piece(token, &mut decoder, true, None)?;
print!("{piece}"); // 逐字印出
std::io::stdout().flush()?;
batch.clear();
batch.add(token, position, &[0], true)?;
context.decode(&mut batch)?;
position += 1;
generated_tokens += 1;
}
Ok(RunModelOutput { generated_tokens })
}
}
幾個值得注意的設計選擇:
Batch 處理 prompt:prompt 的所有 token 一次性放進 LlamaBatch,只有最後一個 token 標記為「需要 logits」(position == last_prompt_index)。這樣 decode() 一次就把整個 prompt 寫入 KV cache,比逐 token decode 快得多。
什麼是 KV Cache?
上面提到好幾次 KV cache,但它到底是什麼?為什麼這麼重要?
Transformer 的注意力機制有一個特性:每生成一個新 token,都需要看過之前所有的 token。更具體地說,每一層的 self-attention 在計算時,需要每個先前 token 經過 Key(K) 和 Value(V) 線性投影後的結果。
如果沒有 KV cache,每生成一個新 token,你就得把整個 prompt 再重新跑一次注意力計算——prompt 有 1000 個 token 的話,每跑一個新 token 就要重算 1000 次。這不是只是慢,而是浪費——因為前面那些 token 的 K 和 V 值根本沒變!
KV cache 的想法很直覺:算過的就存起來,下次直接用。
graph LR
subgraph 第一次 Decode
P1[Prompt: 1000 tokens] --> K1[Key 投影]
P1 --> V1[Value 投影]
K1 --> Cache1[寫入 KV Cache]
V1 --> Cache1
end
subgraph 之後每個新 Token
NT[新 Token: 1 個] --> KN[只算這 1 個的 K 和 V]
KN --> Cache2[追加到 KV Cache]
Cache1 -.-> Q[Query 用新 token
對整個 cache 做 attention]
Cache2 -.-> Q
Q --> Next[預測下一個 token]
end
實際效果有多驚人?以一個 32 層、4096 維度的 LLaMA 模型為例:
- 無 KV cache:每生成 1 個 token,要重新計算整個 prompt 的注意力 → O(n²) 隨 prompt 長度暴增
- 有 KV cache:每生成 1 個 token,只需計算新 token 自己的 K 和 V,然後對 cache 做一次 attention → O(n) 線性增長
這就是為什麼我們在程式碼裡,要把整個 prompt 一次性丟進 context.decode(&mut batch)——這一步就是在預填 KV cache(pre-fill)。之後的生成迴圈裡,每次只需要把一個新 token 的 K 和 V 追加到 cache 裡,再讓 Query 去對整個 cache 做 attention,就能預測下一個 token。
KV cache 的代價是記憶體。它佔用的空間跟「prompt 長度 × 層數 × 維度」成正比,這也是為什麼 context_size 參數這麼重要——它決定了 KV cache 最多能裝多少 token。我們程式碼裡的 context 容量檢查:
if requested_tokens > context.n_ctx() as usize {
anyhow::bail!("prompt plus generated tokens require {requested_tokens} ...");
}
本質上就是在檢查:你要裝進 KV cache 的 token 數,有沒有超過預先分配的容量。
Streaming 輸出:每生成一個 token 就 print!() + flush(),所以你在終端機上會看到模型一個字一個字慢慢冒出來,跟用 ChatGPT 的體驗一樣。這比「全部算完再一次印出」的使用者體驗好太多了。
End-of-generation 偵測:model.is_eog_token(token) 檢查是否遇到了結束符號(<eos>、<|end|> 等)。模型自己覺得說完了就停,不會硬湊到 max_tokens。
Sampler 鏈:LlamaSampler::chain_simple([dist, greedy]) 看起來有點奇怪——先加一個 temperature distribution(seed 1234),再用 greedy?這是 llama-cpp-2 的 API 設計:dist 負責根據機率分佈抽樣,greedy 在最後一步保證取最高機率的 token。對於 deterministic 輸出來說這是常見的簡易設定。
Context 容量檢查:在開始生成之前先檢查 prompt 長度 + max_tokens 有沒有超過 context window。與其跑到一半 OOM 或得到亂碼,不如一開始就明確報錯。
延伸話題:llama.cpp 怎麼「認出」模型架構?
你可能會好奇:同一個 llama-cli 程式,為什麼能跑 LLaMA、Mistral、Qwen2、Gemma、Phi-3、DeepSeek 等幾十種不同的模型?我們在命令列上從來不用特別指定「這是 Mistral 模型」——那 llama.cpp 是怎麼知道該用哪種計算圖的?
答案藏在 GGUF 檔案的 Metadata 裡:general.architecture。
上一篇 GGUF 解析器文章提過,每個 GGUF 檔案都有一組鍵值對形式的 Metadata。其中有一個關鍵的 key:
general.architecture → String("llama")
這就是模型的「身分證」。llama.cpp 在 llama_model_load() 時,第一件事就是讀取這個值,然後對應到一個內部的 enum:
// 概念上等價於 llama.cpp 內部的 e_model enum
enum ModelArch {
Llama,
Mistral,
Gpt2,
Phi2,
Qwen2,
Gemma,
Mamba,
// ... 60+ 種架構
}
如果 general.architecture 的值不在支援清單裡,llama.cpp 會直接拒絕載入,回傳 "model architecture not supported"——它不會猜,也不會 fallback。
架構決定了什麼?
選中的架構控制了推理的一切細節:
| 面向 |
LLaMA |
GPT-2 |
Mamba |
| 注意力機制 |
GQA + RoPE |
MHA + 絕對位置編碼 |
SSM(不是注意力) |
| 前饋網路 |
SwiGLU(gate + up + down) |
兩層 MLP |
SSM 門控 |
| 正規化 |
RMSNorm |
LayerNorm |
RMSNorm |
| 位置編碼 |
RoPE(旋轉位置編碼) |
學習式絕對位置 |
無需位置編碼 |
| Tensor 命名 |
blk.{i}.attn_q.weight |
transformer.h.{i}.attn.c_attn.weight |
blk.{i}.ssm_in.weight |
每一種架構都有自己的一套 tensor 命名慣例、計算圖結構、和特殊的 kernel 實作。llama.cpp 根據 general.architecture 的值,分派到對應的程式碼路徑。
還記得上一篇 GGUF 解析器讀出的 metadata key 嗎?它們有一個很規律的前綴系統:
# 如果 general.architecture = "llama"
llama.context_length → Uint32(2048)
llama.embedding_length → Uint32(4096)
llama.block_count → Uint32(32)
# 如果 general.architecture = "mistral"
mistral.context_length → Uint32(32768)
mistral.embedding_length → Uint32(4096)
架構名稱就是 metadata key 的前綴。 llama.cpp 讀到 general.architecture = "llama" 之後,就只去讀 llama.* 開頭的 metadata key——每種架構定義了自己需要哪些參數,不需要的就不讀。這讓同一個 GGUF 規範可以容納數十種截然不同的模型架構,而不會互相打架。
跟我們的程式碼有什麼關係?
在我們的 LlamaCppRunner::run() 裡,呼叫 LlamaModel::load_from_file() 時,llama-cpp-2 在底層就做完了上面這整套流程——讀 GGUF header、抓 general.architecture、選擇對應的計算圖、載入 tensor 權重。我們的 Rust 程式碼完全不需要知道「這是什麼架構」,因為 GGUF 檔案自帶了身分聲明,llama.cpp 自己會分派。
這也是為什麼同一個 llm-local-studio run 指令,可以對任何 GGUF 模型一體適用——不管它是 LLaMA、Mistral 還是 Qwen2,只要 llama.cpp 支援那個架構,就能跑。
實際使用示範
把整個流程串起來,從搜尋到推理:
# 1. 搜尋 Hugging Face 上的 GGUF 模型
llm-local-studio hf-search "tinyllama gguf"
# 2. 下載模型(自動選 Q4_K_M 量化)
llm-local-studio hf-download TheBloke/TinyLlama-1.1B-GGUF
# 3. 掃描本地已有的模型
llm-local-studio scan models
# 4. 跑推理!
llm-local-studio run tinyllama -p "Tell me a joke about Rust"
或者一氣呵成:
llm-local-studio run TheBloke/TinyLlama-1.1B-GGUF \
-p "Explain ownership in Rust" \
--ctx-size 2048 \
-n 256
測試策略
跟上一篇 GGUF 解析器一樣,Hugging Face 的整合測試也面臨「不能在 CI 裡真的去打 API」的問題。所以我們把測試分成兩類:
純邏輯單元測試(不需要網路):
#[test]
fn parses_hugging_face_model_ref() {
let model: HuggingFaceModelRef = "TheBloke/TinyLlama-1.1B-GGUF".parse().unwrap();
assert_eq!(model.owner, "TheBloke");
assert_eq!(model.repo, "TinyLlama-1.1B-GGUF");
}
#[test]
fn chooses_preferred_quant_when_filename_is_not_requested() {
let files = vec![
HuggingFaceFile { rfilename: "model.Q8_0.gguf".into(), size: None },
HuggingFaceFile { rfilename: "model.Q4_K_M.gguf".into(), size: None },
];
assert_eq!(
client.choose_gguf_file(&files, None).unwrap().rfilename,
"model.Q4_K_M.gguf"
);
}
#[test]
fn builds_local_hugging_face_model_path() {
let model: HuggingFaceModelRef = "owner/repo".parse().unwrap();
let path = local_model_path(Path::new("models"), &model, "nested/model.gguf").unwrap();
// models/huggingface/owner/repo/nested/model.gguf
}
這些測試驗證了 URL 建構、模型名稱解析、量化檔案選擇、本地路徑計算——全是確定性的純函式,不依賴外部狀態。
結語
llm-local-studio 展示了用 Rust 打造本地 LLM 工具鏈的幾個核心觀念:
- 邊界分離:
InferenceEngine trait 讓 app 層不直接依賴 llama.cpp,替換引擎零改動。
- 規劃先行:
plan_download() 把所有決策提前做完,下載前可以先確認。
- 原子寫入:
.part 暫存檔 + rename() 確保不會出現半殘檔案。
- 路徑安全:
.. 檢查防止路徑穿越攻擊。
- 原生 FFI:透過
llama-cpp-2 直接在進程內跑推理,不走子進程 HTTP。
- Streaming 體驗:逐 token 輸出,跟 chat 服務一樣的使用者體驗。
- 模糊搜尋:模型名稱不用背完整路徑,給一部分就能找到。
整個專案四個原始檔、~400 行核心程式碼,就串起了「搜尋→下載→推理」的完整流程。雖然還沒有 chat template 支援、没有 OpenAI 相容 API、也没有漂亮的 GUI,但作為一個學習專案,它把最重要的骨架搭好了——後續的每一個 Phase 都是在這個穩固的基礎上往上蓋。
有興趣的朋友可以自己試著跑跑看,體驗一下「在自己的筆電上、用自己的程式碼、跑自己的語言模型」的樂趣!
參考資源
用 Rust 手寫 GGUF 解析器:記憶體映射與二進位讀取實戰
本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
現在的大型語言模型(LLM)動輒幾十 GB,如果要把它們跑在一般的電腦或筆電上,通常都會使用經過量化(Quantization)的模型格式。而在這些格式中,由 llama.cpp 開源專案所主導的 GGUF (GPT-Generated Unified Format) 毫無疑問是當今最主流的霸主。
身為一個對底層原理充滿好奇的工程師,每次看到一個龐大的二進位檔案,心裡總會癢癢的:「這檔案裡面到底裝了什麼?如果我自己用 Rust 寫一個解析器,要怎麼開工?」
於是,這次我動手實作了一個純 Rust 的 GGUF 模型解析器:llm-gguf-parser。
這篇文章就來跟大家聊聊,在實作這個二進位解析器時,我做出的幾個關鍵設計決定,以及我們如何利用 Rust 的強型別與記憶體管理特性,優雅地拆解這個龐然大物。
設計抉擇:為什麼要這樣實作?
在開始動筆寫程式之前,有幾個技術難題需要克服:
1. 檔案太大,記憶體會爆掉!怎麼辦?
LLM 模型檔案的容量非常驚人。如果直接用 std::fs::read 把整個檔案一口氣讀進記憶體,一般電腦的記憶體(RAM)肯定會瞬間被塞滿,接著被系統強制結束(OOM Crash)。
為了解決這個問題,我使用了 記憶體映射(Memory Mapping),也就是 memmap2 這個套件:
- 零拷貝讀取(Zero-Copy Reading):我們只是將硬碟檔案對應到處理器的虛擬位址空間。當我們需要某一段資料(例如 Tensor 的名稱或某個 Metadata 數值)時,我們可以直接「切片」(Slice)檔案緩衝區,不需要在記憶體中配置新的空間。
- 隨選載入(On-Demand Loading):作業系統的虛擬記憶體管理器非常聰明,它只會將我們真正讀取到的硬碟分頁(通常是檔頭的 Metadata 區塊)載入到 RAM。而佔了檔案 99.9% 空間的 Tensor 權重數值,在解析過程中根本不會被讀入記憶體,完美避開了記憶體不足的問題。
2. 不需要大砲!拒絕複雜的解析框架
雖然 Rust 有很強大的解析器組合子框架(如 nom)或序列化套件(如 serde),但對於 GGUF 這樣結構非常規律、順序明確的格式來說,引入這些厚重的套件反而會增加學習曲線與編譯時間。
因此,我決定回歸本質:手寫一個簡單的順序讀取器(Reader)。
我們只要包裝一個指向 Byte 切片的指標,每次讀完資料就把指標往後移動。因為 GGUF 規定使用小端序(Little-Endian)儲存,我們可以直接利用 Rust 標準函式庫內建的字節轉換方法(如 u32::from_le_bytes、u64::from_le_bytes),編譯器會把這些直接翻譯成硬體層級的指令,速度極快而且直覺好懂。
3. 處理 GGUF 歷史版本演進
GGUF 規範在發展過程中經歷了三個版本:
- Version 1:早期設計給 32 位元系統使用,Metadata 鍵值對和 Tensor 的數量是用
u32 來儲存。
- Version 2 & 3:隨著模型規模暴增,為了支援擁有成千上萬個 Tensor 的巨型模型,數量改用
u64 儲存。
為了讓解析器有足夠的相容性,我們必須在程式一開始讀取版本號,然後動態決定接下來讀取計數時要使用 4 個字節(u32)還是 8 個字節(u64)。
GGUF 檔案格式一覽
在看程式碼之前,我們先來看看 GGUF 在硬碟上的位元組佈局:
| 區段 |
大小 |
說明 |
| Magic Bytes |
4 Bytes |
必須是 ASCII 的 'G' 'G' 'U' 'F' (0x46554747) |
| Version |
4 Bytes |
小端序 u32(目前常見為 2 或 3) |
| Tensor Count |
4 或 8 Bytes |
檔案中包含的張量(Tensor)總數 |
| Metadata Count |
4 或 8 Bytes |
鍵值對(KV)形式的 Metadata 數量 |
| Metadata KVs |
變動大小 |
一系列的 (Key字串, 數值型態ID, Value) 資料 |
| Tensor Metadata |
變動大小 |
每個張量的名稱、維度、資料型態以及在檔案中的偏移量(Offset) |
| 對齊填充 (Padding) |
變動大小 |
用於對齊張量資料區段的邊界 |
| 張量權重資料 (Data) |
變動大小 |
模型的原始權重數值(佔據 99.9% 空間) |
核心實作
接下來,我們來看看如何用 Rust 把這個格式描述出來。
GGUF 支援非常豐富的資料型態。我們可以用 Rust 的 enum 來表達這些型態,不僅安全,還能附帶資料:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum ValueType {
Uint8 = 0,
Int8 = 1,
Uint16 = 2,
Int16 = 3,
Uint32 = 4,
Int32 = 5,
Float32 = 6,
Bool = 7,
String = 8,
Array = 9,
Uint64 = 10,
Int64 = 11,
Float64 = 12,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Uint8(u8),
Int8(i8),
Uint16(u16),
Int16(i16),
Uint32(u32),
Int32(i32),
Float32(f32),
Bool(bool),
String(String),
Array(ValueType, Vec<Value>), // 陣列型態,內部帶有元素列表
Uint64(u64),
Int64(i64),
Float64(f64),
}
當我們在解析 Metadata 時,我們會先讀出一個 u32 代表型態 ID,接著用 TryFrom<u32> 轉換成 ValueType,最後依據型態讀取對應長度的位元組,包裝成 Value enum。
值得注意的是 Value::Array 的設計——它內部帶有元素型態(ValueType)和元素列表(Vec<Value>),這意味著陣列的解析是遞迴的:先讀出元素型態 ID,再讀出陣列長度,然後逐一呼叫 read_value() 讀取每個元素。如果今天是「字串陣列」,每個元素就會進入 ValueType::String 分支;如果是「陣列的陣列」,理論上也可以遞迴下去(雖然 GGUF 規範裡沒有這種用法)。
另外,Bool 在檔案裡其實是 1 byte(0x00 = false,非零 = true),所以解析時用 read_u8()? != 0——這是 GGUF 規範的設計,不是 Rust 的 bool(1 byte),而是把 C 語言的慣例忠實對應過來。
2. 強型別的量化格式:GgmlType Enum
除了 Metadata 的型別系統,GGUF 還定義了一套模型量化(Quantization)格式,用 GgmlType 表示。這個 enum 有 30 個變體,涵蓋了從原始的 FP32 到各種「K 量化」和「IQ 量化」格式:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(non_camel_case_types)]
#[repr(u32)]
pub enum GgmlType {
F32 = 0, F16 = 1, // 原始浮點
Q4_0 = 2, Q4_1 = 3, // 4-bit 量化(基本款)
Q5_0 = 6, Q5_1 = 7, // 5-bit 量化
Q8_0 = 8, Q8_1 = 9, // 8-bit 量化
Q2_K = 10, Q3_K = 11, // K 量化家族(block-wise)
Q4_K = 12, Q5_K = 13,
Q6_K = 14, Q8_K = 15,
Iq2Xxs = 16, Iq2Xs = 17, // IQ 量化(integer quantization)
Iq3Xxs = 18, Iq1S = 19,
Iq4Nl = 20, Iq3S = 21,
Iq2S = 22, Iq4Xs = 23,
I8 = 24, I16 = 25, // 整數型態
I32 = 26, I64 = 27, F64 = 28,
Iq1M = 29,
}
你可能注意到 ID 4、5 是空的——這不是漏掉,而是 GGML 規範裡本來就沒有分配這些 ID(歷史因素,早期有些格式被廢棄了)。用 #[repr(u32)] 標記讓每個變體的判別式(discriminant)直接對應檔案裡的 type ID,TryFrom<u32> 則確保遇到未知型態時優雅地報錯。
這些量化格式的差異在於「每個 block 有多少元素、scale factor 怎麼存、量化精度到哪裡」——例如 Q4_0 是把 32 個 FP16 權重壓成 4-bit,一個 block 佔 18 bytes;Q4_K 則是更進階的版本,用 super-block 加 sub-block 的雙層結構來提升精度。但對於一個解析器來說,我們不需要知道每種格式的解碼細節——只要能正確讀出「這個 tensor 是哪種格式」就夠了,後續的推理引擎才會根據格式去解碼權重。
另一個實作細節:GgmlType 有獨立的 Display impl,把 Rust 的 camelCase 變體名稱轉成更可讀的字串(例如 Iq2Xxs → "IQ2_XXS"),這樣 CLI 輸出才不會讓人一頭霧水:
impl fmt::Display for GgmlType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Iq2Xxs => "IQ2_XXS",
Self::Iq4Nl => "IQ4_NL",
// ...每個變體手動對應
};
write!(f, "{}", name)
}
}
3. 手寫 Byte Reader:用 Const Generics 消除重複
Reader 的核心巧妙之處在於 read_array<const N: usize>()——利用 Rust 的 const generics,一個方法就能處理所有固定大小的讀取:
struct Reader<'a> {
data: &'a [u8],
offset: usize,
}
impl<'a> Reader<'a> {
// 讀取指定長度的 Slice——所有讀取的最終入口
fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], ParserError> {
let slice = self.data
.get(self.offset..self.offset + len)
.ok_or(ParserError::UnexpectedEof {
bytes: len,
offset: self.offset,
len: self.data.len(),
})?;
self.offset += len;
Ok(slice)
}
// Const Generics 技巧:一個方法服務所有固定大小讀取
fn read_array<const N: usize>(&mut self) -> Result<[u8; N], ParserError> {
let slice = self.read_bytes(N)?;
Ok(slice.try_into()?)
}
fn read_u32(&mut self) -> Result<u32, ParserError> {
Ok(u32::from_le_bytes(self.read_array()?)) // N = 4
}
fn read_u64(&mut self) -> Result<u64, ParserError> {
Ok(u64::from_le_bytes(self.read_array()?)) // N = 8
}
fn read_f32(&mut self) -> Result<f32, ParserError> {
Ok(f32::from_le_bytes(self.read_array()?)) // N = 4
}
// 完全相同模式,只是 N 不同!
fn read_u16(&mut self) -> Result<u16, ParserError> { /* N = 2 */ }
fn read_i32(&mut self) -> Result<i32, ParserError> { /* N = 4 */ }
fn read_f64(&mut self) -> Result<f64, ParserError> { /* N = 8 */ }
// 讀取 GGUF 格式的字串(先讀長度 u64,再讀 UTF-8 位元組)
fn read_string(&mut self) -> Result<String, ParserError> {
let len = self.read_u64()? as usize;
let bytes = self.read_bytes(len)?;
String::from_utf8(bytes.to_vec()).map_err(ParserError::from)
}
}
看到玄機了嗎?read_u32、read_u64、read_f32……全部都是 self.read_array() + from_le_bytes() 的組合,差別只是 N 的值。如果不用 const generics,你就要為每種大小各寫一個 read_bytes 變體——程式碼會非常囉唆。有了 read_array<const N: usize>(),編譯器會在編譯期把每個呼叫 monomorphize 成固定大小的讀取,效能和手寫完全一樣,但程式碼乾淨多了。
這個設計非常直觀。如果檔案不小心毀損或讀取到一半檔案結束了,get(..) 會回傳 None,我們就能拋出一個漂亮的 ParserError::UnexpectedEof,而不會讓程式崩潰。
4. 解析主流程
有了 Reader 後,我們就能像寫食譜一樣,按照步驟解析 GGUF:
pub fn parse_gguf(data: impl AsRef<[u8]>) -> Result<GgufFile, ParserError> {
let mut reader = Reader::new(data.as_ref());
// 1. 檢查 Magic Bytes (必須是 "GGUF")
let magic = reader.read_u32()?;
if magic != 0x46554747 {
return Err(ParserError::InvalidMagic(magic));
}
// 2. 讀取版本號
let version = reader.read_u32()?;
if version != 1 && version != 2 && version != 3 {
return Err(ParserError::UnsupportedVersion(version));
}
// 3. 依據版本讀取 Tensor 數量與 Metadata 數量
let (tensor_count, metadata_kv_count) = if version == 1 {
(reader.read_u32()? as u64, reader.read_u32()? as u64)
} else {
(reader.read_u64()?, reader.read_u64()?)
};
// 4. 迴圈讀取所有的 Metadata 鍵值對
let mut metadata = HashMap::with_capacity(metadata_kv_count as usize);
for _ in 0..metadata_kv_count {
let key = reader.read_string()?;
let val_type_u32 = reader.read_u32()?;
let val_type = ValueType::try_from(val_type_u32)?;
let val = reader.read_value(val_type)?;
metadata.insert(key, val);
}
// 5. 迴圈讀取所有 Tensor 的描述資訊
let mut tensors = Vec::with_capacity(tensor_count as usize);
for _ in 0..tensor_count {
let name = reader.read_string()?;
let dimensions_count = reader.read_u32()? as usize;
let mut dimensions = Vec::with_capacity(dimensions_count);
for _ in 0..dimensions_count {
dimensions.push(reader.read_u64()?);
}
let tensor_type_u32 = reader.read_u32()?;
let tensor_type = GgmlType::try_from(tensor_type_u32)?;
let offset = reader.read_u64()?;
tensors.push(TensorInfo {
name,
dimensions,
tensor_type,
offset,
});
}
Ok(GgufFile {
version,
metadata,
tensors,
})
}
這個主流程清晰展現了二進位檔案結構解析的精髓:順序讀取、條件分支、結構映射。
5. 錯誤處理:帶上下文的 ParserError
Reader 的每個讀取操作都可能失敗——檔案截斷、格式不符、編碼錯誤。我們用 thiserror 定義了 6 種精確的錯誤型態:
#[derive(Error, Debug)]
pub enum ParserError {
#[error("unexpected end of file reading {bytes} bytes (offset: {offset}, len: {len})")]
UnexpectedEof { bytes: usize, offset: usize, len: usize },
#[error("invalid GGUF magic bytes: expected 0x46554747 ('GGUF'), found 0x{0:08X}")]
InvalidMagic(u32),
#[error("unsupported GGUF version: {0}")]
UnsupportedVersion(u32),
#[error("unknown value type ID {0}")]
UnknownValueType(u32),
#[error("unknown GGML type ID {0}")]
UnknownGgmlType(u32),
// 以下兩個用 #[from] 自動轉換
#[error("invalid UTF-8 string: {0}")]
InvalidUtf8(#[from] std::string::FromUtf8Error),
#[error("slice conversion failed: {0}")]
TryFromSlice(#[from] std::array::TryFromSliceError),
}
其中最值得說的是 UnexpectedEof——它不只告訴你「檔案結束了」,還告訴你當時想讀多少 bytes、在什麼偏移量、檔案總共有多大。當你在除錯一個截斷的 GGUF 檔案時,這三個數字可以省下大量的猜測時間。背後的實作靠的是 slice::get(..) 回傳 Option,而不是直接索引——直接索引會 panic,對於一個解析器來說,panic 比回傳錯誤糟糕太多了。
6. 顯示大陣列的巧思:Value 的 Display
GGUF 模型的 Metadata 裡,有些陣列非常龐大——例如 tokenizer 的詞彙表可以有 32000 個 entry。如果 Display 把整個陣列印出來,你的終端機會被洗版。所以我們做了一個小小的截斷:
Self::Array(t, v) => {
write!(f, "[Type: {:?}, Len: {}, Elements: [", t, v.len())?;
for (i, elem) in v.iter().take(5).enumerate() {
if i > 0 { write!(f, ", ")?; }
write!(f, "{}", elem)?;
}
if v.len() > 5 {
write!(f, ", ... +{} more", v.len() - 5)?;
}
write!(f, "]]")
}
只顯示前 5 個元素,附上剩餘數量——這樣你一眼就能知道「這是什麼型態的陣列、有多長、大概裝什麼」,而不會被好幾千行輸出淹沒。
CLI 工具:實戰解析 LLM 模型
解析器本身是個 library(lib.rs),但專案也附了一個 CLI 工具(main.rs),讓你可以直接對 GGUF 檔案做查詢:
# 基本用法:顯示檔頭資訊 + Metadata 摘要
cargo run -- path/to/model.gguf
# 顯示所有 Tensor 的名稱、維度、型態
cargo run -- path/to/model.gguf --tensors
# 過濾 Metadata,只看關鍵字含 "context" 的項目
cargo run -- path/to/model.gguf --query context
CLI 用 clap 的 derive 模式定義了三個參數:
#[derive(Parser, Debug)]
#[command(author, version, about = "A parser for GGUF model files")]
struct Args {
/// Path to the GGUF model file
file: String,
/// Print all tensor names, dimensions and types
#[arg(short, long)]
tensors: bool,
/// Filter metadata keys by a query string
#[arg(short, long)]
query: Option<String>,
}
--query 非常實用——GGUF 模型的 Metadata 通常有幾百個 KV pair,全部印出來根本找不到你要的東西。用 --query tokenizer 就只會顯示 key 裡包含 “tokenizer” 的項目,例如 tokenizer 的詞表大小、BOS/EOS token ID 等等。
執行時,檔案透過 memmap2 做 memory map,然後把 &mmap 直接傳給 parse_gguf():
let file = File::open(file_path)?;
let mmap = unsafe { Mmap::map(&file)? };
let gguf = parse_gguf(&mmap).map_err(|e| anyhow::anyhow!("failed to parse: {}", e))?;
unsafe 是因為 OS 無法保證 mmap 期間檔案不會被外部修改,但對於唯讀的解析工具來說,這是完全安全的用法。
單元測試:如何在記憶體裡手搓一個迷你 GGUF?
寫二進位解析器最麻煩的就是測試。我們不可能在跑 CI 測試時真的去下載一個好幾 GB 的模型檔案。
我的作法是:在記憶體中手動建構一個「迷你 GGUF 檔案」的二進位切片。
藉由將代表 Magic、版本、數量、字串長度與資料的位元組依序推入一個 Vec<u8>,我們就能建立一個合法的 GGUF 記憶體資料,並直接餵給解析器驗證:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_gguf_mock() {
let mut data = Vec::new();
// 1. Magic Bytes: 'GGUF'
data.extend_from_slice(&0x46554747u32.to_le_bytes());
// 2. Version: 3
data.extend_from_slice(&3u32.to_le_bytes());
// 3. Tensor count: 1
data.extend_from_slice(&1u64.to_le_bytes());
// 4. Metadata KV count: 2
data.extend_from_slice(&2u64.to_le_bytes());
// Metadata 1: Key "general.architecture" -> "llama"
let key1 = "general.architecture";
data.extend_from_slice(&(key1.len() as u64).to_le_bytes());
data.extend_from_slice(key1.as_bytes());
data.extend_from_slice(&8u32.to_le_bytes()); // Type: String (8)
let val1 = "llama";
data.extend_from_slice(&(val1.len() as u64).to_le_bytes());
data.extend_from_slice(val1.as_bytes());
// Metadata 2: Key "llama.context_length" -> 2048
let key2 = "llama.context_length";
data.extend_from_slice(&(key2.len() as u64).to_le_bytes());
data.extend_from_slice(key2.as_bytes());
data.extend_from_slice(&4u32.to_le_bytes()); // Type: Uint32 (4)
data.extend_from_slice(&2048u32.to_le_bytes());
// Tensor 1: Name "token_embd.weight" (Dims: [4096, 32000], Type: F16, Offset: 0)
let t_name = "token_embd.weight";
data.extend_from_slice(&(t_name.len() as u64).to_le_bytes());
data.extend_from_slice(t_name.as_bytes());
data.extend_from_slice(&2u32.to_le_bytes()); // Dims Count: 2
data.extend_from_slice(&4096u64.to_le_bytes());
data.extend_from_slice(&32000u64.to_le_bytes());
data.extend_from_slice(&1u32.to_le_bytes()); // Type: F16 (1)
data.extend_from_slice(&0u64.to_le_bytes());
// 執行解析
let gguf = parse_gguf(&data).unwrap();
assert_eq!(gguf.version, 3);
assert_eq!(gguf.metadata.len(), 2);
assert_eq!(gguf.tensors.len(), 1);
assert_eq!(gguf.tensors[0].name, "token_embd.weight");
}
}
這個測試案例不僅能確保我們的讀取指標和位元組轉換百分之百正確,也能讓我們在重構解析器時,擁有極大的信心!
結語
寫這類低階二進位格式的解析器,是深入理解系統程式設計(Systems Programming)的最佳途徑之一。在這個專案中,我們重新溫習了以下核心觀念:
- 記憶體映射(Memory Mapping) 避開大檔案的 I/O 與記憶體配置瓶頸,作業系統的虛擬記憶體管理器幫你做 on-demand paging。
- Const Generics 讓
read_array<N>() 一個方法服務所有大小的固定讀取,編譯期 monomorphize,零成本抽象。
- 強型別 Enum(ValueType / GgmlType) 把二進位檔案的型態 ID 映射到 Rust 的型別系統,搭配
TryFrom 在未知 ID 時優雅報錯。
- 帶上下文的錯誤處理
UnexpectedEof 攜帶 offset 和 len,比單純的 panic 或 io::Error 有用得多。
- 手寫 Reader 替代了厚重繁複的解析框架,用標準函式庫的
from_le_bytes 直接對應硬體指令,效能達到極致。
- 動態分支處理 解決了 GGUF 版本演進的向下相容性。
- 記憶體模擬二進位切片 實現了高效且無外部相依性的單元測試。
整個解析器只有 414 行(含測試),4 個依賴——證明了在 Rust 裡,有時候最精簡的做法就是最好的做法。
如果你也對 LLM 的底層檔案格式感興趣,不妨也嘗試自己寫一個簡單的解析器,相信你也會在 byte 與 offset 的移動之間,找到屬於工程師的單純樂趣!
參考資源
Bevy 與 Rust 的共舞:所有權如何塑造遊戲引擎的設計
寫完 NES 模擬器之後,我一直在想一件事:Bevy 用起來跟我過去用過的任何遊戲引擎都不一樣。不是 API 風格的差異——是思考方式根本不同。
用 Unity 寫 C#,你把腳本掛到 GameObject 上,裡面 GetComponent<T>() 隨便抓、隨便改。用 Godot 寫 GDScript,一切都是 Node 樹,$Child.property = value 直覺到不行。但 Bevy?你連「把兩個系統同時存取同一個資源」都得在型別層面交代清楚,不然編譯器直接擋下來。
這不是 Bevy 故意刁難你。是 Rust 的所有權系統逼著 Bevy 必須這樣設計——而這個「被逼的」結果,意外地產出了一個非常乾淨的架構。
這篇文章用 NES 模擬器前端(~2600 行 Rust)作為實例,聊聊 Rust 的所有權、借用和型別系統是怎麼一路影響 Bevy 的 ECS 設計,以及在實際專案裡會碰到哪些有趣的 pattern。
ECS 不只是 Pattern,是 Rust 的必然
傳統 OOP 遊戲引擎的痛
在傳統 OOP 引擎裡,物件之間到處互相引用:
Player → Weapon → Inventory → Player (循環引用)
Enemy → Player (觀察目標)
GameManager → Player, Enemy, UI, ... (上帝物件)
C# 和 Java 有 GC 幫你收拾,循環引用不是問題。但 Rust 沒有 GC,Player 不能隨便持有 &mut Weapon 的引用——因為 Rust 的借用規則說:同一時間只能有一個可變引用。
所以你如果在 Rust 裡用 OOP 寫遊戲引擎,馬上就會遇到一堆 lifetime 地獄:
struct Player<'a> {
weapon: &'a mut Weapon, // ← lifetime 要標
}
struct World<'a> {
player: Player<'a>,
weapons: Vec<Weapon>, // ← player.weapon 借自這裡?
// 🔴 weapons 和 player 不能同時存在於 World 裡
}
ECS 的出現剛好解決了這個問題——不是因為它是什麼偉大的設計理念,而是因為它天然地符合 Rust 的所有權模型。
ECS 如何繞開所有權地獄
ECS 的資料佈局長這樣:
World
├── 實體表 (Entity): [0, 1, 2, ...]
├── 元件儲存 (Components):
│ ├── Position: [Some(pos0), None, Some(pos2), ...]
│ ├── Velocity: [Some(vel0), None, None, ...]
│ └── Health: [None, Some(hp1), Some(hp2), ...]
└── 資源 (Resources):
├── Time
├── InputState
└── ...
每種元件是一個獨立的陣列。系統(System)透過 Query 宣告它需要哪些元件的存取權限:
fn movement_system(mut query: Query<(&mut Position, &Velocity)>) {
for (mut pos, vel) in &mut query {
pos.x += vel.x;
pos.y += vel.y;
}
}
對 Rust 來說,這個 Query 等於是說「我要可變借用 Position 陣列,同時不可變借用 Velocity 陣列」——沒有重疊,沒有衝突,borrow checker 開心,世界和平。
Bevy 的排程器利用這些 Query 的存取宣告來自動判斷哪些系統可以平行執行。如果系統 A 只讀 Position,系統 B 只寫 Velocity,它們不衝突,可以丟到不同執行緒上跑。
這就是 Rust 的所有權模型帶來的附加價值:你為了滿足編譯器而寫的型別宣告,同時也成了排程器的平行化依據。
當所有權撞上硬體模擬
NES 的硬體元件之間有大量的共享狀態——CPU 要讀寫 PPU 的暫存器、APU 的暫存器、Joypad 的暫存器,PPU 和 CPU 共用同一個 Mapper(卡匣晶片)。
用 OOP 的思維畫出來大概是:
graph LR
CPU -->|讀寫暫存器| PPU
CPU -->|讀寫暫存器| APU
CPU -->|讀寫暫存器| JOY[Joypad]
CPU -->|讀寫 ROM/RAM| MAP[Mapper]
PPU -->|讀取 CHR| MAP
APU -->|DMC 取樣| CPU
在 C/C++ 的模擬器裡,這種關係用指標隨便串就好。但在 Rust 裡?CPU 不能同時持有 PPU、APU、Mapper 的 &mut 引用——因為它們都被 System struct 擁有,而 Rust 不允許對同一個 struct 的多個欄位同時取得可變引用(透過方法呼叫時)。
Rc<RefCell<>>:單執行緒的共享擁有權
解法是 Rc<RefCell<>>——Rust 標準庫提供的「引用計數 + 執行期借用檢查」組合:
pub struct System {
pub cpu: Cpu, // CPU 獨佔擁有
pub ppu: Rc<RefCell<Ppu>>, // 共享擁有
pub apu: Rc<RefCell<Apu>>, // 共享擁有
pub joypad1: Rc<RefCell<Joypad>>, // 共享擁有
cpu_cycles: Rc<Cell<u64>>, // 共享計數器
ppu_cycles: Rc<Cell<u64>>, // 共享計數器
}
為什麼 CPU 是直接擁有,其他都是 Rc<RefCell<>>?因為 CPU 的 Bus(記憶體匯流排)裡面也需要持有 PPU、APU 的引用——當 CPU 執行 STA $2000 這種指令時,Bus 要把資料轉發給 PPU 暫存器。所以 PPU 同時被 System 和 Bus 持有——這就是共享擁有權:
System ──owns──→ Rc<RefCell<Ppu>> ←──clone────┐
│
CPU ──owns──→ Bus ──owns──→ PpuBusIo ────owns─┘
Rc::clone() 不複製資料,只增加引用計數。RefCell 在你 borrow_mut() 時做執行期檢查——如果已經有人借了,直接 panic。
Drop-and-Reborrow:和借用檢查器跳舞
最精彩的 pattern 出現在 APU 的 DMC(Delta Modulation Channel)。DMC 播放聲音時需要從 CPU 的記憶體匯流排讀取 sample data——但此時 APU 已經被借用了:
fn step(&mut self) {
// ... 執行 CPU 指令 ...
// 借用 APU 來 tick
let mut apu = self.apu.borrow_mut();
for _ in 0..total_cpu_cycles {
apu.tick();
}
// DMC 需要從 CPU bus 讀資料
if let Some(addr) = apu.dmc_sample_request() {
drop(apu); // ← 先還回去!
let val = self.cpu.bus.read(addr); // 現在可以用 Bus 了
let mut apu = self.apu.borrow_mut(); // 重新借
apu.dmc_load_sample(val);
}
}
看到那個 drop(apu) 了嗎?如果不手動 drop,apu 的可變借用還活著,而 self.cpu.bus.read(addr) 裡面 Bus 的 ApuCompositeBusIo 也會嘗試 borrow_mut() APU——兩個可變借用同時存在,RefCell 直接 panic。
這種「先還再借」的模式在 C++ 裡完全不需要——你隨時都能透過指標存取任何東西。但 Rust 強迫你明確管理每一段借用的生命週期,結果是每個存取路徑都清清楚楚,不會有「誰在什麼時候改了什麼」的幽靈 bug。
Cell vs RefCell:Copy 型別的捷徑
注意 cycle counter 用的是 Rc<Cell<u64>> 而不是 Rc<RefCell<u64>>:
// Cell — 直接 get/set,沒有借用追蹤
self.cpu_cycles.set(self.cpu.cycles);
let ppu_cycles = self.ppu_cycles.get();
// RefCell — 需要 borrow/borrow_mut,有執行期開銷
let mut ppu = self.ppu.borrow_mut();
ppu.tick();
Cell<T> 只能用在實作 Copy 的型別上(數字、bool 等),但好處是完全沒有借用追蹤的開銷——直接覆寫值。在每 CPU cycle 都要更新的計數器上,這個小優化累積起來是有意義的。
Bevy 的型別系統魔法
NonSendMut:「我知道我不安全」
NES 模擬器核心用了 Rc<RefCell<>>,而 Rc 不是 Send(不能安全地跨執行緒傳遞)。Bevy 預設會在多執行緒上排程系統——所以直接把 NesSystem 塞進 Res<> 會編譯失敗:
// 🔴 編譯錯誤:NesSystem 不是 Send
fn my_system(nes: ResMut<NesSystem>) { ... }
解法是 NonSendMut——Bevy 提供的特殊存取器,告訴排程器「這個系統只能在主執行緒上跑」:
// ✅ 編譯通過:Bevy 保證只在主執行緒執行
fn run_emulation_frame(
mut nes: NonSendMut<NesSystem>,
nes_input: Res<NesInput>,
audio_buf: Res<AudioBuffer>,
// ...
) {
nes.sys.joypad1.borrow_mut().set_buttons(nes_input.0);
nes.sys.run_until_frame();
}
這是 Rust 型別系統最美妙的地方之一:限制不是用文件或約定來表達,而是用型別。你不需要在 README 寫「注意:這個函式只能在主執行緒呼叫」——如果有人把它改成 Res<NesSystem>,編譯器就會阻止。
跨執行緒的音訊:Arc<Mutex<>>
音訊 buffer 需要在模擬執行緒(producer)和 cpal 音訊執行緒(consumer)之間共享。這裡不能用 Rc<RefCell<>>——必須用跨執行緒安全的 Arc<Mutex<>>:
#[derive(Resource, Clone)]
pub struct AudioBuffer {
pub buffer: Arc<Mutex<VecDeque<f32>>>,
}
Rust 不會讓你把 Rc<RefCell<>> 傳給另一個執行緒——如果你試著這樣做,編譯器會直接告訴你「Rc 不是 Send」。所以你被迫根據使用情境選擇正確的共享方式:
| 場景 |
型別 |
原因 |
| NES 核心(單執行緒) |
Rc<RefCell<>> |
不需要原子操作的開銷 |
| 音訊 buffer(跨執行緒) |
Arc<Mutex<>> |
需要 Send + 互斥鎖 |
| cycle 計數器(單執行緒、Copy) |
Rc<Cell<>> |
最低開銷 |
在 C++ 裡,你可能會「為了安全」統一用 std::shared_ptr + std::mutex,但代價是不需要鎖的地方也付了鎖的成本。Rust 的型別系統讓你精確地只在需要的地方付出代價。
Exclusive System:獨佔整個世界
初始化模擬器時,我們需要把 NesSystem(非 Send)插入 Bevy 的 World——這需要直接存取 World:
pub fn setup_emulation(world: &mut World) {
let rom_path = world.resource::<RomPath>();
let rom = std::fs::read(&rom_path.0).expect("Failed to read ROM");
let sys = System::new(rom, sample_rate);
world.insert_non_send_resource(NesSystem {
sys,
paused: false,
accumulator: 0.0,
});
}
&mut World 是一個 exclusive system——它獨佔整個 World 的存取權限,不能和其他系統平行跑。Bevy 用這個來處理「需要完全控制權」的初始化邏輯。
在函式簽名裡你就能看出意圖:
world: &mut World → 「我要獨佔一切,請讓所有人都先停下來」
nes: NonSendMut<NesSystem> → 「我只要這一個資源,但我只能在主執行緒」
input: Res<NesInput> → 「我只讀這個資源,隨便誰跟我同時跑都行」
型別本身就是系統的平行化說明書。
Plugin:模組化的秘密武器
Bevy 的 Plugin trait 讓你把相關的系統、資源、事件打包成一個模組:
pub struct CrtPlugin;
impl Plugin for CrtPlugin {
fn build(&self, app: &mut App) {
load_internal_asset!(app, CRT_SHADER_HANDLE,
"../assets/shaders/crt.wgsl", Shader::from_wgsl);
app.add_plugins(Material2dPlugin::<CrtMaterial>::default());
}
}
NES 模擬器的 main.rs 只有 58 行,因為每個功能都是一個獨立的 Plugin 或系統,各自負責自己的資源和生命週期:
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin { ... }))
.add_plugins(CrtPlugin)
.add_plugins(DebugUiPlugin)
.insert_resource(AudioBuffer { ... })
.insert_resource(NesInput(0))
.add_systems(Startup, (setup_emulation, setup_video, setup_audio).chain())
.add_systems(Update, (read_input, run_emulation_frame).chain())
.run();
}
.chain() 保證系統依序執行(先讀輸入,再跑模擬),沒有 chain 的系統 Bevy 會自動判斷是否可以平行。
這種聲明式的架構跟 React 的理念很像——你描述「什麼東西存在」和「它們之間的關係」,框架負責排程和執行。差別在於 Bevy 的描述是用 Rust 的型別系統寫的,所以所有約束都在編譯期就被驗證了。
AsBindGroup:用 Derive Macro 寫 Shader
CRT 效果的 shader 材質用 Bevy 的 AsBindGroup derive macro 自動生成 GPU bind group:
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct CrtMaterial {
#[uniform(0)]
pub params: LinearRgba,
#[texture(1)]
#[sampler(2)]
pub source_texture: Option<Handle<Image>>,
}
impl Material2d for CrtMaterial {
fn fragment_shader() -> ShaderRef {
CRT_SHADER_HANDLE.into()
}
}
#[uniform(0)]、#[texture(1)]、#[sampler(2)] 這些 attribute 對應到 WGSL shader 裡的 binding:
@group(2) @binding(0) var<uniform> params: vec4<f32>;
@group(2) @binding(1) var source_texture: texture_2d<f32>;
@group(2) @binding(2) var source_sampler: sampler;
Rust 端改了 struct 欄位,WGSL 端沒改?你會在執行期看到明確的錯誤訊息。雖然不是完美的編譯期驗證,但比起手寫 bind group layout 再偷偷打錯一個 binding index,好太多了。
BusIo Trait:Rust 的 Trait 如何取代 Virtual Function Table
NES 的 CPU 透過記憶體映射 I/O 跟各個硬體元件溝通——寫入 $2000 是 PPU 控制暫存器,寫入 $4015 是 APU 狀態暫存器。在 C++ 裡你可能用 virtual function:
class BusDevice {
public:
virtual uint8_t read(uint16_t addr) = 0;
virtual void write(uint16_t addr, uint8_t val) = 0;
};
在 Rust 裡用 trait:
pub trait BusIo {
fn read(&mut self, addr: u16) -> u8;
fn write(&mut self, addr: u16, val: u8);
}
然後用組合模式把各個設備串起來:
pub struct ApuCompositeBusIo {
pub inner: CompositeBusIo, // PPU + Joypad
pub apu: Rc<RefCell<Apu>>,
}
impl BusIo for ApuCompositeBusIo {
fn read(&mut self, addr: u16) -> u8 {
match addr {
0x4015 => self.apu.borrow_mut().read_register(addr),
_ => self.inner.read(addr),
}
}
}
跟 C++ 的 virtual dispatch 不同,Rust 的 trait 實作在編譯期就被確定了(除非你用 dyn Trait)。在模擬器這種每 cycle 都要呼叫的 hot path 上,靜態分派比動態分派快不少——因為編譯器可以 inline 整個呼叫鏈。
回顧:Rust 的限制帶來了什麼?
| Rust 的限制 |
被逼出來的設計 |
意外的好處 |
| 沒有 GC,所有權唯一 |
ECS 架構(元件分離儲存) |
自動平行化排程 |
| 同時只有一個 &mut |
Rc<RefCell<>> 共享擁有權 |
每個借用明確可追蹤 |
| Send/Sync trait bound |
NonSendMut 標記主執行緒資源 |
執行緒安全在編譯期驗證 |
| 沒有繼承 |
Trait + 組合模式(BusIo) |
靜態分派、可 inline |
| 嚴格的型別系統 |
系統參數即存取宣告 |
排程器自動推導依賴 |
Bevy 不是「剛好用 Rust 寫的遊戲引擎」——它是一個被 Rust 的型別系統塑造出來的遊戲引擎。每個 API 設計都能追溯到某個 Rust 的語言特性或限制。
寫 NES 模擬器前端的過程讓我深刻體會到這一點:當你和借用檢查器搏鬥的時候,其實是在和它合作設計一個更清晰的架構。那些讓你抓狂的編譯錯誤,往往指向的是你的設計裡真正模糊的地方——誰擁有什麼、誰能改什麼、什麼時候能改。
把這些問題在編譯期就解決掉,比在凌晨三點 debug 一個跨執行緒的 race condition 好太多了。
參考資源
NES 模擬器完成:Bevy 前端、CRT Shader 與 Debug UI
從 CPU、PPU、Joypad、Mapper 到 APU——五個獨立的 crate,各自負責 NES 硬體的一個部分。現在是把它們全部接在一起的時候了。
這個專案是 NES 模擬器的前端——用 Bevy 做渲染、cpal 做音訊輸出、egui 做 debug UI,加上一個 CRT post-processing shader 讓畫面看起來像真的接在老電視上。整個前端不到 2600 行(含自動生成的 FlatBuffer schema),但麻雀雖小,五臟俱全:即時遊玩、存檔讀檔、CPU 反組譯、PPU 調色盤檢視、APU 波形顯示、手把視覺化——全都有。
專案結構
nes-emu/
├── Cargo.toml
└── src/
├── main.rs # Bevy app 設定(58 行)
├── emulation.rs # 模擬核心:主迴圈、時序同步(176 行)
├── video.rs # Framebuffer → GPU 材質(64 行)
├── audio.rs # cpal 音訊串流(53 行)
├── input.rs # 鍵盤/手把輸入對應(97 行)
├── crt.rs # CRT shader 材質定義(61 行)
├── debug_ui.rs # egui 除錯面板(747 行)
├── save_state.rs # FlatBuffer 存檔/讀檔(113 行)
├── generated/ # Planus 自動生成的 schema(1226 行)
└── assets/
└── shaders/
└── crt.wgsl # CRT 後處理 shader(84 行)
依賴的五個 NES crate:nes-cpu、nes-ppu、nes-apu、nes-joypad、nes-mapper。前端框架:Bevy 0.18 + bevy_egui + cpal + planus(FlatBuffers)。
系統架構
所有模擬元件透過 nes-apu 的 System struct 串接在一起——它擁有 CPU、PPU、APU、Joypad 的所有權,負責它們之間的 cycle-level 同步:
graph TB
subgraph "Bevy App"
MAIN[Main Loop
60 FPS] --> EMU[Emulation System]
MAIN --> VID[Video System]
MAIN --> AUD[Audio System]
MAIN --> INP[Input System]
MAIN --> DBG[Debug UI]
end
subgraph "NES System"
EMU --> SYS[nes_apu::System]
SYS --> CPU[CPU
6502]
SYS --> PPU[PPU
Framebuffer]
SYS --> APU[APU
5 Channels]
SYS --> JOY[Joypad
2 Players]
CPU --> MAP[Mapper
Bank Switch]
PPU --> MAP
end
PPU -- "256×240 pixels" --> VID
APU -- "PCM samples" --> AUD
INP -- "button state" --> JOY
CPU 和 PPU 共享 Mapper(透過 Rc<RefCell<>>),APU 的 DMC 也需要存取 CPU 的記憶體匯流排來讀取 sample data。這種多方共享的擁有權結構在 Rust 裡用 interior mutability 來處理——Rc<RefCell<>> 在單執行緒的模擬器裡是最自然的選擇。
主迴圈:Accumulator-Based Timing
模擬器的時序和螢幕的更新率是解耦的——用 accumulator 模式確保不管你的螢幕是 60Hz、144Hz 還是不穩定的幀率,NES 都以精確的 NTSC 速度運行:
const NTSC_FRAME_SECS: f64 = 1.0 / 60.0988;
pub fn run_emulation_frame(
mut nes: NonSendMut<NesSystem>,
time: Res<Time>,
audio_buf: Res<AudioBuffer>,
// ...
) {
if nes.paused {
nes.accumulator = 0.0;
return;
}
nes.accumulator += time.delta_secs_f64();
// 防止 spiral of death:最多追趕 4 幀
nes.accumulator = nes.accumulator.min(NTSC_FRAME_SECS * 4.0);
let mut frames_run = 0;
while nes.accumulator >= NTSC_FRAME_SECS {
nes.sys.joypad1.borrow_mut().set_buttons(nes_input.0);
nes.sys.run_until_frame();
// 把 APU 產生的 sample 送到音訊 ring buffer
let samples = nes.sys.apu.borrow_mut().drain_samples();
if let Ok(mut buf) = audio_buf.buffer.lock() {
if buf.len() < 8192 {
buf.extend(samples.iter());
}
}
nes.accumulator -= NTSC_FRAME_SECS;
frames_run += 1;
}
if frames_run > 0 {
// 把 PPU framebuffer 複製到 GPU 材質
copy_framebuffer_to_texture(&nes, &mut images, &fb_handle);
}
}
幾個關鍵設計:
- 4 幀上限:防止 alt-tab 回來後模擬器瘋狂追趕累積的時間
- 暫停時歸零:避免取消暫停瞬間跑一大堆幀
- 只在有新幀時更新材質:避免無意義的 GPU 上傳
NonSendMut:Rust 所有權的巧妙運用
NesSystem 裡面有 Rc<RefCell<>>——這些型別不是 Send(不能跨執行緒傳遞)。Bevy 預設會在多個執行緒上排程系統,所以我們用 NonSendMut<NesSystem> 告訴 Bevy:「這個資源只能在主執行緒上存取。」
pub fn setup_emulation(world: &mut World) {
// exclusive world access,只在初始化時呼叫一次
let sys = System::new(rom, sample_rate);
world.insert_non_send_resource(NesSystem {
sys,
paused: false,
accumulator: 0.0,
});
}
這是一個很實用的 pattern——當你的核心邏輯本質上是單執行緒的(模擬器幾乎都是),不需要硬把它改成 thread-safe,只要告訴框架「我知道這不能跨執行緒,請安排在主執行緒跑」就好。
影像:從 Framebuffer 到 CRT
Framebuffer 上傳
PPU 每幀輸出 256×240 個 pixel(每個 pixel 是 NES palette 的一個顏色值),前端把它轉成 RGBA8 上傳到 GPU:
fn copy_framebuffer_to_texture(nes: &NesSystem, images: &mut Assets<Image>, handle: &FramebufferHandle) {
let ppu = nes.sys.ppu.borrow();
let fb = ppu.framebuffer();
if let Some(image) = images.get_mut(&handle.0) {
for (i, &color) in fb.iter().enumerate() {
let (r, g, b) = PALETTE[color as usize];
image.data[i * 4] = r;
image.data[i * 4 + 1] = g;
image.data[i * 4 + 2] = b;
image.data[i * 4 + 3] = 255;
}
}
}
256×240×4 = 245,760 bytes per frame——不到 250KB,完全不是瓶頸。
CRT 後處理 Shader
CRT shader 是整個專案最有趣的視覺效果——84 行 WGSL 程式碼模擬老式 CRT 電視的四種特效:
1. Barrel Distortion(桶狀扭曲)
真的 CRT 螢幕是微微凸出的,畫面邊緣會向外彎:
fn barrel_distort(uv: vec2<f32>, curvature: f32) -> vec2<f32> {
let centered = uv - 0.5;
let dist = dot(centered, centered);
let distorted = centered * (1.0 + dist * curvature);
return distorted + 0.5;
}
原理是把 UV 座標從中心算距離,距離越遠的像素被推得越遠——產生凸面鏡的效果。
2. Scanlines(掃描線)
CRT 的電子槍一行一行掃描,行與行之間有暗線:
let scanline = 1.0 - scanline_intensity * 0.5 * (1.0 - cos(uv.y * 240.0 * 3.14159));
用 cos(y × 240 × π) 產生 240 條暗帶,剛好對齊 NES 的 240 條掃描線。
3. Shadow Mask(蔭罩)
真的 CRT 每個「像素」其實是 RGB 三個磷光粉點,相鄰像素的 R/G/B 交錯排列:
let mask_pos = i32(uv.x * 768.0) % 3;
var mask = vec3<f32>(0.8, 0.8, 0.8);
if mask_pos == 0 { mask.r = 1.0; }
else if mask_pos == 1 { mask.g = 1.0; }
else { mask.b = 1.0; }
每 3 個水平像素循環一次 R-G-B 增亮,模擬磷光粉的排列。
4. Vignette(暗角)
CRT 螢幕邊緣比中心暗:
let vignette = 1.0 - vignette_intensity * dot(centered, centered) * 2.0;
距離中心越遠,亮度衰減越多。
三種顯示模式可以在 debug UI 即時切換:Nearest(像素完美)、Linear(雙線性)、CRT(完整後處理)。
音訊:跨執行緒 Ring Buffer
音訊是模擬器裡最需要即時性的部分——如果 buffer underflow,你會聽到爆音。架構上用 Arc<Mutex<VecDeque<f32>>> 做 producer-consumer:
模擬執行緒(producer) cpal 音訊執行緒(consumer)
│ │
│ push samples │ pop samples
└──────→ Ring Buffer ←─────┘
(VecDeque)
max 8192
pub fn setup_audio(mut commands: Commands) {
let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(4096)));
let buf_clone = buffer.clone();
let stream = device.build_output_stream(
&config,
move |data: &mut [f32], _| {
let mut buf = buf_clone.lock().unwrap();
for frame in data.chunks_mut(channels) {
let sample = buf.pop_front().unwrap_or(0.0);
for ch in frame.iter_mut() {
*ch = sample;
}
}
},
|err| eprintln!("Audio error: {err}"),
None,
).unwrap();
// ...
}
- 模擬端每幀 drain APU 的 sample(~735 samples @ 44100Hz / 60fps)
- 音訊端每次 callback pop 需要的量
- 8192 上限防止跑太快時記憶體無限增長
- Underflow 靜默填 0.0——比爆音好
輸入:鍵盤 + 手把
NES 的 joypad 就是 8 個按鈕,用一個 u8 bitfield 就能完整表示:
pub fn read_input(
keyboard: Res<ButtonInput<KeyCode>>,
gamepads: Query<&Gamepad>,
mut nes_input: ResMut<NesInput>,
) {
let mut state = 0u8;
// 鍵盤
if keyboard.pressed(KeyCode::ArrowUp) { state |= 1 << 4; }
if keyboard.pressed(KeyCode::ArrowDown) { state |= 1 << 5; }
if keyboard.pressed(KeyCode::KeyZ) { state |= 1 << 0; } // A
if keyboard.pressed(KeyCode::KeyX) { state |= 1 << 1; } // B
// 手把(第一支)
if let Some(gamepad) = gamepads.iter().next() {
if gamepad.pressed(GamepadButton::DPadUp) { state |= 1 << 4; }
// 類比搖桿 → 方向鍵(deadzone 0.5)
if let Some(axis) = gamepad.get(GamepadAxis::LeftStickY) {
if axis > 0.5 { state |= 1 << 4; } // up
if axis < -0.5 { state |= 1 << 5; } // down
}
// ...
}
nes_input.0 = state;
}
每幀讀一次,bitfield OR 合併鍵盤和手把的狀態——兩個輸入來源可以同時使用,不衝突。
Debug UI:747 行的除錯面板
Debug UI 是這個前端最大的檔案——它在畫面右側開一個 250px 的 egui 面板,塞滿了各種即時資訊:
CPU 反組譯器
暫停時顯示 PC 附近的 6 條指令,每條顯示十六進位機器碼和助記符:
$8000: A9 10 LDA #$10
$8002: 8D 00 20 STA $2000 ← PC
$8005: A9 3F LDA #$3F
加上暫存器狀態(A、X、Y、PC、SP)和 status flags(N、V、B、D、I、Z、C),每個 flag 用顏色標示 on/off。
PPU 調色盤檢視
即時顯示 NES 的 8 組子調色盤(4 組背景 + 4 組精靈),每組 4 個顏色方塊:
BG0: ██ ██ ██ ██ BG1: ██ ██ ██ ██
BG2: ██ ██ ██ ██ BG3: ██ ██ ██ ██
SP0: ██ ██ ██ ██ SP1: ██ ██ ██ ██
SP2: ██ ██ ██ ██ SP3: ██ ██ ██ ██
配合捲軸位置(X, Y, nametable)、rendering flag、精靈統計(數量、8×8 或 8×16)。
APU 波形顯示
用 egui_plot 即時繪製 5 個聲道的波形——Pulse 1(綠)、Pulse 2(藍)、Triangle(橘)、Noise(紫)、DMC(紅),每個聲道顯示最近 256 個 sample 的 ring buffer 資料。
手把視覺化
畫面上顯示一個 NES 手把的圖示,按下的按鈕會變紅——在 debug 輸入問題時非常直觀。
存檔 / 讀檔:FlatBuffers
Save state 用 FlatBuffers(透過 Rust 的 Planus 實作)序列化整個模擬器狀態:
pub fn save(nes: &NesSystem, path: &Path) -> Result<Vec<u8>> {
let mut cpu_state = Vec::new();
nes.sys.cpu.save_state(&mut cpu_state);
let mut ppu_state = Vec::new();
nes.sys.ppu.borrow().save_state(&mut ppu_state);
let mut apu_state = Vec::new();
nes.sys.apu.borrow().save_state(&mut apu_state);
// ... mapper, joypad ...
let state = NesState {
version: 2,
cpu: &cpu_state,
ram: &ram_data,
mapper: &mapper_state,
ppu: &ppu_state,
apu: &apu_state,
// ...
};
let mut builder = planus::Builder::new();
let offset = state.prepare(&mut builder);
builder.finish(offset, None);
Ok(builder.as_slice().to_vec())
}
FlatBuffers 的好處是 zero-copy deserialization——讀檔時不需要把整個 blob 解碼成 Rust struct,可以直接從 buffer 讀取欄位。對於 PPU 的 VRAM(幾 KB)和 APU 的狀態這種大小的資料,效能差異不大,但序列化格式本身很緊湊。
存檔策略:
- F5 存檔:序列化後同時寫入磁碟(
{rom名}.sav)和記憶體快取
- F7 讀檔:優先從記憶體讀取,沒有再從磁碟讀
- 一個 slot:簡單粗暴,一個存檔位就夠
快捷鍵
| 按鍵 |
功能 |
| 方向鍵 |
D-pad |
| Z / X |
A / B 按鈕 |
| A / S |
Select / Start |
| Esc |
暫停 / 繼續 |
| F3 |
顯示 / 隱藏 debug 面板 |
| F5 |
存檔 |
| F7 |
讀檔 |
| F12 |
截圖(PNG,含時間戳) |
總結
| 模組 |
行數 |
負責 |
| main.rs |
58 |
Bevy plugin 註冊 |
| emulation.rs |
176 |
主迴圈、時序同步 |
| video.rs |
64 |
Framebuffer → GPU |
| audio.rs |
53 |
cpal 音訊串流 |
| input.rs |
97 |
鍵盤/手把對應 |
| crt.rs |
61 |
CRT shader 材質 |
| debug_ui.rs |
747 |
egui 除錯面板 |
| save_state.rs |
113 |
FlatBuffer 存檔 |
| crt.wgsl |
84 |
CRT 後處理 shader |
整個 NES 模擬器系列到這裡告一段落。回顧一下各個零件:
| Crate |
負責 |
文章 |
| nes-cpu |
6502 CPU + 記憶體匯流排 |
CPU 文章 |
| nes-ppu |
圖形處理器 + 256×240 渲染 |
PPU 文章 |
| nes-joypad |
手把串列輸入 |
Joypad 文章 |
| nes-mapper |
卡匣 bank switching |
Mapper 文章 |
| nes-apu |
音效處理器 + 5 聲道混音 |
APU 文章 |
| nes-emu |
前端整合 |
本文 |
六個 crate、六篇文章、幾千行 Rust——從單一指令的 fetch-decode-execute,到像素一條一條掃出來,到方波三角波噪音混在一起,到卡匣的 bank 一塊一塊切換,最後全部透過 Bevy 顯示在螢幕上、cpal 從喇叭放出來。NES 的硬體設計在 1983 年是工程奇蹟,用現代語言重新實作一遍,你會更深刻地體會到——那些工程師在 8-bit 的限制裡,真的榨出了不可思議的東西。
參考資源
NES APU 模擬器:方波、三角波、噪音與 DMC 取樣
NES 的聲音來自一顆叫 2A03 的 APU(Audio Processing Unit)——整合在 CPU 晶片裡的音效硬體。它只有 5 個聲道:兩個方波、一個三角波、一個噪音、一個 DMC(差分取樣)。聽起來很陽春?但 Super Mario Bros. 的地上主題、Mega Man 2 的 Dr. Wily Stage、Castlevania 的 Vampire Killer——這些經典旋律全都是從這 5 個聲道榨出來的。
這個專案用 Rust 實作了完整的 NES APU 模擬器,包含 frame counter 時序、envelope 衰減、sweep 掃頻、非線性混音,以及 DMC 的 DPCM 解碼。設計上是純 DSP——不依賴任何音訊輸出函式庫,只負責產生 PCM 樣本。
專案結構
nes-apu/
├── Cargo.toml
└── src/
├── lib.rs # 模組匯出(12 行)
├── apu.rs # APU 核心:tick、取樣、暫存器 I/O(468 行)
├── pulse.rs # 方波聲道:duty cycle + timer(149 行)
├── triangle.rs # 三角波聲道:線性計數器(164 行)
├── noise.rs # 噪音聲道:LFSR 偽隨機(136 行)
├── dmc.rs # DMC 聲道:1-bit DPCM 取樣播放(257 行)
├── envelope.rs # 音量包絡產生器(124 行)
├── sweep.rs # 頻率掃頻單元(140 行)
├── length_counter.rs # 長度計數器(126 行)
├── frame_counter.rs # Frame counter:quarter/half-frame 時序(351 行)
├── mixer.rs # 非線性混音器 + 查表(74 行)
├── composite_io.rs # Bus I/O 轉接器(43 行)
└── system.rs # 整合系統(CPU + PPU + APU + Joypad)
依賴只有 nes-cpu(CPU 核心 + state 序列化工具),整個 APU 沒有外部音訊依賴。
NES 音訊架構
先看全貌——5 個聲道各自獨立產生波形,最後混合成一個單聲道輸出:
graph LR
P1[Pulse 1
方波] --> MIX[Mixer
非線性混音]
P2[Pulse 2
方波] --> MIX
TRI[Triangle
三角波] --> MIX
NOI[Noise
噪音] --> MIX
DMC[DMC
差分取樣] --> MIX
MIX --> HPF[High-Pass
Filter] --> OUT[Audio
Output]
FC[Frame Counter] -.-> P1
FC -.-> P2
FC -.-> TRI
FC -.-> NOI
每個聲道內部都有自己的 timer(控制頻率)和各種修飾單元(envelope、sweep、length counter),由 frame counter 定期觸發更新。
1. Pulse 聲道:方波的藝術
兩個 Pulse 聲道幾乎完全相同,差別只在 sweep 的 negation 模式。每個方波聲道由四個子系統組成:
Timer → Duty Sequencer → 輸出 (0 或 1)
× Envelope 音量
× Length Counter 閘控
× Sweep 掃頻
Duty Cycle:四種波形
方波不一定是 50/50,NES 提供了四種 duty cycle:
Duty 0 (12.5%): ░░░░░░░█ → 極窄脈衝,薄而尖的音色
Duty 1 (25%): ░░░░░░██ → 較窄,清脆
Duty 2 (50%): ░░░░████ → 標準方波,圓潤
Duty 3 (75%): ██████░░ → 等同 25% 反相
實作上就是 8 步的序列查表:
const DUTY_TABLE: [[u8; 8]; 4] = [
[0, 0, 0, 0, 0, 0, 0, 1], // 12.5%
[0, 0, 0, 0, 0, 0, 1, 1], // 25%
[0, 0, 0, 0, 1, 1, 1, 1], // 50%
[1, 1, 1, 1, 1, 1, 0, 0], // 75%
];
Timer 每到 0 就把 sequencer 推進一步,duty table 決定這一步是 1(有聲)還是 0(靜音)。Timer 的 period 就是頻率的倒數——period 越小,頻率越高。
Sweep 掃頻:自動滑音
Sweep 單元每個 half-frame 自動調整 timer period——可以讓音高漸漸升高或降低,遊戲裡的「嗶嗶嗶」滑音效果就是這麼做的:
pub fn tick(&mut self, timer_period: u16) -> Option<u16> {
let target = self.target_period(timer_period);
let muted = timer_period < 8 || target > 0x7FF;
if self.divider == 0 && self.enabled && self.shift > 0 && !muted {
Some(target)
} else {
if self.divider == 0 || self.reload {
self.divider = self.period;
self.reload = false;
} else {
self.divider -= 1;
}
None
}
}
目標頻率的計算是 current ± (current >> shift)——右移 shift 位再加或減。當目標超過 $7FF 或原始 period 小於 8 時,聲道被 mute。
Pulse 1 和 Pulse 2 的 sweep negation 不同是個有趣的硬體細節:
pub enum NegateMode {
OnesComplement, // Pulse 1:-change - 1
TwosComplement, // Pulse 2:-change
}
差 1 的原因是 Pulse 1 用一補數(ones’ complement)做減法,而 Pulse 2 用二補數(two’s complement)。這意味著 Pulse 1 sweep 到最低頻時會比 Pulse 2 多出一個微小的偏移。
2. Triangle 聲道:不需要音量控制
三角波是 NES 聲道裡最特別的——它沒有 envelope(音量控制),波形永遠是滿振幅。但它有一個 linear counter(線性計數器),配合 length counter 一起閘控聲音的開關。
波形是 32 步的固定序列:
15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
上去再下來,形成一個三角形。注意 0 出現了兩次——這讓波形微微不對稱。
一個重要的實作細節——三角波的 output 永遠跟著 sequencer 位置,不管計數器是否為零:
pub fn output(&self) -> u8 {
TRIANGLE_TABLE[self.sequence_pos as usize]
}
計數器只控制 sequencer 要不要繼續推進:
pub fn tick(&mut self) {
if self.length_counter.value() > 0 && self.linear_counter > 0 {
self.sequence_pos = (self.sequence_pos + 1) % 32;
}
}
為什麼這樣設計?因為如果在 sequencer 不在 0 或 15 的位置上突然停止,輸出會卡在一個中間值,造成 DC offset。真實硬體就是這樣——你在某些遊戲裡會聽到三角波聲道留下一個輕微的「嗡」聲。
另一個差異是三角波的 timer 在每個 CPU cycle 都 tick,不像 Pulse 和 Noise 是每兩個 cycle 才 tick 一次。所以三角波能達到的最高頻率是方波的兩倍。
3. Noise 聲道:LFSR 偽隨機
Noise 聲道用一個 15-bit 的 LFSR(Linear Feedback Shift Register) 產生偽隨機噪音:
pub fn tick(&mut self) {
if self.timer == 0 {
self.timer = self.timer_period;
let bit = if self.mode {
(self.shift & 1) ^ ((self.shift >> 6) & 1) // 短模式
} else {
(self.shift & 1) ^ ((self.shift >> 1) & 1) // 長模式
};
self.shift >>= 1;
self.shift |= bit << 14;
} else {
self.timer -= 1;
}
}
LFSR 的運作:每次 timer 歸零,把 bit 0 和另一個 bit XOR,結果塞進 bit 14,整個暫存器右移一位。長模式 XOR bit 0 和 bit 1(週期 32767),短模式 XOR bit 0 和 bit 6(週期 93)。
短模式聽起來像什麼? 像金屬撞擊或電子鼓的 hi-hat——因為短週期讓噪音產生明顯的音調感。長模式則是白噪音。
輸出很直接:bit 0 為 1 時靜音,為 0 時輸出 envelope 音量:
pub fn output(&self) -> u8 {
if self.shift & 1 == 1 || self.length_counter.value() == 0 {
0
} else {
self.envelope.output()
}
}
16 種 period 對應 16 種「音高」的噪音,period 越長聲音越低沉:
const NOISE_PERIOD_TABLE: [u16; 16] = [
4, 8, 16, 32, 64, 96, 128, 160,
202, 254, 380, 508, 762, 1016, 2034, 4068,
];
4. Envelope:音量衰減產生器
Pulse 和 Noise 聲道共用同一個 Envelope 設計。它在每個 quarter-frame(約 240Hz)被觸發一次:
pub fn tick(&mut self) {
if self.start {
self.start = false;
self.decay_level = 15;
self.divider = self.divider_period;
} else if self.divider == 0 {
self.divider = self.divider_period;
if self.decay_level > 0 {
self.decay_level -= 1;
} else if self.loop_flag {
self.decay_level = 15; // 循環:回到最大音量
}
} else {
self.divider -= 1;
}
}
兩種模式:
- Constant volume:直接用 divider_period 當音量(0-15)
- Decay envelope:從 15 開始遞減,每
divider_period + 1 個 quarter-frame 減 1
Loop flag 讓衰減的音量在到 0 之後重新回到 15——可以做出持續的「脈動」效果。
5. DMC:差分取樣播放
DMC(Delta Modulation Channel)是 NES 唯一能播放「真實」音訊樣本的聲道——但它用的是 1-bit DPCM(Differential Pulse-Code Modulation),每個 bit 只表示「+2」或「-2」:
pub fn tick(&mut self) -> Option<u16> {
let mut sample_request = None;
if self.timer == 0 {
self.timer = self.timer_period;
if self.bits_remaining > 0 {
if self.shift_register & 1 == 1 {
if self.output_level <= 125 {
self.output_level += 2; // bit=1: 音量 +2
}
} else {
if self.output_level >= 2 {
self.output_level -= 2; // bit=0: 音量 -2
}
}
self.shift_register >>= 1;
self.bits_remaining -= 1;
}
// 需要新的 sample byte
if self.bits_remaining == 0 && self.bytes_remaining > 0 {
sample_request = Some(self.current_address);
// ...
}
} else {
self.timer -= 1;
}
sample_request
}
DMC 的取樣機制很巧妙:
- CPU memory 的
$C000-$FFFF 區間存放 sample data
- DMC 發出位址請求,外部系統從 CPU memory 讀取 1 byte
- 每個 byte 的 8 個 bit 逐一處理,每個 bit 讓 7-bit output level 加 2 或減 2
- Output level 被 clamp 在 0-127,不會溢出
16 種播放速率(NTSC,CPU cycles per bit):
const DMC_RATE_TABLE: [u16; 16] = [
428, 380, 340, 320, 286, 254, 226, 214,
190, 160, 142, 128, 106, 84, 72, 54,
];
最慢 428 cycles/bit ≈ 4.2 kHz 取樣率,最快 54 cycles/bit ≈ 33.1 kHz。雖然只有 1-bit 差分,但因為取樣率夠高,DMC 可以播放出可辨識的語音和音效——像 Mike Tyson’s Punch-Out!! 裡的裁判喊「TKO!」就是 DMC。
DMC 還有兩個特殊功能:
- Loop flag:播完後自動從頭開始——用來播放循環的底鼓或低音
- IRQ flag:播完時觸發 CPU 中斷——遊戲可以在中斷裡載入下一段 sample
6. Frame Counter:APU 的心跳
Frame counter 是 APU 的「指揮」——它定期發出 quarter-frame 和 half-frame 事件,驅動 envelope、length counter、sweep 等子系統的更新。
兩種模式:
4-step mode(~60 Hz,對齊到 NTSC 的每幀):
Step APU Cycles 動作
1 3729 Quarter-frame(envelope、linear counter)
2 7457 Quarter + Half-frame(+ length counter、sweep)
3 11186 Quarter-frame
4 14914 IRQ flag set
5 14915 Quarter + Half-frame + IRQ set,歸零
5-step mode(~48 Hz,沒有 IRQ):
Step APU Cycles 動作
1 3729 Quarter-frame
2 7457 Quarter + Half-frame
3 11186 Quarter-frame
4 14915 (空步)
5 18641 Quarter + Half-frame,歸零
4-step mode 的 IRQ 讓遊戲不需要用 NMI(PPU vblank)來計時音樂——可以獨立於畫面更新率。5-step mode 則刻意避開 IRQ,寫 $4017 = $80 就切換過去。
實作上用一個 cycle counter 搭配 match:
pub fn tick(&mut self) -> FrameEvent {
self.cycle += 1;
let event = if self.mode == 0 {
// 4-step mode
match self.cycle {
3729 => FrameEvent::quarter(),
7457 => FrameEvent::quarter_and_half(),
11186 => FrameEvent::quarter(),
14914 => { /* set IRQ if not inhibited */ }
14915 => {
self.cycle = 0;
FrameEvent::quarter_and_half()
}
_ => FrameEvent::none(),
}
} else {
// 5-step mode
match self.cycle {
3729 => FrameEvent::quarter(),
7457 => FrameEvent::quarter_and_half(),
11186 => FrameEvent::quarter(),
18641 => {
self.cycle = 0;
FrameEvent::quarter_and_half()
}
_ => FrameEvent::none(),
}
};
event
}
7. Mixer:非線性混音
NES 的混音不是簡單的加法——真實硬體用的是電阻式 DAC,會產生非線性的混音曲線。模擬器用兩張查表來近似這個行為:
impl Mixer {
pub fn new() -> Self {
let mut pulse_table = [0.0f32; 31];
for n in 1..31 {
pulse_table[n] = 95.52 / (8128.0 / n as f32 + 100.0);
}
let mut tnd_table = [0.0f32; 203];
for n in 1..203 {
tnd_table[n] = 163.67 / (24329.0 / n as f32 + 100.0);
}
Self { pulse_table, tnd_table }
}
}
混音公式:
output = pulse_table[pulse1 + pulse2]
+ tnd_table[3 × triangle + 2 × noise + dmc]
Pulse 的索引最大 30(兩個聲道各最大 15),TND 的索引最大 202(3×15 + 2×15 + 127)。查表 O(1),避免了每個 sample 都做除法。
為什麼三角波的權重是 3、噪音是 2?這反映了真實硬體裡各聲道的電阻值比例——三角波聲道的輸出阻抗比噪音低,所以貢獻更大。
8. Downsampling 和高通濾波
NES CPU 跑在 1.789773 MHz,但音訊輸出通常是 44100 Hz。APU 在每個 CPU cycle 都 tick,但只在累積夠足夠 cycle 後才取一個 sample:
pub fn tick(&mut self) {
// ... tick 各聲道 ...
self.sample_counter += 1.0;
if self.sample_counter >= self.sample_period {
self.sample_counter -= self.sample_period;
let mixed = self.mixer.mix(p1, p2, tri, noise, dmc);
// 一階高通濾波,去除 DC offset
let filtered = self.hpf_alpha * (self.hpf_prev_out + mixed - self.hpf_prev_in);
self.hpf_prev_in = mixed;
self.hpf_prev_out = filtered;
self.sample_buffer.push(filtered);
}
}
sample_period = 1789773 / 44100 ≈ 40.59 cycles/sample。累計器用 f64 維持相位對齊,避免 sample 漂移。
高通濾波器的截止頻率約 37 Hz——真實 NES 硬體也有類似的 RC 電路濾掉 DC 偏移。參數計算:
RC = 1 / (2π × 37) ≈ 0.0043
α = RC / (RC + dt),其中 dt = 1 / sample_rate
9. DMC 的 DMA 機制
DMC 取樣需要從 CPU memory 讀資料,但 APU 和 CPU 共享匯流排——DMC 每讀一個 byte 要「偷」CPU 4 個 cycle。這個交互在 system.rs 裡實現:
fn step(&mut self) {
// APU tick
self.apu.borrow_mut().tick();
// 檢查 DMC 是否需要 sample
if let Some(addr) = self.apu.borrow().dmc_sample_request() {
let val = self.cpu.bus_read(addr); // 從 CPU memory 讀取
self.apu.borrow_mut().dmc_load_sample(val);
// 真實硬體這裡會偷 4 個 CPU cycle
}
// 檢查 APU 的 IRQ
if self.apu.borrow().irq_pending() {
self.cpu.irq();
}
}
dmc_sample_request() 回傳 Option<u16>——有值時表示 DMC 需要讀取那個位址的 sample byte。外部系統從 CPU 的記憶體映射讀取後,用 dmc_load_sample() 送回去。
10. State 序列化
每個元件都支援 save_state() / load_state() — 存檔和讀檔功能:
所有有狀態的元件(envelope、sweep、length counter、frame counter、各聲道、mixer 的 per-channel waveform buffer)都參與序列化。狀態用 cursor-based 的方式寫入,配合 nes_cpu::state 模組的 helper function,確保每個元件的狀態都能完整保存和恢復。
11. 測試
APU 的測試以 blargg 的 apu_test ROM 為基準,涵蓋:
- Length counter 行為和 lookup table
- Frame counter 在 4-step / 5-step 模式下的時序
- IRQ 的觸發和清除條件
- DMC 的播放速率表和位址計算
- Envelope 的衰減邏輯
- Sweep 的 negation 差異(ones’ vs two’s complement)
- 混音器的非線性查表
全部模組加起來超過 99 個測試案例。
總結
| 元件 |
行數 |
核心機制 |
| Pulse × 2 |
~149 |
Duty cycle 方波 + sweep 掃頻 |
| Triangle |
~164 |
32-step 三角波 + linear counter |
| Noise |
~136 |
15-bit LFSR + 長/短模式 |
| DMC |
~257 |
1-bit DPCM + DMA 取樣 |
| Envelope |
~124 |
4-bit 音量衰減/常量 |
| Sweep |
~140 |
頻率自動調整 + mute 條件 |
| Length Counter |
~126 |
32-entry 查表 + halt 控制 |
| Frame Counter |
~351 |
4/5-step 時序 + IRQ |
| Mixer |
~74 |
非線性查表混音 |
| APU 核心 |
~468 |
Tick、downsampling、HPF |
NES APU 最讓我印象深刻的是它的層疊式設計——每個聲道都是 timer → sequencer → output 的基本架構,但透過不同的修飾單元(envelope、sweep、length counter、linear counter)組合出截然不同的音色。5 個聲道、總共不到 2000 行 Rust,就能模擬出那個年代所有經典遊戲的配樂。
從 CPU 到 PPU 到 Joypad 到 Mapper 再到 APU,NES 模擬器的每一塊拼圖都就位了。接下來就是把它們全部接在一起,讓畫面動起來、聲音出來、按鍵有反應——一台完整的 NES 模擬器。
參考資源