C語言頭文件中定義全局變量的問題
問題是這麼開始的:
最近在看一個PHP的擴展源碼,編譯的時候的遇到一個問題:
ld: 1 duplicate symbol for architecture x86_64
仔細看了一下源碼,發現在頭文件中 出現了全局變量的定義。
簡化一下後,可以這麼理解:
// t1.h
#ifndef T1_H
#define T1_H
int a = 0;
#endif
//------------------
//t1.c
#include "t1.h"
#include "t2.h"
int main(){
return 0;
}
//-----------------
//t2.h
#include "t1.h"
//empty
//----------------
//t2.c
#include "t2.h"
//empty
//-------
這兩個c文件能否通過編譯?想必有點經驗的必會說 不會,重定義了。
那麼是否真的如此?並不這麼簡單。
•第一個問題,#ifndef 的這個宏是否防止了重定義(redefinition)?
答案:是。但是是在單個translation unit中(wiki translation unit)。
#ifndef 的頭文件宏稱為 include guards的(wiki)
我們知道,一個完整的編譯的過程是經過
one.c --> PREPROCESSOR -> tmp.c(temporary) -> COMPILER -> one.obj -> LINKER -> one.exe
這三個過程的,而在預編譯階段,便會把include的文件展開,我們使用cc -E 命令來查看t1.c的預編譯的結果:
➜ t cc -E t1.c
# 1 "t1.c"
# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 321 "<built-in>" 3
# 1 "<command line>" 1
# 1 "<built-in>" 2
# 1 "t1.c" 2
# 1 "./t1.h" 1
int a = 0;
# 3 "t1.c" 2
# 1 "./t2.h" 1
# 4 "t1.c" 2
int main(void){
return 0;
}
看到編譯器把 t1.h 做了展開,我們看到了 a的定義。
而在t2.c 的預編譯結果裡,我們同樣看到了a的展開定義:
➜ t cc -E t2.c
# 1 "t2.c"
# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 321 "<built-in>" 3
# 1 "<command line>" 1
# 1 "<built-in>" 2
# 1 "t2.c" 2
# 1 "./t2.h" 1
# 1 "./t1.h" 1
int a = 0;
# 2 "./t2.h" 2
# 2 "t2.c" 2
所以到了Link階段,編譯器會看見兩個a的定義。原因在於 include guards 只在同一個translation unit(一個c文件和include的文件的編譯過程)內起作用,兩個編譯單元是編譯過程是分開的,所以無法察覺到另外一個裡面的#ifdefine內容,可以這麼理解:
t1.c -> t1.s -> t2.o
\
*-> - t.otu
/
t2.c -> t2.s -> t2.o
所以,在頭文件中是不應該define 變量,只應該declare。
include guards 是為了防止兩個文件相互引用而造成的循環引用問題。讀者可以試試去除include guards,看看效果。
以上的解答也同時解釋了 為什麼 include guards 沒有在這個例子下起到防止重定義的作用。
那麼,如何強制在頭文件中定義全局變量呢?
正確的做法是頭文件declare,c文件define,老生常談的問題,不再贅述。這裡提供兩個技巧:對於函數,有人給出這麼個辦法,添加inline或者static 關鍵字。
或者有人直接這麼搞:
#ifdef DEFINE_GLOBALS
#define EXTERN
#else
#define EXTERN extern
#endif
EXTERN int global1;
EXTERN int global2;
那麼在頭文件中定義全局變量真的一定是錯誤的嗎?
答案是不一定。
如果我們寫這樣一個c文件:
int a;
int a;
int main(void){
return 0;
}
你肯定認為是重定義了,不過你可以試試 cc ,並不會報錯,甚至沒有warning。
原因其實在於 tentative defination,C99裡的相關定義是
A declaration of an identifier for an object that has file scope without an initializer, and without a storage-class specifier or with the storage-class specifier static, constitutes a tentative definition.If a translation unit contains one or more tentative definitions for an identifier, and the translation unit contains no external definition for that identifier, then the behavior is exactly as if the translation unit contains a file scope declaration of that identifier, with the composite type as of the end of the translation unit, with an initializer equal to 0.
意義是,如果declare了一個變量,但是沒有初始化,在同一個translation unit結束後,還沒有發現初始化,那麼應該把這個變量賦值為0。所以,如果依據C99的規則,你在頭文件中寫入
// t1.h
int a;
仍然會被編譯為int a = 0。所以多次包含,仍然會重定義報錯。
而gcc vc並沒有完全遵循這個標准,C99中最後面還有一段:
Multiple external definitions
There may be more than one external definition for the identifier of an object, with or without the explicit use of the keyword extern; if the definitions disagree, or more than one is initialized, the behavior is undefined (6.9.2).
多麼尴尬的一段話,我們可以理解為gcc 和 vc允許在整個程序編譯過程中的“tentative definition”,而非單一個"translation unit"內。
那麼我們便可以理解之前兩個int a的不會報錯的場景了。gcc vc 視這樣的沒有初始化的變量為extern而非define。
同樣可以理解的是,如果我們添加了初始化值:
int a = 0;
int a = 0;
int main(void){
return 0;
}
則會報錯了:
➜ t cc t1.cpp
t1.cpp:5:5: error: redefinition of 'a'
int a;
^
./t1.h:4:5: note: previous definition is here
int a ;
^
t1.cpp:6:5: error: redefinition of 'a'
int a;
^
./t1.h:4:5: note: previous definition is here
int a ;
^
2 errors generated.
結合tentative definition的定義,便不難理解了。
到這裡,細心的讀者可能發現,我們這裡的tentative definition只局限於C語言,是的。C++並不認可這一概念,把所有的int a; 視為變量定義。所以,如果使用c++,這些又會全部變成 redefinition 或者 duplicate symbol了。
吐槽:一個看似簡單的問題,查閱了一天的資料,引申出這麼多概念,才徹底弄明白,我真的學過C嘛( ⊙ o ⊙ )?