开机预读这个功能是Windows Xp的之后加入的目的是为了加快开机速度改善用户体验,微软不光对技术,对产品方面也是非常用心的。为什么预读就能改加快开机速度呢?其实一个开机过程中所做的操作就是将需要的数据读入内存中并初始化环境,没有太大量的CPU运算属于IO密集性的过程。其中硬盘(机械盘)操作是最浪费时间的(有人说过访问硬盘的时间相对于访问内存的时间可以认为是无限大,单看磁盘的寻道,数据的读取都是非常可观的),这也是为什么换一个SSD,开机时间立刻减半。
假想一下,我们同步的读取数据并用来初始化环境,数据要读半天这不是耽误事吗?而且数据都分布在硬盘各个地方,读的操作也是上万次。如果我们事先把这些数据用异步IO把它们都读出来,等到我们真正去读的时候发现它们已经在缓存里了,这样能节省多少等待的时间。
Windows的预读过程的实现在WRK里是没开源的,只有零星几个数据结构声明和函数的调用。我们不得不用IDA和Windbg结合着来逆向分析ntoskrnl.exe。
在Windows启动的中期即初始化完成BOOT驱动后会创建一个线程进行并发的预读,我们用户直观感觉是XP滚动条刚开始滚,这个过程发生在IoInitSystem函数中,在WRK里面可以看到显示的函数调用(函数实现就没公开了):
CcPfBeginBootPhase会创建新线程去执行CcPfBootWorker,它来负责整个预读取的任务。注:此时文件系统都已初始化完毕。推荐大家看一下潘爱民老师的《Windows内核原理与实现》2.6章节,看完会对Windows的启动过程有一个整体的概念和把握。
下面一起来看一下它是怎么预读的,汇编就不列出来了太装B了,文字会好点:
停!有想法的同学有疑问了,你怎么知道要预读哪些数据呢?操作系统在上一次开机的整个过程中,如果发现缺页中断,就把它偏移与对应的文件 记录下来,记录到C:\Windows\NTOSBOOT-XXXX.pf文件里。
网上有下载的简单PF文件查看器: WinPrefetchView.
我们继续,
---------------------------------------------
Pf文件格式:
文件头:
0x0 | DWORD | Version | Pf版本信息 | Winxp :0x11,Win7,VISTA:0x17 |
0x4 | DWORD | Magic | 魔数标识 | 恒值0x41434353 ('SCCA') |
0x8 | DWORD | Version2 | 版本信息 | Winxp为0xF,Win7为0x11 |
0xC | DWORD | pfLength | PF文件名长度 | |
0x10 | PWCHAR | pfName | PF文件名 | 最长30个字符(含0) |
0x4C | DWORD | Hash | 文件名的HASH | 和真实文件名是对应的 |
0x50 | DWORD | IsBoot | 是否是系统启动pf | NTOSBOOT.PF为1 |
0x54 | DWORD | offsetSum | Sum表的相对文件偏移 | 记录着预读取项的综合文件描述 |
0x58 | DWORD | SumEntryCount | Sum中的文件个数 | 即PF中预读取文件个数 |
0x5C | DWORD | offsetPages | 表Pages的相对文件偏移 | 记录着预读取文件页信息 |
0x60 | DWORD | PagesEntryCount | 整个PF文件中读取页的数目 | 预读取不是整个文件而是某些页面 |
0x64 | DWORD | offsetFileNames | 文件名表的文件偏移 | 记录着预读取文件名字 |
0x68 | DWORD | LenFileNames | 文件名表的长度 | |
0x6C | DWORD | offsetDirs | 元数据表(卷,目录)的文件移 | 记录着预读取文件可能经过的路径信息 |
0x70 | DWORD | DirsEntryCount | 表中大类的个数 | 说白了就是卷的个数 |
0x74 | DWORD | LengthDirs | 元数据表的长度 | |
0x78 | FILETIME | ftLastExecuteTime | 最近一次执行时间 | |
0x80 | DWORD[4] | Observed3 | 没有用到 | |
0x90 | DWORD | ExecutionCount | 执行次数 | |
0x94 | DWORD | Count | 执行循环内次数(1-7) | 应该是最近没重新生成期间的次数 |
汇总表项结构说明(以PF里面预读文件为单位):
0x0 | DWORD | PagesIndex | 在PF页表中的索引 | 此文件要预读哪些页?这是第一个页的索引 |
0x4 | DWORD | PreCountPages | 页表中此文件的页数 | 在Pages表中,相关此文件的页的数目 |
0x8 | DWORD | offsetFileName | 标识文件名 | 在文件名表中的偏移 |
0xC | DWORD | FileNameLen | 文件名长度 | 以WCHAR为单位 |
0xD | DWORD | FileFlag | 文件标识 | 只使用低两位:00-数据,预读;01-数据,不预读;10-镜像(IMAGE),预读;11-镜像,不预读。 |
文件页表项结构说明:
0x0 | DWORD | nIndex | 索引号 | 从0开始,-1代表当前文件结束,但也占一索引数 |
0x4 | DWORD | offset | 预读偏移 | 标记预读文件中的哪些页面需要读取 |
0x8 | DWORD | PageFlag | 页属性 | 只使用低3位,4可执行,2读写,1不能访问即不预读 |
文件名表项(没有索引,长度不定):
WCHAR[1]…..
元数据头结构说明(以下偏移相对元数据头):
0x0 | DWORD | offsetVolume | 卷名的偏移 | UNICODE C字符串 |
0x4 | DWORD | LenVolumeName | 卷名的长度 | 单位是WCHAR |
0x8 | FTIME | FileTime | 文件时间 | 应该是$MFT的时间? |
0x10 | DWORD | VolumeSerial | 卷序列号 | 未使用 |
0x14 | DWORD | offsetHardDisk | 卷预读块偏移 | $MFT预读取,私有接口由NTFS来实现。入口:ZwFsControlFile |
0x18 | DWORD | LenofHardDisk | 卷预读块的长度 | FSCTL_PERFETCH_FILE |
0x1C | DWORD | offsetDir | 目录集合的偏移 | 一堆的目录名字 |
0x20 | DWORD | DirCount | 目录个数 | |
0x24 | DWORD | Reserved | 未使用 |
HardDisk块格式:
Struct HD {
DWORD Version;
DWORD dwCount;
CHAR [8][1] data;
};
前两个是版本和数量,这个应该是ZwFSControlFile对 FSCTL_PREFETCH_FILE的格式要求。Data是什么我也没搞清,只知道在底层会用位移把它解析成相对$MFT的偏移。
Dir块格式:
Struct DirName {
DWORD dwLength;
WCHAR FileName[];
};
以上文件格式也是通过逆向工程得到的。
------------------------------------------------------------------------
首先它会打开NTOSBOOT-XXXX.pf文件,把文件内容读取出来。根据标识,和各个表的大小偏移做安全校验。这一点WINDOWS对文件格式化校验的严格。
1. 针对$MFT进行预读取,取出元数据表把里面的数据(卷预读块)分段调用ZwFsControlFile,参数是FSCTL_PERFETCH_FILE。由NTFS来把这些数据解析成相对$MFT的一个个偏移,做预读取最终调用到MmPrefetchPages(这个函数最后还会调用到NtfsFSDRead)。
2. 针对文件系统目录项预读,把元数据表里,的预读取目录遍历一下用NtQueryDirectoryFile+FileNameInformation,读一下。
3. 解析汇总表,进行预读。把Pages表里的一个个偏移和上面一样组装成MmPrefetchPages可以只用的参数:
typedef struct _READ_LIST {
PFILE_OBJECT FileObject;
ULONG NumberOfEntries;
LOGICAL IsImage;
FILE_SEGMENT_ELEMENT List[ANYSIZE_ARRAY];
} READ_LIST, *PREAD_LIST;(来自WRK)
4. 由于MmPrefetchPages参数 READ_LIST.IsImage的限制,预读是分了两到三个阶段。先读镜像文件内存,再读数据文件内存和镜像文件里的数据内存。
这个是具体Windows的实现步骤,可能觉得会很苦涩,不知道它搞毛~。我们来一一对应着了解一下:
对$MFT的预读,只读文件数据就可以了和$MFT有什么关系呢?MFT是NTFS文件系统中最重要的一个内容,它记录着每个文件除数据以外的信息(小文件的数据也在MFT里)。
再假想一下,要读取一个文件C:\Windows\System32\ntdll.dll的内容怎么读?打开C盘吧,得到根目录吧,遍历出Windows目录,从Windows目录项中遍历出System32目录,继续,最后找到ntdll.dll这个文件的MFT项,然后算出偏移进行读取。
那么上面前两步就是把MFT和目录项里面的内容都先读出来,第一步调用的是公开的接口不公开的参数。翻了一下FAT32的代码它没有处理FSCTL_PREFETCH_FILE请求,可知这一步对FAT32是没用的。第二步是文档化的函数,可以把目录项里面的内容让NTFS先加到自己的缓存里。第三步就是正经的把数据读出来了,系统把pf文件中记录的一个个偏移和对应的文件对象放在一起调用MmPrefetchPages,由于很多偏移是连续的,这个函数会把这些偏移组装合并一下,一个异步IO读取就把它们都读出来了(具体就是发了个异步IRP包并设置一事件给了文件系统,文件系统继续下发)。
最后从代码上面可以看到微软做的很用心的一点是:在预读的时候它会把着内存的使用量,分阶段读出来的,这个阶段是有一定的优先级的,肯定重要的先预读。由于预读和其它的开机操作是并行的,所以不能出内存峰值不够用的情况。一个循环里不断的与MmAvailablePages比较,所以选择合适的量一批一批的把数据读出来。操作系统还会定期根据统计信息整理pf文件里面的内容把不需要的淘汰掉。
这样预读取线程就完成自己的任务了,这里讲的是操作系统启动预读,另外还有注册表预读和应用程序预读他们都是分开的,不过原理是一样的。