9.1 wasm 核心與 JSON ABI#
對應原始碼:
web/wasm/src/lib.rs、web/wasm/src/api.rs、web/src/api.ts、web/build.mjs下一節:9.2 虛擬開發板與麵包板
9.1.1 目標#
把整條 EDA 流程放進瀏覽器。
這意味著:
- 不需要安裝任何東西;
- 不需要伺服器(
web/dist/index.html直接用瀏覽器打開就能用); - 分享一個設計只需要分享一個檔案。
技術上的挑戰:EDA 流程是 Rust 寫的、命令列的、需要檔案系統的。怎麼把它變成瀏覽器裡的應用程式?
9.1.2 三層架構#
flowchart TD
A["web/src/**<br/>TypeScript IDE"] --> B["web/wasm/src/api.rs<br/>JSON 分派"]
B --> C["各語言前端與後端<br/>(Rust crate)"]
A -->|"JSON over C ABI"| B| 層 | 位置 | 職責 |
|---|---|---|
| IDE | web/src/ | 編輯器、專案、範例、虛擬開發板、麵包板 |
| 核心 | web/wasm/ | JSON 分派、編譯成 wasm |
| 工具 | workspace 的各 crate | 實際的工作 |
9.1.3 C ABI#
web/wasm/src/lib.rs 只有五個匯出函式:
#[no_mangle] pub extern "C" fn alloc(n: usize) -> *mut u8
#[no_mangle] pub unsafe extern "C" fn dealloc(p: *mut u8, n: usize)
#[no_mangle] pub extern "C" fn result_len() -> usize
#[no_mangle] pub extern "C" fn panic_message() -> *const u8
#[no_mangle] pub unsafe extern "C" fn load_chipdb(p: *const u8, n: usize) -> i32
#[no_mangle] pub unsafe extern "C" fn call(p: *const u8, n: usize) -> *const u8
用法:
ptr = alloc(n); write request bytes; out = call(ptr, n);
read result_len() bytes at `out` (JSON); dealloc(ptr, n)
為什麼是這麼薄的一層? 因為所有複雜度都在 JSON 那一層。C ABI 只需要做記憶體管理。
這個 wasm 模組沒有任何匯入(zero imports)。 一個 Rust panic 會讓實例 trap;panic hook 把訊息寫進 thread-local,JS 端用 panic_message() 讀出來,然後重新實例化一個模組。
「panic 後重建實例」 是一個務實的選擇。Rust 的 panic 是 unwind 的,但 wasm 沒有 unwinding 支援,panic 只能 trap 整個實例。所以 panic 之後整個狀態都沒了,必須重建。
load_chipdb 讓宿主把 IceStorm 的 chipdb-5k.txt 送進核心(第 5.2.2 節)。因為那個檔案有幾 MB,放進二進位不合理。
9.1.4 JSON 分派#
pub fn dispatch(v: &Value) -> R<Value> {
let cmd = v.get("cmd").and_then(|c| c.as_str()).ok_or("missing cmd")?;
match cmd { ... c => Err(format!("unknown command '{}'", c)) }
}
請求:{"cmd": "...", ...}。 回應:永遠是 {"ok": true, ...} 或 {"ok": false, "error": "..."}。
9.1.4.1 命令清單#
cmd | 請求欄位 | 回應欄位 |
|---|---|---|
version | — | version |
list | files: [{name, src}] | modules: [{name, test}] |
simulate | files、top?、max_time?(預設 1,000,000)、vcd?、gate?、synth? | gate、log、tops: [{name, fails, finished}]、vcd: [{top, text}] |
synth | files、top、sdc? | top、modules、warnings、hier、ports、signals、raw、opt、map |
pnr | files + stage | 各階段結果、.asc |
asic | files + stage | ASIC 流程結果 |
asic_gds | — | top、gds(base64)、bytes |
board_load / board_flash / board_load_bin / board_program / board_load_cpu | 設計 + 接腳檔 / 位元流 / 程式 | 開發板狀態 |
board_run | cycles、vga_seen? | cycle、window、duty、evals、segs、edges、uart、vga |
board_reset | — | cycle |
board_periph | — | 周邊狀態 |
cc | C 原始碼 + 選項 | 編譯出的程式 / ELF |
emu_compile | files、top?、stops? | wasm(base64)、meta、stats |
fluxa_emu_compile | files、top?、params? | wasm、meta、stats |
fluxa_list | files | tests、tasks、fns |
fluxa_run | files、tests?、trace?、stats?、gate?、synth? | results: [{name, passed, message, output, stats}]、gate |
chipdb_loaded | — | loaded |
9.1.4.2 兩個特別重要的命令#
simulate 帶 gate + synth:把每個 synth 實例換成它的網表,然後跑同一個測試平台在閘級網表上。
這就是「閘級驗證矩陣」在 IDE 裡的樣子:使用者按一下「執行測試」,RTL 與閘級兩個結果並排顯示。
emu_compile 的 stops:指定哪些訊號會「停下」模型(UART 輸出與 finisher):
let stops: Vec<String> = v.get("stops").and_then(|s| s.as_array())...unwrap_or_default();
let o = veryl_emu::compile(&files(v)?, top, &stops)?;
fluxa_emu_compile 的 params 可以是數字、布林或Fluxa 運算式:
"String" => fluxa::eval_str(&prog, s).map_err(|e| format!("param {}: {}", k, e))?,
這是一個很貼心的設計:參數可以用語言的運算式指定(例如 TAPS = 8 * 2)。
9.1.5 TypeScript 端#
interface Exports {
memory: WebAssembly.Memory;
load_chipdb(p: number, n: number): number;
alloc(n: number): number;
dealloc(p: number, n: number): void;
call(p: number, n: number): number;
result_len(): number;
panic_message(): number;
}
export class Veryl {
static async load(bytes: Uint8Array): Promise<Veryl>
static fromModule(mod: WebAssembly.Module): Veryl
loadChipdb(text: Uint8Array): void
call<T>(req: object): T {
const enc = new TextEncoder().encode(JSON.stringify(req));
const p = x.alloc(enc.length);
new Uint8Array(x.memory.buffer, p, enc.length).set(enc);
let out: number;
try { out = x.call(p, enc.length); }
catch (e) {
let msg = String(e);
try { msg = this.text(x.panic_message()) || msg; } catch {}
this.reset(); // 重建實例
throw new Error(msg);
}
const s = this.text(out);
x.dealloc(p, enc.length);
const r = JSON.parse(s);
if (!r.ok) throw new Error(r.error);
return r as T;
}
}
幾個細節:
(a) this.reset() 在 panic 後重建實例並重新載入 chipdb。
(b) chipdb-5k.txt 以 gzip + base64 的形式發佈,在瀏覽器裡用 DecompressionStream('gzip') 解壓。
export async function gunzip(gz: Uint8Array): Promise<Uint8Array> {
// DecompressionStream('gzip')
}
用瀏覽器內建的 API 解壓,不需要 pako 這類的函式庫。
(c) fromBase64:wasm 的 base64 編解碼(emu_compile 回傳的 wasm 欄位)。
9.1.6 建置#
web/build.mjs:
- 用
cargo --profile web編譯 wasm32 的 crate; - 把
web/src/**用 esbuild 打包; - 把範例資料生成成
dist/examples.js(範例的原始碼內嵌成 JavaScript); - 把
web/static/複製到dist/。
dist/ 是提交進版控的——所以使用者不需要建置就能用 GitHub Pages 開 IDE。
「提交產物」的取捨:讓部署簡單,但會產生大的 diff。
9.1.6.1 範例的型別#
export interface Example {
id: string; group: string; title: string;
files: VFile[];
synth?: string; pcf?: string; note?: string;
title_en?: string; note_en?: string; group_en?: string;
board?: import('./ide/board').BoardSetup;
lang?: 'fluxa';
emu?: boolean;
piBoard?: import('./ide/bbmodel').Part[];
}
declare const VERYL_EXAMPLES: Example[];
export function examples(): Example[] { return typeof VERYL_EXAMPLES === 'undefined' ? [] : VERYL_EXAMPLES; }
typeof VERYL_EXAMPLES === 'undefined' 的檢查 讓這個模組在沒有生成 examples.js 時也能被匯入(單元測試時用)。
範例分組:教學、實驗、C 程式、基礎、時序、綜合、周邊、RISC-V、RV64 開發板、Fluxa 開發板、Fluxa、其他。
9.1.7 三語言#
export type Lang = 'zh-Hant' | 'zh-Hans' | 'en';
export const LANGS: [Lang, string][] = [['zh-Hant','繁體中文'], ['zh-Hans','简体中文'], ['en','English']];
export function fromLocale(loc: string | undefined): Lang
export function tr(hant: string, en: string): string // 'en' 用英文,否則用中文
export function hans(s: string): string // 繁體 → 簡體
export function translateDom(root: HTMLElement): void
「原始文本是繁體中文,英文寫在旁邊」 的模式:
tr('繁體中文', 'English')
簡體中文是衍生出來的:i18n_hans.ts 匯出 HANS_CHARS(逐字對照表)與 HANS_PHRASES(詞語對照表)。
// hans() 先替換最長的詞語(用依長度遞減排序的鍵建成的單一正則),
// 再映射剩下的字
為什麼要用詞語表? 因為有些字在中國大陸是不同的用法而不是不同的字(例如「資訊」vs「信息」、「軟體」vs「軟件」)。
詞語優先於單字是一個重要的排序:「軟件」要在「軟」與「件」之前被替換掉,否則會變成「軟件件」之類的東西。
translateDom() 處理靜態頁面文字:
- 英文:
data-en、data-en-html、data-en-title、data-en-ph; - 簡體:走訪每個含
[\u3400-\u9fff]的文字節點,加上所有title與placeholder屬性。
9.1.8 IDE 的模組結構#
| 檔案 | 大小 | 職責 |
|---|---|---|
main.ts | 111 KB | 工作台:專案、分頁、檔案總管、大綱、問題、測試、綜合分頁、範例、分享 |
board.ts | 39 KB | 虛擬 iCEBreaker:SVG 板子、PMOD 模組、七段、VGA 監視器、UART 終端機、LED 亮度、按鈕 |
emu.ts | 20 KB | 模擬器分頁:終端機(VT100 子集)、開機/暫停/重設/存檔/檔案/執行按鈕、引擎選擇 |
pi.ts | 14 KB | RV64 樹莓派風格開發板 |
caps.ts | 9 KB | 能力分析:標題列的哪些動作適用於目前專案,以及為什麼不適用 |
highlight.ts | 9 KB | Veryl / Fluxa / C / asm / Python / shell 的語法高亮 |
editor.ts | 8 KB | 程式碼編輯器元件 |
bbmodel.ts | 5 KB | 麵包板的電性模型(純函式,無 DOM,可單元測試) |
breadboard.ts | 13 KB | 麵包板編輯器(SVG) |
caps.ts 是個值得注意的設計:標題列的按鈕會依專案狀態啟用/停用。「為什麼不適用」也能說明——這對使用者體驗的幫助比單純停用按鈕大得多。
9.1.9 編輯器#
export class CodeEditor {
private scroller, gutter, code, curLine, pre, ta;
private markers: Marker[] = [];
onChange: (text: string) => void;
onCursor: (line: number, col: number) => void;
onKey: (e: KeyboardEvent) => boolean;
get lineHeight(): number // 19 px,手機上更大(ide.css --edlh)
setMarkers(m: Marker[]): void
goto(line: number, col = 1, selectLine = false): void
}
export interface Marker { line: number; col: number; msg: string; severity: 'error' | 'warning' }
9.1.9.1 一個透明 textarea 疊在高亮層上#
- 重繪用
requestAnimationFrame批次化; <pre>放高亮的 HTML,<textarea>疊在上面(文字透明),並調整到pre.offsetHeight讓單一捲動容器同時移動行號槽、程式碼與 textarea;- 行號槽只在行數或標記改變時重建(用
this.gutter.dataset.marked快取)。
9.1.9.2 鍵盤處理#
- Tab 縮排/反縮排選取區塊(4 個空格);
- Enter 保持縮排,並在
{/(/[後加一級,同時自動補上閉合括號; - 在空行輸入
}會反縮排; Ctrl+/切換//註解。
9.1.9.3 保留瀏覽器的 undo 堆疊#
// 編輯透過 document.execCommand('insertText'),讓瀏覽器的 undo 堆疊存活,
// 失敗時退回 setRangeText
這是一個很細的細節,但非常重要。 用 value = ... 或 setRangeText 會摧毀 undo 堆疊——使用者按 Ctrl+Z 什麼都不會發生。
execCommand 雖然是過時的 API,但在這裡是唯一能保留 undo 的方法。
9.1.10 語法高亮#
export type Lang = 'veryl' | 'fx' | 'c' | 'asm' | 'py' | 'sh' | 'text';
export function langOf(name: string): Lang // 依副檔名
export function highlight(src: string, lang: Lang = 'veryl'): string[]
六組關鍵字/型別集合:Veryl、Fluxa、C、Python、shell。
Veryl 的 token 正則辨認 Verilog 風格的常數(4'b1010、'd255)、$system 名稱與 #[attr] 屬性。
組合語言的高亮是有狀態的:一行的第一個字是助記符,label: 是名稱,而暫存器用正則著色:
const REGS = /^(x([12]?\d|3[01])|zero|ra|sp|gp|tp|fp|t[0-6]|s([0-9]|1[01])|a[0-7])$/;
CSS 類別:t-com、t-str、t-num、t-sys、t-attr、t-kw、t-type、t-name、t-dom。
9.1.11 為什麼用 JSON 而不是 C ABI#
| C ABI(每個功能一個函式) | JSON 分派 | |
|---|---|---|
| 新增功能 | 要改 Rust 的匯出 + TS 的包裝 | 只加一個 match 分支 |
| 參數 | 每個都要定義型別 | 任意 JSON |
| 除錯 | 二進位介面 | 看得懂的 JSON |
| 型別檢查 | 有 | 無 |
JSON 的代價是效能(序列化開銷)與型別安全。對一個互動式 IDE 來說,這兩者都可以接受。
「看得懂的 JSON」對教學特別重要:你可以用瀏覽器的 devtools 直接看到工具鏈在做什麼。
下一節:9.2 虛擬開發板與麵包板