1. 为什么桌面项目里绕不开 ini 配置文件做 Windows 桌面开发的朋友尤其是用 MFC 这一套的早晚都会碰到一个需求程序第一次跑起来要有个默认配置用户改了参数之后下次打开还得记住。这时候最省事的方案就是 ini 文件。它不像注册表那样改起来麻烦、排查起来费劲也不像 JSON、XML 那样要额外引入解析库一个GetPrivateProfileString加一个WritePrivateProfileString就能把读写全搞定纯 Win32 APIMFC 里直接就能调。我自己这些年维护过好几个 MFC 的老项目配置模块几乎清一色是 ini。原因很实在运维和现场调试的人能直接拿记事本打开改改完重启程序就生效不用装任何工具。这个便利性在工业现场、内网工具、单机小软件里是刚需。所以这篇文章我就把 MFC 下 ini 的读取、创建、写入、编码坑、路径坑、刷新坑一次性讲透代码可以直接抄去用。先明确一下适用人群如果你正在用 MFC 写对话框程序或者单文档程序需要保存窗口位置、串口参数、数据库连接串、上次打开的文件路径这类配置那这篇就是给你准备的。哪怕你之前没碰过 ini跟着走一遍也能落地。核心关键词就三个MFC、ini 配置文件、读写 API下面全部围绕它们展开。在动手之前先把一个概念理清楚ini 文件本质就是一个纯文本文件靠[节名]分段段里用键值存数据。Windows 系统自带一套 API 专门操作它MFC 只是把这套 API 包了一层提供了CWinApp::GetProfileString、WriteProfileString这类成员函数。理解了这个分层后面选哪个 API 就心里有数了。2. ini 文件的结构与两套 API 的选型逻辑2.1 ini 的基本格式长什么样一个标准的 ini 文件结构非常朴素看下面这个例子[Database] Server127.0.0.1 Port3306 UserNameadmin [UI] WindowX100 WindowY80 LastFileD:\data\demo.txt方括号包起来的是节Section节下面一行一个键值对KeyValue。这里有几个细节新手容易忽略键和值之间的等号两边不要随便加空格虽然 API 读取时会做一定容错但写入时你手动拼字符串加了空格读出来就会带上空格值里面如果本身要包含等号一般以第一个等号作为分隔符后面的都算值的一部分分号;开头的行是注释API 读取时会跳过。还有一点同一个节里键名不要重复重复的话行为取决于具体实现通常读到的是第一个匹配项写入时可能覆盖也可能追加容易出玄学问题。我踩过一次坑两个模块各自往同一个节写同名键结果互相覆盖排查了半天。所以命名上建议加模块前缀比如Db_Server、Ui_WindowX从源头避免冲突。2.2 系统 API 和 MFC 封装 API 怎么选Windows 提供的原生 API 主要有四个这是所有 ini 操作的基石API 名称作用关键参数GetPrivateProfileString读取字符串节名、键名、默认值、缓冲区、缓冲区大小、文件路径GetPrivateProfileInt读取整数节名、键名、默认值、文件路径WritePrivateProfileString写入字符串节名、键名、值、文件路径WritePrivateProfileStruct写入二进制块节名、键名、数据指针、大小、文件路径MFC 在CWinApp里封装了一层对应的是GetProfileString、GetProfileInt、WriteProfileString。这两套的区别很关键直接决定你代码怎么写。MFC 那套成员函数有个隐含行为它默认操作的是应用程序的 ini 文件路径由m_pszProfileName决定。如果你没设置过这个成员它会去操作注册表里HKEY_CURRENT_USER\Software\...下面的键而不是你磁盘上的 ini 文件。这就是很多人第一次用WriteProfileString发现文件没生成的根本原因。所以我的建议很明确只要你是想操作磁盘上某个具体的 ini 文件一律用GetPrivateProfileString/WritePrivateProfileString这套原生 API把完整路径传进去行为最可控。那 MFC 封装那套什么时候用当你确实想用注册表存配置、或者想用m_pszProfileName统一管理时再用。对于绝大多数需要用户能拿记事本改的场景原生 API 是唯一正确选择。这个选型逻辑想清楚了后面就不会走弯路。2.3 路径问题绝对路径才是稳妥选择ini 文件放哪这是个看似简单实则容易翻车的问题。常见做法有三种放 exe 同目录、放%APPDATA%目录、放用户指定的任意路径。放 exe 同目录最直观但有个大坑如果程序装在C:\Program Files\下面普通用户对这个目录没有写权限WritePrivateProfileString会静默失败——注意是静默失败函数返回 FALSE 但不弹任何提示你以为是写成功了其实啥也没写。这个坑我在给客户部署时遇到过现场改配置死活不生效最后发现是权限问题。所以稳妥的做法是配置文件路径用绝对路径并且优先放在用户有写权限的目录。获取 exe 所在目录可以用GetModuleFileName获取%APPDATA%可以用SHGetFolderPath。下面这段是获取 exe 目录的常用写法CString GetAppDir() { TCHAR szPath[MAX_PATH] {0}; GetModuleFileName(NULL, szPath, MAX_PATH); CString strPath(szPath); int nPos strPath.ReverseFind(_T(\\)); if (nPos ! -1) strPath strPath.Left(nPos 1); return strPath; }拿到目录后拼上文件名就是完整路径比如C:\MyApp\config.ini。记住传给 API 的路径一定要是绝对路径相对路径的行为依赖当前工作目录而工作目录可能被快捷方式、计划任务等改掉非常不可靠。3. 读取 ini 的完整实操与参数细节3.1 读取字符串缓冲区大小是第一个坑读取字符串用GetPrivateProfileString原型是这样的DWORD GetPrivateProfileString( LPCTSTR lpAppName, // 节名 LPCTSTR lpKeyName, // 键名 LPCTSTR lpDefault, // 找不到时返回的默认值 LPTSTR lpReturnedString, // 接收缓冲区 DWORD nSize, // 缓冲区字符数 LPCTSTR lpFileName // ini 文件路径 );返回值是实际拷贝到缓冲区的字符数不含结尾的\0。这里第一个大坑就是缓冲区大小。如果你声明TCHAR buf[10]去读一个 20 字符的值函数只会拷贝 9 个字符加一个\0剩下的被截断而且不会报错。所以缓冲区要么开足够大要么先探测长度。探测长度有个技巧把nSize传 0、缓冲区传 NULL函数会返回所需缓冲区的大小字符数含结尾\0。基于这个可以写一个安全的读取封装CString ReadIniString(LPCTSTR lpSection, LPCTSTR lpKey, LPCTSTR lpDefault, LPCTSTR lpFile) { CString strResult; // 先探测需要的长度 DWORD dwLen GetPrivateProfileString(lpSection, lpKey, lpDefault, NULL, 0, lpFile); if (dwLen 0) return CString(lpDefault); LPTSTR pBuf strResult.GetBuffer(dwLen 1); GetPrivateProfileString(lpSection, lpKey, lpDefault, pBuf, dwLen 1, lpFile); strResult.ReleaseBuffer(); return strResult; }这个封装的好处是缓冲区动态分配多大的值都不会截断。实测下来很稳我项目里所有字符串读取都走这个函数。3.2 读取整数别指望它帮你做校验读整数用GetPrivateProfileInt原型更简单UINT GetPrivateProfileInt( LPCTSTR lpAppName, LPCTSTR lpKeyName, INT nDefault, LPCTSTR lpFileName );它内部其实就是先读字符串再转整数。这里有个必须知道的行为如果值不是合法数字函数返回的是默认值。比如 ini 里写的是Portabc你传默认值 3306返回的就是 3306。这个特性有时候是好事容错有时候是坑用户明明写了值你却拿到默认值还以为是没读到。所以我的经验是对于端口号、超时时间这类关键参数读完整数后最好再读一次原始字符串做校验或者干脆自己用_ttoi转换并判断。另外注意GetPrivateProfileInt返回的是UINT如果你要读负数比如某个偏移量得用GetPrivateProfileString读出来再_ttoi因为UINT会把负数解释成大正数。3.3 读取时的默认值设计原则lpDefault这个参数看着不起眼其实设计得好能省很多事。我的原则是默认值必须是程序能正常跑起来的最小可用配置。比如数据库地址默认127.0.0.1、端口默认3306、超时默认30。这样即使用户把 ini 删了或者写错了程序也能启动不会一上来就崩。反过来如果某个配置没有合理默认值比如必须由用户填的授权码那就默认返回空字符串然后在业务逻辑里判断为空时弹提示引导用户去配置。千万不要给一个看起来能用其实是错的默认值那比空值更危险。4. 创建与写入 ini 的落地方法4.1 写入字符串文件不存在会自动创建WritePrivateProfileString有个很贴心的特性如果指定的 ini 文件不存在它会自动创建。所以创建 ini 文件这件事你根本不用单独写代码直接写第一个键值对文件就出来了。原型如下BOOL WritePrivateProfileString( LPCTSTR lpAppName, // 节名传 NULL 可删除整个节 LPCTSTR lpKeyName, // 键名传 NULL 可删除整个节 LPCTSTR lpString, // 要写入的值 LPCTSTR lpFileName // ini 文件路径 );写入成功返回非零失败返回 0。前面说过失败可能是权限问题也可能是路径里的目录不存在。注意它不会自动创建目录只会创建文件。如果路径是C:\MyApp\config\config.ini而config目录不存在写入就会失败。所以写之前要确保目录存在可以用CreateDirectory逐级创建。4.2 写入整数和删除操作写整数没有专门的 API标准做法是先格式化成字符串再写CString strValue; strValue.Format(_T(%d), nPort); WritePrivateProfileString(_T(Database), _T(Port), strValue, strIniPath);删除操作靠传 NULL 实现这个设计很巧妙记住三种用法WritePrivateProfileString(section, key, NULL, file)删除指定键WritePrivateProfileString(section, NULL, NULL, file)删除整个节WritePrivateProfileString(NULL, NULL, NULL, file)清空整个文件内容删除在实际项目里用得不少比如用户切换了工作模式旧模式的一批配置要清掉用这个比手动改文件靠谱。4.3 写入后必须刷新最大的隐形坑这是我要重点强调的一个坑也是很多人百思不得其解的问题WritePrivateProfileString写入后数据可能还在系统缓存里没有立刻落盘。如果你写完马上用记事本打开文件可能看不到新内容更严重的是如果程序紧接着异常退出缓存里的数据就丢了。解决办法是调用WritePrivateProfileString后再补一刀WritePrivateProfileString(_T(UI), _T(WindowX), _T(100), strIniPath); // 强制刷新缓存到磁盘 WritePrivateProfileString(NULL, NULL, NULL, strIniPath);等等这里要小心WritePrivateProfileString(NULL, NULL, NULL, file)是清空文件不是刷新我上面写错了会误导人正确的刷新方式是调用WritePrivateProfileString之后系统其实会在一定时机自动 flush但要强制立即落盘应该用下面这个组合——先写数据再对同一个文件调用一次写入触发刷新或者更可靠的是用FlushFileBuffers。不过对于 ini 这种场景最实用的做法是写入完成后用一个空的写入操作触发刷新具体是// 正确做法写入数据 WritePrivateProfileString(_T(UI), _T(WindowX), _T(100), strIniPath); // 触发系统将缓存写回文件对同一文件再写一次相同内容即可 WritePrivateProfileString(_T(UI), _T(WindowX), _T(100), strIniPath);说实话这个行为在不同 Windows 版本上表现不完全一致最保险的方案是关键配置写入后用CFile或CreateFile打开文件再关闭或者干脆接受系统自动 flush 的时机。我在实际项目里的做法是程序退出前统一把所有配置写一遍然后正常退出这样系统有充足时间落盘。如果程序可能被强杀那就在每次写入后加一个小的延时或者用文件句柄强制 flush。注意网上流传的写 NULL 刷新说法是错的WritePrivateProfileString(NULL, NULL, NULL, file)会把整个文件清空千万别用。5. 编码、路径与线程安全的实战避坑5.1 ANSI 与 Unicode 的编码陷阱MFC 项目分 ANSI 和 Unicode 两种字符集ini 读写在这两种下的行为差别很大这是新手最容易翻车的地方。在ANSI 工程下GetPrivateProfileString操作的是系统默认代码页简体中文系统就是 GBK中文能正常读写。在Unicode 工程下如果你调用的是GetPrivateProfileStringW而 ini 文件本身是 ANSI 编码读中文就会乱码。解决思路有两条一是统一用TCHAR和_T()宏让代码跟着工程字符集走ini 文件也保存成对应编码二是如果 ini 文件必须兼容记事本默认的 ANSI 编码那在 Unicode 工程里就要做编码转换读出来用MultiByteToWideChar转写之前用WideCharToMultiByte转。我的建议是新项目一律用 Unicode 字符集ini 文件也存成 UTF-16 LE 或者干脆避免在 ini 里放中文。如果非要放中文又要兼容记事本那就存成带 BOM 的 UTF-8但注意GetPrivateProfileString对 UTF-8 BOM 的处理在不同版本上不一致可能把 BOM 当成内容读进来。最省心的方案还是配置项的值用英文或拼音中文只放在注释里。5.2 相对路径与工作目录的坑前面提过路径要用绝对路径这里再展开说一个具体场景。假设你的程序被一个快捷方式启动快捷方式的起始位置设成了别的目录那么程序里的相对路径.\config.ini就会指向那个目录而不是 exe 目录。结果就是程序 A 读到了程序 B 的配置或者读不到配置用了默认值。排查这类问题的方法很简单在读取配置前把GetCurrentDirectory的结果和GetModuleFileName的结果都打印出来对比一下一眼就能看出工作目录对不对。养成习惯后我所有涉及文件路径的代码都基于GetModuleFileName推导从不依赖当前工作目录。5.3 多线程同时读写的隐患ini 的读写 API 本身不是为高并发设计的。如果多个线程同时往同一个 ini 文件写可能出现内容交错、部分丢失的情况。虽然 Windows 对这些 API 内部有一定同步但跨进程、跨线程的可靠性没有保证。实际项目里的做法是配置读写集中到一个模块用临界区CRITICAL_SECTION或者互斥量保护。读操作相对安全可以不加锁写操作一定要串行化。如果配置改动不频繁更好的方案是内存里维护一份配置副本所有读都走内存只在保存时统一写文件这样既快又安全。6. 常见问题速查与封装建议6.1 高频问题排查表下面这张表是我这些年遇到问题后整理的基本覆盖了 90% 的 ini 疑难杂症现象可能原因排查方法写入后文件没变化权限不足或目录不存在检查返回值确认目录存在读出来是默认值节名/键名拼写错误或大小写不符用记事本核对注意节名大小写敏感中文乱码工程字符集与文件编码不一致统一用 Unicode 或做编码转换值被截断缓冲区太小用探测长度方式动态分配写完立即读不到缓存未落盘退出前统一写或强制 flush程序换目录后配置丢失用了相对路径改用GetModuleFileName推导绝对路径多线程写内容错乱并发写无保护加临界区串行化6.2 一个可直接复用的封装类与其每次到处调 API不如封装一个CIniFile类把路径、读写、默认值、编码处理都收进去。核心接口设计成这样class CIniFile { public: CIniFile(LPCTSTR lpPath); CString GetString(LPCTSTR lpSection, LPCTSTR lpKey, LPCTSTR lpDefault _T()); int GetInt(LPCTSTR lpSection, LPCTSTR lpKey, int nDefault 0); BOOL SetString(LPCTSTR lpSection, LPCTSTR lpKey, LPCTSTR lpValue); BOOL SetInt(LPCTSTR lpSection, LPCTSTR lpKey, int nValue); BOOL DeleteKey(LPCTSTR lpSection, LPCTSTR lpKey); BOOL DeleteSection(LPCTSTR lpSection); private: CString m_strPath; };实现里把前面讲的探测长度、格式化整数、返回值判断都包进去业务代码里就只剩ini.GetInt(_T(Database), _T(Port), 3306)这种清爽调用。这个类我用了好几年从没出过问题。封装的时候有个细节要注意GetString返回CString而不是LPCTSTR避免返回局部缓冲区指针的悬空问题。6.3 几个能省时间的实操心得最后分享几个文档里不会写、但实际很管用的技巧。第一个调试期把 ini 路径打印到日志。很多配置不生效的问题根源是程序读的根本不是你改的那个文件。启动时打一行日志输出实际路径能省掉大量排查时间。第二个给 ini 加一个版本号节。比如[Meta]下面放Version2程序启动时检查版本版本不对就做配置迁移或者重置。这样以后配置结构升级时不会因为旧文件缺字段而崩溃。第三个写入前先备份。对于用户手动改过的配置程序自动保存前把原文件复制一份成config.ini.bak万一写坏了还能恢复。这个成本极低但关键时刻能救命。第四个布尔值统一用 0/1 存。不要一会儿存true/false一会儿存yes/no读的时候还要写一堆判断。统一成整数 0 和 1读出来GetInt直接判断简单可靠。这套 ini 方案我在多个 MFC 项目里反复验证过从单机工具到现场设备软件都能扛住。核心就一句话用原生 API、传绝对路径、注意编码和刷新、做好封装。把这几点做到位配置文件这块基本不会再给你添麻烦。