相機創意外觀與硬體 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 的最佳「數位負片」。

硬體 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}$(中性灰階無色偏,維持光子通量比例)

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

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

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

 階段 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 時溫和壓縮高光動態範圍,模擬大片幅膠卷在過曝時銀鹽晶粒逐漸耗盡的優雅軟滾降。
 階段 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}$$

為什麼 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)的前提下,硬體對局部邊緣梯度進行非線性增益放大,並在低對比度紋理處施加微幅提升,讓織物毛髮、石磚紋理在維持柔和色彩的同時,依舊保有清晰可辨的輪廓觸感。

 階段 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
風格外觀預設:
Stage 0 RAW 線性光負片
Stage 1 (cc1) 白平衡與黑基準
Stage 2 (block13) 16 扇區色彩矩陣
Stage 3 (gamma) 129 點分段樣條
Stage 4 (grid) 5×9×9 3D 網格
Stage 5 (filter) 微對比與細節直出
測試場景:
檢視模式:
即時階段渲染畫布 (Live Stage View) 900 × 600 (3:2) · 16-bit 浮點渲染
階段硬體幾何與數值解剖儀 (Hardware Mathematical Inspector) 即時向量幾何拓撲可視化 (800 × 420 HD)
Stage 0: 感光元件線性場景輻射率 (Sensor Linear Radiance)
Linear Radiance: (R, G, B) ∈ [0.0, 2.2+]
感光元件 RAW 讀出之場景線性光子通量資料(電腦端模擬以 16-bit 線性 JXL 載入)。此時尚未進行白平衡與非線性曲線壓縮,數值嚴格與物理進光量成正比。高光區域包含未截斷的擴展動態範圍。
白平衡色溫 (WB Temp) 5500 K
暗部抬升 (Pedestal Lift) 2.5%
中灰對比坡度 (Contrast) 2.20×
3D 網格陰影冷調 (Teal Delta) +12 Δ
微對比清晰度 (Clarity) +0.45×

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

當我們將上述硬體管線套用於實際影像時,不同創意外觀在各階段參數上的微小差異,會產生風格截然不同的化學反應。以下特別對比現代相機硬體韌體逆向工程中提取的真實 FL2、FL3 數據:

風格外觀 (Creative Look) 129 點曲線特性 (gamma[129]) 16 扇區矩陣傾向 (block13) 5×9×9 3D 色彩網格特色 (grid[3][405]) 視覺調性與直出氛圍
ST (Standard) 標準 S 型曲線,微幅暗部壓制,高光維持線性比例 近似標準 Rec.709 原色映射,各扇區差分接近零 全零網格增量 ($\Delta \vec{C} = 0$) 寫實自然,色彩精準,適合日常紀錄與標準檢視
FL (Film Look) 暗部抬升 2.5%(啞光灰黑),中灰對比略為增強(坡度 2.2) 天空藍色相扇區朝青色偏移約 $8^\circ$,暖黃色微降飽和 陰影綠色飽和度抑制,人像高光橘微增明度 沉穩平靜的日系膠卷感,陰影具備呼吸感,膚色通透
FL2 (Film Look 2) 暗部微抬升 3.2%(gamma[1] 為 1158),中灰反差收斂(坡度 1.88),高光肩部極其平緩(Shoulder 0.78) 植被綠扇區朝復古橄欖綠旋轉,全色域微幅降低飽和,藍天扇區轉為啞光墨青 陰影冷調青綠適度收斂(+8),強化中灰藍綠色彩純淨分離 柔和低飽和的懷舊電影質感,高光溫潤不刺眼,暗部具備厚實片基啞光感
FL3 (Film Look 3) 中低光暗部大幅拉升(gamma[32] 達 40,624,空氣通透感),高光展現開闊線性階調 藍天扇區大幅提亮並維持湛藍純度($\Delta B > 0$),暖黃與膚色扇區粉潤清麗 陰影清透微冷(+6),不偏濁綠,明度動態範圍通透開闊 明亮清爽的高調日系膠卷氛圍,天空湛藍通透,人像氣色白皙粉嫩
Classic Neg (自訂模擬) 極硬的中間調對比(坡度 2.9),暗部深度深邃,高光顯著壓暗 綠色通道大幅往藍綠旋轉,紅色通道微偏洋紅 暗部顯著增加青色增量,人像橘黃維持獨立亮度保護 經典街頭人文負片感,硬調反差強烈,色彩厚重富戲劇張力

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 信任鏈,更是跨領域逆向工程中那種「原來底層都是相通的」最大樂趣。

從原始碼編譯與客製化 Ioskeley Mono

解剖 Iosevka 參數化造字管線、Patel 幾何引擎與 Nerd Font 封裝實戰
featured.svg

在上一篇〈Berkeley Mono 與 Ioskeley Mono〉中,我們深入比較了 U.S. Graphics Company 的商業字體 Berkeley Mono (TX-02) 與開源社群基於 Iosevka 打造的 Ioskeley Mono,並透過線上互動實驗室對照了兩者在點狀 0、單層 g、平弧括號 () 以及 Normal / SemiCondensed 字寬上的細節表現。

然而,當我把 Ioskeley Mono 換成日常主力字體、在編輯器與 terminal 裡寫了一整天程式碼後,心裡突然冒出一個小小的念頭:如果我想讓數字 7 的斜桿正中央多加一道水平橫槓(crossbar),就像歐洲手寫體或老式工程圖紙上的 7 那樣,有沒有辦法自己動手改?

如果今天面對的是一般用 FontForge 或 Glyphs 手工繪製的傳統字體,這個念頭大概會在五分鐘內被打消——因為一個完整的現代程式字體家族包含 10 種字重(Thin 100 到 Black 900)、2 種字寬(Normal 與 SemiCondensed)以及正體與斜體,光是一個家族就有 40 個 .ttf 字型檔。手動去改 40 個檔案的貝茲曲線,不僅筆畫粗細與斜率難以對齊,還會破壞原有的 TrueType hinting 與連字表。

但 Ioskeley Mono 最迷人的地方就在這裡:它不是一套靜態的向量圖檔,而是一座由程式碼驅動的參數化造字工廠。 昨晚我在 herdr 的 ioskeley-mono workspace 裡,直接從零複製儲存庫、修改一行設定、一路追蹤到底層的 Patel (.ptl) 幾何語言,並寫腳本用 8 個平行 worker 打上完整的 Nerd Font 圖示補丁。這篇文章就來完整紀錄這趟從原始碼編譯與客製化字體的實戰過程,以及途中踩到的兩個精彩陷阱。

為什麼從原始碼編譯字體比想像中更有趣?

打開 ahatem/IoskeleyMono 的儲存庫會發現,它本身並不儲存成千上萬個字元的曲線座標,核心只有一份不到 400 行的 private-build-plans.toml 藍圖檔,搭配上游的 Iosevka 造字引擎(固定於 v34.4.0)。

當我們從原始碼建置時,整個流程其實是一條橫跨編譯器、計算幾何與二進位封裝的八階段管線:

ioskeley-build-pipeline.svg

藍圖結構:四個繼承自同一核心的字體家族

在 private-build-plans.toml 中,作者透過 Iosevka 的繼承機制(inherits = "buildPlans.IoskeleyMono")定義了四個編譯目標(build plans):

  1. 標準版 IoskeleyMono:設定 spacing = "normal"、serifs = "sans" 與 noCvSs = true,包含完整的程式碼連字規則(calt)、10 種字重、2 種字寬(Normal = 600、SemiCondensed = 540)與 11.8° 斜體。
  2. 終端機版 IoskeleyMonoTerm:繼承自標準版,唯一關鍵差異是設定 spacing = "term"。這會強制將所有箭頭、幾何方塊與 box-drawing 符號嚴格限制在單一 terminal 字元格(character cell)寬度內,避免在 Ghostty、Kitty 或 Alacritty 中破格。
  3. 無連字版 IoskeleyMonoNL:設定 noLigation = true,供無法單獨關閉連字的編輯器(如舊版 Xcode)使用。
  4. 網頁子集版 IoskeleyMonoWeb:透過 [buildPlans.IoskeleyMonoWeb.subset.include] 僅保留拉丁字母、標點、箭頭、數學運算子與框線符號的 Unicode 區間,將原本完整版每個約 480–550 KB 的 .woff2 透過字元集子集化(subsetting)大幅縮減至約 94 KB。

此外,所有家族都共享一組精心調校的度量參數(private-build-plans.toml 第 201 至 223 行):

[buildPlans.IoskeleyMono.metricOverride]
xHeight = 520               # 小寫字母高度
cap = 690                   # 大寫字母與數字高度 (CAP)
ascender = 740              # 上伸部高度
sb = 85                     # 左右邊距 (Sidebearing)

accentWidth = 182           # 變音符號寬度與間距參數
accentClearance = 76
accentHeight = 162
accentStackOffset = 208

leading = 1250              # 預設行距
parenSize = 860             # 括號垂直跨度
dotSize = 'blend(weight, [100, 110], [400, 125], [900, 150])'
periodSize = 140

essRatio = 1.03             # 微調 S / s 與問號中央轉折處的水平加寬比例
essRatioUpper = 1.03
essRatioLower = 1.03
essRatioQuestion = 1.03
archDepth = 152
smallArchDepth = 157

注意看 dotSize 那行:blend(weight, [100, 110], [400, 125], [900, 150]) 是一個隨字重動態插值的函數,確保在極細體(Thin 100)時標點與 i 的方點不會太小而消失,而在極粗體(Black 900)時又不會過度膨脹糊成一團。

本地端建置環境準備

要在 Linux 本地端從頭編譯 Ioskeley Mono,只需要準備 Node.js 20+(上游 package.json 最低需求為 >=16.0.0,官方 CI 採用 Node.js 20)與 ttfautohint(用於產生標準解析度螢幕所需的 TrueType hinting 指令):

# 1. 複製 Ioskeley Mono 儲存庫
git clone [email protected]:ahatem/IoskeleyMono.git ioskeley-mono
cd ioskeley-mono

# 2. 複製上游 Iosevka 引擎(鎖定 v34.4.0 確保變體名稱與度量完全一致)
git clone --depth 1 --branch v34.4.0 https://github.com/be5invis/Iosevka.git iosevka-src

# 3. 確認系統已安裝 ttfautohint 與 fontforge(後續打 Nerd Font 補丁需要)
sudo pacman -S --needed ttfautohint fontforge python-fonttools

為什麼一定要鎖定 Iosevka v34.4.0?因為上游 Iosevka 的 main 分支演進速度極快,有時會重新命名部分字形變體或調整內部幾何常數;固定在 v34.4.0 可以確保我們編譯出來的結果與官方發行版完全一致。

