안전하지 않은 코드, 포인터 형식 및 함수 포인터

작성하는 C# 코드 대부분은 “확인할 수 있는 안전한 코드”입니다. 확인할 수 있는 안전한 코드란 .NET 도구에서 코드가 안전한지 확인할 수 있음을 의미합니다. 일반적으로 안전한 코드는 포인터를 사용하여 메모리에 직접 액세스하지 않습니다. 또한 원시 메모리를 할당하지 않습니다. 대신 관리형 개체를 만듭니다.

C#은 unsafe 컨텍스트를 지원하는데, 이 컨텍스트에서는 확인할 수없는 코드를 작성할 수 있습니다. unsafe 컨텍스트에서 코드는 포인터를 사용하고, 메모리 블록을 할당 및 해제하고, 함수 포인터를 사용하여 메서드를 호출할 수 있습니다. C#의 안전하지 않은 코드가 반드시 위험한 것은 아닙니다. 단지 CLR에서 안전을 확인할 수 없을 뿐입니다.

안전하지 않은 코드에는 다음과 같은 속성이 있습니다.

  • 메서드, 형식 및 코드 블록은 안전하지 않은 것으로 정의할 수 있습니다.
  • 경우에 따라 안전하지 않은 코드는 배열 범위 검사를 제거하여 애플리케이션의 성능을 향상할 수 있습니다.
  • 포인터가 필요한 네이티브 함수를 호출하는 경우 안전하지 않은 코드가 필요합니다.
  • 안전하지 않은 코드를 사용하면 보안 및 안정성 위험이 발생합니다.
  • 안전하지 않은 블록을 포함하는 코드는 AllowUnsafeBlocks 컴파일러 옵션을 사용하여 컴파일해야 합니다.

포인터 형식

안전하지 않은 컨텍스트에서 형식은 포인터 형식일 수도 있고 값 형식 또는 참조 형식일 수도 있습니다. 포인터 형식 선언은 다음 형식 중 하나를 사용합니다.

type* identifier;
void* identifier; //allowed but not recommended

포인터 형식에서 * 앞에 지정된 형식을 참조 형식이라고 합니다. 비관리형 형식만 참조 형식일 수 있습니다.

포인터 형식은 개체에서 상속되지 않으며 포인터 형식과 object는 서로 변환되지 않습니다. 또한 boxing과 unboxing은 포인터를 지원하지 않습니다. 그러나 다른 포인터 형식 간의 변환 및 포인터 형식과 정수 형식 사이의 변환은 허용됩니다.

동일한 선언에서 여러 포인터를 선언하는 경우 별표(*)는 기본 형식에만 함께 사용됩니다. 각 포인터 이름의 접두사로는 사용되지 않습니다. 예시:

int* p1, p2, p3;   // Ok
int *p1, *p2, *p3;   // Invalid in C#

개체 참조는 포인터가 해당 개체 참조를 가리키는 경우에도 가비지 수집될 수 있으므로 포인터는 참조나 참조가 들어 있는 구조체를 가리킬 수 없습니다. 가비지 수집기는 포인터 형식에서 개체를 가리키는지 여부를 추적하지 않습니다.

MyType* 형식의 포인터 변수 값은 MyType 형식의 변수 주소입니다. 다음은 포인터 형식 선언의 예제입니다.

  • int* p: p는 정수에 대한 포인터입니다.
  • int** p: p는 정수에 대한 포인터를 가리키는 포인터입니다.
  • int*[] p: p는 정수에 대한 포인터의 1차원 배열입니다.
  • char* p: p는 문자에 대한 포인터입니다.
  • void* p: p는 알 수 없는 형식에 대한 포인터입니다.

포인터 간접 참조 연산자 *를 사용하면 포인터 변수가 가리키는 위치의 내용에 액세스할 수 있습니다. 예를 들어, 다음 선언을 참조하십시오.

int* myVariable;

여기서 *myVariable 식은 int에 포함된 주소에 있는 myVariable 변수를 가리킵니다.

fixed에 대한 문서에는 몇 가지 포인터 예제가 있습니다. 다음 예제는 unsafe 키워드 및 fixed 문을 사용하고 정수 포인터를 증분하는 방법을 보여줍니다. 이 코드를 실행하려면 콘솔 애플리케이션의 주 함수에 붙여 넣습니다. 이러한 예제는 AllowUnsafeBlocks 컴파일러 옵션 집합으로 컴파일되어야 합니다.

