fARM64

July 14, 2026 · View on GitHub

fARM64 is a pure-Rust, #![no_std], zero-heap AArch64 (A64) disassembler and semantic encoder. It decodes 64-bit Arm machine code into a rich, Copy value-type Instruction, renders it through a pluggable Formatter, and can re-encode an Instruction to a 32-bit word. The public API is deliberately iced-x86-shaped (a borrowing Decoder, a value-type Instruction, typed OpKind/Operand accessors, and a token-emitting Formatter). The Arm architectural decode tree is hand-written from the Arm Architecture Reference Manual (the "Arm ARM"); tests also cross-check independent toolchains and corpora. Apple AMX and GXF are implementation-defined exceptions whose encodings come from public reverse-engineering references and are explicitly runtime-gated.


Highlights

  • Freestanding by default. #![no_std] unconditionally, with no alloc and no std in the default build. No-CRT / bare-metal / wasm friendly; builds for wasm32-unknown-unknown and aarch64-unknown-none.
  • Zero heap on the core path. Decoder::decode_into writes into a caller-owned Copy Instruction (no Vec, no Box, no internal pointers); the default formatter writes into a fixed &mut [u8] (BufSink) or any core::fmt::Write.
  • Copy value-type Instruction. Pass it by value; inline [Operand; MAX_OPERANDS] storage, <= 112 bytes, asserted at compile time. Never panics on malformed input — bad words decode to Code::Invalid with a recorded last_error.
  • Ergonomic iteration. Decoder is an Iterator (both for insn in &mut dec and consuming for insn in dec), plus a decode_into fast path for tight loops.
  • Broad ISA coverage. Full base A64 plus Advanced SIMD / FP, SVE / SVE2, SME / SME2, the crypto extensions, and a long tail of recent additions: MOPS, CSSC, RCPC3, D128, THE, LSE128, SVE2p1, CMPBR, CPA, and more.
  • Encoder included. Instruction::encode() reconstructs the 32-bit word from instruction semantics (never from the stored raw word), proving the decode is invertible.
  • Pluggable formatting. Default Arm UAL FmtFormatter, an optional GnuFormatter compatibility adapter behind fmt-gnu, a token-classifying FormatterOutput sink, and a SymbolResolver hook. GnuFormatter currently emits the same UAL text as FmtFormatter.
  • Two feature layers. Cargo features compile optional implementation modules; a runtime FeatureSet controls which architectural and implementation-defined encodings the decoder accepts.

Supported targets

TargetNotes
x86_64-*, aarch64-* (hosted)development and std testing
wasm32-unknown-unknowndefault features (no_std, no alloc)
aarch64-unknown-nonebare-metal, no-CRT; checked with --no-default-features
any target providing corethe default tier is core-only

Feature matrix

Cargo features decide which optional implementation modules are compiled; the runtime FeatureSet decides which encodings are accepted at decode time. They are independent layers, but not every runtime extension has a matching Cargo feature.

Cargo featureTierEffect
(none / default)Ano_std, no alloc, freestanding. Decoder + FmtFormatter + all enums + encoder. Always builds.
allocBAdds String/Vec conveniences (format_to_string, a reusable cached InstructionInfoFactory, and a token-collecting String sink).
stdCImplies alloc; adds std::error::Error for DecodeError, EncodeError, and EnumValueError, plus std-only test helpers.
fmt-gnuAAdds GnuFormatter, currently a UAL-equivalent compatibility adapter. Pure no_std.
sveACompiles the SVE/SVE2 decoder and encoder modules.
smeACompiles the SME/SME2 decoder and encoder modules.
cryptoACompiles the Advanced SIMD crypto decoder. Its public enum variants and encoder support remain present without this feature.
fullAEnables sve, sme, and crypto.
no-alloc-audittestEnables allocation-counting tests for the zero-heap core path; not intended as a downstream capability.

