解析TopLevelIrp

何为TopLevelIrp

TopLevelIrp是当前内核线程结构中的一个指针,可以是一个数值标识(ULONG_PTR),也可以是指向一个内存块的指针(内核IRP结构或用户自定义结构),其目的就是给文件系统驱动记录当前线程其内核栈最早传入且正在处理的I/O请求。因为处理一个I/O请求的过程中可能引入新的I/O,这样就会导致文件系统的重入(嵌套调用),重入问题很有可能会带来对资源依赖的死锁,所以文件系统驱动需要在重入的情况也能甄别出当前正处理的请求是不是最早的,即当前线程内核栈及资源的占用情况,从而采取不同的策略以避免死锁的发生。在时间顺序上最早的请求,从调用栈上来看就是处于最高地址(最高位置)的请求。

TopLevelIrp在内核线程结构中的定义如下:

typedef struct _ETHREAD {
    KTHREAD Tcb;
    ...
    //
    //  File Systems
    //

    ULONG_PTR TopLevelIrp;  // either NULL, an Irp or a flag defined in FsRtl.h
    struct _DEVICE_OBJECT *DeviceToVerify;
    ...
} ETHREAD, *PETHREAD;

TopLevelIrp及DevcieToVerify等域均是留给文件系统驱动专用的,可以作为一个当前线程的环境变量或各层调用栈间的共享变量。

系统操作函数

ETHREAD是内核私有结构,系统内核提供给外部组件(主要是文件系统驱动)两个接口:

函数 功能
VOID IoSetTopLevelIrp(IN PIRP Irp); 设置(改变)当前线程的TopLevelIrp值
PIRP IoGetTopLevelIrp(VOID); 读取当前线程的TopLevelIrp值

TopLevelIrp的操作过程

文件系统驱动与操作系统内核组件:Cache Manager(缓存子系统)、Virtual Memory Manager(虚拟内存管理子系统)及I/O Manager (I/O子系统)间交互相非常紧密且调用关系也非常复杂,调用嵌套及重入的情形非常多,分辨并正确处理不同的重入情景正是文件系统驱动不得不面对的复杂难题。

TopLevelIrp就是为了解决这个问题而设计的。其最直接的操纵及访问者也是文件系统驱动,因为用户层I/O请求最初的响应都是由I/O Manager直接传给文件系统的。文件系统驱动在响应过程中会调用操作系统的其它子系统,在调用前会针对TopLevelIrp做不同的设置。另外还有几种常见情况,如Cache Manager及Virtual Memory Manager会根据内存压力来发起I/O操作,所以内核子系统也需要修改、设置TopLevelIrp域以标识出本次I/O操作的发起者。

IoGetTopLevelIrp()返回值 (当前线程的TopLevelIrp域) 含义
FSRTL_CACHE_TOP_LEVEL_IRP 由缓存管理器触发的i/o请求:延迟写(Lazy-write)或预读(Readahead)
FSRTL_MOD_WRITE_TOP_LEVEL_IRP 由系统脏页回写线程触发的写请求(在MiMappedPageWriter回写映射文件的脏页时)
FSRTL_FAST_IO_TOP_LEVEL_IRP 由I/O Manager及文件系统自身处理Fast i/o请求时设置
FSRTL_FSP_TOP_LEVEL_IRP 通常由文件系统本身触发,用以标识文件系统驱动自身的i/o处理线程(Workitem线程池)。由于此情况下TopLevelIrp完全由文件系统自已管理,IRP或私有结构,通常由文件系统自已构造并使用