// Normal pointer to an object.
int[] a = [10, 20, 30, 40, 50];
// Must be in unsafe code to use interior pointers.
unsafe
{
    // Must pin object on heap so that it doesn't move while using interior pointers.
    fixed (int* p = &a[0])
    {
        // p is pinned as well as object, so create another pointer to show incrementing it.
        int* p2 = p;
        Console.WriteLine(*p2);
        // Incrementing p2 bumps the pointer by four bytes due to its type ...
        p2 += 1;
        Console.WriteLine(*p2);
        p2 += 1;
        Console.WriteLine(*p2);
        Console.WriteLine("--------");
        Console.WriteLine(*p);
        // Dereferencing p and incrementing changes the value of a[0] ...
        *p += 1;
        Console.WriteLine(*p);
        *p += 1;
        Console.WriteLine(*p);
    }
}

Console.WriteLine("--------");
Console.WriteLine(a[0]);

/*
Output:
10
20
30
--------
10
11
12
--------
12
*/

void* 형식의 포인터에는 간접 참조 연산자를 적용할 수 없습니다. 그러나 캐스트를 사용하여 void 포인터를 다른 포인터 형식으로 변환하거나 반대로 변환할 수 있습니다.

포인터는 null일 수 있습니다. null 포인터에 간접 참조 연산자를 적용할 때 발생하는 동작은 구현에 따라 다릅니다.

메서드 사이에 포인터를 전달하면 정의되지 않은 동작이 발생할 수 있습니다. in, out 또는 ref 매개 변수를 통해, 또는 함수 결과로 지역 변수에 포인터를 반환하는 메서드를 고려합니다. fixed 블록에서 포인터가 설정되면 이 포인터가 가리키는 변수의 고정 상태가 해제될 수 있습니다.

다음 표에서는 안전하지 않은 컨텍스트에서 포인터에 대해 수행할 수 있는 연산자와 문을 보여 줍니다.

연산자/문 사용할 용어
* 포인터 간접 참조를 수행합니다.
-> 포인터를 통해 구조체 멤버에 액세스합니다.
[] 포인터를 인덱싱합니다.
& 변수 주소를 가져옵니다.
++-- 포인터를 증가 및 감소시킵니다.
+- 포인터 연산을 수행합니다.
==, !=, <, >, <=>= 포인터를 비교합니다.
stackalloc 스택에 메모리를 할당합니다.
fixed statement 해당 주소를 찾을 수 있도록 임시로 변수를 고정합니다.

포인터에 관련 연산자에 대한 자세한 내용은 포인터 관련 연산자를 참조하세요.

모든 포인터 형식을 암시적으로 void* 형식으로 변환할 수 있습니다. 모든 포인터 형식에 값 null을 할당할 수 있습니다. 캐스트 식을 사용하여 모든 포인터 형식을 명시적으로 다른 포인터 형식으로 변환할 수 있습니다. 정수 형식을 포인터 형식으로 변환하거나, 포인터 형식을 정수 형식으로 변환할 수도 있습니다. 이 변환에는 명시적 캐스트가 필요합니다.

다음 예제에서는 int*byte*로 변환합니다. 포인터는 변수의 최하위 주소 지정 바이트를 가리킵니다. int 크기(4바이트)까지 결과를 연속적으로 증가할 경우 변수의 나머지 바이트를 표시할 수 있습니다.

int number = 1024;

unsafe
{
    // Convert to byte:
    byte* p = (byte*)&number;

    System.Console.Write("The 4 bytes of the integer:");

    // Display the 4 bytes of the int variable:
    for (int i = 0 ; i < sizeof(int) ; ++i)
    {
        System.Console.Write(" {0:X2}", *p);
        // Increment the pointer:
        p++;
    }
    System.Console.WriteLine();
    System.Console.WriteLine("The value of the integer: {0}", number);

    /* Output:
        The 4 bytes of the integer: 00 04 00 00
        The value of the integer: 1024
    */
}

고정 크기 버퍼

