使用Microsoft visual Studio和Rational Purify進行運行時調試(二)

作者: Goran Begic, Technical Marketing Engineer, Development Solutions, IBM Rational

翻譯: wyingquan#hotmail.com      2006-02-29

調試——修復缺陷過程中最慢且代價最高的一步——是大型軟件開發過程的一個重要組成部分。並且相信任何一個調試人員都會告訴你,定位引起缺陷的真正原因是一項艱鉅的任務;並且修復一個缺陷比發現一個缺陷容易得多。在本文第一部分,我將向您介紹Microsoft Visual Studio程序開發環境並且討論使用它的編譯器如何進行初步調試。在第二部分中我將介紹如何使用Microsoft Visual Studio調試器和Rational Purify進行運行時調試。有關本文中使用的示例可以參考本文中示例程序的說明和本文內容進行調試環境的搭建。
使用運行時調試器
假設你已經使用Microsoft Visual C++編寫了如文章提到得一些代碼,想要看看它是否能夠正確運行。按照文中描述步驟設置該工程,並且儘量按照預先設計好的步驟來進行。
第一次使用編譯器運行程序時,程序運行正常,但是這並不意味着它沒有bug。實際上你也知道(或許你並不知道),即使程序在你的機器上能夠正常運行,它可能仍然包含錯誤。加入它在特定的機器上以特定的順序運行,它仍有可能出現錯誤。因此,是該找一個更高級的工具來幫助你進行調試了:調試器。Microsoft Visual studio開發環境同時提供了一個強有力調試器,可使你廣泛地洞察程序內部結構、結構體中的數據、寄存器中的內容甚至是彙編指令。
使用這個調試器遇到的一個問題是,你必須告訴它什麼時候暫停執行。可是如果不知道該什麼時候查看比較好該怎麼辦,程序執行的速度是很快的?最簡單的方式是使用just-in-time(JIT)即時調試功能。
JIT調試
JIT調試可以把調試器綁定到一個崩潰的程序上。它可以讓用戶看到程序在崩潰而被“被凍結”時的快照。不幸的是,要看懂引起程序崩潰的信息是比較困難的,而且在使用JIT調試引起崩潰的主要原因時可能已經躍過了該位置。即使是這樣,JIT有時也會幫你很大的忙,所以在這裏我將講述以下如何使用它來調試一個臭蟲成災的C++程序。
本文用到的示例是一個簡單的命令行應用程序,包含一個名爲Bears的類和兩個函數,這兩個函數對MyBear對象進行操作。該程序含有若干嚴重的錯誤,在下面的介紹中我將一個個地進行調試。
首先編譯運行該程序,你將會看到圖1那樣的結果。看起來太讓人難過了!怎麼搞的?這程序怎麼就不行了呢?
如果你想在程序崩潰時進行調試,當調試器與崩潰的進程綁定開始運行時你將看到如圖2顯示的結果。我比較喜歡在任何時候都顯示寄存器窗口和堆棧調用窗口。它們各自顯示了當時寄存器中的內容和已經執行了的函數列表。你可以在主菜單上選擇View->Debug Windows菜單選擇其它你想要顯示的調試窗口。
在程序崩潰前最後調用的一個函數是C Run-Time Library (msvcrt.dll)中的。由於我沒有使用Debug版的庫,所以找不到函數名而沒有在堆棧上顯示。如果使用Debug版的C Run-Time Library,你將看到更多如圖3中顯示的信息。
最後調用的函數是strcat(),它的確是mcvcrt.dll中的。你可以從跟蹤信息和堆棧調用信息中看到程序最後執行的操作是創建Bear對象。
讓我們再來看看調試器給我們提供的其它有用的信息。寄存器EDI的內容是0XCCCCCCCC。這個寄存器是用來進行內存比較和移動的。所顯示的16進制值是該Visual Studio實例用來自動初始化所有本地變量的。這也意味着程序可能設法使用一個未被初始化的變量。在屏幕下方的變量窗口中可以看到變量pBearFriend包含上述值。那麼是這個原因引起程序崩潰嗎?
是的,毫無疑問!看一看源碼就可以知道編寫代碼的人忘記了初始化變量m_pBearFriend的值。而在拷貝粘貼時把變量m_pBearName初始化了兩次。可以查看bear.cpp文件的4549行。
  m_pBearName = new char[strlen(pName)+1];
  strcpy(m_pBearName, pName);
  m_pBearName = new char[strlen(pFriend)+1];    //Copy-paste error!
