C#으로 만드는 네이티브 DLL

남정현
남정현
Feb 10 · 18 min read
이미지 출처: http://scandinavianlibrary.org/

C#과 .NET 기술은 계속해서 발전해나가고 있습니다. 여러가지 기술들이 있을 수 있겠지만, 저 개인적으로는 C#을 처음 접한 이후로 지금까지 중요하게 생각하는 것이 상호 연동 기술에 관한 것입니다.

.NET Core는 예전의 .NET Framework와는 달리 처음부터 Windows, Linux, macOS에 대해 x86, x64, 그리고 ARM32, ARM64까지 폭넓은 아키텍처와 OS를 공식적인 지원 대상으로 정하여 개발을 계속 이어나가고 있습니다. 그 덕분에 더 이상 C#은 Windows 전용이라는 틀은 확실한 옛말이 되어가고 있습니다.

그리고 많은 사람들에게 알려진것처럼 .NET Core 3.0은 이전의 .NET Framework의 주요 기능이었던 Windows Forms와 Windows Presentation Foundation 까지 이어받게되어 완전한 프로그래밍 프레임워크로 거듭나고 있습니다.

Ahead-of-Time에 대하여

이와 같은 보편적인 의미에서의 상호 연동만이 아니라, 더 작고 특이한 주제인 Ahead-of-Time (이하 AOT)에 대한 기술도 같이 개발이 진행되고 있습니다.

이제까지 .NET은 Java와 마찬가지로 Just-in-Time (이하 JIT) 방식으로 실행될 때 최고의 성능을 낼 수 있도록 개발되어왔습니다. 대체로 JIT 방식으로 실행할 때 .NET 입장에서 다른 언어를 바라볼 때의 접근은 쉽습니다. 그러나 그 반대로의 접근은 매우 많은 비효율이 따릅니다.

예를 들어, .NET과 아무런 관련이 없는 Rust 같은 프로그래밍 언어에서 리눅스나 macOS에서 Mono나 .NET Framework의 지원 없이는 .NET이 만든 DLL을 가져다 쓸 방법이 없습니다. 물론 양쪽 프레임워크 모두 C/C++ SDK를 이용하여 CLR을 초기화할 수 있으며, 이 방식을 택하는 경우 .NET 기술만을 사용할 때와는 달리 더 넓은 기술적 선택이 가능합니다. 그러나 당연하게도 많은 오버헤드를 수반합니다.

그 외에도, 단순히 CLR을 초기화한데서 끝나는 것이 아니라 성능을 최대한 이끌어내기 위해서는 NGEN (Native Image Generation) 등의 작업을 선행하여 Cold Boot로 인한 시간 손해를 사전에 만회해야 하며, 필요한 DLL 등이 있다면 빠짐없이 모두 찾아주어야 할 것입니다. 이 정도면 생각만해도 꽤 골치아픈 작업임에 틀림 없습니다.

그런데 만약 이 모든 작업을 한 번에 미리 처리해둘 수 있다면 어떨까요? 런타임 중에 확장성을 고려하여 동적인 기능을 사용하는 아키텍처가 아니라면 꽤 훌륭한 접근이 될 것입니다. 덤으로, 이렇게 만들어지는 DLL을 사용하는 주체는 .NET Core에 대해 아무런 정보가 없다고 하더라도 필요한 초기화 작업을 자동으로 대행해준다면 더할 나위 없이 이상적일 것입니다.

참고로 이런 일을 해주는 도구는 .NET Framework 시절에도 Code Obfuscation이나 Application Virtualization의 명목으로 수많은 상용 도구들이 존재했었습니다. 그러나 당연히 Windows OS에만 국한되는 기술이었고, 대개는 Microsoft가 직접 보증하는 기술이 아니었으며, 지속적인 라이선스 갱신을 필요로 하는 비싼 기술들이었습니다.

오늘 아티클에서 다루려고 하는 것은 방금 이야기한 것을 포함하지 않으며 온전히 .NET Core 기반의 애플리케이션을 어떻게 Native Application으로 만들 수 있는가에 대한 이야기만을 다룹니다.

오늘의 실습을 위한 배경 지식, rundll32

그래서 이번 아티클에서는 네이티브 DLL을 만들어봅니다. 그것도 Windows OS와 직접 소통하는 네이티브 DLL을 C#으로 만들어 보는 것입니다. 이렇게 만든 DLL은 다른 컴퓨터로 복사해서 얼마든지 실행해볼 수 있습니다.