一行設定的魔法:為數字 7 加上水平橫槓

現在回到我們最初的目標:把數字 7 改成中間帶有水平橫槓的版本。

打開 private-build-plans.toml 的第 15 行,在 [buildPlans.IoskeleyMono.variants.design] 區塊中可以看到上游預設使用的是無襯線直桿 7:

[buildPlans.IoskeleyMono.variants.design]
one = "no-base"
two = "straight-neck-serifless"
three = "flat-top-serifless"
four = "semi-open-serifless"
five = "oblique-flat-serifless"
six = "open-contour"
seven = "straight-serifless"             # <-- 上游預設:無橫槓的俐落直桿 7
eight = "two-circles"
nine = "open-contour"
zero = "dotted"

在 Iosevka 的命名系統中,數字 7 的變體名稱是由三個正交維度組合而成的:[主幹形狀 body]-[左上角襯線 serif]-[中段橫槓 overlay]:

變體名稱 (seven = ...) 主幹形狀 (body) 左上角垂直短襯線 (serif) 中段水平橫槓 (crossbar) 視覺風格建議
straight-serifless(預設) 直線斜桿 無 無 極簡現代,貼近 TX-02 原生設定
straight-serifless-crossbar 直線斜桿 無 有 最推薦:保留幾何直桿,同時強化 7 與 1/T 的辨識度
straight-serifed-crossbar 直線斜桿 有 有 帶有濃厚打字機與古典印刷感
bend-serifless-crossbar 微彎弧形斜桿 無 有 偏柔和手寫風格,較不適合方正的 TX-02 家族

因為 IoskeleyMonoTerm、IoskeleyMonoNL 與 IoskeleyMonoWeb 全部都透過 inherits = "buildPlans.IoskeleyMono" 繼承字形設定,我們只需要改動 private-build-plans.toml 的第 15 行:

seven = "straight-serifless-crossbar"

接著把修改後的設定檔複製進 iosevka-src/,啟動 Verda 建置系統:

cp private-build-plans.toml iosevka-src/
cd iosevka-src
npm install

# 同時編譯標準版 (IDE 用) 與 Term 版 (終端機用)
npm run build -- contents::IoskeleyMono contents::IoskeleyMonoTerm

Verda 是 Iosevka 作者 Belleve Invis 專門開發的增量平行建置引擎(定義於 iosevka-src/verdafile.mjs)。當我們修改 private-build-plans.toml 並針對單一家族執行建置時,Verda 會自動解析相依圖、調動多核心 CPU 平行更新 614 個建置目標(終端機印出 Finished with 614 targets updated.),短短幾分鐘內就在 iosevka-src/dist/ 下產出全部 40 種樣式的 TTF/(Hinted)、TTF-Unhinted/、WOFF2/ 與 WOFF2-Unhinted/。

深入造字引擎:Patel (.ptl) 如何將一行字串編譯成 TrueType 輪廓?

改一行 TOML 就能讓 40 個字型檔同步長出比例完美的水平橫槓,這背後到底發生了什麼事?我在 ioskeley-mono workspace 中一路追進了 iosevka-src/packages/font-glyphs/src/number/7.ptl,發現 Iosevka 內部的幾何編譯管線設計得極為精妙。

讓我們結合實際從編譯後的 IoskeleyMono-Regular.ttf 抽取出來的 TrueType 頂點座標,一步步拆解 "straight-serifless-crossbar" 是如何變成字型檔裡的二進位曲線(下圖與後文的頂點追蹤圖皆直接擷取自編譯後 TTF 的真實 glyf 輪廓座標):

seven-anatomy.svg

Patel 語法編譯與 SuffixCfg.weave 變體編織

Iosevka 所有的字形都不是手畫的,而是用一種名為 Patel(.ptl)的領域特定語言(Lisp 方言,採用縮排語法)撰寫的程式。由於 Node.js 無法直接執行 .ptl,建置系統第一步會呼叫 patel-c 編譯器,將 7.ptl 轉譯為標準的 JavaScript ES Module(lib/number/7.mjs):

patel-c --strict --esm packages/font-glyphs/src/number/7.ptl -o packages/font-glyphs/lib/number/7.mjs

打開 packages/font-glyphs/src/number/7.ptl 的第 65 至 93 行,可以看到 SuffixCfg.weave 如何動態編織出所有合法的 7 變體組合:

local SevenConfig : SuffixCfg.weave
    object # body
        straight              StraightBody
        bend                  BendBody
        curly                 CurlyBody
    object # serif
        serifless             nothing
        serifed               StartSerif
    function [body] : object # crossbar
        ""                    nothing
        crossbar : match body
            [Just "straight"] Crossbar
            [Just "bend"]     BendCrossbar
            [Just "curly"]    CurlyCrossbar

foreach { suffix { Body Serif Crossbar } } [Object.entries SevenConfig] : do
    create-glyph "seven.lnum.\(suffix)" : glyph-proc
        include : MarkSet.capital
        if Body : include : Body CAP
        if Serif : include : Serif CAP
        if Crossbar : include : Crossbar CAP

    create-glyph "seven.onum.\(suffix)" : glyph-proc
        include : OnumMarks.p
        include : refer-glyph "seven.lnum.\(suffix)"
        include : ShiftDown

select-variant 'seven.lnum' [CodeLnum '7'] (follow -- 'seven')
select-variant 'seven.onum' [CodeOnum '7'] (follow -- 'seven')

當我們在 TOML 中指定 seven = "straight-serifless-crossbar" 時:

  • Body 對映到 StraightBody 幾何程序;
  • Serif 對映到 nothing(不畫左上角垂直襯線);
  • Crossbar 根據 body == "straight" 自動匹配到 Crossbar 程序;
  • 最後 select-variant 將產生的 seven.lnum.straight-serifless-crossbar 綁定到等高數字(Lining Figures)的 Unicode 碼位 U+0037 ('7')(同時也透過 seven.onum 衍生出供 OpenType onum 特徵使用的老式高低數字版本)。

參數化座標求值:StraightBody 與 Crossbar

當 Node.js 在編譯某個特定字重與字寬(例如 Regular 400,字寬 600、大寫高度 CAP = 690、筆畫粗細 Stroke = 73、左右邊距 SB = 85、右邊界 RightSB = 600 - 85 = 515)時,它會把這些度量常數代入 7.ptl 第 13 至 20 行與第 44 至 49 行的幾何公式中:

define SevenXLeft : mix SB RightSB 0.28
define [StraightBody top] : union [HBar.t SB RightSB top] : intersection
    Rect (top - Stroke) 0 SB RightSB
    dispiro
        widths.rhs
        corner RightSB (top - Stroke)
        corner [mix RightSB (SevenXLeft + [HSwToV : 0.75 * Stroke]) 2] [mix (top - Stroke) 0 2]

# ... (第 21–43 行定義 BendBody、CurlyBody 與 StartSerif)

define [Crossbar top] : glyph-proc
    local m : mix SevenXLeft (RightSB - [HSwToV HalfStroke]) 0.5
    include : HBar.m
        m - [HSwToV QuarterStroke] - LongJut * 0.8
        m + [HSwToV QuarterStroke] + LongJut * 0.7
        mix 0 (top - QuarterStroke) 0.5

這幾行 Patel 程式碼藏著字體設計師滿滿的視覺補償細節:

  1. 頂部橫樑 (HBar.t SB RightSB top):在 X = 85..515、Y = 617..690 畫出一道頂部切齊 CAP (690)、厚度為 Stroke (73) 的水平橫樑。
  2. 斜向主幹 (dispiro + Rect 交集裁切): 如果直接畫一條斜線段,斜線的上下兩端切口會垂直於斜線本身(呈現斜角突出)。因此 Iosevka 故意用 mix ... 2 把 dispiro 斜桿往外延伸兩倍長,再用一個 Rect (top - Stroke) 0 SB RightSB(即 Y = 0..617 的矩形遮罩)做布林交集(intersection)!這樣斜桿頂端就會完美貼合在 Y = 617(頂部橫樑下緣),底端則在 Y = 0 基線上切出俐落的水平平頭(X = 180..267)。
  3. 水平橫槓 (Crossbar) 的不對稱視覺平衡:
    • 垂直位置:不是單純取 CAP / 2 = 345,而是 mix 0 (top - QuarterStroke) 0.5 = 336(扣除頂部橫樑視覺重量後的幾何中心,落在 Y = 299..373)。
    • 水平位置 (m):計算斜桿在半高處的水平中心點 m。
    • 左右突出的視覺微調:注意橫槓往左延伸了 0.8 * LongJut(到 X = 158),往右卻只延伸 0.7 * LongJut(到 X = 501)!為什麼左邊比右邊多出 0.1 * LongJut?因為 7 的斜桿是從右上往左下傾斜,左上方的字懷空間比右下方開闊;左端稍微多探出一點,在人眼看來才會覺得橫槓「正好位在斜桿正中央」。

Spiro 螺旋曲線展開與 2D CSG 布林聯集 (typo-geom)

在傳統字體設計中,曲線是由三次貝茲曲線(cubic Bézier)的控制點手拉出來的;而在 Iosevka 中,曲線是由 Raph Levien(同時也是 Google Fonts Inconsolata 與 Zed GPUI 部分文字渲染的先驅)提出的 Spiro 螺線演算法(Euler spiral)計算而成,確保曲率達到 G2 甚至 G4 連續。

經過 spiro-pen-expand.mjs 將一維骨架向兩側展開成二維筆畫多邊形後,重頭戲交給了 typo-geom 幾何引擎:

  • 當我們把頂部橫樑、斜向主幹與水平橫槓透過 union 組合在一起時,三個零件在十字交會處會有內部的重疊邊線。
  • 如果直接把三個重疊的矩形寫進字型檔,在開啟半透明渲染、外框描邊或低解析度光柵化時,交界處會出現破洞(winding rule 衝突)或墨水過深。
  • typo-geom 會執行嚴謹的 2D 建構實體幾何布林聯集運算(CSG Union),自動找出所有交點(例如斜桿與橫槓相交的 (300, 299)、(329, 373)、(417, 373)、(388, 299) 四個內角),徹底剔除內部重疊線段,融合成單一封閉的 15 頂點外輪廓!

我們直接用 Python 的 fontTools 把編譯出來的 IoskeleyMono-Regular.ttf 拆開,印出 seven 字形的 SVG 路徑指令,就能親眼驗證這個結果:

M180 0 300 299H158V373H329L427 617H85V690H515V617L417 373H501V299H388L267 0Z
seven-csg-vertices.svg

