句柄泄露实例分析

http://www.cnblogs.com/Leo_wl/p/5397274.html

在上篇文章.NET对象与Windows句柄(二):句柄分类和.NET句柄泄露的例子中,我们有一个句柄泄露的

例子。例子中多次创建和Dispose了DataReceiver和DataAnalyzer对象,但由于忘记调用DataAnalyzer的

Stop方法,导致产生句柄泄露。本文假定我们已经发现了泄露现象但还不知道原因,讨论如何在这种情

况下分析问题。

一、发现问题

在程序运行约一个小时以后,通过任务管理器发现句柄数超过5000,线程数也超过1000。对于一段只需

要并行接收和分析数据的简易代码来说,这显然太不正常了,我们可以判断程序已经产生了泄露。

通过任务管理器可以非常方便的查看程序实时的资源占用情况,但无法了解到历史数据和趋势。程序是

一开始就需要分配和使用这么多资源,还是长时间运行的结果?如果是后者,那么是运行过程中平稳持

续的增长,还是在某个时间节点之后的突然增长?弄清楚这些问题是必要的,我们可以借此初步判断出

内存泄露是与用户的特定操作相关,或者与特定时间点上产生的事件相关;是跟程序的初始化有关,还

是跟某些从始至终运行的后台任务相关。

性能监视器可以很直观的显示这一趋势,其中内置了很多有用的计数器,我们可以从图形化界面中观察

这些计数器值的变化规律,了解系统和进程的运行状况。使用Win + R组合键打开“运行”窗口,输入

perfmon打开性能监视器。点击绿色加号按钮打开“添加计数器”对话框,选择Process中的Handle

Count和Thread Count,然后选择LeakExample进程作为实例,添加这两个计数器。

接下来观察这些数值的变化。在这期间,我们像往常一样的使用程序,可以重复进行一些可能造成内存

泄露的操作。在运行过一段时间后,得到了如下的图表。句柄数和线程数在持续的增长,很容易猜测到

跟Timer有关,因为Timer定期触发,并且每次触发都需要使用线程。即便如此,仍然需要确切的定位究

竟是什么对象产生了泄露,因为实际的项目中可能用到的Timer或者后台线程的代码远远不止一两处。

二、分析运行中的进程

首先应该找出5000多个句柄究竟代表什么对象。利用Process Explorer查看该进程,在下方面板中检查

句柄列表,发现有大量的Event句柄和Thread句柄,更进一步的,我们想知道到底有多少Event和Thread

在这个列表中难以看出各种句柄的数量。可以按下Ctrl+A组合键,将Process Explorer中的进程列表和

选中进程的句柄列表保存为文本文件,而后利用你所习惯使用的文本查看工具统计其中特定句柄的数量

,我们这里使用Chrome浏览器的搜索功能看到约有4063个Event句柄和1008个Thread句柄。

到这里,我们有一个大致的印象,即泄露的对象是Event和Thread,其中Event占大多数。下一步需要找

出是谁创建出了这些对象,可以使用Windbg跟踪对象的创建。Windbg是非常方便的Windows调试工具,可

以利用强大的SOS扩展命令诊断.NET程序中的各种问题,最新的Windbg(截止2016年4月)可以从MSDN的

Download the WDK, WinDbg, and associated tools页面下载,点击页面上的Get Debugging Tools for

Windows (WinDbg)链接即可。

将Windbg附加到LeakExample.exe进程,而后使用!handle和!htrace命令对进程句柄进行分析。!handle

命令可以列出进程内所有句柄,也可以查看特定句柄的信息,而!htrace显示句柄的堆栈跟踪。我们先使

用!htrace -enable启用句柄跟踪,然后让进程继续运行几分钟时间,再中断程序的执行,用!htrace -

diff查看自上次快照以来新打开的句柄。由于命令输出过长,一些不重要的信息被隐去用省略号代替。

0:482> !htrace -enable

Handle tracing enabled.

Handle tracing information snapshot successfully taken.

0:482> g

(1988.2f3c): Break instruction exception - code 80000003 (first chance)

eax=7fbc0000 ebx=00000000 ecx=00000000 edx=779fd23d esi=00000000 edi=00000000

eip=77993540 esp=5a75ff28 ebp=5a75ff54 iopl=0         nv up ei pl zr na pe nc

cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000246

ntdll!DbgBreakPoint:

77993540 cc              int     3

0:015> !htrace -diff

Handle tracing information snapshot successfully taken.

0x6 new stack traces since the previous snapshot.

Ignoring handles that were already closed...

Outstanding handles opened since the previous snapshot:

--------------------------------------

Handle = 0x00000b68 - OPEN

Thread ID = 0x00002a68, Process ID = 0x00001988

0x779a4b7c: ntdll!ZwCreateThreadEx+0x0000000c

0x75d3bc5d: KERNELBASE!CreateRemoteThreadEx+0x00000161

0x7643281d: KERNEL32!CreateThreadStub+0x00000020

0x6c54b51f: clr!Thread::CreateNewOSThread+0x0000009b

0x6c54b358: clr!Thread::CreateNewThread+0x000000a8

0x6c54b8ad: clr!ThreadpoolMgr::CreateUnimpersonatedThread+0x00000275

0x6c54b9fc: clr!ThreadpoolMgr::MaybeAddWorkingWorker+0x00000129

0x6c53f298: clr!ManagedPerAppDomainTPCount::SetAppDomainRequestsActive+0x0000002f

--------------------------------------

Handle = 0x00000b64 - OPEN

Thread ID = 0x00002a68, Process ID = 0x00001988

0x779a49fc: ntdll!ZwCreateEvent+0x0000000c

0x75d376a0: KERNELBASE!CreateEventExW+0x0000006e

0x75d376f0: KERNELBASE!CreateEventW+0x00000027

0x6c54a106: clr!CLREventBase::CreateManualEvent+0x00000036

0x6c54a84f: clr!Thread::AllocHandles+0x00000064

0x6c54b4f4: clr!Thread::CreateNewOSThread+0x00000074

0x6c54b358: clr!Thread::CreateNewThread+0x000000a8

0x6c54b8ad: clr!ThreadpoolMgr::CreateUnimpersonatedThread+0x00000275

0x6c54b9fc: clr!ThreadpoolMgr::MaybeAddWorkingWorker+0x00000129

0x6c53f298: clr!ManagedPerAppDomainTPCount::SetAppDomainRequestsActive+0x0000002f

0x6ae49bd3: mscorlib_ni+0x00389bd3

0x6adcd38c: mscorlib_ni+0x0030d38c

--------------------------------------

Handle = 0x00000b60 - OPEN

Thread ID = 0x00002a68, Process ID = 0x00001988

0x779a49fc: ntdll!ZwCreateEvent+0x0000000c

… …

--------------------------------------

Handle = 0x00000b70 - OPEN

Thread ID = 0x00002a68, Process ID = 0x00001988

0x779a49fc: ntdll!ZwCreateEvent+0x0000000c

… …

--------------------------------------

Handle = 0x00000b54 - OPEN

Thread ID = 0x00002a68, Process ID = 0x00001988

0x779a49fc: ntdll!ZwCreateEvent+0x0000000c

… …

--------------------------------------

Handle = 0x00000b50 - OPEN

Thread ID = 0x000011f8, Process ID = 0x00001988

0x779a49fc: ntdll!ZwCreateEvent+0x0000000c

… …

--------------------------------------

Displayed 0x6 stack traces for outstanding handles opened since the previous snapshot.

可以看到,在两次!htrace命令之间有6个handle被打开,由调用堆栈可知其中有1个Thread对象和5个

Event对象,并且在第1个Thread对象之后的4个Event都属于该线程。如果重复!htrace -diff多次,可以

发现一个规律,即每个Thread对象被创建之后,紧接着就会有4个Event对象在同一线程中被打开,说明

在本例中泄露的根源在于Thread对象,这也解释了为什么Event句柄数大致是Thread的4倍。实际上每个

线程在创建的时候的确会创建4个Manual Event,从上面句柄打开时的调用堆栈也能看出,clr!

Thread::CreateNewOSThread方法除了创建Thread对象,也会创建几个Manual Reset Event用于控制线程

的挂起和恢复。

查看Event和Thread句柄的详细信息,下面的输出显示了Thread句柄所指向的线程Id,以及其后的Event

句柄信息。

0:015> !handle 0x00000b68 f

Handle b68

Type            Thread

Attributes         0

GrantedAccess    0x1fffff:

Delete,ReadControl,WriteDac,WriteOwner,Synch

Terminate,Suspend,Alert,GetContext,SetContext,SetInfo,QueryInfo,SetToken,Impersonate,Direct

Impersonate

HandleCount     4

PointerCount      6

Name                   <none>

Object Specific Information

Thread Id   1988.261c

Priority    10

Base Priority 0

Start Address 6c54a086 clr!Thread::intermediateThreadProc

0:015> !handle 0x00000b64 f

Handle b64

Type            Event

Attributes         0

GrantedAccess    0x1f0003:

Delete,ReadControl,WriteDac,WriteOwner,Synch

