第2章:对象和句柄
# 第2章:对象和句柄
Windows是一个基于对象的操作系统,它公开了各种类型的对象(通常称为内核对象,kernel Objects),这些对象提供了Windows的大部分功能。进程、线程和文件都是对象类型的示例。在本章中,我们将讨论与内核对象相关的一般理论,不会过多涉及任何特定对象类型的细节。后续章节将深入探讨其中许多对象类型的详细信息。
本章内容包括:
- 内核对象
- 句柄
- 创建对象
- 对象名称
- 共享内核对象
- 私有对象命名空间
# 内核对象
Windows内核公开了各种类型的对象,供用户模式进程、内核本身以及内核模式驱动程序使用。这些对象类型的实例是系统(内核)空间中的数据结构,由对象管理器(执行体的一部分)在用户模式或内核模式代码请求时创建和管理。内核对象采用引用计数机制,因此只有当对该对象的最后一个引用被释放时,对象才会被销毁并从内存中释放。
Windows内核支持多种对象类型。想要一探究竟的话,可以运行Sysinternals的WinObj工具(需以管理员身份运行),然后找到ObjectTypes目录。图2-1展示了其界面。这些对象类型可以根据其可见性和用途进行分类:
- 通过Windows应用程序编程接口(API,Application Programming Interface )导出到用户模式的类型。例如:互斥体(mutex)、信号量(semaphore)、文件、进程、线程、定时器。本书将讨论其中的许多对象类型。
- 未导出到用户模式,但在Windows驱动程序工具包(WDK,Windows Driver Kit )中有文档说明,供设备驱动程序编写者使用的类型。例如:设备、驱动程序、回调。
- 即使在WDK中也没有文档说明的类型(至少在撰写本文时是这样)。这些对象类型仅供内核本身使用。例如:分区、键控事件、核心消息传递。
图2-1:对象类型
内核对象的主要属性如图2-2所示。
图2-2:内核对象属性
由于内核对象位于系统空间中,用户模式无法直接访问它们。应用程序必须使用一种间接机制来访问内核对象,这种机制被称为句柄(handles)。句柄至少有以下优点:
- 在未来的Windows版本中,对象类型的数据结构发生的任何变化都不会影响任何客户端。
- 可以通过安全访问检查来控制对对象的访问。
- 句柄是进程私有的,因此在一个进程中拥有指向特定对象的句柄,在另一个进程上下文中没有任何意义。
内核对象是引用计数的。对象管理器维护一个句柄计数和一个指针计数,它们的总和就是对象的总引用计数(可以从内核模式获取直接指针)。一旦用户模式客户端不再需要使用某个对象,客户端代码就应该通过调用 CloseHandle
关闭用于访问该对象的句柄。从那时起,代码应该认为该句柄无效。尝试通过已关闭的句柄访问对象将失败,GetLastError
会返回 ERROR_INVALID_HANDLE
(6)。一般情况下,客户端并不知道对象是否已被销毁。如果对象的引用计数降为零,对象管理器将删除该对象。
句柄值是4的倍数,第一个有效句柄是4;零永远不是有效句柄值。在64位系统上,这种机制也不会改变。
从逻辑上讲,句柄是进程维护的句柄表中条目数组的索引,该句柄表在逻辑上指向位于系统空间中的内核对象。有各种 Create*
和 Open*
函数用于创建/打开对象并获取指向这些对象的句柄。如果无法创建或打开对象,在大多数情况下返回的句柄为 NULL
(0)。这条规则有一个显著的例外,即 CreateFile
函数,如果失败,它会返回 INVALID_HANDLE_VALUE
(-1)。
例如,CreateMutex
函数允许创建一个新的互斥体,或者通过名称打开一个互斥体(这取决于具有该名称的互斥体是否存在)。如果成功,该函数会返回一个指向互斥体的句柄。返回值为零意味着句柄无效(并且函数调用失败)。另一方面,OpenMutex
函数尝试打开一个指向指定名称互斥体的句柄。如果具有该名称的互斥体不存在,函数将失败。
如果函数成功并且提供了名称,返回的句柄可能指向一个新的互斥体,也可能指向一个已存在且具有该名称的互斥体。代码可以通过调用 GetLastError
并将结果与 ERROR_ALREADY_EXISTS
进行比较来检查这一点。如果相等,那么它不是一个新对象,而是指向现有对象的另一个句柄。这是一种罕见的情况,即使相关的API调用成功,也可以调用 GetLastError
。
# 运行单实例进程
ERROR_ALREADY_EXIST
情况的一个相当常见的用法是限制可执行文件只能有一个进程实例。通常,如果你在资源管理器中双击一个可执行文件,会基于该可执行文件生成一个新进程。如果你重复此操作,会基于相同的可执行文件创建另一个进程。如果你想阻止第二个进程启动,或者至少让它在检测到已经有另一个相同可执行文件的进程实例在运行时关闭,该怎么办呢?
技巧是使用某个命名的内核对象(通常使用互斥体,不过也可以使用任何命名对象类型),创建一个具有特定名称的对象。如果该对象已经存在,那么肯定有另一个实例已经在运行,所以当前进程可以关闭(可能会通知它的 “兄弟” 进程这个事实)。
“单实例”(SingleInstance)演示应用程序展示了如何实现这一点。它是一个基于对话框的应用程序,使用WTL(Windows Template Library,Windows模板库)构建。图2-3展示了该应用程序运行时的样子。如果你尝试启动多个该应用程序实例,你会发现第一个窗口会记录来自新进程实例的消息,然后新进程实例退出。
图2-3:单实例应用程序
在 WinMain
函数中,我们首先创建互斥体。如果创建失败,说明出现了严重问题,我们直接退出。
HANDLE hMutex = ::CreateMutex(nullptr , FALSE, L"SingleInstanceMutex");
if (!hMutex) {
CString text;
text.Format(L"Failed to create mutex (Error: %d)", ::GetLastError());
::MessageBox(nullptr , text, L"Single Instance", MB_OK);
return 0;
}
2
3
4
5
6
7
创建互斥体失败的情况应该极为罕见。最有可能导致失败的情况是已经存在另一个同名的内核对象(但不是互斥体)。
现在我们获得了指向互斥体的有效句柄,唯一的问题是这个互斥体是实际创建的,还是我们得到了指向现有互斥体(可能是由该可执行文件的先前实例创建的)的另一个句柄:
if (::GetLastError() == ERROR_ALREADY_EXISTS) {
NotifyOtherInstance();
return 0;
}
2
3
4
如果在调用 CreateMutex
之前对象就已存在,那么我们调用一个辅助函数,该函数会向现有实例发送一些消息,然后退出。下面是 NotifyOtherInstance
函数:
#define WM_NOTIFY_INSTANCE (WM_USER + 100)
void NotifyOtherInstance() {
auto hWnd = ::FindWindow(nullptr , L"Single Instance");
if (!hWnd) {
::MessageBox(nullptr , L"Failed to locate other instance window",
L"Single Instance", MB_OK);
return;
}
::PostMessage(hWnd, WM_NOTIFY_INSTANCE, ::GetCurrentProcessId(), 0);
::ShowWindow(hWnd, SW_NORMAL);
::SetForegroundWindow(hWnd);
}
2
3
4
5
6
7
8
9
10
11
12
13
该函数使用 FindWindow
函数搜索现有窗口,并使用窗口标题作为搜索条件。一般来说,这并不理想,但对于这个示例来说已经足够了。
一旦找到窗口,我们就向该窗口发送一条自定义消息,将当前进程ID作为参数。这条消息会显示在对话框的列表框中。
最后一部分是对话框处理 WM_NOTIFY_INSTANCE
消息。在WTL中,窗口消息通过宏映射到函数。MainDlg.h
中对话框类(CMainDlg
)的消息映射如下:
BEGIN_MSG_MAP(CMainDlg)
MESSAGE_HANDLER(WM_NOTIFY_INSTANCE, OnNotifyInstance)
MESSAGE_HANDLER(WM_INITDIALOG, OnInitDialog)
COMMAND_ID_HANDLER(IDCANCEL, OnCancel)
END_MSG_MAP()
2
3
4
5
自定义消息被映射到 OnNotifyInstance
成员函数,实现如下:
LRESULT CMainDlg::OnNotifyInstance(UINT, WPARAM wParam, LPARAM, BOOL &) {
CString text;
text.Format(L"Message from another instance (PID: %d)", wParam);
AddText(text);
return 0;
}
2
3
4
5
6
从 wParam
参数中提取进程ID,然后使用辅助函数 AddText
将一些文本添加到列表框中:
void CMainDlg::AddText(PCWSTR text) {
CTime dt = CTime::GetCurrentTime();
m_List.AddString(dt.Format(L"%T") + L": " + text);
}
2
3
4
m_List
的类型是 CListBox
,它是WTL对Windows列表框控件的包装。
# 句柄
如前所述,句柄间接指向内核空间中的一个小数据结构,该结构为该句柄保存了一些信息。图2-4展示了32位和64位系统下这个数据结构的情况。
图2-4:句柄条目
在32位系统中,这个句柄条目大小为8字节,在64位系统中为16字节(从技术上讲,12字节就足够了,但为了对齐,扩展到了16字节)。每个条目包含以下内容:
- 指向实际对象的指针。由于低位用于标志位,并且为了通过地址对齐提高CPU访问时间,在32位系统中,对象的地址是8的倍数,在64位系统中是16的倍数。
- 访问掩码,用于指示可以使用此句柄执行哪些操作。换句话说,访问掩码决定了句柄的权限。
- 三个标志:继承标志、防止关闭标志和关闭时审核标志(稍后讨论)。
访问掩码是一个位掩码,其中每个 “1” 位表示可以使用该句柄执行的特定操作。在创建对象或打开现有对象以创建句柄时设置访问掩码。如果创建对象,调用者通常对该对象具有完全访问权限。但如果打开对象,调用者需要指定所需的访问掩码,不过不一定能获得该权限。
例如,如果一个应用程序想要终止某个进程,它必须首先调用OpenProcess
函数,使用(至少)PROCESS_TERMINATE
访问掩码来获取所需进程的句柄,否则无法用该句柄终止进程。如果调用成功,那么对TerminateProcess
的调用必然会成功。
以下是一个根据进程ID终止进程的示例:
bool KillProcess(DWORD pid) {
// 打开一个权限足够的进程句柄
HANDLE hProcess = ::OpenProcess(PROCESS_TERMINATE, FALSE, pid);
if (!hProcess)
return false;
// 现在使用任意退出代码终止它
BOOL success = ::TerminateProcess(hProcess, 1);
// 关闭句柄
::CloseHandle(hProcess);
return success != FALSE;
}
2
3
4
5
6
7
8
9
10
11
OpenProcess
函数具有以下原型:
HANDLE OpenProcess(
_In_ DWORD dwDesiredAccess, // 访问掩码
_In_ BOOL bInheritHandle, // 继承标志
_In_ DWORD dwProcessId // 进程ID
);
2
3
4
5
由于这是一个打开操作,相关对象已经存在,客户端需要指定访问该对象所需的访问掩码。访问掩码有两种类型的访问位:通用访问位和特定访问位。我们将在第16章 “安全性” 中讨论这些细节。上述示例中使用的进程的特定访问位之一是PROCESS_TERMINATE
。其他位包括PROCESS_QUERY_INFORMATION
、PROCESS_VM_OPERATION
等。请参考OpenProcess
的文档以查找完整列表。
客户端代码应该使用什么访问掩码呢?一般来说,它应该反映客户端代码打算对对象执行的操作。要求的权限超过所需权限可能会失败,而要求的权限不足显然也不行。
与每个句柄相关联的标志如下:
- 继承(Inheritance):此标志用于句柄继承,这是一种允许在协作进程之间共享对象的机制。我们将在第3章中讨论句柄继承。
- 关闭时审核(Audit on close):此标志指示在关闭该句柄时是否应在安全日志中写入审核条目。此标志很少使用,默认情况下处于关闭状态。
- 防止关闭(Protect from close):设置此标志可防止句柄被关闭。对
CloseHandle
的调用将返回FALSE
,并且GetLastError
返回ERROR_INVALID_HANDLE
(6)。如果进程在调试器下运行,则会引发一个异常,并显示以下消息:“0xC0000235: NtClose was called on a handle that was protected from close via NtSetInformationObject”。这个标志很少有用。
可以使用SetHandleInformation
函数来更改继承和保护标志,其定义如下:
#define HANDLE_FLAG_INHERIT 0x00000001
#define HANDLE_FLAG_PROTECT_FROM_CLOSE 0x00000002
BOOL SetHandleInformation(
_In_ HANDLE hObject,
_In_ DWORD dwMask,
_In_ DWORD dwFlags
);
2
3
4
5
6
7
8
第一个参数是句柄本身。第二个参数是一个位掩码,指示要操作哪些标志。最后一个参数是这些标志的实际值。例如,要在某个句柄上设置 “防止关闭” 位,可以使用以下代码:
::SetHandleInformation(h, HANDLE_FLAG_PROTECT_FROM_CLOSE, HANDLE_FLAG_PROTECT_FROM_CLOSE);
相反,以下代码片段将清除相同的位:
::SetHandleInformation(h, HANDLE_FLAG_PROTECT_FROM_CLOSE, 0);
也存在一个相反的函数用于读取这些标志:
BOOL GetHandleInformation(
_In_ HANDLE hObject,
_Out_ LPDWORD lpdwFlags
);
2
3
4
可以使用Sysinternals的Process Explorer工具查看从特定进程打开的句柄。导航到你感兴趣的进程,并确保显示下部窗格(“查看” 菜单,“显示下部窗格”)。下部窗格显示两种视图之一 —— 切换到 “句柄” 视图(“查看” 菜单,“下部窗格视图”,“句柄”)。图2-5是该工具显示资源管理器(Explorer)进程中打开句柄的屏幕截图。默认显示的列只有 “类型” 和 “名称”。我通过右键单击标题区域并单击 “选择列” 添加了以下列:句柄、对象地址、访问权限和已解码访问权限。
图2-5:Process Explorer中的句柄视图
以下是这些列的简要说明:
- 句柄(Handle):这是句柄值本身,仅与该进程相关。相同的句柄值可能具有不同的含义,即指向不同的对象,或者甚至可能是一个空索引。
- 类型(Type):对象类型名称。这与图2-1中WinObj的 “对象类型” 目录相对应。
- 对象地址(Object Address):这是实际对象结构所在的内核地址。请注意,在64位系统上,这些地址以十六进制的零结尾(在32位系统上,地址以 “8” 或 “0” 结尾)。用户模式代码无法使用此信息,但它可用于调试目的:如果你有两个指向同一对象的句柄,并且想知道它们是否指向同一个对象,可以比较对象地址;如果地址相同,则是同一个对象。否则,这些句柄指向不同的对象。
- 访问权限(Access):这就是上面讨论的访问掩码。要解释存储在此十六进制值中的位,你需要在文档中查找访问掩码位。为了简化这一过程,可以使用 “已解码访问权限” 列。
- 已解码访问权限(Decoded Access):为常见对象类型提供访问掩码位的字符串表示形式。这使得在不深入研究文档的情况下更容易解释访问掩码位。 | 我个人为Process Explorer实现了此列。 | | ------------------------------------ |
Process Explorer的句柄视图默认仅显示指向命名对象的句柄。要查看所有句柄,请从 “查看” 菜单中启用 “显示未命名句柄和映射” 选项。图2-6显示了选中此选项时视图的变化。
图2-6:Process Explorer中的句柄视图(包括未命名对象)
“名称” 这个术语比看起来更复杂。Process Explorer认为的命名对象不一定是实际名称,在某些情况下只是方便的别名。例如,图2-5中显示了进程和线程句柄,即使进程和线程不能有基于字符串的名称。还有其他对象类型的 “名称” 并不是它们的实际名称;最容易混淆的是文件(File)和键(Key)。我们将在本章后面的 “对象名称” 部分讨论这种 “奇怪之处”。
进程句柄表中的句柄总数可在“进程资源管理器(Process Explorer)”和“任务管理器(Task Manager)”中作为一列显示。图2-7展示了添加到“任务管理器”中的这一列。
图2-7:任务管理器中的句柄计数列
请注意,显示的数字是句柄计数,而不是对象计数。这是因为可以存在多个引用同一对象的句柄。
在“进程资源管理器”中双击一个句柄条目会打开一个对话框,显示对象(而非句柄)的一些属性。图2-8是这样一个对话框的截图。
图2-8:进程资源管理器中的内核对象属性
基本对象信息(名称、类型和地址)与句柄条目中的信息重复。这个特定的对象(一个互斥锁)有3个打开的句柄。引用数量具有误导性,并不反映实际的对象引用计数。对于某些类型的对象(如互斥锁),会显示额外信息。在这种特定情况下,显示的是互斥锁当前是否被持有以及是否已被放弃(我们将在第8章详细讨论互斥锁)。
要了解系统在给定时刻的对象和句柄数量,可以运行我在Github仓库(https://github.com/zodiacon/AllTools)中的“内核对象查看器(KernelObjectView)”工具。图2-9是该工具的截图。它显示了每种对象类型的对象总数以及句柄总数。你可以按任何一列进行排序;哪种对象类型的对象最多?哪种对象类型的句柄最多?
图2-9:内核对象查看器
# 伪句柄
有些句柄具有特殊值且不可关闭。这些被称为伪句柄(pseudo-handle),尽管在需要时它们的使用方式与其他任何句柄一样。对伪句柄调用“关闭句柄(CloseHandle)”函数总是会失败。以下是返回伪句柄的函数:
GetCurrentProcess(-1)
- 返回一个指向调用进程的伪句柄GetCurrentThread(-2)
- 返回一个指向调用线程的伪句柄GetCurrentProcessToken(-4)
- 返回一个指向调用进程令牌(token)的伪句柄GetCurrentThreadToken(-5)
- 返回一个指向调用线程令牌的伪句柄GetCurrentThreadEffectiveToken(-6)
- 返回一个指向调用线程有效令牌的伪句柄(如果线程有自己的令牌,则使用该令牌;否则,使用其进程令牌)
最后三个伪句柄(令牌句柄)仅在Windows 8及更高版本中受支持,并且它们的访问掩码仅为“查询令牌(TOKEN_QUERY)”和“查询令牌源(TOKEN_QUERY_SOURCE)”。
本书后面会讨论进程、线程和令牌。 |
---|
# 句柄的资源获取即初始化(RAII)机制
一旦不再需要句柄(handle),及时关闭它非常重要。未能正确做到这一点的应用程序可能会出现 “句柄泄漏” 问题,即如果应用程序打开句柄后 “忘记” 关闭,句柄数量会不受控制地增长。显然,这是个糟糕的情况。
一种有助于代码管理句柄且不会忘记关闭它们的方法是在C++ 中实现一种名为资源获取即初始化(Resource Acquisition is Initialization,RAII)的知名惯用法。这个名字不太好记,但这种惯用法却很实用。其核心思想是,将句柄包装在一个类型中,并利用该类型的析构函数来确保在包装对象被销毁时关闭句柄。
下面是一个简单的针对句柄的RAII包装器(为方便起见,内联实现):
struct Handle {
explicit Handle(HANDLE h = nullptr) : _h(h) {}
~Handle() { Close(); }
// 删除拷贝构造函数和拷贝赋值运算符
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
// 允许移动(转移所有权)
Handle(Handle&& other) : _h(other._h) {
other._h = nullptr;
}
Handle& operator=(Handle&& other) {
if (this != &other) {
Close();
_h = other._h;
other._h = nullptr;
}
return *this;
}
operator bool() const {
return _h != nullptr && _h != INVALID_HANDLE_VALUE;
}
HANDLE Get() const {
return _h;
}
void Close() {
if (_h) {
::CloseHandle(_h);
_h = nullptr;
}
}
private:
HANDLE _h;
};
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
Handle
类型提供了一个RAII句柄包装器应有的基本操作。拷贝构造函数和拷贝赋值运算符被移除,因为拷贝一个可能有多个所有者的句柄没有意义(这会导致对同一个句柄调用两次 CloseHandle
函数)。虽然可以通过复制句柄来实现这些拷贝操作(参见本章后面的 “共享内核对象” 部分),但这是一个较为复杂的操作,最好在隐式拷贝场景中避免。bool
运算符在当前持有的句柄有效时返回 true
;它将 0
和 INVALID_HANDLE_VALUE
(即 -1
)视为无效句柄。Close
函数用于关闭句柄,通常在析构函数中被调用。最后,Get
函数返回底层句柄。
可以添加一个向 HANDLE 的隐式转换运算符,这样就无需调用 Get 函数。 |
---|
下面是一些使用上述包装器的示例代码:
Handle hMyEvent(::CreateEvent(nullptr, TRUE, FALSE, nullptr));
if (!hMyEvent) {
// 处理失败情况
return;
}
::SetEvent(hMyEvent.Get());
// 转移所有权
Handle hOtherEvent(std::move(hMyEvent));
::ResetEvent(hOtherEvent.Get());
2
3
4
5
6
7
8
9
10
11
尽管编写这样一个RAII包装器是可行的,但通常最好使用现有的库来提供这种(以及其他类似)功能。例如,虽然 CloseHandle
是最常用的关闭句柄函数,但还有其他类型的句柄需要不同的关闭函数。微软在Windows代码中使用的一个库是Windows实现库(Windows Implementation Library,WIL)。这个库已在Github上发布,并且可以作为Nuget包获取。
# 使用WIL
将WIL添加到项目中的方式与添加任何其他Nuget包一样。在Visual Studio项目中,右键单击 “引用” 节点,然后选择 “管理Nuget包……”。在 “浏览” 选项卡的搜索文本框中,输入 “wil” 以快速搜索WIL。该包的完整名称是 “Microsoft.Windows.ImplementationLibrary”,如图2-10所示。
图2-10:通过Nuget添加WIL
RAII句柄包装器位于 <wil\resource.h>
头文件中。下面是使用WIL实现的相同代码:
#include <wil\resource.h>
void DoWork() {
wil::unique_handle hMyEvent(::CreateEvent(nullptr, TRUE, FALSE, nullptr));
if (!hMyEvent) {
// 处理失败情况
return;
}
::SetEvent(hMyEvent.get());
// 转移所有权
auto hOtherEvent(std::move(hMyEvent));
::ResetEvent(hOtherEvent.get());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
wil::unique_handle
是一个 HANDLE
包装器,在销毁时会调用 CloseHandle
函数。它主要仿照C++ 的 std::unique_ptr<>
类型设计。注意,通过调用 get()
函数可以获取内部的 HANDLE
。要替换 unique_handle
内部的值(并关闭旧的句柄),可以使用 reset
函数;调用不带参数的 reset
函数只会关闭底层句柄,使包装器对象成为一个空壳。
本书中的代码示例在某些情况下会使用WIL,但并非全部。从学习的角度来看,有时使用原始类型会使内容更易于理解。 |
---|
# 创建对象
所有用于创建新对象的函数都有一些共同的参数。下面以 CreateMutex
和 CreateEvent
函数为例进行说明:
HANDLE CreateMutex(
_In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,
_In_ BOOL bInitialOwner,
_In_opt_ LPCTSTR lpName);
HANDLE CreateEvent(
_In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
_In_ BOOL bManualReset,
_In_ BOOL bInitialState,
_In_opt_ LPCTSTR lpName);
2
3
4
5
6
7
8
9
10
注意,这两个函数都接受一个 SECURITY_ATTRIBUTES
类型的参数。这个结构体几乎在所有的创建函数中都很常见,其定义如下:
typedef struct _SECURITY_ATTRIBUTES {
DWORD nLength;
LPVOID lpSecurityDescriptor;
BOOL bInheritHandle;
} SECURITY_ATTRIBUTES, *PSECURITY_ATTRIBUTES;
2
3
4
5
nLength
成员应设置为该结构体的大小。这是Windows用于对结构体进行版本控制的常用技术。如果在未来的Windows版本中该结构体会有新成员,旧代码仍能正常运行,因为它会将长度设置为旧的大小,这样较新的Windows API就知道不去读取新成员,因为旧代码并不知道这些新成员的存在。也就是说,从第一个Windows NT版本发布以来,SECURITY_ATTRIBUTES
结构体尚未发生变化。
顾名思义,这个结构体与新创建对象的安全设置有关。真正涉及安全性的主要成员是 lpSecurityDescriptor
,它可以指向一个安全描述符对象,该对象本质上指定了谁可以对该对象执行何种操作。我们将在第16章讨论安全描述符。
最后一个成员 bInheritHandle
也有安全方面的意义,这就是它也包含在这个结构体中的原因。它就是前面提到的继承位。这意味着在创建新对象时,无需调用 SetHandleInformation
函数,通过使用这个结构体就可以设置继承位。下面是一个创建事件对象的示例,返回的句柄设置了继承位:
SECURITY_ATTRIBUTES sa = { sizeof(sa) }; // 设置nLength并将其余部分清零
sa.bInheritHandle = TRUE;
HANDLE hEvent = ::CreateEvent(&sa, TRUE, FALSE, nullptr);
DWORD flags;
::GetHandleInformation(hEvent, &flags); // 设置flags = 1
2
3
4
5
句柄继承将在第3章讨论。 |
---|
将 SECURITY_ATTRIBUTES
设置为 NULL
会使继承位保持清除状态。在安全性方面,这意味着 “默认安全性”,它基于存储在进程访问令牌中的安全描述符。我们将在第16章讨论详细内容。无论如何,在大多数情况下,将安全描述符设置为 NULL
(无论是显式设置还是将 SECURITY_ATTRIBUTES
指针设置为 NULL
)都是正确的做法。
# 对象名称
有些类型的对象可以有基于字符串的名称。这些名称可用于通过合适的 Open
函数按名称打开对象。请注意,并非所有对象都有名称;例如,进程和线程没有名称,它们有ID。这就是为什么 OpenProcess
和 OpenThread
函数需要进程 / 线程标识符(一个数字)而不是基于字符串的名称。命名对象可以使用Sysinternals的WinObj工具查看。
在用户模式代码中,如果调用创建函数时指定了一个名称,若具有该名称的对象不存在,则会创建该对象;若对象已存在,则只是打开现有对象。在后一种情况下,调用 GetLastError
会返回 ERROR_ALREADY_EXISTS
,表明这不是一个新对象,并且返回的句柄是指向现有对象的另一个句柄。在这种情况下,影响对象创建的参数(如 SECURITY_ATTRIBUTES
结构体)不会被使用,因为创建者已经设置好了这些参数。
提供给创建函数的名称并非对象的最终名称。在经典(桌面)进程中,名称前面会加上 \Sessions\x\BaseNamedObjects\
,其中 x
是调用者的会话ID。如果会话ID为零,则名称仅在前面加上 \BaseNamedObjects\
。如果调用者恰好运行在应用容器(通常是通用Windows平台进程)中,那么前面添加的字符串会更复杂,它由唯一的应用容器安全标识符(Security Identifier,SID)组成:\Sessions\x\AppContainerNamedObjects\
。
图2-11展示了WinObj中会话1中的命名对象。
图2-11:会话1中的命名对象
图2-12展示了会话0中的命名对象。
图2-12:会话0中的命名对象
以上所有内容意味着对象名称是与会话相关的(在应用容器的情况下,是与包相关的)。如果一个对象必须在多个会话之间共享,可以通过在会话0中创建并在对象名称前加上 Global\
来实现;例如,使用 CreateMutex
函数创建一个名为 Global\MyMutex
的互斥体(Mutex),它将在 \BaseNamedObjects
下创建。请注意,应用容器无权使用会话0的对象命名空间。
可以使用WinObj查看整个对象管理器命名空间层次结构。这个完整的结构存储在内存中,并由对象管理器根据需要进行操作。请注意,未命名的对象不属于这个结构,这意味着在WinObj中看到的对象并非包含所有现有对象,而是所有使用名称创建的对象。
WinObj中显示的 “目录” 实际上是目录对象(Directory objects),它只是一种内核对象,用作逻辑容器。 |
---|
回到进程资源管理器(Process Explorer)的 “句柄” 视图,它默认显示 “命名” 对象。这里的 “命名” 不仅指可以命名的对象,还包括其他对象。可以命名的对象有互斥体(Mutants)、信号量(Semaphores)、事件(Events)、节(Sections)、高级本地过程调用(Advanced Local Procedure Call,ALPC)端口、作业(Jobs)、定时器(Timers)以及其他一些较少使用的对象类型。还有一些对象显示的名称与真正的命名对象含义不同:
- 进程和线程对象:显示的名称是它们的唯一ID。
- 对于文件对象,它显示文件对象指向的文件名(或设备名)。这与对象的名称不同,因为无法根据文件名获取文件对象的句柄,只能创建一个新的文件对象来访问相同的底层文件或设备(前提是原始文件对象的共享设置允许这样做)。
- (注册表)键对象的名称显示为注册表键的路径。出于与文件对象相同的原因,这不是一个名称。
- 目录对象显示的是其逻辑路径,而不是真正的对象名称。目录不是文件系统对象,而是对象管理器目录。
- 令牌对象的名称显示为存储在令牌中的用户名。
要验证上述说法,可以在WinObj中浏览查找文件或键对象。你找不到任何相关对象,这表明这些对象不能被命名。
# 共享内核对象
如我们所见,内核对象的句柄(handle)对于进程来说是私有的。在某些情况下,一个进程可能希望与另一个进程共享内核对象。这样的进程不能简单地以某种方式将句柄的值传递给另一个进程,因为在另一个进程的句柄表中,该句柄值可能指向不同的对象,或者为空。
显然,必须有一种机制来实现这种共享。实际上,有三种机制:
- 通过名称共享
- 通过句柄继承共享
- 通过复制句柄共享
我们将在这里探讨第一种和第三种方式,并在下一章讨论句柄继承。
# 通过名称共享
如果可行的话,这是最简单的选择。这里的 “可行” 是指相关对象可以有名称,并且确实有名称。典型的场景是,协作进程(两个或更多)会使用相同的对象名称调用相应的创建函数。第一个进行调用的进程会创建该对象,后续其他进程的调用将为同一个对象打开额外的句柄。
示例 “BasicSharing” 展示了一个对内存映射文件(Memory Mapped File)对象使用通过名称共享的例子。这个对象可用于在进程之间共享内存(通常情况下,每个进程只能看到自己的地址空间)。运行该应用程序的两个(或更多)实例(如图2-13所示),可以在这些进程之间共享文本数据。
内存映射文件的完整细节将在第14章讨论。 |
---|
图2-13:Basic Sharing应用程序
为了测试它,在编辑框中输入一些内容,然后点击 “Write”。接着切换到另一个实例,只需点击 “Read”。你输入的文本应该会出现在另一个应用程序的编辑框中。当然,你可以交换角色。如果你启动另一个实例,点击 “Read”,最后输入的文本也会出现。这是因为所有这些进程都在读写相同的(共享的)内存。
顺便说一下,这些进程不一定基于相同的可执行文件,这里只是为了方便才这样使用。决定因素是对象的名称。
在查看代码之前,让我们看看在进程资源管理器(Process Explorer)中这是什么样子的。运行可执行文件的两个实例,打开进程资源管理器,找到这两个进程。确保下面的窗格显示 “Handles”(而不是 “DLLs”)。要查找的对象类型是 “Section”(内存映射文件的内核名称)。找到一个名为 “MySharedMemory” 的节(当然带有基于会话的前缀),如图2-14所示。
图2-14:共享节对象
如果你双击该句柄,你应该会看到如图2-15所示的节对象属性。
图2-15:节对象属性
注意,该对象有两个打开的句柄。大概这些句柄来自持有该对象句柄的两个进程。注意共享内存的大小:4KB,我们将在代码中看到这一点的体现。
如果你找到使用该对象的第二个进程(见图2-16),双击句柄时应该会看到相同的信息。你如何确定它们指向同一个对象呢?查看 “Object Address”(对象地址)列。如果地址相同,这就是同一个对象(反之亦然)。还要注意,句柄值是不同的(这是正常情况)。在图2-14中,句柄值是0x14c(进程ID为22384),在图2-16中是0x16c(进程ID为27864)。尽管如此,它们引用的是完全相同的对象。
图2-16:另一个进程中的共享节
如果你关闭其中一个实例,会发生什么呢?一个句柄会关闭,但对象仍然存在。这意味着启动一个全新的实例并点击 “Read”,将显示最新的文本。如果我们关闭所有协作应用程序,然后再次启动一个实例,会发生什么呢?如果我们点击 “Read”,会看到什么呢?试着自己解释一下为什么会这样。
现在让我们把注意力转向代码。
“BasicApplication” 是一个基于WTL对话框的项目。对话框类(CMainDlg)有一个值得关注的成员,即内存映射文件的句柄:
private :
HANDLE m_hSharedMem;
2
在对话框创建时,在WM_INITDIALOG消息处理程序中,我们创建文件映射对象并给它一个名称:
m_hSharedMem = ::CreateFileMapping(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE,
0, 1 << 12, L"MySharedMemory");
if (!m_hSharedMem) {
AtlMessageBox(m_hWnd, L"Failed to create/open shared memory", IDR_MAINFRAME);
EndDialog(IDCANCEL);
}
2
3
4
5
6
CreateFileMapping用于创建(或打开)一个文件映射对象。参数的具体细节将在第14章(第二部分)讨论。这里我们特别关注一个参数(最后一个),即对象的名称。这就是我们在进程资源管理器中看到的名称(带有标准的与会话相关的前缀)。如果这是第一个尝试创建该对象的进程,它将被创建。后续调用会为同一个对象生成额外的句柄(调用GetLastError将返回ERROR_ALREADY_EXISTS)。在这种情况下,我们不在乎这个调用是否是第一次,我们只想要一个指向同一个内核对象的句柄,以便多个进程都能使用它的 “功能”。
倒数第二个参数对(0和1 << 12)将共享内存的大小设置为一个64位值。在这种情况下,它被设置为4KB(1 << 12)。如果由于任何原因调用失败,我们只打印一条简单的消息并关闭对话框,导致进程本身退出。
当对话框关闭时,关闭句柄是个好习惯。严格来说,在这种特定情况下并非必须这样做,因为一旦对话框关闭,进程就会退出,并且内核会确保终止进程的所有句柄都被正确关闭。不过,这仍是个好习惯(除非有针对句柄的资源获取即初始化(RAII)包装器为你处理此事)。为了完整起见,下面是在处理对话框的WM_DESTROY消息时关闭句柄的调用:
if (m_hSharedMem)
::CloseHandle(m_hSharedMem);
2
现在来看写入和读取部分。访问共享内存是通过调用MapViewOfFile来完成的,该函数会返回一个指向共享内存的指针(同样,具体细节在第12章)。然后,只需将文本复制到映射的内存中:
void* buffer = ::MapViewOfFile(m_hSharedMem, FILE_MAP_WRITE, 0, 0, 0);
if (!buffer) {
AtlMessageBox(m_hWnd, L"Failed to map memory", IDR_MAINFRAME);
return 0;
}
CString text;
GetDlgItemText(IDC_TEXT, text);
::wcscpy_s((PWSTR)buffer, text.GetLength() + 1, text);
::UnmapViewOfFile(buffer);
2
3
4
5
6
7
8
9
10
复制操作是使用wcscpy_s函数将内容复制到映射内存中。然后,使用UnmapViewOfFile函数取消内存映射。
读取数据的过程非常相似。访问掩码改为FILE_MAP_READ而非FILE_MAP_WRITE,并且内存数据以相反方向复制,直接复制到编辑框中:
void* buffer = ::MapViewOfFile(m_hSharedMem, FILE_MAP_READ, 0, 0, 0);
if (!buffer) {
AtlMessageBox(m_hWnd, L"Failed to map memory", IDR_MAINFRAME);
return 0;
}
SetDlgItemText(IDC_TEXT, (PCWSTR)buffer);
::UnmapViewOfFile(buffer);
2
3
4
5
6
7
8
# 通过复制句柄共享
通过名称共享内核对象确实很简单。那么对于那些没有(或不能有)名称的对象呢?复制句柄可能是解决方案。复制句柄没有内在限制(除了安全性方面)—— 它几乎可以用于任何内核对象,无论是否命名,并且可以在任何时间使用(在第3章我们会看到,句柄继承仅在一个进程创建子进程时可用)。然而,这也有个缺点;实际上,这是最难实现的共享方式,我们很快就会看到。
一个复制的I/O完成端口句柄在目标进程中无法工作。
复制句柄就像调用DuplicateHandle函数一样简单:
BOOL DuplicateHandle(
_In_ HANDLE hSourceProcessHandle,
_In_ HANDLE hSourceHandle,
_In_ HANDLE hTargetProcessHandle,
_Outptr_ LPHANDLE lpTargetHandle,
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ DWORD dwOptions);
2
3
4
5
6
7
8
复制句柄需要一个源进程、源句柄和一个目标进程。如果成功,一个新的句柄条目会被写入目标进程的句柄表中,指向与源句柄相同的对象。复制前后的情况分别如图2-17和图2-18所示。
图2-17:复制句柄前
图2-18:复制句柄后
从技术上讲,DuplicateHandle可以在任何两个能够获取适当句柄的进程上工作,但典型的场景是将调用者的一个句柄复制到另一个进程的句柄表中。此外,源进程和目标进程也可能是同一个进程。下面详细介绍一下DuplicateHandle的参数:
- hSourceProcessHandle:这是指向源进程的句柄。这个句柄必须具有PROCESS_DUP_HANDLE访问掩码。如果源进程是调用者的进程,那么传递GetCurrentProcess就可以(并且它始终具有完全访问权限)。
- hSourceHandle:要复制的源句柄。这个句柄在源进程的上下文中必须是有效的。
- hTargetProcessHandle:目标进程句柄。通常需要使用一些OpenProcess调用才能获得这样一个句柄。与源进程一样,需要PROCESS_DUP_HANDLE访问掩码。
- lpTargetHandle:这是从目标进程角度来看有效的结果句柄。在图2-18中,返回给调用者的结果句柄是72。这个值是相对于进程B的(假设调用者是进程A)。
- dwDesiredAccess:复制句柄所需的访问掩码。如果dwOptions参数具有DUPLICATE_SAME_ACCESS标志,那么这个访问掩码将被忽略。否则,这就是为新句柄请求的访问掩码。
- bInheritHandle:指定新句柄是否可继承(有关句柄继承的更多信息,请参见第3章)。
- dwOptions:一组标志。其中一个是上面讨论过的DUPLICATE_SAME_ACCESS。另一个支持的标志是DUPLICATE_CLOSE_SOURCE;如果指定了这个标志,在复制成功后会关闭源句柄(这意味着对象的句柄计数不会增加)。
下面是一个简单的示例,创建一个作业对象(job object)并在同一进程中复制其句柄,同时减少访问掩码(省略了错误处理):
HANDLE hJob = ::CreateJobObject(nullptr , nullptr);
HANDLE hJob2;
::DuplicateHandle(::GetCurrentProcess(), hJob, ::GetCurrentProcess(), &hJob2,
JOB_OBJECT_ASSIGN_PROCESS | JOB_OBJECT_TERMINATE, FALSE, 0);
2
3
4
源进程和目标进程都是当前进程。运行这段代码并在进程资源管理器中查看句柄,可以看到差异(图2-19)。
图2-19:简单的句柄复制
一个句柄(0xac)对作业对象具有完全访问权限,而另一个(复制的)句柄(0xb0)仅具有指定的所需访问掩码。
在更常见的情况下,当前进程的句柄会被复制到目标协作进程。以下函数会将当前进程中的源句柄复制到目标进程:
HANDLE DuplicateToProcess(HANDLE hSource, DWORD pid) {
// 打开一个权限足够的目标进程句柄
HANDLE hProcess = ::OpenProcess(PROCESS_DUP_HANDLE, FALSE, pid);
if (!hProcess)
return nullptr;
HANDLE hTarget = nullptr;
// 复制
::DuplicateHandle(::GetCurrentProcess(), hSource, hProcess,
&hTarget, 0, FALSE, DUPLICATE_SAME_ACCESS);
// 清理
::CloseHandle(hProcess);
return hTarget;
}
2
3
4
5
6
7
8
9
10
11
12
13
这就是句柄复制变得复杂的情况。复杂之处不在于复制行为本身 —— 复制很简单,只需调用一个函数。问题在于如何将信息传递给目标进程。必须向目标进程传递两条信息:
- 句柄何时被复制。
- 复制后的句柄值是多少。
请记住,调用方知道创建的句柄值,但目标进程不知道。必须有其他形式的进程间通信(Inter - Process Communication,IPC),使调用方进程能够将所需信息传递给目标进程(因为它们属于同一系统,需要通过共享相关内核对象进行协作)。
在本书中,我们将研究各种进程间通信机制。
# 私有对象命名空间
我们已经了解到,某些类型的内核对象(kernel object)可以有基于字符串的名称。我们也知道,这是在进程间共享此类对象的一种(便捷)方式。然而,使用命名对象(named object)存在一些缺点:
- 其他一些不相关的进程可能会创建同名对象。如果对象类型不同,后续创建对象时可能会失败;更糟糕的是,如果对象类型相同,创建操作会 “成功”,代码会获取到现有对象的句柄。结果就会一团糟,进程使用的对象并非它们所期望的。
- 这是上述一点的特殊情况,在此强调。由于名称是可见的(在工具中可见,也可以通过编程方式获取),另一个进程可以 “劫持” 该对象,或以其他方式干扰对象的使用。从安全角度来看,相关对象过于暴露。未命名对象则隐蔽得多,因为没有办法轻易猜出某个特定对象的用途。
进程能否既方便地共享命名对象,又对其他进程不可见呢?从Windows Vista开始,有一种方法可以创建只有协作进程知道的私有对象命名空间(private object namespace)。使用工具或应用程序编程接口(API)都无法显示其完整名称。
“PrivateSharing” 示例应用程序是 “BasicSharing” 的增强版本,其中内存映射文件对象的名称现在位于私有对象命名空间下,并非对所有进程可见。使用进程资源管理器查看该对象时,只会显示部分名称(图2-20)。
图2-20:具有私有命名空间的命名对象
如果某个随机代码试图查找名为 “MySharedMem” 的对象,它将无法找到,因为这不是该对象的真实名称。
创建私有命名空间需要两个步骤。首先,必须创建一个名为边界描述符(Boundary Descriptor)的辅助对象。该描述符允许添加某些安全标识符(Security IDs,SIDs),这些标识符对应的用户能够使用基于该边界描述符创建的私有命名空间。这有助于加强对私有命名空间的安全性。要创建边界描述符,可以使用 CreateBoundaryDescriptor
函数:
HANDLE CreateBoundaryDescriptor(
_In_ LPCTSTR Name,
_In_ ULONG Flags); // 目前未使用
2
3
一旦创建了边界描述符,有两个函数可用于限制对通过该描述符创建的任何私有命名空间的访问:AddSIDToBoundaryDescriptor
和 AddIntegrityLabelToBoundaryDescriptor
(后者从Windows 7开始可用):
BOOL AddSIDToBoundaryDescriptor(
_Inout_ HANDLE* BoundaryDescriptor,
_In_ PSID RequiredSid);
BOOL AddIntegrityLabelToBoundaryDescriptor(
_Inout_ HANDLE * BoundaryDescriptor,
_In_ PSID IntegrityLabel);
2
3
4
5
6
7
这两个函数都接受边界描述符句柄的地址和一个安全标识符(SID)。使用 AddSIDToBoundaryDescriptor
函数时,安全标识符(SID)通常是某个组的安全标识符(SID),允许该组中的所有用户访问私有命名空间。AddIntegrityLabelToBoundaryDescriptor
函数允许为希望打开由该边界描述符管理的私有命名空间中的对象的进程设置最低完整性级别。
安全标识符(SIDs)和完整性级别将在第16章中讨论。
设置好边界描述符后,下一步是使用 CreatePrivateNamespace
函数创建实际的私有命名空间:
HANDLE CreatePrivateNamespace(
_In_opt_ LPSECURITY_ATTRIBUTES lpPrivateNamespaceAttributes,
_In_ LPVOID lpBoundaryDescriptor, // 边界描述符
_In_ LPCWSTR lpAliasPrefix); // 命名空间名称
2
3
4
容易混淆的是,边界描述符的类型是 void*
而不是 HANDLE
。这是API中的一个小失误,但由于 HANDLE
被定义为 void*
,所以不会有问题。这个失误也暗示了边界描述符不是内核对象,尽管它返回一个 HANDLE
;它有自己的关闭函数 DeleteBoundaryDescriptor
。
对象命名空间也不是真正的内核对象。如果命名空间已存在,该函数会失败,必须使用 OpenPrivateNamespace
函数代替。它也有自己的关闭函数(ClosePrivateNamespace
):
HANDLE OpenPrivateNamespaceW(
_In_ LPVOID lpBoundaryDescriptor,
_In_ LPCWSTR lpAliasPrefix); // 命名空间名称
BOOLEAN ClosePrivateNamespace(
_In_ HANDLE Handle,
_In_ ULONG Flags); // 0 或 PRIVATE_NAMESPACE_FLAG_DESTROY
2
3
4
5
6
7
另一个失误是 ClosePrivateNamespace
函数返回 BOOLEAN
(typedef 为 BYTE
),而不是标准的 BOOL
。
创建或打开命名空间后,可以正常创建命名对象,名称形式为 alias\name
,其中 “alias” 是创建或打开命名空间时 lpAliasPrefix
参数的值。
让我们看一下 “PrivateSharing” 应用程序中的具体代码。对话框类现在有三个成员:
private:
wil::unique_handle m_hSharedMem;
HANDLE m_hBD{ nullptr }, m_hNamespace{ nullptr };
2
3
代码使用WIL的 unique_handle
RAII包装器来管理内存映射文件的句柄,但边界描述符和命名空间则作为原始句柄进行管理。
创建对话框时,会像在 “BasicSharing” 中一样创建相同的内存映射文件,但这次是在私有命名空间下(为清晰起见,省略了错误处理):
// 创建边界描述符
m_hBD = ::CreateBoundaryDescriptor(L"MyDescriptor", 0);
BYTE sid[SECURITY_MAX_SID_SIZE];
auto psid = reinterpret_cast<PSID>(sid);
DWORD sidLen;
::CreateWellKnownSid(WinBuiltinUsersSid, nullptr, psid, &sidLen);
::AddSIDToBoundaryDescriptor(&m_hBD, psid);
// 创建私有命名空间
m_hNamespace = ::CreatePrivateNamespace(nullptr, m_hBD, L"MyNamespace");
if (!m_hNamespace) { // 可能已经创建了?
m_hNamespace = ::OpenPrivateNamespace(m_hBD, L"MyNamespace");
}
m_hSharedMem.reset(::CreateFileMapping(INVALID_HANDLE_VALUE, nullptr,
PAGE_READWRITE, 0, 1 << 12, L"MyNamespace\\MySharedMem"));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在这个例子中,向边界描述符添加了一个安全标识符(SID)。这个安全标识符(SID)对应所有标准用户。也可以添加更严格的安全标识符(SID),比如管理员组的安全标识符(SID),这样以标准用户权限运行的进程就无法访问这个边界描述符。通过调用 CreateWellKnownSid
函数,基于用户组的知名安全标识符(SID)创建安全标识符(SID)。然后调用 AddSIDToBoundaryDescriptor
函数将安全标识符(SID)附加到边界描述符上。
不用担心这些安全标识符(SIDs)和其他安全术语。第16章将详细介绍它们。
设置好边界描述符后,调用 CreatePrivateNamespace
或 OpenPrivateNamespace
函数,使用别名 “MyNamespace”。这个别名将用作使用 CreateFileMapping
函数创建的内存映射文件对象的前缀。
最后,对话框的 WM_DESTROY
消息处理程序会删除命名空间和边界描述符:
if (m_hNamespace)
::ClosePrivateNamespace(m_hNamespace, 0);
if (m_hBD)
::DeleteBoundaryDescriptor(m_hBD);
2
3
4
# 补充内容:用于私有命名空间的WIL包装器
WIL库为各种类型的句柄和指针提供了许多包装器。遗憾的是,它没有边界描述符和私有命名空间的包装器。幸运的是,创建这些包装器并不太难。以下是一种实现方法:
namespace wil {
static void close_private_ns(HANDLE h) {
::ClosePrivateNamespace(h, 0);
};
using unique_private_ns = unique_any_handle_null_only<decltype(
&close_private_ns), close_private_ns>;
using unique_bound_desc = unique_any_handle_null_only<decltype(
&::DeleteBoundaryDescriptor), ::DeleteBoundaryDescriptor>;
}
2
3
4
5
6
7
8
9
10
11
我不会详细讲解上述声明的细节,因为理解它们确实需要熟悉C++ 11中的decltype
、using
和模板。
“PrivateSharing2”项目与“PrivateSharing”项目类似,但使用WIL包装器(包含上述新增内容)来管理所有句柄,甚至包括MapViewOfFile
返回的指针。例如,下面是Read
函数:
wil::unique_mapview_ptr<void> buffer(::MapViewOfFile(
m_hSharedMem.get(), FILE_MAP_READ, 0, 0, 0));
if (!buffer) {
AtlMessageBox(m_hWnd, L"Failed to map memory", IDR_MAINFRAME);
return 0;
}
SetDlgItemText(IDC_TEXT, (PCWSTR)buffer.get());
2
3
4
5
6
7
8
# 其他对象和句柄
在内核编程的背景下,内核对象(Kernel objects)非常重要,也是本书的重点。Windows中还使用了其他常见对象,即用户对象(user objects)和图形设备接口对象(GDI objects)。以下是对这些对象及其句柄的简要介绍。
任务管理器(Task Manager)可以通过添加“用户对象”和“GDI对象”列来显示每个进程的此类对象数量,如图2-21所示。
图2-21:用户对象和GDI对象数量
# 用户对象
用户对象包括窗口(HWND)、菜单(HMENU)和钩子(HHOOK)。这些对象的句柄具有以下属性:
- 没有引用计数。第一个销毁用户对象的调用者将其销毁后,该对象就不存在了。
- 句柄值在窗口站(Window Station)范围内有效。一个窗口站包含剪贴板、桌面和原子表(atom table)。这意味着,例如,这些对象的句柄可以在共享桌面的所有应用程序之间自由传递。
本书后面将讨论窗口站和桌面这两个术语。原子表则不会讨论,因为它们与Windows中的用户界面子系统相关,而这不是本书的重点。
# GDI对象
图形设备接口(Graphics Device Interface,GDI)是Windows中的原始图形API,即使现在有更丰富、更优秀的API(例如Direct2D),它仍在使用。常见的GDI对象示例包括设备上下文(HDC)、画笔(HPEN)、画刷(HBRUSH)、位图(HBITMAP)等。它们具有以下属性:
- 没有引用计数。
- 句柄仅在创建它们的进程中有效。
- 不能在进程之间共享。
# 总结
在本章中,我们探讨了内核对象,以及通过句柄访问和共享它们的方式。我们没有深入研究任何特定的对象类型,因为这些将在其他章节中更详细地讨论。在下一章中,我们将深入探讨所有内核对象中最著名的——进程(process)。