1. UNICODE 与 _UNICODE两个宏各管一摊别混为一谈VS2008 的 Unicode 工程问题十个里面有八个不是字符编码本身的问题而是宏定义没对齐导致的。很多人接手一个老工程先把项目属性里的字符集从使用多字节字符集改成使用 Unicode 字符集然后满怀期待地点下重新生成结果收获一屏红色错误接着就开始怀疑人生甚至觉得还是改回去吧。这种经历我见过太多次问题往往出在最基础的一层认知上Windows 头文件认UNICODEC 运行库认_UNICODE这是两个独立的开关缺一个就会有一半的接口走错分支。1.1 一次典型的 LNK2019 报错现场先还原一个非常有代表性的场景。工程原来是多字节的代码里写的是int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)_tWinMain在tchar.h里是个宏多字节配置下展开成WinMainUnicode 配置下展开成wWinMain。你把字符集切到 Unicode 之后源码里的函数名变成了wWinMain编译器老老实实按wWinMain生成符号但链接器的默认入口点仍然是WinMainCRTStartup它要去找WinMain。于是报错error LNK2019: 无法解析的外部符号 _WinMain16 该符号在函数 ___tmainCRTStartup 中被引用这个报错非常具有迷惑性因为它说的是找不到 WinMain而你明明写了入口函数。根因不在代码在链接器设置。解决方式是打开项目属性 → 配置属性 → 链接器 → 高级 → 入口点手动填入wWinMainCRTStartup。注意 VS2008 的字符集下拉不会帮你改这一项这是它和后续版本行为差异里最坑的一点之一。顺便说一句控制台工程对应的是main/wmain入口点分别是mainCRTStartup和wmainCRTStartup。四种组合记清楚遇到 LNK2019 先对一下表比盲目搜索快得多。入口函数对应入口点适用字符集mainmainCRTStartup多字节wmainwmainCRTStartupUnicodeWinMainWinMainCRTStartup多字节wWinMainwWinMainCRTStartupUnicode1.2 Windows API 走 UNICODEC 运行库走 _UNICODE这两个宏的分工说白了就是谁家的函数听谁的话。UNICODE宏影响的是 Windows SDK 头文件里的那套A/W后缀机制。windows.h里几乎所有涉及字符串的 API 都是成对定义的比如MessageBoxA和MessageBoxW然后通过一个条件编译把通用名MessageBox指向其中之一。UNICODE没定义时指向A版本定义了指向W版本。_UNICODE宏影响的是 C 运行库那一套也就是tchar.h里的_t前缀宏。_tcscpy、_tcslen、_stprintf、_tmain、_T()这些全都归它管。它没定义时展开为strcpy、strlen、sprintf定义了展开为wcscpy、wcslen、swprintf。这两个宏必须同时定义或者同时不定义。只定义UNICODE不定义_UNICODE会出现MessageBox要wchar_t*而_tcscpy还在处理char*的诡异局面编译错误满天飞但每一处看起来都没什么问题。反过来只定义_UNICODE不定义UNICODE则是 API 层还在用窄字符、CRT 层已经用宽字符症状更隐蔽可能编译过了但运行起来字符串被截断。还有一个更隐蔽的问题宏必须定义在包含头文件之前。有些工程在stdafx.h末尾、或者在某个.cpp的#include windows.h之后才写#define UNICODE这种写法完全无效因为头文件里的条件编译早就走完了。项目属性里的预处理器定义之所以可靠就是因为它通过编译命令行/D传入在预处理的最开始就生效。1.3 属性页那个字符集下拉背后改了哪些东西很多人以为这个下拉是个总开关改了就万事大吉其实它做的事很有限在配置属性 → C/C → 预处理器 → 预处理器定义里追加UNICODE和_UNICODE两个宏多字节模式下则是删掉它们并追加_MBCS。就这么多。它不改入口点不改资源编译器的编码不改第三方库的编译方式也不改你源码文件本身的保存编码。所以切换字符集之后需要人工检查的清单至少包括链接器入口点是否匹配新的入口函数名.rc资源文件里的字符串表、对话框控件文本是否需要重新录入所有第三方静态库/动态库是否有对应的 Unicode 版本源码文件里有没有直接写死的非 ASCII 字符中文注释、中文字符串字面量是否还有裸char*变量在传递路径上被当成TCHAR*使用。这五条里前三条属于配置层面后面两条属于代码层面。我个人的习惯是先把配置层面全部对齐、保证能编译通过再逐条处理代码层面的类型不匹配。反过来的话编译器会同时抛出配置错误和类型错误几百条报错混在一起根本看不出哪条是根因。2. 工程属性里的三处联动漏一处就白改切 Unicode 这件事本质上不是改一个设置而是同时改三个地方的设置并保证它们不打架。这三处分别是 C/C 编译器、链接器、资源编译器。任何一处漏掉都会以一个看起来毫不相关的错误形式暴露出来。2.1 预处理器定义多配置、多平台都要改VS2008 的项目属性是按配置Debug/Release分别保存的。你在 Debug 配置下加了UNICODE切到 Release 配置重新生成一切照旧报错——因为 Release 配置里根本没加。这个坑极其常见尤其是接手别人做了一半的工程时。更麻烦的是有些工程还有多个平台配置Win32、x64甚至自定义的配置名。每增加一个维度需要手工同步的地方就翻一倍。我的做法是打开配置管理器先确认一共有几套配置然后在属性页左上角的配置下拉里选所有配置一次性改完。这个下拉框很多人从来没注意过但它是省事的关键。另外还有一个点容易被忽略属性表.vsprops。如果工程引入了属性表而属性表里也定义了_MBCS那么即使你在项目属性里加了UNICODE最终的宏定义里可能同时存在_MBCS、UNICODE、_UNICODE三个。_MBCS和_UNICODE同时存在时tchar.h的选择逻辑会出现未定义行为实际表现取决于头文件里#if的判断顺序。排查这类问题的办法是在项目属性 → C/C → 预处理器里看继承的值或者在编译命令行里加/showIncludes配合观察最直接的是在代码里临时写一句#error把宏值打出来#if defined(_UNICODE) defined(_MBCS) #error UNICODE 与 _MBCS 同时存在检查属性表 #endif这种临时断言写完记得删掉但它定位宏冲突的效率比翻十页属性页高得多。2.2 链接器入口点与运行库的连带关系入口点的问题在 1.1 已经说过这里补充一个连带项运行库CRT的选择。项目属性 → C/C → 代码生成 → 运行时库里有/MT、/MTd、/MD、/MDd四种。这个选项和字符集没有直接关系但它和第三方库能不能链上关系极大。如果你链接的第三方库是用/MT编译的而你的工程用/MD会报一堆LNK2005 符号重复定义或者LNK4098 默认库冲突。当工程从多字节切到 Unicode 时如果顺手换了一个 Unicode 版的第三方库一定要确认这个库的 CRT 链接方式和你当前工程一致。我遇到过的情况是库的 Unicode 版本只有/MD的而工程一直用/MT结果为了用这个库把整个工程的运行库改掉引发了一连串其他依赖库的冲突。这种时候更稳妥的做法是找库的源码自己重编一份/MT的而不是改动主工程的全局设置。2.3 资源编译器对话框和字符串表最容易被漏掉.rc文件是独立编译的走的是资源编译器rc.exe它有自己的一套编码处理逻辑。工程切到 Unicode 之后资源里的字符串会以什么形式存进二进制取决于.rc文件里有没有正确声明代码页。一个典型的症状是程序界面上的中文全部变成乱码或者问号但代码里MessageBox弹出的中文是正常的。这说明字符串在源码层面没问题问题出在资源编译阶段。.rc文件顶部通常需要这样一段#pragma code_page(936)936是简体中文的代码页。如果.rc文件本身是用 UTF-8 保存的则应该写#pragma code_page(65001)。写错或者不写rc.exe 会按系统默认代码页去解释文件里的字节中文资源就废了。还有一点.rc文件里如果#include afxres.h或者#include winres.h这些头文件的路径依赖 VC 的包含目录设置。切换字符集本身不影响这个但如果同时升级了 SDK 或者在别的机器上打开工程路径失效会导致无法打开包含文件 afxres.h。这不是 Unicode 问题但经常和 Unicode 改造同时发生容易混淆。判断方法很简单看报错是RC1015还是RC2xxx前者是找不到文件后者才是编码/语法问题。2.4 从别人机器上拷配置时丢掉的东西老工程经常出现我这边能编你那边编不过的情况。除了绝对路径差异最常见的原因是用户级的属性表或者环境变量。VS2008 支持通过$(VCInstallDir)、$(SolutionDir)这类宏引用路径如果某个同事的机器上设置了额外的包含目录环境变量他的工程能编过你的不能。排查这类问题最快的办法是打开项目属性 → C/C → 命令行把里面展开后的完整命令行复制出来和自己的对比。差异往往一眼可见——多一个/I目录、少一个/D宏问题就出在那。3. TCHAR 体系的正确打开方式配置层面搞定之后剩下的全是代码改造。VS2008 时代的跨字符集代码标准做法就是用TCHAR体系让同一份源码在两种字符集下都能编译。这套东西不复杂但细节很多每一条都对应一个真实会踩的坑。3.1 _T()、TEXT()、L 三个写法怎么选字符串字面量有三种写法含义不同abc永远是const char*跟字符集设置无关Labc永远是const wchar_t*跟字符集设置无关_T(abc)或TEXT(abc)跟着字符集设置走多字节下是const char*Unicode 下是const wchar_t*。_T()来自tchar.hTEXT()来自windows.h实际上定义在winnt.h里功能完全一样。选哪个纯看团队习惯我倾向统一用_T()因为它和_tcslen、_stprintf这一整套命名风格一致视觉上更容易识别。关键原则是凡是传给 API 或者 TCHAR 类型变量的字面量一律用_T()包起来。裸写...在 Unicode 工程下必然报 C2664无法转换参数类型。这个改造量在几万行的老工程里是很吓人的但可以用正则批量替换比如把MessageBox(后面紧跟的第一个开头的参数处理掉。批量替换有风险改完必须逐处 review尤其是那些本来就该是char*的地方——比如传给第三方窄字符接口的字符串批量替换反而会引入新错误。判断某个字面量该不该加_T()看它最终流向哪里流向 Windows API、MFC 的CString、TCHAR 缓冲区就加流向std::string、fopen、第三方窄接口就不加。3.2 格式化与字符串函数的对照表这是改造工作量最大的一块。所有str*系列函数在 Unicode 工程下都要换成_tcs*或_wcs*。整理成表会清晰很多多字节写法TCHAR 通用写法Unicode 写法备注strcpy_tcscpywcscpy不安全建议换_tcscpy_sstrlen_tcslenwcslen返回字符数不是字节数strcmp_tcscmpwcscmpsprintf_stprintfswprintf参数格式串也要带_T()strstr_tcsstrwcsstratoi_ttoi_wtoifopen_tfopen_wfopen见 4.2 的中文路径问题printf_tprintfwprintf控制台输出需配合 locale有一类问题特别值得单独提sizeof和长度计算。多字节下sizeof(buf)和字符数基本对得上中文字符除外切到 Unicode 之后sizeof返回的是字节数而wchar_t占 2 字节所以字符数是sizeof(buf)/sizeof(TCHAR)。代码里凡是写死sizeof(char)或者直接用sizeof当字符数用的地方都要改成_countof(buf)或者sizeof(buf)/sizeof(TCHAR)。这类 bug 不会编译报错但会导致缓冲区长度算错轻则截断重则越界。还有一个更隐蔽的memcpy、memset、ZeroMemory这些是按字节操作的和字符集无关。memset(buf, 0, sizeof(buf))在 Unicode 下依然正确因为sizeof会算出正确的字节数。但如果代码里写的是memset(buf, 0, nLen)而nLen是字符数那就只清了前一半。改造时要逐个确认传入的长度参数是字符数还是字节数。3.3 CString、std::wstring、char* 之间的来回转换MFC 工程的CString在 Unicode 配置下其实是CStringW内部存wchar_t。它和std::string之间没有隐式转换必须显式转。最省事的写法是借助CString自带的转换构造CStringW strW _T(中文内容); // 宽转窄使用系统默认代码页 CStringA strA(strW); // 窄转宽 CStringW strW2(strA);注意CStringA和CStringW这两个显式类型它们不受工程字符集设置影响在两种配置下都能用是做边界转换的好工具。另一个常用工具是 ATL 的转换宏需要包含atlconv.h并在函数开头写USES_CONVERSION;#include atlconv.h void DoSomething(const std::string s) { USES_CONVERSION; const wchar_t* pw A2CW(s.c_str()); // ANSI - 宽字符 // 使用 pw注意生命周期只在本函数内 }USES_CONVERSION会在栈上开一块缓冲区转换结果的生命周期仅限于当前函数返回出去就是野指针。而且它内部用的_alloca在缓冲区需求较大时有栈溢出风险不适合处理大字符串。超过几 KB 的转换老老实实调MultiByteToWideCharstd::wstring Utf8ToWide(const std::string src) { if (src.empty()) return std::wstring(); int n MultiByteToWideChar(CP_UTF8, 0, src.c_str(), (int)src.size(), NULL, 0); std::wstring dst(n, L\0); MultiByteToWideChar(CP_UTF8, 0, src.c_str(), (int)src.size(), dst[0], n); return dst; }这里有几个细节值得说一下。第一MultiByteToWideChar第一次调用传NULL, 0是为了获取所需长度这是标准的两段式调用。第二源串长度传src.size()而不是-1这样不会把结尾的\0算进去也就不需要目标缓冲区额外留位。第三如果源串可能以\0结尾且希望保留传-1并相应地分配n1。第四CP_ACP和CP_UTF8千万别混前者是系统本地代码页后者是 UTF-8选错就是乱码。提示dst[0]这种写法在 C98 标准里严格来说不算完全合规但 VS2008 的 STL 实现支持实际使用没问题。如果在意标准合规性可以用std::vectorwchar_t中转一次。3.4 界面层面的连带影响字号和控件宽度这一条很容易被忽略但在实际改造中一定会遇到同样的文字Unicode 下显示的宽度和多字节下不一样。原因在于多字节配置下如果程序处理中文不当某些字符会被当成两个单字节字符渲染字宽按半角计算切到 Unicode 之后系统正确识别为全角字符字宽翻倍。结果是对话框上的静态文本被截断按钮上的文字显示不全。解决方式有两种。一种是调整控件宽度留出余量重新排版对话框资源。另一种是改用更合适的字体比如把默认的系统字体改成宋体或微软雅黑前者的中文渲染在中低分辨率下更紧凑。VS2008 的资源编辑器里改对话框字体需要手动编辑.rc文本找到DIALOGEX那一行后面的FONT语句修改。还有一个相关现象GetWindowTextLength返回的是字符数GetWindowText需要的缓冲区长度也是字符数不含结尾\0。这两点在 Unicode 下依然成立但因为每个字符占 2 字节如果代码里按字节数分配缓冲区就会不够。用CString::GetWindowText可以绕开这个坑它内部会处理好。4. 多字节第三方库混编桥接层的设计现实中的工程很少是纯 Windows API 加 MFC总会依赖几个第三方库。这些库有的提供 Unicode 版本有的只提供窄字符接口。怎么在 Unicode 工程里用只支持窄字符的库是这类改造里最费心思的部分。4.1 症状编译能过运行时字符串是乱的这类问题的典型特征是编译期完全正常运行时才暴露。比如从数据库里读出来的中文全是问号或者配置文件里的路径找不到文件。根因通常是这样的你的工程是 Unicode 的调用某个第三方库的接口时传进去的是CString宽字符而库的接口参数是const char*。编译器在CStringW到const char*之间找不到隐式转换按说应该报错——但很多人为了先编过加了强制转换// 危险写法 someLibFunc((const char*)(LPCTSTR)strPath);这个转换在 Unicode 下会把wchar_t*直接当char*用指针本身没问题但内容完全错位——原本的中0x4E2D会被读成两个字节0x2D 0x4E变成-N之类的垃圾字符。如果路径里只有 ASCII前一半字节是0x2D之类非零值后一字节是0x00字符串会被提前截断于是变成文件明明存在却打不开。强制转换是所有 Unicode 改造里最危险的操作它让编译器闭嘴但把问题推到了运行时。看到代码里有(char*)强转LPCTSTR的地方一律视为待修的 bug。4.2 在边界处做一次性转换正确的做法是在调用第三方库的那一层做转换库内部保持窄字符转换只发生在进出口。这叫边界转换原则。假设有个只支持char*路径的日志库封装一层void LogMessage(const CString msg) { // 宽转窄使用系统默认代码页 int n WideCharToMultiByte(CP_ACP, 0, msg, -1, NULL, 0, NULL, NULL); if (n 0) return; std::vectorchar buf(n); WideCharToMultiByte(CP_ACP, 0, msg, -1, buf[0], n, NULL, NULL); legacyLogWrite(buf[0]); }这段代码有几个点要注意。第一-1表示源串以\0结尾函数会把结尾符也算进长度所以目标缓冲区正好够用。第二转换后的字符串生命周期由buf管理函数返回后自动释放不会泄漏。第三CP_ACP表示用系统当前代码页在中文系统上就是 GBK。如果日志要跨语言环境查看应该用CP_UTF8但这要求日志查看工具能识别 UTF-8。文件路径的转换有个额外注意点。fopen在 Windows 上有_wfopen版本可以直接接受wchar_t*所以根本不需要转FILE* fp _wfopen(strPath, Lrb);同理_wstat、_waccess、_wmkdir、_wremove都有对应的宽字符版本。能用宽版本就直接用别绕圈子转换因为转换过程中如果遇到系统代码页表示不了的字符比如某些生僻字、日文、韩文会发生不可逆的信息丢失。这也是为什么现代 Windows 程序应该全程用宽字符处理路径。4.3 数据库、脚本引擎、XML 解析的典型表现不同类别的第三方库症状不太一样分开说。数据库访问。ADO的_ConnectionPtr、_RecordsetPtr在接受连接字符串时如果在 Unicode 工程里传_bstr_t内部会自动处理但如果传的是CString强转的char*连接字符串里的中文密码或者数据库名就会失效。SQLite3的sqlite3_open是窄字符接口sqlite3_open16是宽字符接口后者在 VS2008 里可以直接用省掉一次转换。要注意sqlite3_open16期望的是 UTF-16LE和 Windows 的wchar_t一致可以直接传CString。脚本引擎。以某轻量脚本引擎为例它的 C API 通常是const char*的。脚本文件本身一般用 UTF-8 保存所以正确做法是读文件得到char*保持 UTF-8 原始字节直接传给引擎中间不要经过CStringW转换。一旦走了CP_ACP转换UTF-8 字节就被当成 GBK 解释了脚本里的中文字符串常量会全乱。脚本返回的结果如果需要显示在界面上再在出口处从 UTF-8 转成宽字符。XML/JSON 解析。这类库通常以 UTF-8 为内部编码。TinyXML的TiXmlDocument::LoadFile参数是const char*传 UTF-8 编码的文件路径没问题但如果路径本身含中文且工程是 Unicode 的就要先转成 UTF-8而不是CP_ACP。这个区别极其重要CP_ACP转出来是 GBK很多 XML 库只认 UTF-8。一句话总结这一节转换的关键不是转不转而是转到哪个编码。库里内部用什么编码你就在边界处转到什么编码别想当然地用CP_ACP一把梭。4.4 静态库和动态库的字符集不一致如果你的工程链接了一个多字节编译的静态库而这个库的头文件里用了TCHAR体系那么它的.lib文件里存的是符号_SomeFunc对应char版本而你的 Unicode 工程要的是_SomeFuncW或者带了不同的名字修饰。结果是LNK2019。判断方法很直接看报错里的符号名有没有W后缀或者用dumpbin /symbols xxx.lib | findstr SomeFunc看看库里到底有哪些符号。解决方案按优先级排用同一个字符集重新编译这个库。最干净但要能拿到源码。找库的 Unicode 版本。前提是作者发布过。在调用处做窄宽转换直接调用库的窄字符接口。这要求库的接口不是TCHAR而是明确的char*否则头文件在 Unicode 工程里展开出来的声明和库里实际导出的符号对不上。第三点特别说明一下如果库的头文件里写的是void Func(const TCHAR* p)在你的 Unicode 工程里它会被声明成Func(const wchar_t*)而库里实际只有Func(const char*)的实现链接必然失败。这种情况没法在不重编库的前提下解决只能改头文件里的声明为明确的char*然后自己在调用处转换。这是下策但很多时候是唯一可行的路。5. 源文件编码与 C4819比宏更隐蔽的一条线如果宏定义、类型匹配全部处理妥当了程序还是会在某些机器上出乱码那问题很可能不在工程配置而在源文件本身的保存编码。这一层最容易被忽视因为它不在任何属性页里。5.1 C4819 到底在抱怨什么VS2008 编译时出现这个警告warning C4819: 该文件包含不能在当前代码页(936)中表示的字符。 请将该文件保存为 Unicode 格式以防止数据丢失意思很直白编译器按代码页 936简体中文 GBK去解释源文件的字节遇到了它解释不了的字节序列。典型成因是源文件里混入了 UTF-8 编码的中文注释——比如从网页上复制了一段代码粘贴进来或者用别的编辑器VS Code、Sublime保存过。这个警告本身不影响编译结果但它往往预示着更严重的问题如果 UTF-8 的中文字节序列恰好被 GBK 解释成了合法的双字节字符编译器不会报警告而是默默把这段注释解析成别的字符。如果这段注释碰巧包含了\或者引号之类的字符甚至可能把后面的代码也吃掉。更危险的情况是中文字符串字面量。UTF-8 编码的中文在 GBK 下被解释成涓枃之类的乱码编译能过运行时显示的就是乱码。5.2 .rc 文件的 code_page 与中文资源.rc文件的问题在 2.3 提过这里补充具体操作。首先确认.rc文件的实际保存编码。VS2008 里没有直接显示编码的功能可以用记事本打开、点另存为看默认编码是什么。或者在文件开头看有没有 BOM 字节EF BB BF表示 UTF-8 with BOM。如果是 GBK 保存.rc开头写#pragma code_page(936)如果是 UTF-8 保存写#pragma code_page(65001)注意 VS2008 对 UTF-8 without BOM 的支持不好资源编辑器打开这种文件会显示乱码甚至损坏内容。如果要改.rc的编码建议存成 UTF-8 with BOM或者在 VS2008 内部重新编辑保存。另一个常被忽略的点.rc里引用的字符串表STRINGTABLE中的中文以及对话框控件文本是分别存放的。改完code_page之后如果字符串表还是乱码说明这些字符串是在错误的编码下录入并保存的需要重新输入一遍。没有捷径只能一条条改。5.3 一套能长期维持的源文件编码约定踩过几次这样的坑之后我给团队定了几条硬规矩实践证明能省掉大量排查时间所有源码文件统一保存为 GBK代码页 936。理由是 VS2008 在中文系统上默认按这个编码读取最不容易出问题。代价是文件不能包含生僻字或者非中文字符遇到这种情况改用 Unicode 转义\uXXXX。纯 ASCII 文件不强制。如果某个文件全是英文保存成什么都一样不必折腾。禁止从浏览器直接复制代码到 IDE。这条最难执行但效果最明显。复制之前先粘到记事本再从记事本粘进 IDE中间这一步会把编码归一化。新增文件的编码在提交前检查。可以用一个小脚本扫描所有.cpp/.h文件检测是否含非法 UTF-8 序列作为提交前检查的一环。.rc文件和.cpp文件的编码约定要分开考虑。.rc文件如果要用code_page(65001)就必须用 UTF-8 with BOM 保存而.cpp用 GBK 更方便两者不必强求一致。注意不要试图在 VS2008 里用新版本编译器的/utf-8编译选项VS2008 的 cl.exe 不支持这个参数。看到网上教程里写/utf-8的那是 2015 之后的内容在这套工具链上会直接报错。6. 从现象反推原因的排查清单改造过程中会遇到各种症状我把它们和原因整理成对照表遇到问题时先从现象定位大致方向再去验证具体假设比一头扎进代码里搜要快得多。6.1 症状到原因的对照表现象最可能的原因验证方式LNK2019: 无法解析的外部符号 _WinMain16入口点设置与入口函数不匹配查链接器高级设置C2664: 无法将参数从 const char[N] 转换为 LPCWSTR字面量没加_T()看报错行编译通过运行时中文全是问号宽字符被强转成char*或转换编码选错搜索(char*)强转界面资源中文乱码代码弹窗正常.rc文件code_page错误检查.rc开头编译时大量 C4819 警告源文件混入 UTF-8 编码字符另存为检查编码中文路径的文件打不开fopen用了窄字符版本换成_wfopen数据库查询结果中文乱码连接字符串或 SQL 被错误转换检查转换用的代码页LNK2005: 符号重复定义CRT 链接方式/MT与/MD冲突查代码生成选项这张表覆盖了实际工作中八成以上的情况。剩下两成通常是组合问题需要按下面的链路一点点排除。6.2 一次完整的定位链路复盘假设现在有一个现象程序界面上显示的中文全部是方块或者问号但日志文件里记录的中文正常。这个组合本身就很有信息量——日志能正常写出来说明字符串在内存里是完好的问题只出在显示这一环节。第一层怀疑字体问题。如果显示的是方块很可能是对话框用的字体不支持中文。查.rc里FONT语句指定的字体名如果是MS Sans Serif这类西文字体中文确实会显示成方块。改成宋体试一下。第二层怀疑控件创建时的字符串传递。检查SetWindowText、SetDlgItemText这些调用是不是用了_T()包起来的字面量。如果是CString变量检查它的来源可能是在某个转换环节已经损坏了。第三层怀疑资源文件里的字符串表。如果界面上某几个固定的文字是乱的而动态设置的是好的那问题在资源。用资源编辑器打开.rc看看那几条字符串显示是否正常。如果编辑器里就乱说明资源本身存坏了重录。第四层怀疑系统区域设置与清单文件。如果程序在没有安装中文语言包的系统上运行或者清单文件里声明了错误的语言某些 API 的默认行为会变。VS2008 默认不生成清单文件如果手动加了要注意里面的assemblyIdentity语言属性。这一层比较少见前面三层排除完再看。每一层的验证成本都很低改一个地方重新编译看效果十几分钟内能走完。关键是别跳步骤也别一次改多个地方否则改好了不知道是哪个生效的。6.3 我在老工程上固定下来的几条规矩改造过几个几万行级别的老工程之后有几条规矩是我现在做任何类似改造都会先执行的先备份再动手用分支管理。Unicode 改造的改动量很大出了一个差错想回滚没有版本控制就完蛋。改造前拉一个独立分支每完成一个阶段配置对齐、字面量替换、函数替换、第三方库适配就提交一次出问题知道回退到哪里。先让工程能编过再追求运行正确。这两件事要分阶段做。配置对齐、_T()替换这些是能编过的范畴编码转换、资源重录是运行正确的范畴。混在一起做报错信息会互相干扰。先用小目标验证工具链。在动大工程之前新建一个空白的 Unicode 工程把第三方库都链上写几行代码验证中文能正常显示、文件能正常读写。这一步能提前暴露库不兼容这类结构性问题避免改造到一半才发现走不通。保留一份多字节配置作为对照。改造过程中如果某个功能行为异常切回多字节配置跑一遍如果正常说明问题出在改造引入的如果也异常说明是原有 bug。这个对照能极大缩短定位时间。具体做法是在配置管理器里保留一个Debug_MBCS配置只在排查时用。改造完成后跑一遍全量回归。字符串相关的 bug 很多是静默的——不崩溃、不报错只是结果不对。比如配置文件读出来的值变成了空字符串或者导出文件里某几列是乱码。这些靠肉眼点几下界面发现不了必须有覆盖到字符串处理路径的测试用例。没有自动化测试的话至少手工把读配置、写日志、导出文件、查询数据库、显示中文界面这五条路径各走一遍。最后说个我自己的体会Unicode 改造这件事最难的从来不是技术本身那些 API 和宏都有明确文档。难的是在一个没有测试覆盖、没有文档、还带着一堆历史包袱的老工程里一边改一边保证不出新问题。所以真正有价值的做法不是把改动做得多完美而是把改动切得足够小、可验证、可回退。一套能编译通过、有对照分支、每次提交都能回归的流程比任何技巧都管用。