转自:http://www.makaidong.com/%E5%8D%9A%E5%AE%A2%E5%9B%AD%E6%96%87/71405.shtml
"C++Windows核心编程读书笔记":
这篇笔记是我在读《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、comparest ring(有很多比较选项)、comparest ringordinal(相当于_tcscmp)。
8. getthreadlocale返回线程的语言信息:lcid(locale id),供很多函数使用(包括使用comparest ring针对语言来比较的时候)。
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_machinesystemcurrentcontrolsetcontrolsession managerenviroment。用户环境变量:hkey_current_userenviroment。
8. 修改环境变量后可以通知相关的系统开发窗口(如控制面板等):sendmessage(hwnd_broadcast, wm_settingchange, 0, (lparam) “enviroment”)。
9. 可以设置特定线程在一个cpu核心集合上执行。
10. seterrormode。设置该进程如何响应各种错误。
11. 关于相对路径:在通过getenvironmentstrings返回的环境变量中,有一部分不是真正的环境变量,比如“=c:=c:windows”“ =f:=f:projects est05”,他们表示一种进程相关配置“本进程在特定驱动开发器下对应的当前文件夹”。一个进程除了有以上配置外,还有一个当前驱动开发 器,最终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,参数为”\.mailslotabcd”。邮件槽客户端-createfile,参数为””\servernamemailslotabcd””。命名管道服务器开发-createnamedpipe,参数为”\.pipeabcd “。命名管道客户端-createfile,参数为””\servernamepipeabcd “。匿名管道-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)内核对象触发后回调(regis地理信息系统 terwaitforsingleobject)。(4)内置iocp实现(bindiocompletioncallback)。
3. 线程池模块下有几种底层线程:(1)可变数量的长任务线程,用于执行标记为wt_executelongfunction的长时间回调。(2)1个timer线程。所有createtimerqueuetimer调用都被转发为在timer线程上创建以apc方式通知的waitabletimer,这个线程除了删除和创建waitabletimer外,就是在alertable态下休眠等待定时器的apc。由于这个线程一旦创建就贯穿进程生命期不会销毁,因此wt_executeinpersistentthread标志的线程池回调也由本线程执行。(3)多个wait线程。服务于regis地理信息系统 terwaitforsingleobject,每个线程用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-等待内核对象触发回调:regis地理信息系统 terwaitforsingleobject-在内核对象触发或超时后执行回调。标记wt_executeinwaitthread表示在wait线程上执行,效率较高。wt_executeonlyonce只执行一次回调,适用于进程/线程句柄这种触发后不再重置的对象。pulseevent的脉冲可能不会被wait线程检测到(线程刚好在干其他事)。unregis地理信息系统 terwaitex-取消回调,注意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_readwr ite。
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在搜索进程的当前路径过后就会搜索这里。当路径为空串(””