본문 바로가기

C#

C# 고정(Fixed) 키워드로 네이티브 메모리 접근

fixed 키워드는 관리 힙에 있는 객체가 GC에 의해 이동하지 못하도록 "고정(pinning)"하고, 해당 메모리의 주소를 포인터로 얻기 위해 사용합니다. 네이티브 라이브러리 호출(P/Invoke), 파일/네트워크 버퍼 처리, 고성능 메모리 조작에 필수적입니다.

1. fixed 키워드란?

GC는 메모리 압축을 위해 객체를 이동시킵니다. fixed 블록은 지정한 대상(배열, 문자열, 고정 크기 버퍼 등)을 블록 범위 동안 이동 불가 상태로 만들고 그 주소를 포인터로 노출합니다. 블록을 벗어나면 자동으로 고정이 해제됩니다.

2. 언제 사용하나요?

- P/Invoke로 네이티브 함수에 배열/문자열 포인터를 전달할 때
- 관리 배열을 포인터 연산으로 빠르게 순회/수정할 때
- unsafe struct의 고정 크기 버퍼(fixed buffer)와 상호 운용할 때

주의: fixed는 unsafe 컨텍스트에서만 사용 가능합니다. 프로젝트에서 Unsafe 코드 허용 옵션을 켜야 합니다.

3. 기본: 배열 고정 후 포인터로 읽기/쓰기

using System;

public static class FixedBasics
{
    public static int SumWithPointer(int[] arr)
    {
        if (arr == null || arr.Length == 0) return 0;
        int sum = 0;

        unsafe
        {
            fixed (int* p = arr)
            {
                // p는 arr의 시작 주소를 가리킵니다.
                for (int i = 0; i < arr.Length; i++)
                {
                    sum += p[i];
                }
            }
        }

        return sum;
    }

    public static void DoubleInPlace(int[] arr)
    {
        if (arr == null) return;

        unsafe
        {
            fixed (int* p = arr)
            {
                for (int i = 0; i < arr.Length; i++)
                {
                    p[i] *= 2;
                }
            }
        }
    }
}

핵심 팁: fixed 블록은 최소 범위로 유지하고, 블록 내부에서만 포인터를 사용합니다.

4. 문자열 고정: 네이티브에 UTF-16 전달

문자열은 불변이며, C#의 string은 UTF-16입니다. 네이티브 API가 와이드 문자열(const wchar_t*)을 요구할 때 유용합니다.

using System;
using System.Runtime.InteropServices;

public static class FixedString
{
    // 예시: 네이티브 함수가 UTF-16(와이드) 문자열 포인터를 받는다고 가정
    private static class Native
    {
        [DllImport("NativeLib", CallingConvention = CallingConvention.Cdecl, EntryPoint = "use_wchar")]
        public static extern void UseWideChar(char* text);
    }

    public static void PassString(string s)
    {
        if (s == null) return;

        unsafe
        {
            fixed (char* p = s)
            {
                // p는 s의 UTF-16 데이터 시작 주소입니다.
                Native.UseWideChar(p);
            }
        }
    }
}

네이티브가 UTF-8을 요구한다면 Encoding.UTF8.GetBytes(s)로 바이트 배열을 만들고 그 배열을 fixed로 고정해 포인터를 전달합니다.

5. 고정 크기 버퍼: struct에 네이티브 레이아웃 맞추기

unsafe struct 내부에서 fixed 키워드로 고정 크기 배열 필드를 선언할 수 있습니다. 바이너리 프로토콜/파일 헤더와 1:1 매핑할 때 유용합니다.

using System;

public unsafe struct Packet
{
    public int Length;
    public fixed byte Data[16];
}

public static class FixedBufferDemo
{
    public static void InitPacket(ref Packet packet, ReadOnlySpan<byte> payload)
    {
        packet.Length = Math.Min(payload.Length, 16);
        unsafe
        {
            fixed (byte* p = packet.Data)
            {
                for (int i = 0; i < packet.Length; i++)
                {
                    p[i] = payload[i];
                }
            }
        }
    }
}

고정 크기 버퍼는 unmanaged 레이아웃을 기대하는 네이티브 코드와 상호 운용할 때 메모리 형태를 안정적으로 유지합니다.

6. P/Invoke: 배열 포인터를 네이티브에 안전하게 전달

네이티브 라이브러리의 시그니처를 가정하고, 관리 배열을 포인터로 전달하는 전형적인 패턴입니다.

using System;
using System.Runtime.InteropServices;

public static class InteropSample
{
    private static class Native
    {
        // 예시: int sum_ints(const int* data, int length);
        [DllImport("NativeLib", CallingConvention = CallingConvention.Cdecl, EntryPoint = "sum_ints")]
        public static extern unsafe int SumInts(int* data, int length);
    }

    public static int CallNativeSum(int[] arr)
    {
        if (arr == null) throw new ArgumentNullException(nameof(arr));

        unsafe
        {
            fixed (int* p = arr)
            {
                return Native.SumInts(p, arr.Length);
            }
        }
    }
}

플랫폼별 라이브러리 이름(Windows: dll, Linux/macOS: so/dylib)은 실제 환경에 맞게 조정합니다.

7. 대안: GCHandle, Memory.Pin, Span

- GCHandle: 고정 기간을 수동으로 제어할 때 사용합니다.

using System;
using System.Runtime.InteropServices;

public static class PinWithGCHandle
{
    public static IntPtr PinArray(byte[] buffer)
    {
        if (buffer == null) throw new ArgumentNullException(nameof(buffer));
        var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
        try
        {
            return handle.AddrOfPinnedObject();
        }
        finally
        {
            handle.Free();
        }
    }
}

- Memory<T>.Pin: 버퍼 추상화를 유지하면서 포인터가 필요할 때 유용합니다.

using System;

public static class PinWithMemory
{
    public static unsafe void UsePinnedMemory()
    {
        Memory<byte> mem = new byte[256];
        using var handle = mem.Pin();
        byte* p = (byte*)handle.Pointer;
        for (int i = 0; i < 10; i++) p[i] = (byte)i;
    }
}

- 가능하면 Span/Memory로 안전하게 처리하고, 꼭 필요한 경계에서만 fixed/포인터를 사용합니다.

8. 주의사항과 성능 팁

- 고정 기간 최소화: fixed 블록을 짧게 유지해 GC의 단편화와 STW 영향을 줄입니다.
- 큰 객체 핀ning 주의: LOH 대형 배열 고정은 GC 성능에 불리합니다.
- 포인터 수명: 포인터는 fixed 블록(또는 핸들 수명) 밖으로 절대 노출하지 않습니다.
- 문자열 인코딩: char*는 UTF-16입니다. 네이티브가 UTF-8을 요구하면 바이트 배열로 변환하세요.
- 빌드 설정: 프로젝트 속성에서 Unsafe 코드 허용을 켭니다. CI에서도 동일 설정을 유지하세요.
- 플랫폼/아키텍처: 포인터 크기는 IntPtr.Size에 따릅니다(32/64비트 차이 주의).

9. 빠른 체크리스트

- 네이티브 호출 직전에만 fixed로 고정했나요?
- 포인터를 블록 밖으로 저장하지 않았나요?
- 문자열 인코딩 요구사항을 맞췄나요?
- 가능한 경계 내부에서 Span/Memory를 우선 사용했나요?

fixed는 필요한 곳에만, 짧게, 명확한 범위로 사용하면 안전하면서도 매우 빠른 네이티브 접근을 제공합니다.