順著上圖右半邊標出的 [1] 到 [15] 號頂點走一圈,可以看到 typo-geom 如何把原本三個重疊的幾何零件融合成一條順時針方向(TrueType 外輪廓規範)的一筆畫封閉路徑:從基線左下角 [1] (180, 0) 出發,沿斜桿左緣爬升到橫槓下緣交角 [2] (300, 299),往左折出橫槓左翼 [3]➔[4]➔[5],繼續沿斜桿爬升到頂樑下緣交角 [6] (427, 617),繞過頂部橫樑 [7]➔[8]➔[9]➔[10],再沿斜桿右緣一路折出橫槓右翼 [11]➔[12]➔[13]➔[14] 回到基線右下角 [15] (267, 0) 閉合。整顆字元沒有任何多餘的內部節點,而且當 Verda 編譯 Thin (100)、Bold (700)、Black (900) 或 SemiCondensed (90%) 時,這 15 個頂點都會根據各自的 Stroke 與 Width 參數自動重新計算。

二次貝茲曲線擬合 (Quadify) 與 OpenType 封裝 (ot-builder)

最後兩個底層步驟則是跨格式的幾何轉換:

  1. 三次轉二次貝茲曲線 (Quadification): Iosevka 內部的幾何運算全部採用三次樣條(degree-3 cubic curves),但 TrueType(.ttf)的 glyf 表規範只支援二次貝茲曲線(degree-2 quadratic Bézier,每段曲線只有一個 off-curve 控制點)。packages/geometry/src/quadify.mjs 會呼叫 TypoGeom.Quadify.auto(arc, 1, 8),將所有曲線自動細分並擬合為誤差小於 1 個字體設計單位(1/1000 em)的二次貝茲曲線。
  2. 二進位表格序列化 (ot-builder) 與 ttfautohint: 最後由 ot-builder 將輪廓點座標寫入 glyf 與 loca 表、將 Unicode 映射寫入 cmap 表、將連字與 zero 特徵寫入 GSUB 表,產出 TTF-Unhinted/,再交由 ttfautohint 注入對齊螢幕像素網格的 bytecode 指令產出 TTF/。

為自訂字體打上 Nerd Font 補丁:平行加速與兩個實戰陷阱

在 Linux 桌面環境(尤其是搭配 eza、lazygit、neovim、starship 與 herdr 的 terminal 工作流)中,只有純文字的 .ttf 是不夠的——我們還需要把 FontAwesome、Devicons、Octicons、Material Design Icons 與 Powerline 符號打包進去,做成 Nerd Font 版本。

平行化批次修補腳本 (xargs -P 8)

官方的 Nerd Fonts Patcher 是一個由 FontForge Python 介面驅動的腳本。如果用單執行緒 for 迴圈逐一修補 40 個字型檔,每個檔案約需 15 至 20 秒,跑完一個家族就得花上十幾分鐘。

實務上,我們可以直接下載 FontPatcher.zip(下方範例固定於 v3.5.1 確保可重現性),並利用 xargs -n 2 -P 8 啟動 8 個平行 worker 同時修補:

#!/usr/bin/env bash
set -e

# 1. 下載並解壓縮官方 Nerd Fonts Patcher (v3.5.1)
if [ ! -d "nerd-fonts-patcher" ]; then
    curl -fLO https://github.com/ryanoasis/nerd-fonts/releases/download/v3.5.1/FontPatcher.zip
    unzip -q FontPatcher.zip -d nerd-fonts-patcher
fi

DIST_TERM="iosevka-src/dist/IoskeleyMonoTerm/TTF"
PATCHER="nerd-fonts-patcher/font-patcher"
mkdir -p patched-fonts-term/Normal patched-fonts-term/SemiCondensed

patch_font() {
    local font="$1"
    local outdir="$2"
    fontforge -script "$PATCHER" "$font" \
        --complete --careful --mono --outputdir "$outdir" >/dev/null 2>&1
    echo "Finished $(basename "$font")"
}
export -f patch_font
export PATCHER

# 2. 建立任務佇列(區分 SemiCondensed 與 Normal)
for font in "$DIST_TERM"/IoskeleyMonoTerm-SemiCondensed*.ttf; do
    echo "$font" "patched-fonts-term/SemiCondensed"
done > /tmp/fonts-term-to-patch.txt

for font in "$DIST_TERM"/IoskeleyMonoTerm-*.ttf; do
    [[ "$font" == *Condensed* ]] && continue
    echo "$font" "patched-fonts-term/Normal"
done >> /tmp/fonts-term-to-patch.txt

# 3. 使用 8 個平行 worker 批次修補 40 個字型檔
cat /tmp/fonts-term-to-patch.txt | xargs -n 2 -P 8 bash -c 'patch_font "$@"' _

這裡傳給 font-patcher 的三個參數至關重要:

  • --complete:注入所有開發者圖示集(FontAwesome、Devicons、Octicons、Codicons、Powerline 等)。
  • --careful:若圖示碼位與 Ioskeley Mono 原生已有的字元重疊,保留原生字元而不覆蓋。
  • --mono:將所有注入的圖示嚴格縮放至單一等寬字元格(1-cell),產出家族名稱帶有 Nerd Font Mono 後綴的字型,確保在 terminal 裡圖示不會溢位蓋住後面的空白或文字。

FontForge 清除 post.isFixedPitch 等寬旗標的副作用

當 font-patcher 透過 FontForge 重新匯出 .ttf 字型檔時,有一個歷史悠久的經典陷阱:FontForge 有時會將 TrueType post 表中的 isFixedPitch 欄位重設為 0。

一旦 post.isFixedPitch 不等於 1,部分 terminal 模擬器或作業系統的字體選擇器就會誤以為這是一套「比例字體(proportional font)」,進而拒絕在終端機字體清單中顯示它,或是導致字元間距計算異常。

因此在 font-patcher 跑完之後,務必補上一段幾行就能跑完的 Python fontTools 修復腳本,把所有 .ttf 的 isFixedPitch 強制改回 1:

from pathlib import Path
from fontTools.ttLib import TTFont

fixed = 0
for f in sorted(Path("patched-fonts-term").rglob("*.ttf")):
    font = TTFont(f)
    if "post" in font and font["post"].isFixedPitch != 1:
        font["post"].isFixedPitch = 1
        font.save(f)
        fixed += 1

print(f"Restored isFixedPitch on {fixed} fonts")

為何重刷 fc-cache 後終端機裡的 7 依然沒有橫槓?

這是我昨晚在 ioskeley-mono workspace 裡遇到最有趣的一個實戰插曲。

當我改完 seven = "straight-serifless-crossbar"、編譯完 contents::IoskeleyMono、跑完 40 個檔案的 Nerd Font 修補、複製進 ~/.local/share/fonts/,並執行 fc-cache -fv 重新載入後——我滿懷期待地在 terminal 裡打出 1234567890,結果畫面上的 7 依然是光禿禿的一根直桿,完全沒有水平橫槓!

第一時間我還以為是 terminal 沒重讀字體快取(在 Kitty 可以按 Ctrl + Shift + F5 熱重載,Ghostty 可按 Ctrl + Shift + , 重載設定,Alacritty 則會自動監聽設定檔變更,VS Code 可執行 Developer: Reload Window)。但就算重開整個 terminal 視窗,7 還是沒變。

一查之下才揪出真正的兇手:

  1. IDE 與 Terminal 使用不同的編譯目標 (IoskeleyMono vs. IoskeleyMonoTerm): 檢查我的 ~/.config/ghostty/config、~/.config/kitty/kitty.conf、~/.config/alacritty/alacritty.toml、~/.config/foot/foot.ini 與 ~/.config/fontconfig/fonts.conf 後發現,為了讓 terminal 的格線完美對齊,我所有的終端機設定檔指定的都是 IoskeleyMonoTerm Nerd Font Mono(也就是 Term 家族),而不是標準版的 IoskeleyMono Nerd Font Mono。
  2. ~/.local/share/fonts/ 裡殘留了舊版的 Term 字型檔: 因為我第一輪只編譯並安裝了標準版 contents::IoskeleyMono,而 ~/.local/share/fonts/IoskeleyMono/ 資料夾底下還躺著先前安裝的舊版 IoskeleyMonoTermNerdFontMono-*.ttf!於是當 Fontconfig 解析 IoskeleyMonoTerm Nerd Font Mono 時,毫無懸念地繼續指向那批還沒加上橫槓的舊檔案。

找出原因後,解法就非常俐落了:

  1. 補跑 npm run build -- contents::IoskeleyMonoTerm 編譯帶橫槓 7 的 Term 家族。
  2. 執行 patch-term-fonts.sh 將 40 個 IoskeleyMonoTerm 字型檔打上 Nerd Font 補丁並修復 isFixedPitch = 1。
  3. 清理 ~/.local/share/fonts/ 下舊的散落檔案,統一安裝至 ~/.local/share/fonts/IoskeleyMono/ 與 ~/.local/share/fonts/IoskeleyMonoNerdFont/。
  4. 重刷快取並用 fc-match 驗證最終解析路徑:
# 重新建立 Fontconfig 快取
fc-cache -fv

# 驗證 Fontconfig 實際匹配到的字型檔路徑
fc-match -v "IoskeleyMonoTerm Nerd Font Mono" | grep -E "file:|family:"

當看到輸出明確指向剛出爐的 ~/.local/share/fonts/IoskeleyMonoNerdFont/IoskeleyMonoTermNerdFontMono-Regular.ttf,再重新載入終端機,整個畫面裡的 7 瞬間長出了那道俐落的水平橫槓!

反思與取捨:為什麼在 Ghostty 環境下,我最終移除了 Nerd Font 補丁?

跑通上面整套 FontForge 平行打補丁與 isFixedPitch 修復流程後,雖然成功得到了自訂的 IoskeleyMonoTerm Nerd Font Mono,但在 Ghostty 終端機裡實際使用了一陣子後,我卻決定回過頭下達另一個指令:把所有 Nerd Font 補丁徹底移除,只安裝純淨未修補的原版 Ioskeley Mono 與 Ioskeley Mono Term。

為什麼費工夫打完補丁後,反而選擇回歸純淨字型?關鍵在於當今像 Ghostty(以及 WezTerm、Kitty)這類現代終端機的字型渲染架構已經與幾年前截然不同。把 Nerd Font 圖示直接「焊死」進主字型檔裡,實務上會帶來四個明顯的副作用:

屏蔽了 Ghostty 原生內建的智慧圖示縮放引擎

許多人可能沒注意到,Ghostty 出廠就已經原生內建了完整的 Symbols Nerd Font 作為預設後備字型(fallback),不需要使用者額外安裝任何打過補丁的字體。更重要的是,Ghostty 在渲染引擎底層實作了極為細膩的動態字元格縮放邏輯(constraint-based glyph scaling):

  • 當 Ghostty 在你的主字型(例如純淨的 Ioskeley Mono Term)中找不到某個 Private Use Area (PUA) 圖示碼位時,它會自動切換到內建的 Nerd Font 符號表,並動態檢查該圖示右側是否緊鄰空白字元。
  • 在 eza -l、lazygit、yazi 檔案樹或 starship 提示字元中,圖示後面通常都會跟著一個空白( )。此時 Ghostty 會允許圖示突破單一窄格的限制,以更飽滿、清晰且符合原始長寬比的尺寸渲染。
  • 反之,如果你安裝並指定了預先打上 --mono 補丁的 IoskeleyMonoTerm Nerd Font Mono,因為主字型檔本身已經佔據了所有 PUA 碼位,Ghostty 根本不會觸發內建的 fallback 與動態縮放引擎,而是直接畫出字型檔裡被 FontForge 硬生生壓扁塞進單一窄格(Width = 600 或 540)的微型圖示——導致資料夾、Git 分支與語言圖示縮得像郵票一樣小,白白浪費了 Ghostty 原生圖示渲染器的優勢。

FontForge 二次轉檔對 Hinting 與 OpenType 表格的破壞

Iosevka 編譯器透過 ot-builder 與 ttfautohint 產出的原版字型,在 OpenType 表格結構與像素對齊指令上極為嚴謹,並明確區分給標準螢幕用的 TTF/(Hinted)與給高解析度 HiDPI 螢幕用的 TTF-Unhinted/。

然而,一旦把編譯好的 .ttf 丟進 FontForge 執行 font-patcher,FontForge 會將整顆字型反序列化後再重新匯出。這個過程不僅會如前文所述清掉 post.isFixedPitch 旗標,還會改寫 name 表與原有的 hinting 結構(這也是為什麼官方釋出的 Nerd Font 壓縮包無法提供獨立的 Hinted 與 Unhinted 版本)。不經過 FontForge 二次加工,才能保留 Iosevka 100% 原汁原味的曲線與 hinting 品質。

40 倍的圖示重複塞入與 88 MB 體積膨脹

一套完整的 Nerd Fonts 圖示集包含超過一萬個向量圖形。由於圖示本身並沒有「細體 Light」或「粗斜體 Bold Italic」的差別,將圖示直接打進字型檔,等於在 10 種字重 × 2 種字寬 × 正斜體共 40 個 .ttf 檔案裡重複複製了一萬個完全相同的圖示!

這使得單一家族的字型檔從原本輕巧的十幾 MB 暴增到 88 MB,而且每次修改一行 private-build-plans.toml 重新編譯時,都得額外花上好幾分鐘讓 8 個 FontForge 處理程序把 CPU 燒滿。

更現代的解耦配置:純淨字體 + 終端機內建或單一符號 Fallback

把 Nerd Font 補丁移除、重新執行乾淨的 Verda 建置後:

# 直接編譯純淨的四大家族,不經過 FontForge 二次加工
cd iosevka-src
npm run build -- contents::IoskeleyMono contents::IoskeleyMonoTerm contents::IoskeleyMonoNL contents::IoskeleyMonoWeb

在 Ghostty(~/.config/ghostty/config)中,只需要直接指定純淨的 Ioskeley Mono Term,圖示就會自動由 Ghostty 內建的 Nerd Font 引擎接手渲染:

font-family = "Ioskeley Mono Term"
font-style = Regular
font-size = 11

而如果同時有使用 Alacritty、Foot 或 Waybar 等沒有內建圖示的工具,也完全不需要回頭打補丁——只要在 Linux 系統中安裝單一一個僅約 2.5 MB 的獨立符號字型套件(例如 Arch Linux 官方套件庫的 ttf-nerd-fonts-symbols-mono),讓 Fontconfig 在遇到 PUA 圖示碼位時自動 fallback 到該符號字型即可。這樣一來,文字排版由最純淨的 Ioskeley Mono Term 負責,開發者圖示由終端機內建引擎或單一符號字型負責,職責徹底解耦,建置速度與視覺品質都達到了最理想的狀態。

結語:把字體當作可編譯的基礎設施

從單純「下載別人打包好的 .zip 字型檔」,跨出一步到「自己擁有字體的建置管線」,帶來的掌控感非常奇妙。

透過 Iosevka 的 Patel 參數化引擎與 private-build-plans.toml,字體不再是一顆無法修改的黑盒子:不管你是想幫數字 7 加上水平橫槓、把預設的點狀 0 換成斜線 0、微調行距 leading,還是針對 Ghostty 的渲染特性選擇最純淨的 Ioskeley Mono Term 建置方案,都只需要改幾行設定檔,就能在幾分鐘內編譯出一套橫跨 10 種字重、2 種字寬的專屬字體家族。如果你也在使用 Ioskeley Mono,強烈推薦把儲存庫 clone 下來親手編譯一次,打造真正百分百貼合自己視覺習慣的開發者字體!


Berkeley Mono 與 Ioskeley Mono

為 IDE 與終端機打造的等寬字體美學與線上互動對照
featured.svg

每天盯著編輯器與 terminal 超過八小時,字體幾乎是開發者最貼身的視覺介面。回顧過去幾年,我的主力字體一路從微軟經典的 Consolas、支援連字與手寫斜體的 Cascadia Code、字腔寬裕且閱讀極為舒適的商業字體 MonoLisa,一路換到 GitHub Next 主打 texture healing 動態字距與五大超家族的 Monaspace;這些字體都在不同時期陪伴我寫下無數行程式碼,但當工作流越來越依賴高密度的終端機與多視窗並排時,心裡總覺得想找一款更貼近老派工程手冊、俐落緊湊且毫無拖泥帶水的機械秩序感字體。

直到遇見 U.S. Graphics Company 推出的商業字體 Berkeley Mono (TX-02),以及開源社群以此為靈感、基於 Iosevka 參數化引擎打造的 Ioskeley Mono,那種源自 1970 年代技術手冊與資料終端機的字形比例,才真正讓程式碼與 TUI 介面同時安靜下來。

這篇文章除了整理這兩款字體的設計脈絡、字形解剖差異,以及在 IDE 與 Linux terminal(包含 Omarchy 環境)的最佳實務配置外,我也直接在文內嵌入了一套雙模式線上互動字體實驗室:你可以直接拖曳捲簾滑桿或原位閃切,對照 Berkeley Mono (TX-02) 與 Ioskeley Mono 的像素級差異,也能即時調整字級、行高、字寬與 OpenType 連字特徵,親手測試它們在程式碼與 terminal 框線下的實際表現。

為什麼開發者開始迷戀 1970 年代技術手冊風格?

過去我非常喜歡的幾款字體各有千秋:Consolas 在 Windows ClearType 時代奠定了極高的小字級易讀性;Cascadia Code 為現代終端機帶來活潑的連字;MonoLisa 擁有稍微加寬的字腔與極佳的長時間閱讀舒適度;而 Monaspace 則透過局部的字寬補償改善了等寬字體的疏密不均。然而當我們的工作流轉向多重 split pane、在左半邊開著編輯器、右半邊同時跑著多個 Ghostty 終端機與 TUI 工具(如 herdr、lazygit、btop 或 AI coding agent 的多工視窗)時,偏寬或偏柔和的字體往往會遇到三個取捨:

  1. 水平字距偏寬,分割視窗容納量受限:在 14 吋或 16 吋筆電螢幕上一旦切出左右兩個 80 列視窗,常見等寬字體很容易被迫折行。
  2. 字形情緒過於圓潤,大量符號堆疊時缺乏邊界感:當畫面充滿 Rust 的泛型與生命週期標記(<'a, T: Into<Cow<'a, str>>>)或巢狀括號時,過於柔和的弧線反而降低了視覺錨點。
  3. 終端機框線與文字基線的微妙脫節:部分針對 IDE 設計的字體在開啟連字後,遇到 terminal 內的 box-drawing 格線與資訊密度極高的 log 輸出時,整體節奏容易顯得鬆散。

Berkeley Mono (TX-02):源自冷戰時期工程圖紙的秩序感

由設計師 Neil Panchal 與 U.S. Graphics Company 主導設計的 Berkeley Mono (TX-02),最初是為了自家 CAD 與工程軟體介面量身打造的專屬字體。它的設計哲學非常明確:回到 1970 至 1980 年代的微處理器資料手冊(databooks)、航太儀表與早期 CRT 終端機,追求高辨識度、方正的幾何轉角,以及長時間閱讀不疲勞的灰度節奏。

Berkeley Mono 最吸引人的特質在於它「收斂卻不擁擠」的字腔結構:

  • 方中帶剛的幾何輪廓:字母的轉角帶有微妙的平切弧度(superellipse),既不像純點陣字體那樣生硬,也沒有多餘的裝飾襯線。
  • 零歧義的字元家族:針對程式設計最容易混淆的 0 / O、1 / I / l 做了極為鮮明的結構區隔。
  • 緊湊的水平節奏:在維持充裕字懷(counter)的前提下微調字元推進寬度,讓每一行程式碼像排好隊的積體電路針腳一樣整齊。

不過,Berkeley Mono 是一款採用商業授權的閉源字體(個人開發者授權 FX-102 定價為 $75 美元;官方另有提供代號 FX-050 7 天免費評估試用版可供下載測試,但 EULA 授權條款禁止將字型檔二次散佈或直接託管於公開網站)。如果你想在多台 Linux 工作站、容器環境、公開的 dotfiles 儲存庫或網頁專案中自由封裝使用,商業授權條款自然有其邊界。這也促成了開源社群最令人驚豔的一次致敬行動。

Ioskeley Mono:站在 Iosevka 肩膀上的開源致敬之作

Ioskeley Mono 是由開發者 Ahmed Hatem(ahatem/IoskeleyMono)發起、採用 SIL Open Font License 1.1 (OFL) 開源授權釋出的等寬字體專案。

它並沒有從零手繪每一筆貝茲曲線,而是巧妙運用了開源字體界最負盛名的參數化造字引擎 Iosevka v34.4.0。透過一份精心調校的 private-build-plans.toml 建置規格檔,Ioskeley Mono 在 Iosevka 數百種字形變體(glyph variants)與度量參數中,逐一挑選出最貼近 Berkeley Mono (TX-02) 視覺精神的組合,並編譯出完整的字體家族。

ioskeley-architecture.svg

這套建置管線帶來了幾個非常務實的工程優勢:

  • 完整的字重與字寬矩陣:原生提供從 Thin (100) 到 Black (900)(額外包含 SemiLight 350)共 10 種字重,並同時提供標準寬度 Normal (100%) 與微縮寬度 SemiCondensed (90%) 兩種版本。
  • 針對應用場景拆分的專屬變體:除了支援連字的標準版 Ioskeley Mono 與無連字版 Ioskeley Mono NL,還專門提供針對 terminal 單格對齊最佳化的 Ioskeley Mono Term,以及預先打好圖示補丁的 Nerd Font 版本。
  • 完全自由的開源授權:SIL OFL 1.1 允許你自由安裝在任何機器、打包進 Linux 系統映像檔,甚至直接子集化為 .woff2 嵌入個人技術部落格中。

