目录
1、进程的虚拟内存分区与小于0x10000的小地址内存区
1.1、进程的虚拟内存分区
1.2、小于0x10000的小地址内存区
2、保存线程上下文的CONTEXT结构体
3、从汇编代码角度去理解多线程运行过程的典型实例
4、调用TerminateThread强制结束线程会导致线程中的资源没有释放的问题
5、调用WaitForSingleObject监测目标程序有没有退出
5.1、WaitForSingleObject函数说明
5.2、调用WaitForSingleObject函数监测线程或进程是否已经退出
5.2.1、子进程实时监测主进程是否已经退出,主进程退出了,则子进程要自动退出
5.2.2、启动子进程后等待子进程执行完退出后,再执行后续操作
6、死锁检测工具LockCop
7、如何以管理员权限启动一个进程?
8、如何判断程序是否以管理员权限运行?
9、DLL延迟加载与DLL远程注入
9.1、DLL延迟加载
9.2、DLL远程注入
10、SEH结构化异常与C++异常
10.1、SEH结构化异常
10.2、C++异常
10.3、SEH结构化异常与C++异常的区别
11、最后
VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931C++软件分析工具从入门到精通案例集锦(专栏文章正在更新中...)https://blog.csdn.net/chenlycly/article/details/131405795C/C++基础与进阶(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_11931267.html开源组件及数据库技术(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_12458859.html网络编程与网络问题分享(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_2276111.html 《Windows核心编程》是为从事Windows软件开发的C/C++程序员精心设计的,是Windows开发人员的必备参考。第5版全面覆盖Windows XP,Windows Vista和Windows Server 2008中的170个新增函数和Windows特性。书中还讲解了Windows系统如何使用这些特性,以及应用程序如何去充分使用这些特性。我通过阅读本书,解决了开发过程中遇到的多个问题,对Windows编程技术及相关的细节点有了进一步的理解和认知。已经完成构建的《C++软件调试与异常排查技术从入门到精通》技术专栏,专栏中很多基础或核心内容均来自该书。强烈推荐大家研读一下这本书,特别是有工作经验的Windows开发人员,效果更好!
本文根据最近几年的项目实践,大概罗列一下项目中用到的来自于本书的若干知识点与技能,供大家借鉴或参考。
写这篇文章是受一篇文章下的一个评论的启发,分享一下工作中用到的来自《Windows核心编程》的若干知识点与技能,旨在说明本书的重要性,希望大家能够去仔细研读这本书!
我们要多读书,多好书!经典的书,要多读几遍才好,每次都会有新的收获!
1、进程的虚拟内存分区与小于0x10000的小地址内存区
在13.2节(虚拟地址空间的分区)中讲到了进程的虚拟内存分区和小地址内存区(又称空指针赋值分区或空指针内存区)。
1.1、进程的虚拟内存分区
程序的虚拟内存主要分为用户态虚拟内存区和内核态虚拟内存区,比如对于一个32位程序,系统会给进程分配4GB的虚拟内存,默认情况下用户态虚拟内存占2GB,内核态虚拟内存占2GB。
在Visual Studio的工程属性中可以开启/LARGEADDRESSAWARE启用大地址选项:(链接器 -> 系统 -> 启动大地址)
这样用户态的虚拟内存就会从默认的2GB扩充到3GB,内核态内存也就变到了1GB。之前我们新版本的32位软件引入了开源的WebRTC库,程序运行起来后占用了大量的虚拟内存,在执行一些消耗内存的操作时,很容易将用户态的虚拟内存干到接近2GB的上限,当接近2GB后,再执行到申请较大内存的代码时,会申请失败。如果调用malloc申请内存,则会返回NULL;如果使用new申请内存,则会抛出bad_alloc异常。
在测试过程中发现,当执行耗内存的操作时,程序会时不时地发生闪退,但程序中安装的异常捕获模块并没有感知到。于是每次启动程序运行时,都将Windbg挂上去,复现问题后,Windbg感知到中断下来。发现是WebRTC开源库中调用了abort函数触发的中断,abort函数内部会产生一个特殊的异常,会让正在调试的调试器中断下来。然后查看函数调用堆栈发现,WebRTC业务代码中调用malloc去动态申请内存时申请失败了,malloc返回NULL,然后WebRTC内部认为申请不到内存是Fatal致命的,在封装的接口中直接调用了abort强行将程序终止了(程序被强行终止了,这和程序发生闪退是吻合的)。因为并没有产生C++异常或者崩溃,所以异常捕获模块没有好感知到,就不会生成dump文件。
之所以导致程序闪退,是因为WebRTC内部调用abort接口强行将程序终止运行了。而动态申请内存失败,很有可能是用户态虚拟内存接近或达到上限了,没有找到一段连续的内存可供分配了,所以malloc返回NULL,内存申请失败了。
用户态虚拟内存达到上限,可能有两个原因:
1)程序中有内存泄漏,多次泄漏将内存耗尽了
有内存泄漏的代码频繁地被执行,内存持续地被占用不释放,导致程序占用的虚拟内存接近或达到用户态虚拟内存的上限。对于这种情况,则要排查内存泄漏的点,彻底消除内存泄漏。
2)程序中包含了多个业务模块,确实要占用大量的用户态内存
程序中包含了多个业务模块,确实要占用大量的用户态虚拟内存。比如我们软件中使用WebRTC开源库,很庞大,初始化时会申请大量的内存(一上来很多业务就会将内存分配好),即启动时就会占用大量的内存。软件除了WebRTC库,还包含了多个业务模块,这样加起来占用的虚拟内存就更大了。
对于这种情况,我们需要优化代码,减少程序对内存的占用。有一个重要的原则,需要使用时再去申请,不再使用时立即将内存释放掉。
后来我们用Process Explorer工具观察软件在运行过程中占用的总虚拟内存大小(用户态的虚拟内存),在崩溃时间点左右,程序的用户态内存已经接近32位程序默认的2GB的用户态虚拟内存的上限了。进一步排查,排除了内存泄漏的存在,是因为软件中的多个业务模块确实需要占用大量的内存空间。于是从上层到底层对内存占用进行了一些优化,虽然减少出问题的概率,但还是会出现闪退的问题。
因为时间仓促,涉及到底层的多个模块,一时半会很难进行较大的优化。后来就开启了大地址选项,即将32位程序的2GB的用户态虚拟内存扩充了3GB,暂时将这个问题规避掉。经后续测试验证,确实有效地解决了问题。
至于如何开启32位程序的大地址模式,只要在exe主程序的工程属性中开启/LARGEADDRESSAWARE大地址选项即可。该配置选项的相关截图,上面已经给出来了。
在这里,给大家重点推荐一下我的几个热门畅销专栏:
专栏1:(该专栏订阅量接近350个,有很强的实战参考价值,广受好评!专栏文章持续更新中,预计更新到200篇以上!)
C++软件调试与异常排查从入门到精通系列文章汇总https://blog.csdn.net/chenlycly/article/details/125529931
本专栏根据近几年C++软件异常排查的项目实践,系统地总结了引发C++软件异常的常见原因以及排查C++软件异常的常用思路与方法,详细讲述了C++软件的调试方法与手段,以图文并茂的方式给出具体的实战问题分析实例,带领大家逐步掌握C++软件调试与异常排查的相关技术,适合基础进阶和想做技术提升的相关C++开发人员!
专栏中的文章均是通过项目实战总结出来的(通过项目实战积累了大量的异常排查素材和案例),有很强的实战参考价值!专栏文章还在持续更新中,预计文章篇数能更新到200篇以上!
专栏2:
C/C++基础与进阶(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_11931267.html
以多年的开发实战为基础,总结并讲解一些的C/C++基础与进阶内容,以图文并茂的方式对相关知识点进行详细地展开与阐述!专栏涉及了C/C++领域的多个方面的内容,同时给出C/C++及网络方面的常见笔试面试题,并详细讲述Visual Studio常用调试手段与技巧!
专栏3:
开源组件及数据库技术https://blog.csdn.net/chenlycly/category_12458859.html
以多年的开发实战为基础,分享一些开源组件及数据库技术!
1.2、小于0x10000的小地址内存区
Windows系统专门预留了一块从0到0x10000的小地址内存区,又称空指针内存区或空指针赋值内存分区:
专门为了帮程序员定位访问空指针的问题。
当访问到这个内存区域时,就会触发内存访问违例,系统会强制将进程终止掉。为什么使用空指针就会访问到这个小地址内存区呢?比如类对象指针值为NULL,代码中没有判断指针是否为空,直接使用该指针去访问指向的类的成员变量,即将NULL值作为C++类对象的首地址,那么类对象的数据成员的内存地址就是相对于所在对象地址NULL的偏移,即成员变量的地址是个很小的内存地址,而访问变量的值就是去读或写该变量的内存地址中的内容,就是去访问变量的内存,这样就会去访问一个很小的内存地址,即访问空指针赋值区,就会触发内存访问违例,引发崩溃。
这里涉及了类对象中的成员变量内存在类对象中排布问题,成员变量的内存地址都是相对于所在类对象的内存地址的偏移!
空指针是引发程序异常的常见原因之一。另外,访问已经释放内存的野指针,分析思路也是类似的,即类的成员变量的地址是相对所在类对象地址的偏移。
这个地方有一点需要提一下,使用空指针去调用类的成员函数是否一定会导致崩溃呢?答案是不一定,不一定会导致崩溃,这主要看有没有访问不应该访问的内存。比如被调用函数中没有访问类的数据成员(成员变量),即不会访问类对象的内存,就不会崩溃。
调用的函数执行的是代码段上的指令,调用函数本身并不会有异常。
但如果使用空指针去调用虚函数,会涉及到虚函数的二次寻址,即先去访问类对象中隐含的虚函数表指针中的内容(存放的虚函数表的首地址),虚函数表指针变量的地址就是其所属的C++类对象的首地址,就会因为空指针访问很小的内存地址,引发内存访问违例。
虚函数调用,在C++代码中就是一句函数调用,看不出完整的调用过程,需要从汇编代码的角度去看,相关内容可以参见我之前写的文章:
几秒读懂C++虚函数调用的汇编代码实现https://blog.csdn.net/chenlycly/article/details/121046234
2、保存线程上下文的CONTEXT结构体
在7.7节(在实际上下文中谈CONTEXT结构体)中讲到了保存线程上下文信息的CONTEXT结构体。在多个线程之间切换时会使用到系统定义的CONTEXT结构体:
//
// Context Frame
//
// This frame has a several purposes: 1) it is used as an argument to
// NtContinue, 2) is is used to constuct a call frame for APC delivery,
// and 3) it is used in the user level thread creation routines.
//
// The layout of the record conforms to a standard call frame.
//typedef struct _CONTEXT {//// The flags values within this flag control the contents of// a CONTEXT record.//// If the context record is used as an input parameter, then// for each portion of the context record controlled by a flag// whose value is set, it is assumed that that portion of the// context record contains valid context. If the context record// is being used to modify a threads context, then only that// portion of the threads context will be modified.//// If the context record is used as an IN OUT parameter to capture// the context of a thread, then only those portions of the thread's// context corresponding to set flags will be returned.//// The context record is never used as an OUT only parameter.//DWORD ContextFlags;//// This section is specified/returned if CONTEXT_DEBUG_REGISTERS is// set in ContextFlags. Note that CONTEXT_DEBUG_REGISTERS is NOT// included in CONTEXT_FULL.//DWORD Dr0;DWORD Dr1;DWORD Dr2;DWORD Dr3;DWORD Dr6;DWORD Dr7;//// This section is specified/returned if the// ContextFlags word contians the flag CONTEXT_FLOATING_POINT.//FLOATING_SAVE_AREA FloatSave;//// This section is specified/returned if the// ContextFlags word contians the flag CONTEXT_SEGMENTS.//DWORD SegGs;DWORD SegFs;DWORD SegEs;DWORD SegDs;//// This section is specified/returned if the// ContextFlags word contians the flag CONTEXT_INTEGER.//DWORD Edi;DWORD Esi;DWORD Ebx;DWORD Edx;DWORD Ecx;DWORD Eax;//// This section is specified/returned if the// ContextFlags word contians the flag CONTEXT_CONTROL.//DWORD Ebp;DWORD Eip;DWORD SegCs; // MUST BE SANITIZEDDWORD EFlags; // MUST BE SANITIZEDDWORD Esp;DWORD SegSs;//// This section is specified/returned if the ContextFlags word// contains the flag CONTEXT_EXTENDED_REGISTERS.// The format and contexts are processor specific//BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];} CONTEXT;
线程是分配CPU时间片的基本单元,线程失去CPU时间片后,线程就会被挂起,进入了睡眠状态;线程获取到了CPU时间片,则会从睡眠中唤醒,继续执行。
线程失去时间片时,会将线程上下文中的寄存器等信息保存到CONTEXT结构体中,然后进入挂起(睡眠)状态;当线程获取CPU时间片后,则会从之前保存的CONTEXT结构体中把之前保存的寄存器信息给读出来,放到当前的寄存器中,然后线程继续接着挂起前的状态继续运行。这点对于从汇编角度去理解多线程代码的执行细节很重要。
3、从汇编代码角度去理解多线程运行过程的典型实例
了解汇编,不仅可以辅助分析C++软件异常问题,还可以从汇编的角度去理解很多高级不好理解的代码执行细节。在8.1节(原子访问:Interlocked 系列函数)中讲到的那个多线程实例,虽然很简单,但它是从汇编角度去理解多线程代码执行细节的典型实例。从汇编代码的角度才能将这个实例理解透彻的!
该例子中定义了一个long型的全局变量,然后创建了两个线程,线程函数分别是ThreadFunc1和ThreadFunc2,这两个线程函数中均对g_x变量进行自加操作(在访问共享变量g_x时未加锁同步),相关代码如下:
// define a global variable
long g_x = 0;DWORD WINAPI ThreadFunc1(PVOID pvParam)
{g_x++;return 0;
}DWORD WINAPI ThreadFunc2(PVOID pvParam)
{g_x++;return 0;
}
这里有个问题,当这两个线程函数执行完后,全局变量g_x的值会是多少呢?一定会是2吗?
实际上,在两个线程函数执行完后,g_x的值不一定为2。这个实例需要从汇编代码的角度去理解,从C++源码看则很难搞懂,这是一个从汇编代码角度去理解代码执行细节的典型实例。
熟悉汇编代码,不仅可以辅助排查C++软件异常,还可以理解很多高级语言无法理解的代码执行细节。
有些人可能觉得,代码中就是一个自加的操作,一下子就执行完了,中间应该不会被打断。会不会被打断,其实要看汇编代码的,这行C++源码对应三行汇编代码,对g_x变量的自加这句C++代码,对应的汇编代码如下:
MOV EAX, [g_x] // 将g_x变量的值读到EAX寄存器中
INC EAX // 将EAX中的值执行自加操作
MOV [g_x], EAX // 然后将EAX中的值设置到g_x变量内存中
看C++代码:g_x++,只能保证CPU执行某条汇编指令时不会被打断(汇编指令是CPU执行的最小粒度),但3条汇编指令,指令与指令之间是可能被打断的。
为什么说两个线程执行完成后g_x变量的值是不确定的呢?比如可能存在两种场景:
1)场景1(最终结果g_x=2)
假设线程1先快速执行了三行汇编指令,未被打断,g_x的值变成1。然后紧接着线程2执行,在g_x=1的基础上累加,最终两个线程执行完后,g_x等于2。
2)场景2(最终结果g_x=1)
假设线程1先执行,当执行完前两条汇编指令后,线程1失去时间片(线程上下文信息保存到CONTEXT结构体中):
即线程1前两条汇编指令执行完,第3条汇编指令还没来得及执行,就失去CPU时间片了!
线程2执行,一次执行完三条指令,当前g_x=1。然后线程1获得CPU时间片,因为上次执行两条汇编指令后EAX寄存器中的值为1,因为线程1获取了时间片,保存线程上下文信息的CONTEXT恢复到线程1中,EAX=1,继续执行第3条指令,执行完后g_x还是1。
所以,这个多线程问题,需要从汇编代码的角度去理解,从C++源码的角度很难想明白。
从本例可以看出,即使是简单的变量自加操作,多线程操作时也要做同步,可以加锁,可以使用系统的原子锁Interlocked系列函数,比如原子自加函数InterlockedIncrement和原子自减函数InterlockedDecrement:
LONG InterlockedIncrement( LPLONG volatile lpAddend // variable to increment
);LONG InterlockedDecrement( LPLONG volatile lpAddend // variable address
);
这些原子函数能保证会被原子地被执行,中间不会被打断。 修改后的代码为:
// define a global variable
long g_x = 0;DWORD WINAPI ThreadFunc1(PVOID pvParam)
{InterlockedIncrement(&g_x); // 调用原子锁函数InterlockedIncrement实现自加return 0;
}DWORD WINAPI ThreadFunc2(PVOID pvParam)
{InterlockedIncrement(&g_x); // 调用原子锁函数InterlockedIncrement实现自加return 0;
}
关于为什么要学习汇编以及学习汇编有哪些好处,可以查看我之前写的文章:
为什么要学习汇编?学习汇编有哪些好处?https://blog.csdn.net/chenlycly/article/details/130935428 关于排查C++软件异常所需要掌握的基础汇编知识,可以查看我之前写的文章:
分析C++软件异常需要掌握的汇编知识汇总https://blog.csdn.net/chenlycly/article/details/124758670
4、调用TerminateThread强制结束线程会导致线程中的资源没有释放的问题
在6.5节(终止运行线程)中讲到了调用TerminateThread结束线程的相关问题。
创建线程时需要指定线程函数,当代码执行到线程函数中,线程才真正运行起来。线程函数执行完了,线程函数退出了,线程也就退出了。线程结束了,可能是线程函数自然执行完成了,也可能是人为地调用TerminateThread接口强制将进程退出了。
当线程函数自然地执行完退出时,函数中局部变量的栈内存会自动被释放,函数中的资源才会自动地被释放。如果线程函数执行的过程中被TerminateThread函数强行终止了,则会导致函数中的资源不会被释放,比如:
1)线程函数中局部变量的栈内存没有释放
函数没有正常地执行完成,函数中局部变量占用的栈内存没有释放。
2)线程函数中申请的资源没有释放比如线程函数中刚拿到一个锁,就被强行终止了,释放锁的代码没有执行到,导致线程结束了锁也没被释放掉。有次我们在用Windbg排查多线程死锁问题时,一个线程在申请锁,但通过查看锁信息得知该锁被另一个线程占用着,但我们查看进程的线程列表时并没有找到占用锁的那个线程id,估计线程已经结束了,可能就是因为该线程中途被Terminate掉了,还没将锁释放掉,线程就被终止了,所以出现了这个问题。
此外,线程终止与进程终止是有区别的,线程被强行终止时系统不会去释放线程中的资源,但进程终止时系统会释放进程所有的资源。
关于从C++软件调试实战的角度去看多线程编程中的若干细节问题,可以查看我之前写的文章:
从C++软件调试实战的角度去看多线程编程中的若干细节问题https://blog.csdn.net/chenlycly/article/details/134358655
5、调用WaitForSingleObject监测目标程序有没有退出
在4.4节(子进程)中讲到了可以通过WaitForSingleObject监测目标程序有没有退出。
5.1、WaitForSingleObject函数说明
WaitForSingleObject 函数可以等待以下对象:
- 更改通知
- 控制台输入
- 可等待计时器
- 内存资源通知
- 事件(Event)
- 互斥量(Mutex)
- 信号量(Semaphore)
- 线程
- 进程
当等待对象从非触发状态变成触发状态(发出信号), WaitForSingleObject就会返回;或者是等待时间超时,WaitForSingleObject也会返回。关于WaitForSingleObject的返回值如下所示:
对于事件,分有信号和无信号两个状态,当事件对象编程有信号时,WaitForSingleObject立即返回。
对于互斥量和信号量,调用WaitForSingleObject获取所有权,即WaitForSingleObject返回WAIT_OBJECT_0时获取他们的所有权,然后调用ReleaseMutex和ReleaseSemaphore释放对象的所有权。
对于线程和进程,线程和进程创建时无信号,当线程和进程退出时对应的句柄就变成了有信号,这样WaitForSingleObject就返回了。可以通过WaitForSingleObject返回,判断线程或进程是否已经退出了:
WaitForSingleObject(hThread, INFINITE); // 参数INFINITE表示无限等待
5.2、调用WaitForSingleObject函数监测线程或进程是否已经退出
在主程序运行的过程中启动了一个子进程,有时主进程要等待子进程处理结果后根据返回的信息再控制后续代码的执行,有时子进程需要感知主进程是否已经退出,这两种情况都需要感知另一个进程是否已经退出。对于线程在某些场合下页存在类似的需求。
5.2.1、子进程实时监测主进程是否已经退出,主进程退出了,则子进程要自动退出
主进程在运行过程中启动了一个子进程,启动子进程时将主进程的进程id传给子进程。子进程是依赖于主进程存活的,如果主进程退出或者崩溃了,则子进程就没有存在的意义了,要自动退出!所以子进程要实时监测主进程的状态,监测主进程有没有退出(包括崩溃闪退)。
可能有人会说,主进程可以在退出时通知子进程,子进程收到通知后再自行退出。但主进程可能会发生崩溃或闪退,这种情况下一般时没法通知子进程的。
那子进程如何才能实时监测主进程是否退出了呢?不管是主进程正常退出,还是异常崩溃闪退,都要感知到。子进程可以启动一个子线程,在子线程中通过主进程传过来的主进程id,获取主进程句柄,然后调用WaitForSingleObject等待主进程退出,可以在子线程中无限等待。如果主进程一旦退出,WaitForSingleObject函数就会立即返回,这时子进程就可以调用ExitProcess等接口自行退出当前子进程了。
具体的代码实现是,子进程中启动一个子线程,将主进程传过来的主进程id传给该子线程,如下:(其中MonitorMainProcess是线程函数)
HANDLE hThread = (HANDLE)_beginthreadex( NULL, 0, MonitorMainProcess, (void*)dwMainProcessId, 0, NULL );
if ( hThread != NULL )
{CloseHandle( hThread );
}
线程函数MonitorMainProcess实现如下:
// 监控主工程
unsigned __stdcall MonitorMainProcess(void * pParam )
{DWORD dwProcessId = (DWORD)pParam;HANDLE hProcess = OpenProcess( SYNCHRONIZE, FALSE, dwProcessId );if(hProcess == NULL){ExitProcess(-1);}// 设置INFINITE无限等待WaitForSingleObject( hProcess, INFINITE );CloseHandle( hProcess );// WaitForSingleObject返回了,就表示主进程已经退出,直接退出本进程ExitProcess( -1 );
}
进程初始是无信号的,进程退出时就变成了有信号,这样WaitForSingleObject等待到信号后,就返回了,这样子进程就直到主进程退出了。
此处需要注意一下,调用OpenProcess时必须要设置SYNCHRONIZE参数,因为设置该标记参数后才能调用WaitForSingleObject去等待进程。微软MSDN上对SYNCHRONIZE如下:
SYNCHRONIZE:The right to use the object for synchronization. This enables a thread to wait until the object is in the signaled state.
此外,监测主进程是否退出的代码是阻塞式的,不能放在主线程中的,这就是为什么要启动一个子线程去专门做这个监测任务的原因。
5.2.2、启动子进程后等待子进程执行完退出后,再执行后续操作
有时我们需要启动一个子进程去完成某项操作,主进程在等待子进程的执行结果(需要获取子进程的执行数据), 然后主进程再去执行后续操作。主进程在启动子进程后,就可以调用WaitForSingleObject等待子进程退出,比如如下的代码:
// 启动一个子进程去执行一个操作任务
STARTUPINFO s = {sizeof(s)};
PROCESS_INFORMATION pi = {0};
if( CreateProcess( NULL, cmdLine, NULL, NULL, TRUE, NULL, NULL, NULL, &s, &pi ) )
{ // 等待进程执行完毕 WaitForSingleObject( pi.hProcess, INFINITE ); // 关闭进程和主线程句柄CloseHandle( pi.hProcess ); CloseHandle( pi.hThread );
} // ... // 去拿子进程的执行结果,去执行后续操作
有时启动一个子线程,要等子线程执行完退出后,根据处理结果信息去继续执行,和上面等待进程退出是类似的。
关于WaitForSingleObject 函数的诸多用途与使用场景总结,可以查看我之前写的文章:
WaitForSingleObject 函数的诸多用途与使用场景总结https://blog.csdn.net/chenlycly/article/details/135604637%C2%A0
6、死锁检测工具LockCop
在9.8.6节(使用等待链遍历API来检测死锁)中讲到了死锁检测工具LockCop,使用该工具可以检测程序中是否存在多线程死锁:
该工具是调用系统WCT (Wait Chain Traversal,等待链遍历)API函数GetThreadWaitChain来判断目标线程是否发生了死锁,但是只能监测关键代码段、互斥量等引发的死锁,如下所示:
所以在使用LockCop工具应该注意:
1)该工具因为调用的API是Vista以上系统才提供的,所以不支持XP系统。
2)该工具只能检测临界区死锁和互斥量死锁,事件、信号量等引发的死锁是没法监测到的。
3)该工具检测不到WaitForMultipleObjects引发的死锁。
7、如何以管理员权限启动一个进程?
在4.5.1节(自动提升进程的权限)讲到了如果以管理员权限启动一个进程。
在Win7及以上系统中,如果系统UAC开关打开,且当前系统登录用户不是超级管理员Administrator用户,如果程序设置了以管理员权限运行,则在程序启动时系统会弹出UAC提示框,如下所示:
提示用户软件可能会对机器进行更改(以管理员权限运行的程序可以修改系统关键路径中的文件或注册表等)。所以有些软件为了避免系统频繁地弹出UAC提示框,故意将程序设置以标准用户权限运行,比如我们研究过QQ,它默认就是以标准用户权限运行的。
在以标准用户权限运行的进程中,如果要启动一个子进程,则默认情况下子进程是继承父进程的权限,即以标准用户权限运行。但有些场景下,我们需要在以标准用户权限运行的进程中启动一个以管理员权限运行的子进程,比如执行软件的升级更新时,升级程序需要写注册表(向HKEY_LOCAL_MACHINE注册表下写入信息,需要管理员权限;向当前用户的HKEY_CURRENT_USER路径下写入信息,则不需要管理员权限)、需要向系统关键路径拷贝文件,需要有管理员权限才能执行,所以被启动起来的升级程序需要以管理员权限启动运行。
那如何在一个以标准用户权限运行的进程中,去启动一个以管理员权限运行的程序呢?其实很简单,调用系统API接口SheeExecuteEx,传入runas参数即可,如下所示:
SHELLEXECUTEINFO si;
RtlZeroMemory( &si, sizeof( SHELLEXECUTEINFO ) );
si.cbSize = sizeof(SHELLEXECUTEINFO);
si.lpFile = _T("D:\\test.exe");
//si.lpParameters = lpCmdParam;
si.nShow = SW_SHOWNORMAL;
si.lpVerb = _T("runas");
BOOL bRet = ShellExecuteEx( &si );
if ( !bRet ) // 程序启动失败
{TCHAR achLog[256] = { 0 };// 先取lasterror值DWORD dwLastErr = GetLastError();_stprintf( achLog, _T("ShellExecuteEx failed, GetLastError: %d."), dwLastErr );WriteLog( achLog );// 再取hInstApp错误代码int nHInsVal = (int)si.hInstApp;if ( nHInsVal <= 32 ){_stprintf( achLog, _T("ShellExecuteEx failure, errcode: %d."), nHInsVal );WriteLog( achLog );}
}
关于Windows UAC的详细说明及实际项目中遇到的相关问题,可以查看我之前写的文章:
Windows UAC权限详解以及因为权限不对等引发的若干问题分享https://blog.csdn.net/chenlycly/article/details/134418715%C2%A0
8、如何判断程序是否以管理员权限运行?
有时我们需要判断进程是否以管理员权限运行,比如在运行安装包时需要安装包进程以管理员权限运行,因为安装包将执行写注册表、注册组件等需要管理员权限的操作。如果没有申请到管理员权限,这些需要管理员权限的操作都会执行失败,则会导致安装失败。以QQ7.1安装包为例,如果当前以标准用户登录到系统中,并且UAC关闭,双击运行时将申请不到管理员权限,QQ会弹出如下的提示框:
我们的安装程序可以参考QQ的做法,避免出现没有管理员权限导致安装失败的问题,即如果没有申请到管理员权限,则直接弹出如上类似的提示。要弹出提示,则要判断当前安装程序的进程是否以管理员权限运行。那应该如何判断呢?相关示例代码如下所示:
// 1、通过进程id获取进程句柄
DWORD dwPid = 2337; // 目标进程的进程id
HANDLE hProcess = ::OpenProcess( /*PROCESS_ALL_ACCESS*/PROCESS_QUERY_INFORMATION, FALSE, dwPid );
if ( hProcess == NULL )
{
strTip.Format( _T("OpenProcess to get the process handle failed, possible reason: the process id doesn't exsit, GetLastError: %d"), GetLastError() );
AfxMessageBox( strTip );
return;
}// 2、调用IsRunasAdmin函数判断目标进程是否已管理员权限运行
BOOL bRunAsAdmin = IsRunasAdmin( hProcess );
if ( bRunAsAdmin )
{strTip.Format( _T("Pid(%d) run as admin!"), dwPid );
}
else
{strTip.Format( _T("Pid(%d) don't run as admin!"), dwPid );
}
AfxMessageBox( strTip );// 3、判断进程是否已管理员权限运行的函数实现
BOOL IsRunasAdmin( HANDLE hProcess )
{BOOL bElevated = FALSE; HANDLE hToken = NULL; CString strTip;// Get target process tokenif ( !OpenProcessToken( hProcess/*GetCurrentProcess()*/, TOKEN_QUERY, &hToken ) ){strTip.Format( _T("OpenProcessToken failed, GetLastError: %d"), GetLastError() );AfxMessageBox( strTip );return FALSE;}TOKEN_ELEVATION tokenEle;DWORD dwRetLen = 0; // Retrieve token elevation informationif ( GetTokenInformation( hToken, TokenElevation, &tokenEle, sizeof(tokenEle), &dwRetLen ) ){ if ( dwRetLen == sizeof(tokenEle) ){bElevated = tokenEle.TokenIsElevated; }} else{strTip.Format( _T("GetTokenInformation failed, GetLastError: %d"), GetLastError() );AfxMessageBox( strTip );}CloseHandle( hToken ); return bElevated;
}
9、DLL延迟加载与DLL远程注入
在第22章讲到了DLL延迟加载与DLL远程注入的相关内容。下面从实际工程应用的角度去大概地讲述上述两方面的内容。
9.1、DLL延迟加载
当启动一个程序时,系统会给程序进程分配一个进程空间,系统会先将主程序依赖的各个dll库加载到进程空间中,当所以依赖的库加载到进程空间中后,最后才会将exe主程序加载并运行起来,然后进入exe主程序的main函数,主程序运行起来。
软件的某些功能是调用某个DLL库的接口实现的,但这些功能可能客户不会用或很少用,可以将相关的DLL库设置为延迟加载。设置为延迟加载后,就不会在启动主程序时就加载DLL库,而是在调用到DLL接口时再去加载,Windows系统是支持DLL延迟加载的。
将不怎用的功能所在的DLL库设置为延迟加载,主要有以下两大好处:
1)提高程序的启动速度
将DLL库设置为延迟加载后,程序启动时就不会加载该DLL库,这样就能减少程序启动要加载的DLL库个数,可以提升程序的执行速度。
2)较少程序对虚拟内存的占用
DLL库加载到进程空间中,主要有两方面会占用程序的内存空间(虚拟内存空间)。一方面,DLL二进制文件中存放的是要CPU执行的二进制代码,加载到进程空间后会占用进程代码段的内存,这是程序虚拟内存的一部分。
另一方面,DLL库中有些变量,DLL加载到进程空间中后,有些变量需要分配内存,这些变量的内存则是程序数据段的内存,也是程序虚拟内存的一部分。所以减少DLL库的加载,可以降低进程对虚拟内存的占用。
那如何将DLL设置成延迟加载呢?其实很简单,在Visual Studio中就可以设置,在exe主程序中配置即可。打开exe主程序工程的工程属性,到在链接器 -> 输入 -> 延迟加载的DLL 下,输入要延迟加载的DLL文件名称,如下所示:
如果有多个DLL库需要延迟加载,用逗号隔开即可。
9.2、DLL远程注入
有些软件通过DLL模块远程注入实现对目标程序的监控,其实在日常工作中一直都有DLL远程注入的身影,比如输入法之所以能在所有的软件上进行输入,是因为输入法有专门的DLL远程注入到当前系统所有的UI进程中。此外,有些第三方安全软件也是通过DLL远程注入的方式注入到目标进程中,去对目标进程的实时网络数据进行分析和监控。
这两种DLL远程注入的场景,我们在实际的项目问题中都遇到过,关于具体的问题细节描述,可以参见我之前写的文章:
第三方模块远程注入到软件中引发软件异常的若干实战案例分享https://blog.csdn.net/chenlycly/article/details/134545223关于如何实现DLL的远程注入以及相关的细节,可以详细查看《Windows核心编程》的第22章,此处我们就不详细展开了!
10、SEH结构化异常与C++异常
第25章(未处理异常、向量化异常处理与C++异常)中讲到了SEH结构化异常与C++异常相关的内容。
10.1、SEH结构化异常
SEH(Structured Exception Handling),结构化异常处理,是Windows操作系统上,Microsoft对C/C++程序语言做的语法扩展,用于处理异常事件的程序控制结构。异常事件是打断程序正常执行流程的不在期望之中的硬件、软件事件。硬件异常是CPU抛出的如“除0”、数值溢出等;软件异常是操作系统与程序通过RaiseException语句抛出的异常。
Microsoft扩展了C语言的语法,用 try-except与try-finally语句来处理异常。try-except结构如下:
__try
{// guarded code
}
__except ( expression )
{// exception handler code
}
try-finally结构如下:
__try {// guarded code
}
__finally {// termination code
}
异常处理程序可以释放已经获取的资源、显示出错信息与程序内部状态供调试、从错误中恢复、尝试重新执行出错的代码或者关闭程序等等。一个__try语句不能既有__except,又有__finally。但try-except与try-finally语句可以嵌套使用。
以调用系统API函数HtmlHelp打开.chm文件为例,在项目中发现,如果目标路径中的.chm文件不存在,HtmlHelp函数内部会产生异常,导致程序崩溃,为了解决这个问题,我们添加了__try...__except进行保护,相关代码如下所示:
bool OpenChmHelpFile( LPCTSTR lpStrPath )
{HWND hHelpWnd = NULL;__try{hHelpWnd = HtmlHelp( NULL, lpStrPath, HH_DISPLAY_TOPIC, NULL );}__except( EXCEPTION_EXECUTE_HANDLER ){hHelpWnd = NULL;}if ( NULL == hHelpWnd ){WriteLog( _T("[OpenChmHelpFile] HtmlHelp execute failed, path [%s]!"), lpStrPath );return false;}return true;
}
10.2、C++异常
C++异常处理的声明:
try
{// code that could throw an exception
}
[ catch (exception-declaration)
{// code that executes when exception-declaration is thrown// in the try block
}
[catch (exception-declaration)
{// code that handles another exception type
} ] . . . ]// The following syntax shows a throw expression:
throw [expression]
C++异常处理使用try、catch和throw三个关键字实现,用throw抛出异常,然后用try...catch捕获异常。
比如在使用new去动态申请内存时,如果进程内存不足,申请不到指定大小的堆内存时,new内部会抛出bad_alloc异常,捕获这个异常的代码如下所示:
try
{int *p = new int[10000000000];
}
catch (bad_alloc &e)
{cout << e.what() << endl;
}
10.3、SEH结构化异常与C++异常的区别
SEH 是操作系统所提供的便利,它在任何语言中都可以使用。而C++异常处理只有在编写C++代码时才可以使用。Microsof 的 Visual C++编译器使用操作系统的结构化异常机制来实现 C++异常处理机制。所以,在创建一个 C++ try 块时,编译器会为我们生成一个 SEH __try
块。C++的catch 语句对应SEH 异常过滤程序,catch 块中的代码则对应SEH __except块中的代码。而编译器也会为C++ throw语句生成对WindowsRaiseException函数的调用,throw语句所使用的变量则成为RaiseException的附加参数。
11、最后
本文大概地罗列了工作中用到的来自《Windows核心编程》的若干知识点与技能,旨在说明本书的重要性,希望大家能够仔细研读这本书,在提升理论知识水平的同时,也能有效地提高分析解决问题的技能。