本文分為兩部分,在此我們先來學習一些基本的使用
Visual Studio
調試
Win32
應用程式的基礎知識。
作者: Goran Begic, Technical Marketing Engineer, Development Solutions, IBM Rational
翻譯:wyingquan # hotmail.com 2006-02-09圖1: Visual Studio調試器視窗
每當提及我們為提高軟體品質做了多少工作時,開發人員總會拍胸脯保證沒有問題。然而,你要永遠記住一個不爭的事實:程式中將始終存在bug。畢竟,程式是人設計的,那就當然不會沒有bug的。因此,調試——修複缺陷這一最耗時且最昂貴的過程——在大型軟體開發過程中始終佔據一席之地。廣而言之,調試也包括各種各樣的編程技術,它能使開發人員能夠預見程式中潛在的問題。由於開發過程的高度複雜,調試工作就變得更加繁瑣。實際上,為了全面掌控整個調試活動,你就必須關注整個開發過程。正如一些“調試人員”將要告訴你的,定位真正的引起產生缺陷的原因是所有工作中最困難的;修複代碼的缺陷是調試過程中最簡單的一步而已。本文的第一部分將向您介紹Microsoft Visual Studio程式開發環境並將討論使用Microsoft Visual Studio編譯器進行最基本的程式調試方法。
形形色色的
bug
準確的來說,什麼是bug呢?如果你的應用程式安裝在任何機器上都會崩潰,你就會知道程式肯定有bug。但是以下這種情況呢?通過自己的測試發現程式運行良好,就自豪地發布了它。但不久後,一些重要的客戶回函了一些令人為難的“問題”,這些問題僅在一些機器上的某種配置情況下出現。這種問題我們當然也要把它稱為bug。實際上,我們把許多不同類型的問題都稱為bug。資料被破壞是其中層級最高的,但是應用程式由於設計缺陷或者甚至介面設計混亂也會導致使用者操作上的一些不方便(即不符合使用者的習慣或不符合約定俗成)。例如:你是否在Microsoft Outlook中使用快速鍵Ctrl+F,彈出的不是預期的尋找視窗,而是轉寄視窗(在幾乎所有的應用程式中,Ctrl+F快速鍵都會調出尋找視窗)。
調試方法和技術
時下流行的一個口號叫“防禦性的編程”。這是一個由許多技術和策略組成的用於編寫代碼的方法,有助於早期發現錯誤。例如,編寫巧妙的代碼,使用所有開發人員都理解和認可的符號(變數名稱)編寫代碼,這樣有助於減少bug。此外,防禦性的編程技術還包含了由程式語言提供的大量非常有用的宏定義和函數,這些都有助於你在程式執行期間檢查重要的事件。 即使在度量軟體品質期間你也應該進行程式調試。在有了過硬的品質保證的同時,也讓專案管理人員能夠為系統後期開發做出決策。 為了避免由於代碼中的bug引起發布時間的延期,你應該在整個開發週期中儘可能早地開始調試。例如:最理想的是在專案計劃時就開始——在為第一個原型整理需求時。在早期的原型中加入過多的特徵可能會引起缺陷的增加,並且會導致在修複這些特徵上花費大量的人力和時間。花費在調試一個有過多特徵的軟體上的時間要遠遠多於花在一個簡單而又強壯的程式上的時間。你也應當使所有項目組之間保持一致的編程環境,一致的文檔和代碼——使用版本控制軟體——並且對修改後的程式進行“煙霧測試 (Smoke Test)”。如果你正好文檔化了源碼的所有改變,那麼就會發現一般新的bug都出現在最近修改過的代碼中,關注於這些變化會大大地減少花費在問題修複上的時間。在我前面提及到過,不管多麼仔細或者維護你的代碼,甚至你的程式看上去運行正確,但實際情況是:程式中還會包含bug。軟體開發工具一般都會把這些考慮進去,並且包含了有助於盡量消除軟體缺陷。那麼開發人員需要的基本工具有哪些呢?至少需要四個:· 一個編輯器· 一個編譯器· 一個調試器· 一個自動化的運行時調試器 如果沒有這些工具,你不可能開發一個成功的應用程式。實際上自動化運行時調試器是最近才加入到以上列表中的,它的主要任務是在程式運行期間定位錯誤,這些程式在運行時可能很難手工完成——即使對一些開發高手。
Visual Studio
程式開發環境
在本文中,我將會全面地透視使用Mocrosoft Visual C++編譯器構建的一段代碼的運行時調試。當然,市面上也有許多其它類型的編譯器,有些甚至時免費提供的,但是我選擇這個工具的原因時因為它最有可能是你已經安裝的。它並不會快速產生代碼,也不是沒有bug,它也不是最遵循ANSI C++標準的編譯器,但是它被廣泛地用於C++ Win32平台上的軟體開發。 我將會以介紹一些可能用到的術語和工具來開始。儘管這些對Visual C++開發人員來說已經相當的熟悉,但對那些在UNIX上或者使用Java的人來說可能比較陌生。
Visual Studio
整合式開發環境(IDE) 許多應用程式使用此環境進行開發,它由編輯器和一些菜單、工具列組成,通過它們來調用編譯器和連接器、調試器等。也可能使用其它的編輯器編寫的代碼而通過命令列方式調用Visual C++編譯器和連接器。
VC++
工程 一個Visual C++工程就是一個檔案夾,該檔案夾中儲存著用來構建應用程式的所有檔案。它是在你選擇了所開發程式的類型後程式自動產生的。你也可以自己建立一個空的工程然後再單獨編寫代碼。當你構建應用程式時,預設情況下它會產生在工程檔案夾的子檔案夾中。同時預設情況下有種編譯類型:Debug和Release。Debug版包含了一些有助於偵錯工具的額外的資訊,Release版只包含了最基本的資訊,用來發布。Release版的程式在大小和速度上進行了最佳化,並且不會把一些程式源碼資訊暴露給終端使用者。工程定義檔案儲存在副檔名.dsp的檔案中,工作區資訊儲存在副檔名為.dsw的檔案中。
調試器 用來控製程序運行,使使用者能夠瀏覽程式的每個執行步驟、檢查程式使用的所有變數、所有的記憶體配置和處理器的寄存器中的內容。因為即使是一個相當簡單的應用程式也可能包含成千上萬條機器指令,如果要瀏覽這些指令的話你必須熟知這些機器碼,通過它們能夠一直檢查應用程式。你可以使用調試器(這裡指的是Visual Studio調試器)在源碼中設定斷點,從而檢查程式在斷點位置的運行情況。
調試器視窗 在Visual Studio Debuger中運行一個程式時,它會開啟一些預設的視窗;您也可以根據需要開啟其它一些調試視窗。圖1顯示了我在使用Visual Studio Debugger時經常開啟的視窗。 Visual Studio Debugger的主視窗是標準的編輯視窗。箭頭指向了當前應用程式執行到的位置。如果源檔案中設定的斷點不能夠到達,那麼調試器將以raw機器碼的形式顯示彙編視窗。在Visual Studil Debugger介面的右方,你可以看到寄存器視窗和堆棧調用視窗:· “寄存器”視窗顯示寄存器內容。可以用來查看CPU寄存器的名稱及其內容,如果在執行程式過程中將“寄存器”視窗始終開啟,則在代碼執行時會看到寄存器值的變化。最近更改過的值顯示為紅色。· “呼叫堆疊”視窗使您可以查看呼叫堆疊上的函數名、參數類型和參數值。僅當正在調試的程式處於中斷狀態時,才顯示呼叫堆疊資訊。 在呼叫堆疊視窗之下是“記憶體”視窗。顯示了指定地址指向的虛擬記憶體的內容。你可以在視窗該視窗的地址欄中輸入您想查看的地址從而查看它所指向的內容。你也可以從源碼視窗或其它調試視窗中拖動變數或地址到該視窗中查看所對應指向的內容。 最後介紹的是變數視窗和監看式視窗:· 變數視窗包含三個頁簽。自動:當調試本地應用程式和逐過程進行函數調用 (F10) 時,“自動視窗”將顯示這個函數以及可能由這個函數調用的所有函數的傳回值。局部變數:包含當前範圍內所有局部變數的名稱、值以及類型。This (Me):顯示 this 指標指向的對象的名稱、值以及類型。可以直接鍵入變數名或從源碼和調試視窗中直接托拽變數到該視窗來顯示它們的值。· 監看式視窗。可以使用“監視”視窗計算變數和運算式並保留結果。還可以使用“監視”視窗編輯變數或寄存器的值。同樣也可以直接鍵入變數名或從源碼和調試視窗中直接托拽變數到該視窗來顯示它們的值。
使用編譯器進行調試
編譯器用來把源碼編譯成機器碼。然而編譯器的功能不僅僅是這些,它同時可以用來檢測報告在編譯過程中發現的各種錯誤和一些跟靜態記憶體配置的潛在的問題。例如:如果你注意到了編譯器的警告層級的話,你也可以通過選擇正確的警告層級設定來避免一些不必要的麻煩。幾乎每種編譯器都會檢測出語法錯誤,但是即使代碼中沒有語法錯誤也並不意味著代碼中不存在錯誤。讓我們來看下面的例子:
PLeftEdge = new char(strlen(pLeft) + 1);
strcpy(pLeftEdge, pLeft);
pLeftEdge = new char(strlen(pRight) + 1);
strcpy(pRightEdge, pRight); 如果你在使用strcpy()函數前未檢查它的參數--或者大量的使用copy/paste函數――或許這種bug將來就會折磨你。編譯器不會認為這樣的代碼是有錯誤的,因為它的文法是正確的。運行含有這種代碼的程式問題很快就會暴露出來,因為問題的根本在於:你使用了一個未初始化的字串作為函數stecpy(pRightEdge,pRight)的參數。在後面我們還會用到這個例子,屆時我們再調試一個存在相似問題的程式。 但是,編譯器在一些情況下也會有很大用處。接下來詳細介紹一下使用Microsoft C++ 編譯器構建的代碼的Just-in-Time 偵錯方法。
Visual C++
調試設定 Visual C++提供了兩種預設的構建設定:Release(發行版)和Debug(調試版)。兩種構建配置使用同樣的編譯器,你也可以任意設定兩種配置的參數。對C++來說,修改了某些選項後他們還是屬於發行版和調試版。因此可能有人會問,那麼發行版和調試版有什麼不同?調試版配置包含了有助於偵錯工具的資訊,而發行版的配置旨在提高程式的效能。因為大多數程式員更傾向於測試一個接近發布的程式,所以這裡我主要介紹一些有助於調試發行版的相關知識,並會解釋發行版和調試版的不同之處。
符號調試資訊 發行版和調試版最主要的不同之處是調試版的配置預設情況下會建立符號調試資訊。如果你相調試發行版的程式,必須確保編譯器和連接器的選項中設定了建立符號調試資訊的相關參數。符號資訊檔被儲存在一個單獨的檔案或程式碼片段中,包含了從彙編指令到源碼的一系列資訊。如果沒有這些資訊,那調試只能在彙編層級上進行;儘管某些人可能會讀懂彙編代碼,但這顯然不便於進行調試的。 符號調試資訊有多種類型。Microsoft編譯器預設的類型是so-called PDB檔案,使用參數/Zi或者/ZI建立(使用這個參數建立的資訊具有“Edit and Continue”的特性)。 PDB檔案預設儲存在Debug目錄下,名稱同執行檔案名稱,副檔名是.pdb。需要注意的是預設情況下Visual C++ 6編譯器會建立一個名為VC60.pdb的檔案,它是在編譯期間產生的,建立這個檔案的原因是,編譯器不知道將要產生的可執行檔的名字。在連接器啟用之前,所需要的.obj檔案並未確定,所以這些資訊暫時儲存在這個檔案中。預設情況下連接器不會將它合并到projectname.pdb檔案中,除非你設定了讓他們合并。 Visual Studio中的連接器產生pdb的預設選項為/PDBTYPE:SEPT。你也可以在發行版和調試版配置中改為/PDBTYPE:CON。CON表示“統一的”。如果通過Visual Studio的DEBUG設定中改變這些設定的話串連中的設定不會跟著改變。我想如果使用設定產生單獨的PDB檔案會在效能上有一些優勢,但是你完全可以忽略這點,因為當今的機器配置都已經相當的高了。如果你想在別的機器上調試你的程式,那麼包含符號調試資訊就顯得相當的重要——並且如果你使用了其它的測試載入器的話也必須提供所需的符號調試資訊。為了保持完整性,PDB檔案必須和執行檔案相匹配;必須放到同一個目錄下,並且不要使用我上面提及的串連選項。 另外可選的產生符號調試的類型是相容C7(使用編譯選項/Z7,將產生一個.obj檔案和一個.exe檔案,並帶有調試器使用的行號和全部的符號調試資訊)和“Line numbers only”(使用編譯選項/Zd,修改.obj檔案或可執行檔的翻譯,以使其只包含全域和外部符號以及行號資訊,但不包含符號調試資訊)。選擇相容C7類型產生的調試資訊將包含在執行檔案中並包含行號和全部的符號調試資訊。這樣相容了DOS下的CodeView格式。“Line numbers only”不包含符號調試資訊。
重定位資訊 重定位資訊,或稱“relocs”,是儲存在二進位可執行檔中的一個包含定位資訊的表格式資訊。這些資訊服務於所有地址資訊,當執行模組載入到記憶體中得一個基址時其它地址由連接器設定。如果執行模組被載入到預定的基址,那麼重定位資訊就是沒用的。像Rational Purify這樣的運行時調試工具使用這些資訊來對模組進行“插裝”,由於Purify經常不得不重定位記憶體中插裝後的模組(如果原本位置不能再被使用得話)。項目串連設定選項/FIXED:NO,提供了強制建立重定位模組資訊的功能。發布版設定中未設定此選項,你必須手工在串連設定對話方塊中加入它。
最佳化編譯 最佳化的目的是使建立的目標代碼變小(使用記憶體小)或者執行速度加快。為了達到這個目的,編譯器對彙編代碼做了各種最佳化。例如,去除掉一些多餘的代碼,去除多餘的運算式,最佳化迴圈和使用內嵌函式等。如果是為了調試的目的的話那麼應該關閉發布版和調試版中的所有最佳化選項。最佳化編譯的代碼會使調試變得更加困難。同時也意味著當你在調試最佳化編譯的程式時設定斷點要比調試一個未最佳化版本的程式困難的多的多。
串連時使用
debug
版執行階段程式庫 預設情況下調試版本的構建設定在串連階段包含調試版的C執行階段程式庫。這會對尋找bug有所協助,因為這樣一來使用了調試版的動態記憶體分配。例如,增加位元模式來標識已經被分配的記憶體,並且這些資訊有助於檢測是否發生越界訪問。它們也佔用了一定的記憶體,位於每一處新分配記憶體塊的後面,因此被稱作為“守護位元組”,用於檢查越界訪問。 調試版的記憶體配置函數為malloc_dbg和heap_alloc_dbg。這樣所有對的malloc()和new()調用都將解釋為調試版的函數。記憶體釋放函數free()和delete()都解釋為調試版的函數free_dbg。未被初始化的記憶體的內容為0xCD,在記憶體配置結構邊界上的記憶體的內容為0xFD,空閑記憶體的內容為0xDD。在發布版的構建配置中使用調試版的執行階段程式庫也將會使構建成為調試版的構建版本。而且如果你有商業的運行時調試工具的話基本上就用不到它。因此在構建發布版的偵錯工具時使用動態串連執行階段程式庫時非常重要的(預設編譯選項是/MD)。
警告層級 Visual Studio的預設警告層級是Level 3(編譯選項/W3)。該層級將報告例如在函數原型執行前調用的資訊。如果是處於調試的目的的話,可以修改警告層級為/W4,這樣有助於檢測所有未初始化的局部變數和雖然已經初始化但未被使用的變數。因為缺少對Visual C++編譯器的瞭解,這些都是部分有用的。部分原因是由於/W4層級下也產生了大量的並非真正的警告資訊(即它會誤判),這樣就會導致難於定位潛在的問題。然而,如果你把所有的警告資訊都當作錯誤看待,那麼你寫的代碼當然會比預設層級下編寫的代碼存在的缺陷更少。/W4層級的另一個好處是可以預防宣告失敗。
在
Debug
版本中捕獲
Release
版本的錯誤 當使用/GZ選項時,Visual C++將自動初始化所有局部變數(使用值0xCCCCCCCC),檢查函數指標呼叫堆疊的合法性,並檢查呼叫堆疊的合法性。調試版的預設設定中預設啟用了該選項,你也可以在發布版的配置中手工設定。
運行時調試:都是與記憶體相關的 當你要執行你的程式時,它首先會被載入到記憶體。實際上,因為Windows使用了記憶體映射機制,執行檔案只有部分在需要的時候才被換入記憶體。另外Windows系統使用攜帶型格式文檔,從結構上來看也跟他們在記憶體中的映象一致。
堆和棧 當程式啟動時,使用記憶體頁來儲存程式所使用的所有靜態和動態資料。每個進程都至少使用了兩種類型的記憶體地區:· 棧,或待用資料塊,是儲存所有自動變數的記憶體區。在Win32平台,每個線程都有它自己的靜態記憶體地區。主程式線程所使用的堆的大小是在編譯期間就已經確定了的,預設情況下它的值為1MB。棧的大小也可以使用串連選項/STACK:reserve[,commit]選項指定。也可以在模組定義檔案中使用STACKSIZE語句覆蓋這個值,或者使用EDITBIN.EXT工具直接修改二進位可執行檔。· 堆,或稱自由儲存區,是虛擬記憶體中使用不受約束的地區,使用控制代碼來標識,並且使用範圍僅受可用虛擬記憶體的限制。對上的動態結構和控制代碼是在運行時分配的。 每個進程都至少擁有一個預設的堆,但有的進程可能有許多的動態堆。程式運行期間通過堆API函數從堆上分配記憶體塊。進程建立的預設的堆是私人的,並且不能被其它進程使用。預設堆的記憶體保留地區和提交區的大小是在串連期間已經確定了的。你可以改變預設堆的大小(1MB),使用串連選項/HEAP:reserve[,commit]。同樣也可以使用EDITBIN工具修改已經編譯串連好的二進位執行檔案。 堆的分配函數有三種類型:· GlobalAlloc/GlobalFree and LocalAlloc/LocalFree用於在預設堆上分配/回收記憶體· COM Imalloc allocator(CoTaskMemAlloc/CoTaskMenFree),用於預設堆上的記憶體配置· C運行時記憶體配置API——new()/delete()和malloc()/free(),用於C運行時在私人堆上的記憶體配置/回收 此外Win32 API中的VirtualAlloc()和VirtualFree()函數用於虛擬記憶體頁的分配。你可以在Win32應用程式中直接調用這兩個函數,但是一般這樣的函數是用不著的,除非你想一次性分配一大塊記憶體。
這僅僅是調試最艱難一步的開始——控制和調試動態分配結構。然而很顯然第一步是:必須讓程式運行。我將在本文的第2部分講解這一部分。