调试PInvoke导致的内存破坏
缘起
最近项目中遇到一个诡异的问题,程序在升级到 .net4.6.1
后,执行某个功能时会崩溃,提示访问只读内存区。大概规律如下:
-
debug
版不崩溃,
release
版稳定崩溃。 -
只有
x64
位的程序崩溃,
32位
及
anycpu
编译出来的程序运行不会崩溃。 -
出问题的代码范围很小(按钮点击事件代码不多)。
根据以上信息,各位小伙伴有什么思路吗?
排查
由于 release
版可以稳定重现,而且范围不大,故通过二分法(每次注释掉一半代码,看看是否崩溃,如果崩溃,接着注释掉一半代码,如果不崩溃说明崩溃跟注释掉的那段代码有关…)很快定位到了导致问题的代码。
最后发现并不是由于升级 .net
版本导致的,而是程序本身的问题:
代码中通过 P/Invoke
调用了原生 API GlobalMemoryStatus()
。在定义 MemoryStatus
结构体的时候强制按 4
字节定义了每一个字段。而在 x64
下 MemoryStatus
结构体中的成员有些不是 4
字节大小,而是 8
字节大小!这样,传递给 GlobalMemoryStatus()
的 MemoryStatus
参数( 32
字节)比 GlobalMemoryStatus()
预期的( 56
字节)小,导致 GlobalMemoryStatus
写了不该写的内存! :bomb: :bomb: :bomb:
重现
我把有问题的代码独立出来了,完整的测试代码如下(请编译 x64
版本):
using System; using System.Runtime.InteropServices; namespace ConsoleApplication1 { class Program { [StructLayout(LayoutKind.Sequential)] public struct MemoryStatus { [MarshalAs(UnmanagedType.U4)] public uint dwLength; [MarshalAs(UnmanagedType.U4)] public uint dwMemoryLoad; [MarshalAs(UnmanagedType.U4)] public uint dwTotalPhys; [MarshalAs(UnmanagedType.U4)] public uint dwAvailPhys; [MarshalAs(UnmanagedType.U4)] public uint dwTotalPageFile; [MarshalAs(UnmanagedType.U4)] public uint dwAvailPageFile; [MarshalAs(UnmanagedType.U4)] public uint dwTotalVirtual; [MarshalAs(UnmanagedType.U4)] public uint dwAvailVirtual; } [DllImport("kernel32.dll")] public static extern void GlobalMemoryStatus(ref MemoryStatus memoryStatus); class CMyClass { public int n1 = 0; } struct CMyStruct { public CMyClass data; } static void Main(string[] args) { CMyStruct myObj = new CMyStruct(); myObj.data = new CMyClass(); MemoryStatus memoryStatus = new MemoryStatus(); // this line will corrupt the stack if we run in x64. // because memoryStatus is defined on the stack. GlobalMemoryStatus(ref memoryStatus); // myObj.data is corrupted System.Console.WriteLine("{0}", myObj.data); } } }
修复
只需要定义 MemoryStatus
的时候,注意字段的大小即可。正确的 MemoryStatus
定义如下:
public struct MemoryStatus { [MarshalAs(UnmanagedType.U4)] public uint dwLength; [MarshalAs(UnmanagedType.U4)] public uint dwMemoryLoad; // 以下字段 4 bytes on 32-bit Windows, 8 bytes on 64-bit Windows. [MarshalAs(UnmanagedType.SysUInt)] public IntPtr dwTotalPhys; [MarshalAs(UnmanagedType.SysUInt)] public IntPtr dwAvailPhys; [MarshalAs(UnmanagedType.SysUInt)] public IntPtr dwTotalPageFile; [MarshalAs(UnmanagedType.SysUInt)] public IntPtr dwAvailPageFile; [MarshalAs(UnmanagedType.SysUInt)] public IntPtr dwTotalVirtual; [MarshalAs(UnmanagedType.SysUInt)] public IntPtr dwAvailVirtual; }
思考
-
为什么
debug
版不崩溃?而release
版会崩溃?我在测试机器上调查的原因是
debug
版本运行的时候,关键内存恰巧没被破坏(太“幸运”或者太不幸了),而在release
版本中暴露了问题。可能在其它机器上debug
版本也会崩溃或者发生其它诡异的问题。说明:测试代码与项目中的实际代码不一样,有可能现象不一样,但问题的本质是一样的。
-
为什么运行
Any CPU
编译出来的程序不崩溃?当
Platform target
是Any CPU
的时候,在工程属性,Build
下的Prefer 32-bit
的选项默认是勾选的,编译的程序会作为 32 位进程运行,所以不会崩溃。如果取消勾选,则编译出来的程序会作为 64 位应用程序运行,会崩溃。
build settings
Platform target
的作用,具体参考《CLR via C#》,下图是从《CLR via C#》中文版第 4 版上截取的。
/platform option 截自《CLR via C#》
总结
.net
程序中,令人头疼的内存破坏问题很难出现了,这极大的提高了程序的稳定性。如果出现堆破坏,很有可能跟 P/Invoke
或者 unsafe
代码相关,可以重点排查相关代码。
启用托管调试助手( Managed Debugging Assistants
, 下文简称 MDAs
) 有时候会对调试问题有极大的帮助,虽然我这次调试没有借助 MDAs
,但我第一个想到的就是 MDAs
。
关于 MDAs
的介绍请参考 参考资料 第一条 。
参考资料
-
Managed Debugging Assistants [1]
-
GlobalMemoryStatus [2]
-
《CLR via C#》 [3]