C++ Win32记事本源码解析:消息循环、EDIT控件与编码保存 简介这是一份用 C 实现类记事本程序的完整源代码面向希望从课堂语法走向 GUI 实战的初学者与 C 学习者可用于理解窗口创建、消息处理、文本编辑与文件读写的基本流程。压缩包共 21 个文件约 64KB包含 6 个头文件、5 个 cpp 源文件以及图标位图、资源脚本、旧版工程配置和可直接运行的可执行文件头文件与源文件对应类声明和功能实现资源文件则服务于菜单、对话框与界面外观。已有 483 人学习说明其对入门参考具有一定可用性。通过梳理工程结构与代码组织读者可以观察 MFC/WinAPI 下界面与逻辑的分层方式学习打开、保存文本及用户交互的实现思路并据此搭建自己的小型 C 桌面项目。1. 一份记事本 C 源代码值不值得你花一晚上把它编译出来很多人第一次见到记事本 C 源代码时的反应是这能有几行解压之后发现窗口过程、菜单命令、编辑区、文件读写、查找替换、状态栏一个不少代码量又刚好能在一个晚上读完。它真正能解决的问题是给你一个完整的桌面文本编辑器闭环消息循环怎么转、编辑控件怎么使用、文件编码怎么判断、命令 ID 怎么分发、脏标记怎么维护全都能跑起来验证。适合两类人刚学完 C 语法、需要一个能编译能改动的真实工程练手的以及手上有老桌面项目、需要一份干净参考实现的。下面按先跑起来、再看骨架、最后动手改的顺序拆。2. 先让工程跑起来Win32 窗口骨架与 EDIT 控件的四个参数拿到源码最先做的一件事不是读是编译。读一份跑不起来的源码你分不清哪段是设计、哪段是残留。这一章先把工程结构和入口理清再把编辑区这个核心控件建出来后面所有功能都挂在它上面。2.1 工程文件分工与一次命令行编译这类源码包的目录通常长这样先建立一个预期哪个文件是骨架、哪个是血肉、哪个改起来最容易出问题。文件职责改动频率main.cpp入口函数、注册窗口类、消息循环低wndproc.cpp窗口过程所有消息从这分发高editor.cpp编辑控件封装取文本、设文本、定位光标高fileio.cpp打开、保存、编码识别中resource.rc菜单、加速键、图标、字符串中resource.h命令 ID 宏必须跟着 rc 同步改用 IDE 打开解决方案时有三处设置先确认平台选 x64x86 也能编但指针宽度会影响一部分强转代码、字符集设为 Unicode、链接器子系统设为 Windows。这三项任意一项不对最常见的表现就是编译通过但链接报入口点找不到或者程序起来闪一下窗口就没了。不想开 IDE 的话在开发者命令提示符里一条命令也能出 exe:: 一次性编译 链接输出 MiniNote.exe cl /nologo /EHsc /std:c17 /DUNICODE /D_UNICODE ^ main.cpp wndproc.cpp editor.cpp fileio.cpp ^ /link /SUBSYSTEM:WINDOWS /OUT:MiniNote.exe ^ user32.lib gdi32.lib comdlg32.lib comctl32.lib/DUNICODE /D_UNICODE这两个宏是必须的源码里大量L...字面量和...W后缀的 API 调用缺了宏定义会退化成 ANSI 版本中文在编译期就报警告。/SUBSYSTEM:WINDOWS告诉链接器入口是wWinMain而不是main写错这一项就是那个经典的无法解析的外部符号 WinMain。/std:c17不是硬要求但源码里如果用了std::wstring::data()返回可写指针这种写法低于 C17 编不过。2.2 入口函数与消息循环的初始化顺序入口这段代码几乎是所有 Win32 程序的模板但顺序错了会出玄学问题先ShowWindow后注册窗口类窗口不出来消息循环里漏掉IsDialogMessageTab 键在控件间跳不过去。// main.cpp —— 注册窗口类 - 建窗口 - 转消息循环 int APIENTRY wWinMain(HINSTANCE hInst, HINSTANCE hPrev, LPWSTR lpCmdLine, int nCmdShow) { // 1. 注册窗口类。CS_HREDRAW | CS_VREDRAW 保证窗口缩放时客户区强制重绘 WNDCLASSEXW wc { sizeof(wc) }; wc.lpfnWndProc WndProc; // 消息都从这个函数进来 wc.hInstance hInst; wc.hCursor LoadCursorW(nullptr, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); // 用系统色跟着主题走 wc.lpszClassName LMiniNoteWnd; wc.hIcon LoadIconW(hInst, MAKEINTRESOURCEW(IDI_APP)); if (!RegisterClassExW(wc)) return 0; // 2. 创建主窗口。菜单不在这里传由资源脚本在 WM_CREATE 里挂上去 HWND hWnd CreateWindowExW(0, wc.lpszClassName, L未命名 - 记事本, WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, CW_USEDEFAULT, CW_USEDEFAULT, 900, 640, nullptr, nullptr, hInst, nullptr); if (!hWnd) return 0; ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); // 3. 消息循环。GetMessageW 返回 0 退出返回 -1 是出错两种情况要分开判 MSG msg; while (true) { BOOL ret GetMessageW(msg, nullptr, 0, 0); if (ret 0) break; // 收到 WM_QUIT正常退出 if (ret -1) return -1; // 消息队列异常直接放弃 if (!IsDialogMessageW(hWnd, msg)) // 让 Tab / 方向键在控件间导航 { TranslateAcceleratorW(hWnd, hAccel, msg); // 加速键在这里拦一道 TranslateMessage(msg); DispatchMessageW(msg); } } return (int)msg.wParam; }几个参数值得单独说。hbrBackground用COLOR_WINDOW 1而不是自己建画刷好处是系统切深色主题时背景跟着变代价是你没法自由定制颜色想定制就得自己CreateSolidBrush并在退出时DeleteObject。WS_CLIPCHILDREN这个样式容易被忽略它的作用是父窗口绘制时跳过子控件占用的矩形不加的话拉伸窗口时编辑区会明显闪一下。TranslateAcceleratorW的返回值不能丢它返回非零表示这条消息已经被快捷键消费掉了继续往下DispatchMessage会导致同一条命令执行两次——保存文件时按一次 CtrlS 弹两个对话框就是这个原因。2.3 编辑区控件的样式组合整个程序的主角是那个 EDIT 控件它由系统预注册不需要自己写窗口过程但样式参数组合错了就是一堆行为异常。// wndproc.cppWM_CREATE 分支里创建编辑区 HWND hEdit CreateWindowExW( WS_EX_CLIENTEDGE, // 凹陷边框和客户区分开 LEDIT, nullptr, WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL | ES_WANTRETURN | ES_NOHIDESEL, 0, 0, 0, 0, // 尺寸先给 0WM_SIZE 里统一调整 hWnd, (HMENU)IDC_EDIT, hInst, nullptr); // 关键一步多行 EDIT 默认上限 32767 字符不解除写长文本会被静默截断 SendMessageW(hEdit, EM_SETLIMITTEXT, 0x7FFFFFFE, 0); // 换成等宽字体否则行号列号对不齐 SendMessageW(hEdit, WM_SETFONT, (WPARAM)GetStockObject(SYSTEM_FIXED_FONT), TRUE);样式里几个参数的实际影响ES_AUTOVSCROLL让光标到可视区域底部时自动滚不加就得手动滚ES_AUTOHSCROLL配ES_MULTILINE时会让长行横向滚动而不是折行想要自动换行的体验就得把这个样式去掉ES_NOHIDESEL保证编辑区失焦时选中文字仍然是高亮状态不加的话点一下菜单选中的文字就看不见了用户会以为选区丢了ES_WANTRETURN让回车键插入换行而不是触发默认按钮单行控件不需要但多行必须加。EM_SETLIMITTEXT那个参数传0x7FFFFFFE是经验值传 0 在不同系统版本上语义不一致有的版本当无限制有的当清零。这一行不加你粘贴一篇八千字的技术文档进去只会得到前 32767 个字符还不报错属于典型的黑匣子行为。3. 文本层怎么落地EDIT 消息、编码判断与查找替换的实现路径窗口能出来、能打字之后接下来是把文本当成数据来处理。这一层是三块怎么从控件里安全地取文本、怎么判断文件的编码、怎么实现查找替换。三块都有坑而且坑都在边界上。3.1 用 EM_ 系列消息操作编辑区不要用GetWindowText直接读编辑区虽然能用但它是为窗口标题设计的跨进程场景下有额外开销。规范做法是先问长度再取内容缓冲区长度必须比字符数多 1。// 取编辑区全部文本两段式先问长度再分配 std::wstring GetEditText(HWND hEdit) { int len GetWindowTextLengthW(hEdit); // 不含结尾 0 if (len 0) return L; std::wstring buf(static_castsize_t(len) 1, L\0); // 多留一位给 0 int copied GetWindowTextW(hEdit, buf.data(), len 1); buf.resize(copied); // copied 是实际写入的字符数 return buf; } // 跳到第 line 行0 基并把光标放过去 void GotoLine(HWND hEdit, int line) { int lineCount (int)SendMessageW(hEdit, EM_GETLINECOUNT, 0, 0); if (line 0 || line lineCount) return; int idx (int)SendMessageW(hEdit, EM_LINEINDEX, line, 0); // 行首字符偏移 SendMessageW(hEdit, EM_SETSEL, idx, idx); SendMessageW(hEdit, EM_SCROLLCARET, 0, 0); // 滚到可见区域 } // 在光标处插入文本wParam 传 TRUE 表示这次修改可以撤销 void InsertAtCaret(HWND hEdit, const std::wstring text) { SendMessageW(hEdit, EM_REPLACESEL, TRUE, (LPARAM)text.c_str()); }GetWindowTextLengthW返回的是字符数不是字节数这点在 Unicode 下别搞混按字节分配缓冲区然后传给宽字符 API 会直接踩内存。EM_LINEINDEX传行号拿字符偏移配合EM_SETSEL就能实现跳到指定行编辑器里 CtrlG 就是这么做的。EM_REPLACESEL的wParam传TRUE是把这次改写并入撤销栈传FALSE则不进栈——查找替换批量替换时通常传TRUE让用户能一次 CtrlZ 全撤回去代价是撤销栈占内存。还有一个常被问到的EM_GETSEL拿到的两个值不能直接当光标位置用。它返回的是选区起止用户从后往前拖选时光标其实在选区起点而不是终点。要精确拿光标位置用GetCaretPos拿坐标再EM_CHARFROMPOS换算成字符偏移这是可靠路径。3.2 打开与保存编码判断的优先级文本编辑器最容易被吐槽的就是打开是乱码或者保存之后别人打不开。这一块没有银弹只有一套固定的判断顺序先认 BOM再试 UTF-8 严格解码最后退回本地代码页。// fileio.cpp —— 字节流转宽字符输出判定出的代码页 std::wstring BytesToWide(const std::vectorchar raw, UINT cpOut) { const size_t n raw.size(); const unsigned char* p (const unsigned char*)raw.data(); // 1. BOM 优先UTF-8 (EF BB BF)、UTF-16LE (FF FE)、UTF-16BE (FE FF) if (n 3 p[0] 0xEF p[1] 0xBB p[2] 0xBF) { cpOut CP_UTF8; return Utf8ToWide(raw.data() 3, n - 3); } if (n 2 p[0] 0xFF p[1] 0xFE) { cpOut 1200; return std::wstring((const wchar_t*)(raw.data() 2), (n - 2) / 2); } // 2. 无 BOM先按严格 UTF-8 试解MB_ERR_INVALID_CHARS 保证非法序列直接失败 std::wstring w Utf8ToWide(raw.data(), n, /*strict*/true); if (!w.empty()) { cpOut CP_UTF8; return w; } // 3. 还不行退回本地代码页中文环境一般是 936 cpOut CP_ACP; return AnsiToWide(raw.data(), n, cpOut); }判断顺序不能颠倒。先按本地代码页解纯英文文件也能解出来但里面夹的 UTF-8 中文就变成一堆问号先按 UTF-8 严格解GBK 文件会有大量非法序列直接失败这个失败是有价值的信号。MB_ERR_INVALID_CHARS这个标志是关键不带它MultiByteToWideChar遇到非法字节会静默替换成UFFFD你拿到的是一串替换字符而不是失败判断就失效了。保存方向的策略要简单得多统一写 UTF-8 带 BOM。代价是文件头多三个字节好处是任何编辑器打开都不会误判也省掉了这次该用哪个编码的交互设计。真要做编码选择就在保存对话框里加一个下拉框把cpOut原样带回去。3.3 查找替换从整文本匹配到选区替换编辑控件本身不提供查找能力FindTextW那个 API 是给富文本控件用的这里用不上。实际做法是把整个文本取出来在std::wstring上做匹配再把命中的偏移换算回控件位置。// 从 startPos 开始找找到返回字符偏移找不到返回 -1 int FindNext(HWND hEdit, const std::wstring keyword, int startPos, bool matchCase) { if (keyword.empty()) return -1; std::wstring all GetEditText(hEdit); std::wstring hay all, needle keyword; if (!matchCase) { ToLowerInPlace(hay); ToLowerInPlace(needle); } // 大小写不敏感 size_t pos hay.find(needle, static_castsize_t(startPos)); if (pos std::wstring::npos) return -1; // 选中命中区间并把视图滚过去 SendMessageW(hEdit, EM_SETSEL, (WPARAM)pos, (LPARAM)(pos needle.size())); SendMessageW(hEdit, EM_SCROLLCARET, 0, 0); return (int)pos; } // 替换当前选区内容返回下一次查找的起点 int ReplaceCurrent(HWND hEdit, const std::wstring replacement) { DWORD sel (DWORD)SendMessageW(hEdit, EM_GETSEL, 0, 0); SendMessageW(hEdit, EM_REPLACESEL, TRUE, (LPARAM)replacement.c_str()); return LOWORD(sel) (int)replacement.size(); }EM_GETSEL返回的低 16 位是选区起点高 16 位是终点这种打包方式在老 API 里很常见用LOWORD/HIWORD拆。替换之后下一次查找的起点要落在替换文本之后否则替换文本里包含关键词时会原地死循环——把abc替换成aabc从原位置继续找会永远命中自己。大小写不敏感的实现用统一转小写再比缺点是会丢失原始偏移映射某些语言转小写后长度会变英文和中文场景下没问题做多语言就得换成逐字符比较。替换全部时循环要有上限保护避免上面那种自噬情况把程序卡死// 全部替换最多循环 10000 次超过就认为异常并中止 int ReplaceAll(HWND hEdit, const std::wstring from, const std::wstring to, bool matchCase) { int count 0, pos 0; while (count 10000) { int hit FindNext(hEdit, from, pos, matchCase); if (hit 0) break; pos ReplaceCurrent(hEdit, to); count; } return count; }4. 交互层补齐rc 资源脚本、加速键表与状态栏刷新功能能跑通和用起来像个编辑器之间差的是交互层。菜单项、快捷键、状态栏行列号这三样是用户感知最强的部分也是最容易在源码里被写成一锅粥的部分。4.1 资源脚本与命令 ID 的对应关系菜单不在代码里建写在.rc里编译时由资源编译器打成二进制资源运行时LoadMenu加载。这样做的好处是改菜单不用改代码多语言版本也好办。// resource.rc —— 菜单与加速键 #include resource.h #include windows.h IDR_MAINMENU MENU BEGIN POPUP 文件(F) BEGIN MENUITEM 新建(N)\tCtrlN, IDM_FILE_NEW MENUITEM 打开(O)...\tCtrlO, IDM_FILE_OPEN MENUITEM 保存(S)\tCtrlS, IDM_FILE_SAVE MENUITEM 另存为(A)..., IDM_FILE_SAVEAS MENUITEM SEPARATOR MENUITEM 退出(X), IDM_APP_EXIT END POPUP 编辑(E) BEGIN MENUITEM 撤销(U)\tCtrlZ, IDM_EDIT_UNDO MENUITEM SEPARATOR MENUITEM 查找(F)...\tCtrlF, IDM_EDIT_FIND MENUITEM 替换(R)...\tCtrlH, IDM_EDIT_REPLACE MENUITEM 全选(A)\tCtrlA, IDM_EDIT_SELECTALL END END\t在菜单字符串里表示制表符会把后面的快捷键文字右对齐显示它不是语法糖而是字符串的一部分。是助记符标记文件(F)显示成文件(F)按 AltF 展开这个菜单。命令 ID 定义在resource.h里值建议从 100 开始留出空间// resource.h —— 命令 ID改动后必须和 rc 里的名字一一对上 #define IDR_MAINMENU 101 #define IDR_ACCEL 102 #define IDC_EDIT 1001 #define IDC_STATUSBAR 1002 #define IDM_FILE_NEW 40001 #define IDM_FILE_OPEN 40002 #define IDM_FILE_SAVE 40003 #define IDM_FILE_SAVEAS 40004 #define IDM_APP_EXIT 40010 #define IDM_EDIT_UNDO 40020 #define IDM_EDIT_FIND 40021 #define IDM_EDIT_REPLACE 40022 #define IDM_EDIT_SELECTALL 40023血泪经验在这里把 ID 数字手改小一点整理一下rc 里的名字没变但宏的值变了菜单点下去走的是另一个分支。现象是点保存弹出了关于对话框。这类问题编译期一点提示都没有只能靠肉眼核对所以 ID 一旦定下来就别动。4.2 加速键表与主循环的配合快捷键和菜单项要指向同一个命令 ID这样WM_COMMAND里只需要写一份处理逻辑。// resource.rc —— 加速键表VIRTKEY 表示虚拟键码 IDR_ACCEL ACCELERATORS BEGIN N, IDM_FILE_NEW, VIRTKEY, CONTROL O, IDM_FILE_OPEN, VIRTKEY, CONTROL S, IDM_FILE_SAVE, VIRTKEY, CONTROL Z, IDM_EDIT_UNDO, VIRTKEY, CONTROL F, IDM_EDIT_FIND, VIRTKEY, CONTROL H, IDM_EDIT_REPLACE, VIRTKEY, CONTROL A, IDM_EDIT_SELECTALL, VIRTKEY, CONTROL END加载和翻译发生在前面的消息循环里// WM_CREATE 里加载加速键表 hAccel LoadAcceleratorsW(hInst, MAKEINTRESOURCEW(IDR_ACCEL)); // 主循环里拦截注意 TranslateAccelerator 返回非零就不要再 Dispatch if (!TranslateAcceleratorW(hWnd, hAccel, msg)) { TranslateMessage(msg); DispatchMessageW(msg); }焦点在编辑控件上时CtrlA 会被 EDIT 自己吃掉全选这时加速键表里的CtrlA根本收不到。要接管就得在WM_COMMAND之外考虑子类化编辑控件或者在WM_KEYDOWN里提前判断。更省事的做法是把全选交给控件自己处理加速键表里干脆不写这一条。同一个快捷键被两处消费是这类源码里最容易出现有时灵有时不灵的地方。4.3 状态栏与光标行列号刷新状态栏用一个静态控件就够关键是刷新时机和取光标位置的方法。// 计算当前光标所在行列并更新状态栏 void UpdateStatus(HWND hEdit, HWND hStatus) { POINT pt; GetCaretPos(pt); // 相对于客户区的坐标 int idx (int)SendMessageW(hEdit, EM_CHARFROMPOS, 0, MAKELPARAM(pt.x, pt.y)); int line (int)SendMessageW(hEdit, EM_LINEFROMCHAR, idx, 0); int start (int)SendMessageW(hEdit, EM_LINEINDEX, line, 0); int col idx - start; wchar_t buf[64]; swprintf_s(buf, L 第 %d 行第 %d 列, line 1, col 1); SetWindowTextW(hStatus, buf); // 同步脏标记标题栏加星号提示未保存 if (SendMessageW(hEdit, EM_GETMODIFY, 0, 0)) SetWindowTextW(GetParent(hEdit), L* 未保存 - 记事本); }EM_CHARFROMPOS的lParam打包了 x/y低 16 位是 x高 16 位是 y用MAKELPARAM构造。坐标要用GetCaretPos拿的相对坐标不要用GetCursorPos拿的屏幕坐标否则算出来的偏移完全对不上。刷新时机选WM_COMMAND里的EN_UPDATE通知。选EN_CHANGE也行但它是在文本已经改变之后才发光标位置可能又动了选WM_KEYUP则覆盖不到鼠标点击和粘贴。EN_UPDATE在控件重绘之前发位置最准。EM_GETMODIFY是控件自带的脏标记任何EM_REPLACESEL/ 用户输入都会把它置位EM_SETMODIFY传 0 手动清零——用这个比自己在代码里到处设bool bDirty可靠得多不会漏掉某条路径。5. 编译和运行时的五个翻车现场现象、原因与改法5.1 链接报无法解析的外部符号 WinMain现象编译全部通过链接阶段报LNK2019 unresolved external symbol WinMain referenced in function int __cdecl invoke_main(void)。原因入口函数写成wWinMain宽字符版但项目的字符集设置是使用多字节字符集或者链接器子系统被设成了CONSOLE。CRT 会去找main或WinMain找不到宽字符版的wWinMain就报这个。解决项目属性里字符集改成 Unicode链接器 → 系统 → 子系统改成WINDOWS入口点那一栏留空不要手写wWinMainCRTStartup除非你确实知道自己在干什么。命令行编译时加上/DUNICODE /D_UNICODE /SUBSYSTEM:WINDOWS。5.2 中文一输入就变成问号现象英文正常中文打进去显示成????或者存盘后重开全是乱码。原因源码里混用了 ANSI 版 APIGetWindowTextA/SetWindowTextA这类函数按本地代码页转换遇到超出代码页的字符直接替换成问号而且不报错。另一种情况是写文件时用fwrite把宽字符缓冲区按字节写出去没写 BOM 也没做窄化转换文件本身是坏的。解决全链路统一走W后缀的 API所有字符串字面量加L前缀写文件前用WideCharToMultiByte(CP_UTF8, ...)转成 UTF-8 字节流并写三字节 BOM。检查方法很简单在GetEditText返回值上打个断点看里面是不是正常的宽字符是的话问题在文件层不是的话问题在控件交互层。5.3 长文本粘贴到三万字左右就没了现象粘贴一篇长文档只有前半截进去了后半截静默丢失控件不报错也不提示。原因多行 EDIT 控件默认的文本上限是 32767 个字符WM_SETTEXT和用户输入都会受这个限制超出部分直接丢弃。解决创建控件后立刻发一条SendMessageW(hEdit, EM_SETLIMITTEXT, 0x7FFFFFFE, 0)把上限抬到实际无限。注意这个限制是字符数在 Unicode 下和字节数无关别按字节算。5.4 拉伸窗口时编辑区不跟着变大现象拖动窗口边框主窗口变大了编辑区还是原来那个小方块贴在左上角。原因WM_SIZE分支没写或者写了但用的是SetWindowPos而没带SWP_NOZORDER导致层级被打乱。解决在WM_SIZE里调MoveWindow(hEdit, 0, 0, LOWORD(lParam), HIWORD(lParam), TRUE)宽高从lParam的低高 16 位取。再给主窗口加WS_CLIPCHILDREN子控件区域不会被父窗口的绘制覆盖拉伸时闪烁明显减少。5.5 快捷键失效或者触发两次现象CtrlS 有时候保存有时候没反应或者按一次弹出两个保存对话框。原因两种情况。一是TranslateAcceleratorW的返回值被忽略消息继续往下DispatchMessage命令执行两次二是焦点在编辑控件上控件先把组合键消费掉了加速键表收不到。解决主循环里严格按if (!TranslateAcceleratorW(...)) { TranslateMessage; DispatchMessage; }的结构写返回值非零就结束这一轮。对于被控件吃掉的键先确认这个键是不是控件本身就要用的CtrlA 全选、CtrlZ 撤销都是控件内置如果是加速键表里就不要重复定义把处理逻辑统一放到控件自己的行为上。6. 在源码上加一个自动保存三处改动与一条写文件习惯读懂骨架之后最该做的第一件事不是加功能是加一个能验证你读懂了的功能。自动保存刚好卡在这个位置上它需要消息循环定时器、需要控件状态脏标记、需要文件层原子写入三处都得碰一遍。第一个改动点是定时器。在WM_CREATE里起一个 30 秒的定时器SetTimer(hWnd, IDT_AUTOSAVE, 30 * 1000, nullptr)然后在WM_TIMER分支里判断wParam IDT_AUTOSAVE才往下走。别在WM_INITDIALOG或者WM_SHOWWINDOW里起那些消息可能触发多次定时器会叠加。第二个改动点是脏标记。不要自己维护bool用控件自带的SendMessageW(hEdit, EM_GETMODIFY, 0, 0)返回非零表示有未保存改动。每次保存成功后发EM_SETMODIFY传 0 清掉。这一套的好处是用户按了 CtrlZ 撤销回原始状态时控件自己会把标记清掉你的逻辑不用管。第三个改动点是写入。这是最容易翻车的地方直接fopen覆盖原文件写到一半断电或者崩溃用户的文件就废了。稳妥做法是写临时文件再原子替换// 原子保存先写 .tmp落盘后再替换目标文件 bool SaveFileAtomic(const std::wstring path, const std::wstring text) { std::wstring tmp path L.tmp; FILE* fp _wfopen(tmp.c_str(), Lwb); if (!fp) return false; // 统一 UTF-8 BOM避免下次打开编码判断失败 const unsigned char bom[3] { 0xEF, 0xBB, 0xBF }; fwrite(bom, 1, 3, fp); int need WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0, nullptr, nullptr); std::string utf8(static_castsize_t(need), \0); WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), utf8.data(), need, nullptr, nullptr); fwrite(utf8.data(), 1, utf8.size(), fp); fflush(fp); _commit(_fileno(fp)); // 强制把内核缓冲区刷到磁盘别只 flush 用户态 fclose(fp); // MoveFileEx 带 REPLACE_EXISTING同卷内是原子操作 return MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) ! 0; }_commit那一行很多人会漏。fflush只把 C 运行时的缓冲刷到操作系统真正落盘还得靠_commit触发一次同步。少了这一步蓝屏或断电时临时文件可能是个空壳替换上去就是彻底的数据丢失。MOVEFILE_WRITE_THROUGH让替换动作也等落盘完成再返回慢一点但更保险。自动保存的落点建议放在用户目录下的隐藏子目录文件名带上原文件名加时间戳保留最近若干份轮转。这样即使主文件被误改还能从历史副本里捞回来——后悔药这种东西做和不做在真出事那天是两种心情。还有一个细节程序退出前要把定时器 kill 掉KillTimer并在WM_CLOSE里先做一次有未保存改动就弹确认框的检查。很多示例代码在WM_CLOSE里直接DestroyWindow用户关窗口时手一抖半小时的活没了。从那以后我每次接手一份来路不明的源码都强制自己先跑一遍完整的打开—编辑—保存—重开闭环确认编码判断和文件写入这两条链路是通的再去读代码。因为读代码能看出设计只有跑起来才能看出它在边界上到底靠不靠谱。这份记事本的 C 源代码正好适合这么练一遍——它足够小一晚上能读完又足够完整该踩的边界一个不落。希望帮到你。本文还有配套的精品资源点击获取