Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 14개 있습니다.)
(시리즈 글이 5개 있습니다.)
.NET Framework: 634. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (1) - x86 환경에서의 __cdecl, __stdcall에 대한 Name mangling
; https://www.sysnet.pe.kr/2/0/11132

.NET Framework: 635. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (2) - x86 환경의 __fastcall
; https://www.sysnet.pe.kr/2/0/11133

.NET Framework: 637. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (3) - x64 환경의 __fastcall과 Name mangling
; https://www.sysnet.pe.kr/2/0/11139

.NET Framework: 639. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (4) - CLR JIT 컴파일러의 P/Invoke 호출 규약
; https://www.sysnet.pe.kr/2/0/11141

.NET Framework: 642. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (부록 1) - CallingConvention.StdCall, CallingConvention.Cdecl에 상관없이 왜 호출이 잘 될까요?
; https://www.sysnet.pe.kr/2/0/11144




C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (1) - x86 환경에서의 __cdecl, __stdcall에 대한 Name mangling

C#으로 개발하다 보면, 종종 Win32 DLL과 연동하는 경우가 있습니다. 대개의 경우 C/C++ 언어로 제작된 Win32 DLL은 호출 규약을 마음대로 부여할 수 있기 때문에 이에 대해 C# 개발자가 알고 있어야 합니다. 이 글에서는, C# 개발자를 위한 호출 규약 별 함수 이름 규칙을 설명해 보도록 하겠습니다.

우선, x86 대상으로 만들어진 DLL부터 다뤄봅니다.

일반적으로 C/C++에서 별다르게 호출 규약을 지정하지 않고 다음과 같이 함수를 만든 후 export 시키면,

// C++ 헤더
__declspec(dllexport) int CDECL_Func(int value);

// C++ 구현 파일
__declspec(dllexport) int CDECL_Func(int value)
{
    printf("CDECL_Func: %d\n", value);
    return 42;
}

이는 호출 규약이 __cdecl로 정해지는데 다음과 같이 명시적으로 지정하는 것과 동일합니다.

// C++ 헤더
__declspec(dllexport) int __cdecl CDECL_Func(int value);

// C++ 구현 파일
__declspec(dllexport) int __cdecl CDECL_Func(int value)
{
    printf("CDECL_Func: %d\n", value);
    return 42;
}

위의 함수 이름(CDECL_Func)을 depends.exe (또는 dumpbin.exe /EXPORTS) 등으로 확인해 보면 "?CDECL_Func@@YAHH@Z"처럼 나옵니다. 이렇게 개발자가 지정한 이름이 컴파일러의 필요에 따라 변경되는 것을 "name mangling"이라고 합니다. "@@YAHH@Z"라는 문자열은 나름의 규칙을 가지는데 가령 리턴 타입 및 각 인자에 따른 정보 등이 함축되어 있습니다. 따라서 이런 문자열을 풀어내는 것도 가능한데 Visual C++을 설치한 경우 x86 및 x64에 따라 각각 다음의 경로에,

C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin
C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin\amd64

undname.exe이라는 도구가 그런 역할을 합니다. 이를 사용하면 다음과 같이 원래의 C++ 함수의 signature를 구할 수 있습니다.

C:\>undname ?CDECL_Func@@YAHH@Z
Microsoft (R) C++ Name Undecorator
Copyright (C) Microsoft Corporation. All rights reserved.

Undecoration of :- "?CDECL_Func@@YAHH@Z"
is :- "int __cdecl CDECL_Func(int)"

C++의 export 함수 규칙이 이렇게 복잡한 것은 나름의 이유가 있습니다. 가령 같은 이름의 함수 오버로딩이 가능하기 때문에 그런 여러 가지 것들을 표현하기 위해 저렇게 된 것입니다.

문제는, 저런 이름을 C# 측의 DllImport에서 가져다 쓰는 것이 매우 곤혹스럽다는 것입니다. 가령, '@' 등의 문자는 사용할 수 없으므로 다음과 같이 EntryPoint 속성을 통해 이상한 이름을 직접 써줘야만 합니다.

using System.Runtime.InteropServices;

class Program
{

    [DllImport("Win32Project1.dll", EntryPoint = "?CDECL_Func@@YAHH@Z")]
    internal static extern void CDECL_Func(int value);

    static void Main(string[] args)
    {
        CDECL_Func(5);
    }
}

당연히 실수를 유발할 수 있고 행여나 C/C++ 측에서 함수 signature라도 변경할라치면 다시 저 복잡한 이름을 확인해서 EntryPoint에 써줘야 하는 번거로움이 발생합니다.

