CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • Windows 10系统编程 引言
  • 第1章:基础
  • 第2章:对象和句柄
  • 第3章:进程
    • 进程基础
      • 进程资源管理器中的进程
    • 进程创建
      • 主函数
      • 进程环境变量
    • 创建进程
      • 句柄继承
      • 使用Visual Studio调试子进程
      • 进程驱动器目录
      • 进程(和线程)属性
      • 受保护进程和受保护轻量级进程(PPL)
      • UWP进程
      • 最小进程(Minimal Processes)和Pico进程(Pico Processes)
    • 进程终止
    • 枚举进程
      • 使用EnumProcesses函数
      • 获取进程时间(GetProcessTimes)的原型如下:
      • 使用工具帮助函数(Toolhelp Functions)
      • 使用Windows终端服务(WTS)函数
      • 使用原生API(Native API)
    • 练习
    • 总结
  • 第4章:作业(Jobs)
  • 第5章:线程基础
  • 第6章:线程调度
  • 第7章:进程内线程同步
  • 第8章:进程间线程同步
  • 第9章:线程池
  • 第10章:高级线程
  • 第11章:文件和设备输入输出
  • 第12章:内存管理基础
  • 第13章:内存操作
  • 第14章:内存映射文件
  • 第15章:动态链接库
  • 第16章:安全性
  • 第17章:注册表
目录

第3章:进程

# 第3章:进程

进程是Windows中的基本管理和容纳对象。所有执行的操作都必须在某个进程上下文环境下进行,不存在脱离进程运行的情况。本章将从多个角度探讨进程,包括创建、管理、销毁,以及这之间的几乎所有内容。

本章内容包括:

  • 进程基础
  • 进程创建
  • 创建进程
  • 进程终止
  • 枚举进程

# 进程基础

尽管自Windows NT首次发布以来,进程的基本结构和属性并未改变,但系统中引入了具有特殊行为或结构的新进程类型。以下是目前支持的所有进程类型的简要概述,本章后面的部分将更详细地讨论每种进程类型。

  • 受保护进程(Protected Processes):这些进程在Windows Vista中引入。它们的创建是为了支持数字版权管理(Digital Rights Management,DRM)保护,通过防止对渲染DRM保护内容的进程进行侵入式访问来实现。例如,任何其他进程(即使以管理员权限运行)都无法读取受保护进程地址空间内的内存,因此DRM保护的数据不会被直接窃取。
  • UWP进程(UWP Processes):这些进程从Windows 8开始可用,用于承载Windows运行时(Windows Runtime),并且通常发布到Microsoft Store。UWP进程在应用容器(AppContainer)内执行,这是一种沙盒机制,限制了该进程可以执行的操作。
  • 轻量级受保护进程(Protected Processes Light,PPL):这些进程从Windows 8.1开始可用,通过添加多个保护级别扩展了Vista中的保护机制,甚至允许第三方服务以PPL的形式运行,保护它们免受侵入式访问和终止,即使是来自管理员级别的进程也无法进行这些操作。
  • 最小进程(Minimal Processes):这些进程从Windows 10版本1607开始可用,是一种全新的进程形式。最小进程的地址空间不包含普通进程通常具有的图像和数据结构。例如,进程地址空间中没有映射可执行文件,也没有动态链接库(DLL)。进程地址空间实际上是空的。
  • Pico进程(Pico Processes):这些进程是在最小进程的基础上增加了一个Pico提供程序,它是一个内核驱动程序,用于拦截Linux系统调用并将其转换为等效的Windows系统调用。这些进程与从Windows 10版本1607开始可用的Windows子系统 for Linux(WSL)一起使用。

进程的基本信息在诸如任务管理器(Task Manager)和进程资源管理器(Process Explorer)等工具中很容易查看。图3-1展示了任务管理器的“详细信息”选项卡,其中添加了一些默认之外的列。

img

图3-1:任务管理器的“详细信息”选项卡

让我们简要查看一下图3-1中出现的列:

名称

这通常是进程所基于的可执行文件的名称。请记住,这个名称不是进程的唯一标识符。有些进程似乎根本没有可执行文件名。例如系统(System)、安全系统(Secure System)、注册表(Registry)、内存压缩(Memory Compression)、系统空闲进程(System Idle Process)和系统中断(System Interrupts)。

  • 系统中断实际上不是一个进程,它只是用于衡量在内核中处理硬件中断和延迟过程调用所花费时间的一种方式。这两者都超出了本书的范围。你可以在《Windows内核编程》(Windows Internals)和《Windows内核编程》(Windows Kernel Programming)等书籍中找到更多信息。
  • 系统空闲进程也不是一个真正的进程。它的进程ID(PID)始终为零。它用于统计Windows的空闲时间,即CPU在无事可做时所处的状态。
  • 系统进程是一个真正的进程,从技术上讲也是一个最小进程。它的PID始终为4。它代表了内核空间中发生的所有活动,包括内核和内核驱动程序使用的内存、打开的句柄、线程等等。
  • 安全系统进程仅在启用了基于虚拟化的安全性(Virtualization Based Security)并以此方式启动的Windows 10和Server 2016(及更高版本)系统上可用。它代表了安全内核中发生的所有活动。更多信息请参考《Windows内核编程》一书。
  • 注册表进程是从Windows 10版本1803(RS4)开始可用的最小进程,它用作管理注册表的“工作区域”,而不是像以前版本那样使用分页池(Paged Pool)。就本书而言,这是一个实现细节,不会影响通过编程方式访问注册表的方式。
  • 内存压缩进程是从Windows 10版本1607开始可用的最小进程(但在服务器上不可用),其地址空间中保存着压缩后的内存。内存压缩是Windows 10中添加的一项功能,用于节省物理内存(RAM),这对于资源有限的设备(如手机和物联网(Internet of Things,IoT)设备)特别有用。令人困惑的是,任务管理器不会显示这个进程,但进程资源管理器可以正确显示它。

在本章接下来直到“最小进程和Pico进程”部分之前,我们讨论的都是基于可执行文件的“普通”进程。无论如何,最小进程和Pico进程只能由内核创建。

PID

进程的唯一ID。进程ID是4的倍数,其中最低的有效PID值是4(属于系统进程)。进程终止后,其进程ID会被重用,因此有可能看到一个新进程使用曾经分配给(现已终止)进程的PID。如果需要一个进程的唯一标识符,那么进程ID和进程启动时间的组合在特定系统上是真正唯一的。

你可能还记得在第2章中,句柄也是从4开始且是4的倍数,就像进程ID一样。这并非巧合。实际上,进程ID(和线程ID)是用于此目的的特殊句柄表中的句柄值。

状态

“状态”列很有意思。它可以有三个值:运行(Running)、已挂起(Suspended)和未响应(Not Responding)。让我们逐个来看。表3-1根据进程类型总结了这些状态的含义。

进程类型 运行状态(Running) 已挂起状态(Suspended) 未响应状态(Not responding)
非UWP的图形用户界面(GUI)进程 GUI线程响应 进程中的所有线程都被挂起 GUI线程至少5秒未检查消息队列
非UWP的字符用户界面(CUI)进程 至少有一个线程未被挂起 进程中的所有线程都被挂起 从不
UWP进程 在前台 在后台 GUI线程至少5秒未检查消息队列

具有图形用户界面的进程必须至少有一个线程来处理其用户界面。这个线程有一个消息队列,一旦它调用任何UI或图形设备接口(GDI)函数,消息队列就会为它创建。因此,这个线程必须泵送消息,即监听其消息队列并处理到达的消息。典型的监听函数是GetMessage或PeekMessage。如果至少5秒内没有调用这些函数,任务管理器会将状态更改为“未响应”,该线程所拥有的窗口会变灰,并且“(未响应)”会添加到窗口标题中。出现问题的线程未检查其消息队列可能有以下三个原因之一:

  • 它因某种原因被挂起。
  • 它正在等待某些I/O操作完成,并且等待时间超过了5秒。
  • 它正在进行一些占用大量CPU资源的工作,且耗时超过5秒。

我们将在第5章“线程基础”中探讨这些问题。

UWP进程比较特殊,当它们进入后台(例如应用程序窗口最小化时),会自动被挂起。一个简单的实验可以验证这种情况:在Windows 10上打开现代计算器应用,在任务管理器中找到它。你应该会看到其状态为“运行”,这意味着它可以响应用户输入并正常工作。现在最小化计算器应用,几秒钟后你会看到其状态变为“已挂起”。这种行为仅存在于UWP进程中。

没有图形用户界面的非UWP进程始终显示为“运行”状态,因为Windows并不知道这些进程实际上在做什么(或没做什么)。唯一的例外是,如果这样的进程中的所有线程都被挂起,那么其状态会变为“已挂起”。

Windows API没有用于挂起进程的函数,只有用于挂起线程的函数。从技术上讲,可以遍历某个进程中的所有线程并逐个挂起(前提是能够获得足够权限的句柄)。原生API(在NtDll.Dll中实现)有一个用于此目的的函数NtSuspendProcess。如果你在进程资源管理器中右键单击一个进程并选择“挂起”,调用的就是这个函数。当然,也有相反的函数NtResumeProcess。

用户名

用户名表示进程在哪个用户下运行。一个令牌对象(称为主令牌)会附加到进程上,该令牌基于用户持有进程的安全上下文。该安全上下文包含诸如用户所属的组、拥有的特权等信息。我们将在第16章更深入地探讨令牌。进程可以在特殊的内置用户下运行,例如本地系统(在任务管理器中显示为“System”)、网络服务(Network Service)和本地服务(Local Service)。这些用户账户通常用于运行服务,我们将在第16章讨论服务。

会话ID

进程执行所在的会话编号。会话0用于系统进程和服务,会话1及更高编号用于交互式登录。我们将在第16章更详细地讨论会话。

CPU

此列显示该进程的CPU使用率百分比。请注意,它只显示整数。要获得更高的精度,可以使用进程资源管理器。

内存

与内存相关的列有些复杂。任务管理器默认显示的列在Windows 10版本1903中是“内存(活动私有工作集)”(Memory (active private working set)),在早期版本中是“内存(私有工作集)”(Memory (private working set))。术语“工作集”(Working Set)指的是随机存取存储器(RAM,即物理内存)。私有工作集是进程使用且不与其他进程共享的RAM。共享内存最常见的例子是DLL代码。活动私有工作集与私有工作集相同,但对于当前已挂起的UWP进程,其值会设置为零。

上述两个计数器能很好地反映进程使用的内存量吗?很遗憾,不能。它们表示使用的私有RAM,但当前已分页出去的内存呢?还有另一列用于表示这部分内存 —— 提交大小(Commit Size)。这是用于了解进程内存使用情况的最佳列。“遗憾”之处在于,任务管理器默认不显示此列。

进程资源管理器中有一个与提交大小等效的列,但它被称为“私有字节”(Private Bytes),这与同名的性能计数器一致。

这些内存术语将在第12章进一步讨论。

基本优先级

“基本优先级”列,正式名称为“优先级类”(Priority Class),显示六个值之一,这些值为在该进程中执行的线程提供基本的调度优先级。可能的值及其相关的优先级级别如下:

  • 空闲(在任务管理器中称为“低”) = 4
  • 低于正常 = 6
  • 正常 = 8
  • 高于正常 = 10
  • 高 = 13
  • 实时 = 24

最常见(也是默认)的优先级类是“正常”(8)。我们将在第6章讨论优先级和调度。

句柄

“句柄”列显示特定进程中打开的内核对象句柄的数量。第2章已详细讨论过这一点。

线程

“线程”列显示每个进程中的线程数量。通常,这个数量至少应该为1,因为没有线程的进程是无用的。然而,有些进程显示为没有线程(用短划线表示)。具体来说,安全系统显示为没有线程,因为安全内核实际上使用普通内核进行调度。系统中断伪进程根本不是一个进程,所以不可能有任何线程。最后,系统空闲进程也不拥有线程。该进程显示的线程数是系统上逻辑处理器的数量。

任务管理器中还有其他一些有趣的列,我们将在适当的时候进行探讨。

# 进程资源管理器中的进程

进程资源管理器(Process Explorer)可以被视作 “超级任务管理器”。它具备任务管理器的大部分功能,而且还有更多强大功能。我们已经见识过它展示进程中打开句柄的能力。在本节中,我们将探究它与进程相关的一些功能。首先,进程资源管理器能像任务管理器一样,通过不同的列展示进程信息。不过,它的列数比任务管理器更多。进程所显示的颜色十分醒目,每种颜色都代表着进程的某个有趣特征。当然,一个进程可能有多个值得用颜色标记的 “特征”,在这种情况下,会有一种颜色 “胜出”,而其他 “落选” 的颜色则不会显示。所有可用颜色都能通过选择菜单栏中的 “选项(Options)” - “配置颜色(Configure Colors…)” 进行更改、启用或禁用操作,相关对话框如图3-2所示。

img 图3-2:进程资源管理器中的颜色配置

表3-2总结了这些进程背景颜色及其含义。

表3-2:进程资源管理器中的颜色

名称(默认颜色) 含义
新建对象(绿色) 新创建的对象
已删除对象(红色) 已被销毁的对象
自身进程(蓝调颜色) 在当前登录用户账户下运行的进程
服务(粉色) 承载Windows服务的进程(详见第19章)
已暂停进程(灰色) 已暂停的进程
压缩映像(紫色) 使用压缩技术减小文件大小的可执行文件或动态链接库(DLL)。在某些情况下,恶意软件可能会使用此类技术
重定位的DLL(淡黄色) 在模块视图中显示(不在主进程视图中)。详见第15章
作业(棕色) 属于某个作业的进程(详见第4章)
.NET进程(淡黄色) 运行部分.NET代码的进程。更准确地说,是承载.NET公共语言运行时(CLR)的进程
沉浸式进程(青色) 通常是未暂停的通用Windows平台(UWP)进程。更准确地说,是承载Windows运行时的进程。用于判断该进程的函数是IsImmersiveProcess
受保护进程(紫红色) 受保护进程和基于策略的保护(PPL)进程(详见本章后文)
(其他所有情况)(白色) 没有任何已启用特征的进程。如果启用了所有颜色,那么剩下的大多是系统进程
我个人添加了受保护进程的颜色,并选择紫红色(fuchsia,与谷歌新操作系统无关)作为默认颜色。

默认情况下,新建和已销毁对象的颜色会显示一秒钟。你可以通过打开 “选项(Options)” 菜单中的 “差异突出显示持续时间(Difference Highlight Duration …)” 来延长显示时间。

进程资源管理器的另一个有趣功能是能够以进程树(更准确地说是多棵进程树)的形式 “排序” 进程。如果你点击显示映像名称的 “进程” 列,可进行常规排序,而第三次点击时,“进程” 列会转变为进程树形式。图3-3展示了部分进程树。

img

图3-3:进程资源管理器中的进程树

进程树中的每个子节点都是其父节点的子进程。有些进程似乎是左对齐显示的(见图3-3中的 “Explorer.exe”)。这些进程没有父进程,或者更准确地说,它们曾经有父进程,但父进程已经退出。双击这样的进程并切换到 “映像(Image)” 选项卡,就能查看该进程的基本信息,包括其父进程信息。图3-4展示了 “Explorer.exe” 这个实例的相关信息。 img 图3-4:Explorer.exe属性

注意,该进程的父进程未知,但我们知道它的进程ID(PID,图3-4中为4160)。这意味着子进程会存储父进程的PID,但如果父进程已不存在,就不会保留关于它的其他信息。

你可能会好奇,如果创建一个新进程,其PID与图3-4中的4160相同会怎样,因为PID是会被复用的。幸运的是,进程资源管理器不会混淆,它会检查父进程的启动时间。如果父进程的启动时间晚于子进程,那么显然这个进程不可能是父进程。

为什么 “Explorer.exe” 没有父进程呢?实际上这是正常情况,因为 “Explorer” 是由早期运行的 “UserInit.exe” 进程创建的,“UserInit.exe” 的任务之一(还有其他任务)就是启动默认的外壳程序(默认在注册表中配置为 “Explorer.exe”)。一旦 “UserInit” 完成工作,它就会直接退出。

关于这种父子进程关系,需要记住的重点是:如果进程A创建了进程B,当进程A终止时,进程B不会受到影响。换句话说,Windows中的进程更像是 “兄弟姐妹”,创建之后它们彼此之间不会相互影响 。