QueryState,ModifyState

HandleCount     2

PointerCount      3

Name                   <none>

Object Specific Information

Event Type Manual Reset

Event is Set

接下来查看这个新启动的线程在执行什么代码,这个信息将帮助我们找到是哪里的代码创建了该线程。

我们需要加载SOS扩展,并利用上面输出的Thread Id信息。

0:015> .loadby sos clr

0:015> !threads

ThreadCount:      323

UnstartedThread:  0

BackgroundThread: 266

PendingThread:    0

DeadThread:       56

Hosted Runtime:   no
                                                                        Lock

ID OSID ThreadOBJ    State GC Mode     GC Alloc Context  Domain   Count Apt

Exception

0    1  fb8 005015e8     26020 Preemptive  4EEC2A44:00000000 004f9540 0     STA

2    2  a20 0050e080     2b220 Preemptive  00000000:00000000 004f9540 0     MTA

(Finalizer)

8    5 14cc 00553c48   102a220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

284  280  f34 1178fa50   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

286  283 1ff4 117bd278   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

761  764 229c 24cfc070   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

849  865 1bc8 490eb860   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

XXXX  868    0 490e82f0   1039820 Preemptive  00000000:00000000 004f9540 0     Ukn

(Threadpool Worker)

900  900 1054 490edd58   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

898  901  654 490d9370   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

903  903  828 490d9e00   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

XXXX  904    0 490ead30   1039820 Preemptive  00000000:00000000 004f9540 0     Ukn

(Threadpool Worker)

XXXX 1004    0 11758b70   1039820 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

10 1005 2844 117590b8   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

7 1006  314 11759600   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

… …

… …

316  804 2164 0054f960   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

318  803 1758 24a3e810   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

317  802 27bc 116e1540   3029220 Preemptive  00000000:00000000 004f9540 0     MTA

(Threadpool Worker)

5  801 261c 117152d0   3029220 Preemptive  4EEC0C44:00000000 004f9540 0     MTA

(Threadpool Worker)

0:015> ~5s

eax=00000000 ebx=00000258 ecx=00000001 edx=4fa6bc17 esi=0465ee48 edi=00000000

eip=779a64f4 esp=0465ee04 ebp=0465ee6c iopl=0         nv up ei pl nz na pe nc

cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000206

ntdll!KiFastSystemCallRet:

779a64f4 c3              ret

0:005> !clrstack

OS Thread Id: 0x261c (5)

Child SP       IP Call Site

0465eef4 779a64f4 [HelperMethodFrame: 0465eef4] System.Threading.Thread.SleepInternal

(Int32)

0465ef68 6ad83365 System.Threading.Thread.Sleep(Int32)

0465ef6c 001d04cd LeakExample.DataAnalyzer.DoAnalyze(System.Object) [D: \TimerLeak

\TimerLeak\Form1.cs @ 88]

0465ef7c 6adede48 System.Threading.TimerQueueTimer.CallCallbackInContext(System.Object)

0465ef80 6adc2367 System.Threading.ExecutionContext.RunInternal(… …)

0465efec 6adc22a6 System.Threading.ExecutionContext.Run(… …)

0465f000 6adedd91 System.Threading.TimerQueueTimer.CallCallback()

0465f034 6adedc4c System.Threading.TimerQueueTimer.Fire()

0465f074 6ade11a5 System.Threading.TimerQueue.FireQueuedTimerCompletion(System.Object)

0465f078 6adcdd34

System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWork

Item()

0465f08c 6adcd509 System.Threading.ThreadPoolWorkQueue.Dispatch()

0465f0dc 6adcd3a5 System.Threading._ThreadPoolWaitCallback.PerformWaitCallback()

0465f300 6c432652 [DebuggerU2MCatchHandlerFrame: 0465f300]

从调用堆栈可以看出,新线程是由Timer触发的,回调函数是DoAnalyze,参照上篇文章中的代码,得知

它就是DataAnalyzer中的analyzeTimer。这本身没有什么问题,但是检查多个线程的调用堆栈,重复以

上的步骤进行多次分析后,发现所有新增的线程都是由这个timer触发的。Timer本身被设置为每秒触发

一次,而每次触发的执行时间都小于一秒。出现大量的线程,说明timer对象本身产生了泄露,即进程中

有大量的timer实例在运行,而程序设计的本意是进程中只存在一个analyzeTimer。到这里问题已经比较

明显了,往往已经可以从代码审查中找出问题,即analyzeTimer没有被Dispose。

三、小结

针对有句柄泄露的程序,本文描述了一种分析的思路。分析的对象是运行中的进程,因此这是一种动态

分析,即我们可以在它运行的过程中,反复的重现问题,而后观察新的泄露情况。实际的项目中,这个

过程是寻找问题复现关键点的过程,也是反复猜测和证实,以及发现新线索的过程。可以进行动态分析

实际上是比较幸运的,因为另一些情况下,问题发生之后很难再次重现,或者现场环境不允许我们进行

反复的尝试。这时我们需要快速的搜集环境数据,并打好内存转储Dump文件,事后进行静态分析。下一

篇文章,我们仍然用这个例子,探讨如何进行Dump分析,并讨论一点Timer的实现细节。
========

句柄泄露的问题

http://bbs.csdn.net/topics/380196549
写了一个ocx,方在IE上下载远程的图片然后用GDI+显示出来,发现长跑一天后句柄增加了2000多,有的

机器没增加,有的2天增量到了1万, 用Process Explorer看了一下,GDI句柄和用户句柄是保持不变的

,没有增加,不知道那些句柄是哪来的?

有工具查看socket句柄吗?使用的方法时开了多个线程, 在线程里使用调用download_my进行下载,

CHttpConnection,CHttpFile进行下载的,用完也close()了, 如下,会有问题吗? 每个线程都是共用

的一个线程函数,这个函数要加锁吗?

UINT download_my(const CString& strFileURLInServer, //待下载文件的URL
 char *pBuf)//存放到本地的路径
{
    char *pszBuffer = pBuf;
ASSERT(strFileURLInServer != "");
CInternetSession session;
CHttpConnection* pHttpConnection = NULL;
CHttpFile* pHttpFile = NULL;
CString strServer, strObject;
INTERNET_PORT wPort;
DWORD dwType;
UINT nRead;
//int nTimeOut = 10;
//session.SetOption(INTERNET_OPTION_CONNECT_TIMEOUT, nTimeOut); //重试之间的等待延时
session.SetOption(INTERNET_OPTION_CONNECT_RETRIES, 1);   //重试次数  
try
{
AfxParseURL(strFileURLInServer, dwType, strServer, strObject, wPort);
pHttpConnection = session.GetHttpConnection(strServer, wPort);
pHttpFile = pHttpConnection->OpenRequest(CHttpConnection::HTTP_VERB_GET, strObject);
try{
int res = FALSE;
res = pHttpFile->SendRequest();
if( res == FALSE)
return false;

}catch(CInternetException  *p) 
{
            p->Delete();
if(pHttpFile)
{
pHttpFile->Close();
delete pHttpFile;
pHttpFile = NULL;
}

if(pHttpConnection)
{
pHttpConnection->Close();
delete pHttpConnection;
pHttpConnection = NULL;
}
session.Close();
return false;
}
DWORD dwStateCode;
pHttpFile->QueryInfoStatusCode(dwStateCode);
if(dwStateCode == HTTP_STATUS_OK)
{   
char szInfoBuffer[MAXRETURNMESSAGESTRLEN];  //返回消息

DWORD dwInfoBufferSize = sizeof(szInfoBuffer);
BOOL bResult = FALSE;
bResult = pHttpFile->QueryInfo(HTTP_QUERY_CONTENT_LENGTH,
(void*)szInfoBuffer, &dwInfoBufferSize, NULL);
if(bResult == FALSE)
{
return -1;
}
nRead = pHttpFile->Read(pszBuffer, BUFFER_LENGTH); //读取服务器上数据

}
else
{

if(pHttpFile != NULL)
{
pHttpFile->Close();
delete pHttpFile;
pHttpFile = NULL;
}
if(pHttpConnection != NULL)
{
pHttpConnection->Close();
delete pHttpConnection;
pHttpConnection = NULL;
}
session.Close();
return -1;
}
}
catch(CFileException &e )
{
e.Delete(); //add by yuwenying
if(pHttpFile != NULL)
{
pHttpFile->Close();
delete pHttpFile;
pHttpFile = NULL;
}
if(pHttpConnection != NULL)
{
pHttpConnection->Close();
delete pHttpConnection;
pHttpConnection = NULL;
}
session.Close();
return -1;
}
if(pHttpFile)
{
pHttpFile->Close();
delete pHttpFile;
pHttpFile = NULL;
}
if(pHttpConnection)
{
pHttpConnection->Close();
delete pHttpConnection;
pHttpConnection = NULL;
}
session.Close();
return nRead;
}

最后,那些多出来的句柄是怎么来的。。。

process explorer可以查看有名字的对象的句柄,看一下这些句柄的对象名是什么

GDI对象也就是图像绘制里的句柄没有释放
参照MSDN图像绘制函数例子

