普通视图

发现新文章,点击刷新页面。
昨天以前程序员的喵

macOS 奇怪的安全扫码机制

作者 yukang
2026年2月13日 00:55

今天跑 Rust 编译器测试的时候又发现非常地慢,CPU 资源根本无法利用起来,我记得几年前碰到过这个问题,当时我写了篇文章分享出来,并发现很多人都有同样的困扰。

而我今天碰到的这个问题虽然现象一样,但解决方法又不同了。我不确定是 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 左右。

定位:syspolicyd

应该不是我上篇文章提到的 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 的安全检查路径。

Full Disk Access 有效

接着试了 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 有效

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

解决方案按排序:

  1. 给你的程序加如到 Developer Tools 列表
  2. 给你的程序加 Full Disk Access

参考

hexo-rs:玩 Vibe Coding

作者 yukang
2026年1月31日 07:00

月底升级了 Copilot Pro+,月初额度重置,这几天可以放开用,想到什么就 vibe 一把。

我的博客跑在 Hexo 上很多年了。其实没什么大问题,就是每次看到那几百 MB 的 node_modules,心里总有点膈应——生成几百个静态 HTML,真的需要这么多依赖吗?但迁移到别的博客系统又懒得折腾,所以一直拖着。

这次干脆试试:能不能用 AI 一个下午撸一个 Rust 版的 Hexo?我的目标比较简单:生成跟原来一样的静态文件,兼容我现在用的主题就行。

我用的是 OpenCode + Opus 4.5。陆陆续续聊了一下午,产出了 hexo-rs。能用,但还有些边边角角的问题。

Vibe Coding 的工具和体会以后再写,这篇主要聊 hexo-rs 的实现和踩过的坑。

技术选型

EJS 模板引擎

Hexo 主题基本都用 EJS 模板——就是把 JavaScript 嵌到 HTML 里,跟 PHP 差不多。

QuickJS 跑 JS,通过 quick-js crate 调用。好处是不用依赖 Node.js,坏处是 Windows 上编不过(libquickjs-sys 挂了),所以暂时只支持 Linux 和 macOS。

其他

Markdown 用 pulldown-cmark,代码高亮用 syntect,本地服务器用 axum。都是常规选择,没什么特别的。

踩过的坑

HashMap 的坑

这个 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();

Helper 函数

Hexo 有一堆 helper 函数:url_forcssjsdate 之类的。都得在 Rust 里实现一遍,然后塞进 QuickJS。

最烦的是 date。Hexo 用 Moment.js 的格式(YYYY-MM-DD),Rust 的 chrono 用 strftime(%Y-%m-%d)。得写个转换函数,挺无聊的活。

Partial 嵌套

EJS 的 partial 可以套娃,A 引用 B,B 又引用 C,变量还得一层层传下去。搞了个作用域栈,进 partial 压栈,出来弹栈。不难,但容易写错。

Vibe Coding 体感

代码 100% 是 AI 写的。我干的事:描述需求、review 代码、把报错贴给它让它改、偶尔拍板选方案。

像 EJS 模板引擎这种东西,自己从头写估计得半天,AI 几分钟就吐出来了。

但 AI 也挺蠢的:

  • HashMap 那个 bug 它就没注意到,我提出界面上的变化它也没反应过来
  • 一开始它写的 EJS parser 全是字符串 hardcode,丑得不行,我让它按 lexer -> AST 的套路重写了一遍
  • 代码多了以后它会忘事,前面写过的逻辑后面又写一遍

但 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 了。

How Fiber Network Works

作者 yukang
2025年12月10日 01:05

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:

  1. Opening the Channel: You both put some money into a pot and sign a Funding Tx. This goes on the blockchain (L1).
  2. The Fun Part (Off-Chain): Now that the channel is open, you can send money back and forth a million times instantly! You just update the balance sheet between you two (using HTLCs and signatures). No one else needs to know, and no blockchain fees are paid yet.
  3. Closing the Channel: When you’re done, you agree on the final balance, sign a Shutdown Tx, and tell the blockchain.

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 don’t know who sent the money.
  • They don’t know who is receiving it.
  • They only know “pass this to the next guy.”

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

在开源中使用 LLM

作者 yukang
2025年11月26日 18:15

越来越多的开发者开始使用 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 工具最终会更智能。但至少现在,它还处于一个尴尬的中间地带:用得好能帮你节省时间,用不好反而不如不用。

Rust 背锅了:Cloudflare 故障分析

作者 yukang
2025年11月20日 15:07

这两天都在讨论 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 无非是这几种场景:

  • 因为 FL2 是内存受限的,需要预先分配好内存,最大 limit 本来就只能 load 200 个 feature 的配置,现在尺寸超过了,继续 load 应该就是 OOM 错误,不可恢复。
  • load 到最大 limit 的时候停止,这时候不确定整个 feature 文件是否完整,按照上文所说,bot 用这个配置计算的 request score 是 0,同样请求拦截,甚至日志中可能都看不出来什么错误。

可以看到这两种情形都差不多,甚至如果按照 fail fast 的策略,日志中会有明显的 500 错误,我不知道 Cloudflare 是否做了错误监控,因为按理来说这种级别的错误是非常明显的,需要立即报警。

很多人都集中讨论在这里的 unwrap:

当然这不是最佳实践,但这时候即使使用 .expect("invalid bots input") 这样的写法也好不到哪里去,同样会 500 错误,只是日志里面多留一条错误信息。因为如果不监控错误码,是没人立即发现问题所在的。

更好的做法是对输入进行严格校验,例如检查特征数量和大小。如果不符合预期,应保留旧配置并拒绝加载新数据,而不是加载到一半才发现尺寸异常,更不应该没有 fallback 机制。

当然这里代码没有完全开源,我们从短短的代码片段无法了解整个项目的场景。

从这个经典的错误我们应该发现的是更高维度的警戒,开发管理和运维上有这些问题:

  1. 整个配置的更新居然没有灰度发布,比如你模型更新了应该是先同步到 5% 的节点,如果没有问题再继续同步到 20% 的节点,如果没问题再继续。如果有灰度更新,这次的事故不会造成这么大范围的影响,因为在早期应该就观察到了。即使是微软新版本操作系统的发布,都是会分成好几个阶段,比如 ring0, ring1 通常内部团队更新,这样问题就现在暴露在自家团队上。
  2. 整个配置没有 fallback 机制或者全局开关,现在发现了问题,应该有一个安全的控制开关把配置切换到上一个能工作的配置。
  3. 监控不到位,关键组件的 500 错误可以说是救命的警告,但从他们的排查过程上看花费了更长的时间在是否是攻击。
  4. 应该是没有 fuzzing 测试,这种 input 非法的情况甚至需要在单元测试和集成测试中体现。

网络上很多人玩 Rust 的梗,典型的说法是这种这种:

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 的事故虽然造成的影响挺大,但这个公司也确实足够公开透明,事故分析写得非常清晰,值得大家学习并反思自己组织上有没有类似的工程问题。

零知识证明入门

作者 yukang
2025年9月27日 00:49

无论是在聊 L2、隐私还是下一代 Web 技术,零知识证明都是经常会碰到的技术术语,听起来就像是科幻小说里的东西:向你证明我知道一个秘密,但绝不透露这个秘密本身,这简直是程序员的终极浪漫。

大多数人粗看都会觉得这东西是密码学博士们的专属玩具,我花了一段时间学习后,发现这条通往魔法世界的路似乎有迹可循,希望这篇入门介绍能帮助到更多这方向的学习者。

魔法洞穴

忘掉所有数学,我们先从一个故事开始——“阿里巴巴洞穴”,这是理解 ZKP 最经典的例子,最早由 Jean-Jacques Quisquater 等人于 1990 年在他们的论文《如何向你的孩子解释零知识协议》中发表。

想象一个环形洞穴,A、B 两个入口在前方,深处有一扇只有知道咒语才能打开的魔法门。Alice 知道咒语,现在,Alice 想向 Bob 证明她知道咒语,但又不想让 Bob 听到咒语是什么。

他们可以这样玩一个游戏:

  1. 承诺 (Commitment):Alice,作为证明者 (Prover),独自进入洞穴。然后可以随机从 A 口进,也可以从 B 口进。Bob 在洞外等着,但不知道 Alice 走了哪条路。

  2. 挑战 (Challenge):Bob 作为验证者 (Verifier),走到洞口然后随机喊出一个要求,比如:“从 B 通道出来!”

  3. 响应 (Response):Alice 听到要求后:

    • 如果她当初就是从 B 进去的,那简单,就直接从 B 走出来。
    • 如果她当初是从 A 进去的,就必须念动咒语打开那扇魔法门穿过去,然后从 B 通道出来。
  4. 验证 (Verification):Bob 看到 Alice 确实从 B 通道出来了,他对 Alice 的信任度增加了一点。

为什么说“一点”呢?因为如果 Alice 不知道咒语,她仍然有 50% 的概率蒙对(比如 Alice 从 B 进去,Bob 恰好也喊了 B)。

但如果这个游戏重复 20 次,Alice 每次都能从 Bob 指定的出口出来,那 Alice 每次都蒙对的概率就只有$$\left(\frac{1}{2}\right)^{20}$$,也就是大约是百万分之一。这时候 Bob 就有十足的把握相信,Alice 确实知道那个咒语。

这个小游戏完美地展示了 ZKP 的三大特性:

  • 完整性 (Completeness):如果 Alice 真的知道咒语,她总能完成挑战。
  • 可靠性 (Soundness):如果 Alice 不知道咒语,她几乎不可能骗过 Bob。
  • 零知识性 (Zero-Knowledge):在整个过程中,Bob 除了“Alice 知道咒语”这个事实外,没有学到任何关于咒语本身的信息。

另外我们可以看到一个重要的属性是,零知识证明并非数学意义上的证明,因为可能存在一个很小很小的概率,即可靠性误差 – 作弊的证明者能够骗过验证者,但实际实践中我们几乎可以忽略这个极小的概率。

Where’s Wally

还有另外一个比较简单的例子来说明零知识证明:

Alice 和 Bob 玩游戏看谁先找到 Wally,Alice 说她找到了,她想要证明自己已经得到了结果,但又不想透露更多信息给 Bob,所以她可以用一个几倍面积黑色的纸片遮住整个图画,然后把 Wally 位置那里在黑色纸片上打个小孔,这样 Bob 就可以看到 Wally,而不知道 Wally 在哪儿。注意这里为什么强调几倍面积的黑色纸片,如果是和原图相同大小的纸片,就可能暴露了 Wally 的大致方向和范围。

这个例子展示的 ZKP 另外一个特性是 Prover 通常是更耗费资源的 (从图片中找到 Wally 需要花费一定的时间),而 Verifier 通常能很快验证。这个特性才能让一些区块链项目利用 ZKP 把链上计算挪到链下计算,而链上只是做验证。

最简单的 ZKP 代码

两个例子很棒,但代码怎么写?

我接触到的第一个协议叫 Schnorr 身份验证,它要证明的是:“我知道与公钥 h 对应的私钥 x,其中 h = g^x mod p”。这里的“咒语”就是 x,而那扇“魔法门”就是离散对数问题——从 g, h, p 反推出 x 极其困难。

这个协议的“交互式”版本,完美地复刻了洞穴里的“一来一回”:

  1. Prover (我): 随机选个数 k,计算 t = g^k mod p 发给 Verifier。这叫“承诺”。
  2. Verifier (你): 随机给 Prover 一个数 c,这叫“挑战”。
  3. Prover: 根据收到的 c,计算 r = k - c*x mod (p-1) 并发回。这叫“响应”。
  4. Verifier: 验证 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) % &order;

    // 3. 响应:计算 r = k - c*x (mod order)
    let cx = (&c * &secret_x) % &order;
    let mut r = (&k - cx) % &order;
    if r < BigInt::zero() {
        r += &order;
    }

    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) % &order;

    if c == c_prime {
        println!("✅ 验证通过!");
    } else {
        println!("❌ 验证失败!");
    }
}

以上我们通过最简单的代码来演示了 ZKP 的基本思想,从数学原理上都是基于离散对数困难性。

发散到 Passkeys

当我看到 Hash 的时候,我联想到了后台服务的密码存储,比如我们在做一个用户注册和登录功能的时候,为了安全我们是不会去存储用户的原始密码(秘密),而是会使用密码哈希方案,去存储 hash(password + salt)

但这个密码哈希方案其实也泄露了“知识”,当你登录时会把 123456 发送给服务器,服务器计算 hash("123456" + salt) 并与数据库中的值对比。

  • 在传输过程中:密码是明文的(当然可以用 TLS/SSL 加密,但服务器在解密后会看到明文)。
  • 对服务器而言:服务器在验证那一瞬间是知道你的密码的。
  • 如果数据库被盗:攻击者拿到了 hash(password + salt) 的列表。这个哈希值本身就是一条重要的知识!它虽然不是密码原文,但它是密码的一个确定性指纹。攻击者可以进行:
    • 字典攻击:尝试常用密码,计算哈希值来一一比对。
    • 彩虹表攻击:用一个预先计算好的哈希值数据库来反查。
    • 暴力破解:对所有可能的组合进行哈希计算。

这就是为什么我们需要“加盐(salt)”和使用慢哈希函数(如 Argon2, bcrypt),目的就是为了增加攻击者进行上述离线攻击的成本,但无论如何,哈希值本身就是泄露的“知识”

所以如果我们要更安全,一点“知识”都不泄露,似乎 ZKP 适合做认证服务?注册时不存密码哈希,只存公钥 h。登录时,我发送一个 ZKP 证明,服务器验证一下就行了,数据库被拖库了都没事。

甚至更简单点其实就用公私钥对不是更方便和安全么,Nostr 就是这么做的 (钱包也是这个原理),private key 是密码,每次发内容就用私钥签名内容,然后把 pubkey 带上,这样任何收到这条消息的节点都可以验证签名是否一致,这样就本质上通过各个 relay 节点形成一个去中心化的社交网络。

我按照这个思路去找 Web 相关的解决方案,业界给出的答案是 Passkeys (基于 WebAuthn 标准),使用非对称加密来替代密码(私钥不出设备),Passkeys 是这样工作的:

  1. 注册: 你的设备(如 iPhone 或 Android 手机)在本地生成一对密钥(私钥和公钥)。私钥安全地存储在设备的硬件安全芯片中,永远不会离开设备。你把公钥发送给网站服务器。
  2. 登录:
    • 网站向你的设备发送一个“挑战”(一个随机数)。
    • 你的设备用私钥对这个“挑战”进行签名,然后把签名发回给网站。
    • 网站用它存储的你的公钥来验证这个签名是否有效。
  3. 用户体验: 整个过程对于用户来说,可能只是做一次指纹识别或面部识别来授权设备使用私钥。

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 * x
  • v2 = v1 * x
  • v3 = v2 + x
  • out = v3 + 5

