前阵子给一批环境监测网关做低功耗改造,遇到一个挺刁钻的需求:每块网关下面挂着 12 路传感器,每一路只需要汇报“正常/预警/失效”三种状态。按照常规做法,每个传感器用一个字节,一帧报文光装状态就要 12 个字节,但客户那边的 NB-IoT 平台要求整帧不超过 64 字节,还要塞时间戳、电量、信号强度和位置信息。抠来抠去,最后我把目光盯在这 12 个三态数值上,顺手搞出了一套代号叫t3code的三进制编码规则。这篇就把完整的设计思路、编解码实现以及实测中踩的坑都梳理出来。
1. t3code 不是心血来潮:三态数据压缩的真实场景
1.1 一开始我也想过“直接打包 bit”的常规路线
先说背景。这 12 路传感器里,有的是门窗磁,有的是漏水绳,有的是温湿度越限报警。真正落到数据模型上,每一路的状态集合都是同样的三元组:0表示正常、1表示预警、2表示失效。没错,我刻意没用布尔值,因为“预警”和“失效”完全是两个量级的事情,不能混成一个false。
常规想法是:一路状态用一个uint8_t,12 路就是 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。我先把它们按如下公式合并成一个 BigInteger:
value = 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:版本号,当前只有 1;531441:12 路三态状态合并后的十进制数;注意这里我故意留了个“已知最大状态+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假设状态为:s0=1, s1=2, s2=0, s3=1, s4=2, s5=0, s6=1, s7=2, s8=0, s9=1, s10=2, s11=0,我先把这些转成十进制:
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_states=12): if len(states) != num_states: raise ValueError(f"states length must be {num_states}") value = 0 for i, s in enumerate(states): if s not in (0, 1, 2): raise ValueError(f"state 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 f"t3v1{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×1+3×2+1×3+4×4+4×5+0×6 = 5+6+3+16+20+0 = 50,所以校验位是 0,实际应为t3v15314400; - 单路越界:比如某个状态为 3,编码器必须抛异常;
- 校验位被改:解码器必须抛异常;
- 报文被截断:解码器必须抛异常。
我把这些用例写成pytest参数化测试,不到五十行,但之后每次改代码都能快速回归。这里也建议所有做类似编码方案的朋友,先把测试用例固定下来,再回头优化性能,否则改一次挂一次,根本找不到是哪边逻辑出了问题。
4. 实测链路接入后的效果与体积对照
4.1 从 12 字符减到 7 字符,报文压力大幅缓解
改造完成之后,我把真实报文结构重新设计了一遍。原来一帧是这样:
time=2026-01-04T10:30:00Z,power=3.7V,rssi=-68dbm,status=000000000000其中 12 个状态字符直接占了 12 字节。改造后:
time=2026-01-04T10:30:00Z,power=3.7V,rssi=-68dbm,status=t3v1000000状态字段从 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 输出,那又是另一个故事了。