Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

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


C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (1) - x86 환경에서의 __cdecl, __stdcall에 대한 Name mangling
C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (2) - x86 환경의 __fastcall
C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (3) - x64 환경의 __fastcall과 Name mangling
C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (4) - CLR JIT 컴파일러의 P/Invoke 호출 방법
C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (부록 1) - CallingConvention.StdCall, CallingConvention.Cdecl에 상관없이 왜 호출이 잘 될까요?

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://msdn.microsoft.com/en-us/library/windows/desktop/ms682396(v=vs.85).aspx

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

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://msdn.microsoft.com/en-us/library/system.runtime.interopservices.dllimportattribute(v=vs.110).aspx

DllImportAttribute.CallingConvention Field
; https://msdn.microsoft.com/en-us/library/system.runtime.interopservices.dllimportattribute.callingconvention(v=vs.110).aspx

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);

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

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




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

[연관 글]





[최초 등록일: ]
[최종 수정일: 10/20/2020 ]

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

비밀번호

댓글 쓴 사람
 




[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
12384정성태10/27/202033오류 유형: 672. AllowPartiallyTrustedCallers 특성이 적용된 어셈블리의 struct 멤버 메서드를 재정의하면 System.Security.VerificationException 예외 발생
12383정성태10/27/202066.NET Framework: 956. C# 9.0 - (7) Pattern matching improvements파일 다운로드1
12382정성태10/26/202028오류 유형: 671. dotnet build - The local source '...' doesn't exist
12381정성태10/26/202086VC++: 137. C++ stl map의 사용자 정의 타입을 key로 사용하는 방법파일 다운로드1
12380정성태10/26/202034오류 유형: 670. Visual Studio - Squash_FailureCommitsReset
12379정성태10/26/2020141.NET Framework: 955. .NET 메서드의 Signature 바이트 코드 분석파일 다운로드2
12378정성태10/20/2020152.NET Framework: 954. C# - x86/x64 환경에 따라 달라지는 P/Invoke 함수의 export 이름파일 다운로드1
12377정성태10/15/2020152디버깅 기술: 172. windbg - 파일 열기 시점에 bp를 걸어 파일명 알아내는 방법(Managed/Unmanaged)
12376정성태10/15/202059오류 유형: 669. windbg - sos의 name2ee 명령어 실행 시 "Failed to request module list." 오류
12375정성태10/15/2020191Windows: 177. 윈도우 탐색기에서 띄우는 cmd.exe 창의 디렉터리 구분 문자가 'Yen(¥)' 기호로 나오는 경우 [1]
12374정성태10/14/2020180.NET Framework: 953. C# 9.0 - (6) Function pointers파일 다운로드2
12373정성태10/14/202078.NET Framework: 952. OpCodes.Box와 관련해 IL 형식으로 직접 코딩 시 유의할 점
12372정성태10/14/2020167.NET Framework: 951. C# 9.0 - (5) Attributes on local functions파일 다운로드1
12371정성태10/13/202061개발 환경 구성: 519. Visual Studio의 Ctrl+Shift+U (Edit.MakeUppercase) 단축키가 동작하지 않는 경우
12370정성태10/13/202060Linux: 33. Linux - nmcli를 이용한 고정 IP 설정
12369정성태10/21/2020882Windows: 176. Raymond Chen이 한글날에 밝히는 윈도우의 한글 자모 분리 현상 [1]
12368정성태10/12/202054오류 유형: 668. VSIX 확장 빌드 - The "GetDeploymentPathFromVsixManifest" task failed unexpectedly.
12367정성태10/12/202057오류 유형: 667. Ubuntu - Temporary failure resolving 'kr.archive.ubuntu.com'
12366정성태10/13/2020155.NET Framework: 950. C# 9.0 - (4) Native ints파일 다운로드1
12365정성태10/12/2020156.NET Framework: 949. C# 9.0 - (3) Lambda discard parameters파일 다운로드1
12364정성태10/11/2020195.NET Framework: 948. C# 9.0 - (2) Skip locals init파일 다운로드1
12363정성태10/27/2020214.NET Framework: 947. C# 9.0 - (1) Target-typed new파일 다운로드1
12362정성태10/11/2020167VS.NET IDE: 151. Visual Studio 2019에 .NET 5 rc/preview 적용하는 방법
12361정성태10/19/2020263.NET Framework: 946. C# 9.0을 위한 개발 환경 구성
12360정성태10/8/202074오류 유형: 666. The type or namespace name '...' does not exist in the namespace 'Microsoft.VisualStudio.TestTools' (are you missing an assembly reference?)
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...