1. 项目概述从零构建一个C记事本很多刚接触C的朋友尤其是从控制台“黑框框”程序入门后都会有一个跃跃欲试的想法能不能用C写一个带图形界面的、像Windows自带记事本那样的小工具这个想法非常好它不仅是学习C面向对象、文件操作、事件驱动编程的绝佳实践更能让你直观地感受到从命令行到图形界面的跨越。今天我们就来彻底拆解这个项目手把手带你用C实现一个具备创建、编辑、保存文件功能的简易记事本。这不仅仅是调用几个API我会带你理解每一步背后的设计逻辑和Windows编程的核心机制让你知其然更知其所以然。这个教程的目标是构建一个功能完整的窗口程序核心功能包括创建一个带菜单栏和文本编辑区域的窗口能够响应新建、打开、保存、另存为等文件操作实现基本的文本输入与编辑。我们将使用经典的Win32 API来完成这是理解Windows桌面程序运行原理的基石。虽然现在有Qt、MFC等更高级的框架但掌握Win32 API能让你对消息循环、窗口过程、资源管理有更深刻的认识未来学习任何其他GUI框架都会事半功倍。整个过程会涉及窗口注册、消息处理、文件对话框使用、文件流操作等关键知识点我会在每一步都解释清楚“为什么这么做”。2. 开发环境搭建与项目初始化工欲善其事必先利其器。一个顺手的开发环境能极大提升编码效率和调试体验。对于Windows平台的C GUI开发Visual Studio社区版是首选它集成了强大的编译器、调试器和资源编辑器对Win32开发的支持最为友好。2.1 开发工具选择与配置首先你需要安装Visual Studio 2022。在安装过程中务必勾选“使用C的桌面开发”工作负载这个选项包含了编译Win32程序所需的所有工具链、头文件和库比如关键的windows.h和user32.lib等。安装完成后打开VS创建一个新项目。在项目模板选择中不要选择控制台应用而是找到“Windows桌面向导”或直接搜索“Windows Desktop Application”。给项目起个名字比如MyNotepad。在接下来的配置向导中应用程序类型选择“桌面应用程序(.exe)”附加选项建议勾选“空项目”这样我们可以从最纯净的状态开始编写代码更好地理解每个文件的作用。点击完成后VS会为你生成一个包含_tWinMain入口函数和基本窗口框架的项目。注意如果你在编译其他开源C项目时遇到“error: microsoft visual c 14.0 or greater is required”这类错误通常是因为缺少对应的VC运行时库或构建工具。在本项目中由于我们使用完整的VS IDE这些依赖都已自动配置好无需担心。2.2 理解Win32程序的基本骨架项目创建好后你会看到至少一个.cpp文件里面包含了程序入口和窗口过程的基本框架。我们先来快速理解一下这几个核心部分入口函数_tWinMain这是Windows GUI程序的起点相当于控制台程序的main函数。它的参数hInstance代表当前程序的实例句柄hPrevInstance在32位以后已废弃lpCmdLine是命令行参数字符串nCmdShow指定窗口初始显示状态如最大化、最小化。窗口类注册WNDCLASS或WNDCLASSEX在创建窗口前必须向系统注册一个“窗口类”。这有点像设计图纸定义了这类窗口的共同属性比如图标、光标、背景色以及最重要的——窗口过程函数WndProc的地址。所有基于这个类创建的窗口其消息都会交给这个函数处理。窗口创建CreateWindow使用注册好的窗口类名调用CreateWindow函数来实际创建一个窗口。这里指定的是窗口的具体属性如标题、位置、大小、样式是否有边框、菜单、最大化按钮等。消息循环这是GUI程序的心脏。一个while(GetMessage(...))循环不断地从应用程序消息队列中取出消息如鼠标点击、键盘输入、窗口重绘命令并通过DispatchMessage函数将其分发给对应窗口的窗口过程函数处理。只要收到WM_QUIT消息循环结束程序退出。窗口过程函数WndProc这是一个回调函数你需要在里面用switch-case语句处理各种你关心的消息。例如处理WM_CREATE来初始化窗口内的控件处理WM_COMMAND来响应菜单点击处理WM_PAINT来绘制窗口内容处理WM_DESTROY来发出退出消息。理解这个骨架至关重要。我们接下来的所有工作几乎都是在这个框架内填充内容在WM_CREATE中创建编辑控件在WM_COMMAND中实现菜单功能在WM_SIZE中让编辑控件随窗口一起调整大小。3. 核心界面构建窗口与编辑控件有了理论基础我们开始动手构建记事本的界面。一个标准的记事本包含一个主窗口窗口上有菜单栏客户区窗口内部空白区域则完全被一个多行文本编辑控件占据。3.1 创建主窗口与菜单资源首先我们需要定义菜单。在VS中你可以使用资源视图来可视化编辑菜单但为了代码的清晰和可移植性我更喜欢在代码中用字符串定义菜单结构。我们在窗口创建之前定义菜单// 在WinMain函数中注册窗口类后创建窗口前 HMENU hMenu CreateMenu(); HMENU hFileMenu CreatePopupMenu(); AppendMenu(hFileMenu, MF_STRING, ID_FILE_NEW, L新建(N)); AppendMenu(hFileMenu, MF_STRING, ID_FILE_OPEN, L打开(O)...); AppendMenu(hFileMenu, MF_STRING, ID_FILE_SAVE, L保存(S)); AppendMenu(hFileMenu, MF_STRING, ID_FILE_SAVEAS, L另存为(A)...); AppendMenu(hFileMenu, MF_SEPARATOR, 0, NULL); // 分隔线 AppendMenu(hFileMenu, MF_STRING, ID_FILE_EXIT, L退出(X)); AppendMenu(hMenu, MF_POPUP, (UINT_PTR)hFileMenu, L文件(F)); // 可以继续添加“编辑”、“格式”、“查看”等菜单这里ID_FILE_NEW等是我们自定义的菜单命令标识符需要在头文件中用#define定义例如#define ID_FILE_NEW 1001。然后在调用CreateWindow函数时将第6个参数菜单句柄设置为hMenu这样创建的窗口就会带有这个菜单栏。接下来是创建文本编辑控件。我们不应该在WinMain中直接创建因为那时窗口本身可能还没完全准备好。最佳位置是在主窗口的窗口过程函数中响应WM_CREATE消息时创建。3.2 集成多行文本编辑控件Windows系统提供了一个现成的、功能丰富的编辑控件通过CreateWindow函数并指定类名为WC_EDIT或LEDIT即可创建。为了让它像记事本一样工作我们需要设置一些扩展样式case WM_CREATE: { // 获取主窗口的客户区大小 RECT rcClient; GetClientRect(hWnd, rcClient); // 创建编辑控件 HWND hEdit CreateWindowEx( WS_EX_CLIENTEDGE, // 扩展样式带凹陷边框 LEDIT, // 窗口类编辑控件 NULL, // 初始文本 WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL | ES_WANTRETURN, // 样式解释 // WS_CHILD | WS_VISIBLE: 子窗口且可见 // WS_VSCROLL | WS_HSCROLL: 带垂直和水平滚动条 // ES_MULTILINE: 多行编辑 // ES_AUTOVSCROLL | ES_AUTOHSCROLL: 输入时自动滚动 // ES_WANTRETURN: 接收回车键而不是触发默认按钮 0, 0, // 初始位置 (后面会调整) rcClient.right, rcClient.bottom, // 初始大小 hWnd, // 父窗口句柄 (HMENU)IDC_MAIN_EDIT, // 控件ID ((LPCREATESTRUCT)lParam)-hInstance, NULL ); // 设置等宽字体方便文本对齐 HFONT hFont CreateFont(16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, FIXED_PITCH | FF_MODERN, LConsolas); SendMessage(hEdit, WM_SETFONT, (WPARAM)hFont, TRUE); // 将编辑控件的句柄存储起来方便其他地方使用 // 可以使用 SetWindowLongPtr 和 GWLP_USERDATA这里为了简单使用全局变量实际项目建议用更优雅的方式 g_hEdit hEdit; } break;创建控件后我们还需要处理WM_SIZE消息当主窗口大小改变时编辑控件的大小也应随之改变以始终充满客户区case WM_SIZE: { if (g_hEdit ! NULL) { RECT rcClient; GetClientRect(hWnd, rcClient); // 调整编辑控件大小覆盖整个客户区 MoveWindow(g_hEdit, 0, 0, rcClient.right, rcClient.bottom, TRUE); } } break;实操心得编辑控件的样式ES_WANTRETURN非常重要。如果没有它在编辑框中按下回车键系统会将其解释为触发默认按钮如对话框的“确定”按钮的命令导致你无法在控件内换行。另外将编辑控件句柄保存在一个全局变量或通过GetDlgItem获取是后续进行文本操作的关键。4. 文件操作功能实现新建、打开、保存界面搭建好后核心就是实现文件操作逻辑。这涉及到与系统的文件对话框交互以及对文件进行实际的读写。4.1 使用通用文件对话框为了提供与系统风格一致且功能强大的“打开”和“另存为”对话框我们使用GetOpenFileName和GetSaveFileName函数。它们属于“通用对话框”库需要包含头文件commdlg.h并在链接器输入中添加comdlg32.lib。首先我们定义一个函数来弹出“打开”对话框并获取用户选择的文件路径BOOL OpenFileDialog(HWND hWnd, LPWSTR szFileName, DWORD nMaxFileName) { OPENFILENAME ofn {0}; ofn.lStructSize sizeof(OPENFILENAME); ofn.hwndOwner hWnd; // 对话框的父窗口 ofn.lpstrFilter L文本文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0; // 文件类型过滤器 ofn.lpstrFile szFileName; // 接收文件路径的缓冲区 ofn.nMaxFile nMaxFileName; ofn.Flags OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST; // 文件必须存在 ofn.lpstrDefExt Ltxt; // 默认扩展名 return GetOpenFileName(ofn); }“另存为”对话框的函数SaveFileDialog与此类似主要区别是使用GetSaveFileName并且标志位可能包含OFN_OVERWRITEPROMPT覆盖前提示。4.2 实现文件读取与写入获取到文件路径后就需要进行实际的I/O操作。C标准库的fstream提供了非常方便的文件流操作。我们在WM_COMMAND消息处理中根据菜单ID来调用不同的功能。打开文件case ID_FILE_OPEN: { WCHAR szFile[MAX_PATH] {0}; if (OpenFileDialog(hWnd, szFile, MAX_PATH)) { std::wifstream file(szFile); // 使用宽字符流处理中文 if (file.is_open()) { // 清空编辑控件当前内容 SetWindowTextW(g_hEdit, L); std::wstringstream wss; wss file.rdbuf(); // 将文件内容全部读入字符串流 std::wstring content wss.str(); // 将内容设置到编辑控件中 SetWindowTextW(g_hEdit, content.c_str()); // 记录当前打开的文件路径供“保存”使用 wcscpy_s(g_szCurrentFile, MAX_PATH, szFile); // 更新窗口标题显示当前文件名 UpdateWindowTitle(hWnd, szFile); } else { MessageBox(hWnd, L无法打开文件。, L错误, MB_ICONERROR); } } } break;保存文件 保存分为两种情况“保存”和“另存为”。“保存”是保存到当前已打开的文件g_szCurrentFile有值“另存为”则需要弹出对话框选择新路径。void SaveToFile(HWND hWnd, LPCWSTR lpFilePath) { // 1. 获取编辑控件中的文本长度 int nLength GetWindowTextLengthW(g_hEdit); if (nLength 0) { // 2. 分配缓冲区并获取文本 std::wstring content; content.resize(nLength 1); // 1 for safety GetWindowTextW(g_hEdit, content[0], nLength 1); content.resize(nLength); // 移除多余的终止符 // 3. 使用wofstream写入文件 std::wofstream file(lpFilePath); if (file.is_open()) { file content; file.close(); // 4. 更新当前文件路径和窗口标题 wcscpy_s(g_szCurrentFile, MAX_PATH, lpFilePath); UpdateWindowTitle(hWnd, lpFilePath); // 5. 可选标记文件为已保存状态可以清空“修改”标志 SendMessage(g_hEdit, EM_SETMODIFY, FALSE, 0); } else { MessageBox(hWnd, L无法保存文件。, L错误, MB_ICONERROR); } } } // 在WM_COMMAND中 case ID_FILE_SAVE: if (g_szCurrentFile[0] ! L\0) // 如果已有当前文件 { SaveToFile(hWnd, g_szCurrentFile); } else // 等同于“另存为” { // 调用ID_FILE_SAVEAS的处理逻辑 } break; case ID_FILE_SAVEAS: { WCHAR szFile[MAX_PATH] {0}; // 可以默认填入当前文件名 if (g_szCurrentFile[0] ! L\0) { wcscpy_s(szFile, MAX_PATH, g_szCurrentFile); } if (SaveFileDialog(hWnd, szFile, MAX_PATH)) { SaveToFile(hWnd, szFile); } } break;新建文件 新建功能相对简单主要是清空编辑控件内容并重置当前文件路径。case ID_FILE_NEW: { // 检查当前内容是否已修改提示保存这是一个重要的用户体验下文详述 if (CheckForUnsavedChanges(hWnd)) { // 用户取消了操作 break; } SetWindowTextW(g_hEdit, L); g_szCurrentFile[0] L\0; UpdateWindowTitle(hWnd, L无标题 - 我的记事本); } break;注意事项文件读写时特别是处理中文等非ASCII字符一定要使用宽字符版本的类型和函数wchar_t,std::wstring,std::wifstream/wofstream,SetWindowTextW等。同时注意文件流的打开模式默认的文本模式会处理换行符转换\n-\r\n对于纯文本编辑器这通常是期望的行为。如果你需要二进制模式请使用std::ios::binary。5. 功能增强与用户体验优化一个可用的记事本已经完成了。但要让它更好用我们还需要添加一些细节功能并处理一些边界情况这直接关系到程序的健壮性和用户体验。5.1 实现修改状态跟踪与关闭提示一个专业的编辑器应该在用户尝试关闭未保存修改的文件时给出提示。编辑控件内部有一个“修改”标志位当内容被用户改变后这个标志位会被置为TRUE。我们可以利用这个特性。首先在编辑控件创建后或者每次成功保存后清除修改标志SendMessage(g_hEdit, EM_SETMODIFY, FALSE, 0);然后在用户执行可能丢失数据的操作如新建、打开另一个文件、关闭窗口前检查这个标志BOOL CheckForUnsavedChanges(HWND hWnd) { if (SendMessage(g_hEdit, EM_GETMODIFY, 0, 0)) { int ret MessageBox(hWnd, L文档已被修改。是否保存更改, L我的记事本, MB_YESNOCANCEL | MB_ICONQUESTION); switch (ret) { case IDYES: // 触发保存操作 SendMessage(hWnd, WM_COMMAND, ID_FILE_SAVE, 0); // 这里需要判断保存是否成功如果用户取消了“另存为”对话框应返回TRUE取消操作 // 简化处理假设保存成功或用户已处理 break; case IDNO: // 不保存直接继续 return FALSE; case IDCANCEL: // 取消当前操作 return TRUE; } } return FALSE; // 无未保存更改或用户选择不保存 }在WM_CLOSE消息点击窗口关闭按钮的处理中调用此函数如果函数返回TRUE用户取消则阻止关闭case WM_CLOSE: if (!CheckForUnsavedChanges(hWnd)) { DestroyWindow(hWnd); // 只有用户确认不保存或保存完成后才销毁窗口 } // 否则什么也不做窗口保持打开 break;5.2 添加编辑菜单与快捷键基本的编辑功能剪切、复制、粘贴、全选可以通过向编辑控件发送标准消息来实现无需自己处理剪贴板case ID_EDIT_CUT: SendMessage(g_hEdit, WM_CUT, 0, 0); break; case ID_EDIT_COPY: SendMessage(g_hEdit, WM_COPY, 0, 0); break; case ID_EDIT_PASTE: SendMessage(g_hEdit, WM_PASTE, 0, 0); break; case ID_EDIT_SELECT_ALL: SendMessage(g_hEdit, EM_SETSEL, 0, -1); // 起始0结束-1表示全选 break;为了让用户更方便我们还需要添加快捷键支持如CtrlS保存。这可以通过在资源文件中定义快捷键表或者在WM_CREATE中调用RegisterHotKey来实现。更标准的方法是在消息循环中在TranslateMessage之前调用TranslateAccelerator函数来翻译快捷键消息。这需要创建一个快捷键表资源并将其加载。对于初学者一个更简单的方法是在WM_KEYDOWN消息中手动检查GetKeyState(VK_CONTROL)和按键字符。5.3 设置字体与查找替换功能进阶设置字体可以使用通用字体对话框ChooseFont。当用户点击“字体”菜单时弹出一个字体选择对话框根据用户的选择用SendMessage(g_hEdit, WM_SETFONT, ...)来更新编辑控件的字体。查找替换功能则相对复杂。你需要创建一个模式对话框Modal Dialog或无模式对话框Modeless Dialog。对话框中包含输入查找内容、替换内容的编辑框以及“查找下一个”、“替换”、“全部替换”等按钮。核心逻辑是向编辑控件发送EM_FINDTEXTEX消息来进行文本搜索。处理这个功能时要特别注意搜索的起始位置、大小写匹配、全字匹配等选项以及循环搜索到达文档末尾后回到开头的逻辑。这是一个很好的练习可以让你深入理解对话框编程和文本处理。6. 项目编译、调试与发布代码编写完成后最后一步是生成可执行文件并处理发布事宜。6.1 解决常见的编译与链接错误链接错误无法解析的外部符号__imp_GetOpenFileNameW这表示链接器找不到通用对话框库的实现。你需要确保在项目属性 - 链接器 - 输入 - 附加依赖项中添加了comdlg32.lib。对于其他API如ShellAbout关于对话框需要shell32.libChooseFont需要comdlg32.lib等根据错误提示添加对应的库即可。字符集问题如果你的项目设置是“使用Unicode字符集”推荐那么所有API都应使用宽字符版本带W后缀的如CreateWindowW 通常宏定义为CreateWindow。如果设置是“使用多字节字符集”则使用窄字符版本带A后缀。混合使用会导致编译错误或乱码。建议始终使用Unicode设置。运行时错误或界面乱码检查所有字符串字面量是否使用了L前缀如L文本所有字符串处理函数是否使用宽字符版本如wcscpy_s,wcslen所有流是否使用宽字符流wistream,wostream。6.2 调试技巧与发布准备在开发过程中善用Visual Studio的调试器。你可以设置断点单步执行观察变量值这对于理解消息流和排查逻辑错误至关重要。例如在WndProc函数的WM_COMMAND分支设置断点可以清楚地看到是哪个菜单项被点击了。程序调试无误后在VS顶部的解决方案配置下拉菜单中选择“Release”然后重新生成解决方案。Release版本经过了编译器优化去除了调试信息文件更小运行速度也更快。生成的.exe文件默认依赖于Visual C运行时库如vcruntime140.dll。如果你想把程序分享给没有安装VS的电脑有两种方式静态链接在项目属性 - C/C - 代码生成 - 运行库中选择“多线程(/MT)”Release或“多线程调试(/MTd)”Debug。这样会将运行时库静态编译进你的exe文件会变大但可以独立运行。分发运行时库使用“动态链接”默认的“多线程DLL(/MD)”然后将对应的Microsoft Visual C Redistributable安装包可以从微软官网下载分发给用户安装或者将必要的dll文件如vcruntime140.dll,msvcp140.dll复制到exe同级目录需注意许可协议。我个人更倾向于静态链接对于这样的小工具换来“开箱即用”的便利性是值得的虽然exe会大几MB。7. 功能扩展思路与进阶方向完成基础版本后你可以以此为起点添加更多功能让它更像一个真正的记事本甚至超越它。状态栏在窗口底部添加一个状态栏显示当前行号、列号、文件编码等信息。这需要创建状态栏控件CreateWindow使用STATUSCLASSNAME并在WM_SIZE中调整其位置。最近打开的文件列表在“文件”菜单下动态显示最近打开的几个文件。这需要将文件路径保存到注册表或INI配置文件并在程序启动时读取、重建菜单。打印功能实现文档打印。这涉及到打印对话框PrintDlg、打印文档循环StartDoc/StartPage/EndPage/EndDoc以及计算分页和绘制文本是一个综合性很强的功能。语法高亮如果你想让记事本支持编程可以实现简单的语法高亮。这需要子类化编辑控件SetWindowLongPtrwithGWLP_WNDPROC拦截WM_PAINT消息自己分析文本内容并对不同关键词用不同颜色绘制。这是一个巨大的挑战也是性能优化的好课题。多标签页实现一个可以同时打开多个文档的标签式界面。这需要你管理多个编辑控件或一个Richedit控件配合多个文档流并自己绘制和管理标签页。可以考虑使用TabControl控件。通过这个完整的项目你不仅学会了如何用C和Win32 API创建一个图形界面程序更重要的是理解了Windows消息驱动模型、资源管理、文件I/O和对话框交互等核心概念。这些知识是Windows桌面开发的通用基础无论你将来学习Qt、wxWidgets还是直接进行更复杂的Win32开发都会受益匪浅。动手实现一遍遇到问题并解决它这才是成长最快的方式。