關於c++中用extern引用的變數類型與定義類型不一致的一個疑問?

a.cpp:

#include&
using namespace std;
extern double x;
int main(){
cout&<&<"x is : "&<&

b.cpp:

#include&
using namespace std;
int x = 690;
void p(){
cout&<&<"x is : "&<&

輸出結果(gcc4.9.4):

x is : 3.40905e-321 //說明x值為0

x is : 690 //說明x是b.cpp里的那個x

如果把b.cpp改為int y=690;則編譯將報錯。

如果把a,cpp改為extern int x ;則兩個都輸出690。這個比較好理解

那麼請問出現上面3種不同情況的原因是什麼呢?背後編譯器做了什麼?


這個和C++無關, 這個是鏈接的時候"強符號和弱符號"定義 所定義的符號選擇策略導致的結果, 基本上:

1. 初始化的全局變數和函數名是強符號

2. 其他的都是弱符號(包括你的這個extern)

3. 不能有相同的強符號

4. 如果有一樣的強弱符號, link的時候選強的

5. 如果有一樣的弱符號, link的選擇佔用內存最大的

你這個是命中了4, 但是有個很大的隱患就是double的寬度大於int, 所以如果在a.cpp有對x的修改, 就有可能越界寫...導致未定以的錯誤.

我用c來舉個例子, 比如a.c

#include "stdio.h"

extern long i;
extern void printfi();

int main() {
i = 902348023048239084;
printfi();
return 0;
}

然後b.c

#include "stdio.h"

int i = 690;
int f = 100;

void printfi() {
printf("%d
", i);
printf("%d
", f);
}

在64的環境下, gcc-4.9, 輸出:

26277868
210094271

可見f被不可預期的給修改了..

----後記--------

題主又追問了我一個問題: "請教一下,既然有這種隱患,對於規則4,為什麼鏈接器不報錯呢?這是有什麼特殊的應用場景嗎?"

說實話, 這是個很好的問題, 他要是不問, 我還真的沒有考慮過這個問題.....

以下是我對這個問題的推斷, 有不正確的歡迎指正:

我們把題主的問題換一下: "linker能不能實現在:如果發現一個強符號, 和一個弱符號, 在他們尺寸不匹配的時候, 產生warning".

那麼首先, 我們來看, linker作用的目標是目標文件(.o), 那核心的點就在於, 我們能不能在目標文件中得到extern long i的"類型(佔用內存多少)"的信息呢?

以a.c 為例, gcc -c a.c objdump -x a.o:

SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 a.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g F .text 0000000000000020 main
0000000000000000 *UND* 0000000000000000 i
0000000000000000 *UND* 0000000000000000 printfi

可見, 未定義的符號, 並沒有SIZE信息, 也就說, Linker不知道i到底申明是個什麼類型, 所以....此路不通. (實際上, extern僅僅是給了編譯器一個提示, 這個信息只是用在編譯期)

但是如果我們把a.c中的extern去掉, 再次編譯:

$ gcc -c a.c objdump -x a.o
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 a.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000008 O *COM* 0000000000000008 i
0000000000000000 g F .text 0000000000000020 main
0000000000000000 *UND* 0000000000000000 printfi

可以看到, 此時i是已經定義的符號(弱), 有size信息.

那麼此時link的時候就可以得到警告信息了:

$ gcc -Wl,--warn-common a.c b.c
/tmp/ccdYhCJV.o: warning: definition of `i" overriding common
/tmp/ccY3DiiS.o: warning: common is here
/usr/bin/ld: Warning: alignment 4 of symbol `i" in /tmp/ccdYhCJV.o is smaller than 8 in /tmp/ccY3DiiS.o
/usr/bin/ld: Warning: size of symbol `i" changed from 8 in /tmp/ccY3DiiS.o to 4 in /tmp/ccdYhCJV.o

好吧, 我今天真的是有點閑了.......... :)


事實上這個問題來源C語言。

C編譯器在處理全局變數的聲明時是不檢查變數類型是否一致的,因此這裡的x只是指向同一個地址。事實上,以下代碼可以解釋你的例子,我實用vs2015在x64下得到這個結果。

#include &

using namespace std;

int main()
{
int x=690;
double y=*(double*)(x);
cout&<&

至於為什麼是3.40905e-321 而題主誤認為是0,是因為690對應的二進位序列轉化成double類型是一個denormalized number,不是普通的含有尾數和階碼的normalized number。


下面詳細對比不同編譯器在這種情況下的行為

首先看下題主的問題在win 10下的vs community 2015下的行為:

a.cpp

#include&
using namespace std;
extern double x;
int main() {
cout &<&< "x is : " &<&< x &<&< endl; extern void p(); p(); return 0; }

b.cpp

#include&
using namespace std;
int x = 690;
void p()
{
cout &<&< "x is : " &<&< x &<&< endl; }

說明:在題主原來的a.cpp基礎上把extern p()改為了extern void p(),否則直接提示語法錯誤,編譯會出現這樣的錯誤:

缺少類型說明符 - 假定為 int。注意: C++ 不支持默認 int。

編譯結果(注意加粗的部分):

a.obj : error LNK2001: 無法解析的外部符號 "double x" (?x@@3NA)

fatal error LNK1120: 1 個無法解析的外部命令

這個結果說明vs 給出了正確的編譯行為。

下面看看在Ubuntu 14.04LTS g++ 4.8.4下的行為:

和題主運行情況一樣:

下面看下彙編代碼:

main函數

00000000004008ed &:
4008ed: 55 push %rbp
4008ee: 48 89 e5 mov %rsp,%rbp
4008f1: 53 push %rbx
4008f2: 48 83 ec 18 sub $0x18,%rsp
4008f6: 48 8b 1d 7b 07 20 00 mov 0x20077b(%rip),%rbx # 601078 &
4008fd: be a4 0a 40 00 mov $0x400aa4,%esi
400902: bf 80 10 60 00 mov $0x601080,%edi
400907: e8 c4 fe ff ff callq 4007d0 &<_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt&>
40090c: 48 89 5d e8 mov %rbx,-0x18(%rbp)
400910: f2 0f 10 45 e8 movsd -0x18(%rbp),%xmm0
400915: 48 89 c7 mov %rax,%rdi
400918: e8 43 fe ff ff callq 400760 &<_ZNSolsEd@plt&>
40091d: be f0 07 40 00 mov $0x4007f0,%esi
400922: 48 89 c7 mov %rax,%rdi
400925: e8 b6 fe ff ff callq 4007e0 &<_ZNSolsEPFRSoS_E@plt&>
40092a: e8 5e 00 00 00 callq 40098d &<_Z1pv&>
40092f: b8 00 00 00 00 mov $0x0,%eax
400934: 48 83 c4 18 add $0x18,%rsp
400938: 5b pop %rbx
400939: 5d pop %rbp
40093a: c3 retq

p函數

000000000040098d &<_Z1pv&>:
40098d: 55 push %rbp
40098e: 48 89 e5 mov %rsp,%rbp
400991: 53 push %rbx
400992: 48 83 ec 08 sub $0x8,%rsp
400996: 8b 1d dc 06 20 00 mov 0x2006dc(%rip),%ebx # 601078 &
40099c: be ac 0a 40 00 mov $0x400aac,%esi
4009a1: bf 80 10 60 00 mov $0x601080,%edi
4009a6: e8 25 fe ff ff callq 4007d0 &<_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt&>
4009ab: 89 de mov %ebx,%esi
4009ad: 48 89 c7 mov %rax,%rdi
4009b0: e8 bb fd ff ff callq 400770 &<_ZNSolsEi@plt&>
4009b5: be f0 07 40 00 mov $0x4007f0,%esi
4009ba: 48 89 c7 mov %rax,%rdi
4009bd: e8 1e fe ff ff callq 4007e0 &<_ZNSolsEPFRSoS_E@plt&>
4009c2: 48 83 c4 08 add $0x8,%rsp
4009c6: 5b pop %rbx
4009c7: 5d pop %rbp
4009c8: c3 retq

請注意main函數中位於4008f6的那條指令和p函數中400996的那條指令:

main

4008f6: 48 8b 1d 7b 07 20 00 mov 0x20077b(%rip),%rbx # 601078 &

p:

400996: 8b 1d dc 06 20 00 mov 0x2006dc(%rip),%ebx # 601078 &

注意到兩條指令的源地址都是全局變數x,也就是內存為0x601078的地方。

(說明一下,這裡可以用rip+偏移地址相加得到601078,注意rip是下一條指令的地址,也就是:

main: 0x4008fd + 0x20077b = 0x0x601078

p: 0x40099c + 0x2006dc = 0x601078

目的操作數rbx是一個64位寄存器,ebx是一個32位寄存器,準確說是rbx的低32位,那麼上面指令的分別就是:

main: 從x所在的地址0x601078讀64位到rbx.

main: 從x所在的地址0x601078讀32位到ebx.

而實際上我們知道x是一個32位的int,所以在rbx中還多讀了x後面地址的32位,然後調用cout的時候就當成了64位的double來處理了。

下面說明:為什麼32位的int 690為什麼會變成64位double 3.40905e-321?

當讀取64位時,低32位就是原來的int,也就是690,十六進位是0x000002b2。

那麼高32位呢,應該是0,因為出於內在對齊的原因,g++在分配內存的時候分配的內存是8的倍數,也就是int 32佔四個位元組,實際上分配內存有8個位元組,高32位的位元組是沒有使用的,在一般情況下這些多餘位元組的內容是0。

下面來驗證下:

#include&
using namespace std;

int main() {
int x[] = { 690,0 };
double double_x = *((double *)x);
cout &<&< double_x; }

運行結果,vs和g++都是3.40905e-321

那麼32位的690加上一個32位的0為什麼會是64位的3.40905e-321:

CSAPP 103,104,105頁

簡單翻譯一下:

64位的雙精度浮點數,其0到51位是尾數,52位到62位是階碼,最高位是符號位。

當階碼全為0的時候,所表示的數就是非規格化形式。

此時,階碼值是1-Bias,Bias是一個等於2^{k-1} -1的值,k的階碼域的位數,double中是11位。尾數就是小數欄位的值。

那麼寫成數學表達式是:

+1 	imes 2^{1-(2^{11-1}-1) } 	imes 690	imes 2^{-52}

上面的式子依次是符號位*2^(階碼)*尾數然後得到

3.40905	imes 10^{-321}

嗯,完美!


編譯器什麼也沒做,這種情況,說明生成的全局變數的符號規則沒有帶類型信息而已。


可以去看看《程序員的自我修養----鏈接、裝載與庫》這本書。

在第四章 靜態鏈接 4.3節 COMMON塊 里有討論這個問題。


#include &

int main(){
double x = 3.40905e-321;
int y = *(int *)(x);
printf("%d
", y);
return 0;
}

輸出是690。

這說明a.cpp中引用的x還是b.cpp中定義的那個x,只不過整數690被直接以double的方式讀出來了而已。


感覺這種情況下應該報鏈接錯誤


這種情況鏈接器讓你通過真是不懷好意。

反正我這被鏈接器攔下來了,題主可以試試直接把x的定義幹掉還能不能過。

3.2.4 Every program shall contain exactly one de?nition of every non-inline function or variable that is odr-used in that program; no diagnostic required. ...


逆向一下,看看編譯器到底寫了什麼彙編代碼,不就一清二楚了


推薦閱讀:

怎麼樣才算得上熟悉多線程編程?
.NET中如何通過Razor引擎生成這樣的代碼?
作為 .Net 開發人員,我們為什麼要學習 CLR?
程序員自測程序時一般會填寫哪些有意思的資料?
vs2015編譯出現拒絕訪問的問題.該怎麼解決?

TAG:編程語言 | 編程 | CC | 編譯 |