操作场景

  1. 用户态切换到内核态 这是最通常的情形,即请求直接来自于用户,用户请求经过syscall之后到达I/O Manager,而后I/O Manager会构造Irp并直接传给文件系统。 文件系统最通常的做法就是将当前的Irp设为TopLevelIrp。文件系统当然也可以用自己私有的结构,因为此线程在Irp没有完成之前将完全由文件系统所控制,TopLevelIrp也只有文件系统驱动会查询。
  2. Fast I/O 用户态的请求通过syscall到达I/O Manager后,I/O Manager发现文件已经被打开过且可以通过文件系统驱动所提供的callback直接来处理这个请求,不必从内存池中分配Irp等结构。 文件系统被调用后又会调用内核所提供的文件系统支持函数(FsRtl...),此操作将有可能涉及Cache Manager、VM,或重入文件系统驱动等,所以文件系统支持函数会设置TopLevelIrp为FSRTL_FAST_IO_TOP_LEVEL_IRP以标示出最初的请求响应是通过Fast I/O来做的。
  3. 文件系统自身的Workitem线程 类似第一种情形(用户态切换到内核态),Workitem线程本身是由文件系统发起并且是文件系统所私有的内核线程,但Workitem中的操作却是有可能重入文件系统的(这个由文件系统的设计决定)。 TopLevelIrp的值可以由文件系统驱动自行决定,如可以设为FSRTL_FSP_TOP_LEVEL_IRP,也可以设为此Workitem当前要处理的请求Irp,也可以设成是文件系统驱动的私有结构。
  4. 缓存管理器:延迟写 Cache Manager的Lazy-write操作(延迟写)是在Cache Manager私有的内核线程中执行的。在调用文件系统进行I/O操作之前会设置TopLevelIrp为FSRTL_CACHE_TOP_LEVEL_IRP,因为Lazy-write操作会通过Cache Manager callback来获取相应的锁资源,并且最终的I/O操作总要由文件系统来完成。
  5. 虚拟内存管理器:MiMappedPageWriter 内存映射文件的I/O不会经过Cache Manager,而是由Virtual Memory Manager直接处理的,并且是在Virtual Memory Manager私有的内核线程中做的,此线程的处理函数就是MiMappedPageWriter。同Cache Manager延迟写的流程类似,最终的I/O操作总要由文件系统来完成,所以移交控制权之前,VM会将当前线程的TopLevelIrp设置为FSRTL_MOD_WRITE_TOP_LEVEL_IRP,以通知文件系统驱动当前请求是由VM发起的。

不同文件系统驱动的处理差异

前面说过TopLevelIrp的读操作及检测只有文件系统驱支在做,虽然其设置可以是多方的。对于文件系统驱动来说,TopLevelIrp的具体值是由文件系统自己来解释的,所以针对 FSRTL_FSP_TOP_LEVEL_IRP,不同的文件驱动对TopLevelIrp的处理也不尽相同:

文件系统驱动 TopLevelIrp所指向的结构
FastFat nt!_IRP
Cdfs cdfs!_THREAD_CONTEXT
Ntfs ntfs!_TOP_LEVEL_CONTEXT

案例分析

TopLevelIrp的设置是一项精巧的手艺活,稍有不慎就会阴沟里翻船,下面就介绍两个开发中曾遇到的案例:

NTFS:Valid Data Length的设置

Ntfs文件系统驱动在其内部维护着每个数据流的Valid Data Length(VDL,有效数据长度),且VDL是管理于Ntfs的私有结构中的。VDL的更新一般是在cached i/o过程中做的。但实际操作中我们有个特殊的需求:要截获所有的cached i/o请求,只会通过paging i/o请求和Ntfs文件系统驱动交与,这样操作的后果就导致VDL不能被正常更新,虽然数据已经写到了磁盘上但是当试图读回数据的时候,Ntfs会根据VDL对数据进行清零化处理。

最后的解决办法也是一个不得已的投巧的办法:将我们的paging writing 请求模拟成mmap i/o (memory mapping i/o),即映射文件的数据刷写方式。mmap i/o操作本身就是个top level操作,并且也是仅有的top level paging i/o的情形。所以针对mmap i/o,Ntfs会更新VDL,这也是唯一的可以更新VDL的机会。

CDFS: 换了光盘但不能正确加载

这个问题比较妖怪,本来以为是文件系统过滤驱动的问题,但仔细分析过滤驱动又没有发现可疑点,最后经过代码对比及对WDK中cdfs的动态调试才定位到问题症结:原来是top level irp的问题。

我们都知道文件系统对于可移动设备及可移除介质设备都会有个检验(即verify)操作,但cdfs的verify操作会检测当前的TopLevelIrp状态,并根据这个状态来决定是否继续verify操作。当我们设置了TopLevelIrp之后,cdfs会认为它不在最高的调用栈上,故直接跳过了verify操作,最终就导致了换光盘后新光盘无法被正常识别的问题。

参考链接

  1. TopLevelIrp是如何设置的 - blog: Oops Kernel
  2. More on Maintaining Valid Data Length - OSR

<本文用时约5小时>

ERESOURCE加锁函数

最常用的加锁函数只有两个:

  • ExAcquireResourceExclusiveLite:试图获取排它锁
  • ExAcquireResourceSharedLite:试图获取共享锁

除此之外,针对共享锁的获取,还有另外两个函数:

  • ExAcquireSharedStarveExclusive:可以早于等待中的排它请求者
  • ExAcquireSharedWaitForExclusive:不能早于排它的请求者

二者都是试图获取共享锁,和ExAcquireResourceSharedLite只是有些微的小差别。但这两个函数使用的并不多,只限于少见的特殊情况:

ERESOURCE本身是可以重入的,如果当前线程已经获得此锁的话,若再次试图获取,ExAcquireResourceSharedLite将会直接准予此请求,但ExAcquireSharedWaitForExclusive则不会,一定要等到此锁等待列表中所有的排它锁的请求者完成之后才会准予当前调用者。当然如果当前线程之前所获得的ERESOURCE锁是排它锁的话,将会直接准予通行,不然会导致死锁发生。

ExAcquireSharedStarveExclusiveExAcquireResourceSharedLite的差别只在一点:若已是共享锁状态,ExAcquireSharedStarveExclusive将忽略排它锁等待列表中的所有请求者,直接准予当前线程,以尽可能的减少当前线程的等待时间。

KEVENT,ERESOURCE及Thread Boost

在唤醒KEVENT时,函数KeSetEvent的第二个参数(KPRIORITY Increment)是用来临时提高被唤醒线程的调试优先级的,此举可以让睡眠在此KEVENT上的线程尽快得到处理以提高系统整体性能。

同样ERESOURCE也有类似的机制,在放锁时也会调高等待列表中处于饥饿状态的线程的优先级。

不过除此之外,ERESOURCE还有一种反向的Boost机制,即让等待者在试图获取锁时调高此锁当前拥有者线程的调度优先级,以加快属主线程的处理并尽可能早的释放锁资源。

反向操作却有可能遭遇这样一个问题:作为锁拥有者的线程未必是同步的,很有可能在获取ERESOURCE锁之后就因为异步处理事件(如Workitem,异步I/O请求)在不释放锁的情况下就退出(锁的释放将由异步事件来做),在此情况下ERESOURCE的属主线程指针就变成非法的了。为此Windows内核提供了一种机制来阻止问题的发生:线程等结构体一般都是至少8字节对齐的(最后3位均为0),通过将最后两位设为1的方式(|0x03)来甄别此指针数据是用户自己所指定的还是默认的系统线程,以此来避免访问已经释放的内存。

从Win7之后,FastFat及Ntfs都有对ERESOURCE的OwnerThread的处理,主要是异步的Noncached i/o事件的处理,其目的就是处理线程在获取ERESOURCE之后退出的情况。

FastMutex的陷阱

相对ERESOURCE来说,FastMutex的使用比较方便,不用考虑禁APC问题,也不用考虑FastMutex结构体的释放,用完即可以三不管。对于竞争不激烈且读写不明显的情况下,用FastMutex还是不错的。

不过使用过程中要注意其隐藏的陷阱:

1,ExAcquireFastMutex 会提升IRQL至APC_LEVEL,且保存原来的IRQL用以在ExReleaseFastMutex时恢复。所以在嵌套获取FastMutex时要特别注意放锁一定要依加锁顺序反向操作,不然会导致IRQL紊乱。为此系统特别提供了Unsafe操作函数(ExAcquireFastMutexUnsafe及ExReleaseFastMutexUnsafe),这两个函数不会操作IRQL,但使用上却有前提条件。

2,FastMutex的实现是通过原子锁与Gate对象来实现的,在没有竞争的情况下,通过原子锁可以速实现加锁及解锁,此外Gate对象是不可重入的,不像Event等(Dispatch Object)及ERESOURCE等可重入的锁(同一个线程可以同时加锁多次不会死锁)。

至于FastMutex为什么要提升IRQL至APC_LEVEL的原因,和文件系统调用FsRtlEnterFileSystem禁APC的原因是一样的,直接目的是防止当前线程被挂起(Suspend)。设想在没有禁止APC的情况下调用ExAcquireFastMutexUnsafe加锁之后线程随时可能被挂起,一旦被挂起的话系统中试图获取该锁的其它线程则只能等待,不管此线程是否高优先级或关键的系统任务。同理可以理解Event,ERESOURCE等为什么也需要禁止APC的原因。

除了FastMutex之外,Windows内核还实现了另外两种Mutex: Regular Mutex及Guarded Mutex。不过从Windows 8开始,GuardedMutext和FastMutex的实现是一样的,二者并无差别。Regular Mutex通过KeInitializeMutex初始化,加锁及放锁是通过KeWaitForXXXObject及KeReleaseMutex来操作的。我们知道 Event也是通过KeWaitForXXXObject来加锁的,但是Regular Mutex的加锁操作却会禁止当前线程的Kernel APC,Event却不会,所以在操作Event之前要显示地进入临界区或禁止APC操作以防止可能的死锁发生。

需要注意的是禁止Kernel APC并不会影响i/o操作,但是FastMutex及GauardedMutex不止是禁止Kernel APC,还会禁止Special APC,这样的话i/o操作是不允许的,因为i/o完成例程是在Special APC中做的。这些细微差别在使用中必须考虑到。

文件系统开发之特殊文件links篇(上)

链接(links)是文件系统最常用的一类特殊文件,一般分为两类:

  • 硬链接(hardlink): 硬链接和普通文件(或目录)实际并没有差别,只是它和另外的文件共用一个文件数据体,反过来说就是,同一个文件体可以有多个名字,而且这些名字可以在卷内的任意目录中。类似于同一座房子,却有着不同的门牌号。 硬链接必须在同一卷上,就像门牌号必须附着于房子实体上一样。 Linux文件系统Ext3允许针对目录和文件的硬链接的创建,但Windows文件系统NTFS只允许硬链接的创建于文件之上。造成此差别的原因在于文件系统的实现上的不同,Linux Ext3文件系统对dentry(文件名)和inode(文件数据)的管理是分开的,但对NTFS却不是,Fcb是dentry和inode的合体,二者分不开,这部分以后再详述。
  • 软链接(softlink),同时又有符号链接(symbolic link, symlink)之称。符号链接不同于硬链接,它是一类特殊文件,就像是“邮政信箱”,它只是个指向一个门牌号的别名而已,门牌号可以代表一座房子的实体,但“邮政信箱”并不能。 软链接是可以跨卷的,即软链接自身位置和其目标位置,差个108000里也没关系。 Windows NTFS对符号链接的支持是从Vista才开始的,就是说XP系统并不支持。

