一:背景 1. 講故事 這段時間分析了幾個和網路故障有關的.NET程式之後,真的越來越體會到電腦基礎課的重要,比如 電腦網路 課,如果沒有對 tcpip協議 的深刻理解,解決這些問題真的很難,因為你只能在高層做黑盒測試,你無法看到 tcp 層面的握手和psh通訊。 這篇我們通過兩個小例子來理解一 ...
一、簡介
今天是《Net 高級調試》的第六篇文章。記得我剛接觸 Net 框架的時候,還是挺有信心的,對所謂的值類型和引用類型也能說出自己的見解,畢竟,自己一直在努力。當然這些見解都是書本上的,並沒有做到眼見為實,所以總是有些東西說不清楚。今天,我們就好好的說說 C# 的類型,是從記憶體級別、從底層來說一下值類型、引用類型到底是什麼,它們在記憶體中的形態,還有也說說數組的記憶體形態,如何內部佈局的,以及我們如何查找由未捕捉的異常引起的程式崩潰。這些都是基礎的,如果這些掌握不好,以後的高級調試的道路,也不好走。自從我過了這一關,很多東西理解起來,比較透徹 了,但是,還必須努力。當然了,第一次看視頻或者看書,是很迷糊的,不知道如何操作,還是那句老話,一遍不行,那就再來一遍,還不行,那就再來一遍,俗話說的好,書讀千遍,其意自現。
如果在沒有說明的情況下,所有代碼的測試環境都是 Net Framewok 4.8,但是,有時候為了查看源碼,可能需要使用 Net Core 的項目,我會在項目章節里進行說明。好了,廢話不多說,開始我們今天的調試工作。
調試環境我需要進行說明,以防大家不清楚,具體情況我已經羅列出來。
操作系統:Windows Professional 10
調試工具:Windbg Preview(可以去Microsoft Store 去下載)
開發工具:Visual Studio 2022
Net 版本:Net Framework 4.8
CoreCLR源碼:源碼下載
二、基礎知識
1、對象檢查
1.1、簡介
高級調試的目標就是分析應用程式故障所形成的原因,既然是故障,大多數情況下是對象的某種損壞,這就需要我們深入的瞭解各種對象的審查方法。
2、各種檢查方法
2.1、記憶體轉儲
這個方式非常底層,從記憶體地址上觀察地址上的內容,常使用【dp】命令。出來【dp】命令,還有其他一些命令,比如:du,dw,db,da 等,如果想瞭解更多,可以查看 Windbg 的幫助文檔,命令是【.hh】。
2.2、對值類型的轉儲
對值類型的轉儲非常的簡單,一般通過【dp】命令觀察記憶體地址,從記憶體上提取內容,可以在結束處觀察 esp 指針,當然,如果知道棧地址,我們也可以使用【dp】命令查看內容,效果和查看 esp 是一樣的。
2.3、對應用類型的轉儲
如果我們想查看引用類型的內容,我們可以使用【!do】命令,查看應用類型的內容。
2.4、對數組的轉儲
C# 數組的記憶體佈局,大概有兩種形式,值類型和引用類型。
1)值類型數組。
結構:同步塊索引,方法表,數組大小,數組元素1,數組元素2......
2)引用類型數組。
結構:同步塊索引,方法表,數組大小,數組元素1,數組元素2......
| |
|------------------+ +--------------------|
| |
同步塊索引、方法表、內容 同步塊索引、方法表、內容
2.5、對異常的轉儲
有時候,程式存在未處理的異常導致的崩潰,在事後分析 dump 中要是能找到這個異常,對我們分析和解決問題會有很大的幫助。如果我們想查看異常的詳情,我們可以使用【!pe(print exception)】命令,列印異常的詳情,當然也可以使用【!do】命令查看更詳細的異常內容。
三、調試過程
廢話不多說,這一節是具體的調試操作的過程,又可以說是眼見為實的過程,在開始之前,我還是要啰嗦兩句,這一節分為兩個部分,第一部分是測試的源碼部分,沒有代碼,當然就談不上測試了,調試必須有載體。第二部分就是根據具體的代碼來證實我們學到的知識,是具體的眼見為實。
1、測試源碼
1.1、Example_6_1_1
1 namespace Example_6_1_1 2 { 3 internal class Program 4 { 5 static void Main(string[] args) 6 { 7 var person = new Person(); 8 9 Console.ReadLine(); 10 } 11 } 12 13 internal class Person 14 { 15 public int Age = 20; 16 17 public string Name = "jack"; 18 } 19 }View Code
1.2、Example_6_1_2
1 namespace Example_6_1_2 2 { 3 internal class Program 4 { 5 static void Main(string[] args) 6 { 7 Debugger.Break(); 8 9 int a = 10; 10 int b = 11; 11 int c = 12; 12 int d = 13; 13 } 14 } 15 }View Code
1.3、Example_6_1_3
1 namespace Example_6_1_3 2 { 3 internal class Program 4 { 5 static void Main(string[] args) 6 { 7 int[] intArr = { 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }; 8 9 string[] stringArr = { "1", "2", "3", "4", "5" }; 10 11 Debugger.Break(); 12 } 13 } 14 }View Code
1.4、Example_6_1_4
1 namespace Example_6_1_4 2 { 3 internal class Program 4 { 5 static void Main(string[] args) 6 { 7 Console.WriteLine($"請輸入一個能夠整除的數字:"); 8 var num = Console.ReadLine(); 9 10 var ret = 10 / Convert.ToInt32(num); 11 } 12 } 13 }View Code
2、眼見為實
項目的所有操作都是一樣的,所以就在這裡說明一下,但是每個測試例子,都需要重新啟動,並載入相應的應用程式,載入方法都是一樣的。流程如下:我們編譯項目,打開 Windbg,點擊【文件】----》【launch executable】附加程式,打開調試器的界面,程式已經處於中斷狀態。我們需要使用【g】命令,繼續運行程式,然後到達指定地點停止後,我們可以點擊【break】按鈕,就可以調試程式了。有時候可能需要切換到主線程,可以使用【~0s】命令。
2.1、記憶體轉儲
測試代碼:Example_6_1_1
我們首先去托管堆中查找一下 Person 對象。【dp】命令的 p 是 pointer,指針的意思。
1 0:006> !dumpheap -type Person 2 Address MT Size 3 031424c8 013d4dec 16 4 5 Statistics: 6 MT Count TotalSize Class Name 7 013d4dec 1 16 Example_6_1_1.Person 8 Total 1 objects
031424c8(Person 對象的指針地址) 013d4dec(Person 方法表的地址)
1 0:006> !dumpobj /d 031424c8 2 Name: Example_6_1_1.Person 3 MethodTable: 013d4dec 4 EEClass: 013d12f0 5 Size: 16(0x10) bytes 6 File: E:\Visual Studio 2022\Source\Projects\....\Example_6_1_1\bin\Debug\Example_6_1_1.exe 7 Fields: 8 MT Field Offset Type VT Attr Value Name 9 706342a8 4000001 8 System.Int32 1 instance 20 Age 10 706324e4 4000002 4 System.String 0 instance 031424d8 Name
上面兩段代碼,紅色標註的就是 Person 對象的方法表的地址,他們是一樣的。我們有了對象的地址,對象的地址其實就是類型句柄的地址,也就是知道通過快索引的地址,只要減去 0x4就可以,說明一下,每個引用類型都包含【同步塊索引】和【類型句柄】。
1 0:006> dp 031424c8-0x4 l4 2 031424c4 00000000 013d4dec 031424d8 00000014
00000000 就是同步塊索引,後面的 013d4dec 就是方法表的地址,最後兩項就是 Person 具體的值。00000014 就是十進位的20,也就是 Person 對象的 Age 欄位的值。031424d8 就是 Person 對象的 Name 的值。我們來驗證。
1 0:006> !do 031424d8 2 Name: System.String 3 MethodTable: 706324e4 4 EEClass: 70737690 5 Size: 22(0x16) bytes 6 File: C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll 7 String: jack 8 Fields: 9 MT Field Offset Type VT Attr Value Name 10 706342a8 4000283 4 System.Int32 1 instance 4 m_stringLength 11 70632c9c 4000284 8 System.Char 1 instance 6a m_firstChar 12 706324e4 4000288 70 System.String 0 shared static Empty 13 >> Domain:Value 01472260:NotInit << 14 15 16 0:006> ? 00000014 17 Evaluate expression: 20 = 00000014
紅色標註的就是具體的值。我們可以繼續使用【dp】命令,查看 name 的詳情。
1 0:006> dp 031424d8 l4 2 031424d8 706324e4 00000004 0061006a 006b0063
031424d8(字元串 Name 的地址) 706324e4(name 的方法表的地址) 00000004(字元串的長度) 0061006a 006b0063(這兩項就是具體的值),字元串的長度,我們可以通過 name 的地址加上 0x4,就可以得到字元串的長度。
1 0:006> dp 031424d8+0x4 l4 2 031424dc 00000004 0061006a 006b0063 00000000
為什麼是加上 0x4,因為我們在使用【!do】命令,查看 name 詳情的時候,m_stringLength 的偏移量是4。
0061006a(看地址,我們是從低地址往高地址看),所以我們先看 006a,然後再查看 0061,006b0063這個地址也是一樣的。我們可以使用【du】命令查看它的內容,u就是 Unicode。006a 就是 j,0061就是a,0063就是 c,006b就是k。
1 0:006> du 031424d8+0x8 2 031424e0 "jack"
為什麼加 0x8,因為偏移量是8。
2.2、對值類型的轉儲。
測試代碼:Example_6_1_2
我們首先找到棧地址,可以使用【!clrstack】命令,有了棧地址,我們可以使用【bp】命令在該地址上設置斷點。
1 0:000> !clrstack 2 OS Thread Id: 0x358c (0) 3 Child SP IP Call Site 4 007af2e0 7566f262 [HelperMethodFrame: 007af2e0] System.Diagnostics.Debugger.BreakInternal() 5 007af35c 7146f195 System.Diagnostics.Debugger.Break() [f:\dd\ndp\clr\src\BCL\system\diagnostics\debugger.cs @ 91] 6 007af384 029e0879 Example_6_1_2.Program.Main(System.String[]) [E:\Visual Studio 2022\Source\Projects\...\Example_6_1_2\Program.cs @ 9] 7 007af504 71daf036 [GCFrame: 007af504]
紅色標註的【029e0879】就是【Program.Main】方法的棧地址,然後我們設置斷點,【g】繼續運行。
1 0:000> bp 029e0879 2 0:000> g 3 Breakpoint 0 hit 4 eax=00000000 ebx=007af434 ecx=72227ced edx=00b239b0 esi=00000000 edi=007af3b0 5 eip=029e0879 esp=007af384 ebp=007af398 iopl=0 nv up ei pl zr na pe nc 6 cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246 7 Example_6_1_2!COM+_Entry_Point <PERF> (Example_6_1_2+0x23d0879): 8 029e0879 90 nop
在斷點處暫停,如圖:
然後,使用【g】命令,繼續運行。
1 0:000> g 2 Breakpoint 1 hit 3 eax=00000000 ebx=007af434 ecx=72227ced edx=00b239b0 esi=00000000 edi=007af3b0 4 eip=029e0896 esp=007af384 ebp=007af398 iopl=0 nv up ei pl zr na pe nc 5 cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246 6 Example_6_1_2!COM+_Entry_Point <PERF> (Example_6_1_2+0x23d0896): 7 029e0896 90 nop
然後,我們使用【dp esp】命令查看詳細,當然我們可以可以使用【dp 007af384】,dp 命令後跟的是棧地址,都是可以的。
1 0:000> dp esp 2 007af384 0000000d 0000000c 0000000b 0000000a 3 007af394 02a024bc 007af3a4 71daf036 00b239b0 4 007af3a4 007af3f8 71db22da 007af434 007af3e8 5 007af3b4 71ea23d0 007af504 71db2284 b7ed3afc 6 007af3c4 007af544 007af4c8 007af47c 71dbec59 7 007af3d4 007af434 00000000 b7ed3afc 007af3b0 8 007af3e4 007af4c8 007af574 71ea09b0 c64cea1c 9 007af3f4 00000001 007af460 71db859b 00000000
紅色標註的就是變數賦的值。彙編代碼如下:
1 0:000> !clrstack -l 2 OS Thread Id: 0x358c (0) 3 Child SP IP Call Site 4 007af384 029e0896 Example_6_1_2.Program.Main(System.String[]) [E:\Visual Studio 2022\...\Example_6_1_2\Program.cs @ 15] 5 LOCALS: 6 0x007af390 = 0x0000000a(a 變數的值,等號前面是棧地址) 7 0x007af38c = 0x0000000b(a 變數的值,等號前面是棧地址,等不 a 的棧地址減去 0x4) 8 0x007af388 = 0x0000000c(a 變數的值,等號前面是棧地址,等不 b 的棧地址減去 0x4) 9 0x007af384 = 0x0000000d(a 變數的值,等號前面是棧地址,等不 c 的棧地址減去 0x4) 10 11 007af504 71daf036 [GCFrame: 007af504]
棧地址是由高到低分配的,0x007af384 是變數d 的地址,0x007af384+0x4=0x007af388,這個地址就是c 變數的地址,0x007af388+0x4=0x007af38c,0x007af38c這個地址就是 b 變數的地址,0x007af38c+0x4就是 a 變數的地址,也就是 0x007af390。
2.3、對應用類型的轉儲。
測試代碼:Example_6_1_1
1 0:000> !clrstack -l 2 OS Thread Id: 0x38fc (0) 3 Child SP IP Call Site 4 00aff25c 777110fc [InlinedCallFrame: 00aff25c] 5 00aff258 70d99b71 DomainNeutralILStubClass.IL_STUB_PInvoke(Microsoft.Win32.SafeHandles.SafeFileHandle, Byte*, Int32, Int32 ByRef, IntPtr) 6 7 ... 8 9 00aff358 0117088e Example_6_1_1.Program.Main(System.String[]) [E:\Visual Studio 2022\...\Example_6_1_1\Program.cs @ 11] 10 LOCALS: 11 0x00aff360 = 0x02bb24c8 12 13 00aff4dc 71daf036 [GCFrame: 00aff4dc]
我們查看線程棧了,找到了局部變數。也找到了地址,我們通過【!do】或者【!dumpobj】查看Person 變數。
1 0:000> !DumpObj /d 02bb24c8 2 Name: Example_6_1_1.Person 3 MethodTable: 00d14dec 4 EEClass: 00d112f0 5 Size: 16(0x10) bytes 6 File: E:\Visual Studio 2022\Source\Projects\...\Example_6_1_1\bin\Debug\Example_6_1_1.exe 7 Fields: 8 MT Field Offset Type VT Attr Value Name 9 708f42a8 4000001 8 System.Int32 1 instance 20 Age 10 708f24e4 4000002 4 System.String 0 instance 02bb24d8 Name(紅色又是一個地址)
紅色的可以在【!do】,我們就可以看到詳情。
1 0:000> !DumpObj /d 02bb24d8 2 Name: System.String 3 MethodTable: 708f24e4 4 EEClass: 709f7690 5 Size: 22(0x16) bytes 6 File: C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll 7 String: jack(字元串 name 變數的值) 8 Fields: 9 MT Field Offset Type VT Attr Value Name 10 708f42a8 4000283 4 System.Int32 1 instance 4 m_stringLength 11 708f2c9c 4000284 8 System.Char 1 instance 6a m_firstChar 12 708f24e4 4000288 70 System.String 0 shared static Empty 13 >> Domain:Value 00d6d780:NotInit <<說明:我們有了對象的地址,也可以使用【dp】命令,只不過不太好看。
1 0:000> dp 02bb24c8 l4 2 02bb24c8 00d14dec 02bb24d8 00000014 80000000
00d14dec 這個地址就是 Person 對象的方法表的地址,02bb24d8 就是name 變數的地址。00000014 就是 age 變數的值。
2.4、對數組的轉儲。
測試代碼:Example_6_1_3
1)值類型數組
我們定義的數組:int[] intArr = { 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 };
我們先列印出線程棧。
1 0:000> !clrstack -l 2 OS Thread Id: 0x2e54 (0) 3 Child SP IP Call Site 4 00afeeec 7566f262 [HelperMethodFrame: 00afeeec] System.Diagnostics.Debugger.BreakInternal() 5 00afef68 7146f195 System.Diagnostics.Debugger.Break() [f:\dd\ndp\clr\src\BCL\system\diagnostics\debugger.cs @ 91] 6 7 00afef90 00ce0920 Example_6_1_3.Program.Main(System.String[]) [E:\Visual Studio 2022\...\Example_6_1_3\Program.cs @ 13] 8 LOCALS: 9 0x00afef9c = 0x02b82518(int 數組,值類型數組) 10 0x00afef98 = 0x02b82574(string 數組,引用類型的數組) 11 12 00aff118 71daf036 [GCFrame: 00aff118]
我們可以點擊棧地址後面的地址,就可以查看詳情,相當於【!do】命令。
1 0:000> !DumpObj /d 02b82518 2 Name: System.Int32[] 3 MethodTable: 708f426c 4 EEClass: 709f805c 5 Size: 56(0x38) bytes 6 Array: Rank 1, Number of elements 11, Type Int32 (Print Array) 7 Fields: 8 None 9 0:000> !DumpObj /d 02b82574 10 Name: System.String[] 11 MethodTable: 708f2d74 12 EEClass: 709f7820 13 Size: 32(0x20) bytes 14 Array: Rank 1, Number of elements 5, Type CLASS (Print Array) 15 Fields: 16 None
使用【dp】命令,查看結果。
1 0:000> dp 0x02b82518-0x4 L20(為什麼要減去 0x4,因為當前對象的指針指向方法表,每個域是占用4個位元組,L20 的l 是不區分大小寫的) 2 02b82514 00000000 708f426c 0000000b 0000000a 3 02b82524 0000000b 0000000c 0000000d 0000000e 4 02b82534 0000000f 00000010 00000011 00000012 5 02b82544 00000013 00000014 00000000 708f4354 6 02b82554 00000000 00000000 00000000 00000000 7 02b82564 00000000 00000000 00c44dd8 00000000 8 02b82574 708f2d74 00000005 02b824c8 02b824d8 9 02b82584 02b824e8 02b824f8 02b82508 00000000
00000000 表示的同步塊所有,為0就是什麼數據都沒有,708f426c 就是方法表的地址。0000000b 表示的數組長度,十進位是11,表示有11個元素。此項後面的就是數組元素了。
如果想可視化的查看,可以使用命令【!da -details】,內容太多,摺疊了。
1 0:000> !da -details 0x02b82518 2 Name: System.Int32[] 3 MethodTable: 708f426c 4 EEClass: 709f805c 5 Size: 56(0x38) bytes 6 Array: Rank 1, Number of elements 11, Type Int32 7 Element Methodtable: 708f42a8 8 [0] 02b82520 9 Name: System.Int32 10 MethodTable: 708f42a8 11 EEClass: 709f8014 12 Size: 12(0xc) bytes 13 File: C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll 14 Fields: 15 MT Field Offset Type VT Attr Value Name 16 708f42a8 40005a2 0 System.Int32 1 instance 10 m_value 17 [1] 02b82524 18 Name: System.Int32 19 MethodTable: 708f42a8 20 EEClass: 709f8014 21 Size: 12(0xc) bytes 22 File: C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll 23 Fields: 24 MT Field Offset Type VT Attr Value Name 25 708f42a8 40005a2 0 System.Int32 1 instance 11 m_value 26 [2] 02b82528 27 Name: System.Int32 28