C#运行时的托管内存由垃圾回收器统一管理,但这并不意味着所有数据都躺在托管堆上。与C/C++动态库交互时,经常会拿到IntPtr类型的原始地址,它可能指向malloc分配的内存、COM对象内部缓冲区,或者操作系统返回的虚拟内存页。如果直接把这个指针当作托管数组使用,既无法通过GC访问,也难以利用索引语法。理解托管与非托管内存的边界,是正确读取这类数据的前提。

本文会从非托管内存的基本读写开始,逐步介绍Marshal类、unsafe指针以及跨进程读取接口。无论你是做硬件SDK接入、自动化工具开发,还是排查原生库返回的数据异常,这些方法都能派上用场。
一、先弄清楚托管内存和非托管内存的区别
托管代码创建的对象一般由CLR分配在托管堆上,垃圾回收器会负责整理和移动这些对象。对于普通C#数组来说,我们不需要关心它的实际地址,因为GC会在必要时调整它的位置。但非托管内存完全不受CLR管理,它通常来自Marshal.AllocHGlobal、VirtualAlloc、C库的malloc,或者由非托管DLL返回的原始IntPtr。这些内存不会自动释放,也不会被GC移动,因此可以直接通过地址读取。
很多读取异常正是因为没有区分这两种内存。例如把一个非托管指针错误地当成托管数组的引用去访问,或者反过来在非托管回调中保存了托管对象的地址,却没有固定该对象。针对托管对象,如果确实需要拿到固定地址,可以使用fixed语句或GCHandle.Alloc的GCHandleType.Pinned,但这不属于常规读取非托管内存的场景。下面先看一段最基础的非托管内存写入和读回代码。
using System;
using System.Runtime.InteropServices;
class Program
{
static void Main()
{
IntPtr buffer = Marshal.AllocHGlobal(100);
try
{
Marshal.WriteInt32(buffer, 0, 42);
Marshal.WriteByte(buffer, 4, 0x7F);
int intValue = Marshal.ReadInt32(buffer, 0);
byte byteValue = Marshal.ReadByte(buffer, 4);
Console.WriteLine($"{intValue}, {byteValue}");
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
}
这段代码没有任何unsafe指针,却完成了对非托管内存的写入和读取。Marshal.WriteInt32会把数据写到IntPtr指定的偏移处,Marshal.ReadInt32再把它读回来。偏移量从0开始,与C语言中的指针地址偏移一致。这种方式适合访问数量不多、类型明确的内存区域。
二、用Marshal类读取非托管内存中的结构体
如果非托管内存里存放的是结构体或者结构体数组,Marshal.PtrToStructure和Marshal.SizeOf是更直接的工具。它们可以在IntPtr和托管对象之间做转换,同时处理布局、对齐和字节序等细节。需要注意的是,目标结构体必须用StructLayout标注,通常是LayoutKind.Sequential,以保证字段顺序与C/C++结构一致。
以下示例模拟一个非托管DLL返回两个Point结构体的场景。通过计算每个元素的大小,再用指针偏移依次读取,可以避免一次性复制整个缓冲区。
using System;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
struct Point
{
public int X;
public int Y;
}
class Program
{
static void Main()
{
IntPtr ptr = nativeGetPoints();
try
{
for (int i = 0; i < 2; i++)
{
IntPtr p = IntPtr.Add(ptr, i * Marshal.SizeOf<Point>());
Point point = Marshal.PtrToStructure<Point>(p);
Console.WriteLine(point.X + "," + point.Y);
}
}
finally
{
nativeFree(ptr);
}
}
[DllImport("native.dll")]
static extern IntPtr nativeGetPoints();
[DllImport("native.dll")]
static extern void nativeFree(IntPtr ptr);
}
这里Marshal.SizeOf<Point>()返回该结构在非托管内存中的实际大小,IntPtr.Add负责做指针偏移。对于包含数组、字符串或指针字段的复杂结构,还需要配合MarshalAs特性明确长度和类型,否则读取结果可能不完整或引发访问违例。
三、启用unsafe并用指针直接读取
有些高性能场景下,逐次调用Marshal.ReadInt32会产生额外的平台调用开销。此时可以开启C#的unsafe上下文,直接把IntPtr转换为原生指针。需要在项目文件中把<AllowUnsafeBlocks>true</AllowUnsafeBlocks>加入PropertyGroup,然后在代码中使用unsafe关键字。
原生指针的优势在于可以通过+、*等操作符进行地址计算和取值,代码风格更接近C语言。下面这段程序分配16字节非托管内存,用int*写入两个整数,再用byte*写入一个字节,最后从同一段内存中读取。
using System;
using System.Runtime.InteropServices;
class Program
{
unsafe static void Main()
{
IntPtr buffer = Marshal.AllocHGlobal(16);
try
{
int* p = (int*)buffer;
*p = 100;
*(p + 1) = 200;
byte* pb = (byte*)(p + 2);
*pb = 0xAA;
int first = *p;
int second = *(p + 1);
byte third = *pb;
Console.WriteLine(first + "-" + second + "-" + third);
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
}
指针运算完全绕过了Marshal的封装,因此在频繁读取大量数据时更加高效。但它也把内存安全的责任交给了开发者:越界、野指针、错误对齐都可能直接导致进程崩溃。建议只在明确知道内存布局并且确实需要性能优化时使用。
四、跨进程读取内存:ReadProcessMemory
除了读取本进程的非托管内存,有时还需要读取另一个进程的地址空间,例如调试器、性能分析工具或自动化程序。Windows提供了ReadProcessMemory这一原生API,C#可以通过DllImport调用它。使用前需要打开目标进程并获得足够的权限,通常PROCESS_VM_READ就足够。
ReadProcessMemory的缓冲区是字节数组,这意味着你可以先按字节读取,再根据偏移手动解析整数、浮点数或结构体。下面的类封装了字节读取过程,读取失败时抛出Win32Exception。
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
class ProcessMemoryReader
{
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
byte[] lpBuffer,
int dwSize,
out int lpNumberOfBytesRead);
public static byte[] ReadBytes(Process process, IntPtr address, int size)
{
byte[] buffer = new byte[size];
int bytesRead;
bool ok = ReadProcessMemory(
process.Handle,
address,
buffer,
size,
out bytesRead);
if (!ok)
{
throw new System.ComponentModel.Win32Exception(
Marshal.GetLastWin32Error());
}
return buffer;
}
}
跨进程读取很容易触发权限和稳定性问题。目标进程的地址必须在当前会话中可访问,受保护的系统进程或存在反调试机制的程序会拒绝读取。实践中应当仅用于自己开发或授权的程序,并做好异常处理和超时控制。
五、固定托管对象与内存对齐的注意点
有时候非托管代码希望直接使用托管数组,例如把byte[]传给音频采集SDK。由于GC可能移动对象,必须使用fixed语句暂时固定它。下面代码把managed数组固定后,通过指针填充数据,退出fixed块后GC恢复管理。
using System;
using System.Runtime.InteropServices;
class Program
{
unsafe static void Main()
{
byte[] managed = new byte[64];
fixed (byte* p = managed)
{
for (int i = 0; i < managed.Length; i++)
{
p[i] = (byte)(i * 2);
}
}
Console.WriteLine(managed[10]);
}
}
内存对齐是另一个容易忽略的问题。在64位平台上,指针长度是8字节,IntPtr的大小也会随之变化。如果结构体包含long、double或指针字段,C#默认会按照自然对齐方式排列,但C/C++库可能使用#pragma pack改变对齐规则。这时候需要给StructLayout指定Pack参数,否则Marshal.PtrToStructure读到的字段位置会偏移错误。
至于性能,连续读取大块数据时,可以优先考虑Span<T>和MemoryMarshal。它们既能提供类似数组的索引访问,又可以避免复制和额外的平台调用。不过创建指向非托管内存的Span通常仍需要unsafe上下文,所以本质上是对指针操作的进一步封装。
六、选择合适的读取方案
如果只是偶尔读取几个整数或字节,Marshal.ReadInt32、Marshal.ReadByte最省事,不需要项目开启unsafe。如果需要读取结构体或结构体数组,Marshal.PtrToStructure配合StructLayout是标准做法。当数据量大、访问频繁,或者需要灵活做地址运算时,unsafe指针能提供最高性能,但要求开发者对内存布局有准确把控。
跨进程场景则必须依赖ReadProcessMemory或更高层的内存读取组件。这类操作已经超出了普通非托管内存读取的范畴,涉及操作系统安全模型,不能简单用Marshal替代。无论选择哪种方案,都要牢记释放非托管内存、固定托管对象、校验偏移和大小,避免悬空指针和访问越界。