☰
C语言SDK实现移动存储设备监听与自动归档
2026/10/31 13:29:57 网站建设 项目流程

有人拿着"小偷"两个字来问技术方案,我一般是先皱眉、再拆解。这个标题看着像恶作剧程序,实际拆开看,它要解决的全是正经系统编程问题:怎么知道一个移动存储设备插上来了、怎么拿到它的盘符、怎么在C语言里用系统SDK去枚举目录、怎么把文件可靠地搬走。这套东西换个场景就是企业的移动存储审计、设计部门的素材自动归档、实验室里测试数据回收、个人电脑上手机相册的自动备份。所以我不打算聊任何越界的东西,只把这套C语言SDK层面的设备监听与自动归档技术讲透,包括每一步为什么这么写、参数为什么这么取、哪些坑我踩过。适合写过一点C、知道句柄和消息循环是什么、但没系统做过设备监听的同学看,也适合想把手上零散脚本工程化的朋友参考。下面所有代码和方案,前提都一样:设备是你自己的,或者使用者明确知情并授权。

1. 先把标题拆开:三个技术点,一个合规前提

1.1 "U盘小偷"这个词里到底藏着什么技术

把这个标题按技术词汇切一刀,会得到三个彼此独立的能力块。"U盘"指移动存储设备,落到系统编程层面就是卷(Volume)、盘符(Drive Letter)、总线类型(Bus Type)这几个概念;"小偷"描述的是一个行为链——被动等待、识别目标、快速取走,翻译成工程语言就是事件监听、目标筛选、批量复制;"C语言SDK"则限定了实现手段,用C去调系统提供的原生接口,而不是靠上层脚本或者现成的同步软件。

这三块单独拎出来,每一块都是干净的。设备监听是操作系统给应用的标准能力,Windows下通过窗口消息或者WMI事件就能拿到;目标筛选依赖一组设备查询接口,判断是不是可移动介质、是不是USB总线;批量复制更是文件I/O的基本功,难点在长路径、大文件、断点忘记录、拔盘中途失败这些边角上。真正让它听起来像"小偷"的,从来不是技术本身,而是最后那句话——未经许可把别人设备里的东西拿走。技术没有立场,使用场景有。

我见过不少同学搜这个标题,动机其实很朴素:想学怎么在C里监听设备插拔,顺便拿个具体例子练手。这个需求完全成立,只要把目标文件从"U盘里的一切"改成"我有权处理的那些"。所以后面的方案里,我会默认一个设定:程序只做归档和留痕,把移动介质中符合白名单规则的文档复制到本机一个明确的归档目录,并写下完整的来源记录。这个设定不是妥协,它反而让程序更复杂、更有工程价值——因为要处理去重、要处理权限、要处理日志,正好把SDK该练的点全练到。

1.2 为什么这个"小工具"在正经业务里天天出现

先说一个真实存在但被我改掉细节的场景。某制造企业的一线工位会用到大量检测仪器,仪器导出数据的方式非常原始:插U盘,手动点导出,数据落盘,再插电脑复制。工位多、班次多,靠人记几乎必然漏。后来他们做的事就是装一个小程序,检测到指定序列号的U盘接入后,自动把某个固定目录下的数据文件收上来,按"日期-工位号-班次"建目录存好,同时把设备序列号和接入时间写进日志。整个链路的技术核心,和"U盘小偷"这个标题里描述的动作完全一致,区别只在于授权、目标和留痕。

类似需求还有几类。设计院的素材机需要把外拍素材自动归集,避免同事之间来回拷;培训教室的公用电脑需要在课后把学员作业收集起来,防止U盘忘了拔;某实验室的测试机需要记录每次外接存储的接入行为,用于安全审计和事后追溯。这些场景的共同点是:设备接入是确定的、人的操作是不可靠的、记录必须可查。

所以我在给建议的时候,习惯先问三句话:谁拥有这些设备?谁知道自己被记录?数据流向哪里?三句话答清楚了,剩下全是纯技术问题,可以放心往下写代码。

1.3 方案边界先划清楚,再谈实现

必须先把能做和不能做摆明,这决定了后面所有代码的手脚。可以做的是:在自己或本单位拥有管理权的设备上,监听移动介质的接入事件;按预先配置的规则(盘符、卷标、文件类型、目录深度、大小上限)筛选目标;把匹配到的文件复制到本机归档区;保留完整元数据并生成可审计日志;提供图形或托盘界面让使用者随时看到程序在做什么、随时关掉。

