
作為一名程序員,如果說沉迷一門編程語言算作一種樂趣的話,那么與此同時反過來去黑一門編程語言就是這種樂趣的升華。今天我們就來黑一把C語言,好好展示一下這門經典語言令人抓狂的一面。
對于計算機來說,它可能是具有地址的一塊內存(memory)。
從空間分配上看,定義且初始化的全局變量在編譯時在數據段(.data)分配空間,定義但未初始化的全局變量**暫存(tentative definition)**在.bss段,編譯時自動清零,而僅僅是聲明的全局變量只能算個符號,寄存在編譯器的符號表內,不會分配空間,直到鏈接或者運行時再重定向到相應的地址上。
?
/*?t.h?*/
#ifndef?_H_
#define?_H_
int?a;
#endif
/*?foo.c?*/
#include?
#include?"t.h"
struct?{
???char?a;
???int?b;
}?b?=?{?2,?4?};
int?main();
void?foo()
{
????printf("foo:\t(&a)=0x%08x\n\t(&b)=0x%08x\n
????????\tsizeof(b)=%d\n\tb.a=%d\n\tb.b=%d\n\tmain:0x%08x\n",
????????&a,?&b,?sizeof?b,?b.a,?b.b,?main);
}
/*?main.c?*/
#include?
#include?"t.h"
int?b;
int?c;
int?main()
{
????foo();
????printf("main:\t(&a)=0x%08x\n\t(&b)=0x%08x\n
????????\t(&c)=0x%08x\n\tsize(b)=%d\n\tb=%d\n\tc=%d\n",
????????&a,?&b,?&c,?sizeof?b,?b,?c);
????return?0;
}Makefile 如下:
test:?main.o?foo.o
??gcc?-o?test?main.o?foo.o
main.o:?main.c
foo.o:?foo.c
clean:
??rm?*.o?test運行情況:
foo:??(&a)=0x0804a024
??(&b)=0x0804a014
??sizeof(b)=8
??b.a=2
??b.b=4
??main:0x080483e4
main:??(&a)=0x0804a024
??(&b)=0x0804a014
??(&c)=0x0804a028
??size(b)=4
??b=2
??c=0這個項目里我們定義了四個全局變量,t.h頭文件定義了一個整型a,main.c里定義了兩個整型b和c并且未初始化,foo.c里定義了一個初始化了的結構體,還定義了一個main的函數指針變量。
由于C語言每個源文件單獨編譯,所以t.h分別包含了兩次,所以int a就被定義了兩次。兩個源文件里變量b和函數指針變量main被重復定義了,實際上可以看做代碼段的地址。但編譯器并未報錯,只給出一條警告:
/usr/bin/ld:?Warning:?size?of?symbol?'b'?changed?from?4?in?main.o?to?8?in?foo.o運行程序發現,main.c打印中b大小是4個字節,而foo.c是8個字節,因為sizeof關鍵字是編譯時決議,而源文件中對b類型定義不一樣。
但令人驚奇的是無論是在main.c還是foo.c中,a和b都是相同的地址,也就是說,a和b被定義了兩次,b還是不同類型,但內存映像中只有一份拷貝。
我們還看到,main.c中b的值居然就是foo.c中結構體第一個成員變量b.a的值,這證實了前面的推斷——**即便存在多次定義,內存中只有一份初始化的拷貝。**另外在這里c是置身事外的一個獨立變量。
為何會這樣呢?這涉及到C編譯器對多重定義的全局符號的解析和鏈接。
如果有多個弱符號,那么先決議到size最大的那個,如果同樣大小,則按照鏈接順序選擇第一個。
像上面這個例子中,全局變量a和b存在重復定義。如果我們將main.c中的b初始化賦值,那么就存在兩個強符號而違反了規則一,編譯器報錯。
如果滿足規則二,則僅僅提出警告,實際運行時決議的是foo.c中的強符號。而變量a都是弱符號,所以只選擇一個(按照目標文件鏈接時的順序)。
?
/*?foo.c?*/
#include? ;
struct?{
????int?a;
????int?b;
}?b?=?{?2,?4?};
int?main();
void?foo()
{
????printf("foo:\t(&b)=0x%08x\n\tsizeof(b)=%d\n
????????\tb.a=%d\n\tb.b=%d\n\tmain:0x%08x\n",
????????&b,?sizeof?b,?b.a,?b.b,?main);
}
/*?main.c?*/
#include?
int?b;
int?c;
int?main()
{
????if?(0?==?fork())?{
????????sleep(1);
????????b?=?1;
????????printf("child:\tsleep(1)\n\t(&b):0x%08x\n
????????????\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tset?b=%d\n\tc=%d\n",
????????????&b,?&c,?sizeof?b,?b,?c);
????????foo();
????}?else?{
????????foo();
????????printf("parent:\t(&b)=0x%08x\n\t(&c)=0x%08x\n
????????????\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n\twait?child...\n",
????????????&b,?&c,?sizeof?b,?b,?c);
????????wait(-1);
????????printf("parent:\tchild?over\n\t(&b)=0x%08x\n
????????????\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n",
????????????&b,?&c,?sizeof?b,?b,?c);
????}
????return?0;
}運行情況如下:
foo:??(&b)=0x0804a020
??sizeof(b)=8
??b.a=2
??b.b=4
??main:0x080484c8
parent:??(&b)=0x0804a020
??(&c)=0x0804a034
??sizeof(b)=4
??b=2
??c=0
??wait?child...
child:??sleep(1)
??(&b):0x0804a020
??(&c)=0x0804a034
??sizeof(b)=4
??set?b=1
??c=0
foo:??(&b)=0x0804a020
??sizeof(b)=8
??b.a=1
??b.b=4
??main:0x080484c8
parent:??child?over
??(&b)=0x0804a020
??(&c)=0x0804a034
??sizeof(b)=4
??b=2
??c=0(說明一點,運行情況是直接輸出到stdout的打印,筆者曾經將./test輸出重定向到log中,結果發現打印的執行序列不一致,所以采用默認輸出。)
這是一個多進程環境,首先我們看到無論父進程還是子進程,main.c還是foo.c,全局變量b和c的地址仍然是一致的(當然只是個邏輯地址),而且對b的大小不同模塊仍然有不同的決議。
這里值得注意的是,我們在子進程中對變量b進行賦值動作,從此子進程本身包括foo()調用中,整型b以及結構體成員b.a的值都是1,而父進程中整型b和結構體成員b.a的值仍是2,但它們顯示的邏輯地址仍是一致的。
個人認為可以這樣解釋,fork創建新進程時,子進程獲得了父進程上下文“鏡像”(自然包括全局變量),虛擬地址相同但屬于不同的進程空間,而且此時真正映射的物理地址中只有一份拷貝,所以b的值是相同的(都是2)。
隨后子進程對b改寫,觸發了操作系統的**寫時拷貝(copy on write)**機制,這時物理內存中才產生真正的兩份拷貝,分別映射到不同進程空間的虛擬地址上,但虛擬地址的值本身仍然不變,這對于應用程序來說是透明的,具有隱瞞性。
?
test:?main.o?foo.o
??ar?rcs?libfoo.a?foo.o
??gcc?-static?-o?test?main.o?libfoo.a
main.o:?main.c
foo.o:?foo.c
clean:
??rm?-f?*.o?test運行情況如下:
foo:??(&b)=0x080ca008
??sizeof(b)=8
??b.a=2
??b.b=4
??main:0x08048250
parent:??(&b)=0x080ca008
??(&c)=0x080cc084
??sizeof(b)=4
??b=2
??c=0
??wait?child...
child:??sleep(1)
??(&b):0x080ca008
??(&c)=0x080cc084
??sizeof(b)=4
??set?b=1
??c=0
foo:??(&b)=0x080ca008
??sizeof(b)=8
??b.a=1
??b.b=4
??main:0x08048250
parent:??child?over
??(&b)=0x080ca008
??(&c)=0x080cc084
??sizeof(b)=4
??b=2
??c=0從這個例子看不出有啥差別,只不過使用靜態鏈接后,全局變量加載的地址有所改變,b和c的地址之間似乎相隔更遠了些。不過這次編譯器倒是給出了變量b的sizeof決議警告。
到此為止,有些人可能會對上面的例子嗤之以鼻,覺得這不過是列舉了C語言的某些特性而已,算不上黑。
有些人認為既然如此,對于一切全局變量要么用static限死,要么定義同時初始化,杜絕弱符號,以便在編譯時報錯檢測出來。只要小心地使用,C語言還是很完美的嘛~
?
/*?foo.c?*/
#include?
const?struct?{
????int?a;
????int?b;
}?b?=?{?3,?3?};
int?main();
void?foo()
{
????b.a?=?4;
????b.b?=?4;
????printf("foo:\t(&b)=0x%08x\n\tsizeof(b)=%d\n
????????\tb.a=%d\n\tb.b=%d\n\tmain:0x%08x\n",
????????&b,?sizeof?b,?b.a,?b.b,?main);
}
/*?t1.c?*/
#include?
int?b?=?1;
int?c?=?1;
int?main()
{
????int?count?=?5;
????while?(count--?>?0)?{
????????t2();
????????foo();
????????printf("t1:\t(&b)=0x%08x\n\t(&c)=0x%08x\n
????????????\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n",
????????????&b,?&c,?sizeof?b,?b,?c);
????????sleep(1);
????}
????return?0;
}
/*?t2.c?*/
#include?
int?b;
int?c;
int?t2()
{
????printf("t2:\t(&b)=0x%08x\n\t(&c)=0x%08x\n
????????\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n",
????????&b,?&c,?sizeof?b,?b,?c);
????return?0;
}Makefile腳本:
export?LD_LIBRARY_PATH:=.
all:?test
??./test
test:?t1.o?t2.o
??gcc?-shared?-fPIC?-o?libfoo.so?foo.c
??gcc?-o?test?t1.o?t2.o?-L.?-lfoo
t1.o:?t1.c
t2.o:?t2.c
.PHONY:clean
clean:
??rm?-f?*.o?*.so?test*執行結果:
./test
t2:??(&b)=0x0804a01c
??(&c)=0x0804a020
??sizeof(b)=4
??b=1
??c=1
foo:??(&b)=0x0804a01c
??sizeof(b)=8
??b.a=4
??b.b=4
??main:0x08048564
t1:??(&b)=0x0804a01c
??(&c)=0x0804a020
??sizeof(b)=4
??b=4
??c=4
t2:??(&b)=0x0804a01c
??(&c)=0x0804a020
??sizeof(b)=4
??b=4
??c=4
foo:??(&b)=0x0804a01c
??sizeof(b)=8
??b.a=4
??b.b=4
??main:0x08048564
t1:??(&b)=0x0804a01c
??(&c)=0x0804a020
??sizeof(b)=4
??b=4
??c=4
??...其實前面幾個例子只是開胃小菜而已,真正的大坑終于出現了!而且這次編譯器既沒報錯也沒警告,但我們確實眼睜睜地看到作為main()中強符號的b被改寫了,而且一旁的c也“躺槍”了。
眼尖的讀者發現,這次foo.c是作為動態鏈接庫運行時加載的,當t1第一次調用t2時,libfoo.so還未加載,一旦調用了foo函數,b立馬中彈,而且c的地址居然還相鄰著b,這使得c一同中彈了。
不過筆者有些無法解釋這種行為的原因,有種說法是強符號的全局變量在數據段中是連續分布的(相應的弱符號暫存在.bss段或者符號表里),或許可以上報GNU的編譯器開發小組。
另外筆者嘗試過將t1.c中的b和c定義前面加上const限定詞,編譯器仍然默認通過,但程序在main()中第一次調用foo()時觸發了Segment fault異常導致崩潰,在foo.c里使用指針改寫它也一樣。
推斷這是GCC對const常量所在地址啟用了類似操作系統寫保護機制,但我無法確定早期版本的GCC是否會讓這個const常量被改寫而程序不會崩潰。
至于volatile關鍵詞之于全局變量,自測似乎沒有影響。
怎么樣?看了最后一個例子是否有點“不明覺厲”呢?
C語言在你心目中是否還是當初那個“純潔”、“干凈”、“行為一致”的姑娘呢?也許趁著你不注意的時候她會偷偷給你戴頂綠帽,這一切都是通過全局變量,特別在動態鏈接的環境下,就算全部定義成強符號仍然無法為編譯器所察覺。
而一些IT界“恐怖分子”也經常**將惡意代碼包裝成全局變量注入到root權限下存在漏洞的操作序列中,**就像著名的棧溢出攻擊那樣。某一天當你傻傻地看著一個程序出現未定義的行為卻無法定位原因的時候,請不要忘記Richie大爺那來自九泉之下最深沉的“問候”~
或許有些人會偷換概念,把這一切歸咎于編譯器和鏈接器身上,認為這同語言無關,但我要提醒你,正是編譯/鏈接器的行為支撐了整個語言的語法和語義。
你可以反過來思考一下為何C的胞弟C++推出**“命名空間(namespace)”**的概念,或者你可以使用其它高級語言,對于重定義的全局變量是否能通過編譯這一關。
所以請時刻謹記,C是一門很恐怖的語言!
本文整理自網絡,版權歸原作者所有,如有侵權,請聯系刪除。

往期推薦
來都來了,點個?在看?再走吧~~~