https://telcontar.net/store/archive/CrashGallery/

기억 속의 rundll32.exe는 왜 인지는 모르겠지만 위와 같은 시스템 오류 대화 상자에서 종종 등장하던 불친절한 느낌의 프로그램 파일 이름이었습니다. 도대체 뭐에 쓰이는 프로그램이길래 저렇게 나타나는걸까요?

이미지의 출처에도 나와있듯, 조이스틱을 연결하고 조이스틱 설정 제어판 애플릿을 들어갔더니 저런 오류가 나타났다고 했습니다. 즉, Windows 10에새 새로 도입된 설정 앱 이전까지 정말 많이 쓰였던 제어판의 설정 애플릿 프로그램 (CPL) 파일이나 하드웨어 드라이버 따위를 설치하기 위하여 사용되던 내장 플러그인 아키텍처에서 중요한 역할을 수행하는 모듈이 바로 rundll32.exe입니다.

그런데 rundll32.exe는 DLL 안에 무슨 함수가 어떻게 들어있는지 알아내고 저렇게 찾아내는걸까요? 궁금해서 조금 찾아보니 친절하게도 아래 링크와 같은 Knowledge Base 문서가 있었습니다.

https://support.microsoft.com/en-us/help/164787/info-windows-rundll-and-rundll32-interface

원리는 매우 단순합니다. 아래와 같이 생긴 함수들이 외부로 Export 되어있다는 것을 전제로 하는 것입니다.

// Non-Unicode 버전
void CALLBACK
EntryPoint(HWND hwnd, HINSTANCE hinst, LPSTR lpszCmdLine, int nCmdShow);
// Unicode 버전
void CALLBACK
EntryPointW(HWND hwnd, HINSTANCE hinst, LPWSTR lpszCmdLine, int nCmdShow);

위의 함수 원형을 분석해보면, CALLBACK 매크로는 __stdcall Calling Convention을 사용하도록 되어있습니다. 그리고 각 매개 변수는 다음과 같은 의미를 가지고 있습니다.

참고로 Windows NT 기반의 OS는 함수 이름뒤에 W 접미사를 붙여서 유니코드 버전의 함수를 먼저 찾도록 설계되어있습니다. 실패하는 경우 비 유니코드 버전의 함수를 대신 사용합니다. 이에 따라 lpszCmdLine 파라미터의 타입도 달라지게 됩니다. 바꿔 말하면, 항상 유니코드 버전의 함수를 먼저 찾는다고 보시면 되겠습니다.

hwnd: 부모 창 핸들
hinst: DLL 파일에 대한 HINSTANCE 핸들
lpszCmdLine: rundll32.exe로 전달된 인자들 (유니코드 버전이면 LPWSTR, 아니면 LPSTR 타입이 됨)
nCmdShow = CreateProcess 함수로 전달된 nCmdShow 인자값

그러면 위의 함수가 불리도록 하려면 rundll32.exe를 어떻게 이용하면 될까요? 다음과 같은 형태가 됩니다.

%windir%\system32\rundll32.exe <dllname>,<entrypoint> <optional arguments>

dllname 부분에는 DLL 파일의 전체 경로를, entrypoint 부분에는 실행하려는 함수의 이름 (단, W 접미사 제외)을 지정합니다. 그 다음은 lpszcmdLine 파라미터로 전달하고 싶은 내용을 자유롭게 기술합니다.

빌드 환경 준비

우선 .NET Core 2.0 SDK를 시스템에 설치해야 합니다. Windows OS용 DLL을 만드려는 것이므로 Windows용 SDK를 설치하도록 합니다. 아래 웹 사이트에서 다운로드할 수 있습니다.

그 다음 새 클래스 라이브러리 프로젝트를 만듭니다.

dotnet new classlib -n rundll-sample
cd /d rundll-sample

새로 만든 프로젝트에서 CoreRT를 사용할 수 있도록 Nuget 패키지를 추가합니다. 지금 추가하려는 Nuget 패키지는 아직 공식적으로 릴리스된 패키지가 아니며 별도의 사설 리포지터리에서 운영되고 있어서 따로 저장소를 추가해야 합니다.

dotnet new nuget

위의 명령으로 만들어진 nuget.config 파일을 열어 아래와 같이 내용을 수정합니다.

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
<clear />
<add key="dotnet-core" value="https://dotnet.myget.org/F/dotnet-core/api/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>

