GhosPTT 終端機實驗

以 WebGPU 與 libghostty-vt 打造現代 PTT,探索三十年老派 BBS 的 WebAssembly 整合架構
featured.svg

在上一篇探討 libghostty 1.0 的 C 函式庫設計修養 中,我們深入剖析了 Mitchell Hashimoto 如何透過嚴格的 C ABI、不透明控制代碼與自訂記憶體配置器,打造出能跨越作業系統與語言邊界的現代虛擬終端核心。在它的建置管線中,有一條非常引人注目的路徑:能夠直接輸出 wasm32-freestanding 的獨立 WebAssembly 產物。

這次我把 libghostty-vt 編譯成 WebAssembly,再搭配 WebGPU 繪製它輸出的終端狀態。GhosPTT 是這項實驗的原型專案;選擇 PTT 作為題材,是想觀察 Big5、ANSI 與字元寬度在這套組合下會遇到哪些整合挑戰。

若要真正驗證一個現代虛擬終端引擎(VT emulator)的實力,單純的 shell 提示字元往往不足以暴露出邊界問題。我們需要一個足夠複雜、充滿高密度字元排版、特殊轉義控制碼與歷史相容包袱的測試場景。對台灣的軟體開發者來說,最深植人心的經典終端應用,莫過於擁有三十年歷史的 PTT(批踢踢實業坊)——它融合了嚴苛的 80×24 ANSI 雙位元組色塊藝術、歷史遺留的雙色字編碼、東亞字元寬度不明確(East Asian Ambiguous) 問題,以及高頻率的畫面重繪,是檢驗 libghostty-vt 解析能力與狀態機嚴謹度的極佳試煉場。

在此必須先明確釐清專案定位:官方以 HTML5 打造的 term.ptt.cc 歷經多年淬鍊,具備成熟穩定的連線架構與普及的社群基礎;GhosPTT(專案原始碼收錄於 ghosptt)的初衷並非為了取代官方網頁版,而是一個純粹的技術 prototype 與開源展示,核心目標是探索:當我們將工業級的 C/Zig 終端核心(ghostty-vt.wasm)作為 headless engine 搬上現代 Web,能激盪出什麼樣的架構想像與技術可能?

透過 GhosPTT,我們嘗試以 libghostty-vt 為底層中樞,向上銜接 WebGPU 實例化繪製管線、以片元數學幾何避免依賴字型度量所造成的 ANSI 色塊接縫、疊加復古 CRT 著色器,並補齊 Big5-UAO 補完計畫支援、Telnet 閒置保活、響應式視窗縮放與貼圖燈箱導覽等現代 Web 互動。這篇文章將完整拆解 GhosPTT 的設計與實作細節,聊聊如何以現代圖形與 WebAssembly 技術,為老派終端核心打造一場具體且紮實的技術演示。

先玩再說:線上即時體驗

講再多架構理論,都不如直接在瀏覽器裡親自摸摸看。下方為透過 Cloudflare Workers 與 WebGPU / Canvas 2D 雙渲染引擎驅動的 GhosPTT 即時展示:

 Tip
  • 操作方式 - 點擊終端機畫面即可取得鍵盤焦點,可使用方向鍵與 Enter 瀏覽 PTT;若在行動裝置或想快速嘗試,亦可直接點擊頂部快捷按鈕(免打字登入 guest、方向鍵導航、切換 WebGPU / Canvas 2D 渲染器與 CRT 著色器開關)。
  • 獨立視窗體驗 - 若需要更大的閱讀空間或習慣使用全螢幕快捷鍵,也可以直接開啟獨立頁面 ghosptt.simplypatrick.workers.dev。

架構全景:WASM 核心、通訊管線與雙渲染引擎

要在瀏覽器中以 libghostty-vt 驅動高密度的 PTT 串流展示,必須串聯網路通訊、字元編碼解碼、終端機狀態機與硬體加速繪圖四大層級:

architecture.svg

整個資料流由左至右、由上而下清晰分工:

  1. 連線與邊緣代理 - 瀏覽器透過 WebSocket 連線至同源的 /bbs 路徑。生產環境由部署於邊緣的 Cloudflare Workers 轉送 WebSocket 並注入合規的 Origin: https://term.ptt.cc 標頭;同時實作定期 TELNET IAC NOP(0xFF 0xF1)心跳保活機制,降低長文閱覽時因長時間無資料傳輸帶來的連線逾時斷線風險;本機開發時則由 Vite plugin 直接以 tls.connect 連向 ws.ptt.cc 並指定 ALPNProtocols: ['http/1.1'],跨越官方網關的來源與協定防護。
  2. 通訊協定與編碼解析管線 - PTT 伺服器傳回的二進位位元組串流首先進入狀態機過濾 Telnet IAC 指令,在收到 IAC DO NAWS 時自動回覆 80×24 視窗大小。接著載入 256 KiB 二進位表支援 Big5-UAO 2.50(Unicode 補完計畫) 涵蓋簡體字、日文假名與特殊符號,並由 PttBig5StreamDecoder 處理雙色字(DBCS 中斷 ANSI)跨封包拼接與 Mode 2027 寬度對齊。
  3. WebAssembly 終端核心 - 乾淨的字串餵入 891 KiB 的 ghostty-vt.wasm。這正是本次技術展示的終端狀態核心:由 libghostty-vt 獨立維護 80×24 畫面網格、屬性矩陣、游標移動與文字選取,前端完全不需要自行手寫容易出錯的終端狀態機,體現了 headless 終端函式庫的模組化優勢。
  4. 硬體加速雙渲染引擎 - 前端從 WASM 提取畫面快照後,WebGPU 實例化繪製管線(Instanced Quad)以單一 draw call 完成背景色塊、動態 CJK 字形紋理圖集(atlas,基準尺寸為 2048×2048)與純數學幾何 ANSI 色塊運算,再透過第二階段片元著色器疊加復古 CRT 曲面、掃描線與鄰近像素光暈(不支援 WebGPU 的裝置則自動降級至 Canvas 2D),並具備 5:3 視窗自適應縮放。
  5. 現代 Web 互動增強層 - 畫面上即時識別出超連結,當滑鼠懸停於 Imgur 或圖片網址時彈出浮窗預覽,點擊進入全螢幕燈箱與文章相簿模式,並於燈箱開啟時讓出方向鍵操作;同時維護 UAO/Big5 逆向編碼表,確保中文、日文與符號剪貼簿貼上可正常送往 PTT。

WebAssembly 整合:透過型別清單減少 C ABI 硬編碼

在 WebAssembly 與 JavaScript / TypeScript 的互通實作中,最繁瑣且脆弱的環節莫過於結構體的記憶體對齊與欄位位移(field offset)。若手動在前端宣告 offset 數值,只要編譯器旗標或核心結構稍有調整,記憶體讀寫就會立刻錯位。

libghostty-vt 在這次整合中使用的一項設計,就在於它在唯讀資料段中內嵌了一份由編譯期反射生成的 JSON 型別清單。在 GhosPTT 啟動時(src/ghostty-wasm.ts),我們不需要依賴任何 Emscripten 或外部綁定生成器,直接以原生 WebAssembly API 載入模組:

