Skip to content

Internal RISC-V assembler fails on 4/7 bundled examples: missing B-type branch range relaxation #1

Description

@janx

(Filed by claude code)

Summary

cellc … --target riscv64-elf fails for 4 of the 7 bundled examples because the internal assembler rejects conditional branches whose displacement exceeds the RV64 B-type 13-bit signed immediate range (±4 KB). The failure propagates through cargo test --test examples, so bundled_examples_compile_to_elf is currently red on main.

The bug is independent of --target-profile — the default (spora), explicit --target-profile spora, and --target-profile ckb all trigger the same overflow on any example whose code reaches the internal assembler. CKB profile appears to "work" for some examples only because its target-profile policy gate rejects them before codegen runs.

Reproduction

git checkout main
for f in examples/{token,nft,vesting,amm_pool,multisig,timelock,launch}.cell; do
  cellc "$f" --target riscv64-elf 2>&1 | tail -1 | sed "s|^|$f :: |"
done
examples/token.cell    :: error: line 0: immediate '5060'  does not fit 13-bit signed field
examples/nft.cell      :: error: line 0: immediate '4888'  does not fit 13-bit signed field
examples/vesting.cell  :: error: line 0: immediate '5672'  does not fit 13-bit signed field
examples/amm_pool.cell :: error: line 0: immediate '11748' does not fit 13-bit signed field
examples/multisig.cell :: (ok)
examples/timelock.cell :: (ok)
examples/launch.cell   :: (ok)

Same results with explicit --target-profile spora. Under --target-profile ckb, token.cell still fails with the same 5060 overflow; the other examples are rejected by the CKB policy gate in src/lib.rs:465-605 before reaching codegen, which masks (but does not fix) the assembler bug.

cargo test --test examples -- bundled_examples_compile_to_elf
thread 'bundled_examples_compile_to_elf' panicked at tests/examples.rs:293:29:
amm_pool.cell should compile to ELF:
  immediate '11748' does not fit 13-bit signed field
test result: FAILED. 9 passed; 1 failed

Expected vs. actual

  • Expected: every example in BUNDLED_EXAMPLES (tests/examples.rs:4) compiles to ELF, matching the assertion at tests/examples.rs:293.
  • Actual: four examples fail in src/codegen/mod.rs::encode_signed_bits with the 13-bit overflow error. --target riscv64-asm still works for all seven — only the ELF path is broken.

Root cause

RV64 conditional branches (beq / bne / blt / bge / bltu / bgeu, including the beqz / bnez aliases) are B-type: 13-bit signed PC-relative immediate, i.e. ±4096 bytes. The internal assembler encodes them directly with no fallback:

// src/codegen/mod.rs:5274-5282
Instruction::Beqz { rs, label } => {
    let target = parsed.symbol_address(label, layout)?;
    out.extend_from_slice(
        &encode_b_type(0x63, 0b000, *rs, 0, relative_offset(pc, target)?)?
            .to_le_bytes(),
    );
}
Instruction::Bnez { rs, label } => { /* identical pattern */ }
// src/codegen/mod.rs:5534-5538
fn encode_b_type(opcode: u32, funct3: u32, rs1: u8, rs2: u8, imm: i64) -> Result<u32> {
    // ...
    let imm = encode_signed_bits(imm, 13)?;

Because codegen emits validation logic inline — each 32-byte type_hash comparison expands into 32 lbu/lbu/sub/beqz/<5-line fail epilogue> blocks, and every size/bounds/witness-magic check gets its own inline fail stub — the distance from a conditional branch to a shared exit label (.Lentry_witness_fail_*, .Lentry_witness_done_*, etc.) grows quickly. In amm_pool.cell this distance reaches 11748 bytes, ~3× the B-type limit.

The standard fix is branch range relaxation: when a B-type target is out of range, rewrite the branch as an inverted short branch plus a J-type jump (which has 21-bit ±1 MB range):

beqz rs, far_label

becomes

bnez rs, .Lskip_N
j    far_label
.Lskip_N:

GAS, LLVM MC, and Keystone all do this automatically. The internal assembler is currently the only path without it.

Secondary issues uncovered

(1) Error message is missing span information

encode_signed_bits constructs the error with Span::default():

// src/codegen/mod.rs:5577-5580
return Err(CompileError::new(
    format!("immediate '{}' does not fit {}-bit signed field", value, bits),
    crate::error::Span::default(),
));

Result: every failure prints line 0:, regardless of which assembly line or which source position the branch originated from. The assembler has per-line information available during parsing (ParsedAssembly::from_lines); it should be threaded down into encode_instruction.

(2) CI evidently isn't running cargo test --test examples (or isn't gating on it)

A failing panic!-based test in the default test suite has landed on main (commit 10d5762 Initial CellScript standalone release). Either CI isn't running this target or the failure is being ignored. Regardless of the branch-relaxation fix, the test-gating policy needs a look.

Suggested fix

Two options, in increasing scope:

Option A — minimum viable: implement branch range relaxation

Modify encode_instruction in src/codegen/mod.rs:5274-5282 to detect out-of-range B-type branches and emit an inverted-branch-plus-jump pair. Because this changes instruction stream length, SectionLayout needs a fixup pass (conservative first pass for symbol addresses, exact second pass for emission). Standard two-pass assembler shape.

Estimated effort: ~1 day. Fixes all four failing examples. No semantic change to generated code.

Option B — recommended: share fail-handler routines (eliminates the long branches at source)

The generated assembly currently has 302 copies of the li a0, <err>; ld ra; ld fp; addi sp; ret fail epilogue across token.s alone. Collapsing these into ~8 shared handlers (one per distinct error code) and replacing fail sites with single-instruction j __fail_<code> brings every conditional branch target within a few hundred bytes. Also eliminates the equivalent issue in the 128 inlined byte-compare blocks by factoring them into a __verify_32byte_eq(ptr1, ptr2, err_code) helper.

Side effects:

  • The branch-range bug disappears without needing relaxation.
  • Assembly text shrinks by ~40% (observed on token.s: 4505 → ~2600 lines).
  • +~2 cycles per fail path due to the extra j (negligible vs. CKB/Spora cycle budgets).

Estimated effort: ~1 week. Recommended as the actual fix because it addresses the underlying codegen smell rather than papering over it in the assembler.

Either way

  1. Plumb real spans into encode_signed_bits / encode_b_type so future assembler errors point at the offending line.
  2. Add a regression test that generates a >8 KB synthetic assembly fragment with a long-distance branch and asserts it assembles.
  3. Verify CI is actually running cargo test --test examples and failing the build on bundled_examples_compile_to_elf regressions.

Impact assessment

  • User-visible: the documented primary deliverable (cellc <input> --target riscv64-elf for on-chain deployment) fails silently — there is no warning in the README or cellc --help that four of the seven example programs can't actually be produced as ELF. --target riscv64-asm still works, which can mask the problem in casual testing.
  • Trust: the project's own test suite is red on main. Any downstream consumer running cargo test sees the failure immediately.
  • Difficulty: low — this is a missing standard assembler feature, not an architectural issue.

Environment

  • Branch: main at 10d5762 Initial CellScript standalone release
  • Platform: Linux
  • Toolchain: rustc (stable), cargo run --bin cellc
  • No external RISC-V toolchain present (so the internal assembler path is exercised; try_external_elf_toolchain at src/codegen/mod.rs:4713 returns None).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions