Skip to content
encode2026-05-205 分钟阅读

爬虫抓回来的数据全是 ???,MySQL 存中文显示乱码,PHP strlen("你好") 返回 6 而不是 2。这类问题 99% 的根源不在数据库,不在浏览器,而在于没搞清 ASCII、Unicode、UTF-8 三者到底是什么关系。

这篇把三者拆开讲透,让你下次遇到字符问题能立刻判断是字符集问题还是编码问题。想直接查看或转换字符,用我们的 Unicode 编解码工具,可以显示任意文本的码位和 UTF-8 字节。

ASCII:128 个字符的 7-bit 时代

ASCII(American Standard Code for Information Interchange)诞生于 1960 年代,用 7 个 bit 表示一个字符,共 128 个码位:

| 范围 | 内容 | 数量 | |------|------|------| | 0-31 | 控制字符(换行、制表等) | 32 | | 32-126 | 可打印字符(字母、数字、符号) | 95 | | 127 | DEL | 1 |

'A' = 65 = 0x41
'a' = 97 = 0x61
'0' = 48 = 0x30
' ' = 32 = 0x20

ASCII 解决了英文的存储问题,但仅限英文。德文的 ä、中文的"中"、日文的"あ"、Emoji,全部无能为力。

Unicode:覆盖全世界的字符集

Unicode 是一张大表,给全世界几乎所有字符都分配了一个唯一编号(码位 code point)。范围从 U+0000U+10FFFF,理论上能容纳 110 多万个字符,目前已分配超过 14 万。

U+0041  A           (Latin Capital Letter A)
U+4E2D  中          (CJK Unified Ideograph)
U+1F600 😀          (Grinning Face)
U+1F4A9 💩          (Pile of Poo)

注意 Unicode 只规定"哪个字符对应哪个编号",没有规定这个编号在内存里怎么存。把它存成字节序列的方式叫"编码",UTF-8 就是其中一种,也是最流行的一种。

UTF-8:Unicode 的可变长度编码

UTF-8 是 Unicode 的一种实现方式,用 1 到 4 个字节存一个码位:

| 字节长度 | 码位范围 | 典型字符 | 例子 | |----------|----------|----------|------| | 1 字节 | U+0000 - U+007F | ASCII | A → 0x41 | | 2 字节 | U+0080 - U+07FF | 拉丁扩展、希腊、西里尔 | é → 0xC3 0xA9 | | 3 字节 | U+0800 - U+FFFF | CJK 中文日韩 | → 0xE4 0xB8 0xAD | | 4 字节 | U+10000+ | Emoji、古文字 | 😀 → 0xF0 0x9F 0x98 0x80 |

变长编码的好处:英文文本和 ASCII 一样紧凑,CJK 文本虽然多一个字节但能正确存储,没有浪费。

UTF-8 向后兼容 ASCII

这是 UTF-8 设计上最聪明的地方。前 128 个码位(U+0000 到 U+007F)在 UTF-8 里用 1 个字节表示,而且字节值和 ASCII 完全一样:

ASCII 'A' = 0x41
UTF-8 'A' = 0x41   # 同一个字节

任何纯 ASCII 文本,本身就是合法的 UTF-8 文本。这就是为什么 HTML、Email、HTTP 协议至今默认 UTF-8,旧的英文系统不用改一行代码就能继续跑。

字符 vs 字节:以 "你好" 为例

"你好" 是 2 个字符,但 UTF-8 编码后是 6 个字节:

字符:   你                好
码位:   U+4F60            U+597D
UTF-8:  E4 BD A0          E5 A5 BD
字节:   ----- 3 -----     ----- 3 -----
$ echo -n "你好" | xxd
00000000: e4bd a0e5 a5bd                                ......
$ echo -n "你好" | wc -c
       6

PHP 和 C 里的 strlen 数的是字节,所以 strlen("你好") 返回 6。要用 mb_strlen 才能得到字符数 2:

<?php
$s = "你好";
echo strlen($s);     // 6 (字节数)
echo mb_strlen($s);  // 2 (字符数)

这个区别是 90% 字符相关 bug 的来源。前端 String.length 在 JavaScript 里数的是 UTF-16 码元,对 😀 这种 4 字节字符会返回 2,又是另一层坑。

hex dump 对比

"A"   hex: 41                        # 1 字节
"中"  hex: E4 B8 AD                   # 3 字节
"😀"  hex: F0 9F 98 80                # 4 字节
"A中" hex: 41 E4 B8 AD                # 混合编码,共 4 字节

UTF-8 的解码器靠首字节的高位判断后续字节数:

  • 首字节 0xxxxxxx:1 字节字符
  • 首字节 110xxxxx:2 字节字符,后跟 1 个 10xxxxxx
  • 首字节 1110xxxx:3 字节字符,后跟 2 个 10xxxxxx
  • 首字节 11110xxx:4 字节字符,后跟 3 个 10xxxxxx

这就是为什么 UTF-8 能自我同步:从字节流中间任意位置开始,最多回溯几个字节就能定位字符边界。

常见编码 bug

1. 多字节字符被截断

// 错误:按字节长度截中文字符串可能切断一个 3 字节序列
const s = "你好世界";
s.substr(0, 3);  // 可能拿到半个"你"

现代 JavaScript slice 按字符数操作,安全。但 Buffer 操作、fetch 流处理、字节级截断还是容易踩坑。

2. MySQL utf8 vs utf8mb4

MySQL 的 utf8 字符集最多存 3 字节字符,存 Emoji 会失败。要用 utf8mb4。MySQL 8.0 已经把默认改成了 utf8mb4,但老库还很多。

3. BOM 导致首字符识别错

BOM(U+FEFF)放在文件开头,告诉读取器编码是 UTF-8。但 BOM 会作为内容的一部分出现在 PHP header() 之前的输出里,导致 "headers already sent"。多数情况下不要 BOM,存为 UTF-8 without BOM。

何时该考虑编码

  • 存数据库前:确认表字符集是 utf8mb4,连接编码也是 utf8mb4
  • 写 HTTP 响应Content-Type: application/json; charset=utf-8
  • 解析二进制协议:明确每个字段的字节序和编码
  • 跨语言传字符串:永远 UTF-8,不要用本地编码

在本地完成编码调试

调编码问题需要 hex dump、码位查询、字节计数。这些操作最好别把数据贴到陌生网站,尤其是带敏感内容的字符串。

Unicode 编解码工具 能列出每个字符的码位和字节序列,Base64 编解码工具 帮你确认 Base64 里嵌的中文是否完整还原,字符频率统计 能在分析大段文本时验证字符是否正确。全部在浏览器本地运行,数据不上传。

下次遇到乱码,先看 hex,再看码位,最后才是字符集。问题往往比想象中简单。


广告