【C 陷阱與缺陷】(四)連接

碼字不易,對你有幫助 點贊/轉發/關注 支持一下作者

微信搜公衆號:不會編程的程序圓

看更多幹貨,獲取第一時間更新

代碼,練習上傳至:

https://github.com/hairrrrr/C-CrashCourse

一 鏈接

0. 什麼是連接器

C 語言的一個重要思想就是分別編譯(separate compilation),即若干個源程序可以在不同的時候單獨進行編譯,然後在恰當的時候整合在一起。但是,連接器一般是與 C 編譯器分離的,它不可能瞭解 C 語言的諸多細節。

**連接器的工作原理:**連接器的輸入是一組目標模塊和庫文件。連接器的輸出是一個載入摸塊。連接器讀入目標模塊和庫文件同時生皮載入模塊。對每個目標核塊中的每個外部對象,鏈接器都要檢查載入模塊。查看是否有同名的外部對象。如果沒有,連接器就將該外部對象添加到入模塊中,如果有,連接器就要開始處理命名衝突。

**外部對象:**程序中的每個函數和每個外部變量,如果沒有被聲明爲 static,就都是一個外部對象。

除了外部對象之外,目標模塊中還可能包括了對其他模塊中的外部對象的引用。例如一個調用了函數 printf 的 C 程序所生成的目標模塊,就包括了一個對庫函數 printf 的引用。可以推測得出,該引用指向的是一個位於某個庫文件中的外部對象。在連接器生成載入模塊的過程中,它必須同時記錄這些外部對象的應用。當連接器讀入一個目標模塊時,它必須解析出這個目標模塊中定義的所有外部對象的引用,並標記這些外部對象不再是未定義的。

1. 聲明與定義

聲明語句:

int a;

如果其位置出現在所有函數體之外,那麼它就被稱爲外部對象 a 的定義。這個語句說明了 a 是一個外部整型變量,同時爲 a 分配內存空間。它的初始值默認爲 0 。

下面的聲明語句:

int a = 7;

不僅爲 a 分配了內存空間,而且說明了在該內存中應該存儲的值。

下面的聲明語句:

extern int a;

並不是對 a 的定義。這個語句仍然說明了 a 是一個外部整型變量,但是 a 的存儲空間是在程序的其他地方分配的。從連接器的角度來看,上面的聲明是對 a 的引用,而不是定義。

void srand(int n){
    extern int random_seed;
    random_seed = n;
}

每個外部對象都必須在某個地方進行定義。因此,如果程序中包括了語句:

extern int a;

那麼,這個程序就必須在別的某個地方包括語句:

int a;

這兩個語句既可以是在同一個源文件中,也可以位於程序的不同源文件中。

嚴格的規則是,每個外部變量都只能被定義一次

2. 命名衝突與 static 修飾符

兩個具有相同名稱的外部對象實際上代表的是同一個對象,即使編程者的本意並非如此,但系統卻會如此處理。因此,如果在兩個不同的源文件中都包括了定義:

int a;

那麼,它或者表示程序錯誤(如果連接器禁止外部變量重複定義的話),或者在兩個源文件中共享 a 的同一個實例(無論兩個源文件中的外部變量 a 是否應該共享)。

即使其中 a 的一個定義是出現在系統提供的庫文件中,也仍然進行同樣的處理。當然,一個設計良好的函數庫不至於定義 a 作外部名稱。但是,要了解函數庫中定義的所有外部對象名稱卻也並非易事。類似於read 和 write 這樣的名稱不難猜到,但其他的名稱就沒有這麼容易了。

static 修飾符是一個能夠減少此類命名衝突的有用工具。例如,以下聲明語句:

static int a;

其含義與下面的語句相同

int a;

只不過,a 的作用域限制在一個源文件內,對於其他源文件,a是不可見的。因此,如果若干個函數需要共享一組外部對象,可以將這些函數放到一個源文件中,把它們需要用到的對象也都在同一一個源文件中以 static 修飾符聲明。

