十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言Win32 API窗口编程入门:从控制台到窗体应用实战

C语言Win32 API窗口编程入门:从控制台到窗体应用实战 1. 为什么窗口程序不是高级玩法而是C语言学习的一道分水岭很多人学C语言学到指针和链表就卡住了觉得再往下就是无尽的算法题和黑框框。我当年也是这样对着控制台输出一堆字符心里想的是这玩意儿到底能做出什么看得见的东西后来有一次帮朋友做一个串口调试的小工具需求很简单——一个能选串口号、设置波特率、收发数据的界面。我第一反应是用Python写但朋友那边环境受限只装了DevC于是硬着头皮用纯C加Win32 API把窗口程序啃了下来。做完之后我才意识到窗体应用不是C语言的高级玩法而是理解操作系统如何与你的代码交互的最佳入口。你平时写的printf和scanf本质上是在跟一个已经帮你封装好的控制台打交道。控制台替你处理了窗口创建、消息循环、键盘输入解析这些脏活累活。而当你用Win32 API创建一个窗口时你才真正接触到操作系统给你暴露的原始接口窗口类注册、消息队列、窗口过程函数、GDI绘图。这些东西听起来吓人但拆开来看无非就是告诉系统我要一个什么样的窗口然后系统把用户的操作翻译成消息发给你你在一个回调函数里处理这些消息。这篇文章面向的是那些已经掌握C语言基础语法、写过一些控制台程序、但从未接触过Windows窗口编程的读者。我会用DevC作为开发环境因为它安装简单、体积小、对新手友好而且自带MinGW编译器可以直接调用Win32 API。整篇文章会从零开始带你写出一个完整的窗体应用包含窗口创建、按钮响应、文本输出、文件保存这些实用功能。所有代码都是完整的、可编译运行的你可以直接复制到DevC里跑起来。提示本文假设你使用的是Windows系统DevC版本为5.11或更高。如果你用的是其他IDE比如Visual Studio或Code::Blocks代码本身是通用的但项目配置方式会有所不同。在开始写代码之前我想先讲清楚一个概念Win32 API不是一门新的语言它只是一组C函数和结构体的集合。你不需要学习新的语法只需要理解几个核心概念——窗口类、窗口句柄、消息、窗口过程。这四个概念搞懂了剩下的就是查文档和积累经验的事。2. 在DevC里搭建Win32项目的正确姿势2.1 为什么不能直接新建Console Application打开DevC你习惯性地点击文件→新建→项目然后看到一堆选项Console Application、Windows Application、Static Library、DLL……很多人会直接选Console Application因为熟悉。但如果你要写窗口程序必须选择Windows Application这个选项会在链接时使用-mwindows标志告诉编译器生成一个GUI子系统程序而不是控制台子系统程序。两者的区别是什么如果你用Console Application写窗口代码程序运行时会在背后弹出一个黑色的控制台窗口虽然不影响功能但看起来很不专业。更重要的是某些Win32 API在控制台子系统下的行为可能略有不同比如GetModuleHandle(NULL)返回的句柄在两种模式下是一样的但消息循环的处理在GUI子系统下更自然。具体操作步骤打开DevC点击菜单栏的文件→新建→项目在弹出的对话框中选择Windows Application项目名称填FirstWindow语言选择C点击确定选择一个保存路径DevC会自动生成一个main.c文件里面已经包含了一个最简单的Win32窗口模板DevC自动生成的模板代码大概长这样#include windows.h LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { // ... 窗口创建代码 }这个模板已经帮你搭好了骨架但很多人直接编译运行后发现窗口一闪而过或者根本看不到窗口。问题出在哪里模板里的消息循环和窗口显示逻辑需要你手动补全。DevC的模板只给了最基本的框架WndProc函数是空的ShowWindow和UpdateWindow调用可能缺失。2.2 项目属性里必须检查的两个设置在写代码之前先检查项目属性。右键点击左侧的项目名称选择项目属性重点看两个地方第一个是编译器选项卡下的在连接器命令行加入以下命令。对于Windows Application项目这里应该自动填了-mwindows。如果没有手动加上。这个标志的作用是告诉链接器这是一个GUI程序不需要控制台窗口。第二个是文件/目录选项卡下的包含文件目录和库文件目录。正常情况下DevC会自动配置好MinGW的路径但如果你安装时改了路径或者用了绿色版可能需要手动指定。包含文件目录应该指向MinGW的include文件夹库文件目录指向lib文件夹。注意如果你编译时遇到undefined reference toTextOutA之类的错误八成是链接器没有找到gdi32库。Win32的GDI函数都在gdi32.dll里需要在项目属性里添加-lgdi32。不过DevC的Windows Application模板通常会自动链接这个库遇到问题时再检查。2.3 一个容易被忽略的细节字符集问题DevC默认使用ANSI字符集也就是说MessageBox实际上调用的是MessageBoxATextOut调用的是TextOutA。如果你从网上复制了一段使用MessageBoxW的代码可能会编译报错或者显示乱码。统一使用ANSI版本也就是不带W后缀的函数这样在DevC里最省心。如果你确实需要处理Unicode字符比如显示中文路径可以在项目属性里添加-DUNICODE -D_UNICODE然后把所有字符串用TEXT()宏包裹起来。但对于初学者来说先用ANSI把基本流程跑通后面再考虑字符集的问题。3. 从WinMain到消息循环一个窗口的完整生命周期3.1 WinMain函数的四个参数到底有什么用WinMain是Windows程序的入口点相当于控制台程序的main。它的签名是int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)四个参数的含义hInstance当前程序的实例句柄。你可以把它理解为这个程序在内存中的身份证号。创建窗口类、加载资源时都需要用到它。hPrevInstance在Win16时代用来表示前一个实例现在永远是NULL。直接忽略即可。lpCmdLine命令行参数字符串不包括程序名本身。比如你用FirstWindow.exe hello启动这个参数就是hello。nCmdShow窗口的显示方式比如SW_SHOW表示正常显示SW_SHOWMAXIMIZED表示最大化。这个值由系统根据启动方式决定你只需要把它传给ShowWindow就行。很多人写Win32程序时习惯把hInstance存到一个全局变量里因为WndProc函数是回调函数没法直接访问WinMain的局部变量。这是一个很实用的技巧HINSTANCE g_hInstance; int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { g_hInstance hInstance; // ... }3.2 注册窗口类告诉系统我要一个什么样的窗口创建窗口之前必须先注册一个窗口类。窗口类不是C里的类而是一个结构体WNDCLASSEX它描述了窗口的各种属性图标、光标、背景色、窗口过程函数等。WNDCLASSEX wc; wc.cbSize sizeof(WNDCLASSEX); wc.style CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc WndProc; wc.cbClsExtra 0; wc.cbWndExtra 0; wc.hInstance hInstance; wc.hIcon LoadIcon(NULL, IDI_APPLICATION); wc.hCursor LoadCursor(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.lpszMenuName NULL; wc.lpszClassName MyWindowClass; wc.hIconSm LoadIcon(NULL, IDI_APPLICATION); if (!RegisterClassEx(wc)) { MessageBox(NULL, 窗口类注册失败, 错误, MB_ICONERROR); return 0; }几个关键字段的解释styleCS_HREDRAW | CS_VREDRAW表示当窗口水平或垂直尺寸变化时整个窗口重绘。对于简单应用来说这个设置最省事。lpfnWndProc窗口过程函数的指针。这是整个程序的核心所有消息都在这里处理。hbrBackground窗口背景画刷。COLOR_WINDOW 1是系统预定义的颜色对应窗口的默认背景色通常是白色。lpszClassName窗口类的名称。这个名字是你自己起的创建窗口时要用同样的名字。为什么需要注册窗口类因为Windows是一个多任务操作系统可能同时运行着成千上万个窗口。系统需要一种方式来区分不同类型的窗口——按钮是一种窗口类编辑框是一种窗口类你自己创建的窗口也是一种窗口类。注册窗口类就是向系统登记我要创建一个叫MyWindowClass的窗口类型它的行为由WndProc函数负责。3.3 创建窗口与显示窗口那些容易搞混的参数注册完窗口类就可以创建窗口了HWND hWnd CreateWindowEx( 0, MyWindowClass, 我的第一个窗体应用, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL ); if (hWnd NULL) { MessageBox(NULL, 窗口创建失败, 错误, MB_ICONERROR); return 0; } ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd);CreateWindowEx的参数很多但常用的就那几个参数位置含义常用值第1个扩展样式0第2个窗口类名与注册时一致第3个窗口标题显示在标题栏第4个窗口样式WS_OVERLAPPEDWINDOW第5、6个窗口位置CW_USEDEFAULT第7、8个窗口大小宽、高像素第9个父窗口句柄NULL第10个菜单句柄NULL第11个实例句柄hInstance第12个附加参数NULLShowWindow和UpdateWindow的区别经常让人困惑。ShowWindow是告诉系统把这个窗口显示出来但系统只是把它标记为可见实际绘制可能还没发生。UpdateWindow则是强制立即发送WM_PAINT消息让窗口马上绘制自己。两个都要调用否则窗口可能显示为空白或者延迟显示。3.4 消息循环程序的心脏窗口创建并显示之后程序进入消息循环MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return msg.wParam;这个循环看起来简单但它是整个Windows程序运转的核心。GetMessage从消息队列里取出一条消息如果没有消息就阻塞等待。TranslateMessage把键盘的虚拟键码转换成字符消息比如把WM_KEYDOWN转换成WM_CHAR。DispatchMessage把消息分发到对应的窗口过程函数。为什么需要消息循环因为Windows程序是事件驱动的。你的代码不是从头到尾顺序执行的而是等待用户操作点击、输入、移动鼠标系统把这些操作翻译成消息你的程序再响应。消息循环就是不断从队列里取消息、处理消息的过程。当GetMessage返回0时表示收到了WM_QUIT消息循环结束程序退出。msg.wParam就是PostQuitMessage传入的退出码。4. 窗口过程函数所有交互逻辑的汇聚点4.1 WndProc的签名与调用时机窗口过程函数的签名是固定的LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam)四个参数hWnd接收消息的窗口句柄。如果你创建了多个窗口可以通过这个参数区分。msg消息ID比如WM_CREATE、WM_PAINT、WM_DESTROY。wParam和lParam消息的附加参数含义取决于具体的消息类型。这个函数是回调函数由系统调用你不需要主动调用它。系统会在合适的时机调用它比如窗口创建时、需要重绘时、用户点击关闭按钮时。4.2 必须处理的几个核心消息一个最简单的窗口过程至少需要处理三个消息LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: // 窗口创建时调用可以在这里创建子控件 break; case WM_PAINT: // 窗口需要重绘时调用 { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 在这里绘图 EndPaint(hWnd, ps); } break; case WM_DESTROY: // 窗口销毁时调用 PostQuitMessage(0); break; default: return DefWindowProc(hWnd, msg, wParam, lParam); } return 0; }WM_CREATE在窗口创建过程中发送此时窗口还没有完全创建好但你可以在这里创建子控件按钮、编辑框等。WM_PAINT在窗口需要重绘时发送比如窗口第一次显示、被其他窗口遮挡后重新露出、用户调整窗口大小时。WM_DESTROY在窗口被销毁时发送通常在这里调用PostQuitMessage来结束消息循环。DefWindowProc的作用对于你不处理的消息必须调用DefWindowProc让系统进行默认处理。比如窗口移动、调整大小、最小化、最大化这些操作系统都有默认行为。如果你不调用DefWindowProc窗口就会失去这些默认行为变得不听话。4.3 在WM_PAINT里绘制文本和图形WM_PAINT是绘图的主要场所。所有的绘图操作都必须在BeginPaint和EndPaint之间进行case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 设置文本颜色 SetTextColor(hdc, RGB(0, 0, 255)); // 设置背景模式为透明 SetBkMode(hdc, TRANSPARENT); // 输出文本 TextOut(hdc, 50, 50, 欢迎使用我的第一个窗体应用, 26); // 画一个矩形 Rectangle(hdc, 50, 100, 300, 200); // 画一条线 MoveToEx(hdc, 50, 250, NULL); LineTo(hdc, 300, 250); EndPaint(hWnd, ps); } break;HDC是设备上下文句柄你可以把它理解为绘图画布。所有的绘图函数都需要这个句柄。TextOut在指定位置输出文本Rectangle画矩形MoveToEx和LineTo画线。一个常见的坑TextOut的最后一个参数是字符串长度。如果你写TextOut(hdc, 50, 50, Hello, 5)没问题。但如果你写TextOut(hdc, 50, 50, Hello, 6)就会多输出一个字符可能是乱码。对于中文字符串长度要特别注意因为一个中文字符在ANSI编码下占2个字节。更安全的做法是用_tcslen或者strlen来计算长度。提示如果你在WM_PAINT之外的地方需要绘图比如响应按钮点击时更新显示不能直接用BeginPaint而应该用GetDC和ReleaseDC。两者的区别是BeginPaint会清除背景并标记重绘区域为有效GetDC不会。4.4 处理键盘和鼠标消息除了绘图窗口过程还需要处理用户输入。键盘消息主要有WM_KEYDOWN、WM_KEYUP、WM_CHAR。鼠标消息有WM_LBUTTONDOWN、WM_LBUTTONUP、WM_MOUSEMOVE等。case WM_KEYDOWN: if (wParam VK_ESCAPE) { DestroyWindow(hWnd); } break; case WM_LBUTTONDOWN: { int x LOWORD(lParam); int y HIWORD(lParam); char buf[64]; sprintf(buf, 点击位置(%d, %d), x, y); MessageBox(hWnd, buf, 鼠标点击, MB_OK); } break;WM_KEYDOWN的wParam是虚拟键码VK_ESCAPE是Esc键。WM_LBUTTONDOWN的lParam包含了鼠标坐标低16位是x坐标高16位是y坐标用LOWORD和HIWORD宏提取。为什么键盘消息有WM_KEYDOWN和WM_CHAR两种WM_KEYDOWN表示某个键被按下了不管这个键对应什么字符。WM_CHAR表示一个字符被输入了系统已经帮你处理了Shift、Caps Lock等状态。如果你需要处理功能键F1、方向键用WM_KEYDOWN如果你需要处理文本输入用WM_CHAR。5. 给窗口加上按钮和编辑框子控件的创建与消息处理5.1 在WM_CREATE里创建子控件一个只有绘图功能的窗口还不够实用。真正的窗体应用需要按钮、编辑框、列表框这些控件。在Win32 API里这些控件本质上也是窗口只是窗口类不同。#define ID_BUTTON_SAVE 1001 #define ID_EDIT_CONTENT 1002 case WM_CREATE: { // 创建编辑框 HWND hEdit CreateWindowEx( WS_EX_CLIENTEDGE, EDIT, , WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL, 20, 20, 400, 300, hWnd, (HMENU)ID_EDIT_CONTENT, g_hInstance, NULL ); // 创建按钮 HWND hButton CreateWindowEx( 0, BUTTON, 保存到文件, WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 20, 340, 120, 40, hWnd, (HMENU)ID_BUTTON_SAVE, g_hInstance, NULL ); } break;几个关键点子控件的父窗口是主窗口hWnd样式必须包含WS_CHILD | WS_VISIBLE。控件ID通过(HMENU)强制转换传入用于在WM_COMMAND里区分不同的控件。编辑框的ES_MULTILINE样式允许多行输入ES_AUTOVSCROLL让编辑框自动滚动。WS_EX_CLIENTEDGE给编辑框加上凹陷的边框看起来更自然。5.2 WM_COMMAND按钮点击的处理当用户点击按钮时系统会向父窗口发送WM_COMMAND消息。wParam的低16位是控件ID高16位是通知码。对于按钮点击通知码是BN_CLICKED。case WM_COMMAND: { int wmId LOWORD(wParam); int wmEvent HIWORD(wParam); switch (wmId) { case ID_BUTTON_SAVE: if (wmEvent BN_CLICKED) { // 获取编辑框内容 char buffer[4096]; HWND hEdit GetDlgItem(hWnd, ID_EDIT_CONTENT); GetWindowText(hEdit, buffer, sizeof(buffer)); // 保存到文件 FILE *fp fopen(output.txt, w); if (fp) { fprintf(fp, %s, buffer); fclose(fp); MessageBox(hWnd, 保存成功, 提示, MB_OK | MB_ICONINFORMATION); } else { MessageBox(hWnd, 文件保存失败, 错误, MB_OK | MB_ICONERROR); } } break; } } break;GetDlgItem通过控件ID获取控件句柄GetWindowText获取控件里的文本。对于编辑框GetWindowText会返回用户输入的所有内容。一个容易踩的坑GetWindowText对于编辑框控件返回的文本长度可能超过你提供的缓冲区大小。如果缓冲区太小文本会被截断。更安全的做法是先调用GetWindowTextLength获取长度然后动态分配内存。不过对于简单应用给一个足够大的缓冲区比如4096字节通常就够了。5.3 控件字体的设置默认情况下Win32控件的字体是系统字体看起来又小又丑。你可以用WM_SETFONT消息给控件设置更好的字体HFONT hFont CreateFont( 20, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, 微软雅黑 ); SendMessage(hEdit, WM_SETFONT, (WPARAM)hFont, TRUE); SendMessage(hButton, WM_SETFONT, (WPARAM)hFont, TRUE);CreateFont的参数很多但常用的就是前两个高度和宽度。高度设为20表示字体高度为20像素宽度设为0表示由系统自动计算。最后一个参数是字体名称用微软雅黑或者宋体都可以。注意CreateFont创建的字体对象在程序退出前应该用DeleteObject删除否则会泄漏GDI资源。不过对于简单应用程序退出时系统会自动回收问题不大。但如果你在循环里反复创建字体就一定要记得删除。6. 编译、调试与发布那些文档里不会写的经验6.1 DevC编译Win32程序的常见报错与解决报错1undefined reference to CreateWindowExA原因链接器没有找到user32库。解决方法在项目属性→编译器→连接器命令行里添加-luser32。不过DevC的Windows Application模板通常会自动链接遇到时检查一下。报错2cannot convert const char* to LPCWSTR原因项目被配置成了Unicode字符集但代码里用的是ANSI字符串。解决方法要么把项目改成ANSI去掉-DUNICODE要么把所有字符串用TEXT()宏包裹。报错3程序运行后窗口一闪而过原因消息循环写错了或者WM_DESTROY里没有调用PostQuitMessage。检查GetMessage的返回值处理确保循环能正常退出。报错4窗口显示为空白原因WM_PAINT里没有调用BeginPaint和EndPaint或者UpdateWindow没有调用。确保绘图代码在BeginPaint和EndPaint之间。6.2 用MessageBox调试Win32程序Win32程序没有控制台printf没法用。最简单的调试方法是用MessageBoxchar debug[256]; sprintf(debug, hWnd %p, msg %u, hWnd, msg); MessageBox(NULL, debug, 调试信息, MB_OK);把这段代码放在WndProc的开头可以看到每条消息的ID和窗口句柄。虽然原始但非常有效。另一个技巧是用OutputDebugString它会把字符串输出到调试器。在DevC里你需要用调试模式运行才能看到输出。不过DevC的调试器对Win32程序支持一般MessageBox更直接。6.3 发布程序时需要注意的细节当你写好程序想要发给别人使用时直接编译出来的exe文件就可以运行。但有几个细节需要注意第一确保没有依赖DevC的运行时库。MinGW编译的程序默认是静态链接的不依赖额外的DLL。但如果你用了某些特殊的库可能需要把对应的DLL一起打包。第二图标和版本信息。默认编译出来的exe图标是DevC的图标你可以通过资源文件.rc来设置自定义图标。不过对于初学者来说这一步可以跳过。第三杀毒软件误报。MinGW编译的程序有时会被某些杀毒软件误报为病毒。这是编译器的特征导致的不是你的代码有问题。如果遇到这种情况可以尝试用不同的编译选项或者向杀毒软件厂商提交误报。提示如果你想让程序在别人的电脑上也能运行最好在Windows 7、Windows 10、Windows 11上都测试一下。Win32 API的兼容性很好但某些新函数在旧系统上可能不存在。6.4 从窗体应用继续深入的方向写完第一个窗体应用之后你可能会想接下来学什么我的建议是先把这个程序改造成一个真正有用的小工具。比如加一个菜单栏支持打开文件和另存为加一个状态栏显示当前光标位置加一个查找替换功能支持拖拽文件到窗口打开这些功能都需要你学习新的APICreateMenu、CreateStatusWindow、DragAcceptFiles等。每学一个新API你对Win32的理解就深一层。另一个方向是学习GDI或者Direct2D它们提供了更强大的绘图能力可以画出抗锯齿的图形和渐变效果。不过那是另一个话题了先把基础的GDI搞扎实。我个人在实际操作中的体会是Win32 API的学习曲线确实陡峭但它的好处是一旦学会你对Windows程序的理解会达到一个全新的层次。以后再学MFC、Qt、WPF这些框架时你会知道它们底层在做什么遇到问题时也能更快定位。而且用纯C写窗口程序代码量虽然大但每一行都是可控的没有黑盒魔法。这种感觉是使用高级框架时体会不到的。
返回列表