而我今天碰到的这个问题虽然现象一样,但解决方法又不同了。我不确定是 macOS 系统更新,亦或是我更新了 VS Code 造成的。
复现脚本很简单,循环创建随机命名的 shell 脚本,然后对比首次和再次执行的耗时:
#!/bin/bash
rm -rf /tmp/speed_test
mkdir -p /tmp/speed_test
for i in {1..10}; do
FILENAME=$(openssl rand -hex 10)
echo $'#!/bin/sh\necho Hello' > "/tmp/speed_test/$FILENAME.sh"
chmod a+x "/tmp/speed_test/$FILENAME.sh"
FILE="/tmp/speed_test/$FILENAME.sh"
first=$(TIMEFORMAT="%R"; (time $FILE > /dev/null) 2>&1)
second=$(TIMEFORMAT="%R"; (time $FILE > /dev/null) 2>&1)
echo "第一次: $first 第二次: $second"
done
在 VS Code 终端的输出:
第一次: 0.525 第二次: 0.007
第一次: 0.290 第二次: 0.009
第一次: 0.280 第二次: 0.007
第一次: 0.272 第二次: 0.008
第一次: 0.307 第二次: 0.008
...
差距大概 30-50 倍。换到 Warp 终端跑同一个脚本,两次都在 0.006s 左右。
应该不是我上篇文章提到的 SIP 问题,我确定 System Settings → Privacy & Security → Developer Tools 中已经加入了 VS Code。
看起来也不像是文件系统缓存的原因,因为 0.2-0.5 秒远超磁盘缓存的量级。用 log show 看了下系统日志:
log show --predicate 'subsystem == "com.apple.syspolicy.exec"' --last 2m --style compact
输出大量这样的记录:
GK performScan: PST: (path: 8d0e4c2de41c3e77), (team: (null)), (id: (null)), (bundle_id: (null))
Error Domain=NSOSStatusErrorDomain Code=-67062
GK evaluateScanResult: 2, PST: (path: 8d0e4c2de41c3e77), ... (bundle_id: NOT_A_BUNDLE), 0, 0, 1, 0, 7, 7, 0
从日志上看每次执行新文件 syspolicyd 都会做一次 GK performScan。这就是 macOS 的 Gatekeeper 安全扫描——对首次执行的新可执行文件做代码签名验证和恶意软件检查。扫描结果会被缓存,所以同一个文件第二次执行就快了。
进一步验证:我们把测试脚本里改成 (time /bin/sh $FILE > /dev/null) 2>&1,这样就是直接通过 sh 来执行:
直接执行 ./script.sh → 0.248s (触发 execve → Gatekeeper 扫描)
/bin/sh ./script.sh → 0.006s (只是让 /bin/sh 读取文件,不触发安全扫描)
原因确认了。当用 ./script.sh 执行时,内核的 execve 系统调用会触发 AppleSystemPolicy.kext 中的 MACF hook (mpo_proc_notify_exec_complete),通知 syspolicyd 进行评估。而 /bin/sh script.sh 只是让已受信的 /bin/sh 进程读取文件内容来解释执行,不触发 execve 的安全检查路径。
接着试了 System Settings → Privacy & Security → Full Disk Access,给 VS Code 完全磁盘访问权限。重启 VS Code,再跑脚本:
第一次: 0.005 第二次: 0.005
第一次: 0.005 第二次: 0.005
第一次: 0.006 第二次: 0.006
...
问题消失了。syspolicyd 日志中的 performScan 也不再出现。
Full Disk Access (FDA) 在 macOS 的 TCC (Transparency, Consent, and Control) 框架中对应的是 kTCCServiceSystemPolicyAllFiles 权限。这个权限的含义远超“磁盘访问“——它实际上是 TCC 框架中最高级别的信任授权。
macOS 会追踪每个进程的 responsible process(负责进程)。在 VS Code 终端中敲的命令,它的 responsible process 是 VS Code 本身。当 AppleSystemPolicy.kext 的 MACF hook 拦截到 execve 后,会检查 responsible process 的信任级别。拥有 FDA 授权的进程被识别为高信任来源,syspolicyd 会走快速路径,跳过完整的 Gatekeeper 扫描。
而 Warp 这些原生终端,因为我已经加入系统默认信任的开发工具列表,所以它们派生的子进程一开始就不会触发完整扫描。
需要说明:Apple 没有公开文档化这个具体流程。上面的描述来自实验推断和社区逆向分析,不是官方说法。
发现 FDA 有效之后,我尝试反向验证:把 VS Code 从 FDA 列表中移除,重启 VS Code,再跑脚本。
结果:仍然很快。问题没有复现。
syspolicyd 的扫描评估结果存储在 /var/db/SystemPolicyConfiguration/ExecPolicy 这个 SQLite 数据库中(35MB),同时 AppleSystemPolicy.kext 在内核中维护了一个运行时缓存:
$ sysctl security.mac.asp.stats.cache_entry_count
security.mac.asp.stats.cache_entry_count: 4700
也就是说,当 VS Code 拥有 FDA 时,它被评估为可信 responsible process,这个信任结果被持久化了。移除 FDA 后,历史记录并不会被清除。macOS 的安全评估系统是“学习型“的——它记住过去的信任决策。
要彻底重现原来的问题,可能需要重启 Mac 清除内核缓存,或者更极端地清理 ExecPolicy 数据库。
如果你也遇到类似的问题——新编译的程序、新创建的脚本首次执行莫名其妙地慢,可以检查一下是不是 Gatekeeper 的锅:
# 查看最近的 syspolicyd 扫描记录
log show --predicate 'subsystem == "com.apple.syspolicy.exec"' --last 5m --style compact | grep performScan
解决方案按排序:
我的博客跑在 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 写的?
Update 2026-02-13: 鉴于使用 quickjs 对于我来说还是太重了,我后续又做了一些改动,把 ejs template 换成了 tare template,这样就把 vexo 模板直接放在了 Rust 项目里,所以目前 hexo-rs 就是这个博客的 generator 了。
]]>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
]]>现在出现了一些新苗头,比如 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 工具最终会更智能。但至少现在,它还处于一个尴尬的中间地带:用得好能帮你节省时间,用不好反而不如不用。
]]>这个事故当然引起的范围特别广,我当时正在用 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 的事故虽然造成的影响挺大,但这个公司也确实足够公开透明,事故分析写得非常清晰,值得大家学习并反思自己组织上有没有类似的工程问题。
]]>大多数人粗看都会觉得这东西是密码学博士们的专属玩具,我花了一段时间学习后,发现这条通往魔法世界的路似乎有迹可循,希望这篇入门介绍能帮助到更多这方向的学习者。
忘掉所有数学,我们先从一个故事开始——“阿里巴巴洞穴”,这是理解 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 一个非常棒的在线开源书籍,逐步讲解构建一个零知识证明系统所需的数学知识,从有限域、椭圆曲线到多项式承诺,内容非常扎实。
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:
OUTPUT_ARCH(riscv)
ENTRY(_start)
SECTIONS {
. = 0x80;
.text : {
*(.text.boot)
*(.text)
}
.rodata : {
*(.rodata)
}
.data : {
*(.data)
}
.bss : {
*(.bss)
}
}
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
我年初和 Rust 基金会邮件确认参加,但直到 4 月 9 日才开始动手申请签证。在深圳办理荷兰签证流程简单,只需提交材料并录指纹,但我嫌麻烦找了中介帮忙。据说大概也就两周多就会有结果,但直到五一查询还是没结果,中介说可以加 600 元加急。看到官网解释近期审批延迟,我便花钱加急,第二周拿到了签证。不知道这其中是否有猫腻,但确实有华为的朋友同样卡在那个节点审批,结果没有如期下来。
从广州白云机场有直飞阿姆斯特丹的航班,只是起飞时间大概是凌晨 1 点多。飞机上座位空间狭小,难以入睡,趴着或躺着都不舒服,到了阿姆斯特丹是早晨 6 点多。通过海关后,我因不熟悉荷兰语而有些迷茫。由于未携带 Visa 卡,只能使用现金,幸好两位路人热心相助。我直接购买火车票前往 Utrecht,约于上午 10 点抵达酒店。因为时间还比较早没有空房,我把行李箱放在酒店自己去城里逛逛。
Utrecht 据说是荷兰第四大城市,交通方便。我查了一下,人口约为 30 万,这在中国估计只算是小镇了。我信步漫游,城市整洁如画,绿树成荫,空气清新怡人。由于是周六,商户大多已关门,街上行人多在跑步或散步,整体人口密度较低。5 月应该是荷兰最好的天气,不冷不热,只要不在太阳下就会感觉凉凉的。荷兰人身高马大,路上的自行车很多而汽车非常少,大多都是 A 级车,豪车基本没看到。很常见的场景是父母骑自行车,拉着个大篮筐里载着一两个小孩。
街头的人们神情轻松自在,仿佛时间在这里慢了下来。路旁一个年轻人倚着树,手捧书本读得入了神,旁边的金毛小狗却不耐烦地扯着牵绳,发出几声撒娇。草坪边,几个人懒洋洋地躺在长椅上晒着太阳,像是被微风哄睡了,手中未喝完的咖啡杯歪在一旁。运河边,一对白发苍苍的夫妇沐浴在阳光中,丈夫似乎想起什么趣事,侧身在妻子耳边低语几句,惹得她轻笑出声,随即两人轻轻接吻。这些场景对我这个匆匆旅客而言,宛如《楚门的世界》的开场,美好却略显不真实。

更实际的问题是吃饭,到了中午饭点,主城区我都快粗略走完了,但只有一两个餐厅开着。无奈,我去超市买了点面包和牛奶当作午饭,顺便看了一下日用品的物价,水果和蔬菜比较贵,牛奶之类的东西便宜点。回到酒店有了空房,前台给我办理了入住,稍微睡了一个来小时,起来后已经是下午三点多了,这时候我才后悔今天应该多在阿姆斯特丹逛逛,可玩的地方应该更多。我只能又往还没逛的城市另一边漫步,城市面貌都挺漂亮,不过总体而言房子大多是联排,空间不是很大,不像多年前第一次逛圣何塞那样户户大别墅带给我震撼。
晚上看到 zulip 里有消息,和我一样提前到达的人开始组局吃饭了。我们一行四人挑了个餐厅聊了两个小时左右,一个美国教授、一个德国年轻学生、还有一个马来西亚人,聊的都是技术趣闻和 Rust 相关的。但我这时候有点困了,所以主要在听。没想到饭局结束后都晚上九点半了,天还微微暗,这时候 Utrecht 城市运河旁边的餐厅开着,游客熙熙攘攘,这大概是最热闹的时候了。
5.12 是周一,这天没有什么特别的活动,明天才是主会议的第一天,所以很多人还在路上。我上午去了会场注册领了参会牌,然后在会场逛了逛。我陆陆续续认出来了一些用真实照片做 Github 头像的人,期间和一个老哥聊了起来,他常年维护着 rustc-dev 这份开发文档,之前我提 PR 的时候帮我 Review 过。到了饭点就一起边聊边往市区走,我们想爬到教堂的楼顶参观,据说这里是城市的最高点,风景应该不错。去了之后发现一定要预约和请讲解员,而且一个下午才几十号人,便放弃了。走了 20 多分钟,都没找到什么看起来好吃的餐厅,碰到一个超市又买了点面包和牛奶,在附近的公园旁当作午餐。这老哥来自南非,比我大几岁,之前维护过 Debian 上的一些包。他给 Rust 做开源贡献快十年了,大多是一些文档类的工作。他现在全职远程在一个 Rust 相关的咨询公司工作,主要日常还是做些开源维护工作。
下午没什么特别的安排,我参加了一个 workshop。我们要做的是一个腐蚀的 Rust 实体 Logo,可以在背后用马克笔写上自己的名字,用磨砂纸摩擦一遍,然后用酒精还有各种化学染料涂在 Logo 的表面,等待一段时间就会形成腐蚀的效果和图案,我也跟着做了一个,是不是看起来很漂亮?但我第二天忘记去取了 :)

因为时差原因,我下午就开始犯困,在酒店睡了一下午后,看到群里又有人开始组饭局,这次是都是同住个酒店的人,我们在酒店大堂集合,总共大概有 10 个人左右。我看到了更多的熟悉面孔和 id,有几个维护 Rust 多年的成员,碰到了我第一次提 PR 帮我 review 的 estebank。
我们找了个店喝了点啤酒,然后服务员过来说后厨到点下班了,所以不提供晚餐。这对于中国人来说真是难以置信,哪有这么做生意的呢,这里果然是到点下班比挣钱更重要。后来我们坐火车去了主城区,接连询问了两家餐厅,均表示已快到打烊时间 (其实也就快晚上 9 点),最后终于找了一个运河边上的餐厅坐下。我点了个海鲜意面,味道非常不错,20 欧元也值了,但没想到这就是我这一周吃得最满意的一顿饭了。

