1. 学到这里为止,Win32汇编最值得花时间的地方出现了
走到第十部分,前面那些寄存器、寻址、基础指令和子程序调用,基本上已经被你磨得差不多了。这一篇我想认真聊聊 Win32 汇编里一个绕不开的分水岭——消息循环与窗口过程。很多人在这个节点之前都觉得汇编代码就是一段从上往下跑到底的普通程序,可一旦进入 GUI 世界,程序的运行逻辑就完全变了:不是它主动去干某件事,而是系统不断派活给它。用户点一下按钮、按一下键盘、拖动一下窗口边缘,都会变成一条消息,被塞进你的程序里。
这篇文章适合两类人:一类是跟着系列教程把基础语法过完、正准备写第一个带窗口的程序;另一类是原来在高级语言里写界面,却一直没弄懂窗口消息到底怎么流的人。你不需要把每一条 API 的文档背下来,重点是把 GetMessage、DispatchMessage、WndProc 这三样东西的关系理清楚。只要这条主线不断,Win32 汇编的窗口编程就有了落地的基础。
1.1 前面九个章节已经帮你铺好了什么底子
这一篇虽然单独拿出来看也能看懂,但它依赖前面几篇的知识点。你在之前的教程里至少应该已经见过这些内容:MASM 的环境配置,.386 和 .model flat, stdcall 这两行开头的含义,寄存器的基本用法,mov、push、pop、call、ret 这类指令的格式,以及 invoke 伪指令到底替你做了哪些压栈和调用工作。如果这些还没完全消化,建议稍微回看一下,消息循环本身不复杂,但窗口过程中到处要用到它们。
还有一个容易被忽略的基础,就是结构体的定义和访问。窗口类结构 WNDCLASS、消息结构 MSG,都需要你清楚每个字段是干什么的。高级语言里你写wc.lpfnWndProc = xxx,编译器帮你算偏移;汇编里你得自己面对结构体在内存里的排布。好在 MASM 提供了结构体语法,直接用wc.lpfnWndProc这种形式就能访问,不需要手工去加偏移,但这要求你对“结构体是一块连续内存”这件事有直观感受。
1.2 消息驱动到底是什么,用传菜员的例子讲清楚
想象一下餐厅后厨:厨师的菜做好了,放在出菜口,传菜员把它端到指定的桌子。Windows 窗口程序就是那个传菜员。系统把用户操作包成消息,投递到程序的消息队列里,程序通过 GetMessage 从队列里取消息,TranslateMessage 做键盘消息的翻译,DispatchMessage 把消息派发给对应的窗口过程。窗口过程就是某张桌子上负责处理服务需求的客人。整个过程循环往复,直到程序收到 WM_QUIT 消息并退出循环。
传统 DOS 程序或者控制台程序是线性的:读一个输入,处理一个输入,然后再读下一个。窗口程序不是这样,它没有办法预测下一条消息是什么。你在窗口过程中写的那些分支,本质上就是根据消息类型去选择“这桌客人要什么菜”。一条 WM_PAINT 来了,你就重绘;一条 WM_LBUTTONDOWN 来了,你就处理鼠标左键按下;一条 WM_CLOSE 来了,你就决定是销毁窗口还是提示保存。消息机制让程序从“主动执行”变成“被动响应”,这是理解后续所有内容的核心。
这一段里最值得你先记住的,是三个 API 的分工:GetMessage 负责从队列取消息,如果取到 WM_QUIT 就返回 0;TranslateMessage 负责把键盘消息转换成字符消息;DispatchMessage 负责把消息交给窗口过程。剩下的疑问,全部可以在动手写代码之后反过来想通。
2. 窗口过程和消息机制里的关键细节:返回值、默认处理和系统调用
窗口过程是整个 GUI 程序的心脏。你可以把它理解成一个“回调函数”:除了你主动调用的那些 API,系统也会在特定时机反过来调你的代码。系统为什么能调?因为窗口类结构里 lpfnWndProc 字段保存了你窗口过程的地址。RegisterClass 时把这个地址交给系统,之后每次产生消息,系统就把消息参数压进栈里,call 到你这个地址。这里的参数传递规则就是前面反复提过的 stdcall:从右往左压栈,由被调用者清理栈。窗口过程四个参数,系统调用它时负责压栈,你返回时用 ret 16 清理,MASM 的 proc 伪指令会自动生成正确的 ret 指令,不要自己乱写。
2.1 四个参数到底代表什么,寄存器怎么用才安全
窗口过程的签名是固定的:
WndProc proc hWnd:HWND, uMsg:UINT, wParam:WPARAM, lParam:LPARAMhWnd 是当前窗口的句柄,告诉你是哪一个窗口收到了消息。在多窗口程序里,同一个窗口过程可能被多个窗口共用,你必须靠 hWnd 区分。uMsg 是消息编号,比如 0x000F 是 WM_PAINT,0x0010 是 WM_CLOSE,0x0002 是 WM_DESTROY。wParam 和 lParam 是附加参数,具体含义取决于 uMsg,比如鼠标消息里 lParam 的低 16 位是 x 坐标,高 16 位是 y 坐标,wParam 则包含了鼠标按键状态和键盘控制键状态。
在窗口过程内部,尽量不要直接依赖进入时的寄存器值。系统调用你的时候,eax、edx 这些寄存器里的值并不保证有意义,你需要在分支比较之前自己把参数读出来。MASM 的伪指令.if uMsg == WM_PAINT会帮你生成取参数和比较的指令,但在更底层的写法里,你会看到常见的操作是mov eax, uMsg,然后cmp eax, WM_PAINT。这里要特别留个心眼:窗口过程内部的寄存器是可以随便用的,但不要以为所有寄存器的值都能跨函数调用存活。调用 API 之后,eax 通常会被返回值覆盖,ecx、edx 也可能变化,所以在多次调用之间需要保存的关键数据,尽量放到局部变量或者栈上,而不是只放在某个寄存器里。
2.2 窗口过程返回值的作用,以及 WM_PAINT 的正确处理姿势
窗口过程返回值的意义,很多人一开始会忽略。绝大多数消息处理完之后,返回 0 表示“我已经处理了这条消息”,返回非 0 则表示“我处理了,并且附带了一个结果值”。但有些系统消息有特殊要求。例如 WM_PAINT,你的窗口过程在处理完之后应该返回 0。WM_CREATE 如果返回 -1,系统会销毁已创建的窗口;返回 0 才表示创建成功。
WM_PAINT 是新手最容易写错的点。窗口第一次显示、被其他窗口遮挡后露出、用 InvalidateRect 强制刷新,都会产生 WM_PAINT。处理它时有两步不能省:BeginPaint 和 EndPaint。BeginPaint 会取得设备上下文 DC,同时把无效区域清除;EndPaint 释放 DC。如果你只收到了 WM_PAINT 却不调用 BeginPaint,系统会认为无效区域还没有被验证,于是不停地向窗口发送 WM_PAINT,造成界面持续刷新和 CPU 占用升高。
.elseif uMsg == WM_PAINT invoke BeginPaint, hWnd, addr ps ; 在这里用 GetDC 得到的内存 DC 做绘图 invoke EndPaint, hWnd, addr ps这里的 ps 是 PAINTSTRUCT 结构体,BeginPaint 会把绘制所需的信息填进去。在简单的示例里,可以暂时不绘制任何东西,但要保证 BeginPaint 和 EndPaint 成对出现。如果你想彻底避开这个坑,最省事的做法是把这个分支交给 DefWindowProc 处理,但那样窗口背景可能会闪烁,并不能真正验证无效区域,所以不建议这么偷懒。
2.3 PostMessage 与 SendMessage,到底用哪个,区别在哪里
在窗口程序中,除了响应系统消息,你也会主动给窗口发消息。这时候有两个选择:PostMessage 和 SendMessage。很多人只知道一个异步一个同步,但在汇编里用错它们,效果差异非常明显。
PostMessage 把消息投递到目标窗口所在线程的消息队列,然后立刻返回。发送方无法立刻知道消息是否已经被处理,也拿不到窗口过程的返回值。SendMessage 则不同,它会直接调用目标窗口的窗口过程,一直等到窗口过程返回,发送方才能继续执行。同一个线程内 SendMessage 本质上就是一次普通函数调用;跨线程使用时,目标窗口过程在目标线程里执行,发送线程会被阻塞等待结果。
实际开发里我的习惯是:需要窗口过程返回处理结果,必须用 SendMessage;只是通知对方可以稍后处理,用 PostMessage 更安全,不容易因为对方窗口卡住而把自己阻塞住。另外要注意 PostMessage 传的是指针时,这个指针指向的数据缓冲必须保证在消息被处理之前存活。比如你要逐条发送字符串消息,用 PostMessage 挂上共享内存地址的指针后,马上又去覆盖那块内存,接收方拿到的就是脏数据。这种情况下 SendMessage 反而更稳妥,因为它同步返回之后你再释放缓冲区,不会出现这种问题。
3. 手工搭建一个最小窗口程序:注册、创建、循环、销毁
下面这部分我会完整走一遍一个最小窗口程序,从数据段到消息循环,不省略关键步骤。你可以照着这个结构写,然后逐步往里加自己的逻辑。
3.1 数据段与窗口类的准备
先写程序框架:
.386 .model flat, stdcall option casemap:none include windows.inc include user32.inc include kernel32.inc includelib user32.lib includelib kernel32.lib .data szClassName db "DemoClass", 0 szWindowTitle db "Win32 Assembly Demo", 0 .data? hInstance dd ? hWndMain dd ? wc WNDCLASS <> msg MSG <>这里option casemap:none是希望你代码里的符号名严格区分大小写,Windows API 的名称都是大小写敏感的,比如CreateWindowEx不能写成createwindowex。WNDCLASS 结构体在windows.inc中已经定义好了,直接声明一个变量即可。这个结构体有很多字段,注册窗口类之前必须把每个字段都赋值完毕,尤其是lpfnWndProc和lpszClassName,一个不写就会导致注册失败。
注册窗口类的代码如下:
start: invoke GetModuleHandle, NULL mov hInstance, eax mov wc.style, CS_HREDRAW or CS_VREDRAW mov wc.lpfnWndProc, offset WndProc mov wc.cbClsExtra, 0 mov wc.cbWndExtra, 0 mov wc.hInstance, hInstance mov wc.hIcon, NULL mov wc.hCursor, NULL mov wc.hbrBackground, COLOR_WINDOW + 1 mov wc.lpszMenuName, NULL mov wc.lpszClassName, offset szClassName invoke RegisterClass, addr wcCS_HREDRAW or CS_VREDRAW表示窗口大小变化时,水平方向和垂直方向都强制重绘。如果省略,窗口拉伸后内容区域可能不刷新,出现残留。hbrBackground是窗口背景色画刷,COLOR_WINDOW 需要加 1,这个细节很多人容易漏,直接填 0 也不报错,但背景会变成黑色,看着很奇怪。
3.2 CreateWindowEx 参数搭配与常见误区
注册完窗口类,下一步创建窗口:
invoke CreateWindowEx, 0, offset szClassName, offset szWindowTitle, \ WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, \ 640, 480, NULL, NULL, hInstance, NULL mov hWndMain, eax test eax, eax jz @FCreateWindowEx 最后一个参数是扩展样式,一般写 0。接着是类名、窗口标题、窗口样式、初始坐标、宽高、父窗口句柄、菜单句柄、实例句柄、附加数据。WS_OVERLAPPEDWINDOW是常用窗口样式的组合:标题栏、系统菜单、最小化按钮、最大化按钮、可拉伸边框都在里面。如果你只要一个不能改变大小的窗口,可以用WS_CAPTION or WS_SYSMENU or WS_MINIMIZEBOX这种自己组合的方式。
坐标和尺寸用CW_USEDEFAULT表示让系统决定默认位置。宽高如果也给CW_USEDEFAULT,窗口会采用系统默认值;如果像上面代码里那样只给坐标用默认值、宽高给具体数值,窗口就会在默认位置创建一个 640x480 的窗口。这里创建的窗口尺寸是整个窗口的外边框尺寸,客户区实际会小一些。
CreateWindowEx 返回的句柄必须要检查。返回 NULL 时窗口创建失败,原因多半是窗口类名不匹配、窗口类没有注册、或者参数里有非法值。用 GetLastError 查具体错误代码,比盲猜高效得多。我在刚学这一段时,就曾经因为窗口类名里少写了一个字符,CreateWindowEx 一直返回 NULL,还以为是系统权限问题。
创建成功后,常规做法是通过 ShowWindow 显示窗口:
invoke ShowWindow, hWndMain, SW_SHOW invoke UpdateWindow, hWndMainSW_SHOW 是把窗口按当前状态显示出来。UpdateWindow 会立刻发一条 WM_PAINT 给窗口,如果省略,窗口最开始可能有一段短暂的空白期。很多简单示例会直接把 ShowWindow 参数写成 1,功能上等效,但可读性差,建议用系统常量。
3.3 消息循环的完整实现
窗口创建完成,程序不能直接退出,而是进入消息循环:
.while TRUE invoke GetMessage, addr msg, NULL, 0, 0 .break .if eax == 0 .if eax == -1 ; GetMessage 返回 -1 表示出错,应该退出 .break .endif invoke TranslateMessage, addr msg invoke DispatchMessage, addr msg .endwGetMessage 的addr msg用于接收消息。参数里的第二个到第四个,分别是窗口句柄、消息范围最小值、消息范围最大值。全传 NULL 表示接收当前线程所有窗口的消息。这个函数只有在取到 WM_QUIT 时返回 0,正常取到消息时返回 1,出错时返回 -1。所以判断退出条件时不能只判断 eax 是否为 0,-1 的情况也要处理,否则某些异常状态下消息循环会退不出去,或者程序在出错时直接崩溃。
TranslateMessage 的作用,用大白话说就是把“键盘按下的扫描码”翻译成 WM_CHAR 这样的字符消息,然后再放进消息队列。不调用它,Edit 控件里就无法正常输入文本,方向键的上下左右也会出问题。DispatchMessage 则是把 msg 结构里的窗口句柄和消息信息提取出来,找到该窗口对应的窗口过程,然后调用它。它和 SendMessage 的区别在于,DispatchMessage 是系统根据窗口句柄来分发消息,而 SendMessage 是你主动指定目标窗口去调用。
很多人会好奇,消息循环里能不能少掉 TranslateMessage 这一步。对于简单的示例程序,不写它的确也能跑,但一旦窗口里放上一个文本框或者需要处理键盘输入的自定义控件,问题立刻显现:按键消息收不到,字符都打不进去。我的经验是,永远保留这段三件套,不要为了省事删掉总比将来排查半夜好。
3.4 窗口过程的完整骨架
接下来是最关键的窗口过程。一个最简版本是这样的:
WndProc proc hWnd:HWND, uMsg:UINT, wParam:WPARAM, lParam:LPARAM .if uMsg == WM_PAINT ; 需要显示内容时在这里绘制 invoke DefWindowProc, hWnd, uMsg, wParam, lParam ret .elseif uMsg == WM_LBUTTONDOWN ; 鼠标左键按下,弹出一个消息框展示坐标 movzx eax, word ptr lParam movzx ecx, word ptr lParam + 2 invoke wsprintf, addr szBuffer, addr szFormat, eax, ecx invoke MessageBox, hWnd, addr szBuffer, addr szTitle, MB_OK xor eax, eax ret .elseif uMsg == WM_CLOSE invoke DestroyWindow, hWnd xor eax, eax ret .elseif uMsg == WM_DESTROY invoke PostQuitMessage, NULL xor eax, eax ret .else invoke DefWindowProc, hWnd, uMsg, wParam, lParam ret .endif xor eax, eax ret WndProc endp先说 WM_PAINT 分支。我这里直接调用了 DefWindowProc,严格来讲这不是最规范的做法,因为它不会验证无效区域,窗口容易闪烁。更好的方式是像前面那样 BeginPaint 和 EndPaint 成对调用。但很多小工具窗口并不需要真正绘制内容,交给系统默认处理也能看到窗口背景。实际项目中,如果你的窗口不需要自绘,建议直接忽略 WM_PAINT,让它落到 else 分支交给 DefWindowProc 处理,这样代码更简洁。
WM_LBUTTONDOWN 这个分支演示了从 lParam 里提取坐标。lParam 是一个 32 位值,低 16 位是客户区 x 坐标,高 16 位是 y 坐标。用word ptr lParam取出低 16 位,word ptr lParam + 2取出高 16 位。wsprintf 可以用来格式化字符串,但要注意它不像 C 语言那样自动检查参数类型,必须在参数列表里正确给出类型。wsprintf 的返回值是字符串长度,放在 eax 里,这里我们并不关心它。
WM_CLOSE 的处理很值得玩味。系统默认行为是调用 DestroyWindow 销毁窗口,但如果你希望在关闭前弹窗确认“是否保存”,就得自己接管这个消息。上面的代码里,WM_CLOSE 直接调用了 DestroyWindow,然后返回 0。DestroyWindow 会触发 WM_DESTROY 消息,所以 WM_DESTROY 里再调用 PostQuitMessage 让消息循环退出。如果你不处理 WM_CLOSE,消息落入 DefWindowProc,它也会帮你调用 DestroyWindow,效果一样。但如果只处理 WM_DESTROY,不处理 WM_CLOSE,是完全可以的。反过来,只处理 WM_CLOSE 却不调用 DestroyWindow,窗口点关闭按钮后不会真的消失。
最后,在所有分支中,凡是不调用 DefWindowProc 来处理的消息,都要返回 0 表示处理完成。把xor eax, eax放在结构末尾,配合这种分支写法,正好保证处理过的消息返回 0。我为了说明这个行为,在分支内部也重复写了xor eax, eax; ret,实际代码里可以让它们统一走底部出口,但必须避免某些分支落空后继续往下执行错误代码。
4. 调试与常见问题:从编译到运行的真实坑点
写窗口程序不像写控制台程序,编译过了也可能跑不出窗口。下面这些坑我自己基本都踩过,列出来可以帮你省掉不少排查时间。
4.1 编译和链接阶段最容易碰到的几个报错
MASM 的报错信息虽然简洁,但有些提示确实很反直觉。最常见的是error A2006: undefined symbol : xxx。这种错误一般有三种原因:符号名拼错;对应的 include 文件没有引入;符号被option casemap:none影响,大小写不一致。
链接的时候,最常见的错误是unresolved external symbol _WinMain@16。这是因为程序入口点没有正确指定。汇编程序的启动流程要先进入 start 标号,最后用end start声明入口地址。如果你在文件末尾写的是end而不是end start,链接器不知道从哪里开始执行,就会寻找默认的 WinMain 符号,自然找不到。
另一个高频错误是unresolved external symbol _MessageBoxA@16。这表示你在代码里调用了 MessageBox,但链接时找不到它的实现,也就是没有链接 user32.lib。解决办法是在源文件里加上:
includelib user32.lib或者把它放到工程文件的参数里。MASM32 环境里很多模板已经帮你写好了这一行,但如果你从零开始建文件,很容易漏掉。
4.2 窗口创建成功却显示不出来,应该怎么查
CreateWindowEx 返回了有效的窗口句柄,ShowWindow 也调用了,但屏幕上看不到窗口。这种现象有一种常见原因:ShowWindow 的第一个参数要传窗口句柄,第二个参数如果传的是 SW_HIDE,窗口自然不显示。还有一种情况是窗口位置超出了屏幕工作区,坐标设得太偏或者用了负值,窗口被排到屏幕外。调试这种问题时,先看 ShowWindow 的第二个参数是不是 SW_SHOW 或 SW_SHOWDEFAULT,再看看 CreateWindowEx 的坐标值有没有问题。
窗口一闪而过也是新手高频问题。程序的流程是:进入 start,注册窗口类,创建窗口,进入消息循环。如果消息循环里 GetMessage 的退出条件写成了eax != 0时退出,就会变成一取到消息就退出循环,程序立刻结束,窗口看起来只是闪了一下。正确逻辑是eax == 0才退出,也就是说取到 WM_QUIT 才退出。
还有一种情况,窗口能显示,但一移动或改变大小,窗口里一片混乱。先检查窗口类结构里的 style 是否包含了CS_HREDRAW or CS_VREDRAW,没有这两个标志,窗口尺寸变化时不会自动失效重绘区域。再检查 WM_PAINT 处理是否正确,如果 WM_PAINT 直接交给了 DefWindowProc,没有调用 BeginPaint,那么无效区域一直存在,系统会疯狂发 WM_PAINT,界面就会闪烁甚至卡顿。
4.3 窗口过程收到了消息,但代码分支不生效
这种情况最有迷惑性,因为程序没有报错,窗口也正常,唯独你新增的消息处理逻辑死活不执行。最常见的元凶是消息比较写错了。比如你写if uMsg == WM_LBUTTONDOWN,但因为option casemap:none,wm_lbuttondown而不是WM_LBUTTONDOWN,虽然编译不报错,但实际比较的是一个未定义的符号,MASM 会把它当成一个变量地址参与比较,结果整个分支永远不会成立。
另一个容易忽略的问题是:你处理了某个消息,却没有返回 0。例如在 WM_LBUTTONDOWN 分支里调用 MessageBox 后,直接落到了函数末尾的xor eax, eax; ret,这没问题。但如果你在分支中调用了某个 API,它的返回值覆盖了 eax,然后你用 ret 直接返回了一个非零值,系统会认为消息没有处理完成,某些消息会产生错误行为。解决方法是显式地把 eax 清零再返回。
最后,在多窗口程序里,一定要检查窗口过程是不是绑定对了窗口类。如果两个窗口类注册时lpfnWndProc都指向同一个窗口过程,那么所有消息都进同一个过程,你需要靠 hWnd 参数区分消息来自哪个窗口。忽视这一点会出现一个窗口的操作影响了另一个窗口,而且很难排查。
下面把常见问题整理成一张速查表,方便以后遇到时直接查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 编译报 undefined symbol | 符号拼写、include 缺失、大小写问题 | 检查windows.inc引入,确认 API 名大小写 |
| 链接报 unresolved WinMain | 入口点写错 | 文件末尾用end start,start 必须是入口标号 |
| 链接报 unresolved MessageBox | 缺少 user32.lib | 源文件加includelib user32.lib |
| CreateWindowEx 返回 NULL | 窗口类未注册/类名不一致 | 检查 RegisterClass 返回值,核对 lpszClassName |
| 窗口一闪而过 | 消息循环退出条件写反 | GetMessage 返回 0 才退出 |
| 窗口移动后内容错乱 | 缺少 CS_HREDRAW/CS_VREDRAW | 窗口类 style 加上这两个标志 |
| 窗口疯狂刷新/闪烁 | WM_PAINT 没调 BeginPaint | 成对调用 BeginPaint 和 EndPaint |
| 消息分支不生效 | 符号大小写错误/返回值非零 | 检查消息常量拼写,处理完返回 0 |
| 关闭按钮点了没反应 | 没处理 WM_CLOSE 且没走 DefWindowProc | 让 WM_CLOSE 落入 DefWindowProc 或主动调 DestroyWindow |
5. 窗口消息机制还能往哪些方向延展:子类化、钩子与自绘
把基本的消息循环跑通之后,你已经掌握了一个窗口程序的中枢神经系统。接下来要往深走,有几个方向非常值得尝试,它们全都围绕窗口消息展开。
5.1 子类化:接管系统控件的内部消息
子类化是 Win32 里非常巧妙的一种技术。它的本质是替换某个窗口的窗口过程函数地址。正常创建的控件,比如 Button、Edit,它们内部的窗口过程由系统实现,你看不到也改不了。但你可以通过 SetWindowLongPtr 把窗口过程指针替换成你自己写的窗口过程,并在你自己的窗口过程里先处理感兴趣的消息,然后把处理不了的消息交给原来的窗口过程。这样就实现了对现有控件行为的定制,而不需要自己从零实现整个控件。
在汇编里做子类化特别直观。你保存下原来的窗口过程地址:
invoke GetWindowLongPtr, hEdit, GWLP_WNDPROC mov OldWndProc, eax invoke SetWindowLongPtr, hEdit, GWLP_WNDPROC, offset NewEditProcNewEditProc 这个新的窗口过程会在原来编辑框处理消息之前收到所有消息。比如你想屏蔽编辑框的右键菜单,就可以在 NewEditProc 里拦截 WM_CONTEXTMENU,直接返回 0,不把它交给旧窗口过程。想限制输入字符,可以拦截 WM_CHAR,检查 wParam 里的字符编码,不满足条件就直接返回,实现输入过滤。子类化是所有自定义控件程序都必须掌握的技术,虽然高级语言里也有类似的子类化接口,但用汇编写一回,你对“窗口过程是成员函数”这句话的理解会彻底落地。
5.2 钩子与消息拦截的个人体验
钩子比子类化范围更大。SetWindowsHookEx 可以挂一个全局回调函数,让系统在特定消息发生或特定事件触发时调用你的函数。常见的钩子类型有键盘钩子、鼠标钩子、消息钩子、CBT 钩子等。
我在实际项目里用得比较多的是 WH_CBT 钩子,它可以在窗口激活、创建、销毁等操作发生之前收到通知。当时我接手一个老项目,需要在所有弹出窗口出现前统一修改窗口尺寸,用钩子是最干净的方式。如果用子类化,你得知道窗口句柄才能替换过程;用钩子则不用关心窗口从哪里来,只要事件发生,就通知你的回调函数。
钩子回调函数需要写成一个导出函数,并且钩子安装之后要记得卸载。卸载的时机一般是最后一次使用后,或者在收到 WM_DESTROY 之后。钩子用完之后不卸载,会造成内存泄漏,严重时其他进程的行为也会被影响。还有一点让我印象很深:全局钩子回调必须在 DLL 里,因为系统在其他进程的上下文中加载你的钩子函数;线程级钩子可以放在 EXE 里,但作用范围只限于本线程。这个限制初学者最容易撞上。
5.3 把消息机制吃透后,还可以继续啃什么
如果你能自己手写一个窗口程序,并且能讲清楚 GetMessage、DispatchMessage、窗口过程之间的调用关系,Win32 汇编的基础就算彻底立住了。再往下,可以尝试这些方向:PE 文件结构,理解程序文件在磁盘上如何布局;资源文件,把自己写的菜单、图标、对话框模板绑定到程序里;通用控件消息,比如 ListView 和 TreeView 的消息通知机制。
我把这一篇命名为“教程十”的节点,是因为从消息循环开始,你写的程序终于不再像控制台练习题,而是一个真正拥有图形界面的软件。后面的学习会越来越依赖你独立思考:想做一个画图工具,就研究 WM_PAINT 和 GDI 函数;想做一个文本编辑器,就去研究 Edit 控件的消息和子类化。系统已经把所有入口都摆在消息机制里,剩下的只是你打算怎么接招。
在写这些内容的时候,我脑海里浮现的是自己在某项目里用 Win32 汇编写一个自定义按钮的经历。那时候我连子类化是什么都不知道,只是照着文档把控件窗口过程换成了自己的过程,然后处理 WM_PAINT 画一个好看的矩形。当我第一次看到系统消息被自己的代码拦截下来,那种感觉比调通任何高级语言框架都更直接。汇编学习没有捷径,但消息循环和窗口过程绝对是其中最值得反复练习的章节。自己动手写一个最小窗口,然后把 WM_LBUTTONDOWN、WM_KEYDOWN、WM_CLOSE 挨个处理一遍,你会发现整个 Windows 图形界面的骨架已经被你抓住了。