从 utf8.RuneCountInString 看 Go 是如何高性能、安全地解码 UTF-8 的

一、缘由

之前在浅谈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        -> '码'

过程:

  1. 0x61 < 0x80
    • ASCII
    • i += 1
    • n = 1
  2. 0xE7
    • first[E7] → 3 字节
    • 校验 A0, 81
    • i += 3
    • n = 2

结束。

五、结论

RuneCountInString 是一个"表驱动 + ASCII 快速路径 + 容错 UTF-8 解码"的高性能 rune 计数器。

它的核心目标只有一个:

在任何字节输入下,安全、快速、准确地统计 rune 数。

另外,若要熟练理解透这个函数,还需花点功夫理解 Unicode 和 UTF-8

相关推荐
小此方2 小时前
Re: ゼロから学ぶ C++ 入門(七)类和对象·第四篇:拷贝构造函数&赋值运算符重载
开发语言·c++
月明长歌2 小时前
【码道初阶】【LeetCode387】如何高效找到字符串中第一个不重复的字符?
java·开发语言·数据结构·算法·leetcode·哈希算法
凯子坚持 c2 小时前
Protobuf 序列化协议深度技术白皮书与 C++ 开发全流程指南
开发语言·c++
superman超哥2 小时前
仓颉Union类型的定义与应用深度解析
开发语言·后端·python·c#·仓颉
智航GIS2 小时前
1.1 Python的前世今生
开发语言·python
superman超哥2 小时前
仓颉协变与逆变的应用场景深度解析
c语言·开发语言·c++·python·仓颉
Filotimo_2 小时前
在java后端开发中,kafka的用处
java·开发语言
Lethehong2 小时前
GLM-4.7 与 MiniMax M2.1 工程实测:一次性交付与长期 Agent 的分水岭
开发语言·php·ai ping·glm4.7·minimaxm2.1
CertiK2 小时前
CertiK年度安全报告:2025年Web3损失同比增37%,钓鱼攻击与供应链事件成主要威胁
安全·web3