Search
Duplicate

C++/ C++ DLL을 C#에서 사용하기

기본 타입 전달

C#에서 C나 C++로 작성된 비관리(unmanaged; .NET이 관리하지 않는) DLL에 있는 함수나 구조체, 콜백을 사용하는 기술이 Plaform Invocation Service(플랫폼 호출 서비스), 줄여서 P/Invoke라고 한다.
C#에서 이 함수를 호출하는 것은 간단한데, extern 키워드를 적용한 static 메서드에 [DllImport] 어트리뷰트를 사용하면 된다.
// DllImport에 지정되는 이름은 C++ DLL의 이름과 경로를 의미한다. [DllImport("Test.dll")] static extern int Sum(int num1, int num2);
C#
기본적으로 int 형과 같은 타입은 C++에도 동일하게 존재하기 때문에 문제 없이 전달될 수 있지만, string 같은 타입은 C++에서 정확히 대응되는 타입이 없기 때문에 type을 명확히 지정해서 전달해야 하는데, 이때 사용되는 것이 [MarshalAs] 라는 특성이다.
[DllImport("Test.dll")] static extern int YourName([MarshalAs(UnmanagedType.LPStr)] string name);
C#
UnManagedType에 어떤 타입이 있는지는 다음 문서 참조 (https://docs.microsoft.com/ko-kr/dotnet/api/system.runtime.interopservices.unmanagedtype?view=net-5.0)
추가로 비관리 코드에서 .NET 코드로 문자열을 인도하는 과정에서 어느 정도의 메모리 관리가 일어나는데, 문자열을 StringBuilder로 선언하면 그런 작업을 CLR이 알아서 해준다고 한다.

배열 전달

배열을 전달하는 것은 기본 타입을 전달하는 것과 동일하다. 다만 C++에서는 C#처럼 배열을 그대로 받을 수 없기 때문에 포인터 형태로 받고, 배열의 길이를 별도로 주어야 C++ 코드에서 배열을 사용할 수 있다.
// C#에서 호출 [DllImport("Test.dll")] static extern int Sum(int[] nums, int count);
C#
// C++에서 사용 int Sum(int* nums, int count) { int sum = 0; for (int i = 0; i < count; i++) { sum += nums[i]; } return sum; }
C++

클래스, 구조체 전달

기본 타입이나 배열로는 처리가 어려운 데이터를 전달해야 한다면 구조체를 사용해야 한다. 구조체는 C++과 C#에서의 타입이 동일해야 맞춰야 하는데, 이때 StructLayout이라는 특성을 사용한다. 다음 코드는 Rectangle 좌표를 예로 한 것이다.
// C++에서의 구조체 struct Rectangle { int x; int y; int width; int height; }
C#
[StructLayout(LayoutKind.Sequential)] public class Rectangle { public int x; public int y; public int width; public int height; }
C#
LayoutKind.Sequential은 구조체 내부에 선언된 순서대로 C++의 구조체에 대응된다는 의미이다. Sequential 외에 Auto와 Explicit도 존재하는데, 그 내용에 대해서는 다음 문서 참조. (https://docs.microsoft.com/ko-kr/dotnet/api/system.runtime.interopservices.layoutkind?view=net-5.0) 문서를 보면 왜 Auto와 Explicit를 사용하지 않는지 이해할 수 있다.
위와 같이 타입을 맞추었다면 다음 코드와 같이 사용할 수 있다.
[DllImport("Test.dll")] static extern bool GetRectangle(Rectangle rectangle); public static void Main() { Rectangle rectangle = new Rectangle(); bool result = GetRectangle(rectangle); Console.WriteLine($"x: {rectangle.x}, y: {rectangle.y}, width: {rectangle.width}, height: {rectangle.height}"); }
C#
위의 Rectangle은 C#에서 class로 구현했는데, 만일 이를 struct로 구현한다면 함수를 호출할 때 ref나 out 키워드를 사용해야 한다.
[StructLayout(LayoutKind.Sequential)] public struct Rectangle { public int x; public int y; public int width; public int height; } [DllImport("Test.dll")] static extern bool GetRectangle(ref Rectangle rectangle);
C#
ref나 out의 의미는 C#에서 사용되는 것과 동일하다.
만일 클래스나 구조체 내부의 변수에 특정 타입을 지정해야 한다면 MarshalAs를 특성을 이용하면 된다.
public struct CBool { [MarshalAs(UnmanagedType.U1)] public bool b; }
C#

콜백 전달

경우에 따라 C++ 코드에서 C#의 코드를 호출해야 하는 경우가 있을 수 있다. 이런 콜백 함수도 다음과 같이 구현할 수 있다.
// delegate 선언 delegate bool EnumWC(IntPtr hwnd, IntPtr lParam); // C++에 delegate를 넘긴다. [DllImport("user32.dll")] static extern int EnumWindows(EnumWC lpEnumFunc, IntPtr lParam); // delegate 타입과 동일한 콜백 함수 선언 static bool OutputWindow(IntPtr hwnd, IntPtr lParam) { Console.WriteLine(hwnd.ToInt64()); return true; } public static void Main(string[] args) { // C++ 함수에 콜백함수를 넘긴다. EnumWindows(OutputWindow, IntPtr.Zero); }
C#

동적 배열 전달, Marshaling

위의 데이터들을 모두 크기가 확정된 데이터인데, 경우에 따라 크기를 미리 알 수 없는 데이터를 주고 받아야 할 때가 있다. 예컨대 사진에서 얼굴 영역을 찾는 프로그램이라면 사진에 얼굴의 개수가 몇 개일지 미리 알 수 없기 때문에, 크기가 고정된 배열을 이용해서는 데이터를 주고 받을 수가 없다.
이런 경우 C#에서 지원하는 포인터나 핸들을 나타내는데 사용하는 IntPtr이라는 타입을 이용해 처리할 수 있다.
사진에서 얼굴 영역을 찾는 프로그램을 예로 다음과 같이 구조체를 정의한다.
// C++에서 정의한 구조체 struct Rectangle { int x; int y; int width; int height; } // Rectangle의 개수가 정해지지 않았으므로 Rectangle의 배열과 count를 갖는다. struct RectGroup { int count; Rectangle* rectangles; }
C#
// C#에서 정의한 구조체 [StructLayout(LayoutKind.Sequential)] public struct Rectangle { public int x; public int y; public int width; public int height; } // C++의 RectGroup과 대응시키는 구조체. 동적 배열을 IntPtr로 받는다. [StructLayout(LayoutKind.Sequential)] public struct RectGroup { public int count; public IntPtr rectangles; }
C#
실제 얼굴 영역을 찾는 C++ 코드는 다음과 같다.
// C++ 코드 int TryGetFaceRectGroup(unsigned char* source, int width, int height, RectGroup* result) { // source를 이용해서 얼굴 인식 수행하고 결과는 vector<Rectangle>로 받는다. vector<Rectangle> faceRects; int size = faceRects.size(); result->count = size; result->rectangles = new Rectangle[size]; // C++에서 New를 했다는 것에 주목 // faceRect에 받아온 인식 결과를 result에 담는다. for (int i = 0; i < size; i++) { Rectangle rect = faceRects[i]; result->rectangles[i].x = rect.x; result->rectangles[i].y = rect.y; result->rectangles[i].width = rect.width; result->rectangles[i].height = rect.height; } return size; }
C#
위의 C++ 코드를 사용하는 C# 코드는 다음과 같다.
// C# 코드 [DllImport("Test.dll"")] unsafe internal static extern int TryGetFaceRectGroup(byte* image, int width, int height, out RectGroup result); // IntPtr를 다루기 때문에 메서드 앞에 unsafe 키워드를 추가해야 한다. unsafe List<Rectangle> GetFaceRects(Mat source) { // Mat은 OpenCV의 Mat 데이터 형태인데, 이것을 C++에 넘겨주기 위해 byte* 형태로 변환한다. byte* data = (byte*)source.Data; // C++ 코드에 전달할 result 데이터 RectGroup result; int count = TryGetFaceRectGroup(data, source.Size().Width, source.Size().Height, out result); // IntPtr을 array의 시작 주소로 담는다. long address = result.rectangles.ToInt64(); // 구조체의 크기를 담는다. Rectangle faceRect = new Rectangle(); int rectSize = Marshal.SizeOf(faceRect); List<Rectangle> faceRects = new List<Rectangle>(); for (int i = 0; i < count; i++) { // 시작 주소 + 구조체 크기만큼 이동시키며 주소를 찾는다. IntPtr ptr = new IntPtr(address + (i * rectSize)); faceRect = new Rectangle(); // 찾은 주소의 데이터를 Rectagle 형으로 변환한다. Marshal.PtrToStructure(ptr, faceRect); faceRects.add(faceRect); } return faceRects; }
C#
우선 C#의 구조체에 선언한 IntPtr을 이용해서 시작 주소를 담고, Rectangle 구조체 타입을 이용해서 주소의 크기를 담는다.
그리고 그 시작 주소에서부터 Rectangle 구조체 크기만큼 더해가며 해당 위치에 존재하는 데이터를 Rectangle 구조체 타입으로 변환한다.
이것은 Marshal 클래스가 관리되지 않은 메모리 블록에 대해 복사, 변환 등의 일을 해주기 때문인데, 이것을 바로 Marshaling이라고 한다.

동적 배열의 해제

앞선 예제를 통해 데이터를 주고 받는 것은 다 됐지만, 한 가지 치명적인 결함이 있다. 위 예제의 C++ 코드에서 메모리를 할당했는데, 그것을 누구도 해제 하지 않았다는 것이다. 이 상태로는 그 유명한 Memory Leak이 발생할 수 밖에 없다.
그런데 만일 C++ 코드에서 Memory Leak을 우려하여 함수를 종료하기 전에 메모리를 해제 한다면, 이번에는 C#에서는 아무런 데이터도 받지 못할 것이다. 자신이 데이터를 전달 받기 전에 C++이 메모리를 해제해 버렸기 때문이다.
이런 경우를 피하기 위해서 일단 C++에서는 메모리를 할당한 후에 C#에서 전달하고, C#은 전달 받은 데이터를 모두 처리한 후에 적절한 시점에 C++에게 할당한 메모리를 해제하라고 알려줘야 한다.
다음 코드는 이 시나리오에 따라 C++에 메모리 해제 코드를 추가하고, C#에서 이를 부르는 코드이다.
// C++ 코드 bool DeleteRectGroup(RectGroup* result) { // result의 내부의 rectangles는 C++에서 할당했기 때문에 C++에서 해제해야 한다. delete[] result->rectangles; return true; }
C#
// C# 코드 // C++에게 메모리 해제를 요청하는 메서드 // 파라미터 앞의 [In] 특성은 컴파일러에게 배열 함수 안으로 복사될 뿐 밖으로 복사되지 않음을 알려주는 내용이다. [DllImport("Test.dll")] unsafe internal static extern int DeleteRectGroup([In] RectGroup rectGroup); // IntPtr를 다루기 때문에 메서드 앞에 unsafe 키워드를 추가해야 한다. unsafe List<Rectangle> GetFaceRects(Mat source) { // 위의 코드 생략 List<Rectangle> faceRects = new List<Rectangle>(); for (int i = 0; i < count; i++) { // 시작 주소 + 구조체 크기만큼 이동시키며 주소를 찾는다. IntPtr ptr = new IntPtr(address + (i * rectSize)); faceRect = new Rectangle(); // 찾은 주소의 데이터를 Rectagle 형으로 변환한다. Marshal.PtrToStructure(ptr, faceRect); faceRects.add(faceRect); } // result를 다 사용했으므로 C++에게 메모리 해제를 요청한다. DeleteRectGroup(result); return faceRects; }
C#

참조 자료