GDI对象也就是图像绘制里的句柄没有释放
参照MSDN图像绘制函数例子

一直保持在稳定的数目,没有关系的吧?

GDI数目一直是稳定的,没有增加,没关系的吧。主要是句柄是2000多个,GDI句柄600一直不变,用户句

柄200,剩下的那些是什么句柄,一直在增加

现在的代码看不出来,应该还在你读取图像文件显示那一块

把这个 download_my 函数注释掉,看看句柄还有没有增加,问题定位一下。

一部分一部分的注释来找吧,这个是最笨最有效的方法了

单独测试下载图片时句柄的变化,再测试画图

你这段代码里有很多的return -1;的语句,看看当执行到任何一个return -1;时是否所有该释放的都释放

了,有可能是该释放时未释放就退出了.所以当程序里有多个return时,一定要在return 前把所有该释放

的都释放了再退出,不然就会出问题

可以用boundschecker这个工具辅助调试一下,定位问题

try{
int res = FALSE;
res = pHttpFile->SendRequest();
if( res == FALSE)
return false;

这个return false;  前没有释放句柄。其他地方就不知道了。

可以用boundschecker这个工具辅助调试一下,定位问题

最好是用一个出口

C/C++ code

BOOL bError = 0;
 
  CHttpFile* pHttpFile = NULL;
  //……其它初始化语句 
 
  //实体处理段落
  if(bError==0)
  { 
    //处理1……………
    if(出错1)
    {
      bError |= 0x0001;
    }  
  }
 
  if(bError==0)
  { 
    //处理2……………
    if(出错2)
    {
      bError |= 0x0002;
    }  
  }
 
  //……其它处理段落
 
  //结尾清理工作
  if(pHttpFile != NULL)
  {
    pHttpFile->Close();
    delete pHttpFile;
    pHttpFile = NULL;
  }
  //……其它清理工作
 
  //返回
  return XXXXX;

这样结构简单,而且调试也和容易,最后设置个断点就知道出了什么错

最好是用一个出口

C/C++ code
  BOOL bError = 0;

CHttpFile* pHttpFile = NULL;
  //……其它初始化语句

//实体处理段落
  if(bError==0)
  { 
    //处理1……………
    if(出错1)
    {
      bError |= 0x0001;
……

恩,谢谢了,学习,一直是在思考这个问题呢,在多个退出前都要写同样的清理代码,不好看,呵呵

try{
int res = FALSE;
res = pHttpFile->SendRequest();
if( res == FALSE)
return false;

这个return false; 前没有释放句柄。其他地方就不知道了。

多谢了兄弟,我回帖的时候,从来没看的这么仔细过,学习。。
========

句柄泄露

http://blog.csdn.net/maomao171314/article/details/38317159
介绍

欢迎来到这个调试教程系列的第5篇。在这篇文章,我们会介绍Windows中的句柄,什么是句柄以及怎

么调试句柄泄露。在读这篇文章之前,我希望你对前4篇文章掌握的都还不错。这一系列的文章并不是在

重复WinDbg/NTSD的帮助文件,而是介绍实实在在的问题,它们是怎么一回事,以及如何解决它们。

什么是句柄

对于一个程序来说,句柄就是设备,文件,或其它一些系统对象或资源的实例。程序可以通过

CreateFile,RegOpenKey等类似函数来创建一个资源的实例,然后使用这个句柄来进行接下来对这些资

源的操作。

一般来说,你不用关心这个句柄值是多少。那么句柄到底是什么,代表什么?

为了理解的更清晰,我们来看看CreateFile的流程。如果你调试CreateFile,你会注意到它最终会调用

NtCreateFile。

kernel32!CreateFileW+0x34a:

77e7b24c ff150810e677 call dword ptr [kernel32!_imp__NtCreateFile (77e61008)]

0:000> kb

ChildEBP RetAddr  Args to Child

0012f728 77e7b4a3 000007c4 80000000 00000000 kernel32!CreateFileW+0x40e

0012f74c 00401ba9 00406760 80000000 00000000 kernel32!CreateFileA+0x2e

NtCreateFile会导致一个内核调用。这个调用是如何实现的要看系统,可能是sysenter或一个软中断int

2eh。不管哪种方法,这个调用都会进入内核,并会被系统分配到对应的驱动。然后,在内核中就会创

建了一个对象来代表这个资源,在这里就是文件。如果你去观察NtCreateFile的参数,你会发现第一个

参数就是将会返回的句柄。我们来看看第一个参数:

eax=0012f730 ebx=00000000 ecx=80100080 edx=00200000 esi=77f58a3e edi=00000000

eip=77e7b24b esp=0012f69c ebp=0012f728 iopl=0         nv up ei ng nz na pe nc

cs=001b  ss=0023  ds=0023  es=0023  fs=0038  gs=0000             efl=00000282

kernel32!CreateFileW+0x349:

77e7b24b 50               push    eax

0:000>

可以看到,第一个参数是一个地址,就是函数中的一个局部变量。返回后,我们可以检查它的值。

0:000> dd 0012f730

0012f730  000007c4

返回的值是7c4h,并不是程序中指向任何一个地址的指针,更不是内核内存中的指针。为了更深入,有

一个调试器命令可以显示句柄信息,就是!handle。

0:000> !handle 7c4 ff

Handle 7c4

Type          File

Attributes    0

GrantedAccess 0x120089:

ReadControl,Synch

Read/List,ReadEA,ReadAttr

HandleCount   2

PointerCount  3

No Object Specific Information available

我们可以看到,这确实是一个文件对象,还显示了它的属性和访问权限。但是我们并不知道这个句柄到

底指向哪个文件,而我们想知道的就是这个信息。我们看不到是有原因的,但在我告诉你原因之前,我

们来看看系统到底干了什么。

句柄值代表什么?

为了给你展示这个东西,我们需要内核调试器。我在另外一台Windows2000的机器上调试过一个进程。

我需要打开记事本,然后需要获得这个进程中的一些句柄。

注意:在Windows XP/2003中,调试器会显示系统的更多信息。比如,对一个线程使用!handle将会显示

线程的入口函数,而Windows2000不会显示。如果你想将一个线程和一个句柄匹配,这将非常有用,特别

是当句柄已经泄露了或线程也不再运行了。教程中我还介绍过dt命令,Windows2000有时候不能正确显示

一些结构(比如系统结构_EPROCESS)。这可能是因为DBGs和PDBs在Windows2000上被处理的方法不同,或

者是信息丢失了。在Windows XP/2003上调试还有更多其他的好处。

因此,我选择了一个文件,并在在CreateFile上下断点。当我选择完这个文件之后,断点触发,然后

我再返回地址上下一个断点。然后,我打印出EAX中的值来看看句柄信息。

0:000> !handle eax ff

Handle 120

Type          File

Attributes    0

GrantedAccess 0x120089:

ReadControl,Synch

Read/List,ReadEA,ReadAttr

HandleCount   2

PointerCount  3

这和上面一样,只是显示了它是一个文件。下面我们来看看第一个秘密。这些句柄都是针对特定进程的

。120h这个值只在这个进程空间中有用。如果你在另外一个进程中使用这个句柄值,它可能并不存在,

也可能代表了另外一个不同的对象。这些句柄和窗口句柄不一样,它们的作用范围是进程。

句柄还是内核对象的一个代表,这意味着每个进程在内核中都有一个句柄表,其中每一项都指向内核

的一块内存。这是第二个秘密,也是我为什么要使用内核调试器的原因。现在,我们在内核中中断下来

,并来看看这个句柄值。

在内核调试器中,我们使用!process 0 0,列举所有的进程,然后我们使用!handle命令,语法会有一

些不一样,为了列举正确的句柄,我们需要指定相应的进程。

kd> !process 0 0

**** NT ACTIVE PROCESS DUMP ****

...

PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8

DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.

Image: notepad.exe

kd> !handle 120 ff fcc77200

processor number 0

PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8

DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.

Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use

0120: Object: fcd32448  GrantedAccess: 00120089

Object: fcd32448  Type: (fced7c40) File

ObjectHeader: fcd32430

HandleCount: 1  PointerCount: 1

Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

第一个加粗的数字是进程对象,它是内存中的一个结构体,包含了进程中的一些信息。既然句柄是对应

于特定进程的,我们就要告诉!handle它对应于哪个进程。”ff”就是说显示可能的所有信息,帮助文件

中有具体的解释。我总是使用”ff”,因为我需要所有的信息。

第二个加粗的地址是进程中的句柄表,这块内存代表进程中所有的句柄项。可以看到现在有74个句柄

被打开。最后一个加粗的地址是对象的地址,这是所有数据的来源。”Name”属性告诉我们文件名。为

什么我们不能在用户态调试器下得到这些信息呢?

在用户态下显示句柄信息

很明显,这个表是在内核中的,所以你不能直接在用户态下看到。NT系统提供了一些API可以让你得到

这些句柄的信息。如果你看了第3篇文章,我写的那个QuickView:System Explorer就可以完成这样的功

能,并显示其中的一些信息,使用的API是NtQueryObject。

这个API的不足之处就是它可能会使你的进程挂住。这是因为有一些对象是以SYNC的方式打开的。系统

中的管道就是这样的情况,如果你请求它们的信息,你将无限期挂住。为了防止出现这样的情况,调试

器以及我的程序都不会去想请求这样的对象,以防死锁。在我的程序中,我会检查SYNC标志,我还发现

其他一些不会挂起的访问权限。

SysInternals中的HANDLE.EXE工具可以显示所有的信息,那么,它是怎么做到的呢?因为它会使用内

核驱动,并且驱动可以访问所有的内核空间,因此可以直接读内核对象的内存,从而不会使

NtQueryObject死锁。我也在想,在将来的QuickView版本中增加一个驱动,从而不会死锁,并显示更加

具体的信息。

多个句柄

在上面的例子中,我打开了\TripItinerary.txt文件。那么我在另外一个记事本中再打开它会怎么样

呢?我们来看看会发生什么。

在用户态调试器中可以看到:

0:000> !handle eax ff

Handle 58

Type          File

Attributes    0

GrantedAccess 0x120089:

ReadControl,Synch

Read/List,ReadEA,ReadAttr

HandleCount   2

PointerCount  3

在内核调试器中,列举句柄:

kd> !handle 58 ff fcd8ace0

processor number 0

PROCESS fcd8ace0  SessionId: 0  Cid: 0258    Peb: 7ffdf000  ParentCid: 0198

DirBase: 04b19000  ObjectTable: fccc9648  TableSize:  22.

Image: notepad.exe

Handle Table at e1e89000 with 22 Entries in use

0058: Object: fcce7028  GrantedAccess: 00120089

Object: fcce7028  Type: (fced7c40) File

ObjectHeader: fcce7010

HandleCount: 1  PointerCount: 1

Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

我们还可以在原来那个记事本中列举句柄:

kd> !handle 120 ff fcc77200

processor number 0

PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8

DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.

Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use

0120: Object: fcd32448  GrantedAccess: 00120089

Object: fcd32448  Type: (fced7c40) File

ObjectHeader: fcd32430

HandleCount: 1  PointerCount: 1

Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

在这个例子中,我们可以看到它们不仅有各自的句柄,还有各自的内核对象。但并不总是这样的。有时

候内核对象能在不同进程之间共享。如果同一个进程打开了文件,一般来说它们会得到两个句柄,但是

指向同一个内核对象。你可以使用我的工具或HANDLE.EXE来显示句柄信息,并将他们分类排列,这会让

你学习到更多关于句柄的知识。

为什么用户态调试器显示的HandleCount是2,而PointerCount是3?

你会看到,在用户态调试器下显示句柄信息,”HandleCount”是2,而”PointerCount”是3。这是因

为当显示信息的时候,我们需要作一些变动。调试器为了得到信息,会使用DuplicateHandle复制一个句

柄。这个复制会将HandleCount加1,PointerCount加2。

我们来看看上面那个ObjectHeader:”fcd32430”。

kd> !handle 120 ff fcc77200

processor number 0

PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8

DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.

Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use

0120: Object: fcd32448  GrantedAccess: 00120089

Object: fcd32448  Type: (fced7c40) File

ObjectHeader: fcd32430

HandleCount: 1  PointerCount: 1

Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}
 
kd> dd fcd32430

fcd32430  00000001 00000001 fced7c40 00000800

fcd32440  fcce5fc8 00000000 00700005 fceccbd0

fcd32450  fcecc248 e1e730d8 e1e73250 fcc790b4

fcd32460  00000000 00000000 00000000 00010000

fcd32470  00010100 00040042 00380024 e1bf2568

fcd32480  00000000 00000000 00000000 00000000

fcd32490  00000000 00040001 00000000 fcd3249c

fcd324a0  fcd3249c 00040000 00000001 fcd324ac

kd> ba r1 fcd32430

kd> ba r1 fcd32434

kd> g

我们可以看到,handle count是1,pointer count是1。然后我使用”ba r1”在handle count和pointer

count的地址上下断点。BA的意思就是当地址被访问是就断下。R表示它是读还是写。R1中的1表示1字节

然后我们使用g命令让其运行。而后我会在用户态调试器下使用”!handle 120 ff”,这将会使用户态

调试器访问这个对象。我们来看看这个指针是在什么时候增长的。

kd> kb

ChildEBP RetAddr  Args to Child

fb66ebf0 8049ff0d 00000120 00000000 00000000 nt!ObReferenceObjectByHandle+0x1af

fb66ed40 80461691 00000074 00000120 ffffffff nt!NtDuplicateObject+0x12d

fb66ed40 77f83f85 00000074 00000120 ffffffff nt!KiSystemService+0xc4

0006ef28 00000000 00000000 00000000 00000000 ntdll!NtDuplicateObject+0xb

我们可以看到,第一个函数是ObReferenceObjectByHandle。这会增加指针的引用计数。我们来看看。

kd> !handle 120 ff fcc77200

processor number 0

PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8

DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.

Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use

0120: Object: fcd32448  GrantedAccess: 00120089

Object: fcd32448  Type: (fced7c40) File

ObjectHeader: fcd32430

HandleCount: 1  PointerCount: 2

Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

我们看到pointer count已经增加到2了,我们看看下面会发生什么。

kd> kb

ChildEBP RetAddr  Args to Child

fb66ebec 8049fffb 00000002 fcc77800 fcd32448 nt!ObpIncrementHandleCount+0x236

fb66ed40 80461691 00000074 00000120 ffffffff nt!NtDuplicateObject+0x3c3

fb66ed40 77f83f85 00000074 00000120 ffffffff nt!KiSystemService+0xc4

0006eed0 77e846ed 00000074 00000120 ffffffff ntdll!NtDuplicateObject+0xb

0006ef28 69b22188 00000074 00000120 ffffffff KERNEL32!DuplicateHandle+0xd4

0006f454 69b2252e 00000074 00000120 000000ff ntsdexts!GetHandleInfo+0x29

0006f4d0 0100d562 00000074 00000050 01001dec ntsdexts!handle+0x10f

0006f52c 0100e497 00233848 0006f54c 0006f553 ntsd!CallExtension+0x77

0006f65c 0100c2bb 01089c81 010241d8 01089138 ntsd!fnBangCmd+0x377

0006f868 0100be0f 80000003 00000001 0006fc2c ntsd!ProcessCommands+0x2ac

0006fab4 01008406 00000000 00000000 ffffffff ntsd!ProcessStateChange+0x687

0006fc2c 01008a14 0006fc4c 00000000 00000000 ntsd!DebugEventHandler+0x6a5

0006fca8 010076dc 00000000 00000000 7ffdf000 ntsd!NtsdExecution+0x13b

0006ff70 010226bf 00000002 002337b0 00232978 ntsd!main+0x3d7

0006ffc0 77e87903 00000000 00000000 7ffdf000 ntsd!mainCRTStartup+0xff

0006fff0 00000000 010225c0 00000000 000000c8 KERNEL32!BaseProcessStart+0x3d

kd> !handle 120 ff fcc77200

processor number 0

PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8

DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.

Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use

0120: Object: fcd32448  GrantedAccess: 00120089

Object: fcd32448  Type: (fced7c40) File

ObjectHeader: fcd32430

HandleCount: 2  PointerCount: 2

Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

我们可以看到会调用ObpIncrementHandleCount,并且handle count现在也是2了。注意到,这两个调用

都来自于NtDuplicateObject,也就是用户态下的DuplicateHandle。因此,复制对象会增加这块内核内

存的引用计数,并增加句柄数量,这就是两个进程使用同一个内核对象的一个例子。那么第3个指针引用

是从哪来的?

kd> kb

ChildEBP RetAddr  Args to Child

fb66ec68 804baed8 00000100 00000000 00000000 nt!ObReferenceObjectByHandle+0x1af

fb66ed48 80461691 00000100 00000002 0006ef4c nt!NtQueryObject+0xc1

fb66ed48 77f8c4e1 00000100 00000002 0006ef4c nt!KiSystemService+0xc4

0006ef24 69b22203 00000100 00000002 0006ef4c ntdll!NtQueryObject+0xb

0006f454 69b2252e 00000000 00000120 000000ff ntsdexts!GetHandleInfo+0xa4

0006f4d0 0100d562 00000074 00000050 01001dec ntsdexts!handle+0x10f

0006f52c 0100e497 00233848 0006f54c 0006f553 ntsd!CallExtension+0x77

0006f65c 0100c2bb 01089c81 010241d8 01089138 ntsd!fnBangCmd+0x377

0006f868 0100be0f 80000003 00000001 0006fc2c ntsd!ProcessCommands+0x2ac

0006fab4 01008406 00000000 00000000 ffffffff ntsd!ProcessStateChange+0x687

0006fc2c 01008a14 0006fc4c 00000000 00000000 ntsd!DebugEventHandler+0x6a5

0006fca8 010076dc 00000000 00000000 7ffdf000 ntsd!NtsdExecution+0x13b

0006ff70 010226bf 00000002 002337b0 00232978 ntsd!main+0x3d7

0006ffc0 77e87903 00000000 00000000 7ffdf000 ntsd!mainCRTStartup+0xff

0006fff0 00000000 010225c0 00000000 000000c8 KERNEL32!BaseProcessStart+0x3d

kd> !handle 120 ff fcc77200

processor number 0

PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8

DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.

Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use

0120: Object: fcd32448  GrantedAccess: 00120089

Object: fcd32448  Type: (fced7c40) File

ObjectHeader: fcd32430

HandleCount: 2  PointerCount: 3

Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

看到了吗?我前面提到过,调试器会调用NtQueryObject来得到信息,这个函数读这块内存会调用

ObReferenceObjectByHandle,这又会将pointer count增加到3。然而,NtQueryObject并不需要创建或

复制另外一个句柄,它只是增加引用计数,取得信息,然后释放引用计数。

另外一个进程也可能释放它的句柄,并且只显示调试器的引用。调试器返回的信息关系到1个句柄引用

和2个指针引用。调试器显示完信息后,就会将这个数减去。因为NtQueryObject调用完之后,句柄将关

闭,指针引用将不再存在,这就让这个对象回复到原来的状态。

顺便说一下,DuplicateHandle需要一个源进程句柄作为参数,因此,你必须使用OpenProcess,并指

定复制权限来复制句柄。

调试句柄泄露

我们已经了解了句柄,那么,我们该怎么找到句柄泄露呢?除了一些现成的工具可以使用,比

如”Bounds Checker”,我们还可以自己去找。我们要做的第一件事就是在恰当的时机检查程序的句柄

基数。任务管理器有一个选项可以显示句柄。下面,我们来对程序做一些操作,并看看句柄是增加还是

减少。如果句柄在不断增加,你就要注意是否是有句柄泄露,这都必须靠你自己去确定。

比如,如果你有一个服务端程序,并且你在检查网络连接。你注意到你正在和很多客户端连接,服务

端的连接数不断增加,这是在意料中的。当然你不仅仅只是判断这些东西,当所有的客户端都断开连接

了,所有的网络的连接都应该被清除掉。因此,如果你知道你的句柄计数在不正常的增加,那你就要测

试了。

第一步:确定是否是泄露

如果你正在监视一个程序,确定句柄计数是否正常,观察它是否稳定增加,快或慢。如果你确定这就

是一个泄露或在这个时间点你不确定,那么我们就可以进入下一步,找出可能正在泄露的句柄。如果可

能的话,泄露可能会是一种固定的模式,比如你每次按菜单或打开一个文件。这可以帮助我们减小检查

范围。

第二步:确定类型和对象信息

完成这一步最好的方法就是使用Handle.exe这样的工具或调试器。我写了一个句柄泄露速度很快的程

序,运行之后观察任务管理器,发现有超过65000个句柄泄露。因此,我们需要确定泄露的句柄类型,这

样的话,我们就可以确定哪个API导致这样的问题。

0:001> !handle 0 0

65545 Handles

Type            Count

Event           3

Section         1

File            1

Port            1

Directory       3

WindowStation   2

Semaphore       2

Key             65530

Desktop         1

KeyedEvent      1

在这个夸张的例子中,我们可以看到泄露的句柄类型是”Key”,也就是注册表句柄。下面我们要确定发

生问题的位置。如果我们能找到这个键的信息,我们就能知道哪个键被打开了,然后缩小检查范围。

!handle 0 ff Key

Handle 2de0

Type          Key

Attributes    0

GrantedAccess 0xf003f:

Delete,ReadControl,WriteDac,WriteOwner

QueryValue,SetValue,CreateSubKey,EnumSubKey,Notify,CreateLink

HandleCount   2

PointerCount  3

Name          \REGISTRY\USER\S-1-5-21-789336058-706699826-1202660629-1003\Software

Object Specific Information

Key last write time:  01:10:03. 5/9/2004

Key name Software

我们看到,调试器给我们显示键的信息,并且HKCU\Software被打开的次数最多。我们可以确定这是一个

key对象,也就是说可能使用RegOpenKey或RegOpenKeyEx打开的。我们已经确定了被打开的key,下面看

第三步。

第三步:浏览源码,调试程序

我们现在已经知道了类型,使用的API,还有被打开的资源。下面,我们就可以查看源码,找到这个地

方或在RegOpenKey/RegOpenKeyEx上下断点来看栈回溯。然后我们就可以跟踪句柄,确定源头。Bounds

Checker也能完成类似这样功能。

另外,我们还可以使用在”堆”那节讲述的寻找内存泄露的方法。我们可以将创建句柄的API包装成一

些函数,然后将句柄放入链表中,释放的时候将其从链表中删除即可。缺点是,删除的时候遍历链表会

很慢。

//This method could return the actual key, but would require

//a search of the linked list on a close.

DWORD MyOpenKey(..., phKey)

{

dwResult = RegOpenKey(... phKey);

pTemp = Allocate();

pTemp->pNext = gpHead;

gpHead = pTemp;

gpHead->hKey = phKey;

return dwResult;
}

//This method would allow faster look up in the close but

//would also require a wrapper for all functions.

PMYKEY MyOpenKey(...)
{

hKey = RegOpenKey(...);

pTemp = Allocate();

pTemp->pNext = gpHead;

gpHead = pTemp;

gpHead->hKey = hKey;

return gpHead;

}
上面给出的只是一些伪代码。使用的时候必须加上临界区,或者其它一些方法。增加这个全局链表的目

的是让调试扩展可以遍历它。这对大部分问题来说可能都太不需要了,并且可能使用在一些设置好的工

程中。

轻量泄露

不是所有的句柄泄露都像上面一样那么夸张。比如,可能一次就只有1到2个句柄泄露。你可以在程序

中中断,记住所有的句柄数,然后运行泄露程序,然后再得到一次快照。如果确实有问题存在,你可以

再重现一次问题,但是这次你需要在创建对象的地方下断。

其他提示

下面是其他相关技巧。

对线程的不正确的退出等待

我看到很多程序都使用Sleep()/GetExitCodeThread()循环来等待线程退出。比如:
   do {

Sleep(10);

bReturn = GetExitCodeThread(hThread, &ExitCode);

} while(bReturn && ExitCode == STILL_ACTIVE) ;

MSDN上面说到,线程会将STILL_ACTIVE作为它的返回值。如果这是真的,那我就不用担心这么多了。我

发现有些程序使用MFC的CThread库,并使用这个循环。问题是AfxBeginThread()会传递一个已经创建的

线程的句柄,这个线程会使用AfxEndThread()退出,关闭句柄,如果你没有复制这个句柄的话,这个句

柄这时就是无效的。

一般来说,这也不会造成问题,因为出现错误的话,循环也会退出。但是如果另外一个被创建的对象

也使用这个句柄值,问题就来了。记住,这些句柄值是可以重复使用的。这就意味着这个句柄这时代表

另外一个对象,这可能是另外一个线程对象,从而导致这个循环一直持续。

在Afx之外的其他地方,你也可能会关闭句柄,因此保证一定要复制它。你也不需要一直循环,因为线

程退出后,句柄会变为有信号状态。

WaitForSingleObject(hThread, INFINTE);

GetExitCodeThread(hThread, &ExitCode);

CloseHandle(hThread);

因此,上面这种方法将会是更好的选择。

.DMP文件丢失句柄信息?

如果你有一个.DMP文件,并且使用!handle命令,你可能会得到一个错误信息。因为对于.DMP文件,你

不能使用NtQueryObject来得到句柄信息了。在这种情况下,调试器需要取得所有的信息,并把信息存储

在.DMP文件中。设置一些.dump标志可以不存储,在所有的NTSD/CDB/WinDbg版本中,使用.dump /f

x.dmp(full dump)将不存储句柄信息。另外一种选择就是使用.dump /mh x2.dmp,但是这不是完全转储

,而是一个”mini dump”。

如果你从官网下载最新的调试器的话,有一个新选项/ma可以生成带有句柄信息的完整转储,像这

样.dump /ma x3.dmp。这就是我推荐你使用的能生成句柄信息的用户态DUMP。

总结

句柄是Windows中的一部分,我们必须了解它,并使用它。当你检查内存泄露或其他一些问题的时候,

记住不要忘记检查句柄计数。希望这篇文章帮助你了解什么是句柄,以及如何检查它们。
========

windows核心编程--内核对象和句柄泄漏

http://www.cnblogs.com/vivilisa/archive/2009/03/09/1407277.html

1. 什么是内核对象? 
      内核对象是操作系统分配的一个内存块,该内存块是一个数据结构,用于管理对象的各种信息。
当应用程序要与系统设备进行交互的时候,将使用到内核对象,出于安全的考虑,进程是不能直接访问

内核对象的,操作系统提供了对应的函数来对它们进行访问。
存取符号对象、事件对象、文件对象、作业对象、互斥对象、管道对象、等待计时器对象等都是内核对

象。我们在编程时经常要创建、打开和操作它们。
内核对象由内核拥有,并不是进程所拥有,每个内核对象都有一个计数器来存储有多少个进程在使用它

的信息。进程调用时,计数器增1,调用结束,计数器减1,内核对象计数器为零时,销毁此内核对象.
内核对象有安全描述符的保护,安全描述符描述了谁创建了该对象以及谁能够使用该对象。用于创建内

核对象的函数几乎都有一个指向SEC URITY_ATTRIBUTES 结构的指针作为其参数。
大多数应用程序通过传NULL值创建具有默认安全性的对象。如果想限制别人对对象的访问,就需要单独

创建一个SECURITY_ATTRIBUTES对象并对其初始化。
   
2. 什么是句柄?   
     通俗的概念:句柄是WONDOWS用来标识被应用程序所建立或使用的对象的唯一整数,WINDOWS使用各

种各样的句柄标识诸如应用程序实例,窗口,控制,位图,GDI对象等等。当应用程序访问内核对象时,

将返回一个标示内核对象的东东,这些则是句柄。内核对象的“句柄”,可以惟一地标志对象。当应用

创建内核对象时,返回的就是句柄。此内核对象进程的所有线程都可以利用这个句柄访问内核对象。
 
3. 内核对象创建? 
    当利用creat*函数如creatfilemapping来创建内核对象时,调用该函数的时候内核就为该对象分配

一个内存块,并进行初始化,然后内核再扫描该进程的句柄表,初始化一条记录并放在句柄表中。所谓

的句柄表是指每个进程在被初始化的时候,将被分配一个句柄表,该句柄表将存储内核对象的句柄,该

句柄表包含三个内容:内核对象句柄,内核对象句柄地址,访问屏蔽标志。
 
4. 关闭内核对象 
      无论进程怎样创建内核对象,在不使用该对象的时候都应当通过Bool CloseHandle(HANDLE hobj)

来向操作系统声明结束对该对象的访问。
      这里有一个问题:为什么结束进程能释放所有占用的资源?
      是因为进程在运行时有可能出现内存泄露。在进程终止运行时,系统会自动扫描进程的句柄表。

若表中拥有任何无效项目(进程终止前没关闭的对象),系统将关闭这些对象的句柄。对象的计数器被

置0,内核便会撤销这些对象。
 
5. 如何发现和分析句柄泄露?
       经上面的了解知道当应用程序使用完内核对象之后需要释放资源关闭内核对象。如果没用

CloseHandle,那么将可能导致当前进程无法再打开对应的内核对象,而从系统层面上来说将会大量占用

内存,导致系统变慢。所以有时候发现应用程序进程本身占用内存不多,但是系统内存占用却很高,可

能就因为句柄泄露导致。
下面简单介绍两种判断句柄泄露的方法:
1)、打开任务管理器:选择菜单:查看—选择列,勾上“句柄数“,如下图:

此时任务管理器中多了一列句柄数,如果你发现一个进程句柄数在不断增加,那么可能该进程就存在内

存泄露了。
 
2)使用工具Process Explorer,该工具能够非常明了的看到进程所正在使用的内核对象,当存在句柄对

象时,它能够协助你分析找到原因。下面以一个存在句柄泄露的简单程序为例:
 
该程序在访问1.txt这个文件的时候,没有关闭句柄,导致文件不断被打开。
从这里你还可以看到有GDI handles和USER Handles的概念,我理解为这些handle可以统称为内核对像的

handle,只是对应了不同的内容,handles直接操作文件,注册表这类东西,而gdi与user操作的是可见

的这些东西,gdi与user又有细份,gdi更关注图形,而user更关注交互。
========

句柄泄漏检测工具的实现原理

http://ju.outofmemory.cn/entry/49104
  在Windows编程过程中,很多时候我们都要和句柄打交道,比如窗体句柄,内核对象句柄,GDI句柄

,Windows Multimedia库中的多种句柄等等,以及其他更多未曾使用过的句柄类型。句柄(Handle)是

Windows系统下特有的一种数据类型,其本质定义是基本数据类型PVOID,为什么定义为PVOID呢?因为他

的数据长度跟处理器的位数有关,在32位CPU下句柄可以用一个32位无符号整数来表示,同理在64位CPU

下就用64位的无符号整数来表示。
一、句柄和句柄泄露
  句柄值所代表的含义是句柄表中的一个索引项,但是这个索引并不都是常见的递增索引。 句柄存在

意义离不开对象(Object)的概念,它是处理对象的一个接口,对于所涉及的对象,可以通过相应的句

柄来操作它。句柄的引入主要是操作系统为了避免应用程序直接对某个对象的数据结构进行操作为目的

,用操作句柄来代替操作对象。句柄和对象是多对一的关系,即一个有效句柄一定可以映射到一个有效

对象,一个有效对象可以被多个句柄同时映射。
  根据上述介绍,可以用一个简单的图来表述一下句柄和对象的关系:
图1.1 句柄值、句柄表和对象之间的的关系

  这只是一个最简单的句柄对象关联模型,实际中的句柄表要比这里的复很多,比如Windows内核对象

的句柄表就是一张三级表,并且其表项的索引值是按照4进行递增的,但是其实现的原理最终还是离不开

上图所表示的基本关联方法。需要注意的是,句柄值仅仅只是一个表中的索引值,并不是一个内存地址

,无论这个值是按照什么数量递增的,操作系统都会在处理句柄值的时候,根据句柄值转换成其对应的

句柄表中的项的自然数索引,然后根据句柄表首地址和项偏移算出该项的内存地址,从而访问该句柄表

中的项,然后进一步访问该项所关联的对象体。
  句柄的最基本操作就是打开(创建)和关闭,需要用到某一个对象的时候就去打开或者创建这个对

象,然后就可以得到一个与此对象关联的句柄,后续需要处理对象的时候只需要将句柄交给某一功能函

数,系统负责查找该句柄映射到的对象,并处理之,在处理完毕之后,如果在一定时间内不需要对这个

对象有任何操作,就需要把该句柄关闭。在这里句柄带来的一个问题就浮出水面了:如果打开了某一个

对象的句柄之后,在一段时间内程序确实不需要使用这个对象了,但是却由于疏忽而忘记去关闭这个句

柄,而当程序下一次进入相同的功能逻辑中时又再次打开同一个对象的新句柄,就引起了句柄泄漏。
2   句柄泄漏的影响
  在文章开始的地方已经说了几种句柄类型,虽然他们都叫做“句柄”,但是他们的实现原理、管理

方法以及句柄到对象的映射方法却是不完全相同的,唯一相同的地方即句柄都是通过“表”来管理。
内核对象句柄的管理就是以进程为单位在内核态中维护。
Windows Multimedia库中的设备句柄则是在进程的用户态内存空间(具体到堆)中管理维护。
GDI对象句柄是在进程的用户态内存空间中管理维护。 根据不同种类句柄的实现和管理方法的不同,句

柄泄漏带来的副作用也不相同,以上述三种不同的句柄种类来说明:
内核对象句柄泄漏不一定会导致进程用户态内存空间的内存泄漏,因为句柄和句柄所关联的对象均在内

核态中存在,不会对用户态内存产生增量影响。但是由于句柄表的容量不是无限的,所以当泄漏数量超

过了句柄表的容量,该进程就会出现莫名其妙的行为了,比如悄无声息的退出了。
Windows Multimedia库中的设备句柄泄漏一定会导致内存泄漏,因为其句柄表在用户态内存中,所以每

次泄漏一个句柄至少会增长句柄表中一个项的大小的内存。
GDI对象的句柄表在用户态内存中,并且关联的对象也存在与用户态内存中,所以GDI句柄泄漏也会导致

内存泄漏,并且当GDI句柄数量超过GDI对象句柄表的数量时,进程的界面就会出现绘制混乱等现象。 句

柄泄漏不会对程序的功能造成实时的影响,但是随着泄漏数量的增加,程序的性能在运行过程中会逐渐

受到影响,当泄漏最终超过一定的阈值,程序就可能Crash从而影响了程序的正常功能。
3   句柄泄漏的检测方法
  和内存泄漏一样,句柄泄露也无法做到在编译过程中通过词法语法等分析来提前告警,因为代码是

静态的,程序是动态的,即使是同一段代码,由于执行流程的改变就可能是泄漏和不泄露两种情况。正

因为如此,句柄泄漏也成为了程序开发过程中一个比较常见的问题,特别是在中大规模的软件项目中。

在单一模块内,可能一个开发者会因为疏忽而忘记关闭一个打开的句柄,导致句柄泄漏的发生,不过这

种情况是少数。另一种情况是经验丰富的开发者在单一模块内解决了本模块内可能出现的句柄泄漏,但

是上层模块在使用该模块的时候没有做充足的释放清理工作,这也就导致了句柄泄漏,同时还可能伴有

其他资源的泄漏。对于一个追求高性能和稳定性的项目团队来说,资源泄漏这种问题绝对不应该出现在

团队所开发维护的产品中,虽然不能百分之百的从编码阶段规避这些问题,但至少需要在运行期间有相

应的检测处理机制能发现和定位泄漏的源头,从而可以快速的解决问题。
  目前常用的检测句柄泄漏的方法就是使用WinDbg中的提供的一个扩展命令:!htrace。该命令可以检

测出程序在运行过程中发生泄漏的句柄,并且可以通过堆栈定位到句柄泄漏的位置,但是WinDbg是一个

功能丰富的综合调试工具,对于需求单一的应用场景—比如只需要检测句柄泄漏—WinDbg使用起来就显

得杀鸡用牛刀了。其不便之处在于:命令行式的操作和展示,不直观;每次检测都要去启动或者附加到

目标进程;加载符号文件的过程耗时较多;不能存储检测到的详细数据等。
4   句柄泄漏检测工具的原理
  鉴于现有工具和方法的各种不足之处,以及对于工作效率的提升空间较小的问题,我们的团队打造

了自己的用于快速定位句柄泄漏问题的检测工具—HandleSpy,下文将详细介绍我们的泄漏检测工具的原

理和实现。
  HandleSpy是一个基于统计方法来查找句柄泄漏的工具,其设计的目标是针对可在用户程序内访问和

操作的内核对象句柄泄漏的检测。所谓内核对象就是对象体存在于Windows系统内核中,并由系统内核维

护的一些对象,这样设计的目的一方面是保证系统的安全和稳定性,让用户应用程序避免直接操作关乎

系统底层的重要数据,另一方面兼顾了对象的共享性,在内核中维护的对象可以让系统内的所有进程访

问,并且能为各个进程分配其所需的有限的访问权限。常见的内核对象比如:Process,Thread,Event

,Semaphore,Mutex,File,FileMapping,Key等,但是内核对象的类型并不是只有这么多,这些常见

的只是一小部分,还有更多的内核对象类型存在,并且随着Windows系统的更新换代,内核对象的种类也

在不断的增加,可以使用工具WinObj来查看当前系统内的内核对象的种类,如图4.1所示:

图4.1 查看当前操作系统版本的所有内核对象类型

  上图是在Windows 7 SP1(NT 6.1.7601)系统中截取,约有50种内核对象,而所有这些内核对象都会

涉及到句柄,无论是在内核态还是用户态。
  HandleSpy的工作原理是在检测过程中不断对目标进程的句柄数量进行记录并且输出线条图表,这样

就可以直观的反映出进程的句柄数量变化情况,便于选择目标时间段进行分析。在记录句柄数量的同时

HandleSpy还会把目标进程内感兴趣的句柄类型的操作数据记录下来,并且打上时间戳。在一次检测完成

之后,HadleSpy会把句柄数量线条图表和句柄操作数据通过时间这一关联值进行汇总,在用户选定一个

时间段区间之后HandleSpy会根据句柄值对句柄操作进行匹配过滤,最后过滤出在这个选定时间段内打开

或者创建,但是没有被关闭的句柄操作和相应的句柄值,根据其句柄操作的堆栈信息结合符号文件就可

以定位到程序的源代码中的指定行。
  在这里给出一个应用实例,用HandleSpy来试试检测Chrome浏览器的句柄资源泄漏情况。打开Chrome

之后先打开一任意一个页面,然后我们来检测一下Chrome在进行打开新的标签页然后关闭这样的操作的

时候,是否会产生句柄资源的泄漏。详细操作步骤如下:
打开Chrome,然后打开一个页面,在这里使用了12306的网站,然后启动HandleSpy对Chrome进程进行检

测,如图4.2所示结果: 
图4.2 没有打开新的标签页时Chrome进程的句柄数量

打开一个新的标签页,观察句柄数量的变化,如图4.3所示: 
图4.3 打开一个新的标签页时Chrome进程的句柄数量

等待句柄数量稳定之后关闭刚才新打开的标签页,句柄数量变化如图4.4所示: 
图4.4 关闭新打开的标签页时Chrome进程的句柄数量

停止检测,选择一个时间区间然分析句柄泄漏情况,如图4.5所示: 
图4.5 通过工具进行泄漏检测得出的泄漏结果

  这里可以看到未操作之前句柄数是893,进行操作之后句柄数是899,通过数量可以得出句柄泄漏数

为6个,但是这里只显示出来了5个,有一个未显示出来,因为它是我们不关心的句柄类型,并不是由应

用程序的代码引出的(实际上这是Windows 7系统的一个ALPC Port类型的句柄,系统会负责释放,只不

过是延迟释放的)。还有一点要说明的情况是,如果检测时间在稍微长一点,那么上图中2~5号句柄也会

被关闭,从而不会显示出来,因为在举例的时候检测时间比较短,而上述四个句柄在检测时间段内没有

被关闭,从某种意义上来说也是一种泄漏,因为它们在一段时间内打开但是没有关闭。关于Chrome的句

柄泄漏的实例就到这里,其中第一个CreateMutexW操作可以肯定的说是Chrome的一个句柄泄漏,因为经

过多次检测发现这个句柄在每次打开新的标签页的时候都会创建,并且确实没有释放,由于没有Chrome

的符号文件,所以在这里无法看到具体的源文件,函数和行号。
5   句柄泄漏检测工具的实现方法详解
5.1 句柄数量的“实时”检测
  对于目标进程我们需要获取检测过程中其所持有的内核对象的句柄数量的变化情况,这样才可以分

辨出在一个时间段内是否发生了残留的未关闭句柄。完成这一功能的方法有多种:
使用性能计数器收集进程句柄的数:此方法是通过Windows系统提供的性能计数器组件来添加一个代表目

标进程的句柄数量的计数器,然后读取计数器的值得到句柄数。这种方法的缺点是只能根据进程名称指

定计数器,当存在多个同名进程时,无法区分单个进程,所以并不适用于HandleSpy。
使用未公开的API:ZwQueryInformationProcess获取目标进程句柄数:这个函数是Windows系统的未公开

API,存在于Ntdll.dll模块中,该函数功能非常多,其中有一项即可获取目标进程的内的句柄数。
使用现有的API:GetProcessHandleCount获取目标进程句柄数:这个函数其实是上述

ZwQueryInformationProcess函数的一个封装导出并且已经形成文档,在SDK中可以直接使用。唯一的缺

点是只在Windows XP SP1之后系统中才可使用,HandleSpy目前采用的就是这种获取句柄数的方法。
  有了获取句柄数的方法之后就需要考虑如何持续的检测进程的句柄数变化情况,理想的情况是当进

程的句柄数发生变化时进行采集,这样就可以保证句柄数的每次变化都可以被记录下来以达到实时的目

的,但是要达到这个目标的话HandleSpy的部分功能就要放在驱动中去实现了。在内核中截获系统对进程

的句柄表操作的关键位置,当操作的句柄表属于目标进程时就记录下新的句柄数,对于一个轻量的应用

工具来说使用这种技术来完成这个功能成本相对就高了,所以HandleSpy并没有使用这种方法,而是用了

定时查询的方法。
  定时查询的方法就是设置一个周期性的定时器,在计时到期时记录目标进程的句柄数。比如设置定

时器为1秒,每隔1秒得到一个句柄数,然后将所有数据用线条图表绘制出来,就是一个句柄数量变化的

波形图。很容易想到,这种方法与“实时”概念差别很大,因为在1秒的时间间隔内并没有对句柄数进行

计数,而在这1秒内一个进程的句柄数量可能发生多次各种幅度的变化,那么这会影响到对泄漏句柄的计

数么?其实不会,因为在1秒的计数空白时间内如果发生了泄漏那么泄漏的句柄数肯定会在下次获取句柄

数量时被记录,而如果没有发生句柄泄漏,那在这1秒内的句柄数量波动情况就可以忽略,所以使用定时

查询的方法是较为合适的。
5.2 句柄操作的截获
  句柄数量只是表象,而句柄操作才是关乎句柄泄漏的重要数据,因为最终筛选泄漏项的时候是根据

句柄操作和句柄值来进行过滤的。获取句柄操作的基本思路是,对句柄进行分类,然后把需要检测的句

柄类型的所有相关操作都截获,包括创建(打开)和关闭,比如一个目标进程打开或者创建了一个句柄

0x0000000C,HandleSpy会记录下这个操作函数的堆栈和句柄值,然后目标进程又对这个句柄值

0x0000000C进行了关闭操作,HandleSpy同样记录下了这个操作函数的堆栈和句柄值。最终HandleSpy在

处理所有捕获到的句柄操作时,会对所有打开或者创建操作根据句柄值向后匹配一个关闭操作,如果匹

配到了句柄值相同的关闭操作,那么这个句柄值就不是泄漏句柄了,而如果没有匹配到,那就说明在这

段时间内该句柄是泄漏项了。
  要对句柄操作进行截获,毫无疑问的要对系统API进行Inline Hook,一般少数的函数Hook,可以自

己实现Hook代码,但是如果需要Hook的函数数量过多,还是应该使用现有的成熟稳定的第三方库,

HandleSpy在实现的时候使用了Detours库进行Hook操作。因为句柄操作相关的API数量众多,并且涉及到

Unicode和Ansi版本的API的区分,所以这个功能也是HandleSpy在实现过程的核心工作了。此外在进行

Hook操作的时候还应该确定一下选取的目标函数的在系统中的层次。
  Windows系统句柄相关的API大部分都位于Kernel32.dll模块中,而注册表相关的句柄操作API位于

Advapi32.dll模块中,而这些所有的上层模块中的API最终都要通过ntdll.dll模块中的API进入到系统内

核。这种层次模型如图5.2.1所示:
图5.2.1 句柄相关API的层次模型

  由此可以看出,如果要进行Hook就可以有三个层次可以供选择,内核层由于需要驱动支持,所以不

予考虑。而剩下Win32子系统层和Nt Native层这两个层次上面都是可以进行Hook操作的,并且各有各的

优缺点。HandleSpy的第一个版本选择了在Win32子系统层进行Hook,后来又实现了在Nt Native层进行

Hook的版本。在这里我们选取Event类型的对象句柄相关的操作API对各个层次的实现方法进行介绍。
5.2.1  在Win32子系统层进行Hook
  与Event相关并且会造成进程句柄数量增加的Windows API有两个:CreateEvent和OpenEvent。再考

虑深入一点,区分一下Unicode和Ansi版本的API就得到了四个相关函数CreateEventA,CreateEventW,

OpenEventA,OpenEventW。在早期的Windows 操作系统中有这样一个机制,Ansi版本的系统API会调用同

名的Unicode版本的系统API,所以只需要对Unicode版本的系统API进行Hook操作就可以同时截获到Ansi

版本的系统API调用。
  但是在Windows 7之后的版本中,Windows为了实现MinWin框架而引入了ApiSetScheme机制,使系统

API的导出和实现分离。关于ApiSetScheme机制的相关内容无法在这里展开介绍,只需要知道这一机制导

致调用CreateEventA函数的时候不会再去调用CreateEventW,所以如果要截获所有句柄相关的操作的API

就必须把Unicode和Ansi版本的同名API都进行Hook。关于Windows 7和之前的Windows版本在这个问题上

的处理方法对比如下图5.2.1.1所示:
图5.2.1.1 ApiSetScheme机制引起的变化

  所以说如果选择在Win32子系统层次进行Hook,那么就不得不面对这些问题,对系统进行版本判断,

然后采用不同的Hook目标。但是这一选择的优点是所有的API都是文档化的,并且更接近开发者平时所使

用的一些API,所以定位出来的泄漏问题是由于开发者自身编码造成的可能性就比较大。        
  打开和创建句柄的操作都已经处理完了,还需要处理关闭句柄的操作,同样在Win32子系统层关闭句

柄的操作是CloseHandle,并且几乎所有在用户态暴露出来的内核对象的句柄都使用这个API来进行关闭

操作,所以对于其他类型的内核对象句柄无需再去重复处理他们的关闭函数。
5.2.2  在Nt Native层进行Hook
  在Win32子系统层的大部分API会调用一个Ntdll.dll中的API,例如CreateEventA->NtCreateEvent,

CreateEventW->NtCreateEvent。这样的话我们就可以只Hook住NtCreateEvent函数,而不用区分到底是

上层的Unicode版本函数还是Ansi版本函数。而在Nt Native层的句柄关闭操作API是NtClose函数。
  这样做的好处是可以减少被Hook函数的数量,但是缺点是由于所有需要Hook的函数都是未文档化的

API,其原型还需要去搜集。另外一点问题是由于Hook的层次相对很低,所以在写Hook函数的时候就受到

较大限制,不能在Hook函数中使用已经被Hook的任何函数,否则就会引起循环调用耗尽程序栈空间,导

致目标进程Crash。
  通过上述介绍,可以选取选择任意一个方案来实现HandleSpy的功能,Hook函数中只需要记录函数调

用栈和句柄值,并且存储好数据,在检测完毕时通过前面介绍过的过滤方法即可找到句柄泄漏的项。而

要实现HandeSpy支持的句柄类型更加全面,就需要仔细推敲Windows的内核对象类型,然后针对每一种类

型的内核对象尽量找到所有的操作相关的函数。
6   结语
  HandleSpy开发完成之后的第一个实际应用是在QQ的某个功能模块内的句柄泄漏,在解决这个问题的

过程中暴露出了HanldeSy的许多问题,经过不断的修正,最终解决了这个句柄泄漏的问题,并且使得

HandleSpy的功能也更加准确。
========

Windows 句柄泄露学习总结相关推荐

  1. Windows句柄表学习笔记 —— 句柄表全局句柄表

    Windows句柄表学习笔记 -- 句柄表&全局句柄表 句柄表 实验一:在WinDbg中查看句柄表 第一步:打开一个Win32窗口程序 第二步:编译并运行以下代码 第三步:查看运行结果 第四步 ...

  2. 内存映射文件——Windows核心编程学习手札之十七

    内存映射文件 --Windows核心编程学习手札之十七 与虚拟内存一样,内存映射文件保留地址空间,并将物理存储器提交给该区域,差别在于所提交的物理存储器是磁盘上有文件存在的空间,而非系统的页文件,一旦 ...

  3. 内核对象——Windows核心编程学习手札系列之三

    内核对象 --Windows核心编程学习手札系列之三 内核对象可供系统和应用程序使用来管理各种各样的资源,如进程.线程.文件等,是内核分配的一个内存块,只能又内核访问,该内存块是一种数据结构,它的成员 ...

  4. windbg调试实例(4)--句柄泄露

    同事介绍了一篇调试句柄泄露的blog文章,今天有空看了一下,这家伙用视频的方式录下整个调试的过程,学习一目了然,真是有心.鉴于学习的过程总结一下能加深记忆,所以我这里做个记录,感兴趣的朋友可以看这里: ...

  5. 未处理异常和C++异常——Windows核心编程学习手札之二十五

    未处理异常和C++异常 --Windows核心编程学习手札之二十五 当一个异常过滤器返回EXCEPTION_CONTINUE_SEARCH标识符时是告诉系统继续上溯调用树,寻找另外的异常过滤器,但当每 ...

  6. 结束处理程序——Windows核心编程学习手札之二十三

    结束处理程序 --Windows核心编程学习手札之二十三 使用SEH可以只关注程序要完成任务,而运行中发生的错误,系统将会发现并通知.Windows引入SHE是为了便于操作系统的开发,使用SHE所造成 ...

  7. 插入DLL和挂接API——Windows核心编程学习手札之二十二

    插入DLL和挂接API --Windows核心编程学习手札之二十二 如下情况,可能要打破进程的界限,访问另一个进程的地址空间: 1)为另一个进程创建的窗口建立子类时: 2)需要调试帮助时,如需要确定另 ...

  8. 堆栈——Windows核心编程学习手札之十八

    堆栈 --Windows核心编程学习手札之十八 堆栈可以用来分配许多较小的数据块,可以不考虑分配粒度和页面边界之类的问题,但分配和释放内存块的速度比其他机制要慢,并且无法直接控制物理存储器的提交和回收 ...

  9. 线程与内核对象的同步——Windows核心编程学习手札之九

    线程与内核对象的同步 --Windows核心编程学习手札之九 用户方式下的线程同步机制具有速度快的特点,但有其局限性,对于许多应用程序来说,并不合适.例如,互锁函数家族只能在单值上运行,根本无法使线程 ...

