6.7 週邊模型(board/periph.rs)#
原始碼:
veryl-eda/src/board/periph.rs(170 行)。 三個必須「看見每一個時脈週期」的週邊:UART、七段顯示器、 VGA;加上一個純函數的 RLE 壓縮。慢速週邊(LED、按鈕、 蜂鳴器)不需要進這個檔案——duty 計數(6.5 節)就夠了。
6.7.1 為什麼週邊要住在模擬迴圈裡#
模組開頭註解就是設計原則:
Peripherals that must see every clock cycle, so they live inside the board's simulation loop.
LED 亮不亮只取決於高電位比例(duty);但 UART 的取樣 必須落在位元中點、VGA 的畫素由 HSYNC/VSYNC 邊緣切行, 這些都是每拍才看得出來的時間行為。所以它們掛在 Board::run 的每拍迴圈裡(board/mod.rs):
if periph {
let v = &self.eng.v;
if let Some(u) = self.uart.as_mut() { if let Some(tx) = u.tx { u.sample(v[tx as usize] != 0); } }
for s in self.segs.iter_mut() { ... }
if let Some(g) = self.vga.as_mut() { g.sample(v); }
}
attach_* 只把 net 編號記下來(不接線、不模擬):
pub fn attach_uart(&mut self, tx_pin: u32, rx_pin: u32, cpb: f64) {
let rx = self.pin_in.get(&rx_pin).copied();
self.uart = Some(periph::Uart::new(self.out_net(tx_pin), rx, cpb));
}
6.7.2 Uart:波特率與取樣#
8N1 序列,cpb(clocks per bit)是每位元的時脈數:
(RvSoc 的 CPB: u32 = 104 參數就是這個值。)
接收:中點取樣#
Some(t) => {
let t = t + 1;
self.rx_t = Some(t);
// the middle of bit k (k = 0: start bit) is at (k + 0.5) bit times
// (round, not floor: at a fractional bit time t / cpb - 0.5 can fall just short of k)
let k = (t as f64 / self.cpb - 0.5).round();
let at_mid = ((k + 0.5) * self.cpb).round() as u64 == t;
if at_mid && k >= 1.0 && k <= 8.0 && level { self.rx_byte |= 1 << (k as u32 - 1); }
if at_mid && k >= 9.0 {
if level { self.received.push(self.rx_byte as u8); } else { self.framing_errors += 1; }
self.rx_t = None;
}
}
啟始位元的下降緣啟動計時;位元 \(k\) 的中點在 \((k + 0.5)\cdot cpb\) 。取樣在整數拍上進行,所以用 「這一拍是不是最接近中點的拍」判斷。註解裡的教訓值得記住: 用 round 不用 floor——cpb 是 104.17 這種非整數時, floor 會讓中點判定系統性地偏早。
第 9 個位元時槽(stop bit)若是 0 記一次 framing_errors——鮑率設錯的典型症狀。
傳送:RX 線的位準#
rx_level(now) 產生 FPGA 應該看到的位準:
Some(match k { 0 => false, 1..=8 => (b >> (k - 1)) & 1 == 1, 9 => true,
_ => { self.sending = None; return self.rx_level(now); } })
start(0)、8 個資料位元(LSB 先)、stop(1)、閒置。 Board::run 每拍呼叫它,若位準有變就 eng.set(rx, l) 再 propagate——字元就這樣「打進」設計的 UART 接收器。
6.7.3 Seg:七段多工的統計模型#
多工七段在真硬體上是視覺暫留:每位元只亮一個位數, 輪流掃描。模擬器不畫中間態,而是統計每個位數、 每個段的點亮時間:
pub struct Seg {
pub segs: [Option<u32>; 8], // a..g, dp(net,None = 未接)
pub sels: Vec<(Option<u32>, bool)>, // 位數選擇:(net, 致能位準)
pub on: bool, // 亮段的位準
pub lit: Vec<[u64; 8]>, // [位數][段] 點亮拍數
}
取樣:
for (d, (sel, lvl)) in s.sels.iter().enumerate() {
if sel.map(|n| (v[n as usize] != 0) == *lvl).unwrap_or(true) { // 這個位數被選中
for (k, seg) in s.segs.iter().enumerate() {
if let Some(n) = seg { if (v[*n as usize] != 0) == s.on { s.lit[d][k] += 1; } }
}
}
}
顯示值 = \(\text{lit}[d][k] / \text{window}\) (take_segs)。 多工正確的設計會讓每個位數的 8 個段各自接近其 目標亮度;掃描太快/太慢、段驅動衝突都在這個統計下現形。
虛擬模組的腳位約定(AGENTS):7 段 = 腳 1-4、7-9(a-g), 腳 10 是位數選擇——BoardSetup 就照這個約定預設接線。
6.7.4 Vga:800×525 的邊緣取樣#
VGA 監視器不自帶時序假設——它從同步訊號邊緣重建時序:
pub struct Vga {
pub hs: Option<u32>,
pub vs: Option<u32>,
pub r: Vec<u32>, pub g: Vec<u32>, pub b: Vec<u32>, // 顏色腳,最高位元在前
pub active_low: bool, // 同步脈衝低致能(640×480 慣例)
line: Vec<u16>,
lines: Vec<Vec<u16>>,
pub frame: Option<(usize, usize, Vec<u16>)>,
pub frames: u64,
}
if act(self.prev_hs, hs, self.active_low) {
let l = std::mem::take(&mut self.line);
if self.lines.len() < MAX_H { self.lines.push(l); }
}
if act(self.prev_vs, vs, self.active_low) { ... 組合 frame ... }
一拍一畫素:HSYNC 有效邊緣 = 一行結束、 VSYNC 有效邊緣 = 一幀結束。寬高上限(2048×1024)防呆, 顏色通道縮放到 4 bits:
fn chan(pins: &[u32], v: &[u8]) -> u16 {
let n = pins.len() as u32;
let x = pins.iter().fold(0u32, |a, p| (a << 1) | v[*p as usize] as u32);
((x * 15 + ((1 << n) - 1) / 2) / ((1 << n) - 1)) as u16
}
只接了 1 條 R 線也行(0/15 兩階);接 3 條就 8 階。 輸出 frame 是 (寬, 高, 12-bit 畫素),wasm 端再 RLE。
CPU 模式的 text_frame#
VgaText(rvvga.veryl)的文字畫面不需要逐畫素取樣 (CPU 模式沒有閘級訊號),text_frame() 是純函數, 每 800×525 拍產生一幀:
pub fn text_frame(vram: &[u8], font: &[u8], colors: u32) -> (usize, usize, Vec<u16>) {
let (w, h) = (800usize, 525usize);
...
for y in 0..480 {
for x in 0..640 {
let c = vram[(y / 16) * 40 + x / 16] as usize & 127;
let bits = font[c * 8 + (y / 2) % 8];
px[(y + 35) * w + x + 144] = if (bits << ((x / 2) % 8)) & 0x80 != 0 { fg } else { bg };
}
}
(w, h, px)
}
解析度是 800×525(含同步與前後廊)——監視器記錄的 就是這麼多拍;可見區 640×480 從 x=144、y=35 開始。 字元 8×16(x/16、y/16),字型 5×7 放在 8×8 槽 (font[c*8 + (y/2)%8],水平也只取偶數行)。 與硬體的取樣行為一致(VgaText 也是 2 倍放大繪字)。
6.7.5 rle:傳給頁面的壓縮#
VGA 幀 800×525×12bit ≈ 594 KB 原始資料,每幀都傳 會壓垮 wasm 的 JSON 通道。RLE 以 (顏色, 計數) 的 little-endian u16 對壓縮:
pub fn rle(px: &[u16]) -> Vec<u8> {
let mut out = vec![];
let mut i = 0;
while i < px.len() {
let c = px[i];
let mut n = 1;
while i + n < px.len() && px[i + n] == c && n < 0xffff { n += 1; }
out.extend_from_slice(&c.to_le_bytes());
out.extend_from_slice(&(n as u16).to_le_bytes());
i += n;
}
out
}
文字畫面背景佔絕大多數畫素,壓縮比通常在百倍以上。 board_run 只在 vga_seen(頁面已有幀號)落後時 才帶新的幀——增量推送。
6.7.6 attach 機制與跨電源週期的狀態#
wasm 端的 board_periph(api.rs:554):
fn board_periph(v: &Value) -> R<Value> {
BOARD.with(|x| {
let mut x = x.borrow_mut();
let (_, _, b, periph) = x.as_mut().ok_or("no design on the virtual board")?;
*periph = v.clone(); // 存進 BOARD 的第四個槽
b.uart = None; b.segs.clear(); b.vga = None;
attach(b, periph);
Ok(json!({ "ok": true }))
})
}
關鍵是 BOARD 的 thread-local 四元組 (Design, pcf, Board, periph設定)。board_reset (電源重開)時:
*b = match cpu { ... } else { veryl_eda::board::icebreaker::board(d.clone(), pcf.as_deref())? };
attach(b, periph); // 週邊設定原封不動地接回新的 Board
設計、腳位、週邊設定都跨電源週期保留; board_program 換程式時也是 attach(b, periph) 接回。這就是 AGENTS 說的「peripherals kept across power cycles」:終端機的接線不會因為使用者按了 重新開機而消失。
6.7.7 為什麼這樣設計#
- 取樣式而非事件式:週邊每拍被問一次 「線上位準是多少」,自己維護狀態機——與硬體 接收器的行為同構,設計的抖動(jitter)自然被取樣到。
- cpb 用 f64:12e6/115200 不是整數;硬體 UART 也 靠「接近中點」取樣容忍誤差,模型照抄。
- Seg 是統計不是渲染:模擬器不懂「視覺暫留」, 但把平均亮度算出來,渲染交給頁面。
- Vga 邊緣重建時序:不綁 640×480 假設,任何 分辨率的設計(含自訂非標準時序)都能被記錄。
- RLE 在 Rust 端做:壓縮 50 倍以上,JSON 序列化 才不會成為瓶頸;格式簡單到 TS 端一行解開。
6.7.8 本章重點#
- 週邊住在
Board::run的每拍迴圈:UART 取樣、 七段統計、VGA 畫素;慢速週邊(LED/按鈕)用 duty。 - Uart:8N1、cpb = f/baud、中點取樣(round!)、 framing error 計數、RX 由
rx_level逐拍產生。 - Seg:
(位數, 段) → 點亮拍數 / 窗口的多工統計。 - Vga:HSYNC/VSYNC 邊緣切行/幀、一拍一畫素、 顏色腳可任意寬度縮放;CPU 模式改用
text_frame每 800×525 拍產生一幀(可見區 640×480 在 144,35)。 rle:12-bit 畫素的 (顏色, 計數) u16 對。attach_*+BOARDthread-local 第四槽 = 週邊設定跨電源週期保留。