为什么说Windows句柄表是每个进程独立的,并且可以动态增长。当进程需要更多句柄时,系统可以扩展句柄表。 而x86的GDT/LDT是全局的,且大小固定(由GDTR/LDTR寄存器中的界限决定),无法动态增长。
以Windows和x86系统为例,详细解释两种机制的核心原理和差异。
Windows句柄表(每个进程独立,可动态增长)
核心机制和原理
进程句柄表作为进程地址空间的一部分
每个Windows进程都有一个独立的句柄表,存储在进程的地址空间中(实际上是在内核空间,但属于进程上下文)。这意味着每个进程都可以有自己独立的句柄表,互不干扰。句柄表的多级结构
Windows的句柄表通常采用多级表结构(类似于页表)。例如,在32位系统中,句柄表可能是一个两层的表。第一层是固定大小的指针数组,每个指针指向一个第二层的“句柄页”。当进程需要更多句柄时,系统可以分配新的句柄页并链接到第一层表中。动态扩展
当进程创建的句柄数量超过当前句柄表容量时,系统会分配新的句柄页,并更新第一层表。这个过程对应用程序是透明的。句柄表的大小只受系统资源(如内存)限制,而不是编译时或启动时固定的。句柄作为索引
句柄本质上是一个索引,通过这个索引在句柄表中查找对应的内核对象指针。由于句柄表是每个进程独立的,所以不同进程中相同的句柄值可能指向不同的内核对象。
实例说明
假设一个进程初始时句柄表只能容纳100个句柄。当进程创建第101个内核对象时,系统会检测到句柄表已满,然后执行以下步骤:
分配一个新的内存页(例如4KB)作为新的句柄页。
将这个新页链接到进程句柄表的第一层索引中。
在新分配的句柄页中分配一个句柄条目,并返回这个句柄(例如,句柄值可能是100)。
进程使用这个句柄来引用新创建的内核对象。
这样,句柄表就动态扩展了。理论上,只要系统有足够的内存,句柄表可以一直扩展。
x86 GDT/LDT(全局/局部描述符表)
核心机制和原理
GDT/LDT是全局数据结构
GDT(全局描述符表)是系统中唯一的,由GDTR寄存器指向。LDT(局部描述符表)虽然每个任务可以有一个,但它的位置和大小也是在任务切换时由LDTR寄存器加载,且大小固定。固定大小的数组
GDT和LDT在内存中是一个连续数组,每个描述符占8字节(32位模式)。GDTR和LDTR寄存器中有一个16位的界限字段,表示表的最后一个字节的偏移。因此,表的大小在加载GDTR/LDTR时就已经确定,且最大为64KB(因为界限是16位,最大值为0xFFFF,即65535字节,最多8191个描述符)。无法动态扩展
一旦GDTR/LDTR被加载,表的大小就固定了。如果要增加更多的描述符,必须重新分配一个更大的表,然后重新加载GDTR/LDTR,这需要系统暂停并更新所有引用该表的段寄存器,这在实际操作系统中是不现实的。硬件依赖
x86架构的段机制是硬件强制的,描述符表的结构和访问方式由硬件规定。硬件在访问段寄存器时会使用GDTR/LDTR寄存器中的基址和界限来检查索引是否越界。这种硬件机制限制了表的动态扩展。
实例说明
假设系统启动时,操作系统设置了GDT,其中包含10个描述符(从索引0到9)。GDTR的界限设置为10*8-1=79(因为每个描述符8字节,所以10个描述符占用0到79字节)。
当一个程序试图加载一个段寄存器,比如mov ax, 0x50(假设TI=0,索引10,即第11个描述符),CPU会做如下检查:
从GDTR中获取基址和界限。
计算描述符的偏移:索引(10)* 8 = 80。
检查80+7=87是否超出界限(79)。因为87>79,CPU会触发一个异常(一般保护异常,#GP)。
如果要添加第11个描述符,操作系统必须:
分配一个新的内存区域,大小至少为11*8=88字节。
将原来的10个描述符复制到新区域。
添加新的描述符到第11个位置。
重新加载GDTR,设置基址为新区域,界限为87。
确保没有其他CPU正在使用旧的GDT(在多核系统中需要同步)。
这个过程非常复杂,而且需要系统暂停,因为任何在更新GDT过程中访问段寄存器的操作都可能导致错误。因此,操作系统通常会在启动时分配一个足够大的GDT,而不是动态扩展。
总结对比
| 特性 | Windows句柄表 | x86 GDT/LDT |
|---|---|---|
| 所属范围 | 每个进程独立 | 全局(GDT)或每个任务(LDT) |
| 存储位置 | 进程地址空间(内核部分) | 由GDTR/LDTR寄存器指定 |
| 结构 | 多级表,类似页表 | 连续数组 |
| 大小 | 可动态扩展,受内存限制 | 固定,由GDTR/LDTR的界限决定 |
| 扩展操作 | 透明分配新页,更新指针 | 需要重新分配并复制整个表,重新加载寄存器 |
| 硬件依赖 | 软件实现,不依赖特定硬件 | 硬件强制,由CPU检查界限 |
| 最大容量 | 理论上受虚拟内存大小限制 | 最大64KB(8191个描述符) |
因此,Windows句柄表的设计更加灵活,能够适应现代操作系统动态创建大量内核对象的需求。而x86的段描述符表受限于硬件设计,是静态的,无法动态扩展,这也是现代操作系统倾向于使用分页而不是分段的原因之一。
Windows句柄表与x86 GDT/LDT的机制差异详解
一、Windows句柄表的核心机制
1. 三级表结构设计
Windows句柄表采用多级表结构,类似于页表机制:
// Windows句柄表三级结构示例(简化版)typedefstruct_HANDLE_TABLE{ULONG_PTR TableCode;// 指向句柄表的指针(低2位表示层级)struct_EPROCESS*QuotaProcess;HANDLE UniqueProcessId;EX_PUSH_LOCK HandleLock;LIST_ENTRY HandleTableList;ULONG HandleTableSize;// 当前句柄表大小ULONG NextHandleNeedingPool;// 下一个需要分配池的句柄索引// ...}HANDLE_TABLE;// 句柄表条目结构typedefstruct_HANDLE_TABLE_ENTRY{union{PVOID Object;// 指向内核对象的指针(低3位为标志)ULONG_PTR Value;};union{ACCESS_MASK GrantedAccess;struct{USHORT GrantedAccessIndex;USHORT CreatorBackTraceIndex;};};}HANDLE_TABLE_ENTRY;2. 动态增长机制实例
// 句柄表扩展的实际过程HANDLECreateNewHandle(PVOID Object,ACCESS_MASK AccessMask){// 1. 获取当前进程的句柄表HANDLE_TABLE*handleTable=PsGetCurrentProcess()->ObjectTable;// 2. 检查句柄表是否需要扩展if(handleTable->NextHandleNeedingPool>=handleTable->HandleTableSize){// 3. 扩展句柄表ExpandHandleTable(handleTable);}// 4. 分配新句柄HANDLE handle=AllocateHandleFromTable(handleTable);// 5. 设置句柄条目HANDLE_TABLE_ENTRY*entry=ExpLookupHandleTableEntry(handleTable,handle);entry->Object=(PVOID)((ULONG_PTR)Object|OBJ_HANDLE_ATTRIBUTES);entry->GrantedAccess=AccessMask;returnhandle;}VOIDExpandHandleTable(HANDLE_TABLE*HandleTable){ULONG newSize;// 计算新大小(通常加倍)if(HandleTable->HandleTableSize==0){newSize=INITIAL_HANDLE_TABLE_SIZE;// 初始大小,如512}else{newSize=HandleTable->HandleTableSize*2;}// 分配新的句柄表内存PVOID newTable=ExAllocatePoolWithTag(NonPagedPool,newSize*sizeof(HANDLE_TABLE_ENTRY),'htaB');if(newTable){// 复制现有句柄条目if(HandleTable->HandleTableSize>0){RtlCopyMemory(newTable,HandleTable->TableCode&~3,HandleTable->HandleTableSize*sizeof(HANDLE_TABLE_ENTRY));}// 更新句柄表HandleTable->TableCode=(ULONG_PTR)newTable|(HandleTable->TableCode&3);// 保持层级标志HandleTable->HandleTableSize=newSize;// 释放旧表(如果存在)if(HandleTable->TableCode&~3){ExFreePoolWithTag((PVOID)(HandleTable->TableCode&~3),'htaB');}}}3. 三级查找过程
// 句柄的三级查找机制PVOIDDereferenceHandle(HANDLE Handle){// 1. 获取当前进程的句柄表HANDLE_TABLE*handleTable=PsGetCurrentProcess()->ObjectTable;// 2. 从句柄值计算索引ULONG handleIndex=(ULONG)Handle>>2;// 去掉低2位标志// 3. 根据TableCode的低2位确定层级ULONG level=handleTable->TableCode&3;switch(level){case0:// 单级表(小进程)if(handleIndex<handleTable->HandleTableSize){HANDLE_TABLE_ENTRY*entry=(HANDLE_TABLE_ENTRY*)(handleTable->TableCode&~3)+handleIndex;return(PVOID)((ULONG_PTR)entry->Object&~OBJ_HANDLE_ATTRIBUTES_MASK);}break;case1:// 两级表(中等进程){// 第一级:中间表ULONG midIndex=handleIndex/HANDLE_ENTRIES_PER_MID;ULONG lowIndex=handleIndex%HANDLE_ENTRIES_PER_MID;HANDLE_TABLE_ENTRY**midTable=(HANDLE_TABLE_ENTRY**)(handleTable->TableCode&~3);if(midIndex<MID_TABLE_SIZE&&midTable[midIndex]){HANDLE_TABLE_ENTRY*entry=midTable[midIndex]+lowIndex;return(PVOID)((ULONG_PTR)entry->Object&~OBJ_HANDLE_ATTRIBUTES_MASK);}}break;case2:// 三级表(大进程){// 第一级:顶级表ULONG topIndex=handleIndex/(HANDLE_ENTRIES_PER_MID*MID_TABLE_SIZE);ULONG midIndex=(handleIndex%(HANDLE_ENTRIES_PER_MID*MID_TABLE_SIZE))/HANDLE_ENTRIES_PER_MID;ULONG lowIndex=handleIndex%HANDLE_ENTRIES_PER_MID;HANDLE_TABLE_ENTRY***topTable=(HANDLE_TABLE_ENTRY***)(handleTable->TableCode&~3);if(topIndex<TOP_TABLE_SIZE&&topTable[topIndex]&&topTable[topIndex][midIndex]){HANDLE_TABLE_ENTRY*entry=topTable[topIndex][midIndex]+lowIndex;return(PVOID)((ULONG_PTR)entry->Object&~OBJ_HANDLE_ATTRIBUTES_MASK);}}break;}returnNULL;// 无效句柄}二、x86 GDT/LDT的固定大小机制
1. GDT硬件数据结构
; x86 GDT寄存器结构 struc GDTR .limit: resw 1 ; 16位界限(表大小-1) .base: resd 1 ; 32位基地址 endstruc ; GDT描述符格式(每个8字节) struc GDT_ENTRY .limit_low: resw 1 ; 段限长低16位 .base_low: resw 1 ; 段基址低16位 .base_mid: resb 1 ; 段基址中8位 .access: resb 1 ; 访问权限 .gran_limit: resb 1 ; 粒度/限长高4位 .base_high: resb 1 ; 段基址高8位 endstruc2. 硬件强制限制的原因
; 示例:GDT的初始化和限制 section .data gdt_start: dq 0x0000000000000000 ; 空描述符(必须) gdt_code32: dw 0xFFFF ; 限长低16位 (0-15) dw 0x0000 ; 基址低16位 (16-31) db 0x00 ; 基址中8位 (32-39) db 0x9A ; 访问字节 (40-47): P=1, DPL=00, S=1, Type=1010 db 0xCF ; 标志/限长高4位 (48-55): G=1, D/B=1, L=0, AVL=0, 限长16-19=0xF db 0x00 ; 基址高8位 (56-63) gdt_data32: dw 0xFFFF dw 0x0000 db 0x00 db 0x92 ; Type=0010 (数据段) db 0xCF db 0x00 gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 ; 16位界限值(编译时确定) dd gdt_start ; 32位基地址 section .text global _start _start: ; 加载GDT(一次性操作) lgdt [gdt_descriptor] ; 此时GDT大小已固定,无法动态扩展 ; 选择子计算:索引 * 8 ; 最大描述符数 = (界限 + 1) / 8 ; 示例:界限=0xFFFF (65535) → 最大8191个描述符3. 无法动态扩展的技术原因
// 尝试扩展GDT的问题voidattempt_to_expand_gdt(){// 1. 当前GDT大小uint16_tcurrent_limit=get_gdtr_limit();// 例如 0x7FF (1023字节,127个描述符)// 2. 需要添加新描述符if(need_new_descriptor()){// 3. 分配新的更大的GDTuint8_t*new_gdt=kmalloc(current_limit+8+EXTRA_SPACE);// 4. 复制旧的GDT内容memcpy(new_gdt,get_gdt_base(),current_limit+1);// 5. 添加新描述符setup_descriptor(new_gdt+current_limit+1,...);// 6. 问题1:更新GDTRstruct{uint16_tlimit;uint32_tbase;}__attribute__((packed))new_gdtr;new_gdtr.limit=current_limit+8;// 新界限new_gdtr.base=(uint32_t)new_gdt;// 7. 关键问题:加载新GDTR需要非常小心asmvolatile("lgdt %0\n\t"// 此时所有基于旧GDT的段选择子都失效了!// 必须立即重新加载所有段寄存器"movw $0x10, %%ax\n\t"// 数据段选择子"movw %%ax, %%ds\n\t""movw %%ax, %%es\n\t""movw %%ax, %%fs\n\t""movw %%ax, %%gs\n\t""movw %%ax, %%ss\n\t""ljmp $0x08, $1f\n\t"// 代码段选择子"1:\n\t"::"m"(new_gdtr):"eax");// 8. 问题2:多核同步// 其他CPU可能还在使用旧的GDT!// 需要IPI(处理器间中断)来同步所有CPU// 9. 问题3:延迟问题// 中断处理程序可能使用任务门/调用门,这些依赖GDT中的特定条目}}4. 硬件检查的严格性
; CPU对GDT访问的硬件检查 load_segment_register: ; 假设要加载DS = 0x0010(索引=2,TI=0,RPL=0) ; 1. CPU从GDTR获取基址和界限 ; base = GDTR.base ; limit = GDTR.limit ; 2. 计算描述符地址 ; descriptor_address = base + (selector.index * 8) ; 3. 硬件检查1:描述符是否越界 ; if (descriptor_address + 7 > limit) { ; trigger_gp_fault(); // 通用保护故障 ; } ; 4. 硬件检查2:描述符类型是否有效 ; if (!descriptor.present) { ; trigger_np_fault(); // 段不存在故障 ; } ; 5. 硬件检查3:权限检查 ; if (descriptor.dpl < cpl && descriptor.type != conforming) { ; trigger_gp_fault(); ; } ; 这些检查在每条涉及段寄存器的指令中都会发生!三、关键差异对比实例
实例1:创建大量对象
Windows进程创建10000个事件对象:
// 这可以正常工作HANDLE handles[10000];for(inti=0;i<10000;i++){handles[i]=CreateEvent(NULL,FALSE,FALSE,NULL);// 句柄表自动扩展// 1. 初始:512个条目// 2. 扩展到:1024// 3. 扩展到:2048// ... 直到足够}// 每个句柄只需24字节开销x86尝试创建10000个LDT描述符:
; 这不可能实现! ; 假设每个LDT描述符8字节 ; 10000个描述符需要80000字节 ; 但LDTR限制寄存器只有16位 ; 最大限长 = 0xFFFF = 65535字节 ; 最大描述符数 = 65536 / 8 = 8191 ; 即使我们尝试: mov ax, 0x1000 ; 设置LDT选择子 lldt ax ; 加载LDT ; LDT描述符中的限长字段只有20位(加上粒度) ; 实际最大段大小 = 0xFFFFF * 4096 = 4GB(如果G=1) ; 但这是段的大小,不是LDT表的大小! ; LDT表本身的大小受限于: ; 1. LDTR中的16位限长 ; 2. 描述符中的20位限长(如果LDT被当作段来访问)实例2:内存布局差异
Windows句柄表内存布局:
进程A地址空间: 0x00000000-0x0000FFFF: 预留 ... 0x7FFE0000-0x7FFE0FFF: 进程A句柄表(一级表) [0]: 0xFFFFF800`12345678 (指向File对象) [1]: 0xFFFFF800`87654321 (指向Thread对象) ... 当需要更多句柄时: 系统分配新页面:0x7FFE1000-0x7FFE1FFF 更新句柄表指针指向新的两级结构x86 GDT内存布局:
物理内存布局: 0x00001000-0x000017FF: GDT(固定大小) [0]: 00000000 00000000 (空描述符) [1]: 00CF9A00 0000FFFF (代码段) [2]: 00CF9200 0000FFFF (数据段) ... [127]: 最后一个可用描述符 0x00001800: 无法扩展!后面可能是其他重要数据 GDTR寄存器: Base: 0x00001000 Limit: 0x07FF (2047字节,127个描述符) 要扩展必须: 1. 在别处分配新内存:0x00100000-0x00100FFF 2. 复制旧GDT 3. 暂停所有CPU 4. 更新所有CPU的GDTR 5. 祈祷没有竞争条件实例3:实际系统参数
Windows实际限制:
// Windows 10/11实际参数#defineHANDLE_TABLE_INITIAL_SIZE512// 初始句柄数#defineHANDLE_TABLE_MAX_SIZE0x400000// 每个进程最大句柄数(4194304)// 三级表结构参数#defineHANDLE_ENTRIES_PER_PAGE(PAGE_SIZE/sizeof(HANDLE_TABLE_ENTRY))#defineHANDLE_ENTRIES_PER_MID256// 每个中间表条目数#defineMID_TABLE_SIZE1024// 中间表大小#defineTOP_TABLE_SIZE1024// 顶级表大小// 最大理论句柄数 = 1024 * 1024 * 256 = 268,435,456// 实际限制远小于此,但仍然是巨大的数字x86 GDT实际限制:
; x86硬件强制限制 ; 16位界限寄存器 → 最大表大小64KB ; 每个描述符8字节 → 最大8191个描述符 ; 实际操作系统使用情况: ; Linux: 通常32-64个描述符 ; - 内核代码/数据段 ; - 用户代码/数据段 ; - 每个CPU的TSS ; - APM段等 ; Windows x86: ; 0x0000: 空描述符 ; 0x0008: 内核代码段 ; 0x0010: 内核数据段 ; 0x0018: 用户代码段 ; 0x0020: 用户数据段 ; 0x0028: TSS段 ; 0x0030: LDT段(如果使用) ; ... ; 总共通常不超过32个四、根本原因总结
1.设计年代与目标不同
- GDT/LDT (1985):设计于16/32位时代,硬件资源有限,主要目标是内存分段保护
- Windows句柄表 (1993+):设计于32/64位时代,目标是为大量对象提供灵活引用
2.硬件与软件实现差异
// 硬件机制(GDT)的特点:// 1. 硬件寄存器固定大小(GDTR.limit = 16位)// 2. 每次内存访问都需要硬件检查// 3. 更改需要原子操作和所有CPU同步// 4. 硬件缓存(描述符缓存)使动态更改复杂// 软件机制(句柄表)的特点:// 1. 纯数据结构,大小由内存管理器决定// 2. 软件控制所有检查和转换// 3. 可按需分配,惰性初始化// 4. 每个进程独立,无需全局同步3.性能权衡
; GDT访问性能: mov eax, [ebx] ; 1. 检查DS选择子 ; 2. 从GDTR.base + DS.index*8读取描述符 ; 3. 验证权限和界限 ; 4. 计算线性地址 = DS.base + ebx ; 5. 通过分页转换为物理地址 ; 这些步骤很多被硬件并行化,但仍有限制 ; 相比之下,句柄解引用: Object = ObReferenceObjectByHandle(Handle); // 1. 边界检查(软件) // 2. 表查找(可能多级) // 3. 权限检查(软件) // 4. 引用计数增加 // 虽然软件实现更灵活,但比硬件GDT查找慢4.现代发展
// x86-64基本废弃了分段// 段基址强制为0(除了FS/GS)// 内存保护完全通过分页实现// Windows在x64上进一步优化句柄表typedefstruct_HANDLE_TABLE_FREE_LIST{ULONG_PTR FreeLists[1];}HANDLE_TABLE_FREE_LIST;// 使用低碎片堆(LFH)管理句柄分配// 支持锁消除等优化结论
Windows句柄表能动态增长的核心原因是:
- 纯软件实现:不受硬件寄存器限制
- 多级表结构:类似于虚拟内存分页,支持稀疏分配
- 按需分配:初始小,需要时扩展
- 进程独立:每个进程可以有不同的句柄表大小
x86 GDT/LDT固定大小的核心原因是:
- 硬件强制:由16位界限寄存器限制
- 全局共享:更改需要所有CPU同步
- 硬件缓存:描述符缓存在段寄存器中
- 原子性要求:GDT更新必须原子完成
这种差异反映了计算机系统设计从硬件为中心到软件为中心的演进趋势。现代操作系统更倾向于使用灵活的软件机制,而将硬件仅用于关键性能路径的加速。