本章内容包括:
Ø
编写第一个Windows应用程序
Ø CreateProcess函数
Ø
终止进程
Ø
子进程
Ø
管理员以标准用户权限运行
本章将讨论系统如何管理正在运行的所有应用程序。首先要解释什么是进程,以及系统如何创建一个进程内核对象来管理每个进程。然后,我们要解释如何利用与一个进程关联的内核对象来操纵该进程。接下来,我们要讨论进程的各种的特性[1](或属性[2]),以及用于查询和更改这些属性的几个函数。另外,还要讨论如何利用一些函数在系统中创建或生成额外的进程。当然,最后还要讨论如何终止线程,这是讨论进程时必不可少的一个主题。
一般将进程定义成一个正在运行的程序的一个实例,它由以下两部分构成:
Ø 一个内核对象,操作系统用它来管理进程。内核对象也是系统保存进程统计信息的地方。
Ø 一个地址空间,其中包含所有可执行文件或DLL模块的代码和数据。此外,它还包含动态的内存分配,比如线程栈和堆的分配。
进程是有“惰性”的。进程要做任何事情,都必须让一个线程在它的上下文中运行。该线程负责执行进程地址空间包含的代码。事实上,一个进程可以有多个线程,所有线程都在进程的地址空间中“同时”执行代码。为此,每个线程都有它自己的一组CPU寄存器和它自己的栈。每个进程至少有一个线程来执行进程地址空间中的代码。当系统创建一个进程的时候,会自动地为进程创建第一个线程,这称为主线程。然后,这个线程再创建更多的线程,后者再创建更多的线程……。如果已经没有线程在执行进程地址空间中的代码,进程就失去了继续存在的理由,这时系统会自动销毁进程及其地址空间。
对于所有要运行的线程,操作系统会轮流为每个线程调度一些CPU时间。它会采取循环[3]方式,为每个线程都分配时间片(称为量[4]),从而营造出所有线程都在“并发”运行的假象。图4-1展示了一台单CPU的机器的工作方式。

图4-1 在单CPU的计算机上,操作系统以循环方式为每个单独的线程分配时间量
如果计算机配备了多个CPU,操作系统会采用更复杂的算法为线程分配CPU时间。Microsoft Windows可以同时让不同的CPU执行不同的线程,使多个线程能真正并发运行。在这种类型的计算机系统中,Windows内核将负责线程的所有管理和调度任务。我们不必在自己的代码中做任何特别的事情,即可享受到多处理器系统带来的好处。不过,为了更好地利用这些CPU,我们还可以在应用程序的算法中多做一些文章。
Windows支持两种类型的应用程序:一类基于图形用户界面(graphical user interface,后面简称为GUI),另一类基于控制台用户界面(console user interface,后面简称为CUI)。。GUI程序一个图形化的前端。它可以创建窗口,可以拥有菜单,能通过对话框与用户交互,还能使用所有标准的“视窗化[5]”的东西。随Windows附带的几乎所有应用程序(比如记事本、计算器和写字板等等)都是GUI程序。控制台程序则是基于文本的。它们一般不会创建窗口或进程消息,而且不需要GUI。虽然CUI程序是在屏幕上的一个窗口中运行的,但这个窗口中只有文本。命令提示符[6](在Windows Vista下为CMD.EXE)是CUI程序的一个典型的例子。
其实,这两种应用程序的界线是非常模糊的。我们完全可以创建出能显示对话框的CUI应用程序。例如,命令行解释器[7]可以提供一个特殊的命令来显示一个图形化对话框,用户可以在其中选择想要执行的命令,而不必记住命令行解释器所支持的各种命令。另外,还可以创建一个要向控制台窗口输出文本字符串的GUI应用程序。例如,我自己写的GUI程序经常都要创建一个控制台窗口,便于我查看应用程序执行期间的调试信息。不过,我当然鼓励记者尽可能在程序中使用GUI,而不要使用老式的字符界面,后者对用户来说不太友好。
用Microsoft
Visual Studio来创建一个应用程序项目时,集成开发环境会设置各种链接器开关,使链接器将子系统的正确类型嵌入最终生成的可执行文件[8]中。对于CUI程序,这个链接器开关是/SUBSYSTEM:CONSOLE,对于GUI程序,则是/SUBSYSTEM:WINDOWS。用户运行应用程序时,操作系统的加载程序会检查可执行文件映像的文件头,并获取这个子系统值。如果此值表明是一个CUI程序,加载程序会自动确保有一个可用的文本控制台窗口(比如从命令提示符启动这个程序的时候)。另外,如有必要,会创建一个新窗口(比如从Windows资源管理器启动这个CUI程序的时候)。如果此值表明是一个GUI程序,加载器就不会创建控制台窗口;相反,它只是加载这个程序。一旦应用程序开始运行,操作系统就不再关心应用程序的界面是什么类型的。
Windows应用程序必须有一个入口函数,应用程序开始运行时,这个函数会被调用。C/C++ 开发人员可以使用以下两种入口函数:
int WINAPI
_tWinMain(
HINSTANCE hInstanceExe,
HINSTANCE,
PTSTR pszCmdLine,
int nCmdShow);
int
_tmain(
int argc,
TCHAR *argv[],
TCHAR *envp[]);
注意,具体是其中的哪一个要取决于我们是否要使用Unicode字符串。操作系统实际并不调用我们所写的入口函数。相反,它会调用由C/C++运行库实现并在链接时使用-entry:命令行选项来设置的一个C/C++运行时启动函数。该函数将初始化C/C++运行库,使我们能调用malloc和free之类的函数。它还确保了在代码开始执行之前,我们声明的任何全局和静态C++对象都被正确地构造。表4-1总结了我们的源代码要实现什么入口函数,以及每个入口函数应该在什么时候使用。
表4-1 应用程序类型和相应的入口函数
|
应用程序类型 |
入口函数(入口点) |
嵌入可执行文件的启动函数 |
|
处理ANSI字符和字符串的GUI应用程序 |
_tWinMain (WinMain) |
WinMainCRTStartup |
|
处理Unicode字符和字符串的GUI应用程序 |
_tWinMain (wWinMain) |
wWinMainCRTStartup |
|
处理ANSI字符和字符串的CUI应用程序 |
_tmain ( |
mainCRTStartup |
|
处理Unicode字符和字符串的CUI应用程序 |
_tmain (Wmain) |
wmainCRTStartup |
在链接可执行文件的时候,链接器将选择正确的C/C++运行时启动函数。如果指定了/SUBSYSTEM:WINDOWS链接器开关,链接器就会寻找WinMain或wWinMain函数。如果没有找到这两个函数,链接器将返回一个“无法解析的外部符号[9]”错误;否则,它将分别选择调用WinMainCRTStartup或wWinMainCRTStartup函数。
类似地,如果指定了/SUBSYSTEM:CONSOLE链接器开关,链接器就会寻找main或wmain函数,并分别选择调用mainCRTStartup或wmainCRTStartup函数。同样地,如果main和wmain函数都没有找到,链接器会返回一个“无法解析的外部符号”错误。
不过,一个鲜为人知的事实是,我们完全可以从自己的项目中移除/SUBSYSTEM链接器开关。一旦这样做,链接器就会自动判断应该将应用程序设为哪一个子系统。链接时,链接器会检查代码中包括四个函数中的哪一个(WinMain,wWinMain,main或wmain),并据此推断可执行文件应该是哪个子系统,以及应该在可执行文件中嵌入哪个C/C++启动函数。
Windows/Visual C++新手常犯的一个错误是在创建一个新项目时错误选择了项目类型。例如,开发人员可能选择创建一个新的Win32应用程序项目,但创建的入口函数是main。生成应用程序时,会报告一个链接器错误,因为Win32应用程序项目会设置/SUBSYSTEM:WINDOWS链接器开关,但WinMain或wWinMain函数并不存在。此时,开发人员有以下4个选择。
Ø
把main函数改为WinMain。这通常不是最佳方案,因为开发人员真正希望的可能是创建一个控制台应用程序。
Ø 在Visual C++中创建一个新的Win32控制台应用程序项目,然后在新项目中添加现有的源代码模块。这个办法过于繁琐。它相当于一切都从头开始,而且必须删除原来的项目文件。
Ø
在项目属性对话框中,定位到Configuration Properties(配置属性)/Linker(链接器)/System(系统)/SubSystem(子系统)选项,把/SUBSYSTEM:WINDOWS开关改为/SUBSYSTEM:CONSOLE,如图4-2所示。这是最简单的解决方案,很少有人知道这个窍门。
Ø
在项目属性对话框中,删除/SUBSYSTEM:WINDOWS开关。这是我个人最偏爱的选项,因为它能提供最大的灵活性。现在,链接器将根据源代码中实现的函数来执行正确的操作。用Visual Studio创建一个新的Win32应用程序或Win32控制台应用程序项目时,这才应该是真正的默认设定啊!
图4-2 在项目的属性对话框中,为一个项目选择一个CUI子系统
所有C/C++运行时启动函数所做的事情基本都是一样的,区别在于它们要处理的是ANSI字符串,还是Unicode字符串;以及在初始化C运行库之后,它们调用的是哪一个入口函数。Visual C++自带C运行库的源代码。可以在crtexe.c文件中找到四个启动函数的源代码。这些启动函数的用途简单总结如下。
Ø 获取指向新进程的完整命令行的一个指针。
Ø 获取指向新进程的环境变量的一个指针。
Ø 初始化C/C++运行库的全局变量。如果包含了StdLib.h,我们的代码就可以访问这些变量。表4-2总结了这些变量。
Ø 初始化C运行库内存分配函数(malloc和calloc)和其他底层I/O例程所使用的堆。
Ø 调用所有全局和静态C++类对象的构造函数。
表4-2 程序可以访问的C/C++运行时全局变量
|
变量名称 |
类型 |
描述和推荐使用的Windows函数 |
|
_osver |
unsigned int |
操作系统的构建版本号[10]。例如,Windows Vista RTM的构建版本号为6000。所以,_osver的值就是6000。请用GetVersionEx来代替。 |
|
_winmajor |
unsigned int |
以十六进制表示的Windows系统的主版本号。对于Windows Vista,该值为6。请用GetVersionEx来代替。 |
|
_winminor |
unsigned int |
以十六进制表示的Windows系统的次版本号。对于Windows Vista,该值为0。请用GetVersionEx来代替。 |
|
_winver |
unsigned int |
(_winmajor << 8) + _winminor。请用GetVersionEx来代替。 |
|
__argc |
unsigned int |
命令行中传递的参数的个数。请用GetCommandLine来代替。 |
|
__argv __wargv |
char wchar_t |
长度为__argc的一个数组,其中含有指向ANSI/Unicode字符串的指针。数组中的每一项都指向一个命令行参数。注意,如果定义了_UNICODE,__argv就为NULL;如果没有定义,则__wargv为NULL。请用GetCommandLine来代替。 |
|
_environ _wenviron |
char wchar_t |
一个指针数组,这些指针指向ANSI/Unicode字符串。数组中的每一项都指向一个环境字符串。注意,如果没有定义_UNICODE,_wenviron就为 NULL;如果已经定义了_UNICODE,_environ就为 NULL。请用GetEnvironmentStrings或GetEnvironmentVariable来代替。 |
|
_pgmptr _wpgmptr |
char wchar_t |
正在运行的程序的名称及其ANSI/Unicode完整路径。注意,如果已经定义了_UNICODE,_pgmptr就为NULL。如果没有定义_UNICODE,_wpgmptr就为 NULL。请用GetModuleFileName来代替,将NULL作为第一个参数传给该函数 |
完成所有这些初始化工作之后,C/C++启动函数就会调用应用程序的入口函数。如果我们写了一个_tWinMain函数,而且定义了_UNICODE,其调用过程将如下所示:
GetStartupInfo(&StartupInfo);
int
nMainRetVal = wWinMain((HINSTANCE)&__ImageBase, NULL,
pszCommandLineUnicode,
(StartupInfo.dwFlags &
STARTF_USESHOWWINDOW)
? StartupInfo.wShowWindow : SW_SHOWDEFAULT);
如果没有定义_UNICODE,其调用过程将如下所示:
GetStartupInfo(&StartupInfo);
int
nMainRetVal = WinMain((HINSTANCE)&__ImageBase, NULL, pszCommandLineAnsi,
(StartupInfo.dwFlags &
STARTF_USESHOWWINDOW)
? StartupInfo.wShowWindow : SW_SHOWDEFAULT);
注意,_ImageBase是一个链接器定义的伪变量,表明可执行文件被映射到应用程序内存中的什么位置。后面的“进程实例句柄”一节将进一步讨论这个问题。
如果我们写了一个_tmain函数,而且定义了_UNICODE,那么其调用过程如下:
int
nMainRetVal = wmain(argc, argv, envp);
如果没有定义_UNICODE,调用过程如下:
int
nMainRetVal = main(argc, argv, envp);
注意,用Visual
Studio向导生成应用程序时,CUI应用程序的入口中没有定义第三个参数(环境变量块),如下所示:
int _tmain(int
argc, TCHAR* argv[]);
如果需要访问进程的环境变量,只需将上述调用替换成下面这一行:
int
_tmain(int argc, TCHAR* argv[], TCHAR* env[])
这个env参数指向一个数组,数组中包含所有环境变量及其值,两者用等号(=)分隔。对环境变量的详细讨论将在后面的“进程的环境变量”小节进行。
入口函数返回后,启动函数将调用C运行库函数exit,向其传递我们的返回值(nMainRetVal)。exit函数执行以下任务。
Ø 调用_onexit函数所注册的任何一个函数。
Ø 调用所有全局和静态C++类对象的析构函数。
Ø 在DEBUG生成中,如果设置了_CRTDBG_LEAK_CHECK_DF标志,就通过调用_CrtDumpMemoryLeaks函数来生成内存泄漏报告。
Ø 调用操作系统的ExitProcess函数,向其传入nMainRetVal。这会导致操作系统杀死我们的进程,并设置它的退出代码。
注意,出于安全性的考虑,Microsoft反对我们继续使用所有这些变量,因为使用了这些变量的代码可能会在C运行库初始化这些变量之前开始执行。有鉴于此,我们应该直接调用对应的Windows API函数。
加载到进程地址空间的每一个可执行文件或者DLL文件都被赋予了一个独一无二的实例句柄。可执行文件的实例被当作(w)WinMain函数的第一个参数hInstanceExe传入。在需要加载资源的函数调用中,一般都要提供此句柄的值。例如,为了从可执行文件的映像中加载一个图标资源,就需要调用下面这个函数:
HICON
LoadIcon(
HINSTANCE hInstance,
PCTSTR pszIcon);
LoadIcon函数的第一个参数指出哪个文件(可执行文件或DLL文件)包含了想要加载的资源。许多应用程序都会将(w)WinMain的hInstanceExe参数保存在一个全局变量中,这样可执行文件中的所有代码就能非常容易地访问它。
Platform SDK文档指出,有的函数需要一个HMODULE类型的参数。下面的GetModuleFileName函数便是一个例子:
DWORD
GetModuleFileName(
HMODULE hInstModule,
PTSTR pszPath,
DWORD cchPath);
注意
事实上,HMODULE和HINSTANCE是完全等价的。如果某个函数的文档指出需要一个HMODULE参数,我们可以传入一个HINSTANCE,反之亦然。之所以有两种数据类型,是由于在16位Windows中,HMODULE和HINSTANCE表示不同类型的数据。
(w)WinMain的hInstanceExe参数的实际值是一个内存基地址,系统会将可执行文件的映像加载到进程地址空间中的这个位置。例如,假如系统打开可执行文件,并将它的内容加载到地址0x00400000,则(w)WinMain的hInstanceExe参数值为0x00400000。
可执行文件的映像具体加载到哪一个基地址,这是由链接器决定的。不同的链接器使用不同的默认基地址。由于历史原因,Visual Studio链接器使用的默认基地址是0x00400000,这是在运行Windows 98时,可执行文件的映像能加载到的最低的一个地址。使用Microsoft链接器的/BASE:address链接器开关,可以更改要将应用程序加载到哪个基地址。
为了知道一个可执行文件或DLL文件被加载到进程地址空间的什么位置,可以使用如下所示的GetModuleHandle函数来返回一个句柄/基地址:
HMODULE
GetModuleHandle(PCTSTR pszModule);
调用这个函数时,要传递一个以零为终止符的字符串,它指定了已在主调进程的地址空间中加载的一个可执行文件或DLL文件的名称。如果系统找到了指定的可执行文件或DLL文件名称,GetModuleHandle就会返回可执行文件/DLL文件映像加载到的基地址。如果没有找到文件,系统将返回NULL。GetModuleHandle的另一个用法是为pszModule参数传入NULL,这样可以返回主调进程的可执行文件的基地址。如果我们的代码在一个DLL中,那么可通过两种方法来知道代码正在什么模块中运行。第一种方法是使用链接器提供的伪变量__ImageBase,它指向当前正在运行的模块的基地址。如前所述,这是C运行时启动代码在调用我们的(w)WinMain函数时所做的事情。
第二种方法是调用GetModuleHandleEx,将GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS作为它的第一个参数,将当前函数的地址作为第二个参数。最后那个参数是一个指向HMODULE的指针, GetModuleHandleEx会用传入的函数(即第二个参数)所在DLL的基地址来填写它。以下代码展示了这两种方法:
extern
"C" const IMAGE_DOS_HEADER __ImageBase;
void
DumpModule() {
// Get the base address of the
running application.
// Can be different from the
running module if this code is in a DLL.
HMODULE hModule =
GetModuleHandle(NULL);
_tprintf(TEXT("with
GetModuleHandle(NULL) = 0x%x\r\n"), hModule);
// Use the pseudo-variable
__ImageBase to get
// the address of the current
module hModule/hInstance.
_tprintf(TEXT("with
__ImageBase = 0x%x\r\n"), (HINSTANCE)&__ImageBase);
// Pass the address of the
current method DumpModule
// as parameter to
GetModuleHandleEx to get the address
// of the current module
hModule/hInstance.
hModule = NULL;
GetModuleHandleEx(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
(PCTSTR)DumpModule,
&hModule);
_tprintf(TEXT("with
GetModuleHandleEx = 0x%x\r\n"), hModule);
}
int
_tmain(int argc, TCHAR* argv[]) {
DumpModule();
return(0);
}
记住GetModuleHandle函数的两大重要特征。首先,它只检查主调进程的地址空间。如果主调进程没有使用任何通用对话框函数,那么一旦调用GetModuleHandle,并向其传递ComDlg32,就会导致返回NULL——即使ComDlg32.dll也许已经加载到其他进程的地址空间。其次,调用GetModuleHandle并向其传递NULL值,会返回进程的地址空间中的可执行文件的基地址。所以,即使调用GetModuleHandle(NULL)的代码是在一个DLL中,返回值仍是可执行文件的基地址,而非DLL文件的基地址。
前面我们提到过,C/C++运行时启动代码总是向(w)WinMain的hPrevInstance参数传递NULL。该参数是用于16位Windows系统的,之所以还将其保留为(w)WinMain的一个参数,目的只是为了方便我们移植16位Windows应用程序。我们绝对不应该在代码中引用这个参数。因此,我始终会像下面这样编写(w)WinMain函数:
int
WINAPI _tWinMain(
HINSTANCE hInstanceExe,
HINSTANCE,
PSTR pszCmdLine,
int nCmdShow);
由于没有为第二个参数指定参数名,所以编译器不会报告一个“参数没有被引用到[11]”警告。Visual Studio 选择了一个不同的解决方案:向导生成的C++ GUI项目利用UNREFERENCED_PARAMETER宏来消除这种警告,如下面这段代码所示:
int
APIENTRY _tWinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPTSTR lpCmdLine,
int nCmdShow) {
UNREFERENCED_PARAMETER(hPrevInstance);
UNREFERENCED_PARAMETER(lpCmdLine);
...
}
系统在创建一个新进程时,会传一个命令行给它。这个命令行几乎总是非空的;至少,用于创建新进程的可执行文件的名称会是命令行中的第一个标记[12]。不过,在后面讨论CreateProcess函数的时候,我们会知道进程能接收只由一个字符构成的命令行,即字符串终止符零。C运行库的启动代码开始执行一个GUI应用程序时,会调用Windows函数GetCommandLine来获取进程的完整命令行,忽略可执行文件的名称,然后将指向命令行剩余部分的一个指针传给WinMain的pszCmdLine参数。
应用程序可以通过自己选择的任何一种方式来分析和解释命令行字符串。我们实际上可以写入数据到pszCmdLine参数所指向的内存缓冲区,但在任何情况下,写入缓冲区的时候都不应该越界。就我个人而言,我始终把它当作一个只读的缓冲区来对待。如果想对命令行进行改动,我首先会将命令行缓冲区复制到我的应用程序的一个本地缓冲区,再对本地缓冲区进行修改。
我们也可以效仿C运行库的例子,通过调用GetCommandLine函数来获得一个指向进程完整命令行的指针,如下所示:
PTSTR GetCommandLine();
该函数返回一个缓冲区指针,缓冲区中包含了完整的命令行(包括已执行的文件的完整路径名)。注意,GetCommandLine返回的总是同一个缓冲区的地址。这是不应该向pszCmdLine写入数据的另一个理由:它指向同一个缓冲区,修改它之后,就没办法知道原来的命令行是什么。
许多应用程序都倾向于将命令行解析成一个个单独的标记。虽然Microsoft反对我们继续使用全局变量__argc和__argv(或 __wargv),但应用程序仍然可以用它们来访问对命令行中的每个标记。下面的函数CommandLineToArgvW是在ShellAPI.h文件中声明并由Shell32.dll导出的,它可以将任何Unicode字符串解析成一个个的标记:
PWSTR* CommandLineToArgvW(
PWSTR
pszCmdLine,
int*
pNumArgs);
正如函数名最后的W所暗示的一样,这个函数只有Unicode版本(W代表wide)。 第一个参数pszCmdLine指向一个命令行字符串。这通常是前面的GetCommandLineW函数调用的返回值。pNumArgs参数是一个整数的地址,该整数被设为命令行中的实参的数目。CommandLineToArgvW返回的是一个Unicode字符串指针数组的地址。
CommandLineToArgvW会在内部分配内存。许多应用程序不会释放这块内存——它们依靠操作系统在进程终止时释放这块内存。这是完全可以接受的。不过,如果想自己释放内存,正确的做法是调用HeapFree,如下面所示:
int nNumArgs;
PWSTR *ppArgv = CommandLineToArgvW(GetCommandLineW(),
&nNumArgs);
// Use the arguments…
if (*ppArgv[1] == L'x') {
...
}
// Free the memory block
HeapFree(GetProcessHeap(), 0, ppArgv);