解析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小时>

Continue reading » · Rating: · Written on: 02-28-17 · 1 Comment »

Windows内核开发陷阱 – NTSTATUS

STATUS_REPARSE不是“失败”

曾经在过滤驱动中想针对REPARSE的情况做特殊处理,实现代码如下:

if (NT_SUCCESS(status)) {
    /* file created */
} else if (STATUS_REPARSE == status) {
    /* to be repared  */
} else {
    ...
}

结果代码运行没达有预期效果,手工调试时才发现代码进入了NT_SUCCESS(status)分支。STATUS_REPARSE竟然表示是“成功”的!

这件事后才真正注意到status的问题,虽然在学习内核及驱动编程的时候书上都有相关介绍,但这些基础章节并没有引起重视就直接跳过了。但是像STATS_REPARSE这样的特例并不是少数,如STATUS_PENDING、STATUS_TIMEOUT等。

NTSTATUS的定义

再回顾一下NTSTATUS的定义: NTSTATUS是一个32位长的有符号整型数,即LONG型。其格式如下:

  3                   2                   1                   0
1 0|9|8|7 6 5 4 3 2 1 0 9 8 7 6|5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0
---+-+-+-----------------------+-------------------------------
S  |C|N|    Facility           |               Code              
---------------------------------------------------------------

各部分的解释如下:

位数 注解
S 2 Severity: 分4个等级(Success, Informational, Warning, Error)
C 1 1:用户自定义的值 0:默认为系统定义(Microsoft)
N 1 保留位,始终为0值
Facility 12 错误类别或子系统的划分
Code 16 具体错误代码

Severity的4个等级的定义如下:

00 - Success
01 - Informational
10 - Warning
11 - Error

NT_XXXX宏分析

引用WDK代码(ntdef.h):

//
// Generic test for success on any status value (non-negative numbers
// indicate success).
//

#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)

//
// Generic test for information on any status value.
//

#define NT_INFORMATION(Status) ((((ULONG)(Status)) >> 30) == 1)

//
// Generic test for warning on any status value.
//

#define NT_WARNING(Status) ((((ULONG)(Status)) >> 30) == 2)

//
// Generic test for error on any status value.
//

#define NT_ERROR(Status) ((((ULONG)(Status)) >> 30) == 3)

由定义可以看出至少两点:

  1. NT_SUCCESS的定义和其它三者是有区别的,并不是根据Severity的值来判断的。NT_SUCCESS(status)其实包含了Severity == 0及Severity == 1的情形。

  2. NT_SUCCESS与NT_ERROR不是单纯的非黑即白的关系。新手常犯的错误就是主观上认为NT_SUCCESS()与!NT_ERROR(),或者NT_ERROR()与!NT_SUCCESS()是等价。

如果想确保一个irp确实是“成功”完成了,就要严格用STATUS_SUCCESS来判断,而不是NT_SUCCESS()。类似地,用NT_ERROR()可以保证代码的执行确实出现了错误,但是NT_WARNING()的情况即被漏掉了。

总之,必要的基本功始终是不可少的,像熟悉开发场景相关的返回值并弄懂这些返回值的确切含义等此类的苦活累活是必须要做且要做好的。

参考链接

  1. [MS-ERREF]: Windows Error Codes
  2. Windows Driver Kit (WDK)

<本文用时2小时,字体及表格调整花时较多。>

Continue reading » · Rating: · Written on: 01-19-17 · No Comments »

Windows内核开发陷阱: Nt vs Zw

在Windows平台上做过开发的人对Native API都不会陌生。但以Nt开头与以Zw开头的Native API到底有没有差别以及都有什么使用场景等一直以来都是让人感觉比较困扰的问题。另外内核态也有同样的一组Nt及Zw开头的内核支持函数,这两者间又是怎么一回事呢,这其中会有会有开发上有陷阱呢?!

我们先来解析用户层,然后研究下内核层其各自的实现:

用户层的Nt vs Zw

对用户层程序来说Nt与Zw开头函数实际上是一样的,只是同一个函数的两个名称而已。比如下面是对ntdll!NtCreateFile及ntdll!ZwCreateFile(Win7 X64)的反汇编:

1: kd> u ntdll!ZwCreateFile
ntdll!NtCreateFile:
00000000`77480400 4c8bd1          mov     r10,rcx
00000000`77480403 b852000000      mov     eax,52h
00000000`77480408 0f05            syscall
00000000`7748040a c3              ret

1: kd> u ntdll!ZwClose
ntdll!NtClose:
00000000`7747ffa0 4c8bd1          mov     r10,rcx
00000000`7747ffa3 b80c000000      mov     eax,0Ch
00000000`7747ffa8 0f05            syscall
00000000`7747ffaa c3              ret

syscall

用户层ntdll!ZwCreateFile或NtCreateFile会通过syscall切换到内核,最早的Windows系统是通过软中断0x2e来完成模式切换的。如何切换以及切换至何处是由CPU的MSR寄存器所决定的,由操作系统在启动时完成的初始化设定。

针对Win7 X64测试系统来说,此处的syscall指令将跳转由MSR_LSTAR所指向的固定地址:

0: kd> rdmsr 0xc0000082
msr[c0000082] = fffff800`03082ec0
0: kd> u fffff800`03082ec0
nt!KiSystemCall64:
fffff800`03082ec0 0f01f8          swapgs
fffff800`03082ec3 654889242...000 mov   qword ptr gs:[10h],rsp
fffff800`03082ecc 65488b242...000 mov   rsp,qword ptr  gs:[1A8h]
fffff800`03082ed5 6a2b            push    2Bh
fffff800`03082ed7 65ff34251...000 push    qword ptr gs:[10h]
fffff800`03082edf 4153            push    r11
fffff800`03082ee1 6a33            push    33h
fffff800`03082ee3 51              push    rcx
......
fffff800`02c7bfce 6690            xchg    ax,ax
fffff800`02c7bfd0 fb              sti
fffff800`02c7bfd1 48898be0010000  mov     qword ptr [rbx+1E0h],rcx
fffff800`02c7bfd8 8983f8010000    mov     dword ptr [rbx+1F8h],eax
nt!KiSystemServiceStart:
fffff800`02c7bfde 4889a3d8010000  mov     qword ptr [rbx+1D8h],rsp
......

nt!KiSystemCall64主要的工作是:

  1. 参数及用户栈的保存(内核态处理完打好返回用户层)
  2. 内核栈切换、内核线程的初始化设定等
  3. 准备工作做好后,最重要的是调用相应的内核服务(比如nt!NtXXX)

内核层的Nt vs Zw

对内核层来说,Zw与Nt函数虽然参数及功能上都是一致的,但二者对参数的处理及对调用环境的认同是不一样的。以nt!NtClose及nt!ZwClose的代码为例(Win7 X64):

1: kd> u nt!NtClose
nt!NtClose [d:\w7rtm\minkernel\ntos\ob\obclose.c @ 430]:
fffff800`0338e2a0 48895c2408      mov     qword ptr [rsp+8],rbx
fffff800`0338e2a5 57              push    rdi
fffff800`0338e2a6 4883ec20        sub     rsp,20h
fffff800`0338e2aa 65488....010000 mov   rax,qword ptr gs:[188h]
fffff800`0338e2b3 48833....e9ff00 cmp     qword ptr [fffff800`03227450],0
fffff800`0338e2bb 488bd9          mov     rbx,rcx
fffff800`0338e2be 0fb6b8f6010000  movzx   edi,byte ptr [rax+1F6h]
fffff800`0338e2c5 0f8520a70200    jne     nt!NtClose+0x2a74b (fffff800`033b89eb)
fffff800`0338e2cb 400fb6d7        movzx   edx,dil
fffff800`0338e2cf 488bcb          mov     rcx,rbx
fffff800`0338e2d2 488b5c2430      mov     rbx,qword ptr [rsp+30h]
fffff800`0338e2d7 4883c420        add     rsp,20h
fffff800`0338e2db 5f              pop     rdi
fffff800`0338e2dc e91ffdffff      jmp     nt!ObpCloseHandle (fffff800`0338e000)

看nt!NtClose没有针对栈做任何准备工作而是直接进入了任务的处理:即调用nt!ObpCloseHandel。类似NtCreateFile也是类似的流程。

下面再来看nt!ZwClose:

1: kd> u nt!ZwClose
nt!ZwClose [o:\w7rtm.obj.amd64fre\minkernel\ntos\ke\mp\objfre\amd64\sysstubs.asm @ 267]:
fffff800`03073640 488bc4          mov     rax,rsp
fffff800`03073643 fa              cli
fffff800`03073644 4883ec10        sub     rsp,10h
fffff800`03073648 50              push    rax
fffff800`03073649 9c              pushfq
fffff800`0307364a 6a10            push    10h
fffff800`0307364c 488d059d300000  lea     rax,[nt!KiServiceLinkage (fffff800`030766f0)]
fffff800`03073653 50              push    rax
fffff800`03073654 b80c000000      mov     eax,0Ch
fffff800`03073659 e9e2670000      jmp     nt!KiServiceInternal (fffff800`03079e40)