对于Windows系统,还有另外两种比较特殊的“链接”:

  • Junction: Junction其实是一种符号链接,只是在Win2k或XP系统上符号链接并没有实现的情况下的一个临时替代品。Junction只支持目录链接,不支持文件。Junction是可以跨卷的,因为它本质上符号链接。
  • Reparse Point:Reparse Point最早的作为Mount Point(卷加载点)来引入的,但其功能却相当强大,比如上面提到的Symlink/Junction的实现其实都是基本Reparse Point的,再者NTFS的高级特性如Hierarchical Storage,也是通过Reparse Point来实现的。 对Linux Ext3来说,任意子目录都可以是Mount Point。对Windows文件系统NTFS来说,虽然任意子目录也可以作Mount Point,但一旦成为Mount Point,其子目录便转换为了Reparse Point属性,也正是这个实现上的差别导致了Windows系统在链接的操作上出现了不少怪现象。

后面会进一步针对Linux Ext3及Windows NTFS内部对链接的实现进行介绍和分析。

Windows的快捷方式(Shortcut)并不在此文的讨论之列,因为它是Shell层(即Windows Explorer)的行为,并非文件系统本身的特性。

蓝屏,谁之过?

Bug总能在你意想不到的地方给你个措手不及,只是它所带来并不是惊喜,而是Blue Screen Of Death !

既如此,只能兵来将挡。

先介绍一下程序的大体流程:

NTSTATUS
XXXProcessDirents(…)
{    
    do {
        KeEnterCriticalRegion();
        ExAcquireResourceSharedLite(&amp;fcb-&gt;Resource, TRUE);

        /* access several members of fcb structure */
        ExReleaseResourceLite(&amp;fcb-&gt;Resource);
        KeLeaveCriticalRegion();

         XXXXProcessDirent(…);

    } while (list_is_not_empty(….));

    return status;
}

NTSTATUS
XXXXProcessDirent(…)
{
    HANDLE handle = NULL;
    XXXX_FILE_HEADE fileHead;
    ……

    /* open file */
    status = ZwCreateFile(&handle, GENERIC_READ, &oa, &iosb, NULL, 0,
                          FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
                          FILE_OPEN, 0, NULL, 0);

    /* read file header*/
    status = ZwReadFile(handle, ioevent, NULL, NULL, &amp;iosb, (PVOID)&fileHead,
                        sizeof(XXXX_FILE_HEADE), &offset, NULL);

    /* check whether file is interesting to us */
    if (status == STATUS_SUCCESS && iosb.Information == sizeof(……)) {
        /* it’s my taste, haha */
    }

    /* close file, not interested in it any more */

    if (handle){
        ZwClose(handle);
    }

    return status;
}

过程比较简单,XXXProcessDirents()会循环调用XXXProcessDirent(),直至列表中所有项全检查完毕。

下面再来看windbg分析吧:

1: kd> !analyze -v
*******************************************************************************
*                                                                             *
*                        Bugcheck Analysis                                    *
*                                                                             *
*******************************************************************************

IRQL_NOT_LESS_OR_EQUAL (a)
An attempt was made to access a pageable (or completely invalid) address at an
interrupt request level (IRQL) that is too high.  This is usually
caused by drivers using improper addresses.
If a kernel debugger is available get the stack backtrace.
Arguments:
Arg1: 0abc9867, memory referenced
Arg2: 00000002, IRQL
Arg3: 00000001, bitfield :
bit 0 : value 0 = read operation, 1 = write operation
bit 3 : value 0 = not an execute operation, 1 = execute operation (only on chips which support this level of status)
Arg4: 806e7a2a, address which referenced memory

Debugging Details:
------------------

WRITE_ADDRESS:  0abc9867

CURRENT_IRQL:  2

FAULTING_IP:
hal!KeAcquireInStackQueuedSpinLock+3a
806e7a2a 8902            mov     dword ptr [edx],eax

DEFAULT_BUCKET_ID:  DRIVER_FAULT

BUGCHECK_STR:  0xA

PROCESS_NAME:  System

