2026-01-31 07:00:00
月底升级了 Copilot Pro+,月初额度重置,这几天可以放开用,想到什么就 vibe 一把。
我的博客跑在 Hexo 上很多年了。其实没什么大问题,就是每次看到那几百 MB 的 node_modules,心里总有点膈应——生成几百个静态 HTML,真的需要这么多依赖吗?但迁移到别的博客系统又懒得折腾,所以一直拖着。
这次干脆试试:能不能用 AI 一个下午撸一个 Rust 版的 Hexo?我的目标比较简单:生成跟原来一样的静态文件,兼容我现在用的主题就行。
我用的是 OpenCode + Opus 4.5。陆陆续续聊了一下午,产出了 hexo-rs。能用,但还有些边边角角的问题。
Vibe Coding 的工具和体会以后再写,这篇主要聊 hexo-rs 的实现和踩过的坑。
Hexo 主题基本都用 EJS 模板——就是把 JavaScript 嵌到 HTML 里,跟 PHP 差不多。
用 QuickJS 跑 JS,通过 quick-js crate 调用。好处是不用依赖 Node.js,坏处是 Windows 上编不过(libquickjs-sys 挂了),所以暂时只支持 Linux 和 macOS。
Markdown 用 pulldown-cmark,代码高亮用 syntect,本地服务器用 axum。都是常规选择,没什么特别的。
这个 bug 藏得很深。生成 tag 和 category 页面时,一开始用 HashMap 存文章分组:
let mut tags: HashMap<String, Vec<&Post>> = HashMap::new();
HashMap 迭代顺序不确定,每次生成的 HTML 可能不一样。页面看着没问题,但 diff 一下就发现乱了。改成 BTreeMap 就好了:
let mut tags: BTreeMap<String, Vec<&Post>> = BTreeMap::new();
Hexo 有一堆 helper 函数:url_for、css、js、date 之类的。都得在 Rust 里实现一遍,然后塞进 QuickJS。
最烦的是 date。Hexo 用 Moment.js 的格式(YYYY-MM-DD),Rust 的 chrono 用 strftime(%Y-%m-%d)。得写个转换函数,挺无聊的活。
EJS 的 partial 可以套娃,A 引用 B,B 又引用 C,变量还得一层层传下去。搞了个作用域栈,进 partial 压栈,出来弹栈。不难,但容易写错。
代码 100% 是 AI 写的。我干的事:描述需求、review 代码、把报错贴给它让它改、偶尔拍板选方案。
像 EJS 模板引擎这种东西,自己从头写估计得半天,AI 几分钟就吐出来了。
但 AI 也挺蠢的:
但 AI 又确实非常强,我想到应该使用现在线上的 catcoding.me 来和新生成的内容一一对比,然后它就呼啦啦地一通操作把问题都找出来了,自己修改完。
cargo binstall hexo-rs # 或 cargo install hexo-rs
hexo-rs generate # 生成静态文件
hexo-rs server # 本地预览
hexo-rs clean # 清理
hexo-rs new "标题"
不支持 Hexo 插件,不支持 Stylus 编译(.styl 文件得先用 Node 编译好),Windows 也不行。
简单的博客应该够用。复杂主题可能会有兼容问题。
代码在这:github.com/chenyukang/hexo-rs
用 Hexo 的可以试试。有问题提 issue,我让 AI 来修 :)
这篇文章到底是人写的,还是 AI 写的?
2025-12-10 01:05:59
I just got back from CKCon in beautiful Chiang Mai 🌴, where I gave a talk on the Fiber Network. To help everyone wrap their heads around how Fiber (CKB’s Lightning Network) actually moves assets, I hacked a visual simulation with AI.
To my surprise, people didn’t just understand it—they loved it! 🎉
Here is the “too long; didn’t read” version. But first, go ahead and play with the dots yourself, 👉 Play the Simulation: fiber-simulation

We all love Layer 1 blockchains like Bitcoin or CKB for their security, but let’s be honest: they aren’t exactly built for speed.
Every transaction has to be shouted out to the entire world and written down by thousands of nodes. On CKB, you’re waiting about 8 seconds for a block; on Bitcoin, it’s 10 minutes! Plus, the fees can get nasty if you’re just trying to buy a coffee. ☕️
So, how do we fix this?
The Lightning Network is a scalable, low-fee, and instant micro-payment solution for P2P payments.
The secret sauce isn’t actually new. Even Satoshi Nakamoto hinted at this “high-frequency” magic in an early email:
Intermediate transactions do not need to be broadcast. Only the final outcome gets recorded by the network.
A Lightning Network consists of Peers and Channels. A peer can send, receive, or forward a payment. A Channel is used for communication between two Peers.

Imagine you and a friend want to trade money back and forth quickly:
Everything in the middle? That’s off-chain magic. ✨
Now, if Fiber was just about paying your direct neighbor, it would be boring. The real power comes from the Network.

This means Alice can pay Bob even if they don’t have a direct channel between them. The payment can travel through one or more intermediate nodes. As long as there is a path with enough liquidity, the payment will reach its destination instantly.
All data is wrapped in Onion Packets (yes, like layers of an onion). The nodes in the middle serve as couriers, but they are blindfolded:
They simply follow a basic rule: they forward the Hash Time Lock, and if the payment succeeds, they earn a tiny fee for their trouble. Easy peasy.
The “Not So Easy” Part 😅
While the idea is simple, building it is… well, an engineering adventure. We’re dealing with cryptography, heavy concurrency, routing algorithms, and a whole jungle of edge cases. But hey, that’s what makes it fun!
We’ve poured the last two years into building Fiber, and I’m proud to say it’s finally GA ready.
If you want to geek out on the details, check these out:
Here is the full presentation from my talk: CKB Fiber Network Engineering Updates
2025-11-26 18:15:38
越来越多的开发者开始使用 LLM 等 AI 工具,过去半年我看到不少相关讨论:有人非常反感使用 LLM 工具,有人保持中立,但确实有相当数量的 AI 生成 PR 给开源项目维护者带来了负担和困扰。
现在出现了一些新苗头,比如 GitHub 账号也开始“养号”。我推测大概有以下几个原因:
2023–2024 年类似的情况比较多,有些加密货币项目会根据开发者的 GitHub 公开提交记录进行空投。如果一个账号给项目方关注的项目提过 PR,就更容易获得。例如 Rust 在区块链领域用得比较多,所以 Rust compiler 项目是比较容易获得空投的。我自己也因为一些开源记录拿到过空投,当时兑换了 1 万多人民币。我看到不少 Rust 社区维护者也得到了空投,不过他们对加密货币普遍不感兴趣;也有个别人因此换到了不少钱,觉得很惊讶。有同事给以太坊提过几个 PR,他的空投价值大约 15 万人民币,因此还出现了有人收购 GitHub 空投资格的情况。
但我认为这只是短期现象。现在再为了空投去养号是否还有机会?我不敢确定。因为“养号”的特征很明显,其实很好自动识别。而且到了 2025 年类似空投已经很少了,即使有,也会要求复杂的钱包交互,不是币圈的人通常不会折腾这些。
很多开发人员都知道,一个拿得出手的 GitHub 账号应该会对找工作有帮助。但我对此保持怀疑,因为养出来的 GitHub 账号一眼就能看出,从面试官的角度,我认为加上一个这样的 GitHub 账号到简历里是减分项。
总之,如果只是为了以上两种目的去“养号”,我都建议停手,因为这通常是费时费力但得不偿失的事情。
另外一部分人是真的想参与开源项目,他们可能认为使用 LLM 能降低难度。
现在的 AI 工具确实比以前更强大了。你可以把一个 GitHub issue 给它,稍微写点 prompt,AI 就能自动生成 PR,甚至自动发 PR。但这种方式通常会忽略一些开源项目本身的贡献约定,从而导致 PR 一发出来维护者就知道这人肯定连 contribution guide 都没看过,这样就会直接关闭掉这个 PR,这有个典型的例子。
AI 的确比我原本想象的好用很多,我在日常开发中也会使用,但主要把它当成增强版搜索引擎或自动化工具。例如我会让 AI 帮我做一些自动化流程:我有一个 prompt 模板,只需要给一个 issue 号,LLM 就能帮我解析问题,把相关的 bug 重现代码放到测试目录,创建对应的 Git branch,尝试在本地重现问题,然后从 backtrace 定位可疑代码。这确实省了我不少时间。但这建立在我自己按这个流程做过很多遍,能找出一套比较稳定的方法。
正如我之前说的,如果你想用好 AI,你必须具备项目的 domain knowledge,才能判断 AI 有没有“骗你”。
在 Rust compiler 项目里,目前 LLM 生成的 PR 基本只有 typo fix 之类的会偶尔被接受。只要涉及稍微复杂一些的代码修改,一眼就能看出不是人写的。
如果真的想参与开源项目,最好的方式还是从项目中简单的 issues 开始。如果不懂就多问,多看文档和代码。每个人都是从新手阶段慢慢走过来的,维护者一般对真心想参与的贡献者会更有耐心。
即使用 LLM 生成代码,我们依然要逐行 review,确保正确、可维护、简洁。如果你丢一堆机器生成的代码,让 maintainer 帮你审核,这会引起极大的反感。
比如这位开发者,在 maintainer 审核后对代码发出质疑的时候也承认是 AI 写的代码:

建议大家可以去看看上面那个 PR 里的讨论,我觉得有些评论挺有价值。OCaml 的维护者 gasche 表达的观点很明确:
The fact that you were able to generate large amount of code that passes test is interesting, but that’s only 20% of the work, the other 80% are to get the feature discussed, reviewed and integrated, and this work will be paid by you and others. But you only focus on the initial writing phase and you personal success, over-communicate on this, and do not appear to realize that this has very real costs on others.
在多人协作的开源项目中,稍微复杂一点的功能,写代码其实只占很小一部分,更多的是协作与讨论。一个 PR 是否能 merge,还要考虑长期维护成本。
另外,LLM 生成的代码其实是非常容易检测的,比如现在就有类似的工具可以以比较高的准确度判断代码是否是 AI 写的: AI Code Detector by Span
还有一些开发者 (尤其是非英语母语者),他们可能对自己的英语不够自信,所以使用 LLM 来帮忙写 PR description 和 comments。有的开发者就是偷懒,认为 LLM 总结的即全面又好。但从维护者的角度来说,这是不友好的,因为 LLM 生成的内容过于冗长:
The comments left by you are significantly too verbose. While being detailed is good, please be respectful of reviewer time and avoid verbose text that mostly doesn’t convey any useful content.
在 Rust maintainer channel 里也讨论过这点,看起来很多人是反感读 LLM 生成的东西的,大家期待的鲜活的人类讨论,而不是机器生成的文字。
其实英语稍微差点的开发者,只要写的内容不是过于离谱,其他开发者也能理解,不用太在意 typo 之类的错误,因为人的大脑纠正的功能过于强大。后来我在 rustc-dev-guide 上加了这么一段:
If you’re not a native English speaker and feel unsure about writing, try using a translator to help. But avoid using LLM tools that generate long, complex words. In daily teamwork, simple and clear words are best for easy understanding. Even small typos or grammar mistakes can make you seem more human, and people connect better with humans.
AI 工具在开源项目中的过度尝试,只会让更多人反感,比如 zig 项目明确表明:
No LLMs for issues. No LLMs for patches / pull requests. No LLMs for comments on the bug tracker, including translation.
我不知道未来会怎样。也许 AI 工具最终会更智能。但至少现在,它还处于一个尴尬的中间地带:用得好能帮你节省时间,用不好反而不如不用。
2025-11-20 15:07:23
这两天都在讨论 Cloudflare 的安全事故 Cloudflare outage on November 18, 2025,我也写点自己的想法。
这个事故当然引起的范围特别广,我当时正在用 ChatGPT,突然再打开总是提示正在加载,我还以为是自己的 VPN 出了问题,第二天起来才知道 Cloudflare 跪了好久。
没多久 Cloudflare 就发出来了一个非常详细的事故说明。我对里面的场景非常熟悉,因为我之前因为类似的原因把大疆的大部分流量都给搞挂了,具体请看谈谈工作中的犯错中的配置错误。
这次事故里 Cloudflare 给出了一段 Rust 代码,所以讨论自然会集中在 Rust 上。但把事故归咎于 Rust 本身就不太合理。从他们的场景来看和我之前在 Kong 上做流量分发是非常类似的,无非是这里他们使用了机器学习的技术来判断一个流量是否为恶意请求,而文中所说的 features 文件是训练好的模型数据。
根本原因是数据库的权限更改,导致查询出来的 features 是有重复的,size 变成期望的两倍。而这个错误的配置通过自动同步机制会同步到全球各个节点。每个节点会有一个 bot 模块,根据 features 去计算是否拦截请求,可以想象这是个典型的机器学习分类问题,比如带有什么特征的 HTTP agent、或者是请求的 payload 之类的这些特征综合考虑来计算。这个 Bot Management具体内容可以参考其产品说明。
那么如果 features 坏了,这个机器学习模块 bot 能否正常工作?答案是不行的,这点文章已经说明:
Both versions were affected by the issue, although the impact observed was different.
Customers deployed on the new FL2 proxy engine, observed HTTP 5xx errors. Customers on our old proxy engine, known as FL, did not see errors, but bot scores were not generated correctly, resulting in all traffic receiving a bot score of zero. Customers that had rules deployed to block bots would have seen large numbers of false positives.
事故发生的时候新老组件都有同时在运行,两个组件在这种场景下都无法正常工作,只是错误呈现方式不同。这也解释了我当时用 ChatGPT 给出的浏览器错误是一个拦截错误。
所以这里,unwrap 其实已经算是整个错误的最后一环了。试想一下如果不 unwrap 无非是这几种场景:
可以看到这两种情形都差不多,甚至如果按照 fail fast 的策略,日志中会有明显的 500 错误,我不知道 Cloudflare 是否做了错误监控,因为按理来说这种级别的错误是非常明显的,需要立即报警。
很多人都集中讨论在这里的 unwrap:

当然这不是最佳实践,但这时候即使使用 .expect("invalid bots input") 这样的写法也好不到哪里去,同样会 500 错误,只是日志里面多留一条错误信息。因为如果不监控错误码,是没人立即发现问题所在的。
更好的做法是对输入进行严格校验,例如检查特征数量和大小。如果不符合预期,应保留旧配置并拒绝加载新数据,而不是加载到一半才发现尺寸异常,更不应该没有 fallback 机制。
当然这里代码没有完全开源,我们从短短的代码片段无法了解整个项目的场景。
从这个经典的错误我们应该发现的是更高维度的警戒,开发管理和运维上有这些问题:
Rust 过去天天宣传“一旦学会 rust,即便是新手也能写出健壮安全的代码”,而真的出现问题了,又开始指责写代码的人是菜鸟。
Cloudflare Rewrote Their Core in Rust, Then Half of the Internet Went Down
这里有点混淆视听,因为 Rust 所说的要解决的安全问题是内存问题,不是逻辑问题。另外,也不是因为重写导致的问题发生。
为什么 Cloudflare 要用 Rust 重写一些关键组件,可以看看他们之前的文章 Incident report on memory leak caused by Cloudflare parser bug
当然我承认在有的公司,可能有的团队完全是为了绩效或者纯个人偏好而发起重写老组件的项目。而更多公司确实是被内存安全问题折磨得怀疑人生才会去重写,像上面文中所说的安全事故是底裤被人扒了,自己还不知道,得让旁观者告诉你才发现。和这次事故的因为工程管理上所做成的安全事故有明显的分别。所以 Rust 所说的安全,是如何避免内存安全。
甚至即使是用了 Rust,一些内存上的问题还是可能因为逻辑上的错误而出现,比如我这个工作中的 PR Avoid duplicated retryable tasks就是避免往队列里加了重复的 task 而造成内存用得越来越多。
这次 Cloudflare 的事故就比如一个司机驾驶沃尔沃,结果碰上了山体滑坡被压死了,这种场景下就是换成任意其他品牌的车都会是一个结果。但如果你跑来说,看吧,沃尔沃号称安全,结果还不是一样死,这叫做虚假宣传。
这不叫虚假宣传,而是你对车有了不切实际的幻想。沃尔沃确实不完美,但每个人都会有不同的选择偏好。正常人理解沃尔沃说的安全是大部分场景下、对比其他车会安全一点,而不是说买了沃尔沃就会长生不老了。
永远记住:No Silver Bullet。
总之,这次 Cloudflare 的事故虽然造成的影响挺大,但这个公司也确实足够公开透明,事故分析写得非常清晰,值得大家学习并反思自己组织上有没有类似的工程问题。
2025-09-27 00:49:47
无论是在聊 L2、隐私还是下一代 Web 技术,零知识证明都是经常会碰到的技术术语,听起来就像是科幻小说里的东西:向你证明我知道一个秘密,但绝不透露这个秘密本身,这简直是程序员的终极浪漫。
大多数人粗看都会觉得这东西是密码学博士们的专属玩具,我花了一段时间学习后,发现这条通往魔法世界的路似乎有迹可循,希望这篇入门介绍能帮助到更多这方向的学习者。
忘掉所有数学,我们先从一个故事开始——“阿里巴巴洞穴”,这是理解 ZKP 最经典的例子,最早由 Jean-Jacques Quisquater 等人于 1990 年在他们的论文《如何向你的孩子解释零知识协议》中发表。
想象一个环形洞穴,A、B 两个入口在前方,深处有一扇只有知道咒语才能打开的魔法门。Alice 知道咒语,现在,Alice 想向 Bob 证明她知道咒语,但又不想让 Bob 听到咒语是什么。

