2023年12月11日發(作者:洗衣服日記)

(轉)通過地址獲取對應的源代碼信息
你寫了一個程序,很開心地把它發布給用戶。用戶滿心歡喜地運行它,突然Windows彈出了一個熟悉的窗口:
Application Error:
The instruction at “0x00411a28” referenced memory at “0x12345678”. The memory could not be “written”.
Click _disibledevent="left">頓時用戶的心中升起對你的無比仇恨,然而你就會收到用戶憤怒的電話,并且知道了Windows的這個幾乎沒用
的信息。
在這個信息中,你知道出現了一個內存訪問錯誤,而且Windows也告訴你了這個錯誤發生在0x00411a28這個地方……但是,但是,等一
下,即使你精通C++,即使你精通匯編語言,即使你精通計算機原理,你還是不知道這個0x00411a28表示什么,不是嗎?在客戶的計算機
上沒有調試器,即使有調 試器也沒有調試信息,即使有調試信息你也不可能到客戶的機器上去調試,這該怎么辦呢?
這是一種很常見并且很尷尬的情況。那怎么辦呢?我們的第一反應通常就是在我們的開發機上設法重現這個錯誤,然而實際情況并不是很理
想,因為客戶機的軟硬件環境和開發機的軟硬件環境可能有很大的差別,客戶機的運行數據和開發機的數據也有很大差別,這些都導致了錯
誤很難重現。
為了避免這種情況,你需要學會事后調試,進一步的,如果能夠讓你的程序自己能夠提供一些調試所必須的信息則更好。
讓我們首先來看一下我們實際上需要的是什么:
Windows告訴了你一個地址:0x00411a28,你最想知道的是這個地址對應的是哪一句源代碼,然后想知道在這個時刻的計算機運行的上下
文信息,包括當時的堆棧、變量以及寄存器的值。讓我們一個一個來解決:
第一個是源代碼,我們希望能夠知道的是問題發生在哪一個源碼文件的第幾行,退而求其次的是希望知道問題發生在哪個函數里。這就需要
有一個源代碼和目標代碼對應的關系表,但是誰知道這個關系表呢?顯然,對這件事情最清楚的就是編譯程序了,畢竟是它把源代碼翻譯成
機器指令的。
顯然Visual C++的調試程序是知道這個對應關系的,否則它怎么顯示正在執行到源碼的什么地方?C++編譯器在生成目標代碼的時候同時還
會生成很多調試信息,這些調試 信息是包含在OBJ文件中,然后由連接程序把這些調試信息整合起來成為一個調試文件,最典型的就是
PDB文件。在這個文件中包含了和程序相關的所有信息, 包括源代碼和目標代碼的對應行號、類型、變量、函數等等。那太好了,有了這
個文件我們就可以知道那個該死的地址對應于什么地方了。
但是,等一下,這個文件是 Microsoft 的專有文件格式,我們對它是如何組織的無從得知,這該如何是好呢?先不要著急,Microsoft也不是
這么絕情,它提供了一個動態鏈接庫叫做 ,通過這個庫我們就可以訪問PDB文件了。然而這個庫使用起來并不簡單,需要
編寫一個程序,我們現在是火燒眉毛,來不及做這件事情 了,容后續再說。
連接程序另外還會生成一個MAP文件,增加下面的命令行參數可以讓LINK產生這個文件:
LINK /MAP: /MAPINFO:LINES …
這個命令行參數告訴LINK,產生一個名字為的文件,并且這個文件包含行號信息。這兩個參數必須在連接你的程序時指定。有
了這個文件以后我們可以來查找源文件行號和地址的對應關系了。注意,使用這個選項需要配合 /INCREMENTAL:NO 選項使用。
下面是一個簡單的例子,假設有下面這個簡單的C++程序:1 void func()
2 {
3 int *p=0;
4 *p=0;
5 }
6
7 int main()
8 {
9 func();
10 return 0;
11 }
12
顯然,在第4行應該出現一個內存訪問錯誤,運行這個程序,出現了下面這個信息:
– Application Error
The instruction at “0x00401028” referenced memory at “0x00000000”. The memory could not be “written”.
這里報告了一個錯誤,地址是0x00401028。接下來讓我們來看看MAP文件。文件很大,我們只看其中的一部分。
test
Timestamp is 42257ef9 (Wed Mar 02 16:53:13 2005)
Preferred load address is 00400000
Start Length Name Class
0001:00000000 0000d886H .text CODE
0002:00000000 000000fcH .idata$5 DATA
0002:00000100 00001f6bH .rdata DATA
0002:0000206c 00000040H .rdata$debug DATA
...
Address Publics by Value Rva+Ba Lib:Object
0000:00000000 ___safe__handler_table 00000000
0000:00000000 __except_list 00000000
0000:00000000 ___safe__handler_count 00000000
0001:00000000 ?func@@YAXXZ 00401000 f
0001:00000040 _main 00401040 f
0001:00000080 __RTC_InitBa 00401080 f LIBCD:
0001:000000b0 __RTC_Shutdown 004010b0 f LIBCD:
0001:000000d0 __RTC_CheckEsp 004010d0 f LIBCD:
...
Line numbers for .(d:) gment .text
2 0001:00000000 3 0001:0000001e 4 0001:00000025 5 0001:0000002e
8 0001:00000040 9 0001:0000005e 10 0001:00000063 11 0001:00000065
一個MAP文件被分成這么幾個部分:
test
這表示這個MAP文件的模塊名稱,雖然這里看不出什么用途,但是這一點實際上很重要,我們后面就會看到。
Timestamp is 42257ef9 (Wed Mar 02 16:53:13 2005)這是文件的時間戳,這個時間戳并不是文件日期,而是保存在 EXE 文件內部的一個時間戳,通過這個時間戳可以用于確定MAP文件和EXE
是對應的。
Preferred load address is 00400000
這是最佳載入地址,對于EXE來說通常都是 0x00400000,但是對于DLL來說可能實際的載入地址是不同的。這個基地址對于后面的計算很
重要。
Start Length Name Class
0001:00000000 0000d886H .text CODE
0002:00000000 000000fcH .idata$5 DATA
0002:00000100 00001f6bH .rdata DATA
0002:0000206c 00000040H .rdata$debug DATA
這里是段表,我們目前關心的是 .text 段。這一段中包含了程序的實際代碼。上面的數據表示,第一個段就是.text段(0001段),它的起始
地址是0x00000000,長度是 0xd886。按照x86的規定,一個段地址乘上0x10才是實際地址,因此實際地址是從0到0xd8860。由于
Windows的PE文件就是內存映像,而PE文件有0x1000字節的頭部,因此第一段的起始地址是在PE文件的0x1000處。如果PE文件被裝入到
0x400000地址處,那么第一段 的實際地址應該在0x401000處。
Address Publics by Value Rva+Ba Lib:Object
0000:00000000 ___safe__handler_table 00000000
0000:00000000 __except_list 00000000
0000:00000000 ___safe__handler_count 00000000
0001:00000000 ?func@@YAXXZ 00401000 f
0001:00000040 _main 00401040 f
0001:00000080 __RTC_InitBa 00401080 f LIBCD:
0001:000000b0 __RTC_Shutdown 004010b0 f LIBCD:
0001:000000d0 __RTC_CheckEsp 004010d0 f LIBCD:
這是公共符號表,在這個表中將列出所有公共符號的地址和名稱,所謂公共符號就是在匯編語言中聲明為PUBLIC的符號,也就是在其它匯
編文件中可以 通過 EXTERN得到的符號名稱。對于C++來說,如果一個全局變量、常量和函數沒有被聲明為static,那么它就自動聲明為公
共符號。
這個公共符號表是按照地址順序排列的,第一列是Address,是這個符號所在的地址,以 段號:偏移地址的形式表示,段號根據前面段表確
定,偏移地址表示在這個段中的位置。這意味著如果我們要知道這個符號的確切地址,則需要知道段的首地址,然后加上偏移地址,段的首
地址根據段表確定。
第二列是 Publics by Value,是公共符號的名稱,也就是我們一般意義上的變量名、常量名以及函數名。這里需要注意的是,在這里列出來
的名字是經過修飾的名字,例如我們寫的 func()函數實際上的名字是?func@@YAXXZ,main函數的實際名字是_main。關于這一點我們會
在后面再詳細討論的。
第三列是Rva+Ba,表示對象的實際地址。對于Visual C++ 6.0以后的LINK會在MAP文件里面列出這個字段,但是較早的版本以及其它軟
件開發商,比如Borland的連接程序則沒有列出這個字段,因此我們需 要知道一下這個字段是怎么得到的。RVA是“相對虛擬地址”,前面已
經說過,EXE文件就是程序的內存映像,它和在內存中程序的保存形式是完全一樣的,因此在程序中的所有使用地址的地方都應該確定下
來。然而由于EXE和DLL可能被裝入到內存的任意地方,在編譯時不會知道最終的地址是什么,因此只能將程序中所有使用地址的地方用一
個相對于這個EXE文件的頭部的形式表示,這個地址形式稱為RVA。實際地址則是由RVA加上裝入EXE或DLL文件時的基地址 得到的(為
了提高裝入程序的性能,實際上LINK會把它希望的實際地址保存在EXE和DLL文件中,也就是把RVA加上前面所提到的默認裝入地址
(Preferred load address),如果實際的裝入地址和默認裝入地址相同,那么裝入程序就可以省去一次重定位的過程,使得裝入速度有所提
高,這對于EXE來說通常都是可 行的,然而對于DLL一般來說做不到。然而你可以在LINK的時候指定DLL的默認裝入地址,這樣可以提高
DLL的裝入速度,也可以使用REBASE實用 程序改變一個現有DLL的默認裝入地址)。我們來看一下main函數。main函數的公共名字是_main,它所在的地址是0001: 00000040,它所在的段是0001,從前面的段表可以查到它
是第一個段,起始地址是0x00000000,而我們前面提到過,第一個段的起始地址實 際上距離EXE文件的頭部是0x1000,因此這個段的實際
開始地址是0x1000,加上段內偏移地址0x40,那么可以得到_main的RVA是 0x1040,再加上這個模塊的默認裝入地址 0x400000,那么可
以得到結果是0x401040,也就是第三列看到的Rva+Ba的值。
第四列Lib:Object是這個符號所在的OBJ文件,我們知道OBJ文件和CPP文件基本上是一一對應的,因此通過這個信息可以知道對應的CPP
文件是什么。
Line numbers for .(d:) gment .text
2 0001:00000000 3 0001:0000001e 4 0001:00000025 5 0001:0000002e
8 0001:00000040 9 0001:0000005e 10 0001:00000063 11 0001:00000065
最后一部分是行號信息。第一句話表示源文件名,以及這個文件中哪個段的行號信息是包含在下面的列表中的。上面的例子可以看到文件名
是 . 和 d:,段是 .text。如果一個文件有好幾個段,那么它可能被分布在不同的行號信息列表中。
接下來的部分就是行號信息,第一個數字是行號,第二個地址是對應的段地址。這里就表示第8行對應于地址0001:00000040,就是剛才我
們看到的main函數的地址,也就是源代碼中main函數的開始地方。
好了,有了上面的知識,我們再來看地址0x00401028表示什么信息。
這個地址是一個絕對內存地址,而行號信息中只有段偏移地址,我們需要做一個轉換才能完成這件事情。首先把0x00401028減去基地址
0x00400000,得到RVA0x1028,然后減去EXE文件頭的0x1000,得到0x28,然而我們查段表,發現0001段從 0x00000000開始,長度是
0xd886,因此0x28肯定就包含在0001段內,這樣我們就可以得到絕對地址0x00401028的段偏移地址是 0001:00000028。然后我們搜索公
共符號表,發現?func@@YAXXZ函數的段地址從0001:00000000到0001: 00000040,那么0001:00000028就包含在這個地址范圍內,因此
我們可以確定,這個錯誤地址是屬于func函數的。進一步的,我們查行號 表,看到在文件中,第4行的地址是0001:00000025,第5
行的地址是0001:0000002e,那么這說明0001: 00000028地址應該位于由的第4行源代碼生成的機器指令之中(我們應該知道一行
C++程序通常會生成好幾條機器指令,因此錯誤地址很可能沒有和行號對準)。
一般來說到這一步我們就能知道問題出現在什么地方了,如果需要更加詳細的信息,那么我們可以繼續看C++編譯器生成的匯編語言文件。
下面是這個文件的片斷:
_TEXT SEGMENT
_p$ = -8 ; size = 4
func@@YAXXZ PROC NEAR ; func, COMDAT
; 2 : {
00000 55 push ebp
00001 8b ec mov ebp, esp
00003 81 ec cc 00 00
00 sub esp, 204 ; 000000ccH
00009 53 push ebx
0000a 56 push esi
0000b 57 push edi
0000c 8d bd 34 ff ff
ff lea edi, DWORD PTR [ebp-204]
00012 b9 33 00 00 00 mov ecx, 51 ; 00000033H
00017 b8 cc cc cc cc mov eax, -858993460 ; ccccccccH
0001c f3 ab rep stosd
; 3 : int *p=0;
0001e c7 45 f8 00 00
00 00 mov DWORD PTR _p$[ebp], 0
; 4 : *p=0;00025 8b 45 f8 mov eax, DWORD PTR _p$[ebp]
00028 c7 00 00 00 00
00 mov DWORD PTR [eax], 0
; 5 : }
0002e 5f pop edi
0002f 5e pop esi
00030 5b pop ebx
00031 8b e5 mov esp, ebp
00033 5d pop ebp
00034 c3 ret 0
func@@YAXXZ ENDP ; func
_TEXT ENDS
在這個文件中我們可以找到具體錯誤是發生在哪一條指令中的。需要注意的是C++生成的匯編語言文件中都以函數開始作為偏移基準,因此
還需要把一個段 偏移地址轉換為相對于函數開始的偏移地址,方法是在公共符號表中找到這個函數,然后把段偏移地址減去這個函數的開始
段偏移地址就可以了。在我們的這個例子中函數的段偏移地址是0001:00000000,因此函數內的偏移地址和它的段偏移地址是一樣的。
在這里我們可以找到地址0x0028的指令是 mov DWORD PTR [eax], 0,這條指令表示把數值0寫入由eax寄存器所保存的地址中去。而eax寄
存器保存的地址在前一條指令中賦值:mov eax, DWORD PTR _p$[ebp],這里ebp是當前函數的棧幀基址寄存器,_p$被定義為-8,表示變
量p在堆棧上的相對位置,再前面一條指令是mov DWORD PTR _p$[ebp], 0,表示把0賦值到變量p里面去。這樣這三條指令完成了這樣一個
操作序列:把0賦值給p,把p賦值給eax,把0寫入到eax所指定的內存,這里eax就是0,因此實際執行的結果就是把數值0寫入地址0。我們
知道Win98/2000/XP的進程地址空間中,把從地址0開始的64K(Win98是32K) 作為不可寫/不可讀的內存頁保護起來了,所有在這個地址
空間中進行的讀寫操作都會引起操作系統結構化異常,而且如果這個異常沒有被處理,則會被 Windows捕獲,然后就顯示了這樣一個錯誤
信息。
至此,我們算是徹底把這個錯誤找到了根源。然而這還不是全部……
這個步驟有些復雜,如果難得查一次,或許你有這個興趣,如果需要經常查這些信息,你就會很郁悶,因為其中涉及到很多數據和計算。大
家知道計算機科學的發展源于人的惰性,因此我們為了讓我們更加舒服一些就需要做進一步的考慮。
如果發生這種關鍵性錯誤時我們能夠捕獲這個錯誤并且讓我們的程序自己來顯示出現在什么地方,那有多好呢?
要實現這個技術,我們需要解決下面這些問題:
1、怎么來捕獲這個錯誤?
2、捕獲錯誤以后怎么通過程序來完成上述的動作?
3、獲得這些信息以后如何把它記錄下來?
這三個問題實際上就覆蓋了一個很大范圍的知識。讓我們來一個個解決。
1、 怎么來捕獲這個錯誤?
從機制上講,這個錯誤是一個未處理SEH,也就是所謂的結構化異常。我們知道C++等語言支持異常處理,但是這些異常僅僅限制在這種語
言的范圍內(.NET 的異常覆蓋整個 CLR,但是對我們來說范圍還是不夠廣)。而SEH 是整個操作系統范圍內的異常處理,它包括硬件和軟
件異常兩種情況。我們在C++中可以通過下面的語句形式來捕獲SEH的異常:
__try
{
...
}
__except(...)
{
}一種可行的處理方法是在 main 函數或者 WinMain 函數中增加一個最頂層的__try和__except,然而這種情況僅僅對單線程程序有效,而且
更大的問題是在某些情況下main和WinMain函數不是我們寫的(例如MFC),這時這個方法就沒有辦法了。
幸運的是操作系統提供了一個函數: SetUnhandledExceptionFilter,通過這個函數可以給進程安裝一個未處理異常過濾器—— 這個名字是
和所謂__except部分的異常過濾器對應的——當一個進程中任何一個線程出現異常并且沒有被處理時都會調用這個異常過濾器。這是一個好
機 會,作為一個異常過濾器,它可以得到很多有用的信息。讓我們來看一下能得到些什么:
下面是這個函數的原型:
LPTOP_LEVEL_EXCEPTION_FILTER SetUnhandledExceptionFilter(
LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter
);
其中lpTopLevelExceptionFilter是一個函數指針,應該具有下面的原型:
LONG WINAPI UnhandledExceptionFilter(
STRUCT _EXCEPTION_POINTERS* ExceptionInfo
);
它有一個參數,指向_EXCEPTION_POINTERS結構,這個結構包含下面內容:
typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_RECORD ExceptionRecord;
PCONTEXT ContextRecord;
} EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;
在這個結構中包含了兩個指針,一個指向異常記錄,另外一個指向上下文環境記錄。異常記錄包含了發生異常的詳細信息,上下文環境記錄
包含了發生異常時的CPU狀態信息。
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD* ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;
在異常記錄結構中,最重要的信息是ExceptionCode,它包含了異常代碼。對于內存訪問違例來說,它的異常代碼就是
EXCEPTION_ACCESS_VIOLATION。當然還包括其它的異常代碼。
第二個字段是ExceptionFlags,如果它是0,那么表示這個異常是可以恢復的,如果是EXCEPTION_NONCONTINUABLE 那么表明這個異
常是不能恢復的(在C++中,一旦拋出一個異常那么它就不可能再回到拋出異常的地方繼續執行,而SEH則可以)。
第三個字段是前一個未處理異常結構。和C++異常不同,SEH異常可以嵌套拋出,可以在異常處理過程中繼續拋出異常。從這里可以看到多
個異常被組織成了一個鏈表,因此你在異常過濾器中可以追蹤到底發生了多少異常。
第四個字段表明了發生異常的地址,就是Windows顯示給你看到的那個地址。
第五個字段表明,對于這個異常,有多少個附加的參數。參數是保存在第六個字段中的。到目前為止,只有
EXCEPTION_ACCESS_VIOLATION異常會包含2個參數,第一個參數,也就是 ExceptionInformation[0] 表示對內存讀寫狀態。如果是因為
讀內存造成異常的,那么這個參數是0,如果是因為寫內存造成異常的,那么這個參數就是1。第二個參數是訪問違例的內存地址。
上面提到的這個錯誤實際上就是這個異常結構中內容的一種可視形式,現在我們可以重新回頭來看一下造成這個錯誤的異常信息:ExceptionCode: EXCEPTION_ACCESS_VIOLATION
ExceptionFlags: 0
ExceptionRecord: NULL
ExceptionAddress: 0x00401028
NumberParameters: 2
ExceptionInformation[0]: 1
ExceptionInformation[1]: 0x00000000
上下文環境記錄是用于保存發生異常時的CPU狀態,它是在Windows SDK中唯一一個和硬件相關的數據結構,目前我們不需要用它,但是
后面會用到。
我們所要做的第一步現在已經清楚了,我們只需要安裝一個全局的未處理異常過濾器,在這個過濾器中就能得到出現異常的詳細信息。下一
步就是要把這些信息轉換成更加容易理解的形式。
【注意】需要特別注意的一個問題是,如果當前處于調試程序的控制下,例如由Visual C++調試你的程序時,這個全局未處理異常過濾器是
不會被調用的,有些資料上說這是一個BUG,然而我覺得不是。不管怎么樣這個目前是事實??梢酝ㄟ^調用 IsDebuggerPrent函數來判
斷。這個函數在Windows 95下不存在,因此需要在包含Windows.h前加上下面語句:
#define _WIN32_WINNT 0x0400
2 捕獲到這個錯誤以后如何通過程序來處理它
前面我們已經說到可以通過異常來捕獲這個錯誤(當然,需要聲明的是這是對于未處理異常來說通過這個方式來捕獲),一旦捕獲這個異常
后我們就需要通過程序來完成前面手工完成的操作,也就是把一個地址對應到一個源代碼行去。
手工對應是通過閱讀map文件進行的,那么程序該如何做呢?同樣的,當發生一個異常時我們已經知道了對應的地址,然后我們查閱map文
件。然而我們直接處理map文件是不方便的,因此需要有一種內部格式,也就是和map具有類似信息,但是處理起來更加方法的格式。
我把這種格式稱為SDB(Symbol Databa,符號數據庫),與map文件對應的里面包含了:段表、符號表和行號表。差別在于我們用對于
程序來說更加方便理解的二進制形式來保存,而 不是使用對人來說更加容易理解的文本形式保存。這個文件的具體格式按照如下定義:
文件首部,是一個SDBHeader結構,定義為:struct SDBHeader
{
DWORD mMagic; // “SDB0”
DWORD mSizeOfHeader; // 首部長度,包括 mModuleName 的所有字符。
DWORD mTimeStamp; // 時間戳
DWORD mBa; // 參考基準地址
DWORD mFileNum; // 文件表表項數
DWORD mFileTableOfft; // 文件表偏移地址,相對與文件首部的起始字節
DWORD mSegmentNum; // 段表表項數
DWORD mSegmentTableOfft; // 段表偏移地址,相對與文件首部的起始字節
DWORD mSymbolNum; // 符號表表項數
DWORD mSymbolTableOfft; // 符號表偏移地址,相對與文件首部的起始字節
DWORD mLineNum; // 行號表表項數
DWORD mLineTableOfft; // 行號表偏移地址,相對與文件首部的起始字節
DWORD mStringTableSize; // 字符串表長度
DWORD mStringTableOfft; // 字符串表偏移地址,相對與文件首部的起始字節
DWORD mRerved0; // 保留(0)
DWORD mRerved1; // 保留(0)
DWORD mRerved2; // 保留(0)
DWORD mRerved3; // 保留(0)
DWORD mModuleNameSize; // 模塊名長度
CHAR mModuleName[1]; // 模塊名
};
這里mMagic是一個標識符,表示這是一個SDBHeader,值為SDB_HEADER_MAGIC(0x30424453)。在這個結構中包含了五個表的開
始地址:
文件表是用來記錄在這個符號文件中出現的源代碼文件名;
段表是用來記錄map文件中的段信息;
符號表用來記錄map文件中的公共符號信息;
行號表用來記錄map文件中的行號信息;
字符串表用來保存SDB文件中用到的所有字符串。為了節省空間,每個字符串在SDB文件中只出現一次,并且需要使用字符串的地方都用一
個指針去引用而不是包含字符串本身。
需要注意的是這個結構是變長的,因為模塊名長度可變。
文件表,是多個FileIndexItem字段,其數量由文件首部的mFileNum確定,這個結構定義為:
struct FileIndexItem
{
DWORD mFileNameLength; // 文件名長度
DWORD mFileNameOfft; // 文件名在字符串表中的偏移地址,相對與字符串表的起始字節
};
段表,是多個SegmentIndexItem字段,其數量由文件首部的mSegmentNum確定,這個結構定義為:
struct SegmentIndexItem
{
DWORD mAddress; // 段起始地址
DWORD mSegmentLength; // 段長度
DWORD mSegmentNameLength; // 段名長度
DWORD mSegmentNameOfft; // 段名在字符串表中的偏移地址,相對于字符串表的起始字節
DWORD mClassNameLength; // 段類名長度
DWORD mClassNameOfft; // 段類名在字符串表中的偏移地址,相對與字符串表的起始字節
};在上表中我們在創建SDB文件的時候就把段起始地址轉換為RVA的形式。
符號表,是多個SymbolIndexItem字段,其數量由文件首部的mSymbolNum確定,這個結構定義為:
struct SymbolIndexItem
{
DWORD mAddress; // 符號所在地址
DWORD mSymbolNameLength; // 符號名長度
DWORD mSymbolNameOfft; // 符號名在字符串表中的偏移地址,相對于于字符串表的起始字節
DWORD mSymbolFlag; // 符號類型(目前未用)
};
上表中,我們在創建SDB文件的時候就把符號地址轉換為RVA形式。
行號表,是多個LineIndexItem字段,其數量由文件首部的mLineNum確定,這個結構定義為:
struct LineIndexItem
{
DWORD mAddress; // 行號所在地址
DWORD mLineNum; // 行號
DWORD mFileIndex; // 文件表索引
};
同樣,上表中把mAddress轉換成RVA形式。
字符串表,就是多個以0結尾的字符串序列,其總長度(字節數)由文件首部mStringTableSize確定。
最后還有一個文件尾部結構SDBTail,定義如下:
struct SDBTail
{
DWORD mMagic; // “SDBT”
DWORD mHeaderOfft; // 首部偏移地址,計算方法為 SDBTail 的偏移地址減去 mHeaderOfft
// 即為首部的偏移地址
};
其中mMagic是一個標識符,表示這是一個SDBHeader,值為SDB_TAIL_MAGIC(0x54424453)。
為了確保EXE文件和SDB文件能夠一一對應,我決定把SDB文件附加在EXE文件的后面,因此需要有一個文件尾部結構來標記一下,并且
可以通過它找到文件首部在什么地方。
我編寫了一個工具程序,,可以通過它把VC6/7生成的map文件轉換成sdb文件并且綁定到一個exe文件去。
定義了這樣一個文件格式,并且有了一個可以把map文件轉換成sdb文件的工具,那么我們就可以把這些操作自動化了:在VC的Post-Build
事件中添加下列命令:
mapcvt
這里就是你的項目生成的map文件名,就是你最終生成的exe文件名。VC會在每次成功編譯后調用mapcvt來完成這個轉
換。
接下來一步,就是我們如何來讀取這個文件,有了這個文件格式,那么讀取已經不是很復雜的事情了。我編寫了另外一個庫,,
可以協助你完成這個操作。在這個庫中導出了下面這些函數:// SDB Image API
SHAPI HSHIMG WINAPI SDBInitializeImage(HANDLE hProcess, BOOL fInvadeProcess);
SHAPI BOOL WINAPI SDBGetSymbolFromAddress(HSHIMG hImg, DWORD dwAddress,
LPSTR lpszSymbolName, DWORD dwSymbolNameLength,
LPDWORD lpdwDisplacement);
SHAPI BOOL WINAPI SDBGetLineFromAddress(HSHIMG hImg, DWORD dwAddress,
LPDWORD lpdwLine, LPSTR lpszFileName,
DWORD dwFileNameLength, LPDWORD lpdwDisplacement);
SHAPI BOOL WINAPI SDBDestroyImage(HSHIMG hImg);
SDBInitializeImage用于初始化,提供給它一個進程句柄,然后它會搜索這個進程中所有模塊是否具備SDB符號信息。如果 fInvadeProcess
為TRUE,則它試圖裝入所有可能找到的符號信息,否則將在用到的時候才裝入符號信息。在這種情況下,它會自動確定每個符號 文件的基
地址。這個函數返回一個HSHIMG句柄,用來表示一個符號處理器。
SDBGetSymbolFromAddress函數將一個物理地址轉換成對應的符號名,如果轉換成功則返回TRUE,否則返回FALSE。符號名 保存在
lpszSymbolName所指向的緩沖區,這個緩沖區的長度由輸入參數dwSymbolNameLength指定,另外一個參數 lpdwDisplacement用于保存
這個地址相對于符號開始地址的偏移量。
SDBGetLineFromAddress函數將一個物理地址轉換成對應的源文件名和行號,如果轉換成功則返回TRUE,否則返回FALSE。行 號保存在
lpdwLine所指的空間中,文件名保存在lpszFileName所指向的緩沖區中,這個緩沖區的長度由輸入參數 dwFileNameLength指
定,lpdwDisplacement用于保存這個地址相對于行開始地址的偏移量。
SDBDestroyImage用于關閉符號表。
有了這樣一個dll后,我們就可以編寫一些函數來使用它了。下面我對這個dll進行了一些封裝。
在封裝過程中我們需要考慮另外一件事情。前面我們說過,VC本身其實也是具備調試信息的,就是PDB文件,然而這個文件格式沒有公
開,因此我們沒法直接調用它。但是微軟提供了一個文件,它能夠滿足我們的要求,關于的詳細信息在
MSDN的下列位置(這是最 新MSDN位置,如果你使用的MSDN較早,可能有所不同):
MSDN Library-January 2005
Win32 and COM Development
System Services
Debugging and Error Handling
Debug Help Library
在 中有一個函數叫做SymGetLineFromAddr64,另外還有一個SymGetSymbolFromAddr64(這個函數已經過時,應該使用
SymFromAddr),這兩個函數的作用和SDBHELP中提供的兩個函數作用差不多。
我們為什么不直接使用DBGHELP,而要自己寫一個SDBHELP呢?最主要的是基于移植的原因。DBGHELP只能解析Visual C++的專有
PDB格式,而其它編譯器并不生成這個文件。但是幾乎所有的編譯器都生成非常類似的map文件,我們只需要把mapcvt程序做一點修改,就
能使SDBHELP支持Borland C++ Builder,支持 Delphi甚至gcc。另外一個原因是PDB格式文件很大,遠比SDB要大,并且不能和EXE結合
在一個文件內,導致分發不方便。但是如果能夠支持 PDB,那么也是一件很好的事情。class DebugSymbolHandler: public DebugObject
{
public:
virtual bool Open(const DebugString & arch_path="”, HANDLE process=GetCurrentProcess()) =0;
virtual void Clo() =0;
virtual bool GetSymbolFromAddress(ADDRESS addr, DebugSymbol & symbol,
ADDRESS & displacement) const =0;
virtual bool GetLineFromAddress(ADDRESS addr, DebugLine & line,
ADDRESS & displacement) const =0;
static bool GetModuleFromAddress(HANDLE process, ADDRESS addr, DebugModule & module,
ADDRESS & displacement);
};
上面定義了一個基本的DebugSymbolHandler類,用于完成從地址到符號、到行號的轉換,這里使用DebugSymbol、DebugLine和
DebugModule表示符號、行號和模塊名。
DebugObject是所有調試對象的基類,它重載了所有內存分配函數,下面是一個簡單的實現:
class DebugObject
{
public:
virtual ~DebugObject()
{
}
static void * operator new (size_t size)
{
return malloc(size);
}
static void * operator new (size_t size, void * ptr)
{
return ptr;
}
static void * operator new [] (size_t size)
{
return malloc(size);
}
static void * operator new [] (size_t size, void * ptr)
{
return ptr;
}
static void operator delete (void * ptr)
{
free(ptr);
}
static void operator delete (void *, void *)
{
}
static void operator delete [] (void * ptr)
{
free(ptr);
}
static void operator delete [] (void *, void *)
{
}
};【注】上面的類中沒有實現 nothrow 的重載版本。
由于我們還將把這些調試對象類用于內存泄漏分析,我們不能讓它們使用任何標準的new和delete,需要非常小心地進行內存管理,因此我
們重載了這些函數。
由于這個原因,我們如果要使用字符串也需要小心。雖然可以通過自定義分配器來實現一個自定義內存管理的std::string,但是一來這是我另
外 一個更大的但是尚未完全實現的一個框架中的東西,二來對于調試這么基本的代碼來說,使用std::string有些不受控制。因此我自己寫了
一個簡單但是 不使用標準new和delete的字符串類DebugString。
class DebugString: public DebugObject{
private:
static char * mNullBuffer;
private:
char * mBuffer;
DWORD mSize;
public:
DebugString(const char * str=0);
DebugString(const DebugString & rhs);
virtual ~DebugString();
operator const char * () const;
DWORD Length() const;
DebugString & operator = (const char * str);
DebugString & operator = (const DebugString & rhs);
static const char * GetNullBuffer();
};
調試符號名、行號和模塊由下面幾個類定義:
typedef ULONG64 ADDRESS;
struct DebugLine: public DebugObject
{
DebugString mFileName;
DWORD mLineNumber;
ADDRESS mAddress;
DebugLine(const DebugString & filename="”, DWORD linenum=0, ADDRESS addr=0)
:mFileName(filename),
mLineNumber(linenum),
mAddress(addr)
{
}
};
struct DebugSymbol: public DebugObject
{
ADDRESS mModuleBa;
ADDRESS mAddress;
DebugString mSymbolName;DebugSymbol(ADDRESS ba=0, ADDRESS address=0, const DebugString & symbol_name="")
:mModuleBa(ba),
mAddress(address),
mSymbolName(symbol_name)
{
}
};
struct DebugModule: public DebugObject
{
ADDRESS mImageBa;
DWORD mImageSize;
DebugString mModuleName;
DebugModule(ADDRESS ba=0, DWORD size=0, const DebugString & module_name="")
:mImageBa(ba),
mImageSize(size),
mModuleName(module_name)
{
}
};
這幾個都是很簡單的結構,僅僅用于表示這些信息而已。
最后我們需要實現自己的SDBSymbolHandler:
struct SDBSymbolHandlerImpl;
class SDBSymbolHandler: public DebugSymbolHandler
{
private:
SDBSymbolHandlerImpl * mImpl;
public:
SDBSymbolHandler();
~SDBSymbolHandler();
virtual bool Open(const DebugString & arch_path="”, HANDLE process=GetCurrentProcess());
virtual void Clo();
virtual bool GetSymbolFromAddress(ADDRESS addr, DebugSymbol & symbol,
ADDRESS & displacement) const;
virtual bool GetLineFromAddress(ADDRESS addr, DebugLine & line,
ADDRESS & displacement) const;
};
這個類的實現相當簡單,只需要直接調用SDBHELP中的函數即可:
struct SDBSymbolHandlerImpl: public DebugObject
{
HANDLE mProcessHandle;
HSHIMG mImage;
};SDBSymbolHandler::SDBSymbolHandler()
:mImpl(0)
{
}SDBSymbolHandler::~SDBSymbolHandler()
{
Clo();
}bool SDBSymbolHandler::Open(const DebugString & arch_path/* ="” */, HANDLE process/* =GetCurrentProcess( */)
{
if(mImpl==0)
{
if(!LoadSdbHelp())
{
return fal;
}mImpl=new SDBSymbolHandlerImpl;mImpl->mImage=PSDBInitializeImage(process,FALSE);
if(mImpl->mImage!=0)
{
return true;
}
el
{
Clo();
return fal;
}
}
el
return true;
}void SDBSymbolHandler::Clo()
{
if(mImpl!=0)
{
PSDBDestroyImage(mImpl->mImage);
delete mImpl;
mImpl=0;
}
}bool SDBSymbolHandler::GetSymbolFromAddress(ADDRESS addr, DebugSymbol & symbol, ADDRESS & displacement) const
{
if(mImpl!=0)
{
DWORD disp;
CHAR buf[1024+1];
if(PSDBGetSymbolFromAddress(mImpl->mImage,static_cast(addr),buf,1024,&disp))
{
ss=addr;
lName=buf;
displacement=disp;
return true;
}
el
{
return fal;
}
}
el
{
return fal;
}
}
bool SDBSymbolHandler::GetLineFromAddress(ADDRESS addr, DebugLine & line_info, ADDRESS & displacement) const
{
if(mImpl!=0)
{
DWORD line, disp;
CHAR buf[1024+1];
if(PSDBGetLineFromAddress(mImpl->mImage,static_cast(addr),&line,buf,1024,&disp))
{
line_ss=addr;
line_umber=line;
line_ame=buf;
displacement=disp;
return true;
}
el
{
return fal;
}
}
el
{
return fal;
}
}
LoadSdbHelp函數用于裝入。我們使用動態的方式裝入這個程序庫,是因為如果不存在這個文件雖然使調試信息無法讀取,
但是不應該導致程序不能正常運行。typedef HSHIMG(WINAPI*TSDBInitializeImage)(HANDLE hProcess, BOOL fInvadeProcess);
typedef BOOL(WINAPI*TSDBGetSymbolFromAddress)(HSHIMG hImg, DWORD dwAddress,
LPSTR lpszSymbolName, DWORD dwSymbolNameLength, LPDWORD lpdwDisplacement);
typedef BOOL(WINAPI*TSDBGetLineFromAddress)(HSHIMG hImg, DWORD dwAddress, LPDWORD lpdwLine,
LPSTR lpszFileName, DWORD dwFileNameLength, LPDWORD lpdwDisplacement);
typedef BOOL(WINAPI*TSDBDestroyImage)(HSHIMG hImg);
static TSDBInitializeConverter PSDBInitializeConverter;
static TSDBSetModuleInfo PSDBSetModuleInfo;
static TSDBAddSegment PSDBAddSegment;
static TSDBAddSymbol PSDBAddSymbol;
static TSDBAddLine PSDBAddLine;
static TSDBWriteImage PSDBWriteImage;
static TSDBDestroyConverter PSDBDestroyConverter;
static TSDBInitializeImage PSDBInitializeImage;
static TSDBGetSymbolFromAddress PSDBGetSymbolFromAddress;
static TSDBGetLineFromAddress PSDBGetLineFromAddress;
static TSDBDestroyImage PSDBDestroyImage;
static bool LoadSdbHelp()
{
static HINSTANCE lib=0;
if(lib!=0)
{
return true;
}
lib=LoadLibrary(TEXT(""));
if(lib)
PSDBInitializeImage=(TSDBInitializeImage)GetProcAddress(lib,"SDBInitializeImage");
if(PSDBInitializeImage)
PSDBGetSymbolFromAddress=(TSDBGetSymbolFromAddress)GetProcAddress
(lib,"SDBGetSymbolFromAddress");
if(PSDBGetSymbolFromAddress)
PSDBGetLineFromAddress =(TSDBGetLineFromAddress)GetProcAddress
(lib,"SDBGetLineFromAddress");
if(PSDBGetLineFromAddress)
PSDBDestroyImage =(TSDBDestroyImage)GetProcAddress(lib,"SDBDestroyImage");
if(PSDBDestroyImage)
turn true;
FreeLibrary(lib);
lib=0;
return fal;
}
有了這幾個類,我們就可以在自己的程序中根據出錯的地址查找對應的符號。具體的可以參見相應的源代碼。
不過,如果僅僅知道當時的行號和文件還不是完全有用,比如說,如果錯誤出現在一個很常見的實用函數中,而這個函數會在各個地方被調
用,如果我們僅僅知道問題出現在這個函數中,那么還是無法找到問題的根源。此時我們需要知道的是當時的執行上下文,這包括兩個方面
的內容:CPU狀態和堆棧狀態。CPU狀態就是在EXCEPTION_POINTERS中的ContextRecord,這里面包含了CPU的所有寄存器值。也可以通過GetThreadContext函數來
獲取。
在中存在一個函數稱為StackWalk64,如果知道了棧頂地址,那么我們可以使用這個函數來搜索整個堆棧中的函數調用情
況。在此我們需要先了解一下系統的堆棧組織結構。
每個線程具有一個運行棧,這個運行棧是一段內存,其棧頂地址由寄存器ESP指出。每當調用了一個函數,通常會生成下列標準調用代碼
(C調用規范):
Push arg1
Push arg2
Call func
Add esp, 4
這段代碼將在堆棧中形成2個參數和一個函數返回地址,當函數返回時,函數返回地址由ret指令彈出,所以隨后的add指令可以用于平衡壓
入參數后的堆棧。
進入函數后,一般會生成下列標準前序代碼:
Push ebp
Mov ebp, esp
Sub esp, xxxx
該代碼會先把ebp壓入堆棧,然后把esp賦值給ebp,此時ebp的值就稱為堆棧幀的基址,以此作為當前函數在堆棧上所有對象的訪問基準。
例如, 函數的參數就是ebp+8(需要注意的是,堆棧是向低地址增長;ebp本身指向的是壓入堆棧的前一個ebp值,ebp+4是函數返回地址,
因此第一個參數 就是 ebp+8,第二個參數就是ebp+12(設參數為4字節),以此類推。Sub esp, xxxx指令把esp寄存器減去一個數值,這個
數值就是這個函數所有局部變量的總大小。如果函數只有一個局部變量,且其大小為4字節,那么這里就應該是 sub esp, 4。同樣可以通過
ebp寄存器來訪問局部變量,不同的是,訪問參數時使用正偏移量,而使用局部變量時使用負偏移量,因此第一個局部變量就應該是:ebp-
8,第二個局部變量就是ebp-12(假設變量為4個字節),以次類推。
任意一個程序總是由操作系統來調用它的啟動代碼,而它的啟動代碼再依次調用其各個函數。因此ebp寄存器就始終被用作堆棧幀的基址,
而且由于存在 push ebp指令,使當前函數在堆棧中總能夠找到它的調用者的ebp值。這就形成了一個鏈表,通過遍歷這個鏈表就能追蹤整個
堆棧直到第一個啟動當前線程的地方了 (這個地方在Windows內部)。
函數在返回前,會生成下列標準后序代碼,用于平衡堆棧:
Add esp, xxxx
Pop ebp
Ret
所有上面所說的這些代碼形成所謂的標準堆棧幀。
前面我們說到可以以訪問ebp寄存器以及堆棧內存的方式遍歷整個堆棧幀鏈表,從而知道到底是誰調用了誰。這個過程完全可以這樣做,但
是也可以通過StackWalk64函數來完成這個操作。
使用這個函數之前必須要準備好CPU上下文,如果你已經知道了EBP、ESP等寄存器的值,那么沒什么問題,否則就需要使用
GetThreadContext函數來獲取這些信息。需要注意的是,如果當前正在執行的線程用于獲取當前線程的CPU上下文,則此時得到的EIP、
EBP以及ESP寄存器是不正確的(實際上這些寄存器的值表示的是GetThreadContext內部執行時的值,而不是我們所期望的調用
GetThreadContext之前的值),因此對于這種情況需要對這三個寄存器值做一個修正,在Visual C++下可以通過下面指令進行:DWORD regEIP, regEBP, regESP;
__asm
{
call l1 // call 指令將當前 EIP 壓入堆棧
l1: pop edx // 彈出 EIP
mov dword ptr [regEIP], edx // 設置 EIP
mov dword ptr [regEBP], ebp // 設置 EBP
mov dword ptr [regESP], esp // 設置 ESP
}
mImpl->=regEIP;
mImpl->=regEBP;
mImpl->=regESP;
由于我們無法直接獲取EIP寄存器的值,因此執行一個call指令,它會把EIP寄存器的值保存到堆棧,下一條指令就直接把它從堆棧中彈出,
保存在edx中。
有了當前CPU上下文以后就可以構建起堆棧幀的參數結構了:
mImpl-> =AddrModeFlat;
mImpl-> =mImpl->;
mImpl-> =AddrModeFlat;
mImpl-> =mImpl->;
mImpl-> =AddrModeFlat;
mImpl-> =mImpl->;
AddrModeFlat表明使用的平面地址模式。
有了這些結構后就可以通過調用StackWalk64來遍歷整個堆棧幀了。DebugStackFrame類實現了對這個過程的封裝。
需要注意的是,某些編譯器可以有一個“省略堆棧幀”的優化選項,該選項針對沒有參數和沒有局部變量的函數有效,可以減少生成的代碼。
但是由于沒有堆棧幀的構建代碼,因此如果遇到這樣的函數就無法繼續遍歷堆棧了。
本文發布于:2023-12-11 18:56:57,感謝您對本站的認可!
本文鏈接:http://m.newhan.cn/zhishi/a/1702292217243244.html
版權聲明:本站內容均來自互聯網,僅供演示用,請勿用于商業和其他非法用途。如果侵犯了您的權益請與我們聯系,我們將在24小時內刪除。
本文word下載地址:(轉)通過地址獲取對應的源代碼信息.doc
本文 PDF 下載地址:(轉)通過地址獲取對應的源代碼信息.pdf
| 留言與評論(共有 0 條評論) |