ISA & processor
This page describes the ZisK instruction set and the machine that runs it. It covers why ZisK wraps RV64IMA in a proving-friendly machine model with four registers and a fixed (c, flag) = op(a, b) instruction shape, where operands come from and results go, the copyb shim for loads and stores, the VM's state and ROM, how a single instruction is emulated, phantom instructions, initial state and termination, and the opcode catalogue.
The previous pages described the proof system from the outside in: the lifecycle, the trust boundary, the chips, and how the work scales. This page steps back to the level underneath all of that, the ZisK ISA and the processor model that implements it.
Why this design?
RISC-V is a physical ISA shaped by hardware constraints, optimised for execution speed on actual silicon. Among other things, it carries thirty-two general-purpose registers because flip-flops are cheap and the compiler benefits from having lots of them around.
ZisK is a virtual ISA doesn't target hardware, so it doesn't have hardware's reasons for those choices. What it does have is a proving system that turns every register read, every memory access, and every operand into a polynomial constraint. A loose, general-purpose layout in that setting is expensive.
| Goal | What it costs in a hardware ISA | What it costs in ZisK |
|---|---|---|
| Compatibility with existing toolchains | Constrains how registers, memory, and instruction encodings can look. | Achieved by mirroring RV64IMA on the front end and transpiling into ZisK's own shape on the back. |
| Proving efficiency | Doesn't matter — never proved. | The dominant constraint. Every choice gets re-evaluated through "how expensive is this in a proof?" |
| 32 general-purpose registers | Cheap (flip-flops). Compiler gets flexibility, instructions get short. | Expensive as six degrees of freedom per instruction in the constraint system. |
| 4 special-purpose registers | Fewer slots for the compiler; you'd need spills. | Cheap as every instruction lands in the same row shape. |
ZisK ends up with two layers stacked on top of each other:
- A front end that looks like RV64IMA at the source level, so a Rust crate compiles for ZisK with the standard toolchain.
- A machine model that the proving system actually constrains — four registers, one fixed instruction shape, and a small set of source/store options that wire those registers to memory and to the RISC-V register file the front end expects.
The pages above talked about what gets proved. This one is about what gets proved against what — the instruction contract, and the machine that runs it.
Compatibility with RISC-V
The ZisK ISA is anchored to RV64IMA, RISC-V's 64-bit base plus its three core extensions:
- I — base integer instructions
- M — integer multiplication and division
- A — atomic memory instructions
64-bit registers and 32-bit-aligned instruction encoding are the defaults across the toolchain. On top of that, three optional extensions are supported so compilers beyond Rust (C, C#, Go, …) can target ZisK without modification:
| Extension | What it adds | What it costs in ZisK |
|---|---|---|
| F | Single-precision floating-point. | Implemented in a software float library; float operations are expensive to constrain. Use sparingly. |
| D | Double-precision floating-point. | Same as F — software emulation, slow. |
| C | Compressed (16-bit) instructions. | Useful for hardware-RISC-V code locality. ZisK doesn't consume RISC-V bytes directly, so almost no proving impact. |
Precompile calls — SHA-256, Keccak-256, secp256k1 ECDSA, and
the rest — ride on the standard RISC-V ecall mechanism with a
fixed calling convention. The transpiler recognises the
convention and maps each call to the matching ZisK precompile
opcode. From the guest program's perspective, calling a
precompile looks identical to calling any other Rust function.
The instruction shape
Every ZisK instruction performs (c, flag) = op(a, b) with
a, b, c ∈ [0, 2^64) and flag ∈ {0, 1}. The instruction
is, in effect, a single execution step transforming two 64-bit
inputs into a 64-bit output and a boolean. That fixed shape is
what makes proving cheap: every instruction lands in the same
row layout, with the same constraint template covering every
opcode.
The four registers
ZisK exposes four 64-bit registers, each with a fixed role:
| Register | Role |
|---|---|
a | First operand. Always one of the inputs to the current operation. |
b | Second operand. Always the other input. |
c | Result. Whatever the operation produces. |
flag | One-bit side-channel result. Branch decisions and a few comparisons surface here. |
Where a and b come from, and where c ends up, are part of
the instruction's encoding rather than its opcode. The
constraint for any given operation only ever has to talk about
the values in these four registers — it never has to enumerate
which of 32 RISC-V registers happened to be involved.
The 32 RISC-V registers are still there. The ISA addresses them inside the same numerical address space as data memory (more on that in Transpilation) as a load or store targeting the register region behaves exactly as if it targeted RAM, which keeps the ISA uniform for compiler-emitted code. Under the hood, ZisK handles register accesses inside the Main chip rather than routing them through the Memory Bus; that detail is on Specialized chips, and it's invisible to the program either way.
The flow looks like this. Inputs feed a and b, the
operation produces c and flag, and the result is routed to
wherever the instruction says it should go:
For comparison, the same picture for a plain RISC-V processor — three input sources, one output destination, but the operand layout is open-ended and the register file is wide:
The ZisK register file is essentially a wrapper around the
RISC-V one: every RISC-V x register is still reachable, but
the operation itself is always expressed in terms of a, b,
c, and flag. The same constraint template covers every
instruction regardless of which x registers it happened to
touch.
The copyb shim
The fixed c = op(a, b) shape creates one immediate problem:
plain loads and stores have no obvious place. There's no
"operation" being performed when a program copies a memory
location into a register, but the encoding still expects an
opcode.
ZisK solves this with a tiny instruction called copyb. It is
literally c = b — read b from wherever the instruction
says, then copy it through c to wherever the instruction
wants it stored. A RISC-V load from address 0x1000 into x5
becomes a copyb whose b source is memory and whose c
store is a RISC-V register, with no actual computation involved.
The shim also has a strategic payoff: because the operand and destination paths are decoupled from the register file itself, ZisK could in principle wrap a different front-end ISA in the future without disturbing the constraint shape. Today it wraps RISC-V; the same machinery could wrap something else later.
Where operands come from and results go
Each instruction has to declare where its operands come from and where its result goes. The encoding picks one option from each menu, and the constraint template covers them all uniformly.
Where a comes from
a is the first operand, used by every instruction that
performs computation. The encoding picks one of five sources:
| Source | Meaning |
|---|---|
| Immediate | A constant baked into the instruction. |
| Memory | A read from a memory address that is constant for this instruction. |
| Register | A read from a RISC-V register whose index is constant for this instruction. |
c | The result of the previous instruction, threaded directly into the next without a write-back. |
| Step | The current step counter, used by instructions that need to know their position in the trace. |
The "c" source is the one that ties consecutive instructions
together cheaply, when the next operation needs the previous
result, no intermediate write to memory or to a RISC-V register
is required.
Where b comes from
b is the second operand. It has the same first three sources
as a, but instead of the "c" and "Step" options it gains
a sixth that turns out to be load/store's bread and butter:
| Source | Meaning |
|---|---|
| Immediate | A constant baked into the instruction. |
| Memory | A read from a memory address that is constant for this instruction. |
| Register | A read from a RISC-V register whose index is constant for this instruction. |
| Indirect memory | A read from memory at address a + const, with a width of 1, 2, 4, or 8 bytes (also part of the encoding). This is what a base-pointer-plus-offset load compiles down to. |
The indirect-memory source is what makes the addressing modes
useful in practice. Combined with copyb, it covers everything
a RISC-V load does in a single ZisK instruction.
Where c ends up
The result c is written to one of three destinations:
| Store | Meaning |
|---|---|
| Memory | Written to a memory address that is constant for this instruction. |
| Register | Written to a RISC-V register whose index is constant for this instruction. |
| Indirect memory | Written to memory at address a + const, with the same 1/2/4/8-byte width options as the indirect read. |
The result either lives in memory, in a RISC-V register the front end
expects to see, or it's picked up by the next instruction
through the "c" source above.
Where pc comes from
The program counter pc advances after every instruction.
Three rules cover every control-flow case:
| Rule | When it applies |
|---|---|
pc ← c + const | Register-relative jump — the target is computed from the result. |
pc ← pc + const1 | Conditional branch when flag == 1. |
pc ← pc + const2 | Conditional branch when flag == 0. For unconditional sequencing, the instruction sets const1 == const2. |
All three offsets are constants encoded in the instruction, so control flow lands in the same fixed row shape as everything else.
State
The VM's state is everything that changes from one step to the next. ZisK splits it cleanly between guest and host:
| Component | Symbol | Mutable? | Held by |
|---|---|---|---|
| Program ROM | (code + data) | No | guest |
| Program counter | pc_t | Yes | guest |
| Previous result | c_t | Yes | guest |
| Previous flag | flag_t | Yes | guest |
| Data memory | mem_t[] | Yes | guest |
| Input stream | in[] | No (consumed) | host |
| Hint stream | hint[] | No (consumed) | host |
Formally, the guest state at step t is
S_t = (pc_t, c_t, flag_t, mem_t[])
and the host state is H = (in[], hint[]). c_t and flag_t
are part of the state because the next instruction may consume
them — a can take the "c" source mode, and the next-pc
rules read flag directly. Without them in state, the
single-step transition wouldn't be self-contained.
Public outputs are not a separate component. They live in a
designated region of writable RAM (covered on
Transpilation) — the
guest writes to that region through ordinary memory stores
during execution, and when the run terminates, the contents of
that region are extracted from mem_T[] and exposed as the
proof's public output. The buffer is filled in place, not
accumulated as a separate per-step stream.
The Trust boundary page covered the host/guest split in detail; the rest of this section focuses on the parts the ISA exposes.
Program ROM
A ZisK program is two things stitched together:
- Program code — a map from 32-bit instruction addresses to ZisK instructions, loaded before execution begins.
- ROM data — an immutable segment holding program constants (string literals, lookup tables, static data baked into the binary).
Both are determined entirely by the compiled binary and are identical across every execution of that binary. The two parts are committed and validated by different mechanisms:
| Part | How it's bound to the proof |
|---|---|
| Program code | Hashed into a program commitment included in the verifier's context. The Main chip queries this committed table on every fetch via the ROM Bus. Substituting an instruction invalidates the commitment. |
| ROM data | Treated as an immutable memory region. Written once at initialisation; thereafter only reads are permitted. The immutability is enforced by the arithmetization, not the ISA — a read from ROM data looks like any other read. |
Input and hint streams
The host loads two queues into the VM:
- Input stream.
- Hint stream.
Both streams are consumed by copying data into guest memory via
explicit instructions (for example dma_inputcpy for the input
stream). Anything received from the host is untrusted until
the guest has verified it.
How an instruction is emulated
With the contract specified and the machine model fixed, every instruction goes through the same five-stage step. The emulator loops over this step until the program halts:
pc = 0x1000 // start address
for step in 0..max_step
inst = rom.get_inst(pc) // fetch
a = source(inst.source_a) // resolve a
b = source(inst.source_b) // resolve b
c, flag = inst.op(a, b) // operate
store(inst.store_c, c) // commit c
if inst.end break
pc = inst.set_pc(flag) // advance pc
The same five things happen for every instruction the program contains:
Formally, the single-step transition is
S_{t+1} = Step(S_t, hint_t[])
where hint_t[] are the hint values supplied by the host at
step t. The function Step is fully determined by the ZisK
ISA — it decodes the instruction at pc_t, evaluates the
operation, updates mem_{t+1}[], and sets pc_{t+1} according
to the instruction's control-flow rule.
Putting all the choices together
The full picture of an instruction step is a fan-in of source
options on the left, an operation in the middle, and a fan-out
of store and next-pc options on the right:
The diagram is busy on first read, but the underlying picture
is the same one as before: each instruction picks a source for
a, a source for b, an operation, a store for c, and a
rule for the next pc. Everything in the constraint system is
anchored to that template.
Phantom instructions
Most instructions correspond to a single provable opcode and generate a proof obligation for that operation. Some, however, are phantom instructions: they leave the guest state unchanged — they're no-ops from the VM's perspective — but may trigger side effects in the host state (advancing the input or hint streams, emitting debug information).
Phantom instructions appear in the main execution trace but carry no proof obligation of their own. Two notable cases:
- Precompile dispatch — instructions that implement the calling convention for precompiles. What gets proven is the precompile computation itself, not the bookkeeping rows in the Main trace.
- Halting — the
haltinstruction terminates the emulator immediately with an error condition. Its opcode cannot appear in a valid execution trace; if it is reached, proof generation fails. It exists as a sentinel for invalid or unreachable code paths.
Initial state and termination
Execution begins at a designated boot address with memory initialised from the program binary:
S_0 = (pc_0, 0, 0, mem_0[])
where mem_0[] reflects the pre-loaded ROM data and input
data, with all writable memory otherwise zeroed. Specifically:
- The ROM data region is populated with program constants from the binary.
- The input data region is loaded from the public input stream.
- All RISC-V registers (memory-mapped at the start of the system region) are set to zero.
- All other writable memory is zeroed.
A T-step execution is successful if every transition is valid and the final program counter reaches a designated exit address. It fails if any step violates an ISA invariant:
- An invalid opcode is encountered.
- An out-of-bounds memory access is performed.
- The
haltinstruction is reached. - The program counter never reaches the exit address.
A failed execution cannot be proved. When a run terminates
successfully, the public-outputs region of mem_T[] is
extracted and exposed as the proof's public data.
Opcode catalogue
The full opcode list lives in zisk_ops.rs.
At a glance, instructions group into the following categories:
| Category | Examples | Handled by |
|---|---|---|
| Internal | flag, copyb, halt | Main chip directly. |
| Fcall | fcall_param, fcall, fcall_get — the precompile calling convention. | Phantom in Main; the precompile chip proves the actual work. |
| Pubout | pubout — emit a public output. | Main chip; output region. |
Binary (64-bit + 32-bit _w) | add, sub, and, or, xor, lt/ltu, eq, min/max, … | Binary chip. |
| BinaryEx | sll, srl, sra, signextend_*. | Binary-extended chip. |
| ArithAm32 | mul, mulh, mulu, div, rem (and 32-bit variants). | Arith chip. |
| ArithA32 | 32-bit divu_w, remu_w, div_w, rem_w. | Arith chip. |
| DMA | dma_memcpy, dma_memcmp, dma_inputcpy, dma_xmemcpy, dma_xmemcmp, dma_xmemset. | DMA precompile. |
| BigInt | add256. | BigInt precompile. |
| ArithEq | arith256, arith256_mod, secp256k1_*, secp256r1_*, bn254_curve_*, bn254_complex_*. | 256-bit arithmetic precompile. |
| ArithEq384 | arith384_mod, bls12_381_curve_*, bls12_381_complex_*. | 384-bit arithmetic precompile. |
| Keccak | keccak. | Keccak precompile. |
| Sha256 | sha256 (one compression). | SHA-256 precompile. |
| Blake2 | blake2 (one round of BLAKE2b compression). | BLAKE2 precompile. |
| Poseidon2 | poseidon2. | Poseidon2 precompile (ZK-friendly hash). |
Each precompile category corresponds to an independent chip covered on the Specialized chips page. Adding a new precompile means defining a new chip and a new opcode group; existing categories are untouched.
Hard limits
The ISA is bounded — maximum input size, maximum ROM size, maximum program length, and so on. The full table, with how much of each limit a real workload (Ethereum-block validation) actually consumes, is on the Limits page.
Where this picks up
You now know what a ZisK program is in contract terms (a
two-stream input model, a committed ROM split into code and
data, an explicit execution model, a catalogued opcode set) and
how the machine actually runs it (four registers, the
(c, flag) = op(a, b) shape, the source/store options, the
five-stage step). The next page,
Transpilation, shows how the RISC-V binary
you compiled becomes the ZisK ROM that this machine actually
runs against, and where everything lives in the address space
at runtime.