線上雙模式互動字體對照實驗室

講再多字形理論,都不如直接把兩款字體放在同一塊畫布上親眼比對。由於 Berkeley Mono 屬於商業字體,我們無法直接將其原始字型檔散佈於公開網頁;因此我將下方的互動實驗室設計為兩個互補的模式:

  1. 即時互動編輯與終端機實驗室:直接載入自託管的 Ioskeley Mono(含 Regular、SemiCondensed 90% 與 Italic)、Monaspace Neon 與 Cascadia Code / Mono 網頁字型。為了遵守 U.S. Graphics Company 的 EULA 條款(禁止公開散佈字型檔),我們不將商業版或 FX-050 免費試用版字型打包進 GitHub 儲存庫;但只要你本機已安裝 Berkeley Mono / Berkeley Mono Trial,或點擊實驗室右上角的 「載入本機 FX-050 字型檔」 按鈕選取從官網免費下載的 .ttf / .otf / .woff2 檔案(100% 於瀏覽器記憶體內透過 FontFace API 渲染,不經過任何伺服器上傳),就能直接在左右面板即時對照!
  2. 像素級對照儀:將 Berkeley Mono (TX-02) 與 Ioskeley Mono 在完全相同字級(14.2px)、行高(1.54)與推進寬度下渲染的樣本進行零誤差像素對齊。你可以使用 捲簾滑桿 (Curtain Slider)左右拖曳,或點擊 原位閃切 (Blink Comparator)在同一座標瞬間切換,觀察兩者在字腔、筆畫粗細與細節上的異同。
↗ 下載 FX-050 試用版
Ioskeley Mono (Normal 100%) 約 -- 字元/列
Ioskeley Mono (SemiCondensed 90%) 約 -- 字元/列
提示:你可以直接在上方左右區塊打字或貼上自己的程式碼。試著勾選「切換斜線零 ('zero')」,觀察 0xFC82_0000 內部的中心方點瞬間變成斜線;也可以比較 Normal (100%) 與 SemiCondensed (90%) 在相同視窗寬度下每列可容納的字元數差異。
左側:Ioskeley Mono 右側:Berkeley Mono (TX-02)
IOSKELEY MONO (OPEN SOURCE) BERKELEY MONO (TX-02) Berkeley Mono TX-02 specimen
Ioskeley Mono specimen
⇄
實戰程式碼像素對照:兩款字體在完全相同的字級(14.2px)與字元格線上渲染。拖曳滑桿或切換至「原位一鍵閃切」,可以發現兩者的整體行距與字元落點完全吻合;細看則能察覺 Berkeley Mono (TX-02) 的筆畫略微厚實溫潤,而 Ioskeley Mono 的轉角與小寫 r、g、@ 則展現出更鮮明的幾何線條。

字形解剖:Ioskeley Mono 如何重現經典並走出自己的路?

如果你在上方實驗室切換到「Berkeley Mono (TX-02) × Ioskeley Mono 像素級對照儀」的第三個場景(128px 像素疊影解剖),會看到一個非常有趣的現象:兩款字體在單字元推進寬度(advance width)上達成了 +0.00 px 的零誤差同步,但在個別字元的局部筆畫上,因為 Iosevka 參數化骨架與手工繪製曲線的本質差異,展現了耐人尋味的細節對比。

招牌識別字元:點狀 0、單層 g、Q 與 @

我們可以從 ahatem/IoskeleyMono 的建置設定檔 private-build-plans.toml 中,看出作者是如何一磚一瓦挑選字形零件的:

[buildPlans.IoskeleyMono.variants.design]
zero = "dotted"                          # 預設採用中心點狀 0(Iosevka 另自動編譯 GSUB zero 斜線零特徵)
one = "no-base"
two = "straight-neck-serifless"
three = "flat-top-serifless"
six = "open-contour"                     # 6 與 9 採用開放式弧線缺口
eight = "two-circles"                    # 由上下兩個圓環組成的 8
nine = "open-contour"
capital-q = "crossing"                   # Q 的斜向尾筆俐落切過底部邊界
a = "double-storey-serifless"            # 雙層無襯線 a
g = "single-storey-serifless"            # 單層無襯線 g
k = "symmetric-touching-serifless"
r = "hookless-serifless"                 # 無鉤無襯線 r
y = "straight-serifless"
tittle = "square"                        # i 與 j 頂部圓點改為方點
punctuation-dot = "square"               # 句號、冒號與分號全面採用方點
paren = "flat-arc"                       # 近似垂直卻帶微弧的平弧括號 ()
brace = "curly-flat-boundary"
number-sign = "slanted"
at = "fourfold"                          # 高挑緊湊的四折 @ 符號
dollar = "through"                       # 豎線貫穿上下的 $ 符號

透過下方的像素疊影與對照表(圖中 象牙白 為共同骨架、琥珀金 為 Ioskeley Mono、珊瑚橘 為 Berkeley Mono TX-02),我們可以更清楚地歸納兩者在關鍵字形上的異同:

字元類別 像素疊影與視覺對照 Berkeley Mono (TX-02) 細節特徵 Ioskeley Mono 細節特徵
數字 0
方圓外框+中心點
0 像素疊影對照 中心點略呈圓潤橢圓,外框轉角較柔和 設定 zero = "dotted" 呈現俐落方點,且保留 Iosevka 內建的 OpenType zero 特徵可隨時切換為斜線 0
小寫 a 與 g
雙層 a・單層 g
a 與 g 像素疊影對照 a 的肚圍較貼近左緣;g 的下伸迴圈弧度較早收尾 採用 double-storey-serifless 與 single-storey-serifless,筆畫轉折更偏幾何直線
大寫 Q
矩形圓角+斜向尾筆
Q 像素疊影對照 尾筆從字腔內部偏左處起筆向右下延伸 採用 capital-q = "crossing",尾筆切入角度略高且向外探出更鮮明
符號 @ 與 $
高挑比例・貫穿 $
@ 與 $ 像素疊影對照 @ 內圈 a 較窄且外框底部收平 採用 at = "fourfold" 與 dollar = "through",內圈字懷更寬敞易讀
括號 () 與標點
平弧括號・方形句點
括號與標點對照 括號中段近似垂直,在巢狀呼叫時宛如垂直分隔線 透過 paren = "flat-arc"、tittle = "square" 與 punctuation-dot = "square" 忠實還原相同視覺節奏

為什麼點狀零(Dotted Zero)與平弧括號在程式碼中特別耐看?

許多程式字體為了區分 0 與 O,習慣使用斜線零(Slashed Zero)。然而當程式碼中出現大量十六進位常數(如 0x00FF_0000)或浮點數陣列時,密集的對角斜線容易與大寫 Ø、8 或斜線運算子 / 產生視覺干擾。

Berkeley Mono 與 Ioskeley Mono 預設採用的 點狀零 (Dotted Zero),將識別錨點收斂在字腔正中央的一顆方點上,既能瞬間與內部全空的字母 O 區隔,又不會破壞一整排零的水平安定感。而如果你在特定環境下還是偏愛斜線零,Ioskeley Mono 也貼心地把斜線零封裝在 OpenType 的 zero 特徵槽位中——不需要換字體檔,只要在編輯器或 terminal 設定裡加上一行 zero feature 就能隨時切換。

