第 4 章 对象管理 --- 4.3 句柄和句柄表(Handle & Handle Table)
4.3.0 框架图
以下 ASCII 图展示了 Windows/ReactOS 句柄体系的核心结构:从用户态 HANDLE 值的组成,到内核中三级页表式结构,再到一次完整的「打开 → 返回句柄 → 用户态使用 → 内核还原为对象指针」流程。
┌────────────────────────────────────────────────────────────────────────────┐
│ 用户态 HANDLE 值(32-bit / 64-bit) │
├────────────┬─────────────┬──────────────┬────────────┬────────────────────┤
│ KernelFlag│ HighIndex │ MidIndex │ LowIndex │ TagBits(低2位) │
│ (仅64位) │ │ │ │ │
└────────────┴─────────────┴──────────────┴────────────┴────────────────────┘
│
│ 系统调用:NtCreateEvent/OpenFile 等
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ HANDLE_TABLE(每个进程一份) │
├────────────────────────────────────────────────────────────────────────────┤
│ ULONG_PTR TableCode ; 低2位 = 层级(0/1/2), 高位=表指针 │
│ PEPROCESS QuotaProcess ; 配额进程 │
│ PVOID UniqueProcessId ; 进程 ID │
│ EX_PUSH_LOCK HandleTableLock[4] ; 4 把分段锁 │
│ LIST_ENTRY HandleTableList ; 全局句柄表链表 │
│ ULONG FirstFree ; 空闲链表头 │
│ ULONG NextHandleNeedingPool ; 下一次需分配页的句柄值 │
│ LONG HandleCount ; 已使用句柄数 │
└────────────────────────────────────────────────────────────────────────────┘
│
│ TableCode & ~3 → 根表指针
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ 三级页表式结构(类似 x86 页表) │
│ │
│ Level 0 (单页) Level 1 (两级) Level 2 (三级) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Entry[0] │ │ PageTbl* │──┐ │ MidTbl* │──┐ │
│ │ Entry[1] │ │ PageTbl* │ │ │ MidTbl* │ │ │
│ │ ... │ │ ... │ │ │ ... │ │ │
│ │Entry[N-1]│ │ PageTbl* │──┤ │ MidTbl* │ │ │
│ └──────────┘ └──────────┘ │ └──────────┘ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Entry[0] │ │ PageTbl* │──┐ │
│ │ Entry[1] │ │ PageTbl* │ │ │
│ │ ... │ │ ... │ │ │
│ └──────────┘ └──────────┘ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ Entry[0] │ │
│ │ Entry[1] │ │
│ │ ... │ │
│ └──────────┘ │
└────────────────────────────────────────────────────────────────────────────┘
│
│ LowIndex 索引 Entry
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ HANDLE_TABLE_ENTRY(每个句柄 16 字节) │
├────────────────────────────────────────────────────────────────────────────┤
│ union { PVOID Object; ULONG_PTR ObAttributes; ... } ; 对象头指针 + 标志位 │
│ union { ULONG GrantedAccess; LONG NextFreeTableEntry; } ; 访问掩码 / 空闲链 │
└────────────────────────────────────────────────────────────────────────────┘
│
│ & ~7 → POBJECT_HEADER
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ OBJECT_HEADER → 对象体 │
│ PointerCount / HandleCount / Type / NameInfoOffset / ... → PVOID ObjectBody │
└────────────────────────────────────────────────────────────────────────────┘
╔══════════════════════════════ 完整调用流 ══════════════════════════════════╗
║ ║
║ 用户态程序 ║
║ │ ║
║ ├─ CreateEvent(NULL, FALSE, FALSE, NULL); ║
║ │ │ ║
║ │ └──▶ kernel32!CreateEventW ─▶ ntdll!NtCreateEvent ─┐ ║
║ │ │ ║
║ ──────────────────── 系统调用边界 ─────────────────────── ║
║ │ │ ║
║ 内核 ║
║ │ │ ║
║ ├─ NtCreateEvent(...) ║
║ │ ├─ ObCreateObject(...) → 分配 Event 对象体 + OBJECT_HEADER ║
║ │ ├─ ObInsertObject(...) ║
║ │ │ └─ ObpCreateHandle(...) ║
║ │ │ └─ ExpAllocateHandleTableEntry → ExCreateHandle ║
║ │ │ └─ 从空闲链表取 Entry / 必要时分配新页表 ║
║ │ │ └─ 写入 Entry.Object = POBJECT_HEADER ║
║ │ │ └─ Entry.GrantedAccess = EVENT_ALL_ACCESS ║
║ │ └─ 返回 HANDLE 值(TagBits + Low/Mid/High Index) ║
║ │ ║
║ │ ║
║ ├─ 用户态使用 HANDLE: SetEvent(hEvent); ║
║ │ └─ ntdll!NtSetEvent ──▶ 内核 ║
║ │ └─ ObReferenceObjectByHandle(hEvent, ...) ║
║ │ ├─ 检查 KernelFlag → 选择当前进程 / 系统句柄表 ║
║ │ └─ ExpLookupHandleTableEntry → 根据 High/Mid/Low 定位 ║
║ │ └─ 提取 Entry.Object → 去除低 3 位 TagBits → POBJECT_HEADER ║
║ │ └─ OBJECT_TO_OBJECT_HEADER → 检查 Type 是否匹配 ║
║ │ └─ 检查 GrantedAccess & DesiredAccess ║
║ │ └─ InterlockedIncrement(PointerCount) ║
║ │ └─ 返回对象体指针 PKEVENT ║
║ │ ║
║ ├─ 用户态关闭句柄: CloseHandle(hEvent); ║
║ └─ ntdll!NtClose ──▶ 内核 ║
║ └─ ObpCloseHandleTableEntry ║
║ └─ ExDestroyHandle → 将 Entry 挂回空闲链表 ║
║ └─ InterlockedDecrement(HandleCount) ║
║ └─ HandleCount == 0 时调用对象类型的 CloseProcedure ║
║ └─ ObDereferenceObject → PointerCount == 0 → 释放内存 ║
║ ║
╚════════════════════════════════════════════════════════════════════════════╝
框架图深度解读:
这幅图展示了句柄系统的完整架构,从用户态的 HANDLE 值到内核态的对象指针。理解这幅图的关键在于把握三个核心概念:
第一:HANDLE 值的编码方式
图中顶部的 HANDLE 值分解展示了句柄是如何编码的:
- TagBits(低 2 位):始终为 0(或特殊标志),用于快速验证句柄合法性
- LowIndex:在叶子页表中的索引,范围 0~511(每页 512 个 Entry)
- MidIndex:在中间页表中的索引,范围 0~511
- HighIndex:在根页表中的索引,范围 0~511
- KernelFlag(仅 64 位):标识是否为内核句柄
这种编码方式与 x86 的虚拟地址结构完全同构:
- x86 虚拟地址:
PageDirIndex(10) + PageTblIndex(10) + PageOffset(12) - HANDLE 值:
HighIndex + MidIndex + LowIndex + TagBits
第二:三级页表式结构的动态扩展
句柄表采用三级结构,但并非一开始就是三级:
- Level 0(单页):句柄数 ≤ 512 时,直接使用单页存储所有 Entry
- Level 1(两级):句柄数 > 512 时,分配一个根页表指向多个叶子页表
- Level 2(三级):句柄数 > 512×512 时,分配中间页表层
这种动态扩展机制使得句柄表可以支持从几个句柄到数百万句柄的平滑过渡,而不会浪费内存。
第三:句柄表项(HANDLE_TABLE_ENTRY)的双用途
每个 Entry 有两种状态:
- 已使用状态 :
Object字段指向OBJECT_HEADER,GrantedAccess存储权限掩码 - 空闲状态 :
NextFreeTableEntry指向下一个空闲 Entry,形成空闲链表
这种设计使得句柄分配和释放都是 O(1) 操作:
- 分配:从
FirstFree取出第一个空闲 Entry - 释放:将 Entry 插入到空闲链表头部
完整调用流的三个阶段:
图中底部的完整调用流展示了句柄的三个生命周期阶段:
-
创建阶段(左侧):
ObCreateObject分配对象内存ObpCreateHandle在句柄表中分配 Entry- 返回
HANDLE值给用户态
-
使用阶段(中间):
- 用户态传入
HANDLE ObReferenceObjectByHandle查找 Entry- 验证权限、递增引用计数
- 返回对象指针给内核使用
- 用户态传入
-
关闭阶段(右侧):
NtClose触发句柄关闭- 将 Entry 挂回空闲链表
- 递减引用计数,可能触发对象销毁
4.3.0.1 设计意图
核心问题:用户态进程不能、也不应该直接看到或操作内核对象的内存地址。一方面,地址空间隔离(用户态无法访问内核虚拟地址)阻止了它;另一方面,安全策略(不同进程对同一内核对象拥有不同权限)要求必须有一层间接引用。
设计哲学 :「句柄(HANDLE)」是一种 能力(Capability) 设计:持有一个句柄即表示拥有对底层对象的特定权限(GrantedAccess),且只有通过这个句柄(由内核验证)才能操作对象。这比「返回指针 + 权限检查」更安全------用户态根本没有伪造指针的机会。
本节定位 :我们将自底向上地剖析 ReactOS 中 HANDLE 值编码 → HANDLE_TABLE 三级结构 → HANDLE_TABLE_ENTRY 字段 → 句柄创建/引用/关闭全流程 → 内核句柄表 → 跨进程句柄复制 等完整话题,所有结构均引用 ReactOS 源码。
4.3.1 为什么需要句柄
从安全隔离的角度
用户态进程运行在自己的虚拟地址空间中,内核态对象体位于系统地址空间。即使用户态能够偶然得到一个内核虚拟地址,MMU 也会阻止它访问(Ring 3 无法访问 Ring 0 页)。因此必须由内核在内核态「代为访问」对象,并通过一个整数索引(即句柄)让用户态在系统调用中引用之。
句柄作为能力(Capability)
句柄不仅仅是索引,它还携带:
- 安全边界 :内核在每次
ObReferenceObjectByHandle时验证句柄值的合法性(是否位于已分配页、Entry 是否被使用、权限位是否足够)。 - 抽象封装 :用户态不需要知道
KEVENT、EPROCESS、FILE_OBJECT等结构的布局,它们统一由HANDLE表示。 - 跨进程共享:同一内核对象可以同时被多个进程以不同句柄值引用(例如父子进程共享事件对象)。每个进程的句柄表各自独立,权限也可以不同。
与 Unix 文件描述符(fd)的对比
| 特性 | Windows 句柄 HANDLE | Unix 文件描述符 fd |
|---|---|---|
| 本质 | 内核对象的间接引用 | 打开文件表的索引 |
| 可继承 | 由 OBJ_INHERIT 标志决定 |
由 FD_CLOEXEC 反向决定 |
| 跨进程传递 | NtDuplicateObject(显式复制) |
SCM_RIGHTS Unix socket / fork 继承 |
| 类型感知 | 创建时指定类型,引用时检查类型 | 一切皆文件,不检查「类型」 |
| 权限掩码 | GrantedAccess 存在每个 Entry 中 |
由打开时 flags 决定,存于 struct file |
| 内核句柄 | 有系统进程句柄表,供驱动使用 | 由内核自己直接操作 struct file* |
4.3.2 HANDLE_TABLE:三级页表式结构
结构定义
HANDLE_TABLE 定义在 sdk/include/ndk/extypes.h:787-828(file:///d:/reactos/sdk/include/ndk/extypes.h#L787-L828):
c
typedef struct _HANDLE_TABLE
{
ULONG_PTR TableCode; // 低 2 位:层级 (0/1/2);高位:实际表指针
PEPROCESS QuotaProcess; // 配额进程(向谁收取配额)
PVOID UniqueProcessId; // 进程 ID
EX_PUSH_LOCK HandleTableLock[4]; // 4 把分段锁,支持并发
LIST_ENTRY HandleTableList; // 挂入全局 HandleTableListHead
EX_PUSH_LOCK HandleContentionEvent; // 用于 Entry 锁竞争时的等待
PHANDLE_TRACE_DEBUG_INFO DebugInfo; // 调试信息
LONG ExtraInfoPages; // 额外审计页
ULONG FirstFree; // 空闲链表头(HANDLE 值)
ULONG LastFree; // 空闲链表尾
ULONG NextHandleNeedingPool; // 下一个需分配页的句柄
LONG HandleCount; // 已使用句柄数(原子增减)
union { // 标志位
ULONG Flags;
UCHAR StrictFIFO : 1; // 是否严格 FIFO(缺省 0 = LIFO 重用)
};
} HANDLE_TABLE, *PHANDLE_TABLE;
每个 EPROCESS 拥有一个 ObjectTable 字段指向其 HANDLE_TABLE。系统进程(PID 4)额外有一份内核句柄表 ObpKernelHandleTable(见 ntoskrnl/ob/obhandle.c:20(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L20-L20))。
为什么用三级索引
这与 x86 虚拟地址 → 物理地址的映射(Page Directory → Page Table → Page Frame)完全同构。设计目的是 稀疏性 + 可扩展性:
- Level 0(单页) :刚创建的进程只有极少句柄,只需一页(
PAGE_SIZE字节)就能容纳PAGE_SIZE / sizeof(HANDLE_TABLE_ENTRY)个条目,足够日常使用(x86 下 ≈ 256 个)。 - Level 1(两级) :句柄数超过一页后,扩展为一级指针表(Mid Table),每个表项指向一个底层的 Entry 页。每个 Mid Table 是一页,可容纳
PAGE_SIZE / sizeof(PVOID)个指针。 - Level 2(三级) :再次超过时,在最上层加一级 High Table(High Index → Mid Table 指针),这样整个空间可以映射到系统允许的最多句柄数(约 16M 句柄,受限于
MAX_HIGH_INDEX)。
与 x86 页表的对比如下:
| 句柄表层级 | 角色 | 类比 x86 页表 |
|---|---|---|
| TableCode & 3 == 2 → 三级 | HighIndex → MidTable* | Page Directory Index → PDE* |
| TableCode & 3 == 1 → 两级 | MidIndex → PageTable* | Page Table Index → PTE* |
| LowIndex → EntryLowIndex | LowIndex → Entry* | Offset within Page |
EXHANDLE:HANDLE 值的位字段解析
HANDLE 值本身不是一个简单索引,它被编码为多个位域。定义见 ntoskrnl/include/internal/ex.h:89-108(file:///d:/reactos/ntoskrnl/include/internal/ex.h#L89-L108):
c
// 位宽宏(ex.h:78-88)
#define HANDLE_LOW_BITS (PAGE_SHIFT - 3) // x86: 9 位
#define HANDLE_HIGH_BITS (PAGE_SHIFT - 2) // x86: 10 位
#define HANDLE_TAG_BITS 2 // 恒为 2
#define HANDLE_INDEX_BITS (HANDLE_LOW_BITS + 2 * HANDLE_HIGH_BITS)
#define KERNEL_FLAG_BITS (sizeof(ULONG_PTR) * 8 - HANDLE_INDEX_BITS - HANDLE_TAG_BITS)
typedef union _EXHANDLE
{
struct
{
ULONG_PTR TagBits: HANDLE_TAG_BITS; // 低 2 位:TagBits(快速验证)
ULONG_PTR Index: HANDLE_INDEX_BITS; // 真正的线性索引
ULONG_PTR KernelFlag: KERNEL_FLAG_BITS; // 最高位:内核句柄标志
};
struct
{
ULONG_PTR TagBits2: HANDLE_TAG_BITS;
ULONG_PTR LowIndex: HANDLE_LOW_BITS; // 页内索引
ULONG_PTR MidIndex: HANDLE_HIGH_BITS; // 中间层索引
ULONG_PTR HighIndex: HANDLE_HIGH_BITS; // 顶层索引
ULONG_PTR KernelFlag2: KERNEL_FLAG_BITS;
};
HANDLE GenericHandleOverlay; // 与 HANDLE 兼容
ULONG_PTR Value; // 原始整数值
ULONG AsULONG; // 32 位视图
} EXHANDLE, *PEXHANDLE;
以 32 位 x86 为例(PAGE_SHIFT=12):
32-bit HANDLE:
31 30 29 ... 21 20 ... 11 10 9 ... 2 1 0
└────┬──────┘ └─────┬─────┘ └───┬───┘ └┬┘
│ │ │ └── TagBits (2 位, 恒 0)
│ │ └──────── LowIndex (9 位 = 0..511)
│ └──────────────────── MidIndex (10 位 = 0..1023)
└─────────────────────────────────── HighIndex/KernelFlag
(HighIndex 占 10 位,KernelFlag 在 32 位下为 0)
在 64 位系统中,
KernelFlag占若干高位,当最高位置位时表示这是内核句柄(Kernel Handle),指向系统句柄表而非进程句柄表。用户态试图引用它会直接返回STATUS_INVALID_HANDLE。
各级条目数宏(LOW/MID/HIGH)
c
// 见 ntoskrnl/include/internal/ex.h:155-164
#define LOW_LEVEL_ENTRIES (PAGE_SIZE / sizeof(HANDLE_TABLE_ENTRY))
#define MID_LEVEL_ENTRIES (PAGE_SIZE / sizeof(PHANDLE_TABLE_ENTRY))
#define HIGH_LEVEL_ENTRIES (16777216 / (LOW_LEVEL_ENTRIES * MID_LEVEL_ENTRIES))
#define MAX_LOW_INDEX LOW_LEVEL_ENTRIES
#define MAX_MID_INDEX (MID_LEVEL_ENTRIES * LOW_LEVEL_ENTRIES)
#define MAX_HIGH_INDEX (MID_LEVEL_ENTRIES * MID_LEVEL_ENTRIES * LOW_LEVEL_ENTRIES)
由于 sizeof(HANDLE_TABLE_ENTRY) == 16(见下一节),在 x86 4KB 页下:
| 层级 | 每页条目数 | 索引位宽 | 容量 |
|---|---|---|---|
| LOW_LEVEL_ENTRIES | 4096 / 16 = 256 |
LowIndex(8~9 位) | 一页 Entry |
| MID_LEVEL_ENTRIES | 4096 / 4 = 1024(指针大小) |
MidIndex(10 位) | 1024 × 256 = 262144 句柄 |
| HIGH_LEVEL_ENTRIES | 由 16M / 262144 推导 | HighIndex(10 位) | 最多约 16M 句柄 |
TableCode 的特殊编码
HANDLE_TABLE.TableCode 字段的低 2 位同时承担了「表层级」角色:
TableCode & 3 |
含义 |
|---|---|
| 0 | Level 0 单页:TableCode & ~3 直接指向 Entry 页 |
| 1 | Level 1 两级:TableCode & ~3 指向 Mid Table(指针数组),每个指针 → Entry 页 |
| 2 | Level 2 三级:TableCode & ~3 指向 High Table,每个指针 → Mid Table → Entry 页 |
这个编码技巧避免了增加额外字段,同时使得 ExpLookupHandleTableEntry 可以用一段 switch/case 统一处理三种规模。
从 HANDLE 值到 Entry 的定位算法
核心函数 ExpLookupHandleTableEntry 见 ntoskrnl/ex/handle.c:41-103(file:///d:/reactos/ntoskrnl/ex/handle.c#L41-L103):
c
PHANDLE_TABLE_ENTRY
NTAPI
ExpLookupHandleTableEntry(IN PHANDLE_TABLE HandleTable, IN EXHANDLE Handle)
{
ULONG TableLevel;
ULONG_PTR TableBase;
PHANDLE_TABLE_ENTRY HandleArray, Entry;
PVOID *PointerArray;
Handle.TagBits = 0; // 清除 tag,仅保留索引
if (Handle.Value >= HandleTable->NextHandleNeedingPool) return NULL; // 越界
TableBase = HandleTable->TableCode;
TableLevel = (ULONG)(TableBase & 3); // 取层级
TableBase &= ~3; // 清低位,得到真实指针
PointerArray = (PVOID*)TableBase;
HandleArray = (PHANDLE_TABLE_ENTRY)TableBase;
switch (TableLevel)
{
case 2:
PointerArray = PointerArray[Handle.HighIndex]; // 查 High Table
ASSERT(PointerArray != NULL);
// fall through
case 1:
HandleArray = PointerArray[Handle.MidIndex]; // 查 Mid Table
ASSERT(HandleArray != NULL);
// fall through
case 0:
Entry = &HandleArray[Handle.LowIndex]; // 查 Low 表项
break;
default:
ASSERT(FALSE);
Entry = NULL;
}
return Entry;
}
该函数的复杂度为 O(1),最多 3 次指针解引用。它也与 x86 的页表遍历过程在结构上是等价的:
虚拟地址 → [PD Index] → PDE → [PT Index] → PTE → [Offset] → 物理页
HANDLE → [HighIndex] → MidTable → [MidIndex] → EntryPage → [LowIndex] → Entry
4.3.3 HANDLE_TABLE_ENTRY:句柄表项详解
8/16 字节条目的精确定义
在 ReactOS 中,HANDLE_TABLE_ENTRY 定义在 sdk/include/ndk/extypes.h:766-785(file:///d:/reactos/sdk/include/ndk/extypes.h#L766-L785):
c
typedef struct _HANDLE_TABLE_ENTRY
{
union
{
PVOID Object; // 对象头指针(OBJECT_HEADER*)
ULONG_PTR ObAttributes; // 句柄属性(OBJ_INHERIT 等);低 3 位用作锁/标志
PHANDLE_TABLE_ENTRY_INFO InfoTable;
ULONG_PTR Value; // 原始值,用于原子交换
};
union
{
ULONG GrantedAccess; // 授予的访问掩码
struct { USHORT GrantedAccessIndex; USHORT CreatorBackTraceIndex; };
LONG NextFreeTableEntry; // 当条目空闲时,指向空闲链表中的下一项
};
} HANDLE_TABLE_ENTRY, *PHANDLE_TABLE_ENTRY;
第一个 ULONG_PTR:Object 指针 + TagBits + 锁位
- Object 指针 :实际指向
OBJECT_HEADER(而不是对象体)。引用方需要用OBJECT_TO_OBJECT_HEADER/ObpGetHandleObject之类的宏进行转换。 - 低 3 位(TagBits / Lock Bits) :
- ReactOS 中
EXHANDLE_TABLE_ENTRY_LOCK_BIT宏(ex.h:149(file:///d:/reactos/ntoskrnl/include/internal/ex.h#L149-L149))定义了1作为锁位,但实际锁定逻辑通过InterlockedCompareExchangePointer对Object字段整体进行原子操作------当EXHANDLE_TABLE_ENTRY_LOCK_BIT被清除时表示「已被锁定」,其他 CPU 若并发操作将自旋直到解锁者通过InterlockedOr再次置位并唤醒等待者。见ExpLockHandleTableEntry/ExUnlockHandleTableEntry(handle.c:882-940(file:///d:/reactos/ntoskrnl/ex/handle.c#L882-L940))。 - 使用低位的原因:
OBJECT_HEADER总是至少 8 字节对齐,真实对象指针的低 3 位必然为 0,因此可以借用来编码标志。
- ReactOS 中
第二个 ULONG:GrantedAccess 与空闲链表
- 使用中 :
GrantedAccess(访问掩码),例如EVENT_ALL_ACCESS、FILE_READ_DATA | FILE_WRITE_DATA等。这是 per-handle 的权限------同一FILE_OBJECT被不同进程打开时,各自句柄的GrantedAccess可以不同。 - 空闲时 :
NextFreeTableEntry作为LONG,承载下一个空闲条目的HANDLE值(或 0 表示链表结束)。这让整个句柄表支持 LIFO 空闲链表------最近被释放的句柄值会被最快重新分配,提高缓存命中率。
为什么用 16 字节(而不是更大)
- 空间折中:一个典型的 x86 桌面应用通常打开数百到数千个句柄。每个条目 16 字节 × 10000 条目 ≈ 160KB,处于可接受的开销。
- 性能折中 :Entry 正好占用 2 个指针大小(2×
sizeof(PVOID)+ 2×ULONG→ 注意 64 位下是 16 字节),对齐良好,CPU 缓存友好。 - 字段刚好够用:一个条目只需要对象指针 + 访问掩码 + 锁位,不需要更多。
Entry 结构设计的精妙之处
HANDLE_TABLE_ENTRY 的设计体现了多种优化技巧:
1. 指针低位复用(TagBits)
Object 字段的低 3 位被复用为标志位:
- 位 0:锁标志(Lock Bit)
- 位 1-2:其他属性标志
这种复用之所以可行,是因为:
OBJECT_HEADER总是至少 8 字节对齐- 真实指针的低 3 位必然为 0
- 因此可以安全地借用这些位来编码额外信息
2. Union 的双用途设计
第二个 Union 字段有两种用途:
- 已分配状态 :
GrantedAccess存储权限掩码 - 空闲状态 :
NextFreeTableEntry指向下一个空闲 Entry
这种设计避免了为空闲 Entry 分配额外的链表节点------Entry 本身就是链表节点。
3. 原子操作支持
Entry 的锁定通过 InterlockedCompareExchangePointer 实现:
c
// 锁定 Entry
PVOID OldValue = Entry->Object;
if (InterlockedCompareExchangePointer(&Entry->Object, OldValue | LOCK_BIT, OldValue) == OldValue) {
// 成功锁定
}
这种无锁设计避免了传统锁的开销,同时保证了多核环境下的正确性。
Entry 的生命周期
空闲链表 → ExpAllocateHandleTableEntry → 分配 → 写入 Object / GrantedAccess
│
▼
使用中(被 ObReferenceObjectByHandle 引用)
│
▼
ObpCloseHandleTableEntry → ExDestroyHandle
│
▼
Entry.Object = NULL;插入空闲链表尾部
空闲链表由 HANDLE_TABLE.FirstFree / LastFree 维护(见 ExpFreeHandleTableEntry handle.c:277-327(file:///d:/reactos/ntoskrnl/ex/handle.c#L277-L327))。
EXHANDLE 的位编码与快速验证
EXHANDLE(见 4.3.2)之所以把 TagBits 放在最低 2 位而不是把 Index 紧凑地直接映射到 0, 1, 2, ...,就是为了让 非法句柄值(如 NULL=0x0、INVALID_HANDLE_VALUE=(HANDLE)-1)自动落在可被检测的 TagBits 范围之外------这允许内核以简单的位运算拒绝无效值,而不必真的去查三级表。
4.3.4 ObpCreateHandle:句柄的创建
典型的「对象创建 → 返回句柄」完整流程
以 NtCreateEvent 为例(内部会走 ObCreateObject → ObInsertObject → ObpCreateHandle):
- Step 1 :分配对象体
ObCreateObject。从分页/非分页池分配OBJECT_HEADER + sizeof(KEVENT),初始化PointerCount = 1、HandleCount = 0、Type = ExEventObjectType,填充NameInfoOffset等元数据。 - Step 2 :插入对象目录(
ObInsertObject)。若是命名对象(\BaseNamedObjects\MyEvent)则在目录中建立名字 → 对象头的映射;否则匿名。 - Step 3 :
ObpCreateHandle→ 调用底层ExCreateHandle,从当前进程的HANDLE_TABLE分配一个空闲条目。 - Step 4 :填充
Entry.Object = ObjectHeader(含标志位)、Entry.GrantedAccess = GrantedAccess。 - Step 5 :把对应的
HANDLE值(TagBits = 0、Index = 该条目在线性空间中的位置)通过系统调用返回值写入用户态。
ObpCreateHandle 的内部实现
核心代码见 ntoskrnl/ob/obhandle.c:1495-1680(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L1495-L1680),简化的伪代码:
c
NTSTATUS ObpCreateHandle(OB_OPEN_REASON OpenReason, PVOID Object,
POBJECT_TYPE Type, PACCESS_STATE AccessState,
ULONG AdditionalReferences, ULONG HandleAttributes,
POBP_LOOKUP_CONTEXT Context, KPROCESSOR_MODE AccessMode,
PVOID *ReturnedObject, PHANDLE ReturnedHandle)
{
// 1. 类型一致性检查(如果调用方传了 Type)
if (Type && ObjectHeader->Type != Type) return STATUS_OBJECT_TYPE_MISMATCH;
// 2. 如果是内核句柄(OBJ_KERNEL_HANDLE),使用系统句柄表并 Attach 到系统进程
if (HandleAttributes & OBJ_KERNEL_HANDLE) {
HandleTable = ObpKernelHandleTable;
if (PsGetCurrentProcess() != PsInitialSystemProcess) {
KeStackAttachProcess(&PsInitialSystemProcess->Pcb, &ApcState);
AttachedToProcess = TRUE;
}
} else {
HandleTable = PsGetCurrentProcess()->ObjectTable;
}
// 3. 更新对象头计数(HandleCount、Process 句柄计数、CreatorInfo、Type 统计)
Status = ObpIncrementHandleCount(Object, AccessState, AccessMode,
HandleAttributes, PsGetCurrentProcess(), OpenReason);
// 4. 写入 GrantedAccess
DesiredAccess = AccessState->RemainingDesiredAccess | AccessState->PreviouslyGrantedAccess;
GrantedAccess = DesiredAccess & (ObjectType->TypeInfo.ValidAccessMask | ACCESS_SYSTEM_SECURITY);
NewEntry.GrantedAccess = GrantedAccess;
// 5. 释放 lookup context(对象现在已经通过 HandleTable 被引用)
if (Context) ObpReleaseLookupContext(Context);
// 6. 真正分配句柄条目(底层:ExpAllocateHandleTableEntry)
Handle = ExCreateHandle(HandleTable, &NewEntry);
// 7. 如果是内核句柄,设置最高位
if (KernelHandle) Handle = ObMarkHandleAsKernelHandle(Handle);
// 8. 返回句柄给调用方(最终将写入用户态输出参数)
*ReturnedHandle = Handle;
return STATUS_SUCCESS;
}
ExCreateHandle(底层)
ExCreateHandle 位于 ntoskrnl/ex/handle.c:825-854(file:///d:/reactos/ntoskrnl/ex/handle.c#L825-L854),它做两件事:
ExpAllocateHandleTableEntry从空闲链表取一个空闲条目(必要时调用ExpAllocateHandleTableEntrySlow分配新页表并扩展NextHandleNeedingPool);- 写入对象指针 +
GrantedAccess,并解锁条目。
进程配额检查
ObpChargeQuotaForObject 在打开时向 QuotaProcess(通常即当前进程)收取池配额,若配额耗尽则失败。这限制了单个进程通过打开大量句柄耗尽系统池内存的能力。详见 obhandle.c:429-484(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L429-L484)。
句柄创建流程的设计分析
句柄创建流程涉及多个层次的协作,体现了分层设计的思想:
第一层:系统调用层(NtCreateXxx)
系统调用处理函数负责:
- 参数验证(用户态指针、缓冲区大小)
- 调用对象管理器的创建接口
- 将句柄返回给用户态
第二层:对象管理器层(ObpCreateHandle)
对象管理器负责:
- 权限计算(
GrantedAccess) - 配额检查(
ObpChargeQuotaForObject) - 调用句柄表管理器分配 Entry
第三层:句柄表管理器层(ExCreateHandle)
句柄表管理器负责:
- 从空闲链表分配 Entry
- 必要时扩展页表结构
- 写入 Entry 内容
关键设计决策:为什么先检查配额再分配句柄?
配额检查在句柄分配之前执行,原因:
- 避免资源浪费:如果配额不足,不应该分配句柄 Entry
- 简化错误处理:配额失败可以直接返回错误,无需回滚句柄分配
- 公平性:配额检查确保资源分配的公平性
关键设计决策:为什么使用 LIFO 空闲链表?
句柄表使用 LIFO(后进先出)空闲链表:
- 缓存友好:最近释放的 Entry 很可能还在 Cache 中
- 减少碎片:频繁使用的 Entry 集中在少数页中
- 简化实现 :LIFO 只需维护一个
FirstFree指针
4.3.5 ObReferenceObjectByHandle:句柄查对象
完整转换流程
用户态把一个 HANDLE 值作为系统调用参数传入内核时,内核通过 ObReferenceObjectByHandle 将其还原为对象指针并引用。核心流程:
- Step 1 验证句柄合法性 :
HandleToLong(Handle) < 0→ 可能是特殊伪句柄NtCurrentProcess()(-1)、NtCurrentThread()(-2),或是内核句柄标志位。- 伪句柄直接在当前
EPROCESS/ETHREAD上增加PointerCount并返回。 - 内核句柄(KernelFlag 置位)仅允许来自
KernelMode,否则直接返回STATUS_INVALID_HANDLE。
- Step 2 定位 Entry :调用
ExMapHandleToPointer(内部调用ExpLookupHandleTableEntry+ExpLockHandleTableEntry)定位并锁定条目。 - Step 3 取出 Object :从
Entry.Object得到POBJECT_HEADER,用OBJECT_TO_OBJECT_HEADER/ObpGetHandleObject还原对象头指针(去除低 3 位标志)。 - Step 4 得到对象体 :
Object = &ObjectHeader->Body。 - Step 5 验证权限 :检查
Entry.GrantedAccess & DesiredAccess == DesiredAccess。若调用方是用户态但权限不足 →STATUS_ACCESS_DENIED。 - Step 6 验证对象类型 :若调用方传入了
ObjectType(例如PsProcessType),则要求ObjectHeader->Type == ObjectType,否则 →STATUS_OBJECT_TYPE_MISMATCH。 - Step 7 增加 PointerCount :
InterlockedIncrement(&ObjectHeader->PointerCount)。 - Step 8 解锁 Entry 并返回对象体指针。
实现细节(ObReferenceObjectByHandle)
函数位于 ntoskrnl/ob/obref.c:492-...(file:///d:/reactos/ntoskrnl/ob/obref.c#L492-L630)。关键片段:
c
NTSTATUS ObReferenceObjectByHandle(HANDLE Handle, ACCESS_MASK DesiredAccess,
POBJECT_TYPE ObjectType, KPROCESSOR_MODE AccessMode,
PVOID *Object, POBJECT_HANDLE_INFORMATION HandleInfo)
{
*Object = NULL;
// 处理伪句柄:NtCurrentProcess() / NtCurrentThread()
if (HandleToLong(Handle) < 0) {
if (Handle == NtCurrentProcess()) { /* 对当前 EPROCESS 计数并返回 */ }
else if (Handle == NtCurrentThread()) { /* 对当前 ETHREAD 计数并返回 */ }
else if (AccessMode == KernelMode) {
// 内核态:把它看作内核句柄表中的索引
Handle = ObKernelHandleToHandle(Handle);
HandleTable = ObpKernelHandleTable;
} else {
return STATUS_INVALID_HANDLE; // 用户态不允许引用内核句柄
}
} else {
HandleTable = PsGetCurrentProcess()->ObjectTable;
}
// 进入临界区、查句柄表并上锁
KeEnterCriticalRegion();
HandleEntry = ExMapHandleToPointer(HandleTable, Handle);
if (HandleEntry) {
ObjectHeader = ObpGetHandleObject(HandleEntry);
GrantedAccess = HandleEntry->GrantedAccess;
// 类型检查
if (ObjectType && ObjectHeader->Type != ObjectType) {
ExUnlockHandleTableEntry(HandleTable, HandleEntry);
KeLeaveCriticalRegion();
return STATUS_OBJECT_TYPE_MISMATCH;
}
// 权限检查(内核态绕过)
if (AccessMode != KernelMode && (~GrantedAccess & DesiredAccess)) {
ExUnlockHandleTableEntry(HandleTable, HandleEntry);
KeLeaveCriticalRegion();
return STATUS_ACCESS_DENIED;
}
// 引用计数 +1
InterlockedIncrementSizeT(&ObjectHeader->PointerCount);
// 返回句柄信息(若调用方要求)
if (HandleInfo) {
HandleInfo->HandleAttributes = HandleEntry->ObAttributes & OBJ_HANDLE_ATTRIBUTES;
HandleInfo->GrantedAccess = GrantedAccess;
}
ExUnlockHandleTableEntry(HandleTable, HandleEntry);
KeLeaveCriticalRegion();
*Object = &ObjectHeader->Body;
return STATUS_SUCCESS;
}
KeLeaveCriticalRegion();
return STATUS_INVALID_HANDLE;
}
错误路径一览
| 错误 | 来源 |
|---|---|
STATUS_INVALID_HANDLE |
句柄值超出 NextHandleNeedingPool、或对应 Entry 的 Object 为 NULL(已关闭) |
STATUS_ACCESS_DENIED |
GrantedAccess 缺少 DesiredAccess 要求的位 |
STATUS_OBJECT_TYPE_MISMATCH |
ObjectHeader->Type != ObjectType |
句柄查找机制的设计分析
ObReferenceObjectByHandle 是用户态与内核态之间最重要的桥梁函数。它的设计体现了多种安全原则:
1. 多层验证机制
查找过程包含多层验证:
- 句柄合法性验证:检查句柄值是否在有效范围内
- Entry 状态验证:检查 Entry 是否已分配(Object != NULL)
- 权限验证:检查 GrantedAccess 是否满足 DesiredAccess
- 类型验证:检查对象类型是否匹配
每一层验证都有明确的失败原因,便于调试和错误报告。
2. 引用计数的原子操作
引用计数通过 InterlockedIncrement 递增:
c
InterlockedIncrementSizeT(&ObjectHeader->PointerCount);
这种原子操作保证了多线程环境下的正确性:
- 多个线程可以同时引用同一对象
- 引用计数不会出现竞态条件
- 对象不会在使用中被意外释放
3. 临界区保护
函数使用临界区(KeEnterCriticalRegion / KeLeaveCriticalRegion)保护:
- 防止 APC(异步过程调用)打断查找过程
- 保证查找操作的原子性
- 避免死锁和竞态条件
4. 伪句柄的特殊处理
伪句柄(NtCurrentProcess = -1, NtCurrentThread = -2)有特殊处理路径:
- 不需要查找句柄表
- 直接返回当前进程/线程对象
- 不创建新的句柄 Entry
这种设计避免了为"当前进程/线程"创建额外句柄的开销。
5. 内核态的权限绕过
当 AccessMode == KernelMode 时,权限检查被绕过:
c
if (AccessMode != KernelMode && (~GrantedAccess & DesiredAccess)) {
return STATUS_ACCESS_DENIED;
}
这允许内核代码以任意权限访问对象,简化了内核实现。
4.3.6 句柄的关闭与继承
ObCloseHandle 实现
用户态调用 CloseHandle(hXxx) 最终走 kernel32!CloseHandle → ntdll!NtClose → ObCloseHandle → ObpCloseHandle → ObpCloseHandleTableEntry。核心代码:
c
// [obhandle.c:3377-3406] ObCloseHandle / NtClose
NTSTATUS NTAPI ObCloseHandle(HANDLE Handle, KPROCESSOR_MODE AccessMode)
{
return ObpCloseHandle(Handle, AccessMode);
}
NTSTATUS NTAPI NtClose(HANDLE Handle)
{
return ObpCloseHandle(Handle, ExGetPreviousMode());
}
ObpCloseHandleTableEntry(obhandle.c:683-779(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L683-L779))的关闭流程:
- 从
Entry.Object得到OBJECT_HEADER; - 若对象类型注册了
OkayToCloseProcedure,调用它以允许驱动否决关闭(例如某些文件系统过滤); - 检查
OBJ_PROTECT_CLOSE(通过GrantedAccess & ObpAccessProtectCloseBit判断)------若用户态尝试关闭受保护句柄则返回STATUS_HANDLE_NOT_CLOSABLE,在调试版本甚至会触发异常; ExDestroyHandle:将 Entry 归还空闲链表;ObpDecrementHandleCount:对象头HandleCount--;如果为 0 且该类型维护了 Process 句柄计数,同时清理进程条目;调用CloseProcedure;ObDereferenceObject:PointerCount--,归零时调用DeleteProcedure并释放池内存。
OBJ_INHERIT 标志与句柄继承
句柄属性标志位于 sdk/include/ndk/obtypes.h:30-51(file:///d:/reactos/sdk/include/ndk/obtypes.h#L30-L51):
c
#define OBJ_INHERIT 0x00000002L // 子进程创建时继承
#define OBJ_PERMANENT 0x00000010L // 对象永久存在(即使 HandleCount=0)
#define OBJ_EXCLUSIVE 0x00000020L // 独占打开
#define OBJ_CASE_INSENSITIVE 0x00000040L
#define OBJ_OPENIF 0x00000080L
#define OBJ_KERNEL_HANDLE 0x00000200L // 插入系统句柄表
#define OBJ_FORCE_ACCESS_CHECK 0x00000400L
在 NtCreateUserProcess 路径中,若父进程句柄表中某个条目带有 OBJ_INHERIT,会在子进程句柄表中复制一个条目(新的 HANDLE 值、相同 Object 指针、继承而来的 GrantedAccess),并对对象头 HandleCount 累加。
为什么 CloseHandle 可从用户态直接调用?
NtClose 不检查「这个句柄是谁打开的」------它只验证「该 HANDLE 值是否是当前进程句柄表中一个有效条目」。任何持有该句柄的代码都可以关闭它,这是能力模型的核心属性:能力持有者对能力有最终处置权 (但 OBJ_PROTECT_CLOSE 可以禁止关闭,受调试器/驱动用途使用)。
4.3.7 内核句柄(Kernel Handle)
什么是内核句柄
内核句柄引用的是 系统进程句柄表 ObpKernelHandleTable(见 obhandle.c:20(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L20-L20)),而不是当前进程的句柄表。内核组件(驱动、对象管理器自身)经常需要:
- 在不关联到任何用户进程的情况下持有对对象的引用;
- 不希望句柄被用户态
NtClose误关闭; - 跨进程访问对象(例如文件系统缓存对
FILE_OBJECT的引用)。
KernelFlag 位与 ObMarkHandleAsKernelHandle
- 内核句柄在 32 位系统上由最高位(或 64 位系统的
KernelFlag字段)标识。任何用户态把它传入ObReferenceObjectByHandle时,因为AccessMode != KernelMode,直接返回STATUS_INVALID_HANDLE(见ObReferenceObjectByHandle的 early-exit 分支)。 ObMarkHandleAsKernelHandle在创建内核句柄时把标识位置位;ObKernelHandleToHandle逆向去除该标志,得到真正的索引值去查系统句柄表。ObIsKernelHandle(obhandle.c:3523-3529(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L3523-L3529))仅检查KernelFlag位。
内核句柄的特殊规则
- 只能在内核态引用;
- 不受「当前进程退出时清理句柄表」影响(因为它在系统进程句柄表里);
- 需要内核代码显式调用
ZwClose/ObCloseHandle关闭,否则会造成句柄泄漏。
4.3.8 概念解释(术语表)
| 术语 | 定义 | 所在文件 |
|---|---|---|
| HANDLE | 用户态对内核对象的间接引用;本质是一个位编码的索引(见 EXHANDLE) |
ntoskrnl/include/internal/ex.h:89-108(file:///d:/reactos/ntoskrnl/include/internal/ex.h#L89-L108) |
| HANDLE_TABLE | 每个进程一份的根结构,维护三级页表和空闲链表、句柄计数、表级锁 | sdk/include/ndk/extypes.h:787-828(file:///d:/reactos/sdk/include/ndk/extypes.h#L787-L828) |
| HANDLE_TABLE_ENTRY | 每个句柄一个条目,存储对象指针 + 低 3 位锁标志;GrantedAccess;空闲时存 NextFree | sdk/include/ndk/extypes.h:766-785(file:///d:/reactos/sdk/include/ndk/extypes.h#L766-L785) |
| EXHANDLE | HANDLE 的位字段视图:TagBits / LowIndex / MidIndex / HighIndex / KernelFlag |
ntoskrnl/include/internal/ex.h:89-108(file:///d:/reactos/ntoskrnl/include/internal/ex.h#L89-L108) |
| TableCode | HANDLE_TABLE 的第一个字段,把表指针与层级(0/1/2)编码在同一 ULONG_PTR 中 |
同上 |
| NextHandleNeedingPool | 下一次分配新页时起始句柄值;用于判断 ExpLookupHandleTableEntry 越界 |
HANDLE_TABLE |
| FirstFree / LastFree | 空闲条目链表的头/尾;新分配从 FirstFree 取,关闭时插入 |
HANDLE_TABLE |
| TagBits | EXHANDLE 低 2 位,保持为 0,使 NULL=0 等非法值可被快速检测 |
EXHANDLE |
| GrantedAccess | 该句柄持有的访问掩码,由打开时的安全检查决定;per-handle | HANDLE_TABLE_ENTRY 的第二个 union |
| OBJ_INHERIT | 句柄属性标志,表示该句柄在子进程创建时被继承 | sdk/include/ndk/obtypes.h:34(file:///d:/reactos/sdk/include/ndk/obtypes.h#L34-L34) |
| OBJ_KERNEL_HANDLE | 句柄属性标志,创建时插入系统句柄表,置位 KernelFlag | sdk/include/ndk/obtypes.h:40(file:///d:/reactos/sdk/include/ndk/obtypes.h#L40-L40) |
| ProtectFromClose | 通过 GrantedAccess 特定位实现;禁止用户态 NtClose 关闭该句柄 |
ObpCloseHandleTableEntry 中检测 |
| Kernel Handle | 最高位(KernelFlag)置位的句柄,指向系统进程句柄表;仅供内核态 | ObMarkHandleAsKernelHandle |
| Handle Leak(句柄泄漏) | 句柄被创建后从未关闭,导致 HandleCount 持续增长,耗尽句柄表池配额 |
进程退出时会整体释放句柄表 |
| ExpAllocateHandleTableEntrySlow | 句柄表扩容路径:分配新页 / 升级到下一层级并更新 NextHandleNeedingPool |
ntoskrnl/ex/handle.c:500-644(file:///d:/reactos/ntoskrnl/ex/handle.c#L500-L644) |
4.3.9 为什么要这样设计
Q1:为什么不直接把对象指针返回给用户态?
用户态没有访问内核地址空间的权限(Ring 3 不能访问 Ring 0 页)。
即使能访问,也无法强制安全检查------用户态可以跳过检查直接读写对象,等于放弃所有安全模型。
对象地址会随着对象释放/重分配变「野」,用户态无从得知句柄是否仍然有效。
内核对象池可能被压缩/重排(Windows/ReactOS 虽不做对象池移动,但对象被释放后的内存可立即重用为他物)。句柄通过内核维护的一层间接引用解决了「悬挂指针」问题。
Q2:为什么句柄表用三级结构(而不是数组或链表)?稀疏性:大多数进程只打开少量句柄(< 100)。如果句柄表是一个大数组预分配 16M × 16 字节 = 256MB,完全不可行。
可扩展性:极端进程(大型数据库、I/O 密集型服务)可能需要打开数万个句柄。三级结构平滑支持从 256 → 256k → 16M 的扩展。
O(1) 查找:与链表相比,三级索引的查找时间与句柄总数无关。
与页表同构:内核对页表式结构已很熟悉;TableCode 低 2 位编码层级的技巧省掉了额外字段。
Q3:为什么GrantedAccess存在于每个 Entry 中,而不是对象头里?权限是 per-handle 的 :同一对象可以被多个进程以不同权限打开(例如进程 A 以
GENERIC_READ打开文件F,进程 B 以GENERIC_ALL打开同一文件)。若把权限放在对象头里,就无法区分它们。继承与复制语义 :
NtDuplicateObject允许以不同权限复制句柄到目标进程,这必须以 per-handle 方式存储。最小权限原则 :即使拥有对对象的完全权限,打开时也应该只申请需要的权限。每个句柄独立记录
GrantedAccess,正是实现该原则的基础。Q4:为什么需要 Kernel Handle(单独的系统句柄表)?
- 生命周期解耦 :驱动打开的对象不应随某个用户进程退出而被关闭。系统句柄表属于
PsInitialSystemProcess,永不退出。- 安全隔离 :用户态无法引用内核句柄(由
KernelFlag位拦截),防止用户无意或恶意关闭驱动句柄。- 跨进程一致:驱动可以在任意线程上下文中使用同一个内核句柄值,无需担心当前进程的句柄表是否不同。
4.3.10 增强子节:句柄表的并发与锁
设计意图
句柄表是每个进程最频繁被触及的内核结构之一:线程创建/退出、文件打开/关闭、事件/互斥体引用......多个线程可能同时对句柄表进行分配、引用、关闭操作。如果句柄表被单一的全局自旋锁保护,高并发场景下锁竞争将成为严重瓶颈。ReactOS 的设计目标是:在保证正确性的前提下,尽可能降低锁粒度与持有时间。
锁层次一览
ReactOS 使用三层并发机制来保护句柄表:
| 层次 | 锁机制 | 用途 | 源码位置 |
|---|---|---|---|
| 1. 表级分段锁 | EX_PUSH_LOCK HandleTableLock[4] |
保护空闲链表、表扩容、HandleCount 更新。共 4 把,按句柄值哈希到不同锁以减少冲突 | HANDLE_TABLE |
| 2. Entry 级锁位 | EXHANDLE_TABLE_ENTRY_LOCK_BIT(Object 字段低 1 位) |
保护单个 Entry 的字段读写;使用 Interlocked 原子操作,不占额外空间 |
ntoskrnl/ex/handle.c:882-940(file:///d:/reactos/ntoskrnl/ex/handle.c#L882-L940) |
| 3. 等待/唤醒 | EX_PUSH_LOCK HandleContentionEvent |
当 Entry 被锁而无法立刻获取时,线程在此等待;解锁者通过它唤醒等待者 | HANDLE_TABLE |
Entry 级锁的精妙之处
ExpLockHandleTableEntry 通过对 Entry.Object 字段进行 InterlockedCompareExchangePointer 来原子地清除 EXHANDLE_TABLE_ENTRY_LOCK_BIT。这样的设计:
- 零额外空间 :锁位寄生在 Object 指针的最低位上(因为
OBJECT_HEADER是 8 字节对齐的,最低 3 位总是 0); - 细粒度:两个线程若分别访问不同句柄,根本不会相互阻塞;
- 无锁常见路径 :对于只是查 Entry 的路径(例如
NtDuplicateObject)只需要锁定所触及的 Entry,不需要拿整表锁; - 可重入/可嵌套:因为每个 Entry 有自己独立的锁位。
简化的锁定逻辑:
c
BOOLEAN ExpLockHandleTableEntry(PHANDLE_TABLE HandleTable,
PHANDLE_TABLE_ENTRY HandleTableEntry)
{
for (;;) {
OldValue = *(volatile LONG_PTR *)&HandleTableEntry->Object;
if (OldValue & EXHANDLE_TABLE_ENTRY_LOCK_BIT) {
// 未被锁 → 清除锁位以表示锁定
NewValue = OldValue & ~EXHANDLE_TABLE_ENTRY_LOCK_BIT;
if (InterlockedCompareExchangePointer(
&HandleTableEntry->Object,
(PVOID)NewValue, (PVOID)OldValue) == (PVOID)OldValue) {
return TRUE; // 成功获取锁
}
} else {
if (!OldValue) return FALSE; // Entry 已空闲(对象指针为 NULL)
// 已被他人锁住 → 阻塞等待
ExpBlockOnLockedHandleEntry(HandleTable, HandleTableEntry);
}
}
}
VOID ExUnlockHandleTableEntry(PHANDLE_TABLE HandleTable,
PHANDLE_TABLE_ENTRY HandleTableEntry)
{
// 重新置位 LOCK_BIT(=1 表示解锁)并唤醒等待者
InterlockedOr((PLONG)&HandleTableEntry->Value, EXHANDLE_TABLE_ENTRY_LOCK_BIT);
ExfUnblockPushLock(&HandleTable->HandleContentionEvent, NULL);
}
表级锁与空闲链表
ExpAllocateHandleTableEntry(ntoskrnl/ex/handle.c:681-797(file:///d:/reactos/ntoskrnl/ex/handle.c#L681-L797))在从 FirstFree 取条目时会使用分段锁 HandleTableLock[Index % 4]。这把 PushLock 只在修改空闲链表与 HandleCount 时持有,持有时间非常短。
Rundown 机制(进程退出时清理所有句柄)
当进程准备退出(PspExitProcess)时,系统需要释放它的整个句柄表。这是一个典型的 Rundown 场景:
- 阻止新句柄的创建(不再允许从
FirstFree分配); - 遍历所有已使用句柄,对每个条目执行
ObpCloseHandleTableEntry(递减 HandleCount、关闭对象); - 调用
ExDestroyHandleTable释放HANDLE_TABLE本体及其所有 Entry 页; - 最后通知对象管理器释放所有仅被该进程引用的对象。
为什么 Entry 用 lock bit 而不是 SpinLock 保护整个表?
这是一个经典的「空间 vs 竞争」折中:
- 如果用一个全局 SpinLock 保护整个
HANDLE_TABLE,在多线程进程中锁的持有者在等待池分配或执行复杂对象清理时会阻塞所有其他句柄操作,扩展性很差。 - 如果为每个 Entry 都分配一个独立的 SpinLock(4-8 字节),对 16M 句柄表而言就是数百 MB 额外开销。
- Entry lock bit 折中方案 :利用 Object 指针天然冗余的低位来编码锁状态。既获得了 Entry 级的并发性,又不花费额外空间。代价是锁被持有时必须等待(因为没有独立的等待队列头部,需要使用
HandleContentionEvent聚合等待)。
4.3.11 增强子节:句柄泄漏检测与调试
什么是句柄泄漏
句柄泄漏 指进程持续创建句柄(打开文件/事件/键/线程等)却忘记调用 CloseHandle / NtClose 释放,导致:
HANDLE_TABLE.HandleCount单调增长;- 空闲链表永远得不到补充,系统不断分配新的 Entry 页;
- 内核池(Paged Pool)被耗尽;
- 关联的内核对象(
FILE_OBJECT、KEY_VALUE_ENTRY等)无法被释放,加剧内存占用。
典型症状:Process Explorer 中看到某个进程的 Handles 列在持续增长而不回落;系统日志出现 STATUS_NO_MEMORY 或 STATUS_INSUFFICIENT_RESOURCES。
WinDbg / kd 调试扩展命令
在 Windows 内核调试器或 ReactOS 调试环境中可用的典型命令:
!handle <handle> <flags> <process> ; 查看单个句柄或所有句柄
!htrace <process> -enable ; 开启句柄堆栈跟踪(需要 OBJECT_HANDLE_TRACE_INFO)
!htrace <process> -diff ; 对比两个时间点的句柄差异,找出泄漏点
!object <address> ; 查看 OBJECT_HEADER 及其类型/名称
!process <eprocess> 0 ; 列出进程 EPROCESS 及其句柄表信息
ReactOS 源码中 HANDLE_TRACE_DEBUG_INFO 结构(见 sdk/include/ndk/extypes.h:751-759(file:///d:/reactos/sdk/include/ndk/extypes.h#L751-L759))提供了 DebugInfo 扩展字段,用于记录句柄创建时的调用栈。
Process Explorer / Process Hacker
Process Explorer(Sysinternals)与 Process Hacker 可显示:
- 每个进程打开的句柄数量、类型分布(File/Key/Thread/Event/Mutant...);
- 单个句柄的
GrantedAccess、对象名称、在内核中的地址; - 句柄随时间变化的图表(便于发现泄漏趋势)。
OBJECT_HEADER 中的计数
OBJECT_HEADER 维护两个独立的引用计数(见 sdk/include/ndk/obtypes.h:485-505(file:///d:/reactos/sdk/include/ndk/obtypes.h#L485-L505)):
c
typedef struct _OBJECT_HEADER
{
LONG_PTR PointerCount; // 指针引用数(每 ObReferenceObject +1)
union {
LONG_PTR HandleCount; // 句柄引用数(每 ObpCreateHandle +1)
volatile PVOID NextToFree;
};
POBJECT_TYPE Type;
UCHAR NameInfoOffset;
UCHAR HandleInfoOffset;
UCHAR QuotaInfoOffset;
UCHAR Flags;
...
QUAD Body; // 对象体(KEVENT / FILE_OBJECT / ...)
} OBJECT_HEADER, *POBJECT_HEADER;
HandleCount表示该对象被多少个句柄引用;当它归零时,对象将被「断开连接」(从命名空间移除、关闭文件链接等)。PointerCount表示对象头被引用的总次数(包含句柄引用 + 内部ObReferenceObject指针引用);当它归零时,对象体与对象头被释放回池。
对象类型 OBJECT_TYPE 还维护了全系统 TotalNumberOfObjects 与 TotalNumberOfHandles 计数器(见 sdk/include/ndk/obtypes.h:386-393(file:///d:/reactos/sdk/include/ndk/obtypes.h#L386-L393)),可用于判断是「哪种对象在泄漏」。
NtQueryInformationObject 的 HandleFlag 信息类
NtQueryInformationObject(见 ObjectBasicInformation、ObjectHandleFlagInformation 等信息类)允许用户态或内核查询对象的基本属性。OBJECT_HANDLE_ATTRIBUTE_INFORMATION(sdk/include/ndk/obtypes.h:270-275(file:///d:/reactos/sdk/include/ndk/obtypes.h#L270-L275))返回指定句柄是否可继承、是否受保护关闭:
c
typedef struct _OBJECT_HANDLE_ATTRIBUTE_INFORMATION
{
BOOLEAN Inherit; // 是否设置了 OBJ_INHERIT
BOOLEAN ProtectFromClose; // 是否受 ProtectFromClose 保护
} OBJECT_HANDLE_ATTRIBUTE_INFORMATION, *POBJECT_HANDLE_ATTRIBUTE_INFORMATION;
为什么 NtClose 一个无效句柄会崩溃(在调试版中)
调试构建下 NtClose 对无效句柄不是返回错误而是引发用户态异常(或 KeBugCheckEx),这是一个有意的设计:让开发人员更早地发现句柄泄漏/误用。正常的 Release 构建中则返回 STATUS_INVALID_HANDLE 错误码让程序自行处理。
4.3.12 增强子节:DuplicateHandle 与跨进程句柄传递
NtDuplicateObject 的实现流程
用户态 DuplicateHandle(hSourceProc, hSource, hTargetProc, &hTarget, access, inherit, options) 在底层调用 NtDuplicateObject,后者在 ntoskrnl/ob/obhandle.c:3408-3521(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L3408-L3521) 实现。核心流程:
- 验证源进程句柄 :
ObReferenceObjectByHandle(SourceProcessHandle, PROCESS_DUP_HANDLE, PsProcessType, ...)------ 调用者必须对源进程拥有PROCESS_DUP_HANDLE权限。 - 验证目标进程句柄 (如果提供):同样要求对目标进程拥有
PROCESS_DUP_HANDLE权限。 - 查源句柄对应的对象 :在源进程句柄表中定位
SourceHandle,得到OBJECT_HEADER和GrantedAccess。 - 在目标进程句柄表中创建新条目 :调用
ObpCreateHandle(或底层ExCreateHandle),写入目标进程自己的HANDLE_TABLE_ENTRY。 - 写入输出句柄 :如果提供了
TargetHandle输出参数,将新句柄值写入用户态缓冲区(受 SEH 保护)。 - 清理与引用计数处理 :
ObDereferenceObject源进程/目标进程对象。
简化伪代码:
c
NTSTATUS NtDuplicateObject(HANDLE SourceProcessHandle, HANDLE SourceHandle,
HANDLE TargetProcessHandle, PHANDLE TargetHandle,
ACCESS_MASK DesiredAccess, ULONG HandleAttributes, ULONG Options)
{
// 1. 引用源进程(需要 PROCESS_DUP_HANDLE 权限)
Status = ObReferenceObjectByHandle(SourceProcessHandle, PROCESS_DUP_HANDLE,
PsProcessType, PreviousMode,
(PVOID*)&SourceProcess, NULL);
if (!NT_SUCCESS(Status)) return Status;
// 2. 引用目标进程(如果提供)
if (TargetProcessHandle) {
Status = ObReferenceObjectByHandle(TargetProcessHandle, PROCESS_DUP_HANDLE,
PsProcessType, PreviousMode,
(PVOID*)&TargetProcess, NULL);
} else {
TargetProcess = NULL; // 表示目标为当前进程(或特殊语义)
}
// 3. 在目标进程中创建新句柄
Status = ObDuplicateObject(SourceProcess, SourceHandle, TargetProcess,
&NewHandle, DesiredAccess, HandleAttributes,
Options, PreviousMode);
// 4. 把新句柄值写回用户态
if (TargetHandle && NT_SUCCESS(Status)) {
_SEH2_TRY {
*TargetHandle = NewHandle;
} _SEH2_EXCEPT(EXCEPTION_EXECUTE_HANDLER) {
Status = _SEH2_GetExceptionCode();
} _SEH2_END;
}
// 5. 释放对源/目标进程的引用
if (TargetProcess) ObDereferenceObject(TargetProcess);
ObDereferenceObject(SourceProcess);
return Status;
}
Options 标志位
Options 参数由下列标志按位 OR 组合:
| 标志 | 值 | 含义 |
|---|---|---|
| 0 | --- | 复制句柄;新句柄将拥有 DesiredAccess 权限(可以比源句柄更严格) |
DUPLICATE_CLOSE_SOURCE |
0x00000001 |
复制后自动关闭源句柄(相当于原子「移动」句柄) |
DUPLICATE_SAME_ACCESS |
0x00000002 |
忽略 DesiredAccess 参数,使用源句柄原有的 GrantedAccess |
DUPLICATE_SAME_ATTRIBUTES |
0x00000004 |
复制时保留源句柄的 OBJ_INHERIT / OBJ_PROTECT_CLOSE 等属性 |
跨进程句柄传递的用途
- 父子进程通信:父进程创建一对命名/匿名管道(或 Section 对象),把子端句柄复制到子进程句柄表中,从而建立 IPC 通道。
- 服务向应用传递文件句柄:系统服务以 SYSTEM 权限打开一个特权文件,然后把文件句柄复制到普通用户应用的句柄表中,实现「代理打开」。
- 调试器 :Windows 调试器需要对被调试进程的句柄进行枚举与操作(读写内存、挂起线程),通过
DuplicateHandle从目标进程复制出线程/内存句柄。
为什么不能直接传递 HANDLE 值?(为什么需要 NtDuplicateObject?)
一个看似简单的问题------为什么不能直接把源进程中 hFile = 0x24 的值作为参数传递给目标进程,让目标进程直接使用它?答案是:句柄值是进程私有的索引值,不是全局标识符。
两个进程中的 HANDLE = 0x24 指向各自 HANDLE_TABLE 中完全不同的 HANDLE_TABLE_ENTRY,进而指向完全不同的内核对象。跨进程传递句柄必须由内核亲自做两件事:
- 在源进程句柄表中查找
HANDLE值 → 得到对象指针; - 在目标进程句柄表中分配一个新 Entry → 写入同一对象指针,并递增
HandleCount。
NtDuplicateObject 正是完成这两个步骤的唯一合法路径。
4.3.13 小结(增强版)
本节核心知识点回顾
- HANDLE 是一个位编码的索引值 ,由
EXHANDLE解析;最低 2 位TagBits恒为 0,使得NULL / INVALID_HANDLE_VALUE等非法值可被快速识别;KernelFlag高位标识内核句柄。 - HANDLE_TABLE 使用三级页表式结构 :
TableCode的低 2 位编码层级(0/1/2),高位为实际指针;稀疏地支持从 256 → 256k → 16M 句柄。 - HANDLE_TABLE_ENTRY 每个条目 16 字节 :第一个指针大小字段存
Object指针(低 3 位兼做锁位);第二个 32 位字段在使用时存GrantedAccess,空闲时存空闲链表NextFreeTableEntry。 - 句柄创建走
ObpCreateHandle → ExCreateHandle → ExpAllocateHandleTableEntry;句柄引用走ObReferenceObjectByHandle → ExpLookupHandleTableEntry;句柄关闭走ObCloseHandle → ObpCloseHandleTableEntry → ExDestroyHandle。 - 内核句柄(Kernel Handle) 使用独立的
ObpKernelHandleTable,由KernelFlag位标识,用户态无法引用。 - 句柄表并发 :表级分段
PushLock(4 把)+ Entry 级 lock bit(在 Object 指针低位)的双层锁设计,在保证正确性的前提下实现高并发。 - 句柄泄漏 :未关闭句柄导致
HandleCount持续增长;可通过!handle、Process Explorer、对象类型计数器进行定位。 - 跨进程句柄传递 :只能通过
NtDuplicateObject;不能直接传递 HANDLE 值,因为它是进程私有索引。
对照表 1:HANDLE 值各字段含义(32 位系统)
| 位段 | 宽度 | 含义 |
|---|---|---|
| TagBits | 0-1 | 恒为 0;用于快速识别 NULL 等非法句柄 |
| LowIndex | 2-10 | Entry 在 Entry 页中的索引(9 位,0-511) |
| MidIndex | 11-20 | Mid Table 索引(10 位,0-1023);Level 0 时未使用 |
| HighIndex | 21-30 | High Table 索引(10 位);Level 0/1 时未使用 |
| KernelFlag | 31 | 内核句柄标志;0=普通句柄,1=内核句柄 |
对照表 2:句柄操作 API vs 内核实现函数
| Win32 API / Nt 系列 | 对象管理器层 | 执行体层(Ex) | 功能 |
|---|---|---|---|
打开对象(如 CreateFileW) |
ObInsertObject → ObpCreateHandle |
ExCreateHandle → ExpAllocateHandleTableEntry |
在当前进程句柄表创建新条目并返回 HANDLE 值 |
NtSetInformationObject / 使用句柄 |
ObReferenceObjectByHandle |
ExpLookupHandleTableEntry |
查句柄表、验证权限和类型、递增 PointerCount、返回对象体指针 |
CloseHandle(h) |
ObCloseHandle → NtClose → ObpCloseHandleTableEntry |
ExDestroyHandle → ExpFreeHandleTableEntry |
定位 Entry、调用对象类型 CloseProcedure、递减 HandleCount、将 Entry 归还给空闲链表 |
DuplicateHandle |
NtDuplicateObject → ObDuplicateObject → ObpCreateHandle |
ExCreateHandle |
从源进程句柄表查对象、在目标进程创建新句柄条目 |
后续章节展望(4.4 对象的创建 · 4.5 常用内核函数)
本节详细讨论了句柄表的数据结构、生命周期和并发控制。然而,对象管理器的知识体系在「4.3 句柄表」之后并未结束。第 4 章还包含以下两节深入内容,它们是读者真正上手阅读 ReactOS 源码时最常查阅的章节:
4.4 对象的创建(Object Creation)
这一节将把前面 4.1 ~ 4.3 分别讨论的「对象头」「对象类型」「句柄表」三者串联起来,形成一条完整的调用链:
- 从
ObCreateObject到ObInsertObject:一个内核对象如何从分配池内存、初始化OBJECT_HEADER、插入对象目录,到最终生成用户态句柄的完整流程。 - 参数验证与安全检查 :
OBJECT_ATTRIBUTES如何被解析、DesiredAccess如何被校验、安全描述符如何被应用。 - 配额扣除与还原 :对象创建时如何从进程配额中扣除
NonPagedPoolCharge/PagedPoolCharge;失败时如何正确回滚。 - 创建信息(CreateInfo)与审计 :
OB_CREATE_INFO结构如何记录本次创建的元信息;哪些类型会触发审计日志。 - 多阶段创建的代码追踪 :对比「
NtCreateEvent」「NtCreateFile」「NtCreateProcess」三类典型对象创建 API,归纳它们在对象管理器层的共性与差异。
4.5 几个常用的内核函数
阅读 ReactOS 源码时,以下三个函数是读者在 ntoskrnl/ob/ 与其他子系统(ps、io、se)中最频繁遇到的「基础设施函数」。本节将对它们逐行拆解:
-
4.5.1
ObReferenceObjectByHandle()--- 用户态 HANDLE → 内核对象指针的标准转换函数。它在NtReadFile、NtWaitForSingleObject、NtQueryInformationProcess等几乎所有接受句柄参数的系统调用中被第一行调用。本节将逐行分析其参数验证、Entry 查找、权限检查、类型检查、引用计数递增的完整流程,并特别关注「调用者必须显式ObDereferenceObject」这一容易出错的规则。 -
4.5.2
ObReferenceObjectByPointer()--- 在内核态已有对象体指针但需要把它「转换为带引用计数的可靠指针」时使用。典型场景:驱动程序内部拿到对象指针后,在访问前调用一次ObReferenceObjectByPointer以安全地递增PointerCount,防止对象在操作期间被释放。本节将讨论它与ObReferenceObjectByHandle的差异------不查句柄表、不走权限检查、但必须保证对象指针的类型正确性。 -
4.5.3
ObpLookupEntryDirectory()--- 名称查找引擎的核心。在 4.1 节讨论命名空间时已提及,但本节将从代码层面逐段解析:如何在OBJECT_DIRECTORY的 37 个 Hash Bucket 中查找指定名称;如何处理大小写不敏感的比较;如何在找到OBJECT_DIRECTORY_ENTRY后递增LookupContext;如何在查找失败时回退创建新条目;以及OBP_LOOKUP_CONTEXT如何缓存查找上下文以加速「先查后创建」的操作。
这三节读完后,读者应当能够独立阅读 ReactOS 中任何一条涉及对象管理的调用链,例如 NtCreateFile 内部如何调用 ObCreateObject → ObpLookupObjectName → ObInsertObject → ObpCreateHandle 的完整链路。