# 客户程序编程指南

> 让客户程序保持正确、可证明且低成本的习惯。所有易错点和推荐做法集中在一处，每一条都附有背后的理由和应当采取的做法。

编写客户程序（guest），大部分时候就是在写 Rust。本页讲的是其余部分：一台裸机、单 hart、需要被证明的机器，在哪些地方与你习惯的宿主机（host）表现不同。每条规则都说明该怎么做、为什么，以及不这样做会出什么问题。[AI 随行手册](https://apogee.gweb3networks.com/docs/launch/ai-companion)以一种可以直接交给模型的形式，收录了同样的规则。

## 类型与内存

### `usize` 是 32 位，所有指针也是

客户程序的目标是 `riscv32imac`：`usize`、`isize` 和所有指针都是 32 位宽，而在你的宿主机上它们是 64 位。

- `usize` 溢出在客户程序上会 panic，在宿主机上则不会。
- 在客户程序上，从 `u64` 做 `x as usize` 会静默截断。
- 任何包含长度或指针的东西，其 `size_of::<T>()`、结构体布局和 `core::hash` 在两种构建之间都不相同。

**应当**：在你要提交、哈希、序列化或与宿主机计算结果比较的任何东西中，使用显式的 `u32` 和 `u64`。在值可能装不下的地方用 `usize::try_from(x)` 转换，让它在两种构建上都明确地报错。**不要**：提交 `usize`，对包含它的结构体求哈希，或者派生依赖内存布局的编码。

```rust
let n = u64::from_le_bytes(input[..8].try_into().unwrap());
let len = usize::try_from(n).expect("length fits the guest"); // not `n as usize`
```

### 分配器从不释放内存

堆是一个 bump 分配器：`alloc` 把一个指针向上移动，`dealloc` 什么也不做，内存只有在程序退出时才会归还。所以，**让客户程序耗尽内存的，是它在整个运行期间分配的总量，而不是峰值。** 如果一次分配的末端会高于栈的预留区，或高于当前的栈指针，客户程序就以状态 71 退出。

**应当**：

- 分配一次，反复复用：把缓冲区提到循环之外，用 `clear()` 清空它们，而不是构建新的。
- 用 `Vec::with_capacity`、`String::with_capacity` 预先设定集合的容量，让它们在增长时不必重新分配和复制。一个靠逐次 push 增长到 `n` 个元素的 `Vec`，还会把它先前那些较小的缓冲区遗留在堆上。
- 优先借用（`&[u8]`、`&str`）而不是克隆，优先使用迭代器而不是中间集合。
- 就地处理大块的证明者提示（advice）：`advice()` 本来就是内存上的切片，无需复制。

**不要**：在热循环里 `collect()` 出一个新的 `Vec`；克隆只读的值；或者在一个映射表清空后就能重新填充的情况下，为每个请求重建映射表。

```rust
// Total heap grows with the number of requests:
for req in requests {
    let parts: Vec<u32> = req.chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect();
    process(&parts);
}

// Total heap is one buffer:
let mut parts: Vec<u32> = Vec::with_capacity(MAX_PARTS);
for req in requests {
    parts.clear();
    parts.extend(req.chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())));
    process(&parts);
}
```

### 栈有 8 MiB，远端没有任何防护

栈从 `0x8000_0000` 向下增长，有一个任何堆块都不得进入的 8 MiB 预留区。在预留区内深度递归没有问题。没有任何机制能察觉的情况是：堆已经填满了预留区下方的空间，栈却越过了预留区；这时堆块会在深层调用链之下被悄无声息地改写。**应当**：让递归深度有界且可预测，或者用显式的工作列表把深度遍历改写成迭代。**不要**：递归到一个由不可信输入决定的深度。

### 只允许对齐访问

通过未对齐指针进行半字或字访问是致命的，绝不会被拆分处理，这次运行也没有证明。安全的 Rust 永远不会产生这种访问。**不要**把字节指针强制转换为 `*const u32` 再解引用；用 `u32::from_le_bytes` 读取（它会编译成字节加载），或者用 `ptr::read_unaligned`。

### 空指针指向空洞

`0x8000` 以下的地址不属于任何东西，所以空指针或数值很小的野指针会导致致命的 `OutOfBounds`，而不是读到垃圾数据。它表现为一次没有证明的运行，而绝不会表现为一个错误的答案。

## 并发

### 原子操作：受支持，但不用于新的客户程序代码

> [!IMPORTANT]
> A 扩展得到完整支持：`lr.w`、`sc.w` 和全部九条 AMO 指令都能通过它们自己的电路族解码、执行和证明，`core::sync::atomic` 也会编译成这些指令。**尽管如此，仍强烈不建议用原子操作编写客户程序。** 远地虚拟机在单个 hart 上执行，没有中断，也没有线程，所以没有任何需要同步的东西。提供原子操作，是为了让已经使用它们的现有代码（带原子计数器的库，或者 `spin` 锁）不加修改就能编译和证明。它们是一条兼容路径，而不是一种编程实践。

如果原子操作经由依赖进入了你的客户程序，你需要知道：

- **在单个 hart 上，原子操作只是一次读-改-写。** `fetch_add` 就是一条做加法的 `amoadd.w`，没有任何东西能与它交错执行。
- **`sc.w` 总是成功。** 这台机器不保存保留（reservation）状态，所以条件存储（store-conditional）总会执行存储，并向 `rd` 写入 0。编译器为 `compare_exchange` 生成的 `lr.w`/`sc.w` 重试循环不受影响，因为在任何 hart 上首次尝试就成功都是合法的。依赖 `sc.w` 在没有有效保留时*失败*的代码，在这里得不到这种失败。这是远地虚拟机唯一偏离 RV32IMAC 的地方。
- **`fence` 什么也不做**，内存序（`aq`、`rl`、`SeqCst`）在单个 hart 上也不对任何东西排序。
- **它们会多花一个电路族。** 原子操作会把 `ATOMICS` 电路族加入程序，于是程序至少要证明它的一个分片。

**应当**：在新的客户程序代码中，用普通变量、`Cell` 和 `RefCell` 保存状态。**不要**：在一个根本没有第二个线程可共享的客户程序中加入 `AtomicU32`、类似 `Mutex` 的自旋锁或 `Arc`。

## 输入与输出

### 先检查证明者提示，再让由它推导的内容进入公开输出

证明者提示是证明者填写的内存，没有任何东西绑定它。在提交任何依赖于它的内容之前，先把它与证明确实绑定的某样东西核对，例如公开输入中的哈希或 Merkle 根、一个签名，或者结果的某种性质。提交未经检查的证明者提示的函数值，就等于发布一个由证明者选定的值。参见[这一模式](https://apogee.gweb3networks.com/docs/launch/io#pattern)。

### 公开值要小，用于链上时还要定长

输入和公开输出（journal）各自最多容纳 16,380 字节。`commit` 宁可以 70 退出，也不会溢出。大的输入应放进证明者提示，并以承诺加以约束；会增长的输出则应以摘要发布。验证者合约是针对一个输入长度和一个公开输出长度构建的，所以要在以太坊上结算的客户程序应当发布定长的公开输出。

### 想清楚失败是什么样子

以非零状态退出或发生 panic 的客户程序，对它所做的事依然有一份有效的证明，而验证者先读退出状态，再读公开输出。为每一种拒绝分配一个专属的退出码，并避开 SDK 使用的那些（70、71、72 和 101）；在可能拒绝某份未经检查的数据的检查完成之前，不要提交任何由这份数据推导出的内容。

### 没有外部世界

客户程序没有时钟、没有随机数、没有网络、没有文件，也没有环境变量。向宿主程序索取其中任何一样的库调用，都会得到 `-ENOSYS`，并使这次运行无法被证明。给 `HashMap` 一个确定性的种子，或者使用 `BTreeMap`；算法需要随机性时，从输入中推导；时间则作为输入传进来。

## 成本

### 每条执行过的指令都是一行被证明的数据

证明成本取决于周期数，按电路族分别计算。用 `--release` 构建，用周期分析器测量，像嵌入式程序员对待字节那样对待周期。

### 有电路的运算，交给委托

`keccak256`、`sha256`、椭圆曲线加法和乘法、Poseidon2、BN254 域运算以及 256 位模乘都有专用电路。通过 `guest_sdk` 以及 vendored 的 `k256`、`ark-ff` 和 `revm-precompile` 使用它们，而不是把软件实现编译进客户程序。参见[委托](https://apogee.gweb3networks.com/docs/launch/delegations)。

### 验证，而不是计算

当一个结果求解昂贵、检查便宜时，让证明者去求解并作为证明者提示传入，再由客户程序检查：排序顺序、因数分解、逆元、穿过树的路径、搜索结果。

### 避免浮点运算

目标没有 F 或 D 扩展，所以 `f32` 和 `f64` 会编译成整数软件例程。它们正确且确定，但每次运算都要花费许多条指令。使用整数或定点数。

### 高度按整个分片计价

一个有行的电路族，不论填了多少行，至少都要花费一个其高度的分片。只用到某个电路族一次的程序，也要为一整个分片付费；你的代码用到的电路族以及你选择的高度，决定了每份证明的成本下限。参见[高度](https://apogee.gweb3networks.com/docs/launch/prove#heights)。

## 代码与程序身份

### 指令流就是映像

代码是静态的：每个 pc 处的指令都取自加载时构建的解码表，从不取自 RAM。向 `.text` 写入改变的是数据，而不是行为；跳转到没有指令的半字会使运行终止且不产生证明。没有 JIT，也没有自修改代码。

### 任何位置的一个非法字都会让整个程序被拒绝

解码器会处理整个 `.text`，不论是否可达。内联汇编中的 CSR 访问、`fence.i`、浮点或 RV64 编码，或者被汇编进 `.text` 的数据，都会让推导拒绝整个程序。`ebreak` 可以解码，但没有证明。

### 溢出检查是程序的一部分

客户程序的 profile 在 release 中保持 `overflow-checks` 开启，因为关掉它会改变客户程序计算的内容：`u32::MAX + 1` 会回绕并以 0 退出，而不是 panic。在确实需要的地方使用 `wrapping_*`、`checked_*` 和 `saturating_*`。

### 一次构建就是一个程序身份

程序身份绑定代码和数据的每个字节、入口点和每个高度。在另一台机器上重新构建会得到另一个程序身份，因为 ELF 嵌入了绝对路径。登记并交付你证明过的那个 ELF，而不是生成它的命令。

## 检查清单

在证明之前：

- [ ] 为 `riscv32imac-unknown-none-elf` 执行 `cargo build --release`，并已审阅周期分析器的周期报告
- [ ] 提交、哈希或序列化的任何东西中都没有 `usize`
- [ ] 热循环中没有分配；会增长的集合已用 `with_capacity` 设定容量
- [ ] 你自己的客户程序代码中没有原子操作、锁或 `Arc`
- [ ] 每一处对证明者提示的使用，都在任何依赖它的提交之前，与证明所绑定的某样东西核对过
- [ ] 公开输出有上界；若要在链上结算，则为定长
- [ ] 哈希和曲线运算都经由委托完成
- [ ] 每种拒绝都有各自不同的退出码
- [ ] 对于你的测试输入，宿主机构建与模拟器得出的公开输出一致
- [ ] 已从你将要交付的 ELF 记录下程序身份
