还是那个文件监控的应用,发现使用Windows API(ReadDirectoryChangesW)还是不能满足要求,如果变化量大又密集时,丢失通知现象很严重。好在需要监控的大部分的Windows用户都转到NTFS系统,所以打算采用分析NTFS的Change Journal(更改日志)的方法实现监控功能。
Change Journal这名字挺直白。
很火的桌面搜索程序:Everything就是利用了NTFS系统的这个特性,通过读取和监控USN(后面会讲)而不是扫描文件来构建索引,所以搜索速度飞快,看来这个东西很好用。但是,相关的资料却是很少,特别是系统级介绍,而且一会USN,一会Change Journal,晕啊,找到代码都不敢用,还是老老实实做功课吧,找到了微软原始的两篇论文,来好好研究一下这个Change Journal。
Change Journal有什么用
NTFS是Windows 2000及其他基于Windows NT系统的标准文件系统,提供很多新特性(与FAT32比),而Change Journal是一个存储所有NTFS 5.0标卷(Volume)上文件和目录变化信息的数据库。每个标卷都有自己的Change Journal数据库,这些监控这些信息可以用来实现数据恢复,防止系统文件被篡改等系统级功能。在Windows NT 4.0中,这些功能,都是由之前两篇译文:[译]理解ReadDirectoryChangeW (理论部分)和(实现部分)中提到的Windows API来完成监控的,难用的程度真是谁用谁知道。一般的非系统级应用(如杀毒软件)也可以使用Change Journal,可以避免程序扫描整个硬盘,提高效率。
Change Journal工作原理
事实上Change Journal是标卷上一个特殊的文件,系统将其隐藏,所以用资源管理器或者CMD Shell都看不到,当文件系统中的文件或者目录发生改变时,就会向日志中追加记录。记录一般包括:文件名,变化时间,变化类型,而实际的数据不会记录,这样也可以保持记录文件足够小。
最开始的时候,日志文件时一个磁盘标卷上的空文件,随着改变的发生,记录不断被追写进日志。每条日志有个64-bit标识,即USN(Update Sequence Number)
,这个USN是自增的,所以你可以通过比较USN来,找到事件发生的顺序(号码越小,事件越早),但不一定连续,有可能第一个USN是0,而第二个是128。
微软最开始构建Change Journal时,称其为USN Journal,所以winioctl.h头文里的结构定义都是这个命名,写程序的时候也将大量使用这个名词,所以下面不区分,Change Journal=USN Journal。
由于总是向文件末端添加记录,所以采用文件偏移的形式来存储USN,这样查询时只需要计算即可定位。但记录中的文件名是变长的,所以每条USN大小也不一定相同。考虑到性能问题,系统会将记录以4KB(可以参看winioctl.h中的USN_PAGE_SIZE宏)为块大小存放,每块通常会包含三四十条记录。操作系统不允许单条记录横跨两个块页,所以有时候会发生USN为空,用来填充块间隙。
在NTFS标卷上,文件和目录信息存储于Master File Table(MFT)
中,其中的记录都描述了文件或目录名,位置,大小,属性等。NTFS 5.0中,每个MFT记录项都保存了该文件或者目录最后的USN记录。当Change Journal记录时,文件系统更新被更改的MFT中最后的USN值。
如果日志文件过大(大于定义的MaximumSize参数),系统将会清理掉文件开始部位较早的数据,通常截断开始数据需要大量的I/O操作,文件末端必须要被拷贝到新位置,这是一个耗时的过程。幸运的是,NTFS 5.0支持稀疏文件,这种机制允许删除文件中不需要的部分,而保留其余数据的逻辑偏移。所以Change Journal就是一个稀疏文件,允许清除早期记录,而不会损失太多性能,也不影响原先的文件偏移访问。更多关于稀疏文件信息可以参考A File System for the 21st Century: Previewing the Windows NT 5.0 File System。
标卷上的Change Journal功能可以关闭,这样系统就不会记录变化信息,默认情况下,NTFS标卷上的Change Journal功能是关闭的,必须明确的开启才能使用,开启和关闭可以由任意程序,任意时间完成。问题来了,如果两个程序操作时发生冲突怎么办?当一个程序禁用标卷的Change Journal,系统会清理所有先前的记录,以防止其他程序读取不可靠的数据。总的来说,Change Journal启用时会创建日志文件,禁用时会删除日志文件。
每一个Change Journal会被分配一个唯一的64-bit标识(与USN标识不同)USNJournalID
,系统将会在禁用/启用之后改变这个标识,这样程序可以通过读取这个标识,来确定读取信息的可靠性。这个标识在重启后也不会变化,换句话说,如果标识不变,Change Journal会记录开机后所有文件的变化。其实这个标识是一个UTC时间戳,但是程序员不应该利用这个语义,万一微软有一点变了咋办
所有Change Journal操作都可以通过下面函数完成:
BOOL DeviceIoControl(
HANDLE hDevice, // handle to device/file/
// directory
DWORD dwIoControlCode, // control code of operation
// to perform
LPVOID lpInBuffer, // pointer to buffer of
// input data
DWORD nInBufferSize, // size, in bytes, of input
// buffer
LPVOID lpOutBuffer, // pointer to buffer for
// output data
DWORD nOutBufferSize, // size, in bytes, of output
// buffer
LPDWORD lpBytesReturned, // receives number of bytes
// written to lpOutBuffer
LPOVERLAPPED lpOverlapped// for asynchronous
// operation
);
- 第一个参数是通过CreateFile获得的文件/目录/设备的句柄;DeviceIoControl是用来请求驱动对设备进行操作的常用方法
- 参数dwIoControlCode即指定执行什么操作并定义I/O缓冲区的结构;如果CreateFile使用FILE_FLAG_OVERLAPPED调用,DeviceIoControl将会异步操作,如果ReadFile/WriteFile一样。Change Journal由NTFS驱动管理,为了与之通信,需要获得标卷的句柄:
C++:
// Get a handle to access the Change Journal on the
// 'C' volume
HANDLE hcj = CreateFile("\.C:", GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL, OPEN_EXISTING, 0, NULL);
访问标卷句柄必须具有管理员权限,所以普通用户无法运行涉及Change Journal操作的程序,更具体的操作可以查询MSDN。
程序可以通过调用DeviceIoControl传递FSCTL_QUERY_USN_JOURNAL,来查询特定的数据,如果DeviceIoControl返回TRUE,则USN_JOURNAL_DATA结构会被填充;如果返回FALSE可以利用GetLastError(具体查MSDN)获得错误信息。
C++:
typedef struct {
DWORDLONG UsnJournalID; //64-bit标识。
USN FirstUsn; //第一条记录,所有比它还小的ID,都会被清理。
USN NextUsn; //下一条会被写入的记录。
USN LowestValidUsn; //这个日志中最小的USN,不一定是零。
USN MaxUsn; //最大日志,根据最大大小算出,NextUsn比它还大,那就要清理记录。
DWORDLONG MaximumSize; //最大大小
DWORDLONG AllocationDelta; //增长大小,如果增长超过MaximuSize,开始清理记录。
} USN_JOURNAL_DATA, *PUSN_JOURNAL_DATA;
=USN记录=
下面是USN记录结构,注意磁盘具体数据并不是这样存储的,所以永远都是由系统来填充这个结构,下面一一解释各个成员:
C++:
// Version 2.0 USN_RECORD structure
typedef struct {
DWORD RecordLength;
WORD MajorVersion;
WORD MinorVersion;
DWORDLONG FileReferenceNumber;
DWORDLONG ParentFileReferenceNumber;
USN Usn;
LARGE_INTEGER TimeStamp;
DWORD Reason;
DWORD SourceInfo;
DWORD SecurityId;
DWORD FileAttributes;
WORD FileNameLength;
WORD FileNameOffset;
WCHAR FileName[1];
} USN_RECORD, *PUSN_RECORD;
系统会一次读出多条记录缓存,RecordLength是记录总长度,包括文件名。所以利用长度来计算下一条记录位置。
C++:
PUSN_RECORD pNext;
pNext = (PUSN_RECORD) (((PBYTE) pRecord) +
pRecord->RecordLength);
请不要忽视MajorVersion和MinorVersion这两个参数,毕竟NTFS也在不断演化,Change Journal有自己版本控制,要知道最新的结构,不妨参看winioctl.h中的声明,而且可能要在程序中判断版本,区分处理,以免出错。瞧瞧,2.3版本是这个样子的:
C++:
// HYPOTHETICAL Version 2.3 USN_RECORD structure
typedef struct {
DWORD RecordLength;
WORD MajorVersion;
WORD MinorVersion;
DWORDLONG FileReferenceNumber;
DWORDLONG ParentFileReferenceNumber;
USN Usn;
LARGE_INTEGER TimeStamp;
DWORD Reason;
DWORD SourceInfo;
DWORD SecurityId;
DWORD FileAttributes;
WORD FileNameLength;
WORD FileNameOffset; // penultimate of original version 2.0
DWORD ExtraInfo1; // Hypothetically added in version 2.1
DWORD ExtraInfo2; // Hypothetically added in version 2.2
DWORD ExtraInfo3; // Hypothetically added in version 2.3
WCHAR FileName[1]; // variable length always at the end
} USN_RECORD, *PUSN_RECORD;
记录本身并不记录文件或者目录的全路径,而文件名由上面结构中的三个参数确定,FileNameOffset文件名偏移,FileNameLength文件名长度,FileName这个不能直接使用。
C++:
WCHAR szName[MAX_PATH];
CopyMemory(szName,
((PBYTE) pRecord) + pRecord->FileNameOffset,
pRecord->FileNameLength);
// Let's zero-terminate it
szName[pRecord->FileNameLength/sizeof(WCHAR)] = 0;
File Reference Number(FRN)
是文件和目录在NTFS标卷上唯一的标识,可以通过ParentFileReferenceNumber获得全路径。
C++:
TCHAR szFullPath[MAX_PATH];
// Fill in the path of the parent directory
PathFromParentFRN(pRecord->ParentFileReferenceNumber,
szFullPath);// Append name to path using the Win32 function PathAppend
PathAppend(szFullPath, szName);
很遗憾没有一个API叫PathFromParentFRN,不然就可以直接读出目录名。现在你可能会奇怪,FileReferenceNumber是干什么的,如果我们能通过FRN得到全路径信息,那就不用上面的偏移+长度获得文件名了。事实上,找到一个目录的FRN比文件容易得多,FileReferenceNumber不一定是个文件还是目录,但是ParentFileReferenceNumber一定是个目录,所以采用偏移+长度的方式得到本名,再用Parent得到目录,这样就可以组合出全路径了。
没错,Usn就是记录标识了;TimeStamp是一个64bit,UTC时间戳;Reason成员表示文件或者目录发生了何种变化,一个文件打开后,系统将Reason变量置零,但不写入USN记录,当变化动作发生时,如果这是一个新的Reason Code,就设置Reason变量并向日志中写入记录。如果有多个程序同时操作同一个文件,也可能会发生同一条记录的Reason有多个Reason Code,直到USN_REASON_CLOSE被设置,文件被关闭。
C++:还可以通过调用DeviceIoControl传入FSCTL_WRITE_USN_CLOSE_RECORD,使得系统在打开文件时清理Reason变量为0。
DWORD cb;
USN usn;
// Force a close record for
// the open file specified
// by 'hFile'
DeviceIoControl(hFile, FSCTL_WRITE_USN_CLOSE_RECORD,
NULL, 0, &usn, sizeof(usn), &cb, NULL);
唯一特别的的一个Reason Code是USN_REASON_RENAME_OLD_NAME,当一个文件重命名,将会有两条记录被写入日志,分别一条记录老的文件/目录名,另一条记录新的文件/目录名,当然其Reason Code是USN_REASON_RENAME_NEW_NAME。
如果SourceInfo成员非零,说明文件发生了改变,那这与Season有什么区别呢。比如“杀毒软件删除了一个你文档里面的病毒”,杀毒软件需要打开文件并覆盖受感染的部分。这会产生一个Reason=USN_REASON_DATA_OVERWRITE的记录,记录会因为一个数据覆盖操作(Reason),而完成这个工作是为了杀毒(SourceInfo)。也就是说SourceInfo更具有逻辑意义,这个信息并不是系统指出的,而是由操作文件的程序设置。
SecurityId是系统用来描述文件安全性的成员,与设备I/O控制FSCTL_SECURITY_ID_CHECK仪器使用;FileAttributes可以通过GetFileAttributes调用获得文件/目录的属性。
=读取记录=
有了上面对记录结构的认识,下面来读取Change Journal记录。首先准备两个变量,分别是标卷句柄,与日志结构(通过FSCTL_QUERY_USN_JOURNAL获得):
C++:
HANDLE hcj;
USN_JOURNAL_DATA ujd;
再通过调用FSCTL_READ_USN_JOURNAL调用DeviceIoControl,下面这个结构需要填充后作为参数输入:
C++:
typedef struct {
USN StartUsn;
DWORD ReasonMask;
DWORD ReturnOnlyOnClose;
DWORDLONG Timeout;
DWORDLONG BytesToWaitFor;
DWORDLONG UsnJournalID;
} READ_USN_JOURNAL_DATA, *PREAD_USN_JOURNAL_DATA;
StartUsn,第一条你想访问的Usn,如果标识存在就返回,否则返回下一条,如果StartUsn为0,系统将会返回最开始的记录。 ReasonMask和ReturnOnlyOnClose可以按照字面理解(后面会解释),StartUsn并不能保证时候满足这两个条件,所以需要调用者自己验证。系统是以4KB为一块(USN_PAGE_SIZE)写入日志,所有ujd.FirstUsn到ujd.NextUsn都会依据4KB对齐。
系统只会返回满足ReasonMask条件的记录,换句话说,你可以指定自己关心的Reason Code,不符合条件的记录不会包含在缓冲区中。ReturnOnlyOnClose是另一可以过滤记录的成员,如果其值非零,只有Reason=USN_REASON_CLOSE记录才会被返回,这个条件需要与ReasonMask相一致才行。
Timeout与BytesToWaitFor一起使用,作为查询时间的限制。并不是说明DeviceIoControl在指定的超时时间内返回,而是用来指定系统检查请求数据是否可用的周期。这个成员不像其他win32超时参数采用毫秒计时,而是使用FILETIME结构。当设置Timeout为0,即不指定超时时间;使用一个负数来指定超时时间,例如一个25秒的超时可以表示为-2500000000。如果是异步调用DeviceIoControl则超时成员被忽略。
不要混淆BytesToWaitFor成员和输出缓冲区大小,或者DeviceIoControl的返回值,若置零,则表示函数立即返回,即使没有找打匹配的日志,如果非零,至少找到一条匹配数据然后返回。BytesToWaitFor定义了系统检查是否匹配数据创建的周期,例如,如果定义16384,系统将会在新建16KB数据后验证,这样可以防止一个进程读取记录时使用太多资源。Timeout/BytesToWaitFor只有在使用ReasonMask/ReturnOnlyOnClose但没有找到数据时才有效果。
UsnJournalID应该被设为ujd.UsnJournalID,如果日志ID已经被改变,DeviceIoControl调用会失败(前面说过,禁用后数据都会删除,重启后会改变这个ID)。
调用FSCTL_READ_USN_JOURNAL是为了填充输出缓冲。
C++:
DeviceIOControl(hcj, FSCTL_READ_USN_JOURNAL, &InBuf,
sizeof(InBuf), pOut, cbOut, &cbReturned, NULL);
但却无法知道具体填充了几条数据,具体排列形式是这样:
下面的代码利用usnStart和usnEnd判断数据合法性:
C++:
// Read the raw data for USNs from usnStart up to but not including usnEnd
// This can be used to read all available records by using
// the USN_JOURNAL_DATA members FirstUsn and NextUsn
void GetRawRecordData(HANDLE hcj, DWORDLONG journalId,
USN usnStart, USN usnEnd){
READ_USN_JOURNAL_DATA rujd;
rujd.StartUsn = usnStart;
rujd.ReasonMask = 0xFFFFFFFF; // All bits
rujd.ReturnOnlyOnClose = FALSE; // All entries
rujd.Timeout = 0; // No timeout
rujd.BytesToWaitFor = 0; // Do not wait if no records
rujd.UsnJournalID = journalId; // The journal we expect to read fromwhile (rujd.StartUsn <usnEnd) {
DWORD cbRead;
BYTE pData[8192 + sizeof(USN)]; // read in 8 KB chunks
BOOL fOk = DeviceIoControl(hcj, FSCTL_READ_USN_JOURNAL,
&rujd, sizeof(rujd), pData, sizeof(pdata), &cbRead, NULL);
if (!fOk)
break; // handle error
// Get first USN to request next time
rujd.StartUsn = * ((PUSN) pData);
PUSN_RECORD pRecord = (PUSN_RECORD) &pData[sizeof(USN)];
while ((PBYTE) pRecord < (pData + cbRead)) {
// … do something with the record …
pRecord = (PUSN_RECORD)((PBYTE) pRecord + pRecord->RecordLength)
}
}}
==参考==
Keeping an Eye on Your NTFS Drives: the Windows 2000 Change Journal Explained
Keeping an Eye on Your NTFS Drives, Part II: Building a Change Journal Application
Wikipedia: USN_Journal