The default build links neither alloc nor std. std implies alloc. The runtime FeatureSet (FeatureSet::ALL, FeatureSet::BASE, .with(Feature::Sve), .has(..)) is orthogonal to all of the above.


Install

[dependencies]
# Default: no_std, no alloc, zero-heap decoder + formatter + encoder.
fARM64 = "0.0.2"

Opt into more as needed:

# Owned-string conveniences and the cached info factory.
fARM64 = { version = "0.0.2", features = ["alloc"] }

# All optional implementation modules plus std and the GNU adapter.
fARM64 = { version = "0.0.2", features = ["std", "full", "fmt-gnu"] }

The import path uses the stylized crate name: use fARM64::....


Quick start

Zero-allocation decode-and-print into a fixed stack buffer — no heap, no std:

use fARM64::{Decoder, DecoderOptions};
use fARM64::format::{Formatter, FmtFormatter, BufSink};

fn main() {
    // `ADD W0, W1, #1`, little-endian; decode at address 0x1000.
    let code = [0x20, 0x04, 0x00, 0x11];
    let mut dec = Decoder::new(&code, 0x1000, DecoderOptions::default());
    let insn = dec.decode();

    // Format into a fixed [u8; N] — the whole path touches no heap.
    let mut buf = [0u8; 64];
    let mut sink = BufSink::new(&mut buf);
    FmtFormatter::new().format(&insn, &mut sink);

    let text: &str = sink.as_str();
    let _ = text; // e.g. "add     w0, w1, #0x1"
}

Decoder::new(data, ip, options) borrows the byte slice; ip is the address of data[0] and PC-relative operands resolve against it. decode() returns a Copy Instruction and advances the cursor by 4. The FmtFormatter::format call goes through the Formatter trait, which is why that trait is imported.


Decoding

use fARM64::{Decoder, DecoderOptions, Code};

fn main() {
    let code: &[u8] = &[
        0x20, 0x04, 0x00, 0x11, // add w0, w1, #1
        0x1f, 0x20, 0x03, 0xd5, // nop
    ];

    // Construct over the slice; `ip` is the address of code[0].
    let mut dec = Decoder::new(code, 0x1000, DecoderOptions::default());

    // Iterate by &mut: yields instructions until fewer than 4 bytes remain.
    for insn in &mut dec {
        if insn.is_invalid() {
            // Bad/unallocated word: inspect dec.last_error() for why.
            continue;
        }
        let _ = (insn.code(), insn.ip());
    }

    // After the loop, the most recent decode status is available:
    let _ = dec.last_error();
    let _ = Code::Invalid; // the sentinel a failed decode carries
}

Key points:

  • Decoder::new(data, ip, options) never panics. There is also a try_new returning Result<Decoder, DecodeError> for API symmetry. A64 has no bitness parameter — it is always 64-bit, always 4-byte fixed-width.
  • Iteration forms. for insn in &mut dec borrows the decoder (you can inspect dec.last_error() afterward); for insn in dec consumes it. Both yield until fewer than 4 bytes remain.
  • decode() vs decode_into(). decode() returns a fresh Instruction. decode_into(&mut out) writes into a caller-owned Instruction, which is the preferred zero-allocation form in a tight loop because nothing is constructed or moved per iteration:
use fARM64::{Decoder, DecoderOptions, Instruction};

fn main() {
    let code: &[u8] = &[0x20, 0x04, 0x00, 0x11, 0x1f, 0x20, 0x03, 0xd5];
    let mut dec = Decoder::new(code, 0x1000, DecoderOptions::default());

    // Reuse one Instruction across the whole loop — no per-iteration alloc/move.
    let mut insn = Instruction::default();
    while dec.can_decode() {
        dec.decode_into(&mut insn);
        let _ = insn.code();
    }
}
  • Position and address. position()/set_position(pos) move the byte cursor (and keep ip consistent relative to the original base); ip()/set_ip(ip) read/set the current decode address directly.
  • Invalid handling. A malformed or unallocated word never panics — it decodes to an Instruction with Code::Invalid (check insn.is_invalid()), and dec.last_error() returns the reason (DecodeError::Unmatched, DecodeError::EndOfInstruction on a short tail, or DecodeError::None on success).