不做的是:没有任何提示地隐藏运行并长期驻留;复制整盘全部内容而不做任何筛选;绕过系统权限读取其他账户的私有目录;把采集到的数据发往外部。这几条不是道德说教,是硬性红线,也是很多单位内部规范里写死的。

还有一条工程上的边界:不要一上来就想去写内核层的东西。文件系统过滤驱动确实能在更底层做事,但它带来的风险是指数级的——蓝屏、文件损坏、和杀毒软件互相打架,一个业余项目根本扛不住。用户态的窗口消息加文件API,性能上对付几十GB的移动盘完全够用,单线程复制速度能跑到接近U盘自身的顺序读写上限。我做过对比测试,同一块USB3.0移动盘,用用户态大缓冲块复制和一个简单的内核层转发,差距在10%以内,完全没必要冒那个险。

2. 三条技术路线的取舍:监听U盘插拔到底该怎么选

2.1 轮询盘符:最土的办法,反而最不容易翻车

最原始的思路是定时调用GetLogicalDrives拿一个位掩码,和上一次的结果做异或,新出现的位就是新插入的盘符。代码短到可以背下来:调用一次拿到DWORD,每一位对应A到Z,第0位是A盘,第1位是B盘,类推。把当前值和上一次值做cur & ~prev得到新增盘符。这个方案的好处说出来很朴素——它没有依赖、没有窗口、没有消息循环,控制台程序、服务程序、命令行工具全都能用,也不会因为某个窗口被销毁而丢失事件。

坏处同样明显。第一是延迟,轮询间隔设成1秒意味着最坏情况晚1秒响应,设成100毫秒又会让CPU无谓唤醒;第二是时机,盘符出现在位掩码里不代表卷已经挂载完成、不代表能立刻读到文件,用户插上一个慢速U盘,系统可能先给出盘符,几百毫秒后才允许访问,这时候你去FindFirstFile会拿到ERROR_NOT_READY;第三是它只看盘符,不看设备类型,网络驱动器映射、虚拟光驱挂载都会触发。

我的实践结论是:轮询适合做兜底,不适合做主通道。用一个主通道吃事件,再加一个低频轮询做补偿(比如每5秒扫一次,防止漏掉某些通知),这套组合拳在长期运行的程序里几乎不会漏事件。低频轮询的开销可以忽略不计,一个GetLogicalDrives调用是微秒级的。

2.2 窗口消息:WM_DEVICECHANGE 的实战细节

Windows给所有顶层窗口广播WM_DEVICECHANGE消息,这是最正统的监听方式。wParam是事件类型,DBT_DEVICEARRIVAL表示设备到达,DBT_DEVICEREMOVECOMPLETE表示已经完整移除,lParam是个指向DEV_BROADCAST_HDR的结构指针,通过它的dbch_devicetype字段能区分是卷设备、端口设备还是句柄设备。

一个常见的误解是"只要创建了窗口就能收到设备消息"。实际情况是,卷设备的到达通知会广播给所有顶层窗口,但其他类型的设备通知需要注册。稳妥做法是统一调用RegisterDeviceNotification注册一个窗口句柄,把dbcv_devicetype设成DBT_DEVTYP_VOLUME,这样卷相关的消息一定能收到。注册失败要检查两个地方:窗口句柄是否真的是顶层窗口(子窗口或消息专用窗口有时不行),以及调用是否在消息循环所在的线程。

处理消息时有几个必须做的判断,我用列表把它们列清楚,因为这些判断漏一个就会出诡异问题:

  • 先判断wParam == DBT_DEVICEARRIVAL,其他事件直接忽略,别在里面写逻辑。
  • 再取(PDEV_BROADCAST_HDR)lParam,判空。某些系统消息lParam可能是0。
  • 判断dbch_devicetype == DBT_DEVTYP_VOLUME,只有卷事件才有后面的字段。
  • 转成PDEV_BROADCAST_VOLUME,读dbcv_unitmask,这是一个位掩码,第n位为1表示盘符'A'+n出现了。
  • 读dbcv_flags,如果带DBTF_MEDIA说明是介质插入(比如读卡器插卡),带DBTF_NET是网络卷。

最重要的一条:在消息处理函数里绝对不要做耗时操作。消息处理函数跑在创建窗口的线程上,你在里面复制10GB文件,窗口就会假死,后面的设备消息全部堆积。正确做法是把盘符记下来,投递到工作线程或者塞进队列,立刻返回。我见过有人在这里直接调复制函数,结果U盘一拔、复制报错阻塞,整个界面僵住,用户以为程序崩了,直接强杀进程,日志还没落盘。

