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

電腦究竟是如何執行你的程式碼

從你親手打出的原始檔,一路追到中央處理器裡流動的電子:由下而上認識機器模型、抽象堆疊,以及編輯─編譯─執行循環,最後寫出一支真的能跑的 C 語言 hello world。

把機器拆到只剩三個部分

上一篇我們說過,系統程式設計的意思就是貼近機器工作。那就來看看這台機器吧。把螢幕、鍵盤、風扇全拿掉,剩下的東西在輪廓上簡單到令人吃驚:一顆會做算術、會做判斷的處理器,一塊用來存放數字的記憶體,以及幾條讓盒子能與外界對話的輸入/輸出通道。其餘幾乎所有東西——快取、匯流排、顯示卡——都是疊在這三者之上的最佳化或便利設施。這個三部分的草圖就是馮紐曼架構,得名自一份 1945 年的設計筆記,也是每位 C 程式設計師心中隨身攜帶的模型。

記憶體是最該仔細描繪的部分,因為你的資料就住在那裡。把它想成一條長到看不見盡頭、編了號的信箱街,每個信箱恰好裝一個位元組(八個位元,一個 0 到 255 的數)。信箱上的號碼就是它的位址。這正是記憶體即位元組陣列的全部精神:日後你在 C 裡寫下 `int x`,語言會悄悄挑幾個相鄰的信箱,並記住第一個的位址。位址也是日後指標之所以感覺自然、而非神祕魔法的原因——一個指標不過就是被抄寫下來的信箱號碼罷了。

接下來這一步讓整個架構變得如此強大,值得停下來細想:在儲存程式型電腦中,你程式的指令就住在同一條編號記憶體裡,與你的資料比鄰而居。中央處理器並不事先知道哪些位元組是「程式碼」、哪些是「數字」——它只是從某個位址讀取,並把讀到的東西當成指令來執行,因為是你叫它從那裡開始的。程式與資料是同一種東西。正是這個單一的決定,讓同一台機器今天能跑文書處理器、明天能跑編譯器。

心跳:取指、解碼、執行

那處理器到底是怎麼「做」事的?它跑一個極小的迴圈,每秒跑上數十億次,這個迴圈就是取指─執行週期。中央處理器保有一個叫做程式計數器的特殊暫存器,裡頭裝著下一道指令的位址。迴圈每轉一圈:從那個位址取出指令、弄清楚它是什麼意思、把它執行掉,再把程式計數器推進到下一道指令。然後再一次。又一次。晶片裡並沒有什麼總體計畫——只有這個不停歇的三拍節奏。

  1. 取指:讀取程式計數器所指那個位址裡存放的指令。
  2. 解碼:弄清楚那個位元樣式是什麼意思——是相加、複製、還是跳躍,以及它牽動哪些值。
  3. 執行:真的把它做掉,改動某個暫存器或某個記憶體位置。
  4. 推進:把程式計數器移到下一道指令,然後回到開頭再來一遍。一次跳躍不過就是把計數器設到別處去而已。

每一道指令本身也只是一個位元樣式——一個小小的數,晶片被接線成把它解讀為「把這兩個值相加」、「從記憶體複製這個」、「若上次的結果是零就跳到那個位址」。為了完成工作,指令會用到中央處理器內部少數幾個超高速的便條欄位,叫做暫存器(像 rax、rsp、rip 這些名字,你會在組合語言那一級遇到)。記憶體是那條編了號的長街;暫存器則是處理器握在手裡的寥寥幾張便條。很久以後,講求速度的訣竅,將會是把你需要的值留在暫存器裡,而非每次都到記憶體去取。

從你的文字到那些位元樣式

現在來談系統程式設計真正在處理的那道鴻溝。你寫下 `printf("hello\n");`——一段近乎英文、人看得懂的文字。中央處理器卻只會執行那些位元樣式指令。必須有東西把兩者搭起來,而這座橋正是原始碼與機器碼之間的差別。原始碼是給人看的;機器碼則是晶片執行的赤裸數字。C 編譯器就是那位譯者,它預先一次性地把前者翻成後者,產出一個機器能直接執行的成品檔。