export class GhosttyVtTerminal {
  public static async create(wasmUrl = './ghostty-vt.wasm'): Promise<GhosttyVtTerminal> {
    const response = await fetch(wasmUrl);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status} loading ${wasmUrl}`);
    }
    const wasmBytes = await response.arrayBuffer();
    const utf8Decoder = new TextDecoder('utf-8');

    const wasmModule = await WebAssembly.instantiate(wasmBytes, {
      env: {
        log: (ptr: number, len: number) => {
          const exp = wasmModule.instance.exports as GhosttyWasmExports;
          const bytes = new Uint8Array(exp.memory.buffer, ptr, len);
          console.log('[ghostty-vt]', utf8Decoder.decode(bytes));
        },
      },
    });

    const exports = wasmModule.instance.exports as GhosttyWasmExports;

    // 從 WASM 唯讀資料段讀取 NUL 結尾的 JSON ABI 描述
    const jsonPtr = exports.ghostty_type_json();
    const memBytes = new Uint8Array(exports.memory.buffer, jsonPtr);
    let nulIdx = 0;
    while (memBytes[nulIdx] !== 0) nulIdx++;
    const jsonStr = utf8Decoder.decode(memBytes.subarray(0, nulIdx));
    const layout = JSON.parse(jsonStr) as GhosttyTypeLayout;

    return new GhosttyVtTerminal(exports, layout);
  }
}

透過解析這份 JSON,多個核心結構體大小(GhosttyRenderStateColors.size、GhosttyPoint.size 等)與列舉值(GhosttyTerminalOption.values)在執行期取得。

值得指出的是,作為驗證性質的原型實作,GhosPTT 目前仍假設執行於 32 位元 WASM 環境(指標大小為 4 位元組),且在掃描底層資料時保留了部分手動位移與位元遮罩運算(例如在 src/ghostty-wasm.ts 中讀取 GhosttyPoint 標記、選取範圍位移,以及解析行儲存格的樣式旗標)。型別清單的實務價值,在於省去了手動維護大量易碎的 C 結構體綁定程式碼,在原型開發效率與底層讀取效能之間取得良好平衡。

持久控制代碼與共用 scratch 緩衝區

避免每影格重複跨越 WASM 邊界配置與釋放記憶體,降低配置器抖動與記憶體碎片。GhosPTT 在建構式中一次性配置所有持久控制代碼(opaque handle)與共用 scratch buffers:

// 1. 建立 80x24 終端實例
this.opaqueSlotPtr = exports.ghostty_wasm_alloc_opaque();
this.check(
  exports.ghostty_terminal_new(0, this.opaqueSlotPtr, COLS, ROWS),
  'ghostty_terminal_new'
);
this.termPtr = exports.ghostty_wasm_take_opaque(this.opaqueSlotPtr);

// 2. 建立渲染狀態與行迭代器
this.check(
  exports.ghostty_render_state_new(0, this.opaqueSlotPtr),
  'ghostty_render_state_new'
);
this.renderStatePtr = exports.ghostty_wasm_take_opaque(this.opaqueSlotPtr);

this.check(
  exports.ghostty_render_state_row_iterator_new(0, this.opaqueSlotPtr),
  'ghostty_render_state_row_iterator_new'
);
this.rowIterPtr = exports.ghostty_wasm_take_opaque(this.opaqueSlotPtr);

// ...(略去 row_cells 初始化)

// 3. 預先配置可重複使用的結構體緩衝區
this.colorsSize = layout.types.GhosttyRenderStateColors.size;
this.colorsPtr = exports.ghostty_wasm_alloc(this.colorsSize);
this.cursorSize = layout.types.GhosttyRenderStateCursor.size;
this.cursorPtr = exports.ghostty_wasm_alloc(this.cursorSize);
this.cellRawPtr = exports.ghostty_wasm_alloc(8);

提取每一影格時,呼叫 ghostty_render_state_update 更新渲染狀態,再透過 ghostty_render_state_row_iterator_next 由上而下逐行掃描整個視區,依序讀取各列字元格資料,高效產生緊湊的渲染快照(snapshot)。

歷史遺產與標準相容:雙色字與字元寬度的調解之道

PTT 是基於 Big5 繁體中文編碼與 DEC VT100 / ANSI 控制碼運作的純文字系統。當我們使用嚴格遵循現代標準的 libghostty-vt 作為終端引擎時,正好能展示現代 headless 核心如何與充滿歷史包袱的真實串流優雅協調。

雙色字(DBCS Split ANSI Escape)

在傳統 BBS 上,ANSI 美工為了在單一中文字的左右兩半塗上不同色彩(例如半藍半紅),創造出極其特殊的排版技巧:把 ANSI 顏色控制碼 \x1b[...m 硬生生插在 Big5 雙位元組(前導位元組 0x81..0xFE 與接續位元組 0x40..0x7E, 0xA1..0xFE)的中間!

如果直接將 WebSocket 收到的二進位串流送進瀏覽器原生 TextDecoder('big5'),解碼器在讀取前導位元組後看到 0x1b,會判定前導位元組為不合法字元,直接輸出替代字元 \ufffd,畫面隨之錯位。

GhosPTT 在 src/big5.ts 中實作了專屬的狀態機解碼器 PttBig5StreamDecoder。它會在串流中攔截前導位元組,檢測後續是否存在 ANSI 轉義序列,並在跨轉義序列後準確捕獲接續位元組完成解碼:

// 2. Big5 前導位元組(0x81..0xFE)
if (b >= 0x81 && b <= 0xfe) {
  let j = i + 1;
  let interveningEscapes = '';
  let incomplete = false;

  // 收集夾在 Big5 前導與接續位元組中間的所有 ANSI 轉義控制碼
  while (j < buf.length && buf[j] === 0x1b) {
    const end = findAnsiEscapeEnd(buf, j);
    if (end < 0) {
      incomplete = true;
      break;
    }
    for (let k = j; k < end; k++) {
      interveningEscapes += String.fromCharCode(buf[k]);
    }
    j = end;
  }

  if (incomplete || j >= buf.length) {
    // 跨 WebSocket 封包邊界:保留未完成位元組至下一訊框
    break;
  }

  const trail = buf[j];
  const isValidTrail =
    (trail >= 0x40 && trail <= 0x7e) || (trail >= 0xa1 && trail <= 0xfe);

  if (isValidTrail) {
    let ch = '';
    // 優先查詢 Big5-UAO 2.50 對應表
    if (uaoB2U) {
      const code = (b << 8) | trail;
      const u = uaoB2U[code];
      if (u !== 0) {
        ch = String.fromCharCode(u);
      }
    }
    // 未在 UAO 表中則降級至原生解碼器
    if (!ch) {
      pairBuf[0] = b;
      pairBuf[1] = trail;
      ch = this.pairDecoder.decode(pairBuf);
    }
    const cp = ch.codePointAt(0) ?? 0;
    const w = this.codepointWidthFn(cp);
    // 重新排列:先輸出轉義序列,再輸出中文字元,必要時附加寬度補償字元
    out += interveningEscapes + ch + (w === 1 ? BIG5_WIDE_PAD : '');
    i = j + 1;
  } else {
    out += interveningEscapes;
    i = j;
  }
  continue;
}

如此一來,既有的顏色樣式依然能夠依序生效,而雙位元組字元亦能完整復原。需要留意的是,由於中斷的轉義控制碼被提至字元前方生效,字元左右兩半目前會統一取用後段的 SGR 顏色,尚未做到單一字元逐半獨立著色,但已避免這類插入式 SGR 造成的字元解碼錯誤。

East Asian Ambiguous 寬度與 Mode 2027

另一個棘手問題是字元寬度。在 Big5 世界裡,所有雙位元組字元(包括色塊 █、▊、▄、幾何角 ◢、◣、符號 ★、線條 ─、│)皆一律佔據 2 個終端機欄位(24px 寬度)。

然而在現代 Unicode 標準中,這些符號被歸類為「東亞寬度不明確(East Asian Ambiguous)」,在多數標準終端模擬器中預設僅佔 1 個欄位(12px)。這會導致整張 ANSI art 嚴重錯位、向左坍塌。

為了解決這個問題,GhosPTT 結合了 Ghostty 預設建議啟用的 Mode 2027(複合字元模式 / Grapheme Clustering):

// 啟動終端時啟用 Mode 2027
this.writeVt('\x1b[?2027h');

在 Mode 2027 下,Ghostty 會依據 Unicode 規範將基底字元與結合標記聚合成單一複合字元。我們在常數中定義了一個特殊的結合字元:

export const BIG5_WIDE_PAD = '\u0903';

U+0903 是天城文 Visarga(間隔記號)。解碼器透過呼叫 Ghostty 匯出的 ghostty_unicode_codepoint_width(cp) 函式計算字元寬度;當判定某個雙位元組字元寬度為 1 時,便在其後方附加 BIG5_WIDE_PAD。在 Mode 2027 下,Ghostty 會將兩者聚合成佔據 2 欄位寬度的複合圖元。這是原型階段為了因應目前 Ghostty 寬度計算邏輯所採取的相容策略,使雙位元組 ANSI 排版維持正確寬度;而在渲染輸出、字形紋理查詢與剪貼簿複製時,再透過正規表達式剔除該字元。

Big5-UAO 2.50 擴充字集支援

PTT 雖以傳統 Big5 為基礎,但在實際文章與推文中,使用者頻繁使用不在標準 Big5 範圍內的字元,例如簡體字(如「国」0x83f6)、日文假名(如「ぁ」0xc6e7)或特殊符號(如愛心「♥」0x9dde)。這些字元屬於 PTT 官方支援的 Big5-UAO(Unicode 補完計畫 2.50) 擴充字集。

WHATWG Big5 與 PTT 使用的 UAO 對照表並不完全相同;部分位元組可能解碼失敗輸出替代字元,也可能被解成另一個有效字元(例如 0x9dde 在 UAO 中代表「♥」,但在 WHATWG Big5 中則對應「鱅」)。因此原型優先使用 PTT 的對照表進行解碼。

為支援 UAO 字元,GhosPTT 實作了 UAO 2.50 查表機制:

  • 透過獨立腳本(scripts/build-uao.mjs)直接從 ptt/pttbbs 官方儲存庫提取 table.go,預先產出兩張各含 65,536 個 uint16 的對照表(b2u 與 u2b),合併為 262,144 位元組(256 KiB)的二進位檔 public/uao250.bin。
  • 應用啟動時由 initBig5Uao('./uao250.bin') 載入記憶體。解碼串流時,PttBig5StreamDecoder 優先查詢 uaoB2U;若不在 UAO 表中,才降級交由原生 TextDecoder('big5') 處理。
  • 解碼後的 UAO 特殊字元同樣會進入 ghostty_unicode_codepoint_width(cp) 寬度驗證。例如當「♥」由 Ghostty 判定為寬度 1 時,解碼器依然會自動附加 BIG5_WIDE_PAD,依目前 Ghostty 行為在 Mode 2027 下補足兩欄寬度,維持 BBS 排版幾何。

Telnet IAC NOP 閒置保活機制

GhosPTT 的生產環境由 Cloudflare Workers 轉送 WebSocket 連線至 PTT 網關。在 BBS 閱覽長篇好文或推文時,使用者往往超過數分鐘未敲擊鍵盤,長時間缺乏雙向資料傳輸容易增加邊緣網路逾時斷線的風險。

RFC 854 定義了 TELNET IAC NOP(0xFF 0xF1,No Operation)指令。PTT 的 BBS 伺服器在接收到 NOP 時會默默予以忽略,既不會改變終端游標位置,也不會更動畫面緩衝區內容。

GhosPTT 在瀏覽器端(src/ptt-client.ts)與 Cloudflare Worker 代理(worker/index.ts)分別加入了應用層保活機制:每 10 秒檢查一次傳輸狀態,當偵測到連線閒置達 30 秒以上時,便在 WebSocket 資料通道中傳送 TELNET_IAC_NOP 位元組,降低長時間閱讀時的閒置斷線機率。

WebGPU 硬體加速與無縫 ANSI 色塊渲染

終端機看似只有文字,但在高 DPI 螢幕與高更新率下,每秒繪製大量的字形與色塊對 CPU 是不小的負擔。GhosPTT 實作了專屬的 WebGPU 渲染管線(src/webgpu-renderer.ts)。

Instanced Quad 實例化管線

在 80×24 視窗中,總共有 1,920 個字元格。加上背景矩形、文字前景色與選取區域,GhosPTT 配置了一個最多容納 8,192 個實例的實例資料緩衝區。每個 instance 佔用 16 個 float(64 位元組,符合 16 位元組對齊規範):

struct InstanceInput {
  @location(0) rect: vec4<f32>,     // x, y, w, h(物理像素)
  @location(1) uvRect: vec4<f32>,   // u0, v0, u1, v1
  @location(2) color: vec4<f32>,    // r, g, b, a
  @location(3) kind: f32,           // 0 = Solid, 1 = AtlasGlyph, 2 = CustomBlock
  @location(4) codepoint: f32,      // 自訂幾何圖元 Unicode 碼點
};

在 WebGPU 渲染管線中,文字與背景色塊繪製集中在單一終端網格 pass(執行 pass.draw(6, instanceCount));若使用者開啟復古 CRT 效果,管線則會將網格結果先渲染至中介紋理,再透過第二個全螢幕後製 pass(pass.draw(6, 1))疊加著色器特效。中英文字形快取於動態紋理圖集(texture atlas,基準尺寸為 2048×2048,並依視窗縮放、DPR 與 GPU 紋理限制調整),並搭配最近鄰採樣(nearest-neighbor)與物理像素對齊,即便在 Retina 螢幕上文字依然銳利清晰。

為了改善深色背景上細抗鋸齒邊緣容易發虛的問題,片元著色器中特別加入了字幹補暗(stem darkening)伽瑪校正補償:

// 修正深色背景下的透明字形遮罩邊緣
let crispAlpha = pow(sample.a, 0.72) * in.color.a;
return vec4<f32>(in.color.rgb, crispAlpha);

片元幾何消除 ANSI 色塊接縫(Procedural Custom Blocks)

在許多基於 Canvas 2D 或 DOM 的終端渲染中,常見的微小瑕疵是 ANSI 色塊繪畫的接縫。許多字型所設計的色塊字元(▁–█、▉–▏、▐、▔、▕)或角落三角形(◢、◣、◤、◥),在字型度量(metrics)上往往無法精確填滿整個字元格,造成色塊之間出現微小的一像素縫隙。

GhosPTT 對支援的色塊碼點改用幾何覆蓋計算,完全不依賴字型繪製色塊,改在 WGSL 片元著色器中進行純數學幾何分析:

fn evalCustomBlock(cp: u32, p: vec2<f32>) -> f32 {
  // 下方 1/8 .. 8/8 色塊:U+2581 (▁) .. U+2588 (█)
  if (cp >= 0x2581u && cp <= 0x2588u) {
    let eighths = f32(cp - 0x2580u) / 8.0;
    return select(0.0, 1.0, p.y >= (1.0 - eighths));
  }

  // 左側 7/8 .. 1/8 色塊:U+2589 (▉) .. U+258F (▏)
  if (cp >= 0x2589u && cp <= 0x258Fu) {
    let eighths = f32(8u - (cp - 0x2588u)) / 8.0;
    return select(0.0, 1.0, p.x <= eighths);
  }

  // 右半色塊:U+2590 (▐)
  if (cp == 0x2590u) {
    return select(0.0, 1.0, p.x >= 0.5);
  }

  // 上方 1/8 色塊:U+2594 (▔)
  if (cp == 0x2594u) {
    return select(0.0, 1.0, p.y <= 0.125);
  }

  // 右側 1/8 色塊:U+2595 (▕)
  if (cp == 0x2595u) {
    return select(0.0, 1.0, p.x >= 0.875);
  }

  // 角落三角形搭配平滑抗鋸齒(實際邊界過渡寬度隨 quad 尺寸與 DPR 改變):U+25E2 (◢), U+25E3 (◣), U+25E4 (◤), U+25E5 (◥)
  let aa = 0.025;
  if (cp == 0x25E2u) {
    // ◢ 右下角:x + y >= 1.0
    return smoothstep(1.0 - aa, 1.0 + aa, p.x + p.y);
  }
  if (cp == 0x25E3u) {
    // ◣ 左下角:y >= x
    return smoothstep(-aa, aa, p.y - p.x);
  }
  if (cp == 0x25E4u) {
    // ◤ 左上角:x + y <= 1.0
    return 1.0 - smoothstep(1.0 - aa, 1.0 + aa, p.x + p.y);
  }
  if (cp == 0x25E5u) {
    // ◥ 右上角:x >= y
    return smoothstep(-aa, aa, p.x - p.y);
  }

  return 0.0;
}

透過在片元著色器中計算幾何覆蓋,色塊邊緣能緊貼字元格邊界,避免傳統字型渲染在拼接時容易出現的接縫細紋。除了 WebGPU,Canvas 2D 降級路徑(src/renderer.ts 中的 drawCustomBlockGlyph)也同樣採用幾何路徑(fillRect 與 Path2D)繪製色塊,維持乾淨的繪製體驗。

復古 CRT 著色器

為重現舊時代映像管螢幕的氛圍,渲染管線設計了可隨時切換的後製處理 pass(fs_post)。

fn warpCrtUv(uv: vec2<f32>) -> vec2<f32> {
  let centered = uv * 2.0 - 1.0;
  let offset = centered.yx / vec2<f32>(6.5, 5.5);
  let warped = centered + centered * offset * offset;
  return warped * 0.5 + 0.5;
}

透過 warpCrtUv 模擬球面桶狀畸變(barrel distortion),並在採樣時引入空間上的橫向 RGB 色差(chromatic aberration)、正弦掃描線(scanlines)、鄰近像素光暈(local glow)與周邊暗角(vignette)。需要說明的是,此著色器著重於重現單格空間上的映像管幾何感與掃描紋理,並未額外模擬時間維度上的螢光粉動態殘影(temporal persistence)。

現代 Web 體驗注入經典 BBS

除了底層終端渲染的高規格,GhosPTT 在前端操作體驗上也融入了現代化設計。

懸停縮圖預覽與貼文相簿導覽

在 PTT 看文時,文章與推文裡常充滿各類圖片連結(特別是 Imgur)。傳統上使用者必須逐一點開網址在新分頁查看,體驗破碎。

GhosPTT 實作了全自動的 URL 掃描與圖片正規化(src/image-preview.ts):

  • 懸停即時預覽 (popover preview) - 滑鼠移到圖片網址上方時,自動抓取並顯示懸停縮圖卡片,不干擾終端閱讀。
  • 全螢幕燈箱模式 (lightbox viewer) - 點擊網址直接展開高畫質燈箱。
  • 貼文相簿模式 (post gallery navigation) - 閱讀文章時,GhosPTT 會在翻頁過程中持續累計曾出現在終端畫面上的圖片網址。使用者只要進入任一張燈箱,就能用鍵盤左右鍵(← / →)像幻燈片一樣連續翻閱已瀏覽的所有圖片;當畫面偵測到回到主功能表或看板列表的特定提示字串時,便會自動重置相簿清單,關閉燈箱時亦無縫返還終端機鍵盤焦點。

響應式等比例縮放與燈箱鍵盤控制

PTT 終端機受限於 80×24 字元格的嚴格佈局(960×576 邏輯像素)。在行動裝置螢幕、高 DPR 筆電或 4K 螢幕上,若是固定像素寬高,容易造成畫面超出邊界或文字縮得過小。

GhosPTT 在 src/main.ts 中透過 ResizeObserver 監聽主畫面容器(.terminal-stage),動態計算出維持 5:3 等比例的最大縮放係數(scale),並將 Canvas 物理尺寸同步調整為 960 * scale * dpr 與 576 * scale * dpr。實體尺寸會四捨五入取整數;WebGPU 後端另依 GPU texture limit 限制有效像素密度,避免大型視窗或高 DPR 超出裝置能力。同時在 src/terminal-layout.ts 中提供幾何對齊與座標轉換函式:

export function clientPointToCell(canvas: HTMLCanvasElement, x: number, y: number) {
  const rect = canvas.getBoundingClientRect();
  return {
    col: Math.max(0, Math.min(COLS - 1, Math.floor((x - rect.left) * COLS / (rect.width || 1)))),
    row: Math.max(0, Math.min(ROWS - 1, Math.floor((y - rect.top) * ROWS / (rect.height || 1)))),
  };
}

透過 clientPointToCell 座標轉換,滑鼠點擊與 URL 懸停偵測能在一般縮放與 DPR 變動下精確映射至 80×24 的終端行列。

此外,當使用者開啟圖片全螢幕燈箱時,終端隱藏輸入區(#imeInput)的 keydown 處理函式會檢查燈箱開啟狀態,若為燈箱模式則呼叫 preventDefault() 並提前返回,不再向 PTT 送出按鍵碼,讓方向鍵(← / →)與 Esc 專注於燈箱圖片切換與關閉。

逆向編碼表支援 UAO 中日文與符號貼上

PTT 僅接受 Big5 位元組輸入。為了讓使用者能順利貼上剪貼簿內容或透過輸入法輸入,GhosPTT 實作了雙層編碼查表機制(src/big5.ts):

  1. 優先查詢 UAO 2.50 逆向表 - 當已載入 uao250.bin 時,系統直接透過 uaoU2B 查詢 Unicode 字元對應的 Big5 位元組。這使得貼上的文字即使包含簡體字、日文假名或特殊符號,也能正確編碼送出。
  2. 原生 Big5 查表降級 - 若未載入 UAO 表或字元不在 UAO 範圍內,則延遲初始化(lazy initialization)瀏覽器原生 TextDecoder('big5') 建立的 Big5 對應表進行補底。不在支援字集內的字元則直接忽略。
export function encodeStringToBig5(str: string): Uint8Array {
  const normalized = str.replace(/\r\n/g, '\r').replace(/\n/g, '\r');
  const out: number[] = [];

  for (const ch of normalized) {
    const cp = ch.codePointAt(0);
    if (cp === undefined) continue;
    if (cp < 0x80) {
      out.push(cp);
      continue;
    }
    if (uaoU2B && cp <= 0xffff) {
      const b5 = uaoU2B[cp];
      if (b5 > 0xff) {
        out.push((b5 >> 8) & 0xff, b5 & 0xff);
        continue;
      } else if (b5 > 0) {
        out.push(b5);
        continue;
      }
    }
    const big5 = getUnicodeToBig5Map().get(ch);
    if (big5 !== undefined) {
      out.push((big5 >> 8) & 0xff, big5 & 0xff);
    }
  }
  return new Uint8Array(out);
}

當使用者貼上或輸入字串時,系統會先將換行符號(\r\n 或 \n)依照 Telnet BBS 協定慣例正規化為單一 \r,接著將 Unicode 字元即時轉碼為 Big5 位元組送往 PTT,讓中日文與符號輸入流暢自然。

結語:以經典舞台驗證現代終端核心的整合潛力

GhosPTT 最初的原動力,是為了驗證 Mitchell Hashimoto 所設計的 libghostty 函式庫在 WebAssembly 環境下的整合能力。透過 PTT 這個充滿歷史特色、排版規則嚴苛的真實展示場景,我們實證了 libghostty-vt 作為 headless 終端核心的優勢:它能在瀏覽器中維持精確的虛擬終端狀態,並給予前端繪圖管線(WebGPU、自訂著色器、CRT 後製)高度的發揮彈性。

這項原型展示了現代終端技術跳脫單一獨立應用的可能性:不需要重新撰寫易錯的終端狀態機,就能將專業級的虛擬終端解析能力嵌入專屬的前端介面中。

如果你也對 libghostty 的跨平台能力或 WebAssembly 整合感到好奇,歡迎參考 GhosPTT 的開源實作,一同探索現代終端引擎的更多技術可能性。

相關參考資料


libghostty 1.0 與現代 C 函式庫的設計修養

從 Mitchell Hashimoto 的釋出看 ABI 穩定性、不透明控制代碼與記憶體自主權
featured.svg

近日在 Hacker News 上讀到一則熱門討論:Libghostty 1.0。Mitchell Hashimoto 正式宣布 libghostty-vt 1.0 釋出,為這個從 Ghostty 終端機中獨立出來的核心元件建立正式的 ABI 穩定保證與語意化版本承諾。

這篇公告之所以在社群引起廣泛共鳴,不僅是因為這套終端機核心已經投入大型生產環境——例如擁有超過 1,250 萬名定期活躍開發者的 JetBrains 全系列 IDE(IntelliJ、PyCharm、GoLand 等),已將內建終端機底層從 JediTerm 替換為 libghostty;Neovim 亦已有獲批准的 PR 準備取代原有的 libvterm 以改善視窗縮放重排版(reflow)支援(待正式釋出後整合);甚至 Vercel 的 Turborepo 與社群中許多代理式(agentic)程式設計工具(包括我日常在 Hyprland 下使用的多代理管理工具 herdr)都直接依賴它的核心能力。

但對系統工程師而言,更吸引人的是 Mitchell 在文中展現出對「現代 C 函式庫」的細緻雕琢:以 Zig 開發、卻以嚴謹的 C ABI 交付給全世界。

在現代軟體工程中,儘管許多新專案轉向 Rust 或 Zig 開發,C ABI 依然是跨語言、跨系統呼叫的通用語言(lingua franca)。然而,很多現代函式庫在包裝 C 介面時,往往只停留在「能用 extern "C" 呼叫就好」的粗糙層次,忽視了二進位相容性、記憶體自主權與發行版封裝的需求。

藉由 libghostty-vt 1.0 的設計哲學,我們可以具體梳理出一套現代 C 函式庫應該具備的架構修養。

architecture.svg

徹底的不透明控制代碼(Opaque Handle)

在設計 C 語言函式庫時,最常見的架構盲點,就是直接在公開標頭檔(header)中暴露 struct 的內部記憶體佈局:

// 不良設計:公開 struct 佈局
struct ghostty_terminal {
    int rows;
    int cols;
    char *cursor_style;
    // 只要未來內部新增一個欄位,sizeof(struct ghostty_terminal) 改變,
    // 若新版函式庫依新佈局存取舊版呼叫端配置的物件,可能發生記憶體越界,或以錯誤的元素間距存取陣列。
};

一旦 sizeof(struct) 在呼叫端編譯期被固定,函式庫內部若直接調整或新增欄位,就會改變結構大小與位移,進而破壞物件佈局的 ABI(Application Binary Interface)相容性。

libghostty-vt 的首要原則就是:每一個核心物件都是不透明控制代碼(Opaque Handle)。

// 現代 C 函式庫作法:ghostty/vt/types.h
typedef struct GhosttyTerminalImpl *GhosttyTerminal;

標頭檔中只宣告前置型別,實際的結構定義完全留在函式庫實作內部。宿主程式只能持有控制代碼指標,永遠無法直接解參考(dereference)結構,也無法在堆疊(stack)上為其配置空間。物件的配置與生命週期完全由建構與解構函式管控:

// ghostty/vt/terminal.h
GhosttyResult ghostty_terminal_new(
    const GhosttyAllocator *allocator,
    GhosttyTerminal *out,
    uint16_t cols,
    uint16_t rows
);
void ghostty_terminal_free(GhosttyTerminal terminal);

這項設計雖然意味著每一次實例化都必須由函式庫負責,卻換來了堅實的防護邊界:內部無論重構演算法、調整記憶體對齊(alignment),還是新增內部欄位,外部動態鏈結的程式碼都不會受到結構尺寸與佈局變化的波及。

列舉驅動的屬性存取器(Enum-Keyed Accessors)

傳統 C 函式庫常為每一個屬性定義一對專屬的 getter / setter:

// 傳統設計:符號爆炸且無法優雅處理向後相容
int ghostty_terminal_get_rows(GhosttyTerminal terminal);
int ghostty_terminal_get_cols(GhosttyTerminal terminal);
int ghostty_terminal_get_cursor_x(GhosttyTerminal terminal);
int ghostty_terminal_get_cursor_y(GhosttyTerminal terminal);
// 若新版本新增了 ghostty_terminal_get_bracketed_paste(),
// 舊版 .so 執行期直接跳出動態符號未找到(undefined symbol)!

終端機模擬器有數百種狀態值需要讀取與設定。如果每個屬性都獨立成為匯出符號(exported symbol),不僅動態鏈結時的符號表(.dynsym)龐大冗餘,更實際的問題在於:當宿主程式以較新的標頭檔編譯並實際呼叫了新增的存取函式,卻在使用者環境中鏈結到尚未包含該符號的舊版動態函式庫時,動態鏈結器就會因找不到符號而拒絕載入。

libghostty-vt 在屬性存取上採取了極具擴充性的列舉驅動存取模型(enum-keyed getters & setters):

GhosttyResult ghostty_terminal_get(
    GhosttyTerminal terminal,
    GhosttyTerminalData key,
    void *out
);

GhosttyResult ghostty_terminal_set(
    GhosttyTerminal terminal,
    GhosttyTerminalOption opt,
    const void *val
);

這種介面帶來的架構優勢非常明顯:

  1. 屬性存取介面收斂 - 雖然整個函式庫因終端機控制與繪圖支援匯出了 180+ 個函式,但數百種可讀寫屬性全數收斂至同一組列舉存取器,不會隨未來新增終端機狀態而無限膨脹符號表。
  2. 優雅的向後相容性降級 - 當新版應用程式向舊版函式庫請求一個尚未實作的列舉鍵時,函式只會明確回傳 GHOSTTY_UNSUPPORTED 狀態碼,呼叫端可以立刻以預設值回退,避免在載入期發生動態符號遺失(當然,呼叫端仍須確保傳入的 void *out 指標型別與配置空間符合該列舉鍵的規範)。
  3. 固定列舉底層寬度 - 在 C 標準中,enum 的底層整數型別大小可能因編譯器或架構最佳化而浮動(如 -fshort-enums)。libghostty-vt 以 GHOSTTY_ENUM_TYPED 巨集在 C23 與 C++11 之後直接展開為 : int 明確指定底層型別;較舊的 C 模式則在每個列舉尾端加上 INT_MAX 哨兵值,確保底層型別至少具備 int 寬度。這是在 C 標準允許範圍內能達到的最大保證,也讓 Zig 端以 c_int 對應時始終維持 ABI 一致。

對於極少數必須以結構傳遞的情境,libghostty-vt 也規範該結構必須在首個欄位攜帶自身的結構大小(size_t size,並搭配 GHOSTTY_INIT_SIZED 巨集初始化),或是預留足夠的對齊填充空間(reserved padding),讓未來的版本可以在結構尾端擴充新欄位而不破壞舊版的位移量(offset)。

記憶體自主權:不奪走宿主的配置器

許多 C/C++ 函式庫常見的限制,在於內部死板地硬編碼呼叫 malloc、free 或自行啟動背景執行緒(threads)。

這在複雜的宿主環境中常帶來整合限制:

  • WebAssembly 與嵌入式系統 - 可能根本沒有標準的作業系統堆積(heap)或執行緒執行期。
  • 高吞吐遊戲引擎或 IDE - 宿主通常具備自己的專屬記憶體池(memory arena、bump allocator 或 NUMA-aware 配置器),若第三方函式庫直接向作業系統申請記憶體,容易打亂快取並引發記憶體碎片化。
  • Rust 與 Go 等跨語言 FFI - 跨越 FFI 邊界釋放記憶體是常見的雙重釋放(double-free)與記憶體洩漏溫床。

Mitchell Hashimoto 在設計 libghostty-vt 時立下嚴謹規範:

“For resource usage, the library creates no threads, allocates only through an allocator you can customize, and uses minimal compute and memory.”

libghostty-vt 在內部不建立任何背景執行緒,也不直接處理 PTY 或畫面繪製(交由宿主負責);它是一個純粹的狀態機與資料解析引擎(同一 GhosttyTerminal 實例並非執行緒安全,需由宿主自行同步存取;且同步回呼函式內不得對同一 terminal 再次重入呼叫 ghostty_terminal_vt_write() 或 ghostty_terminal_vt_write_until())。所有需要動態記憶體的運算,全數透過呼叫端注入的兩層式 GhosttyAllocator 介面(直接對齊 Zig 的 std.mem.Allocator 模型):

// ghostty/vt/allocator.h
typedef struct {
    GhosttyAllocatorAllocFn  alloc;   // 必要:配置記憶體
    GhosttyAllocatorResizeFn resize;  // 原地調整大小,不移動位址(可為 NULL)
    GhosttyAllocatorRemapFn  remap;   // 允許移動位址的重新配置(可為 NULL)
    GhosttyAllocatorFreeFn   free;    // 必要:釋放記憶體
    void *reserved[4];                // 預留欄位,初始化時必須清零為 NULL
} GhosttyAllocatorVtable;

typedef struct {
    void *ctx;
    const GhosttyAllocatorVtable *vtable;
} GhosttyAllocator;

這套兩層式設計(ctx 承載呼叫端狀態,vtable 承載函式指標表)展現了細膩的介面契約與 ABI 考量:

  1. 共享唯讀分派表與預留槽檢查 - vtable 可以宣告為靜態唯讀(static const)共享,且尾端自帶 reserved[4] 預留槽,未來擴充新配置行為時無需更動結構大小(呼叫端初始化時必須將 reserved 清零為 NULL,debug 建置會主動檢查)。此外,函式庫不會複製 GhosttyAllocator 結構本身,因此傳入的配置器實例在相關記憶體全數釋放前必須維持有效。
  2. 長度與對齊參數對稱交還 - 函式庫在釋放(free)、原地調整(resize)或重新配置(remap)記憶體時,會將原先配置時的 alignment 與目前長度對稱傳遞給配置器,呼叫端無需在每個記憶體區塊前端額外記錄長度標頭。
  3. 支援對齊 SIMD 運算 - 介面中的 alignment 參數儲存對齊位元組數以 2 為底的對數(實際對齊要求為 1 << alignment 位元組),讓向量化操作(AVX2 / NEON)可直接取得對齊緩衝區,避免手動偏移。

當呼叫端傳入 NULL 時,函式庫會溫和地回退至預設配置器;而當宿主是 Rust 或 Zig 時,開發者可以把宿主語言的 arena allocator 或記憶體追蹤器整合進來。

當記憶體配置的主導權還給呼叫端,函式庫才能順暢地嵌入至各種執行環境(從獨立微控制器到大型 JVM 程式)。

發行版友善:嚴格的二進位封裝與 SONAME

一個只在作者本機跑得通、但 Linux 發行版維護者(distro packagers)難以依據發行版規範打包的函式庫,不能稱作優秀的函式庫。

過去許多現代開源專案只提供原始碼建置或靜態鏈結(static linking),對共享函式庫(shared library)的規格缺乏考量。但在 Debian、Arch Linux、Fedora 或 Alpine 等 Linux 生態系中,動態函式庫的升級必須遵循嚴格的系統級規範。

libghostty-vt 1.0 從發布第一天起,就遵循了完備的系統打包準則:

  1. 正確標記 SONAME 與 major version - 在 Linux 與 BSD 系統上,動態庫編譯會產生正確的 libghostty-vt.so.1,並搭配 ELF 符號版本控制檔(ghostty-vt.map)精確定義符號節點。唯有當未來發生不相容的重大 ABI 破壞時,才遞增為 .so.2。
  2. 語意化版本(SemVer)承諾 - 從 1.0 開始,任何 1.x 的次版本號更新,都保證能夠直接作為 drop-in 替換舊版二進位檔案,既有的軟體完全無需重新編譯。
  3. 標準 pkg-config 支援 - 提供標準的 libghostty-vt.pc,讓建置系統透過一行 pkg-config --cflags --libs libghostty-vt 就能準確取得標頭路徑與鏈結旗標。
  4. 自動化 ABI 破壞檢驗 - 在 CI 流程中針對 Linux/ELF 整合 libabigail 工具(abidiff)進行基準比對(1.0 釋出時基準檔尚未提交,CI 先將 dump 存成 artifact,待 vt-1.0.0 標記後正式轉為自動比對;macOS / WASM 另以專屬型別 manifest 把關),將人為疏失阻絕在合併之前。

在 macOS 上,它也同步寫入標準的相容性版本(compatibility version)資訊,確保無論在何種作業系統上,包裝工具都能自動化識別相依關係。

建置工具鏈與 ABI 驗證

許多人好奇:既然核心是用 Zig 寫的,要產出上述高規格的 C 函式庫,是不是只要執行一行 zig build-lib 就夠了?

檢視 Ghostty 儲存庫(以下分析基於儲存庫 commit 0fa3d57)中的 build.zig 與 src/build/ 目錄後會發現,為了將 Zig 模組封裝成符合各作業系統慣例的原生 C 產物,libghostty-vt 實作了一套完整的建置與驗證管線。

build-pipeline.svg

雙模式單一儲存庫與預設零 C++ 執行期依賴

Ghostty 終端機應用程式與 libghostty-vt 函式庫共用同一個單一儲存庫(monorepo),但擁有各自獨立的版本號與建置圖(build graph)。build.zig 開頭就把兩個版本分開宣告:

/// The libghostty-vt version, released separately from the app with
/// `vt-X.Y.Z` tags. See `Config.libVersion` for how builds use it.
///
/// CMakeLists.txt and CI read this line with a regular expression, so
/// keep it on one line in this form.
const lib_version = "1.0.0-dev";

/// Minimum required zig version.
const minimum_zig_version = @import("build.zig.zon").minimum_zig_version;

comptime {
    buildpkg.requireZig(minimum_zig_version);
}

comptime 區塊會在建置腳本編譯期就檢查 Zig 版本,版本不符直接停止,避免打包者用錯編譯器卻得到難以理解的錯誤訊息。

是否進入函式庫模式,則由 src/build/Config.zig 決定:

// Default dependency builds to libghostty-vt-only mode. Consumers can
// still explicitly disable this to request the full Ghostty build.
const is_dep = b.dep_prefix.len > 0;
const emit_lib_vt = b.option(
    bool,
    "emit-lib-vt",
    "Build and install libghostty-vt only. Disables the xcframework, " ++
        "macOS app, and docs by default. Without this, libghostty-vt " ++
        "is not installed unless Ghostty is a dependency.",
) orelse (is_dep or
    // A libghostty-vt source tarball has LIB_VERSION but no VERSION.
    (versions.lib_file != null and versions.app_file == null));

也就是說,當 Ghostty 作為其他 Zig 專案的相依套件(is_dep),或根目錄存在 LIB_VERSION 但沒有應用程式的 VERSION(即官方 libghostty-vt 源碼包的結構),抑或顯式傳入 -Demit-lib-vt 時,build.zig 就會切換至函式庫模式,略過 GUI 資源、字型、佈景主題與多語系翻譯的下載與編譯:

// ...
// Only the app uses them, so a libghostty-vt build skips them, which
// also keeps it from downloading the themes package.
const resources: ?buildpkg.GhosttyResources = if (config.emit_lib_vt)
    null
else
    try buildpkg.GhosttyResources.init(b, &config, &deps);

更值得留意的是它對 C++ 第三方加速庫的預設處理方式。libghostty-vt 內部依賴 simdutf 與 Google Highway(兩者皆為 C++ 專案)來進行高速 UTF-8 驗證與 SIMD 解析。若是一般建置方式,這會導致最終產出的 C 函式庫在執行期被迫連帶依賴 libc++ / libstdc++。為了在預設內嵌(vendored)建置下消除這個額外負擔,pkg/simdutf/build.zig 在編譯期傳入了關鍵旗標:

// Only linked statically into libghostty-vt and the app: export nothing.
try flags.append(b.allocator, "-fvisibility=hidden");

if (no_libcxx) {
    try flags.append(b.allocator, "-DSIMDUTF_NO_LIBCXX");
    try flags.append(b.allocator, "-fno-exceptions");
    try flags.append(b.allocator, "-fno-rtti");
    // ...
    lib.root_module.addCMacro("SIMDUTF_NO_LIBCXX", "1");
}

透過關閉 C++ 例外處理、RTTI 與標準庫依賴,同時以 -fvisibility=hidden 隱藏所有內部符號,libghostty-vt 在預設建置下借用了 C++ 標頭檔與模板能力,卻在執行期剝離了對 libc++ 的依賴,讓預設產物維持單純的 libc 相依性。

若打包者改用系統函式庫,.pc 檔會依選項在 Requires.private 列入 simdutf 或 libhwy;當模組需要連結 C++ 標準庫時(例如啟用系統 simdutf),則另於 Libs.private 加入 -lc++:

/// Returns the Libs.private value for the pkg-config file.
/// Vendored C++ dependencies are built in no-libcxx mode so consumers
/// don't need libc++.  System-provided simdutf still requires it.
fn libsPrivate(
    zig: *const GhosttyZig,
) []const u8 {
    return if (zig.vt_c.link_libcpp orelse false) "-lc++" else "";
}

fn requiresPrivate(b: *std.Build) []const u8 {
    const system_simdutf = b.systemIntegrationOption("simdutf", .{});
    const system_highway = b.systemIntegrationOption("highway", .{ .default = false });

    if (system_simdutf and system_highway) return "simdutf, libhwy";
    if (system_simdutf) return "simdutf";
    if (system_highway) return "libhwy";
    return "";
}

跨平台連結器客製與二進位修補

單靠 Zig 內建的連結器,還不足以處理所有平台在打包與效能上的邊角細節。src/build/GhosttyLibVt.zig 針對四條建置路徑分別實作了專屬的底層處理。

Linux / ELF:強制切換 LLD 執行符號隔離

ELF 平台的符號匯出由 ghostty-vt.map 這份 version script 控制。它只匯出 ghostty_*,其餘符號(simdutf、highway、wuffs、C++ runtime 與 compiler-rt)一律設為 local,避免與宿主程式內的同名函式庫衝突:

GHOSTTY_VT_1.0 {
    global:
        ghostty_*;
    local:
        *;
};

未來每個次版本只需新增一個節點,列出該版本新增的函式並串接到前一版。程式若用到 1.1 才有的函式,載入 1.0 的函式庫時,動態載入器會直接以版本名稱指出問題。

套用這份腳本的建置設定如下:

if (kind == .shared and target.result.ofmt == .elf) {
    // Export only the ghostty_* functions and tag them with a version
    // that the loader checks. The map file explains the versioning.
    lib.version_script = b.path("src/terminal/c/ghostty-vt.map");

    // Zig's own ELF linker, which Debug builds on x86_64 use by
    // default, silently ignores the version script. LLD applies it,
    // and Zig can only link with LLD when it compiles with LLVM.
    lib.use_llvm = true;
    lib.use_lld = true;
}

註解直接點出了原因:Zig 自帶的 ELF 連結器在 x86_64 debug 建置下會默默忽略 version script,強制走 LLVM LLD 才能確保開發期與發行期的符號表行為一致。

靜態庫:PIC 與胖靜態庫合成

靜態庫同樣有幾項容易被忽略的細節:必須內含 compiler-rt,並且要開啟 PIC 才能被連結進現代 Linux 發行版預設的 PIE 執行檔:

if (kind == .static) {
    // ...
    // These must be bundled since we're compiling into a static lib.
    // Otherwise, you get undefined symbol errors.
    lib.bundle_compiler_rt = true;
    lib.bundle_ubsan_rt = true;

    // Enable PIC so the static library can be linked into PIE
    // executables, which is the default on most Linux distributions.
    lib.root_module.pic = target.result.os.tag != .freestanding;
}

為了不讓下游使用者還得手動尋找並連結內嵌的 libsimdutf.a 與 libhighway.a,專案編寫了跨平台工具 combine_archives.zig。它在建置期生成 MRI 腳本,透過標準輸入管線餵給 zig ar -M,將多個靜態封存檔合併為單一的胖靜態庫(fat archive):

// Build the MRI script.
var script: std.ArrayList(u8) = .empty;
try script.appendSlice(alloc, "CREATE ");
try script.appendSlice(alloc, output_path);
try script.append(alloc, '\n');
for (inputs) |input| {
    try script.appendSlice(alloc, "ADDLIB ");
    try script.appendSlice(alloc, input);
    try script.append(alloc, '\n');
}
try script.appendSlice(alloc, "SAVE\nEND\n");

var child = try std.process.spawn(init.io, .{
    .argv = &.{ zig_exe, "ar", "-M" },
    .stdin = .pipe,
    .stdout = .inherit,
    .stderr = .inherit,
});

之所以要另寫一支 Zig 程式,而不是直接在建置步驟裡寫 /bin/sh -c "ar -M < script",是因為 Windows 上沒有 /bin/sh。用 Zig 自己處理管線,才能讓同一份建置腳本在所有主機上都能執行。

macOS:原生 ld64 連結與 libSystem 符號降級

在 macOS 主機上,Zig 內建連結器無法設定 Mach-O 的 compatibility version(永遠寫入 1.0.0),因此 initLibApple 改為先建出靜態庫,再呼叫 Apple 原生連結器產出動態庫:

native_link.addArgs(&.{
    // Emit a Mach-O dynamic library instead of an executable.
    "-dynamiclib",
    // Pull every object from the combined static archive.
    "-Wl,-all_load",
    // Remove unreachable private code pulled in by all_load.
    "-Wl,-dead_strip",
    // Leave room for packaging tools to rewrite install names.
    "-Wl,-headerpad_max_install_names",
});
// ...
// Restrict exports to libghostty-vt's public C ABI.
native_link.addPrefixedFileArg("-Wl,-exported_symbols_list,", exports);
// Link the archive produced by the normal static-library path.
native_link.addFileArg(static.output);
// ...
native_link.addArgs(&.{
    // The name programs record and load at run time, found via @rpath.
    "-install_name",
    shared_version.install_name,
    // The full library version.
    "-current_version",
    shared_version.current_version,
    // The oldest release with the same ABI.
    "-compatibility_version",
    shared_version.compatibility_version,
    // Write the fully versioned dylib used by the install symlinks.
    "-o",
});

另一個問題則與效能有關。Zig 的靜態庫會打包 compiler_rt.o,其中包含強符號(strong global)的 memcpy、memmove 與數學函式。當宿主程式連結該靜態庫時,這些通用實作會覆蓋掉 Apple libSystem 針對硬體最佳化的版本,實測造成數個百分比的 PTY 吞吐量下滑。

libsystem_override.sh(需在 Darwin host 上針對 Darwin target 執行)搭配一份明確列舉 libSystem 穩定匯出符號的清單,把清單內的符號改為 local,同時保留清單以外的 compiler-rt 全域定義(例如 128 位元浮點轉換與 sincos* 等 Apple 未匯出的 intrinsics):

xcrun ar x "$out" compiler_rt.o
chmod 644 compiler_rt.o

# nmedit takes a keep-list (-s): all current global definitions except
# the ones we want to localize.
xcrun nm -g compiler_rt.o | awk '$2 ~ /^[A-TV-Z]$/ {print $3}' | sort -u >all.txt
sort -u localize.txt >loc.txt
comm -23 all.txt loc.txt >keep.txt
xcrun nmedit -s keep.txt compiler_rt.o

xcrun ar r "$out" compiler_rt.o

WebAssembly:可成長的函式表

WebAssembly 版本是一個沒有進入點的執行檔模組,建置設定也各有考量:

// Allow exported symbols to actually be exported.
exe.rdynamic = true;

// Export the indirect function table so that embedders (e.g. JS in
// a browser) can insert callback entries for terminal effects.
exe.export_table = true;

// There is no entrypoint for this wasm module.
exe.entry = .disabled;

// Default Zig stack size in Zig 0.16 is 1MB. Lower to 128 KB since
// this has to be preallocated up front in Wasm linear memory. Peak
// stack under various artificial loads is 17 KB at the time of this
// comment but lets do 128KB to be safe. We can lower later.
exe.stack_size = 128 * 1024;

JavaScript 宿主需要在執行期動態註冊回呼函式(寫入 __indirect_function_table),但 Zig 目前的 WASM 連結器不支援 --growable-table,匯出的函式表上限被鎖死(max == min)。建置系統因此在連結後再跑一支 wasm_patch_growable_table.zig,定位至 Section 4(Table Section),把限制旗標從 0x01(有上限)改為 0x00(無上限),同時剝除原有的 LEB128 max 欄位並重新計算 section 長度:

// Build the new section payload with the max removed:
//   [table count + elem_type] [flags=0x00] [min] [trailing data]
var payload: std.Io.Writer.Allocating = .init(alloc);
try payload.writer.writeAll(input[section_start..flags_pos]);
try payload.writer.writeByte(0x00); // flags: no max
try payload.writer.writeAll(input[min_start..max_start]);
try payload.writer.writeAll(input[max_end..section_end]);

// Reassemble the full binary:
//   [everything before this section] [section id] [new size] [new payload] [everything after]
const before_section = input[0 .. section_start - 1 - uleb128Size(section_size)];
var result: std.Io.Writer.Allocating = .init(alloc);
try result.writer.writeAll(before_section);
try result.writer.writeByte(4); // table section id
try result.writer.writeUleb128(@as(u32, @intCast(payload.written().len)));
try result.writer.writeAll(payload.written());
try result.writer.writeAll(input[section_end..]);
return result.written();

三層自動化 ABI 與標頭檔防禦管線

為了針對已建模的公開型別佈局、匯出符號與函式簽章進行自動化把關,libghostty-vt 建立了三層驗證機制。

第一層:編譯期 translate-c 雙向反射測試

測試套件(abi_test.zig)利用 Zig 的 translate-c 將公開標頭檔 <ghostty/vt.h> 轉換為 Zig 模組,並在編譯期透過 @typeInfo 反射,逐一比對每一個 C struct、union、enum 與回呼函式指標跟 Zig 內部 extern 型別的大小、對齊(alignment)、欄位位移(offset)及列舉值。只要在標頭檔新增型別卻忘記註冊,或兩邊欄位順序不一致,測試就會報錯,例如:

GHOSTTY_CELL_WIDE_WIDE: the header says 5, Zig says 1

此外,build.zig 會以三種語言標準搭配最嚴格的警告設定,編譯一個只 include vt.h 的檔案,確保公開標頭檔在各標準下都零警告:

const warnings: []const []const u8 = &.{
    "-Wall",
    "-Wextra",
    "-Wpedantic",
    "-Werror",
};
const Case = struct { std: []const u8, src: std.Build.LazyPath };
const cases: []const Case = &.{
    .{ .std = "c11", .src = c_src },
    .{ .std = "c23", .src = c_src },
    .{ .std = "c++17", .src = cpp_src },
};

第二層:跨平台二進位內嵌 JSON manifest 檢驗

函式庫在唯讀資料段中內嵌了一份由編譯期反射生成的 NUL 結尾 JSON 型別清單。檢驗工具(src/terminal/c/abi/check.zig)完全不需要執行目標二進位檔,而是直接解析 ELF、Mach-O 或 WASM 結構中的匯出符號表與內嵌 JSON,因此能檢驗交叉編譯出的產物。黃金基準檔依目標平台命名,並以建置步驟的形式掛進 zig build:

// Goldens are named after the target, such as x86_64-linux-gnu. Targets
// with no ABI leave it out, such as aarch64-macos.
const arch_os = b.fmt("{t}-{t}", .{ target.cpu.arch, target.os.tag });
const golden = if (target.abi == .none)
    b.fmt("src/terminal/c/abi/{s}.json", .{arch_os})
else
    b.fmt("src/terminal/c/abi/{s}-{t}.json", .{ arch_os, target.abi });

check.zig 的檔頭說明列出了日常使用的指令。前兩行檢查目前變更是否只新增了 ABI,確認是刻意的新增後,再用第三行更新該平台的基準檔:

zig build test-lib-vt-abi
zig build test-lib-vt-abi -Dtarget=wasm32-freestanding
zig build update-lib-vt-abi -Dtarget=x86_64-linux-gnu

唯有符合規則的擴充(新增型別、新增列舉值、列舉值新增 key 型別、在 sized struct 尾端新增欄位,或已登記於 symbols.txt 的新函式)才會通過。若有人對調了兩個列舉值,檢查會逐行列出:

GhosttyCursorStyle.values.BAR: golden 0, now 1
GhosttyCursorStyle.values.BLOCK: golden 1, now 0

第三層:C 探針庫與 libabigail 深度比對

在 Linux CI 中,直接用 abidiff 分析 Zig 編譯出的 .so 會遇到障礙:Zig 在 DWARF 除錯資訊中記錄的是 Zig 內部命名空間(例如 terminal.c.terminal.new)且未標記為外部符號,導致 libabigail 看不到公開函式的型別。test/abi/check.sh 因此讀取公開標頭檔與符號表,動態生成一個 C 語言「探針函式庫(probe library)」:

#include <ghostty/vt.h>

__typeof__(ghostty_terminal_new) *const abi_ghostty_terminal_new = &ghostty_terminal_new;
__typeof__(ghostty_terminal_resize) *const abi_ghostty_terminal_resize = &ghostty_terminal_resize;
// ...and so on for every function.

這個探針庫由標準 C 編譯器加上 -g 編譯並連結 libghostty-vt.so,再交由 abidiff 與 baseline.abi(搭配 libghostty-vt.suppr 抑制規則)進行兩階段比對:第一輪列出所有差異,第二輪過濾純新增項目。

在日常本機開發中,開發者可以生成當前的 ABI dump,或與官方基準檔比對(注意:baseline.abi 會在 vt-1.0.0 釋出並打上 tag 後由 CI artifact 提交至儲存庫;在此之前本機比對需先透過 CI 下載或本機生成基準檔):

# 編譯函式庫
nix develop -c zig build -Demit-lib-vt -Doptimize=ReleaseFast -Dcpu=baseline

# 本機生成當前 ABI dump(格式與 CI artifact 一致)
nix develop -c test/abi/check.sh dump test/abi/baseline.abi zig-out/lib/libghostty-vt.so

# 若基準檔已存在,比對 ABI 差異
nix develop -c test/abi/check.sh diff test/abi/baseline.abi zig-out/lib/libghostty-vt.so

若某個函式被移除,報告會長這樣:

1 Removed variable:

  [D] 'GhosttyResult (* const abi_ghostty_terminal_resize)(GhosttyTerminal, uint16_t, uint16_t, uint32_t, uint32_t)'    {abi_ghostty_terminal_resize}
error: incompatible ABI change (abidiff exit status 12 without additions)

下游生態系橋接與離線供應鏈支援

即便底層使用 Zig 建置,libghostty-vt 依然把下游整合與 Linux 發行版打包體驗照顧得很周到。

pkg-config 與 CMake 雙軌支援

zig build 安裝時會同時產出動態與靜態兩個 pkg-config 模組。之所以要分成兩個,是因為 pkg-config --static 會納入私有連結相依(Libs.private 與 Requires.private),但不會強制將 -lghostty-vt 解析為 .a 封存檔:

cc -o myapp main.c $(pkg-config --cflags --libs libghostty-vt)

靜態連結則改用 libghostty-vt-static 模組:

cc -o myapp main.c $(pkg-config --static --cflags --libs libghostty-vt-static)

即便建置主機上沒有安裝 CMake,zig build 也會透過自寫的 cmake_package_config.zig 生成 ghostty-vt-config.cmake 與版本檢查檔,因此已安裝的函式庫可以直接用 find_package 找到:

find_package(ghostty-vt 1.0 REQUIRED)
target_link_libraries(myapp PRIVATE ghostty-vt::ghostty-vt)

自動生成的版本檔遵循 SemVer 的 ABI 規則(1.x 只接受相同 major 版本),還會拒絕指標寬度不同的安裝,避免 64 位元專案誤找 32 位元函式庫。這段 CMake 檢查檔是由 GhosttyLibVt.zig 裡的 Zig 多行字串模板動態產生,{[ptr_size]d} 會在建置時自動填入目標平台的指標位元組數:

\\# Skip the pointer size check if either side doesn't know its own.
\\if("${{CMAKE_SIZEOF_VOID_P}}" STREQUAL "")
\\  return()
\\endif()
\\
\\# Reject an install built for a different pointer size, for example
\\# a 32-bit library found by a 64-bit project.
\\if(NOT CMAKE_SIZEOF_VOID_P STREQUAL "{[ptr_size]d}")
\\  math(EXPR installedBits "{[ptr_size]d} * 8")
\\  set(PACKAGE_VERSION "${{PACKAGE_VERSION}} (${{installedBits}}bit)")
\\  set(PACKAGE_VERSION_UNSUITABLE TRUE)
\\endif()

儲存庫頂層也提供了 CMakeLists.txt 委派包裝器,讓 C/C++ 專案以 FetchContent 直接引入原始碼(主機上仍需安裝 zig),並依需求擇一連結動態或靜態目標:

include(FetchContent)
FetchContent_Declare(ghostty
    GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git
    GIT_TAG main
)
FetchContent_MakeAvailable(ghostty)

# 依需求擇一連結:
target_link_libraries(myapp PRIVATE ghostty-vt)        # shared
# target_link_libraries(myapp PRIVATE ghostty-vt-static) # static

需要交叉編譯時,在 FetchContent_MakeAvailable 之後呼叫 ghostty_vt_add_target(),它會建立帶有平台後綴的匯入目標:

ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu)

# 依需求擇一連結:
target_link_libraries(myapp PRIVATE ghostty-vt-linux-amd64)        # shared
# target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64) # static

包裝器內部會把 CMake 的建置類型轉成 Zig 的最佳化等級,加入 GHOSTTY_ZIG_BUILD_FLAGS 後呼叫 zig build -Demit-lib-vt(以下為核心邏輯節錄):

if(CMAKE_BUILD_TYPE)
    string(TOUPPER "${CMAKE_BUILD_TYPE}" _bt)
    if(_bt STREQUAL "RELEASE" OR _bt STREQUAL "MINSIZEREL" OR _bt STREQUAL "RELWITHDEBINFO")
        set(_GHOSTTY_ZIG_OPT_FLAG "-Doptimize=ReleaseFast")
    endif()
    unset(_bt)
endif()

if(_GHOSTTY_ZIG_OPT_FLAG)
    list(APPEND GHOSTTY_ZIG_BUILD_FLAGS "${_GHOSTTY_ZIG_OPT_FLAG}")
endif()

# ...

add_custom_command(
    OUTPUT "${GHOSTTY_VT_SHARED_LIBRARY}" "${GHOSTTY_VT_STATIC_LIBRARY}" "${ZIG_OUT_DIR}/lib/${GHOSTTY_VT_IMPLIB}"
    COMMAND "${ZIG_EXECUTABLE}" build -Demit-lib-vt ${GHOSTTY_ZIG_BUILD_FLAGS}
    WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
    COMMENT "Building libghostty-vt via zig build..."
    USES_TERMINAL
)

成對的離線相依套件包

各大 Linux 發行版(如 Debian、Fedora、Arch 與 Nix)打包時,通常要求在無網路沙盒(hermetic build)中執行。zig build dist-deps -Demit-lib-vt 會把建置所需的 Zig 相依套件打包成結構與 Zig 全域快取一致的 libghostty-vt-VERSION-deps.tar.gz,再由正式發布流程(release workflow)統一以 minisign 產生數位簽章。

PACKAGING-LIBGHOSTTY.md 給出的標準打包流程分成幾步。首先驗證簽章:

minisign -V -P RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV \
  -m libghostty-vt-1.0.0.tar.gz

接著把同一版本的源碼包與相依套件包並排解開:

tar -xzf libghostty-vt-1.0.0.tar.gz
tar -xzf libghostty-vt-1.0.0-deps.tar.gz
cd libghostty-vt-1.0.0

然後以相依套件包作為 --system 目錄完全離線建置(ZIG_GLOBAL_CACHE_DIR 必須是全新的空目錄):

DESTDIR=/tmp/pkg \
ZIG_GLOBAL_CACHE_DIR=/tmp/build-cache \
zig build \
  --prefix /usr \
  --system ../libghostty-vt-1.0.0-deps \
  -Doptimize=ReleaseFast \
  -Dcpu=baseline

測試同樣可以離線執行:

ZIG_GLOBAL_CACHE_DIR=/tmp/build-cache \
zig build test-lib-vt \
  --system ../libghostty-vt-1.0.0-deps

--system 會停用所有網路下載,-Dcpu=baseline 則避免編出目標機器不一定支援的新指令集。若發行版把函式庫放在 /usr/lib64,再加上 --prefix-lib-dir lib64 即可,pkg-config 與 CMake 設定檔會一併指向新位置。

自動化建置圖第三方授權稽核

為了協助發行版維護者撰寫授權聲明(如 Fedora 的 bundled() 或 Debian 的 copyright),專案在各個 build.zig.zon 中擴充了自訂的 .third_party 欄位。Zig 會忽略不認得的欄位,因此這項擴充不影響正常建置:

.dependencies = .{
    .wuffs = .{ .url = "...", .hash = "..." },
    .pixels = .{ .url = "...", .hash = "..." },
},
.third_party = .{
    .{ .name = "pixels", .license = "CC0-1.0" },
    .{ .name = "wuffs", .license = "Apache-2.0 OR MIT", .version = "0.4.0-alpha.10" },
},

這項稽核是由兩個階段的工具鏈共同完成:

  1. 第一階段:建置圖靜態追蹤 - 執行 zig build lib-vt-packages -Demit-lib-vt 時,LibVtPackages.zig 會走訪(walk)vt_c 根模組的所有 import 與連結物件,保守估計並記錄各相依套件中依建置圖判定可能納入編譯的路徑(linked_paths),匯出單一目標的 lib-vt-packages.json。
  2. 第二階段:跨平台彙整與分類 - 發布輔助腳本 nix/build-support/gen-lib-vt-deps.sh 會在各個支援的目標架構上執行此步驟,彙整各目標產生的清單,並依建置圖資訊標記 "use": "linked" 或 "use": "build"(此分類仍保留前述保守估計的限制;節錄自 deps/libghostty-vt-third-party.json,省略 kind、commit 等內部欄位):
{
  "name": "highway",
  "version": "1.2.0",
  "url": "https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz",
  "hash": "N-V-__8AAGmZhABbsPJLfbqrh6JTHsXhY6qCaLAQyx25e0XE",
  "license": "Apache-2.0 OR BSD-3-Clause",
  "use": "linked"
}

執行期依賴最小化與單檔範例(Ghostling)

最後一項常被現代軟體開發忽視的特質,是執行期依賴最小化與直觀的最小可用範例。

在預設建置下,libghostty-vt 於執行期僅依賴標準 libc;所有 Unicode 處理、SIMD 加速與圖像解碼(simdutf、Highway、Wuffs 等第三方程式庫)預設皆以原始碼形式內嵌編譯,避免下游應用程式陷入繁雜的動態相依衝突(同時保留 -fsys= 選項供系統發行版按需改用系統套件)。

為了讓所有打算整合的工程師迅速理解其運作方式,官方專案同步提供了一個名為 Ghostling 的開源範例。官方範例僅用一個 C 語言檔案(約 1,600 行,搭配開源輕量級繪圖庫 raylib),就組裝出了一個具備完整打字、畫面滾動、顏色渲染與 VT 序列支援的極簡終端機。

這種「單檔即可完整看清生命週期與運作骨架」的設計方式,讓整合者能迅速摸清函式庫的呼叫紋理,大幅降低了採用門檻。

結語:新世代系統函式庫的成熟標準

回顧 Mitchell Hashimoto 這篇 1.0 宣告,它給技術社群帶來的啟發超越了「又出現了一個厲害的終端機核心」。

近年來軟體界常見的論調是「C 語言已經過時,所有東西都該用新語言重寫」。然而,現實世界的工程架構是由複雜的跨語言系統交織而成的。Zig、Rust 等新興語言確實賦予了我們更安全、更高產能的實作手段,但當這些系統核心需要走向世界、成為無數生態系的共同基石時,最可靠的載體依然是經過深思熟慮的 C ABI。

libghostty-vt 展現了一條成熟的工程路徑:

  • 用現代語言實作核心 - 享受 Zig 帶來的效能、型別安全與編譯期優勢。
  • 用嚴謹的 C ABI 面向世界 - 以不透明控制代碼、列舉存取器、配置器注入與語意化 SONAME,保護每一個下游使用者的穩定性。

無論未來你打算用 C、C++、Rust 還是 Zig 開發系統元件,libghostty-vt 的這些架構取捨,都是極具參考價值的現代範本。


OpenSSH on musl 的 *sshole 認證繞過漏洞

當防計時攻擊遇上 256 位元組限制:一場跨越 46 年的歷史交會
featured.svg

今天在 Hacker News 上看到一個標題只有一個字(還帶著一顆星號)的項目:*sshole。點進去閱讀 sshhole.com 與作者公開的技術細節,這場跨越數十年的軟體架構演進與邊界條件重疊,相當值得深入探討。

這項漏洞的運作機制十分特殊:在基於 musl libc 且未啟用 PAM 的 Linux 系統(例如 Alpine Linux、OpenWrt、以及 Buildroot 與 Yocto 建置環境)上,當系統開啟密碼認證且 pick_salt() 選中 SHA-crypt salt 時,若目標帳號在 /etc/shadow 中的密碼欄位被設定為 *(原本用意是停用密碼認證),攻擊者輸入長度介於 257 至 1024 位元組的密碼,便可能成功通過密碼雜湊比對。

在實際影響層面上,能否完成登入仍取決於 OpenSSH 其他存取控制與認證要求:

  • 對於 root 帳號,OpenSSH 預設的 PermitRootLogin prohibit-password 會阻斷密碼認證,因此只有在管理者明確設定 PermitRootLogin yes 時才會直接暴露 root 登入。
  • 但對於一般系統帳號(例如僅允許 SSH 金鑰登入的部署帳號、LDAP 整合帳號,或是 Alpine 雲端映像預設的 alpine 帳號),只要密碼欄位為 * 且無其他認證方法限制(如 AuthenticationMethods),便能通過密碼驗證登入。而在許多雲端映像中,預設使用者通常具備免密碼 doas 提權權限,攻擊者登入後即可取得完整系統控制權。

更具工程啟發性的是背後的成因:這不是緩衝區溢位或記憶體損壞,而是多個看似合理、各自出於防禦考量的設計,在歷經 46 年的軟體演進後,於特定條件下產生了預期之外的行為碰撞。

六行虛擬碼說明比對邏輯

作者在專案的說明文件中以六行虛擬碼總結了認證比對的流程:

pw_password = "*"                          # 目標帳號在 shadow 裡為 "*",代表不接受密碼認證
salt = NULL                                # "*" 長度過短,無法作為合法的 salt
salt = pick_salt()                         # OpenSSH 為防計時攻擊,借用有效帳號 bob 的 "$6$abc$"
hash = crypt("AAAA…x300", "$6$abc$")       # musl SHA-crypt 遇長度 > 256 位元組失敗,回傳 "*"
strcmp(hash, pw_password) == 0             # strcmp("*", "*") == 0 -> 比對吻合!
return 1                                   # 通過認證

當比對邏輯走到 strcmp("*", "*") == 0 時,認證便宣告通過。

原本在 Unix 傳統中用來標記「不接受密碼認證」的特殊字元 *(此處為字面值字串,並非萬用字元 wildcard),恰好與 musl libc 在密碼過長時回傳的錯誤替代值 * 完全一致。

以下是該漏洞觸發流程的結構圖:

collision-flow.svg

跨越 46 年的歷史交會

要理解這個現象為何會發生,需要回顧幾個關鍵的歷史節點。

1980 年:第一顆星號(The First Star)

在早期 Unix 時代,使用者密碼的雜湊值直接儲存在所有使用者皆可讀取的 /etc/passwd 中。

1980 年,Bill Joy 在 3BSD 的 /etc/passwd 裡,為系統內建的 daemon 帳號填入了 * 作為密碼欄位。

由於當時 Unix 使用的 DES 雜湊格式固定產出長度為 13 的特定字元集字串,單一字元的 * 絕不可能對應到任何合法的密碼輸入。自此,「在密碼欄位填入 * 代表此帳號不接受密碼登入」成為 Unix 與 Linux 系統廣泛採納的慣例。

1990 年代:OpenSSH 的字串比對與 POSIX 規範

到了 1990 年代,密碼雜湊被移至僅限管理者讀取的 /etc/shadow。當使用者透過 SSH 嘗試密碼登入時,OpenSSH 會呼叫系統提供的 crypt(3) 函式:

encrypted_password = crypt(password, shadow_hash);
if (strcmp(encrypted_password, shadow_hash) == 0) {
    // 登入成功
}

若目標帳號的 shadow_hash 是 *,正常計算出來的雜湊字串(例如現代常用的 SHA-512 雜湊 $6$...)絕不可能等於單一字元 *,比對自然失敗。

但 POSIX 標準(The Single UNIX Specification)對 crypt() 規範了錯誤處理:若 crypt() 無法執行(例如傳入的 salt 指定了不支援的演算法),必須回傳空指標(NULL)。

歷史上部分應用程式在呼叫 crypt() 後未檢查是否為 NULL,便直接將指標傳入 strcmp(),導致空指標解參考引發程式異常終止(segfault)。

2012 年:musl 決定讓 crypt 不再回傳 NULL

輕量化 C 函式庫 musl 的維護者在 2012 年 8 月 9 日提交了一筆修補(commit b3c4cc12)。

為避免呼叫端因未檢查 NULL 而崩潰,musl 決定在 crypt() 發生錯誤時回傳一個特殊字串,而非 NULL。在當時的 DES 實作中,選用的特殊字串為 "*"。

 Note

musl 開發者當時也考慮過安全性。在該次 commit 針對 DES 的實作中加入了一道保護:若傳入的 salt 開頭正是 *,為了避免與目標欄位比對成功,函式會改為回傳 "x":

return (setting[0]=='*') ? "x" : "*";

這項防護建立在一個前提之上:比對用的目標雜湊與傳給 crypt() 的 salt 來自同一個來源。

2012 年(20 天後):musl SHA-crypt 加上 256 位元組上限

在改用特殊錯誤回傳值的 20 天後(2012 年 8 月 29 日,commit 88bf5a8a),musl 新增了 SHA-256 與 SHA-512 的雜湊支援。

由於 SHA-crypt 包含多輪運算,為避免攻擊者藉由超長密碼耗盡伺服器 CPU 資源引發阻斷服務(hash DoS),內部實作加上了長度檢查:

/* 等價示意:musl 內部 sha512crypt helper */
if (strlen(key) > 256) return 0;

當密碼長度超過 256 位元組時,內部的 sha512crypt() 會放棄計算並回傳失敗(0);外層的 __crypt_sha512() 接到失敗後,便遵循 musl 的錯誤慣例,將回傳結果設為 "*",最後由公開的 crypt() 傳回給呼叫端。

此項 256 位元組長度上限是 musl SHA-crypt 的專屬實作,並非所有 libc 或所有雜湊演算法皆有相同限制。

2016 年:OpenSSH 修補計時攻擊(借用鄰居的 salt)

2016 年 7 月,OpenSSH 針對帳號列舉問題釋出了安全性修補(CVE-2016-6210)。

在修補前,OpenSSH 面對不存在的帳號時使用的是帶有 Blowfish 前綴的偽造密碼資料;若系統不支援該演算法,驗證會迅速失敗,而真實存在的帳號則需耗費時間計算 SHA-512 雜湊。攻擊者可藉由失敗路徑與計算延遲的差異,推測帳號是否存在。

OpenSSH 的修補邏輯為:若使用者不存在,或目標帳號的密碼欄位沒有合法的 salt(例如欄位僅為 *),sshd 會透過 pick_salt() 從系統中已有的有效使用者裡搜尋並快取一組可用的 salt(例如其他帳號的 SHA-512 salt),藉此完成一次標準雜湊計算,平衡各路徑的處理時間:

salt = pick_salt(); // 取得有效帳號的 "$6$xyz$..."
encrypted_password = crypt(password, salt);
return strcmp(encrypted_password, pw_password) == 0; // pw_password 依然是目標帳號的 "*"

此設計的隱含假設是:傳入合法的 SHA-512 salt 後,crypt() 計算產生的字串格式必然是以 $6$ 開頭的完整雜湊,絕不可能與只有單一字元的 * 吻合。

兩項各自合理的假設在此處出現分歧:

  1. musl 認為錯誤時回傳 * 不會造成威脅,因為如果 salt 本身以 * 開頭,它會改傳 "x";但未預料到 OpenSSH 傳入的 salt 其實是借來的 $6$...。
  2. OpenSSH 認為給定 $6$ 開頭的 salt,crypt() 的輸出必然是以 $6$ 開頭的雜湊;但未預料到 musl 的 SHA-crypt 在密碼長度超過 256 位元組時會直接中斷並回傳 *。

2021 至 2022 年:Alpine Linux 的 root 密碼設定變更

若系統中沒有密碼欄位為 * 的帳號,該路徑不會被觸發。然而 Alpine Linux 的歷史變更使得 * 帳號在多數環境中普遍存在。

2019 年,Alpine 的 Docker 官方映像曾出現 root 密碼為空的情況(CVE-2019-5021),隨後官方將 root 密碼欄位改為 ! 進行鎖定。

然而在未啟用 PAM 的環境中,shadow 欄位為 ! 不僅會停用密碼,也會導致 SSH key 認證遭系統拒絕。對於依賴預先注入金鑰開機的雲端主機而言,這項限制影響了正常運作。

為了讓帳號在不接受密碼認證的同時仍能正常使用 SSH 金鑰登入,Alpine 在 2021 年 12 月(aports commit fbe926af,出貨於 Alpine 3.16)將 root 欄位自 ! 改為 *。同時,雲端映像的建置腳本中預設的 alpine 使用者也採用了 * 標記。

2026 年:AI agent 差分測試發現異常

這項邊界條件組合在系統中存在了多年。

依研究者 Özgün Kültekin 公開說明,其在探討 OpenSSH 與底層 libc 的邊界行為時,建立了包含 glibc、musl、PAM 啟用與停用等多種組合的差分測試矩陣,並利用 AI agent swarm 生成了 17,600 組邊界測試資料進行自動化比對。

測試結果顯示,在「musl + 無 PAM」的環境下,當輸入超過 256 位元組且不超過 OpenSSH 的 1024 位元組上限時,密碼欄位為 * 的帳號會異常通過比對,該漏洞因而被發現。

檢查系統是否受影響

該漏洞(社群通稱為 *sshole)的觸發需要多項條件同時成立:

  1. 底層為 musl libc :例如 Alpine Linux、OpenWrt、Buildroot、Yocto、postmarketOS(現由 Nura 社群維護)、Void Linux(musl 版本)。使用 glibc 的發行版不受影響。
  2. OpenSSH 未啟用 PAM :Alpine Linux 預設安裝的 openssh-server 套件未包含 PAM 支援,或在設定中指定了 UsePAM no。
  3. 開啟了密碼認證 :sshd_config 中啟用了 PasswordAuthentication yes(OpenSSH 預設值)。
  4. 存在密碼欄位為 * 的帳號 :例如在設定了 PermitRootLogin yes 的環境中的 root 帳號,或是僅允許金鑰登入的部署帳號、具備 doas 權限的雲端預設帳號(如 alpine)。
  5. 系統中存在可供借用的 SHA-crypt salt :pick_salt() 能夠在系統中搜尋到,並實際選中且快取來自 $5$ 或 $6$ 雜湊的 salt。
  6. 輸入密碼長度介於 257 至 1024 位元組 :需超過 musl SHA-crypt 的 256 位元組限制以觸發錯誤回傳,同時不能超過 OpenSSH 在 auth-passwd.c 中定義的 MAX_PASSWORD_LEN(1024 位元組)。

官方專案提供了檢測指令:

curl -sO https://raw.githubusercontent.com/oz9un/sshole/main/check.sh
sudo sh check.sh

OpenSSH 的修補方式

依據 OpenSSH 10.6p2 發布說明 與 上游修補 commit 4353fdc43,該問題已在 OpenSSH 10.6p2 中正式修正。核心驗證邏輯與記憶體管理修補節錄如下:

--- a/auth-passwd.c
+++ b/auth-passwd.c
@@ -68,6 +68,7 @@ extern login_cap_t *lc;
 #define DAY		(24L * 60 * 60) /* 1 day in seconds */
 #define TWO_WEEKS	(2L * 7 * DAY)	/* 2 weeks in seconds */
 
+#define MIN_PASSWORD_HASH_LEN	4
 #define MAX_PASSWORD_LEN	1024
 
...
@@ -198,16 +200,20 @@ sys_auth_passwd(struct ssh *ssh, const char *password)
 	struct passwd *pw = authctxt->pw;
+	int ret;
 	char *encrypted_password, *salt = NULL;
...
 	/*
 	 * Authentication is accepted if the encrypted passwords
 	 * are identical.
 	 */
-	return encrypted_password != NULL &&
+	ret = encrypted_password != NULL &&
+	    strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
 	    strcmp(encrypted_password, pw_password) == 0;
+	freezero(pw_password, strlen(pw_password));
+	return ret;
 }

修補重點包含兩項:

  1. 長度門檻檢查 :在比對雜湊之前,明確要求 shadow 儲存的雜湊長度必須大於等於 4(MIN_PASSWORD_HASH_LEN)。若帳號欄位僅為 * 或 !,比對直接判定為失敗。
  2. 複本記憶體清除 :shadow_pw() 改為透過 xstrdup 複製密碼字串,比對完畢後立即使用 freezero 清除並釋放記憶體複本,同時徹底防範指標殘留。

防禦與暫時緩解措施

對於正在維運相關環境的管理人員,可採行以下處置:

升級 OpenSSH 套件

各發行版已陸續釋出包含 OpenSSH 10.6p2 修補的套件。在 Alpine Linux 上可透過套件管理器更新:

apk update
apk upgrade openssh
rc-service sshd restart

暫時緩解措施:關閉密碼認證

若無法立即升級套件,最直接的緩解措施是停用密碼認證,強制改用 SSH 金鑰登入:

echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
sshd -t
sshd -T -C user=root,host=127.0.0.1,addr=127.0.0.1 | grep -i passwordauthentication
rc-service sshd restart

執行後需透過 sshd -t 確認設定語法無誤,並以 sshd -T -C ... 模擬連線條件,確保 passwordauthentication no 確實生效,未被較早載入的設定檔或 Match 區塊覆蓋。測試時請將 user、host、addr 替換為實際登入帳號、來源主機名稱與來源 IP(host 為客戶端主機名稱,addr 為客戶端 IP),並確認設定檔有透過 Include 載入該 drop-in,若有不同的 Match 條件應分別進行驗證。

架構與工程省思

回顧這項漏洞的演進過程,有幾項值得借鏡的架構思維:

帶內標記(in-band signaling)的長期代價

將控制意義(如「停用認證」或「計算失敗」)編碼在原本正常的資料字串空間中(magic string),容易在日後引發預期外的交互作用。

1980 年使用 * 標記非密碼帳號,2012 年 musl 使用 * 作為替代錯誤回傳值,兩者各自獨立運作時並無大礙,但當系統邊界重疊時便產生了碰撞。以現代語言設計的角度來看,使用明確的錯誤型別(例如 Option 或 Result)區分狀態,往往比依賴特殊字串標記更加健全。

安全性修補的連鎖效應

OpenSSH 在 2016 年為了解決計時攻擊而借用其他帳號 salt 的機制,在單一模組視角下十分直觀,但它改變了一項潛在假設:傳入 crypt() 的 salt 不再與待驗證的目標帳號具備相同的上下文。跨帳號的狀態依賴,最終與底層 libc 的特殊邊界行為產生了非預期的串接。

差分測試與工具輔助的價值

一般程式碼審查通常會關注複雜的演算法與記憶體操作,較少會對標準 API crypt(3) 的回傳值產生疑慮。

透過差分測試矩陣,比對不同 C 函式庫與編譯選項在極端邊界條件下的行為差異,能夠有效檢驗不同軟體層之間的隱含假設,這也展現了自動化測試與 AI 工具在深入挖掘系統交互缺陷時的具體價值。


相機創意外觀與硬體 ISP 色彩管線運作機制

以 JPEG XL 高動態範圍線性影像模擬 16 扇區色彩矩陣、129 點曲線與 5×9×9 3D HSV 空間轉換
featured.svg

當我們拿著現代無反相機拍照時,切換機身內的創意外觀(Creative Look,如 Sony 的 ST、PT、NT、VV、FL、IN、SH 以及後續移植的 FL2 與 FL3)或各種軟片模擬,電子觀景窗(EVF)與機背螢幕能以 60 至 120 fps 的即時幀率,流暢呈現對比鮮明、高光柔和且具備獨特色調的畫面。

在很多人的直覺印象中,相機直出(Straight Out Of Camera,簡稱 SOOC)的風格設定,似乎就像手機修圖 App 裡的濾鏡——只是在已經拍攝完成的影像上套一層調色參數或簡單的對比飽和度增減。然而,如果我們嘗試在電腦上拿一張已經壓縮成 8-bit 的一般 JPEG 照片,暴力拉高暗部曲線或大幅旋轉色相,畫面往往會在幾秒鐘內崩潰:藍天出現一圈圈難看的階調斷層(banding)、陰影處浮現斑駁色噪,高光雲層更會瞬間硬生生「燒成死白」。

為什麼相機在機身內部即時直出的色彩,既能大幅度重塑影調風格,又能保有極為平滑自然的高光滾降與豐富層次?相機硬體影像訊號處理器(Image Signal Processor,簡稱 ISP)在光子撞擊感光元件後,究竟進行了哪些多階段的非線性數學變換?

這篇文章將從數位色彩科學與嵌入式硬體架構出發,拆解現代相機硬體 ISP 的五大核心處理階段;並介紹為什麼 JPEG XL (.jxl) 格式是我們在電腦端精確模擬硬體 ISP 的最佳載體,最後透過即時互動實驗室解構從線性光到成品的端到端色彩轉換管線。

硬體 ISP 與後製濾鏡的根本分歧

要理解創意外觀的運作機制,首先必須釐清「硬體即時 ISP 管線」與「一般軟體後製濾鏡」在資料本質上的分水嶺。

在相機硬體架構中(例如先前我們在分析 Sony BIONZ XR CXD90057 雙處理器架構時所見),感光元件記錄下來的原始信號經過解馬賽克(demosaicing)後,抵達專責相機處理器(CP,運行 TOPPERS/FMP RTOS)時,仍然是完全未壓縮、具備 14-bit 以上動態範圍的 場景線性輻射率 (scene-referred linear radiance)。

在這種線性光空間中,數值大小嚴格對應物理世界中的光子通量(photon flux):數值增加一倍,代表物理進光量實打實增加了一檔(1 stop)。

一般修圖軟體套用後製濾鏡時,面對的往往是已經被壓縮進顯示空間(display-referred)、經過 8-bit 量化並烘焙了非線性 sRGB 伽瑪曲線的成品 JPEG。一旦進入這個階段,原始場景中超過 100% 亮度的動態範圍早被強制剪裁(clipping),每個通道僅剩 256 個離散數值階層,任何劇烈的色調扭曲都會破壞相鄰畫素間的微小梯度,導致視覺可見的階調碎裂。

相機內部的硬體 ISP 則完全不同:它是在純粹的線性光空間中執行白平衡與原色校正,隨後透過專用的硬體加速電路(如色彩矩陣單元、樣條曲線單元與 3D 色彩網格單元)並行運算,將高動態範圍以數學方式平滑映射至目標色域,最後才交由編碼器壓縮儲存。這正是相機直出 JPEG 或 HEIF 能具備「底片質感」卻毫不生硬的硬體基礎。

為什麼選擇 JPEG XL 作為模擬實驗載體?

如果我們想在電腦端驗證並重現相機硬體 ISP 的全部數學運算,我們需要一種合適的數位影像檔案格式作為實驗媒介:

  1. 傳統 JPEG 的限制:每通道只有 8-bit、色彩空間被鎖死在狹窄的 sRGB、且強制使用 4:2:0 破壞性色度抽樣與 8×8 離散餘弦變換(DCT)區塊。拿它來模擬多階段 ISP,只會放大檔案本身的壓縮瑕疵。
  2. 相機 RAW 原檔的限制:RAW 檔(如 Sony 的 ARW、Nikon 的 NEF 或 Adobe DNG)雖然保留了感光元件的 14-bit 線性資料,但由於包含 Bayer 濾鏡陣列的原始馬賽克結構,不同解碼器(LibRaw、Rawpy、Adobe Camera Raw)在去馬賽克階段的插值演算法差異甚大,且缺乏通用的色彩標記,難以直接作為跨平台純量運算的標準容器。

這正是 JPEG XL (.jxl) 展現價值的完美舞台。作為新一代現代影像標準,JPEG XL 具備幾項對於色彩科學模擬極為關鍵的硬實力:

  • 原生支援 16-bit 浮點數與整數線性光空間:JPEG XL 允許直接儲存未經伽瑪壓縮的 16-bit 線性資料(Linear Light Rec.2020 或 Display P3),能夠完整容納高動態範圍的場景輻射率,為後續的多級曲線壓縮與色相旋轉提供充足的運算精度餘裕,從根源杜絕階調斷層。
  • 超廣色域相容性:在硬體 ISP 中,當我們為了模擬復古色調而大幅度調移特定色相的飽和度時,色彩數值經常會暫時被推移至標準 sRGB 邊界之外;JPEG XL 能無縫封裝 Rec.2020 或 ACEScg 廣色域,避免邊緣色彩提早遭到裁切。
  • 感知均勻的 XYB 內部編碼:JPEG XL 內部原生基於人類視覺受體(LMS 錐細胞)的非線性感知空間進行色度與亮度分離,搭配自適應量化機制,即便在極高壓縮率下,也能以極細緻的保真度儲存微對比與細節紋理。

換言之,一張包含 16-bit 浮點線性場景資料的 JPEG XL 影像,就是我們在 PC 端高精度模擬硬體 ISP 的最佳「數位負片」。

Stage 0: 感光元件線性場景輻射率與 EV 動態範圍分佈 (Sensor Linear Radiance & EV Stops) STAGE 0 · RAW / JXL

硬體 ISP 色彩管線全景架構

在 Sony BIONZ XR 世代的韌體實作中,當 CP(相機處理器)接收到來自 AP(應用處理器)派發的創意外觀配置參數後,硬體 ISP 會依照嚴格的硬體流水線依序執行各個運算單元。

下圖呈現了相機內部從感光元件 RAW 讀出、歷經硬體 ISP 各級變換,直到最終直出編碼的完整管線架構(在後續的實驗與電腦端模擬中,我們使用 16-bit 線性 JPEG XL 作為場景線性光資料載體):

creative-look-isp-pipeline.svg

整個管線可以清晰地拆解為五大循序漸進的運算階段:

  1. 線性白平衡與暗部基準增益 (cc1)
  2. 16 扇區色相相依色彩矩陣 (block13)
  3. 129 點分段樣條色調映射 (gamma[129])
  4. 5×9×9 3D 極坐標 HSV 色彩網格 (grid[3][405])
  5. 空間微對比與細節濾波 (Clarity & Detail)

接下來,我們深入剖析每一階段的具體數學定義與在相機內部扮演的角色。

深入五大硬體 ISP 運算階段

線性白平衡與暗部基準增益 (cc1)

色彩管線的第一站,必須發生在完全未經非線性轉換的線性光空間。

在感光元件讀出訊號時,各感光畫素存在微小的暗電流雜訊(dark current),硬體首先扣除黑電平基準(pedestal)。接著,根據當前環境色溫與白平衡設定(對應韌體結構中的 s_kelvin 與 s_adobekelvin,以及 cc1[3][8] 結構中的通道增益),對三原色光電倍增訊號進行線性對稱放大:

$$\begin{bmatrix} R_{wb} \\ G_{wb} \\ B_{wb} \end{bmatrix} = \begin{bmatrix} k_R & 0 & 0 \\ 0 & k_G & 0 \\ 0 & 0 & k_B \end{bmatrix} \left( \begin{bmatrix} R_{in} \\ G_{in} \\ B_{in} \end{bmatrix} - \mathbf{p}_{\text{black}} \right)$$
 階段 1 硬體運算流向

輸入訊號:未校正場景線性輻射率 $\mathbf{C}_{in} = [R_{in}, G_{in}, B_{in}]^T$ 核心運算:扣除感光元件暗電流基準 $\mathbf{p}_{\text{black}}$ ➔ 套用通道乘法對稱縮放 $\operatorname{diag}(k_R, k_G, k_B)$ 硬體暫存器:s_kelvin、s_adobekelvin、cc1[3][8] 輸出特性:線性能量平衡原色 $\mathbf{C}_{wb}$(中性灰階無色偏,維持光子通量比例)

通道增益 $k_R$ 與 R_gain 的實作關聯與組成

在數學模型中的對角矩陣增益 $k_R, k_G, k_B$,與數值解剖儀中顯示的 R_gain, G_gain, B_gain 本質上代表完全相同的物理量:

  • 理論公式與硬體實作記號:在對角矩陣中,$k_R$ 代表紅通道的線性放大乘數;在韌體暫存器與模擬程式碼中,它對應單通道運算 $C_{wb} = \max(0, C_{linear} - p_{\text{black}}) \times \text{Gain}_c$ 當中的 R_gain(或 rGain)。
  • $k_R$ (R_gain) 的兩層權重組成:在硬體 ISP 與創意外觀中,$k_R$ 是由「風格外觀基礎偏差」與「環境色溫動態調變」兩者乘積所決定: $k_R \equiv \text{R\_gain} = k_{R, \text{base}} \times f_R(T_{\text{kelvin}})$
    • 外觀基礎偏差 ($k_{R, \text{base}}$):來自創意外觀預設(例如 FL 膠卷底片風格預設微幅偏暖,基礎值 wbGains[0] = 1.120)。
    • 色溫動態調變 ($f_R(T)$):相對於標準日光($5500\text{ K}$)即時補償。當場景色溫較高($T > 5500\text{ K}$,冷調陰影)時,調高紅通道增益以補充暖光平衡;色溫較低($T < 5500\text{ K}$,黃昏或鎢絲燈)時,則適度收斂以防止高光溢紅。
  • 綠通道基準錨點 ($k_G = 1.000$):在 Bayer 感光元件中,綠色畫素數量佔 50% 且最貼近人眼明度(Luminance)響應。相機 ISP 的 cc1 暫存器通常固定 $k_G = \text{G\_gain} = 1.000$ 作為中性亮度錨點,由 $k_R$ (R_gain) 與 $k_B$ (B_gain) 對稱向兩側展開調節紅藍色溫。

為什麼白平衡必須在最前端的線性空間執行?

如果我們先套用了非線性色調曲線或伽瑪映射,原色數值與物理光子通量之間的比例關係就會被徹底破壞。此時若再調整白平衡,高光區域與暗部區域的色彩偏差將不再一致,導致高光泛黃而暗部偏藍的非線性色偏(crossover distortion)。只有在線性空間下做乘法純量縮放,中性灰色的色度座標在各種曝光級數下才能維持嚴格守恆。

Stage 1: 線性白平衡與黑電平基準扣除 (cc1 Linear WB & Black Pedestal) STAGE 1 · CC1
白平衡色溫 (WB Temp) 5500 K

16 扇區色相相依色彩矩陣 (block13)

完成白平衡後,三原色通道達到能量平衡,但此時的 RGB 座標系仍然是感光元件濾色鏡的專屬光譜響應(camera native sensor primaries),必須轉換至標準色彩空間。

傳統影像處理通常採用單一組 $3 \times 3$ 色彩矩陣:

$$\vec{C}_{out} = \mathbf{M}_{3 \times 3} \cdot \vec{C}_{in}$$

然而,單一全域 $3 \times 3$ 矩陣在色彩設計上存在無法調和的物理矛盾:當我們想要模仿膠卷底片的特徵,把天空中的藍色往青綠色(Cyan / Teal)旋轉、並提高綠色植被的純度時,一個全局矩陣會無可避免地把人臉中的黃色與紅色一同牽動,導致原本健康紅潤的膚色變得枯黃或蠟黃。

為了解決這個難題,現代相機硬體 ISP(在 BIONZ XR 中對應 block13[3][16][6] 結構)導入了 16 扇區色相調適矩陣 機制:

  1. 色相圓盤 16 等分:硬體管線即時計算畫素的色相角 $\theta = \operatorname{atan2}(U, V)$,將 $360^\circ$ 的色彩圓盤劃分為 16 個色相扇區(每個扇區跨度 $22.5^\circ$)。
  2. 基底校正矩陣與差分係數:以基礎矩陣 $\mathbf{M}_{base}$ 為底,每個扇區定義了 6 組差分修正係數(differential coefficients),獨立微調該色相區間的交叉通道貢獻量。
  3. 鄰近扇區連續線性插值:當畫素介於兩相鄰扇區中心時,硬體根據其色相偏移角度對兩組矩陣進行平滑加權混合,確保色相過渡平順、絕不出現邊界色斑跳變。

透過 16 扇區色彩矩陣,相機工程師能夠做到「在大幅旋轉冷色調的同時,將黃橙色至紅色的膚色區間牢牢鎖定在自然範圍內」。

Stage 2: 16 扇區色相相依向量矩陣圓盤 (block13 Hue Wheel & Deviations) STAGE 2 · BLOCK13
 階段 2 硬體運算流向

輸入訊號:白平衡後原色 $\mathbf{C}_{wb}$ 核心運算:計算色相角 $\theta = \operatorname{atan2}(V, U)$ ➔ 定位相鄰扇區 $s$ 與 $s+1$ ➔ 線性權重插值差分矩陣 $\mathbf{M}_{base} + \Delta \mathbf{M}(\theta)$ 硬體暫存器:block13[3][16][6] 輸出特性:目標色域原色(例如天空藍向青綠旋轉,膚色區間完全鎖定保護)

129 點分段樣條色調映射 (gamma[129])

色彩基礎校正完成後,接著迎來決定畫面明暗對比與「影調骨架」的核心階段——色調映射(Tone Mapping)。

許多人習慣用單一的伽瑪公式 $Y_{out} = Y_{in}^{1/\gamma}$ 來理解亮度映射,但在實際相機硬體 ISP 中,這種固定冪次公式完全無法滿足現代攝影對高動態範圍的苛刻要求。在韌體逆向分析中,我們看到的是一整張由 129 個節點組成的分段曲線表格:gamma[129]。

硬體 ISP 透過分段三次樣條(Cubic Spline)或高階單調插值,在 129 個錨點之間構建起連續平滑的映射曲線:

  • 暗部微抬升與灰調啞光 (Matte Fade / Pedestal Lift):在原廠 FL 或 Fujifilm Classic Chrome 風格中,曲線在接近 $Y = 0$ 的最暗端點並未直接落在純黑,而是微微抬升約 $1.5\% \sim 3\%$ 的輸出亮度。這種暗部灰調模擬了傳統底片銀鹽未曝光區域的基礎片基密度(D-min),賦予暗部沉穩而通透的復古啞光感。
  • 中灰對比坡度 (Midtone Contrast Slope):在中間調(通常對應 18% 灰板的曝光基準點附近),曲線擁有更陡峭的斜率,以增強視覺焦點的立體感與階調反差。
  • 高光軟滾降 (Soft Highlight Rolloff):傳統數位拍照最令人詬病的,就是在過曝邊界出現斷崖式的硬剪裁(hard clipping),雲層邊緣會形成突兀的色帶。硬體 ISP 的 129 點樣條在高光區間採用了漸近 S 曲線(shoulder curve),讓亮度在逼近 1.0 時平順地減緩上升斜率,將極端高光的反差柔和壓縮,營造如同大片幅膠卷般的優雅高光過渡。
  • 嚴格單調性約束 (Isotonicity):所有 129 個節點必須滿足嚴格的單調遞增條件 $Y_{out}[i+1] \ge Y_{out}[i]$,否則局部斜率倒轉將直接引發嚴重的太陽化(solarization)光斑異常。

三項式混合模型與光學攝影意圖

在韌體設計與後續的互動模擬實作中,這條 129 點樣條曲線由三個各自承載光學與感光化學意圖的物理項加權疊加而成:

$Y_{out}(x) = Y_{toe}(x) + Y_{mid}(x) \cdot w_{mid} + Y_{shoulder}(x) \cdot w_{shoulder}$

  • 暗部趾部抬升項 (Toe Lift / Film Base D-min): $Y_{toe}(x) = \text{lift} \cdot \max(0, 1 - 3.5x)^{2.2}$ 傳統銀鹽黑白與彩色負片中,未曝光區域依然具備基礎膠片片基的光學密度(Minimum Density, $D_{\min}$)。在數位影像中若直接將數值映射至絕對純黑(0),暗部陰影容易顯得生硬扁平。透過指數為 2.2 的非線性冪次衰減,該項僅作用於最深處約 5% 的暗部階調,營造出底片特有的啞光灰黑質感與空氣呼吸感。
  • 中間調 Sigmoid S 曲線項 (Midtone Contrast Punch): $Y_{mid}(x) = \frac{1}{1 + \exp\left( -\gamma \cdot (\ln x - \ln x_{mid}) \right)}$ 以 RAW 感光元件線性中灰錨點 $x_{mid} \approx 0.105$(保留約 3 級高光動態範圍裕度)為中心,利用對數空間下的 Sigmoid 函數塑造平滑且對稱的階調反差斜率 $\gamma$。這能有效鎖定視覺核心主題的立體感與衝擊力,同時維持中灰基準點的明度穩定。
  • 高光肩部漸近滾降項 (Shoulder Highlight Rolloff): $Y_{shoulder}(x) = 1 - \exp\left( -x^{\alpha} \cdot 1.8 \right)$ 數位感光元件到達飽和電容時會發生嚴重的硬剪裁(hard clipping),導致天空白雲出現邊緣斷層。肩部滾降項採用指數漸近收斂,在亮度逼近 1.0 時溫和壓縮高光動態範圍,模擬大片幅膠卷在過曝時銀鹽晶粒逐漸耗盡的優雅軟滾降。
Stage 3: 129 點硬體分段樣條色調映射曲線 (gamma[129] Spline Curve) STAGE 3 · GAMMA[129]
暗部抬升 (Pedestal Lift) 2.5%
中灰對比坡度 (Contrast) 2.20×
 階段 3 硬體運算流向

輸入訊號:色彩矩陣校正後線性 RGB 核心運算:提取明度 $Y_{in}$ ➔ 129 點分段三次樣條查表(暗部抬升 + 中灰對比坡度 + 高光漸近滾降)➔ 等比例縮放原色 $\mathbf{C} \cdot (Y_{out} / Y_{in})$ 硬體暫存器:gamma[129] 曲線節點表格 輸出特性:高動態範圍壓縮後影像(底片啞光灰黑、通透高光、嚴格色度座標守恆)

5×9×9 3D 極坐標 HSV 色彩網格 (grid[3][405])

如果說 129 點曲線塑造了相片的骨骼,那麼 5×9×9 3D 極坐標 HSV 色彩網格 (在韌體中對應 grid[3][405] 與 405-point 3D lattice) 就是賦予創意外觀獨特靈魂的魔法之源。

下圖展示了這座立體色彩網格的空間幾何拓撲與三線性插值運作方式:

hsv-lattice-transformation.svg

405 個立體網格點的幾何結構

在三維空間中,網格將色彩空間組織為一個柱狀極坐標系(Cylindrical Polar Coordinates):

  • 5 層垂直明度階 (Luma Slices):沿著垂直亮度軸 $Y$,劃分為 5 個離散節點($Y \in \{0, 1, 2, 3, 4\}$),分別對應極暗部(Deep Shadows)、陰影(Shadows)、中灰(Midtones)、高光(Highlights)與極高光(Super Highlights)。
  • 9 圈徑向飽和度環 (Saturation Rings):從中性灰軸心向外輻射,劃分為 9 個飽和度半徑階層($U \in \{-4, -3, \dots, 0, \dots, +4\}$),涵蓋從完全黑白中性、低飽和啞光到高飽和純色的過渡。
  • 9 條角向色相射線 (Hue Rays):圍繞色度圓盤以 $40^\circ$ 為間隔,劃分出 9 個主要的色相取樣方向($V \in \{-4, -3, \dots, 0, \dots, +4\}$),精確錨定紅、橙黃、綠、青、藍、洋紅等關鍵光譜。

三者相乘,構成了立體空間中恰好 $5 \times 9 \times 9 = 405$ 個控制節點。在硬體儲存中,每個節點儲存一組 signed 7-bit 整數增量向量:

$$\mathbf{g} = \left( \Delta H, \Delta S, \Delta V \right) \in [-64, 63]^3$$

空間單元三線性插值原理 (Trilinear Interpolation)

當任何一個畫素進入該單元時,硬體計算其所在的空間連續座標 $(y, u, v)$,並定位包覆該畫素的相鄰 8 個網格頂點構成的微型晶胞(sub-cube):

$$\mathbf{C} = \{ (y_0, u_0, v_0), (y_0, u_0, v_1), \dots, (y_1, u_1, v_1) \}$$

令分數偏移量為 $f_y = y - y_0$、$f_u = u - u_0$、$f_v = v - v_0$,三線性插值在 8 個角點上分別施加空間權重:

$$w_{i,j,k} = \left( i \cdot f_y + (1-i)(1-f_y) \right) \left( j \cdot f_u + (1-j)(1-f_u) \right) \left( k \cdot f_v + (1-k)(1-f_v) \right)$$

最終該畫素獲得的色彩修正量為 8 個角點增量的加權總和:

$\Delta \vec{C} = \sum_{i,j,k \in \{0, 1\}} w_{i,j,k} \cdot \frac{\mathbf{g}_{y_i, u_j, v_k}}{256}$

Stage 4: 5×9×9 3D 極坐標 HSV 色彩網格立體切片 (grid[3][405] 3D Lattice) STAGE 4 · GRID[3][405]
3D 網格陰影冷調 (Teal Delta) +12 Δ

為什麼 3D 網格具有無可替代的表現力?

傳統的 1D LUT 只能單獨調整 R、G、B 各自的曲線,無法做到「根據亮度與飽和度的組合來決定色相偏移」。

3D 網格則實現了完全有界(locally bounded)的多維度精準調色:

  • 暗部冷調青綠、高光微暖:我們可以在 $Y \in \{0, 1\}$(陰影階)將綠色和青色扇區的 $\Delta H$ 向藍綠色偏移,同時在 $Y \in \{3, 4\}$(高光階)保持完全中立或微增暖黃,創造出電影等級的冷暖影調分離。
  • 人像膚色高光保護:在橙黃色相射線上,僅在 $Y \ge 2$ 的高明度區間提升 $\Delta V$ 並微降 $\Delta S$,讓人物臉頰高光呈現白皙通透的粉潤感,而完全不影響畫面其他區域的飽和度。
  • 深色植被消光:在暗部高飽和綠色節點上單獨降低 $\Delta S$,消除數位相機拍攝森林時常見的突兀螢光綠,營造沉穩靜謐的日系膠卷氛圍。
 階段 4 硬體運算流向

輸入訊號:色調映射後影像 核心運算:柱狀極坐標定位 $(y, u, v) \in [0, 4] \times [0, 8] \times [0, 8]$ ➔ 晶胞 8 頂點三線性空間插值 ➔ 疊加有界增量 $\Delta \vec{C}$ 硬體暫存器:grid[3][405] 405 個立體網格點節點 輸出特性:多維度微調色彩(陰影冷調青綠、人像高光粉潤提亮、深綠植被消光)

空間微對比與細節濾波 (Clarity & Detail)

最後一個硬體階段聚焦於空間頻率域。

相機中的清晰度(Clarity)與銳利度(Sharpness)調節,本質上是透過雙邊濾波(Bilateral Filter)或導向濾波(Guided Filter)將影像分解為 基底低頻層 (Base Layer) 與 高頻細節層 (Detail Layer)。

在不產生光暈(halo artifacts)的前提下,硬體對局部邊緣梯度進行非線性增益放大,並在低對比度紋理處施加微幅提升,讓織物毛髮、石磚紋理在維持柔和色彩的同時,依舊保有清晰可辨的輪廓觸感。

Stage 5: 空間雙邊濾波頻率分離與微對比增益 (Clarity & Edge Detail) STAGE 5 · FILTER
微對比清晰度 (Clarity) +0.45×
 階段 5 硬體運算流向

輸入訊號:3D 色彩網格處理後影像 核心運算:空間邊緣濾波分解 ➔ 提取基底低頻層與高頻細節層 ➔ 局部梯度非線性增益放大 硬體暫存器:空間濾波器係數與清晰度閾值 輸出特性:層次分明、無白邊光暈的相機直出成品影像

硬體 ISP 創意外觀即時互動實驗室

理論與公式固然嚴謹,但唯有親手切換管線各階段、即時觀察影像與向量空間的聯動變化,才能真正領略硬體 ISP 的精妙之處。

下方提供了一套完全運行於瀏覽器記憶體中的 相機硬體 ISP 創意外觀即時互動實驗室。這套模擬器基於前述五大階段演算法建構,具備以下核心功能:

  1. 六階段循序演繹 (Pipeline Stepper):點擊上方階段標籤,或點擊「▶ 自動流水線演示」,即可親眼見證一張 16-bit 線性負片如何歷經白平衡、色相旋轉、樣條映射、3D 空間微調與細節濾波,逐步綻放為獨特的直出風格。
  2. 多款經典風格預設 (Creative Looks):可在 FL (Film Look)、ST (Standard)、VV (Vivid) 與 Classic Neg 間任意切換,觀察不同風格在色調曲線、陰影偏色與膚色保護上的策略差異。
  3. 雙面板向量幾何解剖 (Mathematical Inspector):右側畫布即時渲染當前階段的數學拓撲圖,包含動態範圍 EV 直方圖、16 扇區色相圓盤、129 點分段樣條曲線、3D HSV 空間切片與頻率分離曲線。
  4. 捲簾滑桿與原位閃切 (Comparison):支援左右拖曳捲簾滑桿對比原始線性光與直出成品,亦可按住「原位按住閃切」即時檢視單一階段帶來的視覺增量。
  5. 底層硬體參數微調:展開下方控制面板,能即時拉動色溫、暗部抬升比例、中灰對比坡度與 3D 陰影青綠冷調,實時運算呈現畫布結果。
相機硬體 ISP 創意外觀即時互動實驗室 BIONZ XR PIPELINE SIMULATOR
測試場景:
檢視模式:
即時階段渲染畫布 (Live Stage View) 900 × 600 (3:2) · 16-bit 浮點渲染
風格外觀預設: 沉穩平靜的日系膠卷感,陰影具備呼吸感,膚色通透。
Stage 0 RAW 線性光負片
Stage 1 (cc1) 白平衡與黑基準
Stage 2 (block13) 16 扇區色彩矩陣
Stage 3 (gamma) 129 點分段樣條
Stage 4 (grid) 5×9×9 3D 網格
Stage 5 (filter) 微對比與細節直出
Stage 0: 感光元件線性場景輻射率 (Sensor Linear Radiance)
Linear Radiance: (R, G, B) ∈ [0.0, 2.2+]
感光元件原始線性光子能量(電腦端模擬以 16-bit 線性 JXL 載入),維持無損寬容度與物理照度比例,為後續膠卷曲線儲備動態餘量。
● 感光元件原生線性輸入:維持感測器未校正光子能量,數值與物理進光量嚴格成正比,無手動暫存器參數。

風格外觀實例解析:ST、FL、FL2、FL3 與經典負片對比

當我們將上述硬體管線套用於實際影像時,不同創意外觀在各階段參數上的微小差異,會產生風格截然不同的化學反應。

在上方即時互動實驗室中,我們已將各風格外觀的「整體直出氛圍」直接整合至預設選單旁,並將 129 點分段樣條曲線(gamma[129])、16 扇區色相矩陣(block13)與 3D 色彩網格(grid[3][405])等各階段硬體特性動態綁定至階段解剖儀說明欄位中,讀者可隨時在實驗室中切換預設與階段即時對照。

以下特別針對相機硬體韌體逆向工程中提取的真實 FL2、FL3 數據進行深入拆解:

FL2:柔和低反差電影質感與啞光陰影

從韌體提取的真實硬體參數分析,FL2(Film Look 2)是專為追求溫潤柔和、低刺激視覺體驗所調校的電影外觀:

  • 抬升暗部基底 (Toe Lift):其 129 點曲線的起步節點 gamma[1] 高達 1158(約為 3.2%),幾乎是 FL(594,約 1.8%)的兩倍。這使得深暗陰影完全不會出現死黑裁切,而是呈現出如同 35mm 電影負片般的厚實片基質感(Matte Shadow)。
  • 收斂中間調反差:對比坡度從 FL 的 2.2 降至 1.88,階調過渡極為連綿平滑。
  • 平緩的高光滾落 (Soft Highlight Roll-off):高光肩部係數縮減至 0.78(gamma[96] 為 118,863,低於 FL 的 122,524),能有效抑制烈日逆光與金屬反射的生硬邊緣,讓高光散景呈現柔順的擴散過渡。
  • 復古植被與啞光青空:在 16 扇區矩陣中,綠葉扇區微幅增加綠色分量並抑制藍色,將鮮豔的翠綠轉化為沉穩的橄欖綠與鼠尾草色;天空藍扇區則降低飽和度,散發靜謐內斂的膠卷氛圍。

FL3:通透高光清亮膠卷與純淨藍調

與低反差的 FL2 形成鮮明對比,FL3(Film Look 3)走的是明亮、輕盈且富有空氣感的現代高調風格:

  • 暗部至中灰的空氣感躍升:在 129 點曲線中,FL3 的暗部到中灰過渡區段(如 gamma[32])數值高達 40,624(正規化約 0.31),在所有創意外觀中居於首位(遠高於 ST 的 30,329 與 FL 的 36,042)。這代表即使在背光陰影處,畫面依然能維持極佳的明亮度與通透感,毫無沉重壓抑之感。
  • 純淨湛藍的天空表現:在 16 扇區矩陣中,藍天扇區(Sectors 10 ~ 13)維持極高的藍通道增益($\Delta B > 0$),避免了 FL 那種濃重的墨綠偏轉,呈現如初夏晴空般的乾淨清透湛藍。
  • 粉嫩透亮的人像膚色:膚色扇區(Sectors 1 ~ 3)在保持健康暖意之餘,微幅提升紅藍明度,使皮膚呈現白皙粉潤的通透光澤,特別適合陽光下的人像與旅拍紀錄。

在 16-bit 線性 JPEG XL 的加持下,這些複雜的曲線與空間網格運算完全不會在漸層處留下任何鋸齒與斷層。即使我們將相機直出的 JPEG 與這套在 PC 上運算出的 JXL 進行殘差比對(residual comparison),兩者在可見光譜內的色調匹配度依然高達 99% 以上。

結語:數位暗房與硬體工程的交會

探索相機硬體 ISP 的創意外觀管線,是一次極具啟發性的跨領域工程體驗:

  • 打破濾鏡的玄學迷思:相機直出的獨特色彩並不是神秘的黑魔法,而是一套由「線性能量平衡 ➔ 色相分區矩陣 ➔ 分段樣條曲線 ➔ 3D 極坐標微調」嚴密交織而成的空間幾何系統。
  • JPEG XL 作為現代影像橋樑:透過 16-bit 浮點數線性容器與廣色域支援,JPEG XL 成功彌合了「笨重且難以通用的 RAW 檔」與「貧乏脆弱的 8-bit 老 JPEG」之間的巨大鴻溝,成為在現代電腦端高精度探索影像科學的絕佳載體。
  • 從逆向分析到正向創造:當我們在底層看清了 cc1、block13、gamma[129] 與 grid[3][405] 的資料排布與數學含義,我們不僅能深入理解手中的相機,更能反過來在電腦端打造屬於自己的專屬色彩矩陣與立體網格,甚至將其打包注入相機韌體,讓這套硬體管線真正為自己的攝影美學所用。

Sony A7R V 第三方韌體逆向與本地編譯實戰

剖析 BIONZ XR 雙處理器架構、FL2/FL3 風格外觀移植、Unicorn 虛擬 CPU 驗證與 Android Fastboot 信任鏈
featured.svg

Sony Alpha 7R V(ILCE-7RM5,機種代碼 0x11030080,內部代號 R5)憑藉 6,100 萬畫素的感光元件與獨立 AI 處理單元,在畫質細節與自動對焦表現上幾乎無可挑剔;但只要聊到機身直出(SOOC)的色彩選擇,許多 R5 用戶心裡多少都有個小遺憾:明明與隨後推出的 A7C II、ZV-E1、FX3 採用同世代的 BIONZ XR 影像處理器,Sony 卻在後續機型上新增了廣受好評的 FL2 與 FL3 兩種風格外觀(Creative Look),而身為高階機身的 R5 即使升級到官方 v4.00 韌體,依然停留在原本的 10 種預設外觀。

更進一步來說,許多攝影師希望能將 Fujifilm 經典的軟片模擬(如 Classic Chrome、Classic Neg、Nostalgic Neg、Astia、Velvia、Acros、Reala Ace)直接寫入相機的硬體 ISP 管線,在電子觀景窗(EVF)取景與直出 JPEG / HEIF 時即時套用,而不必每次都進 Lightroom 從 RAW 檔慢慢調色。

近期開源社群出現了針對 R5 v4.00 韌體的逆向工程專案(sony-a7r5-custom-firmware v4.34,韌體標頭版本號編碼為 BCD 0x34, 0x04)。出於對嵌入式系統架構與相機底層運作的好奇,我花了一段時間將整套專案與 Sony 官方 v4.00 的 BODYDATA.DAT(754,525,608 bytes)徹底拆解開來,在 Windows 環境下搭配 LLVM Clang 23.1.4 與 uv 從頭走完解包、雙目標交叉編譯、Unicorn 虛擬 CPU 模擬測試與 RSA-2048 重簽章封裝。這篇文章就來完整記錄 Sony BIONZ XR 相機內部的軟硬體架構,以及這套第三方韌體背後的每一個位址、結構與工程細節。

這套自製韌體做了什麼?

在進入記憶體位址與組語之前,先看看這套 R5 v4.34 第三方韌體在功能層面完成了哪些擴充:

  1. 開啟隱藏的 FL2 與 FL3 風格外觀: 原廠 R5 內建 10 組風格外觀(ST、PT、NT、VV、VV2、FL、IN、SH、BW、SE)。這套韌體將新世代 BIONZ XR 機型(A7C II v2.00、ZV-E1 v2.00、FX3 v6.00)原生的 FL2(沉穩柔和的底片感)與 FL3(通透高光與清亮色調)完整移植回 R5,將機身內的 Creative Look 槽位從 10 組擴充為 12 組。
  2. 支援從 SD 卡動態匯入自訂濾鏡與 3D LUT: 使用者只要在插槽 1 或插槽 2(/mnt/sd0 或 /mnt/sd1)根目錄放置一個 29,552 bytes 的 CUSTOM_F.BIN 或 SONY_LUT.BIN(採用 BIN v4 格式,4-byte 魔術字元為 R5F4,版本號 4),就能在選單中一鍵觸發匯入,直接覆寫全部 12 組 Creative Look 在 RAM 與硬體 ISP 中的底層色彩結構。專案隨附了經過實機 ISP 校準的 12 組 Fujifilm 軟片模擬組合:
    • PROVIA(標準)、VELVIA(高飽和風景)、ASTIA(柔和人像)、CC(Classic Chrome 經典正片)
    • PRO_NG(PRO Neg.Hi)、PRO_NS(PRO Neg.Std)、CL_NEG(Classic Neg. 經典負片)、NOSTAL(Nostalgic Neg. 懷舊負片)
    • ETERNA(電影感)、ET_BLC(Eterna Bleach Bypass 跳漂)、ACROS(高質感黑白)、REALA(Reala Ace)
  3. 完整的原生 UI、動態圖示與 Fn 選單整合: 不同於早期單純替換常數表的硬改做法,v4.34 會在拍攝資訊畫面(HUD)與轉盤上顯示自訂的 6 字元簡稱,例如 CL_NEG、NOSTAL,並在 Creative Look 選單列表中顯示 32 字元完整名稱,同時支援動態解析選單圖示(修正了早期 v4.33 誤將部分槽位索引至說明文字資源的問題,確保 ST 顯示原生 ST 圖示且其餘槽位動態對應正確圖示)。此外,它將「匯入自訂濾鏡」直接註冊為可指派的 Fn 快捷選單項目(項目 ID 0x26e / 0x1f8)。
  4. 出廠備份範本與自動白平衡微調: 一併修補了 original.tar 內全部 12 個地區的出廠設定備份範本(0110_backup/SYSIPSX-DSLR/GL/CX89200_*.bin 與 CX89201_*.bin):將 Creative Look 啟用位元遮罩(bitmask)從 10-bit(0x03ff)擴充為 12-bit(0x0fff),在偏移量 0x76c5d 填入 FL2 與 FL3 的 43-byte 預設參數紀錄,並將自動白平衡(AWB)調整為 White Priority = 97(0x61)與 Ambience Priority = -1(0xff),改善 Sony 原廠在室內暖光下容易偏冷或偏綠的傾向。

BIONZ XR (CXD90057) 的雙處理器與雙作業系統架構

要理解為什麼在相機裡「多加兩個濾鏡槽位」需要動到兩套截然不同的編譯器與修補管線,我們必須先看懂 Sony BIONZ XR(晶片代號 CXD90057)內部的異質雙處理器架構。

檢視從韌體解出的 Device Tree Blob(0800_appli/setting/cxd90057.dtb)、Linux 核心映像(0700_part_image/dev/nflasha3:/vmlinux.bin)以及開源授權清單(0650_prfile/PMBP/LICENSE/CP_TOPRS.txt),可以清楚看見 Sony 的「雙腦分工(split-brain)」設計——將「使用者介面與檔案管理」以及「硬體 ISP 即時影像管線」嚴格切分在兩顆不同架構的處理器與兩套作業系統上:

bionz-xr-architecture.svg

AP 應用處理器:64 位元 SCEL Linux 與 97.3 MB 的 appFw.so

第一顆處理器是應用處理器 AP (Application Processor),在 Device Tree 中宣告為 sony,cxd90057,內部搭載四核心 64 位元 ARM Cortex-A35 (arm,cortex-a35,ARMv8-A 架構) 叢集,並配備 arm,gic-400 中斷控制器、arm,pl011 UART、arm,pl022 SPI 與 arm,pl330 DMA 控制器。

從 nflasha3 分區內的 /vmlinux.bin 提取出的核心版本字串如下:

Linux version 4.9.337-scel (jp10239@dic-build-scm-u20-006)
(gcc version 6.4.0 (20201211 (Sony CE Linux 14.0.1.0)) )
#1 SMP PREEMPT Fri Oct 31 13:51:16 JST 2025

AP 運行的是簡稱 SCEL 的 Sony CE Linux 14.0.1.0。最令人印象深刻的是它的應用程式架構:在 nflasha15(一個大小為 410,652,672 bytes、約 391.6 MB 的 ext2 檔案系統分區)中,幾乎整台相機的 GUI 選單樹、轉盤與 Fn 事件派發、組態屬性儲存(對應 nflasha2 / nflasha6)、記憶卡 I/O 以及與底層硬體的 IPC 通訊,全部被編譯進單一一個高達 97,368,304 bytes(97.3 MB)的 64 位元 ARM64 ELF 共享函式庫——/lib/appFw.so。

0.5 秒瞬間開機的秘密:nflasha5 與 WBI2 休眠快照陷阱

拆解分區時,我們注意到一個非常關鍵的機制:如果讓一顆 Cortex-A35 從頭冷啟動 Linux 4.9 kernel、掛載 nflasha15 並動態連結載入 97.3 MB 的 /lib/appFw.so,開機時間至少需要好幾秒鐘,這對抓拍優先的專業相機來說是不可接受的。

為了達成撥開電源開關後「0.5 秒內進入可拍攝狀態」,Sony 在 nflasha5 分區(243,789,184 bytes,約 232.5 MB)中存放了一份預先壓縮好的記憶體休眠快照,開頭 8 bytes 為魔術字元 WBI2\x00\x00\x00\x00(即 Warm Boot Image 2,十六進位 57 42 49 32)。平常開機時,bootloader 完全跳過正常的 Linux 初始化流程,直接將 nflasha5 的 LZO 壓縮快照解壓縮回 DRAM,瞬間恢復已經載入好 appFw.so 的執行緒狀態!

這在韌體逆向工程中構成了一個極易踩中的陷阱:如果你只修改了 nflasha15 裡的 /lib/appFw.so 與 nflasha8 裡的 cpapp-b.bin,刷機重開後,相機依然會從 nflasha5 的 舊版 WBI2 休眠快照 醒來,導致 DRAM 裡跑的還是未修補的舊版 appFw.so,甚至因為與新版 CP 韌體狀態不一致而引發異常。

解決方案卻優雅得令人會心一笑——只需要在建置管線中修改 nflasha5 開頭 0x0 偏移量的 1 個 byte:

Offset 0x00000000:  57 42 49 32 ("WBI2")  ➔  77 62 69 32 ("wbi2")

把大寫 W(0x57)改為小寫 w(0x77)後,刷機完成後的 第一次開機 時,bootloader 檢查 nflasha5 發現 WBI2 魔術字元失效,便會自動放棄快速喚醒,改從 nflasha3:/vmlinux.bin 與 nflasha15:/lib/appFw.so 執行完整的冷開機(cold boot),並在開機完成後自動將包含新版修補程式的 DRAM 狀態重新寫入 nflasha5,產生全新的 WBI2 快照。從第二次開機起,相機就再次恢復 0.5 秒瞬間喚醒的速度。

CP 相機處理器:32 位元 Cortex-R5 與 TOPPERS/FMP RTOS

第二顆處理器是相機處理器 CP (Camera Processor),採用 32 位元 ARM Cortex-R5 (ARMv7-R 架構),上面運行的是由日本名古屋大學高田實驗室主導開發、符合 μITRON 規範的對稱多處理即時作業系統——TOPPERS/FMP。

CP 的韌體存放於 nflasha8(一個 24,531,968 bytes 的 FAT12 分區)內的 /CPAPP-B.BIN,檔案大小為 17,617,008 bytes(約 16.8 MB)。它是一個無檔頭的純二進位映像(flat binary),在開機時被直接載入至 CP 記憶體空間的基底位址 0x67800000。

CP 負責所有嚴格要求硬體即時性(hard real-time)的底層工作:控制 CMOS 感光元件讀出、驅動 BIONZ XR 硬體 ISP 管線、套用 gamma / tone 曲線與 5×9×9 3D HSV 色彩調整格,以及即時自動對焦(AF)、自動曝光(AE)與快門控制。

拆解 R5 v4.00 原廠 BODYDATA.DAT 與雙態 AES 加密層

要從零建置自製韌體,第一步是從 Sony 官方釋出的 R5 v4.00 更新檔 BODYDATA.DAT(754,525,608 bytes,SHA-256 為 51e1176a255a9a4b4782dfbcfa6c5f332b71173bdfe9ed2bcf4fca6bcbc6c2e2)解出乾淨的原廠分區與二進位基底。

外層 UFU 容器格式

BODYDATA.DAT 以 8-byte 魔術字元 \x89UFU\r\n\x1a\n 開頭(刻意仿照 PNG 檔頭的設計,可用於偵測 FTP / HTTP 文字模式傳輸造成的換行損毀),隨後是由 big-endian 的 (uint32 size, char kind[4]) 組成的五個 chunk:

  1. DATV (4 bytes):容器格式版本號(0x00010000)。
  2. PROV (4 bytes):provisioning 旗標。
  3. UDID (20 bytes):目標機種與更新識別碼。
  4. FDAT (754,525,456 bytes):加密後的韌體本體,由 736,841 個 1,024-byte 加密區塊(共 754,525,184 bytes)加上尾端 272 bytes trailer (16-byte AES-CBC IV + 256-byte RSA-2048 數位簽章) 組成。
  5. DEND (4 bytes):big-endian CRC32 校驗碼,涵蓋從檔案偏移 0 一直到 DEND chunk header 之前的所有位元組。

FDAT 的 1,024-Byte 區塊結構與雙態 AES 加密 (CXD90057_k8)

FDAT 內部的明文串流總長度為 751,577,088 bytes。在打包加密時,Sony 將明文每 1,020 bytes 切為一塊,並在前面加上 4 bytes 的標頭,組成固定 1,024 bytes 的區塊:

struct FdatBlock {
    uint16_t sum16;       // Little-endian: 後續 511 個 uint16_le words 的無號總和 (& 0xFFFF)
    uint16_t sizeAndEnd;  // 低 15 bits 為有效 payload 長度 (1..1020);
                          // Bit 15 (0x8000) 在最後一個區塊 (#736,841) 設為 1,其餘為 0
    uint8_t  payload[1020]; // 不足 1,020 bytes 處以 0xFF 填充
};

接著對這 736,841 個區塊(754,525,184 bytes)進行兩階段加密:

  • 前 512 bytes (Block 0 的前半部):使用 Sony 外層通用金鑰(constants.key_aes)以 AES-128-ECB 模式加密。
  • 剩餘所有位元組 (Offset 512 .. 754,525,184):使用 BIONZ XR 世代專屬金鑰(constants.key_cxd90057_k8)與存放在 signature_offset - 16 處的 16-byte IV,以 AES-CBC 模式加密。

當我們解開全部 736,841 個區塊、驗證每個區塊的 sum16 並剝除 4-byte 標頭後,重組出的 751,577,088 bytes 明文串流(original.fdat)包含三個緊密相連的區段:

  1. Bytes 0 .. 512 (FdatHeader):記錄機種代號 model = 0x11030080、region = 0、版本號 versionMajor = 4、versionMinor = 0x00(自製韌體將偏移 0x20..0x21 改為 BCD 0x34, 0x04 代表 v4.34)、fs.offset = 512、fs.size = 1,253,376、firmwareOffset = 1,253,888、firmwareSize = 750,323,200,以及位於偏移 8..12、涵蓋 bytes 12..512 的標頭 CRC32。
  2. Bytes 512 .. 1,253,888 (updater.img):大小 1,253,376 bytes 的 SquashFS 4.0 映像檔,內含更新流程腳本 /bin/startupdate.sh、/bin/endupdate.sh、/bodylib/libupdaterbody.so 以及 CP 更新韌體 /cp/udtrcp.bin。
  3. Bytes 1,253,888 .. 751,577,088 (original.tar):大小 750,323,200 bytes 的 POSIX tar 封裝檔,內含各個 NAND 分區映像與校驗檔。

我們從 BODYDATA.DAT 依序解出全部 9 個原廠輸入檔案,並與專案的 firmware/r5/inputs.lock.json 進行 SHA-256 比對,100% 吻合:

輸入鍵值 (Input Key) 檔案名稱 大小 (Bytes) SHA-256 雜湊值
original_dat original.DAT 754,525,608 51e1176a255a9a4b...bc6c2e2
original_fdat original.fdat 751,577,088 b3af8557d6279d8b...cfcb0ff
original_tar original.tar 750,323,200 8b03700edb87d293...6565813
updater updater.img 1,253,376 c4b18a82eea52a50...dcce4a9
stock_nflasha5 nflasha5 243,789,184 15020d1afe7f1cf4...1363e8
stock_nflasha8 nflasha8 24,531,968 a625468fbbd6b0e0...1f8f46
stock_nflasha15 nflasha15 410,652,672 197fc934db37ed9b...638fa5
stock_ap appFw.so 97,368,304 41bd8f4cda1f44c7...9ce7c7
stock_cp cpapp-b.bin 17,617,008 3269bf6242633979...7ee7c8

深入 AP 修補工程:appFw.so 的 40.6 KB Code Cave 與 17 處 Branch Hooks

AP 端的建置管線(apply_recipe + build_ap_image.py + build_ap_module.py)對 nflasha15 內的 /lib/appFw.so 執行了兩階段改造:

第一階段:基底 FL2 / FL3 配方修補 (ap_baseline)

首先套用 baseline_recipes.json 中的 42 處二進位修補,將 appFw.so 的檔案大小從原廠的 97,368,304 bytes 擴充至 97,452,032 bytes(在 ELF 可執行區段增加了 +83,728 bytes),打通原廠 Creative Look 表格中對 FL2 與 FL3 的基礎支援。

第二階段:C / ARM64 執行期模組注入 (build_ap_module.py)

接著將 3 個 C 語言原始碼檔案(r5_stage413_transfer_core.c、r5_filter_Menu.c、r5_filter_renderer423.c)與 2 個 ARM64 組語跳板檔(r5_stage413_trampoline.S、r5_filter_menu_trampoline.S)編譯為位置無關(PIC)的執行期 payload,並精準釘選(pin)在 appFw.so 的兩個 ELF 虛擬位址區段:

  • RX code cave (可執行程式碼區段):位於虛擬位址 vaddr = 0x03c97ef8(對應檔案偏移 file_offset = 0x03c97ef8),模組本身佔用 40,600 bytes(連同第一階段基底 cave 合計為 63,096 RX bytes)。
  • BSS state region (執行期狀態緩衝區):位於虛擬位址 vaddr = 0x05cf18c0,大小為 136,384 bytes(0x214c0 bytes),用於在 RAM 中存放從 SD 卡解析出來的 .BIN 濾鏡包、當前啟用的 6/32 字元外觀名稱,以及準備傳送給 CP 的 IPC 傳輸緩衝區。

為了讓原本 appFw.so 的生命週期與選單事件流進這塊 code cave,建置腳本在 appFw.so 內植入了 17 處 ARM64 B / BL call-site hooks:

Hook 名稱 檔案偏移量 (File Offset) 原廠指令 (Hex) 修補後跳轉指令 (Hex) 攔截時機與用途
startup 25,170,660 (0x18012a4) 60a26e39 0544da15 系統啟動時跳入初始化跳板
initial 55,422,568 (0x34daae8) 60c21091 69dd6615 Creative Look 子系統初始狀態建立
completed 55,464,256 (0x34e4dc0) 60c21091 fab46615 屬性載入完成後同步自訂濾鏡狀態
media 54,279,380 (0x33c38d4) a003168b 5b3a6b15 監控 SD 卡掛載狀態(/mnt/sd0、/mnt/sd1)
inbound 55,921,996 (0x35549cc) 20008052 86f66415 攔截並注入跨核心 IPC 濾鏡資料
power 56,306,296 (0x35b26f8) fd7bbba9 a57f6315 電源關閉或進入休眠前保存狀態
menu / UI hooks (×11) 多處 UI 分派點 原生選單指令 跳轉至 r5_filter_Menu 註冊 Fn 項目、繪製 6/32 字元名稱、查詢 v4.34 圖示 (icon_query434) 等 11 處選單攔截點

原地 Ext2 區塊整合 (integrate())

由於 nflasha15 是一整個 410,652,672 bytes 的 ext2 檔案系統映像,把變大後的 appFw.so(97,452,032 bytes)寫回 nflasha15 時不能隨便重新打包整個檔案系統(否則 inode 順序或時間戳記改變可能引發非預期行為)。

建置腳本透過 nflasha15_layout 先為 /lib/appFw.so 的 inode 擴充對應的資料區塊鏈(block chain),接著 integrate() 直接將新版 appFw.so 的內容覆寫進對應的 ext2 data blocks,並在最後執行嚴格的位元級斷言:確保 nflasha15 分區中除了 /lib/appFw.so 佔用的區塊與對應的 ext2 metadata 之外,其餘每一個 byte 都與原廠映像 100% 完全相同。

深入 CP 修補工程:cpapp-b.bin 表格重定位與 Unicorn 虛擬 CPU 模擬

看完了 AP 端,接著來看更刺激的 CP 端(nflasha8:/CPAPP-B.BIN,基底位址 0x67800000)。

為什麼必須重定位 12 組 Look 參數表?

在原廠 R5 v4.00 的 cpapp-b.bin 中,CP 將 10 組 Creative Look(ST..SE)的 ISP 參數存放在固定長度為 10 的連續陣列裡,主要包含三大結構:

  • s_kelvin 與 s_adobekelvin 色溫補償表(每組 0xDC bytes,10 × 0xDC = 2,200 bytes)。
  • tone / gamma / 色彩矩陣結構(每組 0x16B4 = 5,812 bytes)。
  • 5×9×9 3D HSV 色彩調整格:每組 184 bytes,由 5 個明度階層 luma levels × 9 個飽和度環 saturation rings × 9 個色相扇區 hue sectors 組成。

問題在於:在 cpapp-b.bin 的記憶體佈局中,這幾個 10-entry 陣列的屁股後面緊接著其他關鍵的 RTOS 全域變數!如果我們直接在原位址把陣列從 10 組硬撐到 12 組(+FL2, +FL3),多出來的 2 組資料就會直接蓋掉後面的 RTOS 全域變數,導致相機一開機就崩潰。

因此,建置管線(build_cp.py)採取了 完整重定位 (table relocation) 策略:

  1. 在 cpapp-b.bin 後段找到一塊乾淨的未使用記憶體區段 0x688df740(其中可執行區段位於 RX = 0x68995080,包含 7,308 bytes 的 C 執行期程式碼 cp_transfer.c / cp_stage413_probe.c,以及 522,152 bytes 的擴充表格與狀態空間)。
  2. 將擴充為 12 組的完整 look 參數表搬移至 0x688df740。
  3. 自動掃描整個 cpapp-b.bin,一舉修補了 167 處 ARM32 指令——包含將所有查表函式的 literal-pool 基底位址改指到 0x688df740 新表,以及將所有的陣列邊界檢查從 cmp rX, #10 修改為 cmp rX, #12。

刷機前的虛擬 CPU 守門員:Unicorn Engine (getter_gates.py)

一口氣修改了裸機 RTOS 裡的 167 處 ARM32 指令,要怎麼確定沒有任何一條指令改錯、或者不小心破壞了呼叫慣例(calling convention)?

這套專案設計了一道極為嚴謹的防線:src/gamma/getter_gates.py。在每次打包韌體前,它會使用 Unicorn Engine 建立一顆 32 位元 ARM 虛擬 CPU(UC_ARCH_ARM, UC_MODE_ARM),將 17.6 MB 的 cpapp-b.bin 實際映射載入至虛擬位址 0x67800000:

  1. 啟用硬體浮點與向量協同處理器 (VFP / NEON): 因為 CP 的色彩查表與插值函式內部使用了 ARM VFP 指令,模擬器在啟動時會先透過協同處理器暫存器啟用 CP10 與 CP11:
    mrc p15, 0, r0, c1, c0, 2
    orr r0, r0, #0x00f00000   ; 啟用 CP10 / CP11 (VFP/NEON 完全存取權限)
    mcr p15, 0, r0, c1, c0, 2
    mov r0, #0x40000000       ; EN bit in FPEXC
    vmsr fpexc, r0
  2. 遍歷模擬 3 種狀態 × 12 組 look 索引: 模擬器直接設定 PC 呼叫 cpapp-b.bin 內真實的 getter 函式(如 0x684b5148、0x684b5220、0x684b527c 等),針對 look 索引 1..12 依序測試三種執行期狀態:
    • 狀態 A:cold boot 未初始化預設狀態
    • 狀態 B:載入內建 FL2 / FL3 預設值狀態
    • 狀態 C:由 SD 卡 .BIN 濾鏡覆寫全部 12 組槽位狀態
  3. 位元級與暫存器完整性斷言:
    • 針對索引 1..10(原廠 10 種外觀),比對修補後韌體回傳的結構體每一個 byte,必須與未修改的原廠 cpapp-b.bin 100% 逐位元一致。
    • 針對索引 11..12(FL2、FL3),驗證回傳的指標與內容精準對應 0x688df740 重定位表中的資料。
    • 檢查函式返回時的 callee-saved 暫存器(r4..r11 與 sp),確保堆疊與暫存器零污染。
unicorn-emulation-gate.svg

Windows + LLVM Clang 23.1.4 本地建置調適與跨工具鏈比對

上游專案原本是在 macOS 環境下使用 Apple Clang 21(clang 21.1.x)開發,並在腳本中寫死了 /usr/bin/clang 與 /usr/bin/openssl 等路徑。當我們將建置管線移植到 Windows 11 並使用 LLVM Clang 23.1.4 與 uv(cpython-3.12.11)執行本地編譯時,解決了三個關鍵的跨平台與編譯器差異:

  1. Clang 23 新增的全域變數靜態分析警告: LLVM Clang 23 引入了更嚴格的 -Wunused-but-set-global 檢查,在開啟 -Werror 時會攔截 r5_filter_renderer423.c:23 中的診斷計數器 static uint32_t allocation_failures。我們在 build_ap_module.py 與 build_cp.py 的交叉編譯旗標中補上了 -Wno-unused-but-set-variable 與 -Wno-unused-but-set-global。
  2. 0701_part_image_sum/part_image.sum 的 454-Byte CRLF 陷阱: 在打包 original.tar 前,build.py 會重新計算 dev/nflasha5、dev/nflasha8 與 dev/nflasha15 的 CRC32 校驗碼並更新 part_image.sum。因為該建置管線採用原地覆寫 tar 成員的策略,part_image.sum 的檔案大小必須嚴格維持在 454 bytes。 在 Windows 上,Python 的 Path.write_text() 預設會將換行符號 \n 轉換為 \r\n(CRLF),導致這份 6 行校驗檔瞬間膨脹為 460 bytes 並觸發 tar 區塊大小 AssertionError!將寫入方式改為 sums.write_bytes(text.encode('ascii')) 後,便完美保持了 UNIX LF 的 454 bytes 結構。
  3. Apple Clang 21 與 LLVM Clang 23 的產出比對: 我們將 Windows + LLVM Clang 23.1.4 編譯出的成品與原作者 macOS + Apple Clang 21 的參考輸出進行了逐檔案比對:
    • 所有非編譯型資料產出(包含 nflasha5、12 個地區的 CX8920*.bin 出廠備份範本與 backup.sum)的 SHA-256 雜湊值 100% 相同,例如 nflasha5 皆為 eac727efd7af2142ddd9b8ae22387614eaaaba9dc9f04ce0434bf8e7c766d693。
    • 經過 C 編譯器產出的 appFw.so(97,452,032 bytes)與 cpapp-b.bin(17,617,008 bytes)不僅檔案大小完全一致,內部的 RX / BSS 區段尺寸(AP 端為 40,600 B / 136,384 B;CP 端為 7,308 B / 522,152 B)與 17 個 hook 跳板位址也分毫不差,僅在 C 函式內部有 Clang 21 與 Clang 23 之間的微幅指令排程與暫存器分配差異,且全數通過 Unicorn 模擬測試。

密碼學簽章驗證、Partition 1 (nflasha1) 信任鏈與 Android Fastboot

當我們修改完 nflasha5、nflasha8 與 nflasha15,並重新打包、以 CXD90057_k8 加密出 736,841 個 FDAT 區塊後,就來到了整個韌體工程最後、也最關鍵的一道安全防線:數位簽章驗證。

為什麼不能直接把改好的 BODYDATA.DAT 放進原廠相機更新?

在 BODYDATA.DAT 的 FDAT 區段最尾端(檔案偏移 754,525,332,緊接在 736,841 個加密區塊與 16-byte IV 之後),存放著一段 256 bytes 的 RSA-2048 數位簽章,採用 RSASSA-PKCS1-v1_5 填充與 SHA-256 雜湊,簽章範圍涵蓋了前面整整 754,525,200 bytes 的加密串流與 IV。

當你在原廠未改機的 R5 上透過 SD 卡執行韌體更新時,相機其實是重新開機進入 Partition 1 (nflasha1) 的更新環境:

  • nflasha1 是一個 25,100,288 bytes(約 23.9 MB)的 FAT12 復原分區,裡面放著 /boot/initrd.img(一個以 zlib 壓縮的 SquashFS 4.0 根檔案系統)以及記錄檔案 MD5 的 /p1chksum。
  • 在 /boot/initrd.img 內部,/etc/VerificationKey.pem(451 bytes,SHA-256 為 8281413b5607446939613e07d020b2c7d8ee2371349099de56bc8288f7be0a43)存放著 Sony 官方的 RSA-2048 公鑰。
  • 更嚴密的是,initrd.img 裡負責韌體更新的四個核心共享函式庫:
    • /usr/lib/libupdaterapi.so
    • /usr/lib/libsdfileinputupdaterapi.so
    • /usr/lib/libseninputupdaterapi.so
    • /usr/lib/libuudfileinputupdaterapi.so 在執行更新時會進行 雙層驗證:
    1. 第一層公鑰指紋驗證 (_ZN7Updater21SignatureVerification9verifyKeyEPKc):即 Updater::SignatureVerification::verifyKey(char const*),先計算 /etc/VerificationKey.pem 的指紋,檢查它是否等於寫死在 .so 裡的 Sony 官方公鑰雜湊!這意味著就算你只換掉 .pem 公鑰檔,第一層檢查也會直接報錯拒絕。
    2. 第二層密碼學簽章驗證 (_ZN7Updater21SignatureVerification15VerifySignatureEPKhjS2_j):使用 /etc/VerificationKey.pem 對 BODYDATA.DAT 的 FDAT 區段執行標準的 RSA-2048 PKCS#1 v1.5 SHA-256 數位簽章驗證。

一次性硬體引導:RP2040 UART 與隱藏在相機裡的 Android Fastboot v0.5

因為全世界只有 Sony 擁有對應的 RSA-2048 私鑰,任何想要透過 SD 卡刷入自簽章 BODYDATA.DAT 的相機,都必須先透過硬體除錯介面完成一次性的 Partition 1 (nflasha1) 公鑰替換。

fastboot-trust-chain.svg

這套硬體引導流程的底層機制最早由安全研究員 David Buchanan 在 DavidBuchanan314/ILCE-7M4-RE 中發掘,完整運作過程如下:

  1. 透過 Sony Multi-port UART 進入暫時性 SRAM Fastboot: 使用 Sony Multi-port 轉接板連接至一塊刷有 uart_boot 韌體的 RP2040(接線為 GPIO28 ➔ RX、GPIO29 ➔ TX、GPIO27 ➔ Reset,初始握手鮑率為非標準的 48000 baud),同時將相機 USB-C 接上電腦。在重置瞬間,主機透過 UART 將 Sony 官方簽章過的 BootROM SRAM loader(fastboot.bin)串流寫入相機內部 SRAM 位址 0x92028000。隨後 UART 切換至 115200 baud 並印出:

    [mira] fastboot ready
  2. Sony 的 mira loader 採用標準 Google Android Fastboot v0.5: 當 mira 啟動 USB 控制器後,電腦端看到的 USB 裝置識別碼竟然是 Google 官方的 Android Fastboot ID:

    • VID = 0x18d1 (Google Inc.)
    • PID = 0x4ee0 (Android Fastboot)
    • bInterfaceClass = 0xFF、bInterfaceSubClass = 0x42、bInterfaceProtocol = 0x03

    透過 USB bulk endpoint 發送 getvar:version 會直接回傳 OKAY0.5。它不僅完整支援標準 Android Fastboot 指令(getvar:partition-size:nflasha1、download:<hex_len>、upload、flash:nflasha1、reboot)與標準的 OKAY / FAIL / INFO / DATA%08x 封包協定,甚至原生支援 Android Sparse Image 格式(魔術字元 0xed26ff3a,v1.0,512-byte 磁區區塊,使用 0xCAC1 RAW chunk 寫入有變動的磁區、以 0xCAC3 DONT_CARE chunk 跳過未修改磁區)! 此外,Sony 工程師還在 mira 裡實作了三個專屬的 oem 擴充指令:

    • oem partition:列出內部 SDM2 eMMC 上全部 17 個分區(nflasha1 .. nflasha17)。
    • oem partition dump nflasha1(搭配 upload):將 Partition 1 讀取至 RAM 並透過 USB 完整傾印回電腦。
    • oem dumpbrom:傾印 CXD90057 的晶片內部 BootROM。

原地修補 nflasha1 的 8-Byte verifyKey() 與 Sparse Image 回寫

取得相機傾印出的 nflasha1 映像後,build_r5_partition1.py 與 build_r5_verifykey_skip.py 會在不改變分區與壓縮區塊幾何結構的前提下進行原地修補:

  1. 替換公鑰:將 initrd.img 內的 /etc/VerificationKey.pem(451 bytes)替換為使用者自己生成的 RSA-2048 public.pem(同樣為 451 bytes)。
  2. 修補 4 個 updater .so 的 verifyKey() 函式入口: 將 _ZN7Updater21SignatureVerification9verifyKeyEPKc 開頭的 8 bytes(兩條 ARM64 指令)替換掉:
    ; 修補前 (Before): fd 7b b2 a9  e0 03 01 aa
    stp x29, x30, [sp, #-0x50]!
    mov x0, x1
    
    ; 修補後 (After):  00 00 80 52  c0 03 5f d6
    mov w0, #0    ; 永遠回傳 0 (代表自訂公鑰指紋合法)
    ret
    這個 8-byte 修補讓 verifyKey() 認可我們放入的自訂 public.pem,同時 完整保留了 VerifySignature() 的所有密碼學檢查,因此相機在透過 SD 卡刷機時,依然會嚴格驗證 BODYDATA.DAT 的 RSA-2048 數位簽章。
  3. 等長重壓縮與 233 磁區 Sparse Image 刷寫: 腳本只重新壓縮 initrd.img 中受影響的 zlib SquashFS fragments,並精確控制壓縮參數使其與原始 fragment 的 byte 長度完全相同;接著更新 /p1chksum 裡的 initrd.img MD5,最後將 25.1 MB 的 nflasha1 中實際發生變動的 233 個 512-byte 磁區封裝成一個不到 125 KB 的 Android Sparse Image,透過 flash:nflasha1 瞬間寫回相機。

本地生成自簽章 BODYDATA.DAT 與 100% 解密驗證

在完成所有分析與工具鏈調適後,我們在本地生成了一組全新的 2048-bit RSA 金鑰對(e = 65537):

  • 私鑰 private.pem(1,679 bytes),用於簽署自製 BODYDATA.DAT。
  • 公鑰 public.pem / VerificationKey.pem(451 bytes,SHA-256 為 9b2ab38babd06c8aea18a4064ddc7bbd4dd9014ed14c16db369df8363fc229c8),用於注入 nflasha1。

接著我們執行完整的 --stage all 自動化建置與簽章管線:

uv run python firmware/r5/build.py --inputs firmware/r5/inputs.local.json --output D:/code/r5-build-all --stage all

整條管線依序完成了以下工作並產出完整驗證報告(D:\code\r5-build-all\verification.json):

  1. 編譯並整合雙處理器映像:產出修補後的 appFw.so、cpapp-b.bin、nflasha5(WBI2 ➔ wbi2)、nflasha8、nflasha15、part_image.sum(精確 454 bytes)以及 12 個地區的 CX8920*.bin 備份檔與 backup.sum,並全數通過 Unicorn Engine 虛擬 CPU 模擬閘門。
  2. 封裝明文 FDAT 串流:組裝出 751,577,088 bytes 的明文 FDAT 映像(SHA-256 為 a46c6bb0811064cec9dce08b47deee40116ea5d51f255faf26d8814684361635)。
  3. 雙態 AES 區塊加密與 RSA-2048 簽章:將明文切分為 736,841 個 1,024-byte 區塊(754,525,184 bytes),以 AES-ECB + AES-CBC(CXD90057_k8, iv) 加密,使用 private.pem 對加密內容與 IV 產生 256-byte RSASSA-PKCS1-v1_5 / SHA-256 簽章,最後計算最外層 DEND 的 CRC32,產出最終的 BODYDATA.DAT(754,525,608 bytes,SHA-256 為 b267a02ba29a7c8f84cbd38fa11413c903772f73d64ffd34e2a61129533cd0f6)。
  4. 雙向密碼學與解密完整性驗證:
    • 同時透過 PyCryptodome 與獨立的 openssl pkeyutl -verify 驗證 BODYDATA.DAT 尾端的 256-byte RSA-2048 簽章,確認回傳 Signature Verified Successfully,並測試竄改簽章後能被正確拒絕(tampered_signature_rejected: true、outer_crc_valid: true)。
    • 將產出的 BODYDATA.DAT 重新丟回解密器,獨立解開全部 736,841 個區塊,確認解出的每一顆 byte 都與打包前的明文分區 100% 吻合。

結語

回頭看這次對 Sony A7R V 第三方韌體 v4.34 的拆解與本地建置,最讓我著迷的不只是最終在相機上多出了 FL2、FL3 與 12 組 Fujifilm 軟片模擬,而是得以深入現代高階數位相機內部的系統工程設計:

  • 一顆 CXD90057 SoC 同時跑著四核 64 位元 SCEL Linux(負責 97.3 MB 的 /lib/appFw.so 單體應用)與 32 位元 Cortex-R5 TOPPERS/FMP RTOS(在 0x67800000 負責硬體即時 ISP 管線),並透過 nflasha5 的 WBI2 LZO 記憶體休眠快照達成 0.5 秒瞬間開機。
  • 面對高風險的雙架構二進位修改,透過原地 ext2 區塊替換、重定位 12 組 ISP 參數表至 0x688df740,並結合啟用 VFP/NEON 的 Unicorn Engine 虛擬 CPU 模擬閘門,將傳統嵌入式修改「刷上去才知道會不會磚」的不確定性,轉化為刷機前就能 100% 自動化驗證的工程管線。
  • 而在底層 BootROM 的 mira SRAM loader(0x92028000)中遇見熟悉的 Google Android Fastboot v0.5(0x18d1:0x4ee0)與 Sparse Image 協定,並只用 8 bytes(mov w0, #0; ret)優雅地將自己的 RSA-2048 金鑰對接入 nflasha1 信任鏈,更是跨領域逆向工程中那種「原來底層都是相通的」最大樂趣。