☰
t3code:用0、1、2三个字符编码任意数据的轻量方案
2026/10/26 8:32:53 网站建设 项目流程

t3code 是我在一个物联网上报链路改造里顺手写出来的小工具,核心思路是把任意字节数据编码成只包含 0、1、2 三个字符的三进制字符串,并且能无损还原。当时对接的渠道只认数字和少量符号,Base64 那一堆+、/、=根本塞不进去,十六进制虽然能过,但长度和可读性都不理想。后来我干脆按三进制做了一套带版本前缀和长度信息的短线文本协议,就是我现在说的 t3code。

这篇文章会把设计思路、数学底子、完整 Python 实现、参数对比和几个真实踩过的坑都摊开讲。如果你正在做短信通道、票据短码、二维码防误读、日志脱敏,或者遇到任何“字符集被死死卡住”的传输场景,可以直接把这套代码拿去改一版用。下文默认你有一点 Python 基础,但我会把原理说得尽量直白,哪怕你只写过几天脚本也能跟上。

先打个预防针:t3code 不是通用编码方案,它的体积膨胀比 Base64 严重得多。它解决的是“只能用极少数符号”的问题,不是在常规网络传输里替代 Base64。搞明白边界再动手,比急着抄代码重要得多。

1. t3code 是什么:一个只靠 0、1、2 三个符号的编码工具

1.1 什么场景逼得我做了三进制编码

那会儿要做一个设备配网回传功能,设备通过短信上行通道把配网结果发给后台。服务商给的通道限制很死:只允许数字和字母开头,中间只能放数字,大小写还不保证保留。常规 Base64 里的+、/、=直接被过滤,hex 虽然能塞进去,但 32 个字符的十六进制串在短信里很容易被运营商侧的规则切碎,而且人眼识别a和4、b和8还是会走神。

后来我梳理了一下可用字符集,发现最稳妥的做法是只用数字。数字里最基础的三符号集合就是 0、1、2,于是想到三进制编码。为什么选三进制而不是十进制?因为当时有几个字段连3到9都不一定保险,某些老设备会把非 0/1/2 的字符当控制字节处理,或者干脆过滤掉。符号越少,兼容面越广。反过来讲,如果你的渠道能接受 0 到 9 十个数字,那用十进制大整数编码会比三进制更短;只有在“可用符号就剩下两三个”的时候,三进制才真正挺身而出。

这个场景给 t3code 定了个基调:它不是为了让数据更小,而是让数据在极度受限的字符集里活下来。编码出来的字符串长了点没关系,能过通道、能无损还原才是第一优先级。

1.2 三进制编码的数学底子:每个三进位能装多少信息

理解三进制编码,先要理解一个三进位(trit)的信息容量。一位二进制数只能表示 0 和 1 两种状态,一个三进制数能表示 0、1、2 三种状态,它携带的信息量是 log2(3) ≈ 1.585 bit。也就是说,平均每 1.585 bit 的数据,才需要输出一个三进制字符。

反过来算,n 个字节一共有 8n bit,理论上最少需要多少个三进制字符来表示?公式是:

output_len = ceil(8n / log2(3))

拿 16 字节举例:16 字节是 128 bit,128 除以 1.585 约等于 80.76,向上取整就是 81 个字符。而同样的 16 字节用 hex 是 32 个字符,用 Base64 是 24 个字符,用 Base32 是 32 个字符。所以必须说实话:三进制编码体积膨胀很明显,大概比 Base64 多三倍多。

那为什么还要用?因为 hex、Base64、Base32 都要求一定大小的符号集。hex 要 16 个符号,Base64 要 64 个符号,Base32 要 32 个符号,这些在通道里根本凑不齐。三进制只要 3 个符号,是最低限度的可逆编码方案之一。你只能给螺丝刀,就别嫌弃它拧得慢,关键是它真能拧得动。

需要一个生活化的类比帮助记忆:把字节串想成一袋子硬币,总金额是固定的。三进制编码不是一张一张数硬币,而是把整袋硬币倒进一个兑换机,按 3 进位的面额重新找零。找出来的零钱张数一定比原来多,但每一张都是 0、1、2 面额的,不会出现你不认识的货币。

2. 编码规范拆解:长度前缀、符号集、校验位一个都不能少

2.1 字节串到三进制字符串:为什么先把数据看成一个大整数

自然的三进制转换方式是把「字节串」当成一个整体的大整数。具体做法:把字节串按大端序读成一个整数,再把这个整数反复除以 3,余数倒序排出来,就是三进制字符串。用 Python 写非常短:

def _to_base3(n: int) -> str: if n == 0: return "0" digits = [] while n > 0: n, r = divmod(n, 3) digits.append("012"[r]) return "".join(reversed(digits))

这里有个值得展开的“为什么”。如果逐字节转换,每个字节有 256 种取值,一个三进制字符只有 3 种取值,2 个不够、3 个不够、4 个只有 81 种还是不够,至少需要 6 个三进制字符才能覆盖 256 种情况(3 的 6 次方是 729)。这样每个字节固定消耗 6 个字符,16 字节就是 96 个字符。

但如果把整个字节串看成一个整数做整体进制转换,16 字节的 128 bit 信息可以被“整除”到接近信息论下界,只要 81 个字符。整体转换能规避每字节整数个三进制字符的浪费,这是这个方案的关键。代价是大整数运算,后面会讲到怎么用分块解决。

2.2 前导零必须靠长度前缀救回来

大整数法有一个非常隐蔽的坑:前导零会丢。b"\x00\xff"读成大整数就是 255,跟b"\xff"读出来的整数一模一样。编码端字符串一样,解码端自然还原不成原来的字节串。

我一开始没意识到这个问题,第一版工具直接把三进制串发出去,结果所有开头带\x00的数据全部缺字节。排查了半天才明白,钱的总数不能推导出钞票的张数,你只知道这叠钱值 255 元,但不知道它是两张还是一张。

解决方案是在编码串里显式带上原始字节长度。t3code 的帧格式设计成三段:

t3:<长度>:<三进制数据>

前面t3是版本前缀,中间是原始字节长度,冒号后面是编码数据。解码时先读长度,再把三进制字符串转回整数,最后用to_bytes(n)强制按 n 个字节还原。这样即使数据里全是\x00,长度信息也能帮我们精准补零。

2.3 符号集与手抄场景:012 之外的备选

默认符号集是字符串"012",所有编码逻辑都用这个字符串的下标来映射数字。为什么不直接用字符'0'、'1'、'2'计算?因为一旦渠道允许你用别的三个字符,你只需要替换这一个常量。

比如某些对讲机文本只认A/B/C,某些票据打印对0和O、1和I的识别容易混淆,那就可以把符号集换成"abc"、"xyz"或者"ABC"。代码里唯一要注意的是:编解码两端必须使用同一套符号集,而且符号集的顺序不能乱。"abc"的意思是下标 0 对应a、下标 1 对应b、下标 2 对应c,如果把A/B/C和a/b/c混着用,解码结果会直接错乱。

如果项目里真的需要换符号集,我建议把符号集编码进前缀。比如t3a:表示用"abc",t3n:表示用"012"。这样两端只要解析前缀就知道该用哪套符号表,不会发生配置漂移。

2.4 校验位:给三进制短码加一道保险

三进制字符串经常在短信、手抄、人工录入这些高噪声环境里流转,一个数字抄错就是静默损坏。我给 t3code 加了一个可选校验位,思路和身份证校验码类似:把所有三进制位的数字累加,对 3 取模,然后在末尾补一个数字,让整个字符串(包含校验位)的数字和能被 3 整除。

def _check_digit(s: str) -> str: total = sum("012".index(ch) for ch in s) return "012"[(-total) % 3]

解码时先验证总和余数是否为 0,如果被改动了一个字符,绝大多数情况下余数都会变成 1 或 2,直接可以判定数据不可信。这个校验位只需要多输出一个字符,换来的是手抄错误能被立刻发现。我个人强烈建议在人工参与的场景里常开校验,后面踩坑部分还会细说。

3. 完整实现与 CLI 封装:可直接抄走的 Python 代码

3.1 核心编解码函数

把上面几节整合一下,就是一套干净的核心实现。这里我用int.from_bytes做字节串到大整数的转换,解码时用int.to_bytes做逆向。特别注意OverflowError要捕获,因为长度前缀和数据不匹配时,to_bytes会抛异常。

# t3code.py import sys ALPHABET = "012" PREFIX = "t3:" CHUNK_SIZE = 16 def _to_base3(n: int) -> str: if n == 0: return ALPHABET[0] digits = [] while n > 0: n, r = divmod(n, 3) digits.append(ALPHABET[r]) return "".join(reversed(digits)) def _from_base3(s: str) -> int: n = 0 for ch in s: n = n * 3 + ALPHABET.index(ch) return n def _check_digit(s: str) -> str: total = sum(ALPHABET.index(ch) for ch in s) return ALPHABET[(-total) % 3] def t3_encode(data: bytes, with_check: bool = True) -> str: n = len(data) b3 = _to_base3(int.from_bytes(data, byteorder="big")) if with_check: b3 += _check_digit(b3) return f"{PREFIX}{n}:{b3}" def t3_decode(text: str, with_check: bool = True) -> bytes: if not text.startswith(PREFIX): raise ValueError("missing prefix") body = text[len(PREFIX):] n_part, sep, b3 = body.partition(":") if not sep or not n_part.isdigit(): raise ValueError("bad frame") n = int(n_part) if any(ch not in ALPHABET for ch in b3): raise ValueError("invalid char") if with_check: if sum(ALPHABET.index(ch) for ch in b3) % 3 != 0: raise ValueError("check digit failed") b3 = b3[:-1] value = _from_base3(b3) try: return value.to_bytes(n, byteorder="big") except OverflowError: raise ValueError("data too long for length field")

这段代码没有用什么高深技巧,胜在结构清楚。PREFIX不只是美观,它承担了版本协商的职责。以后如果我想换符号集或者改帧格式,可以改成t3b:之类的新前缀,老数据依然能用旧逻辑解析。

3.2 命令行封装:管道里直接 encode/decode

为了在 shell 里用起来顺手,我加了一个极简命令行封装。编码时从 stdin 读二进制,把三进制文本写到 stdout;解码时反向操作:

def main() -> None: mode = sys.argv[1] if len(sys.argv) > 1 else "encode" if mode == "encode": data = sys.stdin.buffer.read() sys.stdout.write(t3_encode(data) + "\n") elif mode == "decode": text = sys.stdin.read().strip() sys.stdout.buffer.write(t3_decode(text)) else: raise SystemExit("usage: t3code.py encode|decode") if __name__ == "__main__": main()

实际用法:

echo -n "hello" | python t3code.py encode python t3code.py decode < output.t3c

编码结果被写成一行文本,方便复制进短信、表单或者聊天窗口。如果你要在 Windows 环境用管道,注意二进制和文本模式的区别,可以把解码模式改成把十六进制文本输出到 stdout.buffer,或者干脆直接写文件。

3.3 大文件分块:别把整个文件怼进一个整数

上面的实现有个隐患:如果输入是 1 MB 的文件,int.from_bytes会创建一个非常大的整数,虽然 Python 的大整数很强大,但整体转换再转三进制,时间和内存都会随着数据量非线性上涨。我实测下来,几十 KB 没问题,几 MB 就开始明显卡顿。解决办法是分块。

每块固定 16 字节,块内独立编码,块与块之间用|分隔:

def t3_encode_stream(data: bytes) -> str: if not data: return "" frames = [ t3_encode(data[i:i + CHUNK_SIZE]) for i in range(0, len(data), CHUNK_SIZE) ] return "|".join(frames) def t3_decode_stream(text: str) -> bytes: if not text: return b"" return b"".join(t3_decode(f) for f in text.split("|"))

为什么选 16 字节一块?因为 16 字节是 128 bit,转三进制约 81 个字符,够短,中间任何一个块出错都不会影响其他块;同时块的边界又让大整数运算保持在非常快的区间。如果字符集里允许分隔符|,你可以在短信渠道里安全传输;如果渠道不允许|,就把块标识改成;或者换行符,总之分隔符要选可用字符集里的。

这里有个小经验:真正处理超大文件时,别像我这样把解码结果b"".join(...)一次性拼起来。文件一上几十 MB,内存照样吃紧。更好的做法是循环处理每一块,直接写入输出文件对象,这个留给你按需扩展,核心逻辑不变。

4. 应用场景、参数对比与使用边界

4.1 受限渠道传输:短信上行、对讲机文本、数字键盘

t3code 最典型的应用场景就是字符集被限制死的通道。我遇到过的三个真实例子:

第一个是短信上行通道。很多服务商只接受数字开头的上行内容,甚至后续只能跟数字,这种情况下 Base64 的字母都会触发风控规则。t3code 编出来的字符串全部由 0、1、2 组成,天然就是数字。

第二个是对讲机的自定义文本。有些设备的中文输入法功能薄弱,只能输入几个固定符号,我见过只支持0/1/2三个字符的定制终端。你没法跟终端讲道理让它支持 Base64,只能让数据迁就终端。

第三个是自助终端的数字键盘。用户要手输一串凭证码,屏幕上只有数字键盘,没有字母键盘。如果凭证码是 t3code 的 0/1/2 串,用户用数字键盘就能敲。对比 hex 需要 0-9 加 a-f,在纯数字键盘上根本没法输入,t3code 的优势一下就体现出来了。

4.2 日志脱敏和 ID 短码

还有一种用法是把内部 ID、序列号、临时密钥转成统一前缀的短码写进日志。比如t3:16:...这种格式,日志系统里只需要按t3:前缀就能把所有敏感短码 grep 出来,其他时候日志里是一串不可读的三进制数字,降低了纯文本读取时泄露信息的概率。

但必须强调,这不是加密。它只是让敏感内容在日志里不那么显眼,真正要防攻击者,得在编码之前做密钥哈希或者 HMAC 签名。我见过有人把编码当加密用,这是很危险的误解。编码解决的是表达问题,不是安全问题。

4.3 t3code 对比 hex / Base32 / Base64

不看数字没有说服力,我把 16 字节数据在不同编码下的输出长度和符号集要求整理了一下:

编码符号集大小每字符信息量16 字节输出长度典型限制
hex164 bit32大小写不敏感场景
Base64646 bit24需要处理 + / =
Base32325 bit32无大小写混合限制时可用
t3code31.585 bit81几乎无限制,只要 3 个符号可用

这张表最右列说明的是“什么时候它才有用”。Base64 输出最短,但需要 64 个符号;t3code 最长,但只需要 3 个符号。如果你的渠道允许 64 个符号,别犹豫,用 Base64;你的渠道只留了 3 个符号,Base64 就算理论再好也落不了地。

4.4 什么时候不该用 t3code

我给自己立过几条规矩,遇到这些情况直接放弃 t3code:

第一,普通 HTTP API 传输。接口文档里写明了Content-Type和 Base64 字段,根本没理由为了三进制的噱头引入额外复杂度。网络传输不缺字符集,缺的是体积和速度。

第二,数据要进数据库、做索引、做比较。三进制字符串比 Base64 长三倍,存储空间和索引效率都吃亏,除非数据库字段本身只能存 0/1/2 这三个字节值,否则别这么干。

第三,符号集足够大时追求最短体积。如果可用符号是 10 个数字,十进制大整数编码更好;如果可用符号是 2 个,二进制按位展开更好。三进制只有在可用符号正好是 3 个时才是最贴近需求的答案。

本质上,t3code 是一个“最后一道防线”工具,不该被当成通用编码方案到处推广。

5. 踩坑记录与解码问题排查速查表

5.1 最隐蔽的坑:前导零不声不响地丢了

这个坑前面提过,但我还是想用独立一节课来强调,因为它是我踩过最深的一次。第一版 t3code 没有长度前缀,只输出三进制字符串。当时测试数据碰巧没有以\x00开头的内容,全链路跑通,我以为没问题。后来真实数据一来,解码结果长度永远比原始数据短,而且短的字节全是开头的空字节。

排查思路其实很直接:把编码结果解码再编码,发现第二次编码结果和第一次一模一样,说明问题不是出在传输环节,而是编码本身就不是双射。存了长度前缀之后,这类问题彻底消失。现在我设计任何编码协议,第一件事就是问自己:这个表达方式能不能无损还原出原始数据的“长度信息”?钱的金额和钱的张数是两个概念,编码协议必须同时记录这两个维度。

5.2 符号集不一致导致解码错位

还有一次,同事为了让字符串在大屏上更好看,把ALPHABET从"012"换成了"abc",编码端发出去一堆abcca...,解码端还默认用"012"解析,结果自然是invalid char。严格来说这不算 bug,是两端配置漂移。

后来我不再让符号集散落各处,而是把它跟前缀绑定。用t3a:代表 ASCII 符号集,用t3n:代表纯数字符号集。前缀错了直接抛错,不会带着错误配置继续跑。这种“协议自描述”的思路,其实是从成熟文件格式里学的,与其让两端默默对齐,不如把关键参数写进数据本身。

5.3 常见错误信息与修复对照表

把我在实际使用中遇到过的错误消息和排查方向整理成一张表,方便你之后照着判断:

现象可能原因排查方向
missing prefix文本被截断,或者输入的不是 t3code 编码检查前缀t3:是否完整
bad frame长度字段缺失,冒号被吞校验通道是否允许冒号字符
invalid char混入非符号集字符检查符号集是否统一,字符是否被改写
check digit failed手抄或传输中被改了一个字符换校验位方案,或两端核对符号集
OverflowError或data too long for length field长度前缀和数据不匹配确认编码端和解码端符号集完全一致

另外补一个独门技巧:调试时先编一个小数据跑 round-trip 测试,我习惯用这几个向量验证核心逻辑:

cases = [ (b"", "t3:0:0"), (b"\x00", "t3:1:0"), (b"\x01\x02","t3:2:100120"), (b"\xff", "t3:1:100110"), ]

注意上面的测试向量是不开校验位的输出。如果开了校验位,字符串末尾会多一个字符,但前缀和中段应该一致。这套向量能一次性覆盖空数据、全零、混合字节和全\xff四种边界情况,比你在评论区找一百个样例都管用。

这套东西后来在我那个链路里跑了大半年,短信被运营商截断过、人工抄写过、中间字符串被改过,都没出过问题。如果让我重新做一次,我会在第一天就把校验位默认打开,而不是留个可选参数。一开始为了省那一个字符把 check 关了,后来发现手抄码的出错率比想象中高得多,才意识到那一个字符换来的校验能力非常值。

最后再分享一个后续方向:如果你和我一样经常处理这类受限渠道,可以把 t3code 的符号集、分组长度、前缀协议固化成配置文件,然后按语言分别移植。我后来用同样的逻辑写过一版 Go 的实现,核心代码量不到一百行,解码端放在边缘网关里跑得飞快。遇到字符集被卡死的业务,别硬刚渠道,换一种表达方式往往就通了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询