跳轉到

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)是每位元的時脈數:

\[ cpb = \frac{f_{\text{clk}}}{\text{baud}} = \frac{12\,\text{MHz}}{115200} \approx 104.17 \]

(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 為什麼這樣設計#

  1. 取樣式而非事件式:週邊每拍被問一次 「線上位準是多少」,自己維護狀態機——與硬體 接收器的行為同構,設計的抖動(jitter)自然被取樣到。
  2. cpb 用 f64:12e6/115200 不是整數;硬體 UART 也 靠「接近中點」取樣容忍誤差,模型照抄。
  3. Seg 是統計不是渲染:模擬器不懂「視覺暫留」, 但把平均亮度算出來,渲染交給頁面。
  4. Vga 邊緣重建時序:不綁 640×480 假設,任何 分辨率的設計(含自訂非標準時序)都能被記錄。
  5. 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_* + BOARD thread-local 第四槽 = 週邊設定跨電源週期保留。