fixed 키워드를 사용하여 데이터 구조에서 고정 크기 배열이 있는 버퍼를 만들 수 있습니다. 고정 크기 버퍼는 다른 언어 또는 플랫폼의 데이터 원본과 상호 운용되는 메서드를 작성할 때 유용합니다. 고정 크기 버퍼는 일반 구조체 멤버에 허용되는 모든 특성 또는 한정자를 사용할 수 있습니다. 배열 형식이 bool, byte, char, short, int, long, sbyte, ushort, uint, ulong, float 또는 double이어야 한다는 것이 유일한 제한 사항입니다.

private fixed char name[30];

안전한 코드에서 배열을 포함하는 C# 구조체는 배열 요소를 포함하지 않습니다. 대신 구조체에 요소에 대한 참조가 포함되어 있습니다. unsafe 코드 블록에서 사용될 경우 struct에 고정 크기 배열을 포함할 수 있습니다.

pathName이 참조이므로 다음 struct의 크기는 배열에 있는 요소의 수에 따라 달라지지 않습니다.

public struct PathArray
{
    public char[] pathName;
    private int reserved;
}

구조체는 안전하지 않은 코드에 포함된 배열을 포함할 수 있습니다. 다음 예제에서 fixedBuffer 배열은 고정 크기입니다. fixed을 사용하여 첫 번째 요소에 대한 포인터를 가져옵니다. 이 포인터를 통해 배열의 요소에 액세스합니다. fixed 명령문은 fixedBuffer의 인스턴스 필드를 메모리의 특정 위치에 고정합니다.

internal unsafe struct Buffer
{
    public fixed char fixedBuffer[128];
}

internal unsafe class Example
{
    public Buffer buffer = default;
}

private static void AccessEmbeddedArray()
{
    var example = new Example();

    unsafe
    {
        // Pin the buffer to a fixed location in memory.
        fixed (char* charPtr = example.buffer.fixedBuffer)
        {
            *charPtr = 'A';
        }
        // Access safely through the index:
        char c = example.buffer.fixedBuffer[0];
        Console.WriteLine(c);

        // Modify through the index:
        example.buffer.fixedBuffer[0] = 'B';
        Console.WriteLine(example.buffer.fixedBuffer[0]);
    }
}

128개 요소 char 배열의 크기는 256바이트입니다. 고정 크기 char 버퍼는 인코딩에 관계없이 문자당 항상 2바이트를 사용합니다. 이 배열 크기는 char 버퍼가 CharSet = CharSet.Auto 또는 CharSet = CharSet.Ansi(을)를 사용하여 API 메서드 또는 구조체에 마샬링되는 경우에도 동일합니다. 자세한 내용은 CharSet를 참조하세요.

앞의 예제에서는 고정하지 않고 fixed 필드에 액세스하는 방법을 보여 줍니다. 또 다른 일반적인 고정 크기 배열은 bool 배열입니다. bool 배열의 요소 크기는 항상 1바이트입니다. bool 배열은 비트 배열이나 버퍼를 만드는 데 적합하지 않습니다.

고정 크기 버퍼는 System.Runtime.CompilerServices.UnsafeValueTypeAttribute(으)로 컴파일되며, CLR(공용 언어 런타임)에 잠재적으로 오버플로될 수 있는 관리되지 않는 배열이 형식에 포함되어 있음을 지시합니다. stackalloc을 사용하여 할당된 메모리는 또한 CLR에서 버퍼 오버런 검색 기능을 자동으로 사용하도록 설정합니다. 이전 예제에서는 고정 크기 버퍼가 unsafe struct에 존재할 수 있는 방법을 보여줍니다.

internal unsafe struct Buffer
{
    public fixed char fixedBuffer[128];
}

Buffer에 대해 컴파일에서 생성된 C#은 다음과 같은 특성이 있습니다.

internal struct Buffer
{
    [StructLayout(LayoutKind.Sequential, Size = 256)]
    [CompilerGenerated]
    [UnsafeValueType]
    public struct <fixedBuffer>e__FixedBuffer
    {
        public char FixedElementField;
    }

    [FixedBuffer(typeof(char), 128)]
    public <fixedBuffer>e__FixedBuffer fixedBuffer;
}

고정 크기 버퍼는 다음과 같은 방식으로 일반 배열과 다릅니다.

  • unsafe 컨텍스트에서만 사용할 수 있습니다.
  • 단지 구조체의 인스턴스 필드일 수 있습니다.
  • 항상 벡터 또는 1차원 배열입니다.
  • 선언에는 fixed char id[8]와 같은 길이가 포함되어야 합니다. fixed char id[]를 사용할 수 없습니다.

