UnmanagedCallersOnly + C# 9.0 함수 포인터 사용 시 x86 빌드에서 오동작하는 문제
결론만 먼저 말하면 x86 환경에서는 이렇게 정리가 됩니다.
- .NET 5 런타임인 경우, stdcall, cdecl 호출 규약만 지원
- .NET Framework 런타임인 경우, 모든 호출 규약에서 오동작
- (.NET Core는 어차피 지원하지 못하므로.)
이유는, x64의 경우 모든 호출 규약이 통일되었으므로 native -> managed function 호출 시 특별히 런타임이 끼어들지 않고도 인자 전달이 잘 됩니다. 반면 x86의 경우 native -> [marshaller] -> managed function 식으로 호출이 되는데, 이때 UnmanagedCallersOnly 특성이 부여된 managed function은 단일하게
clrcall 호출 규약을 따르도록 JIT가 코드를 생성합니다. 따라서 native에서의 stdcall, cdecl, fastcall 등의 호출에 대해 중간에 [marshaller]가 적절한 변환을 해야 하는데, 이 코드의 생성을 책임지는 런타임이 .NET 5가 유일하기 때문입니다.
결국, 기존 .NET Framework 런타임은 이에 대한 배려가 없으므로 모든 호출 규약에 대해 managed function으로 정상적인 인자 전달을 하지 못합니다.
이제 위의 사항들을 가지고 기술적으로 한번 접근해 볼까요? ^^
예를 들어, C++ DLL에 C# 메서드를 콜백하는 함수를 하나 만들고,
typedef void(__stdcall* CALLBACK_PROC_STDCALL)(int n1, int n2, int n3, int n4, int n5, int n6);
__declspec(dllexport) void __stdcall Callback_stdcall(/* C#에서 전달 */ CALLBACK_PROC_STDCALL callback)
{
callback(1, 2, 3, 4, 5, 6);
}
C# 측에서 Callback_stdcall을 호출해 콜백 메서드를 넘겨주는 코드를,
[DllImport("Dll1.dll", SetLastError = true, CharSet = CharSet.Auto, EntryPoint = "Callback_stdcall")]
unsafe static extern bool Callback_with_function_ptr_stdcall(delegate* unmanaged[Stdcall]<int, int, int, int, int, int, void> callback);
unsafe
{
Callback_with_function_ptr_stdcall(&callback_stdcall);
}
[UnmanagedCallersOnly(CallConvs = new Type[] { typeof(CallConvStdcall) })]
public static void callback_stdcall(int n1, int n2, int n3, int n4, int n5, int n6)
{
int sum = n1 + n2 + n3 + n4 + n5 + n6;
Console.WriteLine(sum);
}
x64에서 실행해 보겠습니다. 그럼, C++의 callback 호출에서,
9: callback(1, 2, 3, 4, 5, 6);
00007FF982E61826 C7 44 24 28 06 00 00 00 mov dword ptr [rsp+28h],6
00007FF982E6182E C7 44 24 20 05 00 00 00 mov dword ptr [rsp+20h],5
00007FF982E61836 41 B9 04 00 00 00 mov r9d,4
00007FF982E6183C 41 B8 03 00 00 00 mov r8d,3
00007FF982E61842 BA 02 00 00 00 mov edx,2
00007FF982E61847 B9 01 00 00 00 mov ecx,1
00007FF982E6184C FF 95 E0 00 00 00 call qword ptr [0x00007ff9369b05a0]
최초 호출 시 0x00007ff9369b05a0 주소를 보면
PrecodeFixupThunk 단계를 거치지만,
00007FF9369B05A0 E8 6B 3F 54 5F call PrecodeFixupThunk (07FF995EF4510h)
호출 이후에는 곧바로 UnmanagedCallersOnly 메서드로 점프하는 코드로 바뀝니다.
00007FF9369B05A0 E9 8B 0D 00 00 jmp ConsoleApp1.Program.callback_stdcall(Int32, Int32, Int32, Int32, Int32, Int32) (07FF9369B1330h)
단일한 호출 규약으로 통일이 된 덕분에 함수 포인터를 사용해도 native에서 managed까지 자연스럽게 흘러갈 수 있는 구조입니다.
반면, x86은 어떨까요? .NET Framework + x86 환경에서 위의 코드를 동일하게 호출해 보면,
9: callback(1, 2, 3, 4, 5, 6);
790B17C8 8B F4 mov esi,esp
790B17CA 6A 06 push 6
790B17CC 6A 05 push 5
790B17CE 6A 04 push 4
790B17D0 6A 03 push 3
790B17D2 6A 02 push 2
790B17D4 6A 01 push 1
790B17D6 FF 55 08 call dword ptr [0x02870520]
x64와 마찬가지로 PrecodeFixupThunk 코드를 거치고,
02870520 E8 EB EB 19 71 call _PrecodeFixupThunk@0 (73A0F110h)
호출 이후에는 x64와 마찬가지로 닷넷 코드로 바로 점프합니다.
02870520 E9 D3 0A 00 00 jmp ConsoleApp1.Program.callback_stdcall(Int32, Int32, Int32, Int32, Int32, Int32) (02870FF8h)
하지만, 해당 메서드는 clrcall 호출 규약을 따르도록 .NET Framework 런타임이 코드를 생성해 두기 때문에,
66: [UnmanagedCallersOnly(CallConvs = new Type[] { typeof(CallConvStdcall) })]
67: public static void callback_stdcall(int n1, int n2, int n3, int n4, int n5, int n6)
68: {
02870FF8 55 push ebp
02870FF9 8B EC mov ebp,esp
02870FFB 57 push edi
02870FFC 56 push esi
02870FFD 53 push ebx
02870FFE 83 EC 38 sub esp,38h
02871001 8B F1 mov esi,ecx
02871003 8D 7D C8 lea edi,[ebp-38h]
02871006 B9 0B 00 00 00 mov ecx,0Bh
0287100B 33 C0 xor eax,eax
0287100D F3 AB rep stos dword ptr es:[edi]
0287100F 8B CE mov ecx,esi
02871011 89 4D C4 mov dword ptr [ebp-3Ch],ecx
02871014 89 55 C0 mov dword ptr [ebp-40h],edx
02871017 83 3D F8 4A F3 00 00 cmp dword ptr ds:[0F34AF8h],0
0287101E 74 05 je ConsoleApp1.Program.callback_stdcall(Int32, Int32, Int32, Int32, Int32, Int32)+02Dh (02871025h)
02871020 E8 5B ED 53 71 call JIT_DbgIsJustMyCode (73DAFD80h)
02871025 33 D2 xor edx,edx
02871027 89 55 BC mov dword ptr [ebp-44h],edx
0287102A 90 nop
69: int sum = n1 + n2 + n3 + n4 + n5 + n6;
0287102B 8B 45 C4 mov eax,dword ptr [ebp-3Ch]
0287102E 03 45 C0 add eax,dword ptr [ebp-40h]
02871031 03 45 14 add eax,dword ptr [ebp+14h]
02871034 03 45 10 add eax,dword ptr [ebp+10h]
02871037 03 45 0C add eax,dword ptr [ebp+0Ch]
0287103A 03 45 08 add eax,dword ptr [ebp+8]
0287103D 89 45 BC mov dword ptr [ebp-44h],eax
70: Console.WriteLine(sum);
02871040 8B 4D BC mov ecx,dword ptr [ebp-44h]
02871043 E8 F0 08 5C 70 call System.Console.WriteLine(Int32) (72E31938h)
02871048 90 nop
71: }
02871049 90 nop
0287104A 8D 65 F4 lea esp,[ebp-0Ch]
0287104D 5B pop ebx
0287104E 5E pop esi
0287104F 5F pop edi
02871050 5D pop ebp
02871051 C2 10 00 ret 10h
당연히 호출 규약이 맞지 않아 닷넷 메서드에 대한 콜백 이후에는 MDA 오류가 발생합니다.
Managed Debugging Assistant 'FatalExecutionEngineError'
Message=Managed Debugging Assistant 'FatalExecutionEngineError' : 'The runtime has encountered a fatal error. The address of the error was at 0x73ac59c8, on thread 0x6010. The error code is 0xc0000005. This error may be a bug in the CLR or in the unsafe or non-verifiable portions of user code. Common sources of this bug include user marshaling errors for COM-interop or PInvoke, which may corrupt the stack.'
동일한 코드를 .NET 5 런타임에서 x86으로 구동하면,
9: callback(1, 2, 3, 4, 5, 6);
790E17C8 8B F4 mov esi,esp
790E17CA 6A 06 push 6
790E17CC 6A 05 push 5
790E17CE 6A 04 push 4
790E17D0 6A 03 push 3
790E17D2 6A 02 push 2
790E17D4 6A 01 push 1
790E17D6 FF 55 08 call dword ptr [0x02D4345E]
함수 포인터가 건네준 주소에는 이런 코드가 있고,
02D4345E B8 4C 34 D4 02 mov eax,2D4344Ch
02D43463 E9 B4 FB FF FF jmp 02D4301C
첫 호출을 한 이후 여전히 이렇게 stub 코드를 경유하도록 패치가 됩니다.
02D4345E B8 4C 34 D4 02 mov eax,offset Pointer to: CLRStub[MethodDescPrestub]@d8f94f87085d24f8 (02D4344Ch)
02D43463 E9 0C 9C 00 00 jmp CLRStub[StubLinkStub]@d8f94f8702d4d074 (02D4D074h)
위에서 "02D4344Ch" 주소에는 085d24f8 값이 담겨 있고 이것은 닷넷 측의 UnmanagedCallersOnly 메서드로 점프하는 코드를 가리킵니다.
085D24F8 E9 CB 60 00 00 jmp ConsoleApp1.Program.callback_stdcall(Int32, Int32, Int32, Int32, Int32, Int32) (085D85C8h)
즉, .NET 5 런타임은 C++ 측에 넘겨진 함수 포인터의 주소가 stdcall 호출 방식으로 호출될 것이기 때문에 managed 코드의 clrcall 호출 방식으로 변환을 하는 stub 코드를,
02D4D074 55 push ebp
02D4D075 89 E5 mov ebp,esp
02D4D077 53 push ebx
02D4D078 83 EC 08 sub esp,8
02D4D07B 50 push eax
02D4D07C 64 8B 1D 2C 00 00 00 mov ebx,dword ptr fs:[2Ch]
02D4D083 8B 5B 14 mov ebx,dword ptr [ebx+14h]
02D4D086 8B 5B 08 mov ebx,dword ptr [ebx+8]
02D4D089 83 FB 00 cmp ebx,0
02D4D08C 74 70 je CLRStub[StubLinkStub]@d8f94f8702d4d0fe (02D4D0FEh)
02D4D08E 89 D9 mov ecx,ebx
02D4D090 58 pop eax
02D4D091 C6 41 08 01 mov byte ptr [ecx+8],1
02D4D095 83 3D D4 CF 63 7C 00 cmp dword ptr [g_TrapReturningThreads (7C63CFD4h)],0
02D4D09C 75 69 jne CLRStub[StubLinkStub]@d8f94f8702d4d107 (02D4D107h)
02D4D09E FF 71 0C push dword ptr [ecx+0Ch]
02D4D0A1 68 10 D4 50 7C push offset FastNExportExceptHandler (7C50D410h)
02D4D0A6 64 FF 35 00 00 00 00 push dword ptr fs:[CLRStub[StubLinkStub]@d8f94f8702d4d0a9 (00h)]
02D4D0AD 64 89 25 00 00 00 00 mov dword ptr fs:[CLRStub[StubLinkStub]@d8f94f8702d4d0b0 (00h)],esp
02D4D0B4 8D 5D 08 lea ebx,[ebp+8]
02D4D0B7 51 push ecx
02D4D0B8 83 EC 04 sub esp,4
02D4D0BB FF 73 08 push dword ptr [ebx+8]
02D4D0BE FF 73 0C push dword ptr [ebx+0Ch]
02D4D0C1 FF 73 10 push dword ptr [ebx+10h]
02D4D0C4 FF 73 14 push dword ptr [ebx+14h]
02D4D0C7 8B 53 04 mov edx,dword ptr [ebx+4]
02D4D0CA 8B 0B mov ecx,dword ptr [ebx]
02D4D0CC 8B 00 mov eax,dword ptr [eax]
02D4D0CE 89 44 24 10 mov dword ptr [esp+10h],eax
02D4D0D2 FF 54 24 10 call dword ptr [esp+10h]
02D4D0D6 83 C4 04 add esp,4
02D4D0D9 89 43 F0 mov dword ptr [ebx-10h],eax
02D4D0DC 89 53 EC mov dword ptr [ebx-14h],edx
02D4D0DF 59 pop ecx
02D4D0E0 C6 41 08 00 mov byte ptr [ecx+8],0
02D4D0E4 F6 41 04 1B test byte ptr [ecx+4],1Bh
02D4D0E8 75 24 jne CLRStub[StubLinkStub]@d8f94f8702d4d10e (02D4D10Eh)
02D4D0EA 8B 14 24 mov edx,dword ptr [esp]
02D4D0ED 64 89 15 00 00 00 00 mov dword ptr fs:[CLRStub[StubLinkStub]@d8f94f8702d4d0f0 (00h)],edx
02D4D0F4 83 C4 0C add esp,0Ch
02D4D0F7 5A pop edx
02D4D0F8 58 pop eax
02D4D0F9 5B pop ebx
02D4D0FA 5D pop ebp
02D4D0FB C2 18 00 ret 18h
경유하므로 호출이 정상적으로 이뤄지는 것입니다. 또한, stdcall의 경우 피호출 측에서 스택을 정리하기 때문에 마지막에 "ret 18h"로 스택을 정리하는데, 위의 코드를 cdecl 콜백으로 바꿔 테스트를 해보면, 모든 stub 코드가 동일한 상태에서 마지막 코드가 "ret"으로 스택 정리를 하지 않고 끝냅니다.
그래서 stdcall과 cdecl에 대해서는 .NET 5 런타임이 적절한 stub 코드를 경유하게 만듦으로써 정상적인 호출을 가능케 합니다.
재미있는 것은, delegate* unmanaged[Fastcall]입니다. fastcall의 경우 C++ 호출 측에서 ecx, edx를 이용해 인자 전달을 해 사실상 스택을 0x10 바이트만큼 사용하지만,
[C++ 호출 측]
19: callback(1, 2, 3, 4, 5, 6);
790E1748 8B F4 mov esi,esp
790E174A 6A 06 push 6
790E174C 6A 05 push 5
790E174E 6A 04 push 4
790E1750 6A 03 push 3
790E1752 BA 02 00 00 00 mov edx,2
790E1757 B9 01 00 00 00 mov ecx,1
790E175C FF 55 08 call dword ptr [0x033f345e]
[최초 호출 시 코드]
033F345E B8 4C 34 3F 03 mov eax,33F344Ch
033F3463 E9 B4 FB FF FF jmp 033F301C
[JIT 후 코드]
033F345E B8 4C 34 3F 03 mov eax,offset Pointer to: CLRStub[MethodDescPrestub]@4e40adda059f24e8 (033F344Ch)
033F3463 E9 54 9C 00 00 jmp CLRStub[StubLinkStub]@4e40adda033fd0bc (033FD0BCh)
특이하게도 .NET 5 런타임에서는 stub 코드를 cdecl과 동일하게 처리합니다. 이 때문에 마지막 "ret 18h"로 인한 스택 정리의 불균형으로 (디버깅 중에는) Run-Time Check Failure 오류가 발생합니다.
Run-Time Check Failure #0 - The value of ESP was not properly saved across a function call. This is usually a result of calling a function declared with one calling convention with a function pointer declared with a different calling convention.
반면, 동일한 코드를 .NET Framework 런타임에서 호출하면 정상적으로 (운이 좋아) 동작합니다. 왜냐하면, .NET Framework 런타임은 delegate* unmanaged[Fastcall]에 대한 stub 코드를 생성하지 않기 때문에 native -> managed로의 호출이 그대로 이뤄집니다. 그런 와중에 fastcall과 clrcall의 호출 규약의 유사함으로 인해 스택이 깨지는 현상이 없어 표면상 잘 동작하는 것입니다.
하지만, clrcall과 fastcall의 3번째 인자부터 역순 관계이기 때문에 실제로 닷넷 코드 측에서 넘어온 인자를 보면,
[UnmanagedCallersOnly(CallConvs = new Type[] { typeof(CallConvFastcall) })]
public static void callback_fastcall(int n1, int n2, int n3, int n4, int n5, int n6)
{
// n1 == 1, n2 == 2, n3 == 6, n4 == 5, n5 == 4, n6 == 3
int sum = n1 + n2 + n3 + n4 + n5 + n6;
Console.WriteLine(sum);
}
C++ 측에서 fastcall로 넘겨준 인자를 닷넷 메서드에서 잘못 받고 있습니다. (위의 경우 3, 4, 5, 6 인자가 타입이 같고 내부 코드가 인자 순서에 상관없어 운이 좋게 정상 동작했지만, 다른 경우라면 오동작을 하게 됩니다.)
콜백 말고, 직접 GetProcAddress로 구하는 것은 어떨까요? 테스트를 위해 다음의 코드를,
unsafe
{
IntPtr ptrKernel = LoadLibrary("Dll1.dll");
IntPtr ptr_stdcall = GetProcAddress(ptrKernel, "stdcall_func");
IntPtr ptr_cdeclcall = GetProcAddress(ptrKernel, "cdecl_func");
var stdCallFunc = (delegate* unmanaged[Stdcall]<int, int, int, int, int, int, void>)ptr_stdcall;
var cdeclCallFunc = (delegate* unmanaged[Cdecl]<int, int, int, int, int, int, void>)ptr_cdeclcall;
stdCallFunc(1, 2, 3, 4, 5, 6);
cdeclCallFunc(1, 2, 3, 4, 5, 6);
}
실행해 보면 .NET 5/Framework/.NET Core 런타임 모두 64비트/32비트 빌드에 상관없이 잘 동작합니다. 콜백 방식과는 달리 위와 같이 .NET 측에서 호출하는 경우에는 GenericPInvokeCalliHelper라는 내부 코드를 반드시 거치도록 변경하기 때문에,
45: stdCallFunc(1, 2, 3, 4, 5, 6);
00007FF9369A09A9 CC int 3
00007FF9369A09AA 8B 95 C0 00 00 00 mov edx,dword ptr [rbp+0C0h]
00007FF9369A09B0 4C 89 95 A8 00 00 00 mov qword ptr [rbp+0A8h],r10
00007FF9369A09B7 C7 44 24 20 05 00 00 00 mov dword ptr [rsp+20h],5
00007FF9369A09BF C7 44 24 28 06 00 00 00 mov dword ptr [rsp+28h],6
00007FF9369A09C7 4C 8B 95 A8 00 00 00 mov r10,qword ptr [rbp+0A8h]
00007FF9369A09CE 41 BB 60 12 0B 01 mov r11d,10B1260h
00007FF9369A09D4 B9 01 00 00 00 mov ecx,1
00007FF9369A09D9 BA 02 00 00 00 mov edx,2
00007FF9369A09DE 41 B8 03 00 00 00 mov r8d,3
00007FF9369A09E4 41 B9 04 00 00 00 mov r9d,4
00007FF9369A09EA E8 51 09 55 5F call GenericPInvokeCalliHelper (07FF995EF1340h)
잘 동작합니다. 그리고 fastcall의 경우에는,
IntPtr ptr_fastcall = GetProcAddress(ptrKernel, "fastcall_func");
var fastCallFunc = (delegate* unmanaged[Fastcall]<int, int, int, int, int, int, void>)ptr_fastcall;
fastCallFunc(1, 2, 3, 4, 5, 6);
(.NET 5/Framework/.NET Core 런타임 모두) 이런 예외가 발생하는데요,
System.TypeLoadException
HResult=0x80131522
Message=Invalid unmanaged calling convention: must be one of stdcall, cdecl, or thiscall.
Source=<Cannot evaluate the exception source>
StackTrace:
<Cannot evaluate the exception stack trace>
This exception was originally thrown at this call stack:
ConsoleApp1.Program.Main(string[]) in Program.cs
이것은 예전에 정리한 글의 제약을 그대로 따른다는 데에서 일관성은 있는 규칙입니다.
C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (2) - x86 환경의 __fastcall
; https://www.sysnet.pe.kr/2/0/11133
그래도 이해가 안 되는 것은, 어째서 unmanaged[Fastcall]은 컴파일이 가능하게 했냐는 점입니다.
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
그러고 보니, 이번 실습을 하면서 아래의 글이 생각났습니다.
C# - Win32 API를 Trampoline 기법을 이용해 C# 메서드로 가로채는 방법 - 두 번째 이야기 (원본 함수 호출)
; https://www.sysnet.pe.kr/2/0/12151
위의 글에서도 Win32 API를 닷넷 메서드로 가로챌 때 호출 규약의 문제로 인해 x64에서만 실습을 했었습니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]