不安全的代码最佳做法

本文包含针对特定不安全模式、风险以及如何缓解这些风险的细化建议。 这些准则面向所有在 C# 中编写或查看不安全代码的开发人员。 其他 .NET 语言(如 F# 和 Visual Basic)不在本文的范围之外,尽管一些建议也适用于这些语言。

术语表

  • AVE - 访问冲突异常。
  • Byref - 类似于非托管指针但由 GC 跟踪的托管指针(ref T t)。 通常指向对象或堆栈的任意部分。 引用实际上是具有 +0 偏移量的托管指针。
  • CVE - 公开披露的网络安全漏洞
  • JIT - 实时编译器(CoreCLR 和 NativeAOT 中的 RyuJIT)。
  • PGO - 性能分析引导的优化。
  • 非托管指针(或原始指针)-指向任意内存位置的指针(T* p或原始指针),不由 GC 管理或跟踪。

有关其他术语,请参阅 .NET 运行时术语表

常见的不可靠模式

C# 提供了一个安全的环境,开发人员无需担心运行时和 GC 的内部工作。 不安全的代码允许你绕过这些安全检查,潜在地引入可能导致内存损坏的不可靠模式。 虽然此类模式在某些方案中可能很有用,但应谨慎使用它们,并且仅当绝对必要时才使用它们。 C# 和 .NET 不仅没有提供工具来验证不安全代码的安全性(而各种 C/C++ 代码验证工具可能会提供这种功能),不安全 C# 中的 GC 相关行为可能会引入额外的风险,这些风险超过了传统 C/C++ 开发人员可能熟悉的风险。

必须根据以下保守假设来编写与托管引用相关的不安全代码:

  • GC 可以在任何指令的任何时间点中断任何方法的执行。
  • GC 可以在内存中移动对象并更新所有 跟踪的 引用。
  • GC 确切地知道引用不再需要时。

堆损坏的一个经典示例是,当 GC 无法跟踪对象引用或将无效指针视为堆引用时。 这通常会导致不确定的崩溃或内存损坏。 诊断和重现堆损坏错误尤其困难,因为:

  • 这些问题可能长期处于隐藏状态,并且仅在不相关的代码更改或运行时更新后才会显示清单。
  • 它们通常需要精确计时来重现,例如 GC 在特定位置中断执行和启动堆压缩,这是一个罕见且非确定性的事件。

接下来的部分将介绍常见的安全性问题,并给出可以遵循的✔️ DO 和 ❌ DON'T 建议。

1. 未跟踪的托管指针(Unsafe.AsPointer 及相关项)

无法在安全 C# 中将托管(跟踪)指针转换为非托管(未跟踪)指针。 当出现此类需求时,可能会很诱人地使用Unsafe.AsPointer<T>(T)以避免fixed语句的开销。 尽管存在有效的用例,但它会带来创建指向可移动对象的未跟踪指针的风险。 示例:

unsafe void UnreliableCode(ref int x)
{
    int* nativePointer = (int*)Unsafe.AsPointer(ref x);
    nativePointer[0] = 42;
}

如果 GC 在读取指针(引用的地址)之后立即中断UnreliableCode方法执行,然后重新定位所引用的对象,则 GC 会正确更新存储在x中的位置,但对于x不了解,因此不会更新其中包含的值。 此时,写入 nativePointer 便是写入到任意内存。

unsafe void UnreliableCode(ref int x)
{
    int* nativePointer = (int*)Unsafe.AsPointer(ref x);
    // <-- GC happens here between the two lines of code and updates `x` to point to a new location.
    // However, `nativePointer` still points to the old location as it's not reported to the GC
    
    nativePointer[0] = 42; // Potentially corrupting write, access violation, or other issue.
}

GC 恢复执行该方法后,它会将 42 写入旧位置 x,这可能会导致意外异常、整体全局状态损坏或由于访问冲突导致进程终止。

建议的解决方案改为使用 fixed 关键字和 & 地址运算符来确保 GC 无法在作期间重新定位目标引用。

unsafe void ReliableCode(ref int x)
{
    fixed (int* nativePointer = &x) // `x` cannot be relocated for the duration of this block.
    {
        nativePointer[0] = 42;
    }
}

Recommendations

  1. ❌请勿使用隐含协定要求始终由堆栈分配、固定,或在垃圾回收器不能重定位的参数ref X。 同样适用于纯对象和 Spans - 不要在方法签名中引入基于非明显的调用方协定的生存期。 请考虑改用 ref 结构 参数或更改参数,使之成为原始指针类型(X*)。
  2. ❌ 如果指针可能比它指向的原始对象活得更久,请不要使用来自Unsafe.AsPointer<T>(T)的指针。 根据 API 的文档,由调用方 Unsafe.AsPointer<T>(T) 负责保证 GC 无法重新定位引用。 确保代码审阅不会遗漏调用方已满足此先决条件的情况。
  3. ✔️ 请使用 GCHandlefixed 范围,而不是 Unsafe.AsPointer<T>(T) 来定义非托管指针的显式作用域,并确保始终固定对象。
  4. ✔️ 如果需要将数组与特定边界对齐,请使用非托管指针(with fixed)而不是 byref。 这可确保 GC 不会重定位对象,并使逻辑可能依赖的任何对齐假设失效。

2. 在作用域外 fixed 公开指针

虽然 固定 关键字定义了从固定对象获取的指针的范围,但该指针仍有可能逸出 fixed 范围并导致错误,因为 C# 不提供任何所有权/生命周期保护。 典型的示例是以下代码片段:

unsafe int* GetPointerToArray(int[] array)
{
    fixed (int* pArray = array)
    {
        _ptrField = pArray; // Bug!

        Method(pArray);     // Bug if `Method` allows `pArray` to escape,
                            // perhaps by assigning it to a field.

        return pArray;      // Bug!

        // And other ways to escape the scope.
    }
}

在此示例中,该数组使用 fixed 关键字正确固定(确保 GC 无法在 fixed 块内重新定位数组),然而,指针会在 fixed 块外暴露。 这会创建一个悬空指针,对其进行取消引用操作将导致未定义的行为。

Recommendations

  1. ✔️ 请确保 fixed 块中的指针不会超出定义的作用域。
  2. ✔️ DO 更喜欢使用内置转义分析的安全低级别基元,例如 C# 的 ref 结构。 有关详细信息,请参阅 低级别结构改进

3. 运行环境和库的内部实现细节

虽然访问或依赖内部实现详细信息通常是不良做法(但 .NET 不支持),但值得指出具体的常见案例。 这不是一个详尽的列表,列出代码不恰当地依赖内部实现详细信息时可能出现的所有可能问题。

Recommendations

  1. ❌ 请勿更改或读取对象的标头的任何部分。

    • 对象标头在运行时之间可能有所不同。
    • 在 CoreCLR 中,除非先固定对象,否则无法安全地访问对象头。
    • 从不通过修改 MethodTable 指针来更改对象的类型。
  2. ❌ 请勿将任何数据存储在对象的填充中。 不要假定填充内容会被保留,或者默认情况下填充总是为零。

  3. ❌ 不要假设除具有顺序或显式布局的基元和结构以外的任何内容的大小和偏移量。 尽管如此,也有例外情况,例如,在涉及 GC 句柄时。

  4. ❌ 请勿调用非公共方法、访问非公共字段或通过反射或不安全代码修改 BCL 类型中的只读字段。

  5. ❌ 请勿假定 BCL 中的任何给定非公共成员将始终存在或将具有特定形状。 .NET 团队偶尔会修改或删除服务版本中的非公共 API。

  6. ❌ 请勿使用反射或不安全代码更改 static readonly 字段,因为它们假定为常量。 例如,RyuJIT 通常将其内联为显式常量。

  7. ❌ 不要简单地假设引用不可重新定位。 本指南适用于字符串和 UTF-8 ("..."u8) 文本、静态字段、RVA 字段、LOH 对象等。

    • 这些是某些运行时中可能适用的运行时实现细节,但不适用于其他运行时。
    • 指向此类对象的非托管指针可能无法阻止程序集被卸载,导致指针变得悬空。 使用 fixed 范围来确保正确性。
    ReadOnlySpan<int> rva = [1, 2, 4, 4];
    int* p = (int*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(rva));
    // Bug! The assembly containing the RVA field might be unloaded at this point
    // and `p` becomes a dangling pointer.
    int value = p[0]; // Access violation or other issue.
    
  8. ❌ 不要编写依赖于特定运行时实现详细信息的代码。

4.托管指针无效(即使它们从未取消引用)

最终依赖于指针运算和算术运算的某些类型的代码通常可以选择使用非托管指针(T* p)或托管指针(ref T p)。 这些指针可以被任意操作,例如,通过非托管指针上的运算符p++以及托管指针上的Unsafe方法p = ref Unsafe.Add(ref p, 1)。 两者都被视为“不安全代码”,并且可以使用这两者创建不可靠的模式。 但是,对于某些算法,在操作托管指针时,可能更容易出现 GC 不安全的模式。 由于 GC 不跟踪非托管指针,因此它们所包含的值仅在开发人员代码解引用时才相关。 相反,托管指针的值不仅在开发人员通过代码取消引用时重要,而且在被 GC 检查时也同样重要。 因此,只要不取消引用,开发人员就可以创建无效的非托管指针,但创建任何无效的托管指针是一个 bug。 示例:

unsafe void UnmanagedPointers(int[] array)
{
    fixed (int* p = array)
    {
        int* invalidPtr = p - 1000;
        // invalidPtr is pointing to an undefined location in memory
        // it's ok as long as it's not dereferenced.

        int* validPtr = invalidPtr + 1000; // Returning back to the original location
        *validPtr = 42; // OK
    }
}

但是,使用 byrefs(托管指针)的类似代码无效。

void ManagedPointers_Incorrect(int[] array)
{
    ref int invalidPtr = ref Unsafe.Add(ref array[0], -1000); // Already a bug!
    ref int validPtr = ref Unsafe.Add(ref invalidPtr, 1000);
    validPtr = 42; // possibly corrupting write
}

虽然此处的托管实现避免了轻微的固定开销,但这是不健全的,因为invalidPtr当 GC 更新实际地址array[0]成为外部指针。 此类 bug 是微妙的,甚至 .NET 在开发过程中也曾遇到过这些问题

Recommendations

  1. ❌ 请勿创建无效的托管指针,即使它们未取消引用,或者它们位于永不执行的代码路径中。
  2. ✔️ 如果算法需要此类操作,则使用固定的非托管指针。

5. 重新解释类似类型强制转换

虽然所有类型的结构到类或类到结构转换都是一种未定义的行为,但也可以遇到结构到结构或类到类转换的不可靠模式。 不可靠的模式的典型示例如下:

struct S1
{
    string a;
    nint b;
}

struct S2
{
    string a;
    string b;
}

S1 s1 = ...
S2 s2 = Unsafe.As<S1, S2>(ref s1); // Bug! A random nint value becomes a reference reported to the GC.

即使布局类似,在涉及 GC 引用(字段)时,仍应小心。