「先翻一次再執行」和「邊翻邊執行」之間的這個分別,正是編譯式與直譯式執行的核心,而緊接著的下一篇會把它好好拆開來談。眼下先記住一件事:C 是編譯式的。你的 `.c` 檔通過編譯器,從另一頭出來時就成了一個可執行檔——一個自成一體、由機器碼構成、作業系統能載入並啟動的檔案。而像 Python 之類的語言,則是隨附一個直譯器,每次都把你的原始碼一行一行地讀進來執行。通往同一顆中央處理器的不同道路;兩條我們都會走。

為什麼有人要不厭其煩地疊這些翻譯與層次?因為沒有人想徒手寫位元樣式,也沒有人想為每一顆晶片重寫一遍程式。這正是抽象堆疊的全部用意:硬體在最底層,往上是機器碼,再往上是 C 這樣的語言,最頂端才是你的應用程式。每一層都把它底下的雜亂藏起來,並向上提供一個更乾淨的把手。系統程式設計師的本事,就在於東西壞掉時能往鑽一層——而這也正是我們要從硬體往上蓋、而非從應用往下挖的原因。

兩個世界:你的程式與核心

在我們執行任何東西之前,還有一道界線要先講,因為它形塑了往後的一切。當你的 `hello` 程式印出文字時,它並不是自己去戳螢幕硬體,而是去請作業系統代勞。作業系統的核心部分——核心——是唯一一個真正觸碰硬體(記憶體、磁碟、網路、終端機)的受信任程式。其餘所有東西,包括你的程式碼,都以普通、不受信任的市民身分執行。這道分界就是核心空間與使用者空間

築這道牆的理由是安全:倘若任何程式都能寫入任何記憶體或任何裝置,一個臭蟲就足以讓整台機器當掉。於是中央處理器本身分成兩種模式運作——享有特權的核心模式,與受到限制的使用者模式——而一個有臭蟲或心懷惡意的使用者程式只能「請求」,永遠無法「強奪」。這個請求,是透過一道特意設計的界線跨越來完成的,叫做系統呼叫,我們會在後面某一級裡詳加研究。今天,你只要先把這個形狀記在心上:你的程式碼住在一道有人看守的牆的一側,硬體在另一側,而核心就是那位守門人。

你將賴以為生的循環,與一支真的 hello world

上述這一切,匯聚在實際寫程式的節奏裡:編輯─編譯─執行循環。你編輯文字檔裡的原始碼,用工具把它編譯成可執行檔,再執行那個可執行檔、看會發生什麼。當輸出不對時,你就回頭去編輯。今年你會把這個循環轉上好幾千次。負責編譯的整套工具鏈——前置處理器、編譯器、組譯器、連結器——我們會用專門的一級來解剖;眼下先把 `gcc` 當成一位單一的魔法譯者就好。

那就來看一支真實、完整、正確的 C 程式——傳統的 hello world。逐行讀過:`#include <stdio.h>` 把 `printf()` 的宣告引進來;`int main(void)` 是系統會呼叫、用來替你開場的那一個函式;`printf` 請作業系統寫出文字;而 `return 0` 交回一個數,意思是「我做完了,而且一切順利」。

#include <stdio.h>

int main(void)
{
    printf("hello, world\n");
    return 0;
}

# build it, then run it:
#   $ gcc -Wall hello.c -o hello
#   $ ./hello
#   hello, world
hello.c,外加把它編譯並執行的兩道 shell 指令。-Wall 旗標會開啟警告訊息;現在就把這個習慣養起來。

把剛才發生的事,對照整篇指南追一遍。編譯器把那段文字變成可執行檔 `hello` 裡的機器碼指令。執行 `./hello` 是請核心把那些指令載入記憶體、並把程式計數器指向第一道;中央處理器接著一道接一道地取指、執行,直到 `return 0` 跑完為止。那個 `0` 成了程式的結束狀態,一張小小的成績單,shell 讀得到它(用 `echo $?` 查看)。你現在已經跟著你的程式碼走完了全程:從文字、經過翻譯、跨越核心界線、下抵取指─執行迴圈,再以一個數的形式回到地面。下一篇就從這條路上「編譯式對直譯式」的岔口出發,去探究它各自通往何方。