編譯器是什麼?程式變成機器碼的過程
編譯器是一種軟體,會在執行之前,把人寫的程式一次全部翻譯成CPU能執行的0和1指令(機器碼)。翻譯途中,它也會找出寫錯的地方,或把程式改成用更少步驟就能得到相同結果的形式(最佳化)。CPU只能執行機器碼,所以這樣的翻譯角色不可或缺。
這篇文章可以了解
- 人寫的程式,為什麼CPU不能直接執行
- 一行程式變成機器碼的各個階段(切成詞→整理成樹→檢查→最佳化→轉成指令→連結)
- 編譯器、直譯器、JIT的差異,以及Java、JavaScript、Python的情況
- 編譯器找得到的錯誤,和找不到的錯誤
前提知識:不需要。如果知道CPU是「一個一個執行以0和1寫成之簡單指令的零件」,會比較容易讀。
生活中的例子
在電腦上安裝App時,下載的檔案內容,多半是已經翻譯成機器碼的程式。同一款遊戲,電腦版和手機版發布的檔案有時是分開的。寫程式的人在聊天時,常會說「建置成功了」「出現編譯錯誤」。
在這些情境背後運作的,就是編譯器。本文以一行程式為例,依序追蹤編譯器的工作。
為什麼需要「翻譯」
CPU能直接執行的,只有用0和1排列寫成的指令。這種形式的程式稱為「機器碼」。一個指令是「讀入數字」「相加」「比較」這類非常小的工作。詳細說明請見「CPU是什麼?」。
機器碼對人來說很難讀,寫起來也很辛苦。所以人會用接近英文單字或數學式的寫法來寫程式。這種寫法的規則稱為「程式語言」,例如C、Java、JavaScript、Python等。
人寫的程式文字稱為「原始碼」。原始碼只是一串文字,對CPU來說沒有意義。因此,需要一個把它轉成機器碼的翻譯角色。
在執行之前,把整個程式一次轉成機器碼的軟體,稱為「編譯器」。機器碼指令的種類和表示方式,每種CPU都不一樣。因此,編譯器會先決定要轉成哪種CPU用的機器碼,再進行翻譯。電腦版和手機版發布的檔案不同,原因之一就在這裡。
一行程式變成機器碼的過程
舉例來說,我們用下面這一行,把時間 h 換算成秒數 s。「*」是乘法,「;」是表示一句結束的符號。
s = 60 * 60 * h;
這裡為了簡化說明,以接近C語言的語言編譯器為例,把過程分成7個階段。實際的編譯器,階段的分法和名稱會有些不同。
① 切分成詞(詞法分析)
從編譯器的角度看,原始碼一開始只是一串字元。所以首先要把它切成一個個有意義的單位。例如「60」是一個數字,「*」是一個符號。這個階段稱為詞法分析。範例這一行去掉空白後,會切成8個詞。
② 檢查文法,整理成樹狀(語法分析)
接著,要檢查詞的排列是否符合語言的文法。例如像「60 * * h」這樣符號連在一起,就會回報文法錯誤。這個階段稱為語法分析。
如果符合文法,就把計算的組成整理成樹狀。乘法的規則是從左往右算,所以先算「60 × 60」,再把結果「× h」。最上面的「=」,意思是「把右邊計算的結果存進 s」。這棵樹稱為語法樹,正式名稱是「抽象語法樹」(AST)。
③ 檢查意義
即使文法正確,意思也可能說不通。例如,如果哪裡都沒有準備名為 h 的值的容器(變數),就無法計算。編譯器會檢查用到的名稱是否都已經準備好。
值是數字還是文字,這種值的種類稱為「型別」。編譯器也會檢查有沒有型別不合的計算,例如把數字和「abc」這種文字串相乘。
④ 改成步驟更少的形式(最佳化)
編譯器會在不改變結果的範圍內修改程式,讓它跑得更快,或變得更小。這稱為「最佳化」。
範例中的「60 × 60」,在執行前就知道答案是3600。所以編譯器把樹的這個部分替換成3600。這種最佳化稱為常數摺疊。
⑤ 轉成CPU的指令序列(程式碼產生)
接著把最佳化後的結果,轉成CPU的指令序列。範例這一行,會變成類似「讀入 h 的值」「乘以3600」「把結果寫入 s 的位置」的指令。實際指令的數量和形式,依CPU種類而不同。
這個階段的輸出,常常是用英文單字縮寫成的簡短符號,來寫機器碼的指令。這種寫法稱為「組合語言」。
⑥ 轉成0和1(組譯器)
把組合語言的指令,替換成CPU能讀的0和1序列的軟體,就是組譯器。平常只要下一個編譯器的指令,就會自動接著進行⑥和⑦。
⑦ 連結各部分(連結器)
大型程式會分成很多檔案來寫。另外,像在畫面上顯示文字這類常用的處理,會使用事先準備好的零件集。這種零件集稱為「函式庫」。
把翻譯好的各部分接起來,整合成一個可執行檔的軟體,就是連結器。它會一一填上「這個處理位在那個部分的這個位置」的對應關係。函式庫可以直接放進執行檔,也可以在執行時才載入。兩者的差異,整理在「靜態連結與動態連結的差異」的表格裡。
執行是作業系統的工作
完成的執行檔,是存放在儲存裝置裡的檔案。在電腦和手機上,程式要先放進記憶體才能執行。點一下圖示,作業系統就會把檔案內容讀進記憶體。它也會準備好執行時要用的函式庫,再讓CPU從第一個指令開始執行。負責這種讀入與準備的作業系統功能,稱為載入器。作業系統的工作,請見「作業系統(OS)是什麼?」。
最佳化為什麼重要
如果只執行一次,少一個乘法也感覺不出差別。但很多程式裡,都有把同一段重複執行數百萬次的地方。如果範例這一行重複執行1億次,光是常數摺疊,就能少掉1億次乘法。
編譯器還會做許多其他的最佳化。刪掉結果沒有用到的計算。把迴圈裡每次都相同的計算,移到迴圈外面。把這類技巧疊加幾十種,讓整體變快。
另一方面,最佳化會讓翻譯花更多時間,也讓「一行一行停下來檢查程式」的工作變得比較困難。因此,C語言等廣泛使用的編譯器GCC,在沒有指定時幾乎不做最佳化。製作正式版時,一般會用「-O2」這類選項開啟最佳化。
編譯器、直譯器、JIT的差異
也有不把原始碼轉成機器碼檔案,就能讓它運作的方法。一邊讀程式、一邊當場逐一執行的軟體,稱為「直譯器」。優點是不必等翻譯,寫好就能馬上試。代價是同一段即使執行很多次,每次都要重新解讀,所以容易比較慢。
結合兩者優點的方式,就是JIT編譯。JIT是「Just In Time」(在需要的那一刻)的縮寫,所以也稱為即時編譯。常見的做法是,一開始像直譯器一樣邊讀邊執行,同時找出經常執行的部分。然後只把這些部分,在執行中轉成機器碼。
要注意的是,「編譯型」「直譯型」不是語言的性質,而是執行它的軟體的性質。實際上,許多語言都組合運用了這些方式。
- Java:開發者會事先把原始碼編譯成稱為「位元組碼」的形式再發布。位元組碼不是特定CPU的機器碼,而是給Java專用的虛擬機器的指令。執行時,Java的執行軟體會解讀並執行它,並用JIT把常用的部分轉成機器碼。
- JavaScript:Chrome等使用的V8這套軟體,會先把原始碼轉成位元組碼,再用直譯器執行。經常執行的部分,則由進行最佳化的編譯器轉成機器碼。
- Python:標準的CPython這套軟體,也會先把原始碼編譯成位元組碼,再用直譯器執行。2024年的Python 3.13加入了實驗性的JIT,但預設是停用的。
三種方式的比較,整理在「編譯器、直譯器、JIT 的差異」的表格裡。
編譯器找得到的錯誤,找不到的錯誤
編譯器會在執行前找出很多錯誤,例如文法錯誤、使用沒有準備的名稱、型別不合的計算等。有些編譯器,也會對不算錯誤但可疑的寫法發出提醒(警告)。
另一方面,有些錯誤不實際執行就不會知道。例如,用輸入的數字做除法的程式,輸入0就無法計算。要讀的檔案不存在之類的問題,也要到執行時才會發現。這種在執行中發生的錯誤,稱為「執行時錯誤」。
更麻煩的是,程式能跑完不會停,但結果和作者的意圖不同的錯誤。本來想寫「加」卻寫成「減」,編譯器也只會照文法翻譯。這種錯誤,只能靠實際執行並確認結果的「測試」來找出。
哪些錯誤能事先找到,也因語言而不同。例如在Python中,許多型別不合的計算,要到執行那一行時才會出錯。
編譯器的歷史
直到1950年代初期,程式都是由人用機器碼,或接近機器碼的形式來寫。1951~1952年,葛麗絲·霍普(Grace Hopper)為UNIVAC I這台計算機,開發了名為「A-0」的系統。只要用編號指定事先準備好的計算零件,它就會把這些零件接起來,做成機器碼的程式。
A-0在當時就被稱為「編譯器」。不過,它的功能和今天所說的編譯器不同。電機電子領域的學會IEEE,把A-0選為技術史上的里程碑,並在2024年設置了紀念銘牌。其中的說明,也把A-0描述為「作為連結器與載入器運作的一組規格」。
一般認為,為今日編譯器的分工方式與技術奠定基礎的,是IBM的約翰·巴科斯(John Backus)等人開發的FORTRAN。它把接近數學式寫法的原始碼,翻譯成IBM 704這台計算機的機器碼。這項計畫始於1954年夏天,並在1957年4月發送到所有使用704的地方。
如果編譯器產生的機器碼,比人手寫的慢很多,就不會有人用。因此,FORTRAN團隊以接近手寫的速度為目標,在最佳化上投入心力。1982年寫成的回顧論文,評價這個目標達成的程度令人驚訝。
硬體也有類似「編譯器」的東西
在半導體晶片的電路設計中,也用到類似的想法。現在的大型電路,一般不是畫圖,而是用類似文章的形式來寫。用來寫電路的語言稱為硬體描述語言,例如Verilog和VHDL。
把寫好的電路行為,自動轉換成「且」「或」這類基本邏輯電路組合的工作,稱為邏輯合成。讀入、檢查、最佳化、輸出的流程,和編譯器很像。
最大的差別是,輸出的不是「依序執行的指令序列」,而是「同時運作的電路連接」。做好的電路,可以寫進事後能改寫的晶片,也可以做成專用晶片來製造。前者在「FPGA是什麼?」中說明。
常見的誤解
誤解:用編譯器轉成機器碼的語言,一定比較快
實際上,是否編譯,只是決定速度的因素之一。如果計算的步驟本身繞了遠路,再怎麼最佳化也還是慢。JIT能觀察執行中的情況來最佳化,所以長時間執行的程式,有時速度能接近事先翻譯好的程式。另外,Python常用的計算零件集NumPy,核心的計算是用編譯好的C程式碼進行的。比較同樣的處理時,編譯過的往往比較快,所以容易讓人以為「一定比較快」。
誤解:編譯器會理解寫程式的人想做什麼
實際上,編譯器只是照既定的規則翻譯,並不知道作者的意圖。像「這裡少了 ;」這樣的錯誤訊息看起來很貼心,是因為它在回報不合規則的地方。最佳化也只在依語言規則、結果不變的範圍內進行。在C語言中,如果用了規則沒有規定結果的寫法,可能產生和預期不同的機器碼。這種情況稱為未定義行為。編譯成功,不代表程式就是正確的。
誤解:Python不編譯,而是一行一行直接執行
實際上,標準的CPython在執行前,會把整個檔案轉成位元組碼,再用直譯器執行。所以檔案裡只要有一處文法錯誤,連第1行都不會執行,就會出錯。不過,它預設不會轉成機器碼,所以說它是「用直譯器執行的語言」也沒有錯。如果以為「編譯」只是指轉成機器碼,就會產生這種誤解。
從數字來看
| 項目 | 數字/時期 | 備註 |
|---|---|---|
| A-0 | 1951~1952年 | 葛麗絲·霍普。UNIVAC I用 |
| FORTRAN計畫開始 | 1954年夏天 | IBM,約翰·巴科斯的團隊 |
| FORTRAN編譯器發布 | 1957年4月 | 發送到所有IBM 704的設置地點 |
| FORTRAN的開發工作量 | 約18人年 | 相當於1個人做18年的工作量。1957年的論文 |
| GCC的預設設定 | -O0 | 幾乎不做最佳化。重視速度時用 -O2 等 |
| Python的實驗性JIT | 2024年(3.13) | 3.13中預設停用 |
從FORTRAN算起已將近70年,翻譯分成各階段的想法,到今天仍沒有太大改變。增加的是最佳化的種類,以及像JIT這樣在執行中翻譯的方式。
進一步學習
- 用表格看差異:編譯器、直譯器、JIT 的差異、靜態連結與動態連結的差異
- 動手試試看:逆波蘭表示法轉換工具(顯示算式的語法樹與計算順序)
- 下一步的用語:中間表示法、行內展開、LLVM、目的檔、動態連結
- 從基礎讀起:「CPU是什麼?」、「作業系統(OS)是什麼?」、「電腦怎麼用0和1計算?」