2.3 WMI事件订阅:适合跨语言,但要掂量成本

还有一条路是用WMI的Win32_VolumeChangeEvent事件。它的好处是事件里直接带盘符和事件类型,不用自己解析位掩码,而且WMI在系统层面做了很多兼容处理,某些奇怪的读卡器、多合一读卡器槽位用它更省心。C语言调用WMI稍微绕:要先CoInitializeEx初始化COM,再CoCreateInstance拿IWbemLocator,连到ROOT\CIMV2命名空间,用ExecNotificationQuery注册查询语句,然后ExecNotificationQuery返回的枚举器上循环调Next堵塞等待。

代价是三个。一是慢,WMI的启动开销在几百毫秒量级,对一个开机就跑的常驻程序来说无所谓,对命令行工具就有点硌硬。二是依赖,某些精简系统上WMI服务被裁掉了,注册直接失败。三是调试痛苦,COM的错误码是HRESULT,一堆十六进制,新手容易卡住。

我的选型建议很明确:如果你的程序本来就有窗口和消息循环,走2.2的方案;如果是无窗口的守护程序又不想写消息泵,轮询加补偿最省事;WMI留给那些需要和其他管理系统对接、或者必须拿到设备详细属性的场景。用表格对比一下更清楚:

方案响应延迟是否需窗口系统依赖适用场景我的评分
轮询位掩码100ms至5s,取决于间隔否无兜底补偿、命令行工具三颗星
WM_DEVICECHANGE毫秒级是,需消息循环无有界面的常驻程序五颗星
WMI事件订阅十毫秒级否需WMI服务与COM无窗口守护、需设备属性四颗星
文件系统过滤驱动微秒级否需驱动签名高安全等级管控不建议自研

表格最后一行的评分故意留空,因为我不推荐任何人从零自研过滤驱动,那个坑深到能埋人。

3. 核心实现:把"监听—识别—复制"三件事做扎实

3.1 一个最小可用的监听骨架

先把骨架搭起来,后面所有逻辑都往里面填。C语言写Win32窗口程序,注册窗口类、创建窗口、跑消息循环。监听部分的关键是窗口过程里处理WM_DEVICECHANGE,把盘符推给工作线程。

#include <windows.h> #include <dbt.h> #include <stdio.h> #define WM_APP_ARCHIVE (WM_APP + 1) static HANDLE g_hQueue = NULL; static HDEVNOTIFY g_hNotify = NULL; static DWORD WINAPI WorkerThread(LPVOID param); static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: { DEV_BROADCAST_VOLUME filter; ZeroMemory(&filter, sizeof(filter)); filter.dbcv_size = sizeof(filter); filter.dbcv_devicetype = DBT_DEVTYP_VOLUME; filter.dbcv_unitmask = 0xFFFFFFFF; filter.dbcv_flags = 0; g_hNotify = RegisterDeviceNotificationW(hWnd, &filter, DEVICE_NOTIFY_WINDOW_HANDLE); if (g_hNotify == NULL) { OutputDebugStringW(L"RegisterDeviceNotification failed\n"); } return 0; } case WM_DEVICECHANGE: { if (wParam != DBT_DEVICEARRIVAL || lParam == 0) { break; } PDEV_BROADCAST_HDR pHdr = (PDEV_BROADCAST_HDR)lParam; if (pHdr->dbch_devicetype != DBT_DEVTYP_VOLUME) { break; } PDEV_BROADCAST_VOLUME pVol = (PDEV_BROADCAST_VOLUME)lParam; DWORD mask = pVol->dbcv_unitmask; for (int i = 0; i < 26; ++i) { if (mask & (1u << i)) { wchar_t letter = (wchar_t)(L'A' + i); QueueUserWorkItem((WAITORTIMECOMPLETE)0, NULL, 0); /* 占位 */ PostMessageW(hWnd, WM_APP_ARCHIVE, (WPARAM)letter, 0); } } return TRUE; } case WM_APP_ARCHIVE: { wchar_t root[8]; root[0] = (wchar_t)wParam; root[1] = L':'; root[2] = L'\\'; root[3] = L'\0'; /* 把盘符丢进队列,交给工作线程,绝不在这里做I/O */ PushToQueue(g_hQueue, root); return 0; } case WM_DESTROY: if (g_hNotify) { UnregisterDeviceNotification(g_hNotify); g_hNotify = NULL; } PostQuitMessage(0); return 0; } return DefWindowProcW(hWnd, msg, wParam, lParam); } int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE hPrev, LPWSTR cmd, int show) { CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); g_hQueue = CreateQueue(); HANDLE hWorker = CreateThread(NULL, 0, WorkerThread, NULL, 0, NULL); /* 窗口类注册、CreateWindowExW、ShowWindow、消息循环,略 */ /* ... */ WaitForSingleObject(hWorker, INFINITE); CoUninitialize(); return 0; }

