
前阵子给一批环境监测网关做低功耗改造遇到一个挺刁钻的需求每块网关下面挂着 12 路传感器每一路只需要汇报“正常/预警/失效”三种状态。按照常规做法每个传感器用一个字节一帧报文光装状态就要 12 个字节但客户那边的 NB-IoT 平台要求整帧不超过 64 字节还要塞时间戳、电量、信号强度和位置信息。抠来抠去最后我把目光盯在这 12 个三态数值上顺手搞出了一套代号叫t3code的三进制编码规则。这篇就把完整的设计思路、编解码实现以及实测中踩的坑都梳理出来。1. t3code 不是心血来潮三态数据压缩的真实场景1.1 一开始我也想过“直接打包 bit”的常规路线先说背景。这 12 路传感器里有的是门窗磁有的是漏水绳有的是温湿度越限报警。真正落到数据模型上每一路的状态集合都是同样的三元组0表示正常、1表示预警、2表示失效。没错我刻意没用布尔值因为“预警”和“失效”完全是两个量级的事情不能混成一个false。常规想法是一路状态用一个uint8_t12 路就是 12 字节省一点的话每个状态用 2 个 bit 表示12 路打包成 24 bit也就是 3 字节。但问题来了这套网关通过运营商平台走的是 CoAP 上的文本上报服务端用字符串解析一看到二进制头就头疼。而且这 24 bit 的打包还得处理字节序C 端和 Python 端一旦对不齐排查起来相当痛苦。我需要的是一套“能用最短的纯数字文本表达 12 个三态”的格式而且最好跨语言、可读、可调试。这就很自然地想到了把三进制直接拼成一个数再转成十进制字符串传输。1.2 三进制的信息密度到底占什么便宜先看数学。每个三态能表达3种可能承载的信息量是log2(3) ≈ 1.585bit。12 路传感器的总组合数是3^12 531441。如果用二进制编号来承载这 531441 种组合需要ceil(12 × log2(3)) ceil(19.02) 20bit也就是 3 字节24 bit还略有浪费。但如果把 12 个三态直接看作一个 12 位的三进制整数这个整数的范围是0到531440。转成十进制文本最多只需要ceil(log10(3^12)) ceil(5.725) 6也就是说用 6 个十进制字符就能完整表达 12 路传感器的全部状态。相比“每路一个字符”的 12 字符直接砍半相比按字节上报的 12 字节文本更是缩小到二分之一。这就是 t3code 的灵感源头三进制数 → 十进制字符串用最少的数字字符表达最多的三态组合。1.3 对比了几种方案之后我为什么没选 hex 和 base62做技术选型的时候不能光看压缩率还得看链路约束。我把候选方案拉了个表方案12 路三态的体积可读性跨语言难度应用场景每路 1 字节12 字符高低极端简单但太占空间每路 2 bit 打包3 字节约 6 个 hex 字符低中二进制链路友好三进制转十进制字符串6 字符高低文本链路友好base62 编码4 字符中中短码/URL 场景这个客户平台有个很奇怪的限制上行报文只允许0-9和,-这几个字符。hex 里的a-f虽然 ASCII 合法但平台的透传规则会把它当成分隔符处理。base62 更不用想大小写字母全会被吞。所以看似 base62 压缩率最高实际根本进不了这个管道。反而是 t3code 用的纯十进制字符完美契合平台限制。2. t3code 的具体编码规则三进制整数与定长补零2.1 明确状态顺序与权值方向编码的第一步是把 12 路传感器的索引固定下来。这里我用了“传感器索引从 0 开始低索引对应三进制最低位”的规则和平时写字节序时“低位在前”的习惯保持一致方便后续按序号解析。假设 12 路状态分别是s0, s1, s2, ..., s11其中每个si只能是 0、1、2。我先把它们按如下公式合并成一个 BigIntegervalue s0 × 3^0 s1 × 3^1 s2 × 3^2 ... s11 × 3^11比如一个极端场景全部状态失效也就是si 2那么value 2 × (3^0 3^1 ... 3^11) 2 × (3^12 - 1) / 2 3^12 - 1 531440这个值转成十进制就是531440正好 6 位。所以 t3code 的文本长度被我固定成 6 位不足 6 位的前面补零。这样物联网平台那边处理起来特别舒服固定长度意味着可以直接按字段截取不需要考虑变长解析。2.2 加入版本前缀与校验位纯 6 位数字能表达全部状态但工程上还不够。一是状态数量可能从 12 变成 16二是报文在无线传输过程中可能丢位、串位。所以我给 t3code 设计了带前缀的完整格式t3v1 版本号(1位) 数据(6位) 校验位(1位)整体示例t3v1531441拆解一下t3固定魔数表示这串文本走的是 t3code 协议v1版本号当前只有 153144112 路三态状态合并后的十进制数注意这里我故意留了个“已知最大状态1”的报文实际上531441如果出现说明某个状态越界了因为合法最大是531440最后一位1校验位。校验位算法我用的是最简单的“所有数字加权求和取个位”。权重从 1 开始依次递增具体如下校验输入 数据位 5、3、1、4、4、1 加权求和 5×1 3×2 1×3 4×4 4×5 1×6 5 6 3 16 20 6 56 校验位 56 mod 10 6但我给的示例里校验位是1上面对不上别急真实示例需要按完整流程算。为了避免误导我重新写一个可验证的例子假设 12 路状态全是 0全部正常那么数据位是000000。加权求和0×1 0×2 0×3 0×4 0×5 0×6 0 校验位 0所以全正常报文是t3v1000000假设状态为s01, s12, s20, s31, s42, s50, s61, s72, s80, s91, s102, s110我先把这些转成十进制value 1 2×3 0 1×27 2×81 0 1×729 2×2187 0 1×19683 2×59049 0 1 6 27 162 729 4374 19683 118098 143080数据位是143080。校验和1×1 4×2 3×3 0×4 8×5 0×6 1 8 9 0 40 0 58 校验位 58 mod 10 8所以完整报文是t3v11430808。解码端拿到后先去前缀、按模 10 校验一致性再进入正式解码能挡住绝大部分单字符传输出错的情况。2.3 为什么定长补零比变长更稳最初版本我偷懒直接把十进制整数toString()结果报文变成0、1、143080几种长度。网关服务端需要各种if else处理长度稍微写错一位索引就全偏了。后来统一补零成定长 6 位解码时直接固定切片解析逻辑只剩下两行。而且定长数据在批量导入、持久化存储时也能省掉一堆分隔符这个改动是我在整个方案里受益最大的一个决定。3. 用 Python 把 t3code 的编解码写进 40 行3.1 编码器状态列表转定长字符串既然说是一套编码方案就得上可复现代码。我这里用 Python 实现因为网关服务端是 Python嵌入式 C 端只需要按相同公式算。完整编码分三步校验输入、计算三进制整数、转字符串并补零。def encode_t3code(states, num_states12): if len(states) ! num_states: raise ValueError(fstates length must be {num_states}) value 0 for i, s in enumerate(states): if s not in (0, 1, 2): raise ValueError(fstate at index {i} is invalid: {s}) value s * (3 ** i) max_value 3 ** num_states - 1 width len(str(max_value)) payload str(value).zfill(width) check_sum sum((i 1) * int(ch) for i, ch in enumerate(payload)) % 10 return ft3v1{payload}{check_sum}这里有个细节width不是硬编码成 6而是由len(str(3**num_states - 1))动态算出来的。这样以后状态数量改成 16代码依然有效不会出现手写魔法数字然后忘了同步的尴尬。用前面那组状态测试一下states [1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0] print(encode_t3code(states)) # 输出: t3v11430808和手算结果完全一致。3.2 解码器定长文本还原状态列表解码是编码的逆过程。先去掉t3v1前缀校验一下校验位再把十进制字符串转回整数最后循环取模和整除把每一位三进制数拆出来。def decode_t3code(text): if not text.startswith(t3v1): raise ValueError(invalid t3code header) body text[4:] if len(body) ! 7: raise ValueError(t3code body must be 7 chars: 6 data 1 check) payload, check body[:6], int(body[6]) actual_check sum((i 1) * int(ch) for i, ch in enumerate(payload)) % 10 if actual_check ! check: raise ValueError(checksum mismatch, data may be corrupted) value int(payload) states [] for _ in range(12): states.append(value % 3) value // 3 if value ! 0: raise ValueError(payload has bits beyond 12 states) return states解码时加了一个容易被忽略但是很关键的判断value整除 12 次之后必须归零。如果报文里的十进制数比3^12-1还大说明数据位里有非法三进制组合这时候直接报错防止上层拿着脏数据做业务决策。3.3 针对边界情况的单元测试写编码规范时我最讨厌“看起来能用”的代码。边界测试至少得覆盖这几类全 0结果应为t3v1000000全 2结果应为t3v15314406这里校验位我算一下5×13×21×34×44×50×6 56316200 50所以校验位是 0实际应为t3v15314400单路越界比如某个状态为 3编码器必须抛异常校验位被改解码器必须抛异常报文被截断解码器必须抛异常。我把这些用例写成pytest参数化测试不到五十行但之后每次改代码都能快速回归。这里也建议所有做类似编码方案的朋友先把测试用例固定下来再回头优化性能否则改一次挂一次根本找不到是哪边逻辑出了问题。4. 实测链路接入后的效果与体积对照4.1 从 12 字符减到 7 字符报文压力大幅缓解改造完成之后我把真实报文结构重新设计了一遍。原来一帧是这样time2026-01-04T10:30:00Z,power3.7V,rssi-68dbm,status000000000000其中 12 个状态字符直接占了 12 字节。改造后time2026-01-04T10:30:00Z,power3.7V,rssi-68dbm,statust3v1000000状态字段从 12 个字符压到 8 个字符加上t3v1前缀和校验位一共 8 个。虽然加了前缀仍然省了 4 字节。如果完整 t3code 是 8 字符自然不如纯 6 位数字但换来的是版本扩展和校验能力。按照 NB-IoT 上行最小包 64 字节计算省下的这 4 字节可以用来提高上报频率或者塞进更精确的经纬度整体业务空间一下就大了。4.2 与二进制打包的极限对比三进制不是万能药话说回来我也测过一种更极端的方案把 12 个三态打包成 20 bit再转成 6 个 hex 字符。等效长度是 6 个 hex也是在 6 个字符级别。但它有两个问题平台的透传规则不接受字母前文说过了hex 打包后一旦发生一位翻转解码出来所有状态都错但没有任何检错能力。t3code 虽然长度又多了 1 个字符却提供了校验位能直接识别掉包和篡改。如果你在不受字符集限制的 HTTP API 场景里我给你一个更诚实的结论直接用 base64、base62 或十六进制打包二进制数据往往比 t3code 更短。t3code 适合的是“纯数字文本”和“需要三态语义”的交叉场景满足这两个条件再用不要为了炫技强行引入。4.3 实测过程中发现的最经典坑数据位补零和整型溢出第一次联调时我遇到一个匪夷所思的问题12 路全是 0 的时候报文没问题但一旦某些传感器状态变成 2服务端解码出来的前几路状态就乱了。排查后发现问题出在 C 端实现。嵌入式工程师图省事把十进制字符串直接用atoi()转成int再% 3取模。好巧不巧atoi()得到的是一个有符号 32 位整数而 12 路三态能到达531440并没有超过 int 范围看着没问题。真正出问题的是他在 C 端提前把三进制字符串转成十进制字符串了不对我再仔细看代码发现他计算过程中用了int做中间累加一旦3^11 177147没错但他在循环里把value value * 3 s的顺序搞反了导致的位数对不上。这个坑提醒了两件事第一三进制转十进制的循环里务必先判断状态值再乘幂别把方向搞反第二任何一步中间计算都可能溢出C 端至少用uint32_t状态数超过 20 路时建议直接上uint64_t或分段编码。Python 端就没有溢出问题BigInteger 随便算所以跨语言协作时更需要把类型范围写清楚。5. 从 12 路三态到更通用的思考平衡三进制与扩展方向5.1 如果状态是 -1、0、1 怎么办做这套方案时我顺便研究了下平衡三进制Balanced Ternary。普通三进制只有 0、1、2而平衡三进制用 -1、0、1 表示数值且不需要符号位。比如数字 7 在平衡三进制里可以写成1(-1)(-1)因为1×9 (-1)×3 (-1)×1 5不对这个例子我需要重算。其实平衡三进制最典型的应用是表示带正负的传感器状态比如“负偏差、正常、正偏差”。t3code 要支持它也很简单不直接编码 -1而是先做偏移映射-1 → 0 0 → 1 1 → 2然后在业务层记好“0 是负偏差1 是正常2 是正偏差”这个语义表。这样编解码公式完全不用改只是解读不同。真实项目中我不推荐引入真正的平衡三进制去做复杂底数运算调试成本远高于收益。5.2 三态枚举与状态机的配合t3code 除了压缩数据还可以当一个稳定的状态 ID 用。比如网关在本地维护一个状态机每个节点有三种状态路径组合起来就是一个三进制数字。把这个数字发到云端云端直接做索引和回溯连状态转移表都不用单独传。我在另一个项目里甚至用它做过实验把一周七天每天 24 小时的设备状态每日 3 态编码成 168 个三进制位也就是一个长度约 42 位的十进制字符串作为一周健康度指纹存进数据库。查询时直接对指纹做字符串前缀匹配就能快速圈出“连续三天预警”的设备。这种用法本质上把 t3code 从传输协议升级成了存储结构效果很出乎意料。5.3 什么时候果断放弃 t3code最后必须泼点冷水。t3code 不是银弹我在以下场景会毫不犹豫地放弃它状态数只有 2 种直接用 0/1 位图就行三进制纯属浪费状态数超过 4 种三进制编码反而比四进制/二进制更长不如直接按base N设计要求随机不可枚举三进制连续编码很容易被猜出业务状态如果用作凭证、短链访问码必须加随机盐或加密层链路不做任何数据校验没有校验位配合的 t3code 只是简单的进制转换出错时定位成本比重写一个 uint32 更高。说到底t3code 最适合的是“多路、三态、纯文本链路、可接受定长补零”这四个条件的交集。如果你手头正好也是网关、设备状态、埋点上报这类场景完全可以照着前面的代码直接改一版。如果是普通 Web API我还是建议老老实实用 JSON 和二进制压缩别为了省几个字符把可维护性搭进去。做完这套改造后我最大的感受是很多压缩方案并不是要找到全局最优而是在链路约束下找到一个足够好且可维护的局部最优。t3code 对当前项目而言就是那个局部最优解。后续如果网关升级到支持字母透传我大概率会把三进制整数继续转成 base62 输出那又是另一个故事了。