이 문제는 비단 C#과의 연동에서만 발생하는 것은 아니기 때문에 예전부터 이런 불편을 해소하기 위해 C/C++ 측에서는 export되는 함수의 이름을 C 언어 시절처럼 단순한 이름으로 만드는 방법이 제공됩니다.

// C++ 헤더
extern "C"
{
    __declspec(dllexport) int ExternC_CDECL_Func(int value);
}

// 또는 한 줄로 다음과 같이 표현 가능
// extern "C" __declspec(dllexport) int ExternC_CDECL_Func(int value);

__declspec(dllexport) int ExternC_CDECL_Func(int value)
{
    printf("ExternC_CDECL_Func: %d\n", value);
    return 42;
}

저렇게 extern "C"로 함수 선언을 감싸주면, C/C++ 컴파일러는 __cdecl 호출 규약은 유지하면서 이름을 함수 이름 그대로 "ExternC_CDECL_Func"로 내보내줍니다. 따라서 C#에서도 다음과 같이 간편하게 사용할 수 있습니다.

[DllImport("Win32Project1.dll")]
internal static extern void ExternC_CDECL_Func(int value);

static void Main(string[] args)
{
    ExternC_CDECL_Func(5);
}




__cdecl 호출 규약의 경우 C/C++ 개발자들이 호출 규약을 명시하지 않으면 기본으로 적용되는 것이기 때문에 종종 사용하게 되지만, 마이크로소프트에서 만드는 Win32 API들은 (__cdecl보다 __stdcall이 갖는 장점 덕분에 가변 인자를 갖는 API를 제외하고는) __stdcall 호출 규약을 명시하고 있습니다.

실제로 아무 Win32 API나 하나 선택해서 마이크로소프트의 도움말을 보면,

CreateEvent function
; https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createeventa

다음과 같은 함수 선언을 볼 수 있는데,

HANDLE WINAPI CreateEvent(
  _In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
  _In_ BOOL bManualReset,
  _In_ BOOL bInitialState,
  _In_opt_ LPCTSTR lpName
);

저기서의 WINAPI가 실제로는 윈도우 헤더 파일에 "#define WINAPI __stdcall"라고 정의되어 있습니다.

WINAPI 매크로 없이 C++에서는 직접 명시해도 되는데,

// C++ 헤더
__declspec(dllexport) int __stdcall STD_Func(int value);

// C++ 구현 파일
__declspec(dllexport) int __stdcall STD_Func(int value)
{
    printf("STD_Func: %d\n", value);
    return 42;
}

이것 역시 C/C++ 함수이기 때문에 "?STD_Func@@YGHH@Z"와 같은 이름으로 바뀝니다. 따라서, C/C++ 개발자들은 이번에도 extern "C"를 이용해 이름을 간단하게 만들어 줍니다.

// C++ 헤더
extern "C"
{
    __declspec(dllexport) int __stdcall ExternC_STD_Func(int value);
}

// C++ 구현 파일
__declspec(dllexport) int __stdcall ExternC_STD_Func(int value)
{
    printf("ExternC_STD_Func: %d\n", value);
    return 42;
}

그런데, __stdcall 호출 규약으로 export시킨 함수가 extern "C"로 묶인 경우에는 __cdecl과는 다르게 다음과 같이 "_" 밑줄로 시작해 마지막은 '@' 글자와 함께 해당 함수의 인자로 요구되는 바이트 수가 포함됩니다.

_ExternC_STD_Func@4

_ExternC_STD_Func 함수의 인자는 "int" 하나이기 때문에 "@4"가 붙었지만, 가령 인자가 2개인 함수를 export시키면,

// 헤더 파일
extern "C"
{
    __declspec(dllexport) int __stdcall ExternC_STD_Func_Arg2(int value, int *pValue);
}

// C++ 구현 파일
__declspec(dllexport) int __stdcall ExternC_STD_Func_Arg2(int value, int *pValue)
{
    printf("ExternC_STD_Func_Arg2: %d, %d\n", value, *pValue);
    return 42;
}

4바이트 크기의 인자가 2개이므로 8바이트로 이렇게 됩니다. (4바이트가 아닌 인자 2개를 사용해도 4바이트 정렬이 되기 때문에 8로 됩니다.)

_ExternC_STD_Func_Arg2@8

하지만 이런 이름 변경에 대해서는 C#에 알릴 필요는 없습니다. extern "C" + __cdecl이 적용된 경우와 동일하게 다음과 같이 해당 함수를 사용할 수 있습니다.

