C 語言程式設計教學:編譯 C 程式碼的過程

在先前的文章中,我們從工具的觀點來看如何編譯 C 程式碼,在本文中,我們從高階抽象的觀點來看 C 程式碼如何變成執行檔。

從程式碼變程式

如果沒有編譯器 (compiler) 或直譯器 (interpreter),程式碼只不過是有著某種特殊形式的文字檔案,透過編譯或直譯的過程,程式碼才會變成可在電腦中執行的程式。以 C 語言來說,雖然編譯看起來只是在 IDE 中按個鈕或是輸入一行指令,但內部經過許多道步驟,約略分為以下四項:

  • 前處理 (preprocessing)
  • 編譯 (compiling)
  • 組譯 (assembling)
  • 連結 (linking)

在前處理時,前處理器會將 C 程式碼中註解的部分去除、巨集 (macro) 和外部包含 (即 #include) 的部分以文字擴張的方式取代掉,這並不是實質的編譯,僅能算是文字處理。處理過的程式碼就會繼續進行下一個步驟。

接著,編譯器會進行實際的編譯行為,在這個過程中,編譯器會將抽象而高階的 C 程式碼轉換為低階而貼近電腦的組合語言程式碼 (assembly)。這個過程又經過許多步驟,我們將其寫在下一節,有興趣的讀者可以自行閱讀。

組譯器會將組語程式碼轉為相對應的機械碼,這時候的產出物為目的碼 (object code)。最後,透過連結器將目的碼和函式庫結合,就會產出執行檔 (executable),整個步驟就算完成。使用者只要將這個程式載入 (loading) 系統,就可以執行此程式。

這裡僅簡要說明編譯的過程,如果讀者想要知道更多技術面的細節,可以參考 程式設計師的自我修養:連結、載入、程式庫, 碁峰 (2009) 或是 *Advanced C and C++ Compiling, Apress (2014)*。

(選讀) 編譯和直譯流程

我們在這裡以高階的角度來看待編譯和直譯的過程,不僅僅限於 C 語言,如果讀者對這些細節沒興趣或先前沒有程式設計的經驗,可以先跳過這一節的內容也無妨。

我們先說明編譯的流程,直譯有一部分和編譯重疊,有一部分則不同,我們稍後會說明。編譯的過程就像生產線般,有一系列的工作流程 (pipeline),這個過程可分為前端 (front end) 和後端 (back end) 兩個部分;前端將程式碼 (code) 轉為中間碼 (intermediate representation),後端則將中間碼轉為機械碼 (machine code)。

前端包括以下流程:

  • 詞法分析 (lexical analysis)
  • 語法分析 (syntax analysis)
  • 語意分析 (semantic analysis)
  • 生成中間碼 (intermediate code generation)

後端包括以下流程:

  • (選擇性) 和機械碼無關的優化 (machine-independent code improvement)
  • 生成目標程式碼 (target code generation)
  • (選擇性) 和機械碼相關的優化 (machine-specific code improvement)

double n = 3.12 + 1.95; 這句來說,詞法分析會將這串文字變成一個個 token,像 3.12 就是一個 token。接著,語法分析會將 token 串流轉為抽象的樹狀結構,像 parse tree 或 abstract syntax tree。在語義分析中,我們會檢查一些程式中的錯誤,像是型別錯誤、變數未宣告、生存空間錯誤等。如果沒有錯誤的話,會產生中間碼,前端的工作就算告一段落。

後端的工作就是產生目標程式碼和優化程式碼。優化程式碼就是在不更改程式的語義下,將程式碼代換為更有效率或是更省運算資源的寫法。使用更有效率的資料結構或演算法是從使用者端來改善程式,而編譯器則是在既定的演算法下從程式的細部來改善程式效率。一些知名的編譯器,像 GCC 或 clang 等都相當成熟,在編譯時已經加入許多優化的手法,如果覺得自己程式效率不佳,通常先修改自己程式的演算法比較實在。

不同程式語言,這個順序會略有不同。早期的直譯語言,在做完語法樹後,會立即 (on-the-fly) 將語法樹轉為電腦程式,但這個方法效率比較差,現在除了一些教學用途的直譯器,不太會用這個方法。現在的直譯語言,會搭配虛擬機器 (virtual machine),這時候的中間碼就不是組語碼,而是該虛擬機器特定的語法。通常虛擬機器的語法會類似於組合語言,和我們所知的高階語言差異較大;但一般使用者不會直接接觸到這些虛擬機器,所以不用過度在意這些細節。

C 語言的組成

我們重看一次 Hello World 範例:

// Importing stdio into the program.
#include <stdio.h>

// The entry of a program.
int main()
{
    // Printing out the string "Hello World\n" in a console.
    printf("Hello World\n");
    
    // Returning zero, i.e. sucess, to the program.
    return 0;
}

一開始先看到註解 (comment) 的部分:

// Importing stdio into the program.

註解在前處理時會拿掉,不影響實際程式的運作。註解的作用在於提醒程式設計者,包括六個月後的自己,某段程式的意圖。此外,在教學或展示用的程式碼,也會用註解來說明程式,如果將這段程式複製貼上到編輯器中,不會造成程式錯誤。有些文件產生軟體會解析程式碼中的註解文字,藉此產生相對應的文件,程式設計者就不需要額外維護一份文件;這時候的註解文字會有特定的格式,不是任意寫就自動生成文字。

引入 stdio 函式庫:

#include <stdio.h>

C 語言設計時,保持其核心語法精簡,將其他部分委派至函式庫來解決。stdio 是 C 語言中用來處理標準輸出入的函式庫,通常是學習 C 時第一個看到的函式庫。

主函式的寫法如下:

int main()
{
    // Write your code here.
}

若要使用命令列參數,則改為以下寫法:

int main(int argc, char *argv[])
{
    // Write your code here.
}

C 專案大概可分為兩類,應用程式 (application) 和函式庫 (library),前者可在電腦中執行而後者無法執行;一開始寫的終端機程式也是一種簡單的應用程式。主函式 (main function) 是應用程式的進入點,一開始不知道函式怎麼寫也不用擔心,就當成固定寫法即可。ANSI C 的主函式寫法如上所示,至於 void main() 或其他的主函式寫法不標準,不建議使用。

接著,我們看一下這個程式的第一個指令:

// Excerpt.

printf("Hello World\n");

printfstdio 函式庫內的函式,只要在程式中引入 stdio 函式庫即可使用。printf 的意思是格式化輸出,會將字串輸出到標準輸出 (stdout);有關 printf 的詳細說明可見這裡。在本例中,printf 僅接收一個參數,該參數為 C 風格字串 "Hello World\n",我們將於後續文章說明 C 字串。在 C 語言中,每行指令最後要加上 ; (分號),表示指令結束。

我們再看這個程式的最後一個指令:

// Excerpt.

return 0;

在 C 語言中,回傳 0 給主函式代表程式順利結束,如果回傳不同的數字,代表不同的失敗情境;根據 C99 標準,可以回傳 0EXIT_SUCCESSEXIT_FAILURE 三種值,後兩者定義於 stdlib 函式庫中。有些平台會額外定義其他的回傳值,但這些回傳值的意義在不同平台間沒有共識,故程式不建議直接用回傳值判斷程式狀態。

comments powered by Disqus