首页 / 资讯中心 / 文章详情

标准C手搓UTF-8编解码:嵌入式无依赖实现指南

标准C手搓UTF-8编解码:嵌入式无依赖实现指南 ★ FEATURED ARTICLE
1. 为什么我要在C里手搓UTF-8处理做嵌入式或者偏底层的项目时经常会遇到一个尴尬的局面系统环境里没有现成的字符编码处理库编译器只给你标准C的那几十个函数而需求偏偏要求你处理多语言文本。我最早碰到这个问题是在一个串口屏项目上设备要显示中文、日文甚至一些特殊符号但整个工程不允许引入任何第三方库——不是不想用是编译链和存储空间都不允许。这时候很多人第一反应是去找现成的库比如各种Unicode处理库。但你会发现这些库要么体积太大要么依赖一堆平台相关的头文件移植到单片机上直接编译不过。更关键的是你只是想做个UTF-8字符串的长度计算、字符截取、编码转换为这点需求拖进来一个几万行的库实在不划算。所以这篇文章要聊的就是完全不依赖任何第三方库只用标准C实现一套UTF-8编解码和常用工具函数。这套东西我前后在三个项目里迭代过从最初的“能跑就行”到后来能稳定处理各种边界情况踩过的坑不少。适合有C语言基础、正在做嵌入式或跨平台开发、需要自己处理字符编码的读者。哪怕你只是好奇UTF-8底层是怎么运作的跟着走一遍也会有收获。注意本文所有代码只依赖stddef.h、stdint.h、string.h这几个标准头文件不涉及任何平台特定API。2. UTF-8的编码规则到底怎么理解2.1 从ASCII兼容说起UTF-8最巧妙的设计就是向后兼容ASCII。0x00到0x7F这个范围UTF-8的编码和ASCII完全一致一个字节就是一个字符。这意味着你原来处理英文文本的所有C代码在UTF-8环境下不需要任何修改就能正常工作。但超过0x7F之后规则就变了。UTF-8用变长编码一个字符可能占1到4个字节。具体占几个字节由第一个字节的高位比特模式决定首字节范围占用字节数有效数据位0x00-0x7F170xC0-0xDF2110xE0-0xEF3160xF0-0xF7421后续字节的格式固定为10xxxxxx也就是高两位必须是10。这个设计让解码器可以快速判断当前字节是首字节还是续字节——看到10开头就知道是续字节直接跳过。2.2 为什么首字节范围有“空洞”细心的读者会发现2字节的首字节从0xC0开始但0xC0和0xC1实际上永远不会出现在合法的UTF-8序列中。原因是2字节编码能表示的最大码点是0x7FF而0xC0和0xC1对应的码点都小于0x80这些码点本来就用1字节表示用2字节表示属于过度长编码overlong encoding是非法格式。同理3字节的0xE0后面如果跟0x80-0x9F也会产生过度长编码。这些规则在解码时必须严格检查否则会引入安全漏洞——攻击者可以利用过度长编码绕过字符过滤。2.3 码点、字节、字符三者的关系这里必须把概念理清楚否则写代码时很容易混淆码点Code PointUnicode标准给每个字符分配的唯一编号比如 A 是U0041中 是U4E2D。范围是0到0x10FFFF。字节Byte存储和传输的基本单位UTF-8编码后的实际数据。字符Character用户感知的一个书写单位对应一个码点这里不讨论组合字符的复杂情况。一个UTF-8字符串的字节数永远大于等于字符数。比如 中a 这个字符串字节数是4中占3字节a占1字节字符数是2。很多初学者写strlen()来获取字符数遇到中文就出错根本原因就在这里。3. 手写解码器从字节流还原码点3.1 核心解码函数的实现思路解码的目标是给一个指向UTF-8字节序列的指针返回当前字符的码点并让指针前进到下一个字符的起始位置。函数签名设计成这样int utf8_decode(const char *str, uint32_t *code_point);返回值是当前字符占用的字节数失败返回-1。为什么用uint32_t来接收码点因为Unicode最大码点是0x10FFFF21位uint16_t装不下。先看首字节的判断逻辑int utf8_decode(const char *str, uint32_t *cp) { const unsigned char *p (const unsigned char *)str; unsigned char b0 p[0]; if (b0 0x80) { *cp b0; return 1; } else if ((b0 0xE0) 0xC0) { // 2字节序列 if ((p[1] 0xC0) ! 0x80) return -1; uint32_t v ((b0 0x1F) 6) | (p[1] 0x3F); if (v 0x80) return -1; // 过度长编码检查 *cp v; return 2; } else if ((b0 0xF0) 0xE0) { // 3字节序列 if ((p[1] 0xC0) ! 0x80 || (p[2] 0xC0) ! 0x80) return -1; uint32_t v ((b0 0x0F) 12) | ((p[1] 0x3F) 6) | (p[2] 0x3F); if (v 0x800) return -1; if (v 0xD800 v 0xDFFF) return -1; // 代理区检查 *cp v; return 3; } else if ((b0 0xF8) 0xF0) { // 4字节序列 if ((p[1] 0xC0) ! 0x80 || (p[2] 0xC0) ! 0x80 || (p[3] 0xC0) ! 0x80) return -1; uint32_t v ((b0 0x07) 18) | ((p[1] 0x3F) 12) | ((p[2] 0x3F) 6) | (p[3] 0x3F); if (v 0x10000 || v 0x10FFFF) return -1; *cp v; return 4; } return -1; }3.2 三个必须检查的边界条件上面代码里有三处检查每一处都对应一个真实的坑第一处续字节格式检查。(p[1] 0xC0) ! 0x80这个判断确保后续字节的高两位是10。如果不检查遇到损坏的数据会把随机字节当成有效数据解码产生乱码甚至越界读取。第二处过度长编码检查。2字节序列解出的值如果小于0x80说明这个字符本可以用1字节表示属于非法编码。3字节序列解出的值如果小于0x800同理。这个检查在安全场景下非常重要。第三处代理区检查。Unicode标准把0xD800到0xDFFF保留给UTF-16的代理对这些码点不允许出现在UTF-8中。很多解码器漏掉这个检查导致后续处理出现奇怪问题。提示如果你的应用场景对安全性要求不高可以省略过度长编码和代理区检查代码会更简洁。但如果是处理用户输入或网络数据强烈建议保留。3.3 编码函数从码点生成字节序列编码是解码的逆过程给定一个码点输出对应的UTF-8字节int utf8_encode(uint32_t cp, char *out) { if (cp 0x80) { out[0] (char)cp; return 1; } else if (cp 0x800) { out[0] (char)(0xC0 | (cp 6)); out[1] (char)(0x80 | (cp 0x3F)); return 2; } else if (cp 0x10000) { if (cp 0xD800 cp 0xDFFF) return -1; out[0] (char)(0xE0 | (cp 12)); out[1] (char)(0x80 | ((cp 6) 0x3F)); out[2] (char)(0x80 | (cp 0x3F)); return 3; } else if (cp 0x10FFFF) { out[0] (char)(0xF0 | (cp 18)); out[1] (char)(0x80 | ((cp 12) 0x3F)); out[2] (char)(0x80 | ((cp 6) 0x3F)); out[3] (char)(0x80 | (cp 0x3F)); return 4; } return -1; }编码函数相对简单因为输入是可信的码点只需要按规则拆分比特位。但代理区检查不能省否则会生成非法的UTF-8序列。4. 那些真正好用的工具函数4.1 字符串长度字节数不等于字符数这是最常用的函数也是最容易出错的地方。标准库的strlen()返回字节数但我们需要的是字符数size_t utf8_strlen(const char *str) { size_t count 0; const unsigned char *p (const unsigned char *)str; while (*p) { if ((*p 0xC0) ! 0x80) count; p; } return count; }这个函数的逻辑很巧妙只需要统计非续字节的数量。因为每个字符的首字节都不是10开头而所有续字节都是10开头。所以遍历整个字符串遇到不是10开头的字节就计数加一。这个写法比逐个调用utf8_decode快得多因为它不需要做完整的解码和校验。在只需要知道字符数的场景下这是最优解。4.2 安全截取别把字符切成两半按字符数截取子串是个高频需求比如UI上显示“前10个字符”。如果直接按字节截取很可能把一个3字节的中文字符切成1.5个产生乱码int utf8_substr(const char *src, char *dst, size_t max_chars) { const unsigned char *p (const unsigned char *)src; size_t chars 0; size_t bytes 0; while (*p chars max_chars) { int len; unsigned char b *p; if (b 0x80) len 1; else if ((b 0xE0) 0xC0) len 2; else if ((b 0xF0) 0xE0) len 3; else if ((b 0xF8) 0xF0) len 4; else break; // 非法字节停止 // 检查后续字节是否完整 for (int i 1; i len; i) { if ((p[i] 0xC0) ! 0x80) return -1; } memcpy(dst bytes, p, len); bytes len; p len; chars; } dst[bytes] \0; return (int)chars; }这里有个细节在拷贝之前先验证后续字节的完整性。如果数据在中间被截断比如一个3字节字符只剩前2字节直接memcpy会读到缓冲区外面的数据。先检查再拷贝避免越界。4.3 码点转UTF-8字符串的便捷封装有时候我们拿到一个码点想直接追加到现有字符串末尾。封装一个这样的函数会很方便int utf8_append(uint32_t cp, char *buf, size_t buf_size, size_t *used) { char tmp[4]; int len utf8_encode(cp, tmp); if (len 0) return -1; if (*used len 1 buf_size) return -1; // 预留\0位置 memcpy(buf *used, tmp, len); *used len; buf[*used] \0; return len; }这个函数在动态构建字符串时特别有用比如从码点数组生成UTF-8文本。注意缓冲区大小检查要预留一个字节给结尾的\0这个细节很容易漏掉。4.4 验证整个字符串的合法性处理外部数据时先验证再使用是个好习惯int utf8_validate(const char *str, size_t len) { const unsigned char *p (const unsigned char *)str; size_t i 0; while (i len) { int seq_len; unsigned char b p[i]; if (b 0x80) seq_len 1; else if ((b 0xE0) 0xC0) seq_len 2; else if ((b 0xF0) 0xE0) seq_len 3; else if ((b 0xF8) 0xF0) seq_len 4; else return 0; if (i seq_len len) return 0; for (int j 1; j seq_len; j) { if ((p[i j] 0xC0) ! 0x80) return 0; } i seq_len; } return 1; }这个函数接受长度参数而不是依赖\0结尾因为网络数据或文件数据不一定以\0结束。返回1表示合法0表示非法。5. 实测中踩过的坑和性能考量5.1 有符号char的陷阱这是我在实际项目里踩得最狠的一个坑。在x86平台上char默认是有符号的范围是-128到127。当你写if (*p 0x80)时如果*p是0x80以上的字节它会被解释成负数条件居然成立结果就是所有非ASCII字符都被当成单字节处理。解决方案很简单所有处理字节的指针都强制转成unsigned char *。我在上面所有函数里都做了这个转换这不是可选项是必须项。在ARM平台上char默认是无符号的代码跑得好好的换到x86就出问题这种平台差异导致的bug最难排查。5.2 性能对比逐字节 vs 批量处理我做过一个简单的性能测试处理一个100万字符的中英混合字符串方法耗时ms说明逐字符调用utf8_decode约45每次完整解码和校验utf8_strlen计数法约8只判断首字节查表法约5预计算首字节类型表对于大多数应用场景utf8_strlen的计数法已经足够快。只有在极端性能敏感的场景下才需要考虑查表法。但查表法需要额外维护一个256字节的表增加代码体积在单片机上要权衡。5.3 缓冲区大小的计算很多人在分配缓冲区时习惯用strlen(src) 1这在UTF-8场景下是够的因为编码后的字节数不会超过原字节数。但反过来如果你要把码点数组转成UTF-8最坏情况下每个码点占4字节缓冲区大小应该是码点数量 * 4 1。我在一个项目里就犯过这个错从UTF-16转UTF-8时按字符数分配了缓冲区结果遇到大量4字节字符时缓冲区溢出。后来改成按最大可能字节数分配问题解决。5.4 与标准库函数的配合有时候你不得不和标准库函数打交道比如strcpy、strcat。这些函数按字节操作对UTF-8字符串是安全的因为它们不关心字符边界只关心\0。但strncpy要小心如果截断位置正好在字符中间会产生非法UTF-8序列。我的做法是涉及截断的场景一律用自己的utf8_substr不用strncpy。虽然多写几行代码但避免了后续处理乱码的麻烦。6. 这套工具函数在实际项目中的组合使用6.1 串口屏显示场景回到我最初提到的串口屏项目。屏幕的显示区域有限一行最多显示16个中文字符。从服务器收到的文本可能是任意长度需要截取前16个字符显示char display_buf[64]; utf8_substr(server_text, display_buf, 16); send_to_screen(display_buf);就这么两行代码解决了问题。如果用标准库的strncpy(buf, src, 16)遇到中文会截出乱码屏幕显示一堆问号。6.2 日志系统的字符对齐日志系统经常需要对齐输出比如每条日志的标签固定占10个字符宽度。用utf8_strlen计算实际字符数不够的补空格size_t tag_len utf8_strlen(tag); printf(%s, tag); for (size_t i tag_len; i 10; i) putchar( );这样无论标签是中文还是英文都能正确对齐。如果用strlen中文标签会算成3倍宽度对齐全乱。6.3 输入验证与清洗处理用户输入时第一步永远是验证UTF-8合法性if (!utf8_validate(input, input_len)) { // 非法输入拒绝或清洗 return ERROR_INVALID_ENCODING; }这一步能挡掉大量潜在问题包括恶意构造的过度长编码攻击。验证通过后再进行后续处理心里踏实。6.4 码点级操作的扩展思路有了基础的编解码函数可以很容易扩展出更高级的功能。比如统计字符串中某个码点出现的次数size_t utf8_count_cp(const char *str, uint32_t target) { size_t count 0; const char *p str; uint32_t cp; int len; while (*p) { len utf8_decode(p, cp); if (len 0) break; if (cp target) count; p len; } return count; }或者实现大小写转换仅限ASCII范围完整Unicode大小写转换需要查表不在本文讨论范围void utf8_to_upper_ascii(char *str) { while (*str) { if (*str a *str z) *str - 32; str; } }注意这个函数只处理ASCII字母遇到多字节字符直接跳过因为UTF-8的续字节都大于0x7F不会被误改。7. 几个容易被忽略的细节7.1 BOM头处理有些UTF-8文件开头会有BOMByte Order Mark字节序列是EF BB BF对应码点UFEFF。BOM在UTF-8里其实没有存在的必要因为UTF-8没有字节序问题但Windows记事本等工具会加上。处理文件时如果第一个字符解码出来是0xFEFF应该跳过uint32_t cp; int len utf8_decode(data, cp); if (len 0 cp 0xFEFF) { data len; data_len - len; }不处理BOM的话显示时开头会多一个看不见的字符在某些终端上会显示成乱码。7.2 字符串比较的正确姿势strcmp按字节比较对UTF-8字符串来说字节序相同的字符串码点序也相同这是UTF-8的一个重要性质。所以strcmp可以直接用来比较UTF-8字符串的码点顺序。但如果你想按字典序比较比如中文按拼音排序那就需要更复杂的处理不在本文范围内。7.3 内存对齐与访问效率在32位ARM平台上未对齐的内存访问会触发异常。虽然我们逐字节处理不存在对齐问题但如果用memcpy批量拷贝编译器可能会优化成字对齐访问。标准库的memcpy已经处理了这个问题自己写拷贝循环时要注意。7.4 线程安全性上面所有函数都是纯函数不依赖任何全局状态天然线程安全。唯一需要注意的是传入的缓冲区不能是共享的。如果多线程同时写同一个缓冲区那是调用方的问题不是函数的问题。8. 关于这套代码的移植和维护这套代码我在三个不同平台上用过x86 Linux、ARM Cortex-M单片机、以及一个DSP平台。移植时只需要确认两件事uint32_t和uint8_t的定义是否存在stdint.h一般都有以及char的符号性。后者通过强制转换unsigned char *已经解决。代码体积方面全部函数编译后大约1.5KBARM Thumb指令集-Os优化对于大多数单片机来说完全可以接受。如果空间实在紧张可以只保留utf8_decode、utf8_strlen和utf8_substr三个核心函数其他按需裁剪。维护上有个小建议把首字节的类型判断抽成一个宏或内联函数这样解码、验证、截取三个地方可以共用同一套判断逻辑修改时不会漏掉某个地方。我最初就是三处各写各的后来发现2字节的判断条件在一处写成了(b 0xE0) 0xC0另一处写成了(b 0xC0) 0xC0后者会把3字节和4字节的首字节也误判成2字节导致解码错位。这种复制粘贴导致的bug抽成公共函数就能避免。最后分享一个调试技巧遇到UTF-8乱码问题时先把出问题的字节序列用十六进制打印出来对照编码规则表手工解码一遍很快就能定位是哪个环节出了问题。比盲目加printf高效得多。
阅读完成 · 觉得有帮助?
咨询建站