CoreCLR源码探索(六) NullReferenceException是如何发生的
NullReferenceException可能是.Net程序員遇到最多的例外了, 這個例外發生的如此頻繁,以至于人們付出了巨大的努力來使用各種特性和約束試圖防止它發生, 但時至今日它仍然讓很多程序員頭痛, 今天我將講解這個令人頭痛的例外是如何發生的.
可以導致NullReferenceException發生的源代碼
我們先來看看什么樣的代碼可以導致NullReferenceException發生:
第一份代碼, 調用函數時this等于null導致例外發生
using System;namespace ConsoleApp1{ ? ?
class Program{ ? ? ?
?public class MyClass{ ? ? ? ?
? ? ?public int MyMember; ? ?
? ? ? ?public void MyMethod() { }} ? ? ? ?static void Main(string[] args) ? ? ? ?{MyClass obj = null;obj.MyMethod();}} }
第二份代碼, 訪問成員時this等于null導致例外發生
using System;namespace ConsoleApp1{ ?
?class Program{ ? ? ?
?public class MyClass{ ? ? ? ?
?? ?public int MyMember; ? ?
? ? ? ?public void MyMethod() { }} ? ? ? ?static void Main(string[] args) ? ? ? ?{MyClass obj = null;Console.WriteLine(obj.MyMember);}} }
觀察生成的IL代碼
再來看看生成的IL:
第一份代碼的IL
.method private hidebysig static void Main (string[] args) cil managed {// Method begins at RVA 0x2050// Code size 11 (0xb).maxstack 1.entrypoint.locals init ([0] class ConsoleApp1.Program/MyClass)IL_0000: nopIL_0001: ldnullIL_0002: stloc.0IL_0003: ldloc.0IL_0004: callvirt instance void ConsoleApp1.Program/MyClass::MyMethod()IL_0009: nopIL_000a: ret } // end of method Program::Main第二份代碼的IL
.method private hidebysig static void Main (string[] args) cil managed {// Method begins at RVA 0x2050// Code size 16 (0x10).maxstack 1.entrypoint.locals init ([0] class ConsoleApp1.Program/MyClass)IL_0000: nopIL_0001: ldnullIL_0002: stloc.0IL_0003: ldloc.0IL_0004: ldfld int32 ConsoleApp1.Program/MyClass::MyMemberIL_0009: call void [System.Console]System.Console::WriteLine(int32)IL_000e: nopIL_000f: ret } // end of method Program::Main看出什么了嗎? 看不出吧, 我也看不出, 這代表了null檢查不是在IL層面實現的, 我們需要繼續往下看.
觀察生成的匯編代碼
看生成的匯編代碼:
第一份代碼生成的匯編 (架構不同生成的代碼也不同, 以下代碼是windows x64生成的)
? ?10: ? ? ? ? static void Main(string[] args) {00007FF9F5C30482 56 ? ? ? ? ? ? ? ? ? push ? ? ? ?rsi ?00007FF9F5C30483 48 83 EC 30 ? ? ? ? ?sub ? ? ? ? rsp,30h ?00007FF9F5C30487 48 8B EC ? ? ? ? ? ? mov ? ? ? ? rbp,rsp ?00007FF9F5C3048A 33 C0 ? ? ? ? ? ? ? ?xor ? ? ? ? eax,eax ?00007FF9F5C3048C 48 89 45 20 ? ? ? ? ?mov ? ? ? ? qword ptr [rbp+20h],rax ?00007FF9F5C30490 48 89 45 28 ? ? ? ? ?mov ? ? ? ? qword ptr [rbp+28h],rax ?00007FF9F5C30494 48 89 4D 50 ? ? ? ? ?mov ? ? ? ? qword ptr [rbp+50h],rcx ?00007FF9F5C30498 83 3D 49 48 EA FF 00 cmp ? ? ? ? dword ptr [7FF9F5AD4CE8h],0 ?00007FF9F5C3049F 74 05 ? ? ? ? ? ? ? ?je ? ? ? ? ?00007FF9F5C304A6 ?00007FF9F5C304A1 E8 1A B5 C0 5F ? ? ? call ? ? ? ?00007FFA5583B9C0 ?00007FF9F5C304A6 90 ? ? ? ? ? ? ? ? ? nop ? ? ?11: ? ? ? ? ? ? MyClass obj = null;00007FF9F5C304A7 33 C9 ? ? ? ? ? ? ? ?xor ? ? ? ? ecx,ecx ?00007FF9F5C304A9 48 89 4D 20 ? ? ? ? ?mov ? ? ? ? qword ptr [rbp+20h],rcx ? ? ?12: ? ? ? ? ? ? obj.MyMethod();00007FF9F5C304AD 48 8B 4D 20 ? ? ? ? ?mov ? ? ? ? rcx,qword ptr [rbp+20h] ?00007FF9F5C304B1 39 09 ? ? ? ? ? ? ? ?cmp ? ? ? ? dword ptr [rcx],ecx ?00007FF9F5C304B3 E8 E8 FB FF FF ? ? ? call ? ? ? ?00007FF9F5C300A0 ?00007FF9F5C304B8 90 ? ? ? ? ? ? ? ? ? nop ? ? ?13: ? ? ? ? }第二份代碼生成的匯編
? ?10: ? ? ? ? static void Main(string[] args) {00007FF9F5C20B22 56 ? ? ? ? ? ? ? ? ? push ? ? ? ?rsi ?00007FF9F5C20B23 48 83 EC 30 ? ? ? ? ?sub ? ? ? ? rsp,30h ?00007FF9F5C20B27 48 8B EC ? ? ? ? ? ? mov ? ? ? ? rbp,rsp ?00007FF9F5C20B2A 33 C0 ? ? ? ? ? ? ? ?xor ? ? ? ? eax,eax ?00007FF9F5C20B2C 48 89 45 20 ? ? ? ? ?mov ? ? ? ? qword ptr [rbp+20h],rax ?00007FF9F5C20B30 48 89 45 28 ? ? ? ? ?mov ? ? ? ? qword ptr [rbp+28h],rax ?00007FF9F5C20B34 48 89 4D 50 ? ? ? ? ?mov ? ? ? ? qword ptr [rbp+50h],rcx ?00007FF9F5C20B38 83 3D A9 41 EA FF 00 cmp ? ? ? ? dword ptr [7FF9F5AC4CE8h],0 ?00007FF9F5C20B3F 74 05 ? ? ? ? ? ? ? ?je ? ? ? ? ?00007FF9F5C20B46 ?00007FF9F5C20B41 E8 7A AE C1 5F ? ? ? call ? ? ? ?00007FFA5583B9C0 ?00007FF9F5C20B46 90 ? ? ? ? ? ? ? ? ? nop ? ? ?11: ? ? ? ? ? ? MyClass obj = null;00007FF9F5C20B47 33 C9 ? ? ? ? ? ? ? ?xor ? ? ? ? ecx,ecx ?00007FF9F5C20B49 48 89 4D 20 ? ? ? ? ?mov ? ? ? ? qword ptr [rbp+20h],rcx ? ? ?12: ? ? ? ? ? ? Console.WriteLine(obj.MyMember);00007FF9F5C20B4D 48 8B 4D 20 ? ? ? ? ?mov ? ? ? ? rcx,qword ptr [rbp+20h] ?00007FF9F5C20B51 8B 49 08 ? ? ? ? ? ? mov ? ? ? ? ecx,dword ptr [rcx+8] ?00007FF9F5C20B54 E8 87 FB FF FF ? ? ? call ? ? ? ?00007FF9F5C206E0 ?00007FF9F5C20B59 90 ? ? ? ? ? ? ? ? ? nop ? ? ?13: ? ? ? ? }從匯編我們可以看出點端倪了, 注意第一份代碼中的以下指令
00007FF9F5C304B1 39 09 ? ? ? ? ? ? ? ?cmp ? ? ? ? dword ptr [rcx],ecx ?和第二份代碼中的以下指令
00007FF9F5C20B51 8B 49 08 ? ? ? ? ? ? mov ? ? ? ? ecx,dword ptr [rcx+8] ?在第一份代碼中多了一個奇怪的cmp指令,
 這個cmp比較了rcx自身但是卻不使用比較的結果(后續je, jne等等),
 這個指令正是null檢查的真面目,
 rcx寄存器保存的是obj對象的指針, 也是下面的call指令的第一個參數(this),
 如果rcx等于0(obj等于null)時, 這條指令就會執行失敗.
在第二份代碼中mov ecx,dword ptr [rcx+8]指令的作用是把rcx保存的obj的MyMember成員的值移到ecx,
 可以理解為c語言的int myMember = obj->MyMember;或int myMember = *(int*)(((char*)obj)+8),
 這里的8是MyMember距離對象開頭的偏移值,
 想象一下如果obj等于null, rcx+8等于8,
 因為內存地址8上面不存在任何內容, 這條指令就會執行失敗.
 因為這條指令已經帶有檢查null的作用, 所以第二份代碼中你看不到像第一份代碼中的cmp指令.
熟悉c語言的可能會問, 這樣的指令執行失敗以后程序不會立刻退出嗎?
 答案是會, 如果你不做特殊的處理, 訪問((MyClass*)NULL)->MyMember會導致程序立刻退出.
 那么在CoreCLR中是如何處理的?
指令執行失敗以后
CPU指令執行失敗以后(內存訪問失敗, 除0等)時, 會傳遞一個硬件例外給內核, 然后內核會結束對應的進程.
 但在結束之前它會允許進程補救, 補救的方法Windows和Linux都不一樣.
在Linux上可以通過捕捉SIGSEGV處理內存訪問失敗, 示例代碼如下
jmp_buf recover_point;static void sigsegv_handler(int sig, siginfo_t* si, void* unused) { ?
?fprintf(stderr, "catched sigsegv\n");longjmp(recover_point, 1); }
?int main() { ?
??struct sigaction action;action.sa_handler = NULL;action.sa_sigaction = sigsegv_handler;action.sa_flags = SA_SIGINFO;sigemptyset(&action.sa_mask); ?
?? ?if (sigaction(SIGSEGV, &action, NULL) != 0) {perror("bind signal handler failed"); ?
?? ? ? ? ?abort();} ? ?if (setjmp(recover_point) == 0) { ? ? ?
?? ? ??int* ptr = NULL;*ptr = 1;} else { ? ? ? ?printf("recover success\n");;} ? ?return 0; }
而在Windows上可以通過注冊VectoredExceptionHandler處理硬件異常, 示例代碼如下
void* gVectoredExceptionHandler = NULL; jmp_buf gRecoverPoint;LONG WINAPI MyVectoredExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo){ ?
?if (pExceptionInfo->ExceptionRecord->ExceptionCode == STATUS_ACCESS_VIOLATION){ ? ? ? ?fprintf(stderr, "catched access violation\n");longjmp(gRecoverPoint, 1);} ? ?return EXCEPTION_CONTINUE_SEARCH; }int main(){gVectoredExceptionHandler = AddVectoredExceptionHandler(TRUE, (PVECTORED_EXCEPTION_HANDLER)MyVectoredExceptionHandler);
? ?if (setjmp(gRecoverPoint) == 0){ ? ? ? ?int* ptr = NULL;*ptr = 1;} ? ?else{ ? ? ? ?printf("recover success\n");} ? ?return 0; }
在上面的代碼中我使用了longjmp來從異常中恢復, 這是最簡單的做法但也會帶來很多問題, 接下來我們看看CoreCLR會如何處理這些異常.
CoreCLR中的處理 (Linux, OSX)
我們先來看Linux上CoreCLR是如何處理的, 以下代碼來源于CoreCLR 1.1.0, OSX上的處理邏輯和Linux一樣.
首先CoreCLR會注冊SIGSEGV的處理器, 在pal\src\exception\signal.cpp中可以找到以下的代碼
BOOL SEHInitializeSignals(DWORD flags){TRACE("Initializing signal handlers\n"); ??/* we call handle_signal for every possible signal, even ?
? ? ? if we don't provide a signal handler. ? ?
?? handle_signal will set SA_RESTART flag for specified signal. ?
? ?? Therefore, all signals will have SA_RESTART flag set, preventing ? ?
? ?? slow Unix system calls from being interrupted. On systems without ?
? ? ? siginfo_t, SIGKILL and SIGSTOP can't be restarted, so we don't ?
? ? ? handle those signals. Both the Darwin and FreeBSD man pages say ? ?
? ? ? that SIGKILL and SIGSTOP can't be handled, but FreeBSD allows us ?
? ? ? to register a handler for them anyway. We don't do that. ? ?
? ? ? see sigaction man page for more details ? ? ? */handle_signal(SIGILL, sigill_handler, &g_previous_sigill);?handle_signal(SIGTRAP, sigtrap_handler, &g_previous_sigtrap);handle_signal(SIGFPE, sigfpe_handler, &g_previous_sigfpe);handle_signal(SIGBUS, sigbus_handler, &g_previous_sigbus);handle_signal(SIGSEGV, sigsegv_handler, &g_previous_sigsegv);handle_signal(SIGINT, sigint_handler, &g_previous_sigint);handle_signal(SIGQUIT, sigquit_handler, &g_previous_sigquit);
這里除了注冊SIGSEGV以外還會注冊其他信號的處理器, 接下來看sigsegv_handler的內容:
common_signal_handler的內容:
繼續追下去會很長, 這里就只貼跟蹤的調用流程了:
? 跳過去以后會繼續處理, 不再返回
總結:
- 在Linux上 
- 這是為了可以和Windows共享處理的代碼 
- 如果對象是null并且訪問對象的函數或者成員, 會觸發SIGSEGV信號 
- CoreCLR捕捉到SIGSEGV信號后會根據信號生成類似Windows形式的EXCEPTION_POINTERS結構體 
- 處理例外時, 根據例外代碼(0xC0000005L)轉換為CLR中的NullReferenceException的對象 
- 回滾堆棧和調用finally中的代碼 
- 跳到對應的處理例外(catch)的代碼 
 
例外處理不是這一篇的重點所以這里我就不詳細解釋了(目前還未弄清楚).
CoreCLR中的處理 (Windows)
在Windows上CoreCLR會注冊一個VectoredHandler用于處理硬件例外:
這是vm\excep.cpp中的CLRAddVectoredHandlers函數, 啟動時會調用
void CLRAddVectoredHandlers(void){// We now install a vectored exception handler on all supporting Windows architectures.g_hVectoredExceptionHandler = AddVectoredExceptionHandler(TRUE, (PVECTORED_EXCEPTION_HANDLER)CLRVectoredExceptionHandlerShim); ? ?if (g_hVectoredExceptionHandler == NULL){LOG((LF_EH, LL_INFO100, "CLRAddVectoredHandlers: AddVectoredExceptionHandler() failed\n"));COMPlusThrowHR(E_FAIL);}LOG((LF_EH, LL_INFO100, "CLRAddVectoredHandlers: AddVectoredExceptionHandler() succeeded\n"));}當硬件異常發生時會調用這個處理器, 代碼同樣在vm\excep.cpp,同樣的, 繼續跟下去會非常長我就只貼跟蹤流程了:
總結:
- 在Windows上 
- 如果對象是null并且訪問對象的函數或者成員, 會觸發硬件異常 
- CoreCLR通過CLRVectoredExceptionHandlerShim捕捉到異常 
- 調用原生的RaiseException拋出例外給ProcessCLRException處理 
- 處理例外時, 根據例外代碼(0xC0000005L)轉換為CLR中的NullReferenceException的對象 
- 回滾堆棧和調用finally中的代碼 
- 跳到對應的處理例外(catch)的代碼 
 
特殊情況的null檢查
注意到上面第二份代碼中的訪問異常是在訪問了0x8的時候出現的嗎?
 想想如果成員在更后面的位置, 例如0x10000, 并且在0x10000有內容存在的時候還可以檢測出來嗎?
 這里我模擬一下特殊情況下的null檢查, 看看CoreCLR是否可以正確處理.
測試使用的代碼:
運行時的匯編代碼:
注意圖中紅框的部分, CoreCLR加了額外的cmp, 成功避過了使用VirtualAlloc設下的陷阱.
你也可能會問, 如果使用VirtualAlloc來在0x8分配內存可以騙過CoreCLR嗎?
 事實上VirtualAlloc不能在0x8分配內存, 可以分配到的虛擬內存地址有范圍限制,
 如果成員的位置大于最小可以分配的虛擬內存地址, 則CoreCLR會插入一個額外的檢查, 所以這種情況是騙不過CoreCLR的.
性能測試
我們再來測下自動拋出NullReferenceException和手動拋出NullReferenceException性能有多大的差別
測試的代碼如下:
public static string GetString(){ ??return null; }public static void BenchmarkNullReferenceException(){ ?
? ?for (int x = 0; x < 100000; ++x){ ? ? ? ?try{ ? ? ? ? ?
? ? ? ? ? ? ??string str = GetString(); ?
? ?? ? ? ? ?int length = str.Length;} ? ? ? ?
? ?? ?catch (Exception ex){}} }
? ?public static void BenchmarkManualNullReferenceException() { ?
? ?? ? ?for (int x = 0; x < 100000; ++x){ ? ? ?
? ?? ?try{ ? ? ? ? ?
? ?? ? ?string str = GetString(); ? ? ? ?
? ?? ? ??if (str == null){ ? ? ? ? ? ? ?
? ?? ? ?? ??throw new NullReferenceException();} ? ? ? ? ?
? ?? ? ??int length = str.Length;} ? ? ?
? ?? ?catch (Exception ex){}} }
測試結果:
BenchmarkNullReferenceException: 0.9024312s BenchmarkManualNullReferenceException: 0.9746265s測試的結果比較出乎意料,
 BenchmarkNullReferenceException和BenchmarkManualNullReferenceException在Debug和Release配置下所花的時間都是1秒左右,
 這也說明了處理硬件異常的消耗相對于處理CLR異常的消耗并不大, 甚至還比手動拋出的消耗更小.
為什么要這樣實現null檢查
最常見也是最容易理解的null檢查可能是在底層生成類似test rcx, rcx; jne 1f; call ThrowNullReferenceException; 1:的代碼,
 然而CoreCLR并不采用這種辦法, 我個人推測有這些原因:
- 可以節省生成的代碼大小, 一條檢查用的cmp指令只占2個字節 
- 可以提升檢查性能, 例如訪問成員時直接使用mov 寄存器, [對象寄存器+成員偏移值]即可同時取出值并檢查是否null, 不需要額外的檢查指令 
- 可以捕捉非托管代碼中的異常, 調用使用c寫的代碼中發生了內存訪問錯誤也可以捕捉到 
參考鏈接
這篇文章參考了以下鏈接, 并且還在github上向CoreCLR提過了相關問題
- https://www.codeproject.com/kb/cpp/exceptionhandler.aspx?fid=3666&df=90&mpp=25&noise=3&sort=position&view=quick&fr=51 
- https://stackoverflow.com/questions/27926085/how-can-i-register-a-structured-exception-handler-in-assembly-on-x86-64-architec 
- http://www.osronline.com/article.cfm?article=469 
- http://man7.org/linux/man-pages/man7/signal.7.html 
- https://msdn.microsoft.com/en-us/library/ms254246(v=vs.80).aspx 
- https://msdn.microsoft.com/en-us/library/windows/desktop/ms679331(v=vs.85).aspx 
- https://msdn.microsoft.com/en-us/library/windows/desktop/ms681419(v=vs.85).aspx 
- https://msdn.microsoft.com/en-us/library/windows/desktop/ms679274(v=vs.85).aspx 
- https://github.com/dotnet/coreclr/issues/11766 
這篇相對來說比較易懂, 之前講好的JIT篇要繼續延期, 請大家耐心等待了.
相關文章:
- 《代碼的未來》讀書筆記:內存管理與GC那點事兒 
- CoreCLR源碼探索(一) Object是什么 
- CoreCLR源碼探索(二) new是什么 
- CoreCLR源碼探索(三) GC內存分配器的內部實現 
- .NET跨平臺之旅:corehost 是如何加載 coreclr 的 
- .NET CoreCLR開發人員指南(上) 
- CoreCLR源碼探索(四) GC內存收集器的內部實現 分析篇 
- CoreCLR源碼探索(五) GC內存收集器的內部實現 調試篇 
原文地址:http://www.cnblogs.com/zkweb/p/6898627.html
.NET社區新聞,深度好文,微信中搜索dotNET跨平臺或掃描二維碼關注
總結
以上是生活随笔為你收集整理的CoreCLR源码探索(六) NullReferenceException是如何发生的的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: 基于ZKWeb + Angular 4.
- 下一篇: Ubuntu上配置SQL Server
