跳到主要内容

从分析到实现:我用 Rust 造了一个"小程序码"——RCode 诞生记

· 阅读需 16 分钟
Arce
独立开发者

两年前我写过一篇《小程序码生成技术分析》。那篇文章搞清楚了微信小程序码的大部分秘密:射线状的数据分布、与 QR 码一致的 RS 纠错、32 种掩码。但也留下了不少"目测"、"疑似"、"巧合发现"——彼时的我对着一张张截图数像素,能走到那一步已经是极限。

两年后,借助 AI 把每个模糊的环节都掰开揉碎讲透,我终于把这些推测变成了可以运行、可以验证的代码。这篇文章记录的,就是 RCode ——一个用 Rust 写的、开放的放射状码系统——从零到诞生的过程。

先看成品。下面这张码用 RCode 生成,内容是一句话,中央嵌入了本站的 Logo:

带 Logo 的 RCode 成品

一、两年前留下的问号​

回顾 2024 年那篇分析文章,当时的结论大致是这些:

搞清楚了的部分:

  • 小程序码有 36 / 54 / 72 线三个版本,数据点从内向外、逐线顺时针映射;
  • 元数据区域的位置在三个版本中一致,解码时可以在不知道版本的情况下先读到它;
  • 纠错编码与 QR 码完全一致,是 GF(256) 上的 RS 码;
  • 数据最后会与 32 种掩码之一做异或。

没搞清楚的部分:

  • 微信码的 payload 经过了 Base82 -> Base256 -> Base45 的转换,最终拿到的看起来是加密数据——内部编码无从得知,只能止步于此;
  • 32 种掩码的具体生成规则未知,只能逐一试错;
  • "数据点与比特的映射关系"靠目测归纳,没有源码级的验证。

这就是逆向分析的天花板:你能知道它是怎么运作的,但拿不到它的全部规格,更不能把它变成自己的东西。 想拥有一个"菊花码",在当时只剩下等微信开放 API 这一条路。

二、AI 时代重新拆解这个技术​

两年后我重新翻出这篇文章,换了一种问法:与其继续抠微信的私有协议,不如把已验证的那套思想拿过来,设计一个完全属于自己的放射状码制。

这一次,AI 帮我把每个环节都变成了精确的规格:

  • RS 纠错不再是"和 QR 码一致"这句话,而是明确的 GF(256)、生成多项式 0x11d、码字长度、校验字节数、可纠错字节数 t = ⌈n×p⌉ 这些可以写进代码的数字;
  • 掩码不再需要试错——自己设计的码制里,掩码可以是一个确定性的伪随机函数,任何人(包括 AI)用任何语言都能逐位复现;
  • 几何映射不再靠目测,而是 128×128 逻辑画布上一个可以推导、可以画图验证的坐标公式。

把微信码的公开设计资料和 QR 码的标准拼在一起,一个完整的编码流水线就浮现出来了:

这条流水线就是 RCode 的骨架。剩下的工作,是把它变成一个正经的软件。

三、RCode 是什么,不是什么​

先划清边界:RCode 是自建协议,不兼容微信小程序码,也不能被标准 QR 扫码器识别。 这不是技术缺陷,而是刻意的设计决策——复刻微信码有法律风险,而且微信码的 payload 是加密的,复刻了外壳也装不进自己的内容。RCode 的目标是"开放":任何人都可以按照公开的协议文档实现自己的编解码器。

设计目标取值
几何版本36 / 54 / 72 线,解码自动识别
纠错档位L / M / Q / H(目标 10% / 15% / 25% / 35%)
单码容量13 ~ 124 字节
输出格式PNG / SVG,384 ~ 2048 px
视觉样式放射线条(lines)/ 圆点(dots)
解码容错旋转、等比缩放、平移、轻度噪声、适度透视
技术栈纯 Rust,无 Node、无数据库、无外部服务

为什么坚持用纯 Rust + 零外部服务?因为我希望它能一路走到手机上——后面会讲到,FFI 层已经打通了 Android / Apple / Flutter 三端。

四、协议:一张码里到底存了什么​

这是全文最核心的一节。RCode v3 的字节帧布局如下:

偏移 长度 内容
0 4 ASCII "RADC" 魔数
4 1 格式版本(当前为 3)
5 1 线数(36 / 54 / 72)
6 1 纠错等级(L=0, M=1, Q=2, H=3)
7 1 payload 实际字节数
8 capacity payload(不足补 0x00)
8+cap 4 前面全部帧字节的 CRC32(大端)

对应的编码实现只有十几行(节选自 src/codec.rs):

const MAGIC: [u8; 4] = *b"RADC";
const VERSION: u8 = 3;

pub(crate) fn encode(payload: &[u8], profile: Profile) -> Result<Vec<u8>> {
let capacity = profile.capacity();
if payload.len() > capacity {
return Err(Error::InvalidOptions(format!(
"{} 线 {} 档最多 {} 字节,当前 {} 字节",
profile.spokes(), profile.level, capacity, payload.len()
)));
}
let mut frame = vec![0; profile.codeword_len() - profile.parity_bytes()];
frame[..4].copy_from_slice(&MAGIC);
frame[4] = VERSION;
frame[5] = profile.spokes() as u8;
frame[6] = profile.level.id();
frame[7] = payload.len() as u8;
frame[8..8 + payload.len()].copy_from_slice(payload);
let end = frame.len() - 4;
let crc = crc32fast::hash(&frame[..end]);
frame[end..].copy_from_slice(&crc.to_be_bytes());
Ok(Encoder::new(profile.parity_bytes()).encode(&frame).to_vec())
}

几个设计上的取舍值得展开:

魔数为什么是 "RADC"? Radial Code(放射状码)的缩写。解码时它是一张快速身份证:RS 纠错完成后先看前四个字节,不对就直接拒绝,CRC32 再兜底一次。数据真伪的两道关。

线数和纠错档为什么写进帧里? 因为解码端不知道手里的码是哪个版本。这两个字段本身也受 RS 码保护,所以在图像轻度污损的情况下依然能可靠地读出来——这正是当年微信码"元数据区域在三个版本中位置一致"要解决的问题,RCode 用更直接的方式(把元数据放进受纠错保护的帧头)绕开了它。

容量表。 3 个线数版本 × 4 个纠错档,共 12 种组合("容量/校验字节"):

线数码字LMQH
368757/1847/2831/4413/62
5413092/2678/4052/6626/92
72172124/36108/5274/8638/122

有一个容易误解的点:纠错比例的分母是含校验的整个码字,不代表图像上可以遮挡的面积。微信当年宣传"中间 Logo 区不占数据位所以容错更高",RCode 用同样的思路——中央 Logo 区从几何上就不参与数据映射。

掩码。 当年分析微信码时,32 种掩码的具体生成方式是个黑盒。自己设计就简单了——一个无状态的整数扰动函数,第 i 个槽位的掩码位由 i 唯一决定,任何语言的实现都能逐位对上:

/// 第 index 个槽位的掩码位。
/// 从 (index + 0x9e3779b9)(32 位回绕)出发,交替做位移和乘法扰动,
/// 取最低位。刻意设计成无状态,方便任何实现复现。
pub fn mask(index: usize) -> bool {
let mut x = (index as u32).wrapping_add(0x9e37_79b9);
x ^= x >> 16;
x = x.wrapping_mul(0x7feb_352d);
x ^= x >> 15;
x = x.wrapping_mul(0x846c_a68b);
x ^= x >> 16;
x & 1 != 0
}

0x9e3779b9 是 TEA 加密里的黄金分割常数,加上两轮 xorshift-multiply,输出足够"随机"又不依赖任何运行时状态。比起微信的 32 个可选掩码,RCode 更进一步:只有一个掩码,但它对每个槽位都不同,同样能打散数据点、避免出现大片同色。

五、几何与渲染:让花瓣"绽放"​

协议解决的是"存什么",几何解决的是"画在哪"。RCode 的一切坐标都定义在 128×128 的逻辑画布上:36 线版本产出 696 个槽位,54 线 1040 个,72 线 1380 个——当年数出来的"304、416、528 个数据点",在这里以更大的数据密度重新实现。

四个完全相同的定位点(外圆 5.2 / 白圈 3.5 / 圆心 1.1)分布在码盘的四角,既是解码的锚点,也是"放射状码"最重要的视觉特征。内圈还有一圈不承载数据的轮廓填充,让每条放射线的可见内缘整齐地收在半径 22 处,Logo 区域裁剪在半径 20 以内——这就是菊花码"花瓣"轮廓感的来源。

同一套协议支持两种渲染样式,这是当年微信码没有的自由度:

72 线 M 档,线条样式

72 线 H 档,圆点样式

三个几何版本的差异也一目了然——同样的内容,36 线装得最松、花瓣最疏朗,72 线则密得多。下面两张分别是 36 线 L 档和 54 线 Q 档:

36 线 L 档

54 线 Q 档

所有渲染参数 PNG 与 SVG 共用一份,保证两种输出像素级同构。SVG 的好处是任意缩放不糊,PNG 的好处是移动端直接可用。

六、解码:猜也要猜出来​

编码是顺流而下,解码是逆流而上,而且多了一个大问题:图像里没有任何标注告诉我们这是 36、54 还是 72 线,是 L 还是 Q 档。

RCode 的解法朴素而有效——有限枚举。3 个线数 × 4 个纠错档 = 12 种组合,逐一尝试,直到某一组的 RS 纠错 + 魔数 + CRC 全部通过为止:

整套解码对用户完全透明——rcode decode code.png 不需要任何参数,12 种组合全部在毫秒级试完。"猜"在这里不是偷懒,而是把组合空间做得足够小,让暴力枚举成为最优解。

解码链路上的几个关键步骤:

  1. 定位点检测:扫描二值图上符合"实心圆—白环—实心圆"比例特征的图案,聚成候选簇;
  2. 单应变换:四个定位点(左上、右上、右下、左下)求解透视变换,把码盘"拉正",此后所有采样都在归一化坐标系里进行——这是旋转、缩放、平移、透视容错能力的全部来源;
  3. 槽位采样:按几何公式算出 1380 个槽位的坐标,双线性插值采样,黑为 1 白为 0;
  4. 去掩码、RS 纠错、帧校验:与编码完全对称。

实测效果:200 组固定种子的随机旋转 + 缩放测试全部通过,轻度噪声和适度透视变形也能正确解码。

七、工程化:从玩具到系统​

协议跑通只是开始。仓库里真正花时间的是下面这些:

本地网页工作台。 rcode serve 启动一个只监听 127.0.0.1:8787 的本地服务,提供网页版编码/解码工作台。图片和内容只在内存里流转,不持久化、不上传第三方。HTTP API 长这样:

# 编码
curl -X POST http://127.0.0.1:8787/api/encode \
-H 'Content-Type: application/json' \
-d '{"text": "Hello RCode", "spokes": 72, "ecc": "M"}'

# 解码(请求体就是原始图片字节)
curl -X POST http://127.0.0.1:8787/api/decode \
-H 'Content-Type: image/png' \
--data-binary @code.png

# 查询全部 12 种档位的容量(由 Rust 核心计算,网页端不重复维护容量表)
curl http://127.0.0.1:8787/api/profiles

移动端。 通过 ffi/ 暴露稳定 C ABI,scripts/build-mobile-core.sh 一条脚本产出 Android 双 ABI 动态库和 Apple XCFramework,再包一层就是 Flutter 插件。同一份 Rust 核心,四端共享。

测试策略。 这是我最满意的部分——协议类项目的测试不能只测"自己的实现对不对",还要防"实现悄悄变了":

  • 协议固定向量:把 (payload, profile) → (码字、图像) 的映射固化成测试向量,任何重构都必须产出逐字节相同的结果;
  • 纠错边界:恰好纠 1 / 16 / 32 字节的三个用例,卡住 RS 参数;
  • CRC 拒绝:篡改任意字节必须被拒绝;
  • 200 组固定种子的旋转缩放测试卡住解码的几何容错;
  • CI 上 fmt、clippy(-D warnings)、测试、release 构建四道闸门,缺一不可。

八、放在那张对比表里​

两年前的文章末尾有一张主流异形码对比表,现在可以把 RCode 自己加进去了:

名称码眼码点分布版本掩码纠错码编码内容
微信小程序码3 同心圆 + 圆形 Logo射线状36/54/72 线32RS(GF256 0x11d)加密信息
抖音码3 同心圆 + 圆形 Logo完整环形4/5/6 环8RS(GF256 0x11d)完整 URL
鸿蒙码3 同心圆 + 小同心圆双半圆环绕4/5/8/10/11/13 环8RS(GF256 0x11d)协议 + 识别码
RCode4 同心圆定位点射线状36/54/72 线逐槽位伪随机RS(GF256 0x11d)+ CRC32任意字节,完全开放

和这些商业码相比,RCode 的差异点不在"更好看",而在开放:协议文档、固定测试向量、Rust 核心与三端 SDK 绑定全部公开。它的容量(72 线 L 档 124 字节)也远超微信小程序码公开设计资料的 11~43 字节。

九、踩坑与收获​

最后记几条只有亲手做才会遇到的事:

  1. "看懂"和"做出"之间隔着一条鸿沟。 两年前我以为自己理解了射线映射——直到亲手写几何代码才发现,槽位坐标、定位点保留区、内圈轮廓三者会互相"抢地盘",任何一个参数改动都要重新全局验证。目测归纳的规律,在实现时必须变成可推导的公式。
  2. AI 最好的用法不是"替我写",而是"陪我算"。 RS 码的参数表、GF(256) 的生成多项式、单应矩阵的求解顺序,AI 一遍遍演算、我一遍遍用测试向量验证,把"它说的"变成"测试证明的",这个循环比任何一次性的代码生成都值钱。
  3. 确定性是协议类项目的一切。 掩码函数必须无状态,渲染参数必须 PNG/SVG 共用,测试向量必须逐字节固定。凡是引入"这次跑和下次跑不一样"可能性的设计,最后都改掉了。
  4. 诚实的边界比花哨的功能重要。 RCode 明确不做加密、不做签名、明确声明不兼容微信码——一个码制系统最怕的就是让用户误以为它能防伪。README 里把这几点写在最显眼的位置。

RCode 目前还在活跃开发中(下一步是摄像头实时解码的移动端体验优化),欢迎 Star、提 Issue,或者按协议文档自己撸一个其他语言的实现——毕竟,一个"开放"的码制,最好的检验就是有第二个人能独立实现它。

参考资料​