1: kd> u nt!KiServiceInternal
nt!KiServiceInternal:
fffff800`02c7be40 4883ec08        sub     rsp,8
fffff800`02c7be44 55              push    rbp
fffff800`02c7be45 4881ec58010000  sub     rsp,158h
fffff800`02c7be4c 488da...000000  lea     rbp,[rsp+80h]
fffff800`02c7be54 48899dc0000000  mov     qword ptr [rbp+0C0h],rbx
fffff800`02c7be5b 4889bdc8000000  mov     qword ptr [rbp+0C8h],rdi
fffff800`02c7be62 4889b5d0000000  mov     qword ptr [rbp+0D0h],rsi
fffff800`02c7be69 fb              sti
fffff800`02c7be6a 65488b1c2588010000 mov   rbx,qword ptr gs:[188h]
fffff800`02c7be73 0f0d8bd8010000  prefetchw [rbx+1D8h]
fffff800`02c7be7a 0fb6bbf6010000  movzx   edi,byte ptr [rbx+1F6h]
fffff800`02c7be81 40887da8        mov     byte ptr [rbp-58h],dil
fffff800`02c7be85 c683f601000000  mov     byte ptr [rbx+1F6h],0
fffff800`02c7be8c 4c8b93d8010000  mov     r10,qword ptr [rbx+1D8h]
fffff800`02c7be93 4c8995b8000000  mov     qword ptr [rbp+0B8h],r10
fffff800`02c7be9a 4c8d1d3d010000  lea     r11,[nt!KiSystemServiceStart (fffff800`02c7bfde)]
fffff800`02c7bea1 41ffe3          jmp     r11

从代码上来nt!ZwClose前面做的工作则有些类似syscall,如果将syscall称作是硬模式切换的话,那nt!ZwCreate所做的可以称算是软模式切换。前者 是从用户态切换到内核态,涉及到栈及context的切换,转换成本很高。而后者则是从内核态到内核态的切换,其实仅是调用关系,均在同一个内核栈上。

nt!ZwXXX函数最终都会切换到相应的nt!NtXXX函数上,因为系统设定所有的任务都是由系统服务nt!NtXXX来实际处理。

从上面的对比不难看出,nt!ZwXXX函数做了一些看似没必要的切换工作,针对 内核驱动来说貌似完全没有必要。比如打开一个文件用nt!NtCreateFile或nt!IoCreateFile等函数更能节省稀缺的内核栈资源的使用,从这点上来看确实如此。

但二者细微的差别却可能导致严重的资源泄露问题。比如一个内核句柄,即在打开文件时指定了OBJ_KERNEL_HANDLE标志,在使用完毕要释放的时候,如果正巧是在用户context(进程上下文),比如IRP_MJ_CREATE或IRP_MJ_CLEANUP等irp的处理程序中,如果调用nt!NtClose的话,则会导致此句柄的泄露,然后此文件会一直处于打开(被占用)的状态。

其原因是nt!NtClose在处理句柄关闭时默认此句柄属于前一个模式空间(PreviousMode)。相反nt!ZwClose正因为多做了“软模式切换”,其切换过程中会修改PreviousMode为KernelMode,因为nt!ZwClose被调用时的线程已经处于内核线程模式(由用户线程通过syscall切换过来的),此时内核句柄用nt!ZwClose就能够正确关闭。

那么我们这里又有了一个新问题:如果反过来用nt!ZwClose来关闭一个本属于用户进程空间的句柄,会不会出错?

答案是不会的。因为nt!ObpCloseHandle在释放句柄前会针对内核句柄做检查,只有在满足全部条件的情况才会当作内核句柄来处理:

#define KERNEL_HANDLE_MASK ((ULONG_PTR)((LONG)0x80000000))

#define IsKernelHandle(H,M)                           \
    (((KERNEL_HANDLE_MASK & (ULONG_PTR)(H)) ==        \
       KERNEL_HANDLE_MASK) &&                         \
     ((M) == KernelMode) &&                           \
     ((H) != NtCurrentThread()) &&                    \
     ((H) != NtCurrentProcess()))

Object Manager通过句柄的第31位来区分是不是内核句柄,所以即使PreviousMode是KernelMode的情形也只会操作当前用户进程的句柄表(CurrentProcess->ObjectTable)而不是内核句柄表(nt!ObpKernelHandleTable)。

小结

不妨简单一点,用这样几个“简单粗暴”的公式来理解它们之间的关系,以NtClose/ZwClose为例:

  • ntdll!ZwClose(用户层) = ntdll!NtClose (用户层)
  • ntdll!ZwClose = syscall(硬切换) + nt!ZwClose(内核层)
  • nt!ZwClose = 软切换 + nt!NtClose(内核层)

魔鬼总在细节之中 (Devils in the details) !

<一直认为对这部分掌握得比较熟,但真正动笔的时候才发现很多不确定的地方。理解只有够深度够准确够熟练才是真正的理解!本文用时2.5小时。>

参考链接

  1. Nt vs. Zw - Clearing Confusion On The Native API
  2. MSDN: PreviousMode
  3. MSDN: Using Nt and Zw Versions of the Native System Services Routines
  4. MSDN: What Does the Zw Prefix Mean?
  5. Windows 7 x64 下的 System Call
  6. 看雪学院:windows 7 x64 KiSystemCall64 笔记
Continue reading » · Rating: · Written on: 01-13-17 · No Comments »

再谈_chkstk

WDK针对XP系统的build环境,在有_chkstk产生的情况下,link阶段会失败,因为无法解析引入符号__chkstk。

可我的驱动程序却能成功创建,只是在XP系统上加载时失败。检测并证实winxp的ntoskrnl.lib并没有__chkstk的输出。但是,wdmsec.lib的输出表中含有__chkstk,而驱动在SOURCE文件中指定了要加载wdmsec.lib,所以针对XP系统的build能够成功。

针对Win7系统,ntoskrnl.lib中已包含__chkstk符号,所以针对Win7所做的build不会报警。

为了让你的驱动正强壮,有意的针对XP系统编译一下你的驱动,将更容易检查出此类堆栈使用上的问题。

Continue reading » · Rating: · Written on: 12-09-12 · No Comments »

从_chkstk说起,谈谈用户栈的管理

上周在XP系统上测试一个驱动的时候,发现驱动加载不上,“net start”命令只是给出一个无意义的错误代码,驱动的DriverEntry()入口程序还没有得到机会运行。

初步怀疑是引入函数的问题,用WDK工具Depends.exe查看了一下驱动文件,果然是由于_chkstk函数无法解析所导致。

_chkstk是个微软C编译器的辅助库函数,MSDN上对其介绍十分简略:

_chkstk Routine is a helper routine for the C compiler. For x86 compilers, _chkstk Routine is called when the local variables exceed 4096 bytes; for x64 compilers it is 8K.

当编译器察觉到局部变量太大超过限值时(X86系统限值是4K,X64t系统上是8K), 编译器会自动插入_chkstk这个函数以保证栈空间所使用页面在内存中。

问题是发现了,但要查出来究竟在哪个函数中还是要费些心思的。从用户层移植过来不少代码,基本锁定问题出在其中,但如果一个函数一个函数寻找实在是个不讨巧的笨办法,也不符合程序人的一贯风格,便用IDA反编译驱动sys文件,于汇编代码中搜索_chkstk字串,直接锁定出了问题函数。此函数所使用的一个结构体中定义了超大数组,对栈的超常使用在内核中是相当危险的。

解决办法很简单,直接将此结构的定义放在一个从内存分配的结构中即可。问题虽已解决,但对于DDK中有关_chkstk的描述,及其相关的疑问一直让我觉得困扰,比如,为什么X86上是4K,而AMD64架构上可以是8K。

这两天终于有了时间,可以彻底地了结这个疑问了。

要想解决这个问题,还要先从用户栈的分配开始。以ReactOS代码为例,当线程创建时,CreateThread()会调用BasepCreateStack()来创建用户栈,具体可以参见ReactOS源码:
~/ReactOS/lib/kernel32/misc/utils.c。

BasepCreateStack()函数主要做三件事:

  1. 1,分配栈空间所需的虚拟内存,大小为Stack Reserve Size
  2. 2,根据Stack Commit Size锁定内存页面,如果Stack Commit Size小于Stack Reserve Size的话,需要增加一个Page,这个额外申请的Page用作Guard Page之用。
  3. 3,将栈底部的Page设定为Guard Page。

当用户栈被用尽时,会访问到栈底部的Guard Page。而对Guard Page的任何访问都会导致Page Fault的发生。Page Fault处理函数MmAccessFault()可以分析出此次Page Fault是由Guard Page导致,便会默认由用户栈处理程序MiCheckForUserStackOverflow()来处理。如果用户栈并没有溢出的话,即Stack Commit Size小于Stack Reserve Size的情况,MiCheckForUserStackOverflow()会自动向下扩展栈空间,扩展大小为GUARD_PAGE_SIZE。 GUARD_PAGE_SIZE针对不同的CPU架构有不同的定义:
X64:  #define GUARD_PAGE_SIZE   (PAGE_SIZE * 2)
X86:  #define GUARD_PAGE_SIZE   PAGE_SIZE

这里便解释了为什么X86系统上的限制是4K(即PAGE_SIZE),而X64上却为8K的原因。

说到此处,该是解答_chkstk()倒底是干什么的时候了。Visual Studio中有_chkstk的源码,以x86为例:

输入参数eax是所需堆栈大小(字节)
labelP  _chkstk,       PUBLIC

        push    ecx                          ; save ecx
        cmp     eax,_PAGESIZE_     ; more than one page requested?
        lea     ecx,[esp] + 8           ;   compute new stack pointer in ecx
                                                   ;   correct for return address and
                                                   ;   saved ecx
        jb      short lastpage           ; no

;------------

probepages:
        sub     ecx,_PAGESIZE_          ; yes, move down a page
        sub     eax,_PAGESIZE_          ; adjust request and...

        test    dword ptr [ecx],eax     ; ...probe it  (如果是guard page,刚会导致page fault,最终用户栈
                                                        ;  将向下扩展一个页面)

        cmp     eax,_PAGESIZE_          ; more than one page requested?
        jae     short probepages          ; no

lastpage:
        sub     ecx,eax                  ; move stack down by eax
        mov     eax,esp                 ; save current tos and do a...

        test    dword ptr [ecx],eax     ; ...probe in case a page was crossed
                                                        ;  调用函数将要访问的堆栈底部 ,如果此页面为guard page,同
                                                        ;  样会导致用户栈的向下延伸

        mov     esp,ecx                        ; set the new stack pointer
                                                        ; 向下更改栈指针,其上直到原ESP的栈空间为调用函数局部变量

        mov     ecx,dword ptr [eax]     ; recover ecx
        mov     eax,dword ptr [eax + 4] ; recover return address
                                                            ; 将返回地址(调用函数中)放入eax

        push    eax                     ; prepare return address
                                              ; 将返回地址(调用函数中)放入当前栈中,准备返回

                                              ; ...probe in case a page was crossed
        ret

        end

_chkstk()的主要作用是保证栈向下连续的生长。如果没有_chkstk(),当局部变量太多并超过guard page下沿时,若再有压栈操作,将会导致Access violation错误。因为此时堆栈内存页面无效,压栈直接将导致page fault的发生,而page fault处理程序因不能识别此fault的发生原因从而不能做出正确判断和有效处理。

相对用户层,内核程序的处理则相当简单,就如Win7内核中_chkstk实际上就是个空函数。其原因就是内核线程的栈空间是固定的。其取值针对X86及X64架构亦有所不同:
X64: #define KERNEL_STACK_SIZE 0x6000   /* 6个内存页面 */
X86: #define KERNEL_STACK_SIZE 12288    /* 3个内存页面 */

内核中栈资源非常紧缺,并驱动程序的编写有较高的要求,特别是有递归的情况下,一定要注意嵌套的层数,否则很容易收到M$发来的蓝屏。

Windows内核中其实还有一种大堆栈机制,以确保一些对堆栈较高消耗的特殊情况能够得到满足,但这部分完全是黑箱,对用户不可见,不是常见情况,此处不再多述。

参考资料:
1, http://support.microsoft.com/kb/100775/en
2, http://msdn.microsoft.com/en-us/library/ms648426(v=vs.85).aspx
3, http://www.reactos.org  ReactOS源码

Continue reading » · Rating: · Written on: 12-09-12 · No Comments »

Windows IOCTL Methods

在学习Windows内核开发之初,每个新手都会在DeviceIoControl上纠结一阵,然后就选择一个最得心应手的。拿在手中,去走遍全天下。等走了一圈之后就会感慨天下竟如此简单!

或许,天下就是这般简单,但DeviceIoControl却稍有些复杂。

DeviceIoControl的Buffer传输方式有三种:METHOD_BUFFERED,METHOD_IN_DIRECT & METHOD_OUT_DIRECT,METHOD_NEITHER。METHOD_IN_DIRECT和METHOD_OUT_DIRECT算作是同一类。

DeviceIoControl是用户层程序和内核驱动的一种通信方式。这个通信可完全看成是两人之间的私密,二者想怎么样就怎么样,只要双方都知晓并应用同一个规则即可。但如果还有个第三者的话,沟通起来就没有这么方便了,所以Windows内核要定义一个规则。可以说,根本上是因为Windows内核这个第三者的存在,也正是根据Windows内核在其中的参与度的不同才衍生出了这三种不同的ioctl方法。

用户层API定义:

BOOL WINAPI DeviceIoControl( in         HANDLE hDevice, in         DWORD dwIoControlCode, in_opt     LPVOID lpInBuffer, in         DWORD nInBufferSize, out_opt    LPVOID lpOutBuffer, in         DWORD nOutBufferSize, __out_opt    LPDWORD lpBytesReturned, __inout_opt  LPOVERLAPPED lpOverlapped );

内核层Irp Stack中相关DeviceIoControl的参数(取自IRP及IO_STACK_LOCATION结构):

typedef struct DECLSPEC_ALIGN(MEMORY_ALLOCATION_ALIGNMENT) _IRP { CSHORT Type; USHORT Size; PMDL MdlAddress;

union { struct _IRP *MasterIrp; LONG IrpCount; PVOID SystemBuffer; } AssociatedIrp;

…… PVOID UserBuffer; …… } IRP, *PIRP;

typedef struct _IO_STACK_LOCATION { UCHAR MajorFunction; UCHAR MinorFunction; …… union { struct { …… } Create; …… struct { ULONG OutputBufferLength; ULONG POINTER_ALIGNMENT InputBufferLength; ULONG POINTER_ALIGNMENT IoControlCode; PVOID Type3InputBuffer; } DeviceIoControl; …… } Parameters; …… } IO_STACK_LOCATION, *PIO_STACK_LOCATION;

用户层DeviceIoControl()参数及Irp成员之间的关系,针对不同的ioctl method不同的对应,详见下面的表格:

IOCTL-MTHODs

METHOD_BUFFERED:

这种方式是最简单,也是最常用的。内核驱动不用任何处理即可操作从应用层发过来的数据。实际上用户使用上的简单,正是因为内核做了相关的处理而省去了用户自己处理的繁琐。

在DeviceIoContro()阶段:NtDeviceIoControlFile()在构造Irp时,会于nInBufferSize及nOutBufferSize中选择大者以分配irp->AssociatedIrp.SystemBuffer,并将lpInBuffer中的数据复制至新分配的SystemBuffer中。

如果有SystemBuffer的分配,Irp->Flags则会被设置标志位:    IRP_BUFFERED_IO | IRP_DEALLOCATE_BUFFER。如果lpOutBuffer不为空的话,Irp->Flags也会被置上标志: IRP_INPUT_OPERATION。这三个标志在Irp完成时会用到。

在此要注意一点,针对METHOD_BUFFERED,即使lpInputBuffer和lpOutputBuffer为同一个Buffer,调用DeviceIoControl()时也必须全部指定,否则不能正确将请求数据发送至内核层或者用户层接收不到返回的数据。

在IRP_MJ_DEVICE_CONTROL完成阶段:IopCompleteRequest()会根据Irp->Flags标志来完成后续的处理,针对IRP_BUFFERED_IO,即METHOD_BUFFERED:

  • 如果设置了IRP_INPUT_OPERATION标志,会将返回数据从SystemBuffer中转移至用户传递过来的lpOutBuffer
  • 如果设置了IRP_DEALLOCATE_BUFFER标志,则释放SystemBuffer内存至PagedPool

METHOD_IN_DIRECT & METHOD_OUT_DIRECT:

在DeviceIoContro()阶段:如果lpInBuffer存在的话(并且满足nInBufferSize > 0),I/O Manager会以METHOD_BUFFERED的方式来处理lpInBuffer,即会分配SystemBuffer并将输入数据从lpInBuffer转移至SystemBuffer。如果lpOutBuffer存在,则会创建Irp->MdlAddress并将lpOutBuffer所用页面全部锁定。

在IRP_MJ_DEVICE_CONTROL完成阶段:IopCompleteRequest()会解锁irp->MdlAddress中所占用的页面,并释放irp->MdlAddress。

可以看出,如果设置了lpInBuffer的话,系统还是会另外分配内存空间,并造成内存的浪费。好在用户完全可以不用lpInBuffer,而是将lpOutBuffer同时存放输入数据Buffer。另外METHOD_IN_DIRECT及METHOD_OUT_DIRECT的使用也完全由用户决定,系统只是个传递者,并不参与lpInBuffer及lpOutBuffer中数据的处理。

METHOD_NEITHER:

这种方式下,系统参与度最小,一般用于能够快速处理的操作。I/O Manager所做的工作只是将输入参数传递至内核,其它的什么事也不做。如果内核驱动要将Irp挂起来的话,它要将irpSp->Parameters.DeviceIoControl.Type3InputBuffer及Irp->UserBuffer所用页面锁定起来,并在完成时释放所占资源(如果将lpOutBuffer/Irp->UserBuffer锁定至irp->MdlAddress的话,IopCompleteRequest()在调用时会做释放工作)。

总结起来,METHOD_BUFFERED最省事,但会多消耗系统内存;METHOD_NEITHER最直接,虽然看起来并不“直/DIRECT”  :-) METHOD_IN_DIRECT或METHOD_OUT_DIRECT最适合大内存区的操作,即使需要将讲求挂起来,内核驱动也不用做额外的工作。但在内存虚拟地址空间紧缺时,特别是在32位系统上,对于特大的内存区操作,宜采用METHOD_NEITHER方式以节省地址资源。

Continue reading » · Rating: · Written on: 11-19-12 · No Comments »