一群程序员吃饭聊的还是技术话题,作为 Rust 程序员和维护者,吐槽 C++ 是不可避免的,有的聊得比较细节,比如如何提高链接速度之类的。总体来说气氛非常好,大概是很多人都远程工作,平时无法找到这么一群志同道合的人聊天,而且这些人平时在开源社区里合作交流,能见面聊聊自然是非常开心的。
有趣的事发生在我们用完餐之后,老板估计是犯懒,结账说不能分开支付,所以我们需要找个人先替大家把单买了,然后大家再转给他。一群程序员大眼对小眼,那气氛有点尴尬。后来是 eholk 站出来先买了单,然后一群人围着他看账单转钱给他,反正是异常耗时和麻烦,看来还是中国手机支付 AA 来得更方便。
5.13 是 Conference 的第一天,主题很多,而且分了三个分会场:主会场、生态、行业应用。这两天参会的人非常多,我估计得有 500 来人。公司展台比较少,右边有 Jetbrains、Zed、左边有一些硬件相关的和 Rust 培训咨询相关的公司,华为有个招聘展台。现场看到了其他几个中国人,聊了一下他们是在 Cargo 项目团队做开源贡献的。在会场我见到了更多熟悉的面孔,因为挂牌上有 Github 账号,所以基本盯着对方的名片看看就知道是否打过交道,可以说这是一个大型的网友见面会。我在社区里面断断续续也做了四年,有的人只是通过 review comments 交流,有的人通过 zulip 私聊过,能见面聊聊真是一种难得的体验。另外我和华为爱尔兰可信计算实验室的余教授聊了比较久。
这次会场居然选在了一个电影院,一楼是一个大的会展活动空间,整体非常宽敞。这是我第一次坐在电影院听技术演讲,座位宽敞、音效和视觉都棒极了,他们甚至做了一个类似电影的片头动画,看起来诙谐可爱。第一天最热的主题应该是 Alex Crichton 的 10 Years of Rust,我第一场也去听了这个演讲。推荐任何对 Rust 感兴趣的去看看这个视频。

Rust 在嵌入式领域取得了长足的进展,所以也是这次会议的热点,It’s Embedded Rust Time这个演讲谈到长远来看这个领域里的人希望 Rust 能得到更多应用,volvo 的人分享了他们使用 Rust 开发和发布第一个 ECU 的过程。来自南京大学的 Xiaolong 分享了一个异步任务编程框架 Dagrs。Refactoring in Rust 分享了些重构技巧,把一个典型场景的代码变得漂亮易维护。 We deserve helpful tools 介绍了编译器里的错误信息如何更友好,很多初学者喜欢 Rust 的一个原因是 rustc 的错误信息看起来人性化又有帮助,这是社区里很多人努力的结果。
午饭期间,有个比利时的工程师来拼桌,所以就聊了起来,他几年前去广州待过一个多月,所以有些共同话题。他业余时间喜欢折腾硬件和数学,组织了一个学习系统编程的小组。这个人非常健谈有趣,我们一起聊了聊各自的工作经历等,在 Linkedin 上加了个好友。
第一天会议结束后,我跟着 estebank 一行八个人出去就餐。这次我们选的是一家印度餐厅,因为已经有两三天没吃米饭了,我点了一个看起来有点辣的鱼加米饭,结果菜上来之后让我震惊,不管味道还是品相都达不到沙县小吃级别,但这饭饭菜居然也要 20 欧,这是我这周吃过的最差的一顿饭了吧。这周后面几天的晚上我都不想吃晚饭,一方面是白天在会场吃了很多零食,另一方面到了晚上七点左右我就非常困,这边晚上天黑的时间短,所以我通常回到酒店就睡觉。
5.14 第二天我首先去听了 Rust for Linux 这个演讲,这场非常火爆,看来大家都非常关注这个领域,无论是在贡献者数量还是提交到主线内核中的代码行数方面,增长都非常快。然后我去听了大部分 Rust Project track 相关的演讲,compiler-errors 没写 PPT,居然直接在 VsCode 里面讲解代码 。The Rust Vision Doc and You讲解了如何获取用户的意见和反馈,Rust 在全世界的整体采用情况,很高兴看到社区像是对待一个产品一样来发展一门编程语言。
所有两个天的 Conference 会议在油管上直播,也有录像,感兴趣的可以自己找来看看。
5.15 第三天的上午有各种 workshop,会场地址也变了另一个郊区的办公场所,这个会场在运河的旁边,河上经常有各种运货大船漂过,感觉像苏州的运河的场景。华为余教授帮我弄了个参加 workshop 的机会,我去体验了一下玩了会儿跨平台的 Rust GUI 框架 Makepad。然后我去了对面的 Rust all hands 的办公场所,这里都是 Rust 项目维护者和贡献者,大家以分组的方式进行讨论,有的人干脆找个角落写代码。这次组织者非常用心,说不提供单一的 t-shirt,但找了一个打印 Logo 的机器,大家可以自己选择在衣服、背包、帽子上打印 Rust 相关的图案,我把自己的上衣上印了一个黄色的螃蟹。有的人把自己平时收集的一些笔记本贴纸分享出来,我第一次看到这么多 Geek 贴纸,选了好多。

会场提供了很多酒水和小吃,我不停地喝橙汁。期间碰到了在华为俄罗斯工作的 petrochenkov,他穿着一件 Rust 1.0 发布的纪念 t-shirt,年龄和我相仿却已在开源社区工作十多年。他之前帮我 Review 过不少 PR,以前在中国华为办公室工作过,这次终于见面聊了聊。我们俩一起打乒乓球,虽然两个人水平都不太行,但玩得很来劲。
这天下午用来庆祝 Rust 1.0 发布 10 周年,有一个小型 Party,并且用实时发布 1.87 版本的方式进行庆祝。Niko 上台演讲了 Rust turns 10,余教授代表华为作为赞助商演讲了,华为应该是中国企业中对 Rust 投入最多的,不管是国内的华为还是海外分部都有投入,而且也在通过其他组织赞助社区的一些资深维护者。这会儿我才知道发行一个 Rust 新版本的脚本要运行一个多小时多,最后发布倒计时那会儿气氛达到高潮,拍下了这张照片,我大概在右边第三排的位置,但是身高不够被淹没了:

5.16 和 5.17 是两天的 Rust all hands meeting,可以自由选择参加分组讨论也可以自己找个地方工作一会儿。我选择性参与听了一些,比如 Rust society、Let’s talk about Burnout、Infra、Rust Foundation 的 office hours 等,感觉这两天讨论比较多的几个主题是和 C++ 的互操作性。期间也有很多时间留给大家自由讨论,有人安利 jj这个工具,我试用了一下还没理解好在哪里。我比较多时间和 Cargo 组的几个中国人待在一起,突然长时间处于全英文环境中,偶尔会感到表达上的疲惫。
第二天下午和一个德国大学生聊了比较久,很多社区的贡献者都是业余时间在做开源,比如这个大二学生,他在学校学的是音乐,计算机只是自己的业余爱好。为什么 RustNL 比较活跃呢,大多是因为荷兰这个国家其实 Rust 爱好者还挺多的,加上德国这些周围国家,整个欧洲的 Rust 活跃度比我想象中高很多。在聊天的过程中,我问到一个问题:为什么很多贡献者来自德国?可能是因为德国人最喜欢 follow rules 并且个性严谨,这和 Rust 的特性非常符合。
另外,我发现很多人采用的是自己开个咨询公司的方式工作,这样可以同时和多个公司签短期劳动合同,按项目收费,当然这样收入也许并不稳定,但这种方式贵在灵活。我们也聊了一些生活类的话题,总体而言我觉得欧洲的国家大多税收比较重,但基础的生活保障方面的福利也挺好,总得来说人对挣钱的欲望没有那么强烈,人的选择和路径也比较多。
这期间张汉东过来找 jackh726,他们约了一个大约半小时的会谈,主要是聊聊 Rust 在中国的发展和使用情况,jack 让我临时做个翻译。这是我第一次做类似口译这事,几乎有一半时间我只是用英语概括了一下他的讲话。最后我自己也表达了些自己的想法,从全球 IT 产业规模看,Rust 在中国具有广阔发展前景,未来或将有更多中国开发者活跃于 Rust 社区,贡献力量。当然这里有很多因素在影响,一方面大部分国内的 IT 工程师都太忙,还有语言方面的障碍,也许未来情况会好一些,当然这也需要社区的支持。
访谈完后在茶水间,我偶然瞟了一眼对面人胸前的牌子,这个名字看起来眼熟,原来是世界上 crate 包被下载最多的作者,如果你是 Rust 程序员应该都能猜出来他的名字。我上前打了个招呼聊了起来,他前几天的会议都没参加,只是今天过来逛逛,了解到他现在 FB 工作,我说感觉 FB 的社区贡献不多,他反驳说 FB 也是 Rust Foundation 的铂金赞助者,内部还不少 Rust 相关的项目。我突然想起 GOSIM 要找人 9 月份在杭州演讲,开玩笑邀请他来中国参加技术分享,他说那段时间有事委婉拒绝了。
在这次 RustWeek 一周的时间里,我见到了各式各样的工程师,多数人看起来简单纯粹,比如《硅谷》里的 Gilfoyle 式的人物,我见到了一个从头到脚都非常类似的人,长发飘飘还赤脚走来走去,随便找个位置就能完全陷入了自己的编程世界。有次回宾馆的大巴上,nnethercote 坐在我旁边,他是 valgrind 的作者,可以说是一个世界级的基础软件工程师,感兴趣的可以读读这篇 Twenty years of Valgrind。我的第一份工作写 C/C++,特别依赖 valgrind 找内存问题,所以我一路都在问他问题,聊他的技术旅程。他说 Rust 从语言级的角度解决内存的安全性问题,已经成了另外一条路,valgrind 完全由其他人维护了。社区里的很多人他也是第一次见,随口说 all hands 的一大作用是以后大家在 Review PR 的时候能想起对方的面容,这表述得太正确了!
在会场乱逛我有时候会盯一下大家的开发环境,有的人真是非常极客,笔记本看起来巨大厚重,操作系统大多是 Linux。我发现一个编译器贡献排前三的大牛打字使用二指禅,有天早餐时他坐我对面,我问起为什么只用两个手指操作键盘,会不会效率不高。他说两个手指的速度已经够了,敲键盘能跟上我的思考速度就行。我那天早上刚好看到个视频,说是美国大学生使用 AI 写作业,把一个教授给逼得崩溃,我知道他在世界顶级大学 Eth 当教授,我问他如何看待学生使用 AI 完成作业或者写程序,他说他的作业 AI 基本无法解决,可能是因为程序语言类的很多作业都是证明,而 AI 通常只能完成简单的,稍微深入点的无法胜任,他还会和学生当面交流,如果依赖 AI 而脑子里没货是很容易被发现的。
这次没看到什么 AI 相关的主题,聊天时有人会偶尔吐槽一下 LLM。区块链更没有人聊了,每当有人问起我主业在干什么,我都有点不好意思说我在做这个行业,因为我知道社区里面很多人有些厌恶这个术方向,特别是一些投机者损坏了这行的口碑。我通常会解释说,从技术角度来说区块链就是一个抗拜占庭的分布式数据库,还是非常有趣和有挑战的,区块链这行也是 Rust 成为主流选择的第一个领域,推动了 Rust 的发展。仔细想想,做编程语言和编译器的,追求的就是确定性和速度,AI 有其不确定性,而区块链效率不高,所以这些人大多不喜欢相反特性的东西。从去年开始,有些人为了打造出来一个看似大有贡献的 Github 账号来获得某些加密货币的空投,于是用 AI 提出各种琐碎或者错误的 Pull Request,这些维护者看着这些毫无营养的 PR 浪费自己的时间,自然对这两个行业更加厌恶了。
虽然目前我还很喜欢自己在做的工作,但我顺带了解了些全职做 Rust 社区的工作机会,说不定以后会用到。通过和不同的人聊天,我发现主要有以下一些机构:
如果是全职 Rust 开发的工作,欧洲应该是相对好找一些 (但需要签证),国内据我所知除了华为、还有字节、小米、汽车公司会用到 Rust。现在 AI 很火,AI 的 infra 也会用到些 Rust,我了解到社区里一个非常资深的维护者去了 OpenAI,这次也碰到一个 OpenAI 的人说公司内部有些 Rust 项目。但整体来说,整个世界的大环境不好,工作机会相比往年少很多。
十年无疑个具有纪念意义的里程碑,就像 Niko 在Rust turns 10所说的:
I just felt that was the most Rust of all problems: having great success but not being able to decide who should take credit. The reality is there is no perfect list – every single person who got named on that award richly deserves it, but so do a bunch of people who aren’t on the list. That’s why the list ends with _All Rust Contributors, Past and Present
Rust 目前的成功无法简单归功于个人和机构,无疑我们需要感谢项目发起人 Graydon Hoare 设置了宏大正确的愿景,而后 Rust 在开源社区自由生长,甚至完全不像是他所设想的编程语言了。现在几个 IT 巨头都有投入,但实际上也没有一个组织和个人能决定未来的发展,这既是社区的刻意设计,也是自然进化的结果。从我这种业余贡献者的角度来说,基金会虽然因为各种事饱受争议,但他们确实做了很好的幕后工作,比如这次参会的社区成员基本都能报销费用,甚至我的费用超过了计划申明的额度,基金会也直接说别担心,我们会如实报销所有费用。
总的来说,这次参会经历拓宽了我的视野。大家都因为对 Rust 和编程的激情和追求聚到了一起,交流起来非常有趣。十年前,我在 2015 年偶然发现了 Rust,当时并不知道它会塑造我的职业生涯,在我迷茫的时候重新捡起了 Rust,如今成为全职的 Rust 开发和社区贡献者。最近一年育儿和工作占据了我的大部分时间和精力,所以在 Rust 社区没那么活跃,我渴望通过编程和写作继续投入其中。
顺便一提,Rust Week 这一周我都没吃中餐,回家一称瘦了三四斤,算是意外达成健身目标!我应该是无法在欧洲长待的那类人。
]]>先想象一个普通的曲线,比如抛物线(抛个球的轨迹)。椭圆曲线不是椭圆(名字有点误导),而是一种长得像“对称小山丘”或者“歪歪扭扭的环”的数学图形。
椭圆曲线通常表示为:
$$ y^2 = x^3 + ax + b$$
通过这个方程我们可以看到椭圆曲线是上下对称的,其中 (a) 和 (b) 是曲线参数,比如下面这些曲线就是 b = 1 的情况下,a 从 2 变到 -3 的情况:

我们通常使用的椭圆曲线必须满足非奇异 的特征,这些参数必须满足判别式:
$$4a^3 + 27b^2 \neq 0$$
“非奇异”什么意思?在数学上,椭圆曲线要是“非奇异”(non-singular),就是说这条曲线长得“光滑正常”,没有奇怪的尖角、交叉点或者自己打结的地方。简单讲,就是曲线不能太“怪”,得是个平滑的、像个正常曲线的样子。
如果曲线“奇异”了,会出现两种毛病:
这些毛病会让“点加法”出问题,因为公式会算不下去,或者结果不唯一。关于点加法我们后面再谈,下面这两个椭圆曲线都是奇异的:

这名字来自数学家 Niels Henrik Abel,他研究了这种“顺序无所谓”的结构。
“群”(group)是数学里的一种概念,就像一个有规则的“俱乐部”。里面有一堆东西(叫元素),加上一个玩法(叫运算),得满足几个条件。阿贝尔群是群的一种特别类型,特点是这个玩法“顺序无所谓”。 简单说,阿贝尔群就是一个集合,里面有些元素,能用某种运算(比如加法)组合起来,满足以下条件,而且运算顺序随便换都没问题。
群的四个基本条件:
群变成“阿贝尔群”,还得多一条:
密码学里用阿贝尔群(像椭圆曲线),因为它简单又有规律,适合搞安全。
到了 1900 年代,椭圆曲线跟数论彻底绑定,而数论研究的是整数。
椭圆曲线方程
$$y^2 = x^3 + ax + b$$
通常是在普通实数(无限多的小数)上定义的,画出来是个连续的曲线。但安全领域说的椭圆曲线通常是说通过椭圆曲线定义出来的群,其范围定义在某个有限域 : $F_p$ 。
简单说,整数也是一个无限数字的世界,而有限域就是一个只有有限个数字的“数字世界”。这里的表示这个世界里只有 0, 1, 2, ..., p-1 这 (p) 个数,(p) 必须是个素数。为什么是素数?因为这样能保证这个小世界里的数学运算(加减乘除)不会出乱子,规则特别“干净”。
在这个有限域里,所有的计算结果都得落在这 (p) 个数里面。如果算出来超了 (p),就“绕回去”,用模 (p) 的方式把结果限制住。比如在 $F_5$ 里:3+4=7,但 7 不在范围内,所以 $7mod 5=2$,所以结果是 2。
这个时候,曲线不再是连续的,而是变成了散落在 $F_p \times F_p$ 网格上的一堆离散的点。比如我这个程序打印出来这条曲线上的点,这里打印的时候 $y =0$ 是在中间,从中间看上下是对称的:

上面我们只是通过椭圆曲线找到了一组数字,但这些数字之间还需要一种操作,这种操作才能让这些离散的数字组成一个群的概念。这里就需要引入一个“点加法”的概念。
点加法就像一个魔法公式,告诉你怎么从一个点“跳”到另一个点,或者同一个点“跳”两次变成新点。想象椭圆曲线是一条弯弯曲曲的线,上面有很多点,在不断做点加法的过程中不断地移动点。
在椭圆曲线上,点加法有几何和代数两种解释:
几何解释:通过两个点画一条直线,与曲线的第三个交点取关于 x 轴的对称点。

代数公式:

离散对数问题是在一个有限的“数字圈”里玩,比如有限域 $F_p$ (( p ) 是素数)或者椭圆曲线的点群里。比如:
这里的 ( x ) 或 ( k ) 就是“离散对数”。这就是离散对数问题,数学家没找到一个快速方式能解决这个问题。
直观感受一下,我们修改上面那个程序,我定义一个起始点 P,然后不断通过 P + P 的方式进行 20 次,终点是黄色的,然后把中间通过的点用蓝色的线连接起来,可以看到我们经过的点是没有什么规律的。
反向破解就比如你拿到这幅图,如果不告诉你蓝色的连线,而只有那个黄色的终点,现在问你起点在哪里?这个问题是很难回答的。

19 世纪数学家就研究过椭圆曲线的性质,但到 20 世纪数学家发现椭圆曲线上的点能通过点加法这类操作组成一个阿贝尔群,这些点的数量和分布特别“诡异”——既有限,又乱得没规律。而点加法这种正向容易、逆向难的特征刚好就适用于密码学。
Neal Koblitz 是专攻数论的数学家,对椭圆曲线很熟。他当时在研究有限域上的数学问题,注意到椭圆曲线的点群和传统离散对数有点像,但更“紧凑”。他想:如果把 Diffie-Hellman 的思路搬到椭圆曲线上,会不会更高效。
Victor Miller 在 IBM 搞应用数学,也盯着椭圆曲线的群性质。他发现,椭圆曲线的离散对数问题(ECDLP)似乎比普通离散对数还难破解,而且需要的数字(密钥长度)小得多。他俩不谋而合,都觉得这玩意儿能干大事。
他们的想法不是凭空来的,有几个关键“火花”:
为什么不随便挑个别的曲线?因为椭圆曲线(三次方程)正好有“三个交点”的几何性质,点加法规则简单又优雅,其他高次曲线要么太复杂,要么安全性不够。加上数学家几百年的研究,椭圆曲线的性质已经摸得透透的,拿来用最保险。
椭圆曲线加密从 1985 年的“怪胎”到今天的核心技术,走过了从冷门到爆款的路。它的历史是个典型的“学术变实用”的故事:数学家玩了好多年的椭圆曲线,被 Koblitz 和 Miller 一挖掘成了安全的利器。
secp256k1 是一个特殊的椭圆曲线,名字听起来高大上,其实就是密码学里用的一条数学曲线,secp256k1 的名字可以分成几个部分:
它是比特币、以太坊这些区块链的“安全锁”的核心。简单说,它定义了一个“跳跃游戏”的规则,靠这个规则保护你的私钥和公钥。
secp256k1 是这么来的:
secp256k1 就像一个超级复杂的“跳格子游戏”:
类比一下,想象 secp256k1 是个巨大的迷宫,里面有 (p) 个格子(大概 $2^{256}$个,这个数字大到超乎想象,比宇宙里的原子、沙粒、甚至时间秒数都多得多。它不是“很大”,而是“大的离谱”)。迷宫的形状由上面的椭圆曲线决定,起点是 (P)。你拿着私钥 (k),按迷宫的跳法(点加法)走 (k) 步,停在 (Q)。这个迷宫设计得太巧妙,别人站在 (Q) 看你走过的路,根本摸不着头脑,只能从 (P) 一步步试,试到宇宙爆炸也试不完,破解难度像“从银河系找一粒沙”。签名时,拿私钥和消息算出两个数(r, s),别人用公钥验证。
secp256k1 另外一个特点是计算快,主要来自它的特殊数学结构和参数选择。bitcoin-core/secp256k1 是高度优化过的 C 语言实现,用了预存表、内敛汇编等各种优化手段来提高效率。通常我们在 Rust 程序上用的也是这个库的 binding。
本来想查找更多资料来写得更详细,但我发现如此太耗费时间,倒不如先把已经理解的部分写在这里,以后如果有新的理解再丰富。在尝试理解的过程中仍然会有这种感受:

更多的参考资料在这里:
为了验证“Future 大小影响性能”这一说法是否成立,我们先从一些简单代码入手。首要任务是弄清楚一个 Future 的大小是如何确定的。毕竟,在编译器眼里,Future 只是一个 trait:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
那么,其大小取决于实现这个 trait 的具体结构体吗?我翻阅了 smol 的源码,发现在 spawn 一个 Future 时,相关代码是这样处理的:
pub unsafe fn spawn_unchecked<'a, F, Fut, S>(
self,
future: F,
schedule: S,
) -> (Runnable<M>, Task<Fut::Output, M>)
where
F: FnOnce(&'a M) -> Fut,
Fut: Future + 'a,
S: Schedule<M>,
M: 'a,
{
// Allocate large futures on the heap.
let ptr = if mem::size_of::<Fut>() >= 2048 {
let future = |meta| {
let future = future(meta);
Box::pin(future)
};
RawTask::<_, Fut::Output, S, M>::allocate(future, schedule, self)
} else {
RawTask::<Fut, Fut::Output, S, M>::allocate(future, schedule, self)
};
let runnable = Runnable::from_raw(ptr);
let task = Task {
ptr,
_marker: PhantomData,
};
(runnable, task)
}
这里可以看到 mem::size_of::<Fut>() 是在计算这个 Future 的大小,我来写个简单的 Future 验证:
use async_executor::Executor;
use futures_lite::future;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct LargeFuture {
pub data: [u8; 10240],
}
impl Future for LargeFuture {
type Output = usize;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
let value = self.data[0];
println!("First byte: {}", value);
Poll::Ready(self.data.len())
}
}
fn main() {
let ex = Executor::new();
let large_future = LargeFuture { data: [0u8; 10240] };
let res = future::block_on(ex.run(async { ex.spawn(large_future).await }));
println!("Result: {}", res);
}
在上面那个 async-task 的 spawn_unchecked 函数加上日志,打印出来的大小为 10256,刚好比这个 struct 的大小大 16,顺着代码往上可以看到这里在原始的 Future 上做了一个封装,这里的意思是如果这个 Future 以后执行完,需要从 runtime 里面删掉:
let future = AsyncCallOnDrop::new(future, move || drop(state.active().try_remove(index)));
这解释了尺寸略有增加的原因。对于结构体的尺寸,我们不难理解,但对于 async 函数,其大小又是如何计算的呢?这就涉及 Rust 编译器对 async 的转换机制。
当你写下一个简单的 async fn 函数时,Rust 编译器在幕后悄然完成了一场复杂的转换:
async fn function() -> usize {
let data = [0u8; 102400];
future::yield_now().await;
data[0] as usize
}
这段代码会被编译器转化为一个庞大的状态机,负责追踪执行进度并保存所有跨越 .await 点的变量。转换后的结构体封装了状态切换的逻辑:
enum FunctionState {
// 初始状态
Initial,
// yield_now 挂起后的状态,必须包含所有跨 await 点的变量
Suspended {
data: [u8; 102400], // 整个大数组必须保存!
},
// 完成状态
Completed,
}
// 2. 定义状态机结构体
struct FunctionFuture {
// 当前状态
state: FunctionState,
// yield_now future
yield_fut: Option<YieldNow>,
}
impl Future for FunctionFuture {
// 3. 为状态机实现 Future traitimpl Future for FunctionFuture {
type Output = usize;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<usize> {
// 安全地获取可变引用
let this = unsafe { self.get_unchecked_mut() };
match &mut this.state {
FunctionState::Initial => {
// 创建大数组及其长度
let data = [0u8; 102400];
// 创建 yield future 并保存
this.yield_fut = Some(future::yield_now());
// 状态转换,保存所有需要跨越 await 的数据
this.state = FunctionState::Suspended { data };
// 立即轮询 yield
match Pin::new(&mut this.yield_fut.as_mut().unwrap()).poll(cx) {
Poll::Ready(_) => {
// 如果立即完成,返回结果
if let FunctionState::Suspended { data } = &this.state {
let result = data[0] as usize;
this.state = FunctionState::Completed;
Poll::Ready(result)
} else {
unreachable!()
}
}
Poll::Pending => Poll::Pending,
}
}
FunctionState::Suspended { data } => {
// 继续轮询 yield
match Pin::new(&mut this.yield_fut.as_mut().unwrap()).poll(cx) {
Poll::Ready(_) => {
// yield 完成,读取数组首元素并返回
let result = data[0] as usize;
this.state = FunctionState::Completed;
Poll::Ready(result)
}
Poll::Pending => Poll::Pending,
}
}
FunctionState::Completed => {
panic!("Future polled after completion")
}
}
}
}
可以看到,Suspended 状态中包含了那个大数组。当状态从 Initial 切换到 Suspended 时,data 会被完整保留。
由此可知,对于一个 async 函数,若临时变量需跨越 await 存活,就会被纳入状态机,导致编译时生成的 Future 大小显著增加。
明确了 Future 大小的定义后,我们接着通过代码验证其对性能的影响。在之前的 mem::size_of::<Fut>() >= 2048 条件中可以看到,如果 Future 的大小过大,Box::pin(future) 会从堆上分配内存,理论上会带来额外开销。这种设计可能基于几点考量:小型 Future 直接嵌入任务结构体中,能提升缓存命中率;而大型 Future 若嵌入,会让任务结构体过于臃肿,占用过多栈空间,反而不利于性能。
我通过实验验证,若 async 函数中包含较大的结构体,确实会导致 Future 执行变慢(即便计算逻辑相同):
RESULTS:
--------
Small Future (64B): 100000 iterations in 30.863125ms (avg: 308ns per iteration)
Medium Future (1KB): 100000 iterations in 61.100916ms (avg: 611ns per iteration)
Large Future (3KB): 100000 iterations in 105.185292ms (avg: 1.051µs per iteration)
Very Large Future (10KB): 100000 iterations in 273.469167ms (avg: 2.734µs per iteration)
Huge Large Future (100KB): 100000 iterations in 5.896455959s (avg: 58.964µs per iteration)
PERFORMANCE RATIOS (compared to Small Future):
-------------------------------------------
Medium Future (1KB): 1.98x slower
Large Future (3KB): 3.41x slower
Very Large Future (10KB): 8.88x slower
Huge Large Future (100KB): 191.44x slower
在微调这个 async 函数时,我发现了一些微妙的现象。为了让 data 跨越 await 存活,我特意在最后引用了它,以防编译器优化掉:
async fn huge_large_future() -> u64 {
let data = [1u8; 102400]; // 10KB * 10
let len = data.len();
future::yield_now().await;
(data[0] + data[len - 1]) as u64
}
理论上,若改成下面这样,由于 len 在 await 前已计算完成,后面又没用引用到,生成的 Future 大小应该很小:
async fn huge_large_future() -> u64 {
let data = [1u8; 102400]; // 10KB * 10
let len = data.len();
future::yield_now().await;
0
}
fn main() {
let ex = Executor::new();
let task = ex.spawn(huge_large_future());
let res = future::block_on(ex.run(task));
eprintln!("Result: {}", res);
}
然而,我发现 data 仍被保留在状态机中,即便 len 未被后续使用。这涉及到编译器如何判断变量是否跨越 await 存活的问题。当然,若显式限定 data 的生命周期在 await 之前,它就不会被纳入状态机:
async fn huge_large_future() -> u64 {
{
let data = [1u8; 102400]; // 10KB * 10
let len = data.len();
}
future::yield_now().await;
0
}
我查阅了 Rust 编译器的源码,发现变量是否跨越 await 存活由 locals_live_across_suspend_points 函数 决定:
/// The basic idea is as follows:
/// - a local is live until we encounter a `StorageDead` statement. In
/// case none exist, the local is considered to be always live.
/// - a local has to be stored if it is either directly used after the
/// the suspend point, or if it is live and has been previously borrowed.
在我们的代码中,let len = data.len() 构成了对 data 的借用,因此 data 被保留在状态机中。或许这里仍有优化的空间?我去社区问问看。
所有实验代码均可在以下链接找到:async-executor-examples。
在 Rust 异步编程中,代码的细微调整可能引发性能的显著波动。深入理解状态机生成的内在机制,能助你打造更高效的异步代码。下次编写 async fn 时,不妨自问:这个状态机究竟有多大?

这个项目的挑战还是挺大的,上主网只是一个新的开始。我在开发过程中学到了很多东西,这是我前段时间写的一篇关于 Fiber 的大致介绍。
Fiber 是基于 CKB 构建的闪电网络协议,旨在实现快速、安全且高效的链下支付解决方案。借鉴了比特币闪电网络的核心理念,Fiber 针对 CKB 的独特架构进行了深度优化,提供低延迟、高吞吐量的支付通道,适用于微支付和高频交易等场景。与传统的闪电网络不同,Fiber 拥有多项关键特性:
在这篇文章中,我们将从源码层面介绍 Fiber 的整体架构和主要模块,以及项目的后续展望和规划。
我们从最高纬度去看一个 Fiber Node,主要包含下面几个主要模块:

其中:
Channel 的管理是闪电网络中非常重要、也是异常复杂的部分。其中的复杂性主要来自于 Channel 内部数据和状态的改变来自于网络上 peer 之间的交互,事件的处理可能存在并发上的问题,一个 Channel 的双边可能同时都有 TLC 的操作。
闪电网络本质上是一个 P2P 系统,节点之间通过网络消息相互通信进而改变内部的数据状态,我们发现 Actor Model 非常适合这种场景:

Actor Model 极大地简化了代码实现的复杂度,使用 Actor model 后我们不需要使用锁来保护数据的更新,当一个 Message handle 结束的时候,我们会把 channel state 的数据更新写入 db。而像 rust lightning 如果没用使用 actor model,就可能会涉及到非常复杂的锁相关的操作。
我们的所有的重要模块都采用了 Actor Model,Network Actor负责节点内外的消息通信,比如一个节点要给另外一个节点发送 Open channel 的消息,这个消息首先会通过 Fiber node A 的 channel actor 发送到 network actor,node A 的 network actor 通过更底层的网络层 tentacle 发送到 node B 的 network actor,然后 network actor 再发给 node B 里面的所对应的 channel actor。

在一个 Fiber Node 内部,每一个新的 Channel 我们都会建立一个对应的 ChannelActor,而这个 ChannelActorState 里面包含了这个 Channel 所需要持久化的所有的数据。采用 Actor Model 的另外一个好处就是我们能够在代码实现过程中直观地把 HTLC 网络协议相关的操作映射到一个函数里,比如下图中展示了 HTLC 在多个节点之间的流转过程,对于 A 到 B 之间的 AddTlc 操作,节点 A 里的 actor 0 所应对的代码实现就是 handle_add_tlc_command,而节点 B 里的 actor 1 所对应的代码实现是 handle_add_tlc_peer_message。

Channel 之间的 TLC 操作是复杂度非常高的部分,我们在实现上延用了 rust-lightning 的方式,使用状态机来表示 TLC 的状态,根据 actor 之间的 commitment_sign 和 revoke_ack 的消息来改变状态机,总的来说 AddTlc 的操作流程和两个 Peer TLC 状态的改变过程如下:

每个 Fiber 节点都通过 Network graph 保存了自己对于整个网络的了解情况,本质上这是一个双向有向图,每一个 Fiber 节点对应于 Graph 里面的一个 vertex,每一个 Channel 对应于 Graph 里面的一个 edge,出于隐私保护的需求,Channel 的真实 balance 不会广播到网络中,所有 edge 的大小是 Channel 的 capacity。
在支付开始前,发起者会通过路径规划找到一条通往收款者的路径,如果有多条路径就需要找到各方面综合考虑最优的路径,而在信息缺失的图中找到最优路径是一个在工程上非常具有挑战性的问题,Mastering Lightning Network 对这个问题有很详细的介绍:

在 Fiber 中,支付动作由用户向 Fiber Node 通过 RPC 发起请求,节点收到请求后会创建对应的 PaymentSession 来追踪支付的生命周期。
目前我们的路径规划的算法是一个变形的 Dijkstra 算法,这个算法是通过 target 往 source 方向扩展的,搜索路径的过程中通过折算支付成功的概率、fee、TLC 的 lock time 这些因素到一个 weight 来进行排序。其中的概率估算来自于每次支付的结果记录和分析,实现在 eval_probability。路径的选择质量好坏对于整个网络的效率和支付的成功率非常重要,这部分我们今后将会继续改进,Multipart payments (MPP) 也是一个今后可能要实现的功能。
路径规划完成后下一步就是构建 Onion Packet,然后给通过 source node 发起 AddTlcCommand。后续如果 TLC 失败或者成功会通过事件通知的方式处理。
整个支付的过程可能会发生多次的重试,一个常见的场景就是我们使用 capacity 作为 Graph 里边的容量,可能路径规划出来的路线无法真实满足支付的大小,所以我们需要返回错误并更新 Graph,然后再继续自动发起下一次路径规划尝试进行支付。
Fiber 的节点之间的通过相互发送广播消息交换新的 Node 和 Channel 信息,Fiber 中的 Gossip 模块实现了 Botls 7 定义的 routing gossip。在实现过程中我们的主要技术决策在这个 PR: Refactor gossip protocol里面有描述。
当一个 Node 节点第一次启动的时候,会通过配置文件里的 bootnode_addrs来的连接第一批 peers,广播消息的类型有三类:NodeAnnouncement 、ChannelAnnouncement 、ChannelUpdate 。
Fiber 会把收到的广播的原始数据保存下来,这样方便通过 timestamp + message_id 组合的 cursor 来对广播消息进行检索,以方便来自 peer node 的 query 请求。
当一个节点启动的时候,Graph 模块会通过 load_from_store来读取所有的 messages,重新构建自己的 network graph。
我们采用基于订阅的方式在网络中传播消息。一个节点需要主动向另一个节点发送广播消息过滤器(BroadcastMessagesFilter),另一个节点收到了该消息之后会为其创建对应的 PeerFilterActor,在构造函数里创建 Gossip 消息订阅。通过基于订阅的模型这种方式,我们可以让其他节点接收在特定的 cursor 之后接收到新保存的 Gossip 消息。
处于隐私保护的需求,payment 的 TLC 在多个节点之间传播的时候,每个节点只能知道自己所需要的信息,比如当前节点接收的 TLC 的 amount、expiry、下一个传播的节点等信息,而无法获得其他不必要的信息,而且每个 hop 在发送 TLC 给下一个节点的时候也需要做相应的混淆。
类似的,如果 payment 在某个节点传播的过程中发生了错误,这个节点也可能返回一个错误信息,而这个错误信息会通过 payment 的 route 反向传递给 payment 的发起节点。这个错误信息也是需要 Onion 加密的,这样确保中间节点无法理解错误的具体内容,而只有发送者能够获得错误内容。
我们参考了 rust-lightning 在 onion packet 的实现,发现其实现方式还是不够通用 (会绑定于其项目的具体数据结构),所以我们自己从头开始实现了 fiber-sphinx,更详细的内容请参考项目的 spec。
涉及到 Onion 加解密的几个关键节点在这三个地方:

Watchtower 是闪电网络中的重要安全机制,主要用于帮助离线用户防止资金被盗。它通过实时监测链上交易,并在发现违规行为时执行惩罚交易,从而维护闪电网络的公平性和安全性。
Fiber 的 watchtower 实现在 WatchtowerActor里,这个 actor 会监听 Fiber 节点中发生的关键事件,比如一个新的 Channel 创建成功时将会收到 RemoteTxComplete,watchtower 就在数据库里插入一条对应的记录来开始监听这个通道,Channel 双方协商成功关闭时会收到 ChannelClosed,watchtower 从数据库中移除对应的记录。
在 Channel 中 TLC 交互时候,watchertower 将会收到 RemoteCommitmentSigned 和 RevokeAndAckReceived,分别去更新数据库中存储的 revocation_data 和 settlement_data,这些字段将会在后续创建 revocation transaction 和 settlement trasaction 的时候用到。
Watchtower 的惩罚机制是通过比较 commitment_number 来判断 CKB 的链上交易是否使用了老的 commitment transaction,如果发现违规则构建一个 revocation transaction 提交到链上进行惩罚,否则就构建发送一个 settlement transaction 提交到链上。
目前 Fiber 还处于前期活跃开发阶段,后续我们可能将继续做以下几个方面的改进:
Let’s scale P2P finance together! 🩵
]]>年初就起了个好头,众多加密货币开始上涨。总体而言,2024 年是个加密货币和区块链的大年。有那么一小段时间我每天都在关注涨跌,渐渐地我发现这个领域涨跌都是太频繁了,而过多关注除了浪费时间并没有什么大的用处。因为两年前开始在这个领域工作,所以我自然也会投资一些加密货币。刚开始我稍微接触了一下合约,但很快亏掉了几千元,算是交了学费。然后很快理智地退出了,合约本质上来说和赌博有点类似,钱来得也快亏得也快,但大概率是要亏钱的。
我听从了一些行业老鸟的建议,拿住比特币就行,其他的看着买点。我从 2023 年开始陆续买入了一些比特币,当时的价格不算高,到今年年底看来也有不少涨幅了。我抱着长期拿住的心态在买入,打算至少持有八九年以上。所以现在我基本不怎么关心价格了,如果买了就当作这钱是存在那里好了,把时间幅度拉长,我相信比特币未来会更值钱。我愿意相信这个行业是因为从技术的角度考虑是即有趣又有挑战。这两年来我工作的项目和比特币是非常类似的,就当作为信仰充值。
2024 年 5 月开始我投入到了公司的一个新项目开发上,这是个完全开源的项目叫作 nervosnetwork/fiber,简而言之就是 CKB 上的闪电网络实现。所以 2024 年的大部分时间我都专注于这个项目,因为这是个新项目所以很多功能都是从头开始实现,这对于程序员来说时段快乐时光,毕竟维护老项目很多时候都是在考虑兼容性,没有什么大量写代码的快感。
闪电网络似乎现在已经过了最火的时候,但却是古典区块链技术的代表。如何在去中心的环境中构建出信任通道,这是个非常复杂的问题,大多数时候我们都是在参考 BOLT这个规范。开发过程中一直需要考虑的是这样安全么,如果对方出错或者发出恶意的请求会怎样,channel 的基本保证是任何时候任意一方都可以退出,而不会造成资金上的损失,另外还需要兼顾的是隐私的问题,所以支付的多跳传输需要使用洋葱加密,错误的返回链路上也需要用洋葱加密。反正本质上,这些都归结为数学问题,多签、加密和解密、哈希时间锁合约,确保了交易的不可伪造性和隐私性。我不打算继续在这篇文中写更多关于闪电网络的技术细节,也许以后会写一系列的相关文章。
总体来说,2024 年又开心地写了一年代码,甚至我觉得技术越做越有意思了:

远程工作两年后,我更多采用把问题留在脑海中,时不时拿出来思考的工作方式。有几次这样的经历,我像是在睡觉的过程中还在思考某个问题,然后第二天起来还记得当时想出来的办法。
另一方面,有些遗憾的是我今年参与 Rust 等开源项目的时间比较少了,写文章也比较少。似乎在公司的项目上工作得足够有趣、找到了足够的收获感,没有多少动力和时间去做其他项目。但意想不到的是今年年底还是收到了 Rust 基金会的邮件,愿意资助我一年继续做贡献。所以明年我应该还是会把一些业余时间投入到 Rust 项目上,这也算是把爱好折腾成了责任和义务。可以说 Rust 延长了我的技术生命,让我幸运地投入到一堆 Rust 开源项目上,并且找到适合自己的公司,以远程的方式工作。
因为整天除了带娃和宅在家编程,2024 年我似乎没认识什么新的人,社交圈很小,甚至到了年底我才想起是不是该约上许久不见的朋友线下聊聊。我不知道如何解决这个问题,这有一半是远程工作带来的副作用,另一半就是人到中年在社交上的需求小了。我还在 Cambly 上练习口语,这已经变成了我强迫自己和人沟通的一个渠道,我每周三节课一共一个半小时,其中一个小时大多数都是和我的固定老师聊,他比我大 10 岁左右,我们聊过很多话题,我给他科普区块链等技术领域、做模拟演讲等。另外我喜欢找那些一直在旅游的人或者退休了的人聊,因为通常能听到一些好玩的事情,有次有个一直满世界漂流的人对我说他希望的是 die with my boots on,我一下子没听出其含义,后来通过他的解释我知道了这个俗语的意思:一个穿着靴子死去的人会一直生活和战斗到最后,他们像往常一样生活时去世,而不是因为年老和因疾病、体弱等卧床不起,对他来说他希望自己死在旅游的途中。我想这种生活态度真是太好了,而且他也在践行自己的这种生活方式。我喜欢看那些一直在路上的博主,比如 十三要和拳头 和 刘伟元的旅行,可能正是因为我已经不太可能做到像他们那样随心所欲地玩耍。
说到旅行,今年五月底公司团建我们去了大理待了一周,那里的风景和气候都还挺不错,有些地方显得商业化太重,但沿着洱海骑行和在苍山徒步都非常惬意。夏天我和家人去了一趟北方,走的是比较热门的路线,青岛、威海、大连。不过这趟很累,因为暑假期间都是家长带着孩子,所以去哪里都是人挤人,但其实孩子们也还太小,他们只是想找个地方玩沙子赶海,而对于历史遗迹之类的地方则完全不感兴趣。
11 月公司组织去了趟清迈,我们在那里举行了第一次的 CKCON,我也是第一次用英语做技术演讲。感觉清迈的基础设施还有待提升,有一次我一个人打车,司机好像是中途拐进了城中小道上歪歪扭扭的乱窜,我开始担心自己会不会被拉去割腰子。其实司机是个好人,到了终点后我才发现自己的 Grab 不能付款,他就耐心得等我去找人借现金。
我很喜欢公司组织的线下聚会,不但可以和平时合作的同事见面聊聊,也可以暂时从一直带娃的生活中抽离出来,每次出去我的感受是这样的:

所以带孩子真的很累么?确实比较累,而且得看这个孩子是几岁。我喜欢带两三岁到五岁这个年龄段的孩子,因为这时候的孩子都是天真,又比较听话。像我大女儿到了七岁八岁,开始有自主意识了就很淘气,很多时候也不怎么听话,有时候会让我焦头烂额。小学二年级的作业比较多,我女儿每天需要在家里花大概一个小时来写作业,而且现在的数学作业看起来很多应用题,像我女儿这种没接受过幼小衔接的做起来就很慢,肯定需要家长帮忙。有时候孩子做了坏事,我会想起自己小的时候也做过类似的事情,但我现在已经变成了孩子眼中那个严格的父亲了。有次父亲看我对孩子发火,就对我说对孩子还是要适当宽容一些,然后提起小时候每次打了我之后都会心里很后悔,我听了就很感慨。
今年下半年开始,我又开始经常打篮球了。刚开始主要是为了缓解久坐的疲劳,后来就变成每天不断地提升自己的投篮技术。深圳的秋冬季节很舒服,我经常中午 11 点半去小区篮球场投篮差不多一个小时,顺便晒晒太阳。每天这样练习之后投篮技术有了很大的提升,无人防守的情况下基本有 70% 左右的命中率。一个人投篮这种事情看起来很枯燥和无聊,但其实沉下心来运动的感受非常好,我把刻意练习的心态投入到了这个项目上,那一个小时内能达到类似心流的状态,时间变得清澈,仿佛只有我和篮球了。投篮最重要的是掌握出手时候的平衡度,手腕和手指用力,让篮球后旋起来,练习多了投篮动作就形成了肌肉记忆,只要动作做完就大致能知道是否命中,篮球空心入网的声音真是太悦耳了。磨练技艺真是一种最好的状态,而编程、写作、篮球都是这样的事情。
打篮球已经是我整整 20 年的爱好了,但我从未好好练习过投篮,可惜左膝盖在 2017 年伤过一次,运动激烈了容易酸疼,所以再也不怎么去和年轻人打半场了,即使偶尔玩玩总是担心自己受伤,在场上变得畏手畏脚。那些之前理所当然的事情变得奢求了,能力和自由渐渐地丧失,这真是大龄带来的切身痛苦。
有一次我傍晚还在练习投篮,有个看起来比我大七八岁的大哥过来,渐渐地我们聊了起来。我看他的篮球鞋很漂亮,他说是他儿子的,应该叫作空军一号。我们边投篮边聊天,一直聊到天完全黑掉看不到篮筐。没想到这样一个在国企工作的大哥也经常翻墙看新闻,说这几年的情形是聪明人都在蛰伏和休息。还有一次我正在投篮,刚好碰到一个幼儿园班的小朋友们经过,因为球场上就只有我一个人在锻炼,他们就围在场边观看,渐渐地我每进一个球小朋友们就开始欢呼,每次没进就惋惜叹声,这真是个有趣的经历。日子大多平淡如水,但这些小瞬间却留在了心里。
回想起来,今年生活中的一些其他变化,彻底不看朋友圈,不怎么追新闻,总体来说信息更闭塞了。但 2024 却是我生活上最朴素充实的一年,上班做感兴趣的项目下班做喜欢的运动,在我做了很多减法后,现在的生活好像就是自己理想中的状态。
祝各位新年快乐!
]]>CKB 的每一个交易在提交到交易池之前都会经过一个 script verification 的过程,本质上就是通过 CKB-VM 把交易里的 script 跑一遍,如果失败了则直接 reject,如果通过了才会继续后面的流程。

这里的 script 就是一种可以在链上执行的二进制可执行文件,也可以称之为 CKB 上的合约。它是图灵完备的,我们通常可以通过 C、Rust 来实现这些 script,比如 nervosnetwork/ckb-system-scripts 就是 CKB 上的一些常用的系统合约。用户在发起交易的时候就设置好相关的 script,比如 lock script 是用来作为资产才所有权的鉴定,而 type script 通常用来定义 cell 转换的条件,比如发行一个 User Define Token 就需要指定好 UDT 所对应的 type script。script 是通过 RISC-V 指令集的虚拟机上运行的,更多内容可以参考 Intro to Script | Nervos CKB。
通常一个简单的 script 在 CKB-VM 里面执行是非常快的,VM 上跑完之后会返回一个 cycle 数目,这个 cycle 数量很重要,我们用来衡量 script 校验所耗费的计算量。一个合约的 cycle 数多少,理论上来说依赖于 VM 跑的使用用了多少个指令,这由 VM 在跑的时候去计算 VM Cycle Limits。
随着业务的复杂,逐渐出现了一些大 cycles 的交易,跑这些交易可能会耗费更多的时间,但我们总不可能让 VM 一直占着 CPU,比如在处理新 block 的时候,CPU 应该在让渡出来。但之前 CKB-VM 对这块的支持不够,为了达到变相的暂停,处理大 cycles 的时候我们可以设置一个 step cycles,假设我们设置为 100 cycles,每次启动的时候就把 max_cycles 设置为 100,这样 VM 在跑完 100 cycle 的时候会退出,返回的结果是 cycle limitation exceed,然后我们就知道这个 script 其实是没跑完的,先把状态保存为 suspend,然后切换到其他业务上做完处理之后再继续来跑。回来后如何才能恢复到之前的执行状态呢,这就需要保存 VM 的 snapshot,相当于给 VM 当前状态打了一个快照:
根据这个机制,我们老的 script 校验大交易的整个流程是通过一个 FIFO 的队列保存大交易,然后通过一个后台任务不断地从这个队列中取交易跑 VM,每次都跑 1000w cycle 左右,在这个过程中就可能切换出去,没跑完的交易继续放入队列等待下一次执行:

对应到代码就是 ChunkProcess 这个单独服务来处理的。由于 ChunkProcess 是一个单独的服务,它的处理流程和其他交易的处理流程是不一样的,这样会导致代码的复杂度增加,比如:
chunk_process 里的 process_inner 和 _resumeble_process_tx。这些问题的根本是 VM 只能通过 cycle step 的方式来暂停,有没有一种方式是我们任何时候想暂停就暂停,就是 event based 的方式。所以后来 CKB-VM 团队做了一些改进:
这个方法的本质是通过 VM 的 set_pause 接口,把一个 Arc<AtomicU8> 的 pause 共享变量设置给 VM。然后在 VM 外通过更新这个 pause 的变量让 VM 进入暂停状态或者继续执行,这样我们就不需要 dump snapshot 等操作,因为 VM 整个就还是在内存中等着:

基于这些改进我们可以重新设计和实现 CKB verify 这部分的代码,主要是为了简化这部分代码,并且提高大交易处理的效率。这是一个典型的 queue based multiple worker 方案:

主要的核心是就是这段异步执行 VM 的逻辑:chunk_run_with_signal。做的过程中发现一些其他问题:
SubmitLocalTx 和 SubmitRemoteTx 如果 verify 失败目前会立即返回 Reject,如果改成加入队列的方式,这个结果无法实时给到,所以做了如下改动:
Child VM 是执行 syscall 的时候执行 machine.run ,如果不改这块执行 child vm 的时候不可暂停
Pause 传递给子,然后暂停的时候给父的 Pause 设置暂停,这样所有的子 machine 同样返回 VMError::Pause ,同时把当前的 machine 栈重新入栈,恢复的时候继续执行,这里逻辑比较重,相关代码实现:run_vms_child。整个 PR 在这里:New script verify with ckb-vm pause
]]>如果一个交易成功发送到交易池,但可能出现因为费用较低而一直得不到处理。之前 CKB 没有其他措施来处理这种情况。
例如 Dotbit 4 位域名注册拥堵 这个事故发生过程中,CKB 的应用方无法使用任何方式来尽快让自己的交易被打包,这就是引入 Replace-by-fee(RBF) 的原因,我们需要一个机制来提高已经在交易池里交易的费用,替换掉旧的交易,让新的交易尽快被打包。
在新的 multi_index_map 重构后,交易在 pending 阶段也会按照交易的 score 来优先处理 (通常费用高的交易 score 也会高),这会避免高费用的交易被阻塞住,所以理论上述需要手动提高费用的情况会减少,但我们还是需要 RBF 来手动提高交易的费用,应对意外的情况。
另外,RBF 可能将多个老的交易替换出去,因此也是将两个或多个支付合并为一的方法,例如下图所示,如果满足条件 tx-a, tx-b, tx-c, tx-d 都会被 tx-e 这个交易替换掉:

中本聪最初的 Bitcoin 版本中就有引入一个 nSequence 的字段,如果相同交易的 nSequence 更高,就可以替换之前老的交易,这个实现的问题是没有支付额外的 fee,miner 没用动力去替换交易,另外因为没有 rate-limiting 从而导致可能被滥用,所以 Bitcoin 在 0.3.12 版本中禁止了这个功能。后来 Bitcoin 重新引入了新的 RBF 改进,主要包括需要支付额外的费用来替换老交易,另外为 RBF 指定了更多的限制条件。
在 CKB 上我们之前做过两次 RBF 的相关调研,因为之前 Pending 是一个 FIFO 的数据结构,所以处理替换不是很方便,在 RBF in CKB(draft 2023.01.05) 尝试引入一个 high priority queue 来实现 inject-replace。交易池改造之后,整个交易池可当作一个优先队列,所以应对 RBF 会简单很多。

pre-check 为 entry 加入到 tx-pool 之前必须要做的检查,之前只是做双花的检查,新增 RBF 后如果双花检查失败(这里意味着冲突),继续做 RBF 的相关检查,如果 RBF 检查成功则也返回成功,否则直接返回错误。这里默认直接做 resolve_tx 的检查,如果成功则走正常流程,目的是不给正常流程增加额外成本。所以这就是pre-check 修改后的主要逻辑 。RBF 的检查规则参考 Bitcoin 的六条,check_rbf 初步实现
实现细节:(Bitcoin Core 0.12.0)
~~1. 交易需要声明为可替换交易~~
2. 新替换交易没有包含新的、未确认的 inputs
3. 新替换交易的交易费用比待替换交易费用高
4. 新替换交易费用必须比节点的 min relay fee 高
5. 待替换交易的子交易数量不可超过 100 条(即使用了该交易的任意 outputs,该交易替换后它们将被从内存池中移出)
6. 因为 ckb 是做了两步提交,我们新增规则:被替换的交易只能是 Pending 或者 Gap 阶段的。
我们不给交易加新的字段表示是否可以被替换,而是通过节点是否配置了 min_rbf_rate 来决定是否能做替换,因此 规则 1 不做对应考虑。
修改 tx-pool 的 submit_entry 函数,传入 conflicts,在新增 entry 之前把所有冲突的交易删除 放入 rejected 记录,另外确保所有检查完成了之后才做删除和写操作:submit_entry 逻辑。
最终实现在这个 PR 里Tx pool Replace-by-fee。
在最初的实现版本中,隐藏了一个并发的 bug 后来在测试发现了。RBF 的检查如果放在 pre-check 中,如果多个线程中的多个交易发生了冲突,input resolve 可能会出问题。Fix concurrency issue for RBF 这个 PR 修复了这个问题,把 RBF 的冲突检查移动了 submit entry 之前,因为在这个函数里面会持有 write 锁。
后来我们在做闪电网络的时候又发现 RBF 可能会引入 cycling attack 的风险,这个攻击通过构造巧妙的新交易,让支付路径上的中间节点的 commitment tx 不能按时上链,Lightning Replacement Cycling Attack Explained这篇文章有更详细的描述。
所以我们后来又做了这么一个改进:Recover possible transaction in conflicted cache when RBF 来规避这个问题。
]]>第一次听说 CKB 的读者可以参考这个文档以了解什么是 CKB 以及如何工作的:How CKB Works | Nervos CKB。
我加入 Cryptape 之后一年内做的主要工作,涉及到交易池重构、Replace-by-fee 功能、以及 new-verify。这是第一篇关于交易池重构的文章。
在 bitcoin 中交易池叫作 mempool,比如 mempool - Bitcoin Explorer 这个网站就很好地展示了其当前的状态。
交易池是 bitcoin 中的一个重要的组件,但感觉专门关于这块的资料很少,只能通过 PR 和邮件列表上的讨论看到一些文档。但交易池非常重要,因为一个交易要上链必须会通过交易池,而其中的交易打包算法涉及到如何选择合适的交易,这里面有很多因素需要考虑,所以在实现上也是比较复杂的。
当一个交易被提交到一个节点时,或者一个节点从网络中同步到交易时,这个交易首先需要被加入到交易池中,交易池里会根据一定的算法去选择下一个需要被打包的交易,另外交易池作为一个缓存,我们需要为其设置一个最大的 size。所以交易池里面最重要的两个操作就是 packaging 和 evicting。
交易池里面的交易存在父子关系,打包的时候需要从交易链的纬度去考虑,后面的 Replace by fee 这些功能也需要关注整个交易的所有子交易。

根据 RFC consensus-protocol 的设计,CKB 里的 tx-pool 采用了两段提交的方式:

相应地在交易池最初实现的时候, ckb 的代码实现中 tx-pool 同样采用了三个独立的队列,具体定义如下:
pending 交易刚加入到交易池时候的状态,我们每次只能处理不多于 MAX_BLOCK_PROPOSALS_LIMIT 个交易,交易需要先进入 gap 备选,具体代码逻辑在 update_proposals 。gap 已经被 proposed 了,但是还不能被打包,需要等一个块后才能被打包,所以这只是内部中间过渡状态。proposed 交易可以加入到 block_template.transactions , 最终打包到 block 里,具体代码逻辑在 block_assembler。实现中 pending 和 gap 同样都是使用了 PendingQueue(LinkedHashMap),而 proposed 采用了 SortedTxMap(HashMap + BTreeSet) :
pub struct TxPool {
pub(crate) config: TxPoolConfig,
/// The short id that has not been proposed
pub(crate) pending: PendingQueue,
/// The proposal gap
pub(crate) gap: PendingQueue,
/// Tx pool that finely for commit
pub(crate) proposed: ProposedPool,
....
pub(crate) expiry: u64,
}
这样的实现存在以下问题:
我们不容易对所有在交易池中的 entry 做统一排序,这样会存在以下问题:
pending, gap 和 proposed 除了所采用的数据结构不同外,有很多逻辑雷同的代码,比如 entry 的新增和删除等操作,同样都维护了 deps 和 header_deps,resolve_conflict, resolve_conflict_header_dep, resolve_tx 等函数的逻辑也是类似的,但实现上有些细微差异,这导致长期来说代码不容易维护。
同样我们在 tx-pool 上对 entry 做迭代和查询时,需要依次针对 pending, gap, proposed 做相同的逻辑,比如 resolve_conflict_header_dep 这样的函数在 pool 中有几个类似的,甚至 get_tx_with_cycles 这样的函数,需要依次判断各个队列。
实现其他功能不方便,比如我们如果要实现 Replace by fee,就需要找交易池中和新交易有冲突的交易,我们需要在三个数据结构上分别进行检查才能得到结果。
基于以上解决现有问题、应对未来的潜在需求、保持代码可维护性的角度,同时参考 Bitcoin txmempool 的实现,我们提出引入 Multi_index_map 对 tx-pool 进行重构。
总体方向是把所有的 entry 放入统一的数据结构中进行管理,加入一个新的字段 status 标识目前 entry 所处的阶段,然后通过 index_map 的方式根据不同的属性进行排序和迭代:
pub enum Status {
Pending,
Gap,
Proposed,
}
#[derive(MultiIndexMap, Clone)]
pub struct PoolEntry {
#[multi_index(hashed_unique)]
pub id: ProposalShortId,
#[multi_index(ordered_non_unique)]
pub score: AncestorsScoreSortKey,
#[multi_index(ordered_non_unique)]
pub status: Status,
#[multi_index(ordered_non_unique)]
pub evict_key: EvictKey,
// other sort key
pub inner: TxEntry,
}
其中根据 Rust 社区的 multi_index_map 内部实现采用的数据结构看,性能上应该没有什么大问题:
具体实现时我们是否把 inner 也放在 Slab 里面以后可以通过 benchmark 来选择,从实现的简洁性角度考虑统一放在一个数据结构里面更容易。
目前的实现版本:Tx pool rewrite with multi_index_map #3993
我们首先只是做模块内的重构 (保持对外逻辑和以前一样),当然考虑引入了新的数据结构,不管是从性能上还是内存占用上都会有一些影响。
为了做统一排序这件额外的事,本质上我们引入了额外的 Map(FxHashMap 或 BTreeMap) 来存储,所以比以前需要更多内存。另外,我们有时候需要调用 get_by_status 来筛选某个状态的 entries,这在新的实现里面需要先从 index 里面找出 slab 的 id,然后再找到对应的 entry,所以必然也会比以前慢。
从最终的性能对比结果上,除了内存会稍微有增加,性能上没有大的变化。另外我们在实现的过程中对所用到的 Rust 包 multi-index-map 做了一些贡献:Non-unique index support, capacity operations, performance improvement & more by wyjin
]]>简单介绍一下背景,xz 是一个开源的无损压缩工具,在出事之前可能很少有人注意到这个压缩库使用如此之广,几乎任何一个 Unix-Like 的操作系统里面都有 xz-utils。在两年多的时间里,一个名为 Jia Tan 的程序员勤奋而高效地给 xz 项目做贡献,最终获得了该项目的直接提交权和维护权。之后他在 libzma 中加入了一个非常隐蔽的后门,该后门可以让攻击者在 SSH 会话开始时发送隐藏命令,使攻击者能够跳过鉴权远程执行命令。
Timeline of the xz open source attack 总结了该事件的主要时间点,这里我挑一些关键节点:
ifunc,这也是为了避免 fuzz 可能发现后门。.,使得代码会编译失败从而让 Landlock 不会被激活。从主要攻击者的名称看似乎是中国人,但 Git 昵称和时区这种东西很容易伪造,有人分析过开发者的代码提交时间,分析得出实际可能是欧洲人/以色列人冒充。
但不可否认,肯定会有不少国外的开发者会默认这就是中国人所为,我也看到了一些开发者开始带节奏,开始找各种和 Jia Tan 有过互动的中国程序员。
我倾向于相信这不是中国攻击者,感觉其 commit 信息里面的英文中没找到中式表达。比较确定的是,从这些马甲之间的密切配合来看,这像是一个有密谋的组织团体。
开源意味着透明,但并不意味着安全。
10 多年前我们经历了 OpenSSL 的心脏滴血,如今类似的事情再次发生。甚至这次事件的性质更严重,心脏滴血漏洞本身是因为代码的逻辑问题导致被恶意利用,而这次是攻击者通过供应链恶意植入后门。
有一种观点是开源软件被更多人 review,所以理论上来说安全漏洞更容易被发现。但实际上看来,被巧妙设计过的代码改动,很不容易被发现问题,比如这次事件中这个提交,我相信绝大部分开发者无法发现被恶意添加的 .:

这次后门被发现有很大的运气成分,多亏了 Andres Freund 的细心和刨根问底的精神,这也算是有足够多的眼睛盯着所以发现了问题吧。
如果有一个开源贡献者的身份识别机制,就可能预防类似的事情。我看到有人举例 Linux Kernel 提交必须使用 Git 的 Sign-off,但这个 Sign-off 更多的是在解决法律上的问题,Sign-off 本来就是因为法律诉讼而引入的。而且,在最坏情况下,一个开发者可能被社工或者入侵而导致身份被冒用,所以 Sign-off 并不意味着身份识别。
有的人提到通过支付来进行 KYC(Know Your Customer),这必然是不可能的,因为开源本来就是一个黑客文化的产物,大量的开发者会刻意选择使用匿名身份提交代码。
我们来看看 Bitcoin,如果论项目值钱程度,比特币的代码应该能排得上号。但比特币是支持 Permissionless and Pseudonymous development 的,甚至这是保证比特币去中心化的两个很重要的手段,中本聪的身份仍然是一个迷。中本聪选择匿名对比特币本身来说也至关重要,No one controls Bitcoin 是其价值根本。
那比特币如何保证不会被植入后门,比如这种供应链攻击?
另外比特币的安全在于 PoW,其设计本来就假设了少部分节点可能是恶意节点,除非黑客控制住了大部分节点才能造成破坏,而要达成这点在的概率可以认为就是零
从这个安全事件我们可以继续探讨开源的可持续性这个问题。这个事件中 xz 的维护者 Lesse Collin 看起来已经是处于疲于应付的地步。从贡献者统计可以看到这么多年几乎就是他一个人在给项目提交代码,Jia Tan 通过两年的潜伏就成为了贡献者第二的开发者:

长时间维护一个被大量使用的开源项目是个巨大的负担,对维护者而言不仅仅是时间的投入,有时候也是精神上的折磨,即使开发者当初的有多好的愿景,但谁也无法保证常年的持续投入。关于这点可以阅读这篇文章,The Dark Side of Open Source。
Lesse Collin 在这次事件中被利用了这个弱点,他在这封邮件里解释到自己作为项目主导者的困境:

写到这里我想起自己也曾经催过一个库的作者,是不是考虑让更多人来维护项目 Maintenance status · Issue 😅。
也许未来可能有一套机制,能够让基础开源软件的维护者得到经济激励,但这条路如何演化出来我还没看出来,如果真的出来或许与加密货币有一定关联。
可怕的是,现在还有很多人没有意识到开源贡献者困境,那些价值几千上万亿的公司也是在期望开源的开发者能够像雇员似的响应他们的 High Priority:

这个世界上还是有无数的默默耕耘的开源代码维护者,比如 SQLite,全球大概有上万亿的 SQLite 数据实例跑在服务器上、手机上、浏览器里,但这个软件其实只由 3 个程序员维护了 20 多年;几乎所有工程师都使用的工具 curl,由 Daniel Stenberg 从 1998 维护到至今;vim 的作者 Bram Moolenaar 从 1991 年维护项目到自己去世,总共整整 32 年。
实际上没有人知道,多少被广泛使用的基础组件和代码是由各种默默无闻、分毫未取的开发者在用自己的业余时间维护着。

从这个角度看,人类数字基础设施这艘巨轮其实建立在非常脆弱的基础上,说不定哪天一个地方就裂开了。我现在养成了一个习惯,升级从来不追新,任何安装到自己电脑上的二进制都小心翼翼。
这个世界上有无数的恶魔,也会有一些英雄和吹哨人,致敬 Andres Freund。
]]>从明天起,做一个 Rust 程序员,喂马、劈柴,周游世界。
10 年前我开始写第一行 Rust 程序,到如今全职远程做 Rust 开源项目,也许我真能去过喂马劈柴周游世界了😆。但回想自己的学习旅程,其中有各种曲折有几度放弃的时候,如果你也想学习或者提高 Rust 方面的技能,我这篇文章里有一条更容易的路。
Rust 1.0 发布已经快 10 年,所以并不是一门新编程语言了,从发展的角度来看 Rust 已经度过了生存期,并进入了迅速发展的阶段。从目前可见的业界方向来说,Rust 主要在以下几个方面取得了成功:
如果你对 Rust 的发展情况感兴趣,可以参考 2023 Annual Rust Survey Results。在内卷的 IT 市场,作为程序员选择一门小众的编程语言是避免过度竞争的方式,我之前介绍过其他人的类似经验,我们称之为 The Niche Programmer。Rust 还未成为主流编程语言,但潜力和发展空间很大,而门槛相对其他语言比较高,所以我认为从求职的角度来考虑是值得一试的。
之前提到 Google 投入更多的资金在 Rust 上面,钱进来后相关的职位就出来了 C++/Rust Interop Initiative Software Engineer Lead。
我 2014 年时践行每年学习一门新的编程语言,Rust 作为一门新的编程语言进入了我的视野。我开始使用 Rust 写些简单的个人学习项目,然后我继续做了 Rust exercises 。
后续几年我偶尔看看 Rust 相关的新闻和项目,时不时动手写点代码都会有点磕磕碰碰。直到四年前开始在 Github 上给一些 Rust 开源项目贡献,两年前开始给 Rust 编译器做贡献,一年前开始全职从事 Rust 区块链相关的工作。
从技术角度来说,Rust 非常有趣,这里面包含了近些年程序设计方面的一些良好实践。全职写 Rust 程序这一年多是我开发体验最好的阶段,当然有时候我们需要和编译器斗智斗勇、做类型体操,但很多问题在开发阶段给规避掉了。
Rust 的最大问题还是在于学习门槛相对较高,因为在 Rust 中程序员接触最多的 = 语义都变了。从我个人体验来说,在学会了 Rust 语法后会陷入一个瓶颈,如果日常工作中不使用 Rust,就没有多少机会去实践,另外不知道做一些什么项目。
我相信很多人同样如此,看了官方 tutorial 之后不知道如何下手,我想如果有一个经验丰富的老师带,会少走很多弯路,这就是我要介绍的极客时间训练营要解决的问题。

说起来我与这个训练营还有些渊源。
当极客时间在筹划这个 Rust 训练营的时候,策划人员找到过我问我是否有意愿当这个课程的讲师。我还稍微犹豫了一下,因为我之前也想过如何在 Rust 领域做更多的分享,我很羡慕优秀的技术分享者比如 Jon Gjengset能够非常自如地通过视频分享 Rust 方面的技术。当老师当然是个机会能从沟通和表达方面提高这方面的能力。
后来考虑到自己时间方面安排不过来,我有全职工作、有业余的 Rust 社区工作、还有三个小孩,所以我应该真没时间去录制课程了,而且他们已经找到了我认为最合适的讲师:

我看了这个项目的大纲,陈天老师希望可以教大家怎么用 Rust 比较简单的语法和技巧,来完成 80% 的日常工作,主要是通过各种实践项目来学习,这也是我最推崇的 Learn by doing 的方式。
有很多主题我都没怎么接触过,比如构建一个 ChatGPT 的应用、比如跨平台 GUI 之类的,所以我对这个课程很感兴趣,然后我和策划说能不能做这个项目的助教,后来沟通下来发现当助教也需要不少时间的,所以就没机会参与到具体的教学里面了。

总之,这个项目对于想学习 Rust 或者已经有一定 Rust 经验,但想获得更多实践经历的人是非常合适的。在和极客时间的相关人员沟通的过程中,我发现他们做事情很用心,这个训练营的课程质量我认为是有保证的。
这个训练营一共是 15 周的课程安排,其中每周都会有明确的项目安排,课后还有助教答疑。关于训练营的更多信息请参考:极客时间训练营-Rust 训练营

我最早知道陈天是他写的公众号《程序人生》,他是那种技术和文笔都非常棒的程序员,非常难得。我还看过他的 B 站上的技术讲解视频,他的演讲和分享都很流畅。陈天是极客时间《陈天 · Rust 编程第一课》专栏作者,已有 2.3w 人学过,广受好评。技术能力、演讲表达、对技术的热情这些都是讲师最重要的素质要求,所以陈天是这个训练营最好的讲师人选。
再分享一个小故事,我一年多前跳槽的时候还有些犹豫,因为自己的职业规划方面有些困惑,所以想找些人聊聊。当时我突然想到陈天之前从事过区块链方面的创业,后来从里面退出来了,所以我就想向他咨询一下。我没有他的联系方式,但灵机一动我想到了从 Git 的提交记录里面找 Email,然后抱着试一试的想法给他发了个邮件说明了自己的情况和困惑。没想到他很快给我回复了,并很详细地告诉我他对于区块链的想法,还有如何判断自己是否适合一个公司,通过各种途径了解公司的相关产品来作为决策的依据等等。
我作为一个陌生人,陈天老师都会乐于给与指导和帮助,可见为人真的很好。还没能有幸和陈天老师现实中有所交流,我本来想用当助教的机会和陈天老师多学习,但时间方面安排不过来了。希望大家能在老师的的训练营学到知识、经验、还有探索技术的乐趣!
]]>mooz/percol 这个工具是典型的 Unix 风格工具,它唯一做的事情就是通过管道接收输入,提供一个模糊搜索和 UI,用户选择后再把结果返回给后面的管道继续执行。
比如我这个 gt 的 alias 是我日常使用非常多的一个命令,做的事情就是 check out 一个 git 分支,因为我的本地通常有很多的分支,所以使用这个命令来模糊查找,然后选中就非常方便了:
alias gt="git branch| percol | awk '{ print \$1 }' | xargs git checkout "
类似的下面这个命令是 kill 掉某个进程,我们可以通过模糊搜索来找进程:
alias pk="ps eaux | percol | awk '{ print \$2 }' | xargs kill -9 "
如果你仔细总结,日常开发任何需要选择的地方都可以使用这个小工具来达到更高的效率,比如我工作的目录下有很多测试文件,测试其中一个文件的命令是 just ts file-path,我需要找到其中一个来测试:
find ./tests/ui/ -name \*.rs | percol | xargs just ts
percol 可以嵌入到很多配置里面,比如在 tmux.conf 里面加入这个配置,这样可以模糊查找 tmux 的 session 和 window:
bind B split-window "tmux lsw | percol --initial-index $(tmux lsw | awk '/active.$/ {print NR-1}') | cut -d':' -f 1 | tr -d '\n' | xargs -0 tmux select-window -t"
bind b split-window "tmux ls | percol --initial-index $(tmux ls | awk \"/^$(tmux display-message -p '#{session_name}'):/ {print NR-1}\") | cut -d':' -f 1 | tr -d '\n' | xargs -0 tmux switch-client -t"
atuinsh 是一个记录 shell 历史的小工具,不同于普通的记录 shell history 的工具,atuin 会把数据记录在一个 SQLite 的数据库文件中,这样可以支持更丰富的查询功能。
另外 atuin 也支持不同机器之间的同步,当然这需要加密通信。我目前还没使用这种场景,只是把 Ctrl-R 绑定到了 atuin。
atuin 也是一个 Rust 实现的工具。
tmux 我之前听很多人推荐过,但是我一直没怎么尝试,直到某天我需要通过网页打开跳板机登录到服务器上,网络不稳定的情况下我经常需要重新登录,这时候我尝试了一下 tmux 发现真是太好用了。
tmux 的教程很多,比如 Tmux 使用教程 - 阮一峰的网络日志。我的 tmux.conf配置很简单:
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-sensible'
set -g @plugin 'tmux-plugins/tmux-resurrect'
set -g @plugin 'tmux-plugins/tmux-continuum'
unbind-key C-b
set-option -g prefix C-Space
bind-key C-Space send-prefix
set-option -s set-titles on
set-option -g set-titles-string "#W/#T"
run '~/.tmux/plugins/tpm/tpm'
安装 tmux-resurrect 和 tmux-continuum,这样即使我重启了机器,打开 tmux 后我的 session 仍然和之前一样。
最近也有个 Rust 写的 zellij,但我认为这种软件使用更老的会更方便,比如公司的远程服务器必然有 tmux,但不一定有 zellij。
casey/just: 🤖 Just a command runner 是我喜欢的另外一个 Rust 写的工具,我的日常工作中严重依赖这个工具,比如我的 rustc-dev 项目中配置渐渐积累了这么多的配置:rustc-justfile
just 有些像 Makefile,但使用起来又比 Makefile 的语法简单和直观,我通常是来把一些常用的命令写入 justfile,然后留下经常需要调整的参数,比如:
err FILE N:
rustup toolchain link dev2 ./build/aarch64-apple-darwin/stage1/
RUSTC_ICE=/tmp rustc +dev2 {{FILE}} -Z treat-err-as-bug={{N}}
这样我执行 just err tests/ui/consts/const-eval/infinite_loop.rs 1 的时候就相当于执行配置的一系列命令。
另外我也会把一些频繁需要修改的参数放到最后一个位置,比如本来我需要执行:
CKB_TEST_ARGS={{SPEC}} make integration
在 justfile 里面配置:
test-one SPEC:
CKB_TEST_ARGS={{SPEC}} make integration
执行 just test-one SPEC 来测试不同的用例就会方便点。
你有什么喜欢的 Shell 工具,希望也能分享给我。
]]>这几年因为我在外企和开源上的工作经历,接触了很多来自各个国家的程序员,今天想写写我发现的一些国内外程序员间的差异,我相信经常混迹开源社区的人会有些类似感受。
这点是我感受最深的,我自认为已经算是一个对细节比较在乎的人了,但我接触到一些国外的程序员,他们对细节的把握让人佩服。
最近的一个例子是我在写这篇英文文档 的时候遇到的,其中的 Reviewer jordanmack 对我文档里面的所有内容逐字逐句都过了一遍,发现不懂的地方一定要弄明白。这里面有的是中英文表达差异造成的理解偏差,也有他对这个功能的逻辑上的质疑,甚至可以细节到我在文中给出的 json 例子里的数字范围和自洽性。我们在 Github 上来回讨论了很久,然后继续在 Discord 上讨论,而在这个过程中我也确实发现了些代码上需要调整的地方,最后他给我的文章几乎全部润色了一遍。
jordanmack 不算是全职的程序员,但他也有一些程序员背景。在我做开源的经历中,PR 中被挑细节的时候太多了,一度我已经不再认为自己是个对细节很把握的人了。后来我总结了一下,有时候我是在赶时间,觉得某些 corner case 就暂且跳过吧,但大多会在代码 Review 中被提出来的。
然后经历多了也就看淡了,不光是我,任何人的 PR 都可能引发大量的讨论,比如到底是使用 µs 还是 us。也许在很多人看来这是个小问题,但却引起了大量的讨论,细看其中还有些引经据典和长篇大论。随便挑一个 RFC,也都可以看到大量的讨论。
所以我的感受是,国外程序员中在意细节的比例更大。那么问题是,他们为什么能看这么细?固然其中一个很重要的原因是他们确实有时间,才能静下心来看和写。
在国内公司我也碰到过对细节的把握,但很多用在了我最讨厌的形式主义上。在微软的时候,我见过各种不够漂亮的 PPT,有的时候翻来一段 onenote 就开始讲,因为都没人关注这些。
文字表达能力是开源社区里一个非常重要的,因为但凡一个大的改动都需要和其他人广泛讨论和协作。
不少国外的程序员有文字表达的习惯,就是即使看很小的一点问题也会通过文字表达出来。这是很多国内程序员所没有的习惯,因为我们大多比较含蓄,认为多做比说强,说多容易错,说多容易暴露自己。
可能和教育和网络环境也有一定关系,如果不是刻意维持文字表达的习惯,很多人高中毕业后就没有写过几篇长文,对很多事情也没有自己的看法。
另外他们习惯使用 Email 来沟通,但中国开发人员大多习惯使用 IM 沟通。这两者还是有区别的,IM 沟通会让人不自觉地回复得更快,有的模糊想法随口就就表达了。而 Email 沟通更容易让人把事情写清楚,也更容易写得更长和有条理。
这种细微的差异长久了之后就可看出中英文技术社区的巨大差别。另外,中文网络的环境中戾气更重一些,人们对自我推销很反感,容易揣测你的意图。
很多欧美大公司里有不少只做 Individual Contributor 而不做管理的人,在这些公司里,管理和技术是两条并行线,薪资和职级挂钩,也就是说纯 IC 的岗位可能收入比管理岗位更高,因为职级更高。
管理人员和技术人员大多是上下级关系,但下属对管理人员没有绝对的单向服从关系。当然大多数情况下,管理更容易升职上去,因为纯做技术岗位不容易通过杆杠来放大自己,管理就是一种很有效的杠杆。但这种纯粹的并行晋级路线是非常重要的,可以让技术人员有更多的选择权,甚至如果对自己的管理者不满意直接给差评和换组就是了。
所以在国外程序员中,如果一个人做了多年开发,很可能就是他确实喜欢做技术和更擅长做技术。而中国职场中,管理和技术岗的差别太大了,或者说绝大部分人到了一定年龄,如果你不混个管理的 title,好像就已经落后了,甚至没有职场安全感。
另外有些人是喜欢混到管理岗之后,纯粹为了获取更高的薪水,或者是为了把不喜欢做的事情推给别人。当然,这其中也有很大一部分中国文化里的官本位的影响,还有一部分原因是太看中钱了。
我接触过一些年龄在 40 岁多的国外程序员,他们还是对技术有很大热情。如果喜欢做技术,而又能通过做技术挣钱,这没有什么失败的,这与年龄没关系,反而这是一种很好的度过自己短暂一生的方式。
只是在国内要做到这点并不容易,很多岗位做的事情本来就不够有深度,时间更久也无法积累起来足够的壁垒,业务上的开发年轻人上手很快,而需要深入做下去的岗位不够,所以年龄大了就容易失业。
国外程序员的业余时间真是非常多,如果你经常混 Github 就会发现,每当到了 12 月份就很多出来很多 aoc 字样的项目,这是他们在做Advent of Code 2023。
Advent of Code 就是整个 12 月份每天出一道题目,都是些编程谜题,有点类似 leetcode,但题目描述更长。你可以用任何语言来实现,反正要的结果就是答案。可以发现这些 aoc 项目基本都是欧美的程序员在做,因为他们大多在 12 月份有几乎一个月的假期,我在微软工作的时候,很多人也是 12 月份开始基本不见人影。
创造性的前提是不用为生存问题发愁,欧洲那些搞哲学、做研究的,大多都是家底丰厚,闲得多了自然就能搞事。如果有大把的业余时间,用来发展工作外的开源项目可就太好了。其实很多著名的开源项目只有吃饱饭没事做的时候才能搞出来,我之前在知乎问题 为什么中国程序员不如外国程序员有创造性 中写到:
荷兰人蟒蛇大叔想着哇塞圣诞假期这么长,找点事做,结果出了 Python。 日本人松本行弘,经济危机时闲得发慌,搞出了 Ruby。 芬兰人李纳斯,大三不用为了找工作背八股和考研,冬眠似地宅家里写代码,搞出了 Linux。
在 Rust 社区中的贡献人员里,除了美国,第二多的是欧洲,他们也不是为了挣钱,完全就是感兴趣做做而已,我看到好几个大学年轻人做的事情已经非常深入了,当然其中花费的时间也是很多,他们几乎一直在线。
这些总结比较粗略,另外也可能有幸存者偏差因素。这个话题很大,深入下去探讨会包含很多方面。
我也不是在抱怨,年龄大了之后发现多看看历史相关的书还挺好,让自己更容易理解所处的环境为什么是这样的,比如《中国国民性演变史》这本书值得推荐。另外《美国种族简史》这本书也值得一看,多了解了解其他人的特点和长处,努力让自己不要局限于国界,另外做到程序员中的 80% 以上水平,保持英文能力。
]]>Copilot 价格是每个月 10 美金,但我至今还没付费过,感谢微软支持开源,从测试阶段就邀请我试用,到现在还一直在免费使用。Github 应该有些政策,比如如果你持续给一些 star 数比较多的开源项目做贡献,就可以免费使用 Copilot:

我会给出日常碰到过的一些具体的实际案例截图,以方便你更直观地感受到这个工具准确度。
我们在编程过程中经常会碰到一些命令的参数记不太清楚,这种问题很适合问 Copilot。这比自己去 Google 的感受好很多,因为他几乎能完全理解用户说的自然语言,而且给出的答案简介明了:
比 Google 更好的地方在于上下文的交谈,比如我继续基于上面的问题说我的想法,他就能继续给出反馈,比如我说大概有个类似 --exact 的参数,Copilot 会继续给出使用案例。
Copilot 非常善于回答对这种 manual 类的问题,因为这是有标准答案的,并且我作为用户对这些是有判断的,只是我们细节上记不清楚了。
还有一次我发现跑测试的时候挂了,分析下来是这个命令行失败了(但既然 CI 是过的,所以必然只是在 MacOs 下失败了):
diff -u --strip-trailing-cr -r -q A_file.txt A_file.txt
这是在 diff 同一个文件,所以必然应该返回 0,但在 MacOS 下这个命令会报错:
✗ diff -u --strip-trailing-cr -r -q ./x.py ./x.py
error: conflicting output format options.
blah blah 一大堆错误
blah blah 一大堆错误
我知道这里面肯定是有参数冲突了,但我具体不知道是哪两个冲突了,所以这时候我问 Copilot:

可以看到这个解释非常清楚,并且帮我找到了问题的根源,所以我就能很快地发 PR 修复这个问题,并且我 PR 里的描述基本都是从 Copilot 里来的: Fix diff option conflict in UI test #109036
我们在写代码的时候,经常会出现固定的 Pattern,不同的语言对固定的 Pattern 有一些相对固定的代码样式。我很喜欢找 Example 类的代码,然后在这个基础上再思考或者修改:

对这种情况我们需要给 Copilot 足够的信息,他给出的 Rust 代码通常是可直接编译通过的,但当然这些示例代码需要进行仔细的修改,但这也比我自己翻 Doc 会快很多。
VSCode 上的 Copliot 更新很快,肉眼可见地体验越来越好,现在我们可以选择一段代码,然后就选择的代码来进行提问。
有时候我会选中一个函数,然后问这段函数能不能重构得更简单一些,或者我们能不能用其他方式实现。
今天让我有欲望写下这篇分享的文章是因为这个问题: Missing request extension: Extension of type
这是一个有非常明确的报错的繁琐 issue,应该就是 Server 端限制了 HTTP 的请求类型,客户端通过 curl 发 GET 请求的时候报错了,只是这个报错信息看起来很不友好,而且和老版本行为不同。所以我就选中代码中对应的函数,然后问这里为什么会有这个错:
其实我对 Copilot 解决这个问题不怎么报有信心,只是好奇先试了试,没想到 Copilot 真的能理解我的代码,并且指出了问题所在。注意看它加的注释就是我代码中缺少的逻辑 (之前的代码只是在 enable_websocket 的条件下才加载了 stream_config 这个 Extension):

加上它建议的代码之后,那个错误信息没了,但是现在发 GET 请求是另外一个问题:
Connection header did not include 'upgrade'
这看起来是服务端期望客户使用 Websocket,但是客户端只是在通过 Curl 发一个 GET 请求,并没有按照这个期望来。所以我继续问 Copilot:

他给的回复里的代码并没有直接修复问题,但里面的
you can separate the handlers for POST and GET requests
提示了我应该尝试对 HTTP endpoint 和 Websocket endpoint 的 handler 进行分开,所以我一下想到了修复方案:

如今使用 Copilot 已经成为我的一个编程习惯,就如同之前我严重依赖 Google 一样,但这个工具明显比搜索引擎高级了一个维度,当然现在我还是依赖搜索,但使用比率明显下降了不少,搜索引擎更像是成了一个书签的角色了。
我之前认为 Copliot 这种工具甚至是这辈程序员所不能体验到的东西,在我第一次尝试到 ChatGPT 居然可以理解一个函数,并且找出函数中的问题时,就感觉新的编程时代来临了。
前段时间 Redis 的创始人在文章 LLMs and Programming in the first days of 2024 中写到:
随着时间的推移,我们见证了框架、编程语言、各种库的大量涌现。这种复杂性通常是不必要的,甚至无法自圆其说,但事实就是如此。在这样的情况下,一个无所不知的“白痴”成了宝贵的助手。
这是一个事实:现今的编程大多是在微调同样的内容,只是形式略有变化。这种工作并不需要太高的推理能力。
Copilot 已经可以在一些具体的编码问题上给到我们很多帮助,甚至你把这个当作一个包含万物的文档查询工具都非常有效。
当然没有银弹,Copilot 并不能解决编程中的所有问题,比如理解大规模的程序,通过深入分析去找出 bug,或者做设计问题中的各种折中和取舍,这些都是不能取代人类的,这也是我认为编程中的乐趣还没有完全消失。
我会把繁琐和细节的问题抛给 Copilot,然后更开心地做重要和有趣的部分。
]]>