static修飾符不僅適用於變量,也適用於函數。如果函數 f 需要調用另一個函數 g ,而且只有函數 f 需要調用函數 g ,我們可以把函數 g 和 f 放到同一個源文件中,並聲明函數 g 爲 static:

static int g(int x){
    // 函數體
}
int f(){
    // 其他內容
    b = g(a);
}

我們可以在多個源文件中定義同名的函數 g,只要所有的函數 g 都被定義爲 static,或者僅僅只有其中一個函數 g 不是static 。因此,爲了避免可能出現的命名衝突,如果一個函數僅僅被同一個源文件中的其他函數調用,我們就應該聲明該函數爲 static。

3. 形參,實參與返回值

如果任何一個函數在調用它的每個文件中,都在第一次被調用之前進行了聲明或定義,那麼就不會有任何與返回類型相關的麻煩。

比如一個調用 square 函數的程序:

main(){
    printf("%g\n", square(3.0));
}

要使這個程序能夠運行,函數 square 必須要麼在 main 函數之前進行定義:

double
square(double x){
    return x * x;
}

main(){
    printf("%g\n", square(3.0));
}

要麼在 main 函數前進行聲明:

double square(double);

main(){
    printf("%g\n", square(3.0));
}

double
square(double x){
    return x * x;
}

如果一個函數在被定義或聲明之前被調用,那麼它的返回類型就默認爲整型。比如將上面的 main 函數放到一個獨立的源文件中:

main(){
    printf("%g\n", square(3.0));
}

main 函數假定函數 square 返回類型爲整型,而函數 square 返回類型實際上是雙精度類型,當他與 square 函數連接時就會得出錯誤的結果。

如果我們需要在兩個不同的源文件中分別定義函數 main 和函數 square ,那麼應該在調用 square 函數的文件中聲明 square 函數。比如:

double square(double);

main(){
    printf("%g\n", square(3.0));
}

ANSI C 允許程序員在聲明時指定函數的參數類型(省略也是可以的,但是在函數定義時是不能省略參數類型的說明)。

double square(double);

像下面這樣聲明也是可以的:

double square();

默認實參提升

  • float 類型參數會轉換爲 double 類型
  • char 類型,short 類型參數會轉換爲 int 類型

對於聲明:

int isvowel(char);

如果在使用 isvowel 函數前沒有這樣聲明,調用者將把傳遞給 isvowel 函數實參自動轉換爲 int 類型。

main(){
    double s;
    s = sqrt(2);
    printf("%g\n", s);
}

上面的程序不能正常運行,原因有兩個:

  • sqrt 函數本該接受一個雙精度的值作爲實參,而實際上被傳遞了一個整型
  • sqrt 函數的返回類型是雙精度類型,但卻沒有這樣聲明。

一種更正方式是:

double sqrt(double);

main(){
    double s;
    s = sqrt(2.0);
    printf("%g\n", s);
}

當然,最好的更正的方式是這樣:

#include<math.h>

main(){
    double s;
    s = sqrt(2.0);
    printf("%g\n", s);
}

上面 sqrt 的實參已經修改爲 2.0,然而即使仍然寫成 2,在符合 ANSI C 的編譯器上,這個程序也能確保實參會被轉換爲恰當的類型。

因爲函數 printf 和函數 scanf 在不同情形下可以接受不同類型的參數,所以它們特別容易出錯。這裏有個值得注意的例子:

#include<stdio.h>

int main() {

	int i;
	char c;
	for (i = 0; i < 5; i++) {
		scanf("%d", &c);
		printf("%d ", i);
	}
	printf("\n");

	return 0;
}

表面上,這個程序從標準輸入設備讀入 5 個數,在標準輸出設備上寫 5 個數:0 1 2 3 4

實際上,這個程序並不是一定得到上面的結果。例如,在某個編譯器上,它的輸出是:0 0 0 0 0 1 2 3 4

爲什麼呢?問題的關鍵在於,這裏 c 被聲明爲 char 類型,而不是 int 類型。當程序要求 scanf 讀入一個整數,應該傳遞給它一個指向整數的指針。而程序中scanf函數得到的卻是一一個指向字符的指針,scanf 函數並不能分辨這種情況,它只是將這個指向字符的指針作爲指向整數的指針而接受,並且在指針指向的位置存儲一個整數。因爲整數所佔的存儲空間要大於字符所佔的存儲空間,所以字符 c 附近的內存將被覆蓋。

字符 c 附近的內存中存儲的內容是由編譯器決定的,本例中它存放的是整數 i 的低端部分。因此,每次讀入一個數值到 c 時,都會將i的低端部分覆蓋爲 0 ,而 i 的高端部分本來就是 0 ,相當於 i 每次被重新設置爲 0, 循環將一直進行。當到達文件的結束位置後,scanf 函數不再試圖讀入新的數值到 c 。這時,i 纔可以正常地遞增,最後終止循環。

4. 檢查外部類型

假定我們有一個 C 程序,它由兩個源文件組成。一個文件包含外部變量 n 的聲明:

extern int n;

另一個文件中包含外部變量 n 的定義:

long n;

這是一個無效的 C 程序,因爲同一個外部變量在兩個文件中不能被聲明爲不同類型。然而編譯器和連接器可能檢查不出這種錯誤。

當這個程序運行時,究竟會發生什麼情況呢?存在很多的可能情況:

  1. C 語言編譯器足夠“聰明”,能夠檢測到這類型衝突。編程者將會得到一條診斷消息,報告變量 n 在兩個不同的文件中被給定了不同的類型。
  2. 讀者使用的C語言實現對 int 類型的數值與 long 類型的數值在內部表示上是樣的。尤其是在32位計算機上,一般都是如此處理。在這種情況下,程序很可能正常工作,就好像 n 在兩個文件中都被聲明爲long (或int)類型一樣。 本來錯誤的程序因爲某種巧合卻能夠工作,這是一個很好的例子。
  3. 變量 n 的兩個實例雖然要求的存儲空間的大小不同,但是它們共享存儲空間的方式卻恰好能夠滿足這樣的條件:賦給其中一個的值,對另一個也是有效的。這是有可能發生的。舉例來說,如果連接器安排 int 類型的 n 與 long 類型的 n 的低端部分共享存儲空間,這樣給每個long類型的 n 賦值,恰好相當於把其低端部分賦給了 int 類型的 n。本來錯誤的程序因爲某種巧合卻能夠作,這是一個比第 2 種情況更能說明問題的例子。
  4. 變量 n 的兩個實例共享存儲空間的方式,使得對其中一個賦值時,其效果相當於同時給另一個賦了 完全不同的值。在這種情況下,程序將不能正常工作。

因此,保證一個特定的名稱的所有外部定義在每個目標模塊中都有相同的類型,一般來說是程序員的責任。

考慮下面的例子,在一個文件中包含定義:

char filename[] = "/etc/passwd";

而在另一個文件中包含聲明:

extern char* filename;

第一個例子中字符數組 filename 的內存佈局大致如圖:

第二個例子中字符指針 filename 的內存佈局大致如圖:

要更正本例,改法如下:

char filename[] = "/etc/passwd";// 文件 1

extern char filename[]; // 文件 2

或:

char* filename = "/etc/passwd";// 文件 1

extern char* filename; // 文件 2

現在我們回顧前面的程序:

main(){
    double s;
    s = sqrt(2);
    printf("%g\n", s);
}

這個程序在調用函數 sqrt 前沒有對函數 sqrt 進行聲明或定義。因此,這個程序完全等同於下面的程序:

extern int sqrt();

main(){
    double s;
    s = sqrt(2);
    printf("%g\n", s);
}

這樣的寫法當然是錯誤的。

5. 頭文件

有一個好方法可以避免大部分此類問題,這個方法只需要我們接受一個簡單的規則:每個外部對象只在一個地方聲明。這個聲明的地方一般就在一個頭文件中,需要用到該外部對象的所有模塊都應該包括這個頭文件。特別需要指出的是,定義該外部對象的模塊也應該包括這個頭文件。

例如,創建一個文件叫 file.h,它包含聲明:

extern char filename[];

需要用到外部對象 filename 的每個 C 文件都應該加上這樣的一個語句:

#include "file.h"

最後我們選擇一個 C 源文件,在其中給出 filename 的初始值。

file.c

#include "file.h"
char filename[] = "/etc/passwd";

注意,源文件 file.c 中實際上包含了 filename 的兩個聲明,這一點只要把 include 語句展開就可以看出:

extern char filename[];
char filename[] = "/etc/passwd";

只要源文件 file.c 中 filename 的各個聲明是一致的,而且這些聲明中最多隻有 1 個是 filename 的定義,這樣寫就是合法的。

二 練習

練習4-1.

假定一個程序在一個源文件中包含了聲明:

long foo;

而在另一個源文件中包含了:

extern short foo;

又進一步假定,如果給long類型的 foo 賦一個較小的值,例如37,那麼short類型的foo就同時獲得了一個值37。我們能夠對運行該程序的硬件作出什麼樣的推斷?如果short類型的foo得到的值不是37而是0,我們又能夠作出什麼樣的推斷?

如果把值 37 賦給 long 型的 foo,相當於同時把值 37 也賦給了short型的foo,那麼這意昧着 short 型的 foo,與 long 型的foo中包含了值37的有效位的部分,兩者在內存中佔用的是同一區域。long 型的 foo 的低位部分與 short 型的 foo 共享了相同的內存空間,因此我們的一個可能推論就是,運行該程序的硬件是一個低位優先(little-endian:小端) 的機器。

同樣道理,如果在 long 型的 foo 中存儲,了值 37,而 short 型的 foo 的值卻是 0,我們所用的硬件可能是一個高位優先(big-endian:大端)的機器。

注:小端就是將數字的低位放在低地址;大端則相反。

練習4-2

.本章第 4節中討論的錯誤程序,經過適當簡化後如下所示:

#include <stdio.h>

main()
{
	printf("qg\n"sqrt(2) ) ;
}

在某些系統中,打印出的結果是 %g 請問這是爲什麼?

在某些 C 語言實現中,存在着兩種不同版本的 printf 函數:其中一-種實現了用於表示浮點格式的項,如 %e、%f、%g 等;而另一種卻沒有實現這些浮點格式。庫文件中同時提供了printf 函數的兩種版本,這樣的話,那些沒有用到浮點運算的程序,就可以使用不提供浮點格式支持的版本,從而節省程序空間、減少程序大小。

在某些系統上,編程者必須顯式地通知連接器是否用到了浮點運算。而另一些系統,則是通過編譯器來告知連接器在程序中是否出現了浮點運算,以自動地作出決定。

上面的程序沒有進行任何浮點運算!它既沒有包含 math.h 頭文件,也沒有聲明 sqrt 函數,因此編譯器無從得知 sqrt 是一個浮點函數。這個程序甚至都沒有傳送一個浮點參數給sqrt 函數。所以,編譯器“自認合理”地通知連接器,該程序沒有進行浮點運算。

那 sqrt 函數又怎麼解釋呢?難道 sqrt 函數是從庫文件中取出的這個事實,還不足以證明該程序用到了浮點運算? 當然,sqrt 函數是從庫文件中取出的這一點沒錯;但是,連接器可能在從庫文件中取出 sqrt 函數之前,就已經作出了使用何種版本的printf 函數的決定。

注:其實 %g 被 printf 函數當作了字符串輸出,後面的參數被捨棄掉了,你可以用下面這個例子來理解:

#include<stdio.h>

int main(void) {

	printf("Hello World\n", 123);

	return 0;
}

參考資料《C 缺陷與陷阱》


以上就是本次的內容,感謝觀看。

如果文章有錯誤歡迎指正和補充,感謝!

最後,如果你還有什麼問題或者想知道到的,可以在評論區告訴我呦,我在後面的文章可以加上。

最後,關注我,看更多幹貨!

我是程序圓,我們下次再見。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章