再谈_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系统编译一下你的驱动,将更容易检查出此类堆栈使用上的问题。

从_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源码

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方式以节省地址资源。

文件系统开发之特殊文件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(&fcb->Resource, TRUE);

        /* access several members of fcb structure */
        ExReleaseResourceLite(&fcb->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, &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隐藏得太深,总让人捉摸不定!>

Win7调试模式问题一例

BootMgr的引入,虽然增加了操作及维护的复杂度,但相应带来的好处也不少,就比如我曾做过的一次操作:将Win7系统从第一分区移动至第三分区。这样的操作在XP时代,是不太可能的,因为这就意味着盘符的改变,系统内部配置都是以盘符来确定的,而Win7始终将系统盘加载至C:上。

将Win7移动后,修改BootMgr记录中的device及osdevice项后,Win7系统便能正常启动,相应命令如下:

bcdedit /set {uuid} device=partitioin=X:
bcdedit /set {uuid} osdevice=partitioin=X:

然后将debug模式打开:

bcdedit /debug {uuid} on

然后再设置dbgsettings为1394,而不是默认的com串口:

C:\>bcdedit /dbgsettings
An error occurred while attempting to access the boot configuration data.
The system cannot find the file specified.

系统中竟没有dbgsettings,那就自己创建好了:

C:\>bcdedit /create {dbgsettings}
The entry {dbgsettings} was successfully created.
C:\>bcdedit /dbgsettings
There are no debugger settings present.
The operation completed successfully.
C:\>bcdedit /dbgsettings 1394 channel:11
The operation completed successfully.
C:\>bcdedit /dbgsettings
debugtype               1394
channel                 11
The operation completed successfully.

设置好生重启系统,但wndbg死活就是连不上。但同样的硬件,用XP启动windbg确实能正常,所以排除了线材、硬件问题,还是要从Win7系统着手。

经过详细对比正常系统的BootMgr设置后,发现是dbgsettings虽设好了,但并没有关联到Win7启动项上。那就做一下关联试试:

C:\>bcdedit /create {globalsettings}
The entry {globalsettings} was successfully created.
C:\>bcdedit /set {globalsettings}  inherit {dbgsettings}
The operation completed successfully.
C:\>bcdedit /set {current} inherit {globalsettings}
The operation completed successfully.

重启系统后,windbg便能正常连接了。

挺简单的事,非要搞得这么复杂!难怪简单的苹果产品却赢得了如此多用户的芳心!?

Pages:  1 2 3 4 5