ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Visual C++实现仿Unlocker:枚举句柄查找文件占用进程

用Visual C++实现仿Unlocker:枚举句柄查找文件占用进程 简介一套仿Unlocker的Visual C项目源码目标是在Windows下查看某文件正被哪个进程独占打开从而解决文件无法删除的问题。适合具备一定C基础、想深入系统编程的开发者覆盖进程枚举、句柄检测、服务识别和注册表操作等核心技能。压缩包共30个文件主体为10个.h头文件与8个.cpp源文件另含MFC界面资源、窗口图标、bat构建脚本以及ReadMe说明文档整体体积仅63KB结构紧凑。目前已有522人学习。代码模块划分清晰SimpleProcessAPI封装进程枚举与系统交互WindowsRegistry负责注册表查询GetServiceName用于识别服务型占用CListCtrlEx实现进程列表展示WhoSLockingCommandLineInfo支持命令行运行结合WhoSLockingDlg主对话框可完整演示类Unlocker工具从检测到呈现的整个流程。读者可直接编译学习掌握通过系统句柄枚举定位占用进程的方法以及MFC列表控件、命令行解析和Windows注册表操作等实用技巧是深入理解Windows文件锁机制的难得实例。1. 文件被占用时Visual C 能做什么文件被占用导致无法删除是 Windows 运维和开发中最常见的谜题之一。很多人第一反应是重启或者用 Unlocker但 Unlocker 已多年不更新在 Win10/Win11 上兼容性已不佳。这里分享一个用 Visual C 从零实现的仿 Unlocker 工具源码项目核心解决一个问题当某个文件被进程独占打开时如何快速查出是哪个进程、哪个句柄在持有它并尝试释放。项目基于 MFC 对话框框架核心使用 Windows 内核 API 枚举系统句柄再从句柄反查文件路径和进程信息。适合熟悉 Win32 编程、想深入理解文件锁机制的开发者阅读也适合需要维护内部工具链的系统管理员。2. 文件锁定原理与 API 选型从内核句柄到 Restart Manager2.1 文件独占锁的本质共享模式与句柄在 Windows 中文件是否允许删除不是由文件属性单独决定而是由打开文件时指定的共享模式dwShareMode决定的。当一个进程调用 CreateFile 或类似 API 打开文件时可以指定 FILE_SHARE_READ、FILE_SHARE_WRITE、FILE_SHARE_DELETE 的组合。如果某进程没有指定 FILE_SHARE_DELETE那么其他进程对该文件的删除请求会失败返回 ERROR_SHARING_VIOLATION共享冲突。这就是“文件被独占打开”的底层逻辑。从内核视角看文件对象在 Object Manager 中对应一个 File 对象所有打开的句柄都指向该对象句柄的属性里携带了共享标志。因此要找出谁占用了文件本质上是枚举系统中的所有句柄定位到所有指向该文件对象的句柄再反查句柄所属进程。2.2 三种可用检测 API 的横向对比要在用户态解决“谁占用文件”的问题成熟的路线有三种Restart Manager API、NtQuerySystemInformation 句柄枚举、WMI 查询很少见。Restart Manager 是 Windows 提供的专门接口它内部会探测占用指定文件的进程但它的实现包含很多启发式规则对非交互式进程和某些内核句柄识别不全而且初始化会话和回调管理比较繁琐。NtQuerySystemInformation 通过系统调用枚举内核句柄表拿到进程句柄编号再配合 NtQueryObject 查询句柄对象信息可以精准判断句柄对应的文件路径全量可控这也是 Process Explorer 的查找句柄功能采用的思路。在纯用户态下它不依赖驱动但需要进程拥有 SeDebugPrivilege 权限才能遍历其他进程的句柄。三者特性对比如下API 路线原理权限要求兼容性精度Restart Manager系统内部探测普通权限Win Vista可能漏检NtQuerySystemInformation枚举内核句柄表需要 SeDebugPrivilegeWin 2000高但需自行过滤WMI Win32_Process只能查进程不能查句柄普通权限Win XP无法直接定位句柄从表中可以看出NtQuerySystemInformation 在精度和兼容性上最符合一个查锁工具的需求模拟 Unlocker 的行为也最直接。采用它意味着要自己处理句柄类型过滤、路径匹配、权限提升和 64 位偏移地址问题这些恰恰是这类工具的技术含金量所在。2.3 项目的 API 选型与核心声明项目的源码里SimpleProcessAPI.cpp 负责封装进程快照逻辑而 WhoSLocking.cpp 里则直接加载 ntdll.dll 导出函数。这是 ntdll 未官方文档化 API 的常规做法。核心函数声明如下typedef LONG NTSTATUS; #define SystemHandleInformation 16 typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO { USHORT UniqueProcessId; USHORT CreatorBackTraceIndex; UCHAR ObjectTypeIndex; UCHAR HandleAttributes; USHORT HandleValue; PVOID Object; ULONG GrantedAccess; } SYSTEM_HANDLE_TABLE_ENTRY_INFO; typedef struct _SYSTEM_HANDLE_INFORMATION { ULONG NumberOfHandles; SYSTEM_HANDLE_TABLE_ENTRY_INFO Handles[1]; } SYSTEM_HANDLE_INFORMATION; typedef NTSTATUS (NTAPI *pNtQuerySystemInformation)( ULONG SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength); typedef NTSTATUS (NTAPI *pNtQueryObject)( HANDLE ObjectHandle, ULONG ObjectInformationClass, PVOID ObjectInformation, ULONG ObjectInformationLength, PULONG ReturnLength); #define ObjectNameInformation 1这段代码定义了枚举句柄所需的关键结构体和函数指针。注意这里的 SYSTEM_HANDLE_TABLE_ENTRY_INFO 结构体在 32 位和 64 位系统上布局不同实际项目中需要根据 _WIN64 宏分别定义。ObjectTypeIndex 用于判断句柄类型为 File而 HandleValue 才是真正需要传给 NtQueryObject 的句柄值。通过运行时从 ntdll.dll 加载两者可以避免在头文件里显式链接未文档化的导入函数这也是 Unlocker 类工具最常见的实现方式。3. 核心实现枚举进程与句柄的 VC 代码拆解3.1 进程快照与权限提升SimpleProcessAPI 的作用在枚举句柄之前必须拿到系统中所有进程的 PID 和名称方便在界面上显示“哪个进程占用了文件”。SimpleProcessAPI.cpp 在这套源码里承担的就是这一层封装。通常做法是用 CreateToolhelp32Snapshot 遍历进程同时调用 OpenProcess 和 GetModuleFileNameEx 取进程可执行文件路径。下面的代码摘取了关键部分#include tlhelp32.h BOOL GetProcessList(std::mapDWORD, CString processMap) { HANDLE hSnapshot CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (hSnapshot INVALID_HANDLE_VALUE) return FALSE; PROCESSENTRY32 pe { sizeof(pe) }; if (Process32First(hSnapshot, pe)) { do { CString exePath pe.szExeFile; // 如果需要完整路径需配合 OpenProcess QueryFullProcessImageName processMap[pe.th32ProcessID] exePath; } while (Process32Next(hSnapshot, pe)); } CloseHandle(hSnapshot); return TRUE; }这里用 PROCESSENTRY32 获取进程名但若要获得完整路径需要额外调用 OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) 再配合 QueryFullProcessImageName。之所以要进程名是因为在界面上用户看到的不只是 PID还要有“explorer.exe”这类直观的名称方便判断是哪个应用。另外一点枚举句柄前必须提升当前进程的 SeDebugPrivilege 权限否则 NtQuerySystemInformation 返回的句柄列表中其他进程的句柄会因访问拒绝而被跳过。提权代码在 WhoSLocking.cpp 中通常在 OnInitDialog 里调用BOOL EnableDebugPrivilege() { HANDLE hToken; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, hToken)) return FALSE; LUID luid; LookupPrivilegeValue(NULL, SE_DEBUG_NAME, luid); TOKEN_PRIVILEGES tp; tp.PrivilegeCount 1; tp.Privileges[0].Luid luid; tp.Privileges[0].Attributes SE_PRIVILEGE_ENABLED; AdjustTokenPrivileges(hToken, FALSE, tp, sizeof(tp), NULL, NULL); CloseHandle(hToken); return GetLastError() ERROR_SUCCESS; }注意即使以管理员身份运行如果系统 UAC 隔离也需要在这之前通过 ShellExecute 的 runas 重新启动进程。这也是为什么源码里的工程带有清单文件Resource 里的 XP_MANIFEST的原因。3.2 句柄枚举从系统句柄表批量获取句柄信息拿到权限后就可以调用 NtQuerySystemInformation 枚举系统所有句柄。首先要动态获取 ntdll.dll 中的函数地址避免静态链接导致老系统兼容性问题。接着用动态增长缓冲区的方式调用因为第一次调用通常返回 STATUS_INFO_LENGTH_MISMATCH。下面是典型调用代码#include winternl.h // 已经包含 NTAPI 定义 BOOL EnumerateSystemHandles(std::vectorSYSTEM_HANDLE_TABLE_ENTRY_INFO handles) { HMODULE hNtdll GetModuleHandle(_T(ntdll.dll)); auto NtQSI (pNtQuerySystemInformation)GetProcAddress(hNtdll, NtQuerySystemInformation); if (!NtQSI) return FALSE; ULONG bufferSize 16 * 1024 * 1024; // 初始分配 16MB // 动态分配失败则继续扩大 PVOID buffer VirtualAlloc(NULL, bufferSize, MEM_COMMIT, PAGE_READWRITE); if (!buffer) return FALSE; NTSTATUS status NtQSI(SystemHandleInformation, buffer, bufferSize, NULL); if (status ! 0) { VirtualFree(buffer, 0, MEM_RELEASE); return FALSE; } auto pInfo (SYSTEM_HANDLE_INFORMATION*)buffer; handles.resize(pInfo-NumberOfHandles); for (ULONG i 0; i pInfo-NumberOfHandles; i) { handles[i] pInfo-Handles[i]; } VirtualFree(buffer, 0, MEM_RELEASE); return TRUE; }这里将缓冲设成 16MB对于现代 Windows 10/11 上几千个句柄完全足够。每次重新扫描时直接重新调用不需要常驻内存。注意 SYSTEM_HANDLE_TABLE_ENTRY_INFO 在 64 位系统里会有对齐填充需要按照结构的实际大小计算而不是用 sizeof(那个结构体) 当作数组元素大小这一点后面专门说。3.3 句柄反查文件路径NtQueryObject 与设备路径转换枚举到句柄之后必须判断该句柄是否指向 File 对象并提取对应的文件路径。先通过 NtQueryObject 查询 ObjectNameInformation。注意普通用户态可能无法直接查询其他进程的句柄对象信息需要 DuplicateHandle 把远程进程的句柄复制到当前进程而且打开源进程句柄时要带 PROCESS_DUP_HANDLE 权限。然后调用BOOL QueryHandleName(DWORD pid, USHORT handleValue, CString ntPath) { HANDLE hProcess OpenProcess(PROCESS_DUP_HANDLE, FALSE, pid); if (!hProcess) return FALSE; HANDLE hDup NULL; if (!DuplicateHandle(hProcess, (HANDLE)handleValue, GetCurrentProcess(), hDup, 0, FALSE, DUPLICATE_SAME_ACCESS)) { CloseHandle(hProcess); return FALSE; } // 先查询长度 ULONG size 0; pNtQueryObject NtQO ...; // 同前面 GetProcAddress NtQO(hDup, ObjectNameInformation, NULL, 0, size); PVOID buffer malloc(size); NTSTATUS status NtQO(hDup, ObjectNameInformation, buffer, size, NULL); if (status 0) { auto name (PUNICODE_STRING)buffer; if (name-Buffer name-Length 0) { ntPath name-Buffer; } } free(buffer); CloseHandle(hDup); CloseHandle(hProcess); return status 0; }NtQueryObject 返回的路径是 NT 对象命名空间路径例如 \Device\HarddiskVolume2\Users\admin\test.txt。为了跟用户输入的 C:\Users\admin\test.txt 匹配需要把 \Device\HarddiskVolumeN 映射到盘符。一般做法是遍历系统盘符调用 QueryDosDevice 获取设备路径然后做前缀替换。此外这里还有另一个关键点如果句柄是目录句柄返回的路径可能指向文件夹而非文件本身所以界面里需要区分“文件被占用”和“目录被占用”。在 WhoSLockingDlg.cpp 中正是通过磁盘映射函数对所有匹配的句柄进行过滤只显示与目标文件路径完全相同的条目。3.4 服务进程与注册表GetServiceName 和 WindowsRegistry 的角色有些占用文件的进程以 Windows 服务方式运行进程名是 svchost.exe多个服务共享一个进程直接显示 svchost.exe 对用户没意义。源码里的 GetServiceName.cpp 负责通过查询服务控制管理器SCM把 PID 映射到对应的服务名比如 WSearch、Spooler。实现思路是首先 OpenSCManager 获取服务控制管理器句柄然后 EnumServicesStatusEx 遍历服务检查每个服务的 dwProcessId 是否等于目标 PID这样就能在列表多显示一列服务名。WindowsRegistry.cpp 则用于两处一是关联右键菜单在资源管理器右键菜单增加“谁的锁”二是保存上一次输入的文件路径或用户配置。有了服务名问题定位就从“svchost.exe 占了”精细到“Windows Search 服务占了”这对运维排障非常关键。4. 界面与命令行让工具可用且好用4.1 MFC 对话框与 CListCtrlEx 自绘列表工具的主要界面是 WhoSLockingDlg它是基于 CDialogEx 的一个标准 MFC 对话框。中间一个大的 CListCtrlEx 控件CListCtrl 的派生类用于展示进程列表。CListCtrlEx 的实际意义在于把默认的列表视图增强了按列排序功能点击列头时自动排序并显示箭头。它通过拦截 HDN_ITEMCLICK 通知然后调用 SortItemsEx 完成排序。这里的关键是在自定义控件里保存一份“进程信息结构体”指针让排序比较函数可以按 PID、进程名、路径长度列排序。下面是从 CListCtrlEx.cpp 中典型的排序代码int CALLBACK CompareFunc(LPARAM lParam1, LPARAM lParam2, LPARAM lParamSort) { CListCtrlEx* pCtrl (CListCtrlEx*)lParamSort; int col pCtrl-m_sortColumn; // 从 lParam 中拿到各自行的数据指针 ProcessInfo* p1 (ProcessInfo*)lParam1; ProcessInfo* p2 (ProcessInfo*)lParam2; if (col 1) return p1-pid - p2-pid; if (col 2) return p1-processName.Compare(p2-processName); return 0; }注意MFC 的 CListCtrl::SortItems 要求比较函数是静态函数否则无法使用非静态成员。这里的做法是在 OnCreate 时把 this 传给 lParamSort在静态函数内强转回 CListCtrlEx*再访问成员变量。这个模式在自绘排序列表里很常见也让后续在界面上展示文件锁信息时可以按列快速找到占用进程。为了让列表支持这些交互CListCtrlEx 在初始化时设置了扩展样式扩展样式作用LVS_EX_FULLROWSELECT点击任意列都选中整行LVS_EX_GRIDLINES显示网格线便于阅读LVS_EX_DOUBLEBUFFER减少闪烁尤其在 Windows XP 之后的版本通过扩展样式列表的操作手感与传统文件管理器保持一致用户不需要额外学习。4.2 命令行参数WhoSLockingCommandLineInfo 的用途默认情况下Windows 的资源管理器右键菜单调用程序时常常需要把文件路径作为参数传入。这时标准的 CWinApp::ParseCommandLine 会按空格拆分参数导致带有空格的路径中断。因此源码专门实现了 WhoSLockingCommandLineInfo重写 ParseParam 来保留普通空格。核心逻辑如下class WhoSLockingCommandLineInfo : public CCommandLineInfo { public: CString m_filePath; BOOL m_bCheckOnly FALSE; virtual void ParseParam(const TCHAR* pszParam, BOOL bFlag, BOOL bLast) { if (!bFlag !m_filePath.IsEmpty()) { // 非开关参数可能是文件路径 m_filePath pszParam; } else if (bFlag _tcsicmp(pszParam, _T(check)) 0) { m_bCheckOnly TRUE; } CCommandLineInfo::ParseParam(pszParam, bFlag, bLast); } };这里要解释一下MFC 的框架在解析命令行时对含空格的带引号参数会自动去引号但内部把它当作一个参数传给 ParseParam。因此在这里直接 psprintf 赋值即可。通过重写这个类可以在不弹出主对话框的情况下直接用任务栏托盘图标或命令行工具完成一次“扫描并退出”的过程。这也是仿 Unlocker 工具的经典需求右键点击文件选择“谁的锁”直接弹出结果窗口而不是先启动一个空主界面再输入路径。4.3 解锁操作送给进程的“句柄关闭”与结束进程当找到占用进程后工具提供两个动作结束进程或尝试关闭句柄。关闭句柄的实现其实比较复杂需要调用 NtDuplicateObject 把远程句柄复制到当前进程并且用 OBJECT_HANDLE_FLAG_INHERIT 等标志位控制然后调用 NtClose 关闭这个复制的句柄。这相当于强制从系统对象表中移除指向该文件对象的引用。注意这本质上是一种危险操作可能导致进程后续运行异常所以界面里一定要有明确的警告。更安全的做法是提示用户手动结束进程或者调用 Restart Manager 请求进程优雅重启。在 WhoSLockingDlg.cpp 中这两个功能的按钮是分开的默认推荐“结束进程”因为普通用户能理解其后果而“关闭句柄”放在高级模式中。5. 编译验证与常见坑VC 运行库与权限问题5.1 编译环境与运行库依赖项目带有 .dsp 和 .dsw 文件这是 Visual C 6.0 时代的老工程。用高版本 Visual Studio 打开时需要做一次工程转换转换后大部分代码可以原样编译。但要注意最终部署到目标机器时需要安装对应版本的 Visual C Redistributable例如 VS2015-2022 x86/x64否则会碰到缺少 mfc140.dll 或 vcruntime140.dll 的启动错误。这也是很多下载站在提供此类工具时把运行库合集一起放进去的原因。命令行扫描模式依赖入口参数如果直接双击运行应当弹出文件选择对话框。5.2 验证场景以文本文件被 Word 打开为例先用记事本写入一个文件用 Word 打开它然后用本工具扫描该文件路径。操作步骤是以管理员身份运行 WhoSLocking.exe点击“选择文件”按钮选中该文档再点击“查看谁占用”按钮等待 1-2 秒后列表就会显示 WINWORD.EXE 及其 PID点击“结束进程”后文件即可删除。也可以用命令行直接跑WhoSLocking.exe /check C:\path\file.txt结果会输出到系统临时目录的日志文件。这套流程覆盖了最常见的共享冲突场景也是验证枚举逻辑是否生效的最快路径。5.3 权限不足与 64 位偏移问题在 Windows 10 上如果没有以管理员权限运行只能看到自己进程的句柄因为 SeDebugPrivilege 未启用。所以工具内部需要在启动时检查当前令牌中是否启用了该特权如果没有就弹窗提示重新以管理员身份运行。64 位系统下 SYSTEM_HANDLE_TABLE_ENTRY_INFO 的 Object 字段是 64 位指针而且结构体有对齐填充代码里必须使用预编译宏分别定义 32 位和 64 位版本。另一个容易踩的坑是 NtQueryObject 的 ObjectNameInformation 索引值不是文档化常量某些系统版本上行为略有差异所以要做异常保护查询失败时跳过该句柄而不是中断整个扫描。如果遇到扫描结果为空优先检查是否以管理员身份运行以及目标路径是否带中文因为 Win32 的 ANSI 版本 API 在中文路径下可能返回乱码导致匹配失败。本文还有配套的精品资源点击获取
返回列表