[轉]Track'em Down...
http://www.shnenglu.com/jerysun0818/archive/2006/06/04/8153.html
P.S. 很多朋友都抱怨說STL出問題的時候debug很難,編譯期錯誤算是輕的,大不了一串串令人頭暈的出錯信息,至少還能雙擊定位到錯誤行。而神秘的運行期崩潰才是真正令人頭大的問題。下面就是一個比較典型的、五臟俱全的運行期崩潰事件,從幾行簡簡單單的代碼,似乎根本不可能崩潰,一直到最后揪出隱藏在背后的機制。其中的思維分析過程是怎樣的呢?希望對一些朋友有點幫助。標題起作”Track’em Down”一方面是暗指整個分析跟蹤的過程,而是最后所揭露出的機制的確是個tracking機制:-)
很久沒寫blog了,一來是諸事纏身,二來也是實在不像以前那么有熱情坐下來好好寫篇技術文章了。很多時候只是做個旁觀者,四處潛水而已。昨天又跟老婆鬧了矛盾,心里郁悶,于是給自己一個理由四處亂逛,跑到許久未去的cpper(
http://www.cpper.com/c/
)上,cpper還是一如既往的冷清,看來一個論壇要想火起來光靠技術是不行的,cpper(前身為allaboutprogram)圈子里有一批技術很牛的朋友,也許是太牛了,這兩年都開始一個個牛得沒影兒了。二來cpper不像CSDN這樣有一個門戶,主頁上有亂七八糟的好玩信息,新聞,八卦,等等不一而足。國內C++社群的大部分朋友想必是不知道cpper的,尤其是初學者。實在是個不小的遺憾…等等…似乎扯遠了,剛才說到逛到cpper上,看到tomato的一個帖子,提到一段行為怪異的代碼,行為是崩潰,而且是在l析構的時候崩潰:
#include <list>
????????
??????????
int main(int argc, char* argv[])
{
? ? std::list<int> l;
? ? std::list<int>::iterator* it1 = new std::list<int>::iterator(l.begin());
? ? memset(it1, 1, sizeof(std::list<int>::iterator));
? ? l.push_back(1);
? ? l.begin();
? ? return0;
以上是原貼里的代碼,我就不簡化了。大伙看著辦吧,呵呵。
乍看上去這段代碼似乎不應該有什么問題(至少不會崩潰)。最可疑的當然是那行memset,但memset只是把it1指向的一個new出來的iterator給corrupt掉了,況且這個it1也沒被delete,所以也就不會因析構而崩潰了(雖然有內存泄漏,但tomato說的是崩潰),另外it1跟l看上去是井水不犯河水。所以怎么看不像有問題的樣子。
以上是眼睜睜瞪著代碼看出來的結果,然而當我把代碼塞到IDE里面,編譯運行之后卻發現果然如tomato所說,在list析構時程序崩潰了(很湊巧我跟tomato用的IDE都是VC8,這是一個關鍵條件)。對于這類問題,一般我是先黑箱再白箱。這么短的程序,要出問題肯定出在memset那行,邏輯上可以大致這么說:“由于memset把it1的內存corrupt掉了,結果導致l在析構的時候崩潰。”聽到自己下這么個結論我都覺得有點哭笑不得,這壓根兒風馬牛不相及嘛,it1被corrupt與l析構崩潰有屁關系?!it1只不過是個指向l的迭代器,說穿了就是裹著個指向l中的某個node的指針而已,它的死活怎么會影響到l的析構呢?
但福爾摩斯大致說過,去掉那些絕對不可能的,剩下的就是可能。這里這是唯一的可能,只不過問題是,這個邏輯還不夠細致,需要豐滿起來。進一步,要想使得l在析構的時候崩潰,這個memset肯定以某種方式影響到了l。又因為memset直接影響的是it1,那么必然是it1和l的某種聯系導致memset間接對l產生了影響。于是再來看,it1和l有什么聯系呢?得!這下看出來了,it1在構造的時候是通過l.begin()拷貝構造的,哈!問題似乎有點眉目了,但到此我還是想不通,根據常識,iterator的拷貝構造會產生出一個與源iterator不相干的副本,對這個副本干什么事怎么會影響到源呢?更別提影響到源iterator所指向的容器了。開玩笑!甭管他,既然已經肯定下來這是唯一的可能,那么推論只能是:背后還隱藏著什么不為人之的東西。于是進一步注釋掉“(l.begin())”,使it1變成缺省構造的迭代器,果然,不再崩潰了。于是,現在可以肯定作出的結論是:“… it1 = new std::list<int>::iterator(l.begin());這行代碼使得it1所指向的迭代器與l發生了某些關系(當然,不是it1指向l這樣的白癡關系)。很顯然,這個關系的建立點不可能是在l.begin(),因為這里還沒有涉及到it1,所以唯一的可能就是,這個關系是在std::list<int>::iterator的拷貝構造函數階段發生的。這次拷貝構造以l.begin()為參數,而l.begin()推測起來應當帶了l的信息,因而it1就利用這個機會與l建立起了“某種關系”。OK,到目前為止這個“某種關系”只是一個模糊的概念,我們并不明確知道究竟是什么關系,竟然導致list析構會失敗。看來是時候進入白箱了,F5,break,看斷在什么地方了,說實話P.J Plauger寫的STL代碼至少在外觀上不像SGI的那么親近人,nevermind,我看到了我想看到的信息——一個名為_HAS_ITERATOR_DEBUGGING的宏,哈,想起來了,這一版的STL是帶有iterator debugging功能的,所以當然能夠發現iterator被corrupt掉的情況。疑問來了,怎么發現的呢?直接的答案是,肯定有某種追蹤機制,而且根據前面的推理,由于是l在析構的時候發生的崩潰(it1并不析構,因為并沒有對它進行delete),所以l在析構的時候必然能以某種方式訪問到it1并發現它被corrupt掉的事實,再推廣之,l肯定能夠訪問到所有指向它的迭代器(這才叫iterator debugging嘛),而it1在構建的時候的確是拷貝構造l.begin()來的,也就是說指向l,所以l必然應當知道(跟蹤)它(it1)的存在,又因為接下來的memset是個相當low level/native的操作,所以l對此肯定不知情,還以為it1一直好端端的指著它的begin()處呢,最后當它一個個察訪指向它內部的迭代器,當查訪到it1時就發現it1被corrupt掉了,于是崩潰。呼~一切到這里似乎都串起來了。剩下的就是去發現list<int>::iterator的拷貝構造函數究竟在背后搗了什么鬼才使得l最終能夠訪問到it1的,在開始之前我就設想一個場景:肯定是l里面有一個鏈表,把所有指向它內部的迭代器串起來了,這樣它就能夠逐一檢查這些迭代器,看看比如說它們是否越界之類的。而it1在拷貝構造的時候由于是以l.begin()為參數的,l.begin()肯定帶有l的信息,說白了,指向l的指針,或l的引用,于是it1的拷貝構造函數就可以把自身鏈入l內部的鏈表中去。想到這里似乎情況十分明朗了,剩下的就是跟蹤進去驗證一下了…但是等一下…剛才說到,程序的現象是…崩潰!如果是在l對它的迭代器check的時候發現錯誤,大腦沒毛病的庫設計者肯定會拋出一個異常吧,不會是崩潰的癥狀…what the hell。反正我已經知道問題的核心在list<int>::iterator的拷貝構造函數那里,那是唯一同時擁有it1與l信息的地方。于是F11到list<int>::iterator的拷貝構造處,即“… it1 = new std::list<int>::iterator(l.begin());”這行代碼處,郁悶的是,調試器在這里一跳而過,似乎它擁有的是一個trivial的拷貝構造函數(從后來的結果來看這似乎是VC2005的一個小問題),而且我”go to disassembly”居然也就看到聊聊幾行代碼,除了一個對list<int>::begin()的調用之外就沒有其它調用了,傻眼了,似乎真的是個trivial的copy ctor?如果真是這樣的話,就不可能在這里建立起it1跟l之間的聯系了,因為trivial的copy constructing只會是把成員按位復制一下,沒有其它代碼,不涉及函數調用,怎么可能會有機會干其它事情呢?而后面又沒有任何地方同時涉及it1跟l的,那又怎么會最終導致l析構崩潰呢?簡單的推理,反推上去,既然l最終析構崩潰了,那么這個邏輯的某一環肯定錯了,最可能的就是,這并非trivial copy ctor,調試器欺騙了我的眼睛,背后肯定調用了某個拷貝構造函數,然后干了些勾當。于是靜態跟蹤派上了用場,幸好VS2005的intelli-sense非常強大,兩次“go to definition”就置身于了list<int>::iterator的定義里面,剛才不是說關鍵就在list<int>::iterator的拷貝構造函數里面嗎?于是瀏覽一下list<int>::iterator的定義,VC帶的STL的代碼真難看啊,好在這個類比較短,很遺憾,沒有拷貝構造函數,只有構造函數,不過碰巧,在其中一個由_HAS_ITERATOR_DEBUGGING條件控制的構造函數里面發現了一行寶貴的代碼:this->_Adopt(_Plist);,如果沒猜錯,這肯定是把這個迭代器自身鏈到它所指向的容器內的跟蹤鏈表中去的。_Plist(該構造函數的第二個參數)是什么?跟蹤到l.begin()調用里面就會發現,喂給list<int>::iterator的構造函數的第二個參數(也就是_Plist)是this,呵呵,看來this->_Adopt(_Plist)實際上就是把this(迭代器)“收養(adopt)”給_Plist啊。沒猜錯,果然是這樣的跟蹤機制。那么,照理說list<int>::iterator的拷貝構造函數也應該有相應的動作才對啊,不然拷貝構造出的新的iterator就會沒人“收養”了(而我們的跟蹤機制是應當跟蹤到每個“在世”的iterator的,否則就沒意義了)。剛才提到,list<int>::iterator本身沒有拷貝構造函數,那么只有一種可能,要么其成員具有non-trivial的拷貝構造函數,要么在基類里面。實際上list<int>::iterator只有一個成員,一如我們意料之中的,指向list的node的ptr。所以秘密肯定在基類中,順著基類一路找下去,_Const_iterator->_Bidit->_Iterator_base。說實話到_Bidit差點放棄,因為我以為_Bidit里面肯定就是一些typedef,就像unary_function那樣。事實卻不是這樣,下面還隱藏了一層_Iterator_base,而這個_Iterator_base就是一切秘密所在了。它是有拷貝構造函數的,代碼我就不列了,如果你跟蹤到這里,真相也就大白了,簡單的來說,它根據源iterator找到其所指的容器,然后取出該容器里面的用于跟蹤迭代器的鏈表頭指針,然后把自身(this)鏈到這個鏈表里面去。由于一個iterator誕生的方式一共就兩種,一種是從某個容器誕生,這時是調用的它的一般構造函數,容器會把自身的指針當作參數傳遞給這個iterator,后者通過這個容器指針來將其自身鏈接到容器的跟蹤鏈表中。第二個誕生方式就是這里說的,拷貝構造,拷貝構造時會將其自身鏈到源iterator所指向的容器內的跟蹤鏈表中去。反正就是,一切現有的iterator都會恰當被它所指的容器跟蹤著。
那么是時候揭穿謎底了吧,為什么memset會闖下這么大的禍?因為一個iterator要被鏈到鏈表里面,它肯定有next指針,這樣才能鏈成一個鏈表嘛。而memset粗暴地將這個next指針給重置了(事實上它把it1指向的迭代器整個給memset了,當然包括里面的next指針),這里,next指針被重置為了1(如果memset成0就不會崩潰了,原因很簡單,想想看),顯然,這就指向了一塊無意義的內存。于是,l在析構的時候,試圖遍歷并check它所跟蹤的iterator鏈表,隨著它通過next指針一節節跳轉,當到了我們的it1的時候,由于其next指針的值是1,所以試圖再跳(next)的時候就非法內存訪問了!崩潰!所以,這里的問題并不在于最后的check失敗了,而是在于迭代器鏈表被corrupt掉了,才導致的崩潰。這就解除了前面的關于未發生異常的疑惑。事情到此就大致結束了。
當然,這里還隱藏了很多的細節。例如最后l析構時并不是要去“check”每個迭代器。另外這個iterator tracking的機制還是很有代表性的,boost::signal里面就用了如出一轍的手法,這個手法充分顯示出了C++的強大和靈活。原來看上去毫不起眼的構造函數在背后還能做那么多工作。這里就不方方面面總結這一技術了,一是困了,二是這不是這篇blog的初衷,寫這篇blog一是為了平靜一下郁悶的心情,二是為了顯示一個從現象開始分析推理問題,最終接近答案的過程。程序員的大部分時間是在debug,這篇blog其實介紹的就相當于一個debug的思維過程,或許它并不是最好的,但希望你能夠在其中發現一些有用的東西。
P.S. 看上去啰啰嗦嗦一大通,實際上在腦子里轉來轉去是一瞬間的工夫,加上跟蹤(靜態/動態)也就十來分鐘,而寫這篇blog倒花了我四十多分鐘(這也是越來越不寫blog的原因吧),人類自然語言的表達力某些時候是十分啰唆冗余的…
posted on 2006-06-04 19:52
Jerry Cat 閱讀(320)
評論(0) 編輯 收藏 引用