Windows用戶態程序高效排錯 -- 彙編,CPU執行指令的最小單元

讀懂機器的語言:彙編,CPU執行指令的最小單元

2.2.1  需要用匯編來排錯的常見情況

彙編是CPU執行指令的最小單元。下面一些情況下,彙編級別的分析通常是必要的:

1.         閱讀代碼看不出問題,但是跑出來的結果就是不對,懷疑編譯器甚至CPU有毛病。

2.         沒有源代碼可以閱讀。比如,調用某一個API的時候出問題,沒有Windows的源代碼,那就看彙編。

3.         當程序崩潰,訪問違例的時候,調試器裏看到的直接信息就是彙編。

調試中涉及的彙編知識分爲兩部分:

1.         寄存器的運算,對內存地址的尋址和讀寫。這部分是跟CPU本身相關的。

2.         函數調用時候堆棧的變化,局部變量全局變量的定位,虛函數的調用。這部分是跟編譯器相關的。

彙編的知識可以在大學計算機教程裏面找到。建議先熟悉簡單的8086/80286的彙編,再結合IA32芯片結構和32位Windows彙編知識深入。建議的資源:

AoGo彙編小站

http://www.aogosoft.com/

Intel Architecture Manual volume 1,2,3

http://www.intel.com/design/pentium4/manuals/index_new.htm

案例分析:用匯編讀懂VC編譯器的優化

問題描述

客戶在開發一個性能敏感的程序,想知道VC編譯器對下面這段代碼的優化做得怎麼樣:

    int hgt=4;

    int wid=7;

    for (i=0; i<hgt; i++)

      for (j=0; j<wid; j++)

        A[i*wid+j] = exp(-(i*i+j*j));

最直接的方法就是查看編譯器生成的彙編代碼分析。有興趣的話先自己調試一下,看看跟我的分析是否一樣。

我的分析

我分析的平臺是,VC6,release mode下編譯:(因爲當時做這個case的時候,客戶用的VC6。現在VC6已經退出歷史舞臺,微軟不再提供支持)。

int hgt=4;

int wid=7;

24:       for (i=0; i<hgt; i++)

0040107A   xor         ebp,ebp

0040107C   lea         edi,[esp+10h]

25:        for (j=0; j<wid; j++)

26:            A[i*wid+j] = exp(-(i*i+j*j));

00401080   mov         ebx,ebp

00401082   xor         esi,esi

// The result of i*i is saved in ebx

00401084   imul        ebx,ebp

00401087   mov         eax,esi

// Only one imul occurs in every inner loop (j*j)

00401089   imul        eax,esi    

// Use the saved i*i in ebx directly. !!Optimized!!

0040108C   add         eax,ebx 

0040108E   neg         eax         

00401090   push        eax

00401091   call        @ILT+0(exp) (00401005)

00401096   add         esp,4           

// Save the result back to A[]. The addr of current offset in A[] is saved in edi

00401099   mov         dword ptr [edi],eax 

0040109B   inc         esi

// Simply add edi by 4. Does not calculate with i*wid. Imul is never used. !!Optimized!!

0040109C   add         edi,4

0040109F   cmp         esi,7

004010A2   jl          main+17h (00401087)

004010A4   inc         ebp

004010A5   cmp         ebp,4

004010A8   jl          main+10h (00401080)

這段代碼涉及到的優化有:

1.         i*i在每次內循環中是不變化的,所以只需要在外循環裏面重新計算。編譯器把外循環計算好的i*i放到ebx寄存器中,內循環直接使用。

2.         對A[i*wid+j]尋址的時候,在內循環裏面,變化的只有j,而且每次j都是增加1,由於A是整型數組,所以每次尋址的變化就是增加1*sizeof(int),就是4。編譯器把i*wid+j的結果放到了EDI中,在內循環中每次add edi,4來實現了這個優化。

3.         對於中間變量,編譯器都是保存在寄存器中,並沒有讀寫內存。

如果這段彙編讓你手動來寫,你能做得比編譯器更好一點嗎?

案例分析:VC2003 編譯器的bug、debug模式正常,release模式會崩潰

不要迷信編譯器沒有bug。如果你在VS2003中測試下面的代碼,會發現在release mode下面,程序會崩潰或者異常,但是在debug mode下工作正常。

例子程序

// The following code crashes/abnormal in release build when "whole program optimizations /GL"

// is set. The bug is fixed in VS2005

#include <string>

#pragma warning( push )

