收集API調用日志的快速的方法


調試問題時可能面臨的一個常見任務是記錄有關對一個或多個函數的調用的信息。如果你想知道你的程序中有一個你有源代碼的函數,你可以添加一些調試打印和重建程序,有時這是不實際的。例如,您可能不總是能夠重現一個問題,因此可能不可行的是,必須重新啟動調試生成,因為您可能會吹走您的重現。或者,更重要的是,您可能需要記錄對沒有源代碼的函數的調用(或者不作為程序的一部分構建,或者不想修改)。
例如,您可能希望記錄對各種Windows api的調用,以便獲得有關正在排除故障的問題的信息。現在,根據您正在做的工作,您可以在每次調用特定API之前和之后添加調試打印來完成這項工作。但是,這通常不太方便,如果您不是要記錄的函數的直接調用方,那么無論如何,您都不能走這條路。
有許多API spy/API日志記錄軟件包(Windows發行版的調試工具甚至附帶了一個名為Logger的軟件包,盡管它往往相當脆弱——就我個人而言,我經常遇到它崩潰,而不是實際工作中遇到的問題)。盡管您可以使用其中的一個,但是“收縮包裝”日志工具的一個很大的限制是,它們不知道如何正確地記錄對自定義函數的調用,或者日志工具不知道的函數。更好的日志工具在一定程度上是用戶可擴展的,因為它們通常提供某種腳本語言或編程語言,允許用戶(即您)描述函數參數和調用約定,以便對它們進行日志記錄。
但是,通常很難(甚至不可能)向這些工具描述許多類型的函數,例如包含指向包含指向其他結構的指針的結構的指針的函數,或其他此類不重要的結構。因此,在許多情況下,我傾向於建議不要在需要記錄對函數的調用的情況下使用所謂的“收縮包裝”API日志工具。

但是,如果在源代碼中實現調試打印不是一個可行的解決方案,那么表面上看,這會使一個沒有可用的解決方案來記錄調用。事實上並非如此——事實證明,通過謹慎地使用所謂的“條件斷點”,您可以經常使用調試器(例如WinDbg/ntsd/cdb/kd,這是本文其余部分將要提到的)來提供這種調用日志記錄。使用調試器有許多優點;例如,您可以“動態”執行此類API日志記錄,並且在可以在進程啟動后附加調試器的情況下,您甚至不需要特別啟動程序來記錄它。然而,更好的是,調試器對以有意義的形式向用戶顯示數據有廣泛的支持。
如果你仔細想想,向用戶顯示數據實際上是調試器的主要功能之一。這也是調試器通過擴展具有高度可擴展性的主要原因之一,這樣復雜的數據結構就可以以有意義的方式顯示和解釋。通過使用調試器來執行API日志記錄,您可以利用豐富的功能來顯示已烘焙到調試器中的數據(及其擴展,甚至是您自己編寫的任何自定義擴展),從而兼作調用日志記錄功能。
更好的是,因為調試器可以基於符號文件(如果您有私有符號,例如您編譯或提供的程序)以有意義的方式讀取和顯示許多數據類型,而這些數據類型沒有用於顯示它們的特定調試器擴展名(如!把手!錯誤(錯誤代碼)!devobj和soforth),通常可以利用調試器基於符號中的類型信息格式化數據的能力。這通常是通過dt命令完成的,並且通常為大多數自定義數據類型提供一個可行的顯示,而不必像處理日志程序那樣進行任何復雜的“訓練”。(某些數據結構(如樹和列表)可能需要比dt中提供的更多的智能來顯示數據結構的所有部分。對於“container”數據類型,這通常是正確的,盡管即使對於那些類型,您仍然可以經常使用dt以有意義的方式顯示容器中的實際成員。)利用符號文件(通過調試器)中包含的信息進行API日志記錄也使您不必確保日志記錄程序對所有結構和其他類型的定義與您的程序同步正在調試,因為調試器自動接收基於符號的正確定義(如果使用的符號服務器包含自己內部符號的索引版本,調試器甚至可以自己找到符號)。

這種方法的另一個優點是,如果您對調試器相當熟悉,那么您可能不必像使用API日志程序那樣學習新的描述語言。這是因為您可能已經熟悉了調試器從每天的調試器使用中為顯示數據而提供的許多命令。(即使您對調試器不太熟悉,但默認情況下調試器附帶的大量文檔說明了如何通過各種調試器命令格式化和顯示數據。此外,還有許多示例描述了如何在Internet上使用大多數重要或有用的調試器命令。)
好吧,關於為什么要考慮使用調試器來執行調用日志記錄已經足夠了。下一次,快速瀏覽並逐步介紹如何做到這一點(正如前面提到的那樣,這非常簡單),以及一些可能需要注意的注意事項和問題。