于是,证明“我正确运行了程序”,就转化为了证明“我知道一组数 (x, v1, v2, v3, out) 能同时满足上面这一堆等式”。这个过程,就是把代码逻辑“算术化”,变成了 ZKP 系统可以处理的语言。

那我们来看 Verifier 如何验证上面的计算过程,最原始的当然是根据输入,来一条一条的执行上面被拍平后的指令集,但这样的工作量和自己去执行整个程序就差不多了。

为了避免这种蛮力验证,密码学家们引入了一个极其强大的数学工具:多项式 (Polynomials)。 整个魔法流程如下:

  1. 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) 就会是一个完全不同的多项式。

  2. 验证者的捷径 – 随机点检查 (Random Spot-Check) :现在验证者的问题从“检查成千上万个小等式”变成了“如何验证 Prover 的那个巨大多项式 P(z) 是正确的?”

    难道要把整个巨大的多项式传输过来再计算一遍吗?当然不是!这里用到了密码学中一个非常深刻的原理,通常与 Schwartz-Zippel 引理 有关。

    它的直观思想是

    如果我有两个不同的、阶数很高的多项式 P(z)F(z)(F 代表伪造的),然后我从一个极大的数域里随机挑选一个点 s,那么 P(s)F(s) 的计算结果相等的概率几乎为零

    这就给了验证者一个巨大的捷径:

    • Verifier 不需要关心那个巨大的多项式长什么样。
    • 它只需要在一个秘密的、随机选择的点 s 上,对 Prover 的多项式进行一次“抽查”。
    • 它通过密码学协议向 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 的多项式是如何被“隔空”验证的:

  1. 准备阶段 (Setup)

    • 协议约定好了一套公共参数(包含一个“验证密钥”)。这个验证密钥里编码了“游戏规则”,包括对程序正确性的期望。
  2. Prover 的工作

    • 他有他的秘密“见证 (Witness)”。
    • 他按照约定,将程序的约束系统转化成几个巨大的多项式 A(x), B(x), C(x)。(这些多项式满足 A(x) * B(x) - C(x) = H(x) * Z(x) 的关系,这是 R1CS 算术化的结果)。
    • 关键一步:他并没有把这些多项式发出去。而是用他的“证明密钥”,计算出这几个多项式在某个秘密点 s 上的椭圆曲线点表示。这些点就是对多项式的“承诺”。
    • 最终生成的证明 (Proof),就是由这几个计算出来的椭圆曲线点组成的,它非常小。
  3. Verifier 的工作

    • Verifier 收到这个由几个点组成的、小小的证明。
    • Verifier 完全看不到 Prover 的任何多项式 (A(x), B(x), C(x))。
    • Verifier 拿出“验证密钥”,并将 Prover 提交的这几个“承诺点”代入一个预设的配对验证方程 (Pairing Verification Equation)

    这个方程被设计得极其巧妙,它的等号左边和右边分别对应着 Prover 原始多项式关系 A*B-C=H*Z 的加密形式。

    当且仅当 Prover 原始的、未知的那些多项式确实满足正确的数学关系时,这个配对验证方程的等号才能成立。

所以:

  • 证明里是什么? 是对 Prover 秘密多项式的密码学承诺(通常是几个椭圆曲线点)。
  • Verifier 如何知道多项式?不需要知道。他只需要知道验证规则(即那个配对验证方程)。
  • 如何验证? 他把 Prover 的“承诺”(证明)代入“规则”(验证方程)。如果方程成立,他就知道那些他看不见的、被承诺了的多项式一定是正确的,进而推断出 Prover 的原始计算是正确的。

Prover 把“我知道所有题的答案”这个事实,通过复杂的计算,浓缩成了一个包含几个关键“密码学指纹”的信封(证明)。Verifier 不用拆开信封看所有答案,他只需要用一种特殊的“X 光机”(配对验证)照一下这个信封,就能瞬间知道里面的答案是不是都对。

ZKP 相关的应用

区块链因为其去中心化和对隐私性的严苛要求,ZKP 非常适合用在这个领域。

扩容 (ZK-Rollups): 让以太坊快如闪电

以太坊慢又贵,因为每个节点都要重复执行每笔交易。ZK-Rollup 的思路就像是找了个超级课代表:

  1. 在链下 (L2) 执行成千上万笔交易。
  2. 为“我已正确处理了这一切”这个声明,生成一个微小的 ZK 证明。
  3. 把这个证明提交到链上 (L1)。

L1 的所有节点不再需要重复计算那几千笔交易,它们只需要做一件极其廉价的事:验证那个 ZK 证明。就像老师检查作业,不再需要自己从头算一遍,只需要看一眼课代表盖的“全对”印章。

总而言之,Rollup 的核心创新在于将计算执行数据结算分离。它利用 ZKP 等密码学技术,将繁重的“执行”环节放在链下,然后只把一个轻量的“证明”和必要数据放在链上进行“结算”,从而实现了对以太坊主网的大规模扩容。

隐私 (Tornado Cash): 你的钱,只有你知道

Tornado Cash 是个混币器,你存入 100 ETH,然后从一个全新的地址取出来,没人能把这两者联系起来。它的机制是:

  • 存款:你在本地生成一个秘密凭证(包含SecretNullifier),然后计算出它的哈希值——“承诺 (Commitment)”,把承诺和钱一起存入合约。

  • 取款:你用一个全新的地址,提交一个 ZK 证明,这个证明:“我知道某个树叶的 Secret 且未被花费”,同时提交 nullifier(通常是对秘密做散列得到的唯一标识)以标记已花费。这样合约无需关联存款者身份即可阻止双花。

整个过程,合约就像个盲人会计,它不知道是“谁”存的,也不知道取款对应的是“哪一一笔”存款,它只负责验证 ZKP 规则是否被遵守。

ZKP 在 AI 的应用

ZKP 应用在大模型也是最前沿、激动人心的领域。例如 AI 模型(尤其是大型语言模型)的权重是极其宝贵的商业机密。用户的数据又极其隐私。如何让一个 AI 模型在不暴露其内部权重的情况下,处理用户的隐私数据,并向用户证明它确实是用了那个宣称的高级模型,而不是一个廉价的“冒牌货”?

ZKP 解决方案 (ZKML - Zero-Knowledge Machine Learning): 模型推理证明:模型提供方可以对一次推理过程生成 ZK 证明,证实“我使用我宣称的那个模型(其哈希值是公开的),处理了你的输入数据,得出了这个输出结果”。这向用户保证了模型的真实性,同时保护了模型的知识产权。

数据隐私证明:用户可以对自己的数据生成 ZK 证明,证实“我的数据(例如医疗记录)符合某个特定标准(例如,有某种疾病特征)”,然后将这个证明提交给 AI 模型进行统计或研究,而无需上传原始的隐私数据。

这里有更多相关的资料:An introduction to zero-knowledge machine learning (ZKML)

零知识证明和硬件

前面我们谈到,在 ZKP 中Prover(证明者)端计算量最大,主要集中在以下几个方面:

  • 多项式承诺方案:这是现代零知识证明(如 zk-SNARKs、zk-STARKs)的核心。证明者需要将计算任务转化为多项式,并对这些多项式进行一系列复杂的加密运算,例如多项式插值、求导、卷积、快速傅里叶变换(FFT) 等。这些运算的复杂度很高,尤其是当要证明的计算规模很大时。
  • 同态加密运算或椭圆曲线点运算:在一些零知识证明协议中,为了生成和验证证明,需要进行大量的椭圆曲线点乘运算。这种运算在数学上非常耗时,尤其是当需要处理大量的点时。
  • 哈希函数计算:为了将复杂的数据结构或计算结果进行压缩和承诺,证明者会使用到大量的加密哈希函数,例如 SHA-256、Poseidon 等。

而在Verifier(验证者)端计算量相对较小,这也是零知识证明的重要优势之一,但它仍然需要进行一些关键的计算,比如:

  • 椭圆曲线配对运算(Pairing):在 zk-SNARKs 等协议中,验证者需要进行椭圆曲线配对运算来验证证明。这是一种特殊的加密操作,虽然比证明者的计算量小得多,但仍然需要一定的计算资源。
  • 哈希函数和多项式求值:验证者也需要进行一些哈希计算和多项式求值来检查证明的有效性。

总的来说,零知识证明的计算量主要耗费在Prover端,因为它需要对整个计算过程进行完整的加密转换和证明生成,而这些步骤依赖于高复杂度的多项式和椭圆曲线运算。所以我们看到一些专门为此服务的硬件 FPGA、ASIC、GPU。

而 RISC-V 因为其可扩展性模块化设计、开源的标准等优势,是实现零知识证明硬件加速的重要“基石”之一,risc0 是个值得关注的项目

更多参考

RISC-V from Scratch: Building a Virtual Machine

作者 yukang
2025年9月23日 08:54

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.

Understanding the Core Concepts

Before writing any code, I need to grasp the fundamentals of RISC-V.

  • RISC vs. CISC: RISC (Reduced Instruction Set Computing) architectures use a small, highly optimized set of instructions. This is in contrast to CISC (Complex Instruction Set Computing), which has a large number of complex instructions. RISC-V’s simplicity makes it ideal for building a VM.
  • Modular Architecture: RISC-V has a base instruction set (RV32I for 32-bit systems) and optional extensions like M (for multiplication) or F (for floating-point). We’ll focus on the RV32I base to keep things simple.
  • The Three Pillars: At its core, a CPU (and thus our VM) consists of three main components:
    • Registers: A small set of high-speed memory locations used for calculations. RISC-V has 32 general-purpose registers (x0-x31).
    • Memory: A much larger space for storing program code and data.
    • Program Counter (PC): A special register that holds the memory address of the next instruction to be executed.

We can get all the details of RISC-V instructions from RISC-V Technical Specifications.

The VM’s Core Logic

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.

  1. Fetch: Read the 32-bit instruction from the memory address pointed to by the PC.
  2. Decode: Parse the instruction’s binary code to determine its type and what operation to perform.
  3. Execute: Perform the operation (e.g., an addition) and update the relevant registers or memory.

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

From Rust to RISC-V binary

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.

  1. Prepare the Environment: Install the 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.

  1. Write “Bare-Metal” Rust: Our Rust program must be written for a “bare-metal” environment, meaning you cannot use the standard library and must provide your own entry point and panic handler.
#[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 {}
}
  1. Cross-Compile: Use 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
  1. Disassemble and check the binary code: We can use the tool 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

Using the VM to Execute Binary

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

References

Future 的大小对性能的影响

作者 yukang
2025年3月24日 18:21

在 Rust 异步编程中,有一种观点认为:Future 的大小显著影响性能。你是否怀疑过这个说法的真实性?如果是真的,这种性能差异的根源又是什么?今天,我翻阅了一些源码,并编写实验代码来一探究竟。

Future 的大小如何计算?

为了验证“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 Network: 基于 CKB 实现的闪电网络

作者 yukang
2025年3月16日 16:53

最近一年我在做 Fiber Network 这个新的开源项目,上个月底刚好主网第一个版本发布

这个项目的挑战还是挺大的,上主网只是一个新的开始。我在开发过程中学到了很多东西,这是我前段时间写的一篇关于 Fiber 的大致介绍。

Fiber 简介

Fiber 是基于 CKB 构建的闪电网络协议,旨在实现快速、安全且高效的链下支付解决方案。借鉴了比特币闪电网络的核心理念,Fiber 针对 CKB 的独特架构进行了深度优化,提供低延迟、高吞吐量的支付通道,适用于微支付和高频交易等场景。与传统的闪电网络不同,Fiber 拥有多项关键特性:

  • 多资产支持:不再局限于单一币种,能够处理多种资产交易,为复杂的跨链金融应用铺平道路。
  • 可编程性:基于 CKB 的图灵完备智能合约,支持更复杂的条件执行和业务逻辑,拓展了支付通道的应用边界。
  • 跨链互操作性:原生设计支持与其他 UTXO 链(如比特币)的闪电网络交互,提升了链间资产流动性和网络兼容性。
  • 更灵活的状态管理:得益于 CKB 的 Cell 模型,Fiber 可以更高效地管理通道状态,降低链下交互的复杂度。

在这篇文章中,我们将从源码层面介绍 Fiber 的整体架构和主要模块,以及项目的后续展望和规划。

前提知识

  • Rust, and actor framework,Fiber 是一个完全由 Rust 编程语言所实现的项目,另外我们在实现中采用了 actor model 的模式,依赖社区的项目 ractor 框架。
  • Lightning network,Fiber 的基本思想沿用了 Bitcoin 的闪电网络,基本原理是一致的,所以 Mastering lightning network 和 Bolts: lightning/bolts 是非常有用的参考资料。
  • CKB transaction and contract,Fiber 会通过 RPC 与 CKB node 进行交互,比如 funding transaction 或者 shutdown commitment transaction 可能需要通过 RPC 提交给 CKB 的节点,所以掌握 Fiber 需要了解一些 CKB 合约开发方面的知识。

重要模块

我们从最高纬度去看一个 Fiber Node,主要包含下面几个主要模块:

其中:

  • Network Actor 是 Fiber Node 中负责节点内外的消息通信
  • Network graph 包含一个节点对于整个网络里其他节点和 channel 的信息,当一个 Fiber Node 收到一个支付请求的时候,我们首先会尝试从 network graph 中找到一条路径能够触达收款节点,这个 network graph 结构是跟着网络上的 gossip 信息不断更新的
  • PaymentSession 负责管理一个支付的生命周期
  • fiber-sphinx 是我们自己实现的 onion packet 加解密 Rust 库
  • Gossip 是 Fiber 节点之间的交换网络消息的协议,用于 Node 和 Channe 的发现和更新。
  • Watchtower,这里负责监听 Fiber node 所关心的 channel 里面的重要事件,另外如果某个 Node 提交一个老的 commitment transaction,watch tower 负责发出 revocation transaction 来进行惩罚
  • Cross hub,这个模块负责跨链的互操作,比如付款者通过 Bitcoin 的闪电网络发出 Bitcoin,而接收者收到的是 CKB,cross hub 这里会进行一个转换,将 Bitcoin 的 payment 和 invoice 和 Fiber 这边的 payment 和 invoice 进行映射管理
  • Fiber-script 在一个单独的代码仓库,这里面包含了两个主要的合约,funding-lock 是一个资金锁定合约,使用 ckb-auth 库来实现一个 2-of-2 多重签名,commitment-lock 实现了 daric 协议来作为 Fiber 的惩罚机制

Actor Model 和 Channel 管理

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,然后再继续自动发起下一次路径规划尝试进行支付

节点广播协议 Gossip

Fiber 的节点之间的通过相互发送广播消息交换新的 Node 和 Channel 信息,Fiber 中的 Gossip 模块实现了 Botls 7 定义的 routing gossip。在实现过程中我们的主要技术决策在这个 PR: Refactor gossip protocol里面有描述。

当一个 Node 节点第一次启动的时候,会通过配置文件里的 bootnode_addrs来的连接第一批 peers,广播消息的类型有三类:NodeAnnouncementChannelAnnouncementChannelUpdate

Fiber 会把收到的广播的原始数据保存下来,这样方便通过 timestamp + message_id 组合的 cursor 来对广播消息进行检索,以方便来自 peer node 的 query 请求。

当一个节点启动的时候,Graph 模块会通过 load_from_store来读取所有的 messages,重新构建自己的 network graph。

我们采用基于订阅的方式在网络中传播消息。一个节点需要主动向另一个节点发送广播消息过滤器(BroadcastMessagesFilter),另一个节点收到了该消息之后会为其创建对应的 PeerFilterActor,在构造函数里创建 Gossip 消息订阅。通过基于订阅的模型这种方式,我们可以让其他节点接收在特定的 cursor 之后接收到新保存的 Gossip 消息

隐私 Onion 加解密

处于隐私保护的需求,payment 的 TLC 在多个节点之间传播的时候,每个节点只能知道自己所需要的信息,比如当前节点接收的 TLC 的 amount、expiry、下一个传播的节点等信息,而无法获得其他不必要的信息,而且每个 hop 在发送 TLC 给下一个节点的时候也需要做相应的混淆。

类似的,如果 payment 在某个节点传播的过程中发生了错误,这个节点也可能返回一个错误信息,而这个错误信息会通过 payment 的 route 反向传递给 payment 的发起节点。这个错误信息也是需要 Onion 加密的,这样确保中间节点无法理解错误的具体内容,而只有发送者能够获得错误内容。

我们参考了 rust-lightning 在 onion packet 的实现,发现其实现方式还是不够通用 (会绑定于其项目的具体数据结构),所以我们自己从头开始实现了 fiber-sphinx,更详细的内容请参考项目的 spec。

涉及到 Onion 加解密的几个关键节点在这三个地方:

Watchtower

Watchtower 是闪电网络中的重要安全机制,主要用于帮助离线用户防止资金被盗。它通过实时监测链上交易,并在发现违规行为时执行惩罚交易,从而维护闪电网络的公平性和安全性。

Fiber 的 watchtower 实现在 WatchtowerActor里,这个 actor 会监听 Fiber 节点中发生的关键事件,比如一个新的 Channel 创建成功时将会收到 RemoteTxComplete,watchtower 就在数据库里插入一条对应的记录来开始监听这个通道,Channel 双方协商成功关闭时会收到 ChannelClosed,watchtower 从数据库中移除对应的记录。

在 Channel 中 TLC 交互时候,watchertower 将会收到 RemoteCommitmentSignedRevokeAndAckReceived,分别去更新数据库中存储的 revocation_datasettlement_data,这些字段将会在后续创建 revocation transaction 和 settlement trasaction 的时候用到。

Watchtower 的惩罚机制是通过比较 commitment_number 来判断 CKB 的链上交易是否使用了老的 commitment transaction,如果发现违规则构建一个 revocation transaction 提交到链上进行惩罚,否则就构建发送一个 settlement transaction 提交到链上。

其他技术决策

  • 存储:我们使用 RocksDB 作为存储层,写代码的过程中可以直接使用 serde 来序列化。但因为 scheme-less,所以不同版本的数据迁移仍然是一个挑战,我们通过这个独立程序来解决,比较粗暴,但目前没想到更好的办法。
  • 序列化:节点间的消息使用 Molecule 进行序列化和反序列化,带来效率、兼容性和安全性优势。要确保确定性,这样相同的消息在所有节点上序列化方式相同,这对于签名生成和验证非常重要。

后续展望

目前 Fiber 还处于前期活跃开发阶段,后续我们可能将继续做以下几个方面的改进:

  • 修复还未处理好的 corner case,增强项目整体的健壮性
  • 目前的 cross hub 还处于 Demo 阶段,我们会对这部分增加如 payment session 等功能
  • 完善支付路由规划算法,可能会引入其他路径搜索策略,以适应用户不同的路由偏好和需求
  • 扩展合约的功能,比如引入基于版本号的撤销机制和更安全的 Point Time-Locked Contracts

Let’s scale P2P finance together! 🩵

CKB new script verification with VM pause

作者 yukang
2024年11月7日 20:03

CKB 相关技术文章第三篇。

背景

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

大 cycle 交易的挑战

通常一个简单的 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 是一个单独的服务,它的处理流程和其他交易的处理流程是不一样的,这样会导致代码的复杂度增加,比如:

  1. 要针对 ChunkProcess 里面的交易额外判断,例子 1, 例子 2
  2. 暂停 / 恢复 ChunkProcess 处理的时候,需要对 ckb-vm 做相关的状态保存和恢复处理,参考结构 TransactionSnapshot, 代码比较复杂且容易遗漏,历史上也有过相关的 bug 1, bug 2, 以及安全问题。
  3. 代码中包含重复逻辑,比如 chunk_process 里的 process_inner_resumeble_process_tx
  4. 由于它只能同时处理一个大 cycle 交易,在 tx pool 本身比较空闲的情况下如果收到了多个大 cycle 交易也不能并行处理,比如 .bit 团队之前有过反馈他们通过本地 rpc 同时提交多个大 cycle 交易会比较慢的问题。

CKV-VM pause

这些问题的根本是 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。做的过程中发现一些其他问题:

  • 交易提交的时候,SubmitLocalTxSubmitRemoteTx 如果 verify 失败目前会立即返回 Reject,如果改成加入队列的方式,这个结果无法实时给到,所以做了如下改动:
    • 优先处理本地的交易,本地提交的交易不会放入 queue,而是直接会在 RPC 的处理阶段执行
    • 所有的来自网络 peer 的交易都全到放入到 queue
  • 后来 CKB vm 又新增了 spawn 的实现,所以会有 parent、child 的概念,那么Child VM 是执行 syscall 的时候执行 machine.run ,如果不改这块执行 child vm 的时候不可暂停
    • 后来我们讨论了之后决定 spawn 时把父的 Pause 传递给子,然后暂停的时候给父的 Pause 设置暂停,这样所有的子 machine 同样返回 VMError::Pause ,同时把当前的 machine 栈重新入栈,恢复的时候继续执行,这里逻辑比较重,相关代码实现:run_vms_child
  • 后来用重新设计了 spawn,使用了一种新的 determined scheduler 的方式去管理所有的 vms 和 IO,之前和 VM 的使用者角度来说之前需要和 VM 交互,现在变成了都通过 scheduler 来管理。关于 spawn 的设计参考这个文档:Update spawn syscalls

整个 PR 在这里:New script verify with ckb-vm pause

Cryptape 招聘 - 区块链开发工程师

作者 yukang
2024年4月20日 19:42

公司最近出来一个招聘,主要是想招一个 C、Rust 的人,另外要求编程能力、英文读写,如果有 Linux 底层或者编译器的经验就更好了,不强求区块链背景:

HR 说这是 ckb-vm: CKB’s vm 项目的职位,简单来说这是一个基于 RISC-V 的虚拟机,这也是一个远程的职位。

想要尝试的欢迎联系我,邮箱:moorekang@gmail.com

xz-backdoor 观感

作者 yukang
2024年4月5日 02:04

写写最近一周的大瓜 xz-backdoor,该事件可能成为开源供应链安全的一个分水岭,从技术角度看,这里面的社工和混淆也是精彩。

简单介绍一下背景,xz 是一个开源的无损压缩工具,在出事之前可能很少有人注意到这个压缩库使用如此之广,几乎任何一个 Unix-Like 的操作系统里面都有 xz-utils。在两年多的时间里,一个名为 Jia Tan 的程序员勤奋而高效地给 xz 项目做贡献,最终获得了该项目的直接提交权和维护权。之后他在 libzma 中加入了一个非常隐蔽的后门,该后门可以让攻击者在 SSH 会话开始时发送隐藏命令,使攻击者能够跳过鉴权远程执行命令。

Timeline of the xz open source attack 总结了该事件的主要时间点,这里我挑一些关键节点:

潜伏

  • 2005 ~ 2008 xz 项目的初始版本,这是一个文件压缩算法,主要由 Lasse Collin 开发和维护。
  • 2021-10-29 ~ 2022-06-29 Jia Tan 开始较为密集地给 xz 项目贡献代码,同时几个类似马甲的账号 (Jugar Kumar, Dennis Ens) 在邮件列表里抱怨 Merge 得不到及时处理,问题得不到回复,有点逼宫的意思,在这个过程中项目主导者 Lasse Collin 把最近的优秀贡献者加入了维护者列表。

准备

  • 2022-09-27 Jia Tan 获得了信任,并开始主导新版本的发布,他在这期间做了几个看似合理的 PR,但其实是在为今后的后门做伏笔,另一个马甲 Hans Jansen 提供了一个钩子可以让后门里的代码替换全局函数,从而绕过检查。
  • 2023-07-07 Jia Tan 在 Google 的 oss-fuzz 提供修改禁用了 ifunc,这也是为了避免 fuzz 可能发现后门。

发动

  • 2024-02-23 Jia Tan 发布了第一个有害的 PR,在测试代码中包含了几个 binary 文件,这些文件看起来只用于测试,所以在代码 review 的过程中肯定不会被仔细查看。
  • 2024-02-26 Jia Tan 通过一个非常隐蔽的提交,给 CMakeList.txt 增加了一个 .,使得代码会编译失败从而让 Landlock 不会被激活。
  • 2024-02-24 Jia Tan 发布 v5.6.0,其中使用脚本混淆悄悄地把后门的 payload 塞进了目标文件中。Gentoo 和 Debian 开始在 unstable 版本中含有后门。
  • Hans Jansen 同时在发邮件催促 Debian 升级 xz 到 v5.6.1

暴露

  • 2024-03-29: 一个叫 Andres Freund 的开发者在分析一个 sshd 可疑的 500ms 延迟时,发现了隐藏在 xz 的恶意后门。如果不是偶然的发现,估计现在世界上无数的服务器处于肉鸡状态,这位微软的员工如英雄一般拯救了世界。

攻击者是中国人?

从主要攻击者的名称看似乎是中国人,但 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 是其价值根本。

那比特币如何保证不会被植入后门,比如这种供应链攻击?

  • Reproducible builds,这是个极大地缓解供应链风险的办法,不同的人编译相同的源代码必然得到相同的二进制文件,binary file 不能存在于源码库中。Bitcoin 使用 Guix container 从源码编译所有的东西,contrib: Enable building in Guix containers,这个过程可以在任何 Linux 发行版上重现。在这个过程中,几乎所有的一切都从源码编译,所以会存在一个鸡生蛋蛋生鸡的问题,为了解决这个问题必然会需要一些 binary files,但最好是将这个范围限制到最小,Preparing to Use the Bootstrap Binaries
  • Don’t forget to verify yourself!

另外比特币的安全在于 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 程序员

作者 yukang
2024年3月19日 00:28

3 月是怀念海子的月份:

从明天起,做一个 Rust 程序员,喂马、劈柴,周游世界。

10 年前我开始写第一行 Rust 程序,到如今全职远程做 Rust 开源项目,也许我真能去过喂马劈柴周游世界了😆。但回想自己的学习旅程,其中有各种曲折有几度放弃的时候,如果你也想学习或者提高 Rust 方面的技能,我这篇文章里有一条更容易的路。

为什么学习 Rust

Rust 1.0 发布已经快 10 年,所以并不是一门新编程语言了,从发展的角度来看 Rust 已经度过了生存期,并进入了迅速发展的阶段。从目前可见的业界方向来说,Rust 主要在以下几个方面取得了成功:

  • 在基础软件领域成为有力竞争者
    • 大量开源的 Rust 命令行工具和开发库,如果你使用 Python,可以通过 PyO3 用 Rust 来写对性能要求更高的模块,还出现了 opendal 这样优秀的基础库
    • Cloudflare 使用 Rust 开发新的网关 Pingora
    • 开源数据库实现,比如 QdrantRisingWavedatabend
    • AI 方面参考 Are we learning yet,虽然 Rust ML 生态系统还很年轻并处于试验阶段,但已经出现了一些雄心勃勃的项目和模块,Hugging Face 开源了 candle机器学习框架
  • 前端的基础设施
  • 操作系统
    • Windows 开始使用 Rust 开发一些核心组件
    • Rust 开始进入 Linux 内核,使得使用 Rust 开发 Linux module 成为可能
    • Andriod 使用 Rust 开发更多组件,并有效减少了内存方面的漏洞,他们发布的 Comprehensive Rust是一个很好的学习资料。Google 开始尝到 Rust 的好处,并开始投入更多资金和人力,近期 Google 打算捐献 100 万美金给 Rust 基金会着重解决 Rust 和 C++ 的互操作性
  • 区块链领域
    • 以我在这个领域工作一件多的经验来说,Rust 成为了区块链领域的标配,基本区块链相关的工作岗位 Rust 技能是一个极大的加分项
    • 大量公链使用 Rust 来开发
  • 游戏开发,参考 Are we game yet?,目前已经有成熟的游戏开发框架 Bevy Engine

如果你对 Rust 的发展情况感兴趣,可以参考 2023 Annual Rust Survey Results。在内卷的 IT 市场,作为程序员选择一门小众的编程语言是避免过度竞争的方式,我之前介绍过其他人的类似经验,我们称之为 The Niche Programmer。Rust 还未成为主流编程语言,但潜力和发展空间很大,而门槛相对其他语言比较高,所以我认为从求职的角度来考虑是值得一试的。

之前提到 Google 投入更多的资金在 Rust 上面,钱进来后相关的职位就出来了 C++/Rust Interop Initiative Software Engineer Lead

我学习 Rust 的体会

我 2014 年时践行每年学习一门新的编程语言,Rust 作为一门新的编程语言进入了我的视野。我开始使用 Rust 写些简单的个人学习项目,然后我继续做了 Rust exercises

后续几年我偶尔看看 Rust 相关的新闻和项目,时不时动手写点代码都会有点磕磕碰碰。直到四年前开始在 Github 上给一些 Rust 开源项目贡献,两年前开始给 Rust 编译器做贡献,一年前开始全职从事 Rust 区块链相关的工作。

从技术角度来说,Rust 非常有趣,这里面包含了近些年程序设计方面的一些良好实践。全职写 Rust 程序这一年多是我开发体验最好的阶段,当然有时候我们需要和编译器斗智斗勇、做类型体操,但很多问题在开发阶段给规避掉了。

Rust 的最大问题还是在于学习门槛相对较高,因为在 Rust 中程序员接触最多的 = 语义都变了。从我个人体验来说,在学会了 Rust 语法后会陷入一个瓶颈,如果日常工作中不使用 Rust,就没有多少机会去实践,另外不知道做一些什么项目。

我相信很多人同样如此,看了官方 tutorial 之后不知道如何下手,我想如果有一个经验丰富的老师带,会少走很多弯路,这就是我要介绍的极客时间训练营要解决的问题。

极客时间 Rust 训练营

说起来我与这个训练营还有些渊源。

当极客时间在筹划这个 Rust 训练营的时候,策划人员找到过我问我是否有意愿当这个课程的讲师。我还稍微犹豫了一下,因为我之前也想过如何在 Rust 领域做更多的分享,我很羡慕优秀的技术分享者比如 Jon Gjengset能够非常自如地通过视频分享 Rust 方面的技术。当老师当然是个机会能从沟通和表达方面提高这方面的能力。

后来考虑到自己时间方面安排不过来,我有全职工作、有业余的 Rust 社区工作、还有三个小孩,所以我应该真没时间去录制课程了,而且他们已经找到了我认为最合适的讲师:

我看了这个项目的大纲,陈天老师希望可以教大家怎么用 Rust 比较简单的语法和技巧,来完成 80% 的日常工作,主要是通过各种实践项目来学习,这也是我最推崇的 Learn by doing 的方式。

有很多主题我都没怎么接触过,比如构建一个 ChatGPT 的应用、比如跨平台 GUI 之类的,所以我对这个课程很感兴趣,然后我和策划说能不能做这个项目的助教,后来沟通下来发现当助教也需要不少时间的,所以就没机会参与到具体的教学里面了。

总之,这个项目对于想学习 Rust 或者已经有一定 Rust 经验,但想获得更多实践经历的人是非常合适的。在和极客时间的相关人员沟通的过程中,我发现他们做事情很用心,这个训练营的课程质量我认为是有保证的。

这个训练营一共是 15 周的课程安排,其中每周都会有明确的项目安排,课后还有助教答疑。关于训练营的更多信息请参考:极客时间训练营-Rust 训练营

我与陈天老师的小故事

我最早知道陈天是他写的公众号《程序人生》,他是那种技术和文笔都非常棒的程序员,非常难得。我还看过他的 B 站上的技术讲解视频,他的演讲和分享都很流畅。陈天是极客时间《陈天 · Rust 编程第一课》专栏作者,已有 2.3w 人学过,广受好评。技术能力、演讲表达、对技术的热情这些都是讲师最重要的素质要求,所以陈天是这个训练营最好的讲师人选。

再分享一个小故事,我一年多前跳槽的时候还有些犹豫,因为自己的职业规划方面有些困惑,所以想找些人聊聊。当时我突然想到陈天之前从事过区块链方面的创业,后来从里面退出来了,所以我就想向他咨询一下。我没有他的联系方式,但灵机一动我想到了从 Git 的提交记录里面找 Email,然后抱着试一试的想法给他发了个邮件说明了自己的情况和困惑。没想到他很快给我回复了,并很详细地告诉我他对于区块链的想法,还有如何判断自己是否适合一个公司,通过各种途径了解公司的相关产品来作为决策的依据等等。

我作为一个陌生人,陈天老师都会乐于给与指导和帮助,可见为人真的很好。还没能有幸和陈天老师现实中有所交流,我本来想用当助教的机会和陈天老师多学习,但时间方面安排不过来了。希望大家能在老师的的训练营学到知识、经验、还有探索技术的乐趣!

我喜欢的 shell 工具

作者 yukang
2024年3月17日 07:52

分享一些日常经常使用的命令行小工具,我认为这些小东西能提高我的工作效率。

percol

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"

atuin

atuinsh 是一个记录 shell 历史的小工具,不同于普通的记录 shell history 的工具,atuin 会把数据记录在一个 SQLite 的数据库文件中,这样可以支持更丰富的查询功能。

另外 atuin 也支持不同机器之间的同步,当然这需要加密通信。我目前还没使用这种场景,只是把 Ctrl-R 绑定到了 atuin。

atuin 也是一个 Rust 实现的工具。

tmux

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-resurrecttmux-continuum,这样即使我重启了机器,打开 tmux 后我的 session 仍然和之前一样。

最近也有个 Rust 写的 zellij,但我认为这种软件使用更老的会更方便,比如公司的远程服务器必然有 tmux,但不一定有 zellij。

just

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 工具,希望也能分享给我。

Copilot,最好的编程助手

作者 yukang
2024年1月21日 22:43

今天下午我解决一个小问题的时候,在 Copilot 的帮助下快速给出了修复,这个工具似乎有些超过期望了,所以突然想写篇文章分享这个目前我最愿意付费的 AI 工具。

Copilot 价格是每个月 10 美金,但我至今还没付费过,感谢微软支持开源,从测试阶段就邀请我试用,到现在还一直在免费使用。Github 应该有些政策,比如如果你持续给一些 star 数比较多的开源项目做贡献,就可以免费使用 Copilot

我会给出日常碰到过的一些具体的实际案例截图,以方便你更直观地感受到这个工具准确度。

Manual 类查询

我们在编程过程中经常会碰到一些命令的参数记不太清楚,这种问题很适合问 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,然后更开心地做重要和有趣的部分。

我的 2023

作者 yukang
2023年12月31日 06:36

2023 年很快就要结束了,赶紧抓住这个冲动总结一下。今年对我来说有几个大的转变,从几个方面谈起:

生活

生活上最大的变化是我又有了一个儿子,所以我现在是三个孩子的父亲了。

同龄人中几乎没有生三胎的,有些人问我为什么这么想不开,自己找罪受。我只能说是命运的馈赠吧,我从小生活在一个大家庭里,加上我和老婆都算是喜欢小孩的人,三胎顺其自然地接受了,这个孩子也促使了我们更早地离开了苏州。

孩子 8 月底出生,前两个月请到了一个靠谱的月嫂,所以生活方面还不算痛苦。最近女儿生病才开始感受到三个孩子带来的巨大挑战,看来我们是低估了其难度。

我的大女儿开始在深圳上一年级,没想到现在的一年级都这么卷,基本上每天都有语数外作业,一个月一次的考试。我们力不从心已经放弃了一些家庭作业,比如数学之类的无聊作业我们就不怎么做,我认为每个小孩的大脑发育有自己的节奏,小学数学这种东西到了年龄自己会懂,小学阶段重要的是培养学习习惯和兴趣,强压给孩子只会让她产生对数学的恐惧。陪小孩做作业真是一件极其需要耐心的事情,我现在还在努力尝试从孩子的角度考虑问题。

三个孩子带来的另外一件事情就是冲突,大女儿心情好的时候会带着小的玩,心情不好的时候就会和妹妹争东西。如何在这些孩子中平衡,在吵闹中克服情绪去解决问题,这些都是在磨炼心性。

纪伯伦在《论孩子》中写到:你的孩子,其实不是你的孩子,他们藉助你来到这个世界,却非因你而来,他们属于你做梦也无法达到的明天。

有孩子之前我觉得养育孩子重要的是把他们当朋友,但真的等孩子三岁后有了更多自主意识之后,作为父母就会面临更多困难,什么时候该管教孩子,什么时候该放任他们。有时候我也忍不住发火,而后又觉得自己是个失败的父亲,心里多默念『还只是个孩子』几遍,如何做一个好父亲这必然是我今后一直需要学习的。

生活中的另一个变化是今年身体状态更好了,可能是因为深圳的暖和天气更适合我,加上在家办公出去本职工作外,没感受到什么职场上的琐事和压力,另外在家里办公相关的设备更适合自己,所以整体身体上没有大的问题。

但从心理方面,我能感受到和以前的更大差别,主要是彻底接受了中年这个年龄阶段。这是一点点积累起来的,那些曾经我看着长大的晚辈们都到了谈婚论嫁的年龄,或者偶然想起一些人和事心里一算已经是十多二十年前了,或是我发现自己某些方面更像印象中的父亲了。

我的生活看起来极其单调,不是坐在屏幕前写程序就是在带娃和遛娃,和梦想与激情这些词汇毫不沾边。但我满足并感恩目前的状态,我几乎没有焦虑,物欲低所以也不觉得缺钱,做着自己喜欢的工作和事情,有足够多的时间陪家人,这就很好了。

工作

2023 我全职远程做开源项目,很幸运在 Cryptape 这大半年里工作感受非常好,这大概是我工作这么多年来写程序最开心的一段时间。因为远程办公,今年我的人际圈子似乎更小了,日常微信沟通的都是些认识了 10 来年的朋友。

我的工作主要是做区块链 Layer 1 相关的事情,入职以后做的事情是交易池这块,后来又涉及到一些 RPC 相关的工作,还有些 Infra 类的工作。区块链这行涉及范围太多了,有网络、性能、分布式、密码学等各种,所以对于纯喜欢技术的人来说,这里面挑战太多,比 CRUD 之类的项目好玩得多。

我做的主要工作都是集中在 nervosnetwork/ckb这个项目,这里可以看到我做的一些 Pull requests

另外现在日常工作中纯用 Rust,编程体验和之前完全不是一个层次,除了如何实现功能,我们也会在乎项目的长期可维护性和优雅程度。区块链 Layer 1 也算是一个复杂度高和对准确度要求很高的项目,Rust 是很适合的。我虽然这两年一直在写 Rust 代码和做开源,但之前还真没有用 Rust 在实际工作中,特别是异步这块我之前甚少涉及。同事中有对 Rust 理解很深入的人,沟通也很顺畅,所以我特别喜欢这个工作氛围。

在工作过程中我看了更多 Bitcoin 相关的代码,越发觉得这真是一个伟大的发明,这像是个黑客用技术发起的社会性实验,在 beta 阶段就能如此深刻地影响了世界。关于 Bitcoin 推荐看这一系列文章 比特币的过去、现在和未来

开源

今年继续在为 Rust compiler 做贡献,能回想到的一些事情是:

因为在 Cryptape 的工作涉及到其他一些 Rust 项目,所以参与到了一些,比如我们在改造交易池的过程中用到了 multi_index_map 这个数据结构,顺带完善了一些不足 Non-unique index support, capacity operations, performance improvement

作为技术人,能全职使用自己喜欢的编程语言工作是一个很大的幸运,希望能继续在 Rust 开源这条路上走得更远。

阅读和写作

2023 看书的时间也少了很多,回顾了一下很多书没有看完,但这些书看完后值得分享:

  • 《硅谷钢铁侠:埃隆·马斯克的冒险人生》,这就是那些改变世界的人吧
  • 《失明症漫记》,似乎是重新回顾一遍疫情的场景
    • 如果我们亵渎生活的尊严,我们也就扭曲了理智;而人的尊严每天都会受到我们世界中权势者的侮辱;普遍的谎言已经替代了多元的真理;人一旦失去来自其他成员的尊重,他也就不再尊重自己。
  • 《作个闲人:苏东坡的治愈主义》,这书我估计我年轻的时候看不进去,现在看就觉得很好:
    • 人生如逆旅,我亦是行人
    • 可以寓意于物,而不可以留意于物
    • 一张琴,一壶酒,一溪云
  • 《走出戈壁》优秀的人在逆境中也能成长起来。
  • 《了不起的盖茨比》也许是因为我先看了电影,所以再看书就满脑子小李子那样子,也许有的作品就不应该看电影。
  • 《被讨厌的勇气》,一切烦恼都来自人际关系,让干涉你生活的人去见鬼,解决了一些我的日常困惑。
  • 《哲学家们都干了些什么》,你思考过的很多问题,前人必然已经思考过了。
  • 《夜晚的潜水艇》,这就是文笔好。我喜欢里面的《裁云记》
    • 值得人沉迷一生的事太多了。像你说的,每个洞穴都充满诱惑,难以取舍。我年轻时也在分岔处犹豫过。后来我才明白,不是所有洞口都陈列在那里,任人选择;有的埋伏在暗处:我一脚踏空,就一头栽了下来,到现在也没有落到底。
  • 《美国种族简史》
  • 《高山下的花环》
  • 《凤凰项目,一个 IT 运维的传奇故事》

同样在写作上的时间就更少了,总结下来居然是 13 篇博客,勉强达到月更的节奏。

写作这件事情似乎停下来之后就容易长时间停顿。带孩子太耗精力算是一个借口,但我其实很是可以把一些日常的琐碎时间利用好来做这件事情的,只是确实犯懒了。

希望借这次写年终总结的劲头,把写作这件事情捡起来。

Andriod 使用 Obsidian 的客户端

作者 yukang
2023年9月19日 17:16

上周末试着在 Andriod 上配置好了 Obsidian 的客户端,没想到还挺好用。如果你已经买了 Obsidian 的 sync 服务,并且一切用起来都挺好的,那就不用看我这篇介绍了。

我折腾这个的主要的需求是使用私有仓库的 Git repo 来同步日记。为什么不买 Obsidian sync,我认为 Github 更符合我的使用习惯,并且我选择使用 Obisidian 的一个原因就是我不想把笔记数据同步到其他的第三方平台上,相对来说 Github 是我更信任的基础设施,毕竟我已经使用 Github 这么多年了。

Andriod 客户端

Obsidian 的安卓客户端好像没有在国内各个安卓软件市场上,你需要用过 Google play 来安装。

Termux

termux/termux-app 是一个 Andriod 上的终端模拟器,也是一个开源软件。基本上你可以把 Andriod 当作一个简化版本的 Linux 服务器来使用,Termux 高级终端安装使用配置教程 是一个很详细的介绍文章。

注意目前 termux 已经不能在 Google Play 上安装了,你需要去 Releases · termux/termux-app 下 apk 安装包来手动安装。

termux 安装好之后就可以在 Andriod 手机上跑一个 Shell,打卡进去之后运行来创建一个叫作 storage 的目录:

termux-setup-storage

接下来安装一些后面需要用到的依赖:

pkg install git
pkg install openssl

Git

ssh-keygen 来生成一对公钥和私钥,把公钥配置到自己的 Github 账户上,然后 clone 你的 Obsidian vault repo:

cd storage/share
git config --global credential.helper store
git config --global user.email "<your_email>"
git config --global user.name "<The name you want on your commits>"
git config --global pull.rebase true

确保能在 Termux 上正确提交改动到 Github 上。如果有一些文件是不想同步到远程的,可以加入到.git/info/exclude 里,比如把 .obsidian/workspace-mobile.json 忽略了。

打开 Obsidian 的客户端,找到刚才 Git clone 的目录,打开作为 vault 即可使用。

定时备份

先安装 Termux 上的 cron 服务:

pkg install cronie termux-services

然后退出 Termux 重新打开,运行:

sv-enable crond

运行 crontab -e 来创建一个定时备份的 job:

*/2 * * * * ~/sync_repo.sh

每两分钟自动备份一次,我的 sync_repo.sh 是这样的:

#!/bin/bash
cd  /data/data/com.termux/files/home/storage/shared/ob
git add -A && git commit -a -m "android backup: `date +'%Y-%m-%d %H-%M-%S'`"
git pull
git add .
git rebase --continue
git push

上面的同步脚本很粗暴,如果冲突了我会把冲突一起提交进去,但这也是合理的,因为我需要让自动同步尽量成功,至于冲突可以在笔记本上解决。如果 Termux 进程被杀了,自动备份将无法自动运行。但在我的日常使用过程中,这倒不是一个大问题。


参考:

❌
❌