// m_pBearFriend = new char[strlen(pFriend)+1];  //Correct allocation
  strcpy(m_pBearFriend, pFriend);
呵呵,這也太簡單了吧!讓我們繼續來調試這個程序吧。可以把第47行註釋掉,刪除掉那行正確代碼前的註釋符號來糾正這個錯誤。然後rebuild一下在調試狀態下重新運行。
嗯?L怎麼又出現了一個讓人噁心的錯誤消息?如圖4,這次系統提示“Debug Error!”。正如我前面提到的,Debug版的C Run-Time library使用的是Debug版的內存分配函數,它分配了額外一處內存用於報告新分配的內存塊越界的情況。這恰恰是產生問題的地方。你可以單擊忽略按鈕,程序將完整地執行。注意:如果你使用release版的Microsoft C Run-time Library進行程序鏈接的話將不會顯示這個錯誤。那麼如何找出導致問題的原因呢?
在程序終止時,你會看到另外一種信息——這次它時一個警告信息(如圖5
0x80000003 EXCEPTION_BREAKPOINT
這個消息是在調用HeapFree()時出現的。
既然我們提到過斷點,那我將要介紹一下這個幾乎在任何調試工具中包含的重要手段。
什麼是斷點?
斷點是調試器最常用的手段之一。你可以在Visual Studio編輯中選擇一行源碼然後按F9來設置斷點;用來標記調試程序時在這個位置暫停執行。如果設法讓程序在正確的時間暫停,你將能夠查看內存中讀寫的內容。這在程序真正運行時是很難完成的,相對於臃腫的項目來說更加複雜。
從彙編的級別上來說,斷點是一個插入到代碼中的1個字節的指令(0xCC)。當進程運行到0xCC時,將它解釋爲一個特殊的,優先級高的中斷,進程在這個位置暫停執行。除此之外,還把當前的指令(兩個寄存器中的內容)保存下來,這樣當用戶決定從斷點處繼續執行時這些值能夠被重新加載,程序也接着執行。
斷點應該設置在什麼地方呢?在本例中,在程序崩潰時Debug build給你提供了一些線索,根據這些可以查找bug的位置。堆棧調用窗口按系統調用和執行的順序顯示了函數列表和各自的參數。並且程序最後一次執行的指令顯示在堆棧的頂端。通常,堆棧窗口會顯示系統函數而不是用戶函數。這是因爲在執行用戶函數是調用系統函數的過程。本例中MyBear對象的用戶自定義析構函數調用了run-time函數free()來釋放該對象使用的內存,實質上是在內部是通過調用Debug版的函數來釋放內存。
正如圖6所示,在你調用MyBear對象的析構函數時發生的錯誤。堆棧窗口頂部的函數是用於釋放內存的Debug版的函數:
delete(m_pBearName);
delete(m_pBearFriend);
delete(m_pBearHobby);
在這裏我們設置一些斷點,設置在該值被初始化之前的行上(bear.cpp的第52行),和調用delete()函數刪除m_pBearHobby變量的行上(Bear.cpp的第30行)。
設置了斷點以後,rebuild一下並且以調試方式運行。這時程序恰好停在設置了斷點的位置(第52行)。Visual Studio調試器主窗口中以相同的順序顯示了最後一次關閉時你使用過的多個窗口(圖7)。主窗口左側的窗口顯示的是源碼和設置了斷點的代碼行。右側堆棧調用窗口顯示了最後一次暫停前最後執行的方法——MyBear對象的構造函數。
這次,我將使用Debug Momory窗口。它在窗口的下半部分顯示。在Memory窗口的地址欄,輸入存儲在m_pBearHobby指針執向的值。內存塊就定位在了m_pBearHobby所指向的值的位置。在這裏顯示爲0xCD字樣。此外,有4個字節標記着分配內存結束和同時被編譯的由“Debug”版的內存分配程序創建的“安全區域”(顯示爲0xFD字樣)。Debug版的內存分配程序“佔用sprays”了已分配的內存塊,然而沒有初始化,這塊內存顯示爲0xCD,數組周圍的邊界區域顯示爲oxFD
F5鍵繼續在調試器中執行程序。圖8顯示了在數組指針m_pBearHbby被初始化以後的將看到的情況。
在主窗口底部的變量窗口中,你可以看到已創建對象的成員變量的列表。m_BearHobby變量現在指向了程序早已分配給它的“Philosophy”字符串。內存窗口中也顯示了你之前在相同的位置設置的字符串的副本。可以明確地定位到Philosophy字符串在內存中的存儲位置。
如果你細心計算已分配字節數的化,就會看到Philosophy包含10個字母(也就是10個字節),字符串的末尾——字符串終止符(0x00)——在字符串後面的安全邊界區域。如果是Release版的程序,在數據之間將沒有額外的區域,程序實際上可能會爲了程序的執行用這些地方來以未知的推理寫入一些有用的數據。幸運的是bears示例不是一個臨界任務程序。
斷點是強者(知道在哪裏設置斷點)手中的得力工具。你在本程序中看到的所有的斷點設置是最簡單、最直截了當的。你也能夠以更高級的方式操作斷點。在Microsoft Visual Studio主窗口中按ALT+F9打開斷點設置窗口,這裏顯示了所有斷點的位置,表達式,變量和消息發生的條件。甚至可以設置條件表達式斷點,儘管設置正確的條件並非那麼容易。
例如:你可以使用高級斷點設置讓調試器在一個指針變量存儲是值發生改變時暫停。這將有助於讓你檢查獲得的所有指針指向的值並且讓你在指針指向非法地址的地方加入適當的代碼。
現在,你能夠通過在源文件bear.cpp的第50行上分配更多的字節來糾正錯誤然後rebuild程序。現在程序正確執行了,那麼這也就意味着程序中沒有錯誤了嗎?如果這樣認爲,那你就錯了。
使用自動化的運行時調試器
到現在爲止,你已經通過運行Debug版的程序發現並修復了一些錯誤,但你仍然不知道這個小程序是否沒有bug了。事實上我們沒有時間來爲每種可能發生的情況給程序設置斷點來查看它運行時內部的情況。但是你仍然必須確定軟件是否已經準備好分發給客戶了。在這種情況下,最保險的做法是使用一種高級調試工具,最好讓它能夠爲你執行這個時間緊迫的運行時檢查過程。
市面上有許多類似的工具。其中許多使用源碼作爲程序執行時獲取信息的主要來源,但是下面我將使用的工具更像是一個自動版本的調試器,即使在沒有源碼的情況下也可以運行。它依賴於程序的符號調試信息,檢查每一處內存分配和函數的參數,是在程序運行時檢測內存泄漏的。程序需要按下集成在Microsoft Visual Studio中的一個按鈕纔可以進行測試,這樣你就可以運用你對Visual C++編譯器和Visual studio調試器的知識進行分析做出合理的判斷。報告會呈現給你精確的錯誤位置和對應的內存分配的位置,以及所有使用一般方法和調試器難以發現的問題。這裏我也故意保留了程序中前面已經解釋過的錯誤,因爲這是我首先如何定位的——通過在Rational Purify中運行示例。
9顯示了報告的第一個標籤。報告顯示即時你糾正了第一個錯誤,程序中仍然有兩處錯誤和一處發生內存泄漏。
儘管這不是一個關鍵業務程序,你應該推斷這些錯誤的來源和它們是否真實存在。我將在下面逐一分析。
列表中第一個錯誤被標記爲ABW。它是Array Bounds Write的縮寫,表明程序向數組寫入內容時下標越界了。在這種情況,它通過在代碼中的正確的位置設置的斷點和觀察在內存中初始化變量值來標記出錯誤。我不得不承認我首先使用了上述報告來定位錯誤,接着我使用了它提供的信息來設置斷點來調試程序(如圖10)。
展開錯誤信息,Rational Purify會顯示出發生錯誤時調用的堆棧信息。它同時也指出了代碼中發生錯誤的位置。這和通過調試器在堆棧調用窗口、內存窗口、變量窗口和源碼窗口中看得到的信息是同一類型的。單獨使用Microsoft Visual Studio和集成使用Rational Purify最大的不同之處在於後者不需要設置斷點或使用JIT調試器。Purify直接指出了內存問題和發生問題的原因,這更有利於開發人員修復錯誤。
Purify報告的錯誤列表中還剩下什麼問題呢?對了,下一個問題是Array Bound Read,它也是由於下標越界引起的問題。然而這回應用程序嘗試讀取數組地址以外的內存。圖11中,有問題的源碼出現在“計算”bears數量得函數中。最後一刻我決定在這裏加一個“特寫”,它並不是很明顯。這對於當在調試器中測試應用程序時,標註這個未被測試的問題非常重要,儘管使用Debug版的C RunTime庫。
儘管整數數組爲兩個元素分配了內存,數量是從不存在得數組元素中獲得的。這個值下標越界了,在調試版本下它具有一種模式,這種模式通常用來標識分配4個字節數組的界限。邊界值是相同的大小:4個字節。數據的“第三個元素”爲十六進制值:0XFDFDFDFD,這恰巧是程序中顯示得值。勿庸置疑每次運行時population的值都是一樣的。
對於Release版本來說,模樣就大不相同了:0xABABABAB(不再是邊界的值了)。最可怕的是編譯器並沒有告訴你它使用了什麼值。這種問題最難定位,因爲程序看起來是正確運行的。因此除非你在發佈前進行徹底的測試,有一個好機會,但是直到一個不適當的時間(當程序安裝到客戶的機器上時)你纔會知道這些問題。
在圖12中,你可以看到數據的頭兩個元素被標紅了並且該模式被程序理解爲一個無效的數字。有趣的是這個值應該代表bearspopulation,在你改變了Microsoft C Run-Time後就會改變,或者如果你使用Purify來檢測這種錯誤,在這種情況下population的值將是0xAEAEAEAE。程序不會崩潰或者引起系統錯誤,但是代表beaspopulations的數值是錯誤的,它是基於內存模式在該位置讀到的內容。最大的population是跟Rational Purify字節模型I相關聯的。
內存泄漏
Purify報出的最後一個錯誤信息是MLKMemory Leak)。內存泄漏是最難檢測和惱人的,有其是對一個要運行很長的時間而中間又不能重起的程序。這樣的程序會吞掉所有的內存資源最後導致機器上的所有進程都停止。
MLK信息將會在對上所有內存中或在已分配的內存塊中沒有被指到的情況下顯示。這樣的內存區域不會被程序用到,但是他們也不返還給操作系統,持續的內存泄漏可能在即使有G級內存的機器上引起嚴重的問題。
可以使用Debug版運行時庫來檢查堆上的內存泄漏(MSDN中有相應的幫助)。Purify以一種比較巧妙的方式來解決這個問題:泄漏檢查,它在程序終止前使用,以類似於Java垃圾收集器一樣的方式工作。代替了內存釋放時,Purify顯示泄漏內存塊詳細的報告(圖13)。
這個報告是正確的,給pName分配了內存卻一直沒有釋放它。被用來釋放內存的代碼行被註釋掉了,但是也有可能是被忘記了。
類似Rational Purify一樣的工具帶來許多的好處之一是它不僅有發現內存泄漏的能力,而且能夠收集使用中的內存的信息,分配給程序使用的堆中含有的無效的指針。甚至可以給受控制運行的進程分配內存塊,如果程序使用較大塊內存的花,最終結果將會是性能下降。例如,操作系統可能開始使用指針指向的交換區空間或者甚至可是完全在內存外運行。在我定義條件時:有指針指向的內存快,或者在內存塊中有指針指向的位置,不會被當作是內存泄漏。然而,你也應該控制它們,甚至如果它們的大小可能會影響測試時程序的性能-或者甚至可能機器上運行了其它的程序。
例如,如果在使用new()分配內存時聲明變量爲靜態變量,Purify將會把這塊內存報告爲Memory In Use(MIU)
static char* pName = new char[MyBear.getLength()+1];
在你改正完圖14中所有的錯誤後,你可以認定這個程序時沒有bug的,可以發佈了。使用一個自動化的調試工具將會提供一些你需要知道的有關開發中的程序的額外的信息。你應該把它作爲一個構造一個可發佈版本的程序的主要標準。
擅長調試騰出更多的時間用戶設計開發
一個好的項目計劃,風險保障和一系列合適的工具使開發者的生活方式更加簡單,並能夠騰出更多的時間進行研究或實現新的特徵。你可以把基本的工具例如編譯器或調試器用於易於使用來調試aids和把它們結合起來進行運行時的錯誤檢測,就相我在文中講述的一樣。
無論技術和你使用的工具是否達到你的最終目標――生產高質量的軟件——應牢記調試的目的始終的相同的:發現被測試程序“謊言之下到底是什麼”(危機四伏J)。
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章