1. 程式人生 > >除錯技巧之呼叫堆疊

除錯技巧之呼叫堆疊

簡單介紹
  除錯是程式開發者必備技巧。如果不會除錯,自己寫的程式一旦出問題,往往無從下手。本人總結10年使用VC經驗,對除錯技巧做一個粗淺的介紹。希望對大家有所幫助。
  
  今天簡單的介紹介紹呼叫堆疊。呼叫堆疊在我的專欄的文章VC除錯入門提了一下,但是沒有詳細介紹。
  
  首先介紹一下什麼叫呼叫堆疊:假設我們有幾個函式,分別是function1,function2,function3,funtion4,且function1呼叫function2,function2呼叫function3,function3呼叫function4。在function4執行過程中,我們可以從執行緒當前堆疊中瞭解到呼叫他的那幾個函式分別是誰。把函式的順序關係看,function4、function3、function2、function1呈現出一種“堆疊”的特徵,最後被呼叫的函數出現在最上方。因此稱呼這種關係為呼叫堆疊(call stack)。
  
  當故障發生時,如果程式被中斷,我們基本上只可以看到最後出錯的函式。利用call stack,我們可以知道當出錯函式被誰呼叫的時候出錯。這樣一層層的看上去,有時可以猜測出錯誤的原因。常見的這種中斷時ASSERT巨集導致的中斷。
  
  在程式被中斷時,debug工具條的右側倒數第二個按鈕一般是call stack按鈕,這個按鈕被按下後,你就可以看到當前的呼叫堆疊。
  
  例項一:介紹
  我們首先演示一下呼叫堆疊。首先我們建立一個名為Debug的對話方塊工程。工程建立好以後,雙擊OK按鈕建立訊息對映函式,並新增如下程式碼:
  
  void CDebugDlg::OnOK()
  {
  
  // TODO: Add extra validation here
  ASSERT(FALSE);
  
  }
  
  我們按F5開始除錯程式。程式執行後,點選OK按鈕,程式就會被中斷。這時檢視call stack視窗,就會發現內容如下:
  
  CDebugDlg::OnOK() line 176 + 34 bytes
  _AfxDispatchCmdMsg(CCmdTarget * 0x0012fe74 {CDebugDlg}, unsigned int 1, int 0, void (void)* 0x5f402a00 `vcall'(void), void * 0x00000000, unsigned int 12, AFX_CMDHANDLERINFO * 0x00000000) line 88
  CCmdTarget::OnCmdMsg(unsigned int 1, int 0, void * 0x00000000, AFX_CMDHANDLERINFO * 0x00000000) line 302 + 39 bytes
  CDialog::OnCmdMsg(unsigned int 1, int 0, void * 0x00000000, AFX_CMDHANDLERINFO * 0x00000000) line 97 + 24 bytes
  CWnd::OnCommand(unsigned int 1, long 656988) line 2088
  CWnd::OnWndMsg(unsigned int 273, unsigned int 1, long 656988, long * 0x0012f83c) line 1597 + 28 bytes
  CWnd::WindowProc(unsigned int 273, unsigned int 1, long 656988) line 1585 + 30 bytes
  AfxCallWndProc(CWnd * 0x0012fe74 {CDebugDlg hWnd=???}, HWND__ * 0x001204b0, unsigned int 273, unsigned int 1, long 656988) line 215 + 26 bytes
  AfxWndProc(HWND__ * 0x001204b0, unsigned int 273, unsigned int 1, long 656988) line 368
  AfxWndProcBase(HWND__ * 0x001204b0, unsigned int 273, unsigned int 1, long 656988) line 220 + 21 bytes
  USER32! 77d48709()
  USER32! 77d487eb()
  USER32! 77d4b368()
  USER32! 77d4b3b4()
  NTDLL! 7c90eae3()
  USER32! 77d4b7ab()
  USER32! 77d7fc9d()
  USER32! 77d76530()
  USER32! 77d58386()
  USER32! 77d5887a()
  USER32! 77d48709()
  USER32! 77d487eb()
  USER32! 77d489a5()
  USER32! 77d489e8()
  USER32! 77d6e819()
  USER32! 77d65ce2()
  CWnd::IsDialogMessageA(tagMSG * 0x004167d8 {msg=0x00000202 wp=0x00000000 lp=0x000f001c}) line 182
  CWnd::PreTranslateInput(tagMSG * 0x004167d8 {msg=0x00000202 wp=0x00000000 lp=0x000f001c}) line 3424
  CDialog::PreTranslateMessage(tagMSG * 0x004167d8 {msg=0x00000202 wp=0x00000000 lp=0x000f001c}) line 92
  CWnd::WalkPreTranslateTree(HWND__ * 0x001204b0, tagMSG * 0x004167d8 {msg=0x00000202 wp=0x00000000 lp=0x000f001c}) line 2667 + 18 bytes
  CWinThread::PreTranslateMessage(tagMSG * 0x004167d8 {msg=0x00000202 wp=0x00000000 lp=0x000f001c}) line 665 + 18 bytes
  CWinThread::PumpMessage() line 841 + 30 bytes
  CWnd::RunModalLoop(unsigned long 4) line 3478 + 19 bytes
  CDialog::DoModal() line 536 + 12 bytes
  CDebugApp::InitInstance() line 59 + 8 bytes
  AfxWinMain(HINSTANCE__ * 0x00400000, HINSTANCE__ * 0x00000000, char * 0x00141f00, int 1) line 39 + 11 bytes
  WinMain(HINSTANCE__ * 0x00400000, HINSTANCE__ * 0x00000000, char * 0x00141f00, int 1) line 30
  WinMainCRTStartup() line 330 + 54 bytes
  KERNEL32! 7c816d4f()
  
  這裡,CDebugDialog::OnOK作為整個呼叫鏈中最後被呼叫的函數出現在call stack的最上方,而核心中程式的啟動函式Kernel32! 7c816d4f()則作為棧底出現在最下方。
  
  例項二:學習處理方法
  微軟提供了MDI/SDI模型提供文件處理的建議結構。有些時候,大家希望控制某個環節。例如,我們希望彈出自己的開啟檔案對話方塊,但是並不想自己實現整個文件的開啟過程,而更願意MFC完成其他部分的工作。可是,我們並不清楚MFC是怎麼處理文件的,也不清楚如何插入自定義程式碼。
  
  幸運的是,我們知道當一個文件被開啟以後,系統會呼叫CDocument派生類的Serialize函式,我們可以利用這一點來跟蹤MFC的處理過程。
  
  我們首先建立一個預設的SDI工程Test1,並在CTest1Doc::Serialize函式的開頭增加一個斷點,執行程式,並開啟一個檔案。這時,我們可以看到呼叫堆疊是(我只截取了感興趣的一段):
  
  CTest1Doc::Serialize(CArchive & {...}) line 66
  CDocument::OnOpenDocument(const char * 0x0012f54c) line 714
  CSingleDocTemplate::OpenDocumentFile(const char * 0x0012f54c, int 1) line 168 + 15 bytes
  CDocManager::OpenDocumentFile(const char * 0x0042241c) line 953
  CWinApp::OpenDocumentFile(const char * 0x0042241c) line 93
  CDocManager::OnFileOpen() line 841
  CWinApp::OnFileOpen() line 37
  _AfxDispatchCmdMsg(CCmdTarget * 0x004177f0 class CTest1App theApp, unsigned int 57601, int 0, void (void)* 0x00402898 CWinApp::OnFileOpen, void * 0x00000000, unsigned int 12, AFX_CMDHANDLERINFO * 0x00000000) line 88
  CCmdTarget::OnCmdMsg(unsigned int 57601, int 0, void * 0x00000000, AFX_CMDHANDLERINFO * 0x00000000) line 302 + 39 bytes
  CFrameWnd::OnCmdMsg(unsigned int 57601, int 0, void * 0x00000000, AFX_CMDHANDLERINFO * 0x00000000) line 899 + 33 bytes
  CWnd::OnCommand(unsigned int 57601, long 132158) line 2088
  CFrameWnd::OnCommand(unsigned int 57601, long 132158) line 317
  
  
  從上面的呼叫堆疊看,這個過程由一個WM_COMMAND訊息觸發(因為我們用選單開啟檔案),由CWinApp::OnFileOpen最先開始實際處理過程,這個函式呼叫CDocManager::OnFileOpen開啟文件。
  
  我們首先雙擊CWinApp::OnFileOpen() line 37開啟CWinApp::OnFileOpen,它的處理過程是:
  
   ASSERT(m_pDocManager != NULL);
   m_pDocManager->OnFileOpen();
  
  m_pDocManager是一個CDocManager類的例項指標,我們雙擊CDocManager::OnFileOpen行,看該函式的實現:
  
  void CDocManager::OnFileOpen()
  {
   // prompt the user (with all document templates)
   CString newName;
   if (!DoPromptFileName(newName, AFX_IDS_OPENFILE,
   OFN_HIDEREADONLY | OFN_FILEMUSTEXIST, TRUE, NULL))
   return; // open cancelled
   AfxGetApp()->OpenDocumentFile(newName);
   // if returns NULL, the user has already been alerted
  }
  
  很顯然,該函式首先呼叫DoPromptFileName函式來獲得一個檔名,然後在繼續後續的開啟過程。
  
  順這這個線索下去,我們一定能找到插入我們檔案開啟對話方塊的位置。由於這不是我們研究的重點,後續的分析我就不再詳述。
  
  例項三:記憶體訪問越界
  在Debug版本的VC程式中,程式會給每塊new出來的記憶體,預留幾個位元組作為越界檢測之用。在釋放記憶體時,系統會檢查這幾個位元組,判斷是否有記憶體訪問越界的可能。
  
  我們借用前一個例項程式,在CTest1App::InitInstance的開頭新增以下幾行程式碼:
  
   char * p = new char[10];
   memset(p,0,100);
   delete []p;
   return FALSE;
  
  很顯然,這段程式碼申請了10位元組記憶體,但是使用了100位元組。我們在memset(p,0,100);這行加一個斷點,然後執行程式,斷點到達後,我們觀察p指向的記憶體的值(利用Debug工具條的Memory功能),可以發現它的值是:
  
   CD CD CD CD CD CD CD CD
   CD CD FD FD FD FD FD FD
   00 00 00 00 00 00 00 00
   ......
  
  根據經驗,p實際被分配了16個位元組,後6個位元組用於保護。我們按F5全速執行程式,會發現如下的錯誤資訊被彈出:
  
   Debug Error!
   Program: c:\temp\test1\Debug\test1.exe
   DAMAGE: after normal block (#55) at 0x00421AB0
   Press Retry to debug the application
  
  該資訊提示,在正常記憶體塊0x00421AB0後的記憶體被破壞(記憶體訪問越界),我們點選Retry進入除錯狀態,發現呼叫堆疊是:
  
  _free_dbg_lk(void * 0x00421ab0, int 1) line 1033 + 60 bytes
  _free_dbg(void * 0x00421ab0, int 1) line 970 + 13 bytes
  operator delete(void * 0x00421ab0) line 351 + 12 bytes
  CTest1App::InitInstance() line 54 + 15 bytes
  
  很顯然,這個錯誤是在呼叫delete時遇到的,出現在CTest1App::InitInstance() line 54 + 15 bytes之處。我們很容易根據這個資訊找到,是在釋放哪塊記憶體時出現問題,之後,我們只需要根據這個記憶體的訪問過程確定哪兒出錯,這將大大降低除錯的難度。
  
  例項四:子類化
  子類化是我們修改一個現有控制元件實現新功能的常用方法,我們借用例項一中的Debug對話方塊工程來演示我過去學習子類化的一個故事。我們建立一個預設的名為Debug的對話方塊工程,並按照下列步驟進行例項化:
  
  在對話方塊資源中增加一個Edit控制元件
  用class wizard為CEdit派生一個類CMyEdit(由於今天不關心子類化的具體細節,因此這個類不作任何修改)
  為Edit控制元件,增加一個控制元件型別變數m_edit,其型別為CMyEdit
  在OnInitDialog中增加如下語句:
  
  m_edit.SubclassDlgItem(IDC_EDIT1,this);
  
  我們執行這個程式,會遇到這樣的錯誤:
  
  
  Debug Assertion Failed!
  Application:C:\temp\Debug\Debug\Debug.exe
  File:Wincore.cpp
  Line:311
  
  For information on how your program can cause an assertion failure, see Visual C++ documentation on asserts.
  
  (Press Retry to debug the application)
  
  點選Retry進入除錯狀態,我們可以看到呼叫堆疊為:
  
  CWnd::Attach(HWND__ * 0x000205a8) line 311 + 28 bytes
  CWnd::SubclassWindow(HWND__ * 0x000205a8) line 3845 + 12 bytes
  CWnd::SubclassDlgItem(unsigned int 1000, CWnd * 0x0012fe34 {CDebugDlg hWnd=0x001d058a}) line 3883 + 12 bytes
  CDebugDlg::OnInitDialog() line 120
  
  可以看出在Attach控制代碼時出現問題,出問題行的程式碼為:
  
   ASSERT(m_hWnd == NULL);
  
  這說明我們在子類化時不應該繫結控制元件,我們刪除CDebugDialog::DoDataExchange中的下面一行:
  
   DDX_Control(pDX, IDC_EDIT1, m_edit);
  
  問題就得到解決
  
  總結
  簡而言之,call stack是除錯中必須掌握的一個技術,但是程式設計師需要豐富的經驗才能很好的掌握和使用它。你不僅僅需要熟知C++語法,還需要對相關的平臺、軟體設計思路有一定的瞭解。我的文章只能算一個粗淺的介紹,畢竟我在這方面也不算高手。希望對新進有一定的幫助。
  
  
  除錯之程式設計準備
  
  對於一個程式設計師而言,學習一種語言和一種演算法是非常容易的(不包括那些上學花很多時間玩,上班說學習沒時間的人)。但是,任何程式都可能是有瑕疵的,尤其有過團隊協作程式設計經驗的人,對這個感觸尤為深刻。
  
  
  在我前面的述及除錯的文章裡,我側重於VC整合環境中的一些設定資訊和除錯所需要的一些基本技巧。但是,僅僅知道這些是不夠的。一個成功的除錯的開端是程式設計中的準備。
  
  分離錯誤
  很多程式設計師喜歡寫下面這樣的式子:
  
   CLeftView* pView =
   ((CFrameWnd*)AfxGetApp()->m_pMainWnd)->m_wndSplitterWnd.GetPane(0,0);
  
  如果一切順利,這樣的式子當然是沒什麼問題。但是作為一個程式設計師,你應該時刻記得任何一個呼叫在某些特殊的情況下都可能失敗,一旦上面某個式子失敗,那麼整個級聯式就會出問題,而你很難弄清楚到底哪兒出錯了。這樣的式子的結果往往是:省了2分鐘編碼的時間,多了幾星期的除錯時間。
  
  對於上面的式子,應該儘可能的把式子分解成獨立的函式呼叫,這樣我們可以隨時確定是哪個函式調用出問題,進口縮小需要檢查的範圍。
  
  檢查返回值
  檢查返回值對於許多程式設計者來說似乎是一個很麻煩的事情。但是如果你能在每個可能出錯的函式呼叫處都檢查返回值,就可以立刻知道出錯的函式。
  
  有些人已經意識到檢查返回值的重要性,但是要記住,只檢查函式是否失敗是不夠的,我們需要知道函式失敗的確切原因。例如下面的程式碼:
  
  if(connect(sock, (const sockaddr*)&addr,sizeof(addr)) == SOCKET_ERROR)
  {
   AfxMessageBox("connect failed");
  }
  
  儘管這裡已經檢查了返回值,實際上沒有多少幫助。正如很多在vckbase上提問的人一樣,大概這時候只能喊“為什麼連線失敗啊?”。這種情況下,其實只能猜測失敗的原因,即使高手,也無法準確說出失敗的原因。
  
  增加診斷資訊
  在知道錯誤的情況下,應該儘可能的告訴測試、使用者更多的資訊,這樣才能瞭解導致失敗的原因。如果程式設計師能提供如下錯誤資訊,對於診斷錯誤是非常有幫助的:
  
  出錯的檔案:我們可以藉助巨集THIS_FILE和__FILE__。注意THIS_FILE是在cpp檔案手工定義的,而__FILE__是編譯器定義的。當記錄錯誤的函式定義在.h中時,有時候用THIS_FILE更好,因為他能說明在哪個cpp中呼叫並導致失敗的。
  出錯的行:我們可以藉助巨集__LINE__
  出錯的函式:如果設計的好,有以上兩項已經足夠。當然我們可以直接打印出出錯的函式或者表示式,這樣在大堆程式碼中搜索(尤其是不支援go to line的編輯器中)還是很有用的。大家可以參見我的文章http://blog.vckbase.com/arong/archive/2005/11/10/14704.html中的方式進行處理,也許是一個基本的開端。
  出錯的原因:出錯的原因很多隻能由程式自己給出。如果出錯只會問別人,那麼你永遠不可能成為一個合格的程式設計人員。很多函式失敗時都會設定errno。我們可以用GetLastError獲得錯誤碼,並通過FormatMessage打印出具體錯誤的文字描述。