avatar

Catalog
WaitForSingleObject函数分析

前言

前一篇文章学习了线程等待与唤醒机制。了解到无论可等待对象是何种类型,线程都是通过调用函数WaitForSingleObject或WaitForMultipleObjects进入等待状态的,这两个函数是理解线程等待与唤醒机制的核心。本篇以WaitForSingleObject为例,从函数执行流程的角度,分析线程进入等待状态,形成等待网的过程。

WaitForSingleObject

函数原型如下:

c
1
2
3
DWORD WaitForSingleObject(
HANDLE hHandle, //(内核)对象句柄
DWORD dwMilliseconds //定时时间间隔

WaitForSingleObject是3环函数,它不会完成什么功能,仅会根据系统服务号,去内核寻找对应的内核函数(NtWaitForSingleObject)。这里唯一需要注意的是hHandle,这是一个句柄,在句柄表一篇中提到过,句柄相当于内核对象的一个编号,通过这个编号可以获取到对应内核对象的地址。

NtWaitForSingleObject

WaitForSingleObject不会执行函数功能,会调用下一步需要执行的内核函数NtWaitForSingleObject,先来看函数原型:

c
1
2
3
4
5
6
NTSTATUS __stdcall NtWaitForSingleObject(
HANDLE Handle, //用户层传递的等待对象的句柄(具体细节参加句柄表专题)
BOOLEAN Alertable, //对应KTHREAD结构体的Alertable属性
//如果值为1,在插入用户APC时,该线程将被吵醒
PLARGE_INTEGER Timeout //超时时间);
)

这个NtWaitForSingleObject函数它只做了两件事:

  1. 调用ObReferenceObjectByHandle函数,通过对象句柄找到等待对象结构体地址。
  2. 调用KeWaitForSingleObject函数,进入关键循环。

结论,这个NtWaitForSingleObject也没有实现线程等待功能,关键的函数是KeWaitForSingleObject。

KeWaitForSingleObject

函数原型如下:

c
1
2
3
4
5
6
7
NTSTATUS KeWaitForSingleObject (
PVOID Object,
KWAIT_REASON WaitReason,
KPROCESSOR_MODE WaitMode,
BOOLEAN Alertable,
PLARGE_INTEGER Timeout
);

由于KeWaitForSingleObject函数内部实现非常复杂,这里就先不逆向分析该函数,采用海哥逆向的结论,并完成验证。

上半部分

  1. 向_KTHREAD(+0x70)位置的等待块赋值。
  2. 如果超时时间不为0,_KTHREAD(+0x70)第四个等待块将与第一个等待块关联起来:第一个等待块指向第四个等待块,第四个等待块指向第一个等待块。
  3. KTHREAD(+0x5C)指向第一个_KWAIT_BLOCK。
  4. 进入关键循环

这里有两个要素,一个是等待块,一个是超时时间。先说等待块。

在KTHREAD(+0x70)处刚好有一个长度为4的_KWAIT_BLOCK数组,这个数组和等待块的结构体相同,因此当线程等待的对象等于或小于3个时,就会先占用这里的等待块,第4个等待块是给定时器用的。如果有4个等待对象就不会用这个位置了,它会一次性分配新的空间。下面来对此结论进行验证:

  1. 编译并运行如下代码(环境:Windows XP,Visual C++ 6.0)

    c
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #include "stdafx.h"
    #include "windows.h"

    HANDLE hEvent[2];

    DWORD WINAPI ThreadProc(LPVOID lpParameter)
    {
    ::WaitForSingleObject(hEvent[0], -1);

    printf("ThreadProc函数执行...\n");
    return 0;
    }

    int main(int argc, char* argv[])
    {
    hEvent[0] = ::CreateEvent(NULL, TRUE, FALSE, NULL);

    ::CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ThreadProc, NULL, 0, NULL);

    getchar();
    return 0;
    }
  2. 在Windbg中找到相应线程

    可以发现KTHREAD(+0x5C)处所指的等待块,刚好就是KTHREAD(+0x70)的位置(81c353e0+0x70=81c35450)

  3. 代码中只等待了一个事件,所以会使用_KTHREAD(+0x70)

    可以看到,第二个和第三个等待块也都是空的,第四个等待块给了定时器用。

  4. 上面的部分,主要解决的是等待块分配的问题,最关键的核心算法,在于下面的循环。

关键循环

关键循环的逻辑大致如下

