简介:一套仿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 Manager
2.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 | 枚举内核句柄表 | 需要 SeDebugPrivilege | Win 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::map<DWORD, 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::vector<SYSTEM_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 在中文路径下可能返回乱码导致匹配失败。
本文还有配套的精品资源,点击获取