
简介一份使用C编写的记事本程序完整源代码面向有一定C语法基础、希望上手GUI编程的初学者也适合需要参考桌面应用项目结构的开发者。项目基于MFC/WinAPI搭建包含主对话框逻辑、关于窗口、菜单资源、图标文件等模块代码中涉及类与对象、消息映射、文件读取与保存、查找替换等典型操作能帮助读者将面向对象和STL知识应用到实际界面程序中。资源共21个文件以h头文件、cpp源文件为主辅以ico图标、rc资源脚本和Visual Studio工程文件整体仅64KB轻量便于对照学习。已有480人浏览学习。压缩包内附已编译的exe可直接运行观察效果源码目录还包含ReadMe说明适合用于课程设计或自学实践尤其能帮助理解对话框程序从资源定义到功能实现的全流程。 搞了这么多年C我越来越觉得很多初学者问怎么学Windows开发的时候上来就看MFC、看Qt结果被信号槽、消息映射绕得晕头转向。我的建议一直很简单先拿Win32 API手搓一个记事本出来把窗口创建、消息循环、控件的用法、文件读写这些东西全部过一遍Windows编程的底子才算是真正踩实了。这个项目小到一晚上能写完又没有依赖任何第三方库你所有的代码就是几十个函数加一些消息处理而这恰恰是理解桌面应用最干净的方式。这篇文章我就把整个实现路线、核心代码片段、我在实际写的过程中踩过的坑全部摊开讲。新手可以把它当成一份带讲解的完整参考老手也可以看看文件编码处理、查找替换这些细节有没有什么更好的思路。1. 为什么我推荐用C手写一个记事本1.1 麻雀虽小五脏俱全的项目价值很多人在简历上写熟悉C但问他写过什么实际的东西答案往往都是控制台程序。控制台程序练的是算法和语法跟软件差距还是挺大的。记事本这个项目的好处在于它虽然叫记事本但实际上把桌面应用最常见的几块全占了创建窗口和注册窗口类消息循环和窗口过程函数菜单资源和命令消息分发子控件Edit控件的创建和交互标准文件对话框的调用文件的读取、写入和编码转换剪贴板、快捷键、字体选择等系统级功能这些功能单独拆开都不难但串在一起就是一个完整的桌面软件。你做完一遍再去看MFC或者Qt的源码会突然发现很多东西都能对上了因为那些框架本质上就是在Win32 API外面包了一层壳。1.2 技术路线怎么选原生API、MFC还是Qt我见过不少人一上来就问记事本用Qt写是不是更快确实更快一个QTextEdit拖上去就完事了。但学习的目的不是完成一个记事本而是搞明白底层的运行机制。如果直接用框架你永远看不到消息循环长什么样也理解不了为什么控件消息要靠父窗口来分发。我把三条路线的差异列个表你对照着看技术路线依赖学习重点适合场景Win32 API系统自带零依赖窗口、消息、控件底层原理Windows平台入门理解系统机制MFCVisual Studio自带文档视图结构、消息映射维护老项目、快速开发Windows原生应用Qt需要引入第三方库跨平台封装、信号槽机制跨平台桌面应用开发我推荐用Win32 API理由有三个第一代码量最小核心代码就几百行每一个函数都是系统API你能看清每一步在干什么第二不依赖第三方库编译出来就是个很干净的小exe第三你以后做逆向、做外挂、做自动化测试底层全是这套东西逃不掉的。Qt那套封装在底层还是调的这些API。2. 开始之前的准备工作环境、工程与窗口骨架2.1 开发环境选择Visual Studio还是VSCode这个问题其实不用纠结。如果你在Windows上开发我首推Visual Studio哪怕是免费的Community版也行。新建项目的时候选Windows桌面应用程序向导会帮你生成一个带窗口的模板省去很多手工配置。有些人习惯用VSCode配MinGW也行但你要自己搞定tasks.json和launch.json写Win32程序还要注意链接user32.lib、gdi32.lib这些系统库Debug和Release的编译选项也不一样。如果你非要用VSCode我提几个要点编译器用mingw-w64不要把老版的Dev-C自带MinGW拿来用版本太旧容易出问题tasks.json里编译参数至少是g -municode -mwindows main.cpp -o notepad.exe-mwindows表示不使用控制台窗口-municode能让你用宽字符入口点调试要配好launch.json的miDebuggerPath指向gdb路径2.2 先让一个空窗口跑起来很多人学Win32最头疼的就是怎么连个窗口都弹不出来其实骨架是非常固定的。一个标准的Windows窗口程序由四部分组成WinMain入口、窗口类注册、窗口创建、消息循环再加上一个处理消息的WndProc窗口过程函数。最小骨架是这样#include windows.h LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { WNDCLASS wc {}; wc.lpfnWndProc WndProc; // 消息由谁处理 wc.hInstance hInstance; // 当前实例句柄 wc.lpszClassName LNotepadClass; // 窗口类名 wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.hCursor LoadCursor(NULL, IDC_ARROW); RegisterClass(wc); HWND hWnd CreateWindowW(LNotepadClass, L我的记事本, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL); ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return (int)msg.wParam; } LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProcW(hWnd, msg, wParam, lParam); }这段代码不需要背但你需要理解几个概念WNDCLASS注册的是窗口的模板WinMain里的循环不断从消息队列取消息然后派发给WndProc。DefWindowProcW是系统默认处理所有你没处理的消息都交给它。注意宽字符版本带W后缀的API要在代码里用L...字符串。现在写新代码尽量用W版本天然支持中文。3. 核心功能的下手顺序编辑区、菜单和文件读写3.1 给窗口塞进一个文本编辑区记事本的核心是文本编辑区。Windows系统自带了一个现成的Edit控件我们不需要自己写文本渲染和光标管理只要在WM_CREATE的时候创建一个Edit子窗口把它铺满主窗口就行#define ID_EDIT 1001 case WM_CREATE: { HWND hEdit CreateWindowW(LEDIT, L, WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL, 0, 0, 0, 0, hWnd, (HMENU)ID_EDIT, ((LPCREATESTRUCT)lParam)-hInstance, NULL); break; } case WM_SIZE: { HWND hEdit GetDlgItem(hWnd, ID_EDIT); MoveWindow(hEdit, 0, 0, LOWORD(lParam), HIWORD(lParam), TRUE); break; }几个重要的样式说明一下ES_MULTILINE是让Edit控件支持多行WS_VSCROLL和WS_HSCROLL是让它显示滚动条ES_AUTOVSCROLL和ES_AUTOHSCROLL是让它在内容超出范围时自动滚动。没有这些你还以为是自己代码写错了。3.2 把菜单挂上去处理命令消息文本编辑区有了接下来就是菜单。用CreateMenu和AppendMenuW创建菜单然后SetMenu挂到窗口上HMENU hMenu CreateMenu(); HMENU hFile CreateMenu(); AppendMenuW(hFile, MF_STRING, 1001, L新建(N)); AppendMenuW(hFile, MF_STRING, 1002, L打开(O)...); AppendMenuW(hFile, MF_STRING, 1003, L保存(S)); AppendMenuW(hFile, MF_STRING, 1004, L另存为(A)...); AppendMenuW(hFile, MF_SEPARATOR, 0, NULL); AppendMenuW(hFile, MF_STRING, 1005, L退出(X)); AppendMenuW(hMenu, MF_POPUP, (UINT_PTR)hFile, L文件(F)); SetMenu(hWnd, hMenu);注意这里菜单项ID用了1001、1002等数字它们会和WndProc里的WM_COMMAND消息绑定。当用户点击菜单Windows会发送WM_COMMAND消息给主窗口LOWORD(wParam)就是菜单项IDcase WM_COMMAND: { switch (LOWORD(wParam)) { case 1001: // 新建 SetWindowTextW(hEdit, L); break; case 1002: // 打开 OpenFile(hWnd, hEdit); break; case 1003: // 保存 SaveFile(hWnd, hEdit, g_szFilePath); break; case 1005: // 退出 DestroyWindow(hWnd); break; } break; }菜单项的ID在一个窗口内要唯一范围为0到0xF000。你也可以用WM_USER作为基准值来定义防止和其他控件冲突。3.3 打开、保存文件与编码处理文件读写是整个项目里最容易做崩的部分尤其是编码问题。Win32 API里的GetOpenFileNameW可以弹出系统标准文件选择框但它返回的只是一个路径字符串真正读写还需要你打开句柄、读字节流。打开文件的流程void OpenFile(HWND hWnd, HWND hEdit) { OPENFILENAMEW ofn {}; wchar_t szFile[MAX_PATH] L; ofn.lStructSize sizeof(ofn); ofn.hwndOwner hWnd; ofn.lpstrFilter L文本文件(*.txt)\0*.txt\0所有文件(*.*)\0*.*\0; ofn.lpstrFile szFile; ofn.nMaxFile MAX_PATH; ofn.Flags OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY; if (GetOpenFileNameW(ofn)) { HANDLE hFile CreateFileW(szFile, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile INVALID_HANDLE_VALUE) return; DWORD fileSize GetFileSize(hFile, NULL); if (fileSize 0) { CloseHandle(hFile); return; } char* buffer new char[fileSize 1]; DWORD bytesRead 0; ReadFile(hFile, buffer, fileSize, bytesRead, NULL); buffer[fileSize] \0; CloseHandle(hFile); // 这里牵扯到编码转换见下文 SetWindowTextA(hEdit, buffer); delete[] buffer; } }READ。这个版本没有处理好中文编码为什么因为Windows下记事本默认存的是UTF-8或UTF-16而SetWindowTextA默认按当前系统代码页解释中文系统一般是GBK。如果你直接把UTF-8的字节扔给它中文就全变成乱码了。正确做法是用MultiByteToWideChar把UTF-8或者ANSI字节转换成UTF-16宽字符再用SetWindowTextW设置。反过来写文件的时候用WideCharToMultiByte把宽字符转成UTF-8再写入。判断文件编码的通用做法是看文件头BOM编码格式BOM字节处理方式UTF-8EF BB BF跳过BOMMultiByteToWideChar用CP_UTF8UTF-16 LEFF FE跳过BOM直接按wchar_t数组读ANSI无用MultiByteToWideChar传CP_ACP完整实现// 读取并转换到UTF-16宽字符 int codePage CP_ACP; // 默认ANSI if (fileSize 3 (UCHAR)buffer[0] 0xEF (UCHAR)buffer[1] 0xBB (UCHAR)buffer[2] 0xBF) { codePage CP_UTF8; buffer 3; // 跳过BOM } int wLen MultiByteToWideChar(codePage, 0, buffer, -1, NULL, 0); wchar_t* wBuffer new wchar_t[wLen]; MultiByteToWideChar(codePage, 0, buffer, -1, wBuffer, wLen); SetWindowTextW(hEdit, wBuffer); delete[] wBuffer;这个坑我当年真的踩了一晚上你会发现网上很多教程的记事本代码都没有处理编码中文要么乱码要么打开报错。这里你要是不想做太复杂就先处理UTF-8和ANSI两种日常够用。4. 锦上添花的细节查找替换、字体设置与未保存提醒4.1 查找与替换其实很简单写完打开保存记事本已经能用了。但要实现够日常用的功能还得有查找替换。别担心Edit控件本身就支持选区操作查找的核心逻辑就是遍历文本找子串然后用EM_SETSEL选中它。步骤拆开用GetWindowTextW拿到整个编辑区的文本用wcsstr从当前位置开始查找子串找到后用SendMessage(hEdit, EM_SETSEL, start, end)设置选区用SendMessage(hEdit, EM_SCROLLCARET, 0, 0)让光标滚动到可见位置替换就更简单了选中目标子串后用EM_REPLACESEL消息让Edit控件自动替换// 查找从当前光标位置往后找 wchar_t* pos wcsstr(g_pText g_nSearchPos, findText); if (pos) { int start pos - g_pText; SendMessage(hEdit, EM_SETSEL, start, start wcslen(findText), 0); SendMessage(hEdit, EM_SCROLLCARET, 0, 0); g_nSearchPos start 1; } // 替换单个先查找选中再替换 SendMessage(hEdit, EM_REPLACESEL, TRUE, (LPARAM)replaceText);一个实用的小技巧查找对话框不要做成模态窗口否则编辑区没法交互。用非模态对话框每次点击查找按钮时同步更新当前位置。这块是用户体验和代码复杂度之间一个很典型的平衡。4.2 字体修改与文本布局系统自带的ChooseFontW对话框能弹出字体选择器返回后只需要把选中的字体应用到Edit控件上CHOOSEFONTW cf {}; LOGFONTW lf {}; cf.lStructSize sizeof(cf); cf.hwndOwner hWnd; cf.lpLogFont lf; cf.Flags CF_SCREENFONTS | CF_EFFECTS; if (ChooseFontW(cf)) { HFONT hFont CreateFontIndirectW(lf); SendMessage(hEdit, WM_SETFONT, (WPARAM)hFont, TRUE); // 注意老字体记得DeleteObject新字体在窗口销毁时清理 }还有个小问题默认Edit控件没有设置字体前用的是系统默认字体高DPI下面看起来会有点糊。所以在WM_CREATE之后可以立刻调一次SendMessage(hEdit, WM_SETFONT, ...)设置成默认UI字体或等宽字体。4.3 未保存提醒的思路未保存提醒很多新手不会做其实核心就两件事维护一个内容是否被修改过的标记在WM_CLOSE里询问用户。按以下方式维护脏标记在WM_COMMAND接收到编辑区内容变化时对Edit控件需要把父窗口的EN_CHANGE通知重定向过来处理把脏标记置为TRUE在新建、打开、保存成功后置为FALSE在关闭窗口前检查脏标记为TRUE就弹MessageBoxW询问是否保存这里最容易忽略的是对Edit控件通知消息的处理。CreateWindow创建Edit时如果指定了WS_EX_CLIENTEDGE或者默认行为并不会主动发EN_CHANGE给父窗口你需要给Edit设置一个样式或通过EN_UPDATE处理。简单一点的办法是子类化Edit控件用SetWindowLongPtrW替换它的窗口过程或者干脆在父窗口的WM_COMMAND里判断HIWORD(wParam) EN_CHANGE。5. 实际调试中我踩过的坑5.1 中文乱码前面的代码已经提到了这里专门强调一遍。乱码的本质是编码不一致但具体表现有几种打开UTF-8文件显示乱码因为你用ANSI方式设置文本保存后再打开乱码因为你写文件时没有统一用UTF-8编译时L中文字符串在源码里正常运行时乱码保存源文件时建议统一用UTF-8 with BOM或者用宽字符字面量L...建议把保存固定成UTF-8 with BOM这样最兼容Windows记事本和绝大多数编辑器。5.2 文件路径长度和句柄泄漏MAX_PATH是260个字符很多人会忽略这个限制。当用户选了很深的目录文件名加上路径超过260的时候GetOpenFileNameW会直接失败回。Windows长路径版本要求路径以\\?\开头这个处理起来比较繁琐但在工具类软件里可以接受现在的限制。句柄泄漏也是大头。CreateFileW打开的句柄用完后必须CloseHandle。文件对话框出的缓冲区是栈上数组没问题。但凡是new[]出来的内存都记得delete[]尤其是处理文件内容那段频繁开关文件时最容易出问题。5.3 消息处理里的坑最后说一个很多人理解错的地方。当你在WM_COMMAND里处理打开文件如果用到了MessageBoxW弹窗要注意这个弹窗会重新进入消息循环用户点击操作可能触发其他WM_COMMAND造成重入。解决方案是在处理菜单命令前先存一份关键状态的副本或者把耗时操作放到线程里去记事本这种小文件没必要但概念得知道。我还遇到过一个问题DPI缩放。默认情况下Windows会对程序进行DPI虚拟化导致在高分屏上字体发虚。如果你想做得更细致在WinMain开头调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)然后代码里就要正确处理WM_DPICHANGED消息了。这个属于进阶内容新手可以先不做但得知道有这回事。结尾一点实际操作中的感受写完这个记事本我自己最大的收获倒不是会调API了而是理解了一个道理Windows程序就是消息驱动的事件系统整个程序本质上是一个无限循环在分发消息你的所有功能都是对某条消息的响应。想清楚这个模型之后再看任何GUI框架都通透很多。如果看完这篇文章你想动手我建议给自己定个时间先照着骨架自己敲一遍然后不看别人的代码自己试着把打开、保存、新建三个功能补全。卡住了再回来翻这段。相信我这个过程走过一遍你对C和Windows编程的把握会完全不一样。最后再分享一个小技巧调试这种GUI程序的时候留一个OutputDebugStringW的调试输出把所有关键消息和状态变化都打印出来。用DebugView查看比断点调试更符合事件驱动的思维方式很多问题一眼就能看出来。本文还有配套的精品资源点击获取