最新文章

  1. python 选择题 多线程_python多线程一些知识点梳理
  2. Racktables(一)的资产管理软件安装配置
  3. python自定义线程
  4. Golang init函数执行顺序
  5. 设计模式-单例模式(1)
  6. One order search dynamic sql statement生成位置
  7. java foreach和for循环区别_java相关:老生常谈foreach(增强for循环)和for的区别
  8. 一位质量总监的“发飙”
  9. python日志_python 日志模块
  10. [2019杭电多校第四场][hdu6621]K-th Closest Distance(主席树)
  11. 利用POI导出excel
  12. 【windows】修复win7便签
  13. Win7梦幻桌面字体有问题怎么办
  14. h5是可以一键打包小程序的_Vue项目快速输出到小程序H5-如何将h5打包成小程序-h5小程序怎么做...
  15. 列车停站方案_城市轨道交通列车停站方案
  16. Flink Interval Join使用以及源码解析
  17. vscode eslint beautify 格式化 html
  18. 对话行癫:解密阿里云顶层设计和底层逻辑 1
  19. 四川创峄信息科技有限公司企业公章管理
  20. Ubuntu_apt-update_Certificate-verification-failed

热门文章

  1. Terraform的vpc网络自动化配置解决方案
  2. xgboost时间序列预测matlab,LightGBM和XGBoost实现时间序列预测(2019-04-02)
  3. python培训班大概多少钱
  4. 双通道14位、500 MSPS采样率的AD9684 高速并行LVDS ADC接口介绍
  5. 中国计算机协会推荐学术会议、期刊(人工智能)收藏直连版
  6. pyhon3爬取百度搜索结果
  7. loadrunner12录制事件一直卡在11就不动了,大佬们,求助啊!!!
  8. 移动端+京东移动端首页制作
  9. LeetCode207: 课程表(字节手撕算法拓扑排序)
  10. 将微信收到的特定文件自动拷贝到指定目录