调试PInvoke导致的内存破坏

缘起

最近项目中遇到一个诡异的问题,程序在升级到 .net4.6.1 后,执行某个功能时会崩溃,提示访问只读内存区。大概规律如下:

  1. debug 版不崩溃,
    release 版稳定崩溃。
  2. 只有
    x64 位的程序崩溃,
    32位
    anycpu 编译出来的程序运行不会崩溃。
  3. 出问题的代码范围很小(按钮点击事件代码不多)。

根据以上信息,各位小伙伴有什么思路吗?

排查

由于 release 版可以稳定重现,而且范围不大,故通过二分法(每次注释掉一半代码,看看是否崩溃,如果崩溃,接着注释掉一半代码,如果不崩溃说明崩溃跟注释掉的那段代码有关…)很快定位到了导致问题的代码。

最后发现并不是由于升级 .net 版本导致的,而是程序本身的问题:

代码中通过 P/Invoke 调用了原生 API GlobalMemoryStatus() 。在定义 MemoryStatus 结构体的时候强制按 4 字节定义了每一个字段。而在 x64MemoryStatus 结构体中的成员有些不是 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 targetAny 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]