連結器規則會引入的巨坑

劉小緒同學發表於2018-12-09

    首先來看一個簡單的程式。下面是是兩段程式,分別放在link.cbar.c中。

/* link.c */
#include<stdio.h>
void f(void);

int x = 13;

int main()
{
	f();
	printf("x=%d\n", x);
	return 0;
}


/* bar.c */
int x;

void f()
{
	x = 12;
}

    現在我們使用命令gcc -o linkbar link.c bar.c將其編譯為可執行檔案,然後用./linkbar執行檔案,執行結果為:x=12

    有趣的事情就發生了,在link.c中分明定義的是x = 13,而且列印語句使用的也是自己模組的x變數,怎麼就變成了 12 了呢?

    那問題是不是就出在bar.c檔案中呢?這個檔案中也定義了x變數,並且在f()函式中將其賦值了,但是這個x是在bar.c中,link.c中也有自己的x變數,按理來說它們應該是相互不影響的,讓人疑惑!!!!

    實際上這都是連結器搞的鬼,上面場景在工作中遇到的可能性不小,這種錯誤引入程式後,並不會立即表現出來,而是可能在其它你想不到的地方報錯,試想一下,在一個擁有成百上千個模組的大型系統中,發生了這樣的錯誤,而你也不知道錯誤的源頭,讓你定位出這個錯誤,其困難程度可想而知。

    要理清這個問題,需要去了解連結器是怎麼工作的。我們都知道,現在的系統越來越大,我們將其分解成為更小的、更好管理的模組,可以獨立地修改和編譯這些模組(像不像微服務?),這樣協作讓我們不必將整個應用程式組織成一個巨大的原始檔。

    為了構造可執行檔案,連結器需要完成符號解析重定位兩個主要任務,這裡我們主要看看符號解析。

    每個符號都對應一個函式、一個全域性變數或一個靜態變數(C 中以static宣告的變數),符號解析就是要把每個符號引用與符號定義關聯起來,注意不包括區域性變數哦。

    C 語言中 static 宣告的變數和 Java、C++ 中的 private 宣告一樣,不帶 static 的就是 public 型別的。

    你肯定聽說過可重定位這個詞,源程式在經過預處理、編譯、彙編之後產生的就是可重定位目標檔案,怎麼理解可重定位呢?簡單來說,就是說檔案裡面的程式碼段和資料的地址還沒有最終確定。

    在每個可重定位目標檔案中都有一個符號表,這個符號表包含了可重定位目標檔案自己定義和引用的符號資訊。共有三種不同的符號:1、由自己定義並能被其它模組引用的全域性符號;2、由其它模組定義並被自己引用的全域性符號;3、只能被自己引用的區域性符號。

    連結器解析符號引用是將每個引用與它的輸入的可重定位目標檔案的符號表中的一個確定的符號定義關聯起來,但是不同的可重定位目標檔案可能有多個同名的全域性符號,即多重定義的全域性符號。

    函式和已經初始化的全域性變數是強符號,未初始化的全域性變數是弱符號。根據強弱符號的定義,Linux 連結器使用三條規則來處理多重定義的符號。

  1. 不允許有多個同名的強符號;
  2. 如果有多個弱符號和一個強符號同名,那麼選擇強符號;
  3. 如果有多個弱符號同名,那麼選擇其中任意一個。

    看到這裡,就明白為什麼會有開篇程式出現的那個錯誤了,因為它正好滿足規則二,所以bar.c中的x變數實際上還是link.c中的x變數。

    尤其規則 2 和 3 的應用會帶來一些不易察覺的執行時錯誤,這是非常難理解的,尤其是重複的符號中還有不同的型別時,比如下面這個例子。

/* link.c */
#include<stdio.h>
void f(void);

int y = 12;
int x = 13;

int main()
{
    f();
    printf("x=0x%x y=0x%x \n", x, y);
    return 0;
}


/* bar.c */
double x;

void f()
{
    x = -0.0;
}

    在一臺 x86-64/Linux 機器上,double型別是 8 個位元組,而int型別是 4 個位元組,假設系統中x的地址是0x601020y的地址是0x601024,而bar.c的賦值x = -0.0;將會用負零的雙精度浮點表示覆蓋記憶體中xy的位置。

相關文章