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

編譯與直譯:兩條路

你寫的原始碼只是 CPU 看不懂的文字。有兩種策略跨越這道鴻溝——事前一次把整份翻譯成機器碼,或者一邊讀一邊執行。本篇說清楚兩者真正在做什麼,以及 C 為何走上第一條路。

誰都跳不過的那道鴻溝

在上一篇裡,你看著 CPU 反覆做著同一件事:從記憶體取出一個數、把它解碼成一條指令、執行它、再重複——這就是提取–執行週期。關鍵在於它「取出」的到底是什麼。處理器只看得懂機器碼:像 0x48 0x89 0xC3 這樣的原始位元組,在這顆晶片上剛好代表「把暫存器 rax 複製到 rbx」。它完全不知道 `printf` 這個字是什麼意思,它讀不了文字。

可是你寫的是 `int x = 41 + 1;`——給人讀的、易懂的文字。這正是原始碼與機器碼之間的根本張力:好「寫」的形式,恰恰是機器「跑不動」的形式;而機器跑得動的形式,人卻幾乎不可能徒手寫。總得有一段軟體夾在兩者之間做翻譯。做翻譯的兩大策略——先全部翻好,或邊翻邊跑——就是大家口中的編譯與直譯

一開始就先釐清一件事:「編譯」與「直譯」描述的是一種語言「通常怎麼被執行」,而不是語言本身一成不變的性質。原則上,同一份原始碼可以用一個工具編譯、也可以用另一個工具直譯。這個標籤講的是你走哪條路,而不是終點本身——而本篇大半都在談:為什麼位在低階與高階堆疊最底端的 C,幾乎總是走上編譯這條路。

第一條路:先把全部編譯好

編譯器會把你整份原始檔事先讀過一遍,將它翻譯成某一種特定 CPU 的機器碼。產出是一個完工的指令檔——也就是執行檔——作業系統可以把它載入,處理器可以直接執行,執行當下根本沒有任何翻譯器在場。這正是 C 走的路。當你執行 `gcc -O2 -Wall main.c`,你是在請編譯器把 `main.c` 變成一個塞滿「隨取即用」位元組的a.out

因為所有翻譯都發生在程式執行「之前」,編譯器可以慢慢來。它能重排你的迴圈、在編譯期就把 `41 + 1` 摺疊成常數 `42`、把常用的變數留在暫存器而非記憶體裡,還能刪掉它能證明永遠跑不到的死碼。這就是編譯後的 C 通常很快的原因:聰明的功夫只在事前付一次代價,跑起來的程式是純機器碼,不必一再被重新翻譯。這正是「貼近硬體」執行帶來的好處之一。

代價落在兩處。第一,每次改了程式碼,你都必須先編譯才能執行——就是下面會碰到、比較慢也比較講究的節奏。第二,這些位元組是針對單一目標烤出來的。為 x86-64 筆電編譯的執行檔,對 ARM 手機而言只是一堆無意義的亂碼;要在那裡跑,你得針對那顆晶片與作業系統重新編譯一次。這種「綁定目標」的現實,正是可攜性與平台的核心,也是為什麼一支 C 程式可以靠「原始碼」可攜,卻永遠無法靠單一份完成的二進位檔可攜。

第二條路:邊讀邊直譯

直譯器走的是另一條路。它不在事前產出一個獨立的機器碼檔,而是保留你的原始碼(或一種稍微消化過的形式),在程式執行時逐步走過它,走到哪一個運算就執行哪一個。Python、Ruby 與傳統的 shell 大致就是這樣運作。沒有一份 `a.out` 可以交給別人;你交付的是原始碼加上直譯器,而直譯器每次都當場、即時地把活幹完。

這裡有個誠實的取捨。直譯器永遠不會把你的程式碼變成 CPU 的原始指令流——你的迴圈是跑在「直譯器自己的機器碼」之中,由它在執行期決定每一行是什麼意思。這層間接性是要花速度去換的:每次迴圈跑一圈都重新判定一次「這行在做什麼」,是編譯器只需做一次的實打實工作。換來的是極短的回饋迴圈(改一改、執行、立刻看到結果),以及輕鬆的可攜性:同一份腳本在任何建好直譯器的地方都能跑,因為綁定平台的是直譯器,而不是你的程式碼。

C 為何選了編譯這條路

系統程式設計——你在第一篇認識的那種——位在低階與高階這道分界的最底端。你寫的是核心、驅動程式、配置器,乃至於其他語言賴以運行的那些直譯器本身。這種工作要求可預測的速度,以及與記憶體和硬體的直接接觸,不容許任何直譯器卡在你和晶片之間。一個完工的機器碼執行檔正是如此:執行期沒有翻譯器、不必額外附帶任何東西,就只是 CPU 去取出的指令。

還有第二個比較安靜的理由。因為 C 會編譯成單純的機器碼,加上一套小巧而定義明確的呼叫慣例,幾乎任何東西都能呼叫進 C,而 C 也幾乎能呼叫任何東西。這就是為什麼 C 成了作業系統介面所用的通用語:Python 直譯器、你的顯示卡驅動程式、以及核心,全都在 C 的條件上交會。選擇編譯這條路,不只關乎速度——更關乎成為其他一切賴以站立的共同基礎。

實務上的「修改–編譯–執行」迴圈

選了編譯這條路,會塑造你每天的節奏。用直譯器時,你多半就是改一改、跑一跑。用 C,你活在修改–編譯–執行迴圈裡:改文字、請編譯器重新建置,然後才執行結果。多出來的這道編譯步驟其實是一份禮物——編譯器會在程式真正執行之前先把整份讀過,把一整類錯誤(拼錯的名字、對不上的型別)攔在編譯期,那裡修起來便宜,而不是攔在執行期,那裡修起來可不便宜。

以下是最小的完整 C 程式——傳統的hello world——以及把它從文字帶到一個執行中行程的三道指令。注意 `main` 回傳 `0`:它會成為程式的結束狀態,也就是它交還給啟動者的那一個數字,慣例上 0 代表「成功」。第五篇會把這支程式逐行建置並拆解;現在,只要先看清這個迴圈的形狀就好。

/* hello.c */
#include <stdio.h>

int main(void) {
    printf("hello, world\n");
    return 0;            /* 0 = success, becomes the exit status */
}

# the edit-compile-run loop, in three commands:
$ gcc -Wall hello.c -o hello   # compile source -> executable named 'hello'
$ ./hello                      # run it; the OS loads and the CPU fetches
hello, world
$ echo $?                      # the shell shows the exit status
0
文字進、執行檔出,接著是一個執行中的行程——最後交還一個 0,表示「一切順利」。

這就是編譯這條路的全貌縮影。本級接下來,你會去測繪 `./hello` 底下的那些層次——作業系統載入這個檔案時做了什麼、所謂「核心」究竟是什麼——然後第五篇會回過頭來,刻意放慢,建置並執行一支你自己寫的 C 程式。