探索Windows:探索Windows内核对象进程
让我们与星光探索者一起,探索Windows吧!
再次探索Windows应用程序
进程由以下两部分构成:
一个内核对象,操作系统用于管理进程
一个地址空间,包含所有可执行文件或DLL模块的代码和数据,以及动态内存分配。
进程是有惰性的,进程要做的事情,必须让一个线程在他的上下文中运行,该线程负责执行进程空间包含的代码。一个进程可以有多个线程,所有线程都在进程的地址空间“同时”执行代码。为此,每个线程都有他自己的一组CPU寄存器和它自己的堆栈,每个进程至少有一个线程执行进程地址空间包含的代码。当系统创建一个进程的时候,会自动为进程创建第一个线程,这即为主线程(main thread)。 如果没有线程要执行进程地址空间包含的代码,进程失去了继续存在的理由,这是系统就会自动销毁进程及其地址空间。
对于所有要运行的线程,操作系统会轮流给每个线程调度一些CPU时间,采用循环(round-robin,轮询或轮流)方式,为每个线程分配时间片(quantum,称为“量”或“量程”),从而营造出所有线程都在同时运行的假象。 如果计算机配备了多个CPU,操作系统会采用更复杂的算法为线程分配CPU时间,Microsoft Windows可以同时让不同的CPU执行不同的线程,使得多个线程可以真正并发运行,此时Windows内核负责线程所有管理和调度任务。当然,为了更好的利用这些CPU,我们需要在算法上多下功夫。
Windows应用程序类型再认识
Windows支持两种类型的应用程序:GUI(Graphical User Interface,图形用户界面),和CUI(Console User Interface,控制台用户界面)。 我们现在常用的程序都是GUI程序,是图形化的,有窗口,有菜单,有对话框。而控制台程序是基于文本的,一般不会创建窗口或进程消息(当然可以创建窗口),虽然CUI程序是在屏幕中的一个窗口运行的,但这个窗口只有文本,例如命令提示符(CMD.EXE)。
用Microsoft Visual Studio创建应用程序时,这个IDE会设置链接器开关,使源代码可以正确生成可执行文件中。对于CUI程序,这个链接器开关为/SUBSYSTEM:CONSOLE,对于GUI程序,则为/SUBSYSTEM:WINDOWS。用户在运行应用程序时,操作系统的加载程序(loader)会检查执行文件影响的文件头,并获得这个子系统值,如果此值表明为CUI程序,加载程序会确保有一个控制台可以使用,若有必要会创建一个;如果表明为GUI程序,则只会加载程序。
Windows应用程序深刻解读
Windows应用程序必须有一个入口点函数,可以选择下列两种之一的入口函数:
1 | INT WINAPI _tWinMain( |
实际上操作系统并不调用我们所写的入口点函数,而是会调用C/C++运行库实现并在链接时使用-entry:命令行选择C/C++运行时的启动函数,此函数初始化C/C++运行时库,还确保了我们任何全局或竟然变量会被正确初始化。
_tWinMain和_tmain实际上是宏定义,将会在编译时替换为正确的函数。
应用程序类型 | 入口点函数(入口) | 嵌入可执行文件的启动函数 |
---|---|---|
多字节版本的GUI程序 | _tWinMain(WinMain) | WinMainCRTStartup |
Unicode版本的GUI程序 | _tWinMain(WinMain) | wWinMainCRTStartup |
多字节版本的GUI程序 | _tmain(main) | mainCRTStartup |
Unicode版本的GUI程序 | _tmain(wmain) | wmainCRTStartup |
在链接可执行文件时,链接器会选择正确的C/C++运行时库启动函数。开发者通常想制作一个GUI程序,后因为用Visual Studio创建项目时操作有误,导致报错unresolved external symbol(无法解析的外部符号)错误,提示找不到main函数。就是因为链接器开关为/SUBSYSTEM:CONSOLE,即目的是控制台程序,这样的原因。
我们只需在项目属性(之前的探索过程中介绍过如何打开这个窗口),修改SubSystem为WINDOWS即可。当然也可以通过这种方式改回来。
所有C/C++运行时库启动函数工作基本都是一样的。在Visual C++自带C运行库源代码,可在crtexe.c文件中看到四个启动函数源代码,主要做下列工作:
获得指向新进程完整命令行的指针
获取指向新进程环境变量的指针
初始化C/C++运行库全局变量
初始化C运行库内存分配函数和其他底层I/O例程使用的堆
调用所有全局和静态C++类对象构造函数
各位小伙伴可能不知道例程是什么意思,实际上例程,过程,子程序都是函数的不同称呼,本质上还是函数。
变量名 | 类型 | 描述和推荐使用的Windows函数 |
---|---|---|
_osver | unsigned int | 操作系统的构建版本号,例如Windows Vista RTM为build 60000,则_osver值为6000,请换用GetVersionEx函数 |
_winmajor | unsigned int | 以16进制表示的Windows系统主版本号,请换用GetVersionEx函数 |
_winminor | unsigned int | 以16进制表示的Windows系统次版本号,请换用GetVersionEx函数 |
_winver | unsigned int | (_winmajor << 8) + _winminor,请换用GetVersionEx函数 |
__argc | unsigned int | 命令行参数个数,请换用GetCommandLine |
__argv __wargv | char*[] | 长度为__argc的一个数组,其中数组每个元素指向命令行参数。如果定义了_UNICODE,__argv=NULL,否则__wargv=NULL。请换用GetCommandLine |
_environ _wenviron | char*[] | 一个指针数组,数组每一项都指向一个环境字符串。如果定义了_UNICODE,_environ=NULL,否则__wenviron=NULL。请换用GetEnvironmentVariable函数或GetEnvironmentStrings函数 |
_pgmptr _wpgmptr | char[] | 正在运行的程序名称及其完整路径。如果已经定义 _UNICODE,_pgmptr=NULL,否则_wpgmptr=NULL。请换用GetModuleFileName函数 |
完成初始化工作之后,C/C++启动函数就会调用应用程序入口点函数,假如入口函数为WinMain,那么调用过程为:
1 | GetStartupInfo(&StartupInfo); |
__ImageBase是链接器定义的伪变量,表明可执行文件被映射到应用程序内存中的位置,即进程实例句柄。
入口函数返回后,启动函数将调用C运行时库函数exit,向其传递返回值nMainRetVal,exit函数将:
调用_onexit函数调用所注册的任何一个函数
调用所有全局和静态C++类对象的析构函数
在DEBUG模式下,如果设置了__CRTDBG_LEAK_CHECK_DF标志,则调用_CrtDumpMemoryLeaks函数生成内存泄露报告
调用操作系统ExitProcess函数,向其传入nMainRetVal,这会导致操作系统杀掉我们的进程,并设置退出代码。
下面对_tWinMain函数的四个参数进行更深一步的探索
进程实例句柄hInstance
_tWinMain的hInstance参数实际值实际上是一个内存基地址,系统将可执行文件的映像加载到进程空间的这个位置,如操作系统打开可执行文件并决定加载到地址0x00400000,那么hInstance值为0x00400000,
可执行文件映像具体加载到哪个基地址,由链接器决定,不同的链接器使用不同的默认基地址。 由于历史原因,VisualStudio链接器(LINK.EXE)使用默认基地址是0x00400000,这是运行Windows 98时,可执行文件的映像能加载到的最低的一个地址。使用LINK.EXE命令/BASE:address,可修改加载的基地址位置。
我们可以调用GetModuleHandle函数或者调用GetModuleHandleEx函数得到可执行文件或DLL文件加载到底进程空间地址的位置。下方对GetModuleHandle函数的调用方式进行演示
1 | HMODULE GetModuleHandle(PCTSTR pszModule); |
加载到进程地址空间的每一个可执行文件或DLL都被赋予了独一无二的实例句柄。在需要加载资源的函数调用中,一般我们都提供此句柄的值。因此我们一般使用一个全局变量保存下来,这样方便加载资源。
此外,GetModuleHandle函数只会检查主调进程的地址空间,也就是说主调进程如果没有调用任何对话框函数,一旦调用GetModuleHandle,并传递ComDlg32就会返回NULL,即使ComDlg32.dll已经加载到其他进程。调用GetModuleHandle函数并向其传递NULL值,会返回进程的地址空间中的可执行文件的基地址,就算是调用GetModuleHandle(NULL)的代码在一个DLL中,返回值仍是可执行文件的基地址,而不是DLL的基地址。
另外,HMODULE和HINSTANCE实际上完全是一回事,我们可以在需要HMODULE时传入一个HINSTANCE,反之亦然。 之所以有两种数据类型,是因为在16位Windows中他两表示不同类型数据,但现在表示的都相同了。
进程上一个实例的句柄hPrevInstance
_tWinMain函数的第二个参数hPrevInstance是用于16位Windows程序的,现在为了兼容保留下来,因而这个参数总是为NULL。
编程小技巧:对于未使用的参数,在Visual Studio我们可使用UNREFERENCED_PARAMETER 宏来消除警告,就像Qt程序可使用Q_UNUSED宏一样
进程的命令行lpCmdLine
系统在创建一个新进程时,会传一个命令行给他。命令行几乎是非空的,至少命令行的第一个参数即为可执行文件的名称。
进程仅能接收只由一个字符构成的命令行,因而我们不应该通过解析lpCmdLine参数来分析命令行,而是使用PTSTR GetCommandLine()
函数,这个函数返回一个缓冲区指针,包含完整的命令行(包括可执行文件完整路径),并且总是返回同一个缓冲区的地址。因此,为了防止误修改命令行,我们通常把这个数据拷贝到本地的缓冲区,然后再解析命令行。
许多应用程序都倾向于将命令行分割成一个一个单独的标记,但是Microsoft反对继续使用全局变量__argc和__argv,但应用程序仍然可以使用他们。我们可以利用ShellAPI.h头文件中声明并由Shell32.dll导出的函数CommandLineToArgvW 函数即可将任何Unicode字符串分割成单独的标记。
1 | LPWSTR* CommandLineToArgvW( |
这个函数返回一个LPWSTR*数组的数据。CommandLineToArgvW在内部分配内存,因此我们可以不释放这块内存,由操作系统在进程终止时释放。如果想自己释放,应该调用HeapFree
进程的环境变量
每个进程都有一个与它关联的环境块(environment block),这是在进程地址空间分配的一块内存,其中包含的字符串和下面类似:
1 | =::=::\ ... |
每个字符串的第一部分是一个环境变量的名称,后跟一个等号,等号后是环境变量的值(如果有空格,包括空格部分)。除了第一个=::=::\
字符串,可能还有其他字符串以等号开头,但这种字符串不作为环境变量使用。我们可以使用GetEnvironmentStrings 函数获得这种完整的环境块,得到的环境块的格式与本段描述的完全一致。
下面的一段代码展示了如何遍历输出所有的环境变量的值:
1 |
|
注意,对于描述环境变量的字符串
1 | VAR1=WINDOWS |
VAR1和VAR2会被认为是不同的环境变量
用户在登录Windows时,系统创建外壳(shell)进程,系统通过检查注册表中的两个注册表项来获得初始的环境的字符串。
第一个包含系统所有环境变量列表
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager
第二个注册表项包含当前登录用户所有环境变量列表
HKEY_CURRNET_USER\Environment
用户可以自由修改。应用程序还可以使用各种注册表函数来修改这些注册表项,为了使改动对所有应用程序生效,用户必须注销并重新登陆,有些应用程序可以在其主窗口接收到WM_SETTINGCHANGE消息,用新的注册表项更新他们的环境块。如果我们在更新了注册表项后希望应用程序马上更新他们的环境块,可以进行如下调用:
1 | SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, |
上面还提到过,对于修改环境变量,可以使用GetEnvironmentVariable函数来获取环境变量
1 | DWORD GetEnvironmentVariable( |
如果lpBuffer为NULL,这个函数的返回值为需要的缓冲区大小。
调用SetEnvironmentVariable函数可以修改某个环境变量的值
1 | BOOL SetEnvironmentVariable( |
在很多字符串的内部,都包含了“可替换字符串”,类似%环境变量名%
的子串,即为可替换字符串,
例如PATH=%PATH%
,这个%PATH%
即为可替换字符串
我们可调用ExpandEnvironmentStrings函数来将一个带有可替换字符串的的字符串,用环境变量值的实际内容替换相应位置。
1 | DWORD ExpandEnvironmentStrings( |
例如下列调用代码:
1 | LPTSTR lpBuffer = new TCHAR[256]; |
在我的电脑环境变量OS的值为WINDOWS_NT
,故lpBuffer的内容为你的电脑环境变量OS的值:WINDOWS_NT
进程的其他特性
下面探索进程的其他特性
进程的关联性和进程的错误模式
进程中的线程可以在主机任何CPU上运行,也可以强迫线程在可用CPU的一个子集上运行,这称为处理器关联性(processor affinity),子进程继承其父进程的关联性。
每个进程对于错误关联了一组标志,为了让系统知道进程如何响应严重错误。进程可调用SetErrorMode函数来告诉系统如何处理这些错误。
1 | UINT SetErrorMode(UINT fuErrorMode); |
fuErrorMode参数可以为下列及其按位的共同效果。
标志 | 效果 |
---|---|
SEM_FAILCRITICALERRORS | 系统不显示严重错误处理程序(critical-error-handler)消息框,并将错误返回主调进程 |
SEM_NOGPFAULTERRORBOX | 系统不显示常规保护错误(general-protection-fault)消息框。此标志只应该由调试程序设置。该调试程序用一个异常处理程序来自行处理常规保护错误(general protection),即GP错误 |
SEM_NOOPENFILERRORBOX | 系统查找文件失败时,不显示消息框 |
SEM_NOALIGNMENTFAULTEXCEPT | 系统自动修复内存对其错误,并使应用程序看不到这些错误,对x86/x64处理器无效。 |
默认情况下,子进程会继承父进程的错误模式标志,当然子进程不知道这一点,上文已经提到过很多东西子进程都不知道他自己继承下来的东西。
进程当前所在的驱动器和目录
如果不提供完整路径名,Windows函数会在当前驱动器的当前目录查找文件和目录。例如调用CreateFile打开一个未指定完整路径名的文件,系统将在当前驱动器和目录查找相应文件。
系统在内部跟踪记录这一个进程的当前驱动器和目录。这种信息是以进程为单位来维护的,因而假如进程中的一个线程修改了当前驱动器或目录,对于该进程的所有线程来说,此信息被更改了。
一个线程可以调用GetCurrentDirectory函数来获得进程当前所在的驱动器和目录。调用SetCurrentDirectory函数修改之。
1 | DWORD GetCurrentDirectory( |
如果缓冲区不够大,GetCurrentDirectory函数返回所需要的字符数。在头文件WinDef.h
头文件的MAX_PATH
宏(定义为260)定义了目录文件或名称的最大字符数,因而我们通常准备一个有MAX_PATH大小的缓冲区来储存此数据。
系统跟踪记录着进程当前驱动器和目录,但没有记录每个驱动器的当前目录。不过,Microsoft Windows的支持,可以处理多个驱动器的当前目录。这个支持是通过进程的环境字符串来提供的。如一个进程可以有如下的两个环境变量:
1 | =C:=C:\Codes |
上述变量指出进程在C驱动器的当前目录为\Codes,D驱动器当前目录为\Program Files。如果调用一个函数,并且传入的路径名限定的是当前驱动器以外的驱动器,系统会在进程的环境块中查找与指定驱动器号(也称盘符)相关的变量。如果找到了,系统就将变量的值当做当前目录使用,如果没找到,系统就假定指定驱动器的当前目录为它的根目录。
如假定进程当前目录为C:/Codes,我们调用CreateFile函数打开D:README.md
文件,那么系统会查找环境变量=D:
,由于在现在假设的情况该变量存在,则系统尝试在D:\Program Files\
目录打开README.md文件,如果=D:变量不存在,则系统尝试在D盘根目录打开README.md文件
子进程的当前目录默认为每个驱动器的根目录,如果希望子进程继承父进程的当前目录,父进程必须在生成子进程之前,创建这些驱动器号环境变量,并添加到环境块中。父进程可调用GetFullPathName函数获取。
进程的系统版本
很多时候,我们需要判断用户的Windows版本。在很长的时间内,Windows API仅提供了一个DWORD GetVersion()
函数。这个函数具有悠久的历史,最初是为16位Windows系统设计的,此函数返回值高字节返回MS-DOS版本号,低字节返回Windows版本号。在每个字中,高位字节表示主版本号,低位字节表示次版本号。
遗憾的是,写代码的程序员犯了一个错误,导致windows版本号顺序颠倒了。由于历史原因,微软没有废除这个函数,并添加了新的函数GetVersionExA
1 | BOOL GetVersionEx(POSVERSIONINFOEX pVersionInformation); |
OSVERSIONINFOEX结构体的定义如下
1 | typedef struct _OSVERSIONINFOEX { |
我们需要自己初始化一个OSVERSIONINFOEX结构体,然后作为GetVersionEx的参数,这样就可以获取了。