# 舉例

先舉個例子,一個 Django(Python)的專案通常會有 Python、HTML、CSS 和 JavaScript 這幾個語言編寫。道理很簡單,Python 用於後端處理資料,通常執行於伺服器端(Server),而 HTML、CSS 和 JavaScript 通常則是發送到請求的客戶端上(Client)。

# 從撰寫到編譯

在理解為甚麼某些專案會使用多個程式語言進行開發前,你要先了解,一個編譯語言是怎麼從撰寫到執行的。

舉例來說:Golang、Rust 和 C/C++ 都是編譯語言,它們需要編譯器才能從人類看得懂的語言轉換成電腦讀得懂的執行檔。所以編譯器有好幾種,它的工作原理是什麼?

# 範例

c
#include <stdio.h>

int main() {
    printf("This is Tantuty!\n");
}
bash
gcc main.c -o main

首先當你寫完第一支 C 語言的程式碼,並使用廣為人知的 GCC 編譯器進行編譯,它會發生什麼事?

# 1. 預處理器 (Preprocessor)

第一步將會把你的程式碼進行:刪除註解(因為電腦不需要看懂註解)、擴展巨集(macro expansion)、處理條件編譯指令(如 #ifdef)以及處理 #include 指令(將標頭檔內容插入)等操作。

講人話就是從

c
#define ABCD 2
#include <stdio.h>

int main(void)
{

#ifdef ABCD
    printf("1: yes\n");
#else
    printf("1: no\n");
#endif

#ifndef ABCD
    printf("2: no1\n");
#elif ABCD == 2
    printf("2: yes\n");
#else
    printf("2: no2\n");
#endif

#if !defined(DCBA) && (ABCD < 2 * 4 - 3)
    printf("3: yes\n");
#endif
}

變為

c
// 把 stdio.h 引入進來

int main(void)
{
    printf("1: yes\n");
    printf("2: yes\n");
    printf("3: yes\n");
}

所以其實就是把像是 #ifdef #define #include 等等的語法擴展或簡化程式碼

# 2. 編譯器 Compiler

這一步將會把程式碼編譯,但要注意的是,編譯並不會直接把程式碼直接編譯成「機器語言1」,而是被編譯成 Assembly Code(組合語言)也就是電腦的操作指令,但仍是人類可讀的語言。

# 3. 組譯器 Assembler

在技術上來說,其實這算是另一種編譯器,但這一步才會將上一步編譯後的 Assembly Code 轉換為機器語言1,也就是 CPU 可以理解的 1 和 0,我們稱該檔案叫做「目標檔案(Object File)」,不過這個檔案還不可運作,因為 GCC 還需要解析該檔案中函數的放置位置。

比如剛剛寫的 Code

c
// 把 stdio.h 引入進來

int main(void)
{
    printf("1: yes\n");
    printf("2: yes\n");
    printf("3: yes\n");
}

stdio.h 也需要經過剛才的「預處理器 (Preprocessor)」「編譯器 Compiler」「組譯器 Assembler」 才變成目標檔案(Object File)

# 4. 連結器 Linker

到這一步,可能會有多個目標檔案(Object File)有些是我們自己寫的(如:main.c),有些則是被引用的靜態函式庫(如:stdio.h),所以現在有:main.ostdio.o 的目標檔案(Object File)。

而「連結器 Linker」的工作很簡單,就是把目標檔案(Object File)組合成一個獨立的可執行檔,這有兩種做法:簡單的方法是將被引用的靜態函式庫複製到可執行檔中,這稱作「靜態連結」;另一種連結方式則是動態連結。

反思一下,你現在的電腦上要多少個檔案要引用 printf() 功能,如果每個程式都靜態包含該函式的副本,那你的硬碟上就會有成千上萬個相同的副本在組譯後的目標檔案。很明顯,Coding 都不允許我們重複撰寫程式碼,那憑什麼組譯可以?這明顯可以簡化。

所以透過動態連結這些靜態函式庫會預先編譯成一種稱為動態共享庫的特殊類型檔案,這些檔案在 Unix 系統上副檔名被稱作 *.so 檔;在 Windows 系統上則被稱作 *.dll 檔作為標示。這些檔案照理來說因為已經編譯完成,所以他們是可執行的,但是他們並不包含啟動執行的入口點,這是很合理的,因為庫通常用來被引用,也沒有主函式 main() 被呼叫。

因此,原本你編譯後的程式碼大概會長這樣(這裡方便表示我用 <> 表示外部的庫被複製進來)

o stdio.o
000000000000000100000000000000010000000000000001...
o main.o
<000000000000000100000000000000010000000000000001...>01101011010100100110101101010010011010110101001001101011010100100110101101010010...

因為動態連結的關係會改寫成這樣

o main.o
********01101011010100100110101101010010011010110101001001101011010100100110101101010010...

在運行時,若程式需要動態庫中的函數,作業系統會將所需的載入到程式的位置空間。

o main.o
000000000000000100000000000000010000000000000001********0110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010011010110101001001101011010100100110101101010010

這看起來很怪,但確實提供更好的彈性,因為只有在執行時,系統才會將動態庫加載,既省去了磁碟空間,也省去了記憶體。

現在這個檔案就可以拿來被打包成執行檔也就是常見的 *.exe 檔。

# 那每個步驟都模組化的意義何在?

既然我們可以從原始程式碼轉換成可執行檔,為何還要那麼分成那麼多步驟。

# -save-temps

這些過程通常不會被看見的原因是因為 GCC 會將中間產出的檔案隱藏,只會顯示最後可執行檔,但你可以透過指令選項來顯示這些被省略的檔案 -save-temps,也就是

bash
gcc main.c -o main -save-temps

這樣會產出中間檔案(以 [] 表示編譯過程產生的檔案)

   Preprocessor   Compiler    Assembler     linker
main.c  ->  [main.i  ->  main.s  ->  main.o]  ->  main.exe

# -S

其實還有其他選項像是 -S 會在編譯(Assembler)後停止,你可以查看該檔,確實就是組合語言。

bash
gcc main.c -S -o main.s
s main.s
	.file	"main.c"
	.def	___main;	.scl	2;	.type	32;	.endef
	.section .rdata,"dr"
LC0:
	.ascii "1: yes\0"
LC1:
	.ascii "2: yes\0"
LC2:
	.ascii "3: yes\0"
	.text
	.globl	_main
	.def	_main;	.scl	2;	.type	32;	.endef
_main:
LFB10:
	.cfi_startproc
	pushl	%ebp
	.cfi_def_cfa_offset 8
	.cfi_offset 5, -8
	movl	%esp, %ebp
	.cfi_def_cfa_register 5
	andl	$-16, %esp
	subl	$16, %esp
	call	___main
...

你也可以幫 main.s 完成剩下步驟的編譯

bash
gcc main.s -o main

最後就會從組合語言的 main.s 產出 main.exe

# 範例解釋意義何在

假設我們不信任編譯器的最佳化過程,我們可以先把程式碼切成 function 的寫法,然後再將我們寫得 function 進行編譯像是 say_hi() -> say_hi.s 然後再將 main.c 一起完成編譯仍然可被包成執行檔。

c say_hi.c
#include <stdio.h>

void say_hi(void) {
    printf("Hi!\n");
}
bash
gcc say_hi.c -S -o say_hi.s
c main.c
extern void say_hi(void);

int main() {
    say_hi();
}
bash
gcc say_hi.s main.c -o main
Hi!

# 應用範圍

這樣的技術被廣為使用,像是系統:Linux Kernel、ffmpeg、OpenSSL 和很多嵌入式專案

# 答案

我們平時所說的 GCC 並不只是一個編譯工具,更像是一個工具鏈,該工具把上面四個步驟按順序執行。而這四個步驟所用的工具是可以替換的。你可以把 C 的編譯器、組譯器改成 C++ 的,或是 Fortran、Ada,更或是 Golang,所以 GCC 也支援這些程式語言的編譯流程。

有趣的故事是 GCC 原名為「GNU C Compiler」後更名為「GNU Compiler Collection」,原因就如上所說,原本僅支援 C 語言,現在可以編譯不只一種語言了。

也就是說,你可以寫 C 語言搭配 Golang,分別進行編譯,再一起被編寫成一個執行檔。

  1. 分工模組化:最適合的語言完成特定的任務。
  2. 性能考量:以 C/C++、Rust 等高效能語言處理核心運算,再以 Python、Go、JavaScript 等高階語言快速實現業務邏輯。
  3. 生態系:某些領域(像是資料分析、機器學習、前端互動)在特定語言上有成熟函式庫,能大幅提升開發效率。

# 打破迷思

所以編譯 Compiler 並不是所謂編譯成電腦讀得懂的二進制,而是通常將原始程式碼轉換為像是 Assembly Language 等語言來表示甚至有可能是另一個 Programming Language。

# 參考資料


  1. 所謂的機器語言就是電腦才看得懂的 0001110001010001 ↩︎ ↩︎

留言板(尚在測試階段)

登入

還沒有帳號? 註冊