- 시작하기
- 왜 굳이 포인터를 사용하나?
- 성능 향상을 목표로 코드 작성하기
- 네이티브 API 호출하기
- Marshal 클래스 없이 직접 포인터 연산하기
- 예제 살펴보기
- stackalloc 사용해보기
- malloc과 free 사용해보기
- 구조체 직렬화와 복원
- 보너스: macOS에서의 wchar_t 타입 처리
- 마무리
- 참고 자료
시작하기
C# 1.0이 처음 나왔을 때는 C#이 그저 Java 프로그래밍 언어의 복제품이고, MS식 재 해석일 뿐이라는 세간의 평가가 있었습니다. 그리고 이건 사실 어느정도 타당한 평가이기도 했습니다. 지금 설명할 내용만 빼고 본다면요.
바로 Unsafe Context와 여기서 쓸 수 있는 포인터가 그 주인공입니다. 오늘 강의 제목 말머리가 S로 시작하지 않는 이유는 Unsafe Context의 앞 글자만 따서 UC라고 이름을 붙였기 때문이기도 합니다. 😀
왜 굳이 포인터를 사용하나?
그런데 C#은 배우기도 어려운 포인터를 왜 굳이 남겨둔 것일까요? 단순히 C 언어의 계승자임을 자청하고 싶어서 였을까요? C#에서 여전히 포인터를 사용할 수 있도록 남겨둔 것은 크게 세 가지 이유가 있습니다.
성능 향상을 목표로 코드 작성하기
C#에서 정수 값의 배열을 만든다고 가정해보겠습니다. 이 때, 정수 값의 배열은 일정한 범위가 있을 것이고, 인덱스 연산자를 사용하여 N 번째 요소를 접근하려고 할 때 유효한 범위에 있는지 검사하는 코드가 자동으로 실행되어 범위를 벗어나면 IndexOutOfRangeException이 발생하는 것입니다.
이 때 인덱스 연산자를 부를 때마다 이런 작업이 실행되지 않도록 할 수 있고, 메모리 주소를 직접 얻어내어 데이터를 읽고 쓸 수 있다면 성능 향상 효과를 기대할 수 있겠죠.
네이티브 API 호출하기
C#의 기본 문법만으로는 직접 다른 네이티브 라이브러리나 운영 체제 기능을 사용하기 어려운 경우가 있습니다.
특히 네이티브 라이브러리가 어딘가의 메모리 공간을 할당받아 데이터를 기록하고, 이것의 위치를 되돌려주는 함수를 제공한다면, .NET 언어는 고작 IntPtr (즉, C나 C++로 치면 void* 정도로 주소값)만 얻어올 수 있고, 사용을 마치면 할당을 해제해줄 것을 알리기 위해 그 주소를 그대로 네이티브 라이브러리 측의 함수로 전달하는게 할 수 있는 일의 전부입니다.
구체적으로 무슨 타입의 데이터 주소인지 알아야 역참조를 어떤식으로 수행할 것인지 (즉, 포인터를 몇 번 역참조해야 하는지, 한 번의 프레임을 이동할 때 몇 바이트의 메모리를 이동하고 읽어야 하는지) 결정할 수 있을 것입니다.
C#은 이런 부분을 다루기 위해 매번 System.InteropServices.Marshal 클래스 안의 메서드들의 도움을 받지 않아도 언어 수준에서 이를 간편하게 처리할 수 있도록 Unsafe Context와 포인터 문법을 제공하는 것입니다.
Marshal 클래스 없이 직접 포인터 연산하기
앞의 내용과 더불어, 어떤 알고리즘이나 데이터 구조에서는 포인터를 사용하는 것이 더 효율적일 수 있습니다.
예를 들어, 이미지 처리 또는 비디오 처리와 같은 작업에서는 메모리의 바이트를 직접 조작하는 것이 필요할 수 있습니다.
아니면, 데이터 타입이 고정되지 않은 공용체처럼 타입 자체가 아닌 다른 결정 요소에 따라 데이터의 길이 자체가 달라질 수 있는 데이터를 읽으려고 할 때 효율적으로 처리할 수 있도록 돕습니다.
예를 들어, float, int, char라는 타입의 데이터를 하나로 묶은 공용체의 포인터를 반환하는 함수가 있다면, 함수를 어떻게 불렀는 가에 따라 데이터를 읽는 방법이 달라져야 할 텐데, 이 때 Marshal 클래스의 Read나 Write 함수를 부르는 수고 없이 짧은 코딩을 할 수 있습니다.
예제 살펴보기
stackalloc 사용해보기
이 예제는 힙 대신 스택 메모리 공간에 문자열 "Hello,"을 저장하고 포인터를 사용하여 메모리에 접근하는 예제입니다. unsafe
키워드를 사용하여 포인터 연산을 사용하고, C/C++에서 사용하는 타입 이름을 그대로 사용했습니다.
using size_t = System.UInt16;
using @char = System.Byte;
unsafe int Main(string[] args)
{
var szBuffer = stackalloc @char[7];
var pBuffer = new nint(szBuffer);
*(szBuffer + 0) = (@char)'H';
*(szBuffer + 1) = (@char)'e';
*(szBuffer + 2) = (@char)'l';
*(szBuffer + 3) = (@char)'l';
*(szBuffer + 4) = (@char)'o';
*(szBuffer + 5) = (@char)',';
*(szBuffer + 6) = 0;
Marshal.PtrToStringAnsi(pBuffer).Dump();
szBuffer = null;
return 0;
}
malloc과 free 사용해보기
이번에는 Visual C++ Runtime의 malloc, free 함수를 사용하도록 변형한 버전입니다.
using size_t = System.UInt16;
using @char = System.Byte;
[DllImport("msvcrt.dll",
CallingConvention = CallingConvention.Cdecl,
ExactSpelling = true)]
public static unsafe extern void* malloc(size_t size);
[DllImport("msvcrt.dll",
CallingConvention = CallingConvention.Cdecl,
ExactSpelling = true)]
public static unsafe extern void free(void* memblock);
unsafe int Main(string[] args)
{
var szBuffer = (@char*)malloc(7 * sizeof(@char));
var pBuffer = new nint(szBuffer);
szBuffer[0] = (@char)'W';
szBuffer[1] = (@char)'o';
szBuffer[2] = (@char)'r';
szBuffer[3] = (@char)'l';
szBuffer[4] = (@char)'d';
szBuffer[5] = (@char)'!';
szBuffer[6] = (@char)'\0';
Marshal.PtrToStringAnsi(pBuffer).Dump();
free(szBuffer);
szBuffer = null;
return 0;
}
구조체 직렬화와 복원
구조체를 바이트 배열로 직렬화하고, 바이트 배열을 다시 원래의 구조체로 복원하는 샘플 코드입니다. 여기서는 문자열 인코딩을 Wide String을 사용했습니다.
using size_t = System.UInt16;
[DllImport("msvcrt.dll",
CharSet = CharSet.Unicode,
ExactSpelling = true,
CallingConvention = CallingConvention.Cdecl)]
static unsafe extern size_t wcsnlen(char* str, size_t numberOfElements);
static unsafe void Main()
{
// Serialize a struct to a byte array using pointers
var originalStruct = new CommunicationMessage(
1, "Hello, World! from C#!");
var structSize = sizeof(CommunicationMessage);
var byteArray = new byte[structSize];
fixed (byte* byteArrayPtr = byteArray)
{
var structPtr = (byte*)(&originalStruct);
for (var i = 0; i < structSize; i++)
byteArrayPtr[i] = structPtr[i];
}
Console.WriteLine("Serialized Byte Array:");
foreach (var b in byteArray)
Console.Write(b.ToString("x2") + " ");
Console.WriteLine();
// Deserialize the byte array back to a struct
CommunicationMessage deserializedStruct;
fixed (byte* byteArrayPtr = byteArray)
{
var structPtr = (byte*)(&deserializedStruct);
for (var i = 0; i < structSize; i++)
structPtr[i] = byteArrayPtr[i];
}
Console.WriteLine("Deserialized Struct:");
Console.WriteLine($"Message Type: {deserializedStruct.MessageType}");
Console.WriteLine($"Message Data: {new string(deserializedStruct.MessageData)} ({wcsnlen(deserializedStruct.MessageData, CommunicationMessage.MessageDataLength)})");
}
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)]
unsafe struct CommunicationMessage
{
public const int MessageDataLength = 100;
public int MessageType; // 메시지 타입
public fixed char MessageData[MessageDataLength]; // 메시지 데이터 (고정 크기 char 배열)
public CommunicationMessage(int messageType, string messageData)
{
MessageType = messageType;
fixed (char* src = messageData, dest = MessageData)
Buffer.MemoryCopy(src, dest, MessageDataLength * sizeof(char), messageData.Length * sizeof(char));
}
}
보너스: macOS에서의 wchar_t 타입 처리
macOS에서는 닷넷이 기본 제공하는 마샬링 동작만으로는 문자열을 정상적으로 매개 변수로 주고 받을 수 없었습니다. 아래 샘플 코드처럼 byte[] 로 주고 받되 실제 인코딩은 플랫폼에 따라 Encoding.UTF32를 유닉스 (macOS) 환경에서 사용하고, 그 외에는 Encoding.Unicode (UTF-16)을 사용해야 하는 것 같습니다.
// Require unsafe configuration.
using System.Runtime.InteropServices;
using System.Text;
namespace HelloWorld;
internal partial class NativeMethods
{
// https://github.com/mono/mono/issues/9362
static Encoding GetSystemUnicodeEncoding()
=> Environment.OSVersion.Platform == PlatformID.Unix ? Encoding.UTF32 : Encoding.Unicode;
public static byte[] ConvertToWideByteArray(string s)
=> GetSystemUnicodeEncoding().GetBytes(s);
private const string StandardLibraryMacOs = "libSystem.dylib";
[DllImport(StandardLibraryMacOs,
CallingConvention = CallingConvention.Cdecl,
CharSet = CharSet.None,
ExactSpelling = true,
EntryPoint = nameof(wprintf))]
internal static extern int wprintf(
byte[] fmt);
[DllImport(StandardLibraryMacOs,
CallingConvention = CallingConvention.Cdecl,
CharSet = CharSet.None,
ExactSpelling = true,
EntryPoint = nameof(wprintf))]
internal static extern int wprintf(
byte[] fmt,
int arg0);
[DllImport(StandardLibraryMacOs,
CallingConvention = CallingConvention.Cdecl,
CharSet = CharSet.None,
ExactSpelling = true,
EntryPoint = nameof(wprintf))]
internal static extern int wprintf(
byte[] fmt,
int arg0, int arg1);
}
static unsafe class Program
{
static void Main(string[] args)
{
const int nLength = 5;
int* pContent = stackalloc int[nLength];
int nResult = 0;
for (int i = 0; i < nLength; i++)
*(pContent + i) = i;
nResult = NativeMethods.wprintf(
NativeMethods.ConvertToWideByteArray("Printing %d numbers.\n"),
nLength);
NativeMethods.wprintf(
NativeMethods.ConvertToWideByteArray("- Result: %d\n"),
nResult);
for (int i = 0; i < nLength; i++)
{
nResult = NativeMethods.wprintf(
NativeMethods.ConvertToWideByteArray("Index %d: Value %d\n"),
i, pContent[i]);
NativeMethods.wprintf(
NativeMethods.ConvertToWideByteArray("- Result: %d\n"),
nResult);
}
nResult = NativeMethods.wprintf(
NativeMethods.ConvertToWideByteArray("Done.\n"));
NativeMethods.wprintf(
NativeMethods.ConvertToWideByteArray("- Result: %d\n"),
nResult);
}
}
마무리
C#에서 포인터를 사용해서 많은 일들을 처리할 수 있습니다. 특히 C/C++에서 사용하는 구조체를 직렬화한 파일 스트림 데이터나 소켓을 통해 전달받는 데이터를 구조체로 변환하거나 다시 바이트 배열로 직렬화하는 작업을 멀리 돌아가지 않고 직접 처리할 수 있어 상호운용 프로그래밍을 할 때 매우 강력한 능력을 발휘합니다.
그러나 다른 한 편으로, 메모리에 잘못된 데이터가 기재되거나 다른 프로세스, 스레드의 메모리 공간을 침범하여 크래시를 일으키거나 프로그램 동작이 잘못될 가능성을 만들 수 있기 때문에 꼼꼼한 단위 테스트와 예외 처리가 반드시 동반되어야만 합니다.