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 noallocand nostdin the default build. No-CRT / bare-metal / wasm friendly; builds forwasm32-unknown-unknownandaarch64-unknown-none. - Zero heap on the core path.
Decoder::decode_intowrites into a caller-ownedCopyInstruction(noVec, noBox, no internal pointers); the default formatter writes into a fixed&mut [u8](BufSink) or anycore::fmt::Write. Copyvalue-typeInstruction. Pass it by value; inline[Operand; MAX_OPERANDS]storage,<= 112bytes, asserted at compile time. Never panics on malformed input — bad words decode toCode::Invalidwith a recordedlast_error.- Ergonomic iteration.
Decoderis anIterator(bothfor insn in &mut decand consumingfor insn in dec), plus adecode_intofast 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 optionalGnuFormattercompatibility adapter behindfmt-gnu, a token-classifyingFormatterOutputsink, and aSymbolResolverhook.GnuFormattercurrently emits the same UAL text asFmtFormatter. - Two feature layers. Cargo features compile optional implementation modules; a runtime
FeatureSetcontrols which architectural and implementation-defined encodings the decoder accepts.
Supported targets
| Target | Notes |
|---|---|
x86_64-*, aarch64-* (hosted) | development and std testing |
wasm32-unknown-unknown | default features (no_std, no alloc) |
aarch64-unknown-none | bare-metal, no-CRT; checked with --no-default-features |
any target providing core | the 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 feature | Tier | Effect |
|---|---|---|
| (none / default) | A | no_std, no alloc, freestanding. Decoder + FmtFormatter + all enums + encoder. Always builds. |
alloc | B | Adds String/Vec conveniences (format_to_string, a reusable cached InstructionInfoFactory, and a token-collecting String sink). |
std | C | Implies alloc; adds std::error::Error for DecodeError, EncodeError, and EnumValueError, plus std-only test helpers. |
fmt-gnu | A | Adds GnuFormatter, currently a UAL-equivalent compatibility adapter. Pure no_std. |
sve | A | Compiles the SVE/SVE2 decoder and encoder modules. |
sme | A | Compiles the SME/SME2 decoder and encoder modules. |
crypto | A | Compiles the Advanced SIMD crypto decoder. Its public enum variants and encoder support remain present without this feature. |
full | A | Enables sve, sme, and crypto. |
no-alloc-audit | test | Enables 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 atry_newreturningResult<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 decborrows the decoder (you can inspectdec.last_error()afterward);for insn in decconsumes it. Both yield until fewer than 4 bytes remain. decode()vsdecode_into().decode()returns a freshInstruction.decode_into(&mut out)writes into a caller-ownedInstruction, 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 keepipconsistent 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
InstructionwithCode::Invalid(checkinsn.is_invalid()), anddec.last_error()returns the reason (DecodeError::Unmatched,DecodeError::EndOfInstructionon a short tail, orDecodeError::Noneon 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 distinguishB.condfromB).mnemonic()/mnemonic().name()— the width/encoding-independentMnemonic(alias-resolved for preferred disassembly such asMOV/CMP/LSL), and its&'static strspelling.op_count()/op_kind(n)/op(n)— operand count, theOpKinddiscriminant of slotn(out-of-range yieldsOpKind::None), and the full richOperand(out-of-range yieldsOperand::None).op_register(n)/op_immediate(n)— fast indexed accessors.op_registerreturnsRegister::Noneif slotnis not a plain register;op_immediatereturns the unsigned/logical/signed-as-u64/label value, or0otherwise.len()/ip()/next_ip()/word()— fixed length (always 4), decode address, following address (ip + 4), and the raw little-endian word.flow_control()—FlowControlclassification (branch / call / return / exception / next).set_flags()—FlagEffectNZCV behaviour (SetsNormal,SetsFloat, orNone).
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:
| Field | Default | Meaning |
|---|---|---|
aliases | true | Emit preferred aliases (MOV/CMP/MUL/LSL/NOP/...) instead of canonical forms. |
uppercase_mnemonics | false | Upper-case mnemonics. |
uppercase_registers | false | Upper-case register names. |
use_sp_not_xzr | true | Render reg-31 as sp/wsp rather than xzr/wzr where the role is ambiguous. |
hex_prefix | "0x" | Prefix for hex literals. |
signed_immediates | true | Render signed immediates with an explicit - and hex magnitude. |
show_lsl_zero | false | Show LSL #0 explicitly instead of eliding it. |
space_after_operand_separator | true | ", " vs "," between operands. |
first_operand_char_index | 8 | Column 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:
- Cargo features decide which optional implementation modules are compiled into the binary. Omitting
sveorsmeleaves those large decoder/encoder modules out;cryptogates 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. - The runtime
FeatureSetdecides 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 comparison —
tests/golden.rs. Decodes a locally supplied corpus and compares rendered text.cargo test --features "std full" --test golden -- --ignored --nocapture - LLVM differential —
tests/llvm_diff.rs. A discovery sweep that compares fARM64 with an installedllvm-mc.cargo test --features "std full" --test llvm_diff -- --ignored --nocapture - Encoder round-trip —
tests/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 CLI —
examples/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.