技术8/7/2026·约 5 分钟

JNI 有两种 UTF-8

GetStringUTFChars 返回的不是 UTF-8,是一种改过的 UTF-8:任何 BMP 之外的字符都被拆成两个三字节的代理项,六个字节取代标准的那四个。一个信任了它的 Rust 边界会直接拒绝这个字符串。

名字里带着谎的三个函数

每一份 JNI 教程的开头都一样:Java 的字符串通过 GetStringUTFChars 进入 native,通过 NewStringUTF 回来。两个名字里都写着 UTF-8。它们都是最顺手的选法,都在文档的第一页,而它们做的事情和名字说的不一样。

JVM 内部的字符串表示是 UTF-16。那两个 UTF 函数被规定交给你一个 char *,而规范没有选择把 UTF-16 转成真正的 UTF-8,而是定义了一种改过的 UTF-8,有两处不同:

  • 内嵌的 NUL 编成两个字节 c0 80,而不是一个零字节,这样结果仍然能传给 C 字符串函数而不被截断。
  • 基本多文种平面之外的任何东西,按它的 UTF-16 代理对编码,并且把每一半都完整写成三字节序列。真正的 UTF-8 用四个字节的地方,这里用六个。

第一处差异在实践中看不见。第二处不是。拿一个 emoji:

U+1F600
  UTF-16:         D83D DE00
  真正的 UTF-8:    F0 9F 98 80                 4 字节
  改过的 UTF-8:   ED A0 BD ED B8 80            6 字节

六个字节,而且每一个字节序列都是真正的 UTF-8 所禁止的。代理码点 D83D 在 UTF-8 里根本不允许出现,标准的设计者正是为了这件事才引入了四字节编码;C0 80 是 NUL 的超长编码,同样被禁止。结果是一种技术上畸形的 UTF-8,任何正确的 UTF-8 库都不会接受它。

它在哪里咬人

我们的引擎是 Rust,而 Rust 里不存在「不是 UTF-8 的字符串」这种东西。那是类型系统的保证,不是约定,也正是它让一门内存安全的语言可以把 &str 跨模块边界传出去而无需任何检查。这个保证同时也是它从「小瑕疵」变成「故障」的原因。

边界拿到的不是 &str,而是一个 C 字符串指针,于是它用一个严格检查去转换,遇到任何一个非法字节就返回错误。整条链是:

  1. Java 把一个含 emoji 的字符串交给运行时。
  2. 运行时去取它的 UTF-8 形式,拿到六个字节。
  3. 我们的 C 层把这六个字节原封不动拷进缓冲区。
  4. 引擎解析这个缓冲区。ED A0 BD 是一个被编码的代理项,真正的 UTF-8 禁止它,解析失败。
  5. 这个调用返回「没有值」,而不是它本该返回的那个值。

没有崩溃。没有日志里的编码错误。一个用户在某个最终会成为持久化键、或成为引擎要查找的标识符的字段里打了个 emoji,然后那个值就不存在了。

反方向还有第二种故障,而且更糟。NewStringUTF 被规定接受改过的 UTF-8,而我们的引擎产出的是真正的 UTF-8。当一个含四字节字符的字符串从那个函数出去时,我们交给运行时的是一串它没有义务理解的字节,而不同的 Android 版本反应不同:有的截断,有的替换成替代字符,有一个直接抛异常。这就是「有定义的转换」与「未定义行为」的区别。

修法的形状

同一个下午,相隔十分钟的两个 commit。第一个转了一条路径,也就是场景加载器,转码器写成一个独立文件并接进构建;十分钟后第二个 commit 把这一层剩下的地方全部做完:JNI 层里所有其余的调用点,两个方向,二十七个文件。

第二个 commit 才是有意思的那个,因为修法在那里不再是打补丁,而成为一条规则。它没有去修那些恰好出错的调用,而是把可能性本身删掉了:

  • 那三个函数被从我们 native 测试编译时使用的 stub 头文件里删除。我们的 native 测试二进制链接的是一份手写的 jni.h,里面是一张函数表,而这张表不再有那三个槽位。于是任何一个调用 GetStringUTFChars 的源文件都编译不过。错误出现在作者自己的机器上,用几秒钟时间,并且精确地告诉你哪个标识符不存在。
  • 那份 stub 还必须对编码本身诚实,而不只是对参数个数诚实。所以它在 UTF-8 构造器旁边加了一个 UTF-16 构造器,假运行时实现真实的语义:存下 UTF-16 单元,而唯一能把它们读出来的就是转码器。

第二点是值得推广的那部分。一个对编码函数来说悄悄同意 bug 的测试替身,比没有替身更糟,因为它把一个未知变成了一次绿灯。我们的替身存下它拿到的字符串再原样交回去,于是测试可以在生产产生六个字节的情况下通过。一旦替身存的是 UTF-16 单元,转码器就真的必须转码,而 emoji 那一步才变得可表达。

转码器本身

大约一百行,其中大部分的判断都在处理那些本不该出现的输入:

  • 一个补充平面字符以两个相邻单元到来。把它们重新组合是这段代码存在的唯一理由,而且必须从那份数组做,而不是从一个已解码的字符串做——在改过的 UTF-8 上操作的解码器得先把它还原回去。
  • 一个落单的代理单元不是一个字符。我们不猜,把它变成 U+FFFD,也就是替换字符,栈里其他每一层本来也会这么做。显式地这么处理,意味着这个值仍可比较、可搜索,而不是变成一段每个实现长得都不同的字节模式。
  • 一个内嵌的 NUL直接被拒绝,返回没有值。这正是改过的 UTF-8 设计出来要隐藏的情况,而我们的边界是 C 字符串的边界,所以含 NUL 的字符串无法跨过这条线。显式失败是正确的:默默在 NUL 处截断,会把一个值变成另一个值,而那个 bug 会晚很多才浮现,出现在一个完全不知道有字符串存在的地方。
  • 往 native 走的方向上,无效输入被拒绝而不是被修补,包括两个看起来像合法序列的经典编码:超长 NUL,以及代理区间本身。宽容的解码器会把损坏变成看起来合理的文本,而看起来合理的文本正是你之后无法调试的东西。

这个形状

这件事其实与 JNI 无关。它关乎一个以某种编码命名、却产出别的东西的便捷 API,以及信任一个名字的代价。

当一个 API 以某种编码命名时,先弄清它是否真的产出那种编码。

GetStringUTFChars 产出一个人们称作 UTF-8 的东西。那些 c0 80 与六字节代理项写进了规范,而没有人会去读一份自己早已熟悉的函数的那份规范。这个行为从 Android 诞生起就如此,所以这些函数是正确的,整个生态都建在它们之上。唯一错的是那个名字,而名字恰好是唯一每个人都不查就相信的部分。

同一个形状别处也有。承诺「JSON」却接受尾逗号的序列化器。返回缓冲区却不说该由谁释放的压缩 helper。交回一个字符串却不说明连接协商出了什么编码的数据库驱动。模式总是同一个:契约住在头部的注释里,注释输给名字,而 bug 报告以谜案的形式出现在三层之外。

修法的后半部分才是可推广的那一半。一份删掉三个槽位的 stub 头文件不花任何成本,却永久防住了这一类错误。一个带名字的独立文件里的转码器,意味着每个调用点读到的都是同样两个函数名,评审时看调用就够了,不必读实现。而一个存着真实表示、而不是方便表示的测试替身,是这一切能被测到的前提。

小结

GetStringUTFChars 不返回 UTF-8,它返回改过的 UTF-8:补充平面字符是六个字节,内嵌 NUL 是两个,而两者都是真正的 UTF-8 禁止的序列。反方向上,NewStringUTF 接受改过的 UTF-8,于是把真正的 UTF-8 递给它就是未定义行为,不同 Android 版本的反应各不相同。

在一门保证字符串是 UTF-8 的语言里,这个保证是有承重作用的。引擎边界做严格解析,所以那六个字节不是一个被弄乱的字符串,而是一个被拒绝的字符串。用户打了一个 emoji,值静悄悄地不在了,而什么都没有崩。

把转码器做成一个带两个入口(一个方向一个)的独立文件,所有调用点都指向它。只修那些恰好出错的调用点,会把这一层剩下的表面留在离同一个 bug 一步之遥的地方——这也是这件事的两个 commit 相隔十分钟的原因:第一个写出转码器,第二个扫过整层。

把那三个函数从测试编译时使用的头文件里删掉。 编译错误是一道在每台机器、每次构建、永远都生效的防线;文档里的规则只被读到它的人读到。而测试替身要存下真实的表示,这样一个方便的假实现才不可能同意 bug。

文中提到的产品

继续阅读