#pragma warning( disable : 4702 )     // unreachable code in <vector>

#include <vector>

#pragma warning( pop )

#include <algorithm>

#include <iostream>

//vcsig

// T = float, U = std::cstring

template <typename T, typename U>  T func_template( const U & u )

{

  std::cout<<u<<std::endl;

  const char* str=u.c_str();

  printf(str);

  return static_cast<T>(0);

}

void crash_in_release()

{

  std::vector<std::string>  vStr;

  vStr.push_back("1.0");

  vStr.push_back("0.0");

  vStr.push_back("4.4");

  std::vector<float>  vDest( vStr.size(), 0.0 );

  std::vector<std::string>::iterator _First=vStr.begin();

  std::vector<std::string>::iterator _Last=vStr.end();

  std::vector<float>::iterator _Dest=vDest.begin();

  std::transform( _First,_Last,_Dest, func_template<float,std::string> ); 

   _First=vStr.begin();

   _Last=vStr.end();

   _Dest=vDest.begin();

  for (; _First != _Last; ++_First, ++_Dest)

    *_Dest =  func_template<float,std::string>(*_First); 

}

int main(int, char*)

{

  getchar();

  crash_in_release();

  return 0;

}

編譯設定如下:

1.         取消precompiled header。

2.         編譯選項是: /O2 /GL /D "WIN32" /D "NDEBUG" /D "_CONSOLE" /D "_MBCS" /FD /EHsc /ML /GS /Fo"Release/" /Fd"Release/vc70.pdb" /W4 /nologo /c /Wp64 /Zi /TP。

跟蹤彙編指令來分析

拿到這個問題後,首先在本地重現。根據下面一些測試和分析,認爲很有可能是編譯器的bug:

1.         程序中除了cout和printf外,沒有牽涉到系統相關的API,所有的操作都是寄存器和內存上的操作。所以不會是環境或者系統因素導致的,可能性是代碼錯誤(比如邊界問題)或者編譯器有問題。

2.         檢查代碼後沒有發現異常。同時,如果調整一下std::transform的位置,在for loop後面調用的話,問題就不會發生。

3.         問題發生的情況跟編譯模式相關。

代碼中的std::transform和for loop的作用都是對整個vector調用func_template作轉換。可以比較transform和for loop的執行情況進行比較分析,看看func_template的執行過程有什麼區別。在VS2003裏面利用main函數設定斷點,停下來後用ctrl+alt+D進入彙編模式單步跟蹤。下面的分析證明了這是編譯器的bug:

在VisualStudio附帶的STL源代碼中,發現 std::transform的實現中用這樣的代碼來調用傳入的轉換函數:

*_Dest = _Func(*_First);

編譯器對於該代碼的處理是:

EAX = 0012FEA8 EBX = 0037138C ECX = 003712BC EDX = 00371338 ESI = 00371338 EDI = 003712B0 EIP = 00402228 ESP = 0012FE70 EBP = 0012FEA8 EFL = 00000297

 388: *_Dest = _Func(*_First);

00402228 push esi

00402229 call dword ptr [esp+28h]

0040222D fstp dword ptr [edi]

ESI寄存器中保存的是需要傳入_Func的參數*_First。可以看到,std::transform把這個參數通過push指令傳入stack給_Func調用。

對於for loop中的*_Dest =  func_templatefloatstd::string>(*_First);編譯器是這樣處理的:

EAX = 003712B0 EBX = 00371338 ECX = 003712BC EDX = 00000000 ESI = 00371338 EDI = 0037138C EIP = 00401242 ESP = 0012FE98 EBP = 003712B0 EFL = 00000297

37: *_Dest = func_template<float,std::string>(*_First);

00401240 mov ebx,esi

00401242 call func_template <float,std::basic_string<char,std::char_traits<char>,std::allocator<char> > > (4021A0h)

00401247 fstp dword ptr [ebp]

可以看到,使用for loop的時候,參數通過mov指令保存到ebx寄存器中傳入func_template調用。

最後,看一下func_template函數是如何來獲取傳入的參數的。

004021A0 push esi

004021A1 push edi

16:  std::cout<<u<<std::endl;

004021A2 push ebx

004021A3 push offset std::cout (414170h)

004021A8 call std::operator<<<char,std::char_traits<char>,std::allocator<char> > (402280h)

