Windows程序内存泄漏(Memory Leak)分析之Windbg(使用!heap命令分析堆内存)

之前本人写了一篇<<Windows程序内存泄漏(Memory Leak)分析之UMDH>>。这种方法有一定的局限性:

实践证明,当程序复杂,内存频繁的申请释放,通过UMDH对比的文件将会非常的大,并且很难直接看出内存泄露所在。

UMDH在收集信息的需要符号文件,不太适合于在客户的机器上进行操作。

调试方法很难一通百用,因为不同的工具都有自己的局限性,也有适合自己的分析场景,这个取决于碰到的问题。那么本文来介绍一种,使用Windbg分析内存泄露的方法。

样例代码

这个样例代码中循环调用一个Memory Leak的函数:

#include <iostream>
#include <chrono>
#include <thread>

class TestClass
{
public:
    char m_str[100];
};

void MemoryLeakObj()
{
    TestClass * pObj = new TestClass;
    strcpy_s(pObj->m_str, 100, "Memory Leak Sample");
    std::cout << pObj->m_str << std::endl;
}

int main()
{
    while (true)
    {
        MemoryLeakObj();
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
    return 0;
}

基础知识

这个章节了解下堆的一些基本知识。一个进程可以有若干个堆,包括CRT库中malloc也是从堆中申请内存,也可以自己通过Windows API HeapCreate创建堆。在windbg中查看所有的堆, 一般主要通过查看commit的内存来确定是否有内存泄露。

0:008> !heap -s


*****************************************************************************************************
                                              NT HEAP STATS BELOW
*****************************************************************************************************
NtGlobalFlag enables following debugging aids for new heaps:
    tail checking
    free checking
    validate parameters
LFH Key                   : 0x3f0f03d02e6012eb
Termination on corruption : ENABLED
          Heap     Flags   Reserv  Commit  Virt   Free  List   UCR  Virt  Lock  Fast 
                            (k)     (k)    (k)     (k) length      blocks cont. heap 
-------------------------------------------------------------------------------------
0000026349b50000 40000062    2040   1088   2040      2    26     2    1      0      
00000263499d0000 40008060      64      4     64      2     1     1    0      0      
0000026349b30000 40001062      60     20     60      2     2     1    0      0      
000002634b440000 40001062    1080     88   1080      2     4     2    0      0      
-------------------------------------------------------------------------------------

Windows中,一个堆本身并不只是由一个连续的空间组成,而是可以由多个连续的空间组成,而每一个连续的空间我们称之为Segment。我们挑选一个堆来查看他的Segment。可以看到这个堆目前由两个Segment构成,并且列出了每个Segment的地址范围。

0:008> !heap 0000026349b50000 
Index   Address  Name      Debugging options enabled
  1:   26349b50000 
    Segment at 0000026349b50000 to 0000026349c4f000 (000ff000 bytes committed)
    Segment at 000002634bef0000 to 000002634bfef000 (00011000 bytes committed)

可以通过heap -a <heap address>来查看各个Segment中申请内存。我们申请的内存的时候便是占用每一个Entry,有时候也叫做block。

0:008> !heap -a 26349b50000              
Index   Address  Name      Debugging options enabled
  1:   26349b50000 
    Segment at 0000026349b50000 to 0000026349c4f000 (000ff000 bytes committed)
    Segment at 000002634bef0000 to 000002634bfef000 (00011000 bytes committed)
    Flags:                40000062
    ForceFlags:           40000060
    Granularity:          16 bytes
    Segment Reserve:      00200000
    Segment Commit:       00002000
    DeCommit Block Thres: 00000100
    DeCommit Total Thres: 00001000
    Total Free Size:      0000009f
    Max. Allocation Size: 00007ffffffdefff
    Lock Variable at:     0000026349b502a0
    Next TagIndex:        0000
    Maximum TagIndex:     0000
    Tag Entries:          00000000
    PsuedoTag Entries:    00000000
    Virtual Alloc List:   26349b50110
        000002634ba79000: 00100000 [commited 101000, unused 1000] - busy (b)
    Uncommitted ranges:   26349b500f0
            2634bf01000: 000ee000  (974848 bytes)
    FreeList[ 00 ] at 0000026349b50150: 000002634bf00a30 . 0000026349bd9fb0  
        0000026349bd9fa0: 00050 . 00020 [104] - free
        0000026349bd4670: 00050 . 00020 [104] - free
        0000026349bd8630: 000b0 . 00020 [104] - free
        0000026349bd80c0: 00050 . 00020 [104] - free
        0000026349bd60b0: 00060 . 00020 [104] - free
        0000026349bd53f0: 000b0 . 00020 [104] - free
        0000026349b5f4c0: 00060 . 00020 [104] - free
        0000026349b5dea0: 00050 . 00020 [104] - free
        0000026349b61860: 00090 . 00020 [104] - free
        0000026349b57ae0: 00080 . 00020 [104] - free
        0000026349b53990: 00080 . 00020 [104] - free
        0000026349b6a800: 00050 . 00030 [104] - free
        0000026349b629c0: 00050 . 00030 [104] - free
        0000026349b5f610: 00070 . 00030 [104] - free
        0000026349b60a90: 00070 . 00030 [104] - free
        0000026349b62390: 00070 . 00030 [104] - free
        0000026349b5f940: 000c0 . 00030 [104] - free
        0000026349b668b0: 00070 . 00030 [104] - free
        0000026349b65230: 00040 . 00030 [104] - free
        0000026349b65ad0: 00040 . 00030 [104] - free
        0000026349b57e70: 00080 . 00030 [104] - free
        0000026349b57cb0: 00070 . 00030 [104] - free
        0000026349b57930: 00050 . 00030 [104] - free
        0000026349bd9c70: 000a0 . 00040 [104] - free
        0000026349bd9ea0: 00040 . 00070 [104] - free
        000002634bf00a20: 000a0 . 005a0 [104] - free

    Segment00 at 49b50000:
        Flags:           00000000
        Base:            26349b50000
        First Entry:     49b50720
        Last Entry:      26349c4f000
        Total Pages:     000000ff
        Total UnCommit:  00000000
        Largest UnCommit:00000000
        UnCommitted Ranges: (1)

    Heap entries for Segment00 in Heap 0000026349b50000
                 address: psize . size  flags   state (requested size)
        0000026349b50000: 00000 . 00720 [101] - busy (71f)
        0000026349b50720: 00720 . 00130 [107] - busy (12f), tail fill Internal 
        0000026349b50850: 00130 . 00130 [107] - busy (100), tail fill
        .......
        0000026349c4ede0: 000a0 . 000a0 [107] - busy (64), tail fill
        0000026349c4ee80: 000a0 . 000a0 [107] - busy (64), tail fill
        0000026349c4ef20: 000a0 . 000a0 [107] - busy (64), tail fill
        0000026349c4efc0: 000a0 . 00040 [111] - busy (3d)
        0000026349c4f000:      00000000      - uncommitted bytes.
    Segment01 at 4bef0000:
        Flags:           00000000
        Base:            2634bef0000
        First Entry:     4bef0070
        Last Entry:      2634bfef000
        Total Pages:     000000ff
        Total UnCommit:  000000ee
        Largest UnCommit:00000000
        UnCommitted Ranges: (1)

    Heap entries for Segment01 in Heap 0000026349b50000
                 address: psize . size  flags   state (requested size)
        000002634bef0000: 00000 . 00070 [101] - busy (6f)
        000002634bef0070: 00070 . 000a0 [107] - busy (64), tail fill
        .......
        000002634bf00700: 000a0 . 000a0 [107] - busy (64), tail fill
        000002634bf00840: 000a0 . 000a0 [107] - busy (64), tail fill
        000002634bf008e0: 000a0 . 000a0 [107] - busy (64), tail fill
        000002634bf00980: 000a0 . 000a0 [107] - busy (64), tail fill
        000002634bf00a20: 000a0 . 005a0 [104] free fill
        000002634bf00fc0: 005a0 . 00040 [111] - busy (3d)
        000002634bf01000:      000ee000      - uncommitted bytes.

但是Entry的地址并不是我们通过malloc返回的地址,比如通过heap -x <address>来查看刚刚Entry的信息,注意到Entry的地址和User(也就是我们通过malloc申请的内存地址啦)不同,那是堆通过Entry开头_HEAP_ENTRY数据结构进行Entry管理。

0:008> !heap -x 000002634bf00980
Entry             User              Heap              Segment               Size  PrevSize  Unused    Flags
-------------------------------------------------------------------------------------------------------------
000002634bf00980  000002634bf00990  0000026349b50000  000002634bef0000        a0        a0        3c  busy extra fill 

那么假设我们知道泄漏的内存地址了,如何知道申请内存的函数调用栈呢?在进行运行前,使用gflag设置记录函数调用栈信息: "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\gflags" -i MemoryLeakAnalysisViaWindbg.exe +ust。然后调用heap -p -a <address>,就可以看到泄露的内存地址对应的函数调用栈了。

那么接下来我们一起来看看是如何分析内存泄露的。

Windbg内存泄露分析

第一步 要做的和UMDH分析一样,调用以下命令对MemoryLeakAnalysisViaWindbg.exe程序在申请堆上内存的时候记录其函数调用栈"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\gflags" -i MemoryLeakAnalysisViaWindbg.exe +ust。

第二步 开始运行程序一段时间,查看当前堆的使用情况, 主要查看commit的大小,再用g指令运行一段后,查看是哪个对的commit的大小增加比较快。这里锁定到了堆000001471ba50000。

0:006> !heap -s


************************************************************************************************************************
                                              NT HEAP STATS BELOW
************************************************************************************************************************
NtGlobalFlag enables following debugging aids for new heaps:
    stack back traces
LFH Key                   : 0xe82e55f3a47de176
Termination on corruption : ENABLED
          Heap     Flags   Reserv  Commit  Virt   Free  List   UCR  Virt  Lock  Fast 
                            (k)     (k)    (k)     (k) length      blocks cont. heap 
-------------------------------------------------------------------------------------
000001471ba50000 08000002    1220    820   1020     48    25     1    1      0   LFH
000001471a110000 08008000      64      4     64      2     1     1    0      0      
000001471bd50000 08001002     260     36     60      7     2     1    0      0   LFH
000001471bd10000 08001002    1280    112   1080      4     3     2    0      0   LFH
-------------------------------------------------------------------------------------

通过指令!heap -stat [-h Handle [-grp GroupBy [MaxDisplay]]]来做统计信息。这里按照block的数量进行排序筛选出前5的。这里注意有时候数量多不一定就是泄露的点,如果运行时间足够长也可以使用-grp S选项来根据同种类型的内存申请的总和进行排序。

0:006> !heap -stat -h 000001471ba50000 -grp B 5
 heap @ 000001471ba50000
group-by: BLOCKCOUNT max-display: 5
    size     #blocks    total     ( %) (percent of totalblocks)
    64 1fa - c5a8  (30.43)
    30 12c - 3840  (18.04)
    48 d1 - 3ac8  (12.57)
    20 7f - fe0  (7.64)
    10 3c - 3c0  (3.61)

第三步 运行一段时间,足够明显的感觉到内存的增长,此时中断调试,继续按照block的数量进行排序。此时观察到大小为0x64的对象从数量0x1fa增长到0x849,增加了1615次申请。那么如此数量的增长,或者上面如果是用-grp S进行观测,则寻找内存增加较多的Entry Size

0:009> !heap -stat -h 000001471ba50000 -grp B 5
 heap @ 000001471ba50000
group-by: BLOCKCOUNT max-display: 5
    size     #blocks    total     ( %) (percent of totalblocks)
    64 849 - 33c84  (64.14)
    30 12c - 3840  (9.07)
    48 d1 - 3ac8  (6.32)
    20 7e - fc0  (3.81)
    10 3c - 3c0  (1.81)

第四步 然后根据这个特定的大小,查看所有对应的entry。此时可能有很多的entry, 如果想保存下来windbg 提供.logopen和.logclose来保存命令输出结果。

0:009> !heap -flt s 64
    _HEAP @ 1471ba50000
              HEAP_ENTRY Size Prev Flags            UserPtr UserSize - state
        000001471ba61790 0009 0000  [00]   000001471ba617c0    00064 - (busy)
        000001471ba66d80 0009 0009  [00]   000001471ba66db0    00064 - (busy)
        000001471bafaa80 0009 0009  [00]   000001471bafaab0    00064 - (busy)
        000001471bafab10 0009 0009  [00]   000001471bafab40    00064 - (busy)
        ......
        000001471df9fd10 0009 0009  [00]   000001471df9fd40    00064 - (busy)
        000001471df9fda0 0009 0009  [00]   000001471df9fdd0    00064 - (busy)
        000001471df9fe30 0009 0009  [00]   000001471df9fe60    00064 - (busy)
        000001471df9fec0 0009 0009  [00]   000001471df9fef0    00064 - (busy)
        000001471df9ff50 0009 0009  [00]   000001471df9ff80    00064 - (busy)
        000001471df9ffe0 0009 0009  [00]   000001471dfa0010    00064 - (busy)
    _HEAP @ 1471a110000
    _HEAP @ 1471bd50000
    _HEAP @ 1471bd10000

第五步 随便找几个Entry的地址查看其函数调用栈,比如这里查看000001471df9ff50。比较容易就定位到了申请内存的代码。不过这里注意一下为什么函数栈是main 而不是MemoryLeakObj,这是因为我们的编译进行的优化,不过这也不妨碍我们找到问题。

0:009> !heap -p -a 000001471df9ff50
    address 000001471df9ff50 found in
    _HEAP @ 1471ba50000
              HEAP_ENTRY Size Prev Flags            UserPtr UserSize - state
        000001471df9ff50 0009 0000  [00]   000001471df9ff80    00064 - (busy)
        7ff8350fbe47 ntdll!RtlpCallInterceptRoutine+0x000000000000003f
        7ff8350baa6f ntdll!RtlpAllocateHeapInternal+0x000000000009192f
        7ff8315b9686 ucrtbase!_malloc_base+0x0000000000000036
        7ff6558613a3 MemoryLeakAnalysisViaWindbg!operator new+0x000000000000001f
        7ff65586102d MemoryLeakAnalysisViaWindbg!main+0x000000000000002d
        7ff6558615b0 MemoryLeakAnalysisViaWindbg!__scrt_common_main_seh+0x000000000000010c
        7ff834e84034 KERNEL32!BaseThreadInitThunk+0x0000000000000014
        7ff835083691 ntdll!RtlUserThreadStart+0x0000000000000021

总结

本文所阐述的方式是针对同一种大小的内存申请导致的内存泄露。而内存泄露在大型工程中还有可能是可变大小的,那么这种方法就不适合。这也是为什么内存泄露问题写了两篇文章还没写完: 内存泄露各式各样,在客户环境如何定位问题,也是难上加难。计划后面还会写几篇比如vmmap, DebugDialog,以及其他的一些非使用工具的一些方法。

上面的例子是笔者attach到进程调试的结果。如果碰到在客户环境有这样的问题,显然在线调试是不太可能的,可以用gflag开启ust后收集两次Dump来查找问题(这两次dump的间隔时间要足以观测到内存泄露,根据实际情况而定)。

编写代码的时候尽量使用智能指针unique_ptr和shared_ptr,埋坑简单,但找到问题的原因比写代码的时间都长。

猜你喜欢

转载自blog.csdn.net/chenlycly/article/details/131576063