[DllImport("Win32Project1.dll", EntryPoint = "?STD_Func@@YGHH@Z")] // extern "C"가 없는 경우 직접 명시.
internal static extern int STD_Func(int value);

[DllImport("Win32Project1.dll")] // extern "C"가 있는 경우 함수 이름 그대로 사용.
internal static extern int ExternC_STD_Func(int value);

[DllImport("Win32Project1.dll")] // extern "C"가 있는 경우 함수 이름 그대로 사용.
internal unsafe static extern int ExternC_STD_Func_Arg2(int value, int *pValue);




이쯤 되면, export된 함수 이름만 보면 그것이 __cdecl인지, __stdcall인지 알 수 있습니다. 가령, 다음과 같은 이름이라면,

MyFunc

__cdecl 호출 규약에 extern "C"를 사용한 경우입니다. 반면 다음과 같이 구성된 이름이라면,

_MyFunc@12

인자가 3개인 __stdcall 호출 규약의 extern "C"가 적용된 경우입니다.

그런데, 아쉽게도 이런 규칙에 예외가 있습니다. 실제로 마이크로소프트의 kernel32.dll 등에 포함된 Win32 API들은 __stdcall 호출 규약을 따름에도 불구하고 마치 extern "C" + __cdecl 조합처럼 이름만 출력되는 형식입니다. 아래 그림은 kernel32.dll에서 export된 함수들의 목록을 depends.exe로 확인한 것입니다.

calling_convention_for_csharp_1.png

이유는 간단합니다. Visual C++의 경우 확장자가 ".def"인 "Module-Definition File"을 추가해 그 안에 export시키는 함수들과 그것들의 "ordinal number"를 지정할 수 있습니다. (ordinal number는 논외로 여기서는 다루지 않습니다.)

가령 다음과 같이 Source.def를 만들어 주면,

LIBRARY
    EXPORTS
        CDECL_Func_By_DEF
        ExternC_CDECL_Func_By_DEF
        STD_Func_By_DEF
        ExternC_STD_Func_By_DEF

여기 지정된 함수들은 C++ 헤더 파일에 호출 규약이나 extern "C"를 어떤 식으로 지정했든지 간에 상관없이,

__declspec(dllexport) int __cdecl CDECL_Func_By_DEF(int value);
__declspec(dllexport) int __stdcall STD_Func_By_DEF(int value);

extern "C"
{
    __declspec(dllexport) int __cdecl ExternC_CDECL_Func_By_DEF(int value);
    __declspec(dllexport) int __stdcall ExternC_STD_Func_By_DEF(int value);
}

export 함수의 이름 형식은 무조건 "함수 이름 자체"가 됩니다. 즉, 다음의 이름으로 export가 됩니다.

CDECL_Func_By_DEF
ExternC_CDECL_Func_By_DEF
STD_Func_By_DEF
ExternC_STD_Func_By_DEF

이로 인해, export된 함수의 이름만 봐서는 그것이 __cdecl인지, __stdcall인지 단정 지을 수는 없습니다. 단지, "_" 밑줄과 "@바이트수"로 끝나는 형식이라면 __stdcall이 확실하다고 보시면 됩니다.

.def 파일에 지정된 함수의 경우, C#에서 별다른 설정 없이 이전과 동일하게 DllImport를 구성해 주면 됩니다.

[DllImport("Win32Project1.dll")]
internal static extern int CDECL_Func_By_DEF(int value);

[DllImport("Win32Project1.dll")]
internal static extern int ExternC_CDECL_Func_By_DEF(int value);

[DllImport("Win32Project1.dll")]
internal static extern int STD_Func_By_DEF(int value);

[DllImport("Win32Project1.dll")]
internal static extern int ExternC_STD_Func_By_DEF(int value);




이쯤에서 중요한 점 하나를 짚고 넘어가야 합니다.

C#의 DllImport 특성은 기본 CallingConvention이 StdCall로 되어 있습니다.

DllImportAttribute Class
; https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.dllimportattribute

DllImportAttribute.CallingConvention Field
; https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.dllimportattribute.callingconvention

The default value for the CallingConvention field is Winapi, which in turn defaults to StdCall convention.

하지만, 이 글에서 설명한 모든 코드는 __cdecl, __stdcall에 상관없이 C#에서는 명시적인 CallingConvention 없이도 잘 실행됩니다.