這裏直接把ebx推入stack,然後調用std::cout,並沒有讀取stack中的資料,說明func_template(callee)認爲參數應該是從寄存器中傳入的。然而transform函數(caller)卻把參數通過stack傳遞。於是使用transform調用func_template的時候,func_template無法拿到正確的參數,因而導致崩潰。通過for loop調用的時候,由於參數通過寄存器傳遞,所以func_template就可以正常工作。

結論是編譯器對參數的傳入、讀取、處理不統一,導致了這個問題。

爲何問題在debug模式下不發生,或者調換函數次序後也不發生,留作你的練習吧 :-P

案例分析:臭名昭著的DLL Hell如何導致ASP.NET出現Server Unavailable

客戶的ASP.NET程序,訪問任何頁面都報告Server Unavailable。觀察發現,ASP.NET的宿主w3wp.exe進程每次剛啓動就崩潰。通過調試器觀察,崩潰的原因是訪問了一個空指針。但是從call stack看,這裏所有的代碼都是w3wp.exe和.net framework的代碼,還沒有開始執行客戶的頁面,所以跟客戶的代碼無關。通過代碼檢查,發現該空指針是作爲函數參數從調用者(caller)傳到被調用者(callee)的,當callee使用這個指針的時候問題發生。接下來應該檢查caller爲什麼沒有把正確的指針傳入callee。

奇怪的時候,caller中這個指針已經正常初始化了,是一個合法的指針,調用call語句執行callee的以前,這個指針已經被正確地push到stack上了。爲什麼caller從stack上拿的時候,卻拿到一個空指針呢?再次單步跟蹤,發現問題在於caller把參數放到了callee的[ebp+8],但是callee在使用這個參數的時候,卻訪問[ebp+c]。是不是跟前一個案例很像?但是這次的兇手不是編譯器,而是文件版本。Caller和callee的代碼位於兩個不同的DLL,其中caller是.NET Framework 1.1帶的,而callee是.NET Framework 1.1 SP1帶的。在.NET Framework 1.1中,callee函數接受4個參數,但是新版本SP1對callee這個函數作了修改,增加了1個參數。由於caller還使用SP1以前的版本,所以caller還是按照4個參數在傳遞,而callee按照5個參數在訪問,所以拿到了錯誤的參數,典型的DLL Hell問題。在重新安裝.NET Framework 1.1 SP1讓兩個DLL保持版本一致,重新啓動後,問題解決。

導致DLL Hell的原因有很多。根據經驗猜測版本不一致的原因可能是:

1.         安裝了.NET Framework 1.1 SP1後沒有重新啓動,導致某些正在使用的DLL必須要等到重新啓動後才能夠完成更新。

2.         由於使用了Application Center做Load Balance,集羣中的服務器沒有做好正確的設置,導致系統自動把老版本的文件還原回去了:
PRB: Application Center Cluster Members Are Automatically Synchronized After Rebooting
http://support.microsoft.com/kb/282278/en-us

2.2.2  題外話和相關討論

Release比 Debug快嗎

分別在debug/release模式下運行下面的代碼比較效率,會發現debug比release更快。你能找到原因嗎?

    long nSize = 200;

    char* pSource = (char *)malloc(nSize+1);

    char* pDest = (char *)malloc(nSize+1);

    memset(pSource, 'a', nSize);

    pSource[nSize] = '/0';

    DWORD dwStart = GetTickCount();

    for(int i=0; i<5000000; i++)

    {

      strcpy(pDest, pSource);

    }

    DWORD dwEnd = GetTickCount();

    printf("%d", dwEnd-dwStart);

如果讓你自己實現一個strcpy函數,應該考慮什麼?你能做到比系統的strcpy函數快嗎?

一些討論可以參考:

http://eparg.spaces.live.com/blog/cns!59BFC22C0E7E1A76!1498.entry

從效率上說,起決定性作用的至少有下面兩點:

1.         在32位芯片上,應該儘量每次mov一個DWORD,而不是4個byte來提高效率。注意到mov DWORD的時候要4字節對齊。

2.         這裏對strcpy的調用高達5000000次。由於call指令的開銷,使用內聯 (inline) 版本的strcpy函數可以極大提高效率。

所以,彙編、CPU習性、操作系統和編譯器,是分析細節的最直接武器。

上面例子中的strcpy是否內聯,取決於編譯設定。由於strcpy是CRT(CRuntime C運行庫)函數,函數的實現位於MSVCRT.DLL或者MSVCRTD.DLL。如果編譯設定使得函數調用要跨越DLL,這個函數是無法內聯的。

關於性能的另外一些討論:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章