透過範例了解Linker如何處理Weak Symbol

2023-06-29
10分鐘閱讀
Featured Image

最近在工作上遇到了__weak這個keyword的環境,在ST提供的HAL函式庫當中,幾乎都預先定義了一個預設的weak callback function,即便我們沒有定義處理的函式,Linker也會幫我們自動連結到該weak callback function。

以ST I2C HAL來舉例,stm32f7xx_hal_i2c.c當中定義了這個函數

__weak void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c)
{
  /* Prevent unused argument(s) compilation warning */
  UNUSED(hi2c);

  /* NOTE : This function should not be modified, when the callback is needed,
            the HAL_I2C_MasterTxCpltCallback could be implemented in the user file
   */
}

我們從函式名稱可以知道這是一個Tx Complete的Callback函式,但在函式的前方有個__weak關鍵字,__weak的定義如下

#define __weak   __attribute__((weak))

其功能是將該函式定義為__attribute__((weak))的形式,讓我們可以去覆寫符合我們需求的Tx Complete的Callback;若我們沒有重新定義一個新的callback函式,預設會執行該預設的__weak函式內容,讓Tx Complete的Event觸發時仍然有預設的函式能夠執行。

對此,為了更加了解__attribute__((weak))的行為,在這篇文章中將說明以下內容

  1. GCC當中Weak/Strong Symbol的基礎概念
  2. 透過nm與readelf來觀察Linker如何選擇Symbol
  3. 不當Symbol對系統可能造成的危害

Common Symbol

在這裡舉個範例程式來說明與Weak Symbol相似的Common Symbol

/* main.c */
int global1;
int global2 = 123;
int main(){
    return 0;
}

global1屬於未初始化變數(Uninitialized data),而global2屬於初始化變數(Initialized data),Initialized data在編譯後皆屬於同一種Symbol,而Uninitialized data在不同的編譯設定下,其編譯後的Symbol會代表不同的意義,分別為以下兩種

  • 編譯使用-fno-common : global1的Symbol Type為S,根據nm manual的定義如下

在這個情況下global1有可能為uninitialized 或是zero-initialized data,後續Linking階段不能再同樣名稱為global1的symbol

$ gcc -c -fno-common main.c -o a.out
$ nm ./a.out
<...Others...>
0000000000000048 S _global1
0000000000000008 D _global2
0000000000000000 T _main
<...Others...>
  • 編譯使用-fcommon : global1的Symbol Type為C,根據nm manual的定義如下

The symbol is common. Common symbols are uninitialized data. When linking, multiple common symbols may appear with the same name. If the symbol is defined anywhere, the common symbols are treated as undefined references. For more details on common symbols, see the discussion of –warn-common in Linker options in The GNU linker. The lower case c character is used when the symbol is in a special section for small commons.

此時,global1的Symbol Type為C,為一種Common Symbol,在Linking階段可以出現多次同名定義,Linker會選擇會根據環境或是順序差異而連結不同的symbol。但本篇文章內容重點不是common symbol,這邊的說明點到為止,提供給讀者參考。總而言之,多個Weak Symbol在Linker之前可能會有多種相同名稱的symbol,需要注意Link後的結果。

$ gcc -c -fcommon main.c -o a.out
$ nm ./a.out
<...Others...>
0000000000000004 C _global1
0000000000000008 D _global2
0000000000000000 T _main
<...Others...>

在此開始說明Weak/Strong Symbol的例子,Linker在處理Weak Symbol的規則有三點

Rule 1 : 存在多個Strong Symbol且變數名稱相同,在Linking階段是不允許的

Rule 2 : 存在一個Strong Symbol,並且有其他多個相同變數名稱的Weak Symbol,Linker會選擇Strong Symbol作為連結對象

Rule 3 : 存在多個Weak Symbol,Linker可能會選擇任一個Weak Symbol連結(根據編譯流程、設定、系統或其他因素有關)

Weak/Strong Symbol Variable

給定以下程式main.c程式

/* main.c */
__attribute__((weak)) int global1;
__attribute__((weak)) int global2 = 123;
__attribute__((weak)) int global3 = 0;
extern __attribute__((weak)) int global4;

int main(){
	global3=0x12345678; // In order to keep symbol
    return 0;
}

透過經過編譯後,Symbol Table結果如下

$ gcc -c main.c -o a.out
$ nm ./a.out
<...Others...>
0000000000000000 V global1
0000000000000000 V global2
0000000000000004 V global3
                 w global4
0000000000000000 T main
<...Others...>

我們可以觀察到global1global2global3的Symbol Type都是V,根據nm manual的定義如下

The symbol is a weak object. When a weak defined symbol is linked with a normal defined symbol, the normal defined symbol is used with no error. When a weak undefined symbol is linked and the symbol is not defined, the value of the weak symbol becomes zero with no error. On some systems, uppercase indicates that a default value has been specified.

皆屬於Weak Symbol,且在Link之前已經有一個實體或數值存在。

global4同樣也屬於Weak Symbol,其Symbol Type為w,定義上有所區別,定義如下

The symbol is a weak symbol that has not been specifically tagged as a weak object symbol. When a weak defined symbol is linked with a normal defined symbol, the normal defined symbol is used with no error. When a weak undefined symbol is linked and the symbol is not defined, the value of the symbol is determined in a system-specific manner without error. On some systems, uppercase indicates that a default value has been specified.

global4宣告為extern,告訴編譯器此變數會在其他地方被定義,暫時可以不用Link任何實體變數,因此Symbol Table當中的V與W的區別是前者編譯期間已有實體或數值存在後者為外部存在一個__attribute__((weak))屬性的symbol,但在未Link之前並不會有實體數值存在。若主程式當中沒有呼叫global3變數,則不會出現在Symbol Table當中。

此時我們引入第二個檔案,內容如下

/* moduleA.c */
__attribute__((weak)) int global1;
int global2 = 321;
__attribute__((weak)) int global3 = 1;
__attribute__((weak)) int global4 = 1;

可以看到moduleA當中重複定義了一次Weak Type的global1;而global2在moduleA當中則宣告為一般的Strong Symbol,且將初始值設為321(main.c當中為123);global3global4則為給予初始值的Weak Symbol宣告方法

透過以下指令編譯並執行

$ gcc -c main.c -o main.o
$ gcc -c moduleA.c -o moduleA.o
$ gcc main.o moduleA.o -o a.out
$ nm ./a.out
<...Others...>
0000000000020044 V global1
0000000000020030 D global2
0000000000020034 V global3
0000000000020038 V global4
<...Others...>

透過nm來觀察最後連結完成後的執行檔,三個變數的狀況分別如下:

global1: 在兩個檔案當中只有宣告為weak,並沒有給予初始值,因此仍然是Weak Symbol(V type)

global2: 在moduleA.c當中有宣告一個Strong Symbol的int型態global2,根據前述的Rule 2可以得知,Linker會選用moduleA.c當中的Strong Symbol global2作為最終連結的選擇,從原本的V type symbol轉換成了D type symbol(Initialized data section),從Weak Symbol變成Strong Type。並且初始值不再是main.c當中所定義的123,而是moduleA.c當中的321。

global3: 在main.c與moduleA.c都屬於有實體的V type symbol,根據Rule 3的敘述,同時有多個同名Weak Symbol存在時,最終可能會是任一個symbol被連結到最終的執行檔。在此範例中,我們在連結階段的指令如下

gcc main.o moduleA.o -o a.out

連結器依照object的順序依序連結,因此global3會先連結到main.o內的symbol,其初始值為0,後續重複的symbol就不會再被連結器重新連結。因此如果我們將連結的指令改為以下順序

gcc moduleA.o main.o -o a.out

改為先連結moduleA.o的symbol,global3的初始值就會依照moduleA.c內的定義,變成初始值為1,而不是main.c當中定義的0。

global4: 在moduleA.c當中有定義了一個Weak Symbol的實體,因此最終連結出來的結果由原本的w變成了V type symbol,使得原本main.c當中的extern有了實體的連結

Weak Symbol Variable可能造成的危害

Weak Symbol提供我們在Linking階段能夠更有彈性的使用外部的Library,但若程式撰寫不當,或是編譯過程忽略一些細節,可能會造成非預期的行為,這些非預期的行為都是非常難Debug的,所以清楚了解Weak Symbol在Linking的行為才能避免問題發生。以下舉個例子

/* main.c */
__attribute__((weak)) char val;
extern void externalLibA();
extern void externalLibB();
int main(){
    val = 10;
    printf("val in main : %d, sizeof(val) : %u\n", val, sizeof(val));
    externalLibA();
    printf("val in main : %d, sizeof(val) : %u\n", val, sizeof(val));
    externalLibB();
    printf("val in main : %d, sizeof(val) : %u\n", val, sizeof(val));
    return 0;
}
/* exLibA.c */
#include<stdio.h>
__attribute__((weak)) short val;
void externalLibA(){
    val = 1024;
    printf("val in externalLibA : %d, sizeof(val) : %u\n", val, sizeof(val));
}
/* exLibB.c */
#include<stdio.h>
__attribute__((weak)) float val;
void externalLibB(){
    val = 3.14;
    printf("val in externalLibB : %f, sizeof(val) : %u\n", val, sizeof(val));
}

使用以下指令編譯並執行

$ gcc -c main.c -o main.o
$ gcc -c exLibA.c -o exLibA.o
$ gcc -c exLibB.c -o exLibB.o
$ gcc -c main.o externalLibA.o externalLibB.o - a.out
$ ./a.out
val in main : 10, sizeof(val) : 1
val in externalLibA : 1024, sizeof(val) : 2
val in main : 0, sizeof(val) : 1
val in externalLibB : 3.140000, sizeof(val) : 4
val in main : 195, sizeof(val) : 1

此範例的重點為val在每個檔案都有出現,且皆為不同的型態,我們透過readelf觀察最終的執行檔連結了哪個大小的symbol

$ readelf -s a.out
<...Others...>
86: 0000000000020039     1 OBJECT  WEAK   DEFAULT   24 val
<...Others...>

可以看到val最終是連結到了main.c當中的char型態(1 Byte),Linker選擇main.c當中的char的原因為,在Linker順序上我們是輸入main.o → exLibA.o → exLibB.o,因此main.c當中的symbol會先被連結,後續出現的symbol也就會被忽略了。讀者可以嘗試交換檔案的連結順序,再透過readelf來觀察,可以看到不同的結果。

除了這個問題之外,我們可以從執行結果中觀察到,在各自的外部函式,val仍然被宣告為各自的型態,exLibA的val為short型態、exLibB的val為float型態。如此一來會造成很大的問題,雖然最終連結後的val都是同一個(範例為1Byte char),但在exLibB當中宣告為float型態,代表在exLibB當中存取val時的型態為浮點數,一但exLibB存放了浮點數,在main當中認為val為char型態的數值,資料讀取上就會產生錯誤。此外,透過readelf可以得知實際上只有Link 1個byte的變數空間,而float型態會佔用4 bytes的資料,超出的資料存放範圍也會造成程式的潛在風險。

Weak/Strong Symbol Function

函式也可以定義為Weak Symbol,用來給予一個預設的函式實作,像是本篇文章最前面提到的stm32f7xx_hal_i2c.c檔案中定義了STM32的I2C相關實作,其中有個Tx Complete的Callback函式,預設的weak函式當中並沒有進行任何行為,讓使用者可以自行決定是否重新定義該函式。

/* stm32f7xx_hal_i2c.c */
__weak void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c)
{
  /* Prevent unused argument(s) compilation warning */
  UNUSED(hi2c);
  /* NOTE : This function should not be modified, when the callback is needed,
            the HAL_I2C_MasterTxCpltCallback could be implemented in the user file
   */
}

同樣在此以例子來說明Weak Function,給定以下程式

/* main.c */
#include<stdio.h>
__attribute__((weak)) void weakFunc();

int main(){
    weakFunc();
    return 0;
}
__attribute__((weak)) void weakFunc(){
    printf("Default Implementation\n");
}

使用以下指令編譯執行,並觀察Symbol Table

$ gcc main.c -o a.out
$ nm ./a.out
<...Others...>
000000000000076c W weakFunc
<...Others...>
$ ./a.out
Default Implementation

從Symbol Table看到weakFunc是W Type Symbol,不屬於任何實體,而是指向一個Function。我們可以另外引入一個module來覆寫weakFunc的內容,內容如下

/* module.c */
#include<stdio.h>
void weakFunc(){
    printf("Override Weak Function\n");
}

module.c內重新宣告了一個weakFunc,並且沒有加上__attribute__((weak))的關鍵字,為一個Strong Symbol,根據前面所提到的Linker規則,Linker會選擇Strong Symbol作為連結的目標,因此原本main.c內的weakFunc會被覆寫掉,變成module.c內的實作。

$ gcc -c main.c -o main.o
$ gcc -c module.c -o module.o
$ gcc main.o module.o -o a.out
$ ./a.out
Override Weak Function

總結

本篇文章主要紀錄了Weak Symbol的相關特性,與連結後的結果。筆者覺得網路上Weak Symbol的相關資訊非常零散且參差不齊,因此整理了自己的學習過程。不過,編譯後的Symbol Table可能會與編譯器、連結器、作業系統等都有關係,所以讀者在嘗試過程中可能會遇到不同的結果發生,就需要自行搜集資料並確認實際的狀況,為了讓讀者能夠清楚暸解實驗環境,本篇文章的所有結果皆於以下環境所得出:

希望這篇文章內容能夠讓讀者了解Weak Symbol相關特性,善用Weak Symbol可以讓程式在編譯與連結期間更加靈活,但不當使用可能會出現非預期的bug,如果有任何問題歡迎留言討論!

延伸閱讀

  1. nm(1) — Linux manual page
  2. readelf(1) — Linux manual page
  3. Understand Weak Symbols by Examples
  4. Why uninitialized global variable is weak symbol?
  5. Does native C have common symbol?
  6. All about COMMON symbols
  7. Symbol Resolution, Computer Systems: A Programmer’s Perspective, 2/E (CS:APP2e)
comments powered by Disqus