Solidity 的 bytes 和 string:字节长度为什么不等于字数
bytes、bytes32 和 string 看起来都能装文字,实际类型和用途并不相同。本文讲清 UTF-8 字节长度、十六进制表示、拼接与截断,以及合约和前端如何约定一致的数据格式。
在网页里输入“中文”两个字,前端显示长度为二;合约里计算 bytes(s).length,得到的却是六。换成 bytes32 后,又出现三十二字节的固定长度。这些结果可能都没有算错,因为它们测量的对象根本不同。
阅读 Solidity 时,先把三个概念分开:人看到的文本、计算机保存的字节、界面展示的十六进制字符串。bytes 和 string 的区别,从这三层看会容易得多。本文采用 Solidity 0.8.30 的语义说明,示例只演示数据处理,不执行资金操作。
bytes、bytes32、string 分别适合放什么
| 类型 | 长度特点 | 常见用途 |
|---|---|---|
| bytes | 变长字节序列 | 调用载荷、原始二进制数据 |
| bytes1 到 bytes32 | 固定一到三十二字节 | 固定宽度标识、选择器、哈希摘要 |
| string | 面向 UTF-8 文本的变长类型 | 名称、描述、链接等文本 |
bytes32 是一个固定长度的值类型,不是“最多装三十二字节的动态文本框”。bytes 和 string 属于引用类型,函数参数和局部变量需要结合 storage、memory、calldata 来阅读。关于这三种数据位置,站内的专门说明可以作为前置知识。
实际选型从数据含义出发:协议规定为三十二字节的摘要,就用固定宽度;任意长度的调用数据,用 bytes;供用户阅读的名称,用 string。不能因为某种类型在一个例子里更省空间,就不顾接口约定直接替换。
一个字为什么会占多个字节
字节是八个二进制位组成的数据单位。UTF-8 用不同数量的字节编码不同 Unicode 码点,普通 ASCII 字母通常占一个字节,本文中的“中”和“文”各占三个字节。因此 AI 占两个字节,中文 占六个,AI中文 占八个。
而“用户眼里的一个字符”还可能由多个 Unicode 码点组合而成。带组合重音的文字和一些复杂符号尤其如此。把字节数除以三来估计中文字数并不可靠,一段文本往往还混着英文、数字、空格和其他符号。编码形式的区别可参考 Unicode 官方编码问答。
合约检查“最多六十四字节”,不等于界面限制“最多六十四个字”。产品规则应该把计量单位写清楚,并让前后端遵守同一个单位。

用一个完整示例核对长度
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
contract ByteExamples {
function lengths() external pure returns (uint256, uint256, uint256) {
return (
bytes("AI").length,
bytes(unicode"中文").length,
bytes(unicode"AI中文").length
);
}
function rawData() external pure returns (bytes memory, bytes memory) {
return (hex"4142", bytes("4142"));
}
function join(string calldata left, string calldata right)
external pure returns (string memory)
{
return string.concat(left, right);
}
}lengths() 的预期结果是 (2, 6, 8)。源码中的非 ASCII 文本使用 unicode 字面量前缀。这个前缀解决的是如何在 Solidity 源文件中写入该文本,不代表调用者提交的所有外部字节都会自动通过完整的文本合法性检查。
Solidity 的类型文档明确区分字节访问与字符访问。理解了这一点,就不会把 bytes(s)[0] 误认为一定能取到一个完整汉字。
十六进制数字是表示方式,不是另一种文字
一个字节可以用两个十六进制数字显示。hex"4142" 表示两个字节 0x41、0x42,按 ASCII 解读对应 AB;bytes("4142") 则表示四个字符“4”“1”“4”“2”的编码,显示成十六进制是 0x34313432。两者从输入开始就不同。
所以调接口时要问:参数期望的是原始字节,还是包含十六进制字符的文本?常见库用带 0x 前缀的字符串接收字节数据,但这属于库的输入约定。把一个十六进制字符串再按 UTF-8 编码,得到的是它的文字外壳,不是原来的字节载荷。
排查前后端计算不一致时,可以分别打印字节长度和十六进制内容。只打印“看起来一样”的字符串,容易把大小写、隐藏空格和重复编码遗漏掉。
为什么 string 没有直接提供索引
Solidity 的 string 不支持直接使用 s.length 或 s[i] 进行长度和索引访问。转换为 bytes 后可以查看底层字节,但索引依然指向字节位置。若只保留“中”的第一个字节,得到的是被切断的 UTF-8 序列,前端可能出现乱码或替代字符。
这意味着“取前十个字”并不是一个简单的字节切片操作。若业务只是生成展示摘要,通常应在了解 Unicode 的应用层完成;若规则必须由合约强制执行,就需要清楚约定按字节还是按其他文本单位限制,并使用经过验证的实现。
也不要把 bytes 到 string 的类型转换当作文本清洗。转换不会替你去除隐藏字符、统一大小写、过滤同形字符或完成业务名称校验。接口能够解码,只证明它满足某些数据格式条件,并不等于名称适合展示或用于唯一标识。
拼接文本与拼接字节,各有明确入口
string.concat 用于拼接文本,bytes.concat 用于拼接字节数据。上面的 join 函数接收两段文本并返回连接结果。它不会在中间自动插空格、分隔符或长度信息,这些都要由调用者明确提供。
如果只是把两个片段组成一句显示文本,直接连接很好理解;如果拼接结果要用于记录唯一性或签名,就必须考虑字段边界。例如姓与名、订单号与备注合并后,可能无法知道原来在哪里分开。这个问题与字符编码不同,下一篇ABI 编码边界说明会具体演示。
bytes32 装的是内容,还是内容的摘要
三十二字节变量既可能保存固定宽度原始数据,也可能保存哈希结果。类型本身不会告诉你是哪一种。若它是 keccak256 输出的摘要,就不能靠类型转换还原原始长文本;摘要不是压缩包,也不是可以用密码解开的密文。
如果原协议把短文本填入固定宽度字段,可能存在填充零字节的规则。解读时必须知道是否允许尾部零、原长度如何恢复。随意去掉所有零字节,会改变某些本来就包含零值的二进制数据。对任意长输入做窄化或截断,也不会自动保证被舍弃的部分无关紧要。
另一种容易出错的设计,是把前若干字节当唯一标识。两个长输入可能拥有相同前缀,截断后就无法区分。若需要稳定标识,应定义完整的数据格式与冲突处理方式,不能把“固定三十二字节”直接等同于“天然唯一”。

文本比较要先决定什么叫相同
如果业务要求字节完全相同,可以对相同类型、相同编码的文本计算哈希再比较,实际工程中通常依赖哈希抗碰撞性质。这个比较不会自动忽略大小写、前后空格或 Unicode 的不同表示形式。两个肉眼相似的名称,可能对应不同字节序列。
产品若希望名称大小写不敏感,或要求等价文本归一化,需要把规范化规则写进协议,并保证所有入口一致执行。只在前端做处理、合约没有对应约束时,其他调用者仍能绕过界面直接提交数据。展示名称和权限标识最好分别设计,减少文本细节对授权逻辑的影响。
数据长度与 Gas 也需要一起考虑
变长输入会带来复制、编码和存储成本,存储长文本尤其需要评估。不要在一个关键操作中不设上限地拼接历史内容,也不要让任何人提交的超长文本拖累后续遍历。长度上限既是产品规则,也是资源边界。
bytes 在存储中的布局还有专门规则,短数据与较长数据采用不同表示方式。需要做底层优化时,应查看官方存储布局文档并测量实际调用,而不是用“每个字都占一个存储槽”这样的直觉估算费用。
记住这五个区分
- bytes 用来表达变长原始字节,string 面向文本,bytes32 是固定宽度值。
- 字节数、Unicode 码点数和用户看到的字符数可能不同。
- 十六进制文本与它描述的原始字节,需要使用正确方法转换。
- 拼接不会自动保留字段边界,截断可能破坏文本或丢失重要内容。
- 前后端要对编码、长度、大小写和规范化方式建立共同约定。
调试时先从“我到底在比较哪几个字节”问起,常常比盯着界面上的一串文字更快找到问题。
本文为链上指南原创技术科普,不构成任何投资建议。示例仅用于理解类型与编码,生产项目应结合所用编译器版本与实际接口测试。配图来自 Unsplash:Ilya Pavlov、Christian Wiediger、Markus Spiske;图片用于辅助阅读。