UTF-8是目前最主流的文本编码格式,但它允许的只是特定结构的字节序列,而不是任意字节组合。当C++程序读取一个来源不明的文件时,如果文件里混入了二进制数据、被截断的多字节序列或者用其他编码保存的内容,直接当作合法UTF-8处理就会出问题:轻则字符串显示乱码,重则在分词、正则匹配、JSON解析等下游逻辑中直接抛出异常。与其在每一个消费环节做防御,不如在读取文件这一层就把非法字节过滤掉,让进入内存的数据保证是合法的UTF-8流。这篇文章从UTF-8的编码结构讲起,给出完整的过滤实现和性能优化思路。

先弄清楚UTF-8的合法性规则
UTF-8是一种变长编码,一个字符由1到4个字节表示。判断一个字节序列是否合法,核心是看每个多字节序列的结构是否符合规范。单字节字符的范围是0x00到0x7F,也就是ASCII部分,任何处于这个区间的字节都是合法的。两字节字符的首字节必须在0xC2到0xDF之间,三字节首字节在0xE0到0xEF之间,四字节首字节在0xF0到0xF4之间,并且后续字节都必须是0x80到0xBF的继续字节。
有几个容易忽略的细节需要特别说明。首先是0xC0和0xC1这两个首字节,从数学上说它们能编出码点,但编出来的码点用单字节就能表示,属于过度编码,UTF-8规范明确禁止。其次是超过0xF4的首字节,比如0xF5到0xFF,会编出超出Unicode上限0x10FFFF的码点,同样非法。再次是代理区间的排除,首字节为0xED且第二字节大于等于0xA0时,编出来的是UTF-16代理区码点,也不允许出现在UTF-8中。最后还有Unicode规范里建议但非强制的一条,即0xE0后跟0x80到0x9F,或者0xF0后跟0x80到0x8F的情况,属于非最短编码问题,严格校验时通常也一并拒绝。
把这些规则整理成一张速查表,写代码时会方便很多:
| 序列长度 | 首字节范围 | 后续字节范围 |
|---|---|---|
| 1字节 | 0x00 - 0x7F | 无 |
| 2字节 | 0xC2 - 0xDF | 0x80 - 0xBF |
| 3字节 | 0xE0 - 0xEF | 0x80 - 0xBF(含附加限制) |
| 4字节 | 0xF0 - 0xF4 | 0x80 - 0xBF(含附加限制) |
实现逐字节校验的过滤器
有了规则,实现就是一个状态机遍历的过程。思路是:读取一个字节,先判断它是不是ASCII,是就直接输出;不是就根据它的前缀位推断出序列长度,再检查后面跟着的若干字节是否全部是合法的继续字节。任何一个环节校验失败,就丢弃当前序列,从出错的那个字节重新开始扫描,而不是简单跳过一个字节,因为坏数据的中间某个字节可能恰好是一个合法新序列的开头。
下面是一个完整的、不依赖任何第三方库的实现,可以直接嵌入项目中使用:
#include <fstream>
#include <sstream>
#include <string>
#include <cstdint>
// 推断UTF-8序列长度,非法首字节返回-1
static int sequenceLength(unsigned char b) {
if (b < 0x80) return 1; // ASCII
if ((b >> 5) == 0x6) return 2; // 110xxxxx
if ((b >> 4) == 0xE) return 3; // 1110xxxx
if ((b >> 3) == 0x1E) return 4; // 11110xxx
return -1; // 10xxxxx 或 11111xxx 均非法
}
// 判断是否为合法的继续字节
static bool isContinuation(unsigned char b) {
return (b & 0xC0) == 0x80;
}
// 过滤函数:返回只含合法UTF-8序列的新字符串
std::string filterUtf8(const std::string& input) {
std::string out;
out.reserve(input.size());
size_t i = 0;
while (i < input.size()) {
unsigned char b = static_cast<unsigned char>(input[i]);
int len = sequenceLength(b);
if (len == -1 || i + len > input.size()) {
++i; // 首字节非法或序列被截断,跳过一个字节重新对齐
continue;
}
bool valid = true;
// 附加限制:排除代理区与超长编码
if (len == 3 && b == 0xED &&
(unsigned char)input[i+1] >= 0xA0) valid = false;
if (len == 4 && b == 0xF0 &&
(unsigned char)input[i+1] < 0x90) valid = false;
for (int k = 1; valid && k < len; ++k) {
if (!isContinuation((unsigned char)input[i+k])) valid = false;
}
if (valid) {
out.append(input, i, len);
i += len;
} else {
++i; // 丢弃整个坏序列的开头字节,从下一个位置重扫
}
}
return out;
}
// 读取文件并过滤的入口函数
std::string readCleanFile(const std::string& path) {
std::ifstream fin(path, std::ios::binary);
std::ostringstream ss;
ss << fin.rdbuf();
return filterUtf8(ss.str());
}注意代码里有几个关键点。其一,文件必须以二进制模式打开,也就是std::ios::binary,否则在Windows上0x0D 0x0A会被自动转换,可能破坏多字节序列的边界。其二,失败时只前进一个字节而不是len个字节,这是为了不漏掉嵌在坏序列内部的合法字符。其三,reserve预先分配容量,避免大文件场景下字符串反复扩容带来的性能抖动。
跳过、替换还是报错:三种策略的取舍
直接丢弃非法字节是最简单的做法,但它有一个隐患:如果文件本身是GBK等其他编码,全文件都会被过滤成残缺内容,用户却毫不知情。更稳妥的做法有两种。第一种是把非法字节替换为Unicode替换字符U+FFFD,也就是字节序列0xEF 0xBF 0xBD,这样下游能感知到数据有问题,文本长度也不会剧烈塌缩。第二种是统计非法字节的占比,超过某个阈值就判定整个文件不是UTF-8,转交给编码检测模块处理。
下面演示如何在原有函数基础上增加替换和统计能力:
struct FilterResult {
std::string text; // 清洗后的文本
size_t badSequences = 0; // 被处理的非法序列数量
};
FilterResult filterUtf8Ex(const std::string& input,
bool replaceWithU8FFD = true) {
FilterResult result;
std::string& out = result.text;
out.reserve(input.size());
static const std::string REPL = "\xEF\xBF\xBD"; // U+FFFD
size_t i = 0;
while (i < input.size()) {
unsigned char b = (unsigned char)input[i];
int len = sequenceLength(b);
bool valid = (len > 0) && (i + len <= input.size());
if (valid) {
for (int k = 1; k < len && valid; ++k)
if (!isContinuation((unsigned char)input[i+k]))
valid = false;
}
if (valid) {
out.append(input, i, len);
i += len;
} else {
++result.badSequences;
if (replaceWithU8FFD) out.append(REPL);
++i;
}
}
// 非法序列占比超过5%,建议按非UTF-8文件处理
return result;
}实践中建议的决策流程是:先快速扫描统计非法序列数量,占比低于百分之一就做替换或丢弃,占比高就说明编码判断错了,应该先用编码检测工具确认真实编码再转码。这样能避免把一份完好的GBK文档过滤得面目全非。
大文件场景下的流式过滤与性能优化
上面的实现把整个文件读进内存再过滤,对于日志分析、爬虫语料这类动辄几百MB的文件并不合适。更好的方式是分块读取、边读边过滤。分块处理时有一个额外的坑需要处理:一个多字节字符可能恰好横跨两个块的边界,如果每个块独立过滤,边界处的合法字符会被误杀。解决办法是在每块末尾保留最多3个字节(UTF-8最长序列减一),拼到下一块的开头一起处理。
void streamFilter(const std::string& path, std::ofstream& out) {
std::ifstream fin(path, std::ios::binary);
std::string carry; // 上一块遗留的未完成序列
char buf[65536];
while (fin.read(buf, sizeof(buf)) || fin.gcount() > 0) {
std::string chunk(carry);
chunk.append(buf, fin.gcount());
carry.clear();
// 检查块尾是否有不完整的多字节序列,暂存到carry
size_t drop = 0;
for (int probe = 3; probe > 0; --probe) {
size_t pos = chunk.size() - probe;
if (pos >= chunk.size()) continue;
int len = sequenceLength((unsigned char)chunk[pos]);
if (len > 0 && len > probe && pos + len > chunk.size()) {
carry = chunk.substr(pos);
drop = probe;
break;
}
}
if (drop) chunk.resize(chunk.size() - drop);
out << filterUtf8(chunk);
}
if (!carry.empty())
out << filterUtf8(carry); // 文件末尾的残缺序列在这里被清除
}性能方面还有两点可以继续压榨。第一,如果数据中ASCII占绝大多数(程序源码、配置文件、JSON日志都是如此),可以先用memchr在缓冲区里快速定位第一个大于等于0x80的字节,之前的纯ASCII段落用一次append整段拷走,比逐字节循环快一个数量级。第二,如果项目允许引入依赖,ICU库的u_strFromUTF8以及simdutf这个库都提供了带严格校验的高速转换接口,simdutf利用SIMD指令一次校验几十个字节,吞吐量可达每秒数GB,对性能敏感的场景值得考虑。
最后提一个容易被忽视的边界情况:BOM头。UTF-8文件开头的0xEF 0xBB 0xBF本身是合法序列,但它不是可见字符,过滤逻辑会把它原样保留。如果下游解析器不认识BOM,最好在清洗入口显式检测并去掉它,否则第一行首字段可能莫名多出不可见字符,排查起来相当费时间。