我們爬到哪了:從一堆指令到一台正在運轉的機器
在上一篇裡你認識了這些零件:一個暫存器是 CPU 內部一個極小、快得發燙的位元盒子;rax 和它的兄弟們裝著正在被處理的值;rsp 追蹤堆疊的頂端;還有一套指令集,是這顆晶片看得懂的低階運算。你也認識了一個特別的暫存器:rip,也就是程式計數器,它裝著「下一道」指令的位址。我們把零件擺好了。這一篇講的是運動——那些零件如何活過來、如何真正把一支程式往前推進。
這裡有個讓整件事變得好懂的驚喜:CPU 沒有宏大的計畫。它並不像你那樣「理解你的程式」。它只是永遠地重複一個短短的、固定的程序,而無論你跑的是文字編輯器、遊戲、還是作業系統本身,那個程序都一模一樣。那個被重複的程序,就是擷取—解碼—執行循環——也叫做指令循環。你電腦做過的每一件事,都是這個迴圈,轉得快到看起來像在思考。
一道指令的三拍
把迴圈的一圈慢慢走一遍;名字自己就會解釋自己。擷取(Fetch):控制單元讀取 rip,前往記憶體中那個位址,把下一道指令的位元組拉進來。解碼(Decode):它弄清楚那些位元組是什麼意思——是哪一個運算、又作用在哪些暫存器或值上。執行(Execute):它真正把工作做出來,通常是在 ALU(算術/邏輯單元)裡做加法、比較這類事,或是為了載入、儲存而去碰記憶體。接著它把 rip 推進到下一道指令,迴圈再轉一次。
把整顆 CPU 寫成一個緊湊的迴圈,它讀起來幾乎簡單到不可思議:永遠地,擷取 rip 所指的指令位元組;把它們解碼成一個運算與它的運算元;把 rip 推進到指向「下一道」指令;然後執行那份工作。一個跳躍或分支,只是把一個不同的值寫進 rip,於是下一次擷取就落到別處去。這真的就是整個控制核心了——四行永不停歇的步驟。
那段描述裡有兩個細節,比它們看起來更重要。第一,rip 是按指令的長度往前移動的,而不是固定加 1——在 x86-64 上,指令是變動長度的(從 1 到 15 個位元組都有可能),所以解碼這一步還必須算出「這道指令有多長」,下一次擷取才知道該往哪裡看。第二,控制流之所以能「跑到別處去」——一個迴圈、一個 `if`、一次函式呼叫——唯一的辦法,就是執行這一步把一個新位址寫進 rip。沒有別的機制。一個跳躍,不過就是對程式計數器的一次指派。
看著迴圈跑起來,一拍一拍地
當你親手追蹤真實的位元組時,抽象的東西才會沉澱下來,所以我們來手動跑一小段程式碼。假設記憶體裡從位址 0x1000 起放了三道指令,而我們想算出兩個小數的和。一開始 rip = 0x1000,我們會看著 rax 和 rip 在迴圈轉動時如何改變。下面每一行,都是擷取—解碼—執行的完整一圈。
address instruction what it does 0x1000 mov rax, 5 rax <- 5 0x1005 add rax, 7 rax <- rax + 7 0x1009 ret return to caller step before fetch@ after 1 rip=0x1000 rax=? 0x1000 rax=5 rip=0x1005 2 rip=0x1005 rax=5 0x1005 rax=12 rip=0x1009 3 rip=0x1009 rax=12 0x1009 (returns) rip=<caller>
把那張表當成一本翻頁動畫來讀。在第 1 步,rip 指向 0x1000;CPU 擷取 `mov rax, 5`,把它解碼成「把常數 5 放進 rax」,執行它,再把 rip 推到 0x1005(這道 mov 有 5 個位元組長)。在第 2 步它擷取 `add rax, 7`,ALU 算出 5 + 7 = 12 放進 rax。在第 3 步它擷取 `ret`,而它——如同你會在下一篇看到的——會從堆疊上彈出一個返回位址放進 rip,於是「下一次」擷取就落回呼叫我們的人那裡。整個過程中,晶片從未事先計畫;它只是不停地轉著同一個曲柄。
循環如何轉彎:分支、旗標與抉擇
一條直線排下來的指令固然不錯,但程式需要「做抉擇」。循環處理這件事,不需要任何新機件——它只是讓執行這一步去選擇要把什麼寫進 rip。一個像 `cmp rax, 0` 的比較會做一次減法、卻把結果丟掉,只在旗標暫存器裡留下關於它的幾條旁註:結果是不是零、是不是負的、有沒有溢位。每一條都是一個單一的位元。緊接著的下一道指令,一個像 `jz target` 的條件跳躍,會讀那些旗標位元,決定要把 rip 設成 `target`、還是讓它落到後面那道指令去。
那支兩步舞——先比較以設定旗標、再用條件跳躍依旗標行動——正是你 C 裡「每一個」 `if`、`while`、`for` 最終運轉的方式。並沒有一個 `if` 指令。編譯器把你結構化的控制流,切碎成比較、旗標與跳躍,全都搭在同一個樸素的擷取—解碼—執行迴圈上。看見這一點,正是高階控制流不再像是一項獨立的語言特性、而開始顯露它真正樣貌的那一刻:它不過是把幾個跳躍安排得很方便的一種辦法。
- 執行一個 `cmp` 或 `test`,它做某個運算純粹是為了設定旗標位元(零、符號、進位、溢位),而把數值結果丟掉。
- 擷取下一道指令,一個條件跳躍(例如 `je`、`jne`、`jl`、`jg`)——它的意思完全就是「只有當這幾個特定旗標這麼說時,才跳」。
- 如果條件成立,執行這一步就把分支目標寫進 rip;如果不成立,rip 早已指向那個跳躍之後的位置,於是迴圈就直接落下去繼續走。
- 下一次擷取會去讀 rip 此刻所裝的任何位址——那一次寫入,就是你程式裡每一個分支、迴圈與抉擇背後的全部機制。
誠實的附註:迴圈是真的,但時序圖只是一個模型
擷取—解碼—執行這幅圖是真的、也值得信賴——但它是對「結果」的一個模型,而不是對一顆現代晶片如何花掉它那幾奈秒的字面描述。真實的 CPU 並不會等一道指令做完才開始下一道。它會管線化(pipeline):當一道指令在執行時,下一道已經在被解碼、第三道正在被擷取,像一條生產線。它甚至會「亂序」執行指令,並對還沒解決的分支進行「推測」,猜一個跳躍會往哪邊走,好讓生產線保持滿載。誠實的說法比較窄、卻依然牢靠:這台機器產生的結果,就跟它真的一次一道、照順序執行你的指令一模一樣。
為什麼這份誠實對正在爬這座梯子的你很重要?因為簡單模型與真實矽片之間的那道縫隙,正好住著兩個之後的主題。推測執行猜錯了,正是某些安全漏洞(著名的 Spectre/Meltdown 家族,2018 年)能跨越邊界洩漏資料的原因。而一次「預測失敗」的分支所付出的代價——管線必須把它猜出來的工作全部丟掉、再重新填滿——是高效程式碼之所以盡量避開難以預測的跳躍的真實理由。今天你不需要這些。但請把這個迴圈當成你的心智模型來信賴,並把管線當成它底下那個誠實的星號記著。
這把你帶到了哪裡
退一步,看看你現在能宣稱什麼。一支程式並不是「自己跑」的;它是被一個迴圈「走過」的,一次一道指令——那個迴圈擷取 rip 所指的位元組、解碼它們的意義、執行那份工作、然後推進。分支、迴圈與 `if`,無非就是執行這一步替 rip 選了一個新值,而那往往發生在一個比較把判決留在旗標之後。同一個迴圈,既撐起一台計算機,也撐起一個核心。這真的就是整具引擎了——其餘的,不過是哪些指令搭在它上面而已。
我們刻意讓一條線頭懸在那裡兩次:`ret`「從堆疊上彈出一個返回位址」,而一次函式呼叫不知怎地記得了該回到哪裡。下一篇就來拉這條線。它會展示指令層級的堆疊——rsp 和 push/pop 指令如何挖出空間、一個 `call` 如何存下返回位址、一個 `ret` 又如何把它取回,以及為什麼正是這一條約定,讓函式得以層層巢狀、讓遞迴根本能夠成立。循環你已經有了;接下來,我們給它一份「自己去過哪裡」的記憶。