另一個令人一用就回不去的細節是 平弧括號 (flat-arc parentheses)。當我們在寫 Lisp、Rust 巨集或深層函式呼叫 foo(bar(baz(x))) 時,傳統圓弧括號 ((( 會形成一團向外膨脹的視覺噪音;而平弧括號把中段拉直、僅在上下兩端保留俐落折角,讓連續括號排在一起時就像一排整齊的柵欄,大幅降低了配對括號時的眼球掃描負擔。

如何挑選適合你的 Ioskeley Mono 變體?

第一次點開 Ioskeley Mono 的 GitHub Releases 頁面時,看到多達二十幾個壓縮檔可能會稍微猶豫一下。其實只要順著「使用場景(IDE vs. Terminal)」、「字元寬度(100% vs. 90%)」與「螢幕解析度(Hinted vs. Unhinted)」三個維度,就能在十秒內鎖定最適合的安裝包:

變體家族:Ioskeley Mono vs. NL vs. Term

  1. 標準版 Ioskeley Mono (推薦用於 IDE): 內建完整的 Iosevka 程式連字規則(contextual ligatures),包含常見的 !=、===、<=、>=、=>、->、:: 與 <=>。適合在 VS Code、Zed 或 JetBrains IDE 中做為主力字體。
  2. 無連字版 Ioskeley Mono NL (No-Ligature): 如果你是「所見即所得」的純粹主義者,不希望兩個獨立的 ASCII 字元被合併成單一圖形,選 NL 版本即可在字型層級徹底關閉連字。
  3. 終端機專屬版 Ioskeley Mono Term: 這是專為 terminal emulator 打造的變體。在終端機環境中,部分連字或特殊符號如果跨出單一字元格(character cell),可能會導致游標定位偏移或 TUI 表格錯位。Term 版本透過 spacing = "term" 嚴格鎖定所有符號與框線的格線邊界。

該選預先整合的 Nerd Font,還是原版字體搭配終端機內建圖示?

在 GitHub Releases 頁面上,每個家族除了原版外,都還有一份檔案高達 80 MB 以上的 -NerdFont.zip 版本。過去許多開發者習慣無腦下載預先打好補丁的 IoskeleyMonoTerm Nerd Font Mono,但在 Ghostty、WezTerm 或 Kitty 等現代終端機環境下,把 Nerd Font 圖示直接焊進字型檔裡其實有四個明顯的缺點:

  1. 會屏蔽 Ghostty 內建的智慧圖示縮放引擎(Built-in Nerd Fonts): 像 Ghostty 這類現代終端機已經在底層原生內建了完整的 Symbols Nerd Font 作為預設 fallback,並且實作了極為聰明的動態字元格縮放邏輯(constraint-based glyph scaling)——當終端機遇到 Private Use Area (PUA) 區段的圖示碼位、且右側緊鄰空白字元時(例如 eza -l、lazygit、yazi 檔案樹或 starship 提示字元),Ghostty 會自動允許圖示以更清晰飽滿的比例渲染;若右側緊鄰一般文字,則自動收斂回單一字元格寬度。 然而,一旦你安裝並指定了預先打上 --mono 補丁的 IoskeleyMonoTerm Nerd Font Mono,因為主字型檔本身已經佔據了所有 PUA 碼位,Ghostty 根本不會觸發內建的圖示 fallback 與動態縮放機制,而是直接畫出字型檔裡被 FontForge 硬生生壓縮塞進窄小單格(1-cell)的微型圖示,反而讓資料夾、Git 分支與語言圖示縮得又小又難辨識。
  2. FontForge 二次轉檔會破壞原生 Hinting 與 OpenType 表格: Iosevka 編譯器透過 ot-builder 與 ttfautohint 產出的原版字型,同時提供了精心微調的 Hinted/ 與保留原始曲線的 Unhinted/ 兩種版本。但當 CI 管線使用 FontForge 執行 font-patcher 注入圖示並重新匯出 .ttf 時,原有的 hinting 結構會被重新改寫(這也是為什麼官方 README 特別註明 Nerd Font 安裝包無法區分 Hinted 與 Unhinted),甚至在轉檔時還會發生清除 post.isFixedPitch 等寬旗標的副作用。
  3. 40 倍的圖示重複塞入,造成字型檔體積暴增: Nerd Fonts 完整圖示集包含超過一萬個向量圖示。由於圖示本身並沒有「極細體 Thin」或「極粗斜體 Black Italic」的差別,將圖示直接打進字型檔,等於在 10 種字重 × 2 種字寬 × 正斜體共 40 個 .ttf 檔案裡重複複製了一萬個完全相同的圖示,導致單一壓縮包膨脹到近 88 MB,不僅佔用磁碟與字型快取記憶體,也拖慢應用程式啟動時的字型掃描速度。
  4. 字體與圖示版本強耦合,維護成本高: 當未來 Nerd Fonts 釋出新版圖示,或是你想自行微調 Ioskeley Mono 的字形參數重新編譯時,預先打補丁的做法必須重新用 FontForge 跑完 40 個檔案的修補流程;反之,若保持字體純淨,圖示與文字就能徹底解耦。

因此,實務上的最佳選擇策略非常明確:

  • 如果你使用 Ghostty(或 WezTerm 等內建 Nerd Font 的現代終端機):強烈建議直接安裝未打補丁的原版 Ioskeley Mono Term,把圖示渲染交給 Ghostty 原生的內建引擎處理,既能保留最純淨的 Hinted / Unhinted 字體品質,又能享受最清晰的圖示比例。
  • 如果你使用的終端機或狀態列工具(如 Alacritty、Foot、Waybar)沒有內建圖示:比起安裝 40 個預先修補的龐大字型檔,更推薦在系統層級單獨安裝一個僅約 2.5 MB 的符號專用字型(如 Arch Linux 的 ttf-nerd-fonts-symbols-mono),讓 Fontconfig 自動透過 fallback 機制補齊圖示;只有在極少數不支援字型 fallback 的舊型軟體中,才需要考慮安裝預先整合的 IoskeleyMonoTerm Nerd Font Mono。

字寬選擇:Normal (100%) vs. SemiCondensed (90%)

這是 Ioskeley Mono 相比原版商業字體的一大彈性優勢:

  • Normal (100%):字元寬高比與 Berkeley Mono (TX-02) 標準版一致,適合外接 27 吋以上 4K 螢幕、或是習慣將視窗維持在兩欄以內的開發環境。
  • SemiCondensed (90%):將水平推進寬度微縮 10%。正如你在上方互動實驗室看到的即時統計,在相同的視窗寬度下,SemiCondensed 每一列可以多塞進約 11% 的字元數,例如原本只能塞 80 列的分割視窗可以直接擴充到 89 列,而且因為字懷原本就方正飽滿,縮減 10% 後完全不會有壓扁變形的窒息感,非常適合筆電螢幕或重度 tmux / TUI 多視窗使用者。

渲染微調:Hinted vs. Unhinted

  • macOS 與 Linux HiDPI 外接螢幕:在 2x 縮放以上的高解析度螢幕上,建議選擇 Unhinted,讓 FreeType 或 CoreText 直接根據原始向量輪廓進行平滑抗鋸齒渲染,最能保留字體原本的粗細比例。
  • Windows ClearType 或標準 1080p 螢幕:在較低像素密度的環境下,建議選擇 Hinted 版本,透過字型內嵌的網格對齊指令(grid-fitting hints)強制將水平與垂直筆畫貼齊實體像素邊界,避免小字級下出現邊緣發虛。

在 IDE 與 Linux 終端機的實戰配置

以下整理我在日常使用的編輯器與 Linux 終端機環境中,讓 Berkeley Mono 與 Ioskeley Mono 發揮最佳視覺效果的設定範例。

VS Code 與 Zed 編輯器設定

在 VS Code 的 settings.json(支援 JSONC 註解語法)中,我們可以將 Ioskeley Mono 與 Berkeley Mono 排進 fallback 鏈,並透過 editor.fontLigatures 同時控制連字與點狀/斜線零:

{
  "editor.fontFamily": "'Ioskeley Mono', 'Berkeley Mono', 'Monaspace Neon', 'Cascadia Code', Consolas, monospace",
  "editor.fontSize": 14.5,
  "editor.lineHeight": 1.55,
  "editor.fontWeight": "400",
  // 若想維持預設點狀 0 並開啟連字:
  "editor.fontLigatures": "'calt' 1, 'liga' 1, 'zero' 0"
  // 若想將 0 切換為斜線 0,改設為: "'calt' 1, 'liga' 1, 'zero' 1"
}

而在以 Rust 與 GPUI 打造的 Zed 編輯器(~/.config/zed/settings.json)中,則可以透過 buffer_font_features 精準指定:

{
  "buffer_font_family": "Ioskeley Mono",
  "buffer_font_size": 15,
  "buffer_line_height": { "custom": 1.55 },
  "buffer_font_features": {
    "calt": true,
    "zero": false
  }
}

主流終端機設定(Ghostty、Alacritty、Foot)

在 terminal 環境中,建議直接指定原生的 Ioskeley Mono Term,確保 TUI 框線(┌─┬─┐)嚴格對齊字元格,同時讓 Ghostty 的內建圖示引擎或系統的 Symbols Nerd Font Mono fallback 負責渲染開發者圖示。

Ghostty (~/.config/ghostty/config)

Ghostty 原生內建 Nerd Font 圖示支援,直接指定未打補丁的 Ioskeley Mono Term 即可獲得最飽滿的圖示比例與純淨字形:

font-family = "Ioskeley Mono Term"
font-size = 12
adjust-cell-height = 10%
# 若偏好斜線 0,可取消下一行註解:
# font-feature = +zero

Alacritty (~/.config/alacritty/alacritty.toml) 與 Foot (~/.config/foot/foot.ini)

在 Alacritty 與 Foot 這類不含內建圖示的終端機中,只要系統已安裝獨立的 ttf-nerd-fonts-symbols-mono(或透過 Fontconfig 設定 fallback),同樣可以直接指定原生的 Ioskeley Mono Term:

[font]
size = 12.0
normal = { family = "Ioskeley Mono Term", style = "Regular" }
bold = { family = "Ioskeley Mono Term", style = "Bold" }
italic = { family = "Ioskeley Mono Term", style = "Italic" }

在 Wayland 原生輕量終端機 Foot 中(亦可在逗號後顯式串接 Symbols Nerd Font Mono 作為後備字型):

[main]
font=Ioskeley Mono Term:size=12, Symbols Nerd Font Mono:size=12
line-height=18px

在 Omarchy Linux 中一鍵完成全系統切換

如果你跟我一樣在桌機或筆電上使用 DHH 主導的 Omarchy 開發者環境(基於 Arch Linux + Hyprland),設定起來又更省事了。將原版字型檔解壓縮放入 ~/.local/share/fonts/IoskeleyMono/ 並更新字型快取後,只需要執行內建的 omarchy-font-set 指令:

# 重新掃描字型快取並確認系統已識別
fc-cache -fv ~/.local/share/fonts/IoskeleyMono
fc-list | grep -i "Ioskeley Mono Term"

# 一鍵同步更新系統所有終端機與預設等寬字體
omarchy-font-set "Ioskeley Mono Term"
# 或在較新版 Omarchy 使用: omarchy font set "Ioskeley Mono Term"

這行指令會自動幫你把 Ghostty、Kitty、Alacritty、Foot、Waybar 狀態列,以及 ~/.config/fontconfig/fonts.conf 中的全域 <family>monospace</family> 對應一次更新到位。當所有視窗、狀態列與 TUI 面板都換上統一的 1970 年代工程手冊字形時,整個桌面環境的視覺一致性真的非常賞心悅目。

結語:商業原創與開源參數化造字的良性共鳴

回頭看 Berkeley Mono (TX-02) 與 Ioskeley Mono 的關係,我認為它們並不是零和替代,而是字體生態系裡一段很有意思的對話:

  • 如果你深深著迷於 Neil Panchal 對每一筆曲線的手工打磨,且日常主力環境是固定的個人工作站,直接購買一套商業版 Berkeley Mono 支持獨立字體設計師,絕對是一筆值得的投資。
  • 如果你需要跨多台 Linux 機器透過 dotfiles 自動部署、偏愛 SemiCondensed (90%) 的高密度分割視窗排版、需要原生的 Nerd Font terminal 支援,或者想先在自己的日常專案裡零成本體驗這種冷冽俐落的工程手冊美學,那麼開源的 Ioskeley Mono 無疑是目前完成度極高的選擇。

不妨現在就捲回上方的互動實驗室,把你最常寫的程式碼貼進去,親眼試試看哪一種字寬與字形組合最對你的胃口吧!


Python 3.15 的 Tail-Call Interpreter 架構與效能實證

釐清 TCO 迷思 · CPython 指令調度架構演進 · 組合語言剖析與 Rust 2,000 萬次運算基準測試
featured.svg

前陣子翻閱 Python 3.15 的 What’s New 文件時,官方安裝檔的一項底層變更引起了我的注意:在官方 64-bit Windows 安裝檔中,預設正式啟用了 Tail-Call Interpreter(尾呼叫直譯器)。

社群裡不少人的第一反應是:「哇!Python 終於支援尾遞迴最佳化(Tail Call Optimization, TCO)了嗎?以後寫深度遞迴不用再怕 RecursionError 了?」

很可惜,答案是完全沒有。

如果你在 Python 3.15 裡寫了一個沒有終止條件的遞迴函式,它依然會在跑完第一千層時準時噴出熟悉的 RecursionError: maximum recursion depth exceeded。Python 之父 Guido van Rossum 與核心開發團隊過去多次明確表示,為了保留完整的呼叫堆疊資訊(traceback)與除錯能力,Python 不會採用語言層級的尾遞迴消除。

那麼,官方所說的 Tail-Call Interpreter 究竟是什麼?

它不是提供給一般開發者的語言語法特性,而是 CPython 虛擬機核心在執行位元組碼(bytecode)時,在指令調度(opcode dispatch)架構上的一場精妙重構。這項機制最初在 Python 3.14 以選用編譯參數(--with-tail-call-interp)亮相,到了 Python 3.15 則正式成為官方 Windows 64-bit 二進位發行版的預設架構。

這篇文章我們就來拆解:直譯器核心是如何執行位元組碼的?從經典的 switch-case 迴圈到 computed goto,再到現在的 tail-call interpreter,底層發生了什麼改變?接著,我們會用 Rust 親手刻出兩套直譯器,透過真實的 A/B 效能評測與組合語言反組譯,看看尾呼叫調度究竟是憑藉哪些硬體機制跑出 15% 到 20% 的效能提升。

什麼是直譯器的指令調度(Opcode Dispatch)?

要理解尾呼叫直譯器,首先得先看看所有虛擬機的心臟地帶。

當我們把 Python 原始碼編譯成位元組碼後,程式會變成一連串緊湊的指令流。虛擬機的主要工作,就是一個無止境的調度迴圈:讀取下一個 opcode、執行它對應的邏輯、再讀取下一個。

在 CPython 歷史與大多數入門直譯器中,最直觀的寫法是 Switch Dispatch:

// 傳統的 switch 調度迴圈
while (true) {
    switch (*pc++) {
        case OP_ADD:
            acc += *pc++;
            break;
        case OP_SUB:
            acc -= *pc++;
            break;
        case OP_HALT:
            return acc;
    }
}

這段程式碼看起來非常單純,但在現代超純量處理器(superscalar CPU)的硬體管線上,這樣的設計存在兩個嚴重的效能瓶頸:

switch-dispatch.svg

每執行一條 bytecode,CPU 至少得承擔兩次跳躍:

  1. 跳到目標 opcode:從中央 switch 跳躍表發起的間接跳躍(indirect branch)。
  2. 跳回迴圈頂端:opcode 處理完後踩到 break,無條件跳回 while 迴圈開頭。

更致命的是硬體分支預測器(branch predictor)的負擔。在中央 switch 處,只有唯一一個間接跳躍指令位址。不管當前執行的位元組碼是加法、乘法還是屬性存取,全都必須通過這同一個跳躍點。現代處理器的分支目標緩衝區(Branch Target Buffer, BTB)很難在單一站點記錄高動態的跳躍規律,導致間接分支預測失誤(branch misprediction)頻繁發生。每次預測失誤,CPU 都得清空管線(pipeline flush),白白浪費 15 到 20 個時脈週期。

從 Direct-Threaded Code 到編譯器的極限

為了解決中央跳躍點的擁擠問題,直譯器領域多年前就發展出了 Direct-Threaded Code(直通線程代碼,常利用 C 語言的 computed goto 實現):

// GCC / Clang 的 Labels as Values 擴充
static void* dispatch_table[] = { &&do_add, &&do_sub, &&do_halt };

#define DISPATCH() goto *dispatch_table[*pc++]

do_add:
    acc += *pc++;
    DISPATCH();

do_sub:
    acc -= *pc++;
    DISPATCH();

do_halt:
    return acc;

在 direct-threaded code 裡,不再有中央的 while 迴圈與 switch。每個 opcode 執行完後,直接抓取下一個 opcode 的位址並就地跳躍。

這個做法把間接跳躍點分散到各個 opcode 的末端。例如,do_add 末尾有一個專屬的 goto *,do_sub 末尾也有自己的 goto *。硬體分支預測器可以分別記錄不同 opcode 之後通常會接續哪些指令,分支預測命中率因此顯著上升。

自 2010 年前後起,CPython 在 Unix 平台(使用 GCC/Clang 編譯)就一直預設開啟 computed goto。但這個方案有兩個難以克服的痛點:

  • 非標準語法與平台碎片化:Labels as values 是 GCC/Clang 的擴充功能,微軟的 MSVC 編譯器長期以來完全不支援。這意味著近二十年間,Windows 平台上的 Python 官方安裝檔只能退回最慢的 switch 迴圈!
  • 編譯器最佳化的惡夢:CPython 的執行核心 ceval.c(連同內嵌由 Python/bytecodes.c DSL 產生的 generated_cases.c.h)超過五千行,核心評估函式 _PyEval_EvalFrameDefault 本身也有數千行。當一個函式裡塞入幾百個互相 goto 的標籤時,編譯器的暫存器分配器(register allocator)會面臨嚴重的溢出(register spilling)。現代 LLVM/GCC 很難對這種錯綜複雜的控制流程圖(Control Flow Graph, CFG)進行激進的就地最佳化。

這就是為什麼 Faster CPython 團隊(包括 Mark Shannon、Brandt Bucher、Ken Jin 等人)開始探索新的調度架構:Tail-Call Interpreter。

Tail-Call Interpreter 的運作機制

尾呼叫直譯器的核心概念非常純粹:將每一個 opcode 拆成一個獨立、專門的小函式,函式的最後一行直接尾呼叫(tail call)下一個 opcode 函式。

在現代 C 語言中(仰賴 Clang 與 MSVC 提供的屬性):

typedef void (*OpHandler)(State* state, const uint8_t* pc, int64_t acc);

void op_add(State* state, const uint8_t* pc, int64_t acc) {
    acc += *pc++;
    uint8_t next_op = *pc;
    [[clang::musttail]] return state->table[next_op](state, pc, acc);
}

void op_sub(State* state, const uint8_t* pc, int64_t acc) {
    acc -= *pc++;
    uint8_t next_op = *pc;
    [[clang::musttail]] return state->table[next_op](state, pc, acc);
}

這個結構看起來像是相互遞迴,直覺上會讓人擔心:「每執行一個指令就呼叫一個函式,呼叫堆疊(stack frame)不是幾千層就爆掉了嗎?」

這正是關鍵所在:musttail 屬性會強制編譯器保證進行尾呼叫消除 (tail call elimination, TCE)。

當函式在 return 時直接呼叫另一個函式,且本身不再需要保留任何本地變數時,編譯器不需要分配新的堆疊框架,也不需要發出 call 與 ret 指令。它會直接重複使用當前的堆疊框架,並將呼叫改寫成單純的跳躍指令:

jmp    rax                            ; 一條直接跳躍,堆疊深度永遠維持在 1!
tail-call-dispatch.svg

關鍵利器:preserve_none 呼叫慣例

許多人不知道的是,單純依靠 musttail 其實並不足以榨出直譯器的極致效能。在傳統 ABI 呼叫慣例(calling convention)下,跨函式呼叫依然必須維護被呼叫端保存暫存器(callee-saved registers)的序幕(prologue)與尾聲(epilogue)。

CPython 真正的關鍵突破,是結合了源自 GHC 風格的專屬呼叫慣例:__attribute__((preserve_none))。

何謂 GHC 風格?

這裡提到的 GHC,指的是純函數式程式語言 Haskell 最知名的旗艦編譯器——Glasgow Haskell Compiler。

在純函數式語言的世界裡,語法層面完全沒有傳統的 while 或 for 迴圈。所有的重複運算、狀態移轉與控制流程,本質上都是透過高階函式與廣泛的尾遞迴呼叫來實現。這意味著 GHC 編譯出的二進位程式,每一秒鐘都在底層進行數以百萬計的尾呼叫與跳躍。

如果 GHC 遵循標準 C 語言的呼叫慣例(例如 x86-64 上的 System V AMD64 ABI 或 Windows x64 ABI),編譯器就必須在每次呼叫時為呼叫端保留一整批 callee-saved 暫存器(如 rbx、r12–r15)。在無窮無盡的連續尾呼叫場景中,反覆把暫存器 push 到記憶體堆疊、跳躍後再 pop 還原,完全是在虛耗寶貴的 CPU 週期。

為了解決這個瓶頸,GHC 很久以前就為其內部虛擬機設計了專屬的呼叫慣例:

  • 零 callee-saved 暫存器:沒有任何暫存器需要為呼叫端保留,所有一般暫存器全數視為拋棄式的暫時暫存器(scratch registers)。
  • 狀態長駐暫存器:核心狀態(如堆疊指標、當前指令指標、累加值)可以在暫存器之間自由直通傳遞,跳躍時不需要進出記憶體堆疊。

這項設計在編譯器領域極為著名,甚至十多年前 LLVM 就特別為 GHC 內建了專屬呼叫慣例代碼 ghccc(GHC Calling Convention)。

將 GHC 哲學帶入 CPython

傳統上,這種高度客製化的呼叫慣例只存在於 GHC 等少數編譯器內部。但在位元組碼直譯器的場景中,問題本質其實與 GHC 如出一轍:每一個 opcode handler 執行完畢後,只會直接尾呼叫下一個 opcode handler,根本沒有返回原本呼叫端的需求。

現代 Clang 所引入的 __attribute__((preserve_none)),本質上就是將 GHC 這種「零保留負擔」的設計哲學通用化引進 C 語言體系。

當我們為 opcode handler 加上這個屬性時,等於明確告訴編譯器「被呼叫的函式不需要為呼叫端保留任何一般暫存器」。這讓虛擬機能將當前評估堆疊指標(stack pointer)、位元組碼指標(program counter)與累加狀態長駐在 CPU 暫存器中,跳躍時不需要反覆在記憶體堆疊上存取與還原,大幅釋放了暫存器分配器(register allocator)的自由度,才真正實現了與 musttail 完美搭配的極致調度效能。

跨平台編譯器支援:Clang 與 MSVC 18

在編譯器支援層面上:

  • [[clang::musttail]] 屬性早在 Clang 13(2021 年)就已支援;而 CPython 的尾呼叫直譯器之所以在規格上指定 Clang 19+,是因為結合了 preserve_none 慣例與針對 tail call 的深度優化。
  • 在 Windows 平台,微軟在 MSVC 18(Visual Studio 2026)中正式引入了 [[msvc::musttail]](見 CPython 相關 issue gh-143068 與 gh-139922)。這讓 Python 官方 Windows 64-bit 發行版在 Python 3.15 終於能原生啟用尾呼叫直譯器,徹底告別長年落後的 switch 迴圈!

Nelson Elhage 的效能排錯與真實數據

在 Python 3.14 開發初期,社群首次測試 --with-tail-call-interp 時,曾傳出「效能暴增 10% 到 15%」的驚人數據。

知名系統工程師 Nelson Elhage 在其部落格文章中深入分析反組譯與編譯器行為後,揭開了有趣的真相:這項驚人的數據,很大一部分是因為當時主流的 LLVM 19 出現了針對 computed goto 的效能衰退(regression),導致作為對照組的基準直譯器異常變慢,才反襯出尾呼叫直譯器的巨大優勢。

當基準線修正回正常的編譯器設定後:

  • 在原本就有 computed goto 的 Linux/macOS 環境中,尾呼叫帶來的純效能增益大約是穩健的 1% 到 5%。
  • 但在原本只能跑 switch 迴圈的 Windows x86-64 平台,官方 What’s New 數據顯示,在 Visual Studio 18.1.1 編譯下獲得了高達 15% 到 20% 的幾何平均吞吐量躍升(pyperformance geomean)!

用 Rust 親手實作兩套直譯器

理論說得再漂亮,不如親手實作並觀察底層指令。我們用現代系統語言 Rust 來實作一個精簡的虛擬機,並同時打造 Switch Dispatch 與 Tail-Call Dispatch 兩個版本進行 A/B 評測。

指令集定義

我們定義一組包含算術、位元運算與控制流的典型指令:

#[repr(C)]
#[derive(Copy, Clone, Debug)]
pub struct Instruction {
    pub op: u32,
    pub arg: i32,
}

pub const OP_ADD: u32 = 0;
pub const OP_SUB: u32 = 1;
pub const OP_MUL: u32 = 2;
pub const OP_XOR: u32 = 3;
pub const OP_INC: u32 = 4;
pub const OP_DEC: u32 = 5;
pub const OP_HALT: u32 = 6;

對照組:經典 Switch 直譯器

首先是大家最熟悉的中央迴圈版本:

#[inline(never)]
pub fn run_switch(code: &[Instruction], mut acc: i64) -> i64 {
    let mut pc = 0;
    loop {
        let instr = unsafe { *code.get_unchecked(pc) };
        match instr.op {
            OP_ADD => {
                acc = acc.wrapping_add(instr.arg as i64);
            }
            OP_SUB => {
                acc = acc.wrapping_sub(instr.arg as i64);
            }
            OP_MUL => {
                acc = acc.wrapping_mul(instr.arg as i64);
            }
            OP_XOR => {
                acc ^= instr.arg as i64;
            }
            OP_INC => {
                acc = acc.wrapping_add(1);
            }
            OP_DEC => {
                acc = acc.wrapping_sub(1);
            }
            OP_HALT => {
                break;
            }
            _ => unreachable!(),
        }
        pc += 1;
    }
    acc
}

實驗組:Tail-Call 直譯器

在 Tail-Call 版本中,我們定義一個函式指標簽名 OpHandler。每個指令都是一個獨立函式,參數透過暫存器傳遞,最後一行抓出下一個指令並直接尾呼叫:

pub struct VmContext {
    pub table: [OpHandler; 16],
}

pub type OpHandler = unsafe fn(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64;

#[inline(never)]
pub unsafe fn op_add(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_add((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_sub(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_sub((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_mul(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_mul((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_xor(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc ^ ((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_inc(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_add(1);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_dec(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_sub(1);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_halt(_ctx: &VmContext, _pc: *const Instruction, acc: i64) -> i64 {
    acc
}

static TABLE: [OpHandler; 16] = {
    let mut t: [OpHandler; 16] = [op_unreachable; 16];
    t[OP_ADD as usize] = op_add;
    t[OP_SUB as usize] = op_sub;
    t[OP_MUL as usize] = op_mul;
    t[OP_XOR as usize] = op_xor;
    t[OP_INC as usize] = op_inc;
    t[OP_DEC as usize] = op_dec;
    t[OP_HALT as usize] = op_halt;
    t
};

pub unsafe fn op_unreachable(_ctx: &VmContext, _pc: *const Instruction, _acc: i64) -> i64 {
    unreachable!()
}

#[inline(never)]
pub fn run_tail_call(code: &[Instruction], acc: i64) -> i64 {
    let ctx = VmContext { table: TABLE };
    let pc = code.as_ptr();
    unsafe {
        let first_op = (*pc).op as usize;
        let first_fn = *ctx.table.get_unchecked(first_op);
        first_fn(&ctx, pc, acc)
    }
}
 Note

在 Rust 穩定的編譯器版本中,當函式末端的型別簽名與呼叫慣例完全契合時,LLVM 後端會自動將其最佳化為單一跳躍指令。在 Rust 的語言演進中,亦有專屬的 become 關鍵字提案(RFC 3407 / #![feature(explicit_tail_calls)],追蹤於 issue #112788)正在推進,旨在讓開發者能在語言層級強制要求編譯器進行保證消除。

組合語言對決:為什麼尾呼叫能勝出?

我們使用 objdump -d -M intel 將編譯出來的機器碼反組譯,答案立刻浮現眼前。

對照組:run_switch 的反組譯機器碼

; 中央 switch 迴圈的頭部 (位址 16fd0)
.loop_head:
    mov    r8d, DWORD PTR [rdi+rax*8]     ; [16fd0] 讀取 code[pc].op
    cmp    r8, 0x6                        ; [16fd4] 邊界檢查
    ja     .out_of_bounds                 ; [16fd8] 超出範圍跳出 (1701e)
    movsxd rsi, DWORD PTR [rdi+rax*8+0x4] ; [16fda] 讀取 code[pc].arg
    movsxd r8, DWORD PTR [rcx+r8*4]       ; [16fdf] 查詢跳躍表
    add    r8, rcx                        ; [16fe3]
    jmp    r8                             ; [16fe6] 【跳躍 1】間接跳躍到 handler

; OP_ADD 處理區塊 (位址 16fe9)
.op_add:
    add    rdx, rsi                       ; [16fe9] acc += arg
    inc    rax                            ; [16fec] pc++
    jmp    .loop_head                     ; [16fef] 【跳躍 2】無條件跳回迴圈頭部 (16fd0)!

看見問題了嗎? 每執行一個指令,CPU 必須:

  1. 執行運算並增加 pc。
  2. 執行一次無條件跳躍 jmp .loop_head(位址 16fd0)回到迴圈頭部。
  3. 執行 cmp r8, 0x6 與條件分支 ja 進行保護性檢查。
  4. 查表後執行間接跳躍 jmp r8 進入下一個 opcode。

實驗組:op_add(Tail Call)的反組譯機器碼

再來看看 Tail Call 版本的 op_add:

op_add:
    movsxd rax, DWORD PTR [rsi+0x4]       ; [18320] 讀取當前 arg
    add    rdx, rax                       ; [18324] acc += arg
    mov    eax, DWORD PTR [rsi+0x8]       ; [18327] 預先讀取下一個指令的 op
    add    rsi, 0x8                       ; [1832a] pc++
    jmp    QWORD PTR [rdi+rax*8]          ; [1832e] 【唯一的跳躍】直接跳進下一個 handler!

整整只有 5 條組合語言指令!

  • 沒有第二個跳躍指令:執行完運算後,直接一條 jmp 跳到下一個 handler 的位址。
  • 免除熱路徑檢查:不需要跳回迴圈頂端做 cmp/ja。
  • 零堆疊框架:沒有 call、沒有 ret、沒有 push rbp。
  • 分散的分支預測目標:op_add 的間接跳躍位址固定在 1832e,op_sub 則在 1834e,兩者各自建立獨立的分支歷史快取。

A/B 實測 Benchmark 數據

我們寫了一個基準測試程式,隨機生成包含加法、減法、乘法、XOR、遞增、遞減等混合分佈的 2,000 萬條(20,000,000)bytecode 指令序列。

為了避免編譯器 dead code elimination,我們使用 std::hint::black_box 封裝輸入,並嚴格斷言兩種直譯器跑完 2,000 萬條指令後產生的數值完全一致:

// 驗證運算結果正確性
assert_eq!(res_switch, res_tail, "Results must match!");

實測數據對比

在 x86-64 機器(Linux, Release Mode, opt-level = 3)上執行 5 次運算取平均值:

指令調度機制 2,000 萬條指令平均執行時間 指令吞吐量(Ops / sec) 效能表現
Switch Dispatch 91.09 ms 219.6 M ops/sec 基準對照組
Tail-Call Dispatch 74.86 ms 267.1 M ops/sec 快 17.81%(1.22x 吞吐量)

各輪次運算時間十分穩定:

[Switch]   Run 1: 91.25 ms | Run 2: 91.03 ms | Run 3: 90.93 ms | Run 4: 90.87 ms | Run 5: 91.38 ms
[TailCall] Run 1: 74.85 ms | Run 2: 74.82 ms | Run 3: 74.78 ms | Run 4: 74.89 ms | Run 5: 74.98 ms

在完全相同的指令流與相同的硬體環境下,Tail-Call Dispatch 跑出了整整 17.81% 的效能提升!

這 17.8% 的差距正來自我們稍早在反組譯分析中所看見的三大優勢:

  1. 指令跳躍次數減半:每條 opcode 省下了跳回迴圈頂端的一道跳躍指令。
  2. 省除熱路徑檢查:省下了每輪迴圈的邊界比對。
  3. 更友善的分支預測:處理器 BTB 分散記錄跳躍歷史,管線空泡大幅減少。

這個實測幅度,也正好落在 CPython 官方於 What’s New 中公布的 Windows(Visual Studio 18.1.1)15% 到 20% 增益區間。

 Tip

線上即時體驗與組合語言對照:本實驗的完整可重現程式碼已發布至 Compiler Explorer (Godbolt),包含兩種直譯器的完整實作與 2,000 萬次指令隨機測試。讀者可直接在瀏覽器中對照編譯後的 x86-64 組合語言,並點擊 Run 即時執行驗證。

結語與技術省思

回過頭來看 Python 3.15 的變更:

  1. Python 依然沒有尾遞迴最佳化:使用者寫的遞迴函式行為完全沒變,依舊保留最完整的 traceback 偵錯堆疊(沒有語言層級的 TCO)。
  2. Tail-Call 是直譯器核心的工程升級:它藉由把 opcode 拆分為獨立函式、依賴編譯器的尾呼叫消除與 preserve_none 慣例,在兼顧程式碼模組化與編譯器暫存器優化的同時,在硬體層級模擬出高效的分散式間接跳躍。
  3. Windows 使用者是最大受益者:長期受限於 MSVC 無法支援 computed goto 的 Windows 版 Python,透過 MSVC 18 的 [[msvc::musttail]] 終於補齊了近二十年的跳躍效能缺口。

在軟體工程的世界裡,許多看似屬於高階語言語義的名詞,換到了編譯器與硬體管線的視角,往往展現出截然不同、卻又無比精妙的底層面貌。下次看見直譯器更新日誌中的「Tail-Call」,我們就能會心一笑:它不是讓你在程式碼裡寫無窮遞迴,而是悄悄替 CPU 的分支預測器卸下了一副沉重的枷鎖。