X86_64上沒有寫C函式宣告導致的BUG
我的部落格:http://blog.striveforfreedom.net
Table of Contents
1 簡介
最近修改一個用C寫的開來源程式,需要加幾個函數,因為偷懶沒寫函式宣告,導致程式崩潰,最後花了很多時間才查明原因,原來是沒有寫函式宣告惹的禍。感覺這個BUG在X86_64上還挺有代表性,因此這裡把它記錄下來。
2 導致崩潰的代碼及解決思路2.1 導致崩潰的代碼
導致崩潰的代碼簡化後只大致這個樣子的:
//foo.c#include <stdlib.h>#include "bar.h"static const char* value = NULL;void set_value(const char* p){ value = p;}const char* get_value(){ return value;}int main(int argc, char* argv[]){ char p[] = "abcd"; set_value(p); failed_func(); return 0;}//bar.c#include "bar.h" //簡單起見,就沒給出bar.h了,該檔案包含函數failed_func的聲明。char failed_func(void){ const char* p = get_value(); return *p; //進程崩潰}
程式執行每次執行到failed_func函數,都會在注釋的那一行崩潰。
2.2 解決思路
乍一看,這幾個函數很簡單,根本看不出有什麼問題,為什麼會導致崩潰呢?用gdb在函數failed_func上下一個斷點,再step進函數get_value裡,發現傳回值就是是當初用set_value設定的值,然而等get_value函數返回再查看指標p的值時,發現指標p的值卻不是當初設定的那個值了,這就很奇怪了,一個簡單的函數調用卻有如此怪異的結果,當時在C語言層面實在看不出有什麼不對的地方,於是查看彙編代碼,用set disassemble-next-line on,再一次進入函數get_value裡,發現該函數設定好寄存器rax的值就直接返回了,rax的值就是當初用set_value設定的值,這個函數顯然沒有問題(該函數的傳回值存在rax裡)。回到函數failed_func裡,緊接著調用函數get_value的callq指令之後的是一條cltq指令,該指令的作用是對eax的值進行符號擴充(sign-extend),結果存在rax裡,這就導致rax的高32位值被設為全1或全0了(取決於eax最高位的值),再之後的指令是訪問rax所指記憶體的指令,這條指令直接導致了崩潰,因為rax的值已經不是get_value所設定的值了(我們這個例子中rax的高32位全被置為1了)。這裡的關鍵是cltq指令,為什麼gcc會產生這麼一條指令呢?原因在於C89有一個隱式聲明規則(implicit declaration),當需要調用一個函數但找不到函數原型時,編譯器會提供一個隱式聲明,該隱式聲明會假定函數傳回值類型為int,C99已經去掉了這一規則,要求函數調用必須有函式宣告,但gcc可能為了相容老代碼,並沒有強制執行C99,只是給出了一個警告。在我們這個例子裡,gcc找不到函數get_value的原型,於是假定函數get_value的傳回值類型是int,因為X86_64上int是32位的而指標是64位的,於是把函數get_value傳回值賦給指標p就相當於把一個32位的有符號數賦值給一個64位的無符號數(指標值是無符號的),C語言規定當賦值運算式兩邊類型不相同時,等號右邊的類型會轉成等號左邊的類型(當然是在可轉的前提下),於是32位的有符號int被轉換轉成64位的無符號數,於是編譯器便產生了符號擴充指令cltq。這段代碼在X86上不會崩潰,因為X86上int和指標都是32位的,編譯器不會產生符號擴充指令。
設計上面這段範例程式碼的時候還有一個小小的trick,第設計一次這段代碼的時候,在main函數裡,傳給函數set_value的參數我是這麼定義的:
const char* p = "abcd";
但如果這樣的話程式是不會崩潰的,原因在於字串常量通常和代碼一起放在程式碼片段,而通常程式碼片段一般會載入在較低的記憶體位址(通常會小於0x10000000),於是cltq指令執行之前rax值高32位就是0,執行之後rax的高32位還是0,rax值沒有改變,程式也就不會崩潰,後來想到棧一般位於較高的記憶體位址,於是就將代碼改成:
char p[] = "abcd";
因為棧的地址通常會大於0x10000000,執行cltq指令之後rax值的高32位全為1,這時的rax值代表著一個很大的虛擬位址,訪問便會導致段錯誤,原因請看我的另一篇文章: Linux & X86上Segmentation fault原因分析。
3 小結
其實這個BUG完全可以避免,編譯時間gcc給出了一條很明顯的警告:warning: initialization makes pointer from integer without a cast,這條警告已經說明了問題的實質所在——用一個整數值來初始化指標。加上-Wall選項之後,還會一條函數沒有聲明的警告:warning: implicit declaration of function ‘get_value’,如果當時能看到這兩條警告,問題立馬就能得到解決。我平時寫程式,編譯選項都是加上-Wall, -Werror的,這次修改開來源程式,偷懶沒寫函式宣告,再加上這個開來源程式本身產生的警告實在太多了,導致編譯器給出的找不到函式宣告的警告淹沒在這一大堆警告裡,根本沒有察覺,最終花了很多時間才查明原因。這個事情給我的教訓就是:無論如何都要堅持寫函式宣告,一定不能忽視警告,一定要從一開始就消滅警告,否則等警告多起來,就很難有意願去消除警告了。