C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (부록 1) - CallingConvention.StdCall, CallingConvention.Cdecl에 상관없이 왜 호출이 잘 될까요?
지난 글에서,
C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (4) - CLR JIT 컴파일러의 P/Invoke 호출 규약
; https://www.sysnet.pe.kr/2/0/11141
CLR JIT 컴파일러가 생성한 P/Invoke 호출이 왜 __stdcall, __cdecl에 상관없이 잘 되는가에 대해 그냥 그러려니 하고 덮으려다가, 그래도 너무 궁금했습니다. 그래서 분석을 위해 다음의 DllImport 2개를 더 정의하고,
// 아래의 2개는 기존 __cdecl 호출 규약의 "ExternC_CDECL_Func_Arg5" 함수에 대해,
// - ExternC_CDECL_Func_Arg5_2는 명시적으로 "CallingConvention.Cdecl"을 지정하고,
[DllImport("Win32Project1.dll", EntryPoint = "ExternC_CDECL_Func_Arg5", CallingConvention = CallingConvention.Cdecl)]
internal unsafe static extern int ExternC_CDECL_Func_Arg5_2(int value1, int value2, int value3, int value4, int value5);
// - ExternC_CDECL_Func_Arg5_3는 명시적으로 "CallingConvention.StdCall"을 지정
[DllImport("Win32Project1.dll", EntryPoint = "ExternC_CDECL_Func_Arg5", CallingConvention = CallingConvention.StdCall)]
internal unsafe static extern int ExternC_CDECL_Func_Arg5_3(int value1, int value2, int value3, int value4, int value5);
호출을 다음과 같이 추가했습니다.
static unsafe void Main(string[] args)
{
// JIT 컴파일 생성 용.
ExternC_CDECL_Func_Arg5(1, 2, 3, 4, 5);
ExternC_CDECL_Func_Arg5_2(11, 22, 33, 44, 55);
ExternC_CDECL_Func_Arg5_3(111, 222, 333, 444, 555);
ExternC_STD_Func_Arg5(6, 7, 8, 9, 10);
// 실행 후, windbg를 붙이기 위해 일부러 호출
Console.ReadLine();
ExternC_CDECL_Func_Arg5(1, 2, 3, 4, 5);
ExternC_CDECL_Func_Arg5_2(11, 22, 33, 44, 55);
ExternC_CDECL_Func_Arg5_3(111, 222, 333, 444, 555);
ExternC_STD_Func_Arg5(6, 7, 8, 9, 10);
}
Release 모드로 빌드하고, 실행하면 Console.ReadLine에서 응용 프로그램이 멈추는 데요, 이때 windbg.exe를 실행해 "Attach to Process..."를 해줍니다. 이후의 코드를 분석하면 어떤 수수께끼가 있는지 알게 됩니다. ^^
우선 sos 모듈을 로드하고, Managed Code를 실행하는 스레드로 문맥 변경을 합니다.
0:006> .loadby sos clr
0:008> !threads
ThreadCount: 2
UnstartedThread: 0
BackgroundThread: 1
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
Lock
ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception
0 1 f50 013ca400 2a020 Preemptive 030D4F28:00000000 013bece8 1 MTA
5 2 3014 013d9920 2b220 Preemptive 00000000:00000000 013bece8 0 MTA (Finalizer)
0:008> ~~[f50]s
eax=00000000 ebx=0000007c ecx=00000000 edx=00000000 esi=0113efc0 edi=00000000
eip=7774e61c esp=0113eea8 ebp=0113ef08 iopl=0 nv up ei pl nz na po nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202
ntdll!NtReadFile+0xc:
7774e61c c22400 ret 24h
0:000>
콜스택을 확인해 ReadLine을 호출한 부모 스택 프레임을 찾습니다.
0:000> !clrstack
OS Thread Id: 0xf50 (0)
Child SP IP Call Site
0113ef28 7774e61c [InlinedCallFrame: 0113ef28]
0113ef24 71e5b2b3 DomainNeutralILStubClass.IL_STUB_PInvoke(Microsoft.Win32.SafeHandles.SafeFileHandle, Byte*, Int32, Int32 ByRef, IntPtr)
0113ef28 72559df3 [InlinedCallFrame: 0113ef28] Microsoft.Win32.Win32Native.ReadFile(Microsoft.Win32.SafeHandles.SafeFileHandle, Byte*, Int32, Int32 ByRef, IntPtr)
0113ef8c 72559df3 System.IO.__ConsoleStream.ReadFileNative(Microsoft.Win32.SafeHandles.SafeFileHandle, Byte[], Int32, Int32, Boolean, Boolean, Int32 ByRef)
0113efc0 72559d02 System.IO.__ConsoleStream.Read(Byte[], Int32, Int32)
0113efe0 71df7ae8 System.IO.StreamReader.ReadBuffer()
0113eff4 71e0d03c System.IO.StreamReader.ReadLine()
0113f010 726a04f1 System.IO.TextReader+SyncTextReader.ReadLine()
0113f020 72506b20 System.Console.ReadLine()
0113f028 02f104a5 *** WARNING: Unable to verify checksum for C:\ConsoleApplication1\bin\x86\Release\ConsoleApplication1.exe
Program.Main(System.String[]) [C:\ConsoleApplication1\Program.cs @ 73]
0113f198 72ceea96 [GCFrame: 0113f198]
02f104a5 주소를 대상으로 역어셈블을 하면, 각각의 호출 코드를 확인할 수 있습니다.
0:000> !U /d 02f104a5
Normal JIT generated code
Program.Main(System.String[])
Begin 02f10448, size b4
C:\ConsoleApplication1\Program.cs @ 66:
02f10448 55 push ebp
...[JIT 컴파일을 위한 코드 영역 생략]...
C:\ConsoleApplication1\Program.cs @ 71:
02f104a0 e867665f6f call mscorlib_ni+0xae6b0c (72506b0c) (System.Console.ReadLine(), mdToken: 06000a6a)
C:\ConsoleApplication1\Program.cs @ 73:
>>> 02f104a5 6a03 push 3
02f104a7 6a04 push 4
02f104a9 6a05 push 5
02f104ab b901000000 mov ecx,1
02f104b0 8d5101 lea edx,[ecx+1]
02f104b3 e8acfcffff call 02f10164 (Program.ExternC_CDECL_Func_Arg5(Int32, Int32, Int32, Int32, Int32), mdToken: 06000006)
C:\ConsoleApplication1\Program.cs @ 74:
02f104b8 6a21 push 21h
02f104ba 6a2c push 2Ch
02f104bc 6a37 push 37h
02f104be b90b000000 mov ecx,0Bh
02f104c3 8d510b lea edx,[ecx+0Bh]
02f104c6 e8a5fcffff call 02f10170 (Program.ExternC_CDECL_Func_Arg5_2(Int32, Int32, Int32, Int32, Int32), mdToken: 06000007)
C:\ConsoleApplication1\Program.cs @ 75:
02f104cb 684d010000 push 14Dh
02f104d0 68bc010000 push 1BCh
02f104d5 682b020000 push 22Bh
02f104da b96f000000 mov ecx,6Fh
02f104df 8d516f lea edx,[ecx+6Fh]
02f104e2 e895fcffff call 02f1017c (Program.ExternC_CDECL_Func_Arg5_3(Int32, Int32, Int32, Int32, Int32), mdToken: 06000008)
C:\ConsoleApplication1\Program.cs @ 77:
02f104e7 6a08 push 8
02f104e9 6a09 push 9
02f104eb 6a0a push 0Ah
02f104ed b906000000 mov ecx,6
02f104f2 8d5101 lea edx,[ecx+1]
02f104f5 e88efcffff call 02f10188 (Program.ExternC_STD_Func_Arg5(Int32, Int32, Int32, Int32, Int32), mdToken: 06000010)
C:\ConsoleApplication1\Program.cs @ 103:
02f104fa 5d pop ebp
02f104fb c3 ret
개별 call들의 대상 주소에 대해 disassembly 코드를 확인해 보면 jmp 문으로 이어지는 것을 확인할 수 있습니다.,
02f104b3 e8acfcffff call 02f10164 (Program.ExternC_CDECL_Func_Arg5(Int32, Int32, Int32, Int32, Int32), mdToken: 06000006)
02f10164 b8d04d3501 mov eax,1354DD0h
02f10169 89ed mov ebp,ebp
02f1016b e9a0030000 jmp 02f10510
02f104c6 e8a5fcffff call 02f10170 (Program.ExternC_CDECL_Func_Arg5_2(Int32, Int32, Int32, Int32, Int32), mdToken: 06000007)
02f10170 b8fc4d3501 mov eax,1354DFCh
02f10175 89ed mov ebp,ebp
02f10177 e994030000 jmp 02f10510
02f104e2 e895fcffff call 02f1017c (Program.ExternC_CDECL_Func_Arg5_3(Int32, Int32, Int32, Int32, Int32), mdToken: 06000008)
02f1017c b8284e3501 mov eax,1354E28h
02f10181 89ed mov ebp,ebp
02f10183 e938040000 jmp 02f105c0
02f104f5 e88efcffff call 02f10188 (Program.ExternC_STD_Func_Arg5(Int32, Int32, Int32, Int32, Int32), mdToken: 06000010)
02f10188 b8884f3501 mov eax,1354F88h
02f1018d 89ed mov ebp,ebp
02f1018f e92c040000 jmp 02f105c0
그런데, 재미있는 규칙이 있습니다. 위의 2개는 02f10510로 점프하는 반면 아래의 2개는 02f105c0로 점프합니다. 왜일까요? 그렇습니다. __cdecl로 알고 있는 호출에 대해서는 02f10510로, __stdcall이라고 알려진 호출에 대해서는 02f105c0로 처리가 된 것입니다. 이처럼, 호출 규약이 같고 함수의 인자 수가 동일한 호출에 대해서는 같은 래퍼 함수가 처리합니다.
이제 분석 대상은 jmp 문으로 이어지는 코드가 됩니다. 먼저 __cdecl 호출 규약을 처리하는 방식을 조사해 볼 텐데요. 이를 위해 "jmp 02f10510"의 대상 주소에 BreakPoint를 설정하고 Run을 합니다.
0:008> bp 02f10510
0:008> g
Breakpoint 0 hit
...[생략]...
그럼, 다시 응용 프로그램이 실행되는데 현재 Console.ReadLine으로 입력을 받고 있는 상태이므로 엔터 키를 한번 쳐 줍니다. 그와 동시에 BP에 걸린 windbg 화면이 나오는데, 일단 현재의 @esp에 쌓인 스택을 보면 다음과 같습니다.
// 스택은 상위 주소에서 하위 주소로 쌓이므로,
// 아래에서 위로 읽어 나가면 됩니다.
Address Value
0113f018 02f104b8 // CLR Wrapper 함수를 호출 후 돌아갈 주소
0113f01c 00000005 // ExternC_CDECL_Func_Arg5에 전달된 5번째 인자
0113f020 00000004 // ExternC_CDECL_Func_Arg5에 전달된 4번째 인자
0113f024 00000003 // ExternC_CDECL_Func_Arg5에 전달된 3번째 인자
0113f028 0113f034 // Main 메서드 완료 후 복구할 stackframe EBP 주소
...
이런 스택 상황과 함께 실행될 02f10510의 코드는 다음과 같습니다.
02f10510 55 push ebp
02f10511 8bec mov ebp,esp
02f10513 57 push edi
02f10514 56 push esi
02f10515 53 push ebx
02f10516 83ec20 sub esp,20h
02f10519 8945f0 mov dword ptr [ebp-10h],eax
02f1051c 648b35280e0000 mov esi,dword ptr fs:[0E28h]
02f10523 c745d828face72 mov dword ptr [ebp-28h],offset clr!InlinedCallFrame::`vftable' (72cefa28)
02f1052a c745d4a47e3651 mov dword ptr [ebp-2Ch],51367EA4h
02f10531 8b460c mov eax,dword ptr [esi+0Ch]
02f10534 8945dc mov dword ptr [ebp-24h],eax
02f10537 896dec mov dword ptr [ebp-14h],ebp
02f1053a c745e800000000 mov dword ptr [ebp-18h],0
02f10541 8d45d8 lea eax,[ebp-28h]
02f10544 89460c mov dword ptr [esi+0Ch],eax
02f10547 8bd9 mov ebx,ecx
02f10549 8bfa mov edi,edx
02f1054b 8b4df0 mov ecx,dword ptr [ebp-10h]
02f1054e e87d38ed6f call clr!StubHelpers::DemandPermission (72de3dd0)
02f10553 8b45f0 mov eax,dword ptr [ebp-10h]
02f10556 8b4014 mov eax,dword ptr [eax+14h]
02f10559 8b10 mov edx,dword ptr [eax]
02f1055b ff7508 push dword ptr [ebp+8]
02f1055e ff750c push dword ptr [ebp+0Ch]
02f10561 ff7510 push dword ptr [ebp+10h]
02f10564 57 push edi
02f10565 53 push ebx
02f10566 c745e000000000 mov dword ptr [ebp-20h],0
02f1056d 8965e4 mov dword ptr [ebp-1Ch],esp
02f10570 c745e87d05f102 mov dword ptr [ebp-18h],2F1057Dh
02f10577 c6460800 mov byte ptr [esi+8],0
02f1057b ffd2 call edx
02f1057d 83c414 add esp,14h
02f10580 c6460801 mov byte ptr [esi+8],1
02f10584 833d4080357300 cmp dword ptr [clr!g_TrapReturningThreads (73358040)],0
02f1058b 7407 je 02f10594
02f1058d 50 push eax
02f1058e e87d63f76f call clr!JIT_RareDisableHelper (72e86910)
02f10593 58 pop eax
02f10594 c745e800000000 mov dword ptr [ebp-18h],0
02f1059b 8b7ddc mov edi,dword ptr [ebp-24h]
02f1059e 897e0c mov dword ptr [esi+0Ch],edi
02f105a1 8d65f4 lea esp,[ebp-0Ch]
02f105a4 5b pop ebx
02f105a5 5e pop esi
02f105a6 5f pop edi
02f105a7 5d pop ebp
02f105a8 c20c00 ret 0Ch
호흡 한번 가다듬고! 디버거를 이용해 코드 한줄씩 실행해 보겠습니다. ^^ 우선, 02f10516 주소의 "sub esp,20h" 호출까지 실행 후 다시 stack을 확인합니다.
0113efe8 71e0cff5 // 쓰레기 값
0113efec 00000000 // 쓰레기 값
0113eff0 00000000 // 쓰레기 값
0113eff4 030d1228 // 쓰레기 값
0113eff8 00000000 // 쓰레기 값
0113effc 0113f0c4 // 쓰레기 값
0113f000 030d4ee4 // 쓰레기 값
0113f004 72cee516 // 이후 위의 스택은 "sub esp, 20h"로 추가된 32바이트(8개의 WORD 영역)
0113f008 0113f0c4 // push ebx
0113f00c 00000000 // push esi
0113f010 0113f040 // push edi
0113f014 0113f028 // push ebp
0113f018 02f104b8 // CLR Wrapper 함수를 호출 후 돌아갈 주소
0113f01c 00000005 // ExternC_CDECL_Func_Arg5에 전달된 5번째 인자
0113f020 00000004 // ExternC_CDECL_Func_Arg5에 전달된 4번째 인자
0113f024 00000003 // ExternC_CDECL_Func_Arg5에 전달된 3번째 인자
0113f028 0113f034 // Main 메서드 완료 후 복구할 stackframe EBP 주소
보시는 바와 같이, (원래 우리의 C# 코드에서 전달된 처음 2개의 인자를 보관하는 ecx, edx는 보존된 상태이고) 3~5번째 인자는 스택의 저 아래까지 내려간 상태입니다. 당연하겠지만, 이대로는 C++ DLL의 export 함수를 호출하지 못합니다.
그럼, 아래의 호출까지 완료한 다음의,
02f1054e e87d38ed6f call clr!StubHelpers::DemandPermission (72de3dd0)
스택 상태를 다시 보겠습니다.
// [문맥] ebp == 0x113f014
0113efe8 51367ea4 // mov dword ptr [ebp-2Ch],51367EA4h
0113efec 72cefa28 // mov dword ptr [ebp-28h],offset clr!InlinedCallFrame::`vftable' (72cefa28)
0113eff0 0113f198 // mov esi,dword ptr fs:[0E28h] // FS 레지스터 - TEB (Thread Environment Block))
// mov eax,dword ptr [esi+0Ch] // esi == 0x13ca400
// mov dword ptr [ebp-24h],eax
0113eff4 030d1228 // 쓰레기 값
0113eff8 00000000 // 쓰레기 값
0113effc 00000000 // mov dword ptr [ebp-18h],0
0113f000 0113f014 // dword ptr [ebp-14h],ebp
0113f004 01354dd0 // 01354dd0 - jmp 02f10510 호출 전 특별히 담아놓았던 eax 값
0113f008 0113f0c4 // push ebx
0113f00c 00000000 // push esi
0113f010 0113f040 // push edi
0113f014 0113f028 // push ebp
0113f018 02f104b8 // CLR Wrapper 함수를 호출 후 돌아갈 주소
0113f01c 00000005 // ExternC_CDECL_Func_Arg5에 전달된 5번째 인자
0113f020 00000004 // ExternC_CDECL_Func_Arg5에 전달된 4번째 인자
0113f024 00000003 // ExternC_CDECL_Func_Arg5에 전달된 3번째 인자
0113f028 0113f034 // Main 메서드 완료 후 복구할 stackframe EBP 주소
아직 C/C++ 호출을 위한 인자 전달에 별다른 변화는 없습니다. 부수적으로 clr!StubHelpers::DemandPermission 함수 처리 중에 ecx, edx 인자가 변경될 수 있으므로 각각 ebx, edi 레지스터에 별도로 보관하는 작업이 수행되었습니다.
02f10547 8bd9 mov ebx,ecx
02f10549 8bfa mov edi,edx
DemandPermission 호출이 의미있는 것은, P/Invoke 대상이 되는 DLL 측의 함수에 대한 주소를 반환해 준다는 것입니다. 위의 코드에서는 그 결괏값을 다음의 코드를 통해 edx에 보관하고 있습니다.
02f1054e e87d38ed6f call clr!StubHelpers::DemandPermission (72de3dd0)
02f10553 8b45f0 mov eax,dword ptr [ebp-10h]
02f10556 8b4014 mov eax,dword ptr [eax+14h]
02f10559 8b10 mov edx,dword ptr [eax]
즉, C++ 측의 ExternC_CDECL_Func_Arg5 함수 주소가 담겨지게 됩니다.
자, 이걸로 CLR 래퍼함수는 어느 정도 사전 처리작업을 완료했습니다. 이제부터는 본격적으로 P/Invoke 함수를 호출하기 위한 인자 값 복사 작업을 합니다. 바로 이곳이 실제적인 __cdecl 호출 규약에 의한 스택 인자 전달 코드가 수행되는 곳입니다.
// 인자 5개를 right-to-left 순서로 전달
02f1055b ff7508 push dword ptr [ebp+8]
02f1055e ff750c push dword ptr [ebp+0Ch]
02f10561 ff7510 push dword ptr [ebp+10h]
02f10564 57 push edi
02f10565 53 push ebx
여기까지의 스택 변화를 반영한 결과입니다.
// [문맥] ebp == 0x113f014, esi == 0x13ca400 == FS:0e28
// edx == 6c6510f0 (C++측의 ExternC_CDECL_Func_Arg5 함수 주소)
0113efd4 00000001 // 다시 복사된 1번째 인자 push dword ptr [ebp+8]
0113efd8 00000002 // 다시 복사된 2번째 인자 push dword ptr [ebp+0Ch]
0113efdc 00000003 // 다시 복사된 3번째 인자 push dword ptr [ebp+10h]
0113efe0 00000004 // 다시 복사된 4번째 인자 push edi
0113efe4 00000005 // 다시 복사된 5번째 인자 push ebx
0113efe8 51367ea4
0113efec 72cefa28
0113eff0 0113f198
0113eff4 00000000 // mov dword ptr [ebp-20h],0
0113eff8 0113efd4 // mov dword ptr [ebp-1Ch],esp
0113effc 02f1057d // mov dword ptr [ebp-18h],2F1057Dh
0113f000 0113f014
0113f004 01354dd0
0113f008 0113f0c4 // push ebx
0113f00c 00000000 // push esi
0113f010 0113f040 // push edi
0113f014 0113f028 // push ebp
0113f018 02f104b8 // CLR Wrapper 함수를 호출 후 돌아갈 주소
0113f01c 00000005 // ExternC_CDECL_Func_Arg5에 전달된 5번째 인자
0113f020 00000004 // ExternC_CDECL_Func_Arg5에 전달된 4번째 인자
0113f024 00000003 // ExternC_CDECL_Func_Arg5에 전달된 3번째 인자
0113f028 0113f034 // Main 메서드 완료 후 복구할 stackframe EBP 주소
그런 다음 C++의 함수 호출은 02f1057b 주소의 "call edx"에서 이뤄지는 데, __cdecl의 호출 규약으로 인해 edx 대상이 되는 함수에서는 스택 정리를 하지 않습니다. 대신 "call edx" 수행 후 호출자 측의 "add esp, 14h"를 통해 __cdecl의 호출 규약에 맞게 전달한 인자에 해당하는 스택을 정리합니다. 그럼, 이렇게 됩니다.
// add esp, 14h 호출 후
0113efe8 51367ea4
0113efec 72cefa28
0113eff0 0113f198
0113eff4 00000000 // mov dword ptr [ebp-20h],0
0113eff8 0113efd4 // mov dword ptr [ebp-1Ch],esp
0113effc 02f1057d // mov dword ptr [ebp-18h],2F1057Dh
0113f000 0113f014
0113f004 01354dd0
0113f008 0113f0c4 // push ebx
0113f00c 00000000 // push esi
0113f010 0113f040 // push edi
0113f014 0113f028 // push ebp
0113f018 02f104b8 // CLR Wrapper 함수를 호출 후 돌아갈 주소
0113f01c 00000005 // ExternC_CDECL_Func_Arg5에 전달된 5번째 인자
0113f020 00000004 // ExternC_CDECL_Func_Arg5에 전달된 4번째 인자
0113f024 00000003 // ExternC_CDECL_Func_Arg5에 전달된 3번째 인자
0113f028 0113f034 // Main 메서드 완료 후 복구할 stackframe EBP 주소
이후, 흥미로운 코드가 하나 있는데 바로 "lea esp, [ebp-0Ch]" 입니다. 이 호출 하나로 ESP 레지스터가 현재의 래퍼 함수가 호출되는 시점으로 곧바로 복원됩니다. 마지막 ret 코드가 수행되기 전까지 스택 상황은 다음과 같이 바뀝니다.
0113efe8 51367ea4
0113efec 72cefa28
0113eff0 0113f198
0113eff4 00000000
0113eff8 0113efd4
0113effc 00000000 // mov dword ptr [ebp-18h],0
0113f000 0113f014
0113f004 01354dd0
// lea esp,[ebp-0Ch]로 esp 레지스터가 0113f008 주소로 잘림
0113f008 0113f0c4 // pop ebx
0113f00c 00000000 // pop esi
0113f010 0113f040 // pop edi
0113f014 0113f028 // pop ebp
0113f018 02f104b8 // CLR Wrapper 함수를 호출 후 돌아갈 주소
0113f01c 00000005 // ExternC_CDECL_Func_Arg5에 전달된 5번째 인자
0113f020 00000004 // ExternC_CDECL_Func_Arg5에 전달된 4번째 인자
0113f024 00000003 // ExternC_CDECL_Func_Arg5에 전달된 3번째 인자
0113f028 0113f034 // Main 메서드 완료 후 복구할 stackframe EBP 주소
C# 래퍼 함수 자체의 스택 처리 방식은 (__stdcall, __fastcall처럼) callee가 처리하는 방식입니다. 따라서 "ret 0Ch" 코드가 실행되면서 그 자체에 전달된 인자를 위한 스택을 모두 비워버립니다. 결국 ExternC_CDECL_Func_Arg5 호출 이전의 스택 상태로 완벽하게 복원됩니다.
그럼, __cdecl 함수로 정의된 것을 __stdcall로 호출했을 때는 왜 잘 실행이 되는 것일까요? (참고로, Visual Studio에서 디버깅 모드로 실행하면 PInvokeStackImbalance MDA 예외가 발생합니다.)
다음은 __cdecl 대상의 함수를 C# 측에서 CallingConvention.StdCall로 호출했을 때의 "jmp 02f105c0"에 있는 CLR 래퍼 함수의 어셈블리입니다.
02f105c0 55 push ebp
02f105c1 8bec mov ebp,esp
02f105c3 57 push edi
02f105c4 56 push esi
02f105c5 53 push ebx
02f105c6 83ec20 sub esp,20h
02f105c9 8945f0 mov dword ptr [ebp-10h],eax
02f105cc 648b35280e0000 mov esi,dword ptr fs:[0E28h]
02f105d3 c745d828face72 mov dword ptr [ebp-28h],offset clr!InlinedCallFrame::`vftable' (72cefa28)
02f105da c745d4a47e3651 mov dword ptr [ebp-2Ch],51367EA4h
02f105e1 8b460c mov eax,dword ptr [esi+0Ch]
02f105e4 8945dc mov dword ptr [ebp-24h],eax
02f105e7 896dec mov dword ptr [ebp-14h],ebp
02f105ea c745e800000000 mov dword ptr [ebp-18h],0
02f105f1 8d45d8 lea eax,[ebp-28h]
02f105f4 89460c mov dword ptr [esi+0Ch],eax
02f105f7 8bd9 mov ebx,ecx
02f105f9 8bfa mov edi,edx
02f105fb 8b4df0 mov ecx,dword ptr [ebp-10h]
02f105fe e8cd37ed6f call clr!StubHelpers::DemandPermission (72de3dd0)
02f10603 8b45f0 mov eax,dword ptr [ebp-10h]
02f10606 8b4014 mov eax,dword ptr [eax+14h]
02f10609 8b10 mov edx,dword ptr [eax]
02f1060b ff7508 push dword ptr [ebp+8]
02f1060e ff750c push dword ptr [ebp+0Ch]
02f10611 ff7510 push dword ptr [ebp+10h]
02f10614 57 push edi
02f10615 53 push ebx
02f10616 c745e014000000 mov dword ptr [ebp-20h],14h
02f1061d 8965e4 mov dword ptr [ebp-1Ch],esp
02f10620 c745e82d06f102 mov dword ptr [ebp-18h],2F1062Dh
02f10627 c6460800 mov byte ptr [esi+8],0
02f1062b ffd2 call edx
02f1062d c6460801 mov byte ptr [esi+8],1
02f10631 833d4080357300 cmp dword ptr [clr!g_TrapReturningThreads (73358040)],0
02f10638 7407 je 02f10641
02f1063a 50 push eax
02f1063b e8d062f76f call clr!JIT_RareDisableHelper (72e86910)
02f10640 58 pop eax
02f10641 c745e800000000 mov dword ptr [ebp-18h],0
02f10648 8b7ddc mov edi,dword ptr [ebp-24h]
02f1064b 897e0c mov dword ptr [esi+0Ch],edi
02f1064e 8d65f4 lea esp,[ebp-0Ch]
02f10651 5b pop ebx
02f10652 5e pop esi
02f10653 5f pop edi
02f10654 5d pop ebp
02f10655 c20c00 ret 0Ch
주된 차이점은, __cdecl로 작성된 C++ 함수이므로 스택 정리를 안해줌과 동시에 호출자 측에서도 "call edx" 실행 후 "add esp, 14h"와 같은 정리 작업을 안해 주기 때문에 다음과 같이 C++에 전달된 인자가 ESP에 그대로 남아있다는 점입니다.
012ff334 0000006f // 1번째 인자
012ff338 000000de // 2번째 인자
012ff33c 0000014d // 3번째 인자
012ff340 000001bc // 4번째 인자
012ff344 0000022b // 5번째 인자
012ff348 dfbfb46c
012ff34c 72cefa28 clr!InlinedCallFrame::`vftable'
012ff350 012ff500
012ff354 00000014
012ff358 012ff334
012ff35c 015b062d
012ff360 012ff374
012ff364 01444e28
012ff368 012ff42c // push ebx
012ff36c 00000000 // push esi
012ff370 012ff3a0 // push edi
012ff374 012ff388 // push ebp
012ff378 015b04e7 // CLR Wrapper 함수를 호출 후 돌아갈 주소
012ff37c 0000022b // 5번째 인자
012ff380 000001bc // 4번째 인자
012ff384 0000014d // 3번째 인자
012ff388 012ff394 // Main 메서드 완료 후 복구할 stackframe EBP 주소
하지만 그래도 실행에 지장이 없는 이유는, 전에 흥미롭다고 했던 "lea esp,[ebp-0Ch]" 코드 덕분입니다. 이 코드 한 줄로 인해 esp 레지스터의 값이 CLR 래퍼 함수의 초기 상태로 복원됩니다. 즉, 현재 호출된 코드의 스택 프레임(0x12ff374)을 기준으로 -12바이트 (0x012ff368) 위치로 순식간에 잘려집니다. 이렇게!
// lea esp,[ebp-0Ch]로 esp 레지스터가 012ff368 주소로 치환
012ff368 012ff42c // pop ebx
012ff36c 00000000 // pop esi
012ff370 012ff3a0 // pop edi
012ff374 012ff388 // pop ebp
012ff378 015b04e7 // CLR Wrapper 함수를 호출 후 돌아갈 주소
012ff37c 0000022b // 5번째 인자
012ff380 000001bc // 4번째 인자
012ff384 0000014d // 3번째 인자
012ff388 012ff394 // Main 메서드 완료 후 복구할 stackframe EBP 주소
이후 일련의 pop ... 명령과 ret 0Ch로 역시 CLR 래퍼 함수가 호출 이전의 스택 상태로 복원됩니다.
다시 정리해 보면!
__cdecl 함수를 __stdcall로 호출해도 괜찮은 것은, __cdecl 규약을 갖는 대상 함수 측에서도 스택 정리를 안 하고 CLR Wrapper 코드 측에서도 스택 정리를 안하지만, 마지막의 "lea esp, [ebp-0Ch]" 코드 덕분에 동일하게 초기화가 돼 버리기 때문입니다.
그렇다면 그 반대의 경우는 어떨까요? __stdcall 함수를 __cdecl 규약으로 호출한다면?
이렇게 되면 C/C++ 측의 함수에서도 스택 정리를 하고, CLR Wrapper 코드 측에서도 스택 정리를 하므로 2배의 스택이 날아가 버립니다. 어쩌면 스택이 완전히 깨져 버릴 수 있는 것입니다. 그런데 실제로 해보면 예상치 못한 결과가 나옵니다. 가령, __stdcall로 정의된 C++ 함수를 다음과 같이 억지로 (또는 실수로) CallingConvention.Cdecl이라고 지정하면,
[DllImport("Win32Project1.dll", CallingConvention = CallingConvention.Cdecl)]
internal unsafe static extern int ExternC_STD_Func_Arg5(int value1, int value2, int value3, int value4, int value5);
CLR은 "ExternC_STD_Func_Arg5" 함수 이름으로 찾게 됩니다. 하지만, 이전 장에서 설명한 것처럼,
C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (1) - x86 환경에서의 __cdecl, __stdcall에 대한 Name mangling
; https://www.sysnet.pe.kr/2/0/11132
extern "C"로 묶은 __stdcall 함수는 "_ExternC_STD_Func_Arg5@20"으로 name mangling이 되기 때문에 이 함수를 찾지 못하게 됩니다. 그래서 실제로는 다음과 같은 예외가 발생합니다.
An unhandled exception of type 'System.EntryPointNotFoundException' occurred in ConsoleApplication1.exe
Additional information: Unable to find an entry point named 'ExternC_STD_Func_Arg5' in DLL 'Win32Project1.dll'.
오호... 이렇게까지 예방(?)이 되었지만 아직도 문제가 남아 있습니다. 즉 .def로 export 시킨 함수는 이름 그대로 사용되기 때문에 CLR Wrapper 측에서도 그 이름으로 풀이해 호출할 수밖에 없게 됩니다.
실제로 테스트를 해보겠습니다. 스택이 잘(?) 깨질 수 있도록 전달하는 인자의 수를 10개로 늘린 함수를 마련했습니다.
[DllImport("Win32Project1.dll", CallingConvention = CallingConvention.Cdecl)]
internal static extern int ExternC_STD_Func_Arg10_By_DEF(int value1, int value2, int value3, int value4, int value5, int value6, int value7, int value8, int value9, int value10);
ExternC_STD_Func_Arg10_By_DEF(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
이때 생성된 CLR 래퍼 함수는 다음과 같습니다. (이전에 언급했지만, CLR은 P/Invoke 함수의 인자 수에 따라 이런 wrapper 함수를 개별로 생성합니다.)
025204c0 55 push ebp
025204c1 8bec mov ebp,esp
025204c3 57 push edi
025204c4 56 push esi
025204c5 53 push ebx
025204c6 83ec20 sub esp,20h
025204c9 8945f0 mov dword ptr [ebp-10h],eax
025204cc 648b35280e0000 mov esi,dword ptr fs:[0E28h]
025204d3 c745d828face72 mov dword ptr [ebp-28h],offset clr!InlinedCallFrame::`vftable' (72cefa28)
025204da c745d44d22728c mov dword ptr [ebp-2Ch],8C72224Dh
025204e1 8b460c mov eax,dword ptr [esi+0Ch]
025204e4 8945dc mov dword ptr [ebp-24h],eax
025204e7 896dec mov dword ptr [ebp-14h],ebp
025204ea c745e800000000 mov dword ptr [ebp-18h],0
025204f1 8d45d8 lea eax,[ebp-28h]
025204f4 89460c mov dword ptr [esi+0Ch],eax
025204f7 8bd9 mov ebx,ecx
025204f9 8bfa mov edi,edx
025204fb 8b4df0 mov ecx,dword ptr [ebp-10h]
025204fe e8cd388c70 call clr!StubHelpers::DemandPermission (72de3dd0)
02520503 8b45f0 mov eax,dword ptr [ebp-10h]
02520506 8b4014 mov eax,dword ptr [eax+14h]
02520509 8b10 mov edx,dword ptr [eax]
0252050b ff7508 push dword ptr [ebp+8]
0252050e ff750c push dword ptr [ebp+0Ch]
02520511 ff7510 push dword ptr [ebp+10h]
02520514 ff7514 push dword ptr [ebp+14h]
02520517 ff7518 push dword ptr [ebp+18h]
0252051a ff751c push dword ptr [ebp+1Ch]
0252051d ff7520 push dword ptr [ebp+20h]
02520520 ff7524 push dword ptr [ebp+24h]
02520523 57 push edi
02520524 53 push ebx
02520525 c745e000000000 mov dword ptr [ebp-20h],0
0252052c 8965e4 mov dword ptr [ebp-1Ch],esp
0252052f c745e83c055202 mov dword ptr [ebp-18h],252053Ch
02520536 c6460800 mov byte ptr [esi+8],0
0252053a ffd2 call edx
0252053c 83c428 add esp,28h
0252053f c6460801 mov byte ptr [esi+8],1
02520543 833d4080357300 cmp dword ptr [clr!g_TrapReturningThreads (73358040)],0
0252054a 7407 je 02520553
0252054c 50 push eax
0252054d e8be639670 call clr!JIT_RareDisableHelper (72e86910)
02520552 58 pop eax
02520553 c745e800000000 mov dword ptr [ebp-18h],0
0252055a 8b7ddc mov edi,dword ptr [ebp-24h]
0252055d 897e0c mov dword ptr [esi+0Ch],edi
02520560 8d65f4 lea esp,[ebp-0Ch]
02520563 5b pop ebx
02520564 5e pop esi
02520565 5f pop edi
02520566 5d pop ebp
02520567 c22000 ret 20h
그래서 call edx에서 반환하자 마자 다음과 같이 인자가 전달된 스택이 날아가 ESP 레지스터가 가리키는 위치가 바뀌고,
006feeb4 8c72224d
006feeb8 72cefa28 clr!InlinedCallFrame::`vftable'
006feebc 006ff078
006feec0 00000000
006feec4 006fee8c
006feec8 0252053c
006feecc 006feee0
006feed0 00a84f5c // 이후 위의 스택은 "sub esp, 20h"로 추가된 32바이트(8개의 WORD 영역)
006feed4 006fefa4 // push ebx
006feed8 00000000 // push esi
006feedc 006fef20 // push edi
006feee0 006fef08 // push ebp
006feee4 0252048a // CLR Wrapper 함수를 호출 후 돌아갈 주소
006feee8 0000000a // 여기까지는 10개 인자 중 8개가 C# 코드로부터 전달된 스택
006feeec 00000009 // 물론, 나머지 2개는 ecx, edx에 전달됨.
006feef0 00000008
006feef4 00000007
006feef8 00000006
006feefc 00000005
006fef00 00000004
006fef04 00000003
006fef08 006fef14 // Main 메서드 완료 후 복구할 stackframe EBP 주소
이후, 다시 "add esp, 28h"를 하는 바람에 10개의 WORD만큼 ESP 위치가 날아갑니다.
006feedc 006fef20 // push edi
006feee0 006fef08 // push ebp
006feee4 0252048a // CLR Wrapper 함수를 호출 후 돌아갈 주소
006feee8 0000000a // 여기까지는 10개 인자 중 8개가 C# 코드로부터 전달된 스택
006feeec 00000009
006feef0 00000008
006feef4 00000007
006feef8 00000006
006feefc 00000005
006fef00 00000004
006fef04 00000003
006fef08 006fef14 // Main 메서드 완료 후 복구할 stackframe EBP 주소
위의 상태로만 보면, ESP가 가리키는 스택이 깨졌으므로 이후의 실행이 엉망이 될 것입니다. 실제로 날아가버린 스택으로 인해 실행이 잘못될만한 코드가 CLR Wrapper에 보면 "push eax, call clr!JIT_RareDisableHelper, pop eax"가 있습니다. 하지만, 다행인 점이 있다면 clr!g_TrapReturningThreads 전역 변수는 (어떻게 설정하는지 모르겠지만) 0 값을 가지기 때문에 "je 02520553"으로 인해 그 부분의 코드를 건너 뛰고,
0252053a ffd2 call edx
0252053c 83c428 add esp,28h
0252053f c6460801 mov byte ptr [esi+8],1
02520543 833d4080357300 cmp dword ptr [clr!g_TrapReturningThreads (73358040)],0
0252054a 7407 je 02520553
0252054c 50 push eax
0252054d e8be639670 call clr!JIT_RareDisableHelper (72e86910)
02520552 58 pop eax
02520553 c745e800000000 mov dword ptr [ebp-18h],0
0252055a 8b7ddc mov edi,dword ptr [ebp-24h]
0252055d 897e0c mov dword ptr [esi+0Ch],edi
02520560 8d65f4 lea esp,[ebp-0Ch]
02520563 5b pop ebx
02520564 5e pop esi
02520565 5f pop edi
02520566 5d pop ebp
02520567 c22000 ret 20h
결국, "lea esp, [ebp-0ch]"로 인해 다시 ESP 레지스터가 정상으로 복구된다는 점입니다. 그러니까, g_TrapReturningThreads 전역 변수가 0이 아닌 상황에서는 저런 식으로 __cdecl 함수를 __stdcall로 잘못 지정해 호출하면 프로그램이 비정상 종료될 수 있습니다.
하지만, 대개의 경우 저 코드는 아주 잘 실행될 것이므로 Visual Studio만이 디버그 모드에서 P/InvokeStackImbalance MDA 예외로 경고를 표시해 주는 것입니다.
결론을 내리면, 지정한 CallingConvention.StdCall, CallingConvention.Cdecl 호출 규약이 잘못되어도 특수한 상황이 아니라면 P/Invoke 호출은 정상적으로 완료됩니다. (혹시, g_TrapReturningThreads 값을 제어하는 방법을 아시는 분은 덧글 부탁드립니다. CoreCLR을 보면 알 수 있을지도! ^^)
참고로, clr!StubHelpers::DemandPermission 함수의 역 어셈블을 실어봅니다.
clr!StubHelpers::DemandPermission:
72de3dd0 68bc000000 push 0BCh
72de3dd5 b868982773 mov eax,offset clr! ?? ::FNODOBFM::`string'+0x26290 (73279868)
72de3dda e841b4f0ff call clr!_EH_prolog3_catch (72cef220)
72de3ddf 8bf1 mov esi,ecx
72de3de1 bfd03dde72 mov edi,offset clr!StubHelpers::DemandPermission (72de3dd0)
72de3de6 85f6 test esi,esi
72de3de8 0f85ccb1f2ff jne clr!StubHelpers::DemandPermission+0x1e (72d0efba)
72de3dee 33db xor ebx,ebx
72de3df0 c7853cfffffff4f0ce72 mov dword ptr [ebp-0C4h],offset clr!HelperMethodFrame::`vftable' (72cef0f4)
72de3dfa 899d48ffffff mov dword ptr [ebp-0B8h],ebx
72de3e00 89bd50ffffff mov dword ptr [ebp-0B0h],edi
72de3e06 8d8d54ffffff lea ecx,[ebp-0ACh]
72de3e0c e84fb4f0ff call clr!LazyMachStateCaptureState (72cef260)
72de3e11 85c0 test eax,eax
72de3e13 756a jne clr!StubHelpers::DemandPermission+0x6a (72de3e7f)
72de3e15 8d8d3cffffff lea ecx,[ebp-0C4h]
72de3e1b e85fb4f0ff call clr!HelperMethodFrame::Push (72cef27f)
72de3e20 8b8d4cffffff mov ecx,dword ptr [ebp-0B4h]
72de3e26 c745fc03000000 mov dword ptr [ebp-4],3
72de3e2d 803d3080357300 cmp byte ptr [clr!g_StackProbingEnabled (73358030)],0
72de3e34 0f8523922400 jne clr!StubHelpers::DemandPermission+0x15d (7302d05d)
72de3e3a c645fc04 mov byte ptr [ebp-4],4
72de3e3e 53 push ebx
72de3e3f 33d2 xor edx,edx
72de3e41 33c9 xor ecx,ecx
72de3e43 41 inc ecx
72de3e44 e8196ef2ff call clr!SecurityStackWalk::SpecialDemand (72d0ac62)
72de3e49 885de0 mov byte ptr [ebp-20h],bl
72de3e4c c645fc03 mov byte ptr [ebp-4],3
72de3e50 803d3080357300 cmp byte ptr [clr!g_StackProbingEnabled (73358030)],0
72de3e57 0f851f922400 jne clr!StubHelpers::DemandPermission+0x192 (7302d07c)
72de3e5d 834dfcff or dword ptr [ebp-4],0FFFFFFFFh
72de3e61 8d8d3cffffff lea ecx,[ebp-0C4h]
72de3e67 e83cb4f0ff call clr!HelperMethodFrame::Pop (72cef2a8)
72de3e6c 8d8d54ffffff lea ecx,[ebp-0ACh]
72de3e72 e848acf0ff call clr!HelperMethodFrameRestoreState (72ceeabf)
72de3e77 85c0 test eax,eax
72de3e79 0f8571ffffff jne clr!StubHelpers::DemandPermission+0x113 (72de3df0)
72de3e7f e85cb2f0ff call clr!_EH_epilog3 (72cef0e0)
72de3e84 c3 ret
이 함수 내에서 "call clr!HelperMethodFrameRestoreState (72ceeabf)" 호출을 하는데,
clr!HelperMethodFrameRestoreState:
72ceeabf 8bc1 mov eax,ecx
72ceeac1 83782400 cmp dword ptr [eax+24h],0
72ceeac5 7427 je clr!HelperMethodFrameRestoreState+0x2f (72ceeaee)
72ceeac7 8d500c lea edx,[eax+0Ch]
72ceeaca 395008 cmp dword ptr [eax+8],edx
72ceeacd 7502 jne clr!HelperMethodFrameRestoreState+0x12 (72ceead1)
72ceeacf 8b32 mov esi,dword ptr [edx]
72ceead1 8d5004 lea edx,[eax+4]
72ceead4 3910 cmp dword ptr [eax],edx
72ceead6 7502 jne clr!HelperMethodFrameRestoreState+0x1b (72ceeada)
72ceead8 8b3a mov edi,dword ptr [edx]
72ceeada 8d5014 lea edx,[eax+14h]
72ceeadd 395010 cmp dword ptr [eax+10h],edx
72ceeae0 7502 jne clr!HelperMethodFrameRestoreState+0x25 (72ceeae4)
72ceeae2 8b1a mov ebx,dword ptr [edx]
72ceeae4 8d501c lea edx,[eax+1Ch]
72ceeae7 395018 cmp dword ptr [eax+18h],edx
72ceeaea 7502 jne clr!HelperMethodFrameRestoreState+0x2f (72ceeaee)
72ceeaec 8b2a mov ebp,dword ptr [edx]
72ceeaee 33c0 xor eax,eax
72ceeaf0 c3 ret
위에서 최종적으로 edx에 값이 들어가는데, 그 값이 바로 P/Invoke 함수에 대한 C/C++ 측의 주소입니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]