Restricting accepted extensions

DecoderOptions carries a FeatureSet. The default accepts everything (FeatureSet::ALL); narrow it to reject encodings outside the extensions you target:

use fARM64::{Decoder, DecoderOptions, FeatureSet, Feature};

fn main() {
    // Accept only the base ISA plus FEAT_LSE atomics; reject everything else.
    let features = FeatureSet::BASE.with(Feature::Lse);
    let options = DecoderOptions { features };

    let code = [0x20, 0x04, 0x00, 0x11]; // add w0, w1, #1 (base ISA)
    let mut dec = Decoder::new(&code, 0x1000, options);
    let insn = dec.decode();
    let _ = insn.code();
}

Inspecting an Instruction

use fARM64::{Decoder, DecoderOptions};
use fARM64::{OpKind, Operand, Register};

fn main() {
    let code = [0x20, 0x04, 0x00, 0x11]; // add w0, w1, #1
    let mut dec = Decoder::new(&code, 0x1000, DecoderOptions::default());
    let insn = dec.decode();

    // `code()` is the precise encoding identity (e.g. Code::AddImm32);
    // `mnemonic()` is the preferred/alias display spelling; `.name()` is the text.
    let _enc = insn.code();
    let _mnem = insn.mnemonic();
    let _name: &str = insn.mnemonic().name();

    // Address helpers (A64 is fixed 4-byte wide).
    let _ = (insn.ip(), insn.next_ip(), insn.len(), insn.word());

    // Control-flow class and NZCV write behaviour.
    let _ = (insn.flow_control(), insn.set_flags());

    // Walk operands. `op_kind(n)` is the cheap discriminant; `op(n)` is the rich value.
    for i in 0..insn.op_count() {
        match insn.op_kind(i) {
            // Fast typed accessors that skip the match for the common cases:
            OpKind::Register => {
                let r: Register = insn.op_register(i);
                let _ = (r.name(), r.class(), r.number());
            }
            OpKind::ImmUnsigned | OpKind::ImmSigned | OpKind::ImmLogical => {
                let _v: u64 = insn.op_immediate(i);
            }
            _ => {}
        }

        // Or match the full rich Operand for everything:
        match insn.op(i) {
            Operand::Reg { reg, arr, shift, extend, .. } => {
                let _ = (reg, arr, shift, extend);
            }
            Operand::ImmUnsigned(v) | Operand::ImmLogical(v) => { let _ = v; }
            Operand::ImmSigned(v) => { let _ = v; }
            Operand::MemImm { base, imm, mode } => { let _ = (base, imm, mode); }
            Operand::MemExt { base, index, extend, shift } => { let _ = (base, index, extend, shift); }
            Operand::Label(target) => { let _ = target; }
            Operand::Cond(c) => { let _ = c; }
            _ => {}
        }
    }
}

What each accessor means:

  • code() — the encoding-level identity (Code), one variant per distinct ARM ARM encoding row. Use this when you need the exact encoding (e.g. for re-encoding, or to distinguish B.cond from B).
  • mnemonic() / mnemonic().name() — the width/encoding-independent Mnemonic (alias-resolved for preferred disassembly such as MOV/CMP/LSL), and its &'static str spelling.
  • op_count() / op_kind(n) / op(n) — operand count, the OpKind discriminant of slot n (out-of-range yields OpKind::None), and the full rich Operand (out-of-range yields Operand::None).
  • op_register(n) / op_immediate(n) — fast indexed accessors. op_register returns Register::None if slot n is not a plain register; op_immediate returns the unsigned/logical/signed-as-u64/label value, or 0 otherwise.
  • len() / ip() / next_ip() / word() — fixed length (always 4), decode address, following address (ip + 4), and the raw little-endian word.
  • flow_control()FlowControl classification (branch / call / return / exception / next). set_flags()FlagEffect NZCV behaviour (SetsNormal, SetsFloat, or None).