这段代码里有几个点值得单独说。第一,dbcv_unitmask是一个DWORD,最多26位有效,遍历时不必担心高位脏数据,但一定要用1u << i而不是1 << i,后者在i等于31时是未定义行为。第二,我用了PostMessage把盘符回传给同一线程再入队,纯粹是为了让消息处理函数尽早返回,你也可以直接调入队函数,只要入队本身不阻塞。第三,RegisterDeviceNotification的过滤器里dbcv_unitmask填0xFFFFFFFF表示关注所有盘符,如果你只关心某个范围,可以只置对应位,能减少不必要的唤醒。

还有一个容易忽略的细节:当程序以服务身份运行时,没有交互式桌面,窗口消息方案会失效。这种场景要么用WMI,要么用轮询,或者用RegisterDeviceNotification配合DEVICE_NOTIFY_SERVICE_HANDLE给服务句柄注册。我给某单位做过一个后台采集服务,最后选的就是服务句柄注册加轮询兜底,跑了两年没漏过。

3.2 拿到盘符之后:怎么确认它是移动存储

盘符到手只是第一步,接下来要判断这个卷是不是我们关心的类型。这里有个坑我踩过:GetDriveType返回DRIVE_REMOVABLE的是一部分U盘,但大量USB移动硬盘、USB固态盘返回的是DRIVE_FIXED,和本机内置硬盘一模一样。如果你只按DRIVE_REMOVABLE过滤,同事拿移动硬盘来拷数据,你的程序会完全没反应,然后你被叫去现场"看看到底哪儿出问题了"。

更可靠的办法是查总线类型。用CreateFileW打开卷设备\\.\X:(注意结尾有个冒号,没有反斜杠),拿到句柄后调DeviceIoControl发IOCTL_STORAGE_QUERY_PROPERTY,请求StorageDeviceProperty,返回的STORAGE_DEVICE_DESCRIPTOR结构里有BusType字段,值等于BusTypeUsb就是USB设备。这个判断比GetDriveType准得多,代价是要打开卷句柄,某些情况下需要管理员权限。如果权限不够,退一步用GetDriveType加卷信息做粗筛也能用。

static BOOL IsUsbVolume(const wchar_t* driveRoot) { wchar_t path[16]; _snwprintf_s(path, 16, _TRUNCATE, L"\\\\.\\%c:", driveRoot[0]); HANDLE h = CreateFileW(path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (h == INVALID_HANDLE_VALUE) { return GetDriveTypeW(driveRoot) == DRIVE_REMOVABLE; } STORAGE_PROPERTY_QUERY q; ZeroMemory(&q, sizeof(q)); q.PropertyId = StorageDeviceProperty; q.QueryType = PropertyStandardQuery; BYTE buf[1024]; DWORD returned = 0; BOOL result = FALSE; if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, &q, sizeof(q), buf, sizeof(buf), &returned, NULL)) { STORAGE_DEVICE_DESCRIPTOR* d = (STORAGE_DEVICE_DESCRIPTOR*)buf; result = (d->BusType == BusTypeUsb); } CloseHandle(h); return result; }

关于卷的序列号,我建议同时采集两个:GetVolumeInformationW返回的卷序列号(32位,格式化时会变),以及设备描述符里能拿到的硬件序列号(如果厂商写了的话,很多廉价U盘是空的)。卷序列号用来区分"这一块盘",硬件序列号用来区分"这一个设备"。实际做归档去重的时候,卷序列号就够了,因为大部分人去重诉求是"这块盘我上次是不是已经收过了"。

还有一点:卷标不要当唯一标识。我见过有人用卷标做匹配,结果单位里几十个U盘全叫"新加卷",程序直接把所有人的东西收到一个目录里,乱成一锅粥。卷标只能做辅助展示,做判断必须用序列号或者盘符加时间。

3.3 复制引擎:长路径、大文件、跳过规则

复制看起来是最简单的一步,其实是整个程序里代码量最大、坑最多的部分。先把几条硬规则摆出来。