使用調試器執行調用日志記錄並沒有那么困難。所涉及的基本思想是在您感興趣的函數的開頭設置一個“條件”斷點(例如,通過bp命令)。從那里,斷點可以有顯示輸入參數的命令。但是,在某些情況下(例如,顯示返回值、輸出參數中的值等),您也可以變得更聰明一些,盡管根據正在調試的程序的特性,這可能是問題,也可能不是問題。
舉一個簡單的例子,我的意思是,有一個經典的“顯示通過Win32 CreateFile打開的所有文件”。為此,方法是在kernel32!CreateFileW上設置一個斷點。(請記住,大多數“A”win32api都會跳到“W”api,因此您通常可以僅在“W”版本上設置一個斷點來同時獲取這兩個api。當然,這並不總是正確的(有些bizzare api,比如WinInet,實際上是從“W”到“A”的重擊),但作為一般的經驗法則,情況往往是這樣的。)kernel32斷點需要具備如何根據所討論例程的調用約定顯示第一個參數的知識。因為CreateFile是stdcall,所以應該是[esp+4](對於x86)和rcx(對於x64)。

在最基本的情況下,breakpoint命令可能如下所示:

0:001> bp kernel32!CreateFileW "du poi(@esp+4) ; gc"

(注意,gc命令與g類似,只是它是專門為在條件斷點中使用而設計的。如果您跟蹤到用gc控制執行的斷點,它將以用戶控制程序的方式恢復執行,而不是無條件地正常恢復。使用g的斷點和使用gc的斷點的區別在於,如果跟蹤到gc斷點,則跟蹤到下一條指令;而如果跟蹤到g斷點,則控件將全速恢復,您將失去位置。)

此斷點的調試器輸出(命中時)列出傳遞給kernel32的名稱!CreateFileW,與此類似(如果我在cmd.exe中設置此斷點,然后輸入“C:\ readme.txt”,則調試器輸出中可能會出現此情況):

00657ff0  "C:\\readme.txt"

(“注意到,當斷點顯示字符串轉移到函數時,如果程序使用相對路徑,它將是一個相對路徑。”)

從專業翻譯人員、公司、網頁及可自由查看的翻譯庫中學習。就本案而言,這可能是一個很好的想法,顯示返回的句柄和最后的錯誤碼。這可以通過在第一參數倒置后將斷點移到函數返回點來完成,然后顯示附加信息。為了做到這一點,我們可以使用以下斷點:

0:001> bp kernel32!CreateFileW "du poi(@esp+4) ; g @$ra ; !handle @eax f ; !gle ; g"

此斷點的要點是在函數返回后顯示返回的句柄(和最后一個錯誤狀態)。這是通過指示調試器繼續執行直到返回地址被命中,然后對返回值(!handle @eax f)和最后一個錯誤狀態(!gle)。(@$ra符號是一個偽寄存器,它以獨立於平台的方式引用當前函數的返回地址。實際上,g @$ra命令運行程序,直到返回地址被命中為止。)

此斷點的輸出可能如下:
0016f0f0  "coffbase.txt"
Handle 60
  Type             File
  Attributes       0
  GrantedAccess    0x120089:
         ReadControl,Synch
         Read/List,ReadEA,ReadAttr
  HandleCount      2
  PointerCount     3
  No Object Specific Information available
LastErrorValue: (Win32) 0 (0) - The operation
  completed successfully.
LastStatusValue: (NTSTATUS) 0 - STATUS_WAIT_0

但是,如果我們無法打開文件,則結果不太理想:

00657ff0  "c:\\readme.txt"
Handle 4
  Type             Directory
[...] enumeration of all handles follows [...]
21 Handles
Type               Count
Event              3
File               4
Directory          3
Mutant             1
WindowStation      1
Semaphore          2
Key                6
Thread             1
LastErrorValue: (Win32) 0x2 (2) - The system
  cannot find the file specified.
LastStatusValue: (NTSTATUS) 0xc0000034 -
  Object Name not found.

出什么事了?好吧,那個!handle命令本質上擴展為“!handle -1 f“,因為CreateFile返回了無效的句柄值(-1)。這種模式的!handle擴展枚舉進程中的所有句柄,這不是我們想要的。不過,只要稍微聰明一點,我們就可以改進這一點。斷點處的第二次可能如下所示:

0:001> bp kernel32!CreateFileW "du poi(@esp+4) ; g @$ra ; .if (@eax != -1) { .printf \"Opened handle %p\\n\", @eax ; !handle @eax f } .else { .echo Failed to open file, error: ; !gle } ; g"

雖然這個命令一開始看起來有點嚇人,但實際上它相當直截了當。與此斷點的前一版本一樣,它實現的實質是顯示傳遞給kernel32!CreateFileW然后繼續執行,直到CreateFile返回。然后,根據函數是否返回INVALID_HANDLE_VALUE (-1),顯示句柄或最后一個錯誤狀態。改進的斷點的輸出可能如下所示(例如,成功打開文件,但未能打開文件):

Success:

0016f0f0  "coffbase.txt"
Opened handle 00000060
Handle 60
  Type             File
  Attributes       0
  GrantedAccess    0x120089:
         ReadControl,Synch
         Read/List,ReadEA,ReadAttr
  HandleCount      2
  PointerCount     3
  No Object Specific Information available

Failure:

00657ff0  "C:\\readme.txt"
Failed to open file, error:
LastErrorValue: (Win32) 0x2 (2) - The system
  cannot find the file specified.
LastStatusValue: (NTSTATUS) 0xc0000034 -
  Object Name not found.

好多了。斷點中的一點智能允許我們跳過在失敗情況下轉儲整個進程句柄表的不良行為,甚至可以跳過在成功情況下顯示最后一個錯誤代碼。
正如人們可能想象的那樣,一旦考慮到條件斷點提供的靈活性,這里還有一系列其他的可能性。然而,這種方法也有一些缺點,必須加以考慮。更多關於其他一些更高級的條件斷點,以便在以后的文章中進行日志記錄(以及仔細查看使用調試器而不是專用程序的一些限制和缺點,以及使用這種方法可能遇到的一些“問題”)。

 
盡管像我之前描述的那樣記錄斷點(即顯示函數輸入參數和返回值)確實很方便,但是您可能已經想出了一些場景,在這些場景中,像我提供的樣式的斷點不會提供跟蹤問題所需的內容。
最值得注意的例子是,在進行函數調用之后,需要檢查由函數調用填充的out參數。這就帶來了一個問題,因為在函數調用返回后訪問堆棧上的函數參數通常是不可靠的(在Windows上使用的基於堆棧和寄存器的調用約定中,被調用函數可以根據需要自由修改參數位置,這在啟用優化時非常常見)。因此,我們真正需要的是能夠跨函數調用保存一些狀態,以便在函數返回后訪問函數的一些參數。

幸運的是,這在調試器中是可行的,盡管方式相當迂回。這里的關鍵是使用所謂的用戶定義偽寄存器,它們在概念上是獨立於平台的額外存儲位置(就表達式求值器而言,像常規寄存器一樣訪問,因此稱為偽寄存器)。這些偽寄存器本質上只是常規編程意義上的變量,盡管可用的偽寄存器數量有限(當前版本中為20個)。因此,使用它們可以完成的任務有一些限制,但在大多數情況下,20個就足夠了。如果您發現自己需要跟蹤更多的狀態,則應強烈考慮使用C語言編寫調試器擴展,而不是使用調試器腳本語言。
(順便說一句,幾年前在Driver DevCon上,我記得我坐在一個面向WinDbg的會話中,演示者曾經瀏覽過一個用(當時相對較新的)擴展的調試器腳本語言編寫的大型程序,並額外支持條件和錯誤處理。我還是忍不住把調試器腳本程序看作是將Perl最丑陋的部分與cmd.exe樣式的批處理腳本結合在一起(盡管公平地說,調試器表達式求值器比批處理腳本更強大一些,而且它最初也從未打算用於比簡單表達式更強大的部分)。老實說,我仍然強烈建議不要在可能的情況下編寫高度復雜的調試器腳本程序;它們是維護的噩夢之一。在這種情況下,編寫調試器擴展(或完全驅動調試器的程序)是更好的選擇。不過,我離題了;回到通話記錄的話題。)
調試器的用戶定義偽寄存器功能提供了一種存儲狀態的有效方法(如果可能有點笨拙的話),這可以用於保存函數調用中的參數值。例如,我們可能想要記錄對read file的所有調用,這樣我們就需要一個正在讀取的文件數據的轉儲。為了完成這個任務,我們需要轉儲輸出緩沖區的內容(並使用bytes transferred count,另一個out參數)。這可以像這樣完成(在本例中,為了簡潔起見,我假設程序在同步I/O模式下使用ReadFile):