Formatting

The default FmtFormatter renders ARM UAL syntax. It writes through the Formatter trait into any FormatterOutput sink. There is a single operand-dispatch path; nothing allocates inside the formatter itself.

Zero-alloc into a fixed buffer or any core::fmt::Write

use core::fmt::Write;
use fARM64::{Decoder, DecoderOptions};
use fARM64::format::{Formatter, FmtFormatter, BufSink};

fn main() {
    let code = [0x20, 0x04, 0x00, 0x11];
    let mut dec = Decoder::new(&code, 0x1000, DecoderOptions::default());
    let insn = dec.decode();
    let fmt = FmtFormatter::new();

    // (a) Into a fixed [u8; N] via BufSink (no heap). Overflow is observable.
    let mut buf = [0u8; 64];
    let mut sink = BufSink::new(&mut buf);
    fmt.format(&insn, &mut sink);
    assert!(!sink.overflowed());
    let _text: &str = sink.as_str();

    // (b) Into any core::fmt::Write — the blanket impl makes it a sink.
    struct Counter(usize);
    impl Write for Counter {
        fn write_str(&mut self, s: &str) -> core::fmt::Result { self.0 += s.len(); Ok(()) }
    }
    let mut c = Counter(0);
    fmt.format(&insn, &mut c);
    let _ = c.0;
}

Owned String (requires alloc)

use fARM64::{Decoder, DecoderOptions};
use fARM64::format::{FmtFormatter, format_to_string};

fn main() {
    let code = [0x20, 0x04, 0x00, 0x11];
    let mut dec = Decoder::new(&code, 0x1000, DecoderOptions::default());
    let insn = dec.decode();

    let fmt = FmtFormatter::new();
    let s: String = format_to_string(&fmt, &insn);
    let _ = s;
}

FormatterOptions

FmtFormatter::with_options(opts) overrides the defaults. Fields and their defaults:

FieldDefaultMeaning
aliasestrueEmit preferred aliases (MOV/CMP/MUL/LSL/NOP/...) instead of canonical forms.
uppercase_mnemonicsfalseUpper-case mnemonics.
uppercase_registersfalseUpper-case register names.
use_sp_not_xzrtrueRender reg-31 as sp/wsp rather than xzr/wzr where the role is ambiguous.
hex_prefix"0x"Prefix for hex literals.
signed_immediatestrueRender signed immediates with an explicit - and hex magnitude.
show_lsl_zerofalseShow LSL #0 explicitly instead of eliding it.
space_after_operand_separatortrue", " vs "," between operands.
first_operand_char_index8Column at which the first operand starts (mnemonic field width).
use fARM64::format::{FmtFormatter, FormatterOptions};

fn main() {
    let opts = FormatterOptions { uppercase_mnemonics: true, ..FormatterOptions::default() };
    let _fmt = FmtFormatter::with_options(opts);
}

GnuFormatter is available behind feature = "fmt-gnu". It is a compatibility adapter that currently delegates to the UAL renderer, so its output is identical to FmtFormatter; the separate type leaves room for GNU-specific policy later without changing call sites.

A token sink (FormatterOutput + TokenKind)

For syntax coloring or post-processing, implement FormatterOutput and receive every chunk together with its TokenKind:

use fARM64::{Decoder, DecoderOptions};
use fARM64::format::{Formatter, FmtFormatter, FormatterOutput, TokenKind};

struct TokenSink {
    mnemonics: usize,
    registers: usize,
}

impl FormatterOutput for TokenSink {
    fn write(&mut self, _text: &str, kind: TokenKind) {
        match kind {
            TokenKind::Mnemonic => self.mnemonics += 1,
            TokenKind::Register => self.registers += 1,
            _ => {}
        }
    }
}

fn main() {
    let code = [0x20, 0x04, 0x00, 0x11];
    let mut dec = Decoder::new(&code, 0x1000, DecoderOptions::default());
    let insn = dec.decode();

    let mut sink = TokenSink { mnemonics: 0, registers: 0 };
    FmtFormatter::new().format(&insn, &mut sink);
    let _ = (sink.mnemonics, sink.registers);
}

Resolving branch targets (SymbolResolver)

SymbolResolver maps an address to a borrowed name (no allocation required):

use fARM64::Instruction;
use fARM64::format::{SymbolResolver, SymbolResult};

struct MyResolver;

impl SymbolResolver for MyResolver {
    fn symbol(
        &mut self,
        _insn: &Instruction,
        _operand: usize,
        address: u64,
    ) -> Option<SymbolResult<'_>> {
        if address == 0x2000 {
            Some(SymbolResult { name: "my_func", offset: 0 })
        } else {
            None
        }
    }
}

fn main() {
    let mut r = MyResolver;
    // A formatter integration can call `r.symbol(insn, n, target)` for each
    // Label/Address operand to substitute a name for the bare 0x... target.
    let _ = &mut r;
}

Encoding

Instruction::encode() (or the free function fARM64::encode(&insn)) reconstructs the 32-bit little-endian word from the instruction's semantics — its Code, Mnemonic, operands, and ip. It deliberately never reads Instruction::word(), so a successful round-trip proves the decode is invertible. The encoder is no_std, zero-alloc, and total: it returns EncodeError rather than panicking.

use fARM64::{Decoder, DecoderOptions, EncodeError};

fn main() {
    let code = [0x20, 0x04, 0x00, 0x11]; // add w0, w1, #1
    let mut dec = Decoder::new(&code, 0x1000, DecoderOptions::default());
    let insn = dec.decode();

    // Decode -> (optionally inspect/modify) -> re-encode from semantics.
    let word: Result<u32, EncodeError> = insn.encode();
    match word {
        Ok(w) => {
            // Semantic round-trip: re-decoding `w` yields an equivalent instruction.
            let _ = w;
        }
        // Encodings the encoder does not yet cover return EncodeError::Unsupported.
        Err(e) => { let _ = e; }
    }
}

EncodeError variants: Unsupported (this Code/group is not implemented yet), InvalidOperand (operand missing or of the wrong kind), InvalidImmediate (an immediate, shift, or PC-relative target with no valid field encoding), and Invalid (the Code::Invalid sentinel has no encoding).

Because the encoder rebuilds from the canonical Code, the guarantee is a semantic round-trip (the re-encoded word decodes to an equivalent instruction), not necessarily a byte-identical one for encodings that have multiple equivalent spellings.


Feature gating explained

There are two independent layers:

  1. Cargo features decide which optional implementation modules are compiled into the binary. Omitting sve or sme leaves those large decoder/encoder modules out; crypto gates the Advanced SIMD crypto decoder. Public enums are not compiled out, and features such as FP16, BF16, LSE, PAuth, and MTE have no Cargo gate.
  2. The runtime FeatureSet decides what the decoder will accept at decode time. It is the fine-grained architectural gate and includes many extensions that are always compiled, as well as Apple AMX/GXF. An enabled runtime feature cannot restore an implementation module omitted by Cargo.
use fARM64::{Decoder, DecoderOptions, FeatureSet};

fn main() {
    // Restrict the decoder to the base ISA only: an SVE/SME/extension word will
    // be rejected (decoded as Code::Invalid) even when its implementation is compiled in.
    let options = DecoderOptions { features: FeatureSet::BASE };

    let code = [0x20, 0x04, 0x00, 0x11]; // a base-ISA ADD: still accepted
    let mut dec = Decoder::new(&code, 0x1000, options);
    let insn = dec.decode();
    let _ = insn.is_invalid();
}