c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while(true)			//每次线程被其他线程唤醒,都要进入这个循环
{
if(符合激活条件) //1超时 2等待对象SignalState > 0
{
//1修改SignalState
//2退出循环
}
else //SignalState不大于0 也没超时
{
if(第一次执行)
{
//将当前线程的等待块挂到等待对象的链表 (WaitListHead) 中;
//将自己挂入等待链表(KiaitListHead)
//切换线程...再次获得CPU时,从这里开始执行
}
}

}

1)线程将自己+5c位置清0
2)释放_KWAIT_BLOCK所占内存

想要弄清这个循环的逻辑,需要弄明白SignalState这个参数的含义。先看一下_DISPATCHER_HEADER结构:

c
1
2
3
4
5
6
7
8
9
10
kd> dt _DISPATCHER_HEADER
nt!_DISPATCHER_HEADER
+0x000 Type //类型码:表明该对象类型(例如Event对象有两种类型,0和1)
//可通过IDA分析代码查看可等待对象的类型码
//或者Wait一个对象,用WinDbg查看
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState //是否有信号(值大于0就是有信号)
+0x008 WaitListHead //双向链表头,链着所有等待块(此链表包含了所有正在等待该同步对象的线程)

线程等待与唤醒中学习过,可等待对象的特征就是其结构的第一个成员,或者其结构内部包含_DISPATCHER_HEADER结构

现在开始理清循环的逻辑:

  • 进入循环,判断当前被等待对象(例如Event)是否有信号(判断_DISPATCHER_HEADER.SignalState的值)
    • 如果符合激活条件(超时、SignalState>0):
      • 修改SignalState:这里不一定是清零,因为每种类型修改的方式不一样
      • 退出循环。
    • 如果不符合激活条件:
      • 如果是第一次执行:
        • 将当前线程的等待块挂到等待对象的链表(_DISPATCHER_HEADER.WaitListHead)中。此时进入了等待网。
      • 将自己挂入等待链表(KiWaitListHead),等待链表的内容可以参考之前的一篇文章。当线程自己把自己挂入等待链表以后,就相当于交付出去CPU控制权,并进行线程切换。此时循环才执行到一半,并未执行完,自己就被切换出去了。
      • 当线程将自己挂入等待队列后,需要等待另一个线程将自己唤醒设置等待对象信号SignalState>0),此时这个线程会根据其设置的等待对象WaitListHead链着的等待块,给所有等待块的所属线程一次临时复活的机会。这个临时复活意思是将这些线程从KiWaitListHead上面摘下来,也就是从等待链表上摘下来,但是,依旧挂在等待网上,所以被称作临时复活。
      • 复活的线程里,第一个获取到CPU控制权的,从此处,也就是自己把自己挂入等待链表(KiWaitListHead)后的位置,继续执行。并重新进入循环的入口。
  • 如果退出循环了,会执行到这里。这里简单讨论一下,临时复活的线程,如何完全复活。临时复活的线程,会接着执行并重新进入循环,如果它只有一个等待块,那么这个线程会符合激活条件,并退出循环。但又因为它退出循环之前,修改了SignalState的值,导致后面获得CPU控制权的不符合激活条件,并将重新挂到KiWaitListHead等待链表上。如果它有多个等待块,在判断第二个等待块时,也会因为不符合激活条件,也要重新将自己挂到KiWaitListHead上。也就是说,只有第一个获得CPU控制权,且只有一个等待块的线程,才能完全复活,别的临时复活的线程,要重新挂到等待链表上。
  • 执行到这里,已经退出循环了,线程会将自己+5C的位置清0,释放_KWAIT_BLOCK所占内存,将自己从等待网上摘除,成功复活。

总结

不同的等待对象,用不同的方法来修改_DISPATCHER_HEADER.SignalState。比如:如果可等待对象是EVENT,其他线程通常使用SetEvent来设置SignalState = 1;并且,将正在等待该对象的其他线程唤醒,也就是从等待链表(KiWaitListHead)中摘出来。但是,SetEvent函数并不会将线程从等待网上摘下来,是否要下来,由当前线程自己来决定。

关于强制唤醒

在APC专题中讲过,当我们插入一个用户APC时(Alertable=1),当前线程是可以被唤醒的,但并不是真正的唤醒。因为,如果当前的线程在等待网上,执行完用户APC后,仍然要进入等待状态。

参考资料

参考教程:

  • 海哥内核中级预习班

参考链接:

  1. https://blog.csdn.net/qq_41988448/article/details/104650212 (CSDN-lzyddf学习笔记)
  2. https://blog.csdn.net/weixin_42052102/article/details/83419566 (CSDN-My classmates学习笔记)
Author: cataLoc
Link: http://cata1oc.github.io/2020/08/13/WaitForSingleObject%E5%87%BD%E6%95%B0%E5%88%86%E6%9E%90/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