让我们与星光探索者一起,探索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
2
3
4
5
6
7
8
9
10
11
12
13
INT WINAPI _tWinMain(
HINSTANCE hInstance, // 当前实例句柄
HINSTANCE hPrevInstance, // 上一个实例句柄,现在已废弃
PTSTR lpCmdLine, // 命令行参数
int nCmdShow // 窗口显示方式
);


int _tmain(
int argc, // 命令行参数个数
TCHAR *argv[], // 命令行参数数组
TCHAR *envp[] // 环境变量
);

实际上操作系统并不调用我们所写的入口点函数,而是会调用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
2
3
4
5
6
GetStartupInfo(&StartupInfo);
int nMainRetVal = WinMain(
(HINSTANCE)&__ImageBase,
NULL, pszCommandLineAnsi,
(StartupInfo.dwFlags & STARTF_USESHOWWINDOW ?
StartupInfo.wSHowWindow : SW_SHOWEDFAULT);

__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
2
3
4
5
HMODULE GetModuleHandle(PCTSTR pszModule);
// pszModule为已加载的可执行文件或DLL文件名称
// 如果系统找到了pszModule,就会返回它加载的基地址,否则返回NULL
// 给pszModule传入NULL,可返回主调进程可执行文件基地址,
// 当然也可以用伪变量 __ImageBase 获取主调进程可执行文件基地址

加载到进程地址空间的每一个可执行文件或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
2
3
4
LPWSTR* CommandLineToArgvW(
LPCWSTR lpCmdLine, // 指向命令行的字符串,通常为GetCommandLineW返回值
int *pNumArgs // 用于接收命令行中实参数目的地址
);

这个函数返回一个LPWSTR*数组的数据。CommandLineToArgvW在内部分配内存,因此我们可以不释放这块内存,由操作系统在进程终止时释放。如果想自己释放,应该调用HeapFree

进程的环境变量

每个进程都有一个与它关联的环境块(environment block),这是在进程地址空间分配的一块内存,其中包含的字符串和下面类似:

1
2
3
4
5
6
=::=::\ ...
Var1=Value1\0
Var2=Value2\0
Var3=Value3\0
VarN=ValueN\0
\0

每个字符串的第一部分是一个环境变量的名称,后跟一个等号,等号后是环境变量的值(如果有空格,包括空格部分)。除了第一个=::=::\字符串,可能还有其他字符串以等号开头,但这种字符串不作为环境变量使用。我们可以使用GetEnvironmentStrings 函数获得这种完整的环境块,得到的环境块的格式与本段描述的完全一致。

下面的一段代码展示了如何遍历输出所有的环境变量的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <windows.h>
#include <stdio.h>
#include <tchar.h>
#pragma comment(lib, "user32.lib")

int _tmain()
{
// 获取环境变量字符串
LPTSTR lpEnvVars = GetEnvironmentStrings();
LPTSTR lpCurVar = lpEnvVars;

if (lpCurVar != NULL)
{
// 每一个环境变量字符串都以NULL结尾
while (*lpCurVar)
{
// 这一条判断是为了忽略=开头的字符串
if (*lpCurVar != TEXT('='))
{
_tprintf(TEXT("%s\n"), lpCurVar);
}
// 移动指针到下一个字符串开头
lpCurVar += lstrlen(lpCurVar) + 1;
}
}
// 释放字符串缓冲区
FreeEnvironmentStrings(lpEnvVars);
return 0;
}

注意,对于描述环境变量的字符串

1
2
VAR1=WINDOWS
VAR2= WINDOWS

VAR1和VAR2会被认为是不同的环境变量

用户在登录Windows时,系统创建外壳(shell)进程,系统通过检查注册表中的两个注册表项来获得初始的环境的字符串。

第一个包含系统所有环境变量列表

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager

第二个注册表项包含当前登录用户所有环境变量列表

HKEY_CURRNET_USER\Environment

用户可以自由修改。应用程序还可以使用各种注册表函数来修改这些注册表项,为了使改动对所有应用程序生效,用户必须注销并重新登陆,有些应用程序可以在其主窗口接收到WM_SETTINGCHANGE消息,用新的注册表项更新他们的环境块。如果我们在更新了注册表项后希望应用程序马上更新他们的环境块,可以进行如下调用:

1
2
SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, 
(LPARAM)TEXT("Environment"));

