前言
微软技术栈中,目前有一种高深莫测的环境变量叫做DOTNET_EnableWriteXorExecute.如果你去翻看微软文档,发现它的解释非常难懂。但是其实它就做了两件事情,第一映射了两块内存区域,第二这两块内存区域的权限一个为可执行,可写,可读(pRX)。另外一个内存区域的权限则是可读,可写(pRW)。可以参考下前面两篇文章:绝顶技术:断点+内存映射组合的CLR超强BUG?和CLR托管问题,内存+断点映射(lldb+windbg)
这两篇文章认为断点加内存映射是一个CLR的BUG,实际上是不正确的。
它的本质是通过DOTNET_EnableWriteXorExecute.默认的值为1,开启了内存映射。当JIT编译完成之后,通过内存映射对函数头的前八位进行赋值,放置一些必要的信息,比如GCInfo等。GCInfo等信息就是通过两个内存区域的映射完成函数头前八位赋值的。这种内存映射可以避开托管代码的执行,直接跳到非托管代码,但是它的问题就在于不能在内存映射的范围内下断点,否则会报异常或者断点出错等情况。
如果你想要执行托管代码,则可设置
DOTNET_EnableWriteXorExecute=0来实现(这是指没有被hostfxr宿主的程序,比如corerun小型主机以及Debug CLR Source Code等)。当它等于零之后,则是通过普通的赋值方式而非内存映射的方式来进行函数头前八位赋值。这样就不存在断点+内存映射的异常,也可以调试托管代码了。
官方把它称之为性能回归。它有性能上的略微退步,却提高了安全性。
官方建议用内存映射的方法。目前在.Net7之后应该是默认开启了。
概括
简单的例子
namespace abc
{
internal class Program
{
static void Main(string[] args)
{
Console.ReadLine();
Console.WriteLine("Hello, World!");
Program pm=new Program();
}
}
}
1.首先通过lldb来验证下Linux下面的
DOTNET_EnableWriteXorExecute环境变量值的开启与否
root@ubuntu:/home/tang/opt/dotnet/debug_clr# export DOTNET_EnableWriteXorExecute=1
root@ubuntu:/home/tang/opt/dotnet/debug_clr# lldb-11 clrrun abc.dll
Current symbol store settings:
-> Cache: /root/.dotnet/symbolcache
-> Server: https://msdl.microsoft.com/download/symbols/ Timeout: 4 RetryCount: 0
(lldb) target create "clrrun"
Current executable set to '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64).
(lldb) settings set -- target.run-args "abc.dll"
(lldb) bpmd abc.dll Program.Main
(lldb) r
Process 75045 launched: '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64)
1 location added to breakpoint 1
JITTED abc!abc.Program.Main(System.String[])
Setting breakpoint: breakpoint set --address 0x00007FFF79004B30 [abc.Program.Main(System.String[])]
warning: failed to set breakpoint site at 0x7fff79004b30 for breakpoint 3.1: error: 9 sending the breakpoint request
Hello, World!
new Program
Process 75045 exited with status = 0 (0x00000000)
可以看到如果设置
export DOTNET_EnableWriteXorExecute=1,那么在托管的Main里面的断点是无法断下来的。
root@ubuntu:/home/tang/opt/dotnet/debug_clr# export DOTNET_EnableWriteXorExecute=0
root@ubuntu:/home/tang/opt/dotnet/debug_clr# lldb-11 clrrun abc.dll
Current symbol store settings:
-> Cache: /root/.dotnet/symbolcache
-> Server: https://msdl.microsoft.com/download/symbols/ Timeout: 4 RetryCount: 0
(lldb) target create "clrrun"
Current executable set to '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64).
(lldb) settings set -- target.run-args "abc.dll"
(lldb) bpmd abc.dll abc.Program.Main
(lldb) r
Process 75105 launched: '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64)
1 location added to breakpoint 1
JITTED abc!abc.Program.Main(System.String[])
Setting breakpoint: breakpoint set --address 0x00007FFF78FF4B30 [abc.Program.Main(System.String[])]
Process 75105 stopped
* thread #1, name = 'clrrun', stop reason = breakpoint 3.1
frame #0: 0x00007fff78ff4b30
-> 0x7fff78ff4b30: push rbp
0x7fff78ff4b31: sub rsp, 0x20
0x7fff78ff4b35: lea rbp, [rsp + 0x20]
0x7fff78ff4b3a: xor eax, eax
而如果设置
export DOTNET_EnableWriteXorExecute=0,则可以立马断下来。
2.原理
它的原理其实非常简单,就是判断是否设置了
DOTNET_EnableWriteXorExecute环境变量,如果设置了,则判断它的值是0或者1,然后按照相应的逻辑来处理。比如1则内存映射,0则普通赋值。
-> 3165 if (ExecutableAllocator::IsWXORXEnabled())
3166 {
3167 pCodeHdrRW = (CodeHeader *)new BYTE[*pAllocatedSize];
3168 }
(lldb)
Process 75149 stopped
* thread #1, name = 'clrrun', stop reason = step over
frame #0: 0x00007ffff7827a8c libcoreclr.so`EEJitManager::allocCode(this=0x0000555555603750, pMD=0x00007fff78fbb460, blockSize=376, reserveForJumpStubs=0, flag=CORJIT_ALLOCMEM_DEFAULT_CODE_ALIGN, ppCodeHeader=0x00007fffffffbe30, ppCodeHeaderRW=0x00007fffffffbe38, pAllocatedSize=0x00007fffffffbe40, ppCodeHeap=0x00007fffffffbe50, ppRealHeader=0x00007fffffffbe48, nUnwindInfos=1) at codeman.cpp:3171:26
3168 }
3169 else
3170 {
-> 3171 pCodeHdrRW = pCodeHdr;
3172 }
3173
3174 #ifdef USE_INDIRECT_CODEHEADER
(lldb) source info
Lines found in module `libcoreclr.so
[0x00007ffff7827a8c-0x00007ffff7827a93): /home/tang/opt/dotnet/runtime/src/coreclr/vm/codeman.cpp:3171:2
ExecutableAllocator::IsWXORXEnabled()判断它返回值为0或者1,如果1则进入括号分配需要内存映射的源地址(pRW),如果为1,则进入else逻辑直接给函数头前八位的地址里面赋值,不需要内存映射。
3.再来看下hostfxr宿主
这种情况就类似于windbg了,它始终是为零。
0:007> .load C:\Users\Administrator\.dotnet\sos\sos.dll
0:007> !bpmd Program.cs:8
MethodDesc = 00007FFEDBD096C8
Setting breakpoint: bp 00007FFEDBC42959 [ConsoleApp3.Program.Main(System.String[])]
Adding pending breakpoints...
0:007> g
0:000> p
ConsoleApp3!ConsoleApp3.Program.Main+0x35:
00007ffe`dbc42975 e8c68cba5f call coreclr!JIT_TrialAllocSFastMP_InlineGetThread (00007fff`3b7eb640)
0:000> p
ConsoleApp3!ConsoleApp3.Program.Main+0x3a:
00007ffe`dbc4297a 488945f8 mov qword ptr [rbp-8],rax ss:00000063`b097eac8=0000000000000000
0:000> p
ConsoleApp3!ConsoleApp3.Program.Main+0x3e:
00007ffe`dbc4297e 488b4df8 mov rcx,qword ptr [rbp-8] ss:00000063`b097eac8=00000245800158d8
0:000> p
ConsoleApp3!ConsoleApp3.Program.Main+0x42:
00007ffe`dbc42982 e891c7ffff call 00007ffe`dbc3f118
0:000> p
ConsoleApp3!ConsoleApp3.Program.Main+0x47:
00007ffe`dbc42987 90 nop
地址00007ffe`dbc42982就是.Ctor运行的地方,可以看到它完全没有问题,说明windbg并不是通过内存映射来进行函数头前八位赋值的。也就是windbg里面默认了ExecutableAllocator::IsWXORXEnabled==0。
4.疑问点
hostfxr里面它是何时把
ExecutableAllocator::IsWXORXEnabled函数里面的返回值g_isWXorXEnabled赋值为0的?
实际跟踪corerun小型主机的时候,可以看到
ExecutableAllocator::g_isWXorXEnabled的全局变量初始位0,但是在ExecutableAllocator::StaticInitialize函数里面被重新赋值为了1,也就是使用内存映射,这是正常的逻辑。代码如下:
HRESULT ExecutableAllocator::StaticInitialize(FatalErrorHandler fatalErrorHandler)
{
g_isWXorXEnabled = CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_EnableWriteXorExecute) != 0;
}
EXTERNAL_EnableWriteXorExecute宏里面的defaultvalue==1.也就是把g_isWXorXEnabled设置为了1.
但是在hostfxr里面则是,g_isWXorXEnabled等于0。
跟踪发现它虽然跟corerun用的同一个函数
ExecutableAllocator::StaticInitialize赋值,但里面的逻辑似乎完全不一样。这里因为符号问题,并没有具体的逻辑,但是依旧可以看到返回值g_isWXorXEnabled==0.也就是普通赋值,不使用内存映射,这也就导致了windbg无法感知内存映射+断点的异常bug。
结尾
断点+内存映射,从今年一月份开始遇到这个问题,当时并没有重视。实际上的问题并没有解决,而是掩盖了这个问题。1月份的原文:Net7的默认构造函数.Ctor下断点出错续。前几天又遇到了,这种一而再的问题,于是乎必须要解决了。到今天为止,似乎问题的主旨已然清晰。本篇除了阐述问题的来龙去脉,还要更正前两篇的一些错误观点。