TCC,全稱Tiny C Compiler(http://bellard.org/tcc/),是一個頗具特色的C編譯器,你能把它當作一個C語言解譯器來用,也可以嵌入你自己的應用程式作一個動態代碼產生器。是的,我們就是這麼乾的。在我們的項目中,粒子系統的運動規則用C語言來描述,然後由TCC動態產生native code運行。這麼做既不失效率又保持了較高的動態能力。
但是,既然是使用第三方庫,那就要準備好享受成果的同時吞下bug。這一次,我們吃到的可是一隻非常揪心的蟲子。
眾所周知,X86 CPU的浮點計算單元(FPU)共有8個浮點數寄存器,它們是按照棧的形式組織的。如果load浮點數進了一個寄存器,那麼它就屬於被佔用的,需要用類似pop的操作把它釋放掉後才能重新使用。
對於TCC來說,一個函數如果使用了浮點運算,那麼它產生的程式碼在函數返回的時候會在FPU棧上留下一個垃圾(為什嗎?
這是後話,也是本文的主旨。),這樣8個寄存器就只剩下7個可以用了。如果你的程式全部用TCC編譯這沒什麼問題,但是和gcc或者msvc混合使用的話就有問題了。因為這些編譯器一直認為在剛剛進入任何一個函數的時候都會有8個浮點寄存器可用,而如果開啟了最佳化開關的話,它們就有可能產生一些很牛B的代碼,一下子把8個寄存器全都用滿。這就糟糕了,只有7個茅坑(另外一個TCC佔著不拉屎),一下子要蹲8個,於是就觸發了FPU內部的“茅坑使用異常”(學名叫FPU invalid operation
exception:#IE)。關鍵是這個鳥異常一般情況下是被FPU罩(mask)著的,我們根本不知道,以為天下太平,但是從浮點寄存器
上取回的值那就錯得象一坨屎一樣了。
這個bug折磨了我們好幾天,同事雲風已經說過這事情了。接下來我要帶大家掘地三尺,找准位置,痛下殺腳,踩死這隻臭蟲(看丫還蹦達)。
我們的分析標本是tcc-0.9.25,也是目前最新的官方發行版,源碼這裡下:http://download.savannah.nongnu.org/releases/tinycc/tcc-0.9.25.tar.bz2。
TCC通過`fstp %st(1)'指令在FPU棧上留垃圾。st是FPU stack的簡寫,st(n)指的是浮點棧的第n號寄存器,棧頂到棧底依次按0、1、2...進行編號。這句指令的意思是不管st(1)有沒有被佔用,都把st(0)的內容拷貝到st(1),然後釋放st(0)即棧頂,原來的st(1)成為新的棧頂。該指令結束後,FPU棧頂一定是被佔用的。
有兩個地方會產生`fstp %st(1)'指令(二進位編碼是0xd9dd):tccgen.c的689行(vpop函數內);tccgen.c的210行(save_reg函數內)。我們首先把它們都改為產生`fstp %st(0)'指令(二進位編碼是0xd8dd)。`fstp %st(0)'的意思其實就是彈出FPU棧頂寄存器的內容,使st(0)成為未佔用狀態,不做任何多餘的事。本來vpop和save_reg這兩個函數就是為了釋放寄存器才產生相關指令的,這麼一改就合乎函數的原本意圖了。
那是不是這樣就萬事大吉了呢?顯然是不夠的,如果真這麼簡單我用得著寫這篇文章嗎?讓我們試著用修改過的TCC編譯下面的函數:
void foo()
{
double var = 2.7;
var++;
}
它會產生這樣的機器碼:
.text:08000000 public foo
.text:08000000 foo proc near
.text:08000000
.text:08000000 var_18 = qword ptr -18h
.text:08000000 var_10 = qword ptr -10h
.text:08000000 var_8 = qword ptr -8
.text:08000000
.text:08000000 push ebp
.text:08000001 mov ebp, esp
.text:08000003 sub esp, 18h
.text:08000009 nop
.text:0800000A fld L_0
.text:08000010 fst [ebp+var_8]
.text:08000013 fstp st(0)
.text:08000015 fld [ebp+var_8]
.text:08000018 fst [ebp+var_10]
.text:0800001B fstp st(0)
.text:0800001D fst [ebp+var_18]
.text:08000020 fstp st(0)
.text:08000022 fld L_1
.text:08000028 fadd [ebp+var_10]
.text:0800002B fst [ebp+var_8]
.text:0800002E fstp st(0)
.text:08000030 leave
.text:08000031 retn
.text:08000031 foo endp
.text:08000031
.text:08000031 _text ends
--------------------------------------------------
.data:08000040 ; Segment type: Pure data
.data:08000040 ; Segment permissions: Read/Write
.data:08000040 ; Segment alignment '32byte' can not be represented in assembly
.data:08000040 _data segment page public 'DATA' use32
.data:08000040 assume cs:_data
.data:08000040 ;org 8000040h
.data:08000040 L_0 dq 400599999999999Ah
.data:08000048 L_1 dq 3FF0000000000000h
.data:08000048 _data ends
請注意從0800000A到08000013的指令片段:
// double var = 2.7; 把一個常數load進st(0)
.text:0800000A fld L_0
// double var = 2.7; 把st(0)的內容拷貝到變數`var'中
.text:08000010 fst [ebp+var_8]
// double var = 2.7; poping st(0),這會清空浮點棧
.text:08000013 fstp st(0)
這之後的指令是TCC通過調用`void inc(int post, int c)'這個函數(tccgen.c的2150行)產生的。其中08000015到0800001B的指令通過inc->gv_dup這條調用鏈產生:
// 把變數`var'的內容載入進st(0)
.text:08000015 fld [ebp+var_8]
// 把st(0)的內容拷貝進記憶體中的一個臨時位置
.text:08000018 fst [ebp+var_10]
// poping st(0),這會清空浮點棧
.text:0800001B fstp st(0)
接下來,調用鏈(gen_op('+')->gen_opif('+')->gen_opf('+')->gv(rc=2)->get_reg(rc=2)->save_reg(r=3))
產生0800001D到08000020的指令:
// 把st(0)的內容拷貝進記憶體中的一個臨時位置,但是請注意,整個浮點棧都是空的,st(0)雷根本沒有合法的內容!
.text:0800001D fst [ebp+var_18]
// poping st(0),同樣的問題,整個浮點棧都是空的!
.text:08000020 fstp st(0)
實際運行過程中,0800001D號指令會引起FPU invalid operation exception(#IE)。
為什麼TCC會產生這麼傻的代碼?請仔細閱讀inc調用的`gv_dup'函數,注意它裡面有下面幾行:
(1): r = gv(rc);
(2): r1 = get_reg(rc);
(3): sv.r = r;
sv.c.ul = 0;
load(r1, &sv); /* move r to r1 */
(4) vdup();
(5) /* duplicates value */
vtop->r = r1;
首先要解釋一下,機器指令產生中非常關鍵的一件事情就是如何分配變數對寄存器的使用,這是因為絕大部分的硬體指令
都需要至少一個運算元在寄存器裡面。TCC為了做這件事情,把當前所有局部變數的資訊組織成棧的形式,稱為vstack,
棧頂稱為vtop。這其中就包含了變數此時處於什麼位置(是在哪個寄存器裡,還是在某個記憶體位址當中)之類的資訊。TCC對
寄存器的假設是非常保守的,定義了3個通用寄存器和1個浮點寄存器,用enum值標識,TREG_ST0代表浮點寄存器。為什麼做這麼保守的假設?我猜測是因為有些架構(比如ARM?)的CPU並沒有那麼強大,而為了跨平台,TCC選擇了一個所有架構都共有的最小公用寄存器集合。
再解釋一下幾個函數的行為。gv會想辦法(比如產生指令)把vtop代表的變數load進某個寄存器,而該寄存器一定要屬於rc(我猜是register class的縮寫,即寄存器類別)指定的類別,如果vtop已經位於一個該類別的寄存器裡,那麼gv就什麼都不用做,不管哪種情況,gv最終都返回vtop所在的寄存器標號。get_reg(rc)想得到一個自由(即未被任何變數佔用)的rc類別的寄存器,如果該類寄存器全部被佔用,那麼這個過程就會導致某個已經佔用了該類寄存器的變數被擠到記憶體(的一個臨時地址)中去,從而釋放出一個寄存器來。get_reg的傳回值是自由寄存器標號。vdup會複製vtop,並使得新複製出來的元素成為新的vstack棧頂,即新的vtop。
現在讓我們一行一行來分析。(1)會想辦法把vtop載入進一個浮點類型的寄存器中,又因為只有一個浮點寄存器,所以(1)會讓vtop佔用TREG_ST0, 並且傳回值r等於TREG_ST0。(2)嘗試獲得一個自由的浮點寄存器,但是同樣地因為只有一個浮點寄存器,它已經被vtop佔用了,所以get_reg會迫使vtop進入到記憶體當中,並且返回TREG_ST0給r1。(3)其實是想產生指令把r寄存器的內容move到r1寄存器裡去,但是因為r等於r1,所以最終它什麼都沒做。(4)複製vtop。最後(5)會把新複製出來的vtop的
位置指定為TREG_ST0,TCC認為這個新vtop合法地佔有了那個唯一的浮點寄存器。
嘿,發現問題了嗎?請注意,老的vtop已經被擠出寄存器到了記憶體當中,其後複製的新vtop所處的位置應該和老的一樣,也在記憶體中,不在寄存器裡。但是TCC卻通過`vtop->r = r1'想當然地把浮點寄存器指定給了新的vtop,但是同時卻沒有產生任何把新vtop載入進寄存器的代碼。隨後因為`gen_op('+')'需要至少一個運算元在寄存器裡,又因為它不正確地認為TREG_ST0已經被vtop佔用了,所以導致產生0800001D和08000020號指令想把vtop放到一個臨時的記憶體地區中去。
其實gv_dup本來想乾的事情是載入vtop進r寄存器,然後在棧頂複製vtop,並把新vtop載入進r1寄存器中。但是在r等於r1的情況下, 這種語義是沒有辦法保證的。而如果是浮點類寄存器的話,那麼r又一定會等於r1。所以對於浮點寄存器來說,gv_dup是一定不能按照要求完成任務的。
至此,我可以大膽地猜測一下。當初TCC的作者在那兩個產生`fstp %st(1)'指令的位置可能也是想寫`fstp %st(0)'的,但是
因為gv_dup裡面寄存器分配的bug,這樣寫的話會導致很多浮點運算編譯出來的代碼都有錯,於是作者嘗試了一下很tricky的
`fstp %st(1)'指令,發現好象能解決問題,於是代碼保留下來,真正的錯誤被隱藏。苦了我們這些使用者啊!
解決辦法是什麼呢?很簡單,如果r等於r1的情況下,就不要讓新的vtop非法地佔用r1寄存器了。把(6)改成
if (r != r1)
{
vtop->r = r1;
}
用再次修改過的TCC編譯我們的例子,產生代碼如下:
.text:08000000 push ebp
.text:08000001 mov ebp, esp
.text:08000003 sub esp, 10h
.text:08000009 nop
.text:0800000A fld L_0
.text:08000010 fst [ebp+var_8]
.text:08000013 fstp st(0)
.text:08000015 fld [ebp+var_8]
.text:08000018 fst [ebp+var_10]
.text:0800001B fstp st(0)
.text:0800001D fld L_1
.text:08000023 fadd [ebp+var_10]
.text:08000026 fst [ebp+var_8]
.text:08000029 fstp st(0)
.text:0800002B leave
.text:0800002C retn
更加簡單,更加符合直覺,更重要的是它沒有錯誤。
如果需要一個測試案例的話,下面這個程式很適合:
void foo()
{
double var = 2.7;
var++;
}
int
main()
{
// unmask FPU #IE(Invalid Operation Exception) flag of control word
unsigned short custom = 0;
asm("fstcw %0"
:
: "m" (custom));
custom &= 0xfffe;
asm("fldcw %0"
:
: "m" (custom));
// before foo(), FPU registers stack is empty
foo();
// after foo(), st(0) is left unclean
asm("fld1;"
"fld1;"
"fld1;"
"fld1;"
"fld1;"
"fld1;"
"fld1;"
"fld1;");
// fnop will throw a FPU #IE exception if the bug exists
asm("fnop");
return 0;
}
如果該bug存在,那麼用TCC跑這段程式一定會收到SIGFPE。它的關鍵點是開啟了#IE異常開關(注意用的是gcc內嵌彙編的文法),再也不會讓它靜悄悄地發生了。其次是用8個連續的fld1指令類比其它編譯器最佳化過的浮點代碼。
修正方案我已經提交給了TCCTeam Dev,最新的開發版中已經打上了這個patch,讀者可以從這裡(http://repo.or.cz/w/tinycc.git?a=log;h=refs/heads/mob)下載。如果在使用過程中還發現有此bug,請告訴我,
我還要和它鬥,就不信踩不死這隻臭蟲!