그렇다고 문제가 없는 것은 아닌데, 프로젝트의 대상 Framework가 .NET 4.0 이상으로 지정된 경우 이를 Visual Studio에서 "F5 (Start Debugging)"로 실행시키면 __cdecl 호출 규약의 메서드를 호출한 이후마다 다음과 같은 디버거 오류 창이 뜨게 됩니다. (.NET 3.5 이하라면 오류 창이 뜨지 않습니다.)

Managed Debugging Assistant 'PInvokeStackImbalance' has detected a problem in 'C:\Win32Project1\ConsoleApplication1\bin\x86\Debug\ConsoleApplication1.exe'.

Additional information: A call to PInvoke function 'ConsoleApplication1!Program::ExternC_CDECL_Func' has unbalanced the stack. This is likely because the managed PInvoke signature does not match the unmanaged target signature. Check that the calling convention and parameters of the PInvoke signature match the target unmanaged signature.

If there is a handler for this exception, the program may be safely continued.

MDA(Managed Debugging Assistant) 예외 창에 대해서는 이전에도 몇 번 설명한 적이 있습니다.

문제 재현 - Managed Debugging Assistant 'DisconnectedContext' has detected a problem in '...'
; https://www.sysnet.pe.kr/2/0/10961

CallbackOnCollectedDelegate was detected
; https://www.sysnet.pe.kr/2/0/710

C++로 만든 DLL을 C#에서 사용하기
; https://www.sysnet.pe.kr/2/0/11111

즉, Visual Studio가 잠재적인 버그 상황을 감지했을 때 띄워주는 대화창인데요. 이를 무시하면 해당 프로그램이 잘 동작할 수도 있지만 경우에 따라서 그렇지 않을 수도 있음을 미리 경고해 주는 것입니다. 따라서, 이 예외창이 뜨지만 실제로 "Ctrl + F5 (Start Without Debugging)"으로 실행했을 때 잘 동작한다고 해서 그냥 무시하고 지나가는 것은 좋지 않습니다.

위와 같은 경우, 결국 __cdecl 호출 규약에 대해 DllImport에서 명시적으로 CallingConvention을 설정해 주면 됩니다. 즉, 이 글의 예제 같은 경우에는 다음과 같은 DllImport 함수들이 모두 지정되어야 합니다.

[DllImport("Win32Project1.dll", EntryPoint = "?CDECL_Func@@YAHH@Z", CallingConvention = CallingConvention.Cdecl)]
internal static extern int CDECL_Func(int value);

[DllImport("Win32Project1.dll", CallingConvention = CallingConvention.Cdecl)]
internal static extern int CDECL_Func_By_DEF(int value);

[DllImport("Win32Project1.dll", CallingConvention = CallingConvention.Cdecl)]
internal static extern int ExternC_CDECL_Func(int value);

[DllImport("Win32Project1.dll", CallingConvention = CallingConvention.Cdecl)]
internal static extern int ExternC_CDECL_Func_By_DEF(int value);

(첨부 파일은 이 글의 예제 코드를 포함합니다.)

(참고로, 대부분의 경우에 저렇게 지정하지 않아도 잘 동작합니다. 이유는 마지막 글에서 설명합니다. ^^)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 8/18/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2024-04-01 11시37분
The history of calling conventions, part 1
; https://devblogs.microsoft.com/oldnewthing/20040102-00/?p=41213

Why does the x86 have so few registers?
; https://devblogs.microsoft.com/oldnewthing/20040105-00/?p=41203

The history of calling conventions, part 2
; https://devblogs.microsoft.com/oldnewthing/20040107-00/?p=41183

The history of calling conventions, part 3
; https://devblogs.microsoft.com/oldnewthing/20040108-00/?p=41163

The history of calling conventions, part 4: ia64
; https://devblogs.microsoft.com/oldnewthing/20040113-00/?p=41073

The history of calling conventions, part 5: amd64
; https://devblogs.microsoft.com/oldnewthing/20040114-00/?p=41053\

---------------------------------------------------

Why can't I GetProcAddress a function I dllexport'ed?
; https://devblogs.microsoft.com/oldnewthing/20040112-00/?p=41083

How were DLL functions exported in 16-bit Windows?
; https://devblogs.microsoft.com/oldnewthing/20060714-16/?p=30513

How were DLL functions imported in 16-bit Windows?
; https://devblogs.microsoft.com/oldnewthing/20060717-13/?p=30503

What is DLL import hinting?
; https://devblogs.microsoft.com/oldnewthing/20100317-00/?p=14573

Why does the Windows Portable Executable (PE) format have separate tables for import names and import addresses?, part 1
; https://devblogs.microsoft.com/oldnewthing/20231129-00/?p=109077

Why does the Windows Portable Executable (PE) format have separate tables for import names and import addresses?, part 2
; https://devblogs.microsoft.com/oldnewthing/20231130-00/?p=109084
정성태

... 46  47  48  49  50  51  52  53  54  55  56  57  58  59  [60]  ...
NoWriterDateCnt.TitleFile(s)
12115정성태1/14/202010587디버깅 기술: 157. C# - PEB.ProcessHeap을 이용해 디버깅 중인지 확인하는 방법파일 다운로드1
12114정성태1/13/202012429디버깅 기술: 156. C# - PDB 파일로부터 심벌(Symbol) 및 타입(Type) 정보 열거 [1]파일 다운로드3
12113정성태1/12/202013077오류 유형: 590. Visual C++ 빌드 오류 - fatal error LNK1104: cannot open file 'atls.lib' [1]
12112정성태1/12/20209697오류 유형: 589. PowerShell - 원격 Invoke-Command 실행 시 "WinRM cannot complete the operation" 오류 발생
12111정성태1/12/202012901디버깅 기술: 155. C# - KernelMemoryIO 드라이버를 이용해 실행 프로그램을 숨기는 방법(DKOM: Direct Kernel Object Modification) [16]파일 다운로드1
12110정성태1/11/202011473디버깅 기술: 154. Patch Guard로 인해 블루 스크린(BSOD)가 발생하는 사례 [5]파일 다운로드1
12109정성태1/10/20209425오류 유형: 588. Driver 프로젝트 빌드 오류 - Inf2Cat error -2: "Inf2Cat, signability test failed."
12108정성태1/10/20209472오류 유형: 587. Kernel Driver 시작 시 127(The specified procedure could not be found.) 오류 메시지 발생
12107정성태1/10/202010385.NET Framework: 877. C# - 프로세스의 모든 핸들을 열람 - 두 번째 이야기
12106정성태1/8/202011794VC++: 136. C++ - OSR Driver Loader와 같은 Legacy 커널 드라이버 설치 프로그램 제작 [1]
12105정성태1/8/202010464디버깅 기술: 153. C# - PEB를 조작해 로드된 DLL을 숨기는 방법
12104정성태1/7/202011167DDK: 9. 커널 메모리를 읽고 쓰는 NT Legacy driver와 C# 클라이언트 프로그램 [4]
12103정성태1/7/202013825DDK: 8. Visual Studio 2019 + WDK Legacy Driver 제작- Hello World 예제 [1]파일 다운로드2
12102정성태1/6/202011505디버깅 기술: 152. User 권한(Ring 3)의 프로그램에서 _ETHREAD 주소(및 커널 메모리를 읽을 수 있다면 _EPROCESS 주소) 구하는 방법
12101정성태1/5/202010901.NET Framework: 876. C# - PEB(Process Environment Block)를 통해 로드된 모듈 목록 열람
12100정성태1/3/20208919.NET Framework: 875. .NET 3.5 이하에서 IntPtr.Add 사용
12099정성태1/3/202011152디버깅 기술: 151. Windows 10 - Process Explorer로 확인한 Handle 정보를 windbg에서 조회 [1]
12098정성태1/2/202010764.NET Framework: 874. C# - 커널 구조체의 Offset 값을 하드 코딩하지 않고 사용하는 방법 [3]
12097정성태1/2/20209373디버깅 기술: 150. windbg - Wow64, x86, x64에서의 커널 구조체(예: TEB) 구조체 확인
12096정성태12/30/201911347디버깅 기술: 149. C# - DbgEng.dll을 이용한 간단한 디버거 제작 [1]
12095정성태12/27/201912629VC++: 135. C++ - string_view의 동작 방식
12094정성태12/26/201910853.NET Framework: 873. C# - 코드를 통해 PDB 심벌 파일 다운로드 방법
12093정성태12/26/201910914.NET Framework: 872. C# - 로딩된 Native DLL의 export 함수 목록 출력파일 다운로드1
12092정성태12/25/201910317디버깅 기술: 148. cdb.exe를 이용해 (ntdll.dll 등에 정의된) 커널 구조체 출력하는 방법
12091정성태12/25/201911807디버깅 기술: 147. pdb 파일을 다운로드하기 위한 symchk.exe 실행에 필요한 최소 파일 [1]
12090정성태12/24/201910444.NET Framework: 871. .NET AnyCPU로 빌드된 PE 헤더의 로딩 전/후 차이점 [1]파일 다운로드1
... 46  47  48  49  50  51  52  53  54  55  56  57  58  59  [60]  ...