共用方式為


不安全程式碼最佳做法

本文包含針對特定不安全模式、其帶來的風險以及如何減輕這些風險的精細建議。 這些指導方針針對所有在 C# 中撰寫或檢閱不安全程式碼的開發人員。 其他 .NET 語言 (例如 F# 和 Visual Basic) 不在本文的範圍之內,不過某些建議也可能適用於這些語言。

Glossary

  • AVE — 存取違規例外狀況。
  • Byref - 受控指標 (ref T t) 類似於非受控指標,但由 GC 追蹤。 通常指向物件或堆疊的任意部分。 參考實際上是具有 +0 位移的受控指標。
  • CVE - 公開揭露的網路安全漏洞
  • JIT - Just-In-Time 編譯器 (CoreCLR 和 NativeAOT 中的 RyuJIT)。
  • PGO - 設定檔引導式最佳化。
  • 未受控指標 (或原始指標) - 指向任意記憶體位置且不受 GC 管理或追蹤的指標 (T* p)。

如需其他術語,請參閱 .NET 執行階段詞彙表

常見的不可靠模式

C# 提供了一個安全的環境,開發人員無需擔心運行時和 GC 的內部運作。 不安全的程式碼可讓您繞過這些安全檢查,從而可能引入不可靠的模式,從而導致記憶體損壞。 雖然這類模式在某些情況下可能很有用,但您應該謹慎使用它們,並且只有在絕對必要時才使用它們。 C# 和 .NET 不僅不提供驗證不安全程式碼健全性的工具 (各種 C/C++ 清理工具可能提供),GC 特定行為可能會在不安全的 C# 中引入其他風險,而不僅僅是傳統 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位址)後立即中斷方法的x執行,並重新定位引用的對象,則GC將正確更新儲存的位置x,但不會知道任何內容nativePointer,也不會更新它所包含的值。 當執行寫入 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 引數,這些引數應該總是堆疊配置、釘選,或其他方式使得 GC 無法重新定位。 這同樣適用於純物件和 Span,不要在方法簽章中引入關於其生命週期的非明顯呼叫者型合約。 請考慮改為採用 ref struct 參數或將參數變更為原始指標類型 (X*)。
  2. ❌ 如果指標 Unsafe.AsPointer<T>(T) 的生存時間可能比其所指向的原始物件更長久,請勿使用它。 根據 API 的文件,必須由 Unsafe.AsPointer<T>(T) 的呼叫者確保 GC 無法重新定位該引用。 請確定程式代碼檢閱者清楚看到呼叫端已滿足此先決條件。
  3. ✔️ 請使用 GCHandlefixed 作用範圍,而不是 Unsafe.AsPointer<T>(T) 定義非受控指標的明確作用範圍,並確保物件一律固定。
  4. ✔️ 當您需要將陣列與特定界限對齊時,請使用非託管指標 (with fixed) 而不是 byrefs。 這可確保 GC 不會重新定位物件,並使您的邏輯可能依賴的任何對齊假設失效。

2. 將指標暴露在fixed作用域之外

雖然 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. ✔️ 請偏好具有內建逸出分析的安全低階基本類型,例如 C# 的 ref 結構。 如需詳細資訊,請參閱 低階結構改善。

三、運行時和函式庫的內部實作細節

雖然存取或依賴內部實作詳細資料一般是不良做法 (而且 .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 檢查時也相關。 因此,開發人員可以建立無效的非受控指標,只要未解除參照,就不會產生任何後果,但建立任何無效的受管理指標都是錯誤。 範例:

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當 的實際位址array[0]由 GC 更新時,可能會成為外部指標。 這些錯誤很微妙, 甚至 .NET 在開發過程中也遇到了它們

Recommendations

  1. ❌ 請勿建立無效的 Managed 指標,即使它們未取消參考,或位於從未執行的程式碼路徑內。
    • 如需有效受管理指標的組成方式的詳細資訊,請參閱 ECMA-335,第 II.14.4.2 節 受管理指標;和 ECMA-335 CLI 規範附錄,第 II.14.4.2 節。
  2. ✔️ 如果演算法需要這類操作,請使用釘選的非受控指標。

5. 類似 reinterpret 的型別轉換

雖然根據定義,各種結構到類別或類別到結構轉換都是未定義的行為,但結構到結構或類別到類別轉換也可能會遇到不可靠的模式。 不可靠模式的典型範例是下列程式碼:

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. ✔️ 建議優先選擇更安全的逐個欄位的複製、外部程式庫(例如AutoMapper)或 Source Generators 以進行此類轉換。
  4. ✔️ 請更偏好使用 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;
    }
}

同樣地,下列具有 Managed 指標的程式碼也不可靠:

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 中的第 0 層和第 1 層)之間有所不同。 終結器是這類假設可能不正確的常見案例。

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 完成之前,或甚至在其開始之前被呼叫。 因此,重要的是不要假設物件(例如 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 記憶體模型中所述。

範例:當另一個執行程讀取結構時,包含 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. ✔️ 請提供 JIT 的其他提示,例如在迴圈之前手動檢查界限,並將欄位儲存至本機,因為 .NET 記憶體模型 可能會保守地防止 JIT 在某些情況下移除界限檢查。
  4. ✔️ DO 使用 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 不會提供任何完整性檢查,但可能有助於偵測偵錯版本中的潛在錯誤。

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 程式碼生成是:

; 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 執行階段依賴 OS 來修正未對齊的存取,但在某些平臺上仍有某些案例,未對齊的存取可能會導致 DataMisalignedException (或 SEHException)。 一些例子包括:

  • Interlocked 在某些平台上對未對齊的記憶體進行操作(範例)。
  • ARM 上的浮點運算未對齊。
  • 存取具有特定對齊需求的特殊裝置記憶體 (.NET 並不真正支援) 。

Recommendations

  1. ❌ 不要在無鎖定演算法和其他原子性很重要的情況中使用未對齊的記憶體存取。
  2. ✔️ 如有必要,請手動對齊數據,但請記住,GC 可以隨時重新定位對象,從而有效地動態更改對齊方式。 這對於 SIMD 中的各種 StoreAligned/LoadAligned API 尤其重要。
  3. ✔️ 請使用明確未對齊的讀取/寫入 API,例如,Unsafe.ReadUnaligned/Unsafe.WriteUnaligned而不是對齊的Unsafe.Read<T>(Void*)/Unsafe.Write<T>(Void*, T) API,例如 或 Unsafe.As<TFrom,TTo>(TFrom) 如果資料可能未對齊。
  4. ✔️ 請記住,各種記憶體操作 API(例如 Span<T>.CopyTo(Span<T>))也不會提供原子性保證。
  5. ✔️ 請務必參閱 .NET 記憶體模型 檔案(請參閱參考資料),以獲得有關原子性保證的詳細資訊。
  6. ✔️ 請務必測量所有目標平台的效能,因為某些平台會對未對齊的記憶體存取施加重大效能損失。 你可能會發現,在這些平台上,樸素的程式碼比聰明的程式碼表現更好。
  7. ✔️ 請記住,在某些情況下,未對齊的記憶體存取可能會導致例外狀況。

12. 具有填充或不可直接複製成員的結構體的二進位序列化與反序列化

當您使用各種類似序列化的 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. ❌ 請勿使用不安全程式碼來複製/載入/比較包含填補或不可直接存取成員的結構。 來自不受信任輸入的負載即使對於像booldecimal這樣的基本類型也會有問題。 同時,存儲可能會意外地序列化結構的空隙/填充中堆疊中的敏感資訊。
  2. ❌ 請勿依賴 T : unmanaged 條件約束 、 RuntimeHelpers.IsReferenceOrContainsReferences或類似的 API 來保證泛型類型可以安全地執行位元運算。 在撰寫這些指導方針時,沒有可靠的程式設計方式來判斷對指定類型執行任意位元運算是否合法。
    • 如果您必須執行這類位元操作,請只針對此硬式編碼類型清單執行,並注意目前機器的字節碼序:
      • 原始積分類型 ByteSByteInt16UInt16Int32UInt32Int64、 和 ;UInt64
      • 以上述原始整數類型之一為基礎的 Enum
      • CharInt128、、UInt128HalfSingleDoubleIntPtrUIntPtr
  3. ✔️ 請改用逐欄位載入/儲存 (反)序列化。 考慮使用流行且安全的函式庫進行(反)序列化。

13. 空值託管指標

一般來說,byrefs(管理指標)很少為空,目前創建空 byref 的唯一安全方法是用ref struct初始化default。 然後它的所有 ref 欄位都是 null 管理指標:

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

不過,有幾種不安全的方法可以建立空 byref。 一些範例包括:

// 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 中現已修正的錯誤範例,請參閱 dotnet/runtime#98681(和 此相關註解)。這個錯誤涉及到程式庫程式碼不正確地依賴於觸發副作用的解參照,而未注意到 C# 編譯器已有效地截斷了預期的邏輯。

Recommendations

  1. ❌ 如果不需要,請勿在 C# 中建立 Null byref。 請考慮改用一般受管理參照、 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 struct

  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. ✔️ 如果需要,請偏好 [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 時,請進行防禦性編碼,因為它們可能會返回未初始化的緩衝區。

20. ArrayPool<T>.Shared 和類似的池化 API

ArrayPool<T>.Shared 是一個共享的陣列池,用於降低熱程式碼中的 GC 壓力。 它通常用於為 I/O 操作或其他短期場景分配臨時緩衝區。 雖然 API 很簡單,本質上不包含不安全的功能,但它可能會導致 C# 中出現釋放後使用錯誤。 範例:

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

任何在呼叫 _buffer 之後使用 Return 都是使用後釋放錯誤。 這個最小的範例很容易發現,但當 Bug 處於Rent不同的範圍或方法時,會變得更難偵測Return

Recommendations

  1. 如果可能的話,請將RentReturn的匹配調用保留在同一方法中,以降低潛在錯誤的範圍。
  2. ❌ 不要使用 try-finally 模式來呼叫 Return 區塊, finally 除非您確信失敗的邏輯已完成使用緩衝區。 最好放棄緩衝區,而不是冒著因意外的早期 Return 而出現釋放後使用漏洞的風險。
  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.ReadUnalignedMemoryMarshal.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; }

如需更多資訊,請參閱 包含填充或非blittable成員的結構體的二進位序列化和反序列化

22. 互操作

雖然本檔案中的大部分建議也適用於互通性案例,但建議遵循 原生互通性最佳做法 指南。 此外,請考慮使用自動產生的互通包裝函式,例如 CsWin32CsWinRT。 這可將您撰寫手動互通程式代碼的需求降到最低,並降低引入記憶體安全問題的風險。

23. 執行緒安全

記憶體安全和執行緒安全是各自獨立、互不影響的概念。 程式碼可以是記憶體安全的,但仍包含資料競爭、讀取中斷或可見性錯誤;相反地,程式碼可以是執行緒安全的,同時仍會透過不安全的記憶體操作來叫用未定義的行為。 如需更廣泛的指引,請參閱 受控執行緒最佳做法.NET 記憶體模型

24. SIMD/向量化周圍的不安全程式碼

如需詳細資訊,請參閱 向量化指導方針 。 在不安全程式碼的內容中,請務必記住:

  • SIMD 操作有複雜的要求來提供原子性保證(有時,他們根本不提供這些保證)。
  • 大部分的 SIMD 載入/存放 API 都不提供界限檢查。

25. 模糊測試

模糊測試(或「模糊測試」)是一種自動化軟體測試技術,涉及提供無效、意外或隨機資料作為電腦程式的輸入。 它提供了一種方法來檢測程式碼中可能存在測試覆蓋率差距的記憶體安全問題。 您可以使用 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# 編譯器僅限於不支援偵測不正確的不安全程式碼使用方式。

參考資料