HOME BLOG

Windows 窗口与消息:钩子

1. 什么是钩子(hook)

钩子是系统消息处理机制中的一个点,应用可以安装一个子函数(subroutine)来监视系统的消息交通(message traffic),并在消息到达目标窗口过程之前处理某些类型的消息。

钩子 是一种机制,通过它应用可以监听事件,比如消息,鼠标动作,和击键。监听某一特定事件类型的函数就是 钩子过程 。钩子过程可以对接收到的每个事件进行操作,然后修改或丢弃该事件。

(钩子会使系统变慢,因为它们增加了系统对每条消息的处理总量。你应该仅在必须的时候才安装钩子,并及时地去掉它)

2. 钩子链

系统支持许多种不同类型的钩子;每种钩子提供了访问不同方面的消息处理机制的能力。例如,应用可以使用 WH_MOUSE 钩子来监视鼠标消息的消息交通。

系统为每种类型的钩子分别维护着一个分离的钩子链。 钩子链 是一张指针表,上面的指针指向应用定义的回调函数,这些函数就是 钩子过程 。当与某种钩子类型关联的消息出现时,系统会把消息一个接一个发送到钩子链指向的每个钩子过程。钩子过程的行为依赖于钩子的类型。某些类型的钩子只能监视消息;其他的可以在钩子链中修改消息或结束消息的传递,使得消息不能到达下一个钩子过程或目标窗口过程。

3. 钩子过程

要想利用一种特定类型的钩子,需要提供相应的钩子函数并使用 SetWindowsHookEx 函数将它安装到相应的钩子链中。

钩子过程的语法如下:

LRESULT CALLBACK HookProc(
  int nCode,
  WPARAM wParam,
  LPARAM lParam
)
{
   // process event
   ...

   return CallNextHookEx(NULL, nCode, wParam, lParam);
}

nCode 参数是一个钩子码,钩子过程通过它来决定要进行的动作。钩子码的值取决于钩子类型;每种类型都有它自己的特征钩子码集合。

wParamlParam 参数的值依赖于钩子码,但是它们一般包含着与发送或投递的消息相关的信息。

SetWindowsHookEx 函数总会将一个钩子过程安装在钩子链的头部。当被某种钩子监视的消息出现时,系统会从钩子链的链头开始调用与该类型钩子关联的钩子过程。钩子链中的每个钩子决定了是否将消息传递给下一个过程。通过调用 CallNextHookEx,钩子过程将事件传递给下一个过程。

全局钩子 (global hook)会监视与主调(calling thread)在同一桌面中的所有线程。 线程指定钩子 (thread-specific hook)只监视单个独立的线程消息。全局钩子过程可以被在和主调线程相同的桌面中的任何应用的上下文中调用,因此这个过程必须是在一个分离的 DLL 模块中。指定线程的钩子过程只在关联线程中被调用。如果应用为它的一个线程安装了钩子过程,钩子过程可以在应用的代码中,或在 DLL 中。如果应用为其他应用的线程安装了钩子,钩子过程必须在 DLL 中。

(注意,你只该将全局钩子用于调试目的;否则,你应该避免使用它们。全局钩子会伤害系统的性能,并且会与其他实现了相同类型全局钩子的应用产生矛盾。)

4. 钩子类型

4.1. WH_CALLWNDPROC 和 WH_CALLWNDPROCRET

WH_CALLWNDPROCWH_CALLWNDPROCRET 钩子能够让你监视发送到窗口过程的消息。系统在将消息传递到接收消息的窗口过程前会调用一个 WH_CALLWNDPROC 钩子过程,并在窗口过程处理消息后调用 WH_CALLWNDPROCRET 钩子过程。

WH_CALLWNDPROCRET 钩子会将一个指向 CWPRETSTRUCT 结构的指针传递给钩子过程。这个结构包含了处理消息后窗口过程的返回值,以及与消息关联的消息参数。

更多信息可见于 CallWndProcCallWndRetProc

4.2. WH_CBT

在窗口激活、创建、销毁、最小化、最大化、移动或改变尺寸的前一刻;在完成系统命令的前一刻;在从系统消息队列移除鼠标或键盘事件的前一刻;在设置输入焦点的前一刻;或在与系统消息队列同步的前一刻,系统会调用 WH_CBT 钩子过程;钩子过程的返回值决定了系统是允许或阻止这些操作。 WB_CBT 钩子主要用于基于计算机的教学应用。(computer-based training (CBT))

更多信息可见于CBTProcWinEvents

4.3. WH_DEBUG

在调用与系统中的其他钩子关联的钩子过程前,系统会调用 WH_DEBUG 钩子过程。你可以用这个钩子来决定是否允许系统调用其他类型钩子的钩子函数。

更多信息可见于 DebugProc

4.4. WH_FOREGROUNDIDLE

WH_FOREGROUNDIDLE 钩子允许你在前台线程闲置(idle)时处理低优先级的任务。当前台线程将要闲置时,系统会调用 WH_FOREGROUNDIDLE 钩子过程。

更多信息可见于 ForegroundIdleProc

4.5. WH_GETMESSAGE

WH_GETMESSAGE 钩子让应用能够监视将要被 GetMessagePeekMessage 函数返回的消息。你可以使用 WH_GETMESSAGE 钩子来监视鼠标键盘输入,以及其他投递到消息队列的消息。

更多信息可见于 GetMsgProc

4.6. WH_JOURNALPLAYBACK

WH_JOURNALPLAYBACK 钩子让应用能够在系统消息队列中插入消息。你可以使用这个钩子来回放之前使用 WH_JOURNALRECORD 钩子记录的一系列鼠标和键盘事件。在安装了 WH_JOURNALPLAYBACK 钩子后,鼠标和键盘输入一般会被禁用。 WH_JOURNALPLAYBACK 钩子是全局钩子 —— 它不能作为线程特定钩子使用。

WH_JOURNALPLAYBACK 钩子返回一个时间值。这个值告诉系统在处理当前来自回放钩子的消息前需要等待的毫秒数。这让钩子能够控制时间回放的时间。

更多信息可见于 JournalPlaybackProc 回调函数。

4.7. WH_JOURNALRECORD

WH_JOURNALRECORD 钩子让你能够监视输入事件。一般来说,你会使用这个钩子来记录一系列的鼠标和键盘事件,并使用 WH_JOURNALPLAYBACK 钩子来对这些消息进行回放。 WH_JOURNALRECORD 钩子是一个全局钩子 —— 它不能作为线程特定钩子使用。

更多信息可见于 JournalRecordPorc 回调函数。

4.8. WH_KEYBOARD_LL

WH_KEYBOARD_LL 钩子让你能够监视将要投递到一个线程的输入队列中的键盘输入事件。

更多信息可见于 LowLevelKeyboardProc 回调函数。

4.9. WH_KEYBOARD

WH_KEYBOARD 钩子允许应用监视将要由 GetMessagePeekMessage 返回的 WM_KEYDOWNWM_KEYUP 消息。你可以使用 WH_KEYBOARD 钩子来监视投递到消息队列的键盘输入。

更多信息可见于 KeyboardProc 回调函数。

4.10. WH_MOUSE_LL

WH_MOUSE_LL 钩子允许你监视将要投递到线程消息队列的鼠标输入事件。

更多信息可见于 LowLevelMouseProc 回调函数。

4.11. WH_MOUSE

WH_MOUSE 钩子允许你监视将要由 GetMessagePeekMessage 返回的鼠标消息。你可以使用它来监视投递到消息队列的鼠标输入。

更多信息可见于 MouseProc 回调函数。

4.12. WH_MSGFILTER 和 WH_SYSMSGFILTER

WH_MSGFILTERWH_SYSMSGFILTER 钩子允许你监视将由菜单,滚动条,消息框或对话框处理的消息,对用户使用 ALT+TABALT+ESC 组合键来激活另一个窗口的行为进行监视。*WH_MSGFILTER* 钩子只能监视传递由应用创建的菜单,滚动条,消息框或对话框消息。*WH_SYSMSGFILTER* 钩子可以监视所有应用的消息。

WH_MSGFILTERWH_SYSMSGFILTER 钩子允许你在模态循环时过滤消息,这和主循环中的过滤是等价的。例如,在从队列检索消息之后和分派消息之前,应用通常会在主循环中对新的消息进行测试。然而,在模态循环中,系统在检索和分派消息时不允许应用在主循环中进行过滤操作。如果应用安装了 WH_MSGFILTERWH_SYSMSGFILTER 钩子过程,系统可以在模态循环中调用该过程。

应用可以通过直接调用 CallMsgFilter 函数来调用 WM_MSGFILTER 钩子。通过使用该函数,应用可以在模态循环中使用和它在主循环中所使用的一样的消息过滤。要做到这一点,将过滤操作封装到*WH_MSGFILTER* 钩子过程中并在*GetMessage* 和 DispatchMessage 之间调用 CallMsgFilter

更多信息可见于 MessageProcSysMsgProc 回调函数。

4.13. WH_SHELL

shell 应用可以使用 WH_SHELL 钩子来检索重要的通知。系统在 shell 应用将要被激活以及顶级窗口被创建或销毁时调用 WH_SHELL 钩子过程。

需要注意的是,shell 应用不会接收 WH_SHELL 消息。因此,任何将自己注册为默认 shell 的应用必须调用 SystemParametersInfo 以使他能够接收 WH_SHELL 消息。该函数必须使用 SPI_SETMINIMIZEDMETRICS 标志和 MINIMIZEDMETRICS 结构,并把结构的 iArrange 成员设置为 ARW_HIDE

更多信息可见于 ShellProc 回调函数。

5. 钩子的使用

你可以通过调用 SetWindowsHookEx 函数来安装一个钩子过程,并在其中指定钩子过程类型、过程入口点以及钩子是否与在主调线程桌面的所有线程相关联。

若要安装一个全局钩子,你必须将它与应用分开存放,放在一个分离的 DLL 中。安装全局钩子的应用必须在安装前得到 DLL 模块的句柄。要得到 DLL 模块的句柄,可以调用 LoadLibrary 函数。在获得句柄后,你可以调用 GetProcAddress 来得到钩子过程的指针。最终,使用 SetWindowsHookEx 来安装钩子过程。

你可以通过调用 UnhookWindowsHookEx 来释放线程特定的钩子过程,在函数调用中指定钩子过程的句柄。

你当然可以使用 UnhookWindowsHookEx 来释放一个全局钩子,但是该函数不会释放包含钩子过程的 DLL。这是因为全局钩子过程是在桌面中的每个进程上下文中调用的。因为调用 FreeLibrary 函数不能用于另一个进程,所以没有办法来释放 DLL。在所有与 DLL 链接的进程终止或调用了 FreeLibrary ,并且所有调用了钩子过程的进程都脱离 DLL 后(resumed processing outside the DLL),系统最终会释放 DLL。

6. 代码示例

微软的官方文档给出了一个例子,Using Hooks 。可以参考参考。

这里给出一个更简单的例子,使用钩子来监视鼠标的点击事件。

例子程序的功能是:初始条件下,窗口正中央会绘制一个黑色的矩形,单击鼠标(按下不回弹)会使矩形颜色变为红色,回弹后矩形颜色恢复原来颜色。

回调函数如下:

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    static BOOL fClicked = FALSE;
    static int cxSize, cySize;
    static HBRUSH hBrBlack, hBrRed;
    PAINTSTRUCT ps;
    HDC hdc;
    RECT rect;
    switch (message)
    {
    case WM_CREATE:
        hBrBlack = CreateSolidBrush(RGB(0, 0, 0));
        hBrRed = CreateSolidBrush(RGB(255, 0, 0));
        return 0;
    case WM_SIZE:
        cxSize = LOWORD(lParam);
        cySize = HIWORD(lParam);
        return 0;

    case WM_LBUTTONDOWN:
        hdc = GetDC(hWnd);
        SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
        FillRect(hdc, &rect, hBrRed);
        ReleaseDC(hWnd, hdc);
        fClicked = TRUE;
        return 0;

    case WM_LBUTTONUP:
        hdc = GetDC(hWnd);
        SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
        FillRect(hdc, &rect, hBrBlack);
        ReleaseDC(hWnd, hdc);
        fClicked = FALSE;
        return 0;


    case WM_PAINT:
        hdc = BeginPaint(hWnd, &ps);
        if (fClicked)
        {
            SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
            FillRect(hdc, &rect, hBrRed);
        }
        else
        {
            SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
            FillRect(hdc, &rect, hBrBlack);
        }
        EndPaint(hWnd, &ps);

        return 0;

    case WM_DESTROY:
        DeleteObject(hBrBlack);
        DeleteObject(hBrRed);
        PostQuitMessage(0);
        break;
    default:
        return DefWindowProc(hWnd, message, wParam, lParam);
    }
    return 0;
}

这个回调函数基本实现了矩形块变色的功能,但是存在一个问题,那就是:如果在鼠标左键处于按下状态时将鼠标移出窗口,即便你在窗口外部松开的鼠标左键,窗口内的矩形仍然会是红色。

在客户区外时,窗口是接收不到鼠标事件的。 Programming Windows 鼠标一章中给出了一种方法,那就是使用 SetCapture 函数。调用该函数后,Windows 会把所有鼠标消息发送给作为 SetCapture 参数的句柄所对应的窗口过程。如果想要释放鼠标,可以调用 ReleaseCapture 函数。

最简单的解决方法就是按下左键时调用 SetCapture 函数,并在松开后调用 ReleaseCapture 函数。

修改后的消息处理如下:

case WM_LBUTTONDOWN:
        hdc = GetDC(hWnd);
        SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
        FillRect(hdc, &rect, hBrRed);
        ReleaseDC(hWnd, hdc);
        fClicked = TRUE;
        SetCapture(hWnd);
        return 0;

    case WM_LBUTTONUP:
        hdc = GetDC(hWnd);
        SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
        FillRect(hdc, &rect, hBrBlack);
        ReleaseDC(hWnd, hdc);
        fClicked = FALSE;
        ReleaseCapture();
        return 0;

这样一来,即便鼠标已经移开了窗口,窗口依然可以接收到鼠标的点击消息。

使用上面的这两个函数可以很好地解决这个问题,那么,怎么使用钩子来解决问题呢?

6.1. 使用钩子的代码

通过使用钩子,可以检测到所有线程的鼠标消息。需要使用和鼠标消息相关的钩子。

包含钩子过程的 dll 代码如下:

//mousehook module
#include <windows.h>
#include <strsafe.h>
extern "C" __declspec(dllexport) void SetMouseHook(HWND, HMODULE);
extern "C" __declspec(dllexport) void ReleaseMouseHook(void);

HHOOK hHook;
HWND hWnd;

LRESULT CALLBACK MouseProc(int inode, WPARAM wParam, LPARAM lParam)
{
    static int cnt = 0;
    size_t len;
    TCHAR buff[10];
    HDC hdc;
    if (inode == HC_ACTION)
    {
        switch (wParam)
        {
        case WM_LBUTTONUP:
            hdc = GetDC(hWnd);
            StringCbPrintf(buff, 10, TEXT("%d"), cnt);
            StringCchLength(buff, 10, &len);
            TextOut(hdc, 0, 15 * cnt++, buff, len);
            ReleaseDC(hWnd, hdc);
            SendMessage(hWnd, WM_USER + 1, 0, 0);

        }
    }
    return CallNextHookEx(hHook, inode, wParam, lParam);
}

void SetMouseHook(HWND hwnd, HMODULE hModule)
{
    hHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProc, hModule, 0);
    hWnd = hwnd;
}

void ReleaseMouseHook(void)
{
    UnhookWindowsHookEx(hHook);
    hHook = 0;
    hWnd = 0;
}

该 DLL 导出了两个函数, SetMouseHook 负责安装钩子, ReleaseMouseHook 负责释放钩子。

当安装了钩子之后,当鼠标左键释放后,钩子过程会在窗口上输出左键释放的次数,以及向窗口发送 WM_USER + 1 的消息。

窗口处理过程如下:

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    static HMODULE hModule;
    typedef void (*ahook)(HWND, HMODULE);
    typedef void (*afree)(void);
    static ahook AddHook;
    static afree DelHook;
    static BOOL fHooked = FALSE;
    static BOOL fClicked = FALSE;
    static int cxSize, cySize;
    static HBRUSH hBrBlack, hBrRed;
    PAINTSTRUCT ps;
    HDC hdc;
    RECT rect;
    switch (message)
    {
    case WM_CREATE:
        hBrBlack = CreateSolidBrush(RGB(0, 0, 0));
        hBrRed = CreateSolidBrush(RGB(255, 0, 0));
        hModule = LoadLibrary(TEXT("mousehook"));
        if (hModule == NULL)
            exit(1);
        AddHook = (ahook)GetProcAddress(hModule, "SetMouseHook");
        if (!AddHook)
        {
            MessageBox(NULL, TEXT("FAILED"), TEXT("2"), MB_OK);
            exit(1);
        }
        DelHook = (afree)GetProcAddress(hModule, "ReleaseMouseHook");
        if (!DelHook)
        {
            MessageBox(NULL, TEXT("FAILED"), TEXT("3"), MB_OK);
            exit(1);
        }
        return 0;
    case WM_SIZE:
        cxSize = LOWORD(lParam);
        cySize = HIWORD(lParam);
        return 0;

    case WM_LBUTTONDOWN:
        //draw red rect
        hdc = GetDC(hWnd);
        SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
        FillRect(hdc, &rect, hBrRed);
        ReleaseDC(hWnd, hdc);

        fClicked = TRUE;
        if (!fHooked)
        {
            AddHook(hWnd, hModule);
            fHooked = TRUE;
        }
        return 0;

    case WM_USER + 1:
    case WM_LBUTTONUP:
        //draw black rect
        hdc = GetDC(hWnd);
        SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
        FillRect(hdc, &rect, hBrBlack);
        ReleaseDC(hWnd, hdc);
        fClicked = FALSE;

        if (fHooked)
        {
            DelHook();
            fHooked = FALSE;
        }
        return 0;

    case WM_PAINT:
        hdc = BeginPaint(hWnd, &ps);
        if (fClicked)
        {
            SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
            FillRect(hdc, &rect, hBrRed);
        }
        else
        {
            SetRect(&rect, cxSize / 3, cySize / 3, cxSize * 2 / 3, cySize * 2 / 3);
            FillRect(hdc, &rect, hBrBlack);
        }
        EndPaint(hWnd, &ps);

        return 0;

    case WM_DESTROY:
        if (fHooked)
        {
            DelHook();
        }
        FreeLibrary(hModule);
        DeleteObject(hBrBlack);
        DeleteObject(hBrRed);
        PostQuitMessage(0);
        break;
    default:
        return DefWindowProc(hWnd, message, wParam, lParam);
    }
    return 0;
}

这段代码与使用 SetCapture 的代码有相同的效果。

注意,这里使用的是 WH_MOUSE_LL 钩子,而不是 WH_MOUSE 。若调用 SetWindowsHookEx 时指定的是 WH_MOUSE ,将鼠标移出窗口并松开左键并没有反应,必须再按下松开一次。根据参考资料【3】WH_MOUSE_LL 的钩子过程并没有注入其他进程中。消息产生时,上下文会切换到安装钩子的进程并在原始上下文调用钩子过程,随后上下文回到生成时间的应用。而 WH_MOUSE 的钩子过程需要注入。

由此做出猜想:在鼠标移出窗口后松开左键时,若使用的是 WH_MOUSE 钩子,钩子过程可能还没有注入到其他进程中,所以第一次点击没有反应,再次点击时,钩子过程已注入,钩子向原窗口发送消息,从而使得窗口重新绘制矩形。

网上关于这两种钩子区别的内容寥寥无几,参考资料【3】已经是 11 年前的内容了,由于能力不足,这个猜想也只能作为猜想留在这里,待以后再解决。

7. 参考资料

【1】 Hooks Overview: https://docs.microsoft.com/en-us/windows/win32/winmsg/about-hooks

【2】 Programming Windows, Charles Petzold

【3】 What are all the differences between WH_MOUSE and WH_MOUSE_LL hooks? - Stack Overflow https://stackoverflow.com/questions/872677/what-are-all-the-differences-between-wh-mouse-and-wh-mouse-ll-hooks