天天看點

嵌入式C語言自我修養 (09):連結過程中的強符号和弱符号

9.1 屬性聲明:weak

GNU C 通過 attribute 聲明weak屬性,可以将一個強符号轉換為弱符号。

使用方法如下。

void  __attribute__((weak))  func(void);
int  num  __attribte__((weak);
           

編譯器在編譯源程式時,無論你是變量名、函數名,在它眼裡,都是一個符号而已,用來表征一個位址。編譯器會将這些符号集中,存放到一個叫符号表的 section 中。

在一個軟體工程項目中,可能有多個源檔案,由不同工程師開發。有時候可能會遇到這種情況:A 工程師在他負責的 A.c 源檔案中定義了一個全局變量 num,而 B 工程師也在他負責的 B.c 源檔案中定義了一個同名全局變量 num。那麼當我們在程式中列印變量 num 的值時,是該列印哪個值呢?

是時候表演真正的技術了。這時候,就需要用編譯連結的原理知識來分析這個問題了。編譯連結的基本過程其實很簡單,主要分為三個階段。

  • 編譯階段:編譯器以源檔案為機關,将每一個源檔案編譯為一個 .o 字尾的目标檔案。每一個目标檔案由代碼段、資料段、符号表等組成。
  • 連結階段:連結器将各個目标檔案組裝成一個大目标檔案。連結器将各個目标檔案中的代碼段組裝在一起,組成一個大的代碼段;各個資料段組裝在一起,組成一個大的資料段;各個符号表也會集中在一起,組成一個大的符号表。最後再将合并後的代碼段、資料段、符号表等組合成一個大的目标檔案。
  • 重定位:因為各個目标檔案重新組裝,各個目标檔案中的變量、函數的位址都發生了變化,是以要重新修正這些函數、變量的位址,這個過程稱為重定位。重定位結束後,就生成了可以在機器上運作的可執行程式。

上面舉例的工程項目,在編譯過程中的連結階段,可能就會出現問題:A.c 和 B.c 檔案中都定義了一個同名變量 num,那連結器到底該用哪一個呢?

這個時候,就需要引入強符号和弱符号的概念了。

9.2 強符号和弱符号

在一個程式中,無論是變量名,還是函數名,在編譯器的眼裡,就是一個符号而已。符号可以分為強符号和弱符号。

  • 強符号:函數名、初始化的全局變量名;
  • 弱符号:未初始化的全局變量名。

在一個工程項目中,對于相同的全局變量名、函數名,我們一般可以歸結為下面三種場景。

  • 強符号+強符号
  • 強符号+弱符号
  • 弱符号+弱符号

強符号和弱符号在解決程式編譯連結過程中,出現的多個同名變量、函數的沖突問題非常有用。一般我們遵循下面三個規則。

  • 一山不容二虎
  • 強弱可以共處
  • 體積大者勝出

為了友善,這是我編的順口溜。主要意思就是:在一個項目中,不能同時存在兩個強符号,比如你在一個多檔案的工程中定義兩個同名的函數,或初始化的全局變量,那麼連結器在連結時就會報重定義的錯誤。但一個工程中允許強符号和弱符号同時存在。比如你可以同時定義一個初始化的全局變量和一個未初始化的全局變量,這種寫法在編譯時是可以編譯通過的。編譯器對于這種同名符号沖突,在作符号決議時,一般會選用強符号,丢掉弱符号。還有一種情況就是,一個工程中,同名的符号都是弱符号,那編譯器該選擇哪個呢?誰的體積大,即誰在記憶體中存儲空間大,就選誰。

我們接下來寫一個簡單的程式,來驗證上面的理論。定義兩個源檔案:main.c 和 func.c。

//func.c
int a = 1;
int b;
void func(void)
{
    printf("func:a = %d\n", a);
    printf("func: b = %d\n", b);
}
​
//main.c
int a;
int b = 2;
void func(void);
int main(void)
{
    printf("main:a = %d\n", a);
    printf("main: b = %d\n", b);
    func();
    return 0;
}
           

編譯程式,可以看到程式運作結果。

$ gcc -o a.out main.c func.c
main: a = 1
main: b = 2
func: a = 1
func: b = 2
           

我們在 main.c 和 func.c 中分别定義了兩個同名全局變量 a 和 b,但是一個是強符号,一個是弱符号。連結器在連結過程中,看到沖突的同名符号,會選擇強符号,是以你會看到,無論是 main 函數,還是 func 函數,列印的都是強符号的值。

一般來講,不建議在一個工程中定義多個不同類型的弱符号,編譯的時候可能會出現各種各樣的問題,這裡就不舉例了。在一個工程中,也不能同時定義兩個同名的強符号,即初始化的全局變量或函數,否則就會報重定義錯誤。但是我們可以使用 GNU C 擴充的 weak 屬性,将一個強符号轉換為弱符号。

//func.c
int a __attribute__((weak)) = 1;
void func(void)
{
    printf("func:a = %d\n", a);
}
​
//main.c
int a = 4;
void func(void);
int main(void)
{
    printf("main:a = %d\n", a);
    func();
    return 0;
}
           

編譯程式,可以看到程式運作結果。

$ gcc -o a.out main.c func.c
main: a = 4
func: a = 4
           

我們通過 weak 屬性聲明,将 func.c 中的全局變量 a,轉換為一個弱符号,然後在 main.c 裡同樣定義一個全局變量 a,并初始化 a 為4。連結器在連結時會選擇 main.c 中的這個強符号,是以在兩個檔案中,列印變量 a 的值都是4。

9.3 函數的強符号和弱符号

連結器對于同名變量沖突的處理遵循上面的強弱規則,對于函數同名沖突,同樣也遵循相同的規則。函數名本身就是一個強符号,在一個工程中定義兩個同名的函數,編譯時肯定會報重定義錯誤。但我們可以通過 weak 屬性聲明,将其中一個函數轉換為弱符号。

//func.c
int a __attribute__((weak)) = 1;
void __attribute__((weak)) func(void)
{
    printf("func:a = %d\n", a);
}
​
//main.c
int a = 4;
void func(void)
{
    printf("I am a strong symbol!\n");
}
int main(void)
{
    printf("main:a = %d\n", a);
    func();
    return 0;
}
           

編譯程式,可以看到程式運作結果。

$ gcc -o a.out main.c func.c
main: a = 4
func: I am a strong symbol!
           

在這個程式示例中,我們在 main.c 中重新定義了一個同名的 func 函數,然後将 func.c 檔案中的 func() 函數,通過 weak 屬性聲明轉換為一個弱符号。連結器在連結時會選擇 main.c 中的強符号,是以我們在 main 函數中調用 func() 時,實際上調用的是 main.c 檔案裡的 func() 函數。

9.4 弱符号的用途

在一個源檔案中引用一個變量或函數,當我們隻聲明,而沒有定義時,一般編譯是可以通過的。這是因為編譯是以檔案為機關的,編譯器會将一個個源檔案首先編譯為 .o 目标檔案。編譯器隻要能看到函數或變量的聲明,會認為這個變量或函數的定義可能會在其它的檔案中,是以不會報錯。甚至如果你沒有包含頭檔案,連個聲明也沒有,編譯器也不會報錯,頂多就是給你一個警告資訊。但連結階段是要報錯的,連結器在各個目标檔案、庫中都找不到這個變量或函數的定義,一般就會報未定義錯誤。

當函數被聲明為一個弱符号時,會有一個奇特的地方:當連結器找不到這個函數的定義時,也不會報錯。編譯器會将這個函數名,即弱符号,設定為0或一個特殊的值。隻有當程式運作時,調用到這個函數,跳轉到0位址或一個特殊的位址才會報錯。

//func.c
int a __attribute__((weak)) = 1;
​
//main.c
int a = 4;
void __attribute__((weak)) func(void);
int main(void)
{
    printf("main:a = %d\n", a);
    func();
    return 0;
}
           

編譯程式,可以看到程式運作結果。

$ gcc -o a.out main.c func.c
main: a = 4
Segmentation fault (core dumped)
           

在這個示例程式中,我們沒有定義 func() 函數,僅僅是在 main.c 裡作了一個聲明,并将其聲明為一個弱符号。編譯這個工程,你會發現是可以編譯通過的,隻是到了程式運作時才會出錯。

為了防止函數運作出錯,我們可以在運作這個函數之前,先做一個判斷,即看這個函數名的位址是不是0,然後再決定是否調用、運作。這樣就可以避免段錯誤了,示例代碼如下。

//func.c
int a __attribute__((weak)) = 1;
​
//main.c
int a = 4;
void __attribute__((weak)) func(void);
int main(void)
{
    printf("main:a = %d\n", a);
    if (func)
        func();
    return 0;
}
           

編譯程式,可以看到程式運作結果。

$ gcc -o a.out main.c func.c
main: a = 4
           

函數名的本質就是一個位址,在調用 func 之前,我們先判斷其是否為0,為0的話就不調用了,直接跳過。你會發現,通過這樣的設計,即使這個 func() 函數沒有定義,我們整個工程也能正常的編譯、連結和運作!

弱符号的這個特性,在庫函數中應用很廣泛。比如你在開發一個庫,基礎的功能已經實作,有些進階的功能還沒實作,那你可以将這些函數通過 weak 屬性聲明,轉換為一個弱符号。通過這樣設定,即使函數還沒有定義,我們在應用程式中隻要做一個非0的判斷就可以了,并不影響我們程式的運作。等以後你釋出新的庫版本,實作了這些進階功能,應用程式也不需要任何修改,直接運作就可以調用這些進階功能。

弱符号還有一個好處,如果我們對庫函數的實作不滿意,我們可以自定義與庫函數同名的函數,實作更好的功能。比如我們 C 标準庫中定義的 gets() 函數,就存在漏洞,常常成為黑客堆棧溢出攻擊的靶子。

int main(void)
{
    char a[10];
    gets(a);
    puts(a);
    return 0;   
}
           

C 标準定義的庫函數 gets() 主要用于輸入字元串,它的一個 Bug 就是使用回車符來判斷使用者輸入結束标志。這樣的設計很容易造成堆棧溢出。比如上面的程式,我們定義一個長度為10的字元數組用來存儲使用者輸入的字元串,當我們輸入一個長度大于10的字元串時,就會發生記憶體錯誤。

接着我們定義一個跟 gets() 相同類型的同名函數,并在 main 函數中直接調用,代碼如下。

#include<stdio.h>
​
 char * gets (char * str)
 {
     printf("hello world!\n");
     return (char *)0;
 }
​
int main(void)
{
    char a[10];
    gets(a);
    return 0;   
}
           

程式運作結果如下。

hello world!
           

通過運作結果,我們可以看到,雖然我們定義了跟 C 标準庫函數同名的 gets() 函數,但編譯是可以通過的。程式運作時調用 gets() 函數時,就會跳轉到我們自定義的 gets() 函數中運作。

9.5 屬性聲明:alias

GNU C 擴充了一個 alias 屬性,這個屬性很簡單,主要用來給函數定義一個别名。

void __f(void)
{
    printf("__f\n");
}
​
void f() __attribute__((alias("__f")));
int main(void)
{
    f();
    return 0;   
}
           

程式運作結果如下。

__f
           

通過 alias 屬性聲明,我們就可以給 f() 函數定義一個别名 f(),以後我們想調用 f() 函數,可以直接通過 f() 調用即可。

在 Linux 核心中,你會發現 alias 有時會和 weak 屬性一起使用。比如有些函數随着核心版本更新,函數接口發生了變化,我們可以通過 alias 屬性給這個舊接口名字做下封裝,起一個新接口的名字。

//f.c
void __f(void)
{
    printf("__f()\n");
}
void f() __attribute__((weak,alias("__f")));
​
//main.c
void __attribute__((weak)) f(void);
void f(void)
{
    printf("f()\n");
}
​
int main(void)
{
    f();
    return 0;
}
           

當我們在 main.c 中新定義了 f() 函數時,在 main 函數中調用 f() 函數,會直接調用 main.c 中新定義的函數;當 f() 函數沒有新定義時,就會調用 __f() 函數。

本文根據《C語言嵌入式Linux進階程式設計》部分章節改編,視訊學習可通路CSDN學院:https://edu.csdn.net/combo/detail/1038

微信公衆号:宅學部落(armlinuxfun)

QQ群:475504428

更多嵌入式視訊教程:https://wanglitao.taobao.com

電子書籍下載下傳位址:https://pan.baidu.com/s/1a6L0cyIQKKLlmIfRw7U6Dg