포인터를 사용하여 바이트 배열을 복사하는 방법

다음 예제에서는 포인터를 사용하여 배열 간에 바이트를 복사합니다.

이 예제에서는 Copy 메서드에서 포인터를 사용할 수 있도록 하는 unsafe 키워드를 사용합니다. fixed 문은 소스 및 대상 배열에 대한 포인터를 선언하는 데 사용됩니다. fixed 명령문은 소스 및 대상 배열의 위치가 메모리에 고정되므로 가비지 수집에 의해 이동되지 않습니다. fixed 블록이 완료되면 배열에 대한 메모리 블록이 고정 해제됩니다. 이 예제의 Copy 메서드는 unsafe 키워드를 사용하므로 AllowUnsafeBlocks 컴파일러 옵션으로 컴파일해야 합니다.

이 예제에서는 두 번째 관리되지 않는 포인터보다는 인덱스를 사용하여 두 배열의 요소에 액세스합니다. pSourcepTarget 포인터의 선언은 배열을 고정합니다.

static unsafe void Copy(byte[] source, int sourceOffset, byte[] target,
    int targetOffset, int count)
{
    // If either array is not instantiated, you cannot complete the copy.
    if ((source == null) || (target == null))
    {
        throw new System.ArgumentException("source or target is null");
    }

    // If either offset, or the number of bytes to copy, is negative, you
    // cannot complete the copy.
    if ((sourceOffset < 0) || (targetOffset < 0) || (count < 0))
    {
        throw new System.ArgumentException("offset or bytes to copy is negative");
    }

    // If the number of bytes from the offset to the end of the array is
    // less than the number of bytes you want to copy, you cannot complete
    // the copy.
    if ((source.Length - sourceOffset < count) ||
        (target.Length - targetOffset < count))
    {
        throw new System.ArgumentException("offset to end of array is less than bytes to be copied");
    }

    // The following fixed statement pins the location of the source and
    // target objects in memory so that they will not be moved by garbage
    // collection.
    fixed (byte* pSource = source, pTarget = target)
    {
        // Copy the specified number of bytes from source to target.
        for (int i = 0; i < count; i++)
        {
            pTarget[targetOffset + i] = pSource[sourceOffset + i];
        }
    }
}

static void UnsafeCopyArrays()
{
    // Create two arrays of the same length.
    int length = 100;
    byte[] byteArray1 = new byte[length];
    byte[] byteArray2 = new byte[length];

    // Fill byteArray1 with 0 - 99.
    for (int i = 0; i < length; ++i)
    {
        byteArray1[i] = (byte)i;
    }

    // Display the first 10 elements in byteArray1.
    System.Console.WriteLine("The first 10 elements of the original are:");
    for (int i = 0; i < 10; ++i)
    {
        System.Console.Write(byteArray1[i] + " ");
    }
    System.Console.WriteLine("\n");

    // Copy the contents of byteArray1 to byteArray2.
    Copy(byteArray1, 0, byteArray2, 0, length);

    // Display the first 10 elements in the copy, byteArray2.
    System.Console.WriteLine("The first 10 elements of the copy are:");
    for (int i = 0; i < 10; ++i)
    {
        System.Console.Write(byteArray2[i] + " ");
    }
    System.Console.WriteLine("\n");

    // Copy the contents of the last 10 elements of byteArray1 to the
    // beginning of byteArray2.
    // The offset specifies where the copying begins in the source array.
    int offset = length - 10;
    Copy(byteArray1, offset, byteArray2, 0, length - offset);

    // Display the first 10 elements in the copy, byteArray2.
    System.Console.WriteLine("The first 10 elements of the copy are:");
    for (int i = 0; i < 10; ++i)
    {
        System.Console.Write(byteArray2[i] + " ");
    }
    System.Console.WriteLine("\n");
    /* Output:
        The first 10 elements of the original are:
        0 1 2 3 4 5 6 7 8 9

        The first 10 elements of the copy are:
        0 1 2 3 4 5 6 7 8 9

        The first 10 elements of the copy are:
        90 91 92 93 94 95 96 97 98 99
    */
}

함수 포인터