그리고 아래의 명령을 실행합니다.

dotnet add package Microsoft.DotNet.ILCompiler -v 1.0.0-alpha-*

이제 CoreRT의 Native Library Export 기능을 사용하기 위해서는 Foreign Function Interface (이하 FFI)를 정의해야 합니다. 이러한 종류의 인프라는 본디 Base Class Library (BCL)에 포함되어있어야 하지만, CoreRT의 Native Library 관련 기능이 최근에서야 개발되고 있기 때문에 아쉽게도 표준 .NET 스펙에 들어있지는 않습니다.

FFI 역할을 하는 클래스는 System.Runtime.InteropServices 네임스페이스를 가지고 있어야하며, 클래스의 이름은 NativeCallableAttribute 으로 약속되어있습니다. 그래서 별도의 복잡한 설정을 추가하지 않더라도 아래의 코드 파일만 프로젝트에 포함하고 있어도 됩니다.

namespace System.Runtime.InteropServices
{
[AttributeUsage(AttributeTargets.Method)]
public sealed class NativeCallableAttribute : Attribute
{
public string EntryPoint;
public CallingConvention CallingConvention;
public NativeCallableAttribute() { }
}
}

코드 작성 및 테스트

이제 위의 어트리뷰트를 사용하는 네이티브 함수를 하나 만들것입니다. 스캐폴딩 과정에서 만들어진 Class1.cs 파일을 열어 다음과 같이 코드를 작성합니다. 외부 DLL 함수를 가져올 때와 마찬가지로 함수를 내보낼 때에도 Static 함수로 정의되어있어야 합니다. (Interop 목적의 함수는 this 포인터를 받는 것을 고려하지 않기 때문입니다.)

using System;
using System.Runtime.InteropServices;
namespace rundll_sample {
public static class Lib {
[DllImport("user32.dll", EntryPoint = "MessageBoxW", ExactSpelling = true, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.Winapi)]
public static extern int MessageBox(
IntPtr hWnd,
string lpText,
string lpCaption,
[MarshalAs(UnmanagedType.U4)] int uType);
[NativeCallable(EntryPoint = "ShowMessage", CallingConvention = CallingConvention.StdCall)]
public static void ShowMessageNonUnicode(IntPtr hwnd, IntPtr hinst, IntPtr lpszCmdLine, int nCmdShow) {
var arguments = Marshal.PtrToStringAnsi(lpszCmdLine);
MessageBox(hwnd, $"Passed Argument (Non Unicode) - {arguments}", "Hello, World! from CoreRT", 0);
}
[NativeCallable(EntryPoint = "ShowMessageW", CallingConvention = CallingConvention.StdCall)]
public static void ShowMessageUnicode(IntPtr hwnd, IntPtr hinst, IntPtr lpszCmdLine, int nCmdShow) {
var arguments = Marshal.PtrToStringUni(lpszCmdLine);
MessageBox(hwnd, $"Passed Argument (Unicode) - {arguments}", "Hello, World! from CoreRT", 0);
}
}
}

앞에서 이야기한대로 유니코드 버전의 함수인 ShowMessageW 함수와 비 유니코드 버전의 함수인 ShowMessage 함수를 만들었습니다. lpszCmdLine 은 포인터로 값이 넘어오게되며, 포인터 주소에서 문자열을 어떻게 읽을지에 대해서는 Marshal 클래스의 PtrToString 함수를 사용합니다.

유니코드 버전의 함수에서는 LPWSTR 타입의 lpszCmdLine 파라미터가 넘어오게되므로 PtrToStringUni 함수를 사용하며, 비 유니코드 버전의 경우 LPSTR 타입이 사용되므로 PtrToStringAnsi 함수를 사용하여 System::String 인스턴스를 생성하게 됩니다.

여기서 lpszCmdLine 에 할당되는 메모리는 .NET Core 측이 아닌 호출자 측에서 관리하는 메모리로 여기서는 읽기만 하면 됩니다.

이제 이렇게 호출이 발생한 함수의 기능을 구현하기 위하여 Win32 API인 MessageBoxW 함수를 사용하려고 합니다. 여기서는DllImportAttribute 클래스를 사용하여 DLL을 Lazy Loading을 사용하게 됩니다. 이렇게하여 rundll32.exe로 전달된 매개 변수를 메시지 박스에 표시하게 됩니다.

위의 내용을 이제 네이티브 DLL을 만들어 테스트하는 함수를 만들어보겠습니다. build.cmd 라는 파일을 다음과 같이 만들고 실행해봅니다.

