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 函式庫,它們會非常有用。至少你現在知道要從哪裡開始往下挖。
GO TO FULL VERSION