第 4 章 对象管理 --- 4.1 对象与对象目录
4.1.0 框架图
下图展示了从用户态创建一个内核对象(以事件对象 Event 为例)到最终返回句柄的完整流程。整个流程横跨用户态、系统调用层、对象管理器与内核内存池四个层次:
┌──────────────────────────────────────────────────────────────────────────┐
│ 用户态 (User Mode) │
│ │
│ HANDLE hEvent = CreateEvent(NULL, FALSE, FALSE, L"MyEvent"); │
│ │ │
│ ▼ 系统调用 (syscall) │
├──────────────────────────────────────────────────────────────────────────┤
│ 内核态 (Kernel Mode) │
│ │
│ NtCreateEvent(...) │
│ │ │
│ ▼ │
│ ObCreateObject(ProbeMode, EventType, ObjectAttrs, ..., &ObjectBody) │
│ │ │
│ ├───① 计算总分配大小: Header + Info块 + Body │
│ ├───② ExAllocatePoolWithTag(NonPagedPool, TotalSize, 'even') │
│ ├───③ 初始化 OBJECT_HEADER (PointerCount=1, HandleCount=0, ...) │
│ └───④ 返回指向 Body 的指针 (*Object = &Header->Body) │
│ │
│ │ │
│ ▼ 后续继续执行: │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ KeInitializeEvent(&Header->Body, ...) // 初始化对象体内部字段 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ObInsertObject(ObjectBody, ..., &Handle) │
│ │ │
│ ├───① 在对象目录中查找名称 "\BaseNamedObjects\MyEvent" │
│ ├───② 若名称已存在 → STATUS_OBJECT_NAME_COLLISION │
│ ├───③ 若名称不存在 → ObpInsertEntryDirectory 插入到 Hash Bucket │
│ └───④ 在进程句柄表中分配句柄 (Handle Table Entry) │
│ │
│ │ │
│ ▼ │
│ return STATUS_SUCCESS; // 返回句柄值 (如 0x48) 到用户态 │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 用户态返回后 hEvent = 0x48,后续使用 WaitForSingleObject(hEvent) │ │
│ │ → NtWaitForSingleObject → ObReferenceObjectByHandle → 取对象指针│ │
│ └──────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
内存布局(低地址 → 高地址,示意):
┌──────────────────────────────────────────────────────────────┐
│ [CreatorInfo] [NameInfo] [HandleInfo] [QuotaInfo] [Header] [Body]│
│ (可选) (可选) (可选) (可选) 固定 可变│
│◄────────────────────负偏移量定位──────────────────► │
│ Header.NameInfoOffset = 距离 Header 的字节数│
└──────────────────────────────────────────────────────────────┘
对象目录(Hash 表):
┌──────────┐
│ Bucket 0 │→ Entry{Object: "\BaseNamedObjects"} → Entry → NULL
├──────────┤
│ Bucket 1 │→ NULL
├──────────┤
│ ... │
├──────────┤
│ Bucket N │→ Entry{Object: "MyEvent"} → Entry{Object: "OtherEvent"} → NULL
└──────────┘
(共 37 个 Bucket)
图解说明:
-
用户态 → 内核态 :
CreateEvent是 Win32 API,内部通过ntdll!NtCreateEvent进入内核。内核中NtCreateEvent是系统调用处理函数。从图中可以看出,用户态看到的HANDLE只是一个整型值(如0x48),而内核态把它映射到完整的OBJECT_HEADER+KEVENT对象体结构。 -
对象创建 :
ObCreateObject(定义于 ntoskrnl/ob/oblife.c(file:///d:/reactos/ntoskrnl/ob/oblife.c#L1037-L1132))负责从池中分配OBJECT_HEADER+ 各 Info 块 + 对象体(Body),并返回 Body 指针。这里的关键点在于一次分配 ------Header、Info 块、Body 在同一次ExAllocatePoolWithTag调用中连成一片连续内存,而不是各自独立分配。这避免了碎片化,同时也保证了 Header 与 Body 之间有固定的位置偏移。 -
对象初始化 :对象体内部字段(如事件对象的
KEVENT结构)由类型专属的初始化函数完成(例如KeInitializeEvent)。这一步在ObCreateObject返回之后由调用方执行------对象管理器只负责"外壳"(Header + Info 块 + Body 的分配),不关心 Body 内部的具体语义。 -
命名空间插入 :
ObInsertObject会调用ObpLookupObjectName与ObpInsertEntryDirectory将对象插入到OBJECT_DIRECTORY的 Hash Bucket 链中。图中的 37 个 Bucket 数组表示一个 Hash 表------每个 Bucket 指向一个单链表。\BaseNamedObjects\MyEvent经过ObpLookupObjectName先逐段解析出\BaseNamedObjects目录,再在其中查找MyEvent。这是一个逐层遍历的过程,完全类比文件系统的路径解析。 -
句柄分配 :在当前进程的句柄表(Handle Table)中分配一个 Entry,将其指向该对象的
OBJECT_HEADER。在第 4.3 节中我们会看到,句柄表采用三层索引结构(类似 x86 页表),每个 Entry 8 字节,包含 Object 指针 + GrantedAccess 权限掩码。用户态得到的0x48只是一个索引值,并不包含任何直接的地址信息------这使得用户态无法猜测其他对象的地址。 -
使用与回收 :用户态通过句柄操作对象;关闭句柄时
ObDereferenceObject递减PointerCount,归零后调用ObpDeleteObject释放内存。注意图中的虚线箭头(从用户态到对象的间接引用)表示安全边界------用户态永远无法直接拿到对象内核态指针,只能通过句柄表这个"安全网关"间接引用。
图中的内存布局部分 展示了 CreatorInfo、NameInfo、HandleInfo、QuotaInfo 四个 Info 块在 Header 之前的排列顺序。这些 Info 块通过 Header->NameInfoOffset 等 UCHAR 偏移量定位,偏移量的单位是字节。值得注意的一点是:这些 Info 块不一定都存在 。只有对象在创建时被判定"需要"某个 Info 块,它才会被包含在一次性分配中。例如匿名对象不会有 NameInfo,不计配额的进程内对象不会有 QuotaInfo。这体现了"按需分配,避免浪费"的设计原则。
Hash 表部分 展示了对象目录内部的 37 个 Bucket。\BaseNamedObjects 是一个中间目录对象,它自身也有一个 37-Bucket 的 Hash 表------对象目录是嵌套的 ,每个子目录都是一个独立的 OBJECT_DIRECTORY 实例。这种嵌套结构与文件系统的目录嵌套完全同构,语义上也完全一致。
4.1.0.1 设计意图
核心问题
操作系统中有大量不同种类的内核资源:进程、线程、文件、事件、互斥量、信号量、定时器、管道、设备对象、驱动对象、注册表键、段对象、端口...... 如果每种对象都用一套独立的数据结构和管理方式,会导致以下问题:
- 命名冲突 :不同模块使用完全不同的字符串查找逻辑,难以支持全局唯一名称(如命名事件
Global\\MyEvent)。 - 生命周期混乱:每个模块自行实现引用计数,容易出现竞态和泄漏。
- 安全策略分散:访问检查、审计、配额限制散落在各处,难以统一管理。
- 类型安全缺失 :用户态传入的
HANDLE仅为一个整数值,内核需要机制防止误用。 - 调试与诊断困难:没有统一的查询接口,难以回答"系统中当前有哪些打开的对象"。
设计哲学
对象管理器(Object Manager,Ob)的设计哲学可以概括为**"在操作系统内部建立一套统一的命名、生命周期、安全与类型系统"**,它借鉴了面向对象语言中"基类 / 派生类"的思想,但用纯 C 语言实现:
OBJECT_HEADER是所有对象的"公共基类",提供引用计数、标志位、安全描述符等公共字段。- 具体对象体(Body) 是"派生类",如
EPROCESS、FILE_OBJECT、KEVENT,通过OBJECT_TO_OBJECT_HEADER(body)从对象体指针回溯到对象头。 OBJECT_TYPE是"类型对象",描述一类对象的元信息(名称、回调、池标记等)。- 对象目录(
OBJECT_DIRECTORY) 是内核中的"文件系统",提供统一的命名空间。
本节定位
本节聚焦于对象管理器的基础结构与命名空间,涵盖:
- 对象头
OBJECT_HEADER的内存布局与 Info 块机制; - 对象创建 (
ObCreateObject) 与引用计数 (ObReferenceObjectByPointer/ObDereferenceObject) 的生命周期; - 对象目录的 Hash 查找与命名空间遍历 (
ObpLookupObjectName,ObpInsertEntryDirectory); - 符号链接与
\??\目录的设备名映射。
对象类型(OBJECT_TYPE 的详细结构、回调机制)与句柄表(Handle Table 的实现细节)将在后续小节(4.2、4.3)深入。
4.1.1 为什么需要「对象管理器」
从用户态代码出发
考虑以下三段常见的用户态 C 代码:
c
// 示例 1:创建一个命名事件,供跨进程共享
HANDLE hEvent = CreateEventW(
NULL, // 默认安全描述符
FALSE, // 手动重置?否
FALSE, // 初始状态:无信号
L"Global\\MySharedEvent" // 全局命名空间中的名称
);
WaitForSingleObject(hEvent, INFINITE);
CloseHandle(hEvent);
// 示例 2:打开一个文件
HANDLE hFile = CreateFileW(
L"C:\\Users\\Me\\test.txt",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
ReadFile(hFile, ...);
CloseHandle(hFile);
// 示例 3:创建一个新进程
STARTUPINFOW si = {sizeof(si)};
PROCESS_INFORMATION pi;
CreateProcessW(L"C:\\Windows\\system32\\notepad.exe", ..., &si, &pi);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
这三个操作看似毫无关联:事件、文件、进程。但它们共享如下一致的模式:
| 模式 | Win32 语义 | 内核实现 |
|---|---|---|
| 统一的"句柄"类型 | HANDLE 是 typedef void *HANDLE |
句柄表索引 + 标记位 |
| 一致的命名语义 | CreateEventW 的最后一个参数是名称,CreateFileW 的第一个参数也是名称(路径) |
对象命名空间 \??\ → \Device\HarddiskVolume1\... |
| 一致的生命周期 | CloseHandle 对任何句柄都有效 |
NtClose → ObDereferenceObject 统一处理 |
| 一致的安全语义 | SECURITY_ATTRIBUTES 统一作为首参数 |
ObpCaptureObjectCreateInformation 捕获 SD |
| 一致的查询/诊断接口 | GetHandleInformation, QueryObject |
NtQueryObject 基于 OBJECT_HEADER 的公共字段返回信息 |
如果没有对象管理器 ,那么 NtCreateEvent、NtCreateFile、NtCreateProcess 每个都需要自行实现:
- 字符串解析与名称查找(重复代码);
- 引用计数(分散的
LONG volatile字段,难以统一调试); - 安全描述符解析(在每个系统调用里都写一份
SeCaptureSecurityDescriptor); - 池分配与释放(无统一的配额计费)。
这就是 ReactOS 选择统一的「对象管理器」的根本原因。
对象管理器提供的统一服务
| 服务 | 用途 | 核心函数 / 结构 |
|---|---|---|
| 命名空间 | 允许内核对象像文件系统路径一样命名和查找 | OBJECT_DIRECTORY, ObpLookupObjectName |
| 引用计数 | 多线程安全的对象生命周期管理 | PointerCount, ObReferenceObject, ObDereferenceObject |
| 句柄表 | 将内核对象指针"包装"为对用户态安全的整数索引 | HANDLE_TABLE, ExMapHandleToPointer |
| 类型安全 | 验证句柄指向的对象类型是否匹配调用方的期望 | OBJECT_HEADER.Type, ObReferenceObjectByHandle 的类型参数 |
| 安全描述符 | 为每个对象附加自主访问控制列表(DACL) | OBJECT_HEADER.SecurityDescriptor, SeAccessCheck |
| 审计 | 对对象的打开/关闭/删除等操作记录安全审计日志 | ObpAuditObjectAccess(审计回调) |
| 配额管理 | 限制单个进程可分配的分页/非分页池字节数 | OBJECT_HEADER_QUOTA_INFO, PsChargePoolQuota |
| 名称解析/符号链接 | 类似文件系统的 reparse 机制,用于 \??\C: → \Device\HarddiskVolume1 |
OBJECT_SYMBOLIC_LINK, ObpParseSymbolicLink |
对象管理器自身的内部组织架构
对象管理器的代码实现分布在 ntoskrnl\ob\ 目录下的约 13 个 C 文件中。从功能上,它可以划分为以下五个子模块:
第一层:对象生命周期管理(Object Lifecycle) --- 代码位于 oblife.c(file:///d:/reactos/ntoskrnl/ob/oblife.c) 和 obref.c(file:///d:/reactos/ntoskrnl/ob/obref.c)。这一层负责对象的创建(ObCreateObject)、销毁(ObpDeleteObject)、引用计数增减(ObReferenceObjectByPointer、ObDereferenceObject)、以及永久对象与延迟删除的异步清理。它是对象管理器的"心脏"------所有其他模块都通过它来获得或释放对象体指针。
第二层:命名空间管理(Namespace) --- 代码位于 obdir.c(file:///d:/reactos/ntoskrnl/ob/obdir.c)、obname.c(file:///d:/reactos/ntoskrnl/ob/obname.c) 和 oblink.c(file:///d:/reactos/ntoskrnl/ob/oblink.c)。这一层负责对象目录的 Hash 表操作(ObpInsertEntryDirectory、ObpDeleteEntryDirectory、ObpLookupEntryDirectory)、完整路径解析(ObpLookupObjectName)、符号链接(ObpParseSymbolicLink)、以及 DOS 设备映射(ObpCreateDosDevicesDirectory)。它是对象管理器的"肌肉"------支撑所有命名对象的查找与插入。
第三层:句柄表管理(Handle Table) --- 代码位于 obhandle.c(file:///d:/reactos/ntoskrnl/ob/obhandle.c) 和 ex\\handle.c(file:///d:/reactos/ntoskrnl/ex/handle.c)。这一层负责句柄的创建(ObpCreateHandle)、关闭(ObCloseHandle)、通过句柄查找对象(ObReferenceObjectByHandle)、以及句柄继承与跨进程传递(NtDuplicateObject)。它是对象管理器的"安全闸门"------用户态与内核对象之间的唯一通道。
第四层:安全审计(Security & Audit) --- 代码位于 obsecure.c(file:///d:/reactos/ntoskrnl/ob/obsecure.c) 和 obsdcach.c(file:///d:/reactos/ntoskrnl/ob/obsdcach.c)。这一层负责安全描述符的捕获、缓存和释放,以及对象访问的审计记录。它是对象管理器的"安检站"------每个对象被打开时都要通过它的检查。
第五层:初始化与关闭(Init & Shutdown) --- 代码位于 obinit.c(file:///d:/reactos/ntoskrnl/ob/obinit.c)。这一层负责系统启动时对象管理器的初始化------创建根目录、注册系统对象类型、创建关键命名空间入口。它是对象管理器的"地基"------所有其他模块在系统启动后才能正常运转。
这五层之间的关系可以总结为:初始化层为其他四层准备好运行环境 ;生命周期层提供对象的"生与死";命名空间层管理对象的"身份(名称)";句柄表层管理对象的"钥匙(句柄)";安全审计层管理对象的"门禁(权限)"。每个系统调用(如 NtCreateEvent)会依次经过句柄表层 → 命名空间层 → 安全审计层 → 生命周期层,完成一次完整的对象创建操作。
对模块内部实现来说,对象管理器的代码充分体现了分层与降级 的设计理念:每个函数只做一件事,上层函数通过调用下层函数组合复杂行为。例如 ObInsertObject 先调用 ObpLookupObjectName(查找名称),再调用 ObpCreateHandle(分配句柄),最后调用 ObpIncrementHandleCount(更新统计),每一步都有清晰的语义边界。
4.1.2 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; // +0x00 指针引用计数
union // +0x08 (x64) / +0x04 (x86)
{
LONG_PTR HandleCount; // 句柄引用计数
volatile PVOID NextToFree; // 延迟删除链表指针(重用同一内存)
};
POBJECT_TYPE Type; // +0x10 / +0x08 指向类型对象
UCHAR NameInfoOffset; // +0x18 / +0x0C NameInfo 距 Header 的负偏移(字节),0 表示不存在
UCHAR HandleInfoOffset; // +0x19 / +0x0D HandleInfo 距 Header 的负偏移
UCHAR QuotaInfoOffset; // +0x1A / +0x0E QuotaInfo 距 Header 的负偏移
UCHAR Flags; // +0x1B / +0x0F OB_FLAG_* 标志位
union // +0x20 / +0x10
{
POBJECT_CREATE_INFORMATION ObjectCreateInfo; // 创建过程中的临时信息
PVOID QuotaBlockCharged; // 记账用的配额块指针
};
PSECURITY_DESCRIPTOR SecurityDescriptor; // +0x28 / +0x14 安全描述符
QUAD Body; // +0x30 / +0x18 对象体开始位置(对齐占位符)
} OBJECT_HEADER, *POBJECT_HEADER;
注:
QUAD是LONGLONG的别名,作用是让Body字段起于 8 字节对齐边界,确保后续对象体结构自然对齐。
对象头在对象体之前的布局图示
低地址 高地址
┌─────────┬──────────┬────────────┬───────────┬────────┬─────────────┐
│ Creator │ Name │ Handle │ Quota │ Header │ Body │
│ Info │ Info │ Info │ Info │ │ (EPROCESS, │
│ (可选) │ (可选) │ (可选) │ (可选) │ 固定 │ FILE_OBJECT, │
│ │ │ │ │ │ KEVENT ...) │
└─────────┴──────────┴────────────┴───────────┴────────┴─────────────┘
▲ ▲
│ │
│ Header->NameInfoOffset │ Header
│ (字节数,例如 0x20) │
│ │
└────────────── 负偏移量 ──────┘
Header 自身地址为 pHeader。则:
NameInfo = (POBJECT_HEADER_NAME_INFO)((PUCHAR)pHeader - pHeader->NameInfoOffset)
HandleInfo = (POBJECT_HEADER_HANDLE_INFO)((PUCHAR)pHeader - pHeader->HandleInfoOffset)
QuotaInfo = (POBJECT_HEADER_QUOTA_INFO)((PUCHAR)pHeader - pHeader->QuotaInfoOffset)
CreatorInfo= 固定在 Header 前面 (sizeof(OBJECT_HEADER_CREATOR_INFO)),
只在 Flags & OB_FLAG_CREATOR_INFO 时存在
OBJECT_TO_OBJECT_HEADER 宏
这是最重要的"指针回溯"宏,将对象体指针转换为对象头指针。定义于 sdk/include/ndk/obtypes.h:111-112(file:///d:/reactos/sdk/include/ndk/obtypes.h#L111-L112):
c
#define OBJECT_TO_OBJECT_HEADER(o) \
CONTAINING_RECORD((o), OBJECT_HEADER, Body)
CONTAINING_RECORD(ptr, TYPE, Field) 是经典的 C 语言实现"从成员指针回溯容器指针"的惯用法:
c
// 展开后等价于:
(POBJECT_HEADER)((PUCHAR)(o) - FIELD_OFFSET(OBJECT_HEADER, Body))
因为 Body 在 OBJECT_HEADER 中的偏移是固定的(由编译器决定,通常为 0x30 / 0x18),所以这是一条 O(1) 的减法运算------非常高效。
Info 块的负偏移量定位宏
除 CreatorInfo 通过 Flags 标志位判断外,其他三个 Info 块都以"负偏移量字节数 + NULL 判定"的方式定位。定义于 sdk/include/ndk/obtypes.h:114-129(file:///d:/reactos/sdk/include/ndk/obtypes.h#L114-L129):
c
// NameInfo:存储名称与所在目录指针
#define OBJECT_HEADER_TO_NAME_INFO(h) \
((POBJECT_HEADER_NAME_INFO)(!(h)->NameInfoOffset ? \
NULL: ((PCHAR)(h) - (h)->NameInfoOffset)))
// HandleInfo:记录每个进程对该对象打开的句柄数
#define OBJECT_HEADER_TO_HANDLE_INFO(h) \
((POBJECT_HEADER_HANDLE_INFO)(!(h)->HandleInfoOffset ? \
NULL: ((PCHAR)(h) - (h)->HandleInfoOffset)))
// QuotaInfo:记录池配额计费与独占进程指针
#define OBJECT_HEADER_TO_QUOTA_INFO(h) \
((POBJECT_HEADER_QUOTA_INFO)(!(h)->QuotaInfoOffset ? \
NULL: ((PCHAR)(h) - (h)->QuotaInfoOffset)))
// CreatorInfo:调试信息(类型链表、创建者进程 PID)
#define OBJECT_HEADER_TO_CREATOR_INFO(h) \
((POBJECT_HEADER_CREATOR_INFO)(!((h)->Flags & \
OB_FLAG_CREATOR_INFO) ? NULL: ((PCHAR)(h) - \
sizeof(OBJECT_HEADER_CREATOR_INFO))))
设计要点:
- 用
UCHAR存储偏移量,意味着 Info 块的总大小不得超过 255 字节------这恰好足够容纳 4 个 Info 块(每个 ~40 字节)。 - 当
Offset == 0时返回NULL,表示该对象没有对应 Info 块。 CreatorInfo的位置在 Header 紧前面,所以使用sizeof(OBJECT_HEADER_CREATOR_INFO)作为固定负偏移,而非UCHAR字段。
OB_FLAG_* 标志位
定义于 sdk/include/ndk/obtypes.h:97-104(file:///d:/reactos/sdk/include/ndk/obtypes.h#L97-L104):
| 标志位 | 值 | 含义 |
|---|---|---|
OB_FLAG_CREATE_INFO |
0x01 |
对象处于创建过程中,ObjectCreateInfo 字段有效(而非 QuotaBlockCharged)。创建完成后会被清除并替换为记账指针。 |
OB_FLAG_KERNEL_MODE |
0x02 |
该对象由内核模式代码创建,对用户态不可见/不可访问。 |
OB_FLAG_CREATOR_INFO |
0x04 |
Header 前面存在 OBJECT_HEADER_CREATOR_INFO,记录创建者进程 ID、回溯索引、类型链表节点。 |
OB_FLAG_EXCLUSIVE |
0x08 |
对象被一个进程独占(例如独占打开的文件)。此时 OBJECT_HEADER_QUOTA_INFO.ExclusiveProcess 指向独占进程。 |
OB_FLAG_PERMANENT |
0x10 |
永久对象,即使 PointerCount 降到 0 也不被释放,直到调用 NtMakeTemporaryObject 显式解除。典型用例:\ 根目录、\ObjectTypes、\?? 等。 |
OB_FLAG_SECURITY |
0x20 |
该对象存在安全描述符(SecurityDescriptor 字段非 NULL)。 |
OB_FLAG_SINGLE_PROCESS |
0x40 |
该对象仅由单个进程打开(HandleInfo.SingleEntry 而非数据库)。 |
OB_FLAG_DEFER_DELETE |
0x80 |
标记对象为延迟删除。删除不立即执行,而是挂入工作队列由 ObpReaperWorkItem 异步清理。 |
4.1.3 对象的创建:ObCreateObject 全流程
函数签名
ObCreateObject 是对象创建的核心入口,定义于 ntoskrnl/ob/oblife.c:1037-1132(file:///d:/reactos/ntoskrnl/ob/oblife.c#L1037-L1132):
c
NTSTATUS
NTAPI
ObCreateObject(
IN KPROCESSOR_MODE ProbeMode OPTIONAL, // 探测模式:用户态传入需要 SEH
IN POBJECT_TYPE Type, // 对象类型(如 PsProcessType, IoFileObjectType)
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL, // 对象属性(名称、SD、RootDirectory)
IN KPROCESSOR_MODE AccessMode, // 访问模式(KernelMode / UserMode)
IN OUT PVOID ParseContext OPTIONAL, // 解析上下文(供 ParseProcedure 使用)
IN ULONG ObjectSize, // 对象体大小(如 sizeof(EPROCESS))
IN ULONG PagedPoolCharge OPTIONAL, // 分页池配额(默认用 Type 的默认值)
IN ULONG NonPagedPoolCharge OPTIONAL, // 非分页池配额
OUT PVOID *Object // 输出:对象体指针
);
内部流程分解
-
步骤 1:分配并填充
OBJECT_CREATE_INFORMATIONObCreateObject首先通过 lookaside list 分配一个OBJECT_CREATE_INFORMATION(OCI)缓冲区,然后调用ObpCaptureObjectCreateInformation(见 ntoskrnl/ob/oblife.c:453-598(file:///d:/reactos/ntoskrnl/ob/oblife.c#L453-L598)):- 从
ObjectAttributes中提取RootDirectory、Attributes标志; - 捕获
SecurityDescriptor并计算其大小计入配额; - 捕获安全 QoS(Quality of Service);
- 捕获对象名称到独立缓冲区(后续放入 NameInfo)。
- 从
-
步骤 2:从池中分配 Header + Info 块 + Body
实际分配由
ObpAllocateObject(见 ntoskrnl/ob/oblife.c:609-869(file:///d:/reactos/ntoskrnl/ob/oblife.c#L609-L869))完成。该函数:-
按条件计算是否需要 4 个 Info 块:
NameInfo : ObjectName->Buffer != NULL ? 1 : 0 HandleInfo : Type->MaintainHandleCount ? 1 : 0 QuotaInfo : 配额与类型默认值不同 / OBJ_EXCLUSIVE / 用户态创建 ? 1 : 0 CreatorInfo: Type->MaintainTypeList ? 1 : 0 -
计算总大小 =
sizeof(CreatorInfo) + sizeof(NameInfo) + sizeof(HandleInfo) + sizeof(QuotaInfo) + FIELD_OFFSET(OBJECT_HEADER, Body) + ObjectSize; -
调用
ExAllocatePoolWithTag(PoolType, TotalSize, Tag)一次性分配; -
分别初始化各 Info 块并递增
ObpObjectsCreated、ObpObjectsWithName、ObpObjectsWithPoolQuota、ObpObjectsWithHandleDB、ObpObjectsWithCreatorInfo等全局计数器; -
设置 Header 字段:
PointerCount = 1、HandleCount = 0、Type = ObjectType、Flags = OB_FLAG_CREATE_INFO | ...、*InfoOffset = 计算得到的偏移量; -
递增
Type->TotalNumberOfObjects。
-
-
步骤 3:返回 Body 指针
*Object = &Header->Body;,即把"对象头之后的第一个字节"返回给调用者,调用者将其作为具体对象(如PEVENT、PFILE_OBJECT)使用。 -
步骤 4:永久对象特权检查
如果
Header->Flags & OB_FLAG_PERMANENT,且调用方来自用户态,会进行SeCreatePermanentPrivilege特权检查------普通用户无法创建永久对象。
设计解读:为什么 ObCreateObject 只做"壳"不做"肉"?
初学者在看到 ObCreateObject 的步骤分解后,可能会有一个疑问:为什么对象管理器只分配 Header + Info 块 + Body 的空间、初始化的却只有 Header 的公共字段(PointerCount、Type、Flags),而不初始化 Body 内部的任何字段?
这背后的设计哲学是关注点分离(Separation of Concerns)。
对象管理器是"通用基础设施",它不知道某个 Body 的具体类型语义------FILE_OBJECT 有 FileName、DeviceObject 等字段;KEVENT 有 Header(DISPATCHER_HEADER)字段;EPROCESS 有 ImageFileName、Peb、AddressSpace 等几十个字段。如果对象管理器试图去初始化所有这些字段,它就需要"了解"每一个类型------这会导致紧耦合、巨大的 switch-case 语句、以及每次新增类型都需要修改对象管理器。
正确的分工方式应该是:
- 对象管理器 (Ob 模块)只负责所有对象共用的部分------分配内存、设置引用计数、关联类型指针、挂 Info 块、插入命名空间。这些对所有类型都一样。
- 类型创建函数 (如
NtCreateEvent中的KeInitializeEvent、NtCreateProcess中的PspInitializeProcess)负责初始化 Body 中的类型专属字段。
这种分工的直接体现是:ObCreateObject 的参数中需要调用者传入 ObjectSize(Body 大小),但不需要调用者描述"Body 的结构是什么"------对象管理器不知道也不关心 Body 的结构,它只需要知道 Body 在内存中占多少字节。
所以完整的创建流程是:
// ① 对象管理器:分配 "壳"
ObCreateObject(Type, ..., sizeof(TYPE_BODY), ..., &Body);
// ② 调用方自己:初始化 "肉"
((PTYPE_BODY)Body)->Field1 = ...;
((PTYPE_BODY)Body)->Field2 = ...;
// ③ 对象管理器:插入命名空间和分配句柄
ObInsertObject(Body, ..., &Handle);
这 3 步的分工明确:① = 我管内存和公共字段;② = 你管类型特有字段;③ = 我管身份和钥匙。
设计解读:为什么一次性分配 Header + Info 块 + Body?
另一个值得探讨的问题是:为什么所有 Info 块要和 Header、Body 挤在同一次池分配中?为什么不在需要 NameInfo 时再单独分配?
答案是避免碎片化与简化释放路径。
- 释放的原子性 :
ObpDeallocateObject(oblife.c:37-143(file:///d:/reactos/ntoskrnl/ob/oblife.c#L37-L143))在执行时只需调用一次ExFreePoolWithTag就能释放整个块。如果 NameInfo 和 Header 分属两次分配,释放时要释放两次------这意味着释放路径上多一个"判断哪些 Info 块独立分配了"的逻辑分支。 - 内存局部性 :Header 和 NameInfo 在同一个
ExAllocatePoolWithTag调用中分配,它们在物理内存中相邻------访问 Header 时 NameInfo 很可能在同一个 Cache Line 上。这对ObpLookupEntryDirectory这种频繁访问 NameInfo 的路径来说是一个可观的性能收益。 - 碎片控制:如果 N 次独立分配,一个对象占用 N 个池块;而一次分配后,整个对象(Header + Info + Body)是一个连续的块,释放后它是一个连续的"空洞",更容易被后续的大分配复用。
只有 HandleInfo 有一个例外------当对象第一次被多个进程同时打开时,SingleEntry 可能升级为 HandleCountDatabase(一个更复杂的数据结构)。这个升级是受控的、额外的池分配,且只在持有句柄表锁的情况下执行------是"小而受控"的例外。
-
后续:类型专属的 Open/Delete 回调
完整的对象创建流程中,在
ObCreateObject返回后,调用方还需要调用ObInsertObject将其插入命名空间和句柄表。此过程中对象管理器会调用类型的OpenProcedure(记录句柄打开计数)。删除时调用DeleteProcedure。
关键代码片段:ObpAllocateObject 的分配逻辑
以下代码摘自 ntoskrnl/ob/oblife.c:715-835(file:///d:/reactos/ntoskrnl/ob/oblife.c#L715-L835),展示了如何一次性分配多块结构并用负偏移量反向定位:
c
// 计算最终 Header 大小(Header 之前的 Info 块累积之和 + Header 自身到 Body 的偏移)
FinalSize = QuotaSize + HandleSize + NameSize + CreatorSize
+ FIELD_OFFSET(OBJECT_HEADER, Body);
// 从池中一次性分配(含对象体大小 ObjectSize)
Header = ExAllocatePoolWithTag(PoolType, FinalSize + ObjectSize, Tag);
if (!Header) return STATUS_INSUFFICIENT_RESOURCES;
// 依次设置各 Info 块指针并向前移动 Header
if (QuotaSize) {
QuotaInfo = (POBJECT_HEADER_QUOTA_INFO)Header;
QuotaInfo->PagedPoolCharge = ObjectCreateInfo->PagedPoolCharge;
QuotaInfo->NonPagedPoolCharge = ObjectCreateInfo->NonPagedPoolCharge;
QuotaInfo->SecurityDescriptorCharge = ObjectCreateInfo->SecurityDescriptorCharge;
QuotaInfo->ExclusiveProcess = NULL;
Header = (POBJECT_HEADER)(QuotaInfo + 1); // 向前"跳过"QuotaInfo
}
if (HandleSize) {
HandleInfo = (POBJECT_HEADER_HANDLE_INFO)Header;
HandleInfo->SingleEntry.HandleCount = 0;
Header = (POBJECT_HEADER)(HandleInfo + 1); // 再向前"跳过"HandleInfo
}
if (NameSize) {
NameInfo = (POBJECT_HEADER_NAME_INFO)Header;
NameInfo->Name = *ObjectName;
NameInfo->Directory = NULL;
NameInfo->QueryReferences = 1;
Header = (POBJECT_HEADER)(NameInfo + 1); // 再向前"跳过"NameInfo
}
if (CreatorSize) {
CreatorInfo = (POBJECT_HEADER_CREATOR_INFO)Header;
CreatorInfo->CreatorBackTraceIndex = 0;
CreatorInfo->CreatorUniqueProcess = PsGetCurrentProcessId();
InitializeListHead(&CreatorInfo->TypeList);
Header = (POBJECT_HEADER)(CreatorInfo + 1); // 再向前"跳过"CreatorInfo
}
// 设置偏移量(这些是最终 Header 相对各 Info 块的字节数)
if (QuotaSize) Header->QuotaInfoOffset = (UCHAR)(QuotaSize + HandleSize + NameSize + CreatorSize);
if (HandleSize) Header->HandleInfoOffset = (UCHAR)(HandleSize + NameSize + CreatorSize);
if (NameSize) Header->NameInfoOffset = (UCHAR)(NameSize + CreatorSize);
// 初始化 Header 公共字段
Header->Flags = OB_FLAG_CREATE_INFO;
if (CreatorSize) Header->Flags |= OB_FLAG_CREATOR_INFO;
if (HandleSize) Header->Flags |= OB_FLAG_SINGLE_PROCESS;
Header->PointerCount = 1;
Header->HandleCount = 0;
Header->Type = ObjectType;
Header->ObjectCreateInfo = ObjectCreateInfo;
Header->SecurityDescriptor = NULL;
注意这段代码的顺序至关重要:
- 分配顺序(低地址→高地址) :
QuotaInfo → HandleInfo → NameInfo → CreatorInfo → OBJECT_HEADER → Body; - 但偏移量计算是反向的:最终 Header 位置向前"跳过 N 字节"到达 Info 块。
4.1.4 引用计数与生命周期
双计数器设计
每个 OBJECT_HEADER 维护两个独立的计数器:
| 计数器 | 类型 | 含义 | 递增时机 | 递减时机 |
|---|---|---|---|---|
PointerCount |
LONG_PTR |
内核中持有对象体指针的引用数。此计数 ≥ HandleCount。 | 调用 ObReferenceObject(ByPointer/ByHandle),以及在句柄表中建立新句柄时各 +1 |
ObDereferenceObject、关闭句柄(对每句柄 -1) |
HandleCount |
LONG_PTR(与 NextToFree 构成 union) |
当前进程表中指向此对象的句柄总数。仅在类型声明了 MaintainHandleCount 时维护。 |
新建句柄(ObpIncrementHandleCount) |
关闭句柄(ObpDecrementHandleCount) |
为什么需要两个计数器?
HandleCount让内核能够回答"有多少个用户态句柄指向该对象"------用于调试、诊断,以及判断是否需要从命名空间中移除对象(当 HandleCount 归零时,ObpDeleteNameCheck会尝试移除名称)。PointerCount是真正控制释放的计数器。当PointerCount从 1 → 0 时,对象被ObpDeleteObject释放。即使没有句柄打开,内核代码持有指针引用期间对象仍然有效。
ObReferenceObjectByPointer
见 ntoskrnl/ob/obref.c:379-405(file:///d:/reactos/ntoskrnl/ob/obref.c#L379-L405):
c
NTSTATUS
NTAPI
ObReferenceObjectByPointer(
IN PVOID Object,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_TYPE ObjectType,
IN KPROCESSOR_MODE AccessMode
)
{
POBJECT_HEADER Header;
Header = OBJECT_TO_OBJECT_HEADER(Object);
// 验证类型(对用户态调用强制校验,内核态可传 NULL 跳过)
if ((Header->Type != ObjectType) &&
((AccessMode != KernelMode) || (ObjectType == ObpSymbolicLinkObjectType)))
{
return STATUS_OBJECT_TYPE_MISMATCH;
}
// 原子递增 PointerCount
InterlockedIncrementSizeT(&Header->PointerCount);
return STATUS_SUCCESS;
}
实现非常简洁:一次类型验证 + 一次原子递增。InterlockedIncrementSizeT 是跨平台的 InterlockedIncrement 封装,保证 SMP 安全。
ObDereferenceObject
见 ntoskrnl/ob/obref.c:316-353(file:///d:/reactos/ntoskrnl/ob/obref.c#L316-L353)(作为 ObfDereferenceObject 实现,然后 ObDereferenceObject 是其包装):
c
LONG_PTR
FASTCALL
ObfDereferenceObject(IN PVOID Object)
{
POBJECT_HEADER Header;
LONG_PTR NewCount;
Header = OBJECT_TO_OBJECT_HEADER(Object);
// 健壮性检查:PointerCount 绝不应小于 HandleCount
if (Header->PointerCount < Header->HandleCount) {
DPRINT1("Misbehaving object: %wZ\n", &Header->Type->Name);
return Header->PointerCount;
}
// 原子递减
NewCount = InterlockedDecrementSizeT(&Header->PointerCount);
if (!NewCount) {
// 断言:HandleCount 也必须为 0(否则泄漏了句柄引用)
ASSERT(Header->HandleCount == 0);
// APC 禁用时不立即释放,以免在调度级别释放需要被动级的结构
if (!KeAreAllApcsDisabled()) {
ObpDeleteObject(Object, FALSE);
} else {
ObpDeferObjectDeletion(Header); // 挂入 Reaper 链表
}
}
return NewCount;
}
对象生命周期状态图
NtCreateEvent
│
▼
┌──────────┐ ObReferenceObjectByPointer ┌──────────┐
│ Created │ ─────────────────────────────────────▶│Referenced│
│Pointer=1 │ ◀─────────────────────────────────────│Pointer>1 │
│Handle=0 │ ObDereferenceObject(Pointer→1) │Handle≥0 │
└──────────┘ └──────────┘
│ ▲
│ ObInsertObject │ NtDuplicateObject
▼ │ NtOpenFile
┌──────────┐ ┌──────────┐
│ Named │ │ Opened │
│InDirectory│──────── ObInsertObject (handle)─▶│(Handled) │
│Directory │ │Handle>0 │
└──────────┘ └──────────┘
│ │
│ ObpDeleteNameCheck (HandleCount→0 & !Permanent) │ NtClose
│ 从目录中移除名称 ▼
▼ ┌──────────┐
┌──────────┐ │ Closed │
│Unnamed │◀──────────────────────────────────│ Handle=0 │
│ (floating)│ │ Pointer↓│
└──────────┘ └──────────┘
│ │
│ ObDereferenceObject │
▼ │
┌──────────┐ ┌──────────┐
│ Deleted │◀── PointerCount → 0 ─────────────│Deref'ed │
│ (freed) │ ObpDeleteObject + ExFreePool │ Pointer=0 │
└──────────┘ └──────────┘
注:带有 OB_FLAG_PERMANENT 的对象会卡在 "Named" 状态,即使 PointerCount=0
直到 NtMakeTemporaryObject 清除该标志。
4.1.5 对象目录(OBJECT_DIRECTORY)与命名空间
什么是对象目录
对象目录是对象管理器实现的"内核文件系统"。用户态看到的名称路径(如 \BaseNamedObjects\MyEvent、\??\C:\Windows、\Device\HarddiskVolume1)都在对象目录中被解析。
从概念上讲,对象目录与文件系统目录非常相似:
- 每个目录是一个对象(类型为
Directory); - 目录条目将名称(Unicode 字符串)映射到另一个对象(可以是目录、文件对象、设备对象、符号链接等);
- 名称查找按
\分隔符逐段解析; - 支持符号链接(reparse point)。
OBJECT_DIRECTORY 结构
定义于 sdk/include/ndk/obtypes.h:407-425(file:///d:/reactos/sdk/include/ndk/obtypes.h#L407-L425):
c
typedef struct _OBJECT_DIRECTORY
{
// 37 个 Hash Bucket 指针数组,每一项指向单链表(OBJECT_DIRECTORY_ENTRY*)
struct _OBJECT_DIRECTORY_ENTRY *HashBuckets[NUMBER_HASH_BUCKETS];
// 目录锁:用 push lock 实现共享/独占访问
EX_PUSH_LOCK Lock;
// 设备映射(仅 \??\ 类型目录使用):指向 DOS 设备映射
struct _DEVICE_MAP *DeviceMap;
// 会话 ID(用于隔离 Session 0 / Session 1 的 \??\ 目录)
ULONG SessionId;
} OBJECT_DIRECTORY, *POBJECT_DIRECTORY;
Hash Bucket 的数量 由常量 NUMBER_HASH_BUCKETS = 37 定义(见 sdk/include/ndk/obtypes.h:158(file:///d:/reactos/sdk/include/ndk/obtypes.h#L158-L158)):
c
#define NUMBER_HASH_BUCKETS 37
37 是一个质数,有助于减少 Hash 冲突。Windows 的早期版本使用相同的值(后续 Windows 版本动态调整)。
目录条目结构(sdk/include/ndk/obtypes.h:398-405(file:///d:/reactos/sdk/include/ndk/obtypes.h#L398-L405)):
c
typedef struct _OBJECT_DIRECTORY_ENTRY
{
struct _OBJECT_DIRECTORY_ENTRY *ChainLink; // 冲突链表 next 指针
PVOID Object; // 指向对象体(不是 Header)
ULONG HashValue; // 记录 hash 值,加速比较
} OBJECT_DIRECTORY_ENTRY, *POBJECT_DIRECTORY_ENTRY;
Hash 查找算法
Hash 算法位于 ObpLookupEntryDirectory(见 ntoskrnl/ob/obdir.c:156-327(file:///d:/reactos/ntoskrnl/ob/obdir.c#L156-L327))。核心思想:
c
// 对名称中每个 WCHAR 做 hash
for (HashValue = 0; TotalChars; TotalChars--) {
WCHAR CurrentChar = *Buffer++;
HashValue += (HashValue << 1) + (HashValue >> 1); // * 3,扩散位
if (CurrentChar < 'a') {
HashValue += CurrentChar;
} else if (CurrentChar > 'z') {
HashValue += RtlUpcaseUnicodeChar(CurrentChar);
} else {
HashValue += (CurrentChar - ('a' - 'A')); // 小写转大写后相加
}
}
// 取模得到 bucket 索引
HashIndex = HashValue % NUMBER_HASH_BUCKETS; // % 37
注意:这是一个大小写不敏感 的 hash。对 ASCII 小写字母先转换为大写再累加;对非 ASCII 字符调用
RtlUpcaseUnicodeChar做上变换。
随后执行链表线性查找(见 ntoskrnl/ob/obdir.c:231-253(file:///d:/reactos/ntoskrnl/ob/obdir.c#L231-L253)):
c
while ((CurrentEntry = *AllocatedEntry)) {
if (CurrentEntry->HashValue == HashValue) { // 先比较 hash,快失败
ObjectHeader = OBJECT_TO_OBJECT_HEADER(CurrentEntry->Object);
HeaderNameInfo = OBJECT_HEADER_TO_NAME_INFO(ObjectHeader);
// 再比较实际字符串(长度 + 内容)
if ((Name->Length == HeaderNameInfo->Name.Length) &&
RtlEqualUnicodeString(Name, &HeaderNameInfo->Name, CaseInsensitive)) {
break;
}
}
AllocatedEntry = &CurrentEntry->ChainLink;
}
找到后还有一个优化:把命中的条目移动到所在 bucket 的链表头部(见 ntoskrnl/ob/obdir.c:256-273(file:///d:/reactos/ntoskrnl/ob/obdir.c#L256-L273)),加速重复访问(基于局部性原理)。
Windows 标准命名空间路径一览
对象管理器初始化阶段(ObInitSystem,见 ntoskrnl/ob/obinit.c:203-435(file:///d:/reactos/ntoskrnl/ob/obinit.c#L203-L435))会创建以下关键目录:
| 路径 | 用途 | 创建位置 |
|---|---|---|
\ |
根目录对象(Root Directory) | obinit.c:312-332(file:///d:/reactos/ntoskrnl/ob/obinit.c#L312-L332) |
\ObjectTypes |
所有对象类型的目录(Type 对象注册于此) | obinit.c:362-387(file:///d:/reactos/ntoskrnl/ob/obinit.c#L362-L387) |
\KernelObjects |
内核对象(跨会话可见,受限访问) | obinit.c:343-360(file:///d:/reactos/ntoskrnl/ob/obinit.c#L343-L360) |
\?? |
DOS 设备名目录(全局 \??\),每个会话还有一份私有副本 |
obname.c:174-280(file:///d:/reactos/ntoskrnl/ob/obname.c#L174-L280) |
\DosDevices |
\?? 的符号链接(向后兼容) |
obname.c:258-273(file:///d:/reactos/ntoskrnl/ob/obname.c#L258-L273) |
\Device |
设备对象目录(由驱动程序 IoCreateDevice 注册) |
I/O 管理器 |
\Driver |
驱动对象目录 | I/O 管理器 |
\BaseNamedObjects |
会话级命名事件/互斥量/信号量(由 BaseNamedObjects 提供) | Win32k 子系统 |
\Windows |
Windows 子系统对象 | Win32k |
\Callback |
回调对象目录 | 配置管理器 |
\FileSystem |
文件系统驱动和过滤器 | I/O 管理器 |
\Security |
安全子系统对象(如 LSA 认证包) | LSASS |
\Registry |
注册表根键对象(符号链接到 \Registry\Machine、\Registry\User) |
配置管理器 |
命名空间的层次与交互关系
从上表可以看出,对象命名空间是一个以 \ 为根的树状结构,不同的子系统负责管理自己的分支:
-
\根目录 --- 由对象管理器直接管理(ObInitSystem),是所有路径解析的起点。根目录下只有少数几个子目录(\ObjectTypes、\??、\KernelObjects等),大多数系统目录都作为\的直接子项创建。 -
\??与 DOS 设备映射 --- 这是整个命名空间中最特殊的部分。当用户态程序调用CreateFile(L"C:\\xxx")时,I/O 管理器会在内部将C:展开为\??\C:,然后经过ObpLookupObjectName的\??\前缀识别,跳转到当前会话的会话私有\??\目录,在其中查找C:符号链接,最终透明重解析到\Device\HarddiskVolume1。这种"间接"是内核设计中的关键抽象------下层的存储设备可以是 RAID、网络共享或 RAM Disk,但上层的C:盘符始终不变。 -
\BaseNamedObjects与会话隔离 --- 所有命名的事件、互斥体、信号量、计时器、作业对象都注册在\BaseNamedObjects下。它通常是"每个会话一个"------Session 0 的\BaseNamedObjects与 Session 1 的\BaseNamedObjects是不同目录,因此一个服务创建了一个名为"GlobalEvent"的命名事件后,交互式用户在同一名称下创建的事件是另一个独立的事件。这同样是隔离设计。 -
\Device与 I/O 管理器 --- 这个目录下的条目由设备驱动程序通过IoCreateDevice注册。每种类型的设备(磁盘、键盘、鼠标、显示)都有自己的名称(如\Device\KeyboardClass0、\Device\HarddiskVolume1)。\??\中的符号链接本质上是指向\Device\下某个设备对象的别名。 -
\ObjectTypes与类型目录 --- 这是一个特殊的目录:它的条目不是普通对象,而是OBJECT_TYPE结构本身。也就是说,\ObjectTypes\Process这个名称解析后的对象体是一个OBJECT_TYPE指针。这为调试工具(如 WinDbg 的!object \ObjectTypes)提供了统一的类型信息查询入口。"类型对象本身也是对象"这一设计在本节 4.2 中会深入讨论。 -
跨命名空间的交互 --- 不同命名空间并非孤立存在。以
\Registry为例:它本身是对象命名空间中的一个符号链接,但解析后会进入配置管理器(Configuration Manager) 的命名空间(注册表路径\Registry\Machine\SYSTEM\CurrentControlSet\...)。同样地,\FileSystem目录中的条目最终会走到文件系统驱动的 ParseProcedure。对象命名空间是整个内核命名空间的"统一入口",往下可以穿透到其他子系统的私有命名空间。
这种命名空间设计的一个突出优点是:用户态的路径操作无论是打开文件、创建事件还是访问注册表,形式上是统一的(CreateFile vs CreateEvent 都接收一个字符串路径),语义上也是统一的(都会经过 ObpLookupObjectName 的逐段解析),但底层行为由目标对象的类型回调(ParseProcedure、SecurityProcedure 等)决定。 这是"统一入口 + 多态行为"的典型实现。
根目录对象:ObpRootDirectoryObject
在 ObInitSystem 的 Phase 1 阶段(ntoskrnl/ob/obinit.c:306-337(file:///d:/reactos/ntoskrnl/ob/obinit.c#L306-L337)),系统创建 \ 根目录并保存在全局变量 ObpRootDirectoryObject 中:
c
RtlInitUnicodeString(&Name, L"\\");
InitializeObjectAttributes(&ObjectAttributes, &Name,
OBJ_CASE_INSENSITIVE | OBJ_PERMANENT,
NULL, SePublicDefaultUnrestrictedSd);
NtCreateDirectoryObject(&Handle, DIRECTORY_ALL_ACCESS, &ObjectAttributes);
ObReferenceObjectByHandle(Handle, 0, ObpDirectoryObjectType,
KernelMode, (PVOID*)&ObpRootDirectoryObject, NULL);
注意根目录被标记为 OBJ_PERMANENT------它永不被自动释放,贯穿系统整个生命周期。
4.1.6 名称查找:ObpLookupObjectName
函数签名
ObpLookupObjectName 是名称解析的核心函数,见于 ntoskrnl/ob/obname.c:444-1201(file:///d:/reactos/ntoskrnl/ob/obname.c#L444-L1201)。这是对象管理器中最复杂的函数之一(约 700 行),处理:
- 路径分段解析;
- 对
\??\前缀的 DOS 设备名识别; - 符号链接(reparse)的透明解析(最多 30 层,避免循环引用);
- 对象创建时的"查找+插入"语义(当
InsertObject != NULL时); - 访问检查与审计。
路径解析算法
给定一条路径,例如 \??\C:\Windows\System32\notepad.exe,解析过程如下:
路径字符串:\??\C:\Windows\System32\notepad.exe
│
▼ 前缀识别 \??\ → 切换到 DosDevicesDirectory
DosDevicesDirectory(会话私有)
│
▼ 解析下一段 "C:" → 查找符号链接
SymbolicLink -> \Device\HarddiskVolume1
│
▼ reparse(重新解析目标路径 + 剩余路径)
\Device\HarddiskVolume1
│
▼ 继续解析 "\Windows\System32\notepad.exe"
ParseProcedure(FileObject ParseRoutine)
│
▼ 返回 FILE_OBJECT*
在代码中,这由一个双层循环实现(见 ntoskrnl/ob/obname.c:755-1157(file:///d:/reactos/ntoskrnl/ob/obname.c#L755-L1157)):
c
// 外层:重解析循环(最多 MaxReparse = 30 次)
while (Reparse && MaxReparse) {
RemainingName = LocalName; // 剩余未解析路径
Reparse = FALSE;
// 内层:逐段解析循环
while (TRUE) {
// 跳过开头的 '\'
if ((RemainingName.Length) && (RemainingName.Buffer[0] == OBJ_NAME_PATH_SEPARATOR)) {
RemainingName.Buffer++;
RemainingName.Length -= sizeof(OBJ_NAME_PATH_SEPARATOR);
}
// 切分出下一段名称(下一个 '\' 之前的部分)
ComponentName = RemainingName;
while (RemainingName.Length && RemainingName.Buffer[0] != OBJ_NAME_PATH_SEPARATOR) {
RemainingName.Buffer++;
RemainingName.Length -= sizeof(OBJ_NAME_PATH_SEPARATOR);
}
ComponentName.Length -= RemainingName.Length;
if (!ComponentName.Length) { Status = STATUS_OBJECT_NAME_INVALID; break; }
// 在当前 Directory 中查找 ComponentName
Object = ObpLookupEntryDirectory(Directory, &ComponentName,
Attributes, InsertObject ? FALSE : TRUE,
LookupContext);
if (!Object) {
// 若有剩余路径 → 路径不存在;否则 → 对象名不存在
if (RemainingName.Length) Status = STATUS_OBJECT_PATH_NOT_FOUND;
else if (!InsertObject) Status = STATUS_OBJECT_NAME_NOT_FOUND;
// 若 InsertObject 非空,尝试创建并插入新条目
...
break;
}
// 找到对象后,检查其类型是否有 ParseProcedure
// 如果是目录(Directory 类型没有 ParseProcedure),继续下层解析
// 如果是符号链接 → ParseProcedure 会返回 STATUS_REPARSE 以驱动外层循环
// 如果是文件对象等 → ParseProcedure 完成最终解析,返回成功
ObjectHeader = OBJECT_TO_OBJECT_HEADER(Object);
ParseRoutine = ObjectHeader->Type->TypeInfo.ParseProcedure;
if (ParseRoutine) {
// 调用 ParseProcedure
Status = ParseRoutine(Object, ObjectType, AccessState,
AccessCheckMode, Attributes,
ObjectName, &RemainingName,
ParseContext, SecurityQos,
&Object);
if (Status == STATUS_REPARSE || Status == STATUS_REPARSE_OBJECT) {
Reparse = TRUE; // 触发外层 reparse 循环
--MaxReparse;
...
break;
}
...
break;
} else if (!RemainingName.Length) {
// 没有剩余路径且无需 parse → 成功命中目标对象
Status = ObReferenceObjectByPointer(Object, 0, ObjectType, AccessMode);
break;
} else {
// 非目录对象却还有剩余路径 → 类型不匹配
Status = STATUS_OBJECT_TYPE_MISMATCH;
Object = NULL;
break;
}
}
}
OBP_LOOKUP_CONTEXT 的作用
定义于 sdk/include/ndk/obtypes.h:510-518(file:///d:/reactos/sdk/include/ndk/obtypes.h#L510-L518):
c
typedef struct _OBP_LOOKUP_CONTEXT
{
POBJECT_DIRECTORY Directory; // 当前查找所在的目录
PVOID Object; // 找到的对象(或插入的对象)
ULONG HashValue; // 本次查找计算的 hash 值
USHORT HashIndex; // 所在 bucket 索引
BOOLEAN DirectoryLocked; // 是否已持有目录锁(共享或独占)
ULONG LockStateSignature; // 锁状态签名,用于调试检测
} OBP_LOOKUP_CONTEXT, *POBP_LOOKUP_CONTEXT;
其设计目的:
- 缓存 hash 值:避免在同一次查找中反复计算名称 hash。
- 保持目录锁:查找→插入是一个原子操作。通过 context 持有锁,可以避免"查找后到插入前有其他线程插入同名条目"的 TOCTOU 竞态。
- 简化 reparse:每次 reparse 重置 context。
典型调用模式:
c
OBP_LOOKUP_CONTEXT Context;
ObpInitializeLookupContext(&Context);
ObpAcquireLookupContextLock(&Context, ParentDirectory); // 共享锁
Object = ObpLookupEntryDirectory(ParentDirectory, &Name, ..., &Context);
if (!Object) {
// 需将锁升级为独占以插入
ObpInsertEntryDirectory(ParentDirectory, &Context, NewHeader);
}
ObpReleaseLookupContext(&Context);
查找失败的两种处理
| 调用场景 | InsertObject | 失败后的动作 | 返回状态 |
|---|---|---|---|
打开已有对象 (ObOpenObjectByName) |
NULL |
简单返回 | STATUS_OBJECT_NAME_NOT_FOUND / STATUS_OBJECT_PATH_NOT_FOUND |
创建新对象 (ObInsertObject) |
非 NULL | 在路径最后一段所在目录中创建新条目,将 InsertObject 挂上 |
成功:STATUS_SUCCESS;路径中段不存在:STATUS_OBJECT_PATH_NOT_FOUND |
4.1.7 符号链接与 ??\ 目录
对象符号链接(Symbolic Link)
对象命名空间中的符号链接与文件系统符号链接在概念上非常相似:它是一个指向其他名称的对象。当 ObpLookupObjectName 遍历到符号链接时,它会透明地"跳转到"目标名称继续解析。
符号链接对象结构(sdk/include/ndk/obtypes.h:535-542(file:///d:/reactos/sdk/include/ndk/obtypes.h#L535-L542)):
c
typedef struct _OBJECT_SYMBOLIC_LINK
{
LARGE_INTEGER CreationTime; // 创建时间(调试用)
UNICODE_STRING LinkTarget; // 目标路径
UNICODE_STRING LinkTargetRemaining; // 被附加到目标之后的剩余路径
PVOID LinkTargetObject; // 可选:缓存目标对象指针
ULONG DosDeviceDriveIndex; // 仅 DOS 设备符号链接使用(盘符索引)
} OBJECT_SYMBOLIC_LINK, *POBJECT_SYMBOLIC_LINK;
解析函数 ObpParseSymbolicLink(未在本节显示的源码位置)会:
- 用
LinkTarget替换当前路径; - 附加原路径未解析部分(
LinkTargetRemaining); - 返回
STATUS_REPARSE,驱动外层循环重新解析。
??\ 目录的特殊角色
\??\(也称为"DosDevices 目录")是 Windows 中非常特殊的一层命名空间,它承担三项职责:
-
DOS 盘符映射 :
C:、D:、E:等是\??\下的符号链接,分别指向\Device\HarddiskVolume1、\Device\HarddiskVolume2、...。 -
COM/LPT 端口映射 :
COM1→\Device\Serial0、LPT1→\Device\Parallel0。 -
会话隔离 :每个 Terminal Services 会话都有自己的
\??\目录。这样 Session 1 的用户不能看到或干扰 Session 0 的 DOS 设备(但可通过\GLOBAL??\显式访问全局目录)。
设备名映射机制(ObpCreateDosDevicesDirectory)
见 ntoskrnl/ob/obname.c:174-280(file:///d:/reactos/ntoskrnl/ob/obname.c#L174-L280)。系统启动时:
- 创建全局
\GLOBAL??目录(带保护 SD); - 在其中创建
GLOBALROOT→\的符号链接(用于显式绕过\??\直达根); - 在其中创建
Global→\GLOBAL??的符号链接(允许\??\Global\xxx访问全局); - 创建
\DosDevices→\??的符号链接(NT 4 兼容); - 将系统 device map 设置为此目录。
随后,每当一个新会话被创建时,Win32 子系统会调用 ObSetDeviceMap 为该会话建立一个私有 \??\ 目录,然后在其中为该会话创建的任何 DOS 设备(例如 subst Z: D:\MyFolder)挂载符号链接。
用户态路径到内核路径的展开示例:
用户态调用:CreateFileW(L"C:\\Windows\\notepad.exe", ...)
│
▼ NtCreateFile 接收 \??\C:\Windows\notepad.exe
│
▼ ObpLookupObjectName:
1) 前缀匹配 \??\ → 切换到 DosDevicesDirectory
2) 解析 "C:" → 发现 SymbolicLink → target = \Device\HarddiskVolume1
3) reparse → 路径变为 \Device\HarddiskVolume1\Windows\notepad.exe
4) 解析 \Device → 目录对象
5) 解析 HarddiskVolume1 → IoFileObjectType(设备对象,有 ParseProcedure)
6) 调用 IopParseDevice(I/O 管理器的 ParseProcedure)→ 完成文件对象创建
│
▼ 返回 FILE_OBJECT 句柄
ObpLookupObjectName 对符号链接的处理:透明解析
在用户态看来,这整个过程是透明 的------调用者不会知道自己穿过了多少层符号链接。只有通过 NtQueryObject 查询对象类型、或使用特殊标志 OBJ_OPENLINK 打开符号链接本身时,才能感知到符号链接的存在。
4.1.8 概念解释
下表汇总本节核心术语:
| 术语 | 含义 | 数据结构 / 函数 |
|---|---|---|
| 对象头(Object Header) | 所有内核对象共享的前缀结构,记录引用计数、类型、安全、Info 块偏移等公共字段 | OBJECT_HEADER |
| 对象体(Object Body) | 对象头之后的类型专属数据。对外 API 返回的指针通常指向 Body,如 PFILE_OBJECT、PEPROCESS |
EPROCESS, FILE_OBJECT, KEVENT, ... |
| Info 块(Name/Handle/Quota/Creator Info) | 位于对象头之前(低地址方向)的可选扩展结构,按需分配,通过负偏移量定位 | OBJECT_HEADER_NAME_INFO, OBJECT_HEADER_HANDLE_INFO, OBJECT_HEADER_QUOTA_INFO, OBJECT_HEADER_CREATOR_INFO |
| 对象目录(Object Directory) | 对象管理器的"内核文件系统"目录,将名称映射到对象 | OBJECT_DIRECTORY, NtCreateDirectoryObject |
| Hash Bucket | 对象目录内部的 hash 表槽位(共 37 个)。名称先 hash 再取模得到索引,冲突条目以链表串起来 | OBJECT_DIRECTORY.HashBuckets[NUMBER_HASH_BUCKETS] |
| 命名空间(Namespace) | 由 \ 根目录、各级子目录、符号链接构成的统一名称空间。典型路径如 \??\C:\Windows、\BaseNamedObjects\MyEvent |
ObpRootDirectoryObject, ObpLookupObjectName |
| 符号链接(Symbolic Link) | 在对象命名空间中实现"别名→真实路径"的映射。驱动 \??\C: → \Device\HarddiskVolume1 等 |
OBJECT_SYMBOLIC_LINK, ObpParseSymbolicLink |
| 引用计数(Reference Count) | 控制对象生命周期的原子计数器。PointerCount 归零时对象被释放 |
OBJECT_HEADER.PointerCount, ObReferenceObject, ObDereferenceObject |
| PointerCount vs HandleCount | PointerCount 统计内核中所有持有对象指针的引用;HandleCount 统计用户态句柄数量。PointerCount ≥ HandleCount |
OBJECT_HEADER.PointerCount, OBJECT_HEADER.HandleCount |
| 永久对象(Permanent Object) | 带 OB_FLAG_PERMANENT 标志。即使引用计数归零也不释放,直到 NtMakeTemporaryObject 解除。典型用例:根目录、\ObjectTypes |
OB_FLAG_PERMANENT, ObpSetPermanentObject |
4.1.9 为什么要这样设计
Q1. 为什么用「对象头 + 对象体」而不是直接在对象体里存公共字段?
核心权衡:通用性 vs 类型灵活性。
- 通用性:所有对象都需要引用计数、类型指针、安全描述符。若让每个类型自行包含这些字段,既重复代码又难演进------未来修改公共字段需要改 N 个类型定义。
- 类型灵活性 :通过
OBJECT_TO_OBJECT_HEADER(body)宏,对象体对 Header 完全无知------FILE_OBJECT的开发者不需要关心 Header 字段如何布局,只需调用ObCreateObject(IoFileObjectType, ..., sizeof(FILE_OBJECT), ...)。 - Info 块的按需分配 :把可变扩展放在 Header 之前 (负偏移方向)而不是之后(正偏移方向),避免 Header 大小随对象类型变化------这样
CONTAINING_RECORD((body), OBJECT_HEADER, Body)的偏移是编译期常量,所有类型共享一个实现。
反例 :如果把所有公共字段放在对象体开头(即
EPROCESS第一个字段是LONG_PTR RefCount),那每个类型都要手写ObDereferenceObject(&eprocess->RefCount)------无法统一,也无法向现有类型追加新公共字段。
Q2. 为什么 Info 块要用「负偏移量」而不是用结构体指针数组?
核心权衡:内存占用 vs 查找速度 vs 一次性分配。
方案对比:
| 方案 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| A. 负偏移量(当前设计) | UCHAR Offset 字段记录 Info 块到 Header 的字节数。0 = 不存在 |
1) Header 固定小(3 个 UCHAR + 1 个 Flags UCHAR);2) 单次池分配 完成 Header+Info+Body,避免碎片;3) O(1) 定位 | 单个 Info 块大小之和必须 < 256 字节 |
| B. 指针数组 | PVOID InfoPointers[4] 或 struct { PNAME_INFO Name; PHANDLE_INFO Handle; ... } |
灵活,可容纳任意大小的 Info 块 | 1) 需要5 次独立池分配(Header + 4 个 Info 块)→ 碎片化、慢;2) 每个指针 8/4 字节,32 字节开销 vs 3 字节 |
| C. 固定长度数组 | 所有对象都预留 4 个 Info 块空间 | 实现最简单 | 大部分对象不需要全部 Info 块,内存浪费巨大 |
ReactOS 选择方案 A。UCHAR 偏移量限制为 255 字节------但 4 个 Info 块的实际大小都很小(~40 字节/块),总和 ~160 字节 < 255,绰绰有余。
Q3. 为什么对象目录要用 37 个 Hash Bucket 而不是更大的表或 B 树?
核心权衡:查找速度 vs 内存开销 vs 实现复杂度。
- 37 是质数 :使
hash % 37的分布更均匀(若使用合数,与 hash 算法叠加可能导致某些 bucket 永远空)。 - 小表 + 链表冲突 :对象目录的典型条目数在几十到几百级别(一个 Session 的
\??\目录通常 < 100 条目),O(N) 链表扫描足够快。 - 缓存到链表头部:命中条目被提升到 bucket 头部后,热点访问接近 O(1)。
- 对比 B 树 :B 树适合百万级条目(如文件系统索引),但在内核路径名查找场景下实现代价高、锁粒度复杂 。Windows 后续版本的确引入了更大的动态表(
ObpDirectoryEntry结构变化),但 ReactOS 在兼容 NT 5.x 行为时选择了更简单的固定 37-bucket 设计。
Q4. 为什么要有两套引用计数(PointerCount, HandleCount)而不是一个计数器?
核心权衡:释放判断 vs 命名空间管理 vs 诊断。
- PointerCount 控制释放 :当且仅当内核中最后一个指针引用解除时(
PointerCount: 1 → 0),对象才被释放。这是必须的------即使没有句柄打开,内核驱动代码持有对象体指针期间对象必须存活。 - HandleCount 控制命名 :当
HandleCount → 0(即所有用户态句柄都关闭),对象才会从命名空间被移除(ObpDeleteNameCheck)。这样保证"关闭句柄后再次打开同名对象不会失败"。 - 若只用一个计数器(比如每个句柄 +1,每个内核指针引用也 +1):无法区分"是最后一个句柄关闭但仍有内核引用" vs "所有引用都结束"。前者需要保留名称在目录中,后者则不------但仅看一个计数器无法做这个区分。
4.1.10 增强子节:Info 块的动态布局与对齐
4.1.10.1 设计意图
每个对象是否需要 Name/Handle/Quota/Creator Info 取决于该对象的使用方式,而非其类型------即同一类型的对象在不同场景下可能有不同的 Info 块组合。例如:
- 匿名事件 (
CreateEvent(NULL, ..., NULL)):没有名称 → 不需要 NameInfo; - 命名事件 (
CreateEvent(NULL, ..., L"MyEvent")):有名称 → 需要 NameInfo; - 内核创建的文件对象:只在内核中使用,不计入用户配额 → 可能不需要 QuotaInfo;
- 用户态打开的文件对象:需要对进程配额计费 → 需要 QuotaInfo;
- 当 NtGlobalFlag 启用了 FLG_MAINTAIN_OBJECT_TYPELIST 时 :所有对象都需要 CreatorInfo,以便在
!drvobj/!object调试命令中可被遍历。
设计意图是:在创建时静态决定布局,创建后不变,以最小的开销为每个对象选择最精简的头结构。
4.1.10.2 概念解释
Info 块的分配策略(ObpAllocateObject 中的判定逻辑)
根据 ntoskrnl/ob/oblife.c:646-713(file:///d:/reactos/ntoskrnl/ob/oblife.c#L646-L713):
| Info 块 | 何时分配 | 代码条件 |
|---|---|---|
| NameInfo | 调用者提供了非空 ObjectName(即 ObjectName->Buffer != NULL) |
if (ObjectName->Buffer) { NameSize = sizeof(OBJECT_HEADER_NAME_INFO); ObpObjectsWithName++; } |
| HandleInfo | 对象类型声明了 MaintainHandleCount(如进程/线程/文件,而 Directory 类型通常不需要) |
if (ObjectType->TypeInfo.MaintainHandleCount) { HandleSize = sizeof(OBJECT_HEADER_HANDLE_INFO); ObpObjectsWithHandleDB++; } |
| QuotaInfo | 以下任一成立: ① PagedPoolCharge/NonPagedPoolCharge/SdCharge 与类型默认值不同; ② 调用方是用户态进程(非 System 进程); ③ ObjectAttributes 带有 OBJ_EXCLUSIVE |
见 oblife.c:656-667(file:///d:/reactos/ntoskrnl/ob/oblife.c#L656-L667) |
| CreatorInfo | 对象类型声明了 MaintainTypeList,或系统启用全局类型列表维护 |
if (ObjectType->TypeInfo.MaintainTypeList) { CreatorSize = sizeof(OBJECT_HEADER_CREATOR_INFO); ObpObjectsWithCreatorInfo++; } |
对齐要求
- OBJECT_HEADER 的对齐 :Header 内的
Body字段类型为QUAD(即LONGLONG),编译器按 8 字节对齐该字段 → Header 起始地址天然 8 字节对齐。 - Info 块的对齐 :由于 Info 块顺序紧接 Header 之前 分配(在同一次
ExAllocatePoolWithTag中),只要 Header 对齐 8 字节且每个 Info 块大小是 8 的倍数(OBJECT_HEADER_NAME_INFO= 32/24 字节,OBJECT_HEADER_HANDLE_INFO= 16 字节,OBJECT_HEADER_QUOTA_INFO= 32/16 字节,OBJECT_HEADER_CREATOR_INFO= 16 字节),则所有 Info 块也自然 8 字节对齐。 - 对象体的对齐 :
Body以QUAD类型起始 → 对象体从 8 字节对齐地址开始,符合EPROCESS、FILE_OBJECT等结构的对齐要求(这些结构通常以LONGLONG/KEVENT开头,需要 8 字节对齐)。
动态分配时各 Info 块的排列顺序(从低地址到高地址)
由 ntoskrnl/ob/oblife.c:727-778(file:///d:/reactos/ntoskrnl/ob/oblife.c#L727-L778) 的代码顺序决定:
低地址 (Pool 起始) 高地址
┌────────────┬────────────┬──────────┬───────────┬────────┬──────────┐
│ QuotaInfo │ HandleInfo │ NameInfo │CreatorInfo│ Header │ Body │
│ (若有) │ (若有) │ (若有) │ (若有) │ 固定 │ 对象体 │
└────────────┴────────────┴──────────┴───────────┴────────┴──────────┘
▲ ▲ ▲ ▲ ▲
│ │ │ │ │
QuotaSize HandleSize NameSize CreatorSize FIELD_OFFSET(, Body)
│ │ │ │
└──────────────┴───────────┴───────────┴───────────► FinalSize
Header->QuotaInfoOffset = QuotaSize + HandleSize + NameSize + CreatorSize
Header->HandleInfoOffset = HandleSize + NameSize + CreatorSize
Header->NameInfoOffset = NameSize + CreatorSize
为什么这个顺序(而不是反过来)?因为 CreatorInfo 在 Header 紧前面 ,这样 OBJECT_HEADER_TO_CREATOR_INFO 可以用固定的 sizeof(OBJECT_HEADER_CREATOR_INFO) 作为负偏移,无需占用 UCHAR 字段------节省了一个字节(虽然微小,但体现了极致优化)。
4.1.10.3 为什么要这样设计
Q1. 为什么不在对象创建后动态增删 Info 块(例如首次命名时再分配 NameInfo)?
答案:简化并发与内存管理。
- 池分配代价 :若在命名时动态分配 NameInfo,需要额外的
ExAllocatePoolWithTag调用;并且在对象生命周期中可能再次分配------这需要在 Header 上增加锁(目前 Header 没有专门的锁,只有类型的 ObjectLocks)。 - 释放原子性 :对象释放时
ObpDeallocateObject(见 ntoskrnl/ob/oblife.c:37-143(file:///d:/reactos/ntoskrnl/ob/oblife.c#L37-L143))只需一次ExFreePoolWithTag即可释放整个块。如果各 Info 块独立分配,要做 N 次释放------增加复杂度与竞态窗口。 - 一致的大小 :对象创建时大小就固定下来,
ObGetObjectInformation等查询接口可以安全地遍历,无需担心字段在查询过程中从无变有。
唯一例外 :
HandleInfo在对象首次被多个进程同时打开时,可能从SingleEntry(存储在 Header 前的结构中)升级为HandleCountDatabase(额外从池中分配)。这由 Handle Table 管理代码在持锁下进行,是个小而受控的"一次升级"。
Q2. 为什么 CreatorInfo 不用负偏移量字段而用固定 sizeof 判断?
答案 :节省一个 UCHAR,并强化"CreatorInfo 紧接 Header 之前"的不变式。
- CreatorInfo 的分配条件与其他 Info 块不同 :它由 Type 的
MaintainTypeList决定,在对象创建时就已知,并且始终存在或始终不存在------不像 NameInfo 取决于调用者是否传入名称。 - 由于它在 Header 紧前(如果存在),因此
Header - sizeof(OBJECT_HEADER_CREATOR_INFO)即可定位,无需额外字段。OB_FLAG_CREATOR_INFO标志位足以判断是否存在。 - 这反过来又对排列顺序施加了约束:CreatorInfo 必须是离 Header 最近 的 Info 块------这就是
ObpAllocateObject中最后处理 CreatorInfo 的原因(向前推进 Header 指针前,最后一个被"跳过"的块就是 CreatorInfo)。
Q3. 示例对比
| 对象 | 是否有 Name | 是否需要 Quota | 是否需要 Handle Count | CreatorInfo | 最终头部大小(示意) |
|---|---|---|---|---|---|
| 匿名事件(用户态创建) | 否 | 是(用户态) | 是(Event 类型维护句柄计数) | 视 NtGlobalFlag | QuotaInfo(32) + HandleInfo(16) + Header(48) = 96 bytes |
命名文件对象 (路径 C:\x.txt) |
是 | 是 | 是 | 视 NtGlobalFlag | QuotaInfo(32) + HandleInfo(16) + NameInfo(32) + Header(48) = 128 bytes |
| 内核驱动创建的设备对象 | 是(\Device\MyDevice) |
否(系统进程,不计配额) | 否(Device 类型不维护 HandleCount) | 否 | NameInfo(32) + Header(48) = 80 bytes |
进程对象 (EPROCESS) |
否(进程不用名称在命名空间中查找) | 是 | 是 | 通常是 | QuotaInfo(32) + HandleInfo(16) + CreatorInfo(16) + Header(48) = 112 bytes |
实际大小因 x86/x64 以及字段对齐而变化;上表为概念示意。
4.1.11 增强子节:命名空间遍历与 DosDeviceMap
4.1.11.1 设计意图
Windows 需要在同一台物理机器上隔离多个会话的"可见 DOS 设备名":
- Session 0(服务、系统进程)拥有完整的 DOS 设备集;
- Session 1、2...(交互式登录用户)可能看到:
- 全局设备(
C:、D:等磁盘卷); - 自己通过
DefineDosDevice或subst命令创建的本地映射; - 不可见其他会话的私有映射。
- 全局设备(
设计目标:① 使 \??\ 前缀的解析结果取决于当前进程所属会话;② 允许显式访问全局设备(通过 \GLOBAL??\ 或 \??\Global\);③ 驱动 subst、DefineDosDevice、网络重定向盘符等用户态操作。
4.1.11.2 概念解释
DEVICE_MAP 结构
见 sdk/include/ndk/obtypes.h:523-530(file:///d:/reactos/sdk/include/ndk/obtypes.h#L523-L530):
c
typedef struct _DEVICE_MAP
{
POBJECT_DIRECTORY DosDevicesDirectory; // 本会话的 \??\ 目录
POBJECT_DIRECTORY GlobalDosDevicesDirectory; // 全局 \GLOBAL??\ 目录
ULONG ReferenceCount; // 引用计数(会话死亡时递减)
ULONG DriveMap; // 位图:bit N=1 表示盘符 A+N 存在
UCHAR DriveType[32]; // 每个盘符的类型(DRIVE_FIXED/REMOTE/...)
} DEVICE_MAP, *PDEVICE_MAP;
每个进程在 EPROCESS 中有一个 DeviceMap 字段(ReactOS 中对应字段位于 EPROCESS)。当 ObpLookupObjectName 遇到 \??\ 前缀时:
- 读取当前进程的
DeviceMap; - 切换到
DeviceMap->DosDevicesDirectory继续解析; - 在该目录查找失败时,回退到
DeviceMap->GlobalDosDevicesDirectory再查一次(shadow lookup,见 ntoskrnl/ob/obdir.c:288-299(file:///d:/reactos/ntoskrnl/ob/obdir.c#L288-L299))。
会话隔离的 ??\ 目录
系统启动时创建的是全局 \GLOBAL?? 目录。当终端服务会话管理器 (smss.exe) 为每个登录用户创建新会话时:
- 创建一个新的
OBJECT_DIRECTORY(类型 Directory); - 为其分配一个
DEVICE_MAP,令其GlobalDosDevicesDirectory = \GLOBAL??; - 将会话内所有进程的
DeviceMap设置为这张 map; - 当此会话内调用
DefineDosDevice时,新创建的符号链接被挂到会话私有 的DosDevicesDirectory上。
DefineDosDevice / subst 的内核实现
用户态 DefineDosDeviceW(L"Z:", L"\\??\\D:\\MyFolder", ...) 或命令行 subst Z: D:\MyFolder 最终调用:
NtCreateSymbolicLinkObject(
ObjectAttributes->RootDirectory = 当前会话的 \??\ 目录,
ObjectAttributes->ObjectName = L"Z:",
TargetPath = L"\\??\\D:\\MyFolder" // 注意这是另一个符号链接
)
所以 Z: 是一个指向 \??\D:\MyFolder(最终解析为 \Device\HarddiskVolume1\MyFolder)的符号链接,被挂在当前会话的 \??\ 目录下。对其他会话不可见。
全局优先级规则
当 \??\C: 的解析在会话私有目录找到匹配(已被 subst 覆盖为其他目标)时,优先使用会话私有的 。只有在会话私有目录中完全找不到才回退到 \GLOBAL??。这确保:
- 用户无法意外"丢失"自己创建的盘符;
- 管理工具仍可通过
\\?\GLOBALROOT\...访问真实设备。
4.1.11.3 为什么要这样设计
Q1. 为什么 CMD 的 subst 只对当前用户/当前会话可见?
答案 :因为 subst 最终调用 DefineDosDevice,而它创建的符号链接被放入调用者进程所属会话 的 DosDevicesDirectory。其他会话有各自独立的 DosDevicesDirectory------所以它们看不到这条符号链接。这是设计的初衷:为终端服务(多用户远程登录)提供设备名隔离。
如果希望跨会话可见,需在 DefineDosDevice 中指定 DDD_RAW_TARGET_PATH | DDD_NO_BROADCAST_SYSTEM 并直接在 \GLOBAL?? 下创建,或以系统进程身份调用。
Q2. 为什么不直接用 \GLOBAL??\ 作为 \??\,而要引入每个会话一份?
答案:兼容性 + 安全性。
- 兼容性 :DOS 时代遗留的
A:、B:、COM1、LPT1等命名在现代系统上可能不存在------但某些老程序仍会去打开它们。通过会话级的覆盖可以给特定应用"伪造"这些设备。 - 安全性 :服务(Session 0)拥有的设备(如命名管道
\\.\pipe\...)不应被普通登录会话的用户直接以同名方式访问,否则沙箱失效。 - 终端服务 :用户 A 映射了
Z:到他的网络共享\\serverA\share,用户 B 不应看到;这是基本的会话隔离。
Q3. 为什么 \??\ 前缀的识别用硬编码 WCHAR 比较而不是目录遍历?
答案 :性能。ObpLookupObjectName 是所有文件/设备打开路径上的热点。识别 \??\ 前缀可以立即跳转到 DosDevicesDirectory 而无需从根目录逐级遍历------节省 2 次目录查找。
源码中使用的 64-bit 整数比较技巧(ntoskrnl/ob/obname.c:689-716(file:///d:/reactos/ntoskrnl/ob/obname.c#L689-L716)):
c
// "\??\" 恰好是 4 个 WCHAR = 8 字节,可放入一个 ULONGLONG
if ((ObjectName->Length >= 4*sizeof(WCHAR)) &&
(*(PULONGLONG)ObjectName->Buffer == ObpDosDevicesShortNamePrefix.Alignment.QuadPart)) {
DeviceMap = ObpReferenceDeviceMap();
// 跳过前缀,剩余路径从第 4 个字符开始
LocalName.Length -= 4*sizeof(WCHAR);
LocalName.Buffer += 4;
Directory = DeviceMap->DosDevicesDirectory;
}
这是 O(1) 的比较,避免了对路径前缀的 hash 计算和目录遍历。
4.1.12 增强子节:Permanent 对象与延迟删除
4.1.12.1 设计意图
- 系统关键对象的存活保证 :根目录
\、类型目录\ObjectTypes、\??\等对象在系统整个生命周期中必须存在。如果某个驱动"忘记"引用它们,对象不应消失------OB_FLAG_PERMANENT保证这一点。 - 热路径上的快速释放 :当对象的
PointerCount在 APC 禁用的上下文(例如 DPC 级别)中降到 0 时,立即调用ObpDeleteObject是不安全的(可能在高 IRQL 上调用被动级的ExFreePoolWithTag)。延迟删除将释放工作移到 PASSIVE_LEVEL 的工作项中异步完成。 - 用户态可控的对象生命周期 :
NtMakePermanentObject/NtMakeTemporaryObject允许驱动或管理员将某个对象"钉"在命名空间中或释放它。
4.1.12.2 概念解释
OB_FLAG_PERMANENT 的语义
- 当对象被标记为
OB_FLAG_PERMANENT时,它的PointerCount始终保持 ≥ 1(在对象创建时被显式 +1); - 即使所有用户态句柄关闭、所有内核指针引用解除,对象仍不被释放;
ObpDeleteNameCheck(ntoskrnl/ob/obname.c:299-391(file:///d:/reactos/ntoskrnl/ob/obname.c#L299-L391))检查到该标志时跳过从目录中移除名称的操作;- 需要通过
NtMakeTemporaryObject显式"去永久化"才能让对象走正常的释放流程。
NtMakePermanentObject / NtMakeTemporaryObject
两者的实现(见 ntoskrnl/ob/oblife.c:1508-1535(file:///d:/reactos/ntoskrnl/ob/oblife.c#L1508-L1535))非常对称:
c
// 内部通用辅助函数
VOID FASTCALL ObpSetPermanentObject(PVOID ObjectBody, BOOLEAN Permanent)
{
POBJECT_HEADER Header = OBJECT_TO_OBJECT_HEADER(ObjectBody);
ObpAcquireObjectLock(Header);
if (Permanent) {
Header->Flags |= OB_FLAG_PERMANENT;
} else {
Header->Flags &= ~OB_FLAG_PERMANENT;
}
ObpReleaseObjectLock(Header);
// 去永久化 → 检查是否已无句柄/引用,若是则触发删除
if (!Permanent) ObpDeleteNameCheck(ObjectBody);
}
延迟删除(OB_FLAG_DEFER_DELETE / ObpReaperWorkItem)
当 ObDereferenceObject 在 APC 禁用的上下文中被调用(例如在 DPC、持有 spin lock 的代码路径)时,不能立即释放对象(因为池分配/释放要求 PASSIVE_LEVEL)。此时调用 ObpDeferObjectDeletion(见 ntoskrnl/ob/obref.c:51-73(file:///d:/reactos/ntoskrnl/ob/obref.c#L51-L73)):
c
VOID NTAPI ObpDeferObjectDeletion(POBJECT_HEADER Header)
{
PVOID Entry;
do {
Entry = ObpReaperList;
Header->NextToFree = Entry; // 复用 HandleCount 字段的内存作为链表指针
} while (InterlockedCompareExchangePointer(&ObpReaperList, Header, Entry) != Entry);
if (!Entry) {
// 列表从空变非空 → 首次有对象入链 → 排队工作项
ExQueueWorkItem(&ObpReaperWorkItem, CriticalWorkQueue);
}
}
ObpReaperWorkItem(工作项回调 ObpReapObject,见 ntoskrnl/ob/oblife.c:218-244(file:///d:/reactos/ntoskrnl/ob/oblife.c#L218-L244))在 PASSIVE_LEVEL 被调用时,原子地取走整个链表 并逐个调用 ObpDeleteObject 释放对象。
注意
HandleCount/NextToFree的 union 设计:在对象仍然存活时HandleCount有效;在对象被标记删除后(此时HandleCount必为 0,否则不会进入删除),NextToFree被用作链表指针。这是一个典型的"节省内存的 union"手法。
4.1.12.3 为什么要这样设计
Q1. 为什么不在系统启动时把永久对象的引用计数设置为一个巨大的数(如 0x80000000),而要引入一个标志位?
答案:两个原因------① 引用计数与删除逻辑需要统一;② 可撤销。
- 统一引用计数模型 :如果用 "magic number",那么
ObDereferenceObject在递减时必须检查if (PointerCount > SOME_THRESHOLD) ...------不优雅,且有 overflow risk。OB_FLAG_PERMANENT作为一个独立的布尔位,在ObpDeleteNameCheck和释放路径上直接判断,简洁。 - 可撤销 :永久对象需要能变回普通对象(
NtMakeTemporaryObject)。用一个标志位切换比"往回增加"引用计数更自然------后者难以区分"是系统自动加的永久引用还是驱动显式引用"。
Q2. 为什么延迟删除使用无锁链表 + 工作项,而不是直接排队 DPC?
答案:避免在 DPC 级别进行复杂释放。
ExQueueWorkItem(CriticalWorkQueue)会将工作项挂到系统工作线程,在 PASSIVE_LEVEL 执行。此时可以安全地调用ObpDeleteObject(其内部的DeleteProcedure可能涉及复杂的 I/O 资源回收)。InterlockedCompareExchangePointer实现的无锁入队确保了任何 IRQL 上都能安全排队。- 只在链表从空变非空时才排队一次工作项,避免工作项风暴。
Q3. NextToFree 复用 HandleCount 的字段是否存在竞态?
答案 :否。当 ObpDeferObjectDeletion 被调用时,PointerCount 已经降到 0,而在 PointerCount 降到 0 之前 HandleCount 必须已经是 0(由 ASSERT(Header->HandleCount == 0) 保证)。也就是说,当代码决定"延迟删除"时,对象已经处于"所有句柄已关闭 + 所有指针引用已解除"的状态,HandleCount 不再被读取------复用其内存作为链表指针是安全的。
4.1.13 小结(增强版)
本节覆盖的主要内容
- 对象与对象目录的统一抽象 :对象管理器将所有内核资源(进程、线程、文件、事件、驱动、设备、目录、符号链接......)统一为带
OBJECT_HEADER的对象,并通过对象目录组成/根目录下的命名空间。 - OBJECT_HEADER 的布局 :
PointerCount、HandleCount、Type、Flags与四个可选 Info 块(Name/Handle/Quota/Creator)通过负偏移量 定位到 Header 之前。OBJECT_TO_OBJECT_HEADER宏用CONTAINING_RECORD从对象体回溯到对象头。 - 对象创建流程 :
ObCreateObject→ObpCaptureObjectCreateInformation→ObpAllocateObject一次性分配 Header + Info 块 + Body,返回 Body 指针。后续由ObInsertObject完成命名空间插入与句柄分配。 - 双计数器的生命周期管理 :
PointerCount控制对象是否可释放,HandleCount控制对象是否从目录中移除名称。所有增减都用InterlockedIncrementSizeT/InterlockedDecrementSizeT完成,确保 SMP 安全。 - 37-bucket Hash 目录 :对象目录以 hash 表(
NUMBER_HASH_BUCKETS = 37)实现,对大小写不敏感的名称做 hash → 取模 → 链表线性查找;命中条目被提升到 bucket 头部以加速重复访问。 - 名称解析 ObpLookupObjectName :逐段解析
\分隔的路径;识别\??\前缀跳转到会话 DOS 设备目录;通过OBP_LOOKUP_CONTEXT缓存 hash 并持有锁以完成"查找 → 插入"的原子操作;支持最多 30 层符号链接 reparse。 - 符号链接与会话隔离 :
\??\C:→\Device\HarddiskVolume1的透明解析;每个会话有自己的DosDevicesDirectory,subst命令仅对当前会话可见;\GLOBAL??\允许显式访问全局设备。 - Permanent 对象与延迟删除 :
OB_FLAG_PERMANENT保证根目录等关键对象永不被引用计数释放;OB_FLAG_DEFER_DELETE+ObpReaperWorkItem让在高 IRQL 上无法立即释放的对象在工作项中异步清理。
关键数据结构 vs 对应函数对照表
| 数据结构 | 主要操作函数 | 所在文件 |
|---|---|---|
OBJECT_HEADER |
ObCreateObject, ObpAllocateObject, ObReferenceObject, ObDereferenceObject, ObpDeleteObject |
oblife.c, obref.c |
OBJECT_HEADER_NAME_INFO |
ObpAllocateObject, ObpLookupEntryDirectory, ObQueryNameString |
oblife.c, obdir.c, obname.c |
OBJECT_HEADER_HANDLE_INFO |
ObpIncrementHandleCount, ObpDecrementHandleCount |
oblife.c, handle 表相关 |
OBJECT_HEADER_QUOTA_INFO |
ObpAllocateObject, PsChargePoolQuota |
oblife.c |
OBJECT_HEADER_CREATOR_INFO |
ObpAllocateObject, ObInitSystem 中遍历 TypeList |
oblife.c, obinit.c |
OBJECT_DIRECTORY |
NtCreateDirectoryObject, ObpInsertEntryDirectory, ObpLookupEntryDirectory, ObpDeleteEntryDirectory |
obdir.c |
OBJECT_DIRECTORY_ENTRY |
(Hash 链表节点,内部使用) | obdir.c |
OBJECT_SYMBOLIC_LINK |
NtCreateSymbolicLinkObject, ObpParseSymbolicLink |
obname.c |
OBP_LOOKUP_CONTEXT |
ObpLookupObjectName, ObpInsertEntryDirectory, ObpLookupEntryDirectory |
obname.c, obdir.c |
DEVICE_MAP |
ObSetDeviceMap, ObpCreateDosDevicesDirectory |
obname.c |
ObpReaperList / ObpReaperWorkItem |
ObpDeferObjectDeletion, ObpReapObject |
obref.c, oblife.c |
下一步的学习路径
- 4.2 对象类型 :深入
OBJECT_TYPE与OBJECT_TYPE_INITIALIZER------类型如何注册、OpenProcedure/CloseProcedure/DeleteProcedure/ParseProcedure/SecurityProcedure/QueryNameProcedure如何联动、类型对象的 TypeList 与!object调试命令的实现。 - 4.3 句柄表 :
HANDLE_TABLE的三层表结构(LOW_LEVEL_ENTRIES/MID_LEVEL_ENTRIES/HIGH_LEVEL_ENTRIES)、句柄值编码(3 位标记位 + 29 位索引)、句柄表扩容与审计、ExMapHandleToPointer的加锁策略、内核句柄表与进程句柄表的差异。 - 4.4 对象安全 :
SECURITY_DESCRIPTOR在对象头中的存储、ObCheckObjectAccess/ObpAuditObjectAccess的访问检查流程、自主访问控制(DACL)与强制完整性控制(MIC)。 - 4.5 符号链接 / 设备映射 :
ObpParseSymbolicLink的完整实现、DefineDosDevice在内核中的完整语义、会话创建时ObSetDeviceMap如何切换EPROCESS.DeviceMap。
</seed:tool_call>