这篇笔记是我在读《Windows核心编程》第5版时做的记录和总结(部分章节是第4版的书),没有摘抄原句,包含了很多我个人的思考和对实现的推断,因此不少条款和Windows实际机制可能有出入,但应该是合理的。开头几章由于我追求简洁,往往是很多单独的字句,后面的内容更为连贯。
海量细节。
第1章 错误处理
1. GetLastError返回的是最后的错误码,即更早的错误码可能被覆盖。
2. GetLastError可能用于描述成功的原因(CreatEvent)。
3. VS监视窗口err,hr。
4. FormatMessage。
5. SetLastError。
第2章 字符和字符串处理
1. ANSI版本的API全部是包装Unicode版本来的,在传参和返回是多了一次编码转换。
2. MS的C库的ANSI和Unicode版本之间也是没有互相调用关系的,没有编码转换开销。
3. 宽字符函数:_tcscpy,_tcscat,_tcslen。
4. UNICODE宏是Windows API使用的,而MS的C库中,对于非标准的东西用_前缀区分,所以_UNICODE宏是MS的C API使用的。
5. MS提供的避免缓冲区溢出攻击的函数在<StrSafe.h>文件中,包括StringCbCat和StringCchCat等函数(其中Cb表示Count of Byte,Cch表示Count of Character,都用于表示衡量目标缓冲大小的单位);另外<TChar.h>中有_tcscpy_s等_s后缀的函数。在源串过短时,<StrSafe.h>的函数截断,<TChar.h>的函数断言。
6. 要想接管CRT的错误处理(比如assert),使用_set_invalid_parameter_handler设置自己的处理函数,然后使用_CrtSetReportMode(_CRT_ASSERT, 0);来禁止CRT弹出对话框。
7. Windows也提供了字符串处理函数,但lstrcat、lstrcpy(针对T字符的)已经过时了,因为没考虑缓冲区溢出攻击。考虑使用StrFormatKBSize、StrFormatByteSize、CompareString(有很多比较选项)、CompareStringOrdinal(相当于_tcscmp)。
8. GetThreadLocale返回线程的语言信息:LCID(Locale ID),供很多函数使用(包括使用CompareString针对语言来比较的时候)。
9. 宽字节转多字节WideCharToMultiByte,反之MultiByteToWideChar。其中,在宽字节转多字节的时候,如果有Unicode字符在多字节编码中没有对应项,那宽字节会被替换成参数lpDefaultChar,并且lpUsedDefaultChar会被标记为TRUE。当用这两个函数计算结果串的大小时,返回的是字符数。
10. IsTextUnicode。
第3章 内核对象
1. 简单区分内核对象和其他对象的方法:创建需要安全信息的多半是内核对象。
2. 每个进程有一个内核对象表,表的每一项是一个简单结构,包括真实内核对象地址和访问权限等。用户代码持有的内核对象句柄其实是对象表中对应项的索引。因此如果CloseHandle关闭一个对象后没有清空变量,且在对象表的同样位置恰好又创建了一个新的内核对象,对之前没清空的无效变量的访问会造成bug。(比如对同一个句柄多调用了一次CloseHandle导致另一个内核对象被关闭。)
3. 进程退出时,会释放各种内存、内核对象、GDI对象等。
4. 跨进程使用内核对象的理由:跨进程传输:用文件映像对象实现共享内存、邮件槽和命名管道实现数据通信、信号量和互斥量进行同步等。
5. 跨进程使用内核对象的三种方式:对象句柄继承、命名内核对象、复制对象句柄。
6. 对象句柄继承:创建内核对象的时候可以指定SECURITY_ATTRIBUTES. bInheritHandle表示可继承(任何时候可以使用SetHandleInformation修改可继承性等属性),创建子进程时指定CreateProcess的参数bInheritHandles为TRUE,则子进程从父进程的对象表中拷贝所有可继承的对象到自己的对象表的相同表位置中(并增加引用计数),因为表项结构被完全拷贝且内核对象实际地址在地址空间后2G的内核地址段中,所以拷贝过来的表项完全有效,进而父子进程的可继承内核对象的句柄值完全相同,于是只要以任何方式将要继承的对象的句柄值跨进程交给子进程(创建子进程时的命令行参数、环境变量、共享内存、消息等手段),则后者可以使用。
7. 命名内核对象:要访问已经存在的命名内核对象,可以使用CreateXXX或者OpenXXX,后者在对象不存在的时候返回NULL。如果打开了一个已经存在的命名对象,在打开时为API指定的对象名以外的参数被忽略。注意,一个进程打开同一对象两次,除了增加引用两次外,返回的句柄值是不同的,需要分别关闭一次,即打开和关闭完全对称(很合理的行为)。在Vista及以上的系统,对象名可以包括在命名空间下,避免被低授权用户访问。
8. 复制对象句柄:DuplicateHandle。
第4章 进程
1. 进程是执行文件的运行时形态。包括两部分:内核数据(对应内核对象)、地址空间(包括执行文件代码和栈堆等动态内存)。
2. 把VC的“系统-子系统”值删除掉,即不指定控制台或GUI,则编译器会根据代码中存在main或者WinMain来自动选择子系统(这里不谈Unicode了),很方便。
3. 启动程序:根据子系统执行mainCRTStartup/WinMainCRTStartup,在该函数中干几件事(1)准备命令行和环境变量(用于char *argv[]和char *env[])(2)初始化CRT的全局变量(包括_osver、_winmajor、_winver、__argc、_environ等)(3)初始化CRT运行库的内存分配(malloc、free)、IO函数等(4)初始化全局对象调用C++构造函数。
4. 退出程序:main返回后mainCRTStartup会调用exit,exit干以下几件事:(1)执行通过_onexit注册的函数(2)执行全局对象的C++析构函数(通过atexit注册的)(3)判断_CrtDumpMemoryLeaks设置的内存泄漏检测标志,尝试检测内存泄漏(4)调用ExitProcess。
5. HINSTANCE和HMOUDLE完全相同,都是表示映像文件加载到内存后的基址(链接器中可以配置)。GetModuleHandle传入文件名可以获得模块基址;传入NULL可以得到执行文件的HINSTANCE(即使调用者位于某个模块中同样返回应用程序基址);GetModuleHandleEx可以根据函数地址得到模块基址
6. 访问环境变量:char *env[]参数、GetEnvironmentStrings、GetEnvironmentVariable、ExpandEnvironmentStrings(将一个使用了类似”%USERPROFILE%”环境变量的字符串中的变量替换成值)。
7. 系统环境变量:HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Enviroment。用户环境变量:HKEY_CURRENT_USER\Enviroment。
8. 修改环境变量后可以通知相关的系统窗口(如控制面板等):SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, (LPARAM) “Enviroment”)。
9. 可以设置特定线程在一个CPU核心集合上执行。
10. SetErrorMode。设置该进程如何响应各种错误。
11. 关于相对路径:在通过GetEnvironmentStrings返回的环境变量中,有一部分不是真正的环境变量,比如“=C:=C:\Windows”“ =F:=F:\Projects\Test05”,他们表示一种进程相关配置“本进程在特定驱动器下对应的当前文件夹”。一个进程除了有以上配置外,还有一个当前驱动器,最终GetCurrentDirectory返回的当前路径就是当前驱动器+当前驱动器对应的当前文件夹。使用SetCurrentDirectory会改变该驱动器的当前文件夹,还会改变进程的当前驱动器(但这个API的改变并不会在GetEnvironmentStrings上体现出来,使用C函数_chdir可以同时改变两者,故C函数更优)。进程刚启动时,如果不考虑从父进程继承的环境,则只有进程当前驱动有当前文件夹,其他驱动都无配置。使用相对路径访问文件的时候,其绝对路径可以用GetFullPathName得到。”文件名”这样的相对路径的绝对路径是GetCurrentDirectory() + “文件名”;”驱动器盘符:文件名”(注意不是”驱动符:/文件名”)这样的相对路径的绝对路径就是”该驱动器的当前文件夹”(如果无配置,则是根目录) + “文件名”。
看如下代码:
_chdir("D:/Downloads"); // 修改D:的当前路径为Downloads,且进程当前驱动器为D:
_chdir("F:/Projects"); // 修改F:的当前路径为Projects,且进程当前驱动器为F:
std::ofstream("1.txt"); // 当前驱动器是F:,所以绝对路径是F:/Projects/1.txt
std::ofstream("d:1.txt"); // D:的当前路径是Downloads,所以绝对路径是D:/Downloads/1.txt
这种行为从cmd的cd命令也可以看得出点端倪。
归纳:相对路径访问文件的时候,首先将相对路径展开成绝对路径,使用GetFullPathName,后者分两步:首先判断是否包含驱动器(以X:开头),如果没有,则在开头添加进程当前驱动器;然后检查是否以”X:/”开头,如果没有,则将”X:”展开成”X:/” + “对应驱动的当前文件夹”。两步过后得到绝对路径。
12. GetVersionEx获取系统版本信息。VerifyVersionInfo检测当前系统是否满足版本需要。
13. CreateProcess的参数:关于lpApplicationName和lpCommandLine,有两种用法:(1)前者指定应用程序路径,后者指定参数(第一个参数前面要有一个空格,似乎底层会直接连接两个串)(2)前者为NULL,后者指定路径和参数,空格隔开。常用第二种方法。注意,lpCommandLine中由于是用空格分隔参数的,所以对其中含有空格的路径一定要用内层引号括起来。另外CreateProcessW有一个奇怪的行为,它会修改参数lpCommandLine(似乎只在lpApplicationName为空的时候会修改),所以使用Unicode版本的时候传入的该参数不能是常字符串(如L”Nodepad 1.txt”),而应该另外准备缓冲传给该API供其修改,因为ANSI版本是调用Unicode版本的且在编码转换的时候内置了缓冲,所以CreateProcessA的lpCommandLine参数可以是常串(最终API会修改转换编码的临时缓冲)。默认情况下,CUI的CUI型子进程会和父进程共享控制台,在参数dwCreationFlags中添加DETACHED_PROCESS或CREATE_NEW_CONSOLE标志可以阻止这种行为。在dwCreationFlags中添加CREATE_NEW_PROCESS_GROUP标志,可以控制进程组的组织,用户按下Ctrl+C的时候同一进程组的所有进程得到通知。lpEnvironment指定为NULL的时候,底层为用GetEnvironmentStrings来填充。lpCurrentDirectory为NULL的时候,子进程继承父进程的当前目录。lpStartupInfo不能为空,至少要初始化结构为0并将cb赋为sizeof。使用STARTUPINFOEX结构作为lpStartupInfo参数,还可以具体指定子进程要继承哪些父进程的可继承内核对象(即使bInheritHandles参数为FALSE)。
14. cmd进程输入命令行前显示的路径,就是其当前路径(GetCurrentDirectory)。在CreateProcess时,cmd没有设置子进程当前路径,而资源管理器将路径设置成子进程镜像目录。因为cmd的子进程会继承cmd的当前路径(lpCurrentDirectory为空的结果),因此最好在用cmd启动程序的时候先将cmd的当前路径设置为新进程的镜像路径。
15. 进程和线程结束后,句柄对象被标记为激活, WaitForSingleObject会返回。
16. CreateProcess后,可以使用WaitForInputIdle或类似函数来等待新进程初始化环境完毕开始运行。
17. WoW64:Windows 32 On Windows 64。所有64位windows运行着这个虚拟机,用来执行32位程序。判断一个32位程序是否是运行在64位系统的32位虚拟机中:IsWow64Process。
18. 父进程创建子进程时使用的lpStartupInfo,在子进程中可以使用GetStartupInfo来查询。
19. 创建一个子进程时,进程和主线程本身的存在就有了引用1,而调用CreateProcess的父进程又会有他们的引用所以计数到了2。要完全销毁进程和线程,需要计数为0,所以除了需要进程本身结束外,引用的该进程的其他线程也要释放引用。当然,CreateProcess过后父进程马上CloseHandle并不会结束子进程,只是释放自己的引用,使其计数为1,这是正常的行为。要确保某个进程或线程不被销毁,不调用CloseHandle即可。如果进程本身已经退出了,但还有其他进程引用它,则它的地址空间被回收,只有内核对象还存在(比如这时再对句柄使用API查看内存,则内存信息为空),这也是为什么可以查看已经退出的进程的退出码的原因(退出码保存在内核对象中)。
20. 进程和线程的ID位于同一个系统顶层名空间。即任意进程的任意线程ID绝不可能和任意进程ID相同。这个ID会被系统循环利用。
21. GetProcessIDOfThread。
22. 进程只有在它所有线程都结束后才会结束。ExitProcess会杀死所有线程,所以可以直接结束进程,在主线程中调用ExitThread只会结束主线程(即,主线程创建一个死循环线程后自己_exitthreadex,这个进程不会退出。)。main返回后CRT调用exit后者再调用ExitProcess,所以在main中return可以直接结束进程。
23. 通过ExitProcess或ExitThread(单线程时)结束进程,由于这些API比CRT更底层,他们只能保证正确的释放Windows资源(内存、内核对象引用),并不保证释放C++资源(CRT底层资源、全局对象的析构函数),故一定要从main中返回自然的结束进程(其他原因在后面章节说明)。TerminateProcess也出于相同的原因应该避免使用。
24. CreateProcess创建的子进程会继承父进程的Security Token权限,而ShellExecuteEx可以提高子进程的权限(令lpVerb参数为”runas”)。资源管理器使用前者创建子进程,所以通过它开打的程序都具有和资源管理器相同的权限。
25. 关于Vista及更高系统的UAC(User Account Control):Vista以前的系统如果以管理员账号登陆,资源管理器(Explorer)会获得一个管理员权限的Security Token,然后从资源管理器打开的子进程都会继承这个最高权限,这种行为非常危险。Vista以后,即使以管理员账号登陆,资源管理器仍然只持有一个一般权限的Token(Filtered Token),子进程如果想提升权限,有两种途径:(1)用户“以管理员身份运行”启动该进程(2)子进程自己提出请求要求用户提升权限(子进程是安装程序、或者子进程配置有.manifest文件说明权限需求)。另外,在很多软件中出现有小盾牌图标的按钮,也是要求提高权限,点击过后会结束当前进程,重启一个高权限进程(如资源管理器中“显示所有用户的进程”按钮)。其实这三种提高权限都是父进程调用了ShellExecuteEx。
26. IsUserAnAdmin判断当前用户是否是管理员。在Vista及以上的系统中,即使是管理员,进程也有可能因为筛选Token而不具备最高权限。
27. 枚举所有进程:Process32First、Process32Next、EnumProcesses。
28. 可以从HMOUDLE中读取IMAGE_DOS_HEADER和IMAGE_NT_HEADERS,进而从这些PE头中取得模块的推荐加载地址等信息。
29. PEB(Process Enviroment Block)包含了进程的启动命令行、当前路径等数据。该字段可以通过NtQueryInformationProcess的PROCESS_BASIC_INFORMATION参数取得。
30. 可以通过WinDbg的dt命令,查看一些结构的具体成员布局,如PEB等。
31. Windows完整性机制(Windows Integrity Mechanism):这是UAC之外的另一套安全机制,Windows通过在系统访问控制表(SACL, System Access Control List)中增加访问控制项(ACE, Access Control Entry)实现,每一种受保护的资源都有对应的完整性级别(Integrity Level),每个进程都有一个基于Token计算的完整性级别,如果进程的级别小于资源的级别,则不能访问资源。提升Token权限之前的进程级别为中,提升后为高,而像IE这样可以能执行网络代码的进程为低。可以通过GetTokenInfomation查看一些和完整性级别相关的策略。窗口系统也根据完整性级别,拒绝低级别者向高级别使用PostMessage、SendMessage等API。
32. Vista以上有一些进程是特殊的受保护进程,ToolHelp API对他们无效,因此无法查看进程信息。
33. GetProcessTime查看进程时间,GetProcessIoCounters查看IO次数。
34. GetProcessImageFileName返回内核格式的文件名。
第5章 作业
1. Job(作业),也就是进程组的概念,添加进同一个作业的进程能够通过作业内核对象来集中控制,设置一些额外的属性等。添加进一个作业就不能再移出。
2. IsProcessInJob、CreateJobObject、OpenJobObject。
3. 作业内核对象在它内部的所有进程都结束后才会被销毁。
4. 细节:当客户的作业句柄变量都被关闭后,即使作业对象还存在(因为进程没有全部结束),也不能再通过作业名打开作业再操作了。
5. Vista以上,通过任务管理器创建的进程,都被添加进了一个独立的作业;从命令行(cmd)创建的进程则不然。
6. 能够对作业添加的限制:基本限制(限制进程时间、优先级、物理内存占用等)、扩展限制(基础限制之上,还能限制内存使用总量,以及查看峰值内存使用)、UI限制(限制关机/重启、访问剪切板、切换桌面、改变显示器设置、访问作业外进程的句柄等)、安全限制(安全限制一旦设置,则不能修改)。SetInformationJobObject、QueryInformationJobObject用于设置和查询限制。
7. AssignProcessToJobObject添加进程到作业。
8. 父进程位于某一作业中,子进程创建后也自动加入同一作业。除非作业的基本限制中包含JOB_OBJECT_LIMIT_BREAKAWAY_OK(允许进程时脱离作业),并且CreateProcess时指定CREATE_BREAKAWAY_FROM_JOB标记。
9. TerminateJobObject强制结束作业,同时结束作业内所有进程(等价于对作业内每个进程调TerminateProcess)。
10. QueryInformationJobObject除了查看作业限制外,也可以查看作业信息,包括总进程数、活跃进程数、总时间、总IO次数、进程ID列表等。
11. 作业结束后(所有内部进程结束),内核对象处于激活态,WaitForSingleObject返回。
12. 作业通知机制:将作业对象和IO完成端口绑定,作业中的事件(进程结束、时间到期、内存达到限制等)将通过完成端口事件来通知。
第6章 线程基础
1. 像进程一样,线程在数据上也分为两个部分:线程内核对象(包括统计信息)、栈。(进程的两个部分是,内核对象和地址空间)。
2. 比起ExitThread和TerminateThread,应该让线程的主函数返回来结束线程,否则一些栈对象不能正常析构(这里不再考虑CRT函数)。
3. 在C/C++编程中不要使用CreateThread、ExitThread,应该使用编译器厂商提供的包装函数,如MS的_beginthreadex、_endthreadex。因为使用前者,C/C++的CRT不能正常初始化和释放线程相关资源(C/C++中有一些全局变量如errno和一些有内部状态的函数strtok、asctime都需要通过TLS来正确实现,毕竟C库函数的诞生早于多线程)。事实上,如果在C/C++中使用了CreateThread和EndThread,部分有内部状态的函数还是可以正常使用的,因为这些函数内部会尝试取得TLS,发现还未分配的话会自动分配,CRT的Dll版本库也会在得到线程退出通知时尝试释放TLS,只是因为这份TLS是中途分配的信息不够全面,部分状态函数还是会有问题,因此在C/C++中还是要尽量使用后者。
4. 线程栈最大为CreateThread的dwStackSize参数和/STACK链接选项(VC中默认为1MB)两者中的较大值。
5. TerminateThread的一些细节:该函数是异步的,函数返回时,线程还没有结束,需要WaitForSingleObject;DllMain不会收到被Terminate线程的结束通知。
6. 只有当线程函数结束(正常返回或Exit掉)后,该线程的栈空间才会被回收(也就是说TerminateThread函数刚返回时被杀死线程栈空间还在,直到线程对象处于激活态)。
7. 对进程中的各个线程来说,ExitProcess和TerminateProcess都将导致对线程的TerminateThread调用,因此进程的main函数结束前,尽量确保工作线程都正常退出。
8. 大部分的资源都是进程相关的,窗口句柄和hook句柄是线程相关的,线程退出时会释放他们(在C/C++中还有CRT的TLS变量)。
9. GetCurrentProcess、GetCurrentThread返回的都是伪句柄,如果想要把这个句柄保存下来在其他线程、进程中使用的话,是有歧义的,可以用保存ID来代替,如果一定要保存句柄的话,两种方法:(1)DuplicateHandle(2)先GetCurrentThreadID,再OpenThread。
第7章 线程调度、优先级和关联性
1. Windows线程调度的时间间隔(发生上下文切换的时间片)大概是15毫秒(GetSystemTimeAdjustment的lpTimeIncrement参数)。
2. 每个线程都有一个挂起计数,当计数非0的时候,该线程不参与线程调度。CreateThread、CreateProcess传入特定的参数可以使计数初始化为1。SuspendThread可以增加计数,ResumeThread可以减少计数,两者都返回新的挂起计数。显然线程无法对自身调用ResumeThread。
3. 调试进程的WaitForDebugEvent返回后,被调试进程的所有线程被挂起,直到调试进程调用ContinueDebugEvent。
4. Sleep的休眠时间可能不精确,取决于线程调度时间片大小(一般是15毫秒左右)以及其他线程的运行情况。
5. Sleep(0)和SwitchToThread的区别在于:如果存在另一个更低优先级的线程,前者不会将CPU让出,而后者会。即如果存在多个线程,SwitchThread总是让出CPU。
6. YieldProcessor用于支持超线程技术的CPU切换超线程。
7. GetThreadTimes、GetProcessTimes返回指定线程或进程的内核代码时间和用户代码时间(两者都是绝对的CPU执行代码时间,不包括调度过程中的中断时间以及主动的Sleep或者Wait时间)。因此在对代码段计时的时候,使用GetThreadTimes明显优于GetTickCount等,因为后者得出的时间包括了其他线程的时间片。
8. 用于计时,最基本的有clock、GetTickCount、timeGetTime等;为了地提高精度,可以使用QueryPerformanceCounter;为了去掉因线程调度中断的时间和Sleep、Wait的时间,可以使用GetThreadTimes、GetProcessTimes等。在Vista以上的系统中,有新的机制,可以使用ReadTimeStampCounter(对应GetTickCount)、QueryThreadCycleTime(不考虑中断休眠,对应GetThreadTimes)、QueryProcessCycleTime等。对于没有考虑线程调度影响的函数,可以先用SetThreadPriority提高优先级尽量独占时间片。应该确保每次调用QueryPerformanceCounter的时候在同一CPU核心上,使用SetThreadAffinityMask。
9. 线程上下文(CONTEXT)保存在线程的内核对象数据中,主要包括线程相关的CPU寄存器状态等。上下文有两份,分别记录内核和用户模式,GetThreadContext只能返回用户模式上下文,在调用该函数前应该确保用户上下文不再改变了,即线程正处于内核态或者虽然在用户态但已经调用过SuspendThread。
10. 先SuspendThread、再SetThreadContext改变线程上下文,可以改变执行流等,一般用于调试器 “跳到指定位置执行” 的功能等。
11. 高优先级线程可以被调度时(没有Sleep、Wait等),低优先级线程得不到时间片;即使低优先级线程正在执行,一旦有高优先级线程可以调度,前者会被中断并让出CPU资源。
12. SetPriorityClass设置进程的优先级类,SetThreadPriority设置线程的相对优先级(相对于进程优先级类),二者共同决定线程的实际优先级(这个映射根据Windows版本不同而异,是一个0~31的整数,用户不可访问)。将线程的实际优先级设置为最高(31)是危险的,因为它将抢占系统资源,导致IO不能响应等。
13. 当线程有IO事件或消息到来时,操作系统会暂时提高线程的优先级;或者线程可调度但长时间(数秒)都得不到时间片的时候,系统也会暂时提高线程优先级。可以设置是否允许系统自动提升优先级:SetProcessPriorityBoost、SetThreadPriorityBoost。
14. 特定类型计算机的几个相关CPU核心之间可以共享内存缓存等,因此Windows支持设置线程关联CPU核心SetProcessAffinityMask、SetThreadAffinityMask。当然这组API也可以用于为特定线程提供专用CPU资源以提高性能。子进程默认继承父进程的核心关联设置。
15. SetThreadIdealProcessor设置线程最多可以使用的闲置CPU数量。该设置会覆盖AffinityMask。
16. 进程的默认AffinityMask可以在镜像文件头中设置(因为没有链接选项只有手工写文件):ImageLoad->GetImageConfigInformation->ilcd.ProcessAffinityMask->SetImageConfigInformation->ImageUnload。
第8章 用户模式下的线程同步
1. Interlocked系列函数:InterlockedIncrement(对应++)、InterlockedExchangeAdd(对应+=)、InterlockedExchange(对应=)、InterlockedCompareExchange(cas)。
2. _aligned_malloc可以指定分配内存的对齐边界。
3. spinlock(自旋锁)是CAS的应用。使用自旋锁的时候因为有while(true) { …; Sleep(0); }这样的循环,因此线程优先级不能太高,使用SetThreadPriorityBoost来禁用优先级提升,避免被自动提升后不会让出CPU(或者使用SwitchToThread)。自旋锁适用于单个线程不会占用资源太久的情况(因为一个线程占有资源期间,其他线程在循环检测浪费CPU)。
4. CAS(InterlockedCompareExchange)必须是原语!必须!用C++编写的CAS是不行的。
5. InitializeSListHead、InterlockedPushEntrySList、QueryDepthSList等API可以以Interlocked的方式操作一个单链表。
6. CacheLine:是Cache和内存通信的基本单位,可能是32/64字节等,CPU读写内存的时候会先将对应的CacheLine加载进Cache,修改完成后Flush到内存上。因此数据组织为CacheLine Size对齐、以及将只读和读写数据分别组织到不同的CacheLine都能提高效率。多个CPU(或者具有独立Cache的多个CPU核心)访问同一地址时,该地址附近的数据会被多个Cache映射成各自的CacheLine,如果其中某个CPU修改了其CacheLine的数据,该CPU会通知其他CPU更新各自的CacheLine,这种行为会影响性能,故尽量避免跨线程共享数据以及利用AffinityMask尽量使用同一个CPU。
7. GetLogicalProcessorInformation提供CPU描述信息(比如能够查询到包括4个CPU核心,3级Cache,1、2级Cache为各个核心独有,3级Cache为共享Cache,其Cache Line Size为64字节等)。
8. 所有线程都处于等待状态数分钟后,电源管理器介入。
9. volatile的作用:编译器不会将变量优化成寄存器变量,即每次读写都会访问内存。对struct应用该关键字会影响每个字段。
10. CRITICAL_SECTION内部记录了拥有访问权的线程以及引用次数。TryEnterCriticalSection如果返回TRUE,则已经增加了计数需要对称调用LeaveCriticalSection。
11. CRITICAL_SECTION在实现上结合了spinlock(自旋锁),调用EnterCriticalSection时发现资源正被占用需要切换到内核态休眠之前(切换到内核态开销很大,高达数千CPU周期),可以尝试进行一定次数的循环判断。使用InitializeCriticalSectionAndSpinCount可以启用结合自旋锁功能(作为参考,用于保护进程堆的CS的SpinCount为4000),使用SetCriticalSectionSpinCount可以修改旋转次数。当SpinCount为1的时候,关键段内部用于休眠和唤醒的事件对象会第一时间创建,而不是等到EnterCriticalSection的时候才创建。建议总是启用自旋锁。
12. Slim Reader/Writer Lock是性能比关键段更好的选择,相比后者,它的缺陷是不能递归加锁、且没有TryLock。InitializeSRWLock、AcquireSRWLockShared(申请读锁)、AcquireSRWLockExclusive(申请写锁)。
13. 在都能完成任务的情况下,性能从高到底依次是:无锁、volatile、Interlocked、SRW、CRITICAL_SECTION、内核对象(因为切换到内核态开销很大)。
14. SleepConditionVariableCS、SleepConditionVariableSRW用法:已经获得锁(CS、SRW)的线程开始在一个ConditionVariable对象上睡眠,同时释放锁;如果其他线程Wakeup这个ConditionVariable对象,则函数返回TRUE,且再度获得锁;如果超时,返回FALSE,不会获得锁。应用:消费者获得锁后发现没有产品于是开始休眠等待生产者产出产品后唤醒。
15. 技巧:按资源的逻辑个数而不是对象个数来组织锁;需要加多层锁的时候,总是按固定顺序,比如按锁的地址大小来依次加锁,避免死锁;通过拷贝资源等方式来减小锁粒度。
第9章 用内核对象进行线程同步
1. 内核对象用于线程同步更灵活比如可以设置等待时间以及跨进程等,但开销更大(需要切换到内核模式)。
2. 内核对象中都有一个表示触发状态的BOOLEAN值。
3. 进程和线程对象在结束前是非触发,结束后是触发状态,其他时候不会再改变。
4. 文件对象有正在处理的异步IO请求时处于非触发,其他时候触发。
5. 控制台输入句柄在没有输入的时候非触发。
6. 内核对象触发后,Wait在上面的线程被唤醒,决定哪一个线程首先被唤醒的规则基本上就是等待顺序的先入先出,和线程的优先级等无关。
7. PulseEvent会在Event对象上产生一个触发脉冲。近似于SetEvent(h);ResetEvent(h);两句。
8. WaitableTimer在平时处于非触发,第一次时间到或者之后周期性时间到都会处于触发状态。另外在SetWaitableTimer的时候可以传入回调指定在触发的时候往APC(Asynchronous Procedure Call)队列中加入回调,但必须定时器触发时线程正处于Alertable(使用SleepEx等带Ex的API)状态下才会入队列(避免因为回调处理太慢及其他因素导致过量入队)。一般定时器的APC和WaitFor两种模式不混用。SetWaitableTimer指定第一次的时间时,正数表示绝对时间(SystemTimeToFileTime得到),负数表示相对时间。每次调用SetWaitableTimer会自动取消上次调用的设置,故两次调用间不必CancelWaitableTimer。该定时器和基于消息的SetTimer定时器建议适时选用。
9. Semaphore的当前计数非0时处于触发。ReleaseSemaphore增加计数发现达到最大时会返回FALSE,WaitFor减少计数到0的时候会休眠。
10. Mutex和CriticalSection在使用上完全相同,都记录了Owner线程和递归次数。由于CriticalSection和Mutex记录了Owner线程,因此需要该线程来释放计数,如果在计数减少到0前线程退出了,则同步对象处于Abandoned(遗弃)状态。对于Abandoned的情况,系统能检测到发生在Mutex上的问题,并在底层自动释放计数,只是WaitFor会返回WAIT_ABANDONED表示Mutex对象的计数是由系统自动回收的,该Mutex保护的资源可能处在未定义状态。而CS的计数不会被自动释放,一旦Abandoned则CS永远的失效了。
11. WaitForInputIdle:进程中创建第一个窗口的线程的消息队列中没有需要处理的输入消息后返回。
12. MsgWaitForMultipleObjects:等待的内核对象触发后或者线程的消息队列中有相应消息后返回。
13. SignalObjectAndWait增加一个对象计数的同时原子地等待另一个对象。能够增加计数的对象只限于Event(SetEvent)、Mutex(ReleaseMutex)、Semaphore(ReleaseSemaphore),而等待的对象类型不限。使用:客户端填充好请求于是通知服务端准备处理并等待服务端处理完毕。
14. 在Vista以上可以通过WCT(等待链遍历,Wait Chain Traversal)相关API来追踪死锁。OpenThreadWaitChainSession、GetThreadWaitChain。
第10章 同步设备I/O与异步设备I/O
1. 打开设备的方式:文件-CreateFile,参数时路径名或UNC路径名。目录-CreateFile,参数为路径名或UNC路径名,另外指定FILE_FLAG_BACKUP_SEMANTICS允许改变目录属性。逻辑磁盘驱动器-CreateFile,参数为””” \\.\x:”,打开后可以格式化和检测大小等。物理磁盘驱动器-CreateFile,参数为””” \\.\PHYSICALDRIVEx”,(其中x为012等)。串口-CreateFile,参数为”” COMx”。并口-CreateFile,参数为”” LPTx”。邮件槽服务器-CreateMailSlot,参数为”\\.\mailslot\abcd”。邮件槽客户端-CreateFile,参数为””\\serverName\mailslot\abcd””。命名管道服务器-CreateNamedPipe,参数为”\\.\pipe\abcd “。命名管道客户端-CreateFile,参数为””\\serverName\pipe\abcd “。匿名管道-CreatePipe。套接字-Socket、accept、AcceptEx。控制台-CreateConsoleScreenBuffer、GetStdHandle。前面的设备路径规则:””””\\服务器\设备”,其中如果在本机的话,服务器就是”” .”。
2. SetCommConfig可以设置串口波特率等属性。
3. SetMailSlotInfo可以设置超时。
4. 一般用CloseHandle关闭设备。closesocket关闭套接字。
5. GetFileType可以返回设备的类型:FILE_TYPE_DISK-磁盘文件;FILE_TYPE_CHAR-字符文件,包括控制台和打印机等;FILE_TYPE_PIPE-命名管道或匿名管道。
6. 多次CreateFile打开同一个文件得到的是不同的内核对象,各自维护自己的文件指针等数据; DuplicateHandle得到的多个句柄仍然标志的是同一个对象。
7. CreateFile的dwShareMode参数:0表示独占,如果文件已经被打开,则本次打开失败;如果本次打开成功,在关闭前不能在其他地方打开同一个文件。FILE_SHARE_READ,如果本次打开前已经有写句柄,本次打开失败;如果本次打开成功,在关闭前在其他地方不能打开写句柄。FILE_SHARE_WRITE也类似。FILE_SHARE_DELETE表示,如果本次打开成功,其他地方又删除了文件,则删除时只是打上删除标记,待这里的句柄关闭后才真正删除。
8. CreateFile的dwFlagsAndAttributes参数:(1)关于内置缓冲。内置缓冲至少有两个作用,首先,加速,频繁的小字节块访问会被缓冲为少数大字节块的设备读写;其次,最底层设备访问需要按一定的字节块对齐(文件无缓冲读写需要按磁盘扇区大小对齐),缓冲屏蔽了这个限制,方便上层使用。FILE_FLAG_NO_BUFFERING,底层不提供缓冲,需要上层自己提供缓冲,缓冲区首地址、文件读写偏移/指针、读写字节数三者都必须按磁盘扇区大小对齐(扇区大小可以通过GetDiskFreeSpace获得,比如512字节)。文件太大有可能打开失败,也需要指定这个标记。当有缓冲时,FILE_FLAG_SEQUENTIAL_SCAN承诺会连续访问(不会用SetFilePointer),因此底层可以尝试缓冲更多连续内容;FILE_FLAG_RANDOM_ACESS表示会随机访问,因此底层会尽量不要缓冲太多(缓冲的作用还剩下避免要求扇区对齐)。FILE_FLAG_WRITE_THROUGH,表示写文件不使用缓冲,这样避免在数据Flush到文件前对象就被非法关闭导致数据丢失。(2)其他标志。(1)FILE_FLAG_DELETE_ON_CLOSE,关闭文件的时候删除,适合临时文件。FILE_FLAG_OVERLAPPED异步IO。
9. CreateFile的dwFlagsAndAttributes参数:只在创建文件的时候有效,用于指定ARCHIVE、ENCRYPTED(加密)、HIDDEN、READONLY、SYSTEM、TEMPORARY等属性
10. CreateFile的hFileTemplate参数:只在创建新文件时有效,传入另一个文件句柄的话,系统会忽略dwFlagsAndAttributes参数和直接使用该句柄对应的dwFlagsAndAttributes。
11. FILE_ATTRIBUTE_TEMPORARY和FILE_FLAG_DELETE_ON_CLOSE标记结合适用于临时文件,前者会让系统尽量将文件维护在内存而不是磁盘中,后者会在关闭句柄时删除文件。
12. 获取文件大小:GetFileSizeEx、GetCompressedFileSize(尤其针对压缩属性的文件)分别返回逻辑大小和磁盘上的实际大小。
13. SetFilePointerEx可以超出文件实际大小,超出后,除非写文件或者SetEndOfFile否则文件不会变大。
14. SetEndOfFile是减小文件的唯一手段。
15. FlushFileBuffers。
16. 在Vista以上,可以用CancelSynchronousIo来中止一个线程的同步IO。
17. 异步IO的实际访问设备顺序不一定和请求顺序(API调用顺序)相同(比如驱动会根据磁盘磁头位置选择先处理距离最近的IO请求)。
18. 对异步IO的文件发出IO请求有可能是同步操作,因为可能数据正好在底层缓冲中可以立即完成。
19. 关于取消异步IO请求:(1)CancelIo取消调用线程在指定设备上的异步IO请求。(2)线程结束会取消该线程的所有异步请求。(3)关闭设备会取消所有该设备的请求。(4)CancelIoEx能取消调用线程以外线程在指定设备上的特定请求。(5)CancelIoEx能取消特定设备的所有请求。
20. OVERLAPPED结构的Internal表示错误码,InternalHigh表示传输的字节。由于异步IO跟文件指针无关(文件指针来不及修改),所以偏移存储在该结构中。
21. GetOverlappedResult函数实现为,访问结构的Internal、InternalHigh字段,另外如果结构的hEvent为空尝试Wait设备否则Wait事件(函数参数bWait为TRUE的时候)。
22. QueueUserAPC向线程的APC队列抛出一个用户自定义函数。
23. QueueUserWorkItem向线程池抛出任务。
24. 异步IO有四种方式得到完毕通知:(1)设备内核对象触发。(2)OVERLAPPED的hEvent内核对象触发。(3)APC回调(ReadFileEx)。(4)IO完成端口。
25. 异步IO-设备内对象触发:对FILE_FLAG_OVERLAPPED的文件使用ReadFile,将OVERLAPPED的hEvent设置为空,IO完成时设备句柄将触发,因此只能同时进行一次IO(瓶颈)。可以一个线程请求,另一线程响应完成。
26. 异步IO-事件内核对象的触发:将OVERLAPPED的hEvent设置为事件以获得通知。可以用SetFileCompletionNotificationModes来避免IO完成时去触发设备对象。可以一个线程请求,另一线程响应完成。
27. 异步IO-APC队列:ReadFileEx后使用SleepEx等让线程进入Alertable状态。同一个线程发出请求和响应完成(瓶颈)。
28. 异步IO-IO完成端口:步骤(1)CreateIoComplitionPort创建完成端口,指定活跃线程数(建议为CPU核心数)。(2)用CreateIoComplitionPort向完成端口添加异步设备。(3)创建完成端口服务线程(建议为CPU核心*2个,或者动态估计),初始化后使用GetQueuedCompletionStatus使线程和完成端口绑定并休眠。(4)执行异步IO,IO完成后底层会用PostQueuedCompletionStatus令正在GetQueuedCompletionStatus上休眠的服务线程苏醒响应。细节:可以在OVERLAPPED的hEvent指定一个值为hEvent | 1的数,令IO完成后不发出完成通知(即不Post)。可以使用GetQueuedCompletionStatusEx来一次响应多个请求。完成端口服务线程中,使用GetQueuedCompletionStatus休眠的线程叫等待线程,从GetQueued…返回的线程叫释放线程(活跃线程),活跃线程如果因其他原因(如Sleep、Wait)再挂起叫暂停线程,完成端口能够检测到各个线程的数量,会控制GetQueuedCompletionStatus的返回以使活跃线程尽量逼近创建完成端口时指定的数目。默认情况下异步IO即使同步完成,也会Post…,可以使用SetFileCompletionNotificationModes来禁用Post…。对于完成事件的响应是先入先出的,但服务线程的激活却是后入先出的(尽量激活相同线程,其他线程长期休眠其栈内存可以换出到页面文件提高性能)。
第11章 线程池的使用(第4版)
1. MessageBox弹出的对话框是可用修改的,FindWindow找到后,0x0000ffff是静态文本框的控件ID等,因此很容易实现倒计时自动关闭的消息框。
2. 从win2000开始提供的线程池主要有4种用法:(1)异步调用函数(QueueUserWorkItem)。(2)定时器回调(CreateTimerQueueTimer)。(3)内核对象触发后回调(RegisterWaitForSingleObject)。(4)内置IOCP实现(BindIoCompletionCallback)。
3. 线程池模块下有几种底层线程:(1)可变数量的长任务线程,用于执行标记为WT_EXECUTELONGFUNCTION的长时间回调。(2)1个Timer线程。所有CreateTimerQueueTimer调用都被转发为在Timer线程上创建以APC方式通知的WaitableTimer,这个线程除了删除和创建WaitableTimer外,就是在Alertable态下休眠等待定时器的APC。由于这个线程一旦创建就贯穿进程生命期不会销毁,因此WT_EXECUTEINPERSISTENTTHREAD标志的线程池回调也由本线程执行。(3)多个Wait线程。服务于RegisterWaitForSingleObject,每个线程用WaitForMultipleObjects等待最多63(MAXIMUM_WAIT_OBJECTS减去一个用于维护对象数组的工作对象)个内核对象,对象触发后执行回调。(4)可变数量的IO线程。由于发出异步IO请求(ReadFileEx)后,一旦请求线程结束,请求将被撤销,因此请求被驱动执行完毕之前IO请求线程一定要存在,而线程池内的线程大都会根据CPU繁忙情况动态创建和删除,因此线程池中有一部分线程被赋予了特殊行为,他们会检测自己执行回调时发出的异步IO请求是否完成,如果没有,就不会结束运行,这些追踪自身发起的异步IO请求执行情况的特殊线程叫做IO线程。因此只能在线程池的IO线程上执行异步IO调用。(5)可变数量的非IO线程。线程池内部实现了一个IO完成端口,服务于BindIoCompletionCallback,其中IOCP的服务线程(在GetQueuedCompletionStatus上休眠)由于数量会根据CPU情况动态调整,不应用于执行异步IO,故叫非IO线程。
4. 四种用法中,如果Flags参数指定的回调执行线程与默认线程不符,底层可以使用QueueUserWorkItem来切换线程。比如CreateTimerQueueTimer用法的默认线程肯定是Timer线程,发现WT_EXECUTELONGFUNCTION标记后,使用Queue…来切换到专门执行长任务的线程避免阻塞Timer线程影响定时器功能。
5. 用法1-异步函数调用:QueueUserWorkItem 。Flags参数为0(WT_EXECUTEDEFAULT)的时候回调交给非IO线程执行(通过PostQueuedCompletionStatus通知非IO线程)。还可以指定WT_EXECUTEINIOTHREAD交给IO线程、指定WT_EXECUTEINPERSISTENTTHREAD交给Timer线程、指定WT_EXECUTELONGFUNCTION交给长任务线程等。
6. 用法2-定时器回调:CreateTimerQueue-创建专用TimerQueue。DeleteTimerQueueEx-删除专用TimerQueue,参数CompletionEvent是用于接受删除Queue完毕通知的事件对象,如果设置为NULL表示不接受通知,设置为INVALID_HANDLE_VALUE表示阻塞等待删除完成。注意不能在Timer线程上的回调中以INVALID_HANDLE_VALUE为参数调用DeleteTimerQueueEx,因为后者实现为向Timer线程抛出一个要求维护Timer列表的APC,在线程的APC回调中抛出新的APC并且还阻塞等待,结果就是死锁。CreateTimerQueueTimer-创建具体的Timer对象,TimerQueue参数指定为NULL表示在默认的Queue上创建对象,适用于Timer对象不多的用法。使用WT_EXECUTEINTIMERTHREAD标记即要求在Timer线程上执行回调,因不必切换线程效率较高,注意回调不能过长影响Timer线程的功能。ChangeTimerQueueTimer-改变Timer对象的一些参数。DeleteTimerQueueTimer-删除Timer对象,注意使用INVALID_HANDLE_VALUE参数造成死锁的可能。
7. 用法3-等待内核对象触发回调:RegisterWaitForSingleObject-在内核对象触发或超时后执行回调。标记WT_EXECUTEINWAITTHREAD表示在Wait线程上执行,效率较高。WT_EXECUTEONLYONCE只执行一次回调,适用于进程/线程句柄这种触发后不再重置的对象。PulseEvent的脉冲可能不会被Wait线程检测到(线程刚好在干其他事)。UnregisterWaitEx-取消回调,注意INVALID_HANDLE_VALUE参数可能的死锁。
8. 用法4-内置IOCP实现:BindIoCompletionCallback。将异步IO设备和内置的IO完成端口管理起来,异步完成后执行回调。标志只能为0,默认在非IO线程(IOCP的服务线程)上执行,如果需要切换线程,手工QueueUserWorkItem。
第11章 Windows线程池
1. Visita以上的新线程池框架下四种用法:(1)异步调用函数(TrySubmitThreadpoolCallback、CreateThreadpoolWork)。(2)定时器回调(CreateThreadpoolTimer)。(3)内核对象触发后回调(CreateThreadpoolWait)。(4)内置IOCP实现(CreateThreadpoolIo)。
2. 新线程池的实现包括IOCP。
3. 用法1-异步函数调用:TrySubmitThreadpoolCallback-通过IOCP的Post…提交一个回调到线程池。使用Work对象允许一次创建多次提交效率更高:CreateThreadpoolWork、SubmitThreadpoolWork、WaitForThreadpoolWorkCallbacks、CloseThreadpoolWork。其中WaitFor可以等待所有提交项被执行完毕,或者取消掉进入队列但还没开始执行的项。注意不应该在回调中WaitFor,可能死锁。
4. 用法2-定时器回调:CreateThreadpoolTimer、CloseThreadpoolTimer -创建/删除。SetThreadpoolTimer-设置Timer参数。起始时间为-1表示立即开始。如果将起始时间设置为NULL,表示停止Timer,停止后用IsThreadpoolTimerSet判断返回FALSE。另外msWindowLength表示允许回调触发时间有一个向后的波动(0~msWindowLength),这样底层可以在这个波动范围内将多个回调连续执行,避免多次Wait和Wakeup(比如Timer A、B分别在5、6秒后执行,A的波动为2秒,这样系统可以连续执行A、B回调,不必在两者之间插入SleepEx导致额外的线程切换开销)。
5. 用法3-等待内核对象触发回调:CreateThreadpoolWait、CloseThreadpoolWait、WaitForThreadpoolWaitCallbacks类似前面。SetThreadpoolWait指定要等待的内核对象,每次调用只会导致执行一次回调,除非再Set…(即如果Wait进程句柄,进程结束后只会执行一次回调,想要多执行需要再调用Set…)。PulseEvent的脉冲有可能不会触发回调。
6. 用法4-内置IOCP实现:CreateThreadpoolIo、CloseThreadpoolIo同前面。每次异步IO请求之前(ReadFileEx)需要调用StartThreadpoolIo。发出IO请求后停止回调用CancelThreadpoolIo。
7. 对于新线程池回调中的参数PTP_CALLBACK_INSTANCE,可以执行一些操作:LeaveCriticalSectionWhenCallbackReturns、ReleaseMutextWhenCallbackReturns、ReleaseSemaphoreWhenCallbackReturns、SetEventWhenCallbackReturns-这些函数都近似等价于在回调的最后一行释放相关资源(模仿RAII?),不过以上API只有最后一次调用有效(即只能注册一个资源)。FreeLibraryWhenCallbackReturns-回调返回后释放某个Dll,当回调代码本身位于要释放的Dll中时有价值。CallbackMayRunLong-通知线程池回调可能执行较长时间,返回TRUE表示当前线程池有空闲线程,否则表示线程池紧张,建议将剩余执行任务拆分以减少回调时间。DisassociateCurrentThreadFromCallbacks-一般回调返回后,回调就和执行线程解除关系了,那些WaitForThreadpool…Callbacks就能返回,而这个Disassociate函数就是为了在回调结束前提前打上脱离关系的标记,影响包括WaitForThreadpool…的函数等。
8. 定制私有线程池:CreateThreadpool、CloseThreadpool、SetThreadpoolThreadMaximum、SetThreadpoolThreadMinimum-创建线程池对象,设置线程数量范围。注意如果数量上下界相同,那么在线程池中的线程一旦创建就不会销毁,可以用来进行异步IO调用等。InitializeThreadpoolEnviroment、DestroyThreadpoolEnviroment-构建环境。SetThreadpoolCallbackPool-将线程池对象置入环境。SetThreadpoolCallbackRunsLong-标记环境对应的线程池用于执行长任务。SetThreadpoolCallbackLibrary-标记环境对应的线程池中有任务执行期间,该Dll一直在内存中。
9. 线程池清理组(CleanupGroup):一个WaitForThreadpool…Callbacks+CloseThreadpool…的可选替代方案。CreateThreadpoolCleanupGroup、CloseThreadpoolCleanupGroup-创建/删除。SetThreadpoolCallbackCleanupGroup-将清理组置入环境。CloseThreadpoolCleanupGroupMembers-用来在线程池关闭前清理资源,一旦调用该函数就不必再 “遍历每种资源(Work、Timer、Wait、IO)依次调用WaitForThreadpool…Callbacks、CloseThreadpool…”,即该函数调用后,所有以前的线程池组件都被销毁了,句柄也失效。如果该函数的bCancelPendingCallbacks参数为TRUE,那些还在线程池中排队的任务直接取消不再执行,但会通过SetThreadpoolCallbackCleanupGroup注册的函数通知每个被直接取消掉的任务。
第12章 纤程
1. 纤程其实就是Windows在用户模式实现的协程(coroutine)。
2. 将线程自身转化为纤程:ConvertThreadToFiber-它会创建相应的结构保存当前线程的各种寄存器等数据。ConvertThreadToFiberEx-默认的结构中是不包含浮点寄存器的,使用这个API传入FIBER_FLAG_FLOAT_SWITCH可以保证浮点运算正确。ConvertFiberToThread-当不使用纤程后,应该用这种方式还原为线程。
3. CreateFiber、CreateFiberEx:创建一个包括独立栈和寄存器记录结构的新纤程,后一个函数能够指定初始化的栈物理内存、虚拟内存以及浮点寄存器支持标志。不使用这种纤程后,在其他纤程中使用DeleteFiber来结束Create出来的纤程。
4. 从纤程函数中返回会结束当前线程(当然也结束该线程上所有其他纤程)。
5. SwitchToFiber-切换纤程。
6. FLS支持(Fiber Local Storage):FlsAlloc-可以指定一个回调,这个回调在FlsFree或纤程销毁时以FlsGetValue的返回值为参数被执行,可用于清理等。FlsGetValue、FlsSetValue。
7. IsThreadAFiber-判断当前是否在某个纤程的上下文中执行。GetCurrentFiber-返回当前纤程上下文。GetFiberData-返回当前纤程主函数的参数。
第13章 Windows内存体系结构
1. 在32位系统上,虚拟地址空间大致分为4段(64位系统也分为4段,只是大小不同):(1)0x00000000~0x0000ffff,空指针赋值区,辅助调试,禁止任何方式的访问。(2)0x00010000~0x7ffeffff,用户模式分区,各进程单独维护,同一地址值在不同的进程可以有不同解释,各种映像文件(dll、exe)和内存映射文件也载入本区,近2G。(3)0x7fff0000~0x7fffffff,64k禁入分区。(4)0x80000000~0xffffffff,内核模式分区,系统存放内核代码、设备驱动代码、输入输出高速缓存、进程页表等,2G。
2. 32位系统可以配置系统参数让进程用户模式分区达到3G,内核减小为1G。内核内存减小,会影响可以创建的总线程、内核对象数量等。(Visita系统以上,使用bcdedit /set IncreaseUserVa 3072;xp使用…)。
3. 链接选项-启用大地址(/LARGEADDRESSAWARE):因为过去32位系统用户地址空间固定为2G(直到可以设置用户地址最大到3G),所以有惯用法依赖于这种行为(系统对地址参数会先&0x7fffffff的行为)擅自将地址最高位用于其他目的,为了兼容大量的这种用法并且又允许选择使用3G用户内存,MS增加了这个链接选项。如果开启,表示承诺不使用最高位,想要访问超过2G的用户地址;关闭,表示只使用2G内存,最高位可能有其他解释(在实际的系统实现上,如果用户地址最高位非0会报错)。64位系统中,为了便于大量32位程序向64位移植(32位程序中有大量用法如:int i = (int)p; …; int *p = (int)i;),系统默认程序只使用2G用户空间,所以分配的用户地址总是小于2G,直到开启该连接选项。总之,无论32位或64位系统,如果只使用2G,关闭选项,否则开启。
4. VirtualAlloc的MEM_RESERVE参数表示要预定一段空间(如线程栈,即使大部分时候栈都很小,但也需要预留1M左右),叫区域(region)。用户代码申请预留的起始地址必须按allocation granularity(分配粒度,因CPU而异,但当前CPU大都为64KB)对齐,系统的预留申请无限制(如PEB占用的内存是系统申请的)。预留的大小必须按页面大小对齐(x86、x64CPU的页面大小为4KB)。VirtualAlloc的MEM_COMMIT参数表示将区域commit给虚拟存储器,系统会在使用时将对应的页缓存到物理存储器。
5. 在操作系统内存管理模型中,虚拟地址用于访问虚拟存储器,后者存放于磁盘上,主存作为虚拟存储器和CPU之间的缓存(DRAM)被叫做物理存储器。当CPU要访问内存时,首先,检查该虚拟地址是否对应合法的虚拟存储器(是否commit),如果否则报错表示无效地址,如果是,然后判断该虚拟页(VP,Virtual Page)是否被缓存到内存,即是否有对应的物理页(PP, Physical Page),如果否则产生缺页错误(Page Fault)进而判断主存中是否有闲置页面,如果没有闲置页面,则尝试释放一个物理页,先判断要释放的物理页是否被修改,如果被修改了则Flush到对应的虚拟页上然后释放物理页,有了闲置的物理页后,将虚拟地址对应的虚拟页缓存到空闲的物理页上进而更新虚拟地址到物理地址的映射表,然后CPU的MMU(memory management unit,内存管理单元)将虚拟地址翻译为物理地址,再判断该地址对应的内容是否已经在Cache上,如果否则Cache Miss然后再将对应的Cache Line缓存到Cache中,最后读取到CPU寄存器中。在Windows中,虚拟存储器对应的磁盘空间进一步细分到页交换文件(page file)、映像文件(exe、dll)、内存映射文件(mapped file)中,后两者被当做虚拟存储器的时候还可以在多个进程间共享(写时拷贝),由于存在共享机制因而Windows的虚拟存储器占的磁盘空间远小于所有进程提交的用户模式内存之和。
6. VirtualAlloc、VirtualProtect等函数可以设置页保护属性:PAGE_EXECUTE(只能运行代码不能读写)、PAGE_EXECUTE_READ(只读和运行代码)、PAGE_NOACCESS等。其中PAGE_WRITECOPY、PAGE_EXECUTE_WRITECOPY属性表示页面可以被多个进程共享,直到被修改,修改时是先拷贝到进程私有页中再修改私有页,这是copy-on-write。Reserve状态下的保护属性会被Commit下的属性覆盖,但两者都可以在VirtualQuery中查询到。
7. 在CPU体系结构中,CPU要访问的数据需要按数据大小对齐(WORD地址按2对齐,DWORD地址按4对齐),否则会产生异常。修复数据未对齐异常有几种途径:(1)x86 CPU会自动进行硬件修复,访问没对齐的数据只是更慢。(2)SetErrorMode传入SEM_NOALIGNMENTFAULTEXCEPT参数,通知Windows通过软件修复未对齐问题。(3)编译选项__unaligned会自动产生额外代码修复问题。综上,后两种软件修复方案适用于非x86 CPU速度更慢,最好还是按数据大小对齐内存。
第14章 探索虚拟内存
1. 工作集(Working Set):缓存到主存中的那些页面。
2. 32位系统中的32位程序和64位系统中的64位程序,都用GetSystemInfo来获取系统信息,而64位系统中的32位程序(IsWow64Process返回TRUE)用GetNativeSystemInfo。获取处理器信息用GetLogicalProcessorInformation,获取内存信息用GlobalMemoryStatus。
3. SYSTEM_INFO(GetSystemInfo)各字段的解释:dwPageSize-页面大小。lpMinimumApplicationAddress、lpMaximumApplicationAddress-用户模式内存大小,32位系统中是0x0001000到0x7ffeffff。dwActiveProcessorMask-CPU掩码,同AffinityMask。dwNumberOfProcessors-处理器个数。wProcessorArchitecture、wProcessorLevel、wProcessorRevision-决定CPU型号。
4. MEMORYSTATUS(GlobalMemoryStatus)各字段的解释:dwMemoryLoad-内存管理系统负载的大致估计,0~100,可以忽略。dwTotalPhys、dwAvailPhys-系统总的物理内存和剩余物理内存。dwTotalPageFile、dwAvailPageFile-系统总的页交换文件和剩余页交换文件。dwTotalVirtual-系统各进程最大用户模式内存(32位是2G-128K)。dwAvailVirtual-当前进程剩余用户模式内存。
5. PROCESS_MEMORY_COUNTERS_EX(GetProcessMemoryInfo)各字段的解释:PageFaultCount-缺页错误数。WorkingSetSize-工作集,即当前进程物理内存占用。PagefileUsage-当前进程的页交换文件占用(包括全部的类型为private内存块和部分的image、mapped块,后者在写拷贝后其虚拟存储器才转移到page file中)。PrivateUsage-当前进程私有的内存占用,其虚拟存储器位于页交换文件中,虚拟存储器中除去这部分其他的都位于共享文件中了(一般值等于PagefileUsage)。
6. NUMA(非统一内存访问,Non-Uniform Memory Access。一种分布式计算机系统内存模型)机器中的内存管理:GlobalMemoryStatusEx获取各节点总内存。GetNumaHighestNodeNumber-获取系统中总的节点个数。GetNumaAvailableMemoryNode-获取某节点的内存。GetNumaNodeProcessorMask-获取某节点的CPU掩码。GetNumaProcessorNode-判断某CPU位于的哪个节点。
7. VirtualQuery可以查询某地址所在的内存块(内存块是具有相同状态、保护属性和类型的连续页面),也提供了一些信息指出该内存块在reserve的时候其VirtualAlloc起始地址和保护属性等。
8. MEMORY_BASIC_INFORMATION(VirtualQuery)各字段解释:BaseAddress-内存块起始地址。RegionSize-内存块长度(jeffrey把reserve的叫区域把这儿的叫内存块,而Windows只把这儿叫region,我姑且同前者的概念)。State-块状态,可以是free、commited、reserved。Protect-保护属性,状态是commited时有效。Type-类型,可以是private(私有内存,虚拟存储器在页交换文件)、image(在写拷贝之前,其虚拟存储器就是映像文件(exe、dll),写拷贝(修改dll代码或全局变量等)之后虚拟存储器转移到page file)、mapped(类似image,写拷贝之前虚拟存储器是内存映射文件),状态是commited时有效。AllocationBase、AllocationProtect-reserve时候的基地址和保护属性,状态非free的时候字段有效。
9. Windows进程内存布局:分成很多内存块,其中部分内存块属于同一个区域(reserve的region)。如果要实现内存搜索的功能,可以用VirtualQuery遍历各块,在commited的块中搜索。
10. 线程栈的内存块具有PAGE_GUARD保护属性。
11. 一个进程内存使用的统计分析:输出见最后(单位均为KB)。第一部分是用VirtualQuery遍历各块进行统计,可见进程commit了5.8M内存到虚拟存储器,其中4.5M是映像文件(部分在pagefile中),1.1M是内存映射文件(部分在pagefile中),159K是私有内存(全部在pagefile中)。第二部分使用GetProcessMemoryInfo,可见进程占用主存(物理存储器)1.6M,虚拟存储器中有425K在pagefile中(包括第一部分中全部的private和部分的image、mapped),也说明进程使用的5.8M内存中有5.4M是共享的(5.8-425)。第三部分用GlobalMemoryStatus看出该进程可用虚拟内存为2G。
VirtualQuery :
commitedBytes = 5865.47
readAllowedBytes = 5865.47
imageBytes = 4517.89
mappedBytes = 1187.84
privateBytes = 159.744
GetProcessMemoryInfo :
WorkingSetSize = 1658.88
PagefileUsage = 425.984
PrivateUsage = 425.984
GlobalMemoryStatus :
dwAvailVirtual = 2.13826e+006
第15章 在应用程序中使用虚拟内存
1. 用VirtualAlloc来reserve区域的时候:pvAddress为空表示由系统分配区域起始地址,同时使用MEM_TOP_DOWN标志,提示系统优先选择高地址,适用于长时间占用的内存。自定义起始地址的时候,实际reserve到的区域会包含自定义的范围(自定义的起始地址+长度),即返回的地址可能比自定义起始地址小,同时保证该区域起始地址与系统的分配粒度对齐,长度与分页大小对齐。如果找不到这样长的闲置连续空间,返回NULL。reserve和commit的保护属性相同,性能更好。
2. 用VirtualAlloc来commit内存块的时候:实际提交的块会包含自定义范围,并且起始地址和长度都与页面大小对齐。提交的块不应该跨两个区域。
3. VirtualAllocExNuma,适用于NUMA机器。
4. 在Visita以上的系统中,可以分配大页面,大页面是常驻内存的需要有锁定页面的权限(Lock Pages In Memory),同时要求在VirtualAlloc时满足三个条件:(1)大小必须与GetLargePageMinimum对齐(自然该函数必须返回非0)。(2)同时reserve和commit。(3)保护属性必须是PAGE_READWRITE。
5. 在需要使用有空洞的大段连续内存的时候,有一个技巧:reserve一大段,根据需要commit。因为只有commit了才占用虚拟存储器,因此很节省内存。
6. VirtualFree可以反提交和释放内存,其中MEM_RELEASE时的长度参数必须为0,表示释放整个区域。
7. VirtualProtect改变保护属性,注意一次调用不要跨多个区域。
8. VirtualAlloc的MEM_RESET标志,表示愿意暂时放弃一段内存的当前内容,如果系统的物理内存使用紧张,reset的这段内存对应的物理内存可能会被挪用,直到再次访问这段内存。
9. 即使通过VirtualAlloc来commit了,只要没有访问过这段地址,系统也不会分配内存。即如果commit的1.5G内存不读写,开销很小。
10. 地址窗口扩展(AWE,Address Windowing Extension):可以指定一段地址直接映射到物理内存,具有常驻内存和增加可用内存量的优点。以MEM_PHYSIC调用VirtualAlloc来指定要用于映射的虚拟地址段,然后AllocateUserPhysicalPages分配物理页面,再MapUserPhysicalPages将虚拟地址段和分配的物理页面关联,之后随意读写,使用完毕后以NULL作为参数调用MapUserPhysicalPages解除关联,最后FreeUserPhysicalPages、VirtualFree释放物理页面和地址段。一段虚拟地址可以通过Map和Unmap轮流访问多段物理内存,明显增加了进程可访问的内存总量。AWE也要求用户有锁定页面的权限。
第16章 线程栈
1. 链接选项“/statck:reserve[,commit]”可以在PE文件中记录默认的线程栈保留大小和提交大小,实际栈大小还要结合_beginthreadex时的参数。
2. PAGE_GUARD属性的作用:第一次访问具有该属性的页面,会触发一个STATUS_GUARD_PAGE_VIOLATION异常,同时该属性被自动抹除,于是后续的访问正常。即该属性用于首次访问的通知。
3. 默认条件下,线程栈创建时先reserve一块1MB的内存,栈底的两块页面被提交,其中较低地址的那块页面具有PAGE_GUARD属性,被称为保护页面(Guard Page)。当栈的调用层次变深需要更多内存时,系统去掉当前保护页面的PAGE_GUARD属性并提交下一个页面作为保护页面(实现方式见条款4)。这个过程进行下去,栈顶所在的提交页面之后始终有一块被提交的保护页面,直到栈的调用层次足够深,当倒数第二个页面被提交并需要标记为保护页面的时候,这个标记行为终止并抛出EXCEPTION_STACK_OVERFLOW异常。栈最低地址的一个页面始终处于reserve状态,用来隔离栈和栈下方的内存空间,避免非法的栈操作访问越界。捕获了栈溢出结构化异常的线程由于没有了保护页面,需要调用_resetstkoflw来重新标记保护页,否则下次调用层次太深的时候会因为没有保护页不触发栈溢出异常直接访问到最低地址的reserve页,造成非法访问错误。
4. 栈上reserve页被从高到底依次commit的方式:当位于栈顶的函数帧在保护页面中时,访问保护页内存会触发异常,系统捕获异常,提交下一页,并判断下一页是否是倒数第二页,是的话抛出栈溢出异常,否则将一下页标记为保护页。如果栈顶函数帧很大(比如包含大数组),跨越多个分页,由于函数内部可能先访问函数帧中最低地址的reserve页的内存,引起非法访问错误,于是C++编译器对这种栈帧大于1个分页的函数进行了特殊处理:编译器会在大栈帧函数的开始插入_chkstk,后者会沿大栈帧的底部向顶部依次访问每个分页,连续推动保护页,保证后来函数体中的随机访问都作用在commit分页上。
5. Debug版本程序在调用函数前,会备份当前栈的上下文,在函数返回后对比新的栈数据和备份数据,判断是否有栈上的越界错误。Release版本程序开启/Gs开关后能起到类似的效果。
第17章 内存映射文件
1. 内存映射文件的主要应用场合:(1)映射到映像文件(Exe、Dll),加速进程启动。(2)映射到数据文件,代替标准的文件IO。(3)共享内存。
2. 当DLL被LoadLibray时如果发现预定基地址已经被占用时,可能会加载失败(构建DLL时指定了/FIXED链接选项),至少也会重定位,后者会占用额外存储空间和增加DLL载入时间。
3. 段的大小都按页大小对齐。
4. 使用dumpbin.exe /headers可以查看PE文件的各种段。常见段:.bss-未经初始化的全局变量等数据。.CRT-只读的C运行时数据。.data-已初始化的全局变量。.debug-调试信息。.didata-延迟导入名字表(delay imported names table)。.idata-导入名字表。.edata-导出名字表。.rdata-只读的运行时数据。.reloc-重定位表信息。.rsrc-资源。.text-代码段。.textbss-启用增量链接(Incremental Linking)时C++编译器生成。.tls-线程本地存储。.xdata-异常处理表。
5. 默认情况下.data段的页面具有写拷贝属性,因此PE文件的一个实例修改全局变量并不会影响其他进程实例。
6. 使用#pragma data_seg(“MyDataSeg1”); #pragma data_seg();可以声明一个新的数据段,其中初始化的变量会自动加入该段。没有初始化的变量可以通过__declspec(allocate(“MyDataSeg1”)) int g_i;来加入数据段。用#pragma comment(linker, “/section:MyDataSeg1, RWS”)来为段指定属性,”S”表示Shared,它通过去掉段页面的写拷贝保护属性,来达到多进程共享的效果。
7. CreateFileMapping:参数fdwProtect的PAGE_READONLY、PAGE_WRITECOPY等很容易理解,另外还有几种属性:SEC_COMMIT-默认值。SEC_IMAGE-表示该文件是映像文件,该文件被映射到内存时,系统会对其中不同的段添加对应的保护属性。SEC_NOCACHE-无cache,驱动开发人员用。SEC_LARGE_PAGES-大页面支持,类似VirtualAlloc那边。SEC_RESERVE-通过这个标记映射的内存没有是没有被提交的,直到再调用VirtualAlloc来commit才能访问这些页面。参数dwMaximumSizeHigh、dwMaximumSizeLow表示要求的最大文件大小,尤其在共享内存对应的虚拟存储器在页交换文件中时特别有意义(hFile参数为INVALID_HANDLE_VALUE的情况),如果映射的可写磁盘文件本身的大小没有达到这个值,文件也会被自动扩大。如果最大大小为0,表示使用磁盘文件本身大小。
8. MapViewOfFile:创建映射对象的一个视图,多个视图之间的数据是严格同步的,因为同一个映射对象的多个视图尽管虚拟地址段不同,但都映射到同一个虚拟存储器上。该函数返回后,内存已经被commit(除非CreateFileMapping时指定SEC_RESERVE参数)。参数dwFileOffsetHigh、dwFileOffsetLow、dwNumberOfBytesToMap共同决定要把文件的哪部分映射到内存,Offset必须与分配粒度对齐,Size为0的时候表示范围从Offset直到文件尾。对返回的地址VirtualQuery会得到Map的区域。
9. UnmapViewOfFile:释放映射的内存区域。
10. FlushViewOfFile:将缓存中已修改的数据Flush到文件中,如果没修改被直接丢弃。注意如果映射页面具有写保护属性,缓冲中的数据最多被Flush到Page File中。如果是映射到远程文件,该函数只保证数据被Flush到网上,而远程的文件不一定会被修改,除非CreateFile时指定了FILE_FLAG_WRITE_THROUGH。
11. 注意,虽然CreateFileMapping会增加文件对象计数,MapViewOfFile会增加映射对象的计数(也就是说,在UnmapViweOfFile之前这两个内核对象就可以被CloseHandle了),但是如果太早关闭映射对象,其他地方要打开映射对象时会失败(即OpenFileMapping失败或者CreateFileMapping的LastError不是ERROR_ALREADY_EXISTS),也就是说,内核通过视图对映射对象的引用,不能被用户模式代码检测到,因此最好还是按传统顺序先UnmapViewOfFile再CloseHandle。
12. NUMA支持:CreateFileMappingNuma、MapViewOfFileExNuma。
13. 打开同一个磁盘文件的多个文件内核对象,由于各自拥有独立缓冲区,因此文件内容在不同对象间不保证实时同步。
14. 映射到同一文件的多个映射对象的视图不保证数据的实时同步。
15. MapViewOfFileEx:参数pvBaseAddress非空的时候可以指定映射内存的起始地址。系统映射EXE和DLL的时候就这么干的。
16. 各种跨进程通讯手段的通讯双方都位于本机时,这些通讯方式最终都实现为内存映射文件。
17. 要映射到磁盘文件时,一定要判断CreateFile的返回值,因为如果打开文件失败,INVALID_HANDLE_VALUE句柄会让CreateFileMapping创建映射到PageFile的对象,没有报错却是歧义。
18. 对应VirtualAlloc那“reserve一大段内存再小块commit”的用法,内存映射文件中实现如下:以SEC_RESERVE为参数CreateFileMapping,之后MapViewOfFile得到reserve的区域,最后确保访问前要先用VirtualAlloc来commit。注意这样commit的共享内存不能VirtualFree。
第18章 堆
1. 堆适合分配小内存块,不需要按分配粒度或者页大小对齐。堆在最初只是预定了一块区域,在客户分配时将预定的区域提交,在客户释放后可能反提交。
2. 关于默认堆:GetProcessHeap返回,用户模式代码无法销毁它,在进程结束后由系统销毁。进程可以通过链接选项“/HEAP:reserve[,commit]”来设置默认堆大小。因为默认堆属于进程,所以在DLL中不应设置该链接选项。Windows的ANSI版API向Unicode版转化的时候从默认堆分配字符串缓存,LocalAlloc、GlobalAlloc也从默认堆分配内存。默认堆对外界访问进行了同步,即没有使用HEAP_NO_SERIALIZE标记。
3. 使用独立堆的一些好处:(1)写堆内存出错后,不会影响其他堆的数据。(2)对特定类型数据使用独立堆的话,由于分配块大小相同,具有速度快、无碎片的优点。(3)相关数据使用独立的堆,在访问这些数据时访问的页面更集中,减少PageFault。(4)对特定线程上的逻辑结构使用独立堆,不必加锁,提高性能。
4. HeapCreate:参数fdwOption,如果在创建堆的时候指定了部分标志(如HEAP_NO_SERIALIZE标志等),以后每次访问堆这些标志都生效;如果创建的时候没有指定,那后续的每次访问可以单独指定标志。 HEAP_NO_SERIALIZE-访问堆的时候不加锁。HEAP_GENERATE_EXCEPTIONS-分配内存失败的时候抛出异常,默认行为是返回NULL。HEAP_CREATE_ENABLE_EXECUTE-可以在堆内存上放置代码来执行。参数dwInitalSize-初始堆大小。参数dwMaximumSize-如果非0,表示如果堆内存使用量达到这个值后再分配会失败;为0,表示堆会自动增大,直到内存用尽。
5. HeapAlloc、HeapSize、HeapFree、HeapDestroy,容易理解。
6. HeapReAlloc:HEAP_ZERO_MEMORY-增大内存时,增加的字节初始化为0。HEAP_REALLOC_IN_PLACE_ONLY-要求不移动起始地址的情况下改变大小,需要增大时如果当前位置剩余空间不足会返回NULL。
7. HeapSetInformation:标记HeapEnableTerminationOnCorruption-Visita以上使用。默认情况下,堆内存被破坏后只在调试器中触发一个断言然后继续执行,这个标记允许发现堆破坏就抛出异常。该标记影响进程中所有堆,无法清空标记。标记HeapCompatibilityInformation-值为2的时候,表示启用低碎片堆(lowfragmentation heap)算法,启用该算法的堆针对内存碎片问题优化有更好的性能。
8. Heap32ListFirst、Heap32ListNext-遍历快照(CreateToolhelp32Snapshot)中的堆。Heap32First、Heap32Next-遍历指定堆中的块。GetProcessHeaps-获得包括默认堆在内的所有堆句柄。HeapValidate-检查指定堆中所有块或者单个块的有效性。HeapCompact-将堆中闲置块合并,并反提交。HeapLock、HeapUnlock-锁定堆。HeapWalk-遍历指定堆中的块,建议先锁堆。
第19章 DLL基础
1. Kernel32.dll-管理内存、线程、进程。User32.dll-窗口和消息。Gdi32.dll-绘制图像文字。ComDlg32.dll-常用对话框。ComCtl32.dll-常用控件。
2. DLL函数分配的内存应该由DLL自己提供的函数释放:主要是针对通过C/C++函数(malloc、new)分配的内存,因为当DLL和DLL的使用者都在引用静态库版本的CRT时(或有一方在引用静态库CRT),多个静态库版CRT中有多份CRT堆的管理数据(全局变量),如果从一个管理器分配资源交给另一个管理器释放,显然会错误。因此,如果所有模块都使用DLL版CRT就不会有错(因为只有一份全局CRT堆管理数据),或者改用HeapAlloc(GetProcessHeap(),…)也不会错(显然DLL中和EXE中访问到的默认堆是同一个),当然最佳做法还是DLL同时提供匹配的释放函数。
3. .lib文件中只包含函数、变量和类型的符号名。由于模块中只包含要引用的模块名而没有路径,所以主模块被载入后需要按一定的搜索顺序搜索被引用模块再载入,同时这也意味着修改.lib中的符号名,搜索DLL时也会搜索新名称。
4. DLL的导出段中按符号名顺序列出了导出项,每一项包括符号名和RVA(Relative Virtual Address,用于指出该符号在DLL模块中相对于模块基址的地址)。模块可以包含多个导入段,每个导入段指出该段要依赖的DLL名以及需要的符号,导入符号对应的实际地址在DLL被载入后填充,其值为DLL基址+RVA。
5. 在为DLL的导出函数指定名称的时候,最好使用.def文件,其次可以选择链接选项#pragma comment(linker, “/export:MyFunc=_MyFunc@”)。
6. dumpbin.exe的/exports能够查看导出段,/imports能够查看导入段。
7. 关于MSVC编译器对符号改名的策略:C语言下默认不改变函数名,因此C++下使用了extern “C”的__cdecl也不会改名。
第20章 DLL高级技术
1. 加载一个DLL,系统至少会干几件事:(1)将不同段的分页分别映射并赋予不同的保护属性。(2)检查DLL依赖的其他DLL依次加载。(3)执行DllMain。
2. LoadLibraryEx:dwFlags参数-DON’T_RESOLVE_DLL_REFERENCES-将DLL映射到内存后,对于条款1中的三件事,只做按段分配保护属性这件。LOAD_LIBRARY_AS_DATAFILE-比起上个标志,连三件事中仅剩的一件也省了,只是映射文件,用做数据文件。可以加载EXE然后读取其中的资源。LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE-以独占方式映射数据文件。LOAD_LIBRARY_IMAGE_SOURCE-在AS_DATAFILE的基础上,将导出段的所有RVA转换成VA。LOAD_WITH_ALTERED_SEARCH_PATH-可以调整DLL路径的搜索方式。LOAD_IGNORE_CODE_AUTHZ_LEVEL-安全相关,该安全方案被后来的UAC取代。
3. SetDllDirectory:设置加载DLL时的搜索路径,DLL在搜索进程的当前路径过后就会搜索这里。当路径为空串(””\0”)的时候,表示搜索的时候跳过当前路径,当路径为NULL的时候恢复默认搜索方式。
4. FreeLibraryAndExitThread适用于一个场合:要调用FreeLibrary的代码正是位于DLL中。
5. LoadLibrary和LoadLibraryEx返回的地址不等价,不能混用。如先以LOAD_LIBRARY_AS_DATA_FILE做参数调用LoadLibraryEx,再用LoadLibrary加载同一个DLL,返回值是不同的。
6. GetProcAddress。
7. 名为DllMain的函数不存在的时候,系统会使用默认入口。
8. DllMain的fdwReason参数:DLL_PROCESS_ATTACH-DLL第一次被加载的时候传入,对于隐式加载的DLL是主线程执行,而显式加载的DLL由LoadLibrary线程执行,用于执行DLL初始化操作。返回FALSE,程序会报错表示加载DLL失败。DLL_PROCESS_DETACH-隐式卸载的时候由主线程执行,显示卸载的时候由FreeLibrary线程执行,负责清理资源。DLL_THREAD_ATTACH-线程在创建时,会检查进程已经加载的DLL,然后依次通知每个DLL的DllMain函数。进程启动时会先创建主线程,再加载各个DLL,因此这时主线程调用DllMain只会传入DLL_PROCESS_ATTACH而不是DLL_THREAD_ATTACH。DLL_THREAD_DETACH-线程退出的时候检测所有已经加载的DLL依次调用DllMain。
9. DisableThreadLibraryCalls:声明线程在创建和退出的时候不用通知指定DLL的DllMain函数。
10. 所有DLL的DllMain的调用被加载锁(Loader Lock,进程唯一的)序列化了。避免同时创建多个线程以DLL_THREAD_ATTACH调用DllMain时产生竞争。
11. 对于C++编写的DLL,实质上系统通知的是__DllMainCRTStartup,当fdwReason是DLL_PROCESS_ATTACH和DLL_PROCESS_DETACH时,它会调用全局变量的构造或析构函数,然后再调用DllMain。
12. 延迟载入是指直到使用DLL导出的函数时系统才加载DLL和查找函数。优点:加速进程启动、让为高版本系统设计的程序在低版本系统中也能使用部分功能、特殊的设计用途等。部分DLL不能延迟加载:导出了数据的DLL(因为延迟加载利用的是GetProcAddress等功能)、Kernal32.dll。另外在DllMain中也不该使用延迟载入的DLL函数。
13. 延迟加载的使用:在Linker-Input-Delay Loaded Dlls中指定要延迟载入的DLL。如果要Hook延迟加载过程以及停用延迟加载的DLL,需要再导入DelayImp库和开启Linker-Advanced-Delay Loaded Dll的Support Unload。
14. 延迟加载的细节:模块引用的DLL要延迟加载的话,会删除该DLL的idata段,改为包含didata段,对延迟加载函数的调用会跳转到__delayLoadHelper2函数中,该函数会确保该DLL已经被加载,然后检查didata中对应函数的表项是否非空,为空的话用GetProcAddress查找并填充didata项,下次使用就不用再查找。用__FUnloadDelayLoadedDLL2卸载延迟加载DLL,以便之后再次使用延迟函数能够保证正常,该函数会清空didata中已经填充的各项。__pfnDliNotifyHook2、__pfnDilFailureHook2是延迟加载过程的Hook函数指针。
15. 函数转发器:#pragma comment(linker, “/export:SomeFunc=DllA.SomeOtherFunc”)。
16. HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs包括一些影响LoadLibrary路径查找的信息。
17. 关于模块基地址重定位:DLL中的代码访问DLL中的全局变量时用的是绝对地址,同时会增加一个reloc段(重定位段)记录所有引用绝对地址的代码,如果DLL最终加载的位置不是默认基址,之前使用的绝对地址需要根据reloc的记录被修正,这就是重定位过程。可见如果进程加载的时候,多个DLL基址发生冲突,需要被重定位,修复绝对地址的操作增加了加载时间,同时也会因为修改Image内存页造成写拷贝,增加了系统的虚拟内存占用。最理想的情况下,所有使用DLL的进程都不需要重定位,这就需要安排合理的基址,可以使用Rebase.exe工具或者ReBaseImage函数。使用dumpbin.exe /headers命令可以查看包括基址的信息。使用/FIXED开关删除reloc段,禁用重定位。
18. 关于模块的绑定:默认情况下,模块的引入段,会在进程加载模块后被填入导入函数的绝对地址,因此包含引入段的模块会发生内存页的写拷贝。使用Bind.exe工具,可以在映像文件中的idata段填入绝对地址和对应DLL的时间戳,当进程加载时,发现被依赖DLL没有被重定位(即基址和默认基址相同)且时间戳和绑定的DLL相同,那么idata段就可以不用修改直接使用绑定值,避免了写拷贝。可以使用Bind.exe工具或BindImageEx函数来绑定模块。绑定操作应该在软件每次升级后执行。
19. 综合讨论重定位和绑定:一个DLL中,引入段最终包含所依赖的DLL的函数地址,如果所依赖的DLL没有被重定位,那引入段不用被修改避免了写拷贝;DLL内部的全局变量是用绝对地址访问,如果DLL本身没有被重定位,这些绝对地址不用被修改也避免了写拷贝。因此用ReBase.exe工具合理安排所有DLL的基址,然后在用Bind.exe工具写入导入函数地址,能提升性能和减少内存占用。
第21章 线程局部存储区
1. 动态TLS:每个线程都有一个内部DWORD数组用于存放用户数据,MS保证数组至少有TLS_MINIMUM_AVAILABLE(64)个元素。用TlsAlloc申请一个空闲索引,调用TlsSetValue、TlsGetValue时传入这个索引可以访问每个线程上的用户数组,用TlsFree释放索引,Windows会保证被释放的索引在各个线程上的数据都被清零。DLL中使用动态TLS的标准方式:DLL_PROCESS_ATTACH的时候TlsAlloc一个索引;在DLL_PROCESS_DETACH的时候用TlsFree释放;在DLL功能函数内部检测TlsGetValue返回的指针是否为空,为空的话分配一块内存包含DLL要使用的所有线程相关数据;在DLL_THREAD_DETACH中检测TlsGetValue返回值非空则释放掉。
2. 静态TLS:声明为__declspec(thread)的静态变量会保存在模块的tls段中, 每个线程在创建的时候会根据当前所有模块的tls段总大小分配一块内存与线程对象关联,这块线程相关内存的大小也会随LoadLibrary、FreeLibrary增删包含tls段的DLL进行调整。静态TLS只在Vista以上才被完美实现。考虑这样一种实现:每个模块都有一个动态TLS索引(__tls_index),每个线程的该索引下保存的是malloc出来的特定模块的tls段数据,可以认为系统是通过1节中描述的惯用法实现静态TLS的。
第22章 DLL注入和API拦截
1. 利用注册表注入DLL:HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows\下的AppInit_Dlls可以填一系列要加载的DLL,仅当LoadAppInit_Dlls为1的时候。工作原理是:任何GUI程序在加载User32.DLL时,它的DllMain会先尝试加载注册表项AppInit_Dlls中的DLL。因此这种方法会影响所有的GUI程序。
2. 利用钩子来注入DLL:以参数WH_GETMESSAGE来调用SetWindowsHookEx,线程ID为0的时候会注入系统中所有有消息循环的进程。
3. 利用远程线程注入DLL:用CreateRemoteThread在目标进程中创建一个线程,线程函数就是LoadLibrary,线程函数的参数是要注入的DLL名。具体步骤:(1)用VirtualAllocEx在目标进程中分配内存,用WriteProcessMemory写入要注入DLL名的字符串,这样就在目标进程地址空间中准备好了参数(2)通过GetProcAddress得到LoadLibrary的地址。可以利用通常所有进程中Kernel32.DLL的基地址相同这个实时,把本进程的LoadLibrary地址当做目标进程中该函数的地址。(3)调用CreateRemoteThread,线程函数是LoadLibrary。(4)WaitForSingleObject来等待远程线程结束,表示加载完毕,然后VirtualFreeEx释放装有模块名的地址。(5)最后以任何方式在目标进程中释放注入的DLL。比如以 FreeLibrary为线程函数调用CreateRemoteThread。
4. 利用转发器替换DLL:要用自己的A.DLL替换合法的B.DLL,先用转发器在A.DLL中转发所有的函数到B.DLL,再实现自己的功能,最后将A.DLL改名为B.DLL,原本的B.DLL改成其他名。也可以在A.DLL中转发后,修改依赖B.DLL的模块的引入表,将它依赖的DLL名改为A.DLL,这种方式避免了改名。
5. 利用CreateProcess注入DLL:父进程用CreateProcess创建子进程时暂停子进程的主线程,然后查询子进程的入口函数(main),将入口函数的头几个字节改为跳到注入代码,而注入代码的末尾会跳转回入口函数开头。
6. API钩子的两种实现方式:(1)将原函数入口处的代码改为“跳转到挂钩函数;原代码B”,这样原函数的调用都会跳转到挂钩函数;为了能够访问原函数的功能,另外准备一个可执行缓冲区,内容为“原代码A;跳转到原代码B”。(2)修改进程中所有模块的引入表,将所有引入表中指定DLL的指定函数项地址改为挂钩函数地址。注意进程调用LoadLibrary后会引入新的DLL及其依赖DLL,因此需要再遍历一次所有DLL修改引入表。有必要的话也挂钩GetProcAddress返回伪造地址。用到的函数有ImageDirectoryEntryToData,可以查询指定DLL的引入表地址。
第23章 终止处理程序
1. __finally块可能由于三种原因被执行:(1)正常执行完__try块。(2)控制流中断__try块(这种情形叫局部展开,local unwind)。包括continue、break、return、goto、longjump等。(3)发生异常中断__try块,系统正在进行全局展开。包括SEH中的硬件异常(除0、写非法内存)和软件异常(RaiseException)。在__finally块中要区分是(1)或者(2)(3),可以用AbnormalTermination,该函数返回TRUE表示是(2)或(3)。(1)没有额外开销;(2)的开销较大,建议用__leave替代;(3)是正常用途。
2. 当 __finally块是由于__try块中的return被执行时,如果再在__finally块中调用return,最终函数会返回后一个return的值。
3. __finally中的return可以中断全局栈展开(global unwind)。即线程不会从__except的处理代码块继续执行(尽管exception filter返回的是EXCEPTION_EXECUTE_HANDLER),而是从return的__finally的上一级函数继续执行。
4. 不建议在__try或__finally块中使用return、goto等控制流语句。
第24章 异常处理程序与软件异常
1. __try、__except组合主要有filter块(__except后括号中的语句)和handler块(__except后的{}块),它们只在发生结构化异常的时候有可能被执行。发生结构化异常后,系统首先将异常信息(GetExceptionInformation返回值)压入栈顶,然后调用VEH注册函数(见25章),再执行最近的filter块,如果filter块返回EXCEPTION_CONTINUE_SEARCH,则继续查找下一个filter,这个过程中包括异常发生点和异常信息的整个栈一直完好(即GetExceptionInformation返回值有效),直到某个filter返回非EXCEPTION_CONTINUE_SEARCH。如果filter块返回EXCEPTION_CONTINUE_EXECUTION则从异常点继续执行,如果返回EXCEPTION_EXECUTE_HANDLER则先进行全局展开再执行handler块。全局展开会造成异常信息失效和从内到外的__finally块逐个执行,即handler中GetExceptionInformation返回值会失效,且如果某个__finally通过return中断全局展开,handler块将不执行。如果用户编写的所有__except都返回EXCEPTION_CONTINUE_SEARCH,最终系统将执行MS编写在系统线程函数中的最顶层__except的filter块,即UnhandledExceptionFilter,这个过滤函数25章讲。
2. 关于EXCEPTION_CONTINUE_EXECUTION:对于硬件异常(除0、内存非法访问等CPU异常),会从触发异常的那句汇编语句开始执行。对于软件异常,会从RaiseException的下一句汇编开始执行。即filter返回该值可能让硬件异常循环触发,而软件异常只会触发一次。
3. GetExceptionCode只能出现在__except后的filter块或者handler块中,而不能出现在filter函数中,这由编译器保证。GetExceptionInformation同样不能出现在filter函数中,但也不能出现在handler块中,根据条款1中的描述,当系统执行handler块时,全局展开已经结束,异常触发点到handler点之间的栈帧已经失效,当然异常信息也已经失效。
第25章 未处理异常、向量化异常处理与C++异常
1. C++异常机制是由SEH实现的,C++的所有异常都是以EXCEPTION_NONCONTINUABLE为参数调用RaiseException抛出的软件结构化异常。由于EXCEPTION_NONCONTINUABLE只限制__except的filter块,所以VEH函数返回EXCEPTION_CONTINUE_EXECUTION来忽略C++异常是合法的。
2. 无论是C/C++线程(_beginthreadex)还是Windows线程(CreateThread),它们的内部线程函数都将用户线程函数放在一组__try、__except中,当异常发生后所有的用户filter都返回EXCEPTION_CONTINUE_SEARCH时,系统将执行最外层的filter即UnhandledExceptionFilter(一个系统API),如果该函数发现当前进程正在被调试则将控制权交给调试器而后者会中断进程;非调试状态下它会尝试取出用户通过SetUnhandledExceptionFilter注册的顶层过滤函数,如果用户顶层函数返回EXCEPTION_EXECUTE_HANDLER或EXCEPTION_CONTINUE_EXECUTION,则UnhandledExceptionFilter不再进一步处理。显然用户可以通过返回前者来记录日志并无声退出,而返回后者可以实现类似栈内存通过guard page自动commit的功能。如果用户顶层过滤函数也返回EXCEPTION_CONTINUE_SEARCH,则进程再尝试调用通过AddVectoredContinueHandler注册的VEH函数,所有VEH函数都返回EXCEPTION_CONTINUE_SEARCH的话,系统就创建一个子进程并等待,子进程显示对话框询问用户要结束进程还是附加调试器,等待结束后异常的进程要么退出要么已经被调试器附加。
3. SEH实现的理解:有一个叫SEH栈的容器被用来维护相关数据,栈的每一项可能是一个__try/__except或__try/__fianlly组合;线程进入一个__try块就往SEH栈中压项,退出__try块就从SEH栈弹出一项。如果发生异常,系统判断离栈顶最近的__except项的filter返回值,如果其返回值为EXCEPTION_CONTINUE_SEARCH,则系统继续从栈顶往栈底查找__except项并执行其filter。如果找到一个filter返回EXCEPTION_CONTINUE_EXECUTION则流程结束,同时SEH栈保持不变;如果某个filter返回EXCEPTION_EXECUTE_HANDLER,则系统将SEH栈栈顶到该__except项的每一项都出栈,弹出的过程中如果发现__finally项则执行其中的代码块。
4. VEH(Vectored Exception Handler,向量化异常处理),作为SEH的补充,可以通过AddVectoredExceptionHandler、RemoveVectoredExceptionHandler管理一组异常过滤函数,这组函数将在异常发生之后到用户filter被执行之前的这段时间被调用,它可以返回EXCEPTION_CONTINUE_SEARCH让系统执行下一个VEH函数或者用户filter;也可以返回EXCEPTION_CONTINUE_EXECUTION起到忽略异常的效果。这组回调的特殊调用时机可以用于实现异常Hook等。另外还可以通过AddVectoredContinueHandler、RemoveVectoredContinueHandler管理一组过滤函数,由UnhandledExceptionFilter在用户顶层过滤函数(SetUnhandledExceptionFilter)之后调用。
5. 两种情况下调试器会通知用户发生异常:(1)打开IDE相应开关后,一抛出异常就触发断点。另外,无论是否打开开关,调试器都在输出窗口打印异常相关信息。调试器显然通过AddVectoredExceptionHandler注册了VEH函数。可以模仿调试器来记录异常。(2)对于用户没有处理的异常,被调试状态下的UnhandledExceptionFilter内部会通知调试器。
6. 对异常发生时调试器弹出框的解释:(1)中断。保持中断的状态,便于调试。(2)继续。如果对话框在VEH函数中弹出,即异常刚抛出,这个选项会让VEH函数返回EXCEPTION_CONTINUE_SEARCH,继续查找下个处理函数。如果对话框是在UnhandledExceptionFilter中弹出,继续选项等价于忽略。(3)忽略。VEH过滤函数或UnhandledExceptionFilter中代码返回EXCEPTION_CONTINUE_EXECUTION,因此该选项用于忽略包括C++异常在内的软件异常(RaiseException)。
第26章 错误报告与应用程序恢复
1. 本章介绍的WER(Windows Error Reporting,Windows错误报告)内容主要在Vista以上可用。
2. %SystemRoot%\system32\wercon.exe可以显示系统中出现过的错误。
3. WerSetFlags可以影响WER的行为,比如要求不dump堆、发送报告到MS网站等。WerAddExcludedApplication可以指定一些程序崩溃后跳过WER机制,适合正在调试的程序等。
4. WebRegisterMemroyBlock-指定WER的dump数据中要包括指定位置的内存。WerRegisterFile-要求将指定文件加入报告中。
5. 定制WER报告:WerReportCreate、WerReportSetParameter、WerReportAddDump、。WerReportAddFile、WerReportSetUIOption、WerReportSubmit、WerReportCloseHandle。
6. RegisterApplicationRestart-可以指定在何种错误情况下WER以特定参数重启程序。
7. RegisterApplicationRecoveryCallback-注册一个回调,进程将要非正常结束的时候被调用,以便用户自由备份一些状态等。用户可以在回调中以ApplicationRecoveryInProgress、ApplicationRecoveryFinished来通知UI进度。
附录
1. 要在输出窗口中打印调试信息,区别#pragma message和OutputDebugString,前者是在编译期打印,后者是运行时打印。
2. 由于MS提供的函数DebugBreak会断点在Kernel32.DLL中,需要两次才能单步到下一行(第一次跳出Kernel32.DLL);而__asm int 3;是断点在用户代码中,更容易使用。两者都只适合调试器存在时,非调试状态这个断点异常无法捕获会崩掉程序。
3. 自己编写发布版本也有效的断言:VERIFY。
4. #pragma comment(linker, "/manifestdependency:\"type='win32' name='Microsoft.Windows.Common-Controls'…”) 使GUI程序能够自动查找正确版本的ComCtrl32.DLL来自绘,达到自适应系统主题(Theme)的效果。
5. WindowsX.h中包含一些简单函数便于操作窗口,分别是消息处理宏、子控件宏和API宏。
第26章 窗口消息(第4版)
1. 一个进程可以创建上万个用户对象(User Object)。内核对象属于内核,可以跨进程使用,不会随任何进程自动删除;图符、光标、窗口类、菜单、加速键表等用户对象属于进程,允许跨线程访问,进程结束后自动删除;窗口、挂钩两种用户对象属于线程,拥有者线程结束后自动删除。
2. 线程的内部数据结构THREADINFO中至少包括以下内容:Post来的消息队列、Send来的消息队列、Send的应答队列、ExitCode、激活标志、消息队列状态标志(QueueStatus)、虚拟输入队列(VIQ)、局部输入状态(鼠标/键盘焦点窗口、光标外形和可见性等)。
3. PostMessage、PostThreadMessage、PostQuitMessage、GetWindowThreadProcessId。
4. SendMessage发送消息时,如果目标窗口位于发送线程,则SendMessage内部直接调用窗口过程并返回,如果目标线程不是当前线程甚至位于其他进程,SendMessage往目标线程的Send消息队列内添加项过后(并设置QS_SENDMESSAGE),用MsgWaitForMultipleObjects等待处理完成通知(同时还处理本线程消息)。而SendMessageTimeout则包含等待Send处理完毕、处理本线程消息、检测超时三项功能,传入SMTO_BLOCK参数后只进行有超时的等待而不处理消息。
5. SendMessageCallback的目标线程是当前线程时,直接调用窗口过程并用回调通知;如果目标线程是其他线程,Send消息后直接返回,之后本线程应该用GetMessage来响应其他线程Post回来的Send处理完毕的通知,该通知的处理函数会调用注册的回调。SendNotifyMessage相当于回调为空的SendMessageCallback,它不关心完成通知,相比PostMessage它还是具有Send消息的一些特点:比Post消息优先处理、目标线程是当前线程时直接调用窗口过程。一种获取所有窗口句柄的方法:以HWND_BROADCAST 为参数调用SendMessageCallback,然后GetMessage、DispatchMessage处理回调,在回调中搜集所有的窗口句柄。
6. SendMessage会在目标线程不是当前线程时阻塞等待,为避免不必要的阻塞发送线程,消息处理函数一旦确定处理结果就可以马上调用ReplayMessage传入结果值来激活Send线程,处理函数后半段即使进行费时操作也不再干扰Send线程(处理函数的返回值也被忽略)。
7. InSendMessage可以在消息处理过程中判断当前线程是否是Send线程(线程不同返回TRUE)。InSendMessageEx还可以判断抛出消息的具体函数,以及当前是否已经Reply了结果。
8. GetQueueStatus,检测当前线程的消息队列状态,是否有Post消息、Send消息、虚拟输入、以及QS_QUIT、QS_TIMER等特殊标志。
9. TranslateMessage在遇到WM_KEYDOWN/WM_SYSKEYDOWN时,会Post一个WM_CHAR/WM_SYSCHAR。因此如果使用了TranslateMessage,消息的处理顺序会变成WM_KEYDOWN->WM_CHAR->WM_KEYUP。
10. GetMessage/PeekMessage内部算法:先判断线程消息队列状态是否有QS_SENDMESSAGE标志,如果有则从Send队列取消息并处理但不返回(即GetMessage内部检测到Send的消息后,会ReplayMessage(DispatchMessage(msg));而如果将Send消息交给用户代码来Dispatch,后者可能忘记需要答复发送线程);再判断是否有QS_POSTMESSAGE,如果有则从Post队列取消息填充MSG结构然后返回(因此,用户通过MSG结构从GetMessage处取得的消息只能是Post的消息);判断是否有QS_QUIT标记,如有则表示已经PostQuitMessage于是填充MSG结构并返回(因此即使先PostQuitMessage再Post用户消息,也能保证退出前用户消息被处理)。再判断是否有QS_INPUT标志,如果VIQ中有输入则填充MSG结构并返回(因此即使有输入也可以退出且如果有输入则不重绘)。判断是否有QS_PAINT标志,有则表示窗口仍然有脏区域(直到BeginPaint)于是填充MSG产生一个WM_PAINT消息。最后判断是否有QS_TIMER标志,如有则表示刚到时,于是移除标志并填充MSG结构返回。可以看见有几种消息被赋予了相当低的优先级,并不加入消息队列:WM_QUIT是为了保证退出前处理完所有普通消息;WM_PAINT是因为开销大,只在空闲时处理;WM_TIMER是为了避免处理慢触发快而导致消息队列溢出。
11. MsgWaitForMultipleObjects实现为,在事件对象数组后追加一项,如果要检测的消息队列标志被置位则触发新追加的事件对象。关于输入消息的监听,由于MS设计为只在新增输入消息时事件对象才触发,因此需要以MWMO_INPUTAVAILABLE为参数来调用MsgWaitForMultipleObjectsEx,达到一旦输入队列非空就触发的效果。另外MsgWaitForMultipleObjectsEx还支持WaitAll及APC等功能。
12. 对于跨进程用SendMessage发送WM_GETTEXT、WM_SETTEXT等消息,系统会自动使用共享内存来转换消息参数的地址值以跨越进程边界。显然用户自定义消息需要自己来处理跨进程问题。WM_COPYDATA可以用来跨进程发送数据,发送进程传入一个有数据的缓冲,接受进程得到的缓冲地址转而指向一块相同内容的共享内存,系统在SendMessage返回时释放共享内存(故这个消息只能Send)。
13. 任意一个窗口都有编码属性,这个属性在绑定消息处理函数时确定(即调用RegisterClassA或以GWLP_WNDPROC调用SetWindowLongPtrA表示这是一个ANSI窗口而不是Unicode窗口),通过系统在不同窗口间转发数据时,系统会自动进行编码转换。判断窗口的编码IsWindowUnicode。
14. 对GetKeyState和GetAsyncKeyState的理解:线程的局部输入状态中有一份键盘状态表,在处理每个键盘消息的时候更新。GetKeyboardState获取整个表,GetKeyState获取某个表项,由于键盘消息不一定能够及时处理,因此内部表不一定够新,要获得实事状态,用GetAsyncKeyState,该API通过硬件中断获得最新按键状态。考虑一种GetAsyncKeyState的实现:线程先设置中断函数,再等待一个事件,中断到来时发现该线程中断函数指针非空于是执行函数,函数内部查询最新按键状态然后触发事件,中断结束后线程从等待的事件中被唤醒,最后返回按键状态。
第27章 硬件输入模型和局部输入状态(第4版)
1. 系统启动后创建RIT(Raw Input Thread,原始输入线程),它维护一个结构叫SHIQ(System Hardware Input Queue,系统硬件输入队列),鼠标键盘的硬件驱动将各自的消息添加到SHIQ中,如果消息是鼠标消息,RIT就检测当前光标下方的窗口,然后将鼠标消息抛到该窗口创建线程的VIQ中(Virtual Input Queue,虚拟输入队列),除非某个窗口调用了SetCapture,则RIT把鼠标消息抛给捕获窗口所在的线程;如果是键盘消息,RIT将消息抛给前台窗口所在的线程的VIQ中,前台窗口由SetForegroundWindow设置或者用户通过Alt+Tab/Alt+Esc/Ctrl+Alt+Del激活,线程接到键盘消息后根据局部输入状态将消息交给焦点窗口。