查看Linux內(nèi)核代碼時,經(jīng)常能看到一些編譯器選項如__attribute__((weak),起初不太了解,經(jīng)過查資料,算是對gcc的這個編譯屬性有了初步的認(rèn)識,現(xiàn)在總結(jié)如下。
編譯器在編譯源程序時,無論你是變量名、函數(shù)名,在它眼里,都是一個符號而已,用來表示一個地址。編譯器會將這些符號集中存放到一個叫符號表的 section 中。
程序編譯鏈接的基本過程總結(jié)起來其實很簡單,大概分為如下三個階段:
- 編譯階段:編譯器以源文件為單位,將每一個源文件編譯為一個.o后綴的目標(biāo)文件。每一個目標(biāo)文件由代碼段、數(shù)據(jù)段、符號表等組成。
- 鏈接階段:鏈接器將各個目標(biāo)文件組裝成一個大目標(biāo)文件。鏈接器將各個目標(biāo)文件中的代碼段組裝在一起,組成一個大的代碼段;各個數(shù)據(jù)段組裝在一起,組成一個大的數(shù)據(jù)段;各個符號表也會集中在一起,組成一個大的符號表。最后再將合并后的代碼段、數(shù)據(jù)段、符號表等組合成一個大的目標(biāo)文件。
- 重定位:由于鏈接階段各個目標(biāo)文件重新組裝,各個目標(biāo)文件中的變量和函數(shù)的地址都發(fā)生了變化,所以要重新修正這些函數(shù)及變量的地址,這個過程稱為重定位。重定位結(jié)束后,才生成了可以在機器上運行的可執(zhí)行程序。
一、weak屬性
attribute ((weak))表示為弱符號屬性,所謂的弱符號是針對于強符號來說的,我們定義的全局已初始化變量及全局函數(shù)等都是屬于強符號,在鏈接時如果有多個強符號就會報錯誤;而弱符號主要指未初始化的全局變量或通過__attribute__((weak))來顯式聲明的變量或函數(shù)。
在日常編程過程中,我們可能會碰到一種符號重復(fù)定義的情況。如果多個目標(biāo)文件中含有相同名字的全局變量的定義,那么這些目標(biāo)文件鏈接的時候就會出現(xiàn)符號重復(fù)定義的錯誤。比如在源文件date.c 和源文件weak_attr.c都定義了一個全局整型變量year,并且均已初始化,那么當(dāng)date.c和weak_attr.c鏈接時會報錯:
multiple definition of 'xxx'
重復(fù)定義的源碼文件如下:
/* 頭文件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編譯輸出結(jié)果如下:
[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
那么該如何解決這個問題呢?讓我們繼續(xù)往下看,下文會給出解決之道。
二、強符號弱符號
在程序中,無論是變量名,還是函數(shù)名,在編譯器的眼里,都只是一個符號而已。符號可以分為強符號和弱符號。
- 強符號:函數(shù)名、初始化的全局變量名;
- 弱符號:未初始化的全局變量名、 attribute _((weak)修飾的變量或函數(shù)
強符號和弱符號在解決程序編譯鏈接過程中,出現(xiàn)的多個同名變量、函數(shù)的沖突問題非常有用。一般我們遵循下面三個規(guī)則:
- 一山不容二虎
- 強弱可以共處
- 體積大者為主
上面為方便記憶總結(jié)的3點原則,具體表述如下:
強弱符號總結(jié)規(guī)則如下:
規(guī)則1:不允許強符號被重復(fù)多次定義,但是強弱符號可以共存。
規(guī)則2:如果一個符號在某個目標(biāo)文件中是強符號,但在其他文件中都是弱符號,那么編譯時以強符號的值為準(zhǔn)。
規(guī)則3:如果一個符號在所有的目標(biāo)文件中都是弱符號,那么選擇其中占用空間最大的一個。這個其實很好理解,編譯器不知道編程者的用意,選擇占用空間大的符號至少不會造成諸如溢出、越界等嚴(yán)重后果。
下面我們以一個例子說明強弱符號,如下:
#include < stdio.h >
extern int temp; // 非強符號也非弱符號
int weak; // 弱符號
int strong = 1; // 強符號
__attribute__((weak)) int weak_attr = 2; // 弱符號
int main()
{
//代碼
return 0;
}
在默認(rèn)的符號類型情況下,強符號和弱符號是可以共存的,類似于這樣:
/* 源文件test.c */
#include< stdio.h >
int year; /* 弱符號 */
int year = 2023; /* 強符號 */
int main()
{
printf("Current year is %d\\n",year);
return 0;
}
gcc編譯執(zhí)行輸出結(jié)果如下:
[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由強符號轉(zhuǎn)換為弱符號,此時,程序編譯鏈接則不會報錯,源文件以及編譯鏈接和執(zhí)行情況如下:
/* 頭文件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編譯輸出結(jié)果如下:
[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.
由此可見,當(dāng)不同源文件中存在定義同名變量的情況下,要想編譯不報錯,則可根據(jù)具體場景將強符號轉(zhuǎn)換為弱符號,雖然這樣可能沒有太大意義,并且容易引發(fā)問題,但是也不失為一種解決辦法。
但是使用__attribute__((weak))將強符號轉(zhuǎn)換為弱符號時,卻不能在同一個文件中同時存在同名的強符號,類似于這樣:
/* 源文件test.c */
#include< stdio.h >
int __attribute__((weak)) year = 2022;
int year=2023;
int main()
{
printf("Current year is %d\\n",year);
return 0;
}
編譯器將報重復(fù)定義錯誤。
[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;
編程時,通常容易被忽略或者錯誤認(rèn)識的一個點是: 全局變量不進(jìn)行初始化,編譯器在編譯時會自動初始化為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)
大部分人都認(rèn)為 C 程序中的未初始化全局變量會在程序編譯的期間被默認(rèn)初始化為 0,因此不需要在程序中執(zhí)行初始化操作。這個觀點既正確又不完全正確。此話怎講,因為該觀點是有前提條件的,即 該全局變量在項目工程內(nèi)全局唯一時,則編譯器在編譯時會自動將該全局變量初始化為0 。否則,一旦該全局變量在項目工程內(nèi)不唯一,且在另一個文件內(nèi)有已被初始化的另一同名全局變量時,則該變量的值為被初始化的全局變量的值,而非0。
請看如下案例,一個全局變量year在文件weak_attr.c中被定義并初始化為2023,而在文件date.c中被定義但沒有初始化,通過上文的討論可以知道,這并不會報錯,此時date.c文件中的全局變量year(弱符號)被覆蓋,但是它的值并不會是預(yù)想中的被初始化為 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編譯調(diào)試輸出如下:
[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)
執(zhí)行程序輸出結(jié)果如下:
[root@localhost 119]# ./weak_attr
The value of year is : 2023
.This year is 2023.
從上述結(jié)果可看出,year的值被初始化為2023,而并非為0。
當(dāng)然,這并一定就說明所有全局變量在定義時就應(yīng)該初始化為 0,畢竟未初始化的全局變量被放置在 bss 段,對于某些數(shù)據(jù)結(jié)構(gòu)將會節(jié)省大量空間,這是有意義的。只是我們在思考是否需要對全局變量進(jìn)行初始化的時候需要將上面可能出現(xiàn)的問題考慮進(jìn)去,根據(jù)實際的場景選擇合適的方案。
三、函數(shù)的強符號和弱符號
鏈接器對于同名變量沖突的處理遵循上面的強弱規(guī)則,對于同名函數(shù)的沖突,也遵循相同的規(guī)則。函數(shù)名本身就是一個強符號,在一個工程中定義兩個同名的函數(shù),編譯時肯定會報重定義錯誤。但我們可以通過 weak 屬性聲明,將其中一個函數(shù)轉(zhuǎn)換為弱符號。
/* 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;
}
編譯程序,可以看到程序運行結(jié)果如下:
[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 函數(shù),然后將 function.c 文件中的 func() 函數(shù),通過 weak 屬性聲明轉(zhuǎn)換為一個弱符號。鏈接器在鏈接時會選擇 main.c 中的強符號,所以我們在 main 函數(shù)中調(diào)用 func() 時,實際上調(diào)用的是 main.c 文件里的 func() 函數(shù)。
四、弱符號的作用
在一個源文件中引用一個變量或函數(shù),當(dāng)我們僅聲明而未定義時,一般編譯是可以通過的。因為編譯是以文件為單位的,編譯器會將一個個源文件首先編譯為 .o 目標(biāo)文件。編譯器只要能看到函數(shù)或變量的聲明,就會認(rèn)為這個變量或函數(shù)的定義可能會在其它的文件中,所以不會報錯。甚至如果你沒有包含頭文件,連個聲明也沒有,編譯器也不會報錯,頂多就是給你一個警告信息。但鏈接階段是要報錯的,鏈接器在各個目標(biāo)文件、庫中都找不到這個變量或函數(shù)的定義,一般就會報未定義錯誤。
當(dāng)函數(shù)被聲明為一個弱符號時,會有一個特別的地方: 當(dāng)鏈接器找不到這個函數(shù)的定義時,也不會報錯 。編譯器會將這個函數(shù)名,即弱符號,設(shè)置為0或一個特殊的值。只有當(dāng)程序運行時,調(diào)用到這個函數(shù),跳轉(zhuǎn)到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;
}
編譯程序,可以看到程序運行結(jié)果如下:
[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() 函數(shù),僅僅是在 main.c 里作了一個聲明,并將其聲明為一個弱符號。編譯這個工程,你會發(fā)現(xiàn)是可以編譯通過的,只是到了程序運行時才會出錯。
為防止函數(shù)執(zhí)行出錯,可以在執(zhí)行函數(shù)之前,先做一個判斷,即判斷函數(shù)名的地址是不是0,再決定是否調(diào)用、運行,如果是0,則不進(jìn)行調(diào)用。這樣就可以避免段錯誤了,示例代碼如下:
/* 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;
}
編譯程序,可以看到程序運行結(jié)果如下:
[root@localhost 130]# gcc -o main main.c function.c
[root@localhost 130]# ./main
main:num = 5
實際上函數(shù)名的本質(zhì)就是一個地址,在調(diào)用 func 之前,我們先判斷其是否為0,為0的話就不調(diào)用該函數(shù),直接跳過。通過這樣的設(shè)計,即使這個 func() 函數(shù)沒有定義,我們整個工程也能正常的編譯、鏈接和運行。
弱符號的這個特性,在庫函數(shù)中應(yīng)用很廣泛。比如你在開發(fā)一個庫,基礎(chǔ)的功能已經(jīng)實現(xiàn),有些高級的功能還沒實現(xiàn),那你可以將這些函數(shù)通過 weak 屬性聲明,轉(zhuǎn)換為一個弱符號。通過這樣設(shè)置,即使函數(shù)還沒有定義,我們在應(yīng)用程序中只要做一個非0的判斷就可以了,并不影響我們程序的運行。等以后你發(fā)布新的庫版本,實現(xiàn)了這些高級功能,應(yīng)用程序也不需要任何修改,直接運行就可以調(diào)用這些高級功能。
弱符號還有一個好處,如果我們對庫函數(shù)的實現(xiàn)不滿意,我們可以自定義與庫函數(shù)同名的函數(shù),實現(xiàn)更好的功能。比如我們 C 標(biāo)準(zhǔn)庫中定義的 gets() 函數(shù),就存在漏洞,常常成為黑客堆棧溢出攻擊的靶子。
int main(void)
{
char a[5];
gets(a);
puts(a);
return 0;
}
編譯時會出現(xiàn)一個warning,建議我們不要使用gets函數(shù)了。
[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.
我們暫時不管他,先直接運行看結(jié)果:
[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 標(biāo)準(zhǔn)定義的庫函數(shù) gets() 主要用于輸入字符串,它的一個bug就是使用回車符來判斷用戶輸入結(jié)束標(biāo)志。這樣的設(shè)計很容易造成堆棧溢出。比如上面的程序,我們定義一個長度為5的字符數(shù)組用來存儲用戶輸入的字符串,當(dāng)我們輸入一個長度大于5的字符串時,就會發(fā)生內(nèi)存錯誤。
接著我們定義一個跟 gets() 相同類型的同名函數(shù),并在 main 函數(shù)中直接調(diào)用,代碼如下。
/* 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;
}
編譯運行,程序執(zhí)行結(jié)果如下:
[root@localhost 130]# gcc -o test test.c
[root@localhost 130]# ./test
my custom function!
通過運行結(jié)果,我們可以看到,雖然我們定義了跟 C 標(biāo)準(zhǔn)庫函數(shù)同名的 gets() 函數(shù),但編譯是可以通過的。程序運行時調(diào)用 gets() 函數(shù)時,就會跳轉(zhuǎn)到我們自定義的 gets() 函數(shù)中運行,從而實現(xiàn)了漏洞攻擊。
評論