上面还提到过,对于修改环境变量,可以使用GetEnvironmentVariable函数来获取环境变量

1
2
3
4
5
DWORD GetEnvironmentVariable(
LPCTSTR lpName, // 环境变量的名称
LPTSTR lpBuffer, // 接收环境变量的缓冲区
DWORD nSize // 缓冲区大小
);

如果lpBuffer为NULL,这个函数的返回值为需要的缓冲区大小。

调用SetEnvironmentVariable函数可以修改某个环境变量的值

1
2
3
4
5
BOOL SetEnvironmentVariable(
LPCTSTR lpName, // 环境变量的名称
LPCTSTR lpValue // 修改成的环境变量的值,
// 如果为NULL将删掉这个环境变量
);

在很多字符串的内部,都包含了“可替换字符串”,类似%环境变量名%的子串,即为可替换字符串,

例如PATH=%PATH%,这个%PATH%即为可替换字符串

我们可调用ExpandEnvironmentStrings函数来将一个带有可替换字符串的的字符串,用环境变量值的实际内容替换相应位置。

1
2
3
4
5
6
DWORD ExpandEnvironmentStrings(
LPCTSTR lpSrc, // 包含可替换字符串的字符串
LPTSTR lpDst, // 接收替换结果的缓冲区,
// 如为NULL该函数返回值为需要的缓冲区大小
DWORD nSize // 缓冲区大小
);

例如下列调用代码:

1
2
3
4
5
LPTSTR lpBuffer = new TCHAR[256];
ExpandEnvironmentStrings(TEXT("你的电脑环境变量OS的值:%OS%"),
lpBuffer, 256);
MessageBox(NULL, lpBuffer, TEXT("提示"), MB_OK);
delete[] lpBuffer;

在我的电脑环境变量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
2
3
4
5
6
7
8
DWORD GetCurrentDirectory(
DWORD nBufferLength, // 缓冲区长度
LPTSTR lpBuffer // 缓冲区地址
);

BOOL SetCurrentDirectory(
LPCTSTR lpPathName // 修改的路径
);

如果缓冲区不够大,GetCurrentDirectory函数返回所需要的字符数。在头文件WinDef.h头文件的MAX_PATH宏(定义为260)定义了目录文件或名称的最大字符数,因而我们通常准备一个有MAX_PATH大小的缓冲区来储存此数据。

系统跟踪记录着进程当前驱动器和目录,但没有记录每个驱动器的当前目录。不过,Microsoft Windows的支持,可以处理多个驱动器的当前目录。这个支持是通过进程的环境字符串来提供的。如一个进程可以有如下的两个环境变量:

1
2
=C:=C:\Codes
=D:=D:\Program Files

上述变量指出进程在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
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _OSVERSIONINFOEX {
DWORD dwOSVersionInfoSize; // 这个结构体的size
DWORD dwMajorVersion; // 主版本号
DWORD dwMinorVersion; // 次版本号
DWORD dwBuildNumber; // 系统构件号
DWORD dwPlatformId; // 系统支持的平台
TCHAR szCSDVersion[128]; // 系统补丁包名称
WORD wServicePackMajor; // 系统补丁包主版本
WORD wServicePackMinor; // 系统补丁包次版本
WORD wSuiteMask; // 标识系统上的程序组
BYTE wProductType; // 标识系统类型
BYTE wReserved; // 保留的参数
} OSVERSIONINFOEX, *POSVERSIONINFOEX, *LPOSVERSIONINFOEX;

我们需要自己初始化一个OSVERSIONINFOEX结构体,然后作为GetVersionEx的参数,这样就可以获取了。