Skip to content

Add an ARMv8-A (AArch64) backend - #1541

Open
clebreto wants to merge 61 commits into
jasmin-lang:mainfrom
clebreto:feature/armv8a-distilled
Open

Add an ARMv8-A (AArch64) backend#1541
clebreto wants to merge 61 commits into
jasmin-lang:mainfrom
clebreto:feature/armv8a-distilled

Conversation

@clebreto

Copy link
Copy Markdown
Contributor

New architecture -arch armv8a targeting A64 (AArch64), modeled at the full 64-bit register width.

  • Coq model: registers x0..x30/xzr/sp, NZCV flags and conditions (shared with arm-m4 through arm_common), 43 mnemonics — exactly the set the compilation pipeline can emit (lowering, load-immediate and smart-address helpers, mov_ofs, stack zeroization, pseudo-op expansion). Each instruction carries its ARM ARM (DDI 0487 M.a) documentation block, and the DIT classification lives in the descriptors (id_doit), everything being DIT except SDIV/UDIV/ADR.
  • Immediate legality is modeled by architecture-local caimm_cond checkers (arith imm12, bitmask immediates, MOV-alias immediates, shift amounts, MOVZ/MOVN/MOVK halfword shifts restricted per operand width).
  • Proofs: the lowering pass and all ten h_architecture_params hypotheses are proved; the extraction cone contains no admitted statement.
  • OCaml: GNU/ELF AArch64 printer, AAPCS64 calling convention, -sp-min-align (SP stays 16-byte aligned across calls, with a custom epilogue restore since an A64 LDR cannot target SP).
  • Tests: armv8a and stack-zeroization check categories; a hardware differential test (make -C compiler check-armv8a-semantics, AArch64 hosts only) compares the extracted id_semi of every instruction form against the silicon — currently 509 forms / 21442 checks.

Validated end to end by compiling the ML-DSA reference implementations (formosa-mldsa) for the three parameter sets and running the full KAT suites.

Previous review round

