背景
一个单例持有一个 GDI+ 对象的指针。在其生命周期末期(应用程序退出阶段、单例析构过程中)对该指针执行 delete 时发生 crash,报错 Access violation - code c0000005。
怀疑是二次释放导致的 crash,但排查后发现该指针此前并未被显式 delete,问题显得很诡异。
排查过程
-
首先在VS/WinDbg中对crash现场进行分析:delete 这个指针的时候,该内存区域确实已经失效(全部为
?)。
-
第一次尝试:在 Visual Studio 中对该指针对应的内存地址设置数据断点,但未能命中。
-
第二次尝试:使用 WinDbg 调试,输入命令
ba w4 <指针地址>试图对该内存的写操作设置断点,但仍未命中,最终还是触发 crash。 -
经过两次尝试,怀疑并非是自己的代码写坏了内存,更可能是堆内存已被回收。(很多堆分配器会在用户可见内存前后放置元数据,并不会真正改写被监控的内存区域,因此数据断点无法命中;但后续执行 delete 时,堆管理器会发现堆的元数据已被破坏,从而触发 crash)
-
在 VS 中查看
deleteoperator 的定义后发现,它并不是 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); } }; -
第三次尝试:在 WinDbg 中用
x Gdiplus!*找到GdipFree的符号,并用bp为其设置断点,但在 crash 之前还是未命中。由此猜测:程序退出前,GDI+ 的某个阶段可能会统一释放其申请的所有堆内存。 -
第四次尝试:使用
!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> -
第五次尝试:监控 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;
}