TRAP_FRAME:  b9019bbc -- (.trap 0xffffffffb9019bbc)
ErrCode = 00000002
eax=b9019c40 ebx=00000000 ecx=c0000211 edx=0abc9867 esi=c0000128 edi=8842d268
eip=806e7a2a esp=b9019c30 ebp=b9019c68 iopl=0         nv up ei ng nz na pe nc
cs=0008  ss=0010  ds=0023  es=0023  fs=0030  gs=0000             efl=00010286
hal!KeAcquireInStackQueuedSpinLock+0x3a:
806e7a2a 8902            mov     dword ptr [edx],eax  ds:0023:0abc9867=????????
Resetting default scope

LAST_CONTROL_TRANSFER:  from 806e7a2a to 80544768

STACK_TEXT:
b9019bbc 806e7a2a badb0d00 0abc9867 804f4e77 nt!KiTrap0E+0x238
b9019c68 806e7ef2 00000000 00000000 b9019c80 hal!KeAcquireInStackQueuedSpinLock+0x3a
b9019c68 b9019d24 00000000 00000000 b9019c80 hal!HalpApcInterrupt+0xc6
WARNING: Frame IP not in any known module. Following frames may be wrong.
b9019cf0 80535873 00000000 8896fb20 00000000 0xb9019d24
b9019d10 b79d87ff ba668a30 8859b7e8 00000440 nt!ExReleaseResourceLite+0x8d
b9019d2c b79d8a5c 8a3ff2f0 00000003 ba6685f0 XXXXX!XXXProcessDirents+0xef
b9019d88 b79e163a e2f6b170 00000001 00000001 XXXXX!XXXKernelQueryDirectory+0x20c
b9019ddc 8054616e b79e1530 88a8ae00 00000000 nt!PspSystemThreadStartup+0x34
00000000 00000000 00000000 00000000 00000000 nt!KiThreadStartup+0x16

问题出在系统函数ExReleaseResourceLite()及KeAcquireInStackQueuedSpinLock()上,且程序要写的地址为0abc9867 ,明显不对,所以此处可做栈损坏推断。

第一嫌疑要考虑的是,XXXProcessDirents()中有锁保护的部分,此部分是果真是最容易造成栈损坏buffer复制操作。但经过仔细检查及测试,便排除了此部分出错的可能。

在排除第一嫌疑后,就没有明显目标了。只好再接着看windbg log:

貌似KeAcquireInStackQueuedSpinLock()要写的地址是LockHandle的LockQueue->Next,而LockHandle一般都在从当前堆栈分配的,由此可肯定之前对于栈损坏的推断。可问题是,是谁导致的栈损坏。

Stack中有hal!HalpApcInterrupt()调用记录,它是处理APC的软中断。hal!HalpApcInterrupt()会一般会调用nt!KiDeliverApc()来处理线程的APC队列。但当ExReleaseResourceLite()调用的时候,线程还处于临界区内(Critical Section),此时User mode APC及Kernel mode normal APC都会被禁止的,但Kernel mode special APC不会。

Kernel Special APC最常见的情况便是由IoCompleteRequest()添加的:在APC Level中调用IopCompleteRequest()以处理Irp的Stage 2的清理工作。

至此,问题终于有些眉目了。分析代码中唯一有可能导致APC添加的地方就在函数XXXXProcessDirent()中的ZwReadFile()调用,而且fileHead正是于堆栈中分配的。

想到此处,此bug的根据原因便付出水面:

XXXXProcessDirent()没有处理ZwReadFile()返回STATUS_PENDING的情况,此情形下,XXXXProcessDirent()退出并继续执行,而之前的ZwReadFile()的IRP完成操作也在同时进行(还没有完成),并且此完成操作所要写的fileHead地址,正是早已被回收并加以重用的当前栈。

搞清楚之后,便在调用ZwReadFile()后,特别针对STATUS_PENING的情况来调用ZwWaitForSingleObject()以确保读操作全部完成后,再进行下一步操作。

到此,问题解决!

一个蓝屏的问题,竟然如此之绕,不禁让我想起刘震云的《一句顶一万句》,只是这能顶一万句的一句到底是哪句呢?

<下一步打算写写APC相关的东西,操作系统将APC隐藏得太深,总让人捉摸不定!>

Pages:  1 2