☰
C++中的字符串编码处理方法
2026/11/6 3:54:09 网站建设 项目流程

前言


C++ 标准库里的std::string本质上是一个字节序列,它不知道什么是「字符」,更不知道什么是「中文」。一个汉字在 UTF-8 下占 3 个字节,std::string::size()返回的是 3 而不是 1;s[0]取到的是这个汉字三个字节中的第一个,单独拿出来既不是合法字符,也无法正确显示。字符串编码处理的全部麻烦,都源于「字节数」「码元数」「码点数」「用户感知的字符数」这四个概念并不相等。


常见的误解有四个。第一,把size()当成字数,用它做输入长度限制,结果中文用户名被截成半个字。第二,以为wchar_t是「万能的宽字符」,实际上它的宽度由实现决定:Windows 上是 16 位,通常表示 UTF-16 码元;Linux 上多为 32 位,表示 UTF-32。第三,用std::toupper逐字节处理 UTF-8 文本,非 ASCII 字节被当成独立字符,结果被破坏。第四,继续使用<codecvt>里的std::wstring_convert,而它从 C++17 起已被弃用。


另外说明一点:有些资料把 UTF-8 误写成「UF8」,这是笔误,正确写法是 UTF-8(8-bit Unicode Transformation Format)。本文按 UTF-8 讲解。


本文先建立码点与码元的概念,再讲std::string、std::wstring、std::u16string的差别,最后给出只用标准 C++ 的 UTF-8 解码、编码与安全截断代码。涉及平台 API 的地方会明确标注平台。


一、码点、码元与 UTF-8 的字节结构


Unicode 给每个字符分配一个编号,叫码点(code point),写作 U+XXXX,取值范围是 0 到 0x10FFFF。码点只是编号,真正存储时要选一种编码方式,把它表示成一个或多个码元(code unit)。UTF-8 的码元是 8 位字节,UTF-16 的码元是 16 位,UTF-32 的码元是 32 位。


UTF-8 的设计是变长编码,字节数由码点范围决定:




码点范围字节数首字节模式后续字节模式



U+0000 – U+007F10xxxxxxx无

U+0080 – U+07FF2110xxxxx10xxxxxx

U+0800 – U+FFFF31110xxxx两个10xxxxxx

U+10000 – U+10FFFF411110xxx三个10xxxxxx



由此可以推出几条实用规则:ASCII 字符在 UTF-8 下仍是单字节,与旧代码兼容;所有多字节序列的首字节最高位都是 1,后续字节都以10开头,因此按字节扫描时不会把一个后续字节误认成首字节;中文常用字大多落在三字节区间。


UTF-8 还有几条合法性约束,解析时必须检查:不能出现过长编码(例如用两字节表示 U+0041);不能编码代理区码点 U+D800 到 U+DFFF;不能超过 U+10FFFF。很多「能显示但行为诡异」的乱码,源头就是没有做这些校验。


二、std::string、std::wstring 与 UTF-16


std::string是std::basic_string<char>,存放的是字节,编码方式完全由使用者约定。它可以存 UTF-8、GBK、Latin-1,标准库不提供任何区分手段。这也是为什么跨平台代码通常约定「内部一律 UTF-8,只在边界转换」。


std::wstring是std::basic_string<wchar_t>。wchar_t的宽度是实现定义行为:Windows 上为 16 位,一个wchar_t是一个 UTF-16 码元,超出基本多文种平面(BMP)的字符要用代理对(surrogate pair)表示;Linux 上通常为 32 位,一个wchar_t可以直接放一个码点。因此同一段std::wstring代码在两个平台上语义并不相同,wstring::size()在 Windows 上对 emoji 会返回 2。


C++11 引入了std::u16string(元素类型char16_t)和std::u32string(元素类型char32_t),语义比wstring明确:前者是 UTF-16 码元序列,后者是码点序列。C++20 又引入了char8_t与std::u8string,用类型系统把 UTF-8 字节串和普通字节串区分开。这些特性有版本门槛,使用前要确认工具链支持。




类型元素宽度编码语义主要问题



std::string8 位未指定,约定 UTF-8长度按字节,可能截断多字节字符

std::wstring实现定义Windows UTF-16 / Linux UTF-32跨平台语义不一致

std::u16string16 位UTF-16非 BMP 字符占两个码元

std::u32string32 位UTF-32内存占用大,外部协议少用

std::u8string(C++20)8 位UTF-8需要 C++20 及以上的支持



<codecvt>中的std::wstring_convert和std::codecvt_utf8曾经是标准转换手段,但从 C++17 起被弃用,原因是接口设计存在缺陷、对非法输入的处理不统一。新代码不应再依赖它们;需要转换时,可以用本文的手写函数,或使用成熟的第三方库,或调用平台 API。


三、手写 UTF-8 解码、编码与安全截断


先看解码。函数从字节串的某个位置读出一个码点,成功时推进下标并返回true,遇到非法序列返回false且不保证下标位置。除了检查首字节模式,还要检查后续字节是否以10开头、是否过长编码、是否落在代理区。


// 适用于 C++17
#include <cstdint>
#include <string>
#include <string_view>

bool decode_one(std::string_view s, std::size_t& i, char32_t& cp) {
if (i >= s.size()) return false;

const unsigned char c = static_cast<unsigned char>(s[i]);
int extra = 0;
char32_t value = 0;

if (c < 0x80) {
cp = c;
++i;
return true;
} else if ((c & 0xE0) == 0xC0) {
extra = 1;
value = c & 0x1F;
} else if ((c & 0xF0) == 0xE0) {
extra = 2;
value = c & 0x0F;
} else if ((c & 0xF8) == 0xF0) {
extra = 3;
value = c & 0x07;
} else {
return false; // 10xxxxxx 或 11111xxx 都不是合法首字节
}

if (i + static_cast<std::size_t>(extra) >= s.size()) return false;

for (int k = 1; k <= extra; ++k) {
const unsigned char cc = static_cast<unsigned char>(s[i + k]);
if ((cc & 0xC0) != 0x80) return false;
value = (value << 6) | (cc & 0x3F);
}

static const char32_t min_for_len[4] = {0, 0x80, 0x800, 0x10000};
if (value < min_for_len[extra]) return false; // 过长编码
if (value > 0x10FFFF) return false;
if (value >= 0xD800 && value <= 0xDFFF) return false; // 代理区

i += static_cast<std::size_t>(extra) + 1;
cp = value;
return true;
}

编码是逆过程,按码点范围选择字节数,再把码点的高位依次填入首字节和后续字节的低 6 位:


// 适用于 C++17
std::string encode_one(char32_t cp) {
std::string out;
if (cp <= 0x7F) {
out.push_back(static_cast<char>(cp));
} else if (cp <= 0x7FF) {
out.push_back(static_cast<char>(0xC0 | (cp >> 6)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
} else if (cp <= 0xFFFF) {
out.push_back(static_cast<char>(0xE0 | (cp >> 12)));
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
} else if (cp <= 0x10FFFF) {
out.push_back(static_cast<char>(0xF0 | (cp >> 18)));
out.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
}
return out; // 超出范围的码点返回空串
}

有了这两个函数,就可以按码点遍历文本,而不是按字节:


// 适用于 C++17
#include <iostream>

void dump_codepoints(std::string_view s) {
std::size_t i = 0;
while (i < s.size()) {
const std::size_t start = i;
char32_t cp = 0;
if (!decode_one(s, i, cp)) {
std::cout << "invalid byte at " << start << '\n';
return;
}
std::cout << "U+" << std::hex << std::uppercase << cp
<< std::dec << " bytes=" << (i - start) << '\n';
}
}

最常见的工程需求是「按字节上限截断,但不能切碎字符」。做法是从头逐字符解码,记录最后一个完整字符的结束位置,一旦下一个字符会越过上限就停下:


// 适用于 C++17
std::string truncate_utf8(std::string_view s, std::size_t max_bytes) {
if (s.size() <= max_bytes) return std::string(s);

std::size_t i = 0;
std::size_t last_good = 0;
while (i < s.size() && i < max_bytes) {
const std::size_t start = i;
char32_t cp = 0;
if (!decode_one(s, i, cp)) break;
if (i > max_bytes) { // 这个字符越界,回退到上一个边界
i = start;
break;
}
last_good = i;
}
return std::string(s.substr(0, last_good));
}

这段函数保证返回值一定是若干完整 UTF-8 字符的拼接。注意它按「字节上限」工作;如果业务要求的是「最多 N 个字符」,应当在循环里计数码点,而不是比较i与max_bytes。


与 UTF-16 互转时,需要处理代理对:码点大于 0xFFFF 时,减去 0x10000,高 10 位加上 0xD800 得到高位代理,低 10 位加上 0xDC00 得到低位代理。也可以用平台 API:Windows 的MultiByteToWideChar/WideCharToMultiByte,POSIX 系统的iconv。这些接口的细节以各自平台文档为准,本文不展开。


常见坑点


坑点一:用std::string::size()当字符数。


❌if (name.size() > 20) reject();中文名 7 个字就可能超过 20 字节,被误判超长。


✅ 用解码循环数码点,或按业务定义「字符」的口径统计。


坑点二:用substr按字节截断。


❌s.substr(0, 10)可能把一个三字节汉字切成两半,产生非法 UTF-8。


✅ 用逐字符解码找到边界再截断,如上面的truncate_utf8。


坑点三:对 UTF-8 字节调用std::toupper。


❌for (char& c : s) c = static_cast<char>(std::toupper(c));非 ASCII 字节被当成独立字符处理,文本损坏。


✅ 只对 ASCII 范围做大小写转换;Unicode 大小写映射需要专门的库或平台 API。


坑点四:假设wchar_t一定是 2 字节或一定是 4 字节。


❌ 把std::wstring直接按sizeof(wchar_t) == 2序列化后跨平台传输。


✅ 明确目标平台的宽度,跨平台传输统一用 UTF-8;需要定长码元时用char16_t/char32_t。


坑点五:继续使用std::wstring_convert。


❌std::wstring_convert<std::codecvt_utf8<wchar_t>> conv;在 C++17 起被弃用,部分实现可能不再提供。


✅ 手写转换函数,或使用维护中的第三方库;平台代码可用系统 API。


坑点六:忽略 BOM。


❌ 读取带 BOM 的 UTF-8 文件,第一个字段名前面多了 U+FEFF,解析失败。


✅ 读入后检查开头三个字节是否为EF BB BF,是则跳过;写出时一般不加 BOM。


坑点七:不校验就信任输入。


❌ 直接把网络数据当 UTF-8 使用,过长编码或代理区序列进入下游,导致显示异常或安全过滤被绕过。


✅ 在边界处完整校验,非法序列拒绝或替换为 U+FFFD。


坑点八:以为一个码点就是一个「用户看到的字符」。


❌ 按码点反转字符串,组合附加符号与 emoji 的零宽连接序列被拆散。


✅ 普通业务按码点处理即可;涉及排版、光标移动时,需要字素簇(grapheme cluster)级别的库。


总结




概念含义常见误区



字节std::string的存储单位当成字符数

码元编码的基本单位(8/16/32 位)与码点混为一谈

码点Unicode 编号,0 到 0x10FFFF当成用户感知字符

UTF-81–4 字节变长编码认为中文固定 3 字节

wchar_t宽度实现定义认为跨平台一致

截断必须落在字符边界直接substr

转换手写或平台 API依赖已弃用的codecvt



处理 C++ 字符串编码,核心是把「字节」和「字符」彻底分开:存储层用std::string保存 UTF-8 字节,长度与截断按码点计算,边界处做完整校验,跨平台交换统一用 UTF-8。做到这四点,绝大多数乱码与截断问题都可以避免。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询