記一次堆棧平衡錯誤
最近在一個使用Visual Studio開發(fā)的C++程序中,出現(xiàn)了如下錯誤:
Run-Time Check Failure #0 - The value of ESP was not properly saved across a function call. This is usually a result of calling a function declared with one calling convention with a function pointer declared with a different calling convention.
這個錯誤主要指的就是函數(shù)調(diào)用堆棧不平衡。在C/C++程序中,調(diào)用一個函數(shù)前會保存當(dāng)前堆棧信息,目標(biāo)函數(shù)返回后會把堆棧恢復(fù)到調(diào)用前的狀態(tài)。函數(shù)的參數(shù)、局部變量會影響堆棧。而函數(shù)堆棧不平衡,一般是因為函數(shù)調(diào)用方式和目標(biāo)函數(shù)定義方式不一致導(dǎo)致,例如:
void __stdcall func(int a) {
}
int main(int argc, char* argv[]) {
typedef void (*funcptr)(int);
funcptr ptr = (funcptr) func;
ptr(1); // 返回后導(dǎo)致堆棧不平衡
return 0;
}
__stdcall修飾的函數(shù),其函數(shù)參數(shù)的出棧由被調(diào)用者自己完成,而__cdecl,也就是C/C++函數(shù)的默認(rèn)調(diào)用約定,則是調(diào)用者完成參數(shù)出棧。
Visual Studio在debug模式下會在我們的代碼中加入不少檢查代碼,例如以上代碼對應(yīng)的匯編中,就會增加一個檢查堆棧是否平衡的函數(shù)調(diào)用,當(dāng)出現(xiàn)問題時,就會出現(xiàn)提示Run-Time Check Failure...這樣的錯誤對話框:
call dword ptr [ptr] ; ptr(1)
add esp,4 ; cdecl方式,調(diào)用者清除參數(shù)
cmp esi,esp
call @ILT+1345(__RTC_CheckEsp) (0B01546h) ; 檢查堆棧是否平衡
但是我們的程序不是這種低級錯誤。我們調(diào)用的函數(shù)是放在dll中的,調(diào)用約定顯示定義為__stdcall,函數(shù)聲明和實現(xiàn)一致。大致的結(jié)構(gòu)如下:
IParser *parser = CreateParser();
parser->Begin();
...
...
parser->End();
parser->Release(); // 返回后導(dǎo)致堆棧不平衡
IParser的實現(xiàn)在一個dll里,這反而是一個誤導(dǎo)人的信息。parser->Release返回后,堆棧不平衡,并且僅僅少了一個字節(jié)。一個字節(jié)怎么來的?
解決這個問題主要的手段就是跟反匯編,在關(guān)鍵位置查看寄存器和堆棧的內(nèi)容。編譯器生成的代碼是正確的,而我們自己的代碼乍看上去也沒問題。最后甚至使用最傻逼的調(diào)試手段–逐行語句注釋查錯。
具體查錯過程就不細(xì)說了。解決問題往往需要更多的冷靜,和清晰的思路。最終我使用的方法是,在進(jìn)入Release之前記錄堆棧指針的值,堆棧指針的值會被壓入堆棧,以在函數(shù)返回后從堆棧彈出,恢復(fù)堆棧指針。Release的實現(xiàn)很簡單,就是刪除一個Parser這個對象,但這個對象的析構(gòu)會導(dǎo)致很多其他對象被析構(gòu)。我就逐層地檢查,是在哪個函數(shù)里改變了堆棧里的內(nèi)容。
理論上,函數(shù)本身是操作不到調(diào)用者的堆棧的。而現(xiàn)在看來,確實是被調(diào)用函數(shù),也就是Release改寫了調(diào)用者的堆棧內(nèi)容。要改變堆棧的內(nèi)容,只有通過局部變量的地址才能做到。
最終,我發(fā)現(xiàn)在調(diào)用完以下函數(shù)后,我跟蹤的堆棧地址內(nèi)容發(fā)生了改變:
call llvm::RefCountedBase<clang::TargetOptions>::Release (10331117h)
因為注意到TargetOptions這個字眼,想起了在parser->Begin里有涉及到這個類的使用,類似于:
TargetOptions TO;
...
TargetInfo *TI = TargetInfo::CreateTargetInfo(m_inst.getDiagnostics(), TO);
這部分初始化代碼,是直接從網(wǎng)上復(fù)制的,因為并不影響主要邏輯,所以從來沒對這塊代碼深究。查看CreateTargetInfo的源碼,發(fā)現(xiàn)這個函數(shù)將TO這個局部變量的地址保存了下來。
而在Release中,則會對這個保存的臨時變量進(jìn)行刪除操作,形如:
void Delete() const {
assert (ref_cnt > 0 && "Reference count is already zero.");
if (--ref_cnt == 0) delete static_cast<const Derived*>(this);
}
但是,問題并不在于對一個局部變量地址進(jìn)行delete,delete在調(diào)試模式下是做了內(nèi)存檢測的,那會導(dǎo)致一種斷言。
TargetOptions包含了ref_cnt這個成員。當(dāng)出了Begin作用域后,parser保存的TargetOptions的地址,指向的內(nèi)容(堆棧)發(fā)生了改變,也就是ref_cnt這個成員變量的值不再正常。由于一些巧合,主要是代碼中各個局部變量、函數(shù)調(diào)用順序、函數(shù)參數(shù)個數(shù)(曾嘗試去除Begin的參數(shù),可以避免錯誤提示),導(dǎo)致在調(diào)用Release前堆棧指針恰好等于之前保存的TargetOptions的地址。注意,之前保存的TargetOptions的地址,和調(diào)用Release前的堆棧指針值相同了。
而在TargetOptions的Delete函數(shù)中,進(jìn)行了--ref_cnt,這個變量是TargetOptions的第一個成員,它的減1,也就導(dǎo)致了堆棧內(nèi)容的改變。
至此,整個來龍去脈算是摸清。
posted on 2013-08-15 23:01 Kevin Lynx 閱讀(5802) 評論(1) 編輯 收藏 引用 所屬分類: c/c++

