简介:面向Windows中高级开发者的商业编程源码包,围绕“获取系统中打开的文件清单”这一系统级功能,提供完整编码实现与可运行演示。核心逻辑利用CreateToolhelp32Snapshot、NtQueryObject等系统API枚举进程、遍历句柄并映射到具体文件路径,深入剖析底层原理,并覆盖权限不足、句柄访问失败等常见问题的处理思路,适用于系统监控、故障排查、文件占用分析及安全审计等场景。压缩包共2个文件,cpp源文件为核心实现,exe为已编译演示程序,便于对照运行效果,整个包仅14KB,轻量紧凑。从源码到可执行文件一目了然,既可直接编译学习,也可提取句柄枚举逻辑,用于二次开发和功能扩展。目前已有123人学习/下载,适合希望掌握Windows进程与句柄管理技术、通过商业级代码提升系统编程能力的开发者参考研究。
1. 打开的文件清单,比任务管理器能看到的要多
做 Windows 端工具的人迟早要面对一个问题:某台机器上有哪些文件正处于打开状态,被哪个进程占着,占用的句柄是普通读、写入还是排他锁定。任务管理器只能给你进程列表,资源监视器能给你部分句柄,但想做成自动化脚本、商用管理工具或者卸载辅助程序,就必须拿到原始的“文件句柄 → 进程 → 路径”完整清单。这个需求在系统运维、文件服务器审计、软件安装包制作和中大型商业 C/S 项目里都很常见,核心难点不在于“遍历”这个动作,而在于 Windows 的对象管理器里,文件路径不是给人看的字符串,而是一个对象节点的属性。本文用一套能直接编译运行的 C/C++ 方案解决它,同时给出不写代码也能出清单的命令行路径,把权限、位数、竞态这几个真正的坑讲透。
2. 枚举系统句柄表:拿清单的原理与可行性
2.1 文件路径藏在对象管理器里,文件系统看不到
先明确一个事实:文件被打开成“句柄”后,这个句柄归属于某进程的句柄表,最终指向内核对象管理器里的一个 FILE 对象。这个对象的路径是形如\Device\HarddiskVolume2\Users\admin\test.log的设备路径,是对象管理器命名空间里的节点,不是文件系统 API 直接能枚举的东西。因此“获取打开的文件清单”天然分三步:
- 枚举系统所有进程的所有句柄;
- 从这些句柄里筛出类型为 File 的;
- 通过句柄反查对象路径,再做设备路径到盘符路径的换算。
第一直觉是用GetFileInformationByHandleEx这类高层的 Win32 API,但它们需要你先有一个句柄。双刃剑就在这里:你不知道句柄在哪个进程里,就不可能凭空打开文件。所以整个实现只能走原生接口,也就是 ntdll 导出的三个函数:
NtQuerySystemInformation:枚举全局句柄表;NtDuplicateObject:把别的进程的句柄复制到自己进程里;NtQueryObject:查询复制来的句柄对应的对象名称。
这三个函数没有列入微软公开文档,但二十年来行为基本稳定,System Informer(原 Process Explorer 的开源继任者)的核心钩子也长这样。
2.2 用 SYSTEM_HANDLE_TABLE_ENTRY_INFO 拿原始清单
NtQuerySystemInformation的第四个参数是信息类别,SystemHandleInformation(数值 16)返回的结构是一串连续的句柄记录。关键点是很多网上旧教程直接给出 32 位时代的结构体定义,在 64 位系统上字段对齐会错位,枚举结果要么读取越界,要么返回一堆看似合理但完全错误的 PID。所以我建议直接用扩展版SystemExtendedHandleInformation(数值 64),它对应下面这个结构:
typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX { PVOID Object; // 内核对象地址(64位下占8字节) ULONG_PTR UniqueProcessId; // 持有句柄的进程 PID ULONG_PTR HandleValue; // 句柄值,用于 NtDuplicateObject ULONG GrantedAccess; // 句柄创建时申请的访问权 USHORT CreatorBackTraceIndex; USHORT ObjectTypeIndex; // 对象类型索引,File 类型需校准 ULONG HandleAttributes; // 是否继承、是否受保护等 ULONG Reserved; } SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX;结构体头两个字段是记录总数和保留字段:
typedef struct _SYSTEM_HANDLE_INFORMATION_EX { ULONG_PTR NumberOfHandles; ULONG_PTR Reserved; SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX Handles[1]; } SYSTEM_HANDLE_INFORMATION_EX;| 信息类别常量 | 数值 | 用途 |
|---|---|---|
| SystemHandleInformation | 16 | 经典版,32 位布局,64 位系统上谨慎使用 |
| SystemExtendedHandleInformation | 64 | 扩展版,带 ObjectTypeIndex 和 CreatorBackTraceIndex,64 位下推荐 |
| ObjectNameInformation | 1 | 配合 NtQueryObject 查对象名 |
| ObjectTypeInformation | 2 | 配合 NtQueryObject 查对象类型名 |
2.3 复制句柄是读对象名前的标准动作
拿到条目后,接下来要做的不是直接去查询,那个 HandleValue 在你自己的进程里没有任何意义,它是目标进程句柄表里的索引值。标准动作是调用NtDuplicateObject把它复制到当前进程。这个动作有两个实际作用:一是让句柄在你的进程空间里合法可用;二是复制时如果能传DUPLICATE_SAME_ACCESS,复制出的句柄会保留原句柄的访问权限,即使目标进程不是你的子进程,只要拥有PROCESS_QUERY_LIMITED_INFORMATION权限就能继续读名字。复制后再调NtQueryObject(hDup, ObjectNameInformation, ...),拿到的UNICODE_STRING就是这个对象在对象管理器里的全名。
提示:查对象名的行为有时会被对象管理器拦下,返回
STATUS_ACCESS_DENIED。常见于受保护进程和系统关键进程,遇到就直接跳过这条句柄,不要反复重试,重试只会拖慢整轮枚举。
2.4 把内核设备路径换算成盘符路径
NtQueryObject返回的名字是\Device\HarddiskVolume2\...这种形式,这对人来说不可读。换算的常见做法是遍历A:到Z:的盘符,对每个盘符调用QueryDosDeviceW,它会返回对应的设备名如\Device\HarddiskVolume2。把枚举结果里的路径前缀替换成查到的盘符即可。要特别注意的是有些路径不以盘符开头,典型的有\Device\Mup\...(网络共享映射)、\Device\NamedPipe\...(命名管道)、\FileSystem\NamedPipe\...。它们也算“文件对象”,但和用户理解的文件不是一个东西,商用工具的过滤逻辑里要把它们单独归类而不是直接丢弃。
3. C/C++ 写一个最小可运行的句柄枚举程序
3.1 原生 API 声明与关键结构体定义
这一节给出一份可以直接编译的最小完整示例,编译环境是 Windows 10/11 + x64 + MSVC,无需第三方库。因为三个原生 API 没有头文件,需要手动声明。
#include <windows.h> #include <winternl.h> #include <stdio.h> #include <vector> #include <string> #include <map> #pragma comment(lib, "ntdll.lib") #pragma comment(lib, "advapi32.lib") // 扩展句柄信息(64位推荐) typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX { PVOID Object; ULONG_PTR UniqueProcessId; ULONG_PTR HandleValue; ULONG GrantedAccess; USHORT CreatorBackTraceIndex; USHORT ObjectTypeIndex; ULONG HandleAttributes; ULONG Reserved; } SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX; typedef struct _SYSTEM_HANDLE_INFORMATION_EX { ULONG_PTR NumberOfHandles; ULONG_PTR Reserved; SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX Handles[1]; } SYSTEM_HANDLE_INFORMATION_EX; extern "C" NTSTATUS NTAPI NtQuerySystemInformation( ULONG SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength); extern "C" NTSTATUS NTAPI NtDuplicateObject( HANDLE SourceProcessHandle, HANDLE SourceHandle, HANDLE TargetProcessHandle, PHANDLE TargetHandle, ACCESS_MASK DesiredAccess, ULONG HandleAttributes, ULONG Options); extern "C" NTSTATUS NTAPI NtQueryObject( HANDLE Handle, ULONG ObjectInformationClass, PVOID ObjectInformation, ULONG ObjectInformationLength, PULONG ReturnLength);winternl.h里实际上已经带了一套旧版结构,如果直接用它会导致字段偏移错误。这里的关键是不依赖 Windows SDK 里的任何结构,全部自己定义,并且走 64 位扩展接口,绕开对齐问题。
3.2 主流程:查询句柄表 → 复制句柄 → 读对象名
// 开启调试权限,避免访问关键进程句柄时直接失败 static bool EnableDebugPrivilege() { HANDLE hToken = nullptr; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &hToken)) return false; TOKEN_PRIVILEGES tp; tp.PrivilegeCount = 1; LookupPrivilegeValueW(nullptr, SE_DEBUG_NAME, &tp.Privileges[0].Luid); tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; BOOL ok = AdjustTokenPrivileges(hToken, FALSE, &tp, 0, nullptr, nullptr); CloseHandle(hToken); return ok; } // 读取一个复制后句柄的对象名,失败返回 false static bool QueryObjectName(HANDLE h, std::wstring& name) { ULONG len = 0; NtQueryObject(h, 1, nullptr, 0, &len); // 第一次拿长度 if (!len) return false; std::vector<BYTE> buf(len + 64); NTSTATUS st = NtQueryObject(h, 1, buf.data(), (ULONG)buf.size(), &len); if (st != 0) return false; auto* unicode = reinterpret_cast<UNICODE_STRING*>(buf.data()); if (unicode->Length > 0) { name.assign(unicode->Buffer, unicode->Length / sizeof(wchar_t)); return !name.empty(); } return false; } int wmain() { EnableDebugPrivilege(); // 句柄表在查询期间一直在变,需要按 2 倍扩容重试 ULONG bufLen = 64 * 1024; std::vector<BYTE> buffer(bufLen); NTSTATUS st; while (true) { st = NtQuerySystemInformation(64, buffer.data(), bufLen, &bufLen); if (st == 0xC0000004L) { // STATUS_INFO_LENGTH_MISMATCH bufLen *= 2; buffer.resize(bufLen); continue; } break; } if (st != 0) { printf("查询失败 0x%08lX\n", st); return 1; } auto* pInfo = reinterpret_cast<SYSTEM_HANDLE_INFORMATION_EX*>(buffer.data()); HANDLE curProc = GetCurrentProcess(); for (ULONG_PTR i = 0; i < pInfo->NumberOfHandles; i++) { auto& entry = pInfo->Handles[i]; // 打开目标进程,只要受限查询权限即可 HANDLE hProc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD)entry.UniqueProcessId); if (!hProc) continue; HANDLE hDup = nullptr; NTSTATUS dupSt = NtDuplicateObject(hProc, reinterpret_cast<HANDLE>(entry.HandleValue), curProc, &hDup, 0, 0, 0x00000002L /* DUPLICATE_SAME_ACCESS */); CloseHandle(hProc); if (dupSt != 0 || !hDup) continue; std::wstring name; bool hasName = QueryObjectName(hDup, name); CloseHandle(hDup); // 这里只输出带设备路径且不是管道/共享的项 if (hasName && name.rfind(L"\\Device\\", 0) == 0) { wprintf(L"%lu\t0x%llX\t%lu\t%ls\n", (DWORD)entry.UniqueProcessId, (unsigned long long)entry.HandleValue, entry.GrantedAccess, name.c_str()); } } return 0; }逻辑上每一段都是可独立裁剪的。EnableDebugPrivilege不是必须调用成功才能继续,但推荐调用,因为lsass.exe、csrss.exe这类系统进程没有调试权限时OpenProcess会直接返回拒绝。重试循环用0xC0000004L判断缓冲区不足,这是STATUS_INFO_LENGTH_MISMATCH,注意它只在缓冲区完全不够时出现,如果返回成功但ReturnLength比传入长度大,表示数据可能被截断,工程代码里要再对比一次长度。
3.3 你以为的坑:权限、位数、缓冲区
第一个坑是权限模型。PROCESS_QUERY_LIMITED_INFORMATION是 Vista 以后引入的受限版本,普通用户也可以查大多数进程;但带SeDebugPrivilege才能保证整轮枚举不中断。商用程序里建议把权限获取失败当作“降级运行”而不是直接退出,因为对普通应用场景来说,拿不到系统进程的文件清单不影响主要功能。
第二个坑是 64 位系统上结构体布局。Windows SDK 自带的SYSTEM_HANDLE_TABLE_ENTRY_INFO在 32 位和 64 位下的Object字段对齐方式不同,如果直接reinterpret_cast到新版结构,字段错位会导致UniqueProcessId变成乱码。所以上文才全部自定结构体,并且只在_WIN64下编译这段代码。
第三个坑是缓冲区增长策略。有些资料说第一次调用传入nullptr就能拿到所需长度,但SystemExtendedHandleInformation类别的ReturnLength参数在缓冲区不足时并不保证返回完整所需长度,最稳定的就是上面这种从 64 KB 开始按 2 倍扩的循环。系统句柄数轻松上万,每次枚举分配几十 MB 属于正常情况,不要为此惊讶。
4. 不想编译?用 Restart Manager 和 openfiles 快速出清单
4.1 用 Restart Manager 让系统替你筛被占用文件
如果目标只是定位“哪些进程占用了指定文件”,不需要全局清单,那不需要碰 ntdll,直接用 Restart Manager。这是 Vista 之后内置的机制,专门为软件安装时处理“文件被占用”设计。核心调用是RmStartSession、RmRegisterResources、RmGetList,前两个负责注册你要检查的文件,第三个返回被这些文件牵制的进程数组。它不给你全部打开的文件,但能非常可靠地回答“这个文件被谁占用”。
#include <restartmanager.h> #pragma comment(lib, "rstrtmgr.lib") DWORD findProcessesLocking(const wchar_t* path, DWORD pids[64]) { DWORD session = 0; wchar_t key[64] = {}; if (RmStartSession(&session, 0, key) != ERROR_SUCCESS) return 0; LPCWSTR resources[] = { path }; RmRegisterResources(session, 1, resources, 0, nullptr, 0, nullptr); UINT count = 64; DWORD reason = 0; RM_PROCESS_INFO info[64]; DWORD result = RmGetList(session, &count, &count, info, &reason); for (UINT i = 0; i < count; i++) pids[i] = info[i].Process.dwProcessId; RmEndSession(session); return (result == ERROR_SUCCESS) ? count : 0; }RmGetList的第二个参数是数组容量,第三个参数返回实际需要的大小。如果返回的count大于传入的 64,说明数组不够,要按返回值重新分配。reason里可以看到进程是用什么方式占用文件的,比如普通打开、排他打开、映射文件,这在判断“能不能删”时比 pids 信息更有价值。
4.2 openfiles 本地模式:一条命令开挂
不想写 C++ 的话,系统自带openfiles命令,它本身是给“服务器文件共享审计”设计的,但用/local参数开启后,就能列出本机所有打开的文件及其进程。注意必须先开启维护模式并重启一次系统:
openfiles /local on :: 重启后执行 openfiles /query /fo csv /nh/fo csv表示输出 CSV 格式,/nh去掉表头,方便后续直接交给脚本处理。很多人在这一步遇到“错误 5 拒绝访问”,因为openfiles /local on需要管理员权限,而且这个开关写进注册表后要重启才生效,没法热切换。另外它展示的“打开文件”包含映射文件和控制台句柄,输出里的SessionName是 Console 或 Services,和普通用户会话不是一个概念,脚本里要注意区分。
4.3 用 PowerShell 包装成可复用脚本
把openfiles的输出套一层 PowerShell,就能得到一份可以交给监控系统消费的结构化结果:
$rows = openfiles /query /fo csv /nh | ConvertFrom-Csv -Header @( 'Session','User','PID','Type','Path' ) | Where-Object { $_.Path -match '^[A-Z]:\\' } $rows | Group-Object Path | ForEach-Object { [PSCustomObject]@{ Path = $_.Name PIDs = ($_.Group.PID -join ',') Users = ($_.Group.User -join ',') } } | Export-Csv -Path opened-files.csv -NoTypeInformation -Encoding UTF8ConvertFrom-Csv配合-Header是因为openfiles的 CSV 输出在不同 Windows 语言版本里表头文字不一样,硬编码中文表头反而脆弱,直接跳过头行再自己命名最稳。Type字段区分的是句柄类型而不是进程类型,通常能看到 File 和 其他少数类别。这份脚本适合内网批量收集,跑一次耗时看机器负载,几千个文件句柄大概几秒到几十秒。
5. 把清单当判据用:权限、竞态和特征过滤的边界
5.1 三个必调的判断条件
拿到清单后真正的工作才开始,以下条件决定这条记录算不算“占用文件”:
GrantedAccess是否包含DELETE(0x00010000):只有带删除权限的句柄才可能直接阻止删文件;HandleAttributes是否含OBJ_PROTECT_CLOSE(由SetHandleInformation设置 PROTECT_FROM_CLOSE 时生效):受保护句柄不能用普通DuplicateHandle之外的手段关闭;- 路径是否以
\Device\NamedPipe\开头:管道对象也走文件对象模型,但从来不占用磁盘文件,应在统计时剔除。
GrantedAccess的典型值是0x0012019F(FILE_GENERIC_READ/WRITE 组合)和0x00010020,后者是典型的“只同步读取”。字符串过滤放在路径换算之前做性能更好,因为QueryDosDevice是最耗时的环节,能提前丢掉的记录就不该做盘符换算。
5.2 处理竞态:进程退出后句柄立刻失效
枚举期间进程随时可能退出,这会导致两种结果:NtDuplicateObject返回STATUS_INVALID_HANDLE(0xC0000008),或者OpenProcess成功但复制句柄失败。这都是正常现象,不能当作错误中断整轮枚举。商用代码通常在NtDuplicateObject失败后直接continue,并且不对单条失败做日志输出,否则一秒钟能刷出几万行。重试窗口设置在 3 到 5 秒比较合理,超过这个时间还在失败说明文件系统或进程状态异常,适合上报一次警告。
5.3 一个能直接抄的类型索引校准技巧
SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX.ObjectTypeIndex能用整数精确定位对象类型,这个数字在系统启动时固定,但不同机器上不一定相同,所以不能写死。推荐的校准方式是在程序启动时用CreateFileW打开一个临时文件,然后枚举一次全表,找到路径和自己刚创建文件完全一致的记录,记下它的ObjectTypeIndex作为 File 类型值。全部查询只做一次校准,之后过滤句柄只需比较整数,不再需要为每条句柄调用NtQueryObject拿类型名。把这段逻辑放在程序初始化处,用一个static变量缓存,整个枚举过程能快一个数量级,系统大版本更新时该值偶发变化,遇到校准失败就降级回字符串前缀匹配,稳定性和性能两头兼顾。
本文还有配套的精品资源,点击获取