不安全代码、指针类型和函数指针

你编写的大部分 C# 代码都是“可验证的安全代码”。可验证的安全代码是指 .NET 工具可验证代码是否安全。 通常,安全代码不会直接使用指针访问内存, 也不会分配原始内存, 而是创建托管对象。

C# 支持 unsafe 上下文,你可在其中编写不可验证的代码。 在 unsafe 上下文中,代码可使用指针、分配和释放内存块,以及使用函数指针调用方法。 C# 中的不安全代码不一定是危险的,它只是其安全性不可验证的代码。

不安全代码具有以下属性:

  • 可将方法、类型和代码块定义为不安全。
  • 在某些情况下,通过移除数组绑定检查,不安全代码可提高应用程序的性能。
  • 调用需要指针的本机函数时,需使用不安全代码。
  • 使用不安全代码将引发安全风险和稳定性风险。
  • 必须使用 AllowUnsafeBlocks 编译器选项来编译包含不安全块的代码。

指针类型

在不安全的上下文中,类型除了是值类型或引用类型外,还可以是指针类型。 指针类型声明采用下列形式之一:

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

在指针类型中的 * 之前指定的类型被称为“referent 类型”。 只有非托管类型可为引用类型。

指针类型不从对象继承,并且指针类型与 之间不存在转换。 此外,装箱和取消装箱不支持指针。 但是,你可在不同的指针类型之间以及指针类型和整型之间进行转换。

当在同一声明中声明多个指针时,星号 (*) 仅与基础类型一起写入, 而不是用作每个指针名称的前缀。 例如:

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

指针不能指向引用或包含引用的结构,因为无法对对象引用进行垃圾回收,即使有指针指向它也是如此。 垃圾回收器并不跟踪是否有任何类型的指针指向对象。

MyType* 类型的指针变量的值为 MyType 类型的变量的地址。 下面是指针类型声明的示例:

  • int* pp 是指向整数的指针。
  • int** pp 是指向整数的指针的指针。
  • int*[] pp 是指向整数的指针的一维数组。
  • char* pp 是指向字符的指针。
  • void* pp 是指向未知类型的指针。

指针间接寻址运算符 * 可用于访问位于指针变量所指向的位置的内容。 例如,请考虑以下声明:

int* myVariable;

表达式 *myVariable 表示在 int 中包含的地址处找到的 myVariable 变量。

关于 语句 的文章中有几个指针示例。 下面的示例使用 unsafe 关键字和 fixed 语句,并显示如何递增内部指针。 你可将此代码粘贴到控制台应用程序的 Main 函数中来运行它。 这些示例必须使用 AllowUnsafeBlocks 编译器选项集进行编译。

// Normal pointer to an object.
int[] a = new int[5] { 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 指针将导致由实现定义的行为。

在方法之间传递指针会导致未定义的行为。 考虑这种方法,该方法通过 inoutref 参数或作为函数结果返回一个指向局部变量的指针。 如果已在固定块中设置指针,则它指向的变量不再是固定的。

下表列出了可在不安全的上下文中对指针执行的运算符和语句:

运算符/语句 使用
* 执行指针间接寻址。
-> 通过指针访问结构的成员。
[] 为指针建立索引。
& 获取变量的地址。
++-- 递增和递减指针。
+- 执行指针算法。
==!=<><=>= 比较指针。
stackalloc 在堆栈上分配内存。
语句 临时固定变量以便找到其地址。

要详细了解与指针相关的运算符,请参阅与指针相关的运算符

任何指针类型都可以隐式转换为 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 关键字来创建在数据结构中具有固定大小的数组的缓冲区。 当编写与其他语言或平台的数据源进行互操作的方法时,固定大小的缓冲区很有用。 固定大小的缓冲区可以采用允许用于常规结构成员的任何属性或修饰符。 唯一的限制是数组类型必须为 boolbytecharshortint, longsbyteushortuintulongfloatdouble

private fixed char name[30];

在安全代码中,包含数组的 C# 结构不包含该数组的元素, 而该结构包含对这些元素的引用。 当在不安全的代码块中使用数组时,可以在结构中嵌入固定大小的数组。

以下 struct 的大小不依赖于数组中的元素数,因为 pathName 是一个引用:

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 个字节,不考虑编码。 即使将字符型缓冲区封送到 API 方法或具有 CharSet = CharSet.AutoCharSet = CharSet.Ansi 的结构,此数组大小也是相同的。 有关更多信息,请参见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 上下文中使用。
  • 只能是结构的实例字段。
  • 它们始终是矢量或一维数组。
  • 声明应包括长度,如 fixed char id[8]。 不能使用 fixed char id[]

如何使用指针来复制字节数组

下面的示例使用指针将字节从一个数组复制到另一个数组。

此示例使用 unsafe 关键字,使你可以在 方法中使用指针。 fixed 语句用于声明指向源数组和目标数组的指针。 fixed 语句将源数组和目标数组的位置固定在内存中,以便它们不会被垃圾回收所移动。 当完成 fixed 块后,将取消固定数组的内存块。 因为此示例中的 Copy 方法使用 unsafe 关键字,所以必须使用 Copy 编译器选项对其进行编译。

此示例使用索引而非第二个非托管的指针访问这两个数组的元素。 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 方法进行虚拟方法调用。 该虚拟调用使用 IL 指令 callvirt。 在性能关键的代码路径中,使用 IL 指令 calli 效率更高。

可以使用 delegate* 语法定义函数指针。 编译器将使用 calli 指令来调用函数,而不是实例化 delegate 对象并调用 Invoke。 以下代码声明了两种方法,它们使用 delegatedelegate* 来组合两个类型相同的对象。 第一种方法使用 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 上下文中声明。
  • 只能在 unsafe 上下文中调用采用 delegate*(或返回 delegate*)的方法。
  • 只可在 static 函数上使用 & 运算符获取函数的地址。 (此规则适用于成员函数和本地函数)。

此语法与声明 delegate 类型和使用指针具有相似之处。 delegate 上的后缀 * 表示声明是函数指针。 将方法组分配给函数指针时,& 表示操作采用方法的地址。

可以使用关键字 managedunmanageddelegate* 指定调用约定。 另外,对于 unmanaged 函数指针,可以指定调用约定。 下面的声明显示每个示例。 第一个声明使用 managed 调用约定,这是默认值。 后面四个使用 unmanaged 调用约定。 每个声明都指定以下某个 ECMA 335 调用约定:CdeclStdcallFastcallThiscall。 最后的声明使用 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# 9.0 的函数指针建议中详细了解函数指针。

C# 语言规范

有关详细信息,请参阅 C# 语言规范中的不安全代码部分。