@echo off
pushd "%~dp0"
dotnet publish /p:NativeLib=Shared -r win-x64 -c Release
%windir%\system32\rundll32.exe .\bin\Release\netstandard2.0\win-x64\publish\rundll-sample.dll,ShowMessage 안녕
:exit
popd
@echo on

최초 실행 시에는 IL Compiler 패키지를 받아오는 과정에서 시간이 오래 소요될 수 있고, 첫 Static Link 시도 때에도 시간이 많이 필요할 수 있습니다. 정상적으로 명령이 실행되면 아래 그림과 같이 유니코드 버전의 함수가 정상적으로 실행되는 것을 볼 수 있습니다.

NT 버전의 rundll32.exeShowMessage라는 인자를 지정했을 때 자동으로 유니코드 버전의 함수를 찾기 위하여 W 접미사를 붙인다는 스펙이 있었는데, 그대로 동작하는 것을 볼 수 있습니다.

만들어진 DLL 파일의 크기를 확인해보기 위하여 폴더창으로 열어보면 4MB 내외의 단일 DLL 파일이 만들어진 것을 볼 수 있습니다.

생성된 DLL 파일의 크기가 4MB 내외인 것을 볼 수 있습니다.

이제 네이티브 라이브러리가 의도한대로 잘 만들어졌는지 확인해보겠습니다. 저는 Dependency Walker라는 유틸리티를 사용하여 확인해보려고 합니다.

이 유틸리티는 구 버전의 Windows SDK 또는 Visual Studio에 기본 탑재되어있던 유틸리티였으며, DLL들이 참조하는 함수들의 관계를 시각적으로 보여줍니다.

설치를 위하여 Chocolatey Package Manager를 사용합니다. Chocolatey Package Manager의 설치와 사용은 https://chocolatey.org/install 을 참고하세요.

choco install dependencywalker -y

그 다음 만들어진 DLL 파일을 열기 위하여 프로젝트 디렉터리 아래의 .\bin\Release\netstandard2.0\win-x64\publish\rundll-sample.dll 파일을 Dependency Walker로 열어보겠습니다. 처음에는 시스템 DLL 간의 관계 파악에 드는 시간이 긴 편이므로 기다립니다.

Dependency Walker로 DLL 파일을 열어본 모습

위의 화면에 나타난것처럼 Export 영역에 ShowMessageShowMessageW 함수가 노출된 것을 볼 수 있습니다. 반면 Import 영역에는 DllImportAttribute 로 참조한 내역이 표시되지는 않습니다. 이는 Win32 API인 LoadLibrary 함수를 사용하여 Lazy DLL Loading을 사용한 것과 비슷한 것입니다.

노트

앞의 dotnet publish 명령에서는 Shared 라이브러리로 만드는 기능을 예로 들었는데, C/C++ 컴파일러나 Rust 컴파일러등에서 이용할 목적으로 정적 라이브러리로 컴파일하는 기능도 있습니다.

dotnet publish /t:LinkNative /p:NativeLib=Static -c Release -r win-x64

이렇게 만들어진 .lib 파일을 Linker 등에 추가하고, 적절한 C/C++ 헤더를 정의하여 사용할 수 있습니다. 관련된 내용은 https://medium.com/@chyyran/calling-c-natively-from-rust-1f92c506289d 의 내용을 참고하시기 바랍니다.

제약 사항

CoreRT는 아직 개발 중인 기술입니다. 그리고 실용적인 애플리케이션을 만들기 위해서는 UI 프레임워크를 포함해야 하지만, Windows Forms와 Windows Presentation Framework를 실행하기 위해 필요한 기능이 아직 제대로 작동하지 않습니다. 예를 들면 다음과 같은 이슈가 있습니다.

그리고 리눅스에서는 Shared Library를 만들어 사용하는 것에 제한이 있으며 해결이 필요한 이슈입니다.

하지만 계속해서 활발하게 개발이 이루어지고 있으므로 첫 프리뷰 버전이 출시될 무렵에는 본격적인 사용을 검토해볼 수 있을 것 같습니다.

더 읽어볼 내용

남정현의 블로그

DevOps related blogs

남정현

Written by

남정현

Azure와 .NET 기술을 즐겨 사용하는 개발자입니다. 한국 Azure 사용자 페이스북 그룹에서 활동합니다.

남정현의 블로그

DevOps related blogs