他们可以这样玩一个游戏:
承诺 (Commitment):Alice,作为证明者 (Prover),独自进入洞穴。然后可以随机从 A 口进,也可以从 B 口进。Bob 在洞外等着,但不知道 Alice 走了哪条路。
挑战 (Challenge):Bob 作为验证者 (Verifier),走到洞口然后随机喊出一个要求,比如:“从 B 通道出来!”
响应 (Response):Alice 听到要求后:
验证 (Verification):Bob 看到 Alice 确实从 B 通道出来了,他对 Alice 的信任度增加了一点。
为什么说“一点”呢?因为如果 Alice 不知道咒语,她仍然有 50% 的概率蒙对(比如 Alice 从 B 进去,Bob 恰好也喊了 B)。
但如果这个游戏重复 20 次,Alice 每次都能从 Bob 指定的出口出来,那 Alice 每次都蒙对的概率就只有$$\left(\frac{1}{2}\right)^{20}$$,也就是大约是百万分之一。这时候 Bob 就有十足的把握相信,Alice 确实知道那个咒语。
这个小游戏完美地展示了 ZKP 的三大特性:
另外我们可以看到一个重要的属性是,零知识证明并非数学意义上的证明,因为可能存在一个很小很小的概率,即可靠性误差 – 作弊的证明者能够骗过验证者,但实际实践中我们几乎可以忽略这个极小的概率。
还有另外一个比较简单的例子来说明零知识证明:

Alice 和 Bob 玩游戏看谁先找到 Wally,Alice 说她找到了,她想要证明自己已经得到了结果,但又不想透露更多信息给 Bob,所以她可以用一个几倍面积黑色的纸片遮住整个图画,然后把 Wally 位置那里在黑色纸片上打个小孔,这样 Bob 就可以看到 Wally,而不知道 Wally 在哪儿。注意这里为什么强调几倍面积的黑色纸片,如果是和原图相同大小的纸片,就可能暴露了 Wally 的大致方向和范围。
这个例子展示的 ZKP 另外一个特性是 Prover 通常是更耗费资源的 (从图片中找到 Wally 需要花费一定的时间),而 Verifier 通常能很快验证。这个特性才能让一些区块链项目利用 ZKP 把链上计算挪到链下计算,而链上只是做验证。
两个例子很棒,但代码怎么写?
我接触到的第一个协议叫 Schnorr 身份验证,它要证明的是:“我知道与公钥 h 对应的私钥 x,其中 h = g^x mod p”。这里的“咒语”就是 x,而那扇“魔法门”就是离散对数问题——从 g, h, p 反推出 x 极其困难。
这个协议的“交互式”版本,完美地复刻了洞穴里的“一来一回”:
k,计算 t = g^k mod p 发给 Verifier。这叫“承诺”。c,这叫“挑战”。c,计算 r = k - c*x mod (p-1) 并发回。这叫“响应”。g^r * h^c mod p 是不是等于 Prover 一开始给的 t。完整代码在iteractive_schnorr
fn iteractive_schnorr() {
// 公开参数:素数 p=204859, g=5, x=6 (秘密), h = 5^6 mod 204859 = 15625
let p: BigInt = BigInt::from(204859u64);
let g: BigInt = BigInt::from(5u32);
let x: BigInt = BigInt::from(6u32); // 证明者的秘密
let h = g.modpow(&x, &p); // h = g^x mod p
// 进行多轮证明 p
for _ in 0..20 {
// 证明者:生成承诺 t = g^k mod p
let mut rng = thread_rng();
let k = rng.gen_bigint_range(&BigInt::one(), &(&p - BigInt::one()));
let t = g.modpow(&k, &p);
println!("证明者发送 t: {}", t);
// 验证者:生成挑战 c (简化到 0..10)
let c: BigInt = BigInt::from(rng.gen_range(0..10));
println!("验证者挑战 c: {}", c);
// 证明者:响应 r = k - c * x mod (p-1)
let order = &p - BigInt::one(); // 阶
let r = (&k - &c * &x).modpow(&BigInt::one(), &order); // 确保正数
println!("证明者响应 r: {}", r);
// 验证者:检查 g^r * h^c == t mod p
let left = g.modpow(&r, &p) * h.modpow(&c, &p) % &p;
if left == t {
println!("验证通过!");
} else {
println!("验证失败!");
}
}
}
但一来一回也太麻烦了,互联网应用需要的是一次性的“证明”。经过一番研究,密码学家们想出的一个绝妙技巧,叫做 Fiat-Shamir 启发式证明。
它的核心思想是:用哈希函数来模拟一个不可预测的“挑战者”。
Prover 不再等待 Verifier 给出挑战 c,而是自己计算 c = hash(公开信息, 自己的承诺 t)。因为哈希函数的雪崩效应,Prover 无法预测 c 的值来作弊,这就巧妙地把交互过程压缩了。
我们可以用 Rust 写出这样一个完整的非交互式证明程序 fiat_shamir:
fn fiat_shamir() {
// --- 公开参数 ---
// 在真实世界,p 应该是至少 2048 位的安全素数
let p: BigInt = BigInt::from(204859u64);
let g: BigInt = BigInt::from(2u64);
// Prover 的秘密 (只有 Prover 知道)
let secret_x: BigInt = BigInt::from(123456u64);
// Prover 的公钥 (所有人都知道)
let public_h = g.modpow(&secret_x, &p);
println!("--- 公开参数 ---");
println!("p = {}", p);
println!("g = {}", g);
println!("h = g^x mod p = {}", public_h);
println!("-------------------");
// --- PROVER: 生成证明 ---
println!("Prover 正在生成证明...");
let mut rng = thread_rng();
let order = &p - BigInt::one();
// 1. 承诺:随机选一个 k, 计算 t = g^k mod p
let k = rng.gen_bigint_range(&BigInt::one(), &order);
let t = g.modpow(&k, &p);
// 2. 挑战 (Fiat-Shamir 的魔法在这里!):
// 把公开信息和承诺 t 一起哈希,模拟一个无法预测的挑战 c
let mut hasher = Sha256::new();
hasher.write_all(&g.to_bytes_be().1).unwrap();
hasher.write_all(&public_h.to_bytes_be().1).unwrap();
hasher.write_all(&t.to_bytes_be().1).unwrap();
let hash_bytes = hasher.finalize();
let c = BigInt::from_bytes_be(num_bigint::Sign::Plus, &hash_bytes) % ℴ
// 3. 响应:计算 r = k - c*x (mod order)
let cx = (&c * &secret_x) % ℴ
let mut r = (&k - cx) % ℴ
if r < BigInt::zero() {
r += ℴ
}
println!("证明已生成:(r = {}, c = {})", r, c);
println!("-------------------");
// --- VERIFIER: 验证证明 ---
println!("Verifier 正在验证证明...");
// Verifier 为了验证,需要自己重新计算 t' = g^r * h^c mod p
let gr = g.modpow(&r, &p);
let hc = public_h.modpow(&c, &p);
let t_prime = (&gr * &hc) % &p;
// Verifier 再用算出来的 t' 计算 c' = H(g || h || t')
let mut hasher = Sha256::new();
hasher.write_all(&g.to_bytes_be().1).unwrap();
hasher.write_all(&public_h.to_bytes_be().1).unwrap();
hasher.write_all(&t_prime.to_bytes_be().1).unwrap();
let hash_bytes = hasher.finalize();
let c_prime = BigInt::from_bytes_be(num_bigint::Sign::Plus, &hash_bytes) % ℴ
if c == c_prime {
println!("✅ 验证通过!");
} else {
println!("❌ 验证失败!");
}
}
以上我们通过最简单的代码来演示了 ZKP 的基本思想,从数学原理上都是基于离散对数困难性。
当我看到 Hash 的时候,我联想到了后台服务的密码存储,比如我们在做一个用户注册和登录功能的时候,为了安全我们是不会去存储用户的原始密码(秘密),而是会使用密码哈希方案,去存储 hash(password + salt)。
但这个密码哈希方案其实也泄露了“知识”,当你登录时会把 123456 发送给服务器,服务器计算 hash("123456" + salt) 并与数据库中的值对比。
hash(password + salt) 的列表。这个哈希值本身就是一条重要的知识!它虽然不是密码原文,但它是密码的一个确定性指纹。攻击者可以进行:
这就是为什么我们需要“加盐(salt)”和使用慢哈希函数(如 Argon2, bcrypt),目的就是为了增加攻击者进行上述离线攻击的成本,但无论如何,哈希值本身就是泄露的“知识”。
所以如果我们要更安全,一点“知识”都不泄露,似乎 ZKP 适合做认证服务?注册时不存密码哈希,只存公钥 h。登录时,我发送一个 ZKP 证明,服务器验证一下就行了,数据库被拖库了都没事。
甚至更简单点其实就用公私钥对不是更方便和安全么,Nostr 就是这么做的 (钱包也是这个原理),private key 是密码,每次发内容就用私钥签名内容,然后把 pubkey 带上,这样任何收到这条消息的节点都可以验证签名是否一致,这样就本质上通过各个 relay 节点形成一个去中心化的社交网络。
我按照这个思路去找 Web 相关的解决方案,业界给出的答案是 Passkeys (基于 WebAuthn 标准),使用非对称加密来替代密码(私钥不出设备),Passkeys 是这样工作的:

2019 年 3 月 4 日 WebAuthn Level 1 已经被 W3C 正式发布为“推荐标准 (Recommendation)”,标志着它成为了一个成熟、稳定、官方推荐的 Web 标准。
从上面的例子我们看到,ZKP 很适合用来证明 Prover 知道某个秘密,比如一个数 x ,但 ZKP 的用途远不止于此,还可以证明任何计算过程的正确性。
为什么证明一个程序正确运行很重要,因为像以太坊这样的公链,如果所有的节点都运行同样的合约 (本质上就是一段程序代码) 多次,这无疑是很大的浪费,我们想通过 ZKP 把计算挪到链下,这样公链上的节点只需要验证程序被正确执行就可。
“我正确运行了一个复杂的程序,得到了这个输出。”—— 这要怎么证明?
答案是四个字:万物皆可电路 (Arithmetization)。
ZKP 系统(比如我们后面会聊的 zk-SNARKs)的“世界观”非常单纯,甚至有点笨拙,它看不懂我们人类写的高级代码,比如 if/else 语句、for 循环。
如果我们想让 ZKP 为我们工作,就必须先把我们要证明的东西,翻译成它唯一能听懂的语言。这个翻译过程,就是“算术化 (Arithmetization)”。而“电路”或“约束系统”,就是我们翻译出来的最终稿。这个重写的过程,就是“拍扁 (Flattening)”。你把一个有层次、有复杂逻辑的程序,变成了一个长长的、线性的、只包含最基本算术运算的指令列表。
任何程序,无论多复杂,都可以被“拍扁”成一系列最基础的加法和乘法约束。比如 out = x*x*x + x + 5 这段代码,可以被分解为:
v1 = x * xv2 = v1 * xv3 = v2 + xout = v3 + 5于是,证明“我正确运行了程序”,就转化为了证明“我知道一组数 (x, v1, v2, v3, out) 能同时满足上面这一堆等式”。这个过程,就是把代码逻辑“算术化”,变成了 ZKP 系统可以处理的语言。
那我们来看 Verifier 如何验证上面的计算过程,最原始的当然是根据输入,来一条一条的执行上面被拍平后的指令集,但这样的工作量和自己去执行整个程序就差不多了。
为了避免这种蛮力验证,密码学家们引入了一个极其强大的数学工具:多项式 (Polynomials)。 整个魔法流程如下:
Prover 的艰巨任务:将所有约束“编织”进一个多项式 Prover 会执行一个惊人的转换:他会找到一种方法 (Groth16、PLONK、STARKs 等),将我们前面提到的那一整个约束系统 (x * x - v1 = 0, v1 * x - v2 = 0, …) 全部编码成一个单一的、巨大的多项式方程。
我们可以把这个巨大的“主多项式”记为 P(z)。这个 P(z) 有一个神奇的特性:
当且仅当 Prover 提供的所有见证值 (x, v1, v2…) 都完全正确、满足所有原始约束时,这个主多项式
P(z)在某些特定的点上才会等于 0。
如果 Prover 在任何地方作弊,哪怕只修改了一个微不足道的值,最终生成的那个 P(z) 就会是一个完全不同的多项式。
验证者的捷径 – 随机点检查 (Random Spot-Check) :现在验证者的问题从“检查成千上万个小等式”变成了“如何验证 Prover 的那个巨大多项式 P(z) 是正确的?”
难道要把整个巨大的多项式传输过来再计算一遍吗?当然不是!这里用到了密码学中一个非常深刻的原理,通常与 Schwartz-Zippel 引理 有关。
它的直观思想是:
如果我有两个不同的、阶数很高的多项式
P(z)和F(z)(F 代表伪造的),然后我从一个极大的数域里随机挑选一个点s,那么P(s)和F(s)的计算结果相等的概率几乎为零。
这就给了验证者一个巨大的捷径:
s 上,对 Prover 的多项式进行一次“抽查”。s 这个点上,你的多项式计算出来的值是多少?”所以这里的 ZKP 证明里到底包含什么?
在一个典型的 zk-SNARK(比如 Groth16)中,那个小小的证明通常是由几个椭圆曲线上的点 (points on an elliptic curve) 组成的。可以把这些“点”想象成一种具备神奇数学特性的高级指纹。这些点就是 Prover 对他构造的那些巨大多项式(比如 A(x), B(x), C(x),它们共同构成了我们之前说的那个主多项式 P(x)) 的“承诺”。
这里的魔法在于 Verifier 不需要通过这些“点”来反推出原始的多项式。相反,他可以直接在这些“点”上进行一种特殊运算,这种运算的结果等价于在原始多项式上进行“随机点检查”。这个特殊的运算,就是 zk-SNARKs 的核心引擎之一:配对 (Pairings)。并非所有 ZK 架构都用配对;Groth16/部分 KZG-based 系统用配对,STARKs 则用哈希/FRI 等替代方案。
让我们把整个流程串起来 (zk-SNARK),看看 Prover 的多项式是如何被“隔空”验证的:
准备阶段 (Setup):
Prover 的工作:
A(x), B(x), C(x)。(这些多项式满足 A(x) * B(x) - C(x) = H(x) * Z(x) 的关系,这是 R1CS 算术化的结果)。s 上的椭圆曲线点表示。这些点就是对多项式的“承诺”。Verifier 的工作:
A(x), B(x), C(x))。这个方程被设计得极其巧妙,它的等号左边和右边分别对应着 Prover 原始多项式关系 A*B-C=H*Z 的加密形式。
当且仅当 Prover 原始的、未知的那些多项式确实满足正确的数学关系时,这个配对验证方程的等号才能成立。
所以:
Prover 把“我知道所有题的答案”这个事实,通过复杂的计算,浓缩成了一个包含几个关键“密码学指纹”的信封(证明)。Verifier 不用拆开信封看所有答案,他只需要用一种特殊的“X 光机”(配对验证)照一下这个信封,就能瞬间知道里面的答案是不是都对。
区块链因为其去中心化和对隐私性的严苛要求,ZKP 非常适合用在这个领域。
以太坊慢又贵,因为每个节点都要重复执行每笔交易。ZK-Rollup 的思路就像是找了个超级课代表:
L1 的所有节点不再需要重复计算那几千笔交易,它们只需要做一件极其廉价的事:验证那个 ZK 证明。就像老师检查作业,不再需要自己从头算一遍,只需要看一眼课代表盖的“全对”印章。
总而言之,Rollup 的核心创新在于将计算执行与数据结算分离。它利用 ZKP 等密码学技术,将繁重的“执行”环节放在链下,然后只把一个轻量的“证明”和必要数据放在链上进行“结算”,从而实现了对以太坊主网的大规模扩容。
Tornado Cash 是个混币器,你存入 100 ETH,然后从一个全新的地址取出来,没人能把这两者联系起来。它的机制是:
存款:你在本地生成一个秘密凭证(包含Secret和Nullifier),然后计算出它的哈希值——“承诺 (Commitment)”,把承诺和钱一起存入合约。
取款:你用一个全新的地址,提交一个 ZK 证明,这个证明:“我知道某个树叶的 Secret 且未被花费”,同时提交 nullifier(通常是对秘密做散列得到的唯一标识)以标记已花费。这样合约无需关联存款者身份即可阻止双花。
整个过程,合约就像个盲人会计,它不知道是“谁”存的,也不知道取款对应的是“哪一一笔”存款,它只负责验证 ZKP 规则是否被遵守。
ZKP 应用在大模型也是最前沿、激动人心的领域。例如 AI 模型(尤其是大型语言模型)的权重是极其宝贵的商业机密。用户的数据又极其隐私。如何让一个 AI 模型在不暴露其内部权重的情况下,处理用户的隐私数据,并向用户证明它确实是用了那个宣称的高级模型,而不是一个廉价的“冒牌货”?
ZKP 解决方案 (ZKML - Zero-Knowledge Machine Learning): 模型推理证明:模型提供方可以对一次推理过程生成 ZK 证明,证实“我使用我宣称的那个模型(其哈希值是公开的),处理了你的输入数据,得出了这个输出结果”。这向用户保证了模型的真实性,同时保护了模型的知识产权。
数据隐私证明:用户可以对自己的数据生成 ZK 证明,证实“我的数据(例如医疗记录)符合某个特定标准(例如,有某种疾病特征)”,然后将这个证明提交给 AI 模型进行统计或研究,而无需上传原始的隐私数据。
这里有更多相关的资料:An introduction to zero-knowledge machine learning (ZKML)
前面我们谈到,在 ZKP 中Prover(证明者)端计算量最大,主要集中在以下几个方面:
而在Verifier(验证者)端计算量相对较小,这也是零知识证明的重要优势之一,但它仍然需要进行一些关键的计算,比如:
总的来说,零知识证明的计算量主要耗费在Prover端,因为它需要对整个计算过程进行完整的加密转换和证明生成,而这些步骤依赖于高复杂度的多项式和椭圆曲线运算。所以我们看到一些专门为此服务的硬件 FPGA、ASIC、GPU。
而 RISC-V 因为其可扩展性和模块化设计、开源的标准等优势,是实现零知识证明硬件加速的重要“基石”之一,risc0 是个值得关注的项目
Computer Scientist Explains One Concept in 5 Levels of Difficulty 向不同知识背景的人介绍零知识证明。
要深入理解 ZKP 需要更多数学知识,STARKs, Part I: Proofs with Polynomials 以太坊创始人的博客,他用相对简单的语言解释极其复杂的密码学概念,是 ZKP 入门最经典的读物。
The zk-book 一个非常棒的在线开源书籍,逐步讲解构建一个零知识证明系统所需的数学知识,从有限域、椭圆曲线到多项式承诺,内容非常扎实。
2025-09-23 08:54:14
I’ve always wanted to learn RISC-V. A few days ago, I finally got my hands dirty with it now.
This post will guide you through the process of building a simple RISC-V VM from the ground up, using Rust as our implementation language.
Before writing any code, I need to grasp the fundamentals of RISC-V.
x0-x31).We can get all the details of RISC-V instructions from RISC-V Technical Specifications.
Our VM is essentially a program that emulates a real CPU’s behavior. The core of our VM is the instruction loop, which follows a simple fetch-decode-execute cycle.
Here’s a simplified Rust code snippet to illustrate the VM structure and the run loop:
pub struct VM {
x_registers: [u32; 32],
pc: u32,
memory: Vec<u8>,
}
impl VM {
pub fn run(&mut self) {
loop {
// 1. Fetch the instruction
let instruction = self.fetch_instruction();
// 2. Decode
let decoded_instruction = self.decode(instruction);
// 3. Execute
self.execute_instruction(decoded_instruction);
// 4. Increment the PC
self.pc += 4;
}
}
}
The fetch instruction turns out to be very simple, we just load 4 bytes in little-endian format into a u32 integer:
/// Fetch 32-bit instruction from memory at current PC
fn fetch_instruction(&self) -> Option<u32> {
let pc = self.pc as usize;
if pc + 4 > self.memory.len() {
return None;
}
// RISC-V uses little-endian byte order
let instruction = u32::from_le_bytes([
self.memory[pc],
self.memory[pc + 1],
self.memory[pc + 2],
self.memory[pc + 3],
]);
Some(instruction)
}
Then we need to decode the integer into a RISC-V instruction. Here’s how we decode IType and RType instructions. The specifications for these two types are:

/// Decode 32-bit instruction into structured format
fn decode(&self, code: u32) -> Option<Instruction> {
let opcode = code & 0x7f;
match opcode {
0x13 => {
// I-type instruction (ADDI, etc.)
let rd = ((code >> 7) & 0x1f) as usize;
let rs1 = ((code >> 15) & 0x1f) as usize;
let funct3 = (code >> 12) & 0x7;
let imm = (code as i32) >> 20; // Sign-extended
Some(Instruction::IType {
rd,
rs1,
imm,
funct3,
})
}
0x33 => {
// R-type instruction (ADD, SUB, etc.)
let rd = ((code >> 7) & 0x1f) as usize;
let rs1 = ((code >> 15) & 0x1f) as usize;
let rs2 = ((code >> 20) & 0x1f) as usize;
let funct3 = (code >> 12) & 0x7;
let funct7 = (code >> 25) & 0x7f;
Some(Instruction::RType {
rd,
rs1,
rs2,
funct3,
funct7,
})
}
_ => None, // Unsupported opcode
}
}
Then we want to execute the instruction, just following the specification. For demonstration purposes, we return the execution debug string as a result:
/// Execute decoded instruction
fn execute(&mut self, instruction_type: Instruction) -> Result<String, String> {
match instruction_type {
Instruction::IType {
rd,
rs1,
imm,
funct3,
} => {
match funct3 {
0x0 => {
// ADDI - Add immediate
self.write_register(rd, self.x_registers[rs1] + imm as u32);
Ok(format!(
"ADDI x{}, x{}, {} -> x{} = {}",
rd, rs1, imm, rd, self.x_registers[rd]
))
}
_ => Err(format!("Unsupported I-type funct3: {:#x}", funct3)),
}
}
Instruction::RType {
rd,
rs1,
rs2,
funct3,
funct7,
} => {
match (funct3, funct7) {
(0x0, 0x00) => {
// ADD - Add registers
let result = self.x_registers[rs1] + self.x_registers[rs2];
self.write_register(rd, result);
Ok(format!(
"ADD x{}, x{}, x{} -> x{} = {}",
rd, rs1, rs2, rd, self.x_registers[rd]
))
}
(0x0, 0x20) => {
// SUB - Subtract registers
let result = self.x_registers[rs1] - self.x_registers[rs2];
self.write_register(rd, result);
Ok(format!(
"SUB x{}, x{}, x{} -> x{} = {}",
rd, rs1, rs2, rd, self.x_registers[rd]
))
}
_ => Err(format!(
"Unsupported R-type instruction: funct3={:#x}, funct7={:#x}",
funct3, funct7
)),
}
}
}
}
The simplest VM code is available at: riscv-vm-v0
Now we need to write more complex assembly code for testing our VM, but we don’t want to write assembly code by hand.
To test our VM, we will write Rust code then use cross-compile toolchains to compile it into RISC-V executable files.
riscv32imac-unknown-none-elf target toolchain. This is a bare-metal target, meaning it doesn’t rely on any operating system.rustup target add riscv32imac-unknown-none-elf
Next, you’ll need a RISC-V linker. You can get this from the official RISC-V GNU toolchain.
# On Linux or macOS
sudo apt-get install gcc-riscv64-unknown-elf
# Alternatively, on macOS
brew install riscv-gnu-toolchain
Note: The gcc-riscv64-unknown-elf package includes both 32-bit and 64-bit tools.
#[unsafe(no_mangle)]
pub extern "C" fn _start() {
let mut sum = 0;
for i in 1..=10 {
sum += i;
}
// Store the result (which should be 55) in a known memory location.
let result_ptr = 0x1000 as *mut u32;
unsafe {
*result_ptr = sum;
}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
cargo with the specific target and a linker script to build the executable. We need to add options for Cargo in .cargo/config.toml
[target.riscv32imac-unknown-none-elf]
rustflags = ["-C", "link-arg=-Tlink.ld"]
The content for link.ld is as follows. It tells the linker the layout of the binary file generated. Notice that we specify the entry point at address 0x80:
Then we can build the program to a binary:
cargo build --release --target riscv32imac-unknown-none-elf
riscv64-unknown-elf-objdump to double-check the generated binary file:riscv64-unknown-elf-objdump -d ./demo/target/riscv32imac-unknown-none-elf/release/demo
./demo/target/riscv32imac-unknown-none-elf/release/demo: file format elf32-littleriscv
Disassembly of section .text._start:
00000080 <_start>:
80: 4501 li a0,0
82: 4605 li a2,1
84: 45ad li a1,11
86: 4729 li a4,10
88: 00e61763 bne a2,a4,96 <_start+0x16>
8c: 46a9 li a3,10
8e: 9532 add a0,a0,a2
90: 00e61863 bne a2,a4,a0 <_start+0x20>
94: a809 j a6 <_start+0x26>
96: 00160693 addi a3,a2,1
9a: 9532 add a0,a0,a2
9c: 00e60563 beq a2,a4,a6 <_start+0x26>
a0: 8636 mv a2,a3
a2: feb6e3e3 bltu a3,a1,88 <_start+0x8>
a6: 6585 lui a1,0x1
a8: c188 sw a0,0(a1)
aa: 8082 ret
The complete cross-compile Rust code is available at: riscv-demo
The first problem is how do we parse the executable file? It turns out there is a crate called elf that can help us parse the header of an ELF file. We extract the interested parts from the header and record the base_mem so that we can convert virtual address to physical address. Of course, we also load the code into memory:
pub fn new_from_elf(elf_data: &[u8]) -> Self {
let mut memory = vec![0u8; MEM_SIZE];
let elf = ElfBytes::<elf::endian::AnyEndian>::minimal_parse(elf_data)
.expect("Failed to parse ELF file");
// Get the program entry point
let entry_point = elf.ehdr.e_entry as u32;
// Iterate through program headers, load PT_LOAD type segments
for segment in elf.segments().expect("Failed to get segments") {
if segment.p_type == PT_LOAD {
let virt_addr = segment.p_vaddr as usize;
let file_size = segment.p_filesz as usize;
let mem_size = segment.p_memsz as usize;
let file_offset = segment.p_offset as usize;
// Address translation: virtual address -> physical address
let phys_addr = virt_addr - entry_point as usize;
// Check memory boundaries
if phys_addr + mem_size > MEM_SIZE {
panic!(
"Segment is too large for the allocated memory. vaddr: {:#x}, mem_size: {:#x}",
virt_addr, mem_size
);
}
// Copy data from ELF file to memory
if file_size > 0 {
let segment_data = &elf_data[file_offset..file_offset + file_size];
memory[phys_addr..phys_addr + file_size].copy_from_slice(segment_data);
}
}
}
let mut vm = VM {
x_registers: [0; 32],
// Set directly to entry_point to match the linker script
pc: entry_point,
memory,
mem_base: entry_point,
};
vm.x_registers[0] = 0;
vm
}
What’s left is that we need to extend our VM to support all the instruction formats used in this binary file, including li, bne, beq, etc.
There are 16-bit compressed instructions, so we can’t always increment the PC by 4; sometimes we only need to increment it by 2 for shorter ones.
Another interesting thing is that some of them are conditional jump instructions, so we need to get the return new_pc from the execution of the instruction.
So now we need to update the core logic of fetch and execution of instructions:
// Check the lowest 2 bits to determine instruction length
if first_half & 0x3 != 0x3 {
// 16-bit compressed instruction
pc_increment = 2;
new_pc = self.execute_compressed_instruction(first_half);
} else {
// 32-bit instruction
pc_increment = 4;
if physical_pc.saturating_add(3) >= self.memory.len() {
break;
}
let second_half = u16::from_le_bytes([
self.memory[physical_pc + 2],
self.memory[physical_pc + 3],
]);
let instruction = (second_half as u32) << 16 | (first_half as u32);
if instruction == 0 {
break;
}
new_pc = self.execute_instruction(instruction);
}
The complete new VM which can run compiled RISC-V binary files is available at: riscv-vm