1. 程式人生 > >C語言全域性變數那些事兒(深入C中最隱祕的地帶)

C語言全域性變數那些事兒(深入C中最隱祕的地帶)

【雖然自認為對C的角角落落都有所瞭解,但直到看到這篇文章,才知道C中的一些隱祕的坑,是自己之前不知道的。

關於全域性變數的連結問題,之前在我部落格的某文章中有介紹過,一般對C有些瞭解的程式設計師都知道這個問題,但本文最後所提到的使用動態連結庫所出現的問題,著實讓我震驚。

(心急的朋友,可跳過前面的,直接讀“第4個例子”)

所以,以後大家在使用全域性變數的時候要留點心,良好的命名規範,在一定程度上可以避免文中所提到的隱祕Bug。

文章略長,請大家耐心讀完,一定會有收穫的! 】

以下為轉載內容(額,為了能暫時放到顯示,讓更多人看到,把文章型別設為了“原創”,自打30大板。):原文連結:http://coolshell.cn/articles/10115.html

------------------------------------------------割------------------------------------------------

作為一名程式設計師,如果說沉迷一門程式語言算作一種樂趣的話,那麼與此同時反過來去黑一門程式語言就是這種樂趣的昇華。今天我們就來黑一把C語言,好好展示一下這門經典語言令人抓狂的一面。

我們知道,全域性變數是C語言語法和語義中一個很重要的知識點,首先它的存在意義需要從三個不同角度去理解:對於程式設計師來說,它是一個記錄內容的變數(variable);對於編譯/連結器來說,它是一個需要解析的符號(symbol)

;對於計算機來說,它可能是具有地址的一塊記憶體(memory)。其次是語法/語義:從作用域上看,帶static關鍵字的全域性變數範圍只能限定在檔案裡,否則會外聯到整個模組和專案中;從生存期來看,它是靜態的,貫穿整個程式或模組執行期間(注意,正是跨單元訪問和持續生存週期這兩個特點使得全域性變數往往成為一段受攻擊程式碼的突破口,瞭解這一點十分重要);從空間分配上看,定義且初始化的全域性變數在編譯時在資料段(.data)分配空間,定義但未初始化的全域性變數暫存(tentative definition)在.bss段,編譯時自動清零,而僅僅是宣告的全域性變數只能算個符號,寄存在編譯器的符號表內,不會分配空間,直到連結或者執行時再重定向到相應的地址上。

我們將向您展現一下,非static限定全域性變數在編譯/連結以及程式執行時會發生哪些有趣的事情,順便可以對C編譯器/連結器的解析原理管中窺豹。以下示例對ANSI C和GNU C標準都有效,筆者的編譯環境是Ubuntu下的GCC-4.4.3。

第一個例子

/* t.h */
#ifndef _H_
#define _H_
int a;
#endif
 
/* foo.c */
#include <stdio.h>
#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 <stdio.h>
#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-otestmain.o foo.o main.o: main.c foo.o: foo.c clean: rm*.otest 執行情況: 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被重複定義了,實際上可以看做程式碼段的地址。但編譯器並未報錯,只給出一條警告:

1 /usr/bin/ld: Warning: size of symbol'b'changed from 4 inmain.o to 8 infoo.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編譯器對多重定義的全域性符號的解析和連結。在編譯階段,編譯器將全域性符號資訊隱含地編碼在可重定位目標檔案的符號表裡。這裡有個“強符號(strong)”“弱符號(weak)”的概念——前者指的是定義並且初始化了的變數,比如foo.c裡的結構體b,後者指的是未定義或者定義但未初始化的變數,比如main.c裡的整型b和c,還有兩個原始檔都包含標頭檔案裡的a。當符號被多重定義時,GNU連結器(ld)使用以下規則決議:

  • 不允許出現多個相同強符號。
  • 如果有一個強符號和多個弱符號,則選擇強符號。
  • 如果有多個弱符號,那麼先決議到size最大的那個,如果同樣大小,則按照連結順序選擇第一個。

像上面這個例子中,全域性變數a和b存在重複定義。如果我們將main.c中的b初始化賦值,那麼就存在兩個強符號而違反了規則一,編譯器報錯。如果滿足規則二,則僅僅提出警告,實際執行時決議的是foo.c中的強符號。而變數a都是弱符號,所以只選擇一個(按照目標檔案連結時的順序)。

事實上,這種規則是C語言裡的一個大坑,編譯器對這種全域性變數多重定義的“縱容”很可能會無端修改某個變數,導致程式不確定行為。如果你還沒有意識到事態嚴重性,我再舉個例子。

第二個例子

/* foo.c */
#include <stdio.h>;
 
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 <stdio.h>
 
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 setb=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)機制,這時實體記憶體中才產生真正的兩份拷貝,分別對映到不同程序空間的虛擬地址上,但虛擬地址的值本身仍然不變,這對於應用程式來說是透明的,具有隱瞞性。

還有一點值得注意,這個示例編譯時沒有出現第一個示例的警告,即對變數b的sizeof決議,筆者也不知道為什麼,或許是GCC的一個bug?

第三個例子

這個例子程式碼同上一個一致,只不過我們將foo.c做成一個靜態連結庫libfoo.a進行連結,這裡只給出Makefile的改動。
test: main.o foo.o ar rcs libfoo.a foo.o gcc-static -otestmain.o libfoo.a main.o: main.c foo.o: foo.c clean: rm-f *.otest
執行情況如下:
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 setb=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語言還是很完美的嘛~對於抱這樣想法的人,我只想說,請你在夜深人靜的時候豎起耳朵仔細聆聽,你很可能聽到Dennis Richie在九泉之下邪惡的笑聲——不,與其說是嘲笑,不如說是詛咒……

第四個例子

/* foo.c */
#include <stdio.h>
 
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 <stdio.h>
 
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 <stdio.h>
 
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指令碼:
exportLD_LIBRARY_PATH:=. all:test ./test test: t1.o t2.o gcc-shared -fPIC -o libfoo.so foo.c gcc-otestt1.o t2.o -L. -lfoo t1.o: t1.c t2.o: t2.c .PHONY:clean clean: rm-f *.o *.sotest*

執行結果:

./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是一門很恐怖的語言!