Recommendations

  1. ❌ 请勿将结构体转换为类,也不要将类转换为结构体。
  2. ❌ 切勿使用 Unsafe.As 进行结构体到结构体或类到类的转换,除非您绝对确定强制转换是合法的。 有关详细信息,请参阅 Unsafe.As”部分。
  3. ✔️ DO 首选更安全的逐字段复制、外部库(如 AutoMapper 或源生成器)进行此类转换。
  4. ✔️ DO 首选 Unsafe.BitCast 而不是 Unsafe.As,因为 BitCast 提供一些基本的使用情况检查。 请注意,这些检查不提供完全正确性保证,这意味着 BitCast 仍被视为不安全的 API。

6. 绕过 GC 引用上的写屏障和非原子操作

通常,所有种类的 GC 引用的写入或读取始终是原子性的。 此外,所有将 GC 引用(或将 byref 分配给含 GC 字段的结构)至潜在堆位置的尝试都经过写屏障,以确保 GC 了解对象之间的新连接。 但是,不安全的代码允许我们绕过这些保证并引入不可靠的模式。 示例:

unsafe void InvalidCode1(object[] arr1, object[] arr2)
{
    fixed (object* p1 = arr1)
    fixed (object* p2 = arr2)
    {
        nint* ptr1 = (nint*)p1;
        nint* ptr2 = (nint*)p2;

        // Bug! We're assigning a GC pointer to a heap location
        // without going through the Write Barrier.
        // Moreover, we also bypass array covariance checks.
        *ptr1 = *ptr2;
    }
}

同样,具有托管指针的以下代码也不可靠:

struct StructWithGcFields
{
    object a;
    int b;
}

void InvalidCode2(ref StructWithGcFields dst, ref StructWithGcFields src)
{
    // It's already a bad idea to cast a struct with GC fields to `ref byte`, etc.
    ref byte dstBytes = ref Unsafe.As<StructWithGcFields, byte>(ref dst);
    ref byte srcBytes = ref Unsafe.As<StructWithGcFields, byte>(ref src);

    // Bug! Bypasses the Write Barrier. Also, non-atomic writes/reads for GC references.
    Unsafe.CopyBlockUnaligned(
        ref dstBytes, ref srcBytes, (uint)Unsafe.SizeOf<StructWithGcFields>());

    // Bug! Same as above.
    Vector128.LoadUnsafe(ref srcBytes).StoreUnsafe(ref dstBytes);
}

Recommendations

  1. ❌ 不要对 GC 引用使用非原子操作(例如,SIMD 操作通常不提供这种操作)。
  2. ❌ 请勿使用非托管指针将 GC 引用存储到堆位置(省略写入屏障)。

7. 关于对象生命周期的假设(终结器,GC.KeepAlive

避免从 GC 的角度假设对象的生存期。 具体而言,不要假定对象在可能不再活动时仍然处于活动状态。 对象生存期可能因不同运行时而异,甚至相同方法的不同层(RyuJIT 中的 Tier0 和 Tier1)。 终结器是一种常见方案,其中此类假设可能不正确。

public class MyClassWithBadCode
{
    public IntPtr _handle;

    public void DoWork() => DoSomeWork(_handle); // A use-after-free bug!

    ~MyClassWithBadCode() => DestroyHandle(_handle);
}

// Example usage:
var obj = new MyClassWithBadCode()
obj.DoWork();

在此示例中,DestroyHandle 可能会在 DoWork 完成之前被调用,甚至可能在 DoWork 开始之前就被调用。 因此,不假定对象(如 this)将保持活动状态,直到方法结束,这一点至关重要。

void DoWork()
{
    // A pseudo-code of what might happen under the hood:

    IntPtr reg = this._handle;
    // 'this' object is no longer alive at this point.

    // <-- GC interrupts here, collects the 'this' object, and triggers its finalizer.
    // DestroyHandle(_handle) is called.

    // Bug! 'reg' is now a dangling pointer.
    DoSomeWork(reg);

    // You can resolve the issue and force 'this' to be kept alive (thus ensuring the
    // finalizer will not run) by uncommenting the line below:
    // GC.KeepAlive(this);
}

因此,建议使用GC.KeepAlive(Object)SafeHandle显式延长对象的生存期。

此问题的另一个经典实例是 Marshal.GetFunctionPointerForDelegate<TDelegate>(TDelegate) API:

var callback = new NativeCallback(OnCallback);

// Convert delegate to function pointer
IntPtr fnPtr = Marshal.GetFunctionPointerForDelegate(callback);

// Bug! The delegate might be collected by the GC here.
// It should be kept alive until the native code is done with it.

RegisterCallback(fnPtr);

Recommendations

  1. ❌ 不要对对象生存期做出假设。 例如,从不假定 this 始终在方法末尾处于活动状态。
  2. 请使用 SafeHandle 来管理本机资源。
  3. ✔️ 如有必要,请使用 GC.KeepAlive(Object) 延长对象的生存期。

8. 跨线程访问局部变量

从其他线程访问局部变量通常被视为不良做法。 但是,当涉及托管引用时,根据 .NET Memory Model 的定义,这会明确成为未定义行为。

示例:包含 GC 引用的结构可能以非线程安全的方式在 no-GC 区域中被零或覆盖,而另一个线程正在读取它,从而导致未定义的行为。

Recommendations

  1. ❌ 不要跨线程访问局部变量(尤其是如果它们包含 GC 引用)。
  2. ✔️ 请改用堆或非托管内存(例如 NativeMemory.Alloc)。

9. 不安全的边界检查移除

在 C# 中,所有习惯性内存访问默认包括边界检查。 JIT 编译器可以删除这些检查,如果它可以证明这些检查是不必要的,如以下示例所示。

int SumAllElements(int[] array)
{
    int sum = 0;
    for (int i = 0; i < array.Length; i++)
    {
        // The JIT knows that within this loop body, i >= 0 and i < array.Length.
        // The JIT can reason that its own bounds check would be duplicative and
        // unnecessary, so it opts not to emit the bounds check into the final
        // generated code.
        sum += array[i];
    }
}

尽管 JIT 在识别此类模式方面不断改进,但仍有一些情况会保留检查,从而可能影响频繁执行代码的性能。 在这种情况下,你可能希望使用不安全的代码手动删除这些检查,而无需完全了解风险或准确评估性能优势。

例如,请考虑以下方法。

int FetchAnElement(int[] array, int index)
{
    return array[index];
}

如果 JIT 无法证明 index 始终在 array 的范围内,它将重写该方法,使其看起来类似下面的形式。

int FetchAnElement_AsJitted(int[] array, int index)
{
    if (index < 0 || index >= array.Length)
        throw new IndexOutOfBoundsException();
    return array.GetElementAt(index);
}

为了减少签入热代码的开销,你可能倾向于使用不安全的等效 API(UnsafeMemoryMarshal):

int FetchAnElement_Unsafe1(int[] array, int index)
{
    // DANGER: The access below is not bounds-checked and could cause an access violation.
    return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}

或使用固定化和原始指针:

unsafe int FetchAnElement_Unsafe2(int[] array, int index)
{
    fixed (int* pArray = array)
    {
        // DANGER: The access below is not bounds-checked and could cause an access violation.
        return pArray[index];
    }
}

如果index超出了array的边界,这可能会导致随机崩溃或状态损坏。 这种不安全的变换可以在热点路径上带来性能优势,但这些优势通常是短暂的,因为每个 .NET 版本都提高了 JIT 在安全时消除不必要边界检查的能力。

Recommendations

  1. ✔️ 请验证最新版本的 .NET 是否仍无法消除边界检查。 如果可以,请使用安全代码重写。 否则,针对 RyuJIT 提出问题。 将此跟踪问题作为一个良好的起点来使用。
  2. ✔️ 请测量实际性能影响。 如果性能提升微不足道,或者代码未证明在微基准测试之外具有显著性能优势,请改写为安全代码。
  3. ✔️ DO 为 JIT 提供其他提示,例如循环前的手动边界检查,并将字段保存到局部变量,因为 .NET 内存模型 可能会保守地阻止 JIT 在某些方案中删除边界检查。
  4. 使用 Debug.Assert 边界检查来保护代码,如果不安全代码仍然是必要的。 请考虑以下示例。
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
// Unsafe code here

你甚至可以将这些检查重构为可重用的帮助程序方法。

[MethodImpl(MethodImplOptions.AggressiveInlining)]
static T UnsafeGetElementAt<T>(this T[] array, int index)
{
    Debug.Assert(array is not null);
    Debug.Assert((index >= 0) && (index < array.Length));
    return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}

Debug.Assert 的包含不会为 Release 构建提供任何健全性检查,但可能有助于检测 Debug 构建中的潜在错误。

10. 内存访问合并

你可能倾向于使用不安全的代码来合并内存访问以提高性能。 经典示例是用于写入 "False" char 数组的以下代码:

// Naive implementation
static void WriteToDestination_Safe(char[] dst)
{
    if (dst.Length < 5) { throw new ArgumentException(); }
    dst[0] = 'F';
    dst[1] = 'a';
    dst[2] = 'l';
    dst[3] = 's';
    dst[4] = 'e';
}

// Unsafe coalesced implementation
static void WriteToDestination_Unsafe(char[] destination)
{
    Span<char> dstSpan = destination;
    if (dstSpan.Length < 5) { throw new ArgumentException(); }
    ulong fals_val = BitConverter.IsLittleEndian ? 0x0073006C00610046ul : 0x00460061006C0073ul;
    MemoryMarshal.Write(MemoryMarshal.AsBytes(dstSpan.Slice(0, 4)), in fals_val); // Write "Fals" (4 chars)
    dstSpan[4] = 'e';                                                             // Write "e" (1 char)
}

在早期版本的 .NET 中,使用 MemoryMarshal 不安全的版本比简单的安全版本快得多。 但是,新式版本的 .NET 包含一个大大改进的 JIT,可为这两种情况生成等效的 codegen。 从 .NET 10 起,x64 codegen 为:

; WriteToDestination_Safe
cmp      eax, 5
jl       THROW_NEW_ARGUMENTEXCEPTION
mov      rax, 0x73006C00610046
mov      qword ptr [rdi+0x10], rax
mov      word  ptr [rdi+0x18], 101

; WriteToDestination_Unsafe
cmp      edi, 5
jl       THROW_NEW_ARGUMENTEXCEPTION
mov      rdi, 0x73006C00610046
mov      qword ptr [rax], rdi
mov      word  ptr [rax+0x08], 101

代码版本更简单、更易于阅读:

"False".CopyTo(dst);

从 .NET 10 起,此调用生成与上面相同的 codegen。 它甚至有一个额外的好处:它向 JIT 表明严格的逐元素写入不需要是原子性的。 JIT 可以将此提示与其他上下文知识相结合,以提供更多优化,超出此处讨论的内容。

Recommendations

  1. ✔️ DO 偏好使用惯用的安全代码,而不是采用不安全的内存访问的合并
    • 复制数据时,为首选 Span<T>.CopyToSpan<T>.TryCopyTo
    • 首选 String.EqualsSpan<T>.SequenceEqual 比较数据(即使在使用 StringComparer.OrdinalIgnoreCase时也是如此)。
    • 首选 Span<T>.Fill 用于填充数据和 Span<T>.Clear 清除数据。
    • 请注意,JIT 可能会自动合并按元素或字段的写入/读取操作。
  2. ✔️ 如果编写惯用代码并观察到它未按预期进行优化,请针对 dotnet/runtime 提出问题。
  3. ❌ 如果不确定错位内存访问风险、原子性保证或相关的性能优势,请不要手动归并内存访问。

11. 未对齐内存访问

内存访问合并 中描述的内存访问合并常常导致显式或隐式的未对齐读写。 虽然这通常不会导致严重问题(除了由于跨越缓存和页面边界而导致的潜在性能处罚外),但它仍然会带来一些真正的风险。

例如,请考虑一次性清除数组的两个元素的方案:

uint[] arr = _arr;
arr[i + 0] = 0;
arr[i + 1] = 0;

假设这些位置的先前值都是 uint.MaxValue0xFFFFFFFF)。 .NET 内存模型保证两个写入操作都是原子的,因此进程中的所有其他线程只会观察到新值0或旧值0xFFFFFFFF,而不会遇到类似0xFFFF0000的“破损”值。

但是,假设使用以下不安全代码以绕过边界检查,并使用单个 64 位存储将这两个元素置零:

ref uint p = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(arr), i);
Unsafe.WriteUnaligned<ulong>(ref Unsafe.As<uint, byte>(ref p), 0UL);

此代码具有删除原子性保证的副作用。 其他线程可能会观察到撕裂的值,从而导致未定义的行为。 要使此类合并写入成为原子写入,内存必须与写入大小对齐(在本例中为 8 个字节)。 如果在操作之前尝试手动对齐内存,则必须考虑,GC 可以随时重新定位数组(并且实际上改变数组的对齐方式),如果该数组没有固定。 有关更多详细信息,请参阅 .NET 内存模型 文档。

未对齐内存访问的另一个风险是在某些情况下应用程序崩溃的可能性。 虽然某些 .NET 运行时依赖于操作系统解决未对齐的内存访问问题,但在某些平台上,仍有一些场景中不对齐的内存访问可能导致 DataMisalignedException (或 SEHException)。 一些示例包括:

  • Interlocked 在某些平台上对未对齐内存的操作(例如)。
  • ARM 上的浮点运算不对齐问题。
  • 访问具有某些对齐要求的特殊设备内存(.NET 并不真正支持)。

Recommendations

  1. ❌ 请勿在无锁算法和其他原子性非常重要的情况下使用无对齐内存访问。
  2. ✔️ 如有必要,请手动对齐数据,但请记住,GC 可以随时重新定位对象,从而有效地动态更改对齐方式。 这对于 SIMD 中的各种 StoreAligned/LoadAligned API 尤其重要。
  3. ✔️ 应使用显式未对齐的读/写 API,例如Unsafe.ReadUnaligned/Unsafe.WriteUnaligned,而不是对齐的 API,例如Unsafe.Read<T>(Void*)/Unsafe.Write<T>(Void*, T)Unsafe.As<TFrom,TTo>(TFrom),如果数据可能不对齐。
  4. ✔️ 请记住,多种内存操作 API,例如 Span<T>.CopyTo(Span<T>),也不提供原子性保证。
  5. ✔️ 请查阅 .NET 内存模型 文档(请参阅参考),了解有关原子性保证的更多详细信息。
  6. ✔️ DO 测量所有目标平台的性能,因为某些平台会对未对齐的内存访问造成重大性能损失。 你可能会发现,在这些平台上,天真代码的性能优于聪明的代码。
  7. ✔️ 请记住,在某些情况下和某些平台上,未对齐的内存访问可能导致异常。

12. 具有填充或非 blittable 成员的结构的二进制序列化与反序列化

在使用各种类似序列化的 API 复制或读取结构与字节数组之间的数据时,请谨慎。 如果结构包含填充或非 blittable 成员(例如 bool 或 GC 字段),则经典不安全的内存操作(例如 FillCopyToSequenceEqual)可能会意外地将敏感数据从堆栈复制到填充,或在比较期间将垃圾数据视为重要内容,从而创建很少可重现的错误。 常见的反模式可能如下所示:

T UnreliableDeserialization<TObject>(ReadOnlySpan<byte> data) where TObject : unmanaged
{
    return MemoryMarshal.Read<TObject>(data); // or Unsafe.ReadUnaligned
    // BUG! TObject : unmanaged doesn't guarantee that TObject is blittable and contains no paddings.
}

唯一正确的方法是使用专门针对每个 TObject 输入的逐字段加载/存储方式(或者通过反射、源生成器或(反)序列化库来实现通用化)。

Recommendations

  1. ❌ 切勿使用不安全代码来复制、加载或比较含有填充或非 blittable 成员的结构体。 即使对于基本类型(如 booldecimal)而言,来自不受信任的输入的负载也存在问题。 同时,数据存储可能会意外地将数据结构的间隙/填充中的堆栈上的敏感信息进行序列化。
  2. ❌ 不要依赖 T : unmanaged 约束、RuntimeHelpers.IsReferenceOrContainsReferences或类似的API,以保证泛型类型可以安全地执行按位操作。 在编写这些准则时,没有可靠的编程方式来确定对给定类型执行任意位运算是否合法。
    • 如果您必须执行此类按位操作,请仅针对此硬编码的类型列表进行操作,并注意当前计算机的字节序。
      • 基元整型类型ByteSByte、、Int16UInt16Int32UInt32Int64UInt64;
      • Enum由上述基元整型类型之一提供支持;
      • CharInt128UInt128HalfSingleDoubleIntPtrUIntPtr.
  3. ✔️ 请改用逐字段加载/存储(de)序列化。 请考虑使用常见且安全的库来进行序列化和反序列化。

13. Null 托管指针

通常,byrefs(托管指针)通常不是 null,目前创建 null byref 的唯一安全方法是使用 ref structdefault 进行初始化。 然后,其 ref 所有字段则为空管理指针:

RefStructWithRefField s = default;
ref byte nullRef = ref s.refFld;

但是,有多种不安全的方法可以创建 null byrefs。 一些示例包括:

// Null byref by calling Unsafe.NullRef directly:
ref object obj = ref Unsafe.NullRef<object>();

// Null byref by turning a null unmanaged pointer into a null managed pointer:
ref object obj = ref Unsafe.AsRef<object>((void*)0);

引入内存安全问题的风险较低,任何取消引用 null byref 的尝试都会导致定义完善的 NullReferenceException。 但是,C# 编译器 假定 取消引用 byref 始终成功,并且不会产生可观察的副作用。 因此,丢弃结果值时可以合法地省略任何取消引用作为一种优化。 有关 .NET 中现已修复的 bug 的示例,请参阅 dotnet/runtime#98681(以及 此相关注释)。这个 bug 是因为库代码错误地依赖取消引用来触发副作用,却没有意识到 C# 编译器有效地中断了预期逻辑。

Recommendations

  1. ❌ 如果不需要,请勿在 C# 中创建 null byrefs。 请考虑改用普通托管引用、 Null 对象模式或空范围。
  2. ❌ 不要丢弃 byref 取消引用的结果,因为结果可能被优化掉并导致潜在的缺陷。

14. stackalloc

stackalloc 以前一直用于在堆栈上创建小型非转义数组,从而减少 GC 压力。 将来,JIT 的转义分析可能会开始优化数组到堆栈对象的非转义 GC 分配,这可能会导致 stackalloc 冗余。 在此之前, stackalloc 对于在堆栈上分配小缓冲区仍然很有用。 对于较大的缓冲区或转义缓冲区,通常与 ArrayPool<T> 结合使用。

Recommendations

  1. ✔️ DO 始终在表达式左侧将stackalloc合并到ReadOnlySpan<T>/Span<T>中,以提供边界检查。

    // Good:
    Span<int> s = stackalloc int[10];
    s[2] = 0;  // Bounds check is eliminated by JIT for this write.
    s[42] = 0; // IndexOutOfRangeException is thrown
    
    // Bad:
    int* s = stackalloc int[10];
    s[2] = 0;
    s[42] = 0; // Out of bounds write, undefined behavior.
    
  2. ❌ 请勿在循环中使用 stackalloc 。 在方法返回之前,不会回收堆栈空间,因此包括 stackalloc 内部循环可能会导致由于堆栈溢出而导致进程终止。

  3. ❌ 不要对stackalloc使用较大的长度。 例如,1024 字节可以被视为合理的上限。

  4. ✔️ 请检查用作 stackalloc 长度的变量范围。

    void ProblematicCode(int length)
    {
        Span<int> s = stackalloc int[length]; // Bad practice: check the range of `length`!
        Consume(s);
    }
    

    已修复的版本:

    void BetterCode(int length)
    {
        // The "throw if length < 0" check below is important, as attempting to stackalloc a negative
        // length will result in process termination.
        ArgumentOutOfRangeException.ThrowIfLessThan(length, 0, nameof(length));
        Span<int> s = length <= 256 ? stackalloc int[length] : new int[length];
        // Or:
        // Span<int> s = length <= 256 ? stackalloc int[256] : new int[length];
        // Which performs a faster zeroing of the stackalloc, but potentially consumes more stack space.
        Consume(s);
    }
    
  5. ✔️ 请尽可能使用新式 C# 功能(如集合文本(Span<int> s = [1, 2, 3];params Span<T>和内联数组,以避免手动内存管理。

15. 固定大小的缓冲区

固定大小的缓冲区对于与来自其他语言或平台的数据源的互作方案非常有用。 然后,它们被更安全、更方便 的内联数组所取代。 固定大小的缓冲区(需要 unsafe 上下文)的示例如下:

public struct MyStruct
{
    public unsafe fixed byte data[8];
    // Some other fields
}

MyStruct m = new();
ms.data[10] = 0; // Out-of-bounds write, undefined behavior.

新式和更安全的替代方法是 内联数组

[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer
{
    private int _element0; // can be generic
}

public struct MyStruct
{
    public Buffer buffer;
    // Some other fields
}

MyStruct ms = new();
ms.buffer[i] = 0; // Runtime performs a bounds check on index 'i'; could throw IndexOutOfRangeException.
ms.buffer[7] = 0; // Bounds check elided; index is known to be in range.
ms.buffer[10] = 0; // Compiler knows this is out of range and produces compiler error CS9166.

避免固定大小缓冲区而选择默认情况下始终为零初始化的内联数组的另一个原因是,固定大小缓冲区在某些情况下可能具有非零内容。

Recommendations

  1. ✔️ 最好尽可能将固定大小的缓冲区替换为内联数组或 IL 封送属性。

16. 将连续数据作为指针和长度组合形式传递(或依赖零终止方式)

避免定义接受指向连续数据的非托管或托管指针的 API。 请改用 Span<T>ReadOnlySpan<T>

// Poor API designs:
void Consume(ref byte data, int length);
void Consume(byte* data, int length);
void Consume(byte* data); // zero-terminated
void Consume(ref byte data); // zero-terminated

// Better API designs:
void Consume(Span<byte> data);
void Consume(Memory<byte> data);
void Consume(byte[] data);
void Consume(byte[] data, int offset, int length);

零终止尤其有风险,因为并非所有缓冲区都是零终止的,并且读取任何零终止符都可能导致信息泄露、数据损坏或进程终止(通过访问冲突)。

Recommendations

  1. ❌ 当这些参数用于表示缓冲区时,请勿公开其参数为指针类型(非托管指针 T* 或托管指针 ref T)的方法。 使用安全缓冲区类型,例如 Span<T>ReadOnlySpan<T>

  2. ❌ 请勿对 byref 参数使用隐式协定,例如要求所有调用方在堆栈上分配输入。 如果需要此类协定,请考虑改用 ref 结构

  3. ❌ 请勿假定缓冲区是零终止的,除非方案明确记录这是一个有效的假设。 例如,即使 .NET 保证 string 实例和 "..."u8 文本是以 null 结尾的,但对于其他缓冲区类型(如 ReadOnlySpan<char>char[])并不符合此条件。

    unsafe void NullTerminationExamples(string str, ReadOnlySpan<char> span, char[] array)
    {
        Debug.Assert(str is not null);
        Debug.Assert(array is not null);
    
        fixed (char* pStr = str)
        {
            // OK: Strings are always guaranteed to have a null terminator.
            // This will assign the value '\0' to the variable 'ch'.
            char ch = pStr[str.Length];
        }
    
        fixed (char* pSpan = span)
        {
            // INCORRECT: Spans aren't guaranteed to be null-terminated.
            // This could throw, assign garbage data to 'ch', or cause an AV and crash.
            char ch = pSpan[span.Length];
        }
    
        fixed (char* pArray = array)
        {
            // INCORRECT: Arrays aren't guaranteed to be null-terminated.
            // This could throw, assign garbage data to 'ch', or cause an AV and crash.
            char ch = pArray[array.Length];
        }
    }
    
  4. ❌ 不要在没有传递显式长度参数的情况下将固定的 Span<char>ReadOnlySpan<char> 跨越 p/invoke 边界。 否则,p/invoke 边界的另一端的代码可能错误地认为缓冲区以 null 结尾。

unsafe static extern void SomePInvokeMethod(char* pwszData);

unsafe void IncorrectPInvokeExample(ReadOnlySpan<char> data)
{
    fixed (char* pData = data)
    {
        // INCORRECT: Since 'data' is a span and is not guaranteed to be null-terminated,
        // the receiver might attempt to keep reading beyond the end of the buffer,
        // resulting in undefined behavior.
        SomePInvokeMethod(pData);
    }
}

若要解决此问题,请使用同时接受数据指针和长度(如果可能)的备用 p/invoke 签名。 否则,如果接收器无法接受单独的长度参数,请确保在固定之前将原始数据转换为 string 类型,然后将其传递至 p/invoke 界面边界。

unsafe static extern void SomePInvokeMethod(char* pwszData);
unsafe static extern void SomePInvokeMethodWhichTakesLength(char* pwszData, uint cchData);

unsafe void CorrectPInvokeExample(ReadOnlySpan<char> data)
{
    fixed (char* pData = data)
    {
        // OK: Since the receiver accepts an explicit length argument, they're signaling
        // to us that they don't expect the pointer to point to a null-terminated buffer.
        SomePInvokeMethodWhichTakesLength(pData, (uint)data.Length);
    }
    
    // Alternatively, if the receiver doesn't accept an explicit length argument, use
    // ReadOnlySpan<T>.ToString to convert the data to a null-terminated string before
    // pinning it and sending it across the p/invoke boundary.
    
    fixed (char* pStr = data.ToString())
    {
        // OK: Strings are guaranteed to be null-terminated.
        SomePInvokeMethod(pStr);
    }
}

17. 字符串突变

C# 中的字符串在设计上是不可变的,任何使用不安全代码进行变异的尝试都可能导致未定义的行为。 示例:

string s = "Hello";
fixed (char* p = s)
{
    p[0] = '_';
}
Console.WriteLine("Hello"); // prints "_ello" instead of "Hello"

修改驻留字符串(多数 字符常量都是)将更改所有其他用途的值。 即使没有字符串驻留,写入新创建的字符串时也应使用更安全的 String.Create API 来替换。

// Bad:
string s = new string('\n', 4); // non-interned string
fixed (char* p = s)
{
    // Copy data into the newly created string
}

// Good:
string s = string.Create(4, state, (chr, state) =>
{
    // Copy data into the newly created string
});

Recommendations

  1. ❌ 不要改变字符串。 String.Create如果需要复杂的复制逻辑,请使用 API 创建新字符串。 否则,请使用 .ToString()StringBuildernew string(...)字符串内插语法。

18. 原始 IL 代码(例如 System.Reflection.Emit 和 Mono.Cecil)

发出原始 IL(无论是通过System.Reflection.Emit、第三方库(例如Mono.Cecil)还是直接编写 IL 代码)本质上都会绕过 C# 提供的所有内存安全保证。 除非绝对必要,否则请避免使用此类技术。

Recommendations

  1. ❌ 请勿发出原始 IL 代码,因为它没有导轨,因此可以轻松引入类型安全性和其他问题。 与其他动态代码生成技术一样,如果不是在构建时完成,发出原始 IL 代码也不会是 AOT 友好的。
  2. ✔️ 如果可能,请改用源生成器。
  3. ✔️ 如果出现需要,DO 首选 [UnsafeAccessor] 而不是发出原始 IL 来编写私有成员的低开销序列化代码。
  4. ✔️ 如果缺少某些 API,并且你被迫改用原始 IL 代码,请针对 dotnet/runtime 提交 API 建议。
  5. ✔️ 如果必须使用原始 IL,请使用 ilverify 或类似工具来验证发出的 IL 代码。

19. 未初始化的局部变量 [SkipLocalsInit]Unsafe.SkipInit

[SkipLocalsInit] 是在 .NET 5.0 中引入的,允许 JIT 在方法中跳过对局部变量的清零,可以基于单个方法或在模块范围内进行。 此功能通常用于帮助 JIT 消除诸如初始化 stackalloc 之类的冗余零初始化。 但是,如果在使用之前未显式初始化局部变量,则可能会导致未定义的行为。 随着 JIT 消除零初始化和执行向量化能力的最新改进,对 [SkipLocalsInit]Unsafe.SkipInit 的需求显著减少。

Recommendations

  1. ❌ 请勿使用 [SkipLocalsInit]Unsafe.SkipInit 如果观察到热代码中没有性能优势,或者不确定它们引入的风险。
  2. ✔️ 在使用类似 GC.AllocateUninitializedArrayArrayPool<T>.Shared.Rent 的 API 时,这些 API 可能返回未初始化的缓冲区,请采取防御性编程。

20. ArrayPool<T>.Shared 和类似的资源池 API

ArrayPool<T>.Shared 是一个共享数组池,用于减少热代码中 GC 的压力。 它通常用于为 I/O 操作或其他短暂存在的情况分配临时缓冲区。 虽然 API 非常简单,并且本身不包含不安全的功能,但它可能会导致 C# 中的无后用 bug。 示例:

var buffer = ArrayPool<byte>.Shared.Rent(1024);
_buffer = buffer; // buffer object escapes the scope
Use(buffer);
ArrayPool<byte>.Shared.Return(buffer);

进行_buffer调用后,如果仍继续使用Return,则会出现使用后释放错误。 这个最小的示例很容易发现,但当 RentReturn 位于不同的范围或方法中时,bug 会变得更难检测。

Recommendations

  1. ✔️ 尽可能在同一个方法内保留对RentReturn的匹配调用,以缩小潜在 bug 的范围。
  2. ❌除非确信try-finally块中的失败逻辑已完成对缓冲区的使用,否则请勿使用Return模式来调用finally。 最好放弃缓冲区,而不是由于意外的早期 Return而冒无用 bug 的风险。
  3. ✔️ 请注意,其他池化 API 或模式可能会出现类似的问题,例如 ObjectPool<T>

21. bool<->int 转换

虽然 ECMA-335 标准将布尔值定义为 0 至 255,其中任何非零值都由 true 表示,但为了避免引入会导致不可靠行为的"非规范化"值,最好避免在整数和布尔值之间进行任何显式转换,因为只有当值为 0 或 1 时才表现正常。

// Bad:
bool b = Unsafe.As<int, bool>(ref someInteger);
int i = Unsafe.As<bool, int>(ref someBool);

// Good:
bool b = (byte)someInteger != 0;
int i = someBool ? 1 : 0;

早期 .NET 运行时中存在的 JIT 未完全优化此逻辑的安全版本,导致开发人员使用不安全构造在性能敏感代码路径之间 boolint 中转换。 这种情况不再如此,新式 .NET JIT 能够有效地优化安全版本。

Recommendations

  1. ❌ 不要使用不安全代码在整数和布尔值之间编写“无分支”转换。
  2. ✔️ 请改用三元运算符(或其他分支逻辑)。 新式 .NET JIT 将有效地优化它们。
  3. ❌请勿使用不安全的 API(例如boolUnsafe.ReadUnaligned)读取MemoryMarshal.Cast,如果您不信任输入。 请考虑改用三元运算符或相等比较:
// Bad:
bool b = Unsafe.ReadUnaligned<bool>(ref byteData);

// Good:
bool b = byteData[0] != 0;

// Bad:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = MemoryMarshal.Cast<byte, bool>(byteSpan).ToArray();

// Good:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = new bool[byteSpan];
for (int i = 0; i < byteSpan.Length; i++) { boolArray[i] = byteSpan[i] != 0; }

有关详细信息,请参阅 包含填充或非可原生转换成员的结构的二进制(反)序列化

22. 互操作性

虽然本文档中的大多数建议也适用于互作方案,但建议遵循 本机互作性最佳做法 指南。 此外,请考虑使用自动生成的互作包装器,例如 CsWin32CsWinRT。 这样可以最大程度地减少编写手动互作代码的需求,并降低引入内存安全问题的风险。

23. 线程安全

内存安全和线程安全是正交概念。 代码可以是内存安全的,但仍可能包含数据争用、断裂读取或可见性问题;相反,代码可以是线程安全的,但依然可能通过不安全的内存操作导致未定义行为。 有关更广泛的指导,请参阅 托管线程最佳做法.NET 内存模型

24. SIMD/矢量化周围的不安全代码

有关更多详细信息 ,请参阅矢量化指南 。 在不安全代码的上下文中,请务必记住:

  • SIMD操作有复杂的要求来提供原子性保证(有时,它们根本不提供这样的保证)。
  • 大多数 SIMD 加载/存储 API 不提供边界检查。

25. 模糊测试

模糊测试(或称为“fuzzing”)是一种自动化软件测试技术,其中提供无效、意外或随机的数据作为计算机程序的输入。 它提供了一种方法来检测代码中可能存在测试覆盖率差距的内存安全问题。 可以使用 SharpFuzz 等工具为 .NET 代码设置模糊测试。

26. 编译器警告

通常,C# 编译器不提供广泛的支持,例如不附带警告或分析器以处理不正确的不安全代码用法。 但是,有一些现有警告可以帮助检测潜在问题,不应忽略或禁止,而无需仔细考虑。 一些示例包括:

nint ptr = 0;
unsafe
{
    int local = 0;
    ptr = (nint)(&local);
}
await Task.Delay(100);

// ptr is used here

此代码生成警告 CS9123(“不应对异步方法中的参数或局部变量使用”'&'运算符),这意味着代码可能不正确。

Recommendations

  1. ✔️ 请注意编译器警告并修复潜在问题,而不是禁止显示它们。
  2. ❌ 不要假定缺少编译器警告意味着代码正确。 C# 编译器限制为不支持检测不正确的不安全代码使用情况。

References