记录一次使用 WinDbg 排查程序退出时 GDI+ 对象内存二次释放问题

由Jeza Chen 发表于 June 26, 2026

背景

一个单例持有一个 GDI+ 对象的指针。在其生命周期末期(应用程序退出阶段、单例析构过程中)对该指针执行 delete 时发生 crash,报错 Access violation - code c0000005

怀疑是二次释放导致的 crash,但排查后发现该指针此前并未被显式 delete,问题显得很诡异。

排查过程

  1. 首先在VS/WinDbg中对crash现场进行分析:delete 这个指针的时候,该内存区域确实已经失效(全部为?)。

    指针对应内存区域的截图

  2. 第一次尝试:在 Visual Studio 中对该指针对应的内存地址设置数据断点,但未能命中

  3. 第二次尝试:使用 WinDbg 调试,输入命令 ba w4 <指针地址> 试图对该内存的写操作设置断点,但仍未命中,最终还是触发 crash。

  4. 经过两次尝试,怀疑并非是自己的代码写坏了内存,更可能是堆内存已被回收。(很多堆分配器会在用户可见内存前后放置元数据,并不会真正改写被监控的内存区域,因此数据断点无法命中;但后续执行 delete 时,堆管理器会发现堆的元数据已被破坏,从而触发 crash)

  5. 在 VS 中查看 delete operator 的定义后发现,它并不是 CRT 自带的 operator,而是由 GDI+ 自行定义的,最终会调用其内部的 GdipFree(void* ptr) 函数

     class GdiplusBase
     {
     public:
         void (operator delete)(void* in_pVoid)
         {
            DllExports::GdipFree(in_pVoid);
         }
         void* (operator new)(size_t in_size)
         {
            return DllExports::GdipAlloc(in_size);
         }
         void (operator delete[])(void* in_pVoid)
         {
            DllExports::GdipFree(in_pVoid);
         }
         void* (operator new[])(size_t in_size)
         {
            return DllExports::GdipAlloc(in_size);
         }
     };
    
  6. 第三次尝试:在 WinDbg 中用 x Gdiplus!* 找到 GdipFree 的符号,并用 bp 为其设置断点,但在 crash 之前还是未命中。由此猜测:程序退出前,GDI+ 的某个阶段可能会统一释放其申请的所有堆内存

  7. 第四次尝试:使用 !address <指针值> 获取对应内存页的信息,并尝试在 Base Address 上设置数据断点。但仍未命中,程序依旧直接 crash;且 crash 后该页已被回收。

    释放前的内存页信息:

     0:008> !address 20227690810
        
                                             
     Mapping file section regions...
     Mapping module regions...
     Mapping PEB regions...
     Mapping TEB and stack regions...
     Mapping heap regions...
     Mapping page heap regions...
     Mapping other regions...
     Mapping stack trace database regions...
     Mapping activation context regions...
        
     Usage:                  <unknown>
     Base Address:           00000202`27690000
     End Address:            00000202`2769f000
     Region Size:            00000000`0000f000 (  60.000 kB)
     State:                  00001000          MEM_COMMIT
     Protect:                00000004          PAGE_READWRITE
     Type:                   00020000          MEM_PRIVATE
     Allocation Base:        00000202`27690000
     Allocation Protect:     00000004          PAGE_READWRITE
    

    释放后的内存页信息:

     0:000> !address 20227690810
        
                                             
     Mapping file section regions...
     Mapping module regions...
     Mapping PEB regions...
     Mapping TEB and stack regions...
     Mapping heap regions...
     Mapping page heap regions...
     Mapping other regions...
     Mapping stack trace database regions...
     Mapping activation context regions...
        
     Usage:                  Free
     Base Address:           00000202`27661000
     End Address:            00000202`276a0000
     Region Size:            00000000`0003f000 ( 252.000 kB)
     State:                  00010000          MEM_FREE
     Protect:                00000001          PAGE_NOACCESS
     Type:                   <info not present at the target>
    
  8. 第五次尝试:监控 Win32 系统内部 API NtFreeVirtualMemory 的调用;每次命中时打印其参数(Base Address、大小等)。

    该函数在 x64 下的调用约定为:

     NtFreeVirtualMemory(
         HANDLE  ProcessHandle,  // rcx
         PVOID*  BaseAddress,    // rdx
         PSIZE_T RegionSize,     // r8
         ULONG   FreeType        // r9
     );
    

    设置一个打印型断点:

     bp ntdll!NtFreeVirtualMemory ".if ((@r9d & 0x8000) != 0) {.printf \"MEM_RELEASE: Base=%p Size=%p Ret=%p\n\", poi(@rdx), poi(@r8), poi(@rsp); kb; gc; } .else { gc }"
    

    拿到指针的值后,用 !address 得到对应的 Base Address,然后退出程序(此时会触发 crash)。观察 Command 面板输出,发现这块内存在退出阶段确实被抢先一步释放了:

     MEM_RELEASE: Base=00000223f6b90000 Size=0000000000000000 Ret=00007ffadb260b5f  # RetAddr               : Args to Child                                                           : Call Site
     00 00007ffa`db260b5f     : 00000223`f1080000 00000000`00000000 00000000`00000000 00000223`f1082480 : ntdll!NtFreeVirtualMemory
     01 00007ffa`db26062a     : 00000223`f6b90000 000000be`c394f958 00000000`7ffe0388 00000223`f1080000 : ntdll!RtlpSecMemFreeVirtualMemory+0x2f
     02 00007ffa`db2602e1     : 00000000`00000000 00000223`f6b90000 00000223`f6b90000 00000223`f1080110 : ntdll!RtlpDestroyHeapSegment+0x5e
     03 00007ffa`d8b09f5b     : 00000000`00000000 00000000`00000000 00000000`000c3000 00000223`f88f0000 : ntdll!RtlDestroyHeap+0x101
     04 00007ffa`ae298d17     : 00000000`00000000 00000000`000003b0 00000000`00000000 00000000`00000000 : KERNELBASE!HeapDestroy+0xb
     05 00007ffa`ae2780bb     : 00000000`00000000 000000be`c394fab9 00000000`00000000 00000000`00000000 : gdiplus!InternalGdiplusShutdown+0x60f
     06 00007ff6`9f9e3e5f     : 00000223`f0e8c630 00000000`00000000 00007ff6`9fa0db58 00000000`06cf0000 : gdiplus!GdiplusShutdown+0x9b
     07 00007ff6`9fa07be2     : 00000000`0000000a 00000000`00000000 00000000`00000000 00000000`00000000 : DuilibDemo!wWinMain+0x243 [D:\zoom-src-billable-hours-6x\win-common\test\DuilibDemo\DuilibDemo.cpp @ 126] 
     08 (Inline Function)     : --------`-------- --------`-------- --------`-------- --------`-------- : DuilibDemo!invoke_main+0x21 [D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 118] 
     09 00007ffa`d987e957     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : DuilibDemo!__scrt_common_main_seh+0x106 [D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288] 
     0a 00007ffa`db2a7c1c     : 00000000`00000000 00000000`00000000 000004f0`fffffb30 000004d0`fffffb30 : KERNEL32!BaseThreadInitThunk+0x17
     0b 00000000`00000000     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x2c
    

    也就是说,GDI+ 的 GdiplusShutdown 会释放其申请的堆内存。此时单例持有的 Bitmap指针所指向的内存区域已经失效,随后再执行 delete 就会触发二次释放,最终导致 crash。

总结

  • 程序退出阶段的内存释放操作一定要谨慎,尤其是在持有第三方模块对象指针时:其 new/delete operator 可能被重载。
  • 如果确实需要持有第三方模块的对象指针,在卸载该模块时务必确认是否还有其他地方持有该模块的对象,尤其要关注对象的内存管理是否(不经意地)被该模块接管。
  • Gdi+ 对象涉及的动态内存由其自行管理,delete 时要特别谨慎,尤其不要在 Gdi+ 模块释放后再 delete 相关对象。因为模块释放时会在内部将其申请的堆内存统一清理掉。

案例代码

由于涉及公司内部代码,这里提供一份同样能够触发 crash 的示例代码(需要事先在 Visual Studio 里创建一个 Windows Desktop Application 项目),程序退出时必然崩溃:

/// vvv
#include <objbase.h>
#define GDIPVER 0x0110
#include <gdiplus.h>
#pragma comment(lib, "gdiplus.lib")
/// ^^^

#include "framework.h"
#include "WindowsProject1.h"

/// vvv
class BitmapOwner {
public:
  BitmapOwner() : bitmap_(new Gdiplus::Bitmap(1, 1)) {}
  ~BitmapOwner() { delete bitmap_; }
private:
  Gdiplus::Bitmap* bitmap_;
};

BitmapOwner& GetBitmapOwner() {
  static BitmapOwner instance;
  return instance;
}
/// ^^^

#define MAX_LOADSTRING 100

// Global Variables:
HINSTANCE hInst;                                // current instance
WCHAR szTitle[MAX_LOADSTRING];                  // The title bar text
WCHAR szWindowClass[MAX_LOADSTRING];            // the main window class name

// Forward declarations of functions included in this code module:
ATOM                MyRegisterClass(HINSTANCE hInstance);
BOOL                InitInstance(HINSTANCE, int);
LRESULT CALLBACK    WndProc(HWND, UINT, WPARAM, LPARAM);
INT_PTR CALLBACK    About(HWND, UINT, WPARAM, LPARAM);

int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
                     _In_opt_ HINSTANCE hPrevInstance,
                     _In_ LPWSTR    lpCmdLine,
                     _In_ int       nCmdShow)
{
    UNREFERENCED_PARAMETER(hPrevInstance);
    UNREFERENCED_PARAMETER(lpCmdLine);
		
		// vvv
    ::CoInitialize(NULL);
    ULONG_PTR gdiToken = NULL;
    Gdiplus::GdiplusStartupInput gdiSI;
    Gdiplus::GdiplusStartup(&gdiToken, &gdiSI, NULL);

    GetBitmapOwner();  // will initialize a singleton
		/// ^^^
		
    // Initialize global strings
    LoadStringW(hInstance, IDS_APP_TITLE, szTitle, MAX_LOADSTRING);
    LoadStringW(hInstance, IDC_WINDOWSPROJECT1, szWindowClass, MAX_LOADSTRING);
    MyRegisterClass(hInstance);

    // Perform application initialization:
    if (!InitInstance (hInstance, nCmdShow))
    {
        return FALSE;
    }

    HACCEL hAccelTable = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDC_WINDOWSPROJECT1));

    MSG msg;

    // Main message loop:
    while (GetMessage(&msg, nullptr, 0, 0))
    {
        if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
        {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        }
    }
		
		/// vvv
    Gdiplus::GdiplusShutdown(gdiToken);
    ::CoUninitialize();
    /// ^^^

    return (int) msg.wParam;
}

ATOM MyRegisterClass(HINSTANCE hInstance)
{
    WNDCLASSEXW wcex;

    wcex.cbSize = sizeof(WNDCLASSEX);

    wcex.style          = CS_HREDRAW | CS_VREDRAW;
    wcex.lpfnWndProc    = WndProc;
    wcex.cbClsExtra     = 0;
    wcex.cbWndExtra     = 0;
    wcex.hInstance      = hInstance;
    wcex.hIcon          = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_WINDOWSPROJECT1));
    wcex.hCursor        = LoadCursor(nullptr, IDC_ARROW);
    wcex.hbrBackground  = (HBRUSH)(COLOR_WINDOW+1);
    wcex.lpszMenuName   = MAKEINTRESOURCEW(IDC_WINDOWSPROJECT1);
    wcex.lpszClassName  = szWindowClass;
    wcex.hIconSm        = LoadIcon(wcex.hInstance, MAKEINTRESOURCE(IDI_SMALL));

    return RegisterClassExW(&wcex);
}

BOOL InitInstance(HINSTANCE hInstance, int nCmdShow)
{
   hInst = hInstance; // Store instance handle in our global variable

   HWND hWnd = CreateWindowW(szWindowClass, szTitle, WS_OVERLAPPEDWINDOW,
      CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, nullptr, nullptr, hInstance, nullptr);

   if (!hWnd)
   {
      return FALSE;
   }

   ShowWindow(hWnd, nCmdShow);
   UpdateWindow(hWnd);

   return TRUE;
}

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_COMMAND:
        {
            int wmId = LOWORD(wParam);
            // Parse the menu selections:
            switch (wmId)
            {
            case IDM_ABOUT:
                DialogBox(hInst, MAKEINTRESOURCE(IDD_ABOUTBOX), hWnd, About);
                break;
            case IDM_EXIT:
                DestroyWindow(hWnd);
                break;
            default:
                return DefWindowProc(hWnd, message, wParam, lParam);
            }
        }
        break;
    case WM_PAINT:
        {
            PAINTSTRUCT ps;
            BeginPaint(hWnd, &ps);
            // TODO: Add any drawing code here...
            EndPaint(hWnd, &ps);
        }
        break;
    case WM_DESTROY:
        PostQuitMessage(0);
        break;
    default:
        return DefWindowProc(hWnd, message, wParam, lParam);
    }
    return 0;
}

INT_PTR CALLBACK About(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam)
{
    UNREFERENCED_PARAMETER(lParam);
    switch (message)
    {
    case WM_INITDIALOG:
        return (INT_PTR)TRUE;

    case WM_COMMAND:
        if (LOWORD(wParam) == IDOK || LOWORD(wParam) == IDCANCEL)
        {
            EndDialog(hDlg, LOWORD(wParam));
            return (INT_PTR)TRUE;
        }
        break;
    }
    return (INT_PTR)FALSE;
}