查看Linux內核代碼時,經常能看到一些編譯器選項如__attribute__((weak),起初不太了解,經過查資料,算是對gcc的這個編譯屬性有了初步的認識,現在總結如下。
編譯器在編譯源程序時,無論你是變量名、函數名,在它眼里,都是一個符號而已,用來表示一個地址。編譯器會將這些符號集中存放到一個叫符號表的 section 中。
程序編譯鏈接的基本過程總結起來其實很簡單,大概分為如下三個階段:
- 編譯階段:編譯器以源文件為單位,將每一個源文件編譯為一個.o后綴的目標文件。每一個目標文件由代碼段、數據段、符號表等組成。
- 鏈接階段:鏈接器將各個目標文件組裝成一個大目標文件。鏈接器將各個目標文件中的代碼段組裝在一起,組成一個大的代碼段;各個數據段組裝在一起,組成一個大的數據段;各個符號表也會集中在一起,組成一個大的符號表。最后再將合并后的代碼段、數據段、符號表等組合成一個大的目標文件。
- 重定位:由于鏈接階段各個目標文件重新組裝,各個目標文件中的變量和函數的地址都發生了變化,所以要重新修正這些函數及變量的地址,這個過程稱為重定位。重定位結束后,才生成了可以在機器上運行的可執行程序。
一、weak屬性
attribute ((weak))表示為弱符號屬性,所謂的弱符號是針對于強符號來說的,我們定義的全局已初始化變量及全局函數等都是屬于強符號,在鏈接時如果有多個強符號就會報錯誤;而弱符號主要指未初始化的全局變量或通過__attribute__((weak))來顯式聲明的變量或函數。
在日常編程過程中,我們可能會碰到一種符號重復定義的情況。如果多個目標文件中含有相同名字的全局變量的定義,那么這些目標文件鏈接的時候就會出現符號重復定義的錯誤。比如在源文件date.c 和源文件weak_attr.c都定義了一個全局整型變量year,并且均已初始化,那么當date.c和weak_attr.c鏈接時會報錯:
multiple definition of 'xxx'
重復定義的源碼文件如下:
/* 頭文件date.h */
#ifndef __DATE_H__
#define __DATE_H__
void currentYear();
#endif
/* 源文件date.c */
#include < stdio.h >
#include "date.h"
int year=2023;
void currentYear()
{
printf("This year is %d.\\n", year);
}
/* 源文件weak_attr.c */
#include < stdio.h >
#include "date.h"
int year=2022;
int main()
{
currentYear();
return 0;
}
gcc編譯輸出結果如下:
[root@localhost 119]# gcc -o weak_attr date.c weak_attr.c
/tmp/ccpmkhms.o:(.data+0x0): multiple definition of `year'
/tmp/ccsxbab2.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status
那么該如何解決這個問題呢?讓我們繼續往下看,下文會給出解決之道。
二、強符號弱符號
在程序中,無論是變量名,還是函數名,在編譯器的眼里,都只是一個符號而已。符號可以分為強符號和弱符號。
- 強符號:函數名、初始化的全局變量名;
- 弱符號:未初始化的全局變量名、 attribute _((weak)修飾的變量或函數
強符號和弱符號在解決程序編譯鏈接過程中,出現的多個同名變量、函數的沖突問題非常有用。一般我們遵循下面三個規則:
- 一山不容二虎
- 強弱可以共處
- 體積大者為主
上面為方便記憶總結的3點原則,具體表述如下:
強弱符號總結規則如下:
規則1:不允許強符號被重復多次定義,但是強弱符號可以共存。
規則2:如果一個符號在某個目標文件中是強符號,但在其他文件中都是弱符號,那么編譯時以強符號的值為準。
規則3:如果一個符號在所有的目標文件中都是弱符號,那么選擇其中占用空間最大的一個。這個其實很好理解,編譯器不知道編程者的用意,選擇占用空間大的符號至少不會造成諸如溢出、越界等嚴重后果。
下面我們以一個例子說明強弱符號,如下:
#include < stdio.h >
extern int temp; // 非強符號也非弱符號
int weak; // 弱符號
int strong = 1; // 強符號
__attribute__((weak)) int weak_attr = 2; // 弱符號
int main()
{
//代碼
return 0;
}
在默認的符號類型情況下,強符號和弱符號是可以共存的,類似于這樣:
/* 源文件test.c */
#include< stdio.h >
int year; /* 弱符號 */
int year = 2023; /* 強符號 */
int main()
{
printf("Current year is %d\\n",year);
return 0;
}
gcc編譯執行輸出結果如下:
[root@localhost 119]# gcc -o test test.c
[root@localhost 119]# ./test
Current year is 2023
編譯不會報錯,在編譯時year的取值將會是2023。
這里我們回到本文最初的例子,我們將源文件weak_attr.c中的year=2022使用__attribute___((weak)修飾,則會將源文件weak_attr.c中的year=2022由強符號轉換為弱符號,此時,程序編譯鏈接則不會報錯,源文件以及編譯鏈接和執行情況如下:
/* 頭文件date.h */
#ifndef __DATE_H__
#define __DATE_H__
void currentYear();
#endif
/* 源文件date.c */
#include < stdio.h >
#include "date.h"
int year=2023;
void currentYear()
{
printf("This year is %d.\\n", year);
}
/* 源文件weak_attr.c */
#include < stdio.h >
#include "date.h"
int __attribute__((weak)) year=2022;
int main()
{
printf("The value of year is : %d\\n.",year);
currentYear();
return 0;
}
gcc編譯輸出結果如下:
[root@localhost 119]# gcc -o weak_attr date.c weak_attr.c -g
[root@localhost 119]#
[root@localhost 119]# ./weak_attr
The value of year is : 2023
.This year is 2023.
由此可見,當不同源文件中存在定義同名變量的情況下,要想編譯不報錯,則可根據具體場景將強符號轉換為弱符號,雖然這樣可能沒有太大意義,并且容易引發問題,但是也不失為一種解決辦法。
但是使用__attribute__((weak))將強符號轉換為弱符號時,卻不能在同一個文件中同時存在同名的強符號,類似于這樣:
/* 源文件test.c */
#include< stdio.h >
int __attribute__((weak)) year = 2022;
int year=2023;
int main()
{
printf("Current year is %d\\n",year);
return 0;
}
編譯器將報重復定義錯誤。
[root@localhost 119]# gcc -o test test.c
test.c:4:5: error: redefinition of ‘year’
int year = 2023;
^~~~
test.c:3:27: note: previous definition of ‘year’ was here
int __attribute__((weak)) year = 2022;
編程時,通常容易被忽略或者錯誤認識的一個點是: 全局變量不進行初始化,編譯器在編譯時會自動初始化為0 。如下即為編譯器在編譯期間自動為未初始化的全局變量初始化為0的案例:
[root@localhost 119]# gcc -o test test.c -g
[root@localhost 119]# gdb ./test
GNU gdb (GDB) Red Hat Enterprise Linux 8.2-19.el8
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later < http://gnu.org/licenses/gpl.html >
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
< http://www.gnu.org/software/gdb/bugs/ >.
Find the GDB manual and other documentation resources online at:
< http://www.gnu.org/software/gdb/documentation/ >.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./test...done.
(gdb) list
1 #include< stdio.h >
2
3 //int __attribute__((weak)) year = 2022;
4 int year;
5
6 int main()
7 {
8 printf("Current year is %d\\n",year);
9 return 0;
10 }
(gdb)
Line number 11 out of range; test.c has 10 lines.
(gdb) print year
$1 = 0
(gdb)
大部分人都認為 C 程序中的未初始化全局變量會在程序編譯的期間被默認初始化為 0,因此不需要在程序中執行初始化操作。這個觀點既正確又不完全正確。此話怎講,因為該觀點是有前提條件的,即 該全局變量在項目工程內全局唯一時,則編譯器在編譯時會自動將該全局變量初始化為0 。否則,一旦該全局變量在項目工程內不唯一,且在另一個文件內有已被初始化的另一同名全局變量時,則該變量的值為被初始化的全局變量的值,而非0。
請看如下案例,一個全局變量year在文件weak_attr.c中被定義并初始化為2023,而在文件date.c中被定義但沒有初始化,通過上文的討論可以知道,這并不會報錯,此時date.c文件中的全局變量year(弱符號)被覆蓋,但是它的值并不會是預想中的被初始化為 0,而是weak_attr.c中初始化的值,這種情況下就可能造成一些問題。
/* 源文件date.c */
#include < stdio.h >
#include "date.h"
int year=2023;
void currentYear()
{
printf("This year is %d.\\n", year);
}
/* 源文件weak_attr.c */
#include < stdio.h >
#include "date.h"
int year;
int main()
{
printf("The value of year is : %d\\n.",year);
currentYear();
return 0;
}
gcc編譯調試輸出如下:
[root@localhost 119]# gdb ./weak_attr
GNU gdb (GDB) Red Hat Enterprise Linux 8.2-19.el8
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later < http://gnu.org/licenses/gpl.html >
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
< http://www.gnu.org/software/gdb/bugs/ >.
Find the GDB manual and other documentation resources online at:
< http://www.gnu.org/software/gdb/documentation/ >.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./weak_attr...done.
(gdb) l
1 #include < stdio.h >
2 #include "date.h"
3
4 int year;
5
6 int main()
7 {
8 printf("The value of year is : %d\\n.",year);
9 currentYear();
10 return 0;
(gdb)
11 }
(gdb) print year
$1 = 2023
(gdb)
執行程序輸出結果如下:
[root@localhost 119]# ./weak_attr
The value of year is : 2023
.This year is 2023.
從上述結果可看出,year的值被初始化為2023,而并非為0。
當然,這并一定就說明所有全局變量在定義時就應該初始化為 0,畢竟未初始化的全局變量被放置在 bss 段,對于某些數據結構將會節省大量空間,這是有意義的。只是我們在思考是否需要對全局變量進行初始化的時候需要將上面可能出現的問題考慮進去,根據實際的場景選擇合適的方案。
三、函數的強符號和弱符號
鏈接器對于同名變量沖突的處理遵循上面的強弱規則,對于同名函數的沖突,也遵循相同的規則。函數名本身就是一個強符號,在一個工程中定義兩個同名的函數,編譯時肯定會報重定義錯誤。但我們可以通過 weak 屬性聲明,將其中一個函數轉換為弱符號。
/* function.c */
int num __attribute__((weak)) = 1;
void __attribute__((weak)) func(void)
{
printf("func:num = %d\\n", num);
}
/* main.c */
#include < stdio.h >
int num = 4;
void func(void)
{
printf("I am a strong symbol!\\n");
}
int main(void)
{
printf("main:num = %d\\n", num);
func();
return 0;
}
編譯程序,可以看到程序運行結果如下:
[root@localhost 130]# gcc -o main main.c function.c
[root@localhost 130]#
[root@localhost 130]# ./main
main:num = 4
I am a strong symbol!
在這個程序示例中,我們在 main.c 中重新定義了一個同名的 func 函數,然后將 function.c 文件中的 func() 函數,通過 weak 屬性聲明轉換為一個弱符號。鏈接器在鏈接時會選擇 main.c 中的強符號,所以我們在 main 函數中調用 func() 時,實際上調用的是 main.c 文件里的 func() 函數。
四、弱符號的作用
在一個源文件中引用一個變量或函數,當我們僅聲明而未定義時,一般編譯是可以通過的。因為編譯是以文件為單位的,編譯器會將一個個源文件首先編譯為 .o 目標文件。編譯器只要能看到函數或變量的聲明,就會認為這個變量或函數的定義可能會在其它的文件中,所以不會報錯。甚至如果你沒有包含頭文件,連個聲明也沒有,編譯器也不會報錯,頂多就是給你一個警告信息。但鏈接階段是要報錯的,鏈接器在各個目標文件、庫中都找不到這個變量或函數的定義,一般就會報未定義錯誤。
當函數被聲明為一個弱符號時,會有一個特別的地方: 當鏈接器找不到這個函數的定義時,也不會報錯 。編譯器會將這個函數名,即弱符號,設置為0或一個特殊的值。只有當程序運行時,調用到這個函數,跳轉到0地址或一個特殊的地址才會報錯。
/* function.c */
int num __attribute__((weak)) = 1;
/* main.c */
int num = 5;
void __attribute__((weak)) func(void);
int main(void)
{
printf("main:num = %d\\n", num);
func();
return 0;
}
編譯程序,可以看到程序運行結果如下:
[root@localhost 130]# gcc -o main main.c function.c
[root@localhost 130]#
[root@localhost 130]# ./main
main:num = 5
Segmentation fault (core dumped)
在這個示例程序中,我們沒有定義 func() 函數,僅僅是在 main.c 里作了一個聲明,并將其聲明為一個弱符號。編譯這個工程,你會發現是可以編譯通過的,只是到了程序運行時才會出錯。
為防止函數執行出錯,可以在執行函數之前,先做一個判斷,即判斷函數名的地址是不是0,再決定是否調用、運行,如果是0,則不進行調用。這樣就可以避免段錯誤了,示例代碼如下:
/* function.c */
int a __attribute__((weak)) = 1;
/* main.c */
#include < stdio.h >
int num = 5;
void __attribute__((weak)) func(void);
int main(void)
{
printf("main:num = %d\\n", num);
if(func)
{
func();
}
return 0;
}
編譯程序,可以看到程序運行結果如下:
[root@localhost 130]# gcc -o main main.c function.c
[root@localhost 130]# ./main
main:num = 5
實際上函數名的本質就是一個地址,在調用 func 之前,我們先判斷其是否為0,為0的話就不調用該函數,直接跳過。通過這樣的設計,即使這個 func() 函數沒有定義,我們整個工程也能正常的編譯、鏈接和運行。
弱符號的這個特性,在庫函數中應用很廣泛。比如你在開發一個庫,基礎的功能已經實現,有些高級的功能還沒實現,那你可以將這些函數通過 weak 屬性聲明,轉換為一個弱符號。通過這樣設置,即使函數還沒有定義,我們在應用程序中只要做一個非0的判斷就可以了,并不影響我們程序的運行。等以后你發布新的庫版本,實現了這些高級功能,應用程序也不需要任何修改,直接運行就可以調用這些高級功能。
弱符號還有一個好處,如果我們對庫函數的實現不滿意,我們可以自定義與庫函數同名的函數,實現更好的功能。比如我們 C 標準庫中定義的 gets() 函數,就存在漏洞,常常成為黑客堆棧溢出攻擊的靶子。
int main(void)
{
char a[5];
gets(a);
puts(a);
return 0;
}
編譯時會出現一個warning,建議我們不要使用gets函數了。
[root@localhost 130]# gcc -o test test.c
test.c: In function ‘main’:
test.c:7:4: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
gets(a);
^~~~
fgets
/tmp/cckNkApE.o: In function `main':
test.c:(.text+0x15): warning: the `gets' function is dangerous and should not be used.
我們暫時不管他,先直接運行看結果:
[root@localhost 130]# ./test
hello,my name is localhost, nice to meet you
hello,my name is localhost, nice to meet you
Segmentation fault (core dumped)
C 標準定義的庫函數 gets() 主要用于輸入字符串,它的一個bug就是使用回車符來判斷用戶輸入結束標志。這樣的設計很容易造成堆棧溢出。比如上面的程序,我們定義一個長度為5的字符數組用來存儲用戶輸入的字符串,當我們輸入一個長度大于5的字符串時,就會發生內存錯誤。
接著我們定義一個跟 gets() 相同類型的同名函數,并在 main 函數中直接調用,代碼如下。
/* test.c */
#include < stdio.h >
char * gets (char * str)
{
printf("my custom function!\\n");
return (char *)0;
}
int main(void)
{
char a[5];
gets(a);
puts(a);
return 0;
}
編譯運行,程序執行結果如下:
[root@localhost 130]# gcc -o test test.c
[root@localhost 130]# ./test
my custom function!
通過運行結果,我們可以看到,雖然我們定義了跟 C 標準庫函數同名的 gets() 函數,但編譯是可以通過的。程序運行時調用 gets() 函數時,就會跳轉到我們自定義的 gets() 函數中運行,從而實現了漏洞攻擊。
評論
查看更多