0:000> bp kernel32!ReadFile "r @$t0 = poi(@esp+8) ; r @$t1 = poi(@esp+10) ; g @$ra ; .if (@eax != 0) { .printf \"Read %lu bytes: \\n\", dwo(@$t1) ; db @$t0 ldwo(@$t1) } .else { .echo Read failed! ; !gle } ; g "

此命令的輸出可能如下:

Read 22 bytes: 
0016ec3c  54 68 69 73 20 69 73 20-61 20 74 65 78 74 20 66
              This is a text f
0016ec4c  69 6c 65 2e 0d 0a
              ile...

這個命令本質上是昨天示例的邏輯擴展,添加了一些在調用中共享的狀態。具體來說,@$t0和@$t1用戶定義的偽寄存器用於在函數執行期間將lpBuffer([esp+08h])和lpNumberOfBytesRead([esp+10h])參數保存到ReadFile調用。當在返回地址停止執行時,通過取消對由@t0和@t1引用的值的引用,將轉儲剛剛讀取的文件數據的內容。

雖然這種跨執行的狀態保存是有用的,但也有缺點。 首先,這種類型的斷點從根本上講與多個線程是不兼容的(至少在多個線程同時命中有問題的斷點的情況下)。這是因為調試器沒有提供“expression local”或“thread local”狀態——可以這么說,多個線程同時命中斷點可以互相踩腳。(這個問題也可能發生在任何類型的斷點上,這些斷點包括在“g<address>”命令創建隱式斷點之前繼續執行,盡管“有狀態”斷點可能更嚴重。)
調試器中的這種限制可以通過在g命令中通過線程說明符使斷點線程特定來以有限的方式解決,盡管這通常很難做到。許多調用日志程序本機將考慮多線程,並且不需要任何特殊工作來適應多線程函數調用。(請注意,這個問題通常沒有聽起來那么嚴重——在許多情況下,甚至在多線程程序中,通常只有一個函數調用您感興趣的函數,或者線程沖突的可能性足夠小,以至於它絕大多數時間都能正常工作。但是,在某些情況下,如果問題函數經常從多個線程調用,並且需要在函數返回后檢查數據,則這些樣式的斷點就不能很好地工作。)
使用調試器進行調用日志記錄(與專用程序相反)的另一個重要限制是,調試器通常比執行日志記錄的去延遲程序非常慢。這里的原因是,對於每個斷點事件,實際上程序中的所有線程都被凍結,各種狀態信息從程序復制到調試器,然后在調試器端計算斷點表達式。另外,與專用程序不同的是,日志斷點的結果是實時顯示的,而不是(比如)存儲在二進制日志緩沖區中,以便以后格式化和顯示。這意味着,由於需要在每個斷點上更新調試器UI,因此會產生更多的開銷。因此,如果在頻繁命中的函數上設置條件斷點,則可能會注意到程序速度明顯減慢,甚至可能到無法使用的程度。專用日志程序可以采用各種技術來規避調試器的這些限制,這些限制主要是調試器主要設計為調試器而不是高速API監視器這一事實的產物。
這在內核調試器的情況下更為明顯,因為在KD模式下轉換到調試器的速度非常慢,以至於即使每秒幾次轉換也足以使系統在實際中幾乎不可用。因此,在選擇在內核調試器中設置條件日志斷點的位置時需要格外小心(可能將它們放在函數的中間、特定的有趣代碼路徑中,而不是放在開始時,以便捕獲所有調用)。
考慮到這些限制,有必要對問題進行一些分析,以確定調試器或專用日志程序是否是最佳選擇。這兩種方法都有優點和缺點,盡管調試器非常靈活(並且通常非常方便),但它未必是每一種可能場景中的最佳選擇。換言之,用最好的工具來做這項工作。但是,在某些情況下,唯一的選擇是使用調試器,例如內核模式調用日志記錄,因此我建議您至少掌握一些如何使用調試器完成日志記錄任務的基本知識,即使您通常總是使用專用的日志記錄程序。(不過,在內核模式調試的情況下,同樣,調試器轉換的緩慢性使得選擇“低流量”位置作為斷點非常重要。)
盡管如此,有效地調試和解決問題的一個重要部分是了解您的選項以及何時(何時)使用它們。使用調試器執行調用日志記錄應該只是“調試工具包”中許多這樣的選項之一。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM