語言堆疊的最底層
到現在你已經把 ISA 看成一份契約:一串固定的指令、幾個暫存器,還有一條扁平的位元組陣列。組合語言不過就是那份契約寫給人眼看的版本。每一行就是一條機器指令,用一個可讀的名字寫出來——add、lw、beq——而不是一堆原始的位元圖案。這裡幾乎沒有任何抽象:一行組合語言就是硬體真正會跑的一條指令。那些親切的名字是對我們的一份體貼;晶片從來看不到它們。
像 C 這樣的高階語言則住在上一層樓。當你寫下 y = (a + b) * c;,你描述的是一個結果,而不是一連串機器步驟。在這兩層樓之間搭橋的工作,屬於編譯器——那個讀進 C、吐出組合語言的工具。編譯器對目標 ISA 瞭如指掌——這正是所謂編譯器以某個 ISA 為目標的意思——它必須把你那層層套疊、加了括號的運算式,攤成一條嚴格、單行排隊的指令隊伍,好讓機器能一次走一步地行軍過去。
把運算式攤平
像 (a + b) * c 這樣的運算式,在你腦中是一棵小樹:一個乘法,它的左枝本身又是一個加法。硬體握不住一棵樹;它一次只能做一個運算,並把每個中間結果暫存在某處。於是編譯器從葉子往內走過這棵樹,把每個子結果算進一個暫存器,再餵給下一步。這就是程式碼產生的核心:一棵樹變成一條筆直、有序的隊伍。回想 ISA 那一級的載入-儲存規則——算術只碰暫存器——所以任何在記憶體裡的變數,都得先被載入。
- 假設 a、b、c 已經坐在暫存器 x10、x11、x12 裡,而答案 y 該落到 x13。(如果它們住在記憶體裡,每一個都得先載入——算術只碰暫存器。)
- 先算內層的加法,因為它是最深的葉子:add x13, x10, x11 讓 x13 存著 a + b。
- 把那個中間結果直接餵進乘法:mul x13, x13, x12 把 x13 當成累加器重複使用,得到 (a + b) 乘以 c。
- 那棵套疊的樹,現在成了一對扁平、有序的指令,機器能一次走一步地行軍過去——那道攤直的動作,正是程式碼產生在做的事。
注意,從編譯器的角度看,減法不需要任何獨立的機械。因為整數是以二補數儲存的,硬體計算 a - b 的方式,是把 a 加上 b 的相反數,而同一個加法器電路就把兩者都辦了。這個單一的設計選擇——你在資料表示那一級遇過的——正是為什麼 ISA 能把 add 與 sub 當成幾乎一模一樣的雙胞胎來提供。編譯器只要挑對助記符就好;二補數讓底下的矽片用同一個加法器幹完兩份活。
把 if/else 變成一條分支
晶片是依位址順序執行指令的——取一條,接著取緊鄰的下一條,再下一條——靠的是悄悄把程式計數器往前推。一個 if 必須打斷這場行軍:依照一次測試的結果,機器得跳過一塊程式碼,去跑另一塊。能做到這件事的指令,就是條件分支:它檢視一個條件,並且只有在條件成立時,才把程式計數器導向另一個位址,而不是順著掉進下一行。一條控制流指令,是唯一能讓程式在路口轉彎的東西。
這裡有個會嚇到新手的小把戲:組合語言會把你的測試反過來。C 說的是「如果條件為真,就執行 then 區塊」,而自然的組合語言說的是「如果條件為假,就跳開去略過 then 區塊」。順著掉下去保持便宜又常見;分支才是例外。所以 if (a < b) { ... } 會編譯成單單一條條件分支:當 a 不小於 b 時,就跳過整個本體。組合語言讀得夠久,這套翻轉的邏輯就會變成本能。
# C: if (a < b) m = a; else m = b; // m = min(a,b)
# a in x10, b in x11, m to land in x12
blt x10, x11, then # if a < b, jump to 'then' (branch taken)
add x12, x11, x0 # else-block: m = b (x0 is always 0)
j done # skip over the then-block
then:
add x12, x10, x0 # then-block: m = a
done:
# ... m is now in x12條件、旗標與比較
一條分支到底是怎麼決定的?這裡有兩大風格,兩種都懂能讓你讀不同機器時不致混淆。RISC-V 把比較直接折進分支本身:blt 吃兩個暫存器,當場比較它們,若第一個比較小就跳。許多較舊的 ISA——x86、ARM——卻把它拆成兩半。一條比較(或任何算術)指令會先設好幾個條件旗標——記錄上一個運算是否為零、是否為負、是否產生進位的微小單位元結果——接著一條獨立的分支指令再去讀那些旗標來決定。
把旗標想成 ALU 在每次運算後留下的便利貼:「結果是零」、「結果是負的」、「最高位元有進位跑出來」。「相等則分支」真正的意思是「零旗標被設起來則分支」,因為 a == b 是靠計算 a - b、再檢查結果是否為零來測試的。這種以旗標為基礎的設計,讓一次比較能餵給好幾條不同的分支,但它多帶了一個隱藏的相依:在比較與分支之間,那些旗標必須原封不動地存活下來。RISC-V 那種融合式的風格繞開了這點,這也是它的組合語言讀起來如此乾淨的原因之一。
組譯器:把助記符變成位元
組合語言是給我們看的,不是給晶片看的,所以最後還得有一個工具,把它向下翻譯成 ISA 所定義的、真正的位元圖案:那就是組譯器。它的工作大半是機械式的記帳。對每一行,它查出運算碼,把暫存器編號與任何立即值打包進指令那些固定格式的欄位裡,再吐出產生的機器字組。關鍵是,它還會把你上面看到的那些標籤——then、done——解析成真實的數字位址,算出每條分支所需要的位移。組譯器正是把你從手動數位址這件事裡解放出來的東西。
組譯器還提供一個會絆倒新手的小便利:虛擬指令(pseudo-instruction)。你寫的某些行,根本不是真正的機器指令——它們是親切的簡寫,由組譯器展開成一條或多條貨真價實的指令。RISC-V 的 mv x12, x10(複製一個暫存器)並不是一個運算碼;組譯器把它變成 addi x12, x10, 0。同樣地,li(載入一個常數)可能變成一或兩條真指令。虛擬指令是組合語言原始碼裡的一份善意,而不是硬體所認得的東西——這正是為什麼反組譯出來的二進位檔,會跟你寫的原始碼看起來有微妙的不同。
組譯器一旦完工,就產出一個目的檔——機器指令,外加一張記著「指向住在別的檔案裡的東西」的未完成參照表。把那些檔案縫在一起、再把程式擺進記憶體裡跑,是連結器與載入器的工作,我們會在這一級的尾聲遇到它們。現在,你已經握有整條管線的前段:C 經由編譯器變成組合語言,組合語言再經由組譯器變成可執行的位元。