CodeGym /課程 /C# SELF /在 C# 中的低階記憶體管理

在 C# 中的低階記憶體管理

C# SELF
等級 65, 課堂 1
開放

1. 介紹

在前一堂課我們談到 C# 與 .NET Runtime 如何替你處理大部分的記憶體管理責任。不過,有時你會需要或想要對記憶體做更 低階的控制,特別是為了優化效能或與非管理(native)程式碼互動(例如使用 C++ 撰寫的系統 API)。C# 提供了為了這些目的的一些功能,雖然它們很強大,但需要謹慎使用。

當我們在 C# 中談「低階的記憶體管理」時,通常指的是:

  • 直接操作指標:像在 C/C++ 一樣透過位址存取記憶體。
  • 在 GC 堆以外分配記憶體:使用 GC 不會追蹤的記憶體。
  • 管理在管理/非管理程式碼邊界的資源:與原生函式庫做有效率的互動。

在 C# 中,預設的程式碼不允許直接操作記憶體位址(指標),以確保安全性與穩定性。然而針對低階操作,可以在 unsafe 上下文中使用指標。

什麼是 unsafe 上下文?

被關鍵字 unsafe 標記的方法或區塊,允許你使用指標語法並執行不受 CLR 安全檢查的操作。unsafe 上下文中的程式碼在安全性上並不比原生程式碼更安全。要使用 unsafe 程式碼,專案必須使用 /unsafe 選項來編譯。

範例:宣告 unsafe 方法與區塊


public unsafe class UnsafeExamples
{
    // Unsafe 方法
    public static unsafe void ManipulatePointer(int* ptr)
    {
        Console.WriteLine($"指標所指的值: {*ptr}");
        *ptr = 200; // 變更位址上的值
    }

    public static void DemoUnsafeBlock()
    {
        int value = 100;
        unsafe // 在一般方法內的 unsafe 區塊
        {
            int* ptr = &value; // 取得變數的位址
            ManipulatePointer(ptr); // 將指標傳入 unsafe 方法
            Console.WriteLine($"新值: {value}"); // 輸出: 新值: 200
        }
    }
}

指標類型

在 C# 可以宣告對值型別的指標(int*、bool*、MyStruct*)以及對 void 的指標(void* 作為通用指標)。不能直接宣告對參考型別的指標,但如果參考型別的欄位是值型別,則可以取得該欄位的指標。

範例:不同類型的指標


unsafe
{
    double d = 123.45;
    double* dPtr = &d; // 指向 double 的指標

    int[] numbers = { 1, 2, 3 };
    fixed (int* arrPtr = numbers) // 'fixed' 將物件釘在記憶體,防止 GC 移動它
    {
        Console.WriteLine($"陣列的第一個元素: {arrPtr[0]}");
        Console.WriteLine($"陣列的第二個元素: {*(arrPtr + 1)}");
    }
}

指標運算子

  • & (位址):取得變數的位址。
  • * (解參照):取得位址上的值。
  • -> (存取成員):透過指向結構/類別的指標存取其成員(僅限值型別)。
  • [] (索引):像 C/C++ 一樣透過指標存取陣列元素。

2. 釘住與分配記憶體區塊

當你使用指標時,確保指標所指向的物件在你操作期間不會被 GC 移動是很重要的。為此可以使用關鍵字 fixed 與 stackalloc。

運算子 fixed

運算子 fixed 可以將參考型別的變數「釘住」在記憶體中,防止它在 fixed 區塊執行時被 GC 移動。這在你要把指標傳給非管理程式碼或直接操控它們時非常重要。

範例:對陣列使用 fixed


public unsafe class FixedExample
{
    public static void ProcessFixedArray()
    {
        byte[] buffer = new byte[10];
        // 填充緩衝區
        for (int i = 0; i < buffer.Length; i++)
        {
            buffer[i] = (byte)(i + 1);
        }

        unsafe
        {
            // 將陣列釘在記憶體中,取得第一個元素的指標
            fixed (byte* p = buffer)
            {
                // 現在可以安全地使用 p,因為陣列在此期間不會被移動
                Console.WriteLine($"第一個位元組的值: {*p}");
                Console.WriteLine($"第二個位元組的值: {*(p + 1)}");
                // 可以將 'p' 傳給期待指標的原生函式
            } // 離開 'fixed' 區塊後,陣列可能會被 GC 移動
        }
    }
}

fixed 也可以用在結構的欄位或字串上。

在堆疊上分配記憶體:stackalloc

stackalloc 允許在堆疊上分配一段記憶體。這非常快速,但記憶體只在當前方法期間可用。這段記憶體不受 GC 管理。

  • 優點:分配非常快、沒有 GC 開銷、記憶體釋放是確定性的。
  • 缺點:大小受限(堆疊相對較小),過大分配可能導致堆疊溢位(StackOverflowException)。
  • 使用情境:非常適合小的、短暫存在的緩衝區。

範例:使用 stackalloc


public unsafe class StackAllocExample
{
    public static void ProcessStackAlloc()
    {
        unsafe
        {
            // 在堆疊上分配 10 個 int
            int* numbers = stackalloc int[10];

            for (int i = 0; i < 10; i++)
            {
                numbers[i] = i * 10;
            }

            Console.WriteLine($"第一個元素的值: {numbers[0]}");
            Console.WriteLine($"第五個元素的值: {numbers[4]}");
        } // 這個方法結束時記憶體會自動被釋放。
    }
}

運算子 stackalloc 可以與 Span<T> 一起使用,這使得對這類記憶體的操作更安全,因為 Span<T> 是一個受管理的型別(struct),提供對連續記憶體區塊的安全存取。關於 Span 的更多內容會在下一堂課詳述。

範例:用 stackalloc 與 Span<T>


using System;

public class StackAllocWithSpan
{
    public static void DemoSpanStackAlloc()
    {
        // 在堆疊上分配,但透過安全的 Span
  
    操作 Span
   
     buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Span 的第一個元素: {buffer[0]}"); Console.WriteLine($"Span 的最後一個元素: {buffer[9]}"); // 可以將 Span
    
      傳入接受它的方法 PrintSpan(buffer); } public static void PrintSpan(Span
     
       s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } } 
     
    
   
  

這是一種混合方式:在堆疊上分配(低階),但透過 Span<T>(高階)來安全存取。

3. 與非管理程式碼互動(P/Invoke)

Platform Invoke (P/Invoke) 是一個機制,允許 C# 程式呼叫非管理函式庫(例如 Windows API 的 DLL 或 Linux 的 .so 檔案)。這是低階記憶體管理的一個重要面向,因為你經常需要在管理與非管理世界之間傳遞資料的指標。

宣告外部函式

你會使用屬性 DllImport 來宣告對應到原生函式的靜態 extern 方法。

範例 3.1:呼叫 Windows API 函式


using System.Runtime.InteropServices;

public class PInvokeExample
{
    // 從 user32.dll 匯入原生函式 MessageBox
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    public static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    public static void ShowMessageBox()
    {
        // 呼叫原生函式
        MessageBox(IntPtr.Zero, "從 C# 傳來的問候!", "視窗標題", 0);
    }
}

資料的 Marshal(封送)

呼叫原生函式時,.NET Runtime 會執行 封送(marshaling) —— 將資料型別在管理與非管理格式之間轉換。例如,C# 的 string 可能會被封送為 C++ 的 char* 或 wchar_t*。

對於更複雜的封送可以使用屬性 MarshalAs 與類別 Marshal。

範例:結構的封送


using System.Runtime.InteropServices;

// 這個結構會被封送成原生結構
[StructLayout(LayoutKind.Sequential)] // 指定欄位要順序排列
public struct NativePoint
{
    public int X;
    public int Y;
}

public class StructMarshalExample
{
    [DllImport("your_native_lib.dll")] // 範例:原生函式庫中的函式
    public static extern void ProcessPoint(NativePoint point);

    [DllImport("your_native_lib.dll")]
    public static extern void FillPoint(out NativePoint point); // 接受指向結構的參考

    public static void DemoStructMarshal()
    {
        NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
        ProcessPoint(myPoint); // 結構會以值方式封送(複本)

        NativePoint resultPoint;
        FillPoint(out resultPoint); // 原生函式會填入結構
        Console.WriteLine($"從原生得到的點: ({resultPoint.X}, {resultPoint.Y})");
    }
}

4. GCHandle 與釘住物件

GCHandle 是一個結構,允許你取得管理堆中物件的「handle」,並在需要時暫時釘住它,防止被移動或回收。當你需要把一個穩定的指標傳給非管理程式碼時,這非常有用。

範例:使用 GCHandle 釘住物件


using System.Runtime.InteropServices;

public class GCHandleExample
{
    public static void PinObject()
    {
        byte[] data = new byte[100];
        GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // 將陣列釘在記憶體
        try
        {
            IntPtr pointer = handle.AddrOfPinnedObject(); // 取得釘住物件的位址
            Console.WriteLine($"釘住的陣列位址: {pointer:X}");

            // 現在可以安全地把 'pointer' 傳給原生函式。
            // 原生函式可以直接操作這段記憶體。

            Marshal.WriteByte(pointer, 0, 255); // 透過位址改變第一個位元組
            Console.WriteLine($"陣列的第一個位元組: {data[0]}"); // 輸出: 255
        }
        finally
        {
            if (handle.IsAllocated)
            {
                handle.Free(); // 釋放 handle,讓 GC 再次管理物件
            }
        }
    }
}

對 P/Invoke 與 GCHandle 的深入探討超出本課程範圍,但如果你想直接呼叫 Windows 函式庫,它們會非常有用。至少你現在知道要從哪裡開始往下挖。

2
任務
C# SELF, 等級 65, 課堂 1
上鎖
使用 stackalloc 在堆疊上分配記憶體
使用 stackalloc 在堆疊上分配記憶體
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION