C++的字符串处理长期以来依赖char和wchar_t两种基本类型,前者通常是单字节编码的载体,后者在Windows上是2字节,在Linux上却是4字节。这种平台差异让字符串编码转换成为跨平台项目中的高频痛点。比如,把一个std::string类型的UTF-8数据交给Windows API时,需要先转成宽字符;反过来,从Windows读取的中文路径要写入JSON,又得转回UTF-8。下面把常见的转换方法和适用边界讲清楚,避免继续被乱码问题消耗时间。

一、C++字符串编码的基础概念与类型选择
C++标准并没有强制规定字符串在内存中的编码方式,char只是字符类型,而std::string更像一个字节容器,本身不携带编码信息。同样是std::string,它既可以保存UTF-8文本,也可以保存GBK、Latin-1甚至任意二进制数据。早期很多乱码问题的根源,就是代码在读取或输出时误判了字节序列的实际编码。
wchar_t同样存在平台差异:在Windows下它占用2个字节,通常对应UTF-16编码单元;在Linux和macOS下一般占用4个字节,对应UTF-32编码单元。因此不能假设wchar_t在任何平台上都等价于UTF-16。C++11之后标准引入了char16_t和char32_t,分别明确表示UTF-16和UTF-32的编码单元,C++20又增加了char8_t用于表示UTF-8编码单元。现代项目如果需要在不同平台间保持行为一致,内部统一使用std::string承载UTF-8是最常见的选择,只在系统API边界处做转换。
编码转换的本质是对码点进行重新编码。以UTF-8和UTF-16为例,两者并不是简单的字节替换:UTF-8采用变长1到4字节表示一个码点,UTF-16采用2或4字节表示一个码点。转换过程需要先按源编码规则解码出Unicode码点,再按目标编码规则重新编码。理解这一点比死记函数名更重要,因为一旦出现非法字节序列,错误处理逻辑就取决于解码阶段的判断。
二、标准库方案:wstring_convert与codecvt的用法与局限
C++11的标准库提供了一套编码转换设施,核心是std::wstring_convert和std::codecvt_utf8_utf16。借助这两个模板,可以在UTF-8与UTF-16之间进行转换,代码非常简洁。下面的示例演示了UTF-8字符串与宽字符串之间的互转:
#include <string>
#include <locale>
#include <codecvt>
#include <iostream>
std::wstring utf8_to_wstring(const std::string& str) {
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> converter;
return converter.from_bytes(str);
}
std::string wstring_to_utf8(const std::wstring& wstr) {
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> converter;
return converter.to_bytes(wstr);
}
虽然这段代码看起来方便,但std::wstring_convert已经在C++17标准中被标记为弃用。它的问题在于依赖全局locale设置,并且异常处理方式不够直观:一旦输入中包含非法编码字节,可能抛出std::range_error,但在某些标准库实现中行为并不一致。另外,codecvt家族在不同编译器上的性能差异较大,跨平台项目如果依赖它,升级编译器后可能面临编译警告甚至移除风险。
如果不想依赖第三方库,又希望行为完全可控,手写UTF转换函数是更稳妥的选择。UTF-8的编码规则比较清晰:单字节字符最高位为0;多字节字符以若干个连续的1加0开头,后续字节均为10开头。UTF-16则需要处理代理对,码点小于等于0xFFFF时直接放入一个单元,大于0xFFFF时拆成高代理和低代理。下面是完整的UTF-8与UTF-16互转实现:
#include <string>
#include <cstdint>
#include <stdexcept>
std::u16string utf8_to_utf16(const std::string& utf8) {
std::u16string result;
size_t i = 0;
while (i < utf8.size()) {
uint32_t codepoint = 0;
unsigned char c = static_cast<unsigned char>(utf8[i]);
if (c < 0x80) {
codepoint = c;
++i;
} else if ((c & 0xE0) == 0xC0) {
if (i + 1 >= utf8.size()) throw std::runtime_error("invalid utf8");
codepoint = (c & 0x1F) << 6;
codepoint |= (static_cast<unsigned char>(utf8[i + 1]) & 0x3F);
i += 2;
} else if ((c & 0xF0) == 0xE0) {
if (i + 2 >= utf8.size()) throw std::runtime_error("invalid utf8");
codepoint = (c & 0x0F) << 12;
codepoint |= (static_cast<unsigned char>(utf8[i + 1]) & 0x3F) << 6;
codepoint |= (static_cast<unsigned char>(utf8[i + 2]) & 0x3F);
i += 3;
} else if ((c & 0xF8) == 0xF0) {
if (i + 3 >= utf8.size()) throw std::runtime_error("invalid utf8");
codepoint = (c & 0x07) << 18;
codepoint |= (static_cast<unsigned char>(utf8[i + 1]) & 0x3F) << 12;
codepoint |= (static_cast<unsigned char>(utf8[i + 2]) & 0x3F) << 6;
codepoint |= (static_cast<unsigned char>(utf8[i + 3]) & 0x3F);
i += 4;
} else {
throw std::runtime_error("invalid utf8");
}
if (codepoint <= 0xFFFF) {
result.push_back(static_cast<char16_t>(codepoint));
} else {
codepoint -= 0x10000;
result.push_back(static_cast<char16_t>(0xD800 + (codepoint >> 10)));
result.push_back(static_cast<char16_t>(0xDC00 + (codepoint & 0x3FF)));
}
}
return result;
}
std::string utf16_to_utf8(const std::u16string& utf16) {
std::string result;
for (size_t i = 0; i < utf16.size(); ++i) {
uint32_t codepoint = utf16[i];
if (codepoint >= 0xD800 && codepoint <= 0xDBFF) {
if (i + 1 >= utf16.size()) throw std::runtime_error("invalid utf16");
uint32_t low = utf16[++i];
if (low < 0xDC00 || low > 0xDFFF) throw std::runtime_error("invalid utf16");
codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + (low - 0xDC00);
} else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) {
throw std::runtime_error("invalid utf16");
}
if (codepoint <= 0x7F) {
result.push_back(static_cast<char>(codepoint));
} else if (codepoint <= 0x7FF) {
result.push_back(static_cast<char>(0xC0 | (codepoint >> 6)));
result.push_back(static_cast<char>(0x80 | (codepoint & 0x3F)));
} else if (codepoint <= 0xFFFF) {
result.push_back(static_cast<char>(0xE0 | (codepoint >> 12)));
result.push_back(static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F)));
result.push_back(static_cast<char>(0x80 | (codepoint & 0x3F)));
} else {
result.push_back(static_cast<char>(0xF0 | (codepoint >> 18)));
result.push_back(static_cast<char>(0x80 | ((codepoint >> 12) & 0x3F)));
result.push_back(static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F)));
result.push_back(static_cast<char>(0x80 | (codepoint & 0x3F)));
}
}
return result;
}
手写转换的优势是无额外依赖,且能精确控制错误处理,比如抛出异常、返回空字符串或者跳过非法字节。对于嵌入式环境、核心底层库或者不希望引入iconv的项目,这种方案非常实用。缺点是代码量较大,而且需要开发者对Unicode编码细节有足够理解,容易在边界条件上出错。
三、Windows平台下的稳定转换:MultiByteToWideChar与WideCharToMultiByte
Windows操作系统内部使用UTF-16编码,因此几乎所有Windows API都接受宽字符字符串。当程序需要把UTF-8字符串传给Windows API,或者把从Windows获取的宽字符串转成UTF-8保存时,使用系统提供的MultiByteToWideChar和WideCharToMultiByte是最可靠的方式。这两个函数直接由系统实现,支持多种代码页,并且性能经过优化。
下面的代码封装了UTF-8与宽字符串之间的转换,代码页参数使用CP_UTF8,这样无论系统区域设置如何,转换结果都与本地代码页无关:
#include <windows.h>
#include <string>
std::string utf16_to_utf8(const std::wstring& wstr) {
if (wstr.empty()) return std::string();
int size = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), static_cast<int>(wstr.size()),
nullptr, 0, nullptr, nullptr);
std::string result(size, 0);
WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), static_cast<int>(wstr.size()),
&result[0], size, nullptr, nullptr);
return result;
}
std::wstring utf8_to_utf16(const std::string& str) {
if (str.empty()) return std::wstring();
int size = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast<int>(str.size()),
nullptr, 0);
std::wstring result(size, 0);
MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast<int>(str.size()),
&result[0], size);
return result;
}
这两个函数的调用模式都是先以目标缓冲区为空的方式调用一次,获取所需缓冲区大小,然后分配内存再调用一次完成实际转换。MultiByteToWideChar的第一个参数指定源字符串的代码页,CP_UTF8表示输入是UTF-8;WideCharToMultiByte的第一个参数指定输出代码页。传入CP_UTF8可以避免系统区域设置带来的干扰。
使用这两个API时还要注意错误处理。如果输入字符串中包含非法字节序列,函数可能返回0,此时可以调用GetLastError获取具体错误码。对于来自文件或网络的不可信数据,最好在调用前做基本的UTF-8合法性检查,或者将非法字节替换为U+FFFD。此外,在C++17之后,std::string和std::wstring的data()成员函数返回的是可修改指针,因此代码中也可以直接使用result.data()替代&result[0],语义更清晰。
四、Linux与Unix下的通用方案:iconv
Linux和Unix系统普遍提供iconv函数簇,声明在iconv.h头文件中。与Windows API相比,iconv的优势在于支持非常丰富的编码名称,例如UTF-8、GBK、Big5、ISO-8859-1等,适合处理来源多样的文本数据。iconv的接口相对底层,需要手动管理输入输出缓冲区和错误码,但熟悉之后可以封装成通用工具函数。
下面是一个基于iconv的通用编码转换函数,它动态扩展输出缓冲区,能处理输出缓冲区不足的情况,并在遇到非法序列时抛出异常:
#include <iconv.h>
#include <cerrno>
#include <string>
#include <stdexcept>
#include <vector>
std::string convert_encoding(const std::string& input,
const char* from_code,
const char* to_code) {
iconv_t cd = iconv_open(to_code, from_code);
if (cd == reinterpret_cast<iconv_t>(-1)) {
throw std::runtime_error("iconv_open failed");
}
std::vector<char> output(input.size() * 4 + 16);
char* in_ptr = const_cast<char*>(input.data());
size_t in_bytes = input.size();
char* out_ptr = output.data();
size_t out_bytes = output.size();
while (in_bytes > 0) {
size_t ret = iconv(cd, &in_ptr, &in_bytes, &out_ptr, &out_bytes);
if (ret == static_cast<size_t>(-1)) {
if (errno == E2BIG) {
size_t used = out_ptr - output.data();
output.resize(output.size() * 2);
out_ptr = output.data() + used;
out_bytes = output.size() - used;
continue;
}
iconv_close(cd);
throw std::runtime_error("iconv failed");
}
}
iconv_close(cd);
return std::string(output.data(), out_ptr - output.data());
}
iconv在转换过程中会更新输入和输出指针,因此每次遇到E2BIG错误时需要重新计算已使用空间和剩余空间。除E2BIG外,EILSEQ表示输入包含非法字节序列,EINVAL表示输入被截断。根据业务需要,可以将这两种错误也单独捕获,并采取替换、跳过或报错策略。
iconv的缺点是需要额外链接libiconv,在某些最小化Linux发行版上可能没有预装。此外,不同系统中iconv支持的编码名称存在细微差异,例如有些系统使用“UTF-8”,有些也接受“UTF8”。实际项目中可以通过编译检测或运行期枚举来确认。如果项目已经引入Boost库,使用Boost.Locale可以获得更友好的C++接口,但会增加编译依赖和链接体积。
五、BOM处理与常见编码陷阱
BOM是字节顺序标记的缩写,用于标识文本的编码方式。UTF-8 BOM由三个字节EF BB BF组成,UTF-16 LE BOM为FF FE,UTF-16 BE BOM为FE FF。很多文本编辑器和Windows记事本会在文件开头写入BOM,但网络协议和部分解析器并不把BOM视为合法内容。读取文件后如果直接进行编码转换,BOM可能被当作真实字符处理,生成多余的内容。遇到UTF-8文件时,最好在转换前去除BOM。下面是一个简单的去除函数:
#include <string>
bool strip_utf8_bom(std::string& text) {
const unsigned char bom[] = {0xEF, 0xBB, 0xBF};
if (text.size() >= 3 &&
static_cast<unsigned char>(text[0]) == bom[0] &&
static_cast<unsigned char>(text[1]) == bom[1] &&
static_cast<unsigned char>(text[2]) == bom[2]) {
text.erase(0, 3);
return true;
}
return false;
}
另一个常见陷阱是源码文件本身的编码。MSVC编译器在未指定编码时,可能按系统本地代码页解释源码中的字符串字面量,而GCC和Clang通常默认按UTF-8处理。如果源码文件以GBK保存,却包含中文注释或中文字符串,在Linux编译后就可能出现乱码。跨平台项目应该统一源码文件为UTF-8,并在MSVC下添加/utf-8编译选项,或者使用u8前缀明确字符串字面量编码。
最后要注意wchar_t的宽度差异以及std::string的长度语义。在Windows上wchar_t是2字节,在Linux上是4字节,因此不要把sizeof(wchar_t)硬编码为2或4。同时,std::string::size()返回的是字节数而非字符数,UTF-8字符串中一个中文汉字可能占3个字节,直接使用size()做截断可能切断多字节字符。实际开发中,应尽量在系统边界和网络传输层统一使用UTF-8,在Windows API边界使用宽字符,在数据库和文件读写层明确规定编码,这样能最大程度减少编码转换的复杂度。