A first round by @bgregoir happened on the fork: clebreto#5. Summary of the points and their resolutions:

  • "Do we need this file?" (compiler/doc/extract_armv8a_isa_docs.py) — removed from tracking and history.
  • MOVZ/MOVN/MOVK at U32 with shifts 32/48 accepted by the checker but not encodable: "we should fix that in the model" — fixed: the checker carries the operand width (CAimmC_armv8a_halfword_shift) and rejects 32/48 in the W form.
  • Stale skip note about ROR-shifted arithmetic forms — removed; those forms are invalid in the model (shift_allowed in id_valid).
  • jasmin_checksafety.ml error reporting "should go in a separated PR" — moved out (jasmin-checksafety: report analysis-time errors cleanly clebreto/jasmin#6); only the armv8a stub change in safetyMain.ml stays (error out instead of claiming safety).
  • check-print-roundtrip script "can go to a separated PR" — moved out (Add a check-print-roundtrip test script clebreto/jasmin#7); the armv8a-print-roundtrip category left with it.

clebreto added 30 commits July 10, 2026 14:19
New architecture `-arch armv8a` targeting A64, modeled at the full
64-bit register width (reg_size = U64, X registers only).

Coq:
- armv8a_decl.v: registers R0..R30/RZR/RSP printed x0..x30/xzr/sp,
  NZCV flags, 14 condition codes, AAPCS64 calling convention,
  register shifts (0..63).
- armv8a_instr_decl.v: 76 mnemonics with full semantics; each carries
  its ARM ARM (DDI 0487 M.a) documentation block (C6.2 section, page,
  summary, syntax and Operation pseudocode). Flag-setting forms are
  separate mnemonics (ADDS, ...); conditional selection (CSEL family)
  takes the condition as a first-class operand; loads/stores rely on
  the framework for the memory access and only extend values;
  MOVZ/MOVN/MOVK model wide immediate moves.
- armv8a.v: NZCV condition evaluation and asm instance.
- armv8a_extra.v: swap, add_large_imm and smart_li pseudo-instructions
  (smart_li expands to MOVZ/MOVK sequences; A64 has no conditional
  execution so there is no conditional variant).
- armv8a_lowering.v (new): lowering pass; conditions via CMP/TST and
  NZCV, word conditional expressions via CSEL, large ADD/SUB
  immediates via add_large_imm, shifted-operand fusion.
- armv8a_params_core.v, armv8a_params.v, armv8a_stack_zeroization.v:
  compiler passes parameters at U64.
- arch_decl.v: new CAimmC_armv8a_shift_amount and
  CAimmC_armv8a_0_16_32_48 immediate checkers (other architectures
  reject them).

OCaml:
- pp_arm_v8a.ml: GNU/ELF AArch64 printer; W-register forms for narrow
  loads/stores, extensions and 32-bit multiplies; ADRP/ADD :lo12: for
  global addresses; b.cc branches and ret-based returns.
- coreArchFactory retargeted to Armv8a_* modules; callstyle returns
  via X30.

Docs: compiler/doc/armv8a_isa_docs.json machine-readable extraction
of the ARM ARM instruction documentation (recording the
PDF edition and hash of the source document).

Tests: 14 test programs under tests/success/armv8a, wired as the
`armv8a` check category (compiled, assembled with llvm-mc
--triple=aarch64 and linked with ld.lld).

Correctness proofs are deferred; every semantics function is total
and the extraction cone contains no admitted statement.
Data-processing instructions, comparisons, conditional selection, moves
and loads/stores now exist in both the 64-bit (X) and 32-bit (W) forms,
selected by the new [opts_size] instruction option (intrinsic suffix
_32, e.g. #ADD_32). W-form destinations zero the upper 32 bits
([MSB_CLEAR]) as in the architecture. The lowering pass lowers word
operations at both sizes, including 32-bit comparisons, CSEL, MOVZ/MOVK
immediate materialization, register-to-register sign/zero extensions
(SXTB/SXTH/SXTW/UXTB/UXTH/UXTW) and narrow zero/sign-extending loads.
Fixed-size instructions (UMULL, SMULH, ADR, LDRSW, REV32, ...) are only
valid in the X form.

The shared CAimmC_armv8a_shift_amount immediate checker takes the
operand size (shift amounts are 0..31 in the W form).
Fixes the codegen "blind spots" that made real AArch64 output fault at
run time, and documents each modeling decision against the spec.

Register model (armv8a_decl.v):
- Drop XZR from the register set. Field value 31 is the zero register in
  the X[] accessor (reads zero, ignores writes), so allocating it is
  unsound; it was producing `mov sp, xzr`. Like RISC-V's x0, omit it and
  emit ZR-operand ops (CMP, NEG, TST, ...) as dedicated mnemonics.
- Drop x18: it is the platform register, reserved on Apple/Windows/SCS
  Linux where the OS may clobber it asynchronously (invisible to Jasmin's
  clobber analysis), which corrupted the saved stack pointer.

Stack pointer (armv8a_params.v):
- Round every frame allocation to 16 bytes and never re-align a frame to
  less than 16, so SP stays quadword-aligned across nested calls (AArch64
  raises an SP Alignment exception otherwise).
- Restore a spilled SP via a scratch register + MOV, since A64 LDR cannot
  target SP (its result goes through the X[] accessor, where 31 = ZR).

Each decision is sourced (Arm ARM DDI0487M.a B1.2/D1.4.10.2, AAPCS64,
Apple platform ABI) in the inline comments and in the new
compiler/doc/armv8a_register_model.md.

Validated: ML-DSA-65 compiled with -arch armv8a assembles cleanly and
passes the full formosa-mldsa KAT suite (NIST DRBG, PQC, Wycheproof)
natively on arm64.
Add armv8a_params_core_proof.v, mirroring arm_params_core_proof.v, with the
sem_fopn_args correctness lemmas for the single-instruction core builders used
by stack handling and immediate materialization: add, addi, sub, subi, mov,
andi (the AND behind align), plus the smart_mov combinator. All proven, no
admits.

One AArch64-specific wrinkle vs ARMv7-M: SUB/AND semantics use word-scope
operators (sub_word / wand) that are not definitionally equal to the ring
operators in the lemma statements, so the proofs bridge with sub_wordE (ADD is
fine since add_word is defeq to ring add).

Deferred (documented in the file header): li and the gen_smart_opi /
smart_addi / smart_subi combinators built on it. AArch64 li emits a
variable-length MOVZ/MOVN/MOVK sequence over up to four 16-bit halves of a
64-bit register, whose correctness needs bit-level decomposition lemmas that do
not port from ARMv7-M's fixed MOV/MOVT (two-halfword) materialization.
Add armv8a_params_common_proof.v, mirroring the portable part of
arm_params_common_proof.v. Lifts the core sem_fopn_args results through the
to_opn/Oarmv8a wrapper to the generic linear semantics:

- sem_fopn_equiv / sem_fopns_equiv: the Oarmv8a-tagged op runs exactly as the
  core builder;
- mov / addi / subi / align at sem_fopn_args level, and their eval_instr forms
  (the single-instruction builders used by allocate/free_stack_frame and
  set_up_sp_register);
- armv8a_align_mask: the AND mask Z_mod_lnot (wsize_size al - 1) equals
  -wsize_size al modulo the register base, so align computes exactly
  align_word (proved via wrepr_wnot + ZlnotE).

All proven, no admits. As in armv8a_params_core_proof.v, the li-based smart_*
combinators remain deferred (AArch64 MOVZ/MOVK materialization; see that file's
header).
Document why the two shift-amount checkers in caimm_checker_s carry different
arguments (shift_kind for AArch32, wsize for AArch64), with verbatim spec
sources:

- shift_kind.v [shift_amount_bounds]: AArch32 amount range is type-dependent
  (LSL 0..31, LSR/ASR 1..32, ROR 1..31), from DecodeImmShift (Arm ARM
  DDI0487M.a, J1.2.3.7, p. J1-16079): LSR/ASR encode jasmin-lang#32 as imm5=0 and ROR #0
  is RRX, so only LSL admits 0.
- armv8a_decl.v [check_shift_amount]: AArch64 amount range is width-dependent
  and type-independent, [0, datasize), from the C6.2 shifted-register decode
  (p. C6-1813): shift_amount = UInt(imm6), datasize = 32 << UInt(sf), and
  "if sf == '0' && imm6<5> == '1' then UNDEFINED".
- arch_decl.v: note at the caimm_checker_s constructors explaining the axis
  each is keyed on and pointing to the two predicates.

Comment-only.
Replace the permissive CAimmC_none immediate checkers with predicates
grounded in the Arm ARM (DDI 0487 M.a):

- CAimmC_armv8a_arith_imm: imm12, optionally LSL jasmin-lang#12 (C6.2.5), for
  ADD/ADDS/SUB/SUBS/CMP/CMN. Same predicate as is_arith_small, which
  now aliases it.
- CAimmC_armv8a_bitmask_imm: logical bitmask immediates (DecodeBitMasks),
  for AND/ANDS/ORR/EOR/TST. Executable check: replication tested as
  invariance under rotation by the element size, the element tested as a
  rotated run of ones over all (bounded) rotations.
- CAimmC_armv8a_mov_imm: MOV alias immediates (wide, inverted wide, or
  bitmask; C6.2.192-194).
- BIC/BICS and MVN have no immediate form in A64: register-only now.

Non-encodable immediates (e.g. and x, x, #8380417) are now rejected at
assembly generation with an error naming the constraint, instead of
emitted as assembly the assembler refuses.

Also settle the smart_li_args FIXME: rejecting sizes other than U64/U32
is by design (the only A64 operand widths with MOVZ/MOVK forms).
Discharge the central proof blocker of the ARMv8-A backend: correctness
of ARMv8AFopn_core.li and the gen_smart_opi combinators built on it.

Word level (ws-generic, reusable at U32 for the Oarmv8a_smart_li
assembly-generation proof later):
- wbit_n_add: bit-splitting of an n-aligned sum, requiring only that the
  sum fits the word (the ARMv7-M analog needs 2^n * 2^n <= wbase, which
  fails for the high chunks of a 64-bit word).
- wor_wrepr_add: OR of an n-aligned value with a value below 2^n is
  addition.
- wand_movk_mask_id: the MOVK keep-mask leaves a value untouched when
  its bits from the insertion position up are zero.
- armv8a_MOVZ/MOVN/MOVK_semiE: closed forms of the wide-move semantics;
  MOVK inserting chunk c at position sh into a value below 2^sh yields
  wrepr (2^sh * c + m).

Semantic level (register size):
- movz/movn/movk sem_fopn_args lemmas.
- li_lsem_1: the three-way case split of li (single MOVZ; single MOVN
  via Z_mod_lnot; general MOVZ + up-to-three conditional MOVK), the
  general case by the chunk-chain invariant that after treating the
  chunk at position sh the register holds wrepr U64 (n mod 2^(sh+16)) --
  chunks skipped because they are zero preserve the invariant for free
  (z_mod_recombine).
- gen_smart_opi_sem_fopn_args, ported from ARMv7-M now that li is
  available, and the ARMv8AFopnP lifts smart_addi/smart_subi and their
  _tmp variants in armv8a_params_common_proof.v.
…acle

The linearization correctness specs (sf_correct, set_up_sp_register_correct)
require lip_allocate/free_stack_frame to adjust SP by exactly the size they
are given and set_up_sp_register to align by exactly the frame alignment,
for any size — so the round_up_16 wrapping and the U128 alignment bump
inside the ARMv8-A linearization params were unprovable as stated.

Keep the params spec-exact and enforce the architectural invariant
(SP alignment checking, Arm ARM DDI0487M.a D1.4.10.2) where frame shapes
are decided instead:

- Arch_full.Core_arch gains sp_min_align (U128 on armv8a, U8 elsewhere).
- stackAlloc.ml raises every stack-using function's final alignment to
  sp_min_align (both the subroutine and export paths, next to the
  existing stack-zeroization bump). Frame sizes are rounded up to the
  frame alignment (stack_frame_allocation_size), so every SP adjustment
  becomes a multiple of 16 and the platform-guaranteed entry alignment
  is preserved across calls. Functions with no stack footprint keep
  their alignment so exports can still use SavedStackNone (which
  requires sf_align = U8).
- armv8a_params.v: drop round_up_16 and the set_up_sp_register alignment
  bump; document the invariant's new home.

Validated: emitted code aligns an 8-byte frame to 16 and ANDs SP with ~15
in the export prologue; all 14 armv8a tests pass; formosa-mldsa KATs pass
for all three parameter sets.
New armv8a_params_proof.v, mirroring arm_params_proof.v:

Stack alloc (complete, armv8a_hsaparams record built):
- armv8a_mov_ofsP: the full ADR/LDR/MOV/ADD/shifted-ADD/add_large_imm/STR
  case analysis over mk_lea, ported from ARMv7-M (the two mov_ofs
  definitions are structurally identical) with the AArch64 semi
  unfoldings.
- armv8a_immediateP: the Oarmv8a_smart_li sopn semantics is the identity,
  so this is immediate.
- armv8a_swapP.

Linearization (9 of 10 obligations):
- allocate/free_stack_frame via the smart_subi/addi_tmp lemmas (tmp case)
  and the plain subi/addi lemmas (no-tmp case), now that the definitions
  are spec-exact (no rounding).
- set_up_sp_register: sub-align-mov-mov chain over abstract var_i's.
- lmove, lstore, lload; lstores via the generic lstores_imm_dfl_correct
  plus ladd_imm correctness of ARMv8AFopn_smart_addi.
- lip_tmp <> lip_tmp2 and check_ws; ok_lip_tmp/tmp2 (X16/X17).
- h_lower_addressing_params (identity on armv8a).

Still missing: lloads_correct for the custom armv8a_lloads (SP restored
through X17 + MOV since LDR cannot target SP) -- the last field of
h_linearization_params -- then the lowering, asm-gen and
stack-zeroization proofs before h_architecture_params can be assembled.
Prove lloads_correct for the custom armv8a_lloads and assemble the full
h_linearization_params record.

armv8a_lloads restores the saved stack pointer through the scratch
register X17 and a MOV, since an A64 load writes through the X[]
accessor for which register number 31 is ZR, not SP. The proof:

- lloads_foldM_eq_ex: a restore foldM only modifies the variables it
  sets (induction over the restore list).
- hsp: the SP-restoring tail, proven from any varmap holding the old SP,
  covering both the LDR-with-small-offset form and the
  smart_addi-through-X17 form, by direct computation of the LDR/MOV
  semantics.
- The register-restore prefix by the generic lloads_aux_correct for the
  small-offset branch, and for the rebased branch an induction relating
  the specification's foldM (offsets from the old SP) to the rebased
  foldM (offsets from X17 = SP + ofs0), mirroring
  lloads_imm_dfl_correct's inner induction.

Proof-engineering notes (the goals mix two convertible elaborations of
the same instances: the asm_opI flavor from armv8a_params.v definitions
and the _asmop-of-sip flavor from linearization_proof.v): cross-flavor
facts cannot be rewritten directly; they are bridged with occurrence
patterns folded by kernel conversion (rewrite -[X in ..]/(..)),
exact:-checked restatements, and a generic bind_ok_estate/foldM_1 pair
to reduce binds without simpl (whose over-reduction breaks matching).
Condition assembly (the NZCV/condition-code model):
- condt_of_rflagP, condt_notP, condt_andP, condt_orP against
  armv8a_eval_cond, by exhaustive flag-boolean case analysis (the A64
  cond-code table, C1.2.4), and the eval_assemble_cond induction over
  Fvar/Onot/Obeq(GE-trick)/Oand/Oor, giving assemble_cond_spec.

Extra ops (assemble_extra_correct):
- Oarmv8a_swap: the three-EOR sequence, via assemble_opsP.
- Oarmv8a_add_large_imm: via the smart_addi correctness lemma.
- Oarmv8a_smart_li at BOTH widths: U64 via li_lsem_1, and U32 via a new
  li_lsem_1_w32 in armv8a_params_core_proof.v (W-form MOVZ/MOVK chain of
  at most two chunks, reusing the ws-generic MOVZ/MOVN/MOVK_semiE word
  lemmas; the 32-bit value lives in the register-typed variable under
  the withsubword discipline and value_uincl absorbs the upper-bit
  clearing at the assembly level).
- assemble_extra_sz, and the armv8a_hagparams record.

Also armv8a_hshp (SLH is empty on armv8a) and armv8a_is_move_opP
(MOV/LDR/STR/STRH/STRB all return their input truncated to the output
width, proved uniformly over the operand size).

Remaining before h_architecture_params: armv8a_lowering_proof.v and
armv8a_stack_zeroization_proof.v.
Port arm_stack_zeroization_proof.v (781 lines). The two zeroization
strategies (loop and unrolled) and their state-relation invariants carry
over almost verbatim since the armv8a code generator mirrors ARMv7-M's
structure and the NZCV flag semantics (nzcv_of_aluop) are shared; the
differences:

- store_zero_eval_instr: armv8a's STR at default_opts is 64-bit even for
  a 32-bit store (the Store lexpr does the truncation), so the ARM
  store_mn_of_wsizeP shape does not apply; proven by four-way case
  computation over the store width, now up to U64 (the ws <= U32 bound
  becomes ws <= U64 throughout, matching the armv8a command's assert).
- fopn_li/fopn_movi/fopn_sub eval_instr lemmas proven locally (the
  armv8a wrappers live in armv8a_stack_zeroization.v itself; fopn_li is
  the Oarmv8a_smart_li pseudo-instruction whose sopn semantics is the
  identity -- its MOVZ/MOVK expansion is the already-proven
  assembly-generation lemma).

armv8a_params_proof.v gains armv8a_hszparams. Eight of the ten
h_architecture_params fields are now proven; only the lowering proof
(hap_hlop) remains.
Add armv8a_lowering_proof.v (lower_callP and it_lower_callP) and build
the full armv8a_h_params : h_architecture_params armv8a_params record:
all ten fields are now proven.

The proof is a port of arm_lowering_proof.v, generalized from ARM's
single register width to the W (U32) and X (U64) forms: chk_ws_reg
yields a width disjunction rather than an equation, so the operand
width stays abstract and the operator resolution peels the per-operator
width asserts instead of enumerating wsizes. Goal-number-based tactic
selectors from the ARM proof are replaced by mnemonic-dispatched
branches, which are insensitive to the goal count and order.

armv8a-specific differences from the ARM proof:
- Pif is lowered to CSEL (A64 has no conditional execution), with a
  direct semantic proof against armv8a_CSEL_semi.
- The new large_arith_imm path (64-bit ADD/SUB immediates that do not
  fit imm12) is proven against Oarmv8a_add_large_imm.
- MADD/MSUB replace MLA/MLS, with the product width pinned to the
  operation width by antisymmetry.
- Stores only accept registers (no conditional stores), with the
  narrow-store accumulator width bump.
- lower_mulu and the shifted three-operand class do not exist on A64
  and are dropped; the shiftable unop class covers MVN and NEG.

Fix a soundness gap in the shift lowering found while proving it:
armv8a_shift_semi reduces the shift amount modulo the operand size
(faithful to A64 register shifts), but Jasmin's source shifts clamp it,
so the two disagree for amounts >= the operand size. Olsr/Olsl/Oasr now
only lower compile-time-constant amounts in [0, wsize), matching the
constant-only discipline of Orol and get_arg_shift; in-range constant
shifts compile unchanged, while variable logical shifts (previously
silently miscompiled for out-of-range amounts) are rejected. Rotates
are unaffected since rotation is periodic.
Generalize the shift-amount discipline from constant-only to the x86 and
RISC-V design: a logical/arithmetic shift amount is accepted if it is
either a compile-time constant in [0, wsize) or an expression explicitly
masked to the operand size (a & (wsize_bits - 1)). In the masked case
the mask is dropped, since the A64 register shifts already reduce the
amount modulo the operand size; on that range the hardware modulus and
Jasmin's clamping source semantics coincide, which check_shift_exprP
proves (via wand_modulo). This restores idiomatic variable shifts,
x = y >> (n & 63), while keeping unmasked variable shifts a compile-time
error rather than the silent miscompilation they used to be.

The armv8a shift test is updated to the masked idiom. Validated: full
proofs build, the armv8a test suite, and the ML-DSA KATs for all three
parameter sets with the rebuilt compiler.
The backend supports both the X (u64) and W (u32) register forms and the
correctness proofs are width-generic, but every test exercised u64 only.
Add a u32 test covering the lowered surface at the W width: immediate
materialization (MOVZ/MOVK sequences and bitmask immediates), additions
and subtractions with imm12 (optionally shifted) and shifted-register
operands, MUL/MADD/MSUB, UDIV/SDIV, the logical operations with 32-bit
bitmask immediates, constant and masked-variable shifts, rotations by a
constant and by a register, conditional selection for the four condition
kinds, and 32-bit loads and stores including the narrow extending forms
and stack slots.

Validated beyond compilation: the generated W-form assembly was executed
natively (arm64 macOS) against an independent C reference over multiple
input vectors, including shift amounts beyond the operand size going
through the mask, with all checks passing.
The ARMv8-A stub of the safety analyzer returned true, silently claiming
every program safe. Raise a proper compilation error instead.
The same code and comment appeared twice (subroutine and export blocks);
introduce enforce_sp_min_align.
Add -sp-min-align to override the per-architecture default
(Core_arch.sp_min_align: u128 on armv8a, u8 elsewhere).
AArch32 and AArch64 have the same flag register: move rflag (with its
instances and printer) and the flag-combination description out of
arm_decl/armv8a_decl into a new arm_common module that both re-export.
The A64 condition codes are those of AArch32 (minus the always-true
AL/NV, which are deliberately not modeled on either architecture: an
unconditional instruction simply has no condition operand). Move condt
and its instances/printer to arm_common.
arm_eval_cond and armv8a_eval_cond were identical; keep a single
definition next to the shared condt type.
The alias forms ASR/LSL/LSR/ROR already accept both the register and the
immediate shift amount (ak_rrr ++ ak_rr_imm_shift) with the same
semantics, so the V forms were dead duplicates: the lowering only ever
emits the alias mnemonics.
Pair each assembly argument with the width of the register form to
print (W registers for the sources of the extensions, long multiplies
and zero-extending narrow loads/stores), as x86 does, instead of
patching the printed form position-by-position in the OCaml printer.
Remove wform_positions: the printer now derives the W form purely from
the declared width.
A64 conditions are ordinary last operands (CSEL, CSET, ...), so print
them like any other argument instead of returning None and re-appending
the condition after a filter_map.
Pure reorganization of armv8a_instr_decl.v, mirroring arm_instr_decl.v:
every *_semi (with its ISA doc block) now sits right before the
instruction description (or family helper) that uses it, so both can be
reviewed together.
armv8a_params_core, armv8a_lowering and armv8a_stack_zeroization are
dependencies of armv8a_params and are extracted transitively, exactly as
for x86/arm/riscv; drop the redundant entries.
Same idiom as JModel_m4; CMP/CMN/TST become the flag-only aliases of
SUBS/ADDS/ANDS instead of re-deriving the formulas.
clebreto and others added 21 commits July 11, 2026 09:46
AAPCS64 is the same on Linux, macOS and Windows for everything Jasmin
uses, and the only platform-dependent register (x18) is excluded from
the register file, so the convention is platform-independent; document
this where it is declared.
Proof-using annotations required by 'Default Proof Using=' and the
removal of lsem1, missed by the merge commit (the merge-time build
reported success spuriously).
armv8a-stack-zero-loop and armv8a-stack-zero-unrolled, mirroring the
other architectures. The new tests exposed two gaps, fixed here:
- store_zero emitted the X form of STR for a 32-bit clear step, which
  assembly generation rejects; use the W form;
- the default clear step is the frame alignment, which sp_min_align
  raises to u128 on armv8a, beyond what one store can clear; cap the
  default at the register size.
Add a differential test that checks, for every instruction form modeled
in armv8a_instr_decl.v, that the Coq semantics matches native execution.

An OCaml generator (compiler/tests/armv8a-hw-semantics/, linked against
the extracted model) enumerates every mnemonic at both operand sizes
(and the encodable shifted-register variants), evaluates id_semi on
edge-case and fixed-seed pseudo-random operand vectors via app_sopn, and
emits a C program that runs the same instructions with inline assembly
and compares result words and NZCV flags (mrs/msr nzcv); loads and
stores run against a scratch buffer. Every mismatch is reported and the
program exits nonzero.

Run with 'make -C compiler check-armv8a-semantics' on an AArch64 host:
748 instruction forms, 33544 checks, 0 failures. The only untestable
mnemonic is ADR (PC-relative address formation, whose id_semi is the
identity on an address computed by the framework); all other skips are
non-encodable operand combinations, documented in the generator and in
the header of the generated C file.
New JModel_arm holds the 32-bit operations whose semantics coincide on
AArch32 and AArch64 (arithmetic and its flags, comparisons, result-only
logical, multiplication, division, CLZ, REV/REV16, bit fields, loads and
stores), using JModel_m4's exact formulations. JModel_m4 re-exports it
and abbreviates the bare AArch32 names (abbrev [-printing] ADD = ADD_32,
...); JModel_armv8a re-exports it and keeps only the 64-bit forms and
the AArch64-specific operations (flag-setting logical instructions,
shifts, extensions, conditional selection, ...).
The DOIT/DIT classification used by jasmin-ct --doit was kept in
hand-written OCaml tables (is_doit_asm_op / is_doit_asm_extra in
{x86,arm,riscv}_arch_full.ml), disconnected from the instruction
descriptions.

Move this information into the descriptors:
- add a boolean field id_doit to arch_decl.instr_desc_t and a
  corresponding field i_doit to sopn.instruction_desc; base ops forward
  id_doit to i_doit (arch_extra.get_instr_desc);
- x86: the per-operator classification is transcribed from the OCaml
  table into x86_op_is_doit and stamped into the descriptor by
  x86_instr_desc;
- arm: every instruction description sets id_doit, transcribing the
  per-mnemonic OCaml table; mk_cond, mk_shifted and idt_dropn preserve
  the field;
- riscv: all instructions are classified as DOIT, as before;
- extra operators (x86_extra, arm_extra, riscv_extra) are all
  classified as DOIT, as in the OCaml tables; pseudo and SLH operators
  are classified as DOIT, which matches the CT checkers that treat
  them as constant-time;
- delete the OCaml tables; Arch.is_ct_sopn ~doit:true now reads i_doit
  from the instruction descriptor.

The classification is unchanged.
The width-generic MADD/MSUB proof was missing wopp_zero_extend (the
subtraction unfolds to an addition of the opposite), and the failure was
masked by reading a piped build status; also complete the Proof using
annotations of the file.
…ands

Address review feedback on the argument kinds of shifted instructions.
On A64 a shifted operand exists only in the register form of an
instruction: the immediate forms admit either no shift at all (logical
instructions, whose bitmask immediate is checked by
CAimmC_armv8a_bitmask_imm) or only LSL #0/LSL jasmin-lang#12, which is part of the
immediate encoding itself and folded into CAimmC_armv8a_arith_imm.

mk_shifted used to append the shift-amount operand to every alternative
of the base instruction, relying on the argument-kind builders to have
dropped the immediate alternatives beforehand. Make the distinction
where it belongs: mk_shifted now keeps only the register alternatives of
the base instruction and appends the shift amount to those, and the
ak_rr_or_imm/ak_rrr_or_imm builders always list the immediate
alternative unconditionally.

Validated: full proofs build, extraction, compiler build, the armv8a
test categories, and the ML-DSA KATs for the three parameter sets.
The arithmetic instructions (ADD/ADDS/SUB/SUBS/NEG/CMP/CMN) only admit
LSL, LSR and ASR on their shifted-register operand; ROR is reserved
(C6.2.5 "ADD (shifted register)"). Only the logical instructions admit
all four shifts (C6.2.14 "AND (shifted register)"). The lowering used to
fuse a rotation into any shift-taking instruction, so x + (y >>r 3)
compiled to "add x0, x0, x1, ror #3", which assemblers reject.

Classify the shift kinds per mnemonic (shift_allowed) and enforce the
restriction at both levels:
- the instruction descriptor rejects the combination: mk_shifted takes
  the mnemonic and conjoins shift_allowed to id_valid, so the semantics
  of a rotated arithmetic instruction is a type error rather than a
  claim the hardware does not honour;
- the lowering only fuses an allowed shift (arg_shift and
  lower_base_op), so a rotated operand of an arithmetic instruction now
  falls back to the unfused form and surfaces as a compile-time error
  instead of invalid assembly, while rotations keep fusing into the
  logical instructions.

The with_shift_unop/with_shift_binop lemmas take the corresponding
shift_allowed premise, discharged at their call sites by the new
lowering guard.

Validated: full proofs build, extraction, compiler build, the armv8a
test categories, the ML-DSA KATs for the three parameter sets, and
native assembly of both directions of the gate (eor/and with ror still
fuse and assemble; add with ror is now rejected at compile time).
Address review feedback on the stack-zeroization default clear step:
capping it at Arch.reg_size bakes in the assumption that a register
store is the widest store the architecture can perform, which stops
holding once SIMD stores exist (a 128-bit NEON store on AArch64, a
256-bit YMM store on x86-64). Add a max_store_size field to the Arch
module giving the widest single-instruction store and use it for the
cap instead, so the default step follows the architecture's actual
store capability.
Address review feedback: MVN has no immediate form (it is an alias of
ORN (shifted register); writing a bitmask immediate goes through the
MOV alias of ORR (immediate) instead), so say ak_rr rather than going
through ak_rr_or_imm with the no-immediate parameter.
The variable forms (ASRV/LSLV/LSRV/RORV) are not modeled, so their
alias plumbing in the ASR/LSL/LSR/ROR documentation (encoding-naming
notes, double syntax, pseudocode attribution) was noise; keep the
reference, the description and the operation pseudocode.
Reduce the modeled mnemonics from 72 to 43, keeping exactly the
instructions that the compiler pipeline can emit: the lowering pass,
the load-immediate and smart-address helpers, mov_ofs (ADR), stack
zeroization, and pseudo-operation expansion.

Dropped: SBC, SBCS, the widening/high multiplies (UMULL, SMULL,
UMADDL, SMADDL, UMULH, SMULH), ANDS, BIC, BICS, the bit-field family
(BFC, BFI, BFXIL, SBFX, UBFX, EXTR), the bit-manipulation family
(RBIT, REV, REV16, REV32, CLZ, CLS), CMN, and the CSEL aliases
(CSINC, CSINV, CSNEG, CSET, CSETM).

The mnemonic tables (has_shift_mnemonics, ror_shift_mnemonics,
sized_mnemonics), the lowering binop list and its proof, the OCaml
DIT table, the hardware-semantics generator, and the intrinsics tests
are pruned to match. Proofs, extraction, the armv8a test categories,
the hardware differential test (21442 checks) and the ML-DSA known
answer tests for all three parameter sets remain green.
The CAimmC_armv8a_0_16_32_48 checker accepted shifts 32 and 48
regardless of the operand width, while the W forms of MOVZ/MOVN/MOVK
cannot encode them (the hw field has one bit in the W form); the
mismatch was only documented as a skip in the hardware-semantics test.

Make the checker carry the operand width, like
CAimmC_armv8a_shift_amount, and rename it
CAimmC_armv8a_halfword_shift: the legal shifts are the multiples of 16
below the operand width. The corresponding skip note disappears from
the hardware-semantics generator.
Since the descriptors forbid ROR on the shifted arithmetic
instructions (shift_allowed in id_valid), the shift kinds A64 cannot
encode are invalid in the model rather than accepted-but-unassemblable,
so the documented skip no longer describes a model/hardware gap. Also
tighten a CSEL wording left over from the distillation.
…rface

Move the five armv8a immediate conditions from the removed shared
inductive to an architecture-local armv8a_caimm_cond type, following
the caimm_cond interface of arch_decl: eqTypeC instance, checker and
error-message printer live in armv8a_decl.v, and the argument kinds
wrap the conditions in Some (None being an unconditioned immediate).
The other architectures no longer see the armv8a conditions at all.
Set id_doit in every instruction description and i_doit on the extra
operators, transcribing the per-mnemonic OCaml table: everything in
the modeled set belongs to the A64 DIT instruction list (Arm ARM
DDI0487M.a, section C3.2.13) except SDIV, UDIV and ADR. mk_rrr_instr
takes the classification as an argument since its users differ (MUL is
DIT, the divisions are not); mk_shifted preserves the field. The
hand-written is_doit_asm_op / is_doit_asm_extra tables disappear from
armv8a_arch_full.ml, as on the other architectures.
lip_lstore and lip_lload now take the width of the transferred value
(main commit "Linearization params: make lload and llstore more
general"): thread it through armv8a_lstore/armv8a_lload and the custom
armv8a_lloads (per-variable width for the restored registers, Uptr for
the saved stack pointer), and drop the vtype hypotheses that
disappeared from the correctness specifications.
Comment thread tests/success/armv8a/add.jazz Outdated
tests/success/armv8a/add.jazz at the repository root is a byte-identical
duplicate of compiler/tests/success/armv8a/add.jazz, staged from the
wrong directory in the initial backend commit; nothing references it
(the test runner resolves its paths relative to compiler/).
Comment on lines +257 to +262
"-sp-min-align",
Arg.Symbol (List.map fst Annot.ws_strings, set_sp_min_align),
" Minimal alignment kept by the stack pointer across calls (default \
depends on the target architecture: u128, i.e. 16 bytes, on armv8a; \
no constraint otherwise). Lowering it below the architectural \
requirement may produce faulting code";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this option?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I understand, it's only useful in marginal cases: lowering the alignment on a platform where the OS has disabled SP alignment checking (SCTLR_ELx.SA0) in order to reclaim the stack padding, or raising it for experimentation purposes.

Since it is really hypothetical for me, this can be removed for now, as long as we keep the enforce_sp_min_align which is compulsory for armv8a.

Comment thread compiler/src/arm_arch_full.ml Outdated
Comment thread proofs/arch/arch_decl.v Outdated
Armv7-M guarantees that stack pointer values are at least 4-byte
aligned: writes to SP force bits [1:0] to zero (Arm v7-M Architecture
Reference Manual, B1.5.7). With no minimal alignment, a function whose
frame is only 1- or 2-byte aligned lets the export prologue write a
non-4-byte-aligned SP (BIC #1 on the aligned copy), which the hardware
silently rounds, shifting every subsequent SP-relative access. Set
sp_min_align to U32 for arm-m4 so such frames are padded to 4 bytes.

The instack function of tests/success/common/unaligned.jazz asserted
the computed alignment with #[stackalign=u16], which is now
architecture-dependent; move it to per-architecture copies
(unaligned_stackalign.jazz): u16 on x86-64 and risc-v, u32 on arm-m4
(failing before this change), and u128 on armv8a.
The doit/not_doit aliases in arch_decl.v were a leftover of the
pre-squash version of the DOIT-in-descriptors branch this work was
based on; the version merged on main writes the constructors directly.
Drop the aliases and follow suit, leaving arch_decl.v untouched by this
branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants