1. 介绍
在上一讲我们说到 C# 和 .NET Runtime 会替你处理大部分内存管理的事。但有时候你需要或者想要更低层次的内存控制,尤其是为了性能优化或者和非托管代码交互(比如用 C++ 写的系统 API)。C# 提供了一些用于这些场景的特性,虽然它们很强,但用起来要小心。
当我们说在 C# 中的“低级内存管理”时,通常指:
- 直接使用指针:按地址访问内存,就像在 C/C++ 里一样。
- 在 GC 堆之外分配内存:使用垃圾回收器不追踪的内存。
- 管理托管/非托管边界的资源:高效地和本地库交互。
普通的 C# 代码为了安全和稳定,不允许直接操作内存地址(指针)。但为了低级操作,可以在 unsafe 上下文中使用指针。
什么是 unsafe 上下文?
被 unsafe 标记的块或方法允许你使用指针语法并执行 CLR 不会验证安全性的操作。unsafe 里的代码跟本地代码一样不受 CLR 的完全保护。要使用 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 块执行期间被垃圾回收器移动。这在把指针传给非托管代码或直接操作托管对象时至关重要。
示例:对数组使用 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 开销,释放是确定性的。
- 缺点:大小受限(栈比较小),分配太多可能导致栈溢出(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> 是一个托管类型(结构体),提供对连续内存块的安全访问。关于 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 是个结构体,允许你获得一个托管堆上对象的“句柄”,并在需要时临时把它固定,防止被移动或回收。当你需要给非托管代码传一个稳定的指针到托管对象时,这很有用。
示例:用 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(); // 释放句柄,让 GC 可以再次管理对象
}
}
}
}
对 P/Invoke 和 GCHandle 的深入讲解超出本课程范围,但它们在你需要直接调用 Windows 库时会很有用。至少现在你知道该往哪个方向继续学习。
GO TO FULL VERSION