C編譯器入門~想懂低階系統從自幹編譯器開始~
  • 譯者序
  • 前言
    • 符號與規範
    • 本書的開發環境
    • 關於作者
    • 結束前言之前
  • 機械語言與組譯器
    • CPU 與記憶體
    • 什麼是組譯器
    • C程式和所對應的組合語言
      • 簡單的範例
      • 包含呼叫函式的範例
    • 本章小結
  • 創造計算機等級的語言
    • 第1步:創造能編譯1個整數的語言
    • 第2步:製作可以算加減法的編譯器
    • 第3步:加入標記解析器(tokenizer)
    • 第4步:改良錯誤訊息
    • 文法的記法與遞迴下降分析法
      • 將文法結構表示為樹(tree)
      • 以生成規則定義文法
      • 以 BNF 描述生成規則
      • 簡單的生成規則
      • 以生成規則描述運算子的優先順序
      • 包含遞迴的生成規則
      • 遞迴下降語法分析
    • 堆疊機
      • 堆疊機的概念
      • 編譯成堆疊機指令
      • 以x86-64實作堆疊機的方法
    • 第5步:製作可進行四則運算的編譯器
    • 第6步:單項加與單項減
    • 第7步:比較運算子
      • 修改標記解析器
      • 新的文法
      • 產生組合語言指令
  • 分離編譯與連結
    • 分離編譯
      • 分離編譯與其必要性
      • 標頭檔的必要性與其內容
      • 連結錯誤
      • 全域變數的宣告與定義
    • 第8步:分割檔案與修改 Makefile
      • 分割檔案
      • 修改 Makefile
  • 函式與區域變數
    • 第9步:1個字的區域變數
      • 堆疊上的變數空間
      • 修改標記解析器
      • 修改分析器
      • 左邊值與右邊值
      • 從任意的記憶體位址取得其值
      • 修改指令產生器
      • 修改主函式
    • 第10步:複數文字的區域變數
    • 第11步:return
    • 1973年的C編譯器
Powered by GitBook
On this page

Was this helpful?

  1. 分離編譯與連結
  2. 分離編譯

標頭檔的必要性與其內容

在分離編譯中,編譯器只看見程式的一部份程式碼,但編譯器並不能編譯任意的程式碼片段。舉例來說,我們考慮底下的程式碼:

void print_bar(struct Foo *obj) {
  printf("%d\n", obj->bar);
}

上述的程式碼中,只要知道Foo的資料結構,就能輸出對應這段程式碼的組合語言指令,但若不知道的話就無法編譯。

分離編譯時,需要把足以編譯每一個C程式檔的資訊,放進每一個檔案裡。不過,如果把別的檔案的程式碼全部寫進去的話,根本就不是分離編譯了,所以得做一定程度的資訊取捨。

舉個例子,我們來考慮要呼叫別的C檔案裡的函式,到底需要什麼樣的資訊吧。編譯器需要以下這些資訊:

  • 首先,作為辨識碼,需要知道函式的名字。

  • 編譯器輸出呼叫函式的組合語言程式,必須把引數照規定的順序放到暫存器中,然後使用call指令跳到函式的開頭。引數根據型態不同可能會是整數或浮點數。並且引數的型態或數量不對的時候,必須要能顯示錯誤訊息。所以要知道函式的引數個數和各個引數的型態。

  • 不管呼叫的函式做了什麼,對呼叫者來說就只知道其回傳,所以編譯時,呼叫者不需要知道被呼叫者的程式碼。

  • call所要跳到的位址,雖然在分離編譯時還不知道,但是可以在組合語言程式中,總之先輸出跳到位址0的call指令,然後在目標檔中留下「目標檔的第X位元請修正為Y這個函式的位址」的資訊。連結器看到這個訊息,在執行檔佈局規劃好後,把程式的片斷接起來,然後修正跳躍的目的位址(這個步驟稱為「重新定位」(relocate))。因此,分離編譯需要知道函式的名字,但是不需要函式的位址。

綜上所述,只要省略函式本體的{...},就有充份的資訊可以呼叫出該函式了。像這樣省去函式本體的就叫「宣告」(declaration)。宣告只告訴編譯器型態和名字,並不包含函式的程式碼。舉例來說底下就是strncmp的宣告:

int strncmp(const char *s1, const char *s2, size_t n);

編譯器只要看到上面這1行,就能知道有strncmp的存在與其型態。反之,包含函式程式碼的就稱為「定義」(definition)。

在函式的宣告前,也可以加上代表宣告的關鍵字extern:

extern int strncmp(const char *s1, const char *s2, size_t n);

但是函式的宣告,只要省略函式的本體就足以區分定義和宣告,所以不加上extern也沒問題。

此外,宣告中只要知道引數的型態就可以了,所以宣告中可以省略變數的名字,但是一般來說為了要讓人類容易理解,所以就算是宣告通常也會寫上名字。

另一個例子,我們來看型態為結構的情況。如果有2個以上的C程式檔用到同一個結構的話,需要在每個檔案中都宣告同樣的結構。如果該結構只有一個C程式檔用到的話,就不需要讓其他的C檔案知道該結構。

在C語言中,會把像這類編譯其他C程式檔案需要的宣告做整理,寫進標頭檔(副檔名為 .h)中。在 foo.h 裡寫上宣告,在別的C程式檔需要的它們時寫上#include "foo.h",就可以把#include這行換成 foo.h 裡的內容。

typedef這類用來告訴編譯器型態的語法,如果要在複數個C程式檔中用到,也需要寫進標頭檔中。

編譯器讀到宣告並不會輸出組合語言指令。因為,宣告只是在要用到別的檔案裡的函式或是變數時,所需要的資訊,其本身並不是函式或是變數的定義。

關於分離編譯,談到這邊應該可以知道「使用printf時像咒語一樣寫的#include <stdio.h>」具體來說到底在做什麼了。它會默默地把C標準函式庫丟給連結器,連結器就可以做出連結到包含printf的目標檔的執行檔。同時,編譯器自己其實並不知道關於printf相關的知識。printf並不是內嵌的函式,C語言也沒有自動讀進標準函式庫標頭檔的規則,所以剛啟動的編譯器是完全不知道printf的。在這個狀態下,透過讀進C標準函式庫隨附的標頭檔,編譯器才知道printf的存在和其型態,於是可以編譯出呼叫printf的指令。

小知識:One-Pass 編譯器與前置宣告(Forward Declaration)

C語言中,就算把所有函式寫在同一個檔案裡,有時也需要宣告。C語言的規格中,編譯器並不會一次讀進整個檔案,而是可以從上到下1個1個函式編譯。於是,不論哪個函數,都必須要可以根據到該函數在檔案中的位址為止的資訊進行編譯。所以,如果像要用到在檔案中比較後面定義的函式的話,就必須事先寫好宣告。像這樣的宣告就稱作「前置宣告」。

花點功夫調整函式在檔案中的順序的話,也可以幾乎不用前置宣告,但是如果想寫互相為遞迴的函數時,前置宣告就是必要的。

不讀完整個檔案,而是允許只讀取部份檔案編譯的C語言規格,在主記憶體很小的時代可能有意義,現在看起來可能是相當落後的規格也說不定。如果編譯器稍微聰明一點,寫在同一個檔案中的定義應該可以不寫宣告。但是這個步驟已經是語言規格的一部份,所以得放在心上。

Previous分離編譯與其必要性Next連結錯誤

Last updated 5 years ago

Was this helpful?