一、缘由
之前在浅谈golang字符编码这篇博客里留了个尾巴,就是如何计算一个字符串的长度,里面用到了 Go 内置的 utf8.RuneCountInString 函数,它可以正确识别如 a码 为 2。
go
func TestCountStr(t *testing.T) {
str := "a码"
fmt.Println(utf8.RuneCountInString(str)) // 2
}
那这个函数内部是如何计算识别的呢,今天就来揭秘它。
二、UTF-8 的"工程版"规则
概览
Go 语言规范规定:Go 源代码文件必须使用 UTF-8 编码。
UTF-8 是变长编码,每个 Unicode 字符(rune)长度 1--4 字节:
| Unicode 范围 | 字节数 | 起始字节位模式 | 续字节位模式 |
|---|---|---|---|
| U+0000 -- U+007F | 1 | 0xxxxxxx | --- |
| U+0080 -- U+07FF | 2 | 110xxxxx | 10xxxxxx |
| U+0800 -- U+FFFF | 3 | 1110xxxx | 10xxxxxx 10xxxxxx |
| U+10000 -- U+10FFFF | 4 | 11110xxx | 10xxxxxx 10xxxxxx 10xxxxxx |
从工程实现角度,UTF-8 有三条"硬规则":
规则一、起始字节决定长度
| 起始位型 | 字节数 |
|---|---|
| 0xxxxxxx | 1 |
| 110xxxxx | 2 |
| 1110xxxx | 3 |
| 11110xxx | 4 |
规则二、所有续字节必须满足
10xxxxxx ⇔ 0x80 -- 0xBF
在前两位 10 固定的情况下,范围 10000000 ~ 10111111,用 16 进制表示就是 0x80 ~ 0xBF
规则三、有些"看起来合法"的编码其实是非法的
Overlong Encoding(过长编码)
定义: 用超过标准所需的字节数来表示一个字符。
通俗讲就是明明可以用一个字节来表示一个字符,实际中却用了两个字节表示。比如以 ASCII 中的空字符 \0 为例
- 标准写法(1 字节):00000000 (十六进制 0x00)
- 过长写法(2 字节):11000000 10000000 (十六进制 0xC0 0x80)。当你按照 UTF-8 规则解码这个 2 字节序列时,提取出来的有效位依然是 0000000。
这样来看过长写法除了浪费些内存外,也不无不妥啊。ok,接下来我举个实际案例来说说危害。
绕过过滤:假设系统禁止输入 /etc/passwd。如果黑客把其中的 / (原本是 0x2F) 编码成过长格式的 2 字节(0xC0 0xAF),简单的字符串匹配算法可能认不出来,但文件系统解码后却发现它依然是 /。
SQL 注入:同样的道理,可以用来隐藏 ' 或 ; 等敏感字符。
Surrogates(代理区/替代对)
定义: Unicode 专门留出的一块禁区(U+D800 到 U+DFFF),专门用于 UTF-16 编码。
背景故事:在很久以前,Unicode 认为 65536 个位置(2 字节)就够了。后来发现不够,于是为了让原本只有 2 字节的 UTF-16 能表示更大的数字,就划出了这块"代理区"。
在 UTF-16 中,这两个字节的组合代表一个大数字。
但在 UTF-8 中,这块区域是绝对禁止使用的。
为什么禁止?
为了保持纯粹性。如果 UTF-8 允许出现这些代理对字符,那么在进行字符转换(UTF-8 转 UTF-16)时,就会出现严重的歧义和混乱。
超过 U+10FFFF
这个就很好解释了,UTF-8 最多能容纳 4 个字节
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
那么有效 payload 位数:
3 + 6 + 6 + 6 = 21 bits
也就是说:UTF-8 的设计本身,就只打算编码 21 位 Unicode 码点。
最大可表示值
21 bits 全 1 = 0b1_1111_1111_1111_1111_1111
= 0x1FFFFF(理论)
但 Unicode 只用了其中一部分:
实际合法最大值 = 0x10FFFF
至于超出 U+10FFFF 的 UTF-8 序列:
- 在位层面"可编码"
- 但在 Unicode 标准层面"没有语义"
三、Go 的核心设计:位压缩与"查表法"
Go 并没有通过复杂的 if-else 去判断每个字节的含义,而是在 unicode/utf8 包中设计了一个名为 first 的 256 字节状态表。
go
// first is information about the first byte in a UTF-8 sequence.
var first = [256]uint8{
// 1 2 3 4 5 6 7 8 9 A B C D E F
as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x00-0x0F
as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x10-0x1F
as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x20-0x2F
as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x30-0x3F
as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x40-0x4F
as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x50-0x5F
as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x60-0x6F
as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x70-0x7F
// 1 2 3 4 5 6 7 8 9 A B C D E F
xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0x80-0x8F
xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0x90-0x9F
xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0xA0-0xAF
xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0xB0-0xBF
xx, xx, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, // 0xC0-0xCF
s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, // 0xD0-0xDF
s2, s3, s3, s3, s3, s3, s3, s3, s3, s3, s3, s3, s3, s4, s3, s3, // 0xE0-0xEF
s5, s6, s6, s6, s7, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0xF0-0xFF
}
- 位压缩公式: x = ( 类别 ≪ 4 ) ∣ 长度 x = (\text{类别} \ll 4) \mid \text{长度} x=(类别≪4)∣长度。
- 逻辑精髓:将 8 位字节拆分,高 4 位存"校验类别"(索引),低 3 位存"字节长度"。
- 性能优势:将逻辑分支判断降维为 O(1) 的内存寻址。CPU 无需进行分支预测,指令流平滑如镜。
下面简单说下这个 first 数组是如何生成的
第一组:ASCII 字符 (0x00 - 0x7F)
00000000 --> 0x00
11111111 --> 0x7F
- 特征:二进制以 0 开头。
- 长度:1。
- 类别:0 (as)。
- 计算: ( 0 ≪ 4 ) ∣ 1 = 0 ∣ 1 = 0 x 01 (0 \ll 4) \mid 1 = 0 \mid 1 = \mathbf{0x01} (0≪4)∣1=0∣1=0x01。
- 结论:first[0] 到 first[127] 全都是 0x01。
其实,在代码中没有用到这块,而是通过如下代码高效完成
go
c := s[i]
if c < RuneSelf { // RuneSelf = 0x80
// ASCII fast path
i++
continue
}
这一步的意义:
- 英文字符串占比极高
- 直接 i++
- 避免复杂 UTF-8 校验
- 这是 Go 字符串性能非常高的原因之一
第二组:延续字节 (0x80 - 0xBF)
延续字节是 10 开头的,表示范围
10000000 --> 0x80
10111111 --> 0xBF
- 特征:二进制以 10 开头。它们不能作为首字节。
- 长度:1(占位)。
- 类别:15 (xx, 非法)。
- 计算: ( 15 ≪ 4 ) ∣ 1 = 240 ∣ 1 = 0 x F 1 (15 \ll 4) \mid 1 = 240 \mid 1 = \mathbf{0xF1} (15≪4)∣1=240∣1=0xF1。
- 结论:这些位置在表中标记为非法。
这里需要说明下类别 15 为非法
go
x := first[c]
if x == xx {
i++ // invalid.
continue
}
因为通过查表法找到 x 此时为打包后的状态字节,它包含
x 的二进制结构:
┌───────────────┬───────────────┐
│ 高 4 位 │ 低 3 位 │
│ accept 索引 │ UTF-8 长度 │
└───────────────┴───────────────┘
也就是
go
first[c] = (acceptIndex << 4) | size
还有一个特殊情况
go
x == xx // 非法起始字节
也就是查看此字节是否为正常的 UTF-8 首字节。
如果查出来的结果是 xx(代表这个字节根本不配作为任何字符的开头,比如它是 10 开头的延续字节),那就判定为非法。
处理:只挪动 1 位(i++),然后立刻开始看下一个字节。
第三组:2 字节字符首字节 (0xC2 - 0xDF)
11000000 --> 0xC0
11011111 --> 0xDF
为什么从 0xC2 开始而不是 0xC0? 这就是为了防止"Overlong Encoding"。如果允许 0xC0 或 0xC1 开头,它们后面跟一个延续字节,算出来的字符编号(码点)会小于 128。
小于 128 的字符应该用 1 字节的 ASCII 表示。
为了保证唯一性,Go 的 first 表把 0xC0 和 0xC1 也标记为了非法(0xF1),只从 0xC2 开始给它们 0x02 的待遇。
- 特征:二进制以 110 开头。
- 长度:2。
- 类别:0 (as)。
- 计算: ( 0 ≪ 4 ) ∣ 2 = 0 x 02 (0 \ll 4) \mid 2 = \mathbf{0x02} (0≪4)∣2=0x02。
- 结论:如 first[0xC2] 等于 0x02。
第四组:3 字节字符 (0xE0 - 0xEF)
11100000 --> 0xE0
11101111 --> 0xEF
特殊的 0xE0
- 特征:为了防止"Overlong Encoding",0xE0 后面的字节范围很窄。
- 长度:3。
- 类别:1 (特定范围校验)。
- 计算: ( 1 ≪ 4 ) ∣ 3 = 16 ∣ 3 = 0 x 13 (1 \ll 4) \mid 3 = 16 \mid 3 = \mathbf{0x13} (1≪4)∣3=16∣3=0x13。
普通的 0xE1 - 0xEF
- 特征:正常的 3 字节开头。
- 长度:3。
- 类别:0 (as)。
- 计算: ( 0 ≪ 4 ) ∣ 3 = 0 x 03 (0 \ll 4) \mid 3 = \mathbf{0x03} (0≪4)∣3=0x03。
第五组:4 字节字符首字节 (0xF0 - 0xF4)
11110000 --> 0xF0
11110111 --> 0xF7
为什么只到 0xF4 就结束了? 因为 Unicode 标准规定,最大的码点是 U+10FFFF。
超过 0xF4 的开头(如 0xF5-0xF7)虽然符合 UTF-8 格式,但它们代表的数字已经超过了 Unicode 的上限。
所以 Go 极其严谨地把 0xF5 以后的位置全部填上了 0xF1(非法)。
- 特殊的 0xF0:类别为 3,长度 4。计算: ( 3 ≪ 4 ) ∣ 4 = 48 ∣ 4 = 0 x 34 (3 \ll 4) \mid 4 = 48 \mid 4 = \mathbf{0x34} (3≪4)∣4=48∣4=0x34。
- 普通的 0xF1-0xF3:类别为 0,长度 4。计算: ( 0 ≪ 4 ) ∣ 4 = 0 x 04 (0 \ll 4) \mid 4 = \mathbf{0x04} (0≪4)∣4=0x04。
- 特殊的 0xF4:类别为 4,长度 4。计算: ( 4 ≪ 4 ) ∣ 4 = 64 ∣ 4 = 0 x 44 (4 \ll 4) \mid 4 = 64 \mid 4 = \mathbf{0x44} (4≪4)∣4=64∣4=0x44。
特殊值
针对续字节(紧挨首字节的第二个字节)又有如下几个特殊情况
| 首字节 | 风险 | 类别 | 长度 | 计算值 (十六进制) x=(cat≪4)∣len |
|---|---|---|---|---|
| 0xE0 | 过长编码 | 1 | 3 | (1≪4)∣3=0x13 |
| 0xED | 代理区 | 2 | 3 | (2≪4)∣3=0x23 |
| 0xF0 | 过长编码 | 3 | 4 | (3≪4)∣4=0x34 |
| 0xF4 | 超上限 | 4 | 4 | (4≪4)∣4=0x44 |
所以 Go 引入了 acceptRanges
go
type acceptRange struct {
lo uint8
hi uint8
}
var acceptRanges = []acceptRange{
{0x80, 0xBF}, // 0: 通用
{0xA0, 0xBF}, // 1: E0
{0x80, 0x9F}, // 2: ED
{0x90, 0xBF}, // 3: F0
{0x80, 0x8F}, // 4: F4
}
E0 防 overlong
func TestHexToBinary(t *testing.T) {
fmt.Printf("%b\n", 0xA0) // 10100000
fmt.Printf("%b\n", 0xBF) // 10111111
}
E0 对应的二进制为 11100000,从首字节看代表 3 个字节,且没有填值,此时就需要看是否过长编码了。
如果你把首字节设为 0xE0,后面跟两个普通的延续字节(比如 0x80 0x80),计算出来的码点是 0。
- 问题:码点 0 本该用 1 字节的 0x00 表示。
- 风险:如果系统允许用 3 个字节来表示 \0(空字符),黑客就可以利用这一点绕过一些安全检查(比如在文件名中间塞入这种"伪造"的空字符来截断路径)。
- 对策:Go 将 0xE0 设为 类别 1。
安全检查:
- 当 first[0xE0] 返回 0x13 时,类别索引是 1。
- 代码去查 acceptRanges[1],发现它的 lo(最小值)是 0xA0。
- 如果你第二个字节是 0x80,那么 0x80 < 0xA0,安检失败!
- 结论:通过提高"准入门槛",强制规定以 0xE0 开头的 3 字节序列,第二个字节必须大一些,从而掐死了"过长编码"的可能。
go
x := first[c] // 0x13
if x == xx {
i++ // invalid.
continue
}
size := int(x & 7) // 3
if i+size > ns {
i++ // Short or invalid.
continue
}
accept := acceptRanges[x>>4] // x>>4 --> acceptRanges[1]
if c := s[i+1]; c < accept.lo || accept.hi < c {
size = 1
}
仔细对比上面的代码,是不是发现一个现象
go
int(x & 7) // 3
x>>4 // 1
正好对上了 0x13,为什么会这样呢?可以看看很久之前写的聊聊十进制、二进制、八进制、十六进制
0x13 可以拆成 1 和 3,分别对应 0001、0011,低三位和7做与运算,可以获取字节长度 3,高四位向右移动四位,正好获取类型 1。同时,咱们也可以再重新推算出 (1≪4)∣3 正好为 0x13,把类型和长度都压缩到了一个字节内,这就是位压缩,也即是 first 数组里的值包含了两个信息。
ED 禁 surrogate
在 Unicode 体系里,有一块内存区域叫做 Surrogates(代理区),范围是 U+D800 到 U+DFFF。
- 规则:这块地盘是专门给 UTF-16 编码用的,在 UTF-8 里严禁使用。
- 特征:这些禁区码点转换成 UTF-8 后,首字节全是 0xED。
- 对策:Go 将 0xED 设为 类别 2。
安全监检测:
- acceptRanges[2] 的 hi(最大值)被设为了 0x9F。
- 如果你的 3 字节字符试图踏入禁区,第二个字节必然会大于 0x9F。
- 结果:监测到 c > 0x9F,直接拦截,判定非法。
4 字节的特殊情况 (0x34 和 0x44)
4 字节的首字节是 0xF0 到 0xF4。这里的特殊逻辑是为了对齐 Unicode 的天花板。
11110 000
11110 111
- 类别 3 (0xF0):防止过长编码。确保它表示的字符足够大,大到必须用 4 字节存(所以第二个字节最小值是 0x90)。
- 类别 4 (0xF4):防止超出上限。Unicode 最大只到 U+10FFFF。如果首字节是 0xF4 且第二个字节超过了 0x8F,算出来的数字就会超出这个上限。
总结:这 256 个数是怎么填满的?
- 0x00 - 0x7F: 全部填 0x01(ASCII)。
- 0x80 - 0xC1: 全部填 0xF1(非法,要么是延续字节,要么是过长编码)。
- 0xC2 - 0xDF: 全部填 0x02(2 字节正常开头)。
- 0xE0: 填 0x13(3 字节特检)。
- 0xE1 - 0xEF: 填 0x03(3 字节正常)。
- 0xF0: 填 0x34(4 字节特检 1)。
- 0xF1 - 0xF3: 填 0x04(4 字节正常)。
- 0xF4: 填 0x44(4 字节特检 2)。
- 0xF5 - 0xFF: 全部填 0xF1(非法,超出 Unicode 范围)。
| 字节范围 | 二进制前缀 | 长度 | 类别 | 计算值 (十六进制) |
|---|---|---|---|---|
| 0x00-0x7F | 0xxxxxxx | 1 | 0 | 0x01 |
| 0x80-0xBF | 10xxxxxx | 1 | 15 | 0xF1 |
| 0xC2-0xDF | 110xxxxx | 2 | 0 | 0x02 |
| 0xE0 | 11100000 | 3 | 1 | 0x13 |
| 0xE1-0xEF | 1110xxxx | 3 | 0 | 0x03 |
| 0xF0 | 11110000 | 4 | 3 | 0x34 |
| 0xF4 | 11110100 | 4 | 4 | 0x44 |
四、utf8.RuneCountInString 解读
其实在上面零星的已经把代码都说完了,这次合一起。
go
func RuneCountInString(s string) (n int) {
ns := len(s)
for i := 0; i < ns; n++ {
c := s[i]
if c < RuneSelf {
// ASCII fast path
i++
continue
}
x := first[c]
if x == xx {
i++ // invalid.
continue
}
size := int(x & 7)
if i+size > ns {
i++ // Short or invalid.
continue
}
accept := acceptRanges[x>>4]
if c := s[i+1]; c < accept.lo || accept.hi < c {
size = 1
} else if size == 2 {
} else if c := s[i+2]; c < locb || hicb < c {
size = 1
} else if size == 3 {
} else if c := s[i+3]; c < locb || hicb < c {
size = 1
}
i += size
}
return n
}
RuneCountInString 的目标
统计字符串中 UTF-8 编码的 rune(Unicode code point)数量。
注意:
- 不是字节数
- 不是字符数(grapheme)
- 而是 UTF-8 解码后的 rune 数
整体结构概览
go
func RuneCountInString(s string) (n int) {
ns := len(s)
for i := 0; i < ns; n++ {
...
}
return n
}
关键点
| 变量 | 含义 |
|---|---|
ns |
字符串字节长度 |
i |
当前字节索引 |
n |
rune 计数器(返回值) |
ASCII 快速路径(性能关键)
go
c := s[i]
if c < RuneSelf {
// ASCII fast path
i++
continue
}
RuneSelf 是什么?
go
const RuneSelf = 0x80 // 128
含义:
- c < 128 → ASCII
- ASCII 在 UTF-8 中:
- 1 字节
- 1 rune
- 无需解码
这一步的意义:
- 英文字符串占比极高
- 直接 i++
- 避免复杂 UTF-8 校验
- 这是 Go 字符串性能非常高的原因之一
UTF-8 解码表驱动机制(核心)
go
x := first[c]
if x == xx {
i++ // invalid.
continue
}
first[c] 是什么?
这是一个 256 长度的查表数组:
- 根据 UTF-8 标准
- 判断:
- 是几字节字符
- 是否非法起始字节
- xx 表示:
- 非法 UTF-8 起始字节
- 例如:
- continuation byte 被当成起始 byte,也就是过滤续字节被当成起始字节的情况
设计哲学:
- 非法 UTF-8 也要"容错地"算作一个 rune
计算 UTF-8 字节长度
go
size := int(x & 7)
为什么是 & 7?
UTF-8 起始字节格式:
| 编码 | 二进制 |
|---|---|
| 1 字节 | 0xxxxxxx |
| 2 字节 | 110xxxxx |
| 3 字节 | 1110xxxx |
| 4 字节 | 11110xxx |
x & 7 取的是 低 3 位,存的是 编码长度
越界检测(防止 panic)
go
if i+size > ns {
i++ // Short or invalid.
continue
}
场景:
- 字符串被截断
- UTF-8 不完整
Go 的处理策略:
- 不 panic
- 当成 非法 rune
- 吞掉 1 字节
continuation byte 校验(UTF-8 合法性)
go
accept := acceptRanges[x>>4]
acceptRanges 是什么?
- UTF-8 标准定义的:
- 第二个字节允许的范围
- 防止:
- 过长编码(overlong)
- surrogate
- 非法区间
逐字节校验逻辑(关键)
go
if c := s[i+1]; c < accept.lo || accept.hi < c {
size = 1
} else if size == 2 {
} else if c := s[i+2]; c < locb || hicb < c {
size = 1
} else if size == 3 {
} else if c := s[i+3]; c < locb || hicb < c {
size = 1
}
解读方式:
- 逐个 continuation byte 校验
- 每一步都检查:
- 10xxxxxx 合法性
- 一旦失败:
- size = 1
- 退化成非法 rune
步进与计数(最关键的设计)
go
i += size
同时:
go
for i := 0; i < ns; n++ {
这意味着:
- 不论合法 / 非法
- 每次循环一定计数一个 rune
- UTF-8 解码失败 ≠ rune 不存在
这正是 Go 对 UTF-8 的哲学:
- 字符串是字节序列,rune 是解码视角
示例
go
"a码"
UTF-8 字节:
go
61 -> 'a'
E7 A0 81 -> '码'
过程:
- 0x61 < 0x80
- ASCII
- i += 1
- n = 1
- 0xE7
- first[E7] → 3 字节
- 校验 A0, 81
- i += 3
- n = 2
结束。
五、结论
RuneCountInString 是一个"表驱动 + ASCII 快速路径 + 容错 UTF-8 解码"的高性能 rune 计数器。
它的核心目标只有一个:
在任何字节输入下,安全、快速、准确地统计 rune 数。
另外,若要熟练理解透这个函数,还需花点功夫理解 Unicode 和 UTF-8。