原文地址: http://www.microsoft.com/msj/0999/journal/journal.aspx.
Windows 2000 “更改日志”是NTFS 5.0卷所有文件或目录的变化的数据库。每一卷都有各自的更改日志数据库,该库中包含了卷文件和目录发生的变化记录。
Windows 2000 充满了各种新奇、令人振奋的技术,更改日志就是其中一个。它将要开启一个全新的基于Windows应用程序的功能世界,并且提供给今天很多程序大幅度改善性能的机会。从企业级应用到您个人杀毒软件,每一样都会从中受益。我们要解释这项技术,包括它的实现,还要介绍访问更改日志的API们。我们的例子程序会帮助您学习更改日志的这些功能。未来的文章里,我们将会提供更改日志编程的所有细微之处,并提供一个全新的更改日志样本,可以用来作为您自己的应用程序模板。
简单来说, 更改日志是一个包含了NTFS 5.0卷下文件或目录每一个变化清单的数据库。任何文件和目录被写入,NTFS保证有一条记录会被添加到更改记录中。每一卷都有自己的更改日志数据库,这里包含了反映出文件和目录发生变化的记录。如果你有多个NTFS卷,每一个都有各自的日志, 当然,FAT卷并没有这个更改日志。
更改日志很容易使用,它用最多的是在服务上,没有谁会阻止正常的应用程序去读取它。 通过相应API函数来访问它,使得可以被任何Windows 2000版本上的应用程序使用。
任何应用程序和服务都可以同时访问这个信息。一个备份服务程序可以读取它,从中发现哪些文件需要备份。同时,一个安全程序可能在查看有没有人会篡改系统目录下的文件。在Windows NT 4.0,这些任务都是由FindFirstChangeNotification和ReadDirectoryChangesW来做的,用过的人都知道这些函数是多么的局限。更改日志为这些需要监视NTFS卷变化的应用程序们提供了一个全新的模式。
更改日志还能减少应用程序遍历整个硬盘的需求(完整的重新扫描)。很多公司依靠定期全面重新扫描硬盘来收集最新的信息。 现在,应用程序可以只做一次全盘扫描然后依靠更改日志来确切的知道哪些文件或目录何时被修改了。
实现细节
更改日志实际上是NTFS卷上的特殊的文件,系统会隐藏该文件所以你用平常的工具像资源管理器或命令行就看不到它。文件系统对文件或目录产生变化修改的时候,就会追加一条记录日志,这条记录标识了文件名字,发生的时间,发生何种变化等。 实际上变化了的数据并没有保存在日志中,所以别指望能够撤回更改——记录日志都是尽可能的小。
更改日志初始是磁盘上的一个空文件, 随着卷中发生变化,记录都会追加到文件的末尾,每条记录都分配有64位的标识,叫做更新序列号(USN)。当微软最初开发更改日志的时候,内部都称其为USN日志,这就是为什么参考了更改日志的winioctl.h头文件的结构体和定义都叫它USN日志。当一条记录添加到日志时,都被分配一个USN,这么多USN以递增的顺序生成, 所以你可以比较USN来发现事件的先后顺序(USN小的都是发生较早的事件)。USN不是连续的,所以很可能第一个USN是0而第二个是128。
更改日志总是把新记录写在文件末尾,所以实现人员选择使用文件的记录偏移作为USN,这使得查找日志很快捷,因为系统可以简单的利用USN搜到所需的记录。由于记录包含一个文件名字,各个长度都不相同,所以你会注意到相邻记录的USN之间存在距离的变化。典型的记录长度大约100字节长。为提高性能,系统将这些记录写入到4KB大小的包含30到40条记录一组的块里(在winioctl.h头文件的USN_PAGE_SIZE定义),系统不允许单条记录横跨两个页的边界,所以有时你会看到USN之间有一段空距,这是用来填补块末尾的细碎间隙。
在NTFS卷里,文件和目录信息保存在主文件表中(MFT)。MFT的每条记录描述了文件或目录名字,位置,大小,属性等等。NTFS 5.0中,每个文件的MFT条目记录了上一次生成的USN,目录也如此,随着记录追加到更改日志,文件系统也为那些被修改的文件或目录更新MFT的最近一次的USN值。在下一篇文章里,我们要介绍这个技术是如何快速浏览那些修改了一段时间的文件MFT。
如果日志文件太大了,系统会清理掉文件开始的最旧的记录。传统上讲,在文件的开头截断数据需要大量的文件IO。文件的末尾必须拷贝到一个新的地方,这是一个耗时的任务。幸运的是,NTFS 5.0支持稀疏文件,这个机制允许文件里那些不需要的部分删掉,而保留剩余数据的逻辑偏移量。更改日志是一个稀疏文件,允许记录的清理而没有任何性能损失。此外,剩余记录利用USN仍然可以很快定位,因为它们的逻辑偏移没有变。更多信息有关稀疏文件,请阅读文章“ http://www.microsoft.com/msj/1198/ntfs/ntfs.htm”
更改日志可以被禁用,以此防止系统记录下来文件和目录出现的变化。缺省情况下,NTFS卷会禁用自己的更改日志。应用程序必须显式激活更改日志。还要注意的是任何应用程序都可以在任何时候激活或禁止卷日志。应用程序必须能够优雅的处理“日志被禁止了但前一个应用程序还在使用着日志”这种情况。接下来文章里我们会描述应用程序怎样处理这种情况。 当应用程序把一个卷的更改日志禁止了,系统就会把现存的记录都清理了,阻止信息的恢复。这一措施避免应用程序不经意间读取不可靠的记录。更改日志只会包含当日志在持续激活状态下的数据记录。
在当前的更改记录实现中,当更改日志被禁止时磁盘上的日志文件实际上是被删掉了。下一次应用程序激活更改日志时一个新的日志文件就创建出来了。虽然应用程序不应该在乎这点实现细节,但是这也是为什么术语‘创建’‘删除’日志在平台SDK中使用。我们更希望去思考更改日志是激活或是禁止,因为它描述了作为一个系统提供的服务。像‘创建’‘删除‘这些术语在我们试图理解磁盘上更改日志作为一个文件的实现是很有用的。我们也发现了思考这个更改日志是激活还是禁止可以帮助我们理解它怎么被应用程序所使用。
更改日志分配了一个独一无二64位的日志ID(不要和USN混淆了)。当有文件或目录变化没有被记录时,系统会改变日志的ID。举个例子,如果某个卷的日志已禁止,然后又激活了,这个日志ID就会改变。只要日志ID没有改变,应用程序就可以放心的使用更改日志所记录的每个文件和目录的变化了。即使系统重启了,日志ID通常也无须更改。换句话说,经过重启之后,若日志ID没有改变,那么不用怀疑,系统就是记下了关机,开机后所有的文件与目录变化。细心的开发人员会发现,日志ID其实是经由系统时间来生成的标准的64位UTC时间戳,应用程序不应该从中派生出其他任何意义(记住,在Windows 2000以前微软可能改变了日志ID的生成)。
Windows NT 4.0 SP4提供了对NTFS 5.0限制级的访问。不幸的是,更改日志不能访问,卷变化不会被记录。在双线引导系统中,当Windows 2000 重启时所有日志ID都改变了。同样,这使得在Windows 2000 上运行的程序知道自己可能已经错过了一些文件或目录的变化。
用途
更改日志的所有功能经由DeviceIoControl这个函数来访问。
bool DeviceIoControl(
HANDLE hDevice, //文件/目录/等各种设备的句柄
DWORD dwIoControlCode, //待执行的操作控制码
LPVOID lpInBuffer, //指向输入缓冲区
DWORD nInBufferSize, //输入缓冲区字节大小
LPVOID lpOutBuffer, //指向输出缓冲区
DWROD nOutBufferSize, //输出缓冲区字节大小
LPDWORD lpBytesReturned, //收到的写入输出缓冲区的字节数
LPOVERLAPPED lpOverlapped //异步操作时使用
)
第一个参数是文件、目录或者其他设备经由CreateFile获得的句柄。DeviceIoControl是一个公共方法,利用传递指定设备的请求到驱动管理句柄处。第二个参数指定了哪种操作。并决定了输入/输出缓冲区的结构。如果CreateFile调用时有FILE_FLAG_OVERLAPPED,函数就要异步操作,方式和readFile/WriteFile相同。
NTFS驱动器管理更改日志。要与卷的日志交流沟通,调用DeviceIocontrol,传入改卷的句柄。调用CreateFile如下,来获取一个卷的句柄:
//获取C盘的句柄用来访问其更改日志
HANDLE hcj = CreateFile("\\\\.\\C:",GENERIC_READ,FILE_SHARE_READ | FILE_SHARE_WRITE,NULL,OPEN_EXISTING,0,NULL);
访问到此卷的句柄仅限于系统和管理员组的成员,所以一般用户不能运行这个程序了。这意味着这些应用程序由管理员来运行。
控制码支持更改日志,已在sdk中记录在案。它们可以位于索引中,但不是直接列出在DeviceIoControl的文档中。学习它,最好的办法是搜“更改日志”。
更改日志的统计
应用程序通过调用DeviceIoControl传入FSCTL_QUREY_USN_JOURNAL码来查询某一卷的更改日志,如果函数返回真,那USN_JOURNAL_DATA结构就被填充数据了。如下图所示
Figure 1:
typedef struct{
DWORDLONG UsnJournalID; //64位唯一日志标示符
USN FirstUsn; //标识了日志中实际的首个USN,比这个小的USN值都被清理了
USN NextUsn; //下一条USN
USN LowestValidUsn; //本日志最小的有效USN,可能不是0,
USN Maxusn; //最大的USN,
DWORDLONG MaximumSize; //日志的最大字节数
DWORDLONG AllocationDelta; //需要的情况下增长日志的大小,
}USN_JOURNAL_DATA,*PUSN_JOURNAL_DATA;
如返回假,getlastError可以返回下列之一:
ERROR_INVALID_DEVICE_STATE 本卷不支持日志
ERROR_JOURNAL_NOT_ACTIVE 支持日志,但当前没有激活,使用FSCTL_CREATE_USN_JOURNAL激活之
ERROR_JOURNAL_DELETE_IN_PROGRESS 日志正在被禁用中。。
更改日志 记录
我们仔细看一下单条更改日志记录的存储信息。
应用程序通过结构体USN_RECORD来处理这些记录,这不是一个磁盘上的记录结构,但它包含了单条记录里所有有用的信息。
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是记录的总长度,以字节为单位,包括了文件名字。在输出缓冲区里会提供多条记录,所以RecordLength应该用来计算下一条记录的位置。
PUSN_RECORD pNext;
pNext = (PUSN_RECORD)( ((PBYTE)pRecord) + pRecord->RecordLength );
主版本和最低版本
忽视掉版本检查的重要性很容易,但做出粗心的错误激怒用户更容易。任何人在Windows NT 4.0上安装软件并收到“要求Windows NT 3.5 版本或以上”这样的消息,会见证滥用GetVersion函数造成的灾难。GetVersionEx 会帮助开发者弄清楚版本的一团糟,但还不够。Windows 2000 添加了VerifyVersionInfo方法,为简单的程序提供一个更安全的保障。
为了这篇文章,我们不关心运行的是WIndow什么版本,但更改日志要有它自己的版本约束。没有什么花哨的函数来帮你排忧解难,所以你花时间来弄懂这个信息很重要,(我们只提到VerifyVersionInfo作为一个公共服务公告)。
Windows 2000 初始版本预计使用更改日志的版本 2.0(主版本是2,低版本是0)。正当我们写这篇文章时,平台SDK只包含了2.0版本的USN_RECORD定义(该结构在winioctl.h定义)。您的应用程序负责在编译期间知道结构的版本。Winioctl头文件目前没有提供任何带此版本信息的常量,所以所以最好办法是看这个头文件的注释了。安全起见,最好是创建你自己的编译期常量,执行运行时检查,以验证新的结构定义不经意间包含进来。
#include<winioctl.h>
#define CJ_MAJOR_VERSION_EXPECTED 2
#define CJ_MINOR_VERSION_EXPECTED 0
#define CJ_SIZEOF_USN_RECORD_EXPECTED 64
void RunTimeSanityCheck()
{
if(sizeof(USN_RECORD)!= CJ_SIZEOF_USN_RECORD_EXPECTED)
{
//someone probably updates winioctl.h or changed the default structrue packing,
//any code placed here will run if we are compiling with a different size USN_RECORD than when we wrote
//this module.we'd better take a look at it!
}
}
在运行时期,应用程序检查日志的主版本和低版本来确定信息的兼容性。如果检测到主版本变化,USN_RECORD结构会有显著的变化,你还能用的只有Record- Length, MajorVersion,MinorVersion了,不幸的是,系统在运行时期并不提供向兼容版本妥协的能力。换句话说,如果系统给输出缓冲区填充的记录使用的主版本号和预期的不一致,那信息完全废了!更改日志在早期的Windows 2000 测试版上主版本是1,但不久就不再使用了。
如果检测到低版本号改变了,新成员都添加到旧有结构的倒数第二个成员的后面了。应用程序假设USN_RECORD结构的倒二成员以前那些还是有效的。举个例子,考虑下Figure 3所示的结构假想版本2.3。
Figure 3
//hypothetical version 2.3 USN_RECORD structrue
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;
如果应用程序编译时使用的是USN_RECORD当下版本2.0,它可以检测到内存缓冲区里填充的是版本2.3的东西。里面的成员可以还引用到FileNameOffset(包括)之前,(我们稍后会讨论正确的访问FileName这个成员)。另一方面,想象一下,程序编译时使用的是版本2.3,如果输出缓冲区的记录都是2.1的,那么2.3的结构还能使用ExtraInfo1(包括)之前的成员的。
即使记录的版本信息在每条记录中都给提供,但应用程序只能是在启动时检查一次。版本号在同一个机器上的不同卷里还是一致的,而且只可能在安装了一个带有新更改日志的服务包(service pack)后重启才会变的。
这听起来像很多工作吗?可能吧,但是想象一下如果不正确读取系统提供的缓冲区所带来的后果。很有可能你的软件被当做服务来运行,访问冲突会把服务干掉!幸运的是,目前这个结构只有版本2.0一家。
文件名长度,文件名偏移,文件名(FileNameLength, FileNameOffset, and FileName)
日志记录描述了卷中文件或目录的变化。为了方便,“记录的全路径”参考了文件/目录的全路径,该文件/目录的变化被那条记录所描述。记录的全路径并没有存到记录本身里。为了节省空间,文件/目录的名字存下了,但没有其路径信息。USN_RECORD的三个成员提供了名字的访问。
见Figure 4:FileName of a USN record
FileNameOffset //记录起始处到文件/目录起始处的字节数
FileNameLength //文件名(FileName)的字节数。编码为Unicode,所以FileName的字符数是FileNameLength/sizeof(WCHAR),该值不包括0终止符,你不能靠记录里的FileNam //e来0结尾
FileName //不要直接用这个成员。运行时,文件/目录的位置可能不是这里,用FileNameOffset和FileNameLength配合。
下面是把USN_RECORD的名字拷贝到另一处的正确方法,你要有一个0终止的字符串:
WCHAR szName[MAX_PATH];
CopyMemory(szName, ((PBYTE)pRecord) + precord->FileNameOffset, precord->filenameLength );
//let's zero-terminate it
szName[pRecord->FIleNameLength/sizeof(WCHAR)] = 0;
文件引用次数,父文件引用次数 (FileReferenceNumber and ParentFileReferenceNumber)
文件/目录的名字,如果连它在什么目录下都不知道那完全没有用了。ParentFileReferenceNumber就指定了这个目录是谁,文件引用次数(FRN)是一个64位的ID唯一标识了NTFS卷中的某个文件/目录。下面就是我要想做的来找到记录的全路径(假设szName已经包含了记录里的文件/目录名):
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);
不幸,PathFromParentFRN这个函数并不存在,实际上,目前并没有这么一个API来直接的把FRN转换成全路径。下一篇文章一大篇幅我们要做这件事情。
你现在可能怀疑 FileReferenceNumber 这个东西了。如果我们可以把这个FRN转换成全路径,那么它就是我们要找的那个记录的全路径(我们也不用去讨论什么FileNameOffset, FileNameLength,ParentFileReferenceNumber了)。**事实证明,从目录的frn发现全路径要比从文件FRN发现全路径要容易很多。FileReferenceNumber可能就是文件或者目录的FRN(取决于是否描述了一个文件或目录的改变)。但是ParentFileReferenceNumber这个东西肯定就是一个目录的FRN。因为这点,最简单之办法就是检查ParentFileReferenceNumber,并且把名字追加,名字的获取用FileNameOffset 和 FileNameLength。
USN,时间戳,原因
如你所想,Usn成员会告诉你记录的USN。时间戳是一个标准的UTC时间戳,64位格式。Reason成员会告诉你该变化是何种变化(什么样的变化)。Figure 5显示的是变化的类型(原因码),这种条目在更改日志里生成。原因成员可能是一个或多个原因码的集合。为了解释这个成员,我们一起看看系统是怎么把记录写在日志里。
Figure 5 原因码
USN_REASON_BASIC_INFO_CHANGE 一个用户改变了若干个文件或目录的属性(比如只读、隐藏、稀疏文件等),或是时间戳
USN_REASON_CLOSE 文件、目录关闭了
USN_REASON_COMPRESSION_CHANGE 压缩状态改变了
USN_REASON_DATA_EXTEND 文件的未命名流追加了数据
USN_REASON_DATA_OVERWRITE 文件、目录的未命名流数据重写了
USN_REASON_DATA_TRUNCATION 文件的未命名流截断了
USN_REASON_EA_CHANGE 用户改变了文件、目录的扩展属性。这些NTFS属性,win32程序时访问不到的
USN_REASON_ENCRYPTION_CHANGE 文件目录被加密或解密了
USN_REASON_FILE_CREATE 文件目录创建了
USN_REASON_FILE_DELETE 文件目录删除了
USN_REASON_HARD_LINK_CHANGE NTFS硬链接添加或移除了
USN_REASON_INDEXABLE_CHANGE 用户改变了FILE_ATTRIBUTE_ NOT_CONTENT_INDEXED 之属性。它可能把以前需要内容索引的文 件改成了不需要的,反之亦然
USN_REASON_NAMED_DATA_EXTEND 命名流有数据追加了
USN_REASON_NAMED_DATA_OVERWRITE 命名流有数据重写了
USN_REASON_NAMED_DATA_TRUNCATION 命名流数据截断了
USN_REASON_OBJECT_ID_CHANGE 文件目录的对象标示符改变了
USN_REASON_RENAME_NEW_NAME 文件目录重命名,在USN_RECORD里是新名字
USN_REASON_RENAME_OLD_NAME 文件目录重命名,在USN_RECORD里是旧名字
USN_REASON_REPARSE_POINT_CHANGE 文件目录多分点属性被添加、修改或是删除
USN_REASON_SECURITY_CHANGE 访问权限变化了
USN_REASON_STREAM_CHANGE 命名流添加、重命名,或是被移除了
系统追踪每一个打开文件的原因变量。当系统第一回打开文件,会设置原因变量为0。文件打开时,没有记录添加,即使带有写访问权。如果变化真的发生了,系统检查原因码是否在原因变量里标记了。如果这是一个新的原因码,原因变量里就有新的位设置,一条记录添加到更改日志(原因变量直接拷贝到记录的原因成员中)。可能不止一个程序在修改文件和目录,原因变量会为文件所有变化来积累原因码、原因变量继续积累变化原因位的链表一直到文件句柄关闭才结束。就在这时,最终的记录添加到更改日志中,带有积攒的原因码和USN_REASON_CLOSE。Figure 6描述了这个过程。
Figure 6 原因码的处理
用FSCTL_WRITE_USN_CLOSE_RECORD控制码来告诉系统清理打开文件的原因变量,*这有可能发生。DeviceIoControl这个函数调用时用的是打开文件的句柄(不是卷的句柄),并且一个关闭的记录会立刻为文件生成。
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);
没有输入缓冲区,**输出缓冲区就会填充sizeof(USN)个字节的代表生成的关闭记录的USN。当这步完成时,系统立即向日志里写一条已经积攒好的原因码和USN_REASON_CLOSE码。但没有关闭文件。原因变量重置为0,它会再一次开始积累各种变化。如果当FSCTL_WRITE_USN_CLOSE_RECORD使用时,原因变量为0,那么仍然会生成一条日志记录;这意味着你会看到一条只带有USN_REASON_CLOSE码的记录。
唯一不符合上述规则的原因码是USN_REASON_RENAME_OLD_NAME。当文件被重命名时,有两条记录添加到日志中,首先,USN_REASON_RENAME_OLD_NAME码添加到原因变量里,记录创建了。FileNameOffset 和 FileNameLength成员指定源文件名字,ParentFileReferenceNumber指定源目录。(把文件或目录移动到本卷的另一处被视为重命名)。下一步,USN_REASON_RENAME_OLD_NAME标志从原因变量中移除,取而代之的是USN_REASON_RENAME_NEW_NAME.第二条记录使用了新的文件名和ParentFileReferenceNumber来生成。**透过直到文件/目录的下一条关闭记录,原因成员一直保有USN_REASON_RENAME_NEW_NAME码,而不是USN_REASON_RENAME_OLD_NAME。如果是重命名或者移动到本卷下另一位置,文件/目录的FileReferenceNumber是不变的。
假设你重命名并移动了这个文件:D:\dir1\before.txt 到 D:\dir2\after.txt。下面这条命令:
move D:\dir1\before.txt D:\dir2\after.txt
你会在日志中看到这三条记录:
FileNameOffset/Length Parent FRN points to Reason
before.txt D:\dir1 Rename Old Name
after.txt D:\dir2 Rename New Name
after.txt D:\dir2 Rename New Name | Close
那如果给一个拥有数以百计的子文件和子文件夹的目录重命名呢?你把D:\Program Files 改成 D:\PFiles。系统只会生成下面三条
FileNameOffset/Length Parent FRN points to Reason
Program Files D:\ Rename Old Name
Pfiles D:\ Rename New Name
Pfiles D:\ Rename New Name | Close
没有必要给子文件/子文件夹来创建记录,因为信息可以从ParentFileReferenceNumber来推断出来。正因为这个原因,你会发现如果条目以名字和父ID的方式存储,维护一个文件/文件夹的数据库很容易。缺陷会在你试图监视某个文件时发生,你需要把它的父目录溯源至本卷的根驱动盘,这些都要监视,否则你可能错过移动文件或是重命名文件。
当目录删除,你不必担心推断其子文件和子文件夹会受影响。系统不会允许一个有子系的目录被删除。如果你在资源管理器里删除了一整个目录树,你会看到在目录删除记录之前有它所有子系的删除记录。
更改日志不提供依靠FindFirstChangeNotification或者ReadDirectoryChangesW实现变化通知功能这样的超级集合,理解这一点很重要。更改日志是设计用来报告所有文件/目录的显式动作。那些副作用并不全部报告,举个例子,如果应用程序调用了SetFileTime函数,更改日志会报告一个基本信息变化。然而,如果应用程序写入一个文件,更改日志只会报告数据的重写(这个显式动作),并没有时间戳变化(这是副作用)。在同样情况下,当一个目录/文件创建了,更改日志不会报告其父目录的时间戳变化。另一方面,这些变化通知API,是设计成可以报告它们检测到的所有变化,即使是其他动作的副作用。
源信息,安全ID,文件属性(SourceInfo, Securityld, and FileAttributes)
如果源信息成员不是0,它会指定一个文件正在变化的原因(而非原因成员)。原因成员和源信息之间的区别很微妙,考虑下这个说法,“病毒检查人从你的文档里移除了一个宏病毒”这个检查人员可能要打开文件然后重写了被感染的那部分。这会生成一个带USN_REASON_DATA_OVERWRITE码的记录。这条记录因为数据重写(原因成员)而存在。但是这是为了移除病毒(源信息)。应用程序可以利用这条信息来判断要对这个文件/目录做什么,如果病毒程序受信任的,离开文档,内容不变,变化大概可以忽略不计。
这条信息不来自系统。是由打开文件的那个应用程序提供的,(下一篇文章会讨论如何提供信息FSCTL_MARK_HANDLE)。目前,只有三个标志。
figure 7:
USN_SOURCE_DATA_MANAGEMENT
USN_SOURCE_AUXILIARY_DATA
USN_SOURCE_REPLICATION_MANAGEMENT
安全ID系统用来标识文件的安全描述符的标示符。利用FSCTL_SECURITY_ID_CHECK可以访问。
文件属性是调用GetFileAttributes函数的返回值。很有价值,通过查看FILE_ATTRIBUTE_DIRECTORY标志,你可以很容易确定USN_RECORD是不是指的某个文件/目录
读取更改日志
最后,我们准备从日志里读记录了。首先,需要两件事情:卷的句柄和用FSCTL_QUERY_USN_JOURNAL码来获取的一个有效的USN_JOURNAL_DATA结构。我们来说一说下面这两个变量:
HANDLE hcj;
USN_JOURNAL_DATA ujd;
为了读到记录,要用FSCTL_READ_USN_JOURNAL码调用DeviceIoControl函数。输入缓冲区必须指向下面的结构:
typedef struct{
USN StartUsn;
DWORD ReasonMask;
DWORD ReturnOnlyOnClose;
DWORDLONG timeOut;
DWORDLONG BytesToWaitFor;
DWORDLONG UsnJournalID;
}READ_USN_JOURNAL_DATA,*PREAD_USN_JOURNAL_DATA;
第一个参数设置为你想要读的USN记录之第一条,可能是日志中的一个USN现有记录,为0,要么就是USN的下一条准备往日志里写入的。如果是0,系统就会开始在适当位置(有数据可读的地方)读取第一条。如果是日志中现有记录,系统就在这个位置读取。如果是下一条准备写入的(比如ujd.NextUsn),系统会等更多的数据出现,由Timeout/BytesToWaitFor来指定该怎么等,我们后面会讨论。
因为没有办法知道由第一个参数StartUsn确定的记录是否能匹配过滤标准(参见对于ReasonMask/ReturnOnlyOnClose的讨论)。输出缓冲区可能不包含指定的记录。应用程序要检查返回的USN_RECORD结构的Usn成员来找到实际返回的记录USN。
因为系统写入的日志都在4KB的块里(USN_PAGE_SIZE),所有4KB对齐值从UJD.FirstUsn到ujd.NextUsn都要保证是一条记录的USN,因此,这些都是StartUsn的有效值。除此之外,获取StartUsn有效值的唯一途径是那些更改日志的API们返回的USN。
原因掩码,返回关闭(ReasonMask and ReturnOnlyOnClose)
系统只会返回那些至少有一个由ReasonMask指定原因码的日志记录。换句话说,你可以通过指定你关心的原因码来过滤大规模的你要处理的信息。不包含特定码的记录不会在输出缓冲区返回。
系统使用下面的逻辑来决定是否要返回一条记录:
//该函数如果由READ_USN_JOURNAL_DATA结构的ReasonMask成员来指定过滤标准,会返回真
bool ReturnRecord(PREAD_USN_JOURNAL_DATA prujd,PUSN_RECORD preacord)
{
if( prujd->ReasonMask & precord->Reason )
{
return 1; //用户想要这个记录
}
return 0;//跳过
}
ReturnOnlyOnClose是另一个你可以用来执行过滤的成员。如果非0,只有带有USN_REASON_CLOSE的记录可以返回。它和ReasonMask协同工作(两种条件都得满足)。只想拿回关闭记录,设置ReasonMask为你感兴趣的原因码,ReturnOnlyOnClose设为1。那么系统就只返回关闭记录、关闭记录带有若干个由ReasonMask指定的原因码的,等等这些。ReturnRecord函数像这样,
Figure 8:
//该函数在遇到由READ_USN_JOURNAL_DATA结构里的ReasonMask和ReturnOnlyOnClose指定的过滤标准时返回真
Bool ReturnRecord(PREAD_USN_JOURNAL_DATA prujd,PUSN_RECORD precord)
{
bool bReturnToUser = false;
//测ReasonMask
//用户只对那些带有由ReasonMask指定的一个或多个码之记录感兴趣
if ((prujd->ReasonMask & precord->Reason) != 0)
bReturnToUser = TRUE;
//测ReturnOnlyOnClose
//若我们通过了上个if选择,
//并且,用户只想要带有USN_REASON_CLOSE,
//并且,记录中没有关闭码,设置bReturnToUser为FALSE
if (bReturnToUser && prujd->ReturnOnlyOnClose && ( (precord->Reason & USN_REASON_CLOSE)==0 ) )
bReturnToUser = FALSE;
return bReturnToUser;
}
超时,等待字节(Timeout and BytesToWaitFor)
Timeout 和 BytesToWaitFor协同使用。它并不能保证DeviceIoControl函数在指定的timeout值后返回,而是指定系统多长时间检查请求的数据是否可用。此成员并不像常规的win32 API的timeout参数那样是以毫秒为单位,**它和win3的FILETIME结构使用相同的级别(内部100纳秒,每秒千万级)。值为0时指定无超时(无限的)。使用负值来指定固定超时(即使参数类型是无符号)。举个例子,25秒的超时可以这样来写(DWORDLONG)(-2500000000)。如果DeviceIoControl函数异步调用,该参数忽略之。
不要把 BytesToWaitFor 和输出缓冲区的大小或者DeviceIoControl函数返回字节数混为一谈。若该值设为0,函数会立即返回,即使发现日志里没有匹配的信息。如果该值非0,系统在发现至少有一条记录后才返回,BytesToWaitFor指定了系统多久重新检查日志是否有匹配的记录创建。举个例子,如果你指定16384,系统只会在一个新的16KB(16384字节)原始数据块添加后才向日志检查新纪录。如果Timeout和BytesToWaitFor都是非0,系统会在日志添加够了指定数量字节之前去检查记录,其是否超时时间到期。
如果BytesToWaitFor非零,但发现的记录匹配用户的请求,DeviceIoControl会立刻返回;BytesToWaitFor 和 TimeOut 只在没有现有记录符合ReasonMask/ReturnOnlyOnClose的要求时,发挥作用。
usn日志ID(UsnJournalID)
UsnJournalID应该设置为ujd.UsnJournalID。如果激活状态日志的日志ID被系统改变了,DeviceIoControl的调用就会失败。这样会保护应用程序使其免读到数据丢失的日志记录。
FSCTL_READ_USN_JOURNAL的意义是把若干个匹配ReasonMask 和 ReturnOnClose指定的标准的记录数组填充到输出缓冲区。没有办法知道会有多少个记录匹配,所以系统会尽可能给输出缓冲区多的填。函数的表现取决于记录的数量,输出缓冲区的大小,还有BytesToWaitFor和Timeout这两个值。由lpOutBuffer和nOutBufferSize指定的输出缓冲区必须至少有USN大小的字节长,与32位边界对齐;否则DeviceIoControl就失败了。如果函数成功了,lpOutBuffer会被填充进一个USN结构列的首个单位字节,随后是若干个记录数组。想看这个调用后的结果,参见Figure 9.输出缓冲区的布局。
DeviceIoControl(hcj,FSCTL_READ_USN_JOURNAL,&InBuf,sizeof(InBuf),pOut,cbOut,&cbReturned,NULL);
Figure 9:
返回的缓冲区首先是指向下一条USN记录,它接着上一次返回的记录,这个在不知道多大空间需求时,对于遍历记录很有用。用这个USN作为下一次调用DeviceIoControl(FSCTL_READ_USN_JOURNAL码)的StartUsn参数, Figure 10展示了怎样获得两个USN之间所有的数据,还有在输出缓冲区里怎样遍历记录:
//
void GetRawRecordData(HANDLE hcj,DWORDLONG journalId,USN usnStart,USN usnEnd)
{
READ_USN_JOURNAL_DATA rujd;
rujd.StartUsn = usnStart;
rujd.ReasonMask = 0xFFFFFFFF; //所有位
rujd.ReturnOnlyOnClose = FALSE; //所有条目
rujd.Timeout = 0;
rujd.BytesToWaitFor = 0;//如果没有记录不会等待
rujd.UsnJournalID = journalId;//用户想要的记录
while(rujd.StartUsn < usnEnd)
{
DWORD cbRead;
BYTE pData[8192 + sizeof(USN)];//读取8kb大小的块
bool fOk = DeviceIoControl(hcj,FSCTL_READ_USN_JOURNAL,&rujd,sizeof(rujd),pData,sizeof(pData),&cbRead,NULL);
if( !fOk )
break; //句柄错误
//得到下一次所需的头一个USN
rujd.StartUsn = *((PUSN)pData);
PUSN_RECORD pRecord = (PUSN_RECORD)&pData[sizeof(USN)];
while( (PBYTE)pRecord < (pData + cbRead) )
{
//
pRecord = (PUSN_RECORD)((PBYTE)pRecord + pRecord->RecordLength);
}
}
}
上述代码用来读取已知存在于日志里的记录。usnStart和usnEnd参数应该等于或位于FSCTL_QUERY_USN_JOURNAL确定的StartUsn和NextUsn之间。
例子程序
例子应用CJDump(文章开头下载代码)使用了我们讨论的每一知识点。倾倒了更改日志的所有内容。因为每一个卷都有自己的更改日志,CJDump只是使用了当前卷,这是一个控制台程序,所有的工作都在主函数里。
首先要做的是打印出 FSCTL_QUERY_USN_JOURNAL 码返回的信息。CJDump会读取所有可用的记录,打印出USN,原因码,每条记录的文件名。CJDump可以容易的修改为显示USN_RECORD结构里其他成员的内容,可以在调试器里看这些信息。
**既然你想看看激活的更改日志的卷驱动器,使用一台带有Indexing service服务启动的机器。这个服务会让你执行全文本的搜素硬盘上所有文档。它使用了NTFS卷上的更改日志来监视,文档的创建、移动、删除等等。这项服务在各种Windows 2000版本以上都有。
接下来是什么
在我们下一篇文章里,我们要创建一个多功能的更改日志程序。涉及到激活和禁止更改日志,怎么从日志变化里接收到通知,还有怎么使用记录的信息来维护在硬盘里的精确的文件和目录数据库。还有,展示应用程序在关闭时和下一次启动发现日志变化的时候把信息存到磁盘中。还会展示把文件引用数转化成文件绝对路径,通过使用更改日志本身,维护一个磁盘所有目录的数据库。
曾如你所见,更改日志在不必话费代价的重扫,提供给应用程序强大的能力来监视NTFS卷里的变化信息。像病毒查杀、搜索引擎、备份数据软件等都从中受益。或许它会鼓励开发出新的、我们从未想到的应用新类型。希望这些都对你有用,做出你自己的杀手级应用。