UTF-8 是 Ken Thompson 在一张餐巾纸上画出来的

我前几天在 Java 里随手写了个小工具,要算一段用户昵称的长度,用来判断有没有超出数据库字段限制。代码跑出来我愣了一下

一个「🚀」,length() 返回 2。

我盯着屏幕看了几秒钟。其实这个坑不是第一次见,写 Java 几年的人多少都踩过。但踩了还不长记性,每次遇到都得重新想一下。一个 emoji 是一个字符吧,肉眼看就是一个,怎么到了 Java 里就变成 2 了。

说到这个我又想起来,JavaScript 也是一样。"🚀".length === 2,你自己拿浏览器控制台试就知道。更早的坑是 Python 2,那时候一段中文写错编码 UnicodeDecodeError 能让你卡一整天。

这玩意不是 bug。是 1992 到 1996 那四年,几家大公司选错了编码方案,我们今天还在替他们填坑。

这事儿真正的源头,在 1992 年 9 月 2 日那个周三晚上,美国新泽西一家路边小餐馆的餐桌上。

餐桌旁坐着两个人,一个叫 Ken Thompson,写过 Unix;另一个叫 Rob Pike,写过 Plan 9。

Thompson 在一张餐巾纸上写了几行二进制。

那张纸被服务员收走扔了。

但纸上那几行二进制活下来了,今天全世界 98% 的网页、你手机里每一条聊天消息,都靠着那几行二进制的规矩在跑。


要讲明白那一夜发生的事,得先讲一下它发生之前的 30 年,世界上的字符编码有多乱。

一切从 1963 年开始。那年美国标准协会发布了 ASA X3.4-1963,也就是后来的 ASCII。主导者叫 Bob Bemer,在 IBM 干过很多年,被人叫 ASCII 之父。

ASCII 是 7 位的,128 个字符。26 个英文字母大小写加上数字、标点、回车、空格,够英文世界用了。

第二年 1964,IBM 自己跳票。他们本来也是 ASCII 委员会的主力之一,但 System/360 大型机要发布,外设赶不及改成 ASCII,于是老大拍板,我们先用原来的 BCD 扩展一下,搞了个叫 EBCDIC 的 8 位编码顶上。这个「临时方案」一用就是 60 年,今天的银行大型机、航空公司订票系统还在跑 EBCDIC。不过这是另一个故事了。

到了 80 年代,事情开始乱起来。

1980 年我国出了 GB2312,7445 个汉字,够日常用。1982 年日本出了 Shift-JIS,是日本 ASCII 公司做的。1984 年台湾资策会出了 Big5,繁体汉字。1987 年欧洲出了 ISO 8859-1,也就是后来大家熟的 Latin-1,把西欧那些带重音的字母塞进去。

你数数,光是那几年,亚洲和欧洲就至少有四套互相不兼容的编码在并行跑。每个国家各自为政,各自的文本文件,到了别国的机器上一读就是满屏乱码。

互联网一起来,直接就炸了。

你想象一下,1989 年一个日本人给一个中国人发邮件,中间经过美国的服务器。日本人用 Shift-JIS 编码,中国人的机器按 GB2312 解码。结果就是,那个邮件在中国人屏幕上是一片乱码,没有任何办法能自动读出来。

这不是工程师不努力。这是一个结构性问题,每套编码都占着 0 到 255 这 256 个字节值,但对应的字符完全不同。一个字节值在 Shift-JIS 里是「あ」,在 GB2312 里可能是一个汉字的半截,在 Latin-1 里是个西班牙字母。机器看到一串字节,根本不知道应该用哪套表去翻译。

到了 1988 年,Xerox 和 Apple 的几个工程师坐下来说,这不行,得搞一个全世界通用的编码。他们拉了一堆公司组了个 Unicode 联盟。1991 年出了 Unicode 1.0。

Unicode 的想法很理想,给世界上所有文字一个唯一的编号,英语里的 A 是 65,中文的「中」是 20013。编号空间留得很大,理论上能装下所有文字。

但 Unicode 1.0 的编码方式选错了。

他们选了 UCS-2,16 位定长。每个字符不管是英文还是中文,都占 2 个字节。65536 个坑,当时看够用了,思路也简单,处理字符串就像处理数组。

这个选择有个根上的毛病。

C 语言里每个字符串都以 \0 也就是 0 字节作为结尾标志。ASCII 里英文字母 A 是 65,B 是 66,都不会出现 0。但到了 UCS-2,英文字母 A 变成了两个字节 00 41,高字节就是 0。C 程序一读这个字符串,看到第一个 0 就停了,认为字符串已经结束,后面全丢。

全世界几十年积累下来的 C 代码,一夜作废。

你是一个操作系统厂商,你敢不敢让用户升级一下,然后所有老程序都跑不起来?你不敢。

这时候就有人站出来了,国际组织 X/Open 委员会。他们想搞一个过渡方案,既能表达全世界的字符,又能兼容老的 C 程序。他们拉了几个人,出了一份草案,叫 FSS/UTF,全称 File System Safe UTF。

1992 年 9 月 2 日,周三下午,X/Open 的人正在德州奥斯汀开会讨论这份草案。其中有几个是 IBM 的,他们觉得这方案还得再听听懂行人怎么说。于是他们打了个电话到新泽西的贝尔实验室,找 Ken Thompson 和 Rob Pike 帮忙审一下。

电话接通了。


Pike 后来在 2003 年的一封邮件里详细回忆了那天晚上发生了什么。

电话挂了之后他和 Thompson 对了一下。方案有个大问题,从一段字节流中间任何位置切进去,FSS/UTF 分不清自己现在读到的这个字节是字符起点,还是某个字符的中间某一段。这听起来是个小毛病,其实是大事,比如一个文本文件损坏了前半段,你拿后半段打开应该还能看,而 FSS/UTF 做不到。

Thompson 想了想,说我能重写一个。

他们那天晚上就去新泽西的一家 diner 吃饭,具体是哪一家 Pike 没说过,反正就是那种美国公路边上的老式餐馆。Thompson 拿了一张餐巾纸,在上面写 bit-packing 的方案。

他的方案核心只有一个想法,让每个字节自带位置信息。

ASCII 字符的 8 个 bit,首位是 0。也就是说所有老 C 程序里存的英文字母,在新方案里完全不变。一个字节 0xxxxxxx,就代表这是一个单独的 ASCII 字符。完全兼容。

多字节字符怎么办。Thompson 给每个多字节字符的首字节加一个长度前缀。

两个字节的字符,首字节是 110xxxxx,第二个字节是 10xxxxxx。一看首字节以 110 开头,就知道这是一个 2 字节字符的开头。

三个字节的字符,首字节是 1110xxxx,后面两个字节都是 10xxxxxx

四个字节的字符,首字节是 11110xxx,后面三个字节都是 10xxxxxx

所有非首字节都是 10xxxxxx 开头。

这想法太妙了。你从字节流任何一个位置切进去,看一眼当前字节的最高几位,就能判断自己是处在一个字符的起点,还是中间。如果是 10 开头,往回找,找到第一个不是 10 开头的字节就是字符的起点。整个字节流可以从任何地方切断重连,都能自动同步。

Pike 说他当时在旁边看着,Ken 一笔一笔在餐巾纸上写这套规则。

吃完饭回到贝尔实验室,他俩打电话给 X/Open 的委员会,说我们有一个改进的方案,给你们。

当天晚上 Thompson 就开始写编码和解码的 C 代码。Pike 分头改 Plan 9 操作系统的 C 库和图形库。两个人通宵。

第二天周四,代码基本写完。周五,整个 Plan 9 操作系统已经跑在这个新编码上了。

从餐桌上那张纸到一个完整操作系统全量切换,一共 3 天。

9 月 8 日周二凌晨 3 点 22 分,Thompson 发了一封邮件出去,公告这事。邮件开头写的是 Here is our modified FSS-UTF proposal.,那会儿这玩意还叫「修改过的 FSS-UTF 方案」,UTF-8 这个名字是后来起的。

邮件最后一句 Thompson 说,The code has been tested to some degree and should be pretty good shape.,意思是代码测过了,应该差不多能用。

https://doc.cat-v.org/bell_labs/utf-8_history

半年后,1993 年 1 月,USENIX 冬季大会在圣迭戈开。Thompson 和 Pike 在会上把这套方案作为 UTF-8 正式对外发布。

那张餐巾纸呢。

Pike 在 2003 年给 Markus Kuhn 的邮件里写过一句话,原话是 I very clearly remember Ken writing on the placemat and wished we had kept it! 我记得很清楚 Ken 在餐巾纸上写东西的那一幕,真希望我们当时把那张纸留下来了。

他等了 11 年才说出那句话。


故事到这里还没完。真正的剧情是,Thompson 公告过后,世界并没有立刻跟上。

1993 年 7 月 27 日,Thompson 公告 UTF-8 整整 10 个月之后,微软发布了 Windows NT 3.1。NT 系列第一个版本,内部字符串选了 UCS-2。10 个月听起来长,但做操作系统的人都懂,一个产品要发布,18 个月前整套架构就定死了,UTF-8 那会儿刚出来,Windows 这一版已经没时间改了。我能理解。

两年过去了。1995 年 5 月,Sun 发布 Java 1.0。char 类型 16 位,内部 UCS-2。这回没有「来不及」的理由,UTF-8 已经公告快 3 年,USENIX 大会上都讲过。Sun 的工程师是看过、想过、然后主动选的 UCS-2。

半年后。1995 年 12 月,网景发布 JavaScript,规范文档里直接写明 String 是 16 位整数序列。一点模糊空间都没留,直接在规范层面把 16 位写死了。这一条到今天的 ECMAScript 标准里都没改过。

这三家的理由是一样的。UCS-2 定长 16 位好处理,每个字符 2 字节,字符串操作就像操作数组,charAt(i) 直接拿第 i 个字符,简单粗暴。65536 个字符当年看起来绰绰有余。他们都没想到,一年之后这个假设就被打破了。

然后就到了 1996 年 7 月。

Unicode 联盟发布 2.0 版本。他们发现当年预留的 65536 个坑不够,要放进更多的汉字、日韩字、历史文字、后来还要放 emoji,远远超出 65536。于是他们引入了一个机制叫 surrogate pair,中文叫代理对。

Unicode 从这一版开始,突破了 16 位边界。对于超出 65536 的字符,编号需要拆成两个 16 位的「代理半对」来表示。原来的 UCS-2 从这一刻起,被迫升级为 UTF-16。

但这一升级,UCS-2 那个「定长、好处理」的核心优势,彻底没了。

一个 emoji 的 Unicode 编号超过 65536,到了 UTF-16 里就得用两个 16 位代理对来表示。Java 和 JavaScript 都是把字符串当成 16 位 code unit 的序列,一个 emoji 就占两个 code unit。

所以我开头说的那一幕,"🚀".length() 返回 2、"🚀".length === 2,就是从那一年留下来的病根。

Java 后来在 Java 5 引入了对代理对的处理函数,但 String.length() 返回的还是 code unit 数,不是真正的字符数。改不了,因为改了就破坏所有老代码。Windows API 那套 LPWSTRwchar_t 也一样,至今 Windows 内部字符串还是 UTF-16。

这些平台今天还的债,都是 1993 到 1995 那两年做错决定时欠的。

另一边是 Unix 阵营。Plan 9、Linux、后来的 macOS、Go、Rust、Python 3,默认字符串都是 UTF-8。这些平台当年都是跟着 Thompson 那张餐巾纸走的。

我国这一路走得更曲折。GB2312 不够用,1995 年扩成 GBK,21886 个汉字。2000 年再升级成 GB18030,成为强制标准。相当长一段时间里,国内的软件、网页、文档都是 GBK 和 UTF-8 并行。你现在打开一些 2005 年前后的老网页,还能看到 charset=gb2312 或者 charset=gbk。新项目基本全切到 UTF-8 了。

2012 年前后,W3C 的 HTML 规范把 UTF-8 定为 Web 的标准编码。今天 W3Techs 的统计显示,全球 98% 以上的网页都在跑 UTF-8。


过去那些年的那些事儿,我一直觉得挺有意思。

你看当年 X/Open 委员会在奥斯汀开会,一屋子专家,流程走完起草完一套方案,花了不知道多少人月。Unicode 联盟一屋子工程师、语言学家、标准化官员,开了几年会出了 UCS-2。

Thompson 和 Pike 两个人,一顿饭的工夫,重画了一套方案,直接推翻桌子上那份草案,6 天之内让一个完整操作系统跑在新方案上,30 年后这套方案是全世界的事实标准。

而且两个人都没想到会有今天这个结果。Pike 的原邮件里写得很清楚,他当时只是觉得 FSS/UTF 有个可以改进的技术问题,顺手改一下。他不知道这一改,改出了一套用 30 年还在用的东西。

技术世界里能决定未来的那些决定,很多时候不是在会议室里开出来的,是在餐桌上一张纸上画出来的。很多看着「就该是这样」的默认规则,背后都是某一天某个具体的人做的具体的选择。

也许他们只是在那一天,正好在那个位置上,做了一个当时看起来合理的选择。然后世界就按这个选择跑了几十年。

所以 Java 里 "🚀".length() 是 2。所以你打开老文件会看到乱码。所以你在国内写代码时会偶尔遇到编码陷阱。这些都不是天经地义。都是当年某个会议、某个决定、某个工程师的一念之差。

那张餐巾纸 Pike 很遗憾没留下。但其实想想,这纸没留下来也挺好。留下来了,它就只是一张纸,放在博物馆里。没留下来,它的设计就活在你屏幕上每一个字节里。

你下次在 Java 里遇到 "🚀".length() 返回 2 的时候,想想 1995 年那个 Sun 做决定的人。

你下次看到中文字符正常显示的时候,想想 1992 年 9 月 2 日那个周三晚上,Ken Thompson 在一张餐巾纸上写下的 110xxxxx 10xxxxxx

这种事,计算机这个行业里还有很多。每一个你习以为常的默认值背后,都站着一个早就被忘掉的、做那个选择的人。


署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)
位旅人路过 次翻阅 初次见面