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

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

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

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

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

指针类型

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

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

在指针类型中 * 之前指定的类型称为 引用类型

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

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

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

垃圾回收器并不跟踪是否有任何类型的指针指向对象。 如果被引用对象位于托管堆中(包括由 lambda 表达式或匿名委托捕获的局部变量),那么只要使用该指针,就必须固定该对象。

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

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

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

int* myVariable;

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

在有关 fixed 语句的文章中,有几个指示的例子。 以下示例使用 unsafe 关键字和 fixed 语句,并演示如何递增内部指针。 可以将此代码粘贴到控制台应用程序的 Main 函数中以运行它。 必须使用 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 指针会导致实现定义的行为。

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

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

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

有关指针相关运算符的详细信息,请参阅 与指针相关的运算符

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

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]);
    }
}

char 数组的 128 个元素的大小为 256 字节。 在固定大小的 char 缓冲区中,每个字符总是占用 2 个字节,不考虑编码。 即使将字符缓冲区封送到具有 CharSet = CharSet.AutoCharSet = CharSet.Ansi的 API 方法或结构,此数组大小也是如此。 有关详细信息,请参阅 CharSet

前面的示例演示访问未固定的 fixed 字段。 另一个常见的固定大小数组是 布尔 数组。 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[]

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

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

此示例使用不安全 关键字,这使你可以在 Copy 方法中使用指针。 固定 语句用于声明指向源数组和目标数组的指针。 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* 语法定义函数指针。 编译器使用 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 unsafe T UnsafeCombine<T>(delegate*<T, T, T> combinator, T left, T right) => 
    combinator(left, right);

以下代码演示如何声明静态本地函数,并使用指向该本地函数的指针调用 UnsafeCombine 方法:

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

前面的代码演示了作为函数指针访问的函数上的多个规则:

  • 函数指针只能在 unsafe 上下文中声明。
  • 采用 delegate*(或返回 delegate*)的方法只能在 unsafe 上下文中调用。
  • 仅允许在 static 函数上使用 & 运算符来获取函数地址。 (此规则适用于成员函数和本地函数)。

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

可以使用关键字 managedunmanageddelegate* 指定调用约定。 此外,对于 unmanaged 函数指针,可以指定调用约定。 以下声明显示了每个声明的示例。 第一个声明使用 managed 调用约定,这是默认值。 后面四个使用 unmanaged 调用约定。 每个调用约定指定 ECMA 335 调用约定中的一种:CdeclStdcallFastcallThiscall。 最后一个声明使用 unmanaged 调用约定,指示 CLR 为平台选取默认调用约定。 CLR 将在运行时选择调用约定。

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

可以在函数指针 特征规范 中了解有关函数指针的详细信息。

C# 语言规范

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