FeatureSet::ALL (the default) accepts everything; FeatureSet::BASE/NONE accept only the base ISA; .with(Feature::X) enables one extension; .has(Feature::X) queries one. Feature::Base is always present.


no_std, embedded, and wasm

The default build is #![no_std] with no alloc: it links neither an allocator nor std. The core decode path (Decoder + Instruction) and the default formatter (FmtFormatter + BufSink) never allocate — all names are &'static str from const tables, there are no thread-locals, no I/O, no time, and no panics-as-control-flow. This makes fARM64 usable directly in kernels, bootloaders, hypervisors, and wasm32-unknown-unknown. The alloc and std features are strictly additive conveniences; enabling them never changes the zero-heap behaviour of the core path.


Validation and testing

fARM64 is validated with focused unit/integration tests plus optional differential sweeps. Large corpus-dependent sweeps are #[ignore]d and require a locally supplied corpus that is not included in the published crate:

  • Binary Ninja corpus comparisontests/golden.rs. Decodes a locally supplied corpus and compares rendered text.
    cargo test --features "std full" --test golden -- --ignored --nocapture
    
  • LLVM differentialtests/llvm_diff.rs. A discovery sweep that compares fARM64 with an installed llvm-mc.
    cargo test --features "std full" --test llvm_diff -- --ignored --nocapture
    
  • Encoder round-triptests/roundtrip.rs. Decodes the corpus, re-encodes from semantics, and checks the semantic round-trip.
    cargo test --features "std full" --test roundtrip -- --ignored --nocapture
    
  • Example CLIexamples/disasm.rs. Decodes 8-hex-digit words from args or stdin:
    cargo run --example disasm 11000420 d503201f
    

The fast (non-ignored) unit and integration tests run with cargo test --features "std full". See docs/VALIDATION.md for the reproducible validation procedure and the distinction between required tests and optional local-oracle sweeps.


Project layout and architecture

src/
  lib.rs          crate docs, public re-exports, MAX_OPERANDS / INSN_LEN, static asserts
  decoder.rs      Decoder, DecoderOptions, iterators, position/ip, last_error
  decode/         hand-written recursive A64 decode tree (+ shared ARM pseudocode)
  encode/         hand-written A64 encoder (the inverse of decode)
  instruction.rs  the Copy value-type Instruction and its accessors
  operand.rs      Operand enum + OpKind discriminant
  register.rs     Register, RegClass, RegWidth, gp_register
  enums.rs        Condition / ShiftType / ExtendType / VectorArrangement / FlowControl / FlagEffect
  mnemonic.rs     Code (encoding identity) + Mnemonic (display) enums
  features.rs     Feature + FeatureSet (runtime accept/reject)
  format/         Formatter trait, FmtFormatter, BufSink, options, token sink
  info/ sysop/ sysreg/ tables/   info factory, system ops/registers, name tables
tests/            golden.rs, llvm_diff.rs, roundtrip.rs, the_atomics.rs
examples/         disasm.rs
docs/             DESIGN.md, API.md, ENCODING.md, ROADMAP.md, VALIDATION.md

Design and reference docs: docs/DESIGN.md, docs/API.md, docs/ENCODING.md, docs/ROADMAP.md, docs/VALIDATION.md.


Status

Version 0.0.2 adds complete, allocation-free Code::values() and Mnemonic::values() catalogs plus checked integer conversions compatible with iced-style consumers. The checked-in test suite is the release gate; optional corpus and LLVM sweeps provide additional local cross-checking but are not packaged and no fixed coverage percentage is promised. The Code/Mnemonic/Register/Feature enums are #[non_exhaustive] with an append-only discriminant policy.

License

Licensed under the MIT License; see LICENSE. Arm architectural instruction handling is based on the publicly documented Arm ARM. Apple AMX naming and encodings reference the public corsix/amx reverse-engineering project; GXF encodings reference Asahi Linux's Apple Proprietary Instructions documentation and are isolated behind Feature::Gxf. See NOTICE for provenance details.