# 进程创建

进程创建所涉及的主要环节如图3-5所示。

img 图3-5:进程创建流程

首先,内核会打开映像(可执行文件),并验证其是否为 “可移植可执行文件(Portable Executable,PE)” 格式。顺便说一下,文件扩展名并不重要,关键在于实际内容。假设各种文件头都有效,内核随后会创建一个新的进程内核对象和一个线程内核对象,因为正常情况下,创建进程时会同时创建一个线程,该线程最终会执行主入口点的代码。

此时,内核会将映像映射到新进程的地址空间,同时映射NtDll.Dll。除了最小进程(Minimal)和Pico进程外,NtDll会被映射到每个进程中,因为它在进程创建的最后阶段承担着重要职责(下文会概述),并且是调用系统调用的 “跳板”。创建进程的进程执行的最后一个主要步骤是通知Windows子系统进程(Csrss.exe)新进程和线程已创建这一事实。(Csrss可以看作是内核管理Windows子系统进程某些方面的助手)。

从内核的角度来看,此时进程已成功创建,因此调用者(通常是调用CreateProcess,下一节会讨论)调用的进程创建函数会返回成功。然而,新创建的进程此时还不能执行其初始代码。进程初始化的第二部分必须在新进程的上下文中,由新创建的线程来完成。

有些开发者认为,新进程运行的第一段代码是可执行文件的主函数。但事实并非如此,在实际的主函数开始运行之前,还有很多工作要做。此时的 “主角” 是NtDll,因为此时进程中没有其他操作系统级别的代码。NtDll在这个阶段有多项任务。

首先,它会创建进程的用户模式管理对象,即进程环境块(Process Environment Block,PEB),以及第一个线程的用户模式管理对象,即线程环境块(Thread Environment Block,TEB)。这些结构在(<winternl.h>)头文件中有部分文档说明,官方不建议开发者直接使用。不过,在某些情况下,这些结构很有用,特别是在尝试实现一些其他方式难以完成的任务时。

当前线程的TEB可通过NtCurrentTeb()访问,而当前进程的PEB可通过NtCurrentTeb()->ProcessEnvironmentBlock访问。

然后会进行一些其他初始化操作,包括创建默认进程堆(详见第13章)、创建并初始化默认进程线程池(第9章)等。如需详细信息,可查阅《Windows内核原理与实现》(Windows Internals)一书。

在入口点开始执行之前的最后一个主要步骤是加载所需的动态链接库(DLL)。NtDll的这部分功能通常被称为加载器(Loader)。加载器会查看可执行文件的导入部分,这部分包含了该可执行文件所依赖的所有库,通常包括Windows子系统的DLL,如kernel32.dll、user32.dll、gdi32.dll和advapi32.dll。

为了了解这些导入库,我们可以使用DumpBin.exe工具,该工具包含在Windows SDK和Visual Studio安装包中。打开 “开发者命令提示符”,就能方便地使用各种工具。在命令提示符中输入以下命令,查看Notepad.exe的导入库:

c:\>dumpbin /imports c:\Windows\System32\notepad.exe
1

结果会输出所有导入库以及从这些库中导入(使用)的符号。以下是一个简略的输出示例(Windows 10 1903版本):

Dump of file c:\Windows\System32\notepad.exe

File Type: EXECUTABLE IMAGE

Section contains the following imports:

GDI32.dll
140022788 Import Address Table
1400289E8 Import Name Table
0 time date stamp
0 Index of first forwarder reference
35C SelectObject
2D0 GetTextFaceW
1C2 EnumFontsW
...
USER32.dll
140022840 Import Address Table
140028AA0 Import Name Table
0 time date stamp
0 Index of first forwarder reference
364 SetThreadDpiAwarenessContext
2AD PostMessageW
BA DialogBoxParamW
...
msvcrt.dll
140022FD8 Import Address Table
140029238 Import Name Table
0 time date stamp
0 Index of first forwarder reference
2F ?terminate@@YAXXZ
496 memset
...
api-ms-win-core-libraryloader-l1-2-0.dll
140022C60 Import Address Table
140028EC0 Import Name Table
0 time date stamp
0 Index of first forwarder reference
F GetModuleFileNameA
18 LoadLibraryExW
13 GetModuleHandleExW
...
urlmon.dll
|00000001|Characteristics|
|---|---|
|000000014002C0D0|Address of HMODULE|
|000000014002F0E0|Import Address Table|
|0000000140028368|Import Name Table|
|0000000140028638|Bound Import Name Table|
|0000000000000000|Unload Import Name Table|
0 time date stamp
0000000140020F31    3B FindMimeFromData
...
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

对于每个所需的DLL,dumpbin会显示从该DLL导入的函数,即可执行文件实际使用的函数。有些DLL名称可能看起来很奇怪,实际上你找不到对应的实际文件。上述输出中的api - ms - win - core - libraryloader - l1 - 2 - 0.dll就是一个例子。这被称为API集(API Set),它是从契约(API集)到实际实现DLL(有时称为宿主)的间接映射。

API集从Windows 7开始存在。

另一种查看这些依赖项的方式是通过图形化工具。图3-6展示了这样一款工具——PE Explorer,可从http://github.com/zodiacon/AllTools (opens new window)下载,图中显示的是记事本程序(Notepad.exe)的依赖项。对于每个API集(API Set)或动态链接库(DLL),它都会展示导入的函数。

img

图3-6:显示记事本程序(Notepad.exe)的PE Explorer

API集允许微软将函数 “声明” 与实际实现分离。这意味着在后续的Windows版本中,实现功能的动态链接库可能会发生变化,甚至在不同的设备类型(物联网设备、混合现实头显(HoloLens)、Xbox等)上也会有所不同。API集与实现之间的实际映射关系存储在每个进程的进程环境块(PEB)中。你可以使用ApiSetMap.exe工具查看这些映射关系,该工具可从https://github.com/zodiacon/WindowsInternals/releases (opens new window)下载。以下是部分输出内容:

C:\>APISetMap.exe

ApiSetMap - 列出API集映射 - 版本1.0

(c) Alex Ionescu、Pavel Yosifovich及贡献者

http://www.alex-ionescu.com

api-ms-onecoreuap-print-render-l1-1-0.dll -> s{printrenderapihost.dll}
api-ms-win-appmodel-identity-l1-2-0.dll -> s{kernel.appcore.dll}
api-ms-win-appmodel-runtime-internal-l1-1-6.dll -> s{kernel.appcore.dll}
api-ms-win-appmodel-runtime-l1-1-3.dll -> s{kernel.appcore.dll}
api-ms-win-appmodel-state-l1-1-2.dll -> s{kernel.appcore.dll}
api-ms-win-appmodel-state-l1-2-0.dll -> s{kernel.appcore.dll}
api-ms-win-appmodel-unlock-l1-1-0.dll -> s{kernel.appcore.dll}
api-ms-win-base-bootconfig-l1-1-0.dll -> s{advapi32.dll}
api-ms-win-base-util-l1-1-0.dll -> s{advapi32.dll}
api-ms-win-composition-redirection-l1-1-0.dll -> s{dwmredir.dll}
api-ms-win-composition-windowmanager-l1-1-0.dll -> s{udwm.dll}
api-ms-win-containers-cmclient-l1-1-1.dll -> s{cmclient.dll}
api-ms-win-core-apiquery-l1-1-1.dll -> s{ntdll.dll}
api-ms-win-core-apiquery-l2-1-0.dll -> s{kernelbase.dll}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

动态链接库或API集的名称并没有关联完整路径。加载程序会按以下顺序在各个目录中搜索,直到找到相应的动态链接库:

  1. 如果动态链接库名称属于已知动态链接库(在注册表中指定),则首先搜索系统目录(见第4点)(已知动态链接库在第二部分的第15章中有介绍)。Windows子系统的动态链接库(kernel32.dll、user32.dll、advapi32.dll等)就存放在这里。
  2. 可执行文件所在的目录。
  3. 进程的当前目录(由父进程确定)。(下一节将对此进行讨论)
  4. 通过GetSystemDirectory函数返回的系统目录(例如c:\windows\system32)。
  5. 通过GetWindowsDirectory函数返回的Windows目录(例如c:\Windows)。
  6. PATH环境变量中列出的目录。

在已知动态链接库注册表项(HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs)中列出的动态链接库始终从系统目录加载,这是为了防止动态链接库劫持(DLL hijacking),即同名的替代动态链接库被放置在可执行文件的目录中。

一旦找到动态链接库,就会将其加载,并调用其DllMain函数(如果存在),传入的原因参数为DLL_PROCESS_ATTACH,表明该动态链接库已被加载到进程中。(关于动态链接库加载的详细讨论留到第15章)。

这个过程会递归进行,因为一个动态链接库可能依赖另一个动态链接库,依此类推。如果任何一个动态链接库未找到,加载程序会显示一个类似于图3-7的消息框,然后终止该进程。

img

图3-7:未能找到所需的动态链接库

如果任何一个动态链接库的DllMain函数返回FALSE,则表明该动态链接库未能成功初始化。然后加载程序会停止进一步操作,并显示图3-8中的消息框,之后进程将关闭。

img

图3-8:未能初始化所需的动态链接库

一旦所有所需的动态链接库都成功加载并初始化,控制权就会转移到可执行文件的主入口点。这里所说的入口点并不是开发者提供的实际主函数,而是由C/C++运行时提供的函数,由链接器进行适当设置。为什么需要这样做呢?调用C/C++运行时的函数(如malloc、operator new、fopen等)需要进行一些设置。此外,即使在主函数执行之前,全局C++对象的构造函数也必须被调用。所有这些操作都是由C/C++运行时启动函数完成的。

实际上,开发者可以编写四个主要函数,对于每个函数,都有一个对应的C/C++运行时函数。表3-4总结了这些函数名及其使用场景。

表3-4:main函数和C/C++启动函数

开发者的main函数 C/C++运行时启动函数 场景
main mainCRTStartup 使用ASCII字符的控制台应用程序
wmain wmainCRTStartup 使用Unicode字符的控制台应用程序
WinMain WinMainCRTStartup 使用ASCII字符的图形用户界面(GUI)应用程序
wWinMain wWinMainCRTStartup 使用Unicode字符的图形用户界面(GUI)应用程序

正确的函数由链接器的/SUBSYSTEM开关设置,也可以通过Visual Studio在“项目属性”对话框(如图3-9所示)中进行设置。

img

图3-9:Visual Studio中的系统链接器设置

基于控制台的进程与基于图形用户界面的进程有很大不同吗?其实并没有。这两种类型的进程都是Windows子系统的成员。控制台应用程序可以显示图形用户界面,图形用户界面应用程序也可以使用控制台。它们的区别在于各种默认设置,比如主函数的原型以及默认情况下是否应该创建控制台窗口。

图形用户界面应用程序可以使用AllocConsole创建一个控制台。

# 主函数

根据表3-4中的内容,开发者编写的主函数有四种变体:

int main(int argc, const char* argv[]);     //  const 是可选的
int wmain(int argc, const wchar_t* argv[]); //  const 是可选的
int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
            LPSTR commandLine, int showCmd);
int wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
            LPWSTR commandLine, int showCmd);
1
2
3
4
5
6
有时你会看到主函数被写成_tmain或_tWinMain。你可能已经猜到了,这允许根据编译常量_UNICODE和UNICODE分别进行Unicode或ASCII编码的编译。

对于经典的main/wmain函数,在调用(w)main函数之前,C/C++运行时会对命令行参数进行解析。argc表示命令行参数的数量,至少为1,因为第一个 “参数” 是可执行文件的完整路径。argv是一个指针数组,指向解析后的(基于空格分割)参数。这意味着argv[0]指向可执行文件的完整路径。

对于w(WinMain)函数,参数如下:

  • hInstance表示进程地址空间内的可执行模块本身。从技术上讲,它是可执行文件映射到的地址。该值由链接器指定。默认情况下,Visual Studio使用链接器选项/DYNAMICBASE,每次构建项目时都会生成一个伪随机基地址。无论如何,这个数字本身并不重要,但在许多函数中都需要它,例如加载资源(LoadIcon、LoadString等)。 | HINSTANCE类型实际上只是一个空指针。顺便说一下,有时会与HINSTANCE互换使用的HMODULE类型,实际上也是一回事。之所以存在两种类型而不是一种,这与16位Windows有关,在那时它们表示不同的含义。 | | ------------------------------------------------------------ |
  • hPrevInstance应该表示同一可执行文件的前一个实例的HINSTANCE。然而,这个值始终为NULL,实际上并没有被使用。在16位Windows时代,它是非NULL的。这意味着没有直接的方法可以知道是否已经存在另一个正在运行相同可执行文件的进程。在第2章的单例(Singleton)演示应用程序中,我们看到了一种在需要时处理该问题的方法。WinMain函数的签名从16位Windows保留下来,以便于移植到32位Windows(当时)。最终结果是,这个参数在编写时常常不写变量名,因为它完全没有用。 | 有一种替代方法可以消除编译器警告(特别是在纯C语言中),即使用UNREFERENCED_PARAMETER宏和变量名,如下所示:UNREFERENCED_PARAMETER(hPrevInstance);。具有讽刺意味的是,这个宏实际上只是通过在变量名后加上分号来引用它的参数;但这足以让编译器不再报错。 | | ------------------------------------------------------------ |
  • commandLine是不包含可执行文件路径的命令行字符串 —— 它是命令行的其余部分(如果有的话)。它没有被 “解析” 成单独的标记,只是一个字符串。如果需要解析,可以使用以下函数:
#include <ShellApi.h>
LPWSTR* CommandLineToArgvW(_In_ LPCWSTR lpCmdLine, _Out_ int* pNumArgs);
1
2

该函数接受命令行字符串并将其分割成标记,返回一个指向字符串指针数组的指针。字符串的数量通过*pNumArgs返回。该函数会分配一块内存来保存解析后的命令行参数,最终必须通过调用LocalFree释放这块内存。以下代码片段展示了如何在wWinMain函数中正确解析命令行:

int wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR lpCmdLine, int nCmdShow) {
    int count;
    PWSTR* args = CommandLineToArgvW(lpCmdLine, &count);

    WCHAR text[1024] = { 0 };
    for (int i = 0; i < count; i++) {
        ::wcscat_s(text, 1024, args[i]);
        ::wcscat_s(text, 1024, L"\n");
    }
    
    ::LocalFree(args);

    ::MessageBox(nullptr, text, L"Command Line Arguments", MB_OK);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

如果传递给CommandLineToArgvW的字符串为空字符串,那么它的返回值是一个包含完整可执行文件路径的单个字符串。另一方面,如果传入的字符串包含非空参数,它会返回一个字符串指针数组,其中只包含解析后的参数,而不将完整的可执行文件路径作为第一个解析后的字符串。

进程可以随时通过调用GetCommandLine获取其命令行,并且它是CommandLineToArgvW的合适参数。如果在wWinMain之外需要进行解析,这可能会很有用。

  • showCmd是最后一个参数,它建议应用程序主窗口的显示方式。它由进程创建者决定,默认值是SW_SHOWDEFAULT(10)。当然,应用程序可以忽略这个值,以任何它喜欢的方式显示主窗口。

# 进程环境变量

环境变量(Environment Variables)是一组名称/值对,可以通过图3-10所示的对话框在系统范围或用户范围内进行设置(可从“系统属性”对话框访问,或者直接通过搜索打开)。名称和值存储在注册表(Registry,就像Windows中的大多数系统数据一样)中。

img

图3-10:环境变量编辑器

用户环境变量存储在HKEY_CURRENT_USER\Environment中。系统环境变量(适用于所有用户)存储在HKEY_LOCAL_MACHINE\System\Current Control Set\Control\Session Manager\Environment中。

一个进程从其父进程接收环境变量,这些环境变量是系统变量(适用于所有用户)和特定于用户的变量的组合。在大多数情况下,进程接收的环境变量是其父进程环境变量的副本(详见下一节)。

控制台应用程序可以通过main或wmain函数的第三个参数获取进程环境变量:

int main(int  argc, char* argv[], const  char* env[]);        //  const 是可选的
int wmain(int  argc, wchar_t* argv[], const  wchar_t* env[]); //  const 是可选的
1
2

env是一个字符串指针数组,最后一个指针为NULL,表示数组的结束。每个字符串的格式如下:

name=value
1

等号将名称和值分隔开。下面的main函数示例会打印出每个环境变量的名称和值:

int main(int  argc, const  char* argv[], char* env[]) {
    for (int  i = 0; ; i++) {
        if (env[i] == nullptr)
            break;
        
        auto equals = strchr(env[i], '=');
        // 将等号替换为NULL
        *equals = '\0';
        printf("%s: %s\n", env[i], equals + 1);
        // 为保持一致性,恢复等号
        *equals = '=';
    }
    
    return  0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

图形用户界面(GUI)应用程序可以调用GetEnvironmentStrings函数来获取指向环境变量内存块的指针,其格式如下:

name1=value1\0
name2=value2\0
...
\0
1
2
3
4

下面的代码片段使用GetEnvironmentStrings函数在一个大消息框中显示所有环境变量:

PWSTR env = ::GetEnvironmentStrings();
WCHAR text[8192] = { 0 };
auto p = env;
while (*p) {
    auto equals = wcschr(p, L'=');
    if (equals != p) {    //  排除空的名称/值
        wcsncat_s(text, p, equals - p);
        wcscat_s(text, L": ");
        wcscat_s(text, equals + 1);
        wcscat_s(text, L"\n");
    }
    
    p += wcslen(p) + 1;
}

::FreeEnvironmentStrings(env);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

可以使用SetEnvironmentStrings函数一次性替换环境块,该函数使用GetEnvironmentStrings函数返回的相同格式。

环境块必须使用FreeEnvironmentStrings函数释放。通常,应用程序不需要枚举环境变量,而是更改或读取特定的值。为此可以使用以下函数:

BOOL SetEnvironmentVariable(
    _In_ LPCTSTR lpName,
    _In_opt_ LPCTSTR lpValue);

DWORD GetEnvironmentVariable(
    _In_opt_ LPCTSTR lpName,
    _Out_ LPTSTR lpBuffer,
    _In_ DWORD nSize);
1
2
3
4
5
6
7
8

如果缓冲区足够大,GetEnvironmentVariable函数会返回复制到缓冲区中的字符数,否则返回环境变量的长度。如果函数执行失败(即指定名称的变量不存在),则返回零。通常的做法是调用该函数两次:第一次,不传入缓冲区以获取长度;然后在分配一个大小合适的缓冲区后,再次调用该函数以接收结果。下面的函数可以通过返回一个C++的std::wstring类型结果来获取变量的值:

std::wstring ReadEnvironmentVariable(PCWSTR name) {
    DWORD count = ::GetEnvironmentVariable(name, nullptr , 0);
    if (count > 0) {
        std::wstring value;
        value.resize(count);
        ::GetEnvironmentVariable(name, const_cast<PWSTR>(value.data()), count);
        return  value;
    }
    
    return  L"";
}
1
2
3
4
5
6
7
8
9
10
11

上面代码中的const_cast运算符去除了value.data()的常量属性,因为它返回的是const wchar_t*类型。使用C风格的强制类型转换(PWSTR)value.data()也能达到同样的效果。

在许多情况下,环境变量用于根据其当前值指定相关信息。例如,文件路径可以指定为“%windir%\explorer.exe”。百分号之间的名称就是一个环境变量,应该将其扩展为实际值。普通的应用程序编程接口(API)函数并不理解这些含义。相反,应用程序必须调用ExpandEnvironmentStrings函数,将百分号之间的任何环境变量转换为其实际值:

DWORD ExpandEnvironmentStrings( _In_      LPCTSTR lpSrc,
    _Out_opt_ LPTSTR  lpDst,  _In_      DWORD   nSize);
1
2

与GetEnviromentVariable函数类似,ExpandEnvironmentStrings函数返回复制到目标缓冲区中的字符数,如果缓冲区太小,则返回所需的字符数(加上空字符结束符)。以下是一个使用示例:

WCHAR path[MAX_PATH];
::ExpandEnvironmentStrings(L"%windir%\\explorer.exe", path, MAX_PATH);
printf("%ws\n", path); //  c:\windows\explorer.exe
1
2
3

# 创建进程

进程是使用CreateProcess函数在相同的用户账户下创建的。还有一些扩展函数,例如CreateProcessAsUser,将在第16章中讨论。CreateProcess函数需要一个实际的可执行文件,它不能基于文档文件的路径来创建进程。例如,假设c:\MyData\data.txt是某个文本文件,传入这样的路径会导致进程创建失败。CreateProcess函数不会搜索与TXT文件关联的可执行文件来启动。例如,当在资源管理器中双击一个文件时,会调用Shell API中的一个更高级别的函数——ShellExecuteEx。这个函数可以接受任何文件,如果文件扩展名不是“EXE”,它会根据文件扩展名在注册表中搜索关联的程序来执行。找到关联程序后(如果能找到),最终会调用CreateProcess函数。

资源管理器在哪里查找这些文件关联呢?我们将在第17章(“注册表”)中探讨这个问题

CreateProcess函数接受9个参数,如下所示:

BOOL CreateProcess(
    _In_opt_ PCTSTR pApplicationName,
    _Inout_opt_ PTSTR pCommandLine,
    _In_opt_ PSECURITY_ATTRIBUTES pProcessAttributes,
    _In_opt_ PSECURITY_ATTRIBUTES pThreadAttributes,
    _In_ BOOL bInheritHandles,
    _In_ DWORD dwCreationFlags,
    _In_opt_ PVOID pEnvironment,
    _In_opt_ PCTSTR pCurrentDirectory,
    _In_ PSTARTUPINFO pStartupInfo,
    _Out_ PPROCESS_INFORMATION lpProcessInformation);
1
2
3
4
5
6
7
8
9
10
11

如果函数成功执行,会返回TRUE,这意味着从内核的角度来看,进程和初始线程已成功创建。不过,在新进程上下文中进行的初始化(上一节有描述)仍有可能失败。

如果函数执行成功,真正返回的信息可以通过PROCESS_INFORMATION类型的最后一个参数获取:

typedef  struct  _PROCESS_INFORMATION {
    HANDLE hProcess;
    HANDLE hThread;
    DWORD dwProcessId;
    DWORD dwThreadId;
} PROCESS_INFORMATION, *PPROCESS_INFORMATION;
1
2
3
4
5
6

这个结构体提供了四条信息:唯一的进程ID和线程ID,以及两个对新创建的进程和线程的打开句柄(除非新进程受到保护,否则具有所有可能的权限)。使用这些句柄,创建(父)进程可以对新进程和线程执行任何操作(同样,除非进程受到保护,详见本章后面的内容)。通常,一旦不再需要这些句柄,最好关闭它们。

现在让我们关注该函数的其他输入参数。

  • pApplicationName和pCommandLine:这些参数应该提供要作为新进程运行的可执行文件路径,以及所需的任何命令行参数。然而,这些参数不能互换。

在大多数情况下,你会将可执行文件名以及需要传递给可执行文件的任何命令行参数都放在第二个参数中,并将第一个参数设置为NULL。相较于第一个参数,第二个参数有以下一些优点:

  • 如果文件名没有扩展名,在搜索匹配项之前会隐式添加.EXE扩展名。
  • 如果作为可执行文件只提供了文件名(而非完整路径),系统会在上一节中列出的加载程序查找所需动态链接库(DLL)的目录中进行搜索,为方便起见,在此重复这些目录:
    1. 调用者可执行文件所在的目录。
    2. 进程的当前目录(本节稍后讨论)。
    3. GetSystemDirectory函数返回的系统目录。
    4. GetWindowsDirectory函数返回的Windows目录。
    5. PATH环境变量中列出的目录。

如果pApplicationName不为NULL,则必须将其设置为可执行文件的完整路径。在这种情况下,pCommandLine仍会被视为命令行参数。

pCommandLine参数存在一个问题,它的类型为PTSTR,这意味着它是一个指向字符串的非常量指针。这表明CreateProcess函数实际上会对这个缓冲区进行写入操作(并非仅仅读取),如果像这样使用常量字符串调用,会导致访问冲突:

CreateProcess(nullptr , L"Notepad", ...);
1

编译时的静态缓冲区默认位于可执行文件的只读部分,并映射为只读保护,任何写入操作都会引发异常。最简单的解决方法是通过动态构建字符串或将其放置在栈上(栈始终是可读可写的),将字符串存放在可读可写内存中,例如:

WCHAR name[] = L"Notepad";
CreateProcess(nullptr , name, ...);
1
2

缓冲区的最终内容与最初提供的内容相同。你可能想知道为什么CreateProcess函数要对缓冲区进行写入操作。遗憾的是,据我所知并没有合理的原因,微软应该修复这个问题。但多年来他们一直未修复,所以我对此也不抱希望了。

这个问题在CreateProcessA(CreateProcess的ASCII版本)函数中不会出现。原因很明显:CreateProcessA函数必须将其参数转换为Unicode编码,为此它会动态分配一个缓冲区(该缓冲区是可读可写的),转换字符串后再使用分配的缓冲区调用CreateProcessW函数。但这并不意味着你应该使用CreateProcessA函数!

pProcessAttributes和pThreadAttributes

这两个参数是SECURITY_ATTRIBUTES指针(用于新创建的进程和线程),在第2章中已讨论过。在大多数情况下,应传入NULL,除非返回的句柄需要是可继承的,在这种情况下,可以传入一个bInheritHandle = TRUE的实例。

bInheritHandles

此参数是一个全局开关,用于允许或禁止句柄继承(下一小节将对此进行描述)。如果为FALSE,新创建的子进程不会继承父进程的任何句柄。如果为TRUE,所有标记为可继承的句柄都会被子进程继承 。

dwCreationFlags

此参数可以是各种标志的组合,表3-5中描述了一些更有用的标志。在许多情况下,零是一个合理的默认值。

标志 描述
CREATE_BREAKAWAY_FROM_JOB 如果父进程属于某个作业(job),则子进程不属于该作业,除非该作业不允许脱离,在这种情况下,子进程仍在同一作业下创建(有关作业的更多信息,请参阅下一章)
CREATE_SUSPENDED 创建进程和线程,但线程处于挂起状态。父进程最终应调用返回的线程句柄上的ResumeThread函数来启动执行
DEBUG_PROCESS 父进程成为调试器,创建的进程成为被调试对象。调试器将开始接收与子进程相关的调试事件。子进程创建的任何进程也会在父进程的控制下成为被调试对象
DEBUG_ONLY_THIS_PROCESS 与DEBUG_PROCESS类似,但只有子进程成为被调试对象,而非子进程创建的所有进程
CREATE_NEW_CONSOLE 新进程会获得自己的控制台(如果它是一个控制台用户界面(CUI)应用程序),而不是继承其父进程的控制台
CREATE_NO_WINDOW 如果子进程是一个CUI应用程序,则创建时没有控制台
DETACHED_PROCESS 某种程度上与CREATE_NEW_CONSOLE相反。子进程不会获得任何控制台。如果它需要控制台,可以调用AllocConsole函数来创建一个
CREATE_PROTECTED_PROCESS 新进程必须以受保护模式运行(详见本章后面的内容)
CREATED_PROTECTED_PROCESS 将新进程创建为受保护进程。这仅适用于由微软专门为此签名的可执行文件
CREATE_UNICODE_ENVIRONEMT 为新进程创建的环境块为Unicode格式,而不是默认的(具有讽刺意味的是,默认是ASCII格式)
INHERIT_PARENT_AFFINITY (Windows 7及更高版本)子进程继承其父进程的组亲和力(有关亲和力的更多信息,请参阅第6章)
EXTENDED_STARTUPINFO_PRESENT 使用包含进程属性的扩展STARTUPINFOEX结构创建进程(详见本章后面的“进程(和线程)属性”部分)
CREATE_DEFAULT_ERROR_MODE 使用系统默认的错误模式创建进程,而不是从父进程继承错误模式。有关错误模式的信息,请参阅第20章

除了表3-5中的标志外,创建者还可以根据表3-6设置进程优先级类。

优先级类标志 基本优先级值
IDLE_PRIORITY_CLASS 4
BELOW_NORMAL_PRIORITY_CLASS 6
NORMAL_PRIORITY_CLASS 8
ABOVE_NORMAL_PRIORITY_CLASS 10
HIGH_PRIORITY_CLASS 13
REALTIME_NORMAL_PRIORITY_CLASS 24

如果未指定优先级类标志,默认情况下为“Normal”,除非创建者的优先级类是“Below Normal”或“Idle”,在这种情况下,新进程将继承其父进程的优先级类。如果指定了“Real-time”优先级类,子进程必须以管理员权限执行;否则,它将获得“High”优先级类。

优先级类对进程本身意义不大。相反,它为新进程中的线程设置默认优先级。我们将在第6章中探讨优先级的影响。

pEnvironment

这是一个可选指针,指向子进程要使用的环境变量块。其格式与本章前面讨论的GetEnvironmentStrings函数返回的格式相同。在大多数情况下,传入NULL,这会导致父进程的环境块被复制到新进程的环境块中。

pCurrentDirectory

此参数用于设置新进程的当前目录。当只使用文件名而非完整路径时,当前目录会作为文件搜索路径的一部分。例如,调用CreateFile函数打开名为“mydata.txt”的文件时,会在进程的当前目录中搜索该文件。pCurrentDirectory参数允许父进程为创建的进程设置当前目录,这会影响加载所需DLL时的搜索位置。在大多数情况下,传入NULL,这会将新进程的当前目录设置为父进程的当前目录。

通常,进程可以使用SetCurrentDirectory函数更改其当前目录。请注意,这是一个进程范围的设置,而非线程设置:

BOOL SetCurrentDirectory(
    _In_ PCTSTR pPathName);
1
2

当前目录由驱动器号和路径或通用命名约定(UNC)中的共享名组成,例如\\MyServer\MyShare。

自然地,可以使用GetCurrentDirectory函数查询当前目录:

DWORD GetCurrentDirectory(
    _In_  DWORD  nBufferLength, 
    _Out_ LPTSTR lpBuffer
);
1
2
3
4

函数调用失败时返回值为零,成功时返回复制到缓冲区中的字符数(包括空字符终止符)。如果缓冲区太小,返回值则是所需的字符长度(包括空字符终止符)。

pStartupInfo

此参数指向STARTUPINFO或STARTUPINFOEX这两种结构中的一种,其定义如下:

typedef struct _STARTUPINFO {
    DWORD  cb;
    PTSTR  lpReserved;
    PTSTR  lpDesktop;
    PTSTR  lpTitle;
    DWORD  dwX;
    DWORD  dwY;
    DWORD  dwXSize;
    DWORD  dwYSize;
    DWORD  dwXCountChars;
    DWORD  dwYCountChars;
    DWORD  dwFillAttribute;
    DWORD  dwFlags;
    WORD   wShowWindow;
    WORD   cbReserved2;
    PBYTE  lpReserved2;
    HANDLE hStdInput;
    HANDLE hStdOutput;
    HANDLE hStdError;
} STARTUPINFO, *PSTARTUPINFO;

typedef  struct  _STARTUPINFOEX {
    STARTUPINFO StartupInfo;
    PPROC_THREAD_ATTRIBUTE_LIST pAttributeList;
} STARTUPINFOEXW, *LPSTARTUPINFOEXW;
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

此参数的最小使用方式是创建一个STARTUPINFO结构,初始化其大小(cb成员),并将其余部分清零。将结构清零很重要,否则它会包含垃圾值,这很可能导致CreateProcess函数调用失败。以下是最小化的代码示例:

STARTUPINFO si = { sizeof(si) };
CreateProcess(..., &si, ...);
1
2

STARTUPINFOEX将在本章后面的 “进程(和线程)属性” 部分进行讨论。

STARTUPINFO和STARTUPINFOEX结构为进程创建提供了更多自定义选项。它们的一些成员仅在dwFlags成员(以及其他标志)中设置了特定值时才会使用。表3-7详细列出了dwFlags的可能值及其含义。

标志 含义
STARTF_USESHOWWINDOW wShowWindow成员有效
STARTF_USESIZE dwXSize和dwYSize成员有效
STARTF_USEPOSITION dwX和dwY成员有效
STARTF_USECOUNTCHARS dwXCountChars和dwYCountChars成员有效
STARTF_USEFILLATTRIBUTE dwFillAttribute成员有效
STARTF_RUNFULLSCREEN 对于控制台应用程序,以全屏模式运行(仅适用于x86架构)
STARTF_FORCEONFEEDBACK 指示Windows显示 “后台工作” 光标,其形状可在图3-11所示的 “鼠标属性” 对话框中找到。如果在接下来的2秒内,进程进行图形用户界面(GUI)调用,则会让该进程再显示此光标5秒。如果在任何时候,进程调用GetMessage函数,表明它已准备好处理消息,则光标会立即恢复正常
STARTF_FORCEOFFFEEDBACK 不显示 “后台工作” 光标
STARTF_USESTDHANDLES hStdInput、hStdOutput和hStdError成员有效
STARTF_USEHOTKEY hStdInput成员有效,并且是作为WM_HOTKEY消息的wParam参数发送的值。更多信息请参考文档
STARTF_TITLEISLINKNAME lpTitle成员是用于启动进程的快捷方式文件(.lnk)的路径。Shell(资源管理器)会适当地设置此值
STARTF_TITLEISAPPID lpTitle成员是一个应用程序用户模型ID(AppUserModelId)。请在本表之后的内容中查看相关讨论
STARTF_PREVENTPINNING 防止该进程创建的任何窗口被固定到任务栏。只有在同时指定了STARTF_TITLEISAPPID时才有效
STARTF_UNTRUSTEDSOURCE 表示传递给进程的命令行来自不可信的源。这是一个提示,让进程仔细检查其命令行

图3-11:“后台工作” 鼠标光标

现在让我们来研究STARTUPINFO的其他成员。

有三个保留成员,lpRserved、lpReserved2和cbReserved2,它们应分别设置为NULL、NULL和零。

lpDesktop成员为新进程指定一个备用窗口站(Window Station),为新线程指定一个备用桌面(Desktop)。如果此成员为NULL(或空字符串),则使用父进程的窗口站和桌面。或者,可以使用windowstation\desktop的格式指定完整的桌面名称。例如,可以使用winsta0\mydesktop。有关更多信息,请参阅 “窗口站和桌面” 侧边栏。

窗口站和桌面

窗口站是内核对象,是会话的一部分。它包含与用户相关的对象:剪贴板、原子表和桌面。桌面包含窗口、菜单和钩子。一个进程与单个窗口站相关联。交互式窗口站始终名为WinSta0,并且是会话中唯一可以 “交互” 的窗口站,即可以与输入设备一起使用。

默认情况下,交互式登录会话有一个名为winsta0的窗口站,其中存在两个桌面:“默认” 桌面,用户通常在此工作,在这个桌面上可以看到资源管理器、任务栏以及其他正常运行的程序。另一个桌面(由Winlogon.exe进程创建)名为Winlogon,是在按下著名的Ctrl+Alt+Del组合键时使用的桌面。Windows会调用SwitchDesktop函数将输入桌面切换到Winlogon桌面。桌面可以分别使用CreateDesktop和OpenDesktop函数来创建或打开。

你可以在我的博客文章 https://scorpiosoftware.net/2019/02/17/windows-10-desktops-vs-sysinternals-desktops/ (opens new window) 中找到更多详细信息。

lpTitle成员可以保存控制台应用程序的标题。如果为NULL,则使用可执行文件的名称作为标题。如果dwFlags包含STARTF_TITLEISAPPID标志(Windows 7及更高版本),那么lpTitle是一个应用程序用户模型ID(AppUserModelId),它是一个字符串标识符,Shell用它来进行任务栏项目分组和跳转列表(jump lists)操作。进程可以通过调用SetCurrentProcessExplicitAppUserModelID函数显式设置自己的应用程序用户模型ID,而不是由其父进程指定。使用跳转列表和其他任务栏功能超出了本书的范围。

dwX和dwY可以设置为进程创建的窗口的默认位置。只有当dwFlags包含STARTF_USEPOSITION时才会使用它们。如果新进程在调用CreateWindow或CreateWindowEx函数时,将窗口位置设置为CW_USEDEFAULT,那么它可以使用这些值。(更多详细信息,请参阅CreateWindow函数的文档。)dwXSize和dwYSize类似,如果子进程在调用CreateWindow/CreateWindowEx函数时,将窗口的宽度和高度设置为CW_USEDEFAULT,那么这两个成员指定了子进程创建的新窗口的默认宽度和高度。(当然,dwFlags中必须设置STARTF_USESIZE,这些值才会生效。)

dwXCountChars和dwYCountChars设置子进程创建的控制台窗口(如果有)的初始宽度和高度(以字符为单位)。与前面的成员一样,dwFlags必须包含STARTF_USECOUNTCHARS,这些值才会有效果。

dwFillAttribute指定如果随进程创建一个新的控制台窗口,其初始文本和背景颜色。通常情况下,只有当dwFlags包含STARTF_USEFILLATTRIBUTE时,此成员才会生效。文本和背景颜色的可能组合各有4位,因此共有16种组合。可能的值如表3-8所示。

颜色常量 值 文本/背景
FOREGROUND_BLUE 0x01 文本
FOREGROUND_GREEN 0x02 文本
FOREGROUND_RED 0x04 文本
FOREGROUND_INTENSITY 0x08 文本
BACKGROUND_BLUE 0x10 背景
BACKGROUND_GREEN 0x20 背景
BACKGROUND_RED 0x40 背景
BACKGROUND_INTENSITY 0x80 背景

wShowWindow(如果dwFlags包含STARTF_USESHOWWINDOW,则此成员有效)指示进程应如何显示其主窗口(假设它有图形用户界面)。这些值通常是传递给ShowWindow函数的,带有SW_前缀。wShowWindow比较特殊,因为它直接作为WinMain函数的最后一个参数提供。当然,创建的进程可以忽略提供的值,以任何它认为合适的方式显示其窗口。但遵循这个值是一个好习惯。如果创建者没有提供此成员,则使用SW_SHOWDEFAULT,这表明应用程序可以使用任何逻辑来显示其主窗口。例如,它可能保存了上次窗口的位置和状态(最大化、最小化等),因此它会将窗口恢复到保存的位置/状态。

有一种情况可以控制这个值,那就是使用Shell创建的快捷方式。图3-12展示了为运行记事本(Notepad)创建的快捷方式。在快捷方式属性中,可以选择初始窗口的显示方式:正常、最小化或最大化。资源管理器在创建进程时,会在wShowWindow成员中传递这个值(SW_SHOWNORMAL、SW_SHOWMINNOACTIVE、SW_SHOWMAXIMIZED)。

图3-12:快捷方式中的显示窗口设置

STARTUPINFO中的最后三个成员是标准输入(hStdInput)、输出(hStdOutput)和错误(hStdError)的句柄。如果dwFlags包含STARTF_USEHANDLES,这些句柄将在新进程中按此使用。否则,新进程将使用默认设置:输入来自键盘,输出和错误输出到控制台缓冲区。

如果进程是从任务栏或跳转列表(Windows 7及更高版本)启动的,那么hStdOutput句柄实际上是启动该进程的监视器(HMONITOR)的句柄。

考虑到进程创建有这么多不同的选项(本章后面的 “进程(和线程)属性” 部分还会讨论更多选项),创建一个进程可能看起来令人生畏,但在大多数情况下,如果接受默认设置,其实相当简单。以下代码片段创建了一个记事本实例:

WCHAR name[] = L"notepad";
STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;

BOOL success = ::CreateProcess(nullptr , name, nullptr , nullptr , FALSE,
    0, nullptr , nullptr , &si, &pi);
if  (!success) {
    printf("Error creating process: %d\n", ::GetLastError());
}
else  {
    printf("Process created. PID=%d\n", pi.dwProcessId);
    ::CloseHandle(pi.hProcess);
    ::CloseHandle(pi.hThread);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

从CreateProcess函数返回的句柄可以用来做什么呢?其中一个用途是在进程终止(无论何种原因)时得到通知。这可以通过WaitForSingleObject函数来实现。这个函数并不特定于进程,它可以等待各种内核对象,直到它们变为已通知状态(signaled)。已通知状态的含义取决于对象类型;对于进程来说,它意味着已终止。关于 “等待” 函数的详细讨论将在第8章进行。这里,我们来看几个示例。首先,我们可以无限期等待,直到进程退出:

// 进程创建成功
printf("Process created. PID=%d\n", pi.dwProcessId);
::WaitForSingleObject(pi.hProcess, INFINITE);
printf("Notepad terminated.\n");

::CloseHandle(pi.hProcess);
::CloseHandle(pi.hThread);
1
2
3
4
5
6
7

WaitForSingleObject函数会使调用线程进入等待状态,直到相关对象变为已通知状态或超时。如果超时时间为INFINITE(-1),则永远不会超时。以下是一个非无限超时的示例:

DWORD rv = ::WaitForSingleObject(pi.hProcess, 10000);   // 10秒
if (rv == WAIT_TIMEOUT)
    printf("Notepad still running...\n");
else  if (rv == WAIT_OBJECT_0)
    printf("Notepad terminated.\n");
else //  WAIT_ERROR  (在这种情况下不太可能出现)
    printf("Error! %d\n", ::GetLastError());
1
2
3
4
5
6
7

调用线程最多阻塞10000毫秒,之后返回值会指示进程的状态。

进程始终可以通过调用GetStartupInfo函数获取它创建时使用的STARTUPINFO结构。

# 句柄继承

在第2章中,我们探讨了在进程间共享内核对象的方法。一种是通过名称共享,另一种是通过复制句柄。第三种选择是使用句柄继承。只有在一个进程创建子进程时,这种方法才可行。在创建子进程时,父进程可以将选定的一组句柄复制到目标进程。一旦调用 CreateProcess 函数并将第五个参数设置为 TRUE,父进程中所有设置了继承位的句柄都将被复制到子进程中,且子进程中的句柄值与父进程中的相同。

最后一句话很重要。子进程与父进程协同工作(大概它们属于同一个软件系统),并且子进程知道它将从父进程获取一些句柄。但它不知道这些句柄的值是什么。一种简单的提供这些值的方法是使用发送给正在创建的进程的命令行参数。

设置句柄为可继承有几种方法:

  • 如果相关对象是由父进程创建的,那么可以用句柄继承标志初始化其 SECURITY_ATTRIBUTES(安全属性),并将其传递给创建函数,如下所示:
SECURITY_ATTRIBUTES sa = { sizeof(sa) };
sa.bInheritHandles = TRUE;
HANDLE h = ::CreateEvent(&sa, FALSE, FALSE, nullptr);
// handle h  will be  inherited by  child processes
1
2
3
4
  • 对于现有句柄,可以调用 SetHandleInformation:
::SetHandleInformation(h, HANDLE_FLAG_INHERIT , HANDLE_FLAG_INHERIT);
1
  • 最后,大多数 Open 函数允许在成功返回的句柄上设置继承标志。以下是一个命名事件对象的示例:
HANDLE h = ::OpenEvent(EVENT_ALL_ACCESS,
TRUE,   //  inheritable
L"MyEvent");
1
2
3

InheritSharing 应用程序是第2章内存共享应用程序的又一变体。这一次,共享是通过将内存映射句柄继承给从第一个进程创建的子进程来实现的。现在,对话框中有一个额外的 “创建” 按钮,用于使用继承的共享内存句柄生成新进程(图3-13)。

img 图3-13:通过继承实现共享的应用程序

当点击 “创建” 按钮时,InheirtSharing 进程会创建自身的另一个实例。新实例必须获取共享内存对象的句柄,这是通过继承来完成的:现有的共享内存句柄(保存在 wil::unique_handle 对象中)需要设置为可继承,以便可以复制到新进程。“创建” 按钮的点击处理程序首先设置继承位:

::SetHandleInformation(m_hSharedMem.get(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
1

现在,可以将 CreateProcess 的第五个参数设置为 TRUE 来创建新进程,这表明所有可继承的句柄都将被复制到新进程中。此外,新进程需要知道其复制句柄的值,这个值通过命令行传递:

STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;
// build  command  line
WCHAR path[MAX_PATH];
::GetModuleFileName(nullptr, path, MAX_PATH);
WCHAR handle[16];
::_itow_s((int)(ULONG_PTR)m_hSharedMem.get(), handle, 10);
::wcscat_s(path, L" ");
::wcscat_s(path, handle);

// now  create  the process
if (::CreateProcess(nullptr , path, nullptr , nullptr , TRUE,
					0, nullptr , nullptr , &si, &pi)) {
    //  close unneeded handles
    ::CloseHandle(pi.hProcess);
    ::CloseHandle(pi.hThread);
}
else {
	MessageBox(L"Failed to create new process", L"Inherit Sharing");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

构建命令行时,首先调用 GetModuleFileName 函数,该函数通常用于获取进程中加载的任何DLL的完整路径。将第一个参数设置为 NULL 时,会返回可执行文件的完整路径。这种方法很可靠,因为它不依赖于可执行文件在文件系统中的实际位置。

获取路径后,将句柄值作为命令行参数附加。请记住,继承的句柄始终与原始进程中的句柄值相同。这是可行的,因为新进程的句柄表最初是空的,所以该条目肯定未被使用。

最后一部分是进程启动时的情况。它需要知道自己是第一个实例,还是获取了现有继承句柄的实例。在 WM_INITDIALOG 消息处理程序中,需要检查命令行。如果命令行中没有句柄值,则该进程需要创建共享内存对象。否则,它需要获取句柄并直接使用。

int count;
PWSTR* args = ::CommandLineToArgvW(::GetCommandLine(), &count);
if (count == 1) {
	// "master"  instance
	m_hSharedMem.reset(::CreateFileMapping(INVALID_HANDLE_VALUE,
										nullptr, PAGE_READWRITE, 0, 1 << 16, nullptr));
}
else {
	// first  "real" argument  is  inherited handle  value
	m_hSharedMem.reset((HANDLE)(ULONG_PTR)::_wtoi(args[1]));
}

::LocalFree(args);
1
2
3
4
5
6
7
8
9
10
11
12
13

由于这不是 WinMain 函数,因此无法直接获取命令行参数。不过,可以随时使用 GetCommandLine 函数获取命令行。然后使用 CommandLineToArgvW 函数解析参数(本章前面已讨论过)。如果没有传递句柄值,则使用 CreateFileMapping 函数创建共享内存。否则,该值将被解释为句柄,并附加到 wil::unique_handle 对象中以妥善保存。你可以尝试从子进程创建一个新实例,它的工作方式与使用 “原始” 句柄传播到子进程完全相同。

# 使用Visual Studio调试子进程

在 InheritSharing 应用程序中,不仅希望调试主实例,还希望调试子进程,因为子进程是使用不同的命令行启动的。默认情况下,Visual Studio不会调试子进程(由被调试进程创建的进程)。

不过,有一个Visual Studio扩展可以实现这一点。打开扩展对话框(在VS 2017中是 “工具” -> “扩展和更新”,在VS 2019中是 “扩展” -> “管理扩展”),转到 “联机” 节点,搜索 “Microsoft Child Process Debugging Power Tool” 并安装(图3-14)。

img

图3-14:扩展中的子进程调试工具

安装完成后,转到 “调试” -> “其他调试目标” -> “子进程调试器设置…”,勾选 “启用子进程调试” 并点击 “保存”。现在设置一个断点并正常开始调试(按F5)。

第一次命中断点是在新进程弹出对话框并创建自己的共享内存对象时。此时 count 变量的值应该为1(图3-15)。

img 图3-15:第一个进程中的断点

继续调试并点击 “创建”。一个新进程应该在调试器的控制下启动,并且同样的断点应该再次命中(图3-16)。注意,此时 count 的值现在为2。还要注意这是一个不同的进程,“进程” 工具栏组合框中应该显示两个进程(图3-17)。

img

图3-16:第二个进程中的断点

img

图3-17:调试多个进程

# 进程驱动器目录

每个进程都有其当前目录,可以使用 SetCurrentDirectory 函数设置,使用 GetCurrentDirectory 函数获取。当访问没有任何路径前缀(如 “mydata.txt”)的文件时,会使用这个目录。那么,当访问带有驱动器前缀(如 “c:mydata.txt”,注意没有反斜杠)的文件时,默认目录是怎样的呢?

事实证明,系统使用进程环境变量跟踪每个驱动器的当前目录。如果调用 GetEnvironmentStrings 函数,你会在变量块的开头发现类似以下内容:

=C:=C:\Dev\Win10SysProg
=D:=D:\Temp
1
2

要获取某个驱动器的当前目录,可以调用 GetFullPathName 函数:

DWORD GetFullPathNameW(
_In_ LPCWSTR lpFileName,
_In_ DWORD nBufferLength,
_Out_ LPWSTR lpBuffer,
_Outptr_opt_ LPWSTR* lpFilePart);
1
2
3
4
5

一般来说,这个函数返回给定文件名的完整路径。具体来说,当传入驱动器盘符时,它返回该驱动器的当前目录。以下是一个示例:

WCHAR path[MAX_PATH];
::GetFullPathName(L"c:", MAX_PATH, path, nullptr);
1
2

不要在冒号后面的驱动器盘符后附加反斜杠!如果你这样做,只会得到相同的字符串。

使用驱动器盘符和文件名调用该函数,会返回驱动器当前目录和文件名组合而成的完整路径:

WCHAR path[MAX_PATH];
::GetFullPathName(L"c:mydata.txt", MAX_PATH, path, nullptr);   //  no  backslash
1
2

上述代码可能会返回类似 “c:Win10SysProg\mydata.txt” 的内容。GetFullPathName 函数不会检查所提供文件是否存在。

# 进程(和线程)属性

我们在 CreateProcess 函数中遇到的 STARTUPINFO 结构包含许多字段。可以合理推测,未来版本的Windows可能需要更多自定义进程创建的方法。一种可能的方法是扩展 STARTUPINFO 结构并添加更多标志,使某些成员有效。从Windows Vista开始,微软决定以不同的方式扩展 STARTUPINFO 结构。

定义了一个扩展结构 STARTUPINFOEX,它扩展了 STARTUPINFO 结构,为方便起见,再次展示如下:

typedef  struct  _STARTUPINFOEX {
	STARTUPINFO StartupInfo;
	PPROC_THREAD_ATTRIBUTE_LIST pAttributeList;
} STARTUPINFOEXW, *LPSTARTUPINFOEXW;
1
2
3
4

STARRTUPINFOEX 的内存布局以 STARTUPINFO 结构开头,仅添加了一个成员:一个不透明的属性列表。这个属性列表是 CreateProcess 函数(以及第5章讨论的 CreateRemoteThreadEx 函数)的主要扩展机制。由于这个属性列表可以指向任意数量的属性,因此无需进一步扩展 STARTUPINFOEX 结构。

创建和填充属性列表需要以下步骤:

  1. 使用 InitializeProcThreadAttributeList 函数分配并初始化一个属性列表。
  2. 根据需要调用 UpdateProcThreadAttribute 函数,为每个属性调用一次来添加属性。
  3. 将 STARTUPINFOEX 的 pAttribute 成员设置为指向属性列表。
  4. 使用扩展结构调用 CreateProcess 函数,不要忘记在创建标志(CreateProcess 的第六个参数)中添加 EXTENDED_STARTUPINFO_PRESENT 标志。
  5. 使用 DeleteProcThreadAttributeList 函数删除属性列表。

我们依次来看这些步骤。以下是 InitializeProcThreadAttributeList 函数的简化声明:

BOOL InitializeProcThreadAttributeList(
_Out_ PPROC_THREAD_ATTRIBUTE_LIST pAttributeList,
_In_ DWORD dwAttributeCount,
_Reserved_ DWORD dwFlags,  // must be zero
PSIZE_T pSize);
1
2
3
4
5

第一步是分配一个足够大的缓冲区来容纳所需数量的属性。这通过调用 InitializeProcThreadAttributeList 函数两次来完成:第一次获取所需的大小,分配一个缓冲区,然后第二次调用初始化缓冲区以容纳属性列表。

以下示例展示了这些步骤(省略了错误处理):

SIZE_T size;
// get required size
::InitializeProcThreadAttributeList(nullptr , 1, 0, &size);
// allocate  the required size
auto attlist = (PPROC_THREAD_ATTRIBUTE_LIST)malloc(size);
// initialize
::InitializeProcThreadAttributeList(attlist, 1, 0, &size); // just one attribute
1
2
3
4
5
6
7

第一次调用 InitializeProcThreadAttributeList 函数会返回 FALSE,GetLastError 函数返回122(“传递给系统调用的数据区域太小。”)。这是预期的,因为实际返回值是所需的大小。属性列表本身必须由调用者分配(上述代码片段中使用了 malloc 函数),这也意味着在调用 CreateProcess 函数后必须释放它。

接下来,根据属性数量多次调用UpdateProcThreadAttribute函数。随着几乎每一次Windows系统的发布,可能的属性列表都在不断增加,并且很可能会继续增长。表3-9展示了(撰写本文时)已记录的进程和线程属性,并附有简要说明。

表3-9:已记录的进程和线程属性

属性常量 适用对象 最低版本 描述
PARENT_PROCESS 进程 Windows Vista 设置一个不同的父进程,子进程将从该父进程继承各种属性
HANDLE_LIST 进程 Windows Vista 指定一个句柄列表,供子进程继承
GROUP_AFFINITY 线程 Windows 7 为新线程设置默认的CPU亲和性组(见第6章)
PREFERRED NODE 进程 Windows 7 为新进程设置首选的非统一内存访问(NUMA)节点
IDEAL PROCESSOR 线程 Windows 7 为新线程设置理想的CPU(见第6章)
UMS THREAD 线程 Windows 7 为新线程设置用户模式调度(UMS)上下文(见第10章)
MITIGATION POLICY 进程 Windows 7 为新进程设置安全缓解策略(见第16章)
SECURITY CAPABILITIES 进程 Windows 8 设置应用容器的安全功能(见第16章)
PROTECTION LEVEL 进程 Windows 8 以与创建者相同的保护级别启动新进程
CHILD PROCESS POLICY 进程 Windows 10 指定新进程是否可以创建子进程
DESKTOP APP POLICY 进程 Windows 10(1703版本) 适用于使用桌面桥(Desktop Bridge)转换为通用Windows平台(UWP)的应用程序。指定新进程的子进程是否将在桌面应用环境之外创建
桌面桥将在第18章讨论。

UpdateProcThreadAttribute函数定义如下:

BOOL UpdateProcThreadAttribute(
    _Inout_ PPROC_THREAD_ATTRIBUTE_LIST pAttributeList,
    _In_ DWORD dwFlags,        	   // 必须为零
    _In_ DWORD_PTR Attribute,
    _In_ PVOID pValue,
    _In_ SIZE_T cbSize,
    _Out_ PVOID pPreviousValue,    // 必须为NULL
    _In_opt_ PSIZE_T pReturnSize); // 必须为NULL
1
2
3
4
5
6
7
8

以下示例使用PROC_THREAD_ATTRIBUTE_PARENT_PROCESS属性,通过指定另一个进程的句柄来设置不同的父进程:

HANDLE hParent = ...;
::UpdateProcThreadAttribute(attlist, 0, PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
    &hParent, sizeof(hParent), nullptr, nullptr);
1
2
3

使用PROC_THREAD_ATTRIBUTE_PARENT_PROCESS属性时,属性值是指向相关进程的打开句柄。有关其他属性值的详细信息,请参阅文档。

一旦属性列表完全更新,就可以开始调用CreateProcess函数,要注意使用正确的结构和标志,以使属性生效:

STARTUPINFOEX si = { sizeof(si) };
si.lpAttributeList = attlist;

PROCESS_INFORMATION pi;
WCHAR name[] = L"Notepad";

::CreateProcess(nullptr, name, nullptr, nullptr, FALSE,
    EXTENDED_STARTUPINFO_PRESENT, nullptr, nullptr, (STARTUPINFO*)&si, &pi);
1
2
3
4
5
6
7
8

要指示使用属性列表,必须设置两件事:STARTUPINFOEX结构和EXTENDED_STARTUPINFO_PRESENT标志。如果没有后者,属性将不会应用。

最后一步是清理属性列表及其分配的内存:

::DeleteProcThreadAttributeList(attList);
::free(attList);
1
2

根据上述步骤,以下函数根据进程ID创建一个以另一个进程为父进程的指定进程:

DWORD CreateProcessWithParent(PWSTR name, DWORD parentPid) {
    HANDLE hParent = ::OpenProcess(PROCESS_CREATE_PROCESS, FALSE, parentPid);
    if (!hParent)
        return 0;

    PROCESS_INFORMATION pi = { 0 };
    PPROC_THREAD_ATTRIBUTE_LIST attList = nullptr;
    do {
        SIZE_T size = 0;
        ::InitializeProcThreadAttributeList(nullptr, 1, 0, &size);
        if (size == 0)
            break;

        attList = (PPROC_THREAD_ATTRIBUTE_LIST)malloc(size);
        if (!attList)
            break;

        if (!::InitializeProcThreadAttributeList(attList, 1, 0, &size))
            break;

        if (!::UpdateProcThreadAttribute(attList, 0, PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
            &hParent, sizeof(hParent), nullptr, nullptr))
            break;

        STARTUPINFOEX si = { sizeof(si) };
        si.lpAttributeList = attList;

        if (!::CreateProcess(nullptr, name, nullptr, nullptr, FALSE,
            EXTENDED_STARTUPINFO_PRESENT, nullptr, nullptr, (STARTUPINFO*)&si,
            &pi))
            break;

        ::CloseHandle(pi.hProcess);
        ::CloseHandle(pi.hThread);
    } while (false);

    ::CloseHandle(hParent);
    if (attList) {
        ::DeleteProcThreadAttributeList(attList);
        ::free(attList);
    }
    
    return pi.dwProcessId;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

大部分代码与使用属性的步骤相同,只是添加了错误处理和适当的清理操作。注意,使用PROCESS_CREATE_PROCESS访问掩码打开进程句柄。当使用PROC_THREAD_ATTRIBUTE_PARENT_PROCESS属性时,这是必需的。这意味着并非所有进程都可以随意作为父进程。

在记事本(Notepad)中运行此函数,并传入资源管理器(Explorer)进程的PID,成功按预期创建了记事本进程。在进程资源管理器(Process Explorer)中打开其属性,可以看到资源管理器被显示为父进程(图3-18)。

图3-18:更改父进程

再举一个例子,考虑以下应用进程安全缓解策略的函数:

DWORD CreateProcessWithMitigations(PWSTR name, DWORD64 mitigation) {
    PROCESS_INFORMATION pi = { 0 };
    PPROC_THREAD_ATTRIBUTE_LIST attList = nullptr;
    do {
        SIZE_T size = 0;
        ::InitializeProcThreadAttributeList(nullptr, 1, 0, &size);
        if (size == 0)
            break;

        attList = (PPROC_THREAD_ATTRIBUTE_LIST)malloc(size);
        if (!attList)
            break;

        if (!::InitializeProcThreadAttributeList(attList, 1, 0, &size))
            break;

        if (!::UpdateProcThreadAttribute(attList, 0, PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY,
            &mitigation, sizeof(mitigation), nullptr, nullptr))
            break;

        STARTUPINFOEX si = { sizeof(si) };
        si.lpAttributeList = attList;

        if (!::CreateProcess(nullptr, name, nullptr, nullptr, FALSE,
            EXTENDED_STARTUPINFO_PRESENT, nullptr, nullptr, (STARTUPINFO*)&si,
            &pi))
            break;

        ::CloseHandle(pi.hProcess);
        ::CloseHandle(pi.hThread);
    } while (false);

    if (attList) {
        ::DeleteProcThreadAttributeList(attList);
        ::free(attList);
    }
    return pi.dwProcessId;
}
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
30
31
32
33
34
35
36
37
38

除了属性不同,代码几乎完全相同。与PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY相关的值是一个DWORD或DWORD64类型,用于指示要应用于新进程的缓解措施。

有关进程缓解措施的完整讨论见第16章。 |

例如,使用以下参数调用此函数:

WCHAR name[] = L"notepad";
auto pid = CreateProcessWithMitigations(name,
    PROCESS_CREATION_MITIGATION_POLICY_WIN32K_SYSTEM_CALL_DISABLE_ALWAYS_ON);
1
2
3

这会导致记事本初始化失败并终止,并且会先显示图3-19中的消息框。原因是特定的缓解措施阻止了对Win32k.sys(窗口管理器)的调用,这本质上意味着User32.dll无法正确初始化。没有这个能力,记事本就无法正常运行。这种缓解措施适用于没有用户界面(UI)的进程,可以确保Win32k.sys的安全漏洞不会在这类进程中被利用。

图3-19:记事本初始化失败

# 受保护进程和受保护轻量级进程(PPL)

受保护进程是在Windows Vista中引入的,用于抵御数字版权管理(Digital Rights Management,DRM)侵权行为。即使是管理员级别的用户,也只能被授予对这些进程的特定访问权限。受保护进程仅允许以下访问掩码:PROCESS_QUERY_LIMITED_INFORMATION(进程查询有限信息)、PROCESS_SET_LIMITED_INFORMATION(进程设置有限信息)、PROCESS_SUSPEND_RESUME(进程暂停/恢复)和PROCESS_TERMINATE(进程终止)。

只有经过微软签名且具有特定扩展密钥用法(Extended Key Usage,EKU)的可执行文件,才被允许以受保护模式执行。

Windows 8.1引入了受保护轻量级进程(Protected Processes Light,PPL),它扩展了保护模型,包含多个保护级别。较高级别的受保护进程可以完全访问较低级别的进程,但反之则不行。通过这种扩展模型,现在第三方反恶意软件服务可以通过与微软协商并获得适当签名来运行。此外,一些PPL级别(如用于反恶意软件的级别)会拒绝PROCESS_TERMINATE访问权限,这样即使恶意软件拥有提升的权限,也无法停止或终止这些服务。表3-10列出了PPL签名者级别及其简要描述。

表3-10:PPL签名者

PPL签名者 级别 描述
WinSystem 7 系统和基本进程
WinTcb 6 关键Windows组件。拒绝PROCESS_TERMINATE权限
Windows 5 处理敏感数据的重要Windows组件
Lsa 4 Lsass.exe(如果配置为以受保护模式运行)
Antimalware 3 反恶意软件服务进程,包括第三方的。拒绝PROCESS_TERMINATE权限
CodeGen 2 .NET本机代码生成
Authenticode 1 承载DRM内容
None 0 无效(无保护)

表3-10中显示的级别(以及进程是“普通”受保护进程还是PPL进程)存储在内核进程对象中。

受保护/PPL进程允许的“有限”访问掩码用于设置/查询进程的表面信息,例如查询其启动时间、优先级类别或可执行文件路径。由于获取受保护进程内加载的模块列表需要PROCESS_QUERY_INFORMATION访问掩码,而该掩码是不被允许的,所以无法获取此列表。图3-20展示了在进程资源管理器(Process Explorer)中选中的Csrss.exe。注意,底部窗格中的模块列表为空。此外,还显示了“保护”列,其中的值来自表3-10中的签名者。

img

图3-20:受保护进程

图3-20还展示了微软自己的反恶意软件可执行文件(MsMpEng.exe和NisSrv.exe,即“Windows Defender”)与其他第三方反恶意软件服务一样,作为反恶意软件PPL运行。

受保护和PPL进程不能加载任意的DLL,这样可以防止受保护进程被诱骗加载不可信的DLL,避免这些DLL在进程的保护下运行。受保护/PPL进程加载的所有DLL都必须经过正确签名。

通过在CreateProcess中使用CREATE_PROTECTED_PROCESS标志,可以创建一个受保护进程。当然,这仅适用于经过正确签名的可执行文件。由于这种保护机制过于特殊,普通应用程序不会使用,因此本书不再进一步讨论。

你可以在《Windows Internals 7th edition Part 1》一书的第3章中找到关于受保护/PPL进程的更多信息。

# UWP进程

通用Windows平台(Universal Windows Platform,UWP)进程与其他标准进程类似。它使用Windows运行时平台/API来完成大部分工作,如用户界面(UI)、图形、网络、后台处理等。它的一些独特属性包括:

  • UWP进程始终在一个名为应用容器(AppContainer)的应用沙箱中运行,该沙箱限制了它的操作和访问权限(将在第16章“安全性”中详细讨论)。
  • UWP进程的状态由运行在Explorer.exe进程下的进程生命周期管理器(Process Lifetime Manager,PLM)管理。PLM可以根据进程的前台/后台活动和内存使用情况启动进程挂起、恢复和终止操作(将在第18章中进一步讨论)。
  • UWP应用包包含一组功能声明,即应用想要访问的内容(如摄像头、位置、图片文件夹),这些功能会在微软应用商店(Microsoft Store)中列出,以便用户决定是否下载该应用。
  • UWP进程默认是单实例的(从Windows 10版本1803开始支持多实例)。

从进程创建的角度来看,标准的CreateProcess调用无法创建UWP进程。这是因为UWP应用具有身份标识,而标准可执行文件没有。UWP应用被构建成一个包含可执行文件、库、资源文件以及应用正常执行所需的其他所有内容的应用包。这个应用包有一个全局唯一的名称,而创建UWP进程需要这个名称。

你可以在任务管理器(Task Manager)或进程资源管理器中通过添加“包名称”列来查看这个名称。

作为一个简单的示例,在Windows 10上运行计算器应用,然后使用进程资源管理器查看其属性(图3-21)。注意其命令行;忽略命令行的长度和复杂性,直接复制它,然后使用“开始”/“运行”并粘贴该命令行来启动另一个计算器应用。你会得到一个类似于图3-22的错误消息框。

img

图3-21:进程资源管理器中的计算器属性

img 图3-22:手动创建计算器时的错误消息框

图3-22中的错误消息似乎与任何内容都无关。缺少的信息是应用包的完整名称,它需要作为进程属性进行指定。不幸的是,这个特定属性没有文档记录,因此无法借助Windows头文件进行指定。有一种方法可以使用专为这个目的设计并通过COM接口(和类)公开的另一种创建机制来指定此参数。

图3-23所示的MetroManager应用程序列出了计算机上可用的UWP应用包,并允许用户启动任何选定的应用包。该应用展示了一些有趣的功能:

  • 从非UWP应用程序中使用Windows运行时API。
  • 枚举应用包。
  • 以文档记录的方式启动UWP进程。

img 图3-23:MetroManager应用程序

Windows运行时(Windows Runtime,WinRT)构建在COM之上,这意味着它利用了COM世界中的接口、类、类工厂、GUID和其他概念(尽管给了一些新名称)。Windows运行时支持高级语言中常见的实体,包括静态方法和泛型。我们将在第18章中探讨其工作原理。这里,我主要关注使用Windows运行时的机制。C++客户端可以通过以下几种方式使用Windows运行时API:

  1. 直接实例化适当的类,并使用低级对象工厂,直到创建实例,然后使用普通的COM调用。
  2. 使用Windows运行时库(Windows Runtime Library,WRL)的C++ 包装器和辅助函数。
  3. 使用C++/CX语言扩展,通过以非标准方式扩展C++ 来轻松访问WinRT。
  4. 使用CppWinRT库,它仅通过标准C++ 就可以相对轻松地访问WinRT API。

以上四种方式均得到官方支持。第一种方式最为繁琐,仅建议用于学习目的,因为它对开发者几乎没有隐藏细节,所以代码会非常冗长。第二种方式使用起来更轻松,但如今已不受青睐,甚至微软也不推荐。第三种方式最为简便,在WinRT早期也最为常用,但如今却不被看好,因为它要求开发者使用非标准的C++ 扩展。这样一来,就只剩下第四种方式,这是在C++ 中使用WinRT的推荐方式,因为它使用起来足够简便,同时仍采用标准的C++ 结构。

CppWinRT超出了本书的讨论范围,但我们会介绍一些基础知识,这些知识能让你有很大收获。首先,我们需要添加该库的Nuget包(图3-24)。接下来,我们要添加想要使用的WinRT命名空间的包含文件。以下内容被添加到预编译头文件(项目源代码中的pch.h)中:

img

图3-24:CppWinRT的Nuget包

更多关于CppWinRT的信息可在微软在线文档中获取。

#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.ApplicationModel.h>
#include <winrt/Windows.Management.Deployment.h>
#include <winrt/Windows.Storage.h>
1
2
3
4
5

这是CppWinRT头文件的一般格式:以winrt为前缀,后面跟着命名空间(可在WinRT文档中找到)。

没有winrt前缀的头文件是 “真正的” WinRT头文件,它们在内部被包含。在使用CppWinRT时,通常不需要这些头文件。

由于所有WinRT API都位于命名空间和嵌套命名空间中,类型名称会变得很长。在源文件中添加using namespace语句,或者在某些情况下,在函数中添加该语句,会更便于访问各种类型:

using namespace winrt;
using namespace winrt::Windows::Management::Deployment;
using namespace winrt::Windows::ApplicationModel;
1
2
3

winrt命名空间本身包含一些通用的CppWinRT辅助函数,所以使用它是必要的。其他命名空间的使用则取决于我们所使用的类型。

枚举UWP应用包是通过winrt::Windows::Management::Deployment命名空间中的PackageManager类来完成的:

auto packages = PackageManager().FindPackagesForUser(L"");
1

传递给用户的空字符串表示查找当前用户(系统中的其他用户可能安装了不同的应用包)。

在这里,auto关键字非常实用,因为实际返回的类型是IIterable<Package>(假设我们已经使用了命名空间,我对这两种类型都进行了简化)。关键在于,这个方法返回的是WinRT术语中的一个集合(IIterable<>)。由于CppWinRT提供了更多便利,任何这样的集合都可以使用C++ 增强的基于范围的for语句进行迭代:

for (auto package : packages) {
    auto item = std::make_shared<AppItem>();
    item->InstalledLocation = package.InstalledLocation().Path().c_str();
    item->FullName = package.Id().FullName().c_str();
    item->InstalledDate = package.InstalledDate();
    item->IsFramework = package.IsFramework();
    item->Name = package.Id().Name().c_str();
    item->Publisher = package.Id().Publisher().c_str();
    item->Version = package.Id().Version();

    m_AllPackages.push_back(item);
}
1
2
3
4
5
6
7
8
9
10
11
12

AppItem是应用定义的一个普通C++ 类,用于以普通C++ 类型而非WinRT类型存储信息,在处理字符串等情况时,这样做更合理:

struct AppItem {
    CString Name, Publisher, InstalledLocation, FullName;
    winrt::Windows::ApplicationModel::PackageVersion Version;
    winrt::Windows::Foundation::DateTime InstalledDate;
    bool IsFramework;
};
1
2
3
4
5
6

标准的Windows运行时字符串类型为HSTRING,它是一个存储了长度的不可变UTF - 16字符数组。

AppItem中存储的数据用于在应用的列表视图中显示信息。

运行UWP应用包是通过以下C++ 中的COM(而非WinRT)接口来实现的:

struct IApplicationActivationManager : public IUnknown {
    virtual HRESULT stdcall ActivateApplication(
        /*  [in]  */  LPCWSTR appUserModelId,
        /*  [unique][in]  */  LPCWSTR arguments,
        /*  [in]  */ ACTIVATEOPTIONS options,
        /*  [out]  */  DWORD *processId) = 0;
    virtual HRESULT stdcall ActivateForFile(
        /*  [in]  */  LPCWSTR appUserModelId,
        /*  [in]  */  IShellItemArray *itemArray,
        /*  [unique][in]  */  LPCWSTR verb,
        /*  [out]  */  DWORD *processId) = 0;
    virtual HRESULT stdcall ActivateForProtocol(
        /*  [in]  */  LPCWSTR appUserModelId,
        /*  [in]  */  IShellItemArray *itemArray,
        /*  [out]  */  DWORD *processId) = 0;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

“激活” UWP应用有多种方式,使用一种称为契约(contracts)的机制,其中一种契约称为Launch,它自然是用于启动应用的。ActivateApplication使用的就是Launch契约,而其他函数使用的是不同的契约。在这个应用中,我们仅使用Launch契约。

启动应用程序的完整代码位于CView::RunApp成员函数中。首先,我们需要使用其唯一的包全名(存储在前面展示的AppItem结构中)来查找有关某个包的信息。以下是首次调用:

bool CView::RunApp(PCWSTR fullPackageName) {
    PACKAGE_INFO_REFERENCE pir;
    int error = ::OpenPackageInfoByFullName(fullPackageName, 0, &pir);
    if (error != ERROR_SUCCESS)
        return false;
1
2
3
4
5

OpenPackageInfoByFullName函数返回一个指向内部数据结构的不透明指针,该数据结构保存了所请求包的相关信息。遗憾的是,仅有包全名是不够的,因为理论上一个包可以包含多个应用程序(目前尚未支持这种情况),所以需要从包中提取另一个应用程序ID:

UINT32 len = 0;
error = ::GetPackageApplicationIds(pir, &len, nullptr, nullptr);
if (error != ERROR_INSUFFICIENT_BUFFER)
    break;
auto buffer = std::make_unique<BYTE[]>(len);
UINT32 count;
error = ::GetPackageApplicationIds(pir, &len, buffer.get(), &count);
if (error != ERROR_SUCCESS)
    break;
1
2
3
4
5
6
7
8
9

这一过程分两步完成:首先调用GetPackageApplicationIds函数,将应用程序ID参数设为NULL指针,长度设为零。这会使该函数填充所需的长度。然后,使用make_unique创建一个缓冲区(确保在变量超出作用域时自动销毁),并进行第二次调用。

返回的应用程序ID存储时,前面有一个4字节的长度,后面跟着数据本身。由于预期只有一个应用程序,我们可以跳过前4个字节,将其余部分用作应用程序ID。最后一步是创建实现IApplicationActivationManager接口的实例,并使用应用程序ID调用ActivateApplication函数:

CComPtr<IApplicationActivationManager> mgr;
auto hr = mgr.CoCreateInstance(CLSID_ApplicationActivationManager);
if (FAILED(hr))
    break;
DWORD pid;
hr = mgr->ActivateApplication((PCWSTR)(buffer.get() + sizeof(ULONG_PTR)),
    nullptr, AO_NOERRORUI, &pid);
1
2
3
4
5
6
7

ActivateApplication函数甚至还会返回所创建进程的进程ID。最后,需要释放包信息数据:

::ClosePackageInfo(pir);
1

如果你查看任何通用Windows平台(UWP,Universal Windows Platform )进程的父进程,你会发现它是一个Svchost.exe进程,而不是直接创建者(见图3-22)。这是因为UWP进程实际上是由DCOM启动服务(DCOM Launch service)启动的,该服务托管在一个服务主机中(见图3-25)。

img 图3-25:DCOM启动服务

# 最小进程(Minimal Processes)和Pico进程(Pico Processes)

最小进程仅包含一个用户模式地址空间。内存压缩(Memory Compression)和注册表(Registry)是最小进程的典型示例。最小进程只能由内核创建,因此本书不再进一步讨论。

Pico进程是一种特殊的最小进程:它添加了一个Pico提供程序,这是一个内核驱动程序,负责将Linux系统调用转换为等效的Windows系统调用。这是Windows 10版本1607(及更高版本)和Windows 2016(及更高版本)中可用的Windows子系统 for Linux(WSL,Windows Subsystem for Linux )的基础。尽管我未来可能会发布一篇关于WSL的专题章节,但Pico进程超出了本书的范围。

# 进程终止

大多数进程会在系统关机前的某个时刻终止。进程可能通过几种方式退出或终止。需要记住的一点是,无论进程如何终止,内核都会确保进程的私有内容不会残留:所有私有(非共享)内存都会被释放,进程句柄表中的所有句柄都会被关闭。当满足以下任何一个条件时,进程就会终止:

  1. 进程中的所有线程都退出或终止。
  2. 进程中的任何线程调用ExitProcess函数。
  3. 进程被TerminateProcess函数终止(通常是外部终止,但也可能是因为未处理的异常)。

编写Windows应用程序的人通常会在某个时候发现,执行主函数的线程是 “特殊的”,通常称为主线程。可以观察到,每当主函数返回时,进程就会退出。这似乎不在上述进程退出原因之列。然而,它其实属于,并且是第2种情况。C/C++运行时库调用main/WinMain(本章前面 “进程创建” 部分有讨论),然后执行所需的清理工作,例如调用全局C++析构函数、C运行时清理等,最后,它会调用ExitProcess函数,从而导致进程退出。

从内核的角度来看,进程中的所有线程都是平等的,不存在主线程。当进程中的所有线程都退出/终止时,内核会销毁该进程,因为没有线程的进程大多是无用的。实际上,这种情况只在原生进程(仅依赖NtDll.dll且没有C/C++运行时的可执行文件)中才会出现。换句话说,在正常的Windows编程中不太可能发生这种情况。

ExitProcess函数的定义如下:

void ExitProcess(_In_ UINT exitCode);
1

只有调用进程能够调用ExitProcess函数,当然,这个函数永远不会返回。外部进程可以尝试使用TerminateProcess函数(稍后讨论)来终止一个进程。退出代码会成为进程的退出代码,任何持有该进程句柄的人都可以使用GetExitCodeProcess函数读取,该函数定义如下:

BOOL GetExitCodeProcess(
    _In_ HANDLE hProcess,
    _Out_ LPDWORD lpExitCode);
1
2
3

进程退出后仍可获取其退出代码,这可能看起来很奇怪,但由于进程的句柄仍然打开,内核进程结构仍然存在,而退出代码就存储在其中。如果对一个正在运行的进程调用GetExitCodeProcess函数会怎样呢?你可能会认为该函数会失败,但令人困惑的是,它会成功并返回一个名为STILL_ACTIVE(0x103)的退出代码。

在内核中,STILL_ACTIVE被称为STATUS_PENDING,在这种情况下表示进程仍在运行。

最后一句话的意思是,查看退出代码并不能100% 确定一个进程是否仍在运行。正确的做法是调用WaitForSingleObject(hProcess, 0),并将返回值与WAIT_OBJECT_0进行比较;如果相等,则进程已终止;因为至少有一个打开的进程句柄,所以只有内核管理对象仍然存在。

ExitProcess函数会以有序的方式关闭进程,执行以下重要操作:

  1. 终止进程中的所有其他线程。
  2. 使用原因值PROCESS_DLL_DETACH调用进程中所有DLL的DllMain函数,表明该DLL即将被卸载,应该进行清理工作。
  3. 终止进程和调用线程(ExitProcess函数永远不会返回)。

进程可能终止的第三种方式是因为调用了TerminateProcess函数,其定义如下:

BOOL TerminateProcess(
    _In_ HANDLE hProcess,
    _In_ UINT   uExitCode);
1
2
3

假设能够获得具有PROCESS_TERMINATE访问掩码的句柄,就可以从进程外部调用TerminateProcess函数。该函数会立即终止进程,并指定其退出值。相关进程对此没有发言权。

TerminateProcess函数与ExitProcess函数在一个重要方面有所不同:目标进程中所有DLL的DllMain函数不会被调用,因此无法执行任何清理操作。这可能会导致功能丧失或数据丢失。例如,如果一个DLL在卸载时会将一些信息写入日志文件,那么它将没有机会这样做。显然,TerminateProcess函数应该作为最后的手段使用。

任务管理器 “详细信息” 选项卡中的 “结束任务” 按钮,如果能够使用PROCESS_TERMINATE访问掩码打开进程句柄,就会调用TerminateProcess函数。“进程” 选项卡中的 “结束任务” 按钮则更为复杂,对于图形用户界面(GUI,Graphical User Interface )进程,它首先会尝试通过向其主窗口发送关闭消息(使用SendMessage或PostMessage函数,消息类型为WM_CLOSE)来请求进程正常退出。

# 枚举进程

在某些情况下,枚举现有进程是有益的。一个可能的原因是查找特定感兴趣的进程。另一个原因可能是创建某种工具,用于提供有关现有进程的信息。像任务管理器和进程资源管理器这样的知名工具都使用进程枚举。

Windows API提供了三种有文档记录的枚举进程的方法。我们将研究所有这些方法,然后简要讨论第四种半文档化的选项。

# 使用EnumProcesses函数

最简单易用的函数是EnumProcesses,它属于所谓的进程状态API(PSAPI,Process Status API ),包含在<psapi.h>头文件中:

BOOL EnumProcesses(
    _Out_ DWORD *pProcessIds,
    _In_  DWORD cb,
    _Out_ DWORD *pBytesReturned);
1
2
3
4

这个函数仅提供最基本的信息 —— 所有进程的ID。调用者必须分配一个足够大的缓冲区来存储所有进程ID。返回时,该函数会指示实际存储在提供的缓冲区中的字节数。如果这个数字小于缓冲区大小,意味着缓冲区足够大,可以容纳所有进程ID。如果它等于缓冲区大小,那么缓冲区可能太小,调用者应该使用更大的缓冲区大小进行第二次调用,直到满足第一种情况。

最简单的方法是使用一个(希望)足够大的缓冲区调用该函数:

const int MaxCount = 1024;
DWORD pids[MaxCount];
DWORD actualSize;
if (::EnumProcesses(pids, sizeof(pids), &actualSize)) {
    // 假设actualSize < sizeof(pids)
    int count = actualSize / sizeof(DWORD);
    for(int i = 0; i < count; i++) {
        // 对pids[i]做一些操作
    }
}
1
2
3
4
5
6
7
8
9
10

一种更保守的方法是动态分配进程ID数组,以便在需要时可以调整大小。一种相对简单的方法是利用C++的std::unique_ptr<>模板类。以下是修改后的示例:

int maxCount = 256;
std::unique_ptr<DWORD[]> pids;
int count = 0;
for ( ; ; ) {
    pids = std::make_unique<DWORD[]>(maxCount);
    DWORD actualSize;
    if (!::EnumProcesses(pids.get(), maxCount * sizeof(DWORD), &actualSize))
        break;
    
    count = actualSize / sizeof(DWORD);
    if (count < maxCount)
        break;
    // 需要调整大小
    maxCount *= 2;
}
for (int i = 0; i < count; i++) {
    // 对pids[i]做一些操作
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

请记住,为了使上述代码能够编译,需要添加#include <psapi.h>。还需要#include <memory>,以便可以使用unique_ptr<>。

如果你更倾向于使用经典的C++甚至C语言,当然也可以通过使用new和delete运算符,或者malloc和free函数来实现。我建议使用现代C++方法,因为它更不容易出错,因为在相关对象被销毁时,分配的内存会自动释放。

EnumProcesses函数的缺点是它提供的信息极少,仅为每个进程的进程ID。如果需要关于进程的任何其他信息(通常是这种情况),则需要再次调用OpenProcess函数来获取每个感兴趣进程的句柄,然后进行适当的调用以检索信息或对进程执行所需的操作。当然,OpenProcess函数可能会失败,因为并非每个进程都一定能够获取到所需的访问掩码。

以下代码片段展示了在成功调用EnumProcesses函数后,如何获取进程的映像名称及其启动时间:

// count是进程数量
for (int i = 0; i < count; i++) {
    DWORD pid = pids[i];
    HANDLE hProcess = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
    if (!hProcess) {
        printf("Failed to open a handle to process %d (error=%d)\n",
            pid, ::GetLastError());
        continue;
    }
    
    FILETIME start = { 0 }, dummy;
    ::GetProcessTimes(hProcess, &start, &dummy, &dummy, &dummy);
    SYSTEMTIME st;
    ::FileTimeToLocalFileTime(&start, &start);
    ::FileTimeToSystemTime(&start, &st);
    WCHAR exeName[MAX_PATH];
    DWORD size = MAX_PATH;
    DWORD count = ::QueryFullProcessImageName(hProcess, 0, exeName, &size);
    printf("PID: %5d, Start: %d/%d/%d %02d:%02d:%02d Image: %ws\n",
        pid, st.wDay, st.wMonth, st.wYear, st.wHour, st.wMinute, st.wSecond,
        count > 0 ? exeName : L"Unknown");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

OpenProcess函数用于获取具有PROCESS_QUERY_LIMITED_INFORMATION访问掩码的进程句柄,这是你可以请求的最低权限。这足以获取进程的一些基本信息,例如其启动时间(GetProcessTimes函数)或映像文件名(QueryFullProcessImageName函数)。

# 获取进程时间(GetProcessTimes)的原型如下:

BOOL GetProcessTimes(
    _In_ HANDLE hProcess,
    _Out_ LPFILETIME lpCreationTime,
    _Out_ LPFILETIME lpExitTime,
    _Out_ LPFILETIME lpKernelTime,
    _Out_ LPFILETIME lpUserTime
);
1
2
3
4
5
6
7

FILETIME 是一个64位值,它被拆分为两个32位值。创建时间和退出时间以100纳秒为单位,从协调世界时(UTC)1601年1月1日午夜开始计算。使用 FileTimeToSystemTime 函数可以将创建时间转换为更易于管理的形式,该函数会将64位值拆分为人类可读的部分(日、月、年等)。

内核时间和用户时间是相对的,同样以100纳秒为单位。在上述代码中没有使用它们,只是将结果存储在一个临时变量中。请注意,如果任何一个参数为 NULL,该函数将引发访问冲突异常。

QueryFullProcessImageName 函数用于获取给定进程的完整可执行文件路径,其定义如下:

BOOL QueryFullProcessImageName(
    _In_ HANDLE hProcess,
    _In_ DWORD dwFlags,
    _Out_ LPTSTR lpExeName,
    _Inout_ PDWORD lpdwSize
);
1
2
3
4
5
6

dwFlags 参数通常为零,但也可以是 PROCESS_NAME_NATIVE(值为1),此时返回的路径为设备形式,这是Windows表示路径的原生方式(类似 \Device\HarddiskVolume3\MyDir\MyApp.exe)。我们将在第11章更深入地探讨这种形式。lpExeName 参数是由调用者分配的缓冲区,最后一个参数是指向缓冲区大小(以字符为单位)的指针。这是一个输入/输出参数,因此必须初始化为已分配的缓冲区大小,函数会将其更改为写入缓冲区的实际字符数。

使用标准用户权限运行这段代码会产生如下输出:

Failed to get a handle to process 0 (error=87)
Failed to get a handle to process 4 (error=5)
Failed to get a handle to process 88 (error=5)
Failed to get a handle to process 152 (error=5)
Failed to get a handle to process 900 (error=5)
Failed to get a handle to process 956 (error=5)
Failed to get a handle to process 1212 (error=5)
...
PID: 9796, Start: 26/7/2019 14:13:40 Image: C:\Windows\System32\sihost.exe
PID: 9840, Start: 26/7/2019 14:13:40 Image: C:\Windows\System32\svchost.exe
Failed to get a handle to process 9864 (error=5)
PID: 9904, Start: 26/7/2019 14:13:40 Image: C:\Windows\System32\svchost.exe
PID: 9936, Start: 26/7/2019 14:13:40 Image: C:\Windows\System32\svchost.exe
PID: 10004, Start: 26/7/2019 14:13:40 Image: C:\Windows\System32\taskhostw.exe
Failed to get a handle to process 10032 (error=5)
PID: 9556, Start: 26/7/2019 14:13:40 Image: C:\Windows\explorer.exe
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

获取进程ID为0(任务管理器中的系统空闲进程)的句柄会失败,因为进程ID 0无效。这就是错误代码为87(ERROR_INVALID_PARAMETER)的原因。低进程ID范围内的其他进程也无法打开,错误代码为5(ERROR_ACCESS_DENIED)。

通过打开提升权限的命令窗口,导航到输出目录并运行可执行文件,以管理员权限运行相同的代码会产生如下输出:

Failed to get a handle to process 0 (error=87)
PID:     4, Start: 26/7/2019 14:13:20 Image: Unknown
PID:    88, Start: 26/7/2019 14:13:01 Image: Unknown
PID:   152, Start: 26/7/2019 14:13:01 Image: Unknown
PID:  900, Start: 26/7/2019 14:13:20 Image: C:\Windows\System32\smss.exe
Failed to get a handle to process 956 (error=5)
PID:  1212, Start: 26/7/2019 14:13:33 Image: C:\Windows\System32\wininit.exe
Failed to get a handle to process 1220 (error=5)
PID:  1288, Start: 26/7/2019 14:13:33 Image: C:\Windows\System32\services.exe
PID:  1300, Start: 26/7/2019 14:13:33 Image: C:\Windows\System32\LsaIso.exe
PID:  1316, Start: 26/7/2019 14:13:33 Image: C:\Windows\System32\lsass.exe
...
1
2
3
4
5
6
7
8
9
10
11
12

这里有两点需要注意。第一,与使用标准用户权限相比,我们成功打开了更多进程的句柄。第二,对于某些进程,我们无法检索到其映像名称。

这是因为它们没有常规的可执行文件名。这些是一些 “特殊” 进程:4(系统)、88(安全系统)、152(注册表),以及后面(未显示)的内存压缩进程。显然,QueryFullProcessImageName 函数无法提供这些进程的名称。

即使拥有管理员权限,我们似乎也无法打开所有可能的进程。通过启用调试权限(该权限默认存在于管理员令牌中,但默认未启用),这种情况可以得到改善。有趣的是,如果直接从Visual Studio(以提升权限运行)运行代码,你会得到与启用调试权限相同的效果,因为Visual Studio已经启用了其调试权限,并且由于它是启动进程的程序,其访问令牌会被复制到新进程中,所以调试权限已经启用。

为确保无论从何处启动可执行文件,调试权限都能启用,我们可以使用以下函数:

bool EnableDebugPrivilege() {
    wil::unique_handle hToken;
    if (!::OpenProcessToken(::GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES,
        hToken.addressof()))
        return false;

    TOKEN_PRIVILEGES tp;
    tp.PrivilegeCount = 1;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
    if (!::LookupPrivilegeValue(nullptr, SE_DEBUG_NAME, &tp.Privileges[0].Luid))
        return false;

    if (!::AdjustTokenPrivileges(hToken.get(), FALSE, &tp, sizeof(tp),
        nullptr, nullptr))
        return false;

    return ::GetLastError() == ERROR_SUCCESS;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

完整的项目在示例存储库中名为 ProcEnum。

该函数的详细解释将在第16章(“安全”)中给出。目前,我们可以直接使用它并获得更好的结果:

Failed to get a handle to process 0 (error=87)
|PID:|4, Start: 26/7/2019|14:13:20|Image: Unknown|
|---|---|---|---|
|PID:|88, Start: 26/7/2019|14:13:01|Image: Unknown|
|PID:|152, Start: 26/7/2019|14:13:01|Image: Unknown|
|PID:|900, Start: 26/7/2019|14:13:20|Image: C:\Windows\System32\smss.exe|
|PID:|956, Start: 26/7/2019|14:13:30|Image: C:\Windows\System32\csrss.exe|
|PID:|1212, Start: 26/7/2019|14:13:33|Image: C:\Windows\System32\wininit.exe|
|PID:|1220, Start: 26/7/2019|14:13:33|Image: C:\Windows\System32\csrss.exe|
|PID:|1288, Start: 26/7/2019|14:13:33|Image: C:\Windows\System32\services.exe|
|PID:|1300, Start: 26/7/2019|14:13:33|Image: C:\Windows\System32\LsaIso.exe|
|PID:|1316, Start: 26/7/2019|14:13:33|Image: C:\Windows\System32\lsass.exe|
|PID:|...|1436, Start: 26/7/2019|14:13:33|Image: C:\Windows\System32\svchost.exe|
1
2
3
4
5
6
7
8
9
10
11
12
13

现在,我们可以打开每个进程的句柄(当然,进程ID为0的不是实际进程,无法打开)。

我们仍然无法获取特殊进程的名称。下一种进程枚举技术可以解决这个问题。

# 使用工具帮助函数(Toolhelp Functions)

所谓的 “工具帮助” 函数提供了一种更便捷的方式来获取进程的基本信息,包括那些并非基于可执行映像的特殊进程的 “名称”。所有这些操作在标准用户权限的进程中即可完成,无需提升权限。

要使用这些函数,需要包含 <tlhelp32.h> 头文件。首先要调用的函数是 CreateToolhelp32Snapshot,它可以创建一个包含进程和线程(可选组合)的快照,对于特定进程,还可以包含堆和模块信息。以下是创建仅获取进程信息的快照的调用:

HANDLE hSnapshot = ::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE) {
    // 处理错误
}
1
2
3
4

CreateToolhelp32Snapshot 的第二个参数用于指定在请求模块或堆信息时,哪个进程是快照的目标。对于进程和线程,该参数必须为零,此时所有进程/线程都会包含在快照中。

现在,进程枚举从 Process32First 函数开始,后续进程可以通过调用 Process32Next 函数获取,直到 Process32Next 返回 FALSE,表示没有更多进程。这两个函数都接受一个 PROCESSENTRY32 结构指针,每个进程的信息会通过该指针返回。

typedef struct tagPROCESSENTRY32 {
    DWORD dwSize;    			// 结构大小
    DWORD cntUsage;  			// 未使用
    DWORD th32ProcessID;  		// 进程ID(PID)
    ULONG_PTR th32DefaultHeapID;// 未使用
    DWORD th32ModuleID;  		// 未使用
    DWORD cntThreads;  			// 线程数
    DWORD th32ParentProcessID;  // 父进程ID(PPID)
    LONG pcPriClassBase;  		// 基本优先级
    DWORD dwFlags;  			// 未使用
    TCHAR szExeFile[MAX_PATH];  // 路径
} PROCESSENTRY32;
1
2
3
4
5
6
7
8
9
10
11
12

该结构的第一个成员(dwSize)必须设置为结构的大小。从注释中可以看出,实际上只有部分成员会被使用。以下代码展示了如何获取进程快照提供的所有可能信息:

PROCESSENTRY32 pe;
pe.dwSize = sizeof(pe);
if (!::Process32First(hSnapshot, &pe)) {
    // 不太可能 - 处理错误
}

do {
    printf("PID:%6d (PPID:%6d): %ws (Threads=%d) (Priority=%d)\n",
        pe.th32ProcessID, pe.th32ParentProcessID, pe.szExeFile,
        pe.cntThreads, pe.pcPriClassBase);
} while (::Process32Next(hSnapshot, &pe));

::CloseHandle(hSnapshot);
1
2
3
4
5
6
7
8
9
10
11
12
13

本章示例中的 ProcList 项目包含完整代码。以下是输出的前几行:

PID:    0 (PPID:     0): [System Process] (Threads=12) (Priority=0)
PID:    4 (PPID:     0): System (Threads=359) (Priority=8)
PID:   88 (PPID:     4): Secure System (Threads=0) (Priority=8)
PID:  152 (PPID:     4): Registry (Threads=4) (Priority=8)
PID:  900 (PPID:     4): smss.exe (Threads=2) (Priority=11)
PID:  956 (PPID:   932): csrss.exe (Threads=13) (Priority=13)
PID:  1212 (PPID:   932): wininit.exe (Threads=2) (Priority=13)
PID:  1220 (PPID:  1204): csrss.exe (Threads=27) (Priority=13)
1
2
3
4
5
6
7
8

# 使用Windows终端服务(WTS)函数

Windows终端服务(WTS,Windows Terminal Services)函数用于在终端服务(也称为远程桌面服务)环境中工作,在这种环境下,一台服务器可以同时承载多个远程(和本地)会话。也就是说,WTS API在单会话机器上的使用方式与多会话机器相同。该API中有几个有趣的函数,但就本节的目的而言,我们将使用其进程枚举函数:WTSEnumerateProcesses 和 WTSEnumerateProcessesEx。WTS API在其自己的头文件(默认情况下,<windows.h> 不会包含该头文件)<wtsapi32.h> 中定义。它还需要添加导入库 wtsapi32.lib 才能成功链接。

WTSEnumerateProcesses 的定义如下:

typedef struct _WTS_PROCESS_INFO {
    DWORD SessionId;
    DWORD ProcessId;
    LPTSTR pProcessName;
    PSID pUserSid;
} WTS_PROCESS_INFO, *PWTS_PROCESS_INFO;

BOOL WTSEnumerateProcesses(
    _In_ HANDLE hServer,
    _In_ DWORD Reserved,
    _In_ DWORD Version,
    _Out_ PWTS_PROCESS_INFO *ppProcessInfo,
    _Out_ DWORD *pCount
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14

该函数可以通过调用 WTSOpenServer 获取的第一个句柄来枚举其他机器上的进程。如果要使用本地机器,可以使用常量 WTS_CURRENT_SERVER_HANDLE 代替。Version 参数必须设置为1,实际结果由函数本身分配和填充,返回一个指向 WTS_PROCESS_INFO 类型结构数组的指针,每个结构对应一个发现的进程。最后一个参数返回返回数组中的进程数量。由于函数分配了内存,客户端应用程序最终必须使用 WTSFreeMemory 释放该内存。

以下函数是 ProcList2 项目的一部分,它使用 WTSEnumerateProcesses 函数显示系统中所有进程的信息:

bool EnumerateProcesses1() {
    PWTS_PROCESS_INFO info;
    DWORD count;
    if (!::WTSEnumerateProcesses(WTS_CURRENT_SERVER_HANDLE, 0, 1, &info, &count))
        return false;

    for (DWORD i = 0; i < count; i++) {
        auto pi = info + i;
        printf("\nPID: %5d (S: %d) (User: %ws) %ws",
            pi->ProcessId, pi->SessionId,
            (PCWSTR)GetUserNameFromSid(pi->pUserSid), pi->pProcessName);
    }

    ::WTSFreeMemory(info);

    return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

每个进程返回的信息相当有限,包括进程ID、会话ID、进程名称(可执行文件名称或特殊进程名称,如“System”)以及运行该进程的用户的安全标识符(Security Identifier,SID)。上述函数会显示所有可用信息,并使用一个辅助函数将SID(这是一个二进制数据块,将在第16章讨论)转换为人类可读的名称:

CString GetUserNameFromSid(PSID sid) {
    if (sid == nullptr)
        return L"";
    
    WCHAR name[128], domain[64];
    DWORD len = _countof(name);
    DWORD domainLen = _countof(domain);
    SID_NAME_USE use;
    if (!::LookupAccountSid(nullptr, sid, name, &len, domain, &domainLen, &use))
        return L"";
    
    return CString(domain) + L"\\" + name;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

为了正确链接,必须在项目/属性中把wtsapi32.lib添加到链接器的附加依赖项中,或者也可以在源代码中使用适当的#pragma指令添加:

#pragma comment(lib, "wtsapi32")
1

以标准用户权限运行调用EnumerateProcesses1的应用程序,输出结果如下:

PID:     0 (S: 0) (User: )
PID:     4 (S: 0) (User: ) System
PID:    88 (S: 0) (User: ) Secure System
PID:   152 (S: 0) (User: ) Registry
PID:   812 (S: 0) (User: ) smss.exe
PID:  1004 (S: 0) (User: ) csrss.exe
...
PID:  8904 (S: 1) (User: VOYAGER\Pavel) nvcontainer.exe
PID:  8912 (S: 1) (User: VOYAGER\Pavel) nvcontainer.exe
PID:  8992 (S: 1) (User: VOYAGER\Pavel) sihost.exe
PID:  9040 (S: 1) (User: VOYAGER\Pavel) svchost.exe
PID:  9104 (S: 0) (User: ) PresentationFontCache.exe
...
1
2
3
4
5
6
7
8
9
10
11
12
13

就WTS API而言,PID为0的空闲进程没有名称。如前所述,这不是一个真正的进程,所以无论如何,任何名称都是虚构的。在所有非运行用户进程中,API提供的SID均为NULL。为了充分利用此函数,包括获取SID,我们可以在提升权限的命令窗口中运行它(或者以管理员身份启动Visual Studio并在集成开发环境中执行)。这样得到的结果会更好:

PID:     0 (S: 0) (User: )
PID:     4 (S: 0) (User: ) System
PID:    88 (S: 0) (User: NT AUTHORITY\SYSTEM) Secure System
PID:   152 (S: 0) (User: NT AUTHORITY\SYSTEM) Registry
PID:   812 (S: 0) (User: NT AUTHORITY\SYSTEM) smss.exe
PID:  1004 (S: 0) (User: NT AUTHORITY\SYSTEM) csrss.exe
...
PID:  1360 (S: 0) (User: Font Driver Host\UMFD-0) fontdrvhost.exe
PID:  1388 (S: 0) (User: NT AUTHORITY\LOCAL SERVICE) WUDFHost.exe
PID:  1492 (S: 0) (User: NT AUTHORITY\NETWORK SERVICE) svchost.exe
PID:  1540 (S: 0) (User: NT AUTHORITY\SYSTEM) svchost.exe
PID:  1608 (S: 0) (User: NT AUTHORITY\LOCAL SERVICE) WUDFHost.exe
PID:  1684 (S: 1) (User: NT AUTHORITY\SYSTEM) winlogon.exe
PID:  1760 (S: 1) (User: Font Driver Host\UMFD-1) fontdrvhost.exe
...
PID:  8396 (S: 0) (User: NT AUTHORITY\NETWORK SERVICE) svchost.exe
PID:  8904 (S: 1) (User: VOYAGER\Pavel) nvcontainer.exe
PID:  8912 (S: 1) (User: VOYAGER\Pavel) nvcontainer.exe
PID:  8992 (S: 1) (User: VOYAGER\Pavel) sihost.exe
PID: 9040 (S: 1) (User: VOYAGER\Pavel) svchost.exe
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

WTSEnumerateProcessesEx API(从Windows 7开始可用)是WTSEnumerateProcesses的扩展版本,它为每个进程提供了更多信息:

BOOL WTSEnumerateProcessesEx(
    _In_    HANDLE hServer,
    _Inout_ DWORD  *pLevel,
    _In_    DWORD  SessionID,
    _Out_   PTSTR *pProcessInfo,
    _Out_   DWORD  *pCount);
1
2
3
4
5
6

pLevel参数可以设置为0或1。当设置为1时,会返回一个扩展结构数组,每个进程都有更多信息:

typedef struct _WTS_PROCESS_INFO_EX {
    DWORD       SessionId;
    DWORD       ProcessId;
    LPTSTR      pProcessName;
    PSID        pUserSid;
    DWORD       NumberOfThreads;
    DWORD       HandleCount;
    DWORD       PagefileUsage;
    DWORD       PeakPagefileUsage;
    DWORD       WorkingSetSize;
    DWORD       PeakWorkingSetSize;
    LARGE INTEGER UserTime;
    LARGE INTEGER KernelTime;
} WTS_PROCESS_INFO_EX, *PWTS_PROCESS_INFO_EX;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

SessionID参数允许枚举特定感兴趣会话中的进程,但也可以提供WTS_ANY_SESSION来表示对所有会话都感兴趣。

ppProcessInfo的类型是指向字符串指针的指针,这没有意义。它必须是基于level值指向PWTS_PROCESS_INFO或PWTS_PROCESS_INFO_EX的指针。以下是一个修订后的枚举函数,它使用了扩展结构:

bool EnumerateProcesses2() {
    PWTS_PROCESS_INFO_EX info;
    DWORD count;
    DWORD level = 1;   // 扩展信息
    if (!::WTSEnumerateProcessesEx(WTS_CURRENT_SERVER_HANDLE, &level,
        WTS_ANY_SESSION, (PWSTR*)&info, &count))
        return false;
    
    for (DWORD i = 0; i < count; i++) {
        auto pi = info + i;
        printf("\nPID: %5d (S: %d) (T: %3d) (H: %4d) (CPU: %ws) (U: %ws) %ws",
            pi->ProcessId, pi->SessionId, pi->NumberOfThreads, pi->HandleCount,
            (PCWSTR)GetCpuTime(pi),
            (PCWSTR)GetUserNameFromSid(pi->pUserSid), pi->pProcessName);
    }
    
    ::WTSFreeMemoryEx(WTSTypeProcessInfoLevel1, info, count);
    return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

WTS_PROCESS_INFO_EX中与内存相关的字段(PagefileUsage、PeakPagefileUsage、WorkingSetSize和PeakWorkingSetSize)实际上存在缺陷,因为即使在64位进程中,它们的大小也是32位的(该结构至少应该将DWORD改为DWORD_PTR)。所以,如果这些计数器中的任何一个超过4GB,它们显示的数字就会错误。

必须使用专门的函数(WTSFreeMemoryEx)并结合适当的相关枚举级别来释放分配的内存块。与EnumerateProcesses1相比,这段代码显示了一些额外的数据,如进程中的线程数、句柄数以及使用的总CPU时间。GetCpuTime辅助函数返回CPU时间的字符串表示形式:

CString GetCpuTime(PWTS_PROCESS_INFO_EX pi) {
    auto totalTime = pi->KernelTime.QuadPart + pi->UserTime.QuadPart;
    return CTimeSpan(totalTime / 10000000LL).Format(L"%D:%H:%M:%S");
}
1
2
3
4

WTS_PROCESS_INFO_EX的KernelTime和UserTime成员分别是进程线程在内核模式和用户模式下花费的时间。这个时间以100纳秒为单位提供(100乘以10的 -9次方),这在Windows API中非常常见。这意味着转换为秒需要除以1000万。上述代码使用了ATL辅助类CTimeSpan,它接受秒数并可以对该值进行简单的字符串格式化。

以下是使用管理员权限运行EnumerateProcesses2的一些示例输出:

PID: 0 (S: 0) (T: 12) (H: 0) (CPU: 7:16:22:09) (User: )
PID: 4 (S: 0) (T: 365) (H: 27610) (CPU: 0:00:14:39) (User: ) System
PID: 88 (S: 0) (T: 0) (H: 0) (CPU: 0:00:00:00) (User: NT AUTHORITY\SYST\
EM) Secure System
PID: 152 (S: 0) (T: 4) (H: 0) (CPU: 0:00:00:01) (User: NT AUTHORITY\SYST\
EM) Registry
PID: 812 (S: 0) (T: 2) (H: 53) (CPU: 0:00:00:00) (User: NT AUTHORITY\SYST\
EM) smss.exe
PID: 1004 (S: 0) (T: 14) (H: 951) (CPU: 0:00:00:05) (User: NT AUTHORITY\SYST\
EM) csrss.exe
...
1
2
3
4
5
6
7
8
9
10
11

# 使用原生API(Native API)

用于进程(和线程)枚举的最后一个选项是使用NtDll.dll公开的原生API。这个API大多没有文档记录,在某些情况下只有部分文档。其中一些文档是Windows驱动程序工具包(Windows Driver Kit,WDK)的一部分,因为一些内核API是原生函数的目标,所以会共享一个原型。在用户模式下,微软在<winternl.h>文件中为原生API提供了非常有限的定义。

其中一个函数是NtQuerySystemInformation。这个功能强大的函数可以根据指定所需信息类型的SYSTEM_INFORMATION_CLASS返回各种信息。以下是<winternl.h>中的定义:

typedef enum _SYSTEM_INFORMATION_CLASS {
    SystemBasicInformation = 0,
    SystemPerformanceInformation = 2,
    SystemTimeOfDayInformation = 3,
    SystemProcessInformation = 5,
    SystemProcessorPerformanceInformation = 8,
    SystemInterruptInformation = 23,
    SystemExceptionInformation = 33,
    SystemRegistryQuotaInformation = 37,
    SystemLookasideInformation = 45,
    SystemCodeIntegrityInformation = 103,
    SystemPolicyInformation = 134,
} SYSTEM_INFORMATION_CLASS;
1
2
3
4
5
6
7
8
9
10
11
12
13

从这个枚举中可以看出,有很多未记录的信息类存在空缺。SystemProcessInformation是一个可以检索系统中所有进程数据的值,它比WTS_PROCESS_INFO_EX提供的字段更多,甚至包括每个进程的所有线程。实际上,任务管理器(Task Manager)和进程资源管理器(Process Explorer)就是使用这个函数来获取进程信息的。

使用上述枚举会返回SYSTEM_PROCESS_INFORMATION类型的对象,它在<winternl.h>中的声明如下:

typedef struct _SYSTEM_PROCESS_INFORMATION {
    ULONG NextEntryOffset;
    ULONG NumberOfThreads;
    BYTE Reserved1[48];
    UNICODE_STRING ImageName;
    KPRIORITY BasePriority;
    HANDLE UniqueProcessId;
    PVOID Reserved2;
    ULONG HandleCount;
    ULONG SessionId;
    PVOID Reserved3;
    SIZE_T PeakVirtualSize;
    SIZE_T VirtualSize;
    ULONG Reserved4;
    SIZE_T PeakWorkingSetSize;
    SIZE_T WorkingSetSize;
    PVOID Reserved5;
    SIZE_T QuotaPagedPoolUsage;
    PVOID Reserved6;
    SIZE_T QuotaNonPagedPoolUsage;
    SIZE_T PagefileUsage;
    SIZE_T PeakPagefileUsage;
    SIZE_T PrivatePageCount;
    LARGE_INTEGER Reserved7[6];
} SYSTEM_PROCESS_INFORMATION, *PSYSTEM_PROCESS_INFORMATION;
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

虽然它提供了相当多的信息,但有很多成员名称包含“reserved”字样,这些实际上是未记录的字段,而不是真正保留的字段。我们能获取这些“保留”字段的详细信息吗?

尽管这些结构和枚举没有文档记录,但在一些泄露的Windows源代码中找到了部分内容,也有人对其进行了逆向工程,或者可以在微软提供的公共符号中找到。一个使用了许多这些官方未记录函数和类型的应用程序是Process Hacker,这是一个在Github上可用的开源进程资源管理器克隆项目。它的一个相关项目是phnt,其中包含我所知道的对原生API、结构、枚举和定义最全面的定义(https://github.com/processhacker/phnt)。

举个简单的例子,完整的SYSTEM_PROCESS_INFORMATION看起来如下:

typedef struct _SYSTEM_PROCESS_INFORMATION {
    ULONG NextEntryOffset;
    ULONG NumberOfThreads;
    LARGE_INTEGER WorkingSetPrivateSize;
    ULONG HardFaultCount;
    ULONG NumberOfThreadsHighWatermark;
    ULONGLONG CycleTime; // 自WIN7起
    LARGE_INTEGER CreateTime;
    LARGE_INTEGER UserTime;
    LARGE_INTEGER KernelTime;
    UNICODE_STRING ImageName;
    KPRIORITY BasePriority;
    HANDLE UniqueProcessId;
    HANDLE InheritedFromUniqueProcessId;
    ULONG HandleCount;
    ULONG SessionId;
    ULONG_PTR UniqueProcessKey;
    SIZE_T PeakVirtualSize;
    SIZE_T VirtualSize;
    ULONG PageFaultCount;
    SIZE_T PeakWorkingSetSize;
    SIZE_T WorkingSetSize;
    SIZE_T QuotaPeakPagedPoolUsage;
    SIZE_T QuotaPagedPoolUsage;
    SIZE_T QuotaPeakNonPagedPoolUsage;
    SIZE_T QuotaNonPagedPoolUsage;
    SIZE_T PagefileUsage;
    SIZE_T PeakPagefileUsage;
    SIZE_T PrivatePageCount;
    LARGE INTEGER ReadOperationCount;
    LARGE INTEGER WriteOperationCount;
    LARGE INTEGER OtherOperationCount;
    LARGE INTEGER ReadTransferCount;
    LARGE INTEGER WriteTransferCount;
    LARGE INTEGER OtherTransferCount;
    SYSTEM_THREAD_INFORMATION Threads[1];
} SYSTEM_PROCESS_INFORMATION, *PSYSTEM_PROCESS_INFORMATION;
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
30
31
32
33
34
35
36
37

大多数在文档记录的结构中不存在的字段,仍然可以通过其他API函数获取。例如,进程创建时间可以使用GetProcessTimes检索,但这确实需要打开进程句柄(在这种情况下,访问掩码为PROCESS_QUERY_LIMITED_INFORMATION)。显然,一次性获取大量信息是很有优势的,这就是进程信息工具通常使用原生API的原因。

在本书中,除非有非常充分的理由,否则我们不会使用原生API。你可以研究Process Hacker的代码,了解如何使用这些API。无论如何,如果有官方API可用,使用官方API总是比依赖未记录的原生API更安全。

# 练习

  1. 编写一个名为MiniProcExp的图形用户界面(GUI)或控制台应用程序,即一个小型进程资源管理器。它可以使用工具帮助API(Toolhelp APIs)、WTS API或原生API来显示进程信息。对于任何无法直接获取的信息,可以通过打开进程的适当句柄并使用正确的函数来获取。(在MinProcExp项目中提供了一个基本的应用程序)。
  2. 通过添加对进程的操作(如终止进程和更改优先级类)来扩展上一个应用程序。
  3. 以你认为合适的方式继续扩展该应用程序!

# 总结

进程是Windows中最基本的构建块。它包含一组资源,如地址空间,允许线程使用这些资源执行代码。在下一章中,我们将探讨如何使用作业(Jobs)将进程作为一个单元进行管理。

第2章:对象和句柄
第4章:作业(Jobs)

← 第2章:对象和句柄 第4章:作业(Jobs)→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式