C#은 안전한 함수 포인터 개체를 정의하는 delegate 형식을 제공합니다. 대리자를 호출하려면 System.Delegate에서 파생된 형식을 인스턴스화하고 해당 Invoke 메서드에 대한 가상 메서드 호출을 수행해야 합니다. 이 가상 호출은 callvirt IL 명령을 사용합니다. 성능이 중요한 코드 경로에서는 calli IL 명령을 사용하는 것이 더 효율적입니다.

delegate* 구문을 사용하여 함수 포인터를 정의할 수 있습니다. 컴파일러는 delegate 개체를 인스턴스화하고 Invoke를 호출하는 대신 calli 명령을 사용하여 함수를 호출합니다. 다음 코드에서는 delegate 또는 delegate*를 사용하여 같은 형식의 두 개체를 결합하는 두 메서드를 선언합니다. 첫 번째 메서드는 System.Func<T1,T2,TResult> 대리자 형식을 사용합니다. 두 번째 메서드는 동일한 매개 변수 및 반환 형식이 포함된 delegate* 선언을 사용합니다.

public static T Combine<T>(Func<T, T, T> combinator, T left, T right) => 
    combinator(left, right);

public static T UnsafeCombine<T>(delegate*<T, T, T> combinator, T left, T right) => 
    combinator(left, right);

다음 코드에서는 정적 로컬 함수를 선언하고 해당 로컬 함수에 대한 포인터를 사용하여 UnsafeCombine 메서드를 호출하는 방법을 보여 줍니다.

static int localMultiply(int x, int y) => x * y;
int product = UnsafeCombine(&localMultiply, 3, 4);

위의 코드는 함수 포인터로 액세스되는 함수에 대한 몇 가지 규칙을 보여 줍니다.

  • 함수 포인터는 unsafe 컨텍스트에서만 선언할 수 있습니다.
  • delegate*를 사용하거나 delegate*를 반환하는 메서드는 unsafe 컨텍스트에서만 호출할 수 있습니다.
  • 함수의 주소를 가져오는 & 연산자는 static 함수에서만 사용할 수 있습니다. 이 규칙은 멤버 함수와 로컬 함수에 모두 적용됩니다.

구문은 delegate 형식 선언 및 포인터 사용과 유사합니다. delegate* 접미사는 선언이 함수 포인터임을 나타냅니다. 메서드 그룹을 함수 포인터에 할당할 때 &는 연산에 메서드의 주소가 사용됨을 나타냅니다.

managedunmanaged 키워드를 사용하여 delegate*에 대한 호출 규칙을 지정할 수 있습니다. 또한 unmanaged 함수 포인터의 경우 호출 규칙을 지정할 수 있습니다. 다음 선언에서는 각각의 예제를 보여 줍니다. 첫 번째 선언은 managed 호출 규칙(기본값)을 사용합니다. 다음 4개에서는 unmanaged 호출 규칙을 사용합니다. 각각은 ECMA 335 호출 규칙(Cdecl, Stdcall, Fastcall 또는 Thiscall) 중 하나를 지정합니다. 마지막 선언은 unmanaged 호출 규칙을 사용하여 CLR에 플랫폼에 대한 기본 호출 규칙을 선택하도록 지시합니다. CLR은 런타임에 호출 규칙을 선택합니다.

public static T ManagedCombine<T>(delegate* managed<T, T, T> combinator, T left, T right) =>
    combinator(left, right);
public static T CDeclCombine<T>(delegate* unmanaged[Cdecl]<T, T, T> combinator, T left, T right) =>
    combinator(left, right);
public static T StdcallCombine<T>(delegate* unmanaged[Stdcall]<T, T, T> combinator, T left, T right) =>
    combinator(left, right);
public static T FastcallCombine<T>(delegate* unmanaged[Fastcall]<T, T, T> combinator, T left, T right) =>
    combinator(left, right);
public static T ThiscallCombine<T>(delegate* unmanaged[Thiscall]<T, T, T> combinator, T left, T right) =>
    combinator(left, right);
public static T UnmanagedCombine<T>(delegate* unmanaged<T, T, T> combinator, T left, T right) =>
    combinator(left, right);

함수 포인터 기능 사양에서 함수 포인터에 대해 자세히 알아볼 수 있습니다.

C# 언어 사양

자세한 내용은 C# 언어 사양안전하지 않은 코드 챕터를 참조하세요.