ZKVM 的工作原理

  • RareSkills
  • 发布于 2025-04-16 10:04
  • 阅读 154

本文介绍了零知识虚拟机(ZKVM)的概念,它能创建零知识证明来验证机器指令的正确执行。文章通过一个简化的栈式ZKVM示例,展示了如何使用Circom实现基本的算术运算,并探讨了提高ZKVM效率的现代方法,如查找表和递归证明。 ZKVM在零知识Layer2区块链中至关重要,并可用于验证机器学习算法的正确执行。

一个 零知识虚拟机 (ZKVM) 是一个可以创建 ZK 证明的虚拟机,该证明验证它正确执行了一组机器指令。这允许我们获取一个程序(一组操作码),一个虚拟机规范(虚拟机如何运行,它使用什么操作码,等等),并证明生成的输出是正确的。验证者不必重新运行程序,只需检查生成的 ZK 证明 — 这使得验证变得简洁。这种简洁性是使 ZK layer 2 区块链可扩展的基础。它还使某人能够检查机器学习算法是否按照声明运行,而无需重新运行整个算法。

与名称相反,ZKVM 在保持计算私密性的意义上很少是“零知识”的。相反,它们使用 ZK 算法来生成一个简洁的证明,证明程序在某个输入上正确执行,以便验证者可以用指数级减少的工作量来复查计算。即使显示程序输入是可选的,但避免意外的数据泄露以及让多方就私有状态达成一致是非常具有挑战性的工程问题,仍然存在尚未解决的挑战和扩展限制。

ZKVM “计算”操作码中的每个步骤,然后约束该操作码已正确执行。约束的设计方式必须使我们可以使用任意但有效的操作码序列。

我们可以将 ZKVM 视为一系列“状态”转换。“状态转换函数”采用先前的状态和当前要执行的操作码,并生成一个新状态。ZKVM 实现“状态转换函数”和模拟其行为的电路约束。请注意,“状态”可以包括诸如“程序计数器”之类的东西,或者 VM 正常运行所需的其他簿记信息。

本教程将构建一个极其简单的 ZKVM,该 ZKVM 仅支持基本算术,但可扩展以用于其他操作码。此处提供的 VM 只有一个栈,没有内存或存储。我们在文章末尾提供了有关 ZKVM 进一步研究的建议。

先决条件

本教程假定对 EVM 的工作方式(或其他基于栈的架构,例如 Java 虚拟机)有基本的了解。我们大量借鉴了上一章的栈示例,因此我们假设你已经了解了该示例。

简单的基于栈的 ZKVM

我们将构建一个简单的基于栈的 ZKVM,它带有一个特殊用途的信号,该信号包含计算结果。VM 接受一系列操作码和数字,然后将最终结果输出到一个我们称为 out 的特殊信号。

我们的 ZKVM 仅具有以下操作码:

  • PUSH(将第一个参数压入栈)
  • ADD(从栈中弹出最上面的两个项目并压入它们的总和)
  • MUL(从栈中弹出最上面的两个项目并压入它们的乘积)
  • NOP(无操作,什么都不做)

为简单起见,所有操作码都采用单个参数,但只有 PUSH 使用该参数。其余指令忽略该参数。我们为不使用参数的操作码提供参数的原因是,我们不必根据操作码有条件地检查参数是否存在。

没有 STOP 或 RETURN 操作码(稍后会解释替代方法)。VM 接受一个 steps 参数,并在执行 steps 多条指令后返回栈底部的任何值。

以下动画给出了在此架构中将两个数字相加的简单示例:

https://img.learnblockchain.cn/2025/04/16/zkvm.mp4

在 Circom 中,循环不能是可变长度的,它们必须始终执行固定数量的迭代,因为底层 Rank 1 Constraint System (R1CS) 本身必须是固定大小的。同样,程序的大小也不能是可变的。但是,无论程序运行如何,它们都必须具有相同数量的操作码。

要运行操作码数量少于固定数量的程序,我们只需用 NOP 填充它,直到程序达到最大大小。为了知道何时“停止执行”,用户必须提供上述 steps 参数以确定何时将返回栈底部的值。

