JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

從 C 到組合語言:運算式與分支

一支 C 程式滿是算術、if 與大括號——但晶片只認得一串扁平的小指令。看編譯器如何把一個運算式攤平,把 if/else 變成一條條件分支,並認識那個把可讀助記符變成硬體實際執行的位元的組譯器。

語言堆疊的最底層

到現在你已經把 ISA 看成一份契約:一串固定的指令、幾個暫存器,還有一條扁平的位元組陣列。組合語言不過就是那份契約寫給人眼看的版本。每一行就是一條機器指令,用一個可讀的名字寫出來——add、lw、beq——而不是一堆原始的位元圖案。這裡幾乎沒有任何抽象:一行組合語言就是硬體真正會跑的一條指令。那些親切的名字是對我們的一份體貼;晶片從來看不到它們。

像 C 這樣的高階語言則住在上一層樓。當你寫下 y = (a + b) * c;,你描述的是一個結果,而不是一連串機器步驟。在這兩層樓之間搭橋的工作,屬於編譯器——那個讀進 C、吐出組合語言的工具。編譯器對目標 ISA 瞭如指掌——這正是所謂編譯器以某個 ISA 為目標的意思——它必須把你那層層套疊、加了括號的運算式,攤成一條嚴格、單行排隊的指令隊伍,好讓機器能一次走一步地行軍過去。

把運算式攤平

像 (a + b) * c 這樣的運算式,在你腦中是一棵小樹:一個乘法,它的左枝本身又是一個加法。硬體握不住一棵樹;它一次只能做一個運算,並把每個中間結果暫存在某處。於是編譯器從葉子往內走過這棵樹,把每個子結果算進一個暫存器,再餵給下一步。這就是程式碼產生的核心:一棵樹變成一條筆直、有序的隊伍。回想 ISA 那一級的載入-儲存規則——算術只碰暫存器——所以任何在記憶體裡的變數,都得先被載入。

  1. 假設 a、b、c 已經坐在暫存器 x10、x11、x12 裡,而答案 y 該落到 x13。(如果它們住在記憶體裡,每一個都得先載入——算術只碰暫存器。)
  2. 先算內層的加法,因為它是最深的葉子:add x13, x10, x11 讓 x13 存著 a + b。
  3. 把那個中間結果直接餵進乘法:mul x13, x13, x12 把 x13 當成累加器重複使用,得到 (a + b) 乘以 c。
  4. 那棵套疊的樹,現在成了一對扁平、有序的指令,機器能一次走一步地行軍過去——那道攤直的動作,正是程式碼產生在做的事。

注意,從編譯器的角度看,減法不需要任何獨立的機械。因為整數是以二補數儲存的,硬體計算 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
一個 if/else 被降階成一條條件分支(blt=小於則分支)外加一條無條件跳躍,用來略過另一條手臂。像 'then' 這樣的標籤,只是位址的名字罷了。

條件、旗標與比較

一條分支到底是怎麼決定的?這裡有兩大風格,兩種都懂能讓你讀不同機器時不致混淆。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 經由編譯器變成組合語言,組合語言再經由組譯器變成可執行的位元。