学 C 语言的前半年,我的程序全都活在一个黑窗口里——printf 进,scanf 出。2023 年 2 月,我决定去摸一摸 Windows 真正的样子:窗口、消息、控件、对话框。到 7 月,我已经能在内存里徒手拼出一个对话框、写出一个带菜单和列表视图的课程设计。这篇文章按时间顺序复盘这条轨迹的四个站点:鼠标热身 → 第一个窗口 → 内存里手搓对话框 → 学生考勤管理系统。
热身:做一个鼠标监听器
进 Win32 的世界之前,先在控制台里热身。目标很简单:实时显示鼠标坐标、统计左右键点击次数。核心是两个 API——GetAsyncKeyState 查按键状态、GetCursorPos 拿坐标:
鼠标检测.cpp1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| #include <windows.h> #include <iostream> #define KEY_DOWN(VK_NONAME) ((GetAsyncKeyState(VK_NONAME) & 0x8000) ? 1:0) using namespace std;
int main() { int a=1,b=1; HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); DWORD mode; GetConsoleMode(hStdin, &mode); mode &= ~ENABLE_QUICK_EDIT_MODE; SetConsoleMode(hStdin, mode);
POINT point; while(true) { if(KEY_DOWN(VK_LBUTTON)) { cout<<"鼠标左键被按下"<<a<<"次"<<endl<<endl; a++; } if(KEY_DOWN(VK_RBUTTON)) { cout<<"鼠标右键被按下"<<b<<"次"<<endl<<endl; b++; }
GetCursorPos(&point); cout << point.x << "," << point.y << endl;
Sleep(100); }
return 0; }
|
这段小代码里藏着一个 Win10 用户才会懂的坑:控制台的”快速编辑模式”。你在控制台里点一下鼠标选中文本,整个程序就暂停了——对交互程序是致命的。所以开头那几行 GetConsoleMode/SetConsoleMode 不是废话,是把快速编辑模式摘掉。另一个习惯也在这里养成:循环里的 Sleep(100),不睡觉的轮询会把 CPU 吃满。
第一个窗口:四件套
真正的起点是窗口。Win32 的窗口程序有一套固定仪式,四件套缺一不可:注册窗口类 → 创建窗口 → 消息循环 → 窗口过程。我的第一个窗口(wind.c)在这套骨架上多玩了两个控件——一个按钮和一个列表框,按钮按下就往列表框里塞一条消息:
wind.c1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
| #include <windows.h>
int DisplayConfirmSaveAsMessageBox() { int msgboxID = MessageBox( NULL, "temp.txt already exists.\nDo you want to replace it?", "Confirm Save As", MB_ICONEXCLAMATION | MB_YESNO );
if (msgboxID == IDYES) { }
return msgboxID; }
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); break; case WM_COMMAND: if (LOWORD(wParam) == 1) { DisplayConfirmSaveAsMessageBox(); HWND hListBox = GetDlgItem(hwnd, 2); if(hListBox == NULL) MessageBox(NULL, "NULL!", "Button", MB_OK); SendMessage(hListBox, LB_ADDSTRING, 0, (LPARAM)"Button clicked!"); InvalidateRect(hListBox, NULL, TRUE); } break; default: return DefWindowProc(hwnd, msg, wParam, lParam); } return 0; }
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { ShowWindow(GetConsoleWindow(), SW_HIDE);
const char CLASS_NAME[] = "Hello, World!";
WNDCLASS wc = { };
wc.lpfnWndProc = WndProc; wc.hInstance = hInstance; wc.lpszClassName = CLASS_NAME;
RegisterClass(&wc);
HWND hwnd = CreateWindowEx( 0, CLASS_NAME, "Hello, World!", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, NULL, NULL, hInstance, NULL );
if (hwnd == NULL) { return 0; } CreateWindow( "BUTTON", "Click me", WS_TABSTOP | WS_VISIBLE | WS_CHILD | BS_DEFPUSHBUTTON, 10, 10, 100, 30, hwnd, (HMENU)1, (HINSTANCE)(LONG_PTR)GetWindowLong(hwnd, GWLP_HINSTANCE), NULL);
CreateWindow( "LISTBOX", "", WS_VISIBLE | WS_CHILD | LBS_STANDARD, 10, 50, 500, 200, hwnd, (HMENU)2, (HINSTANCE)(LONG_PTR)GetWindowLong(hwnd, GWLP_HINSTANCE), NULL);
ShowWindow(hwnd, nCmdShow);
MSG msg = { }; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); }
return 0; }
|
这里要理解的核心机制是消息驱动:程序不再是从上到下执行完就结束,而是进入 GetMessage 死循环,等 Windows 把事件(点击、按键、关闭)打包成”消息”投递给 WndProc,由它分发处理。按钮被点击时,WM_COMMAND 消息的 wParam 低 16 位带着创建按钮时给的那个 ID——(HMENU)1——WndProc 靠这个编号认出是哪个控件在说话。
另外注意第一行的 ShowWindow(GetConsoleWindow(), SW_HIDE):WinMain 编译出的程序默认还拖着个控制台黑框,一行代码把它藏起来,程序才算”像个 Windows 程序”。
进阶:在内存里手搓一个对话框
资源文件里的对话框见得多了,但微软的文档里藏着一个更野的玩法:在内存里直接拼一张对话框模板,运行时用 DialogBoxIndirect 凭空创建。动态创建对话框.c(以及它的前身”乱搞的创建窗口.c”)干的就是这个。
流程是:GlobalAlloc 切一大块零初始化内存,然后按 DLGTEMPLATE 结构的格式一个字段一个字段地填——先填对话框的样式、控件数、坐标,再逐个填控件模板,每个控件之间用 lpwAlign 对齐到 DWORD 边界:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| hgbl = GlobalAlloc(GMEM_ZEROINIT, 2048); lpdt = (LPDLGTEMPLATE)GlobalLock(hgbl);
lpdt->style = WS_POPUP | WS_BORDER | WS_SYSMENU | DS_MODALFRAME | WS_CAPTION; lpdt->cdit = 2; lpdt->x = 10; lpdt->y = 10; lpdt->cx = 200; lpdt->cy = 200;
lpw = (LPWORD)(lpdt + 1); *lpw++ = 0; *lpw++ = 0;
lpwsz = (LPWSTR)lpw; nchar = 1 + MultiByteToWideChar(CP_ACP, 0, "My Dialog", -1, lpwsz, 50); lpw += nchar;
lpw = lpwAlign(lpw); lpdit = (LPDLGTEMPLATE)lpw; lpdit->x = 150; lpdit->y = 0; lpdit->cx = 50; lpdit->cy = 20; lpdit->id = IDOK; lpdit->style = WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON; ... ret = DialogBoxIndirect(hinst, (LPDLGTEMPLATE)hgbl, hwndOwner, (DLGPROC)DialogProc);
|
这套写法我是照着微软官方文档的示例一步步复刻的,但复刻的过程把三个概念焊进了脑子:模板是一段有格式的二进制,不是配置文件;对齐不是玄学(lpwAlign 那个把指针推到 4 字节边界的函数,错一步整个模板解析全乱);控件的类名用 0xFFFF + 原子值 表示(按钮是 0x0080)——系统控件在注册表里早已注册,报个编号就行。
收官:学生考勤管理系统
这条轨迹的终点是 2023 年 4 月的课程设计:一个完整的学生考勤管理系统,单文件 32KB,纯 Win32。源码单独建了仓库:Student-Attendance-System。它是跟着微软官方教程的代码一步一步搭起来的,但搭完之后,之前轨迹上的每样东西都在里面派上了用场:
控件编号用宏管理。 四组”姓名 / 年龄 / 学号 / 班级”标签加编辑框,每个控件一个宏编号——这是从 wind.c 里 (HMENU)1、(HMENU)2 的裸数字进化来的:
1 2 3 4 5 6 7 8 9
| #define ID_TEXT_NAME 11 #define ID_TEXT_AGE 12 #define ID_TEXT_ID 13 #define ID_TEXT_CLASS 14
#define ID_EDIT_NAME 21 #define ID_EDIT_AGE 22 #define ID_EDIT_ID 23 #define ID_EDIT_CLASS 24
|
静态标签 + 编辑框成对创建,CreateTextAndEdit 一个函数把整个录入表单铺出来:
1 2 3 4 5 6 7 8 9
| hLabUsername = CreateWindow( TEXT("static"), TEXT("姓名:"), WS_CHILD | WS_VISIBLE | SS_CENTERIMAGE | SS_RIGHT , 0, 20, 70, 26, hwndDlg, (HMENU)ID_TEXT_NAME, hInst, NULL);
|
ListView 当主界面——增删改查的载体。列名用 InsertListViewColumns 建好,学生数据一条条 InsertListViewItems 塞进去,选中删除走 DeleteListViewChooseItems。菜单挂了打开、保存、退出和编辑操作(复制/粘贴/剪切/删除)。整站三个窗口过程(主窗口、添加窗口、修改窗口)各管各的消息。
最值得一提的是它把前面”内存拼对话框”的技能直接复用了:添加和修改学生信息的弹窗,不是资源文件里的静态模板,而是运行时 GlobalAlloc + DLGTEMPLATE 现场拼出来的——学到的招式真的被用上了。
轨迹复盘
回头看这条从 2 月到 7 月的轨迹,有三个记忆点值得单独立此存照:
快速编辑模式的坑。 不摘掉它,控制台程序点一下鼠标就假死——这种坑只有踩过才知道,文档里绝不会写。
lpwAlign 的意义。 对齐这个问题,在结构体那一章人人都”学过”,但只有当你亲手在一段二进制里靠 lpwAlign(lpw) 跳过 2 个字节、否则整个对话框解析崩掉的时候,对齐才从考点变成常识。
藏起黑框。 ShowWindow(GetConsoleWindow(), SW_HIDE)——一行代码的仪式感,程序从”学 C 时的作业”变成”一个 Windows 程序”。
Win32 如今已经很老了,老到新项目都不好意思用它。但它就是 Windows 本身:今天所有现代框架的窗口、消息、控件,剥开壳都是在调这一套。要理解一个系统,没有比用它最裸的方式驱动它更好的入口了。