长路径。Windows传统路径上限是260个字符,U盘里如果有个层级很深、名字很长的目录树,FindFirstFileW会返回ERROR_PATH_NOT_FOUND或者ERROR_FILENAME_EXCED_RANGE。解法是给路径加\\?\前缀,一旦加了这个前缀,系统就不做路径解析和规范化,上限能到约32767个字符。注意三个限制:路径必须是绝对路径、必须用反斜杠不能混用、不能用.和..这种相对段。我在代码里统一在拼接时加前缀,拼接函数只接受绝对路径,避免混淆。

大文件。CopyFileW内部做了不少优化,几百MB以内的文件用它完全没问题,而且它保留时间戳、处理稀疏文件。但对于4GB以上的超大文件,或者需要显示进度、支持取消的场景,就得自己用ReadFile/WriteFile循环。缓冲区大小我试过64KB、256KB、1MB、4MB几档,在USB3.0接口上256KB到1MB之间差别不大,64KB明显慢一截(系统调用次数太多),4MB以上收益递减还占内存。最终定在1MB,兼顾内存占用和吞吐。

跳过规则。归档工具必须有一张白名单和一张黑名单。白名单决定收什么:常见做法是按扩展名(docx、xlsx、pdf、jpg、mp4之类)或者按目录(只收\DCIM\、只收\Export\)。黑名单决定不收什么:至少跳过System Volume Information、$RECYCLE.BIN、隐藏属性文件、系统文件、带重解析点属性的目录(防止符号链接导致的无限递归)。重解析点这一条尤其重要,有些U盘里有快捷方式构成的环,不判FILE_ATTRIBUTE_REPARSE_POINT会直接死循环把磁盘写满。

