首页 / 资讯中心 / 文章详情

t3code:用0、1、2三个字符编码任意数据的轻量方案

t3code:用0、1、2三个字符编码任意数据的轻量方案 ★ FEATURED ARTICLE
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 bit128 除以 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:表示用abct3n:表示用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, byteorderbig)) 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, byteorderbig) 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 的实现核心代码量不到一百行解码端放在边缘网关里跑得飞快。遇到字符集被卡死的业务别硬刚渠道换一种表达方式往往就通了。
阅读完成 · 觉得有帮助?
咨询建站