在這底層真正在跑的是什麼
一路回到工具鏈那一階,你看著你的原始碼變成了機器碼:組譯器把人類看得懂的組合語言變成位元組,連結器把那些位元組縫成一個執行檔,載入器再把它們放進記憶體去跑。我們把結果當成一個黑盒子,就這麼往前走了。本階要打開那個盒子。處理器從來看不到你的 `int x`、你的 `for` 迴圈,或 `main` 這個字——等到任何東西真正開始跑時,這一切都已經溶解成一串平坦、編了號的位元組,由 CPU 一次一小步地讀過去。
先弄清楚你面對的是哪一種機器,會很有幫助。幾乎你這輩子會去寫程式的每一台電腦,都是一台儲存程式電腦:告訴 CPU 該做什麼的那些指令,和它們所操作的資料一樣,都住在同一塊記憶體裡、是同一種位元組。這一個設計抉擇——指令與資料共用一個位址空間——就是范紐曼架構,這也是為什麼你先前建立的那幅圖至今仍然成立。記憶體依舊是一條長長、編了號的位元組陣列;我們現在只是把 CPU 指向其中某些位元組,對它說:「這些是命令。」
所以在這個層級上,一個正在跑的程式,就只不過是這樣:一塊裝著一連串指令位元組的記憶體,以及一個不斷讀取下一個指令、並照辦的處理器。沒有迴圈、沒有函式、沒有所謂的變數——只有一趟長長的、飛快地走過位元組的路。比這更豐富的一切,都是編譯器「用」這個蓋出來的,而本階的全部重點,就是學會讀懂這趟路。第一件要弄懂的事,是 CPU 把它此刻正在處理的那些值放在哪裡——因為大多時候,那並不在記憶體裡。
暫存器:CPU 那塊極小、零延遲的便箋
讀寫記憶體以人類的標準來說很快,但以 CPU 的標準來說很慢。所以處理器在晶片裡內建了一小組格子,它真正的工作就在這裡進行。這些格子叫做暫存器,總共只有幾十個。在一台 64 位元機器上,每一個都恰好裝一個機器字——64 位元、8 個位元組——剛好夠放一個整數或一個位址。CPU 讀或寫一個暫存器幾乎不花時間,遠比碰記憶體快,這正是為什麼值會被拉進暫存器裡來操作、只有在需要時才寫回記憶體。
在 x86-64 上,通用暫存器有像 rax、rbx、rcx、rdx、rsi、rdi,以及 r8 到 r15 這些名字——共十六個,每一個都是 64 位元的盒子。大多數是可以互換的苦力:一個待相加的值、一個迴圈計數器、一個正在算的位址。不過有少數幾個,被硬體和慣例指派了特殊任務,其中三個現在值得認識一下,因為本階接下來會不斷倚靠它們。它們並不神奇;它們是再普通不過的暫存器,只是大家都同意把它們用在某一個特定的用途上。
三個幫 CPU 記住位置的暫存器
第一個特殊暫存器回答的問題是:「下一個指令是哪個?」因為指令不過是記憶體裡的位元組,CPU 需要記住它目前在那串位元組的哪個位置——而它把那個位址存在一個專屬的暫存器裡,也就是程式計數器。在 x86-64 上它叫 rip(指令指標);在 ARM 上是 pc。程式計數器永遠裝著「下一個要執行的指令」的位址。在除錯器裡逐步執行程式碼,你在 rip 看到的那個值,「就是」你在程式裡的位置,以十六進位寫成,像 0x401136。
另外兩個負責追蹤堆疊。從記憶體那一階你已經知道有一塊叫呼叫堆疊的區域,每一次函式呼叫的區域性資料就住在那裡,隨著呼叫層層巢套而漲落。在指令層級,那塊區域由堆疊指標追蹤——在 x86-64 上是 rsp,在 ARM 上是 sp——它永遠裝著堆疊目前頂端的位址。一個搭檔暫存器,基底指標 rbp,常常標出目前函式框架內一個固定的參考點。rsp 和 rbp 合起來就是堆疊指標與基底指標,本階第 3 篇將整篇用來觀察它們如何移動。
還有一個小但不可或缺的暫存器:旗標暫存器(在 x86-64 上是 rflags)。它不是你拿來運算的值;它是一條由單位元開關組成的帶子,CPU 會在做算術時順帶設定它們——一個結果為 0 時亮起的零旗標、一個給負數結果的正負號旗標、一個加法溢位越過最高位元時亮起的進位旗標,還有其他幾個。這些旗標是 CPU 記住「上一次比較結果」的方式,也是條件跳躍用來決定要不要分支時所查閱的東西。這就是一個 `if` 背後的全部機制:先比較,這會設定旗標;再「若為零則跳」或「若較大則跳」,這會讀取它們。
一個指令就是一個動詞加上它的運算元
現在來看動詞。一個指令是 CPU 能做的最小工作單位,而每一個本質上都是一道細小的命令:把這個搬到這裡、把這兩個相加、比較那些、跳到那裡。一個給定處理器所看得懂的整份指令清單——每一道命令、每一種編碼——就是它的指令集架構,簡稱 ISA。ISA 是一份契約:它定義了這晶片可以被要求做什麼,與內部的矽是怎麼造的無關。為某個 ISA 寫的程式碼不能在另一個上面跑,這也是為什麼為你筆電編譯的程式,除非重新編譯,否則無法原封不動地在手機上跑。
我們不直接讀原始的指令位元組;我們讀組合語言,那是它們一層薄薄的、人類看得懂的拼法,一行一個指令。每一行都是一個助憶符號——一個簡短的動詞,像 mov、add、cmp、jmp——後面跟著它的運算元,也就是它作用的對象。一個運算元可以是一個暫存器(rax)、一個烤進指令裡的常數(叫做立即數,像 5),或一個由位址指名的記憶體位置。所以組合語言讀起來幾乎像簡短、粗暴的英文:`mov rax, 5` 意思是「把 5 放進 rax」,`add rax, rbx` 意思是「把 rbx 加進 rax」。每一行的複雜度,真的就是這個程度。
; compute (7 + 3) and leave it in rax mov rax, 7 ; rax <- 7 mov rbx, 3 ; rbx <- 3 add rax, rbx ; rax <- rax + rbx (now rax == 10) ; the shape of every instruction: ; MNEMONIC destination, source ; ^verb ^operands
一個真實 ISA 裡的數百個指令,可以歸進少數幾個家族,而一旦你看出這些家族,整件事就不再嚇人了。有資料搬移(mov、push、pop——在暫存器與記憶體之間挪動值)、算術與邏輯(add、sub、mul、and、or、shl——ALU 的活)、比較(cmp、test——做算術但只保留旗標)、控制流程(jmp、je、jl、call、ret——改變下一個執行的是哪個指令),還有少數幾個用來與作業系統對話。你的 C 所做的幾乎一切,都是用這幾種搬移、運算和跳躍蓋出來的。
兩大 ISA 家族:x86-64 與 ARM 一瞥
你會不斷遇到兩個 ISA。x86-64(也叫 amd64)是大多數桌機、筆電和雲端伺服器在跑的;ARM(它的 64 位元形式叫 AArch64)幾乎跑遍每一支手機、Apple 自家晶片的 Mac,以及越來越大一塊的伺服器市場。它們體現了兩種老哲學。x86-64 大致屬於 CISC——複雜指令集,指令多、編碼長度可變(一個指令從 1 到 15 個位元組都有可能),而且操作可以直接碰記憶體。ARM 大致屬於 RISC——精簡指令集,指令較少、較簡單、長度固定(每一個 ARM 指令都是 4 個位元組),而且除了極少數例外,只在暫存器上運算,並用獨立的載入/儲存指令去碰記憶體。
對學習者來說,好消息是「概念」在兩者上是一樣的,而那正是本階所教的。兩者都有程式計數器、堆疊指標、通用暫存器、旗標暫存器,以及同樣那幾個搬移—比較—跳躍的指令家族。不同的只是拼法:x86-64 寫 `mov rax, 5`,ARM 則寫 `mov x0, #5`;x86-64 用 `call` 呼叫函式,ARM 用 `bl`(branch-with-link,帶連結的分支)。只要你會讀其中一個,另一個就是翻譯,而不是新語言。我們會以 x86-64 語法為預設,因為那最可能是你第一個去反組譯的對象,並在重要之處標出 ARM 的差別。
CPU 怎麼跑,以及這把你帶到了哪裡
我們現在可以勾勒出那台把暫存器和指令綁在一起的引擎了。CPU 裡頭坐著兩個協作的部分:控制單元,負責讀取指令並指揮接下來發生的事;以及算術邏輯單元(ALU),那台真正在做加、減、比較和位移的計算器。控制單元與 ALU以一種不歇的節奏工作:控制單元擷取程式計數器裡那個位址上的指令,弄清楚(解碼)它是什麼意思,接著執行它——把數字推過 ALU、在暫存器間搬動它們、設定旗標——最後把程式計數器推進到下一個指令。然後一切從頭再來,一秒鐘做上數十億次。
那個迴圈——擷取、解碼、執行、推進——就是指令週期,它是每一個正在跑的程式的心跳。它如此核心,以致本階下一篇將整篇專講它:一個跳躍指令如何只靠「改變程式計數器」、而不是推進它,來運作;一個比較加一個條件跳躍如何成為一個 `if`;以及這個粗暴簡單的迴圈,反覆下去,如何說明了你的程式碼所做的一切。眼下先抓住這個一句話的版本:CPU 永遠擷取程式計數器處的那個指令並照辦,僅此而已。
退一步盤點一下。你現在認識了這齣戲的角色:一小組裝著此刻正在處理之值的暫存器,其中三個有固定職務(程式計數器追蹤你在哪、堆疊指標追蹤堆疊頂端、旗標暫存器記住比較結果),以及一串歸進少數幾個搬移、運算、跳躍家族的細小指令。你知道 x86-64 和 ARM 拼法不同,但概念相同。這就是本階接下來所說的語彙。第 2 篇讓週期動起來;第 3 篇看堆疊指標在一次呼叫中如何起舞;第 4、5 篇揭開那些讓分開編譯的函式得以協作的慣例,最後放出一個真實的編譯器,好讓你讀懂你自己那段 C 的反組譯。