关于我们架构的一些注意事项:

  • VM 是基于栈的,类似于 EVM、Java 虚拟机或(对于那些了解的人)Reverse Polish Notation calculator
  • 没有跳转指令,因此程序计数器仅递增。
  • 所有操作码都采用单个参数,但 ADD、MUL 和 NOP 忽略传递给它们的参数。这使我们可以每次都将程序计数器递增相同的量 — 我们不必为 PUSH 将程序计数器更新 2,为 ADD 更新 1,依此类推。我们始终将计数器向上移动 2。
  • 要读取 PUSH 的参数,我们只需从程序计数器“向前看”一个索引。
  • 加法和乘法使用模运算完成(Circom 中的默认设置)。我们使用 Circom 默认字段的顺序作为我们的“字大小” — 我们不尝试模拟具有传统字大小(如 64 位或 256 位)的 VM。使用固定大小的位来模拟计算是下一章的主题。

更新我们的栈代码

我们可以对上一章的栈代码进行一些关键更改,以构建满足上述规范的 ZKVM。

  • 我们删除了 POP 操作码,因为它不再需要。
  • 我们添加了 ADD 和 MUL 操作码。

回想一下,以前复制前一个栈的规则是:

  • A. 如果 sp 大于或等于 1,并且列 jsp 低 1 个索引,并且当前指令是 PUSH 或 NOP,我们应该复制列 j
  • B. 如果 sp 大于或等于 2,并且列 jsp 低 2 个索引,并且当前指令是 POP,我们应该复制列 j

规则 A 保持不变,但 B 需要更新为以下内容:

  • B. 如果 sp 大于或等于 2,并且列 jsp3 个索引,并且当前指令是 ADD 或 MUL,我们应该复制列 j

此更改的原因是,先前的 POP 指令没有更改栈上倒数第二个项目,它只是删除了最上面的项目。但是,ADD 有效地将栈弹出两次并压入总和。同样,MUL 将栈弹出两次并压入乘积。

先前的栈实现仅将新值写入栈指针。但是,新的实现可以将总和或乘积写入栈指针下方两个索引的位置。例如,下面栈中的 12 在加法后将变为 15,并且该位置位于栈指针下方两个索引的位置:

加法前:

[12 , 3, sp] (sp = 3)

加法后:

[15, sp] (sp = 2)

在这里,我们有 12 作为栈的底部,sp 指向栈上方的空白区域。

因此,我们需要一个信号来指示特定列低于栈指针两个元素。

下面的代码很大程度上源自上一章的栈,但它实现了本章中描述的更新。具体来说:

  • 我们用 NOP、PUSH、ADD 和 MUL 替换了 NOP、PUSH 和 POP。ADD 和 MUL 将栈指针减一,NOP 使栈指针保持不变,PUSH 将栈指针加一并将其参数复制到栈顶。

pragma circom 2.1.6;

include "circomlib/comparators.circom";
include "circomlib/gates.circom";

template AND3() {
  signal input in[3];
  signal output out;

  signal temp;
  temp <== in[0] * in[1];
  out <== temp * in[2];
}

// i 是列号
// bits 是我们需要多少位
// 用于 LessEqThan 组件
template ShouldCopy(i, bits) {
  signal input sp;
  signal input is_push;
  signal input is_nop;
  signal input is_add;
  signal input is_mul;

  // out = 1 如果应该复制
  signal output out;

  // 健全性检查
  is_add + is_mul + is_push + is_nop === 1;
  is_nop * (1 - is_nop) === 0;
  is_push * (1 - is_push) === 0;
  is_add * (1 - is_add) === 0;
  is_mul * (1 - is_mul) === 0;

  // 计算 ≠ 0 比 > 0 更便宜,以避免
  // 将数字转换为二进制
  signal spEqZero;
  signal spGteOne;
  spEqZero <== IsZero()(sp);
  spGteOne <== 1 - spEqZero;

  // 计算 ≠ 0 和 ≠ 1 比 ≥ 2 更便宜
  signal spEqOne;
  signal spGteTwo;
  spEqOne <== IsEqual()([sp, 1]);
  spGteTwo <== 1 - spEqOne * spEqZero;

  // 当前列是 1 或更多
  // 低于栈指针
  signal oneBelowSp <== LessEqThan(bits)([i, sp - 1]);

  // 当前列是 3 或更多
  // 低于栈...

剩余50%的内容订阅专栏后可查看

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/