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

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

C# 语言参考记录了最近发布的 C# 语言版本。 它还包含即将发布的语言版本公共预览版中功能的初始文档。

本文档标识了在语言的最后三个版本或当前公共预览版中首次引入的任何功能。

小窍门

若要查找 C# 中首次引入功能时,请参阅 有关 C# 语言版本历史记录的文章。

C# 还支持 unsafe 上下文,可在其中编写 不可验证 的代码。 不安全代码不一定危险;它只是.NET工具无法验证其安全性的代码。 使用不安全的代码调用需要指针的本机函数,在某些情况下,通过直接内存访问来提高性能,从而避免数组绑定检查。 不安全的代码还引入了安全性和稳定性风险。 若要编译包含 unsafe 上下文的代码,请添加 AllowUnsafeBlocks 编译器选项。

C# 定义两个模型,用于计算为不安全代码的内容:原始模型和 C# 15 和 .NET 11 中预览版的更新内存安全模型。 有关这两个模型的不同之处的信息,请参阅 两个不安全代码的模型

有关 C# 中不安全代码的最佳做法的信息,请参阅 不安全代码最佳做法

不安全代码的两个模型

C# 为不安全代码定义两个模型。 实际上,模型确定哪些操作需要 unsafe 上下文,以及成员的修饰符如何影响 unsafe 调用方。

  • 原始不安全模型:上下文 unsafe 涵盖 指针特征的存在。 声明指针类型、获取变量的地址、取消引用指针、将表达式转换为 stackalloc 指针,或仅应用于 sizeof 上下文中的 unsafe 任意类型。 stackalloc(在安全代码中分配给Span<T>ReadOnlySpan<T>允许的表达式)。unsafe类型、成员或块上的修饰符可建立该上下文,但对调用方没有义务。 C# 1.0 引入了此模型,它仍然是默认值。
  • 更新的内存安全模型:上下文 unsafe 涵盖 访问运行时不管理的内存的操作。 指针的存在并不不安全;指针的取消引用为。 成员的 unsafe 修饰符将成为将审核安全的义务传播给调用方的协定。 此模型以 C# 15 提供预览版,.NET 11。

下表比较了哪些操作需要每个模型中的 unsafe 上下文。

运算 原始模型 更新的模型
声明指针类型或采用地址 & 需要 unsafe 在安全代码中允许
fixed 语句 需要 unsafe 在安全代码中允许
stackalloc 表达式转换为指针 需要 unsafe 在安全代码中允许
sizeof任何非托管类型的运算符 需要 unsafe 在安全代码中允许
指针间接()、成员访问(*pp->m)或元素访问(p[i] 需要 unsafe 需要 unsafe
函数指针调用 需要 unsafe 需要 unsafe
固定大小的缓冲区上的元素访问 需要 unsafe 需要 unsafe
调用标记的成员 unsafe 没有调用方要求 需要 unsafe

若要尝试更新的模型,请使用 .NET 11 SDK(预览版),并将编译器选项设置为 LangVersionpreview。 每当使用 C# 15 编译器和 preview 语言版本进行编译时,指针放宽都适用。 全面执行,包括调用方义务和程序集选择加入,仍在开发之中。 有关详细信息,请参阅 更新的内存安全模型(预览版)。

原始不安全模型

在原始模型中, unsafe 关键字在类型、成员或块上建立不安全的上下文,该上下文可解锁以下部分中介绍的指针功能。 unsafe修饰符只更改标记的代码可以执行的操作;它不要求调用方。 若要编译上述任何示例,请设置 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 表示在 int中包含的地址中找到的 myVariable 变量。

在有关 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: {number}");

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

固定大小的缓冲区

数组是引用类型,因此在安全代码中,数组的结构字段仅存储对数组元素的引用,而不是元素本身。 以下 struct 大小不取决于数组中的元素数,因为 pathName 是引用:

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

若要将数组的内容存储在结构本身中,请使用 fixed 关键字声明 固定大小的缓冲区。 关键字 fixed 需要 unsafe 上下文。 当你编写与其他语言或平台中的数据源互作的方法时,固定大小缓冲区非常有用。 固定大小的缓冲区可以采用允许常规结构成员的任何属性或修饰符。 唯一的限制是数组类型必须是bool、、、char、、shortintsbytefloatlongushortuintulong或:doublebyte

private fixed char name[30];

在以下示例中,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[]

函数指针

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 表示声明是函数指针。 在将方法组分配给函数指针时,& 表示该操作取该方法的地址。

可以使用 delegate* 关键字 managedunmanaged. 此外,对于 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# 语言规范的 “函数指针”部分中了解有关函数指针 的详细信息。

示例:使用指针复制字节数组

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

此示例使用 unsafe 关键字,使你可以在方法中使用 Copy 指针。 该 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
    */
}

更新的内存安全模型(预览版)

Important

更新后的内存安全模型是 C# 15 和 .NET 11 中的预览功能。 它在预览版期间会根据反馈不断改进。 若要试用模型,请使用 .NET 11 (预览版) SDK 并将编译器选项设置为 LangVersionpreview。 .NET 11 预览版 5 中的编译器实现指针放松,但尚未强制执行调用方义务、程序集选择加入或safe关键字。 有关完整设计,请参阅 内存安全功能规范

更新后的模型将原始模型视为一种:指针代码 的存在 以及向调用方 传播 安全义务这两项。 标记成员 unsafe 不再只允许其正文中的指针;它使成员 调用方变得不安全,因此每个调用方都必须传播该义务或将其释放在经过验证的安全可调用边界后面。 为了支持这种分离,模型还会缩小不安全的上下文:指针的存在并不不安全,只有访问运行时不管理的内存的操作。 通过缩小,可以在安全代码中保存、传递和返回指针,同时 unsafe 标记实际违反内存安全的操作和成员。

调用方不安全的成员

在原始模型中, unsafe 成员上的修饰符仅允许成员的签名和正文中的指针。 它不会通知呼叫者安全。 更新后的模型为调用方提供修饰符的含义。 标记成员 unsafe时,编译器将其视为 调用方不安全 (也称为 requires-unsafe):每个调用方都必须从 unsafe 上下文中调用它,并有义务审核安全转移到该调用方。

unsafe成员签名上的修饰符不再为正文建立不安全的上下文。 这两个角色拆分:

  • 签名 unsafe 上的修饰符将义务传播给调用方。
  • 内部 unsafe 块限定访问非托管内存的操作的范围。

在以下预览模拟中, ReadInt32 调用方不安全。 签名包含 unsafe 修饰符,内部 unsafe 块包装取消引用:

// Preview: illustrates the updated model, which the current compiler doesn't fully enforce yet.
public static unsafe int ReadInt32(byte* source)
{
    unsafe
    {
        return *(int*)source;
    }
}

调用方将调用包装在其自己的 unsafe 块中:

// Preview
unsafe
{
    int value = ReadInt32(buffer);
}

更新后的模型还收紧了一些相关规则:

  • 修饰 unsafe 符在类型声明、静态构造函数和终结器上生成错误,因为修饰符没有要通知的调用方。
  • 委托不能 unsafe,因为委托是类型形的。
  • 无参数构造函数 unsafe 不满足约束的类型 new()

需要不安全上下文的操作

访问指向内存的操作需要上下文 unsafe

  • 指针间接(*p)、指针成员访问(p->member)和指针元素访问(p[i])。
  • 函数指针调用。
  • 固定大小的缓冲区上的元素访问。

以下示例固定一个没有上下文的 unsafe 数组,但取消引用其中一个指针:

public static int ReadValue(int[] numbers)
{
    fixed (int* first = numbers)
    {
        // Dereferencing a pointer accesses unmanaged memory, so it still
        // requires an unsafe context.
        unsafe
        {
            return *first;
        }
    }
}

宽松的操作

无法访问指向内存的操作不再需要 unsafe 上下文:

  • 声明指针类型并使用运算符获取变量 & 的地址。
  • 固定 fixed 变量的语句。
  • stackalloc 表达式转换为指针。
  • sizeof应用于任何非托管类型的运算符。

以下示例创建并固定没有 unsafe 上下文的指针:

public static void CreatePointer()
{
    int value = 42;
    // Creating a pointer doesn't require an unsafe context.
    int* pointer = &value;
    int** pointerToPointer = &pointer;
}
public static void PinArray(int[] numbers)
{
    // The fixed statement no longer requires an unsafe context.
    fixed (int* first = numbers)
    {
        int* current = first;
    }
}

每当使用 preview 语言版本进行编译时,无论程序集是否选择加入更新的内存安全规则,这些放松都适用。

解除调用方不安全义务

调用调用方不安全操作的成员有两种选择:传播义务或解除义务。

  • 传播:标记自己的成员 unsafe。 义务将传递给呼叫方。 当无法完全验证义务时,请使用传播。
  • 出院:使成员的签名安全。 验证成员内部的义务,通常使用运行时防护,然后在内部 unsafe 块中执行不安全操作。 包含内部 unsafe 块但不标记其自己的签名 unsafe 的成员是 不安全的边界:它将不安全的代码转换为可调用的安全图面。

以下预览模拟使用防护验证其输入,固定托管数组并读取指针。 调用方不需要 unsafe 上下文,因为该方法会履行义务:

// Preview
public static int SumBytes(byte[] source)
{
    ArgumentNullException.ThrowIfNull(source);

    fixed (byte* first = source)
    {
        unsafe
        {
            // SAFETY: the null check and source.Length bound every read to the pinned array.
            int total = 0;
            for (int i = 0; i < source.Length; i++)
            {
                total += first[i];
            }

            return total;
        }
    }
}

null 检查和数组长度排除了允许读取运行超过缓冲区的输入,因此块内的 unsafe 取消引用是声音。 该方法不承担剩余义务,因此会公开可安全调用的签名。

安全文档

调用方不安全的成员应记录调用方必须保证的内容。 更新后的模型鼓励两种补充注释样式:

  • /// <safety>签名上方的文档块表示正式合同:调用方必须满足的条件。 分析器可以标记缺少调用方不安全的成员。
  • 块内一条// SAFETY:unsafe注释记录了操作在那个位置听起来的原因,供阅读正文的开发人员和审核员。

以下预览模拟显示调用方不安全 ReadByte 方法上的两种样式:

// Preview
/// <summary>Reads a single byte from unmanaged memory.</summary>
/// <safety>
/// The sum of <paramref name="ptr"/> and <paramref name="offset"/> must address a byte
/// the caller is permitted to read.
/// </safety>
public static unsafe byte ReadByte(IntPtr ptr, int offset)
{
    byte* address = (byte*)ptr;
    unsafe
    {
        // SAFETY: relies on the caller obligation stated in the <safety> block.
        return address[offset];
    }
}

/// <safety> 块会告诉你合同。合同属于每个调用方和审阅者都能看到的文档。

不安全字段

unsafe当字段的声明类型不表示封闭类型所维护的协定和其他代码所依赖时,请使用字段的修饰符。 类型系统看到的内容与类型承诺之间的差距存在不安全。 修饰符将字段的每个写入强制写入到一个块中,使写入在一个 unsafe 位置可查看。

最清晰的情况是保存本机指针的字段。 指针不声明其地址的 System.Span<T> 字节数,因此包含类型维护该信息本身:

// Preview
public class NativeBuffer
{
    /// <safety>
    /// Null, or points to a buffer of Length bytes.
    /// </safety>
    private unsafe byte* _pointer;

    public int Length { get; }

    public byte ReadAt(int index)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(index);
        ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Length);
        unsafe
        {
            // SAFETY: the bounds checks confine the read to the buffer that _pointer addresses.
            return _pointer[index];
        }
    }
}

字段 readonly unsafe 将协定与内置防护配对: unsafe 将固定名称命名,并 readonly 阻止在构造后中断它的写入。 标记属性或事件 unsafe 不会使其支持字段调用方不安全。 在结构中[StructLayout(LayoutKind.Explicit)],标记每个字段或safeunsafe标记。

安全关键字

更新后的模型会添加一个 safe 上下文关键字,用于证明声明在编译器要求你明确选择的位置。

成员 extern 调用本机代码,因此编译器无法对其安全性进行分类。 在更新的模型中,将标记每个extern声明,包括分LibraryImport部方法,或safeunsafe

// Preview
[LibraryImport("libc")]
internal static safe partial int getpid();

[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)]
internal static unsafe partial nint strlen(byte* str);

getpid 不采用任何参数并返回基元,因此作者证明调用是安全的,调用方在没有仪式的情况下使用它。 strlen 获取本机代码取消引用的原始指针,因此声明是 unsafe 并向调用方传播义务。 省略这两个修饰符是一个错误,这迫使你做出安全决定。 具有显式布局的结构中的字段使用相同的规则。

选择加入和跨程序集行为

更新后的模型具有两个独立的项目级开关:

  • 新的选择加入属性将打开更新的规则。 属性关闭时,将应用原始规则。 打开时, unsafe 在成员上传播到调用方,编译器会记录程序集中具有该属性的选项 MemorySafetyRulesAttribute
  • 现有的 AllowUnsafeBlocks 属性会入口关键字的每个外观 unsafe ,包括调用站点中的内部块。 它默认为 false,因此默认项目无法调用任何不安全的 API。

这两个属性组合如下:

Opt-in 属性 AllowUnsafeBlocks Result
开启 关闭(默认值) 最安全的配置。 项目使用更新后的模型,并且不允许不安全的代码。
开启 开启 项目使用更新后的模型并允许不安全的代码。
关闭 关闭 原始模型适用,项目无法使用指针类型。
关闭 开启 原始模型适用,项目可以使用指针类型。

一个程序集是否对另一个程序集强制实施更新的规则取决于哪个方选择加入:

  • 更新模型调用方、更新模型被调用方:被调用方的 unsafe 标记通过元数据传输。 调用方在块中 unsafe 包装对调用方不安全成员的每个调用。
  • 更新的模型调用方、原始模型被调用方:兼容模式将签名中具有指针类型的任何被调用方成员视为调用方不安全,因此调用站点需要封闭 unsafe 块。 此模式使基于指针的 API 无法无提示地失去其 unsafe 要求。
  • 原始模型调用方、更新模型被调用方:原始指针规则仍适用。 其签名中没有指针类型的调用方成员可从安全代码调用,因为原始模型调用方无法读取新标记。

C# 语言规范

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

有关更新的内存安全模型的设计,请参阅 内存安全功能规范

另请参阅