static BOOL ShouldSkip(const WIN32_FIND_DATAW* fd) { if (fd->dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) return TRUE; if (fd->dwFileAttributes & FILE_ATTRIBUTE_SYSTEM) return TRUE; if (wcscmp(fd->cFileName, L"System Volume Information") == 0) return TRUE; if (wcscmp(fd->cFileName, L"$RECYCLE.BIN") == 0) return TRUE; if (wcscmp(fd->cFileName, L".") == 0) return TRUE; if (wcscmp(fd->cFileName, L"..") == 0) return TRUE; return FALSE; } static BOOL CopyOneFile(const wchar_t* src, const wchar_t* dst, DWORD bufSize) { HANDLE hs = CreateFileW(src, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_FLAG_SEQUENTIAL_SCAN, NULL); if (hs == INVALID_HANDLE_VALUE) return FALSE; HANDLE hd = CreateFileW(dst, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (hd == INVALID_HANDLE_VALUE) { CloseHandle(hs); return FALSE; } BYTE* buf = (BYTE*)VirtualAlloc(NULL, bufSize, MEM_COMMIT, PAGE_READWRITE); BOOL ok = TRUE; DWORD rd = 0; while (ok && ReadFile(hs, buf, bufSize, &rd, NULL) && rd > 0) { DWORD written = 0, off = 0; while (off < rd) { if (!WriteFile(hd, buf + off, rd - off, &written, NULL) || written == 0) { ok = FALSE; break; } off += written; } } VirtualFree(buf, 0, MEM_RELEASE); CloseHandle(hs); CloseHandle(hd); if (!ok) DeleteFileW(dst); return ok; }

这段代码里FILE_FLAG_SEQUENTIAL_SCAN是个小技巧,它告诉缓存管理器我们的访问模式是顺序的,系统会提前预读、回收已读页,对大文件能提升一点吞吐,对U盘这种顺序读远快于随机读的介质效果更明显。写入端我没加FILE_FLAG_NO_BUFFERING,试过,对齐要求麻烦,收益不明显,放弃了。

3.4 断点续传与去重:一份文件只搬一次

归档工具最怕两件事:重复搬运和半截文件。重复搬运好解决,用"卷序列号 + 相对路径 + 文件大小 + 最后写入时间"拼一个键,落一个索引文件,下次启动先加载索引,遇到同键直接跳过。为什么不用哈希?因为算哈希要把整个文件读一遍,U盘读取速度本来就慢,为了去重算哈希等于把U盘读两遍,得不偿失。大小加时间戳的组合在实践中的误判率极低,真怕撞再补一个前8KB的指纹就够。

半截文件更麻烦。用户看复制进度条慢,直接拔U盘,你得保证目标目录里不会留下一个看起来完整、实际残缺的文件。我的做法是:写入时用.part后缀,写完校验大小一致、刷盘成功,再MoveFileExW重命名为正式文件名,MoveFileEx带MOVEFILE_REPLACE_EXISTING。这样任何时刻中断,目标目录里要么是完整的正式文件,要么是可被下次启动清理的.part残片。程序启动时扫一遍归档目录,把超过24小时没变的.part删掉。

进度记录也要落盘,但不能每写一个缓冲刷一次文件——那太浪费。做法是每复制完一个文件更新一次索引,索引文件用"写临时文件再替换"的方式,保证不会因为断电变成一个半截的索引。这样即使中断,最多丢一个文件的记录,下次重跑会把那个文件重做一遍,成本可接受。

还有个细节叫"拔盘感知"。复制过程中U盘被拔掉,ReadFile会返回失败,错误码通常是ERROR_DEVICE_NOT_CONNECTED(1167)或者ERROR_NOT_READY(21)。这时候不要重试,直接终止当前卷的归档任务,把已经完成的文件记进索引,日志里写一条"设备未正常移除"。等服务下一次收到该盘的到达事件,从上次断点继续。这个体验很重要,我见过一个内部工具因为拔盘后死循环重试,把CPU吃到100%,最后是靠任务管理器杀掉才消停。

3.5 配置结构与审计日志

配置文件我强烈建议用INI或者JSON这种纯文本格式,别用二进制。原因是运维现场总要改参数,文本格式运维自己就能编辑,二进制每次都得找你。用INI的话Windows自带GetPrivateProfileStringW,几行代码就能读,缺点是嵌套能力差;用JSON要多引一个库,编译变麻烦。我的取舍是:配置项少于20个就用INI,超过就上JSON,别纠结。

配置项大致这么几只:

键名含义建议值说明
ArchiveRoot归档根目录D:\Archive必须可写,且不在被监控的U盘上
IncludeExt只收的扩展名docx;xlsx;pdf;jpg分号分隔,不分大小写
MaxFileSizeMB单文件上限2048防止超大视频拖慢整个流程
MinFreeSpaceMB本地最低剩余空间10240低于此值暂停并告警
AllowListSerial只处理这些卷序列号十六进制,逗号分隔留空表示全部处理
RetryCount单文件重试次数2共享冲突时有用
LogPath日志目录D:\Archive\logs建议和归档区分开

日志这块,我的习惯是双份:一份给人看的滚动文本,按天切割,记录"何时、哪块盘、收了多少文件、失败多少";一份给机器看的CSV或SQLite,一文件一行,字段包括源路径、目标路径、大小、修改时间、结果码、耗时毫秒。后者是做统计和追责的底料,前者是给人快速判断"今天有没有出事"。

注意:日志里不要记录文件内容摘要,也不要记录与业务无关的个人信息。字段最小化,够追溯就行。日志目录权限收紧到只有管理员可读,因为文件路径本身就可能泄露业务信息。

日志写入要用追加模式,每写一行刷一次,否则进程被杀会丢最后几条,而最后几条往往正是崩溃前最关键的线索。刷盘的开销相对于复制文件来说可以忽略。我在实际项目里还加了一个"心跳行",每5分钟写一条当前状态(待机/复制中/已暂停),事后看日志能立刻判断程序是卡住了还是正常空闲。

4. 工程化落地:从能跑的Demo到能长期驻留

4.1 权限、自启与界面呈现

权限问题会以两种方式找上门。第一种是打不开卷设备句柄,IsUsbVolume退化到GetDriveType判断,准确率下降;第二种是读不了U盘里某些受保护目录,FindFirstFileW直接返回拒绝访问。要不要提权取决于场景:如果是企业统一部署的采集工具,用管理员权限运行是合理的;如果是给普通同事用的小工具,就别要求管理员,退化为只处理可读的部分,日志里记录跳过项即可。

自启动我推荐任务计划程序,而不是往注册表启动项里塞。任务计划能配置"开机后延迟30秒启动"(避开登录瞬间的磁盘争抢)、"断开电源时也运行"(笔记本场景)、"失败后重试"。注册表启动项做不到这些,而且用户很容易在任务管理器的启动项里一键禁掉,你还没法知道它被禁了。

界面这件事,能省则省。我做过最省事的版本是一个托盘图标加右键菜单,菜单里就三项:立即扫描、打开归档目录、退出。加一个气泡提示"已归档 128 个文件",用户立刻明白程序在干什么。这里有个伦理层面的考虑,也是合规层面的:程序在做什么必须可见。托盘图标存在、有气泡提示、能一键退出,这三条满足,就不会给人"偷偷摸摸"的感觉,也避免同事之间因为这个工具产生误会。相反,那些故意隐藏进程、隐藏图标、不给退出入口的实现,无论目的多正当,都会让人不舒服。

托盘图标用Shell_NotifyIconW实现,注意程序退出时必须调一次NIM_DELETE,否则托盘区会残留一个点不动的幽灵图标,用户只能重启资源管理器才能清掉。这个细节小,但很影响观感,我第一次做托盘程序就忘了,被同事吐槽了好久。

4.2 资源占用与稳定性

长期驻留的程序,用户对它的容忍度取决于两个数字:内存和CPU。空转时的目标我定在内存20MB以内、CPU占用接近0。要做到这个,关键是别开不必要的线程、别做无谓的轮询。前面说的低频轮询定在5秒一次,一次调用的开销在微秒级,用户完全感知不到。

复制阶段的内存控制在几十MB,主要来自文件缓冲区。如果同时处理多块盘,缓冲区数量要乘以并发数。我建议并发数锁死在2,不要贪多:U盘的读写能力有限,两块盘并行已经能跑满USB控制器的大部分带宽,四块盘并行反而互相拖慢,还容易在慢速老U盘上触发超时误判。

稳定性里最容易出问题的是"拔盘后立即插入另一块盘"这种高频操作。这时可能出现的情况是:上一个卷的句柄还没释放,新卷的通知已经来了。解法是给每块盘维护一个独立的任务状态机,状态包括待处理、复制中、暂停、完成、失败,用盘符加卷序列号作为键,同一个键上的操作串行化。我建议别用全局互斥去锁整个复制流程,粒度太粗,会互相阻塞。

还有一个我必须提的坑:网络位置和虚拟盘。有些工具会把云盘挂成本地盘符,GetDriveType返回DRIVE_FIXED但实际是网络重定向。如果不做判断,你的程序会去遍历一个可能有几十万文件的云盘,速度慢到怀疑人生。判断办法是查GetDriveType返回DRIVE_REMOTE的直接跳过,另外对DRIVE_FIXED的卷,如果总线类型查询失败,就加一个"单卷文件数上限"保护,比如超过5万个文件就停止并告警。

4.3 部署与回滚

推给一群人用的东西,部署一定要能滚。我的做法是把程序、配置、启动脚本打成一个目录,配置里预留%ProgramData%路径用于存放运行时数据(索引、日志),程序目录本身只读。升级时只替换程序目录,不碰数据目录,回滚就是把旧目录改回来再重启服务或任务计划。

给非技术同事的部署包,我会额外放三个文件:一个README.txt写清"这个程序做什么、数据存在哪里、怎么关掉",一个config.example.ini,一个uninstall.cmd。uninstall.cmd至少要做三件事:停掉任务计划、删掉自启动项、提示手工清理数据目录(不自动删数据,避免误删)。这三样东西让我少接了无数个"这东西怎么卸"的电话。

5. 常见问题与排查实录

5.1 收不到设备通知的几种典型原因

第一类原因和窗口生命周期有关。有人用CreateWindowExW创建了窗口但没调ShowWindow,或者用了WS_POPUP且尺寸为0的隐藏窗口,部分系统版本上收不到广播。稳妥做法是用一个正常的顶层窗口,或者用HWND_MESSAGE创建仅消息窗口,后者专门用于收消息,不显示,是推荐做法。

第二类原因和线程有关。RegisterDeviceNotification必须在有消息循环的线程上调用,而且窗口句柄属于那个线程。有同学在A线程创建窗口、B线程注册通知,消息会被投递到A线程的队列,但A线程在阻塞等待,消息永远处理不了。这类问题的排查方法很简单:在窗口过程里加一行调试输出,看有没有进WM_DEVICECHANGE。没进就是注册或线程问题,进了但逻辑不对就是分支判断问题。二分法定位,永远比盯着代码猜快。

第三类原因和运行环境有关。程序以服务运行时没有交互桌面,必须先RegisterDeviceNotification用服务句柄注册,而且要在SERVICE_ACCEPT_DEVICEEVENT里处理通知。这条我在前面提过,实际踩过的人非常多。第四类原因是特定类型设备,比如某些读卡器槽位是"多合一"的,插卡时不一定触发卷事件,可能只有介质事件。这时候dbcv_flags会带DBTF_MEDIA,需要额外处理。

5.2 复制失败的排查路径与错误码速查

复制失败我一般按"能不能打开、能不能读、能不能写、能不能重命名"四步排查。打不开多半是权限或共享冲突,读不了多半是设备已断开或介质有坏块,写不了是本地磁盘满或目标文件被占用,重命名失败通常是杀毒软件正在扫描刚写完的文件。

错误码是最快的线索,我把实际遇到过的高频错误码整理成表,遇到直接查:

错误码常量名常见成因处理建议
5ERROR_ACCESS_DENIED无权限读取该目录或文件跳过并记日志,不要反复重试
21ERROR_NOT_READY卷已挂载但介质未就绪等待500ms后重试,最多3次
32ERROR_SHARING_VIOLATION文件正被其他程序占用重试2次,仍失败则跳过
39ERROR_HANDLE_EOF已读到文件末尾正常结束,不算错误
80ERROR_FILE_EXISTS目标文件已存在配合覆盖标志或先查索引
112ERROR_DISK_FULL本地空间不足立即暂停并告警
1167ERROR_DEVICE_NOT_CONNECTED复制过程中U盘被拔出终止当前卷任务,保留断点
206ERROR_FILENAME_EXCED_RANGE路径过长改用\\?\前缀
1392ERROR_FILE_CORRUPT源文件损坏跳过并记录,别试图修复

有一类问题错误码给不出线索:复制速度异常慢。我遇到过一次,单文件复制速度只有几MB/s,查了半天发现是杀毒软件对每个新建文件做了实时扫描。解法是把归档目录加进杀毒软件的排除列表,速度立刻回到正常水平。这个坑在部署前一定要和运维确认,否则用户会以为是你的程序写得烂。

5.3 我踩过的几个印象深刻的坑

第一个是时区。索引里我存的是UTC时间戳,比较时忘记统一,导致"文件修改时间比上次记录更新"这个判断在夏令时切换那天全线错乱,几千个文件被重复收了一遍。后来所有时间统一用FILETIME(100纳秒粒度、UTC、无时区概念),比较逻辑再也没出过问题。

第二个是文件名编码。我用FindFirstFileW拿到宽字符文件名,但在写日志时图省事用了fprintf配%s,中文文件名全变成问号,日志等于废了。正确做法是写日志也用宽字符接口,或者显式用WideCharToMultiByte转成UTF-8再写。这个问题隐蔽在于程序功能完全正常,只有日志坏了,而日志恰恰是出事之后唯一能查的东西。

第三个是空间检查。我一开始在归档开始前检查一次剩余空间,结果一块64GB的U盘里有50GB大文件,检查通过、复制到一半磁盘满了,报错退出,留下一堆.part。改成每写完一个文件就检查一次剩余空间,低于阈值就优雅暂停,用户清理空间后点继续,从断点接着做。

第四个是杀毒软件误报。用C写的程序如果引用了设备相关的API、又有些文件操作行为,某些杀毒软件会不太友好地对待它。解法是:给程序加版本信息资源和数字签名(哪怕自签名也比没有好)、提交误报申诉、把行为写清楚在说明文档里。这不是纯技术问题,但会直接决定你的工具能不能在别人电脑上跑起来。

5.4 一个我比较推荐的参数组合

如果不想从头调参,可以直接抄这套:监听用窗口消息加5秒低频轮询兜底;文件缓冲1MB;单文件上限2GB;单卷文件数上限5万;并发卷数2;单文件重试2次、间隔300ms;本地剩余空间低于10GB暂停;日志按天切割保留30天;索引用文本行存"序列号|相对路径|大小|FILETIME",每完成一个文件追加一行并刷盘;.part残片保留24小时后清理。这套参数在十几种U盘、三个不同厂商的机器上跑过,没出过大问题。

提示:如果你把这套东西用在自己电脑上做个人备份,把IncludeExt放宽到全类型、把MaxFileSizeMB调大即可,其他参数不用动。个人场景下唯一要额外注意的是手机通过MTP连接不会产生卷事件,那是完全不同的协议栈,别指望这套代码能处理。

6. 最后聊两句技术之外的事

技术上能实现,和应不应该做成那个样子,是两件事。我在前面把"小偷"这个标题里的技术点全部拆开讲了一遍,但我给出的每一个实现,都带着明确的筛选、可见的界面、完整的日志。这三样东西看着是增加了工作量,实际上是把一个"可能引起误会的东西"变成了"可以摆到台面上说的东西"。做工具的人如果一开始就把可审计、可退出、可解释当成设计约束,很多后面的麻烦根本不会出现。

另外一个实际体会是,这类小工具的价值往往不在代码本身,而在你对现场的理解。同样是自动归档,工位上的程序要按班次建目录,实验室的要按设备编号建目录,培训教室的要按学员账号建目录。这些规则写不进通用框架,只能到现场问清楚、记下来、再回来改代码。我做过一个版本因为目录规则不符合现场习惯,被要求推倒重来,那次的教训是:动手写第一行代码之前,先花半天时间坐在现场看别人怎么用,比读十篇技术文档管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询