Skip to content

Repository files navigation

armlint

armlint examines AArch64 machine code to find suboptimal instruction sequences. For example, building the constant 0x66666666 as

movz w0, #0x6666
movk w0, #0x6666, lsl #16

is two instructions where one would do, because 0x66666666 is encodable as an AArch64 logical (bitmask) immediate:

mov w0, #0x66666666     ; orr w0, wzr, #0x66666666

armlint helps compiler writers and assembly authors generate tighter code, and documents corners of the A64 instruction set.

Design and limitations

armlint is a peephole analyzer. It decodes each 32-bit A64 instruction directly from the binary and matches it by mask and value, resolving aliases (for example MUL is MADD with a zero accumulator) so that both spellings of a pattern are caught. It then looks for a short window of adjacent instructions that a shorter or cheaper encoding can replace.

The overriding rule is soundness: armlint emits a finding only when the rewrite provably preserves the architectural result. For a tool that suggests code changes, a false positive is the worst failure, so it errs toward false negatives -- a missed opportunity is cheaper than a wrong one. Each check documents the exact conditions under which its rewrite is equivalent; the constraints below are the ones they share, and analyses.md's appendix collects the near-miss folds that are deliberately never matched (FP contraction, fcsel -> fmax, the SDIV remainder, and friends) with the argument against each.

  • Strict adjacency for matching; bounded lookahead for proof. The instructions of a matched pattern must be consecutive -- an unrelated instruction between a producer and its consumer suppresses the finding -- and armlint does not reorder code or match through intervening instructions. Emission, though, is not confined to the pattern window: as the following bullets describe, many findings are held back while a bounded forward scan (16 instructions) walks the fall-through path past the consumer to prove a register or the flags dead, so an instruction well after the pair can also suppress a finding. The scan only gates emission -- it never widens a match, so every reported rewrite still replaces only the adjacent instructions shown.
  • Liveness is proved structurally, or by a bounded forward scan. A producer-into-consumer fold fires when the consumer overwrites the producer's destination register, proving the intermediate value is dead. Folds whose saving is a deleted write with no such overwrite defer instead: a bounded forward scan of the fall-through path must see the register overwritten before any read or control transfer. This is how the address folds admit stores and loads into a fresh register -- add x8, sp, #32 ; str x0, [x8] folds to str x0, [sp, #0x20] only once x8 provably dies -- and how the producer folds (shift, funnel, extend, MUL/SMULL, NEG, MVN) admit consumers that write a register other than the producer's: lsl w8, w1, #3 ; add w9, w2, w8 folds to add w9, w2, w1, lsl #3 under the same proof. The single-bit and CSET branch folds additionally require the folded branch's taken edge to land inside that proven-clean span -- a general-purpose register, unlike NZCV, is routinely live into a branch target, so no block-locality assumption is made for it.
  • MOV-chain folds verify the constant dies. Folds that absorb a materialized constant -- MUL/MNEG/UDIV by a constant, MOV + ADD/AND/ORR/EOR/CCMP/FMOV, MOV #0, the register-offset and MOVI-zeroing folds -- report only once the consumer's own overwrite or the forward scan proves the constant register dead. The consumer rewrite itself stays valid regardless.
  • Flag liveness uses a bounded forward scan. The branch- and flag-folding checks drop a CMP/TST only after a bounded scan of the fall-through path confirms that no later instruction reads N/C/V before they are overwritten. Every NZCV reader is recognized -- the integer conditionals (B.cond, CSEL/CSINC/..., CCMP/CCMN, ADC/SBC) and the floating-point ones (FCSEL, FCCMP/FCCMPE) -- and any branch off the path whose destination the scan cannot see -- an unconditional B, or a conditional CBZ/CBNZ/TBZ/TBNZ (which do not themselves touch NZCV but whose taken target may still observe it) -- ends it conservatively. The scan does not follow the folded branch's own taken edge, so these folds assume N/C/V is dead at every branch target. That holds for compiled code, where the flags are defined within a basic block, but not for hand-written assembly that deliberately keeps a flag live into a branch target.

Findings are opportunities, not guaranteed speedups: some -- the pre- and post-indexed addressing folds -- are code-size and front-end wins that are backend-neutral. Each check's notes say what its rewrite actually saves.

Implemented analyses

Each row links to its full description -- mechanics, soundness, and what the rewrite saves -- in analyses.md. Candidate checks not yet implemented live in TODO.md.

Pattern Rewrite
movz/movn + movk (over-long constant) bitmask-immediate mov, or minimal movz/movn + movk chain
lsl/lsr/asr/ror + add/sub/and/orr/eor add Rd, Rn, Rm, <shift> #n
lsl/lsr + shifted orr/eor/add (funnel/rotate) extr Rd, Rhi, Rlo, #lsb (or ror when both halves match)
sxtw/uxtb/sxtb + add/sub add Rd, Rn, Wm, sxtw
cmp/cmn/tst zero-test + b.eq/b.ne (b.hi/b.ls after cmp) cbz/cbnz
cmp/cmn/tst zero-test + b.lt/b.ge/b.mi/b.pl tbnz/tbz Rn, #(msb)
tst #(1<<k) + b.eq/b.ne tbz/tbnz Rn, #k
tst #(1<<k) + cset/csetm ubfx/sbfx Rd, Rn, #k, #1
single-bit and/ubfx/lsr #31 + cbz/cbnz tbz/tbnz Rs, #k
cset + cbz/cbnz b.<cond> / b.<inverse cond>
cset + eor #1 cset <inverse cond>
cset + neg csetm
br x30 ret (engages the return-address predictor)
branch to the next instruction delete (both outcomes fall through; bl excluded)
lsl + lsr/asr ubfx/sbfx/ubfiz/sbfiz
lsr + and #mask ubfx
and #mask + lsr ubfx
and #mask/uxtb/uxth/uxtw/mov + lsl (or lsr + lsl) ubfiz (or clearing and)
zeroing producer + uxtb/uxth/uxtw/and drop the zero-extension
mov xd, xd remove (architectural no-op)
sign-extending producer + sxtb/sxth/sxtw drop the sign-extension
and/orr/eor/sub/bic/orn/eon with Rs, Rs mov / zero / all-ones
ldr+ldr / str+str (consecutive) ldp/stp (integer or FP/SIMD, and ldpsw)
str wzr+str wzr (consecutive zero stores) str/stur xzr
stp wzr, wzr (W-form) str/stur xzr
movi #0 + vector cmeq/cmge/cmgt (or FP fcm*) cmeq/cmge/cmgt/cmle/cmlt Vd, X, #0 (drop the movi)
and + and/ubfiz + orr (clear/isolate/merge) bfxil/bfi
csel Rd, Rn, Rn, cond mov Rd, Rn
fcsel Vd, Vn, Vn, cond fmov Vd, Vn
add/sub Rd, Rn, #0 mov Rd, Rn, or remove
adds/subs/ands + cmp #0 + b.eq/b.ne drop the redundant cmp/tst
add/sub/and/bic + cmp #0 + b.eq/b.ne adds/subs/ands/bics (drop the cmp/tst)
sub + cmp / add + cmn of the same operands (either order) subs/adds (flag-exact; drop the compare)
subs/adds + cmp/cmn of its own operands drop the compare (flags already set)
cmp #0 + cset/csetm of lt/mi lsr/asr Rd, Rn, #(msb) (the sign bit; drop the compare)
mov #2^N + mul lsl, or add Rd, Ra, Ra, lsl #N
mov #C + mneg neg, or shifted neg/sub
mov #2^N + madd/msub add/sub Rd, Ra, Rn, lsl #N
mov #2^N + udiv lsr
mov #2^N + udiv + msub (remainder) and Rd, Rn, #(2^N-1)
mov #C + add/sub add/sub Rd, Rn, #C (sign-crossed addsub, cmpcmn for #-C)
mov #C + and/orr/eor/ands or bic/orn/eon/bics and/orr/eor/ands Rd, Rn, #C (#~C for the inverting forms)
mov #C + ccmp/ccmn ccmp/ccmn Rn, #C, #nzcv, cond (sign-crossed for #-C)
mov #1 + csel csinc Rd, Rn, wzr, cc (cset when the other operand is ZR)
mov #-1 + csel csinv Rd, Rn, wzr, cc (csetm when the other operand is ZR)
mov #C + lsl/lsr/asr/ror (register amount) immediate-form shift, amount C mod 32/64
mov #bits/#C + fmov/scvtf/ucvtf from GPR fmov Sd/Dd, #imm
fmov/scvtf/dup of wzr/xzr (or mov #0 + transfer) movi dN, #0 / movi vN.T, #0
sxtw/mov w, w + scvtf/ucvtf Xn scvtf/ucvtf of Wn
ldr w8 + scvtf/ucvtf from GPR ldr s0 + FP-side convert (no cross-file transfer)
ldr/ldrsw (literal) of an encodable constant mov #imm / fmov #imm8 / movi+mvni for Q (no memory access)
adr + ldr [x8]/br x16 ldr Rt, <literal> / b L
cmp + csel (max/min shape) smax/smin/umax/umin (-m cssc)
cmp #0 + cneg abs (-m cssc)
rbit + clz ctz (-m cssc)
NEON popcount round trip cnt Xd, Xn (-m cssc)
autiasp/autibsp + ret retaa/retab (-m pauth; auto-armed on arm64e)
unsigned LR spill; raw br/blr audit-only review items (-a pac; auto-armed on arm64e)
ldxr/stxr fetch-op retry loop ldadd/ldset/ldeor/ldclr (+ mvn/neg/mov pre-op) (-m lse)
ldxr/stxr exchange retry loop swp (-m lse)
ldxr + cmp + b.ne + stxr CAS retry loop mov + cas + cmp (-m lse)
fmul + in-place fneg fnmul (bit-exact in every rounding mode)
mov #0 + str/add/and/csel/ccmp use use wzr/xzr
mov #C + ldr/str [xn, xc] ldr/str [xn, #C] (or ldur/stur)
mul + add/sub madd/msub (or mneg)
smull/umull + add/sub smaddl/umaddl/smsubl/umsubl
neg + add/sub sub/add
neg + csel csneg (inverted cond for the then slot)
mvn + csel csinv (inverted cond for the then slot)
add #1 + csel csinc (inverted cond for the then slot)
mvn + and/orr/eor/ands bic/orn/eon/bics
add + ldr/str [xt] ldr/str [xn, xm{, lsl #s}]
sxtw + ldr/str [xn, xt] ldr/str [xn, ws, sxtw {#s}]
ldrb/ldrh/ldr (or ldrsb/ldrsh Wt) + sxtb/sxth/sxtw ldrsb/ldrsh/ldrsw (Xt for the re-widened sign loads)
add #a + ldr/str [xt] (incl. mov xt, sp) ldr/str [xn, #a] / ldr [sp]
ldr/ldp [xn] + add/sub xn ldr [xn], #±imm / ldp [sp], #imm (post-index)
add/sub xn + ldr/stp [xn] ldr [xn, #±imm]! / stp [sp, #-imm]! (pre-index)

Compilation

armlint depends on Capstone and uses pkg-config to locate it. On macOS:

brew install capstone

On Debian/Ubuntu:

apt install libcapstone-dev pkg-config

Build:

git clone https://github.com/gaul/armlint.git armlint
cd armlint
make all

Two test suites are available. make test runs the unit tests against fabricated byte sequences, exercising the check registry directly. make integration-test runs the snapshot suite under fixtures/: each .s is assembled with clang -arch arm64 and armlint's output is diffed against a checked-in .expected file. The integration suite covers the Mach-O parser and the report formatting, which the unit tests bypass. It needs a clang that can assemble AArch64 and fails without one rather than reporting a pass it did not earn; off an arm64 host it names the target explicitly, so an x86-64 Linux box runs the suite too. After an intentional output change, regenerate the snapshots with make integration-test-regen and review the diff before committing.

Setting ARMLINT_LIVENESS_SWEEP=1 extends the unit tests' NZCV-liveness cross-check against Capstone to the entire 2^32 encoding space -- minutes of single-threaded CPU time, so CI runs it on pushes rather than PRs. ARMLINT_LIVENESS_SWEEP_THREADS divides the sweep across that many worker threads:

ARMLINT_LIVENESS_SWEEP=1 ARMLINT_LIVENESS_SWEEP_THREADS="$(sysctl -n hw.ncpu)" \
    make test

(nproc on Linux.)

Testing against Capstone 6

Capstone 6 (in alpha as of mid-2026) rewrote the AArch64 module from LLVM. armlint compiles against it unchanged via Capstone's compatibility header, and CI tracks the pinned pre-release below. To reproduce locally:

git clone --depth 1 --branch 6.0.0-Alpha10 \
    https://github.com/capstone-engine/capstone.git capstone6
cmake -B capstone6/build -S capstone6 -DCMAKE_BUILD_TYPE=Release
cmake --build capstone6/build -j8
make clean   # never mix objects built against different Capstone ABIs
make CAPSTONE_CFLAGS="-I$PWD/capstone6/include -DCAPSTONE_AARCH64_COMPAT_HEADER" \
     CAPSTONE_LIBS="$PWD/capstone6/build/libcapstone.a" all

The overrides must be make arguments (not environment variables) to beat the Makefile's pkg-config defaults. The unit tests and the ARMLINT_LIVENESS_SWEEP=1 sweep pass under both major versions; make integration-test is expected to show a handful of cosmetic snapshot diffs under v6 (it prints shift/bitfield immediates in decimal and drops # on adr/ldr-literal operands), so the fixtures remain pinned to Capstone 5.x rendering until v6 stabilizes.

Usage

armlint is intended to be part of compiler test suites which should #include "armlint.h" and link libarmlint.a. Disassemble the just-emitted machine code with check_instructions; its return value is the number of opportunities found, which a test can assert is zero:

#include "armlint.h"   // also includes <capstone/capstone.h>

// code/code_len: the AArch64 bytes to check (e.g. a function the
// compiler just emitted); base_addr is the address they load at.
// Returns the opportunity count (0 == clean), or -1 on a decode error.
int lint(const uint8_t *code, size_t code_len, uint64_t base_addr)
{
    csh handle;
    if (cs_open(CS_ARCH_ARM64, CS_MODE_ARM, &handle) != CS_ERR_OK) {
        return -1;
    }
    cs_option(handle, CS_OPT_DETAIL, CS_OPT_ON);

    armlint_summary *summary = armlint_summary_create();
    int findings = check_instructions(
        handle, code, code_len, base_addr, /*verbose=*/true, summary,
        /*features=*/0);   // or ARMLINT_FEATURE_CSSC etc.
    armlint_summary_print(summary);   // optional by-type tally

    armlint_summary_destroy(summary);
    cs_close(&handle);
    return findings;
}

The summary is optional -- pass NULL to skip the by-type tally -- and verbose controls whether each opportunity is printed as it is found. armlint can also read arbitrary AArch64 binaries (ELF, thin Mach-O, or universal/fat Mach-O) directly:

./armlint /path/to/aarch64/binary
./armlint /bin/ls
./armlint -m cssc /bin/ls   # also suggest CSSC instructions

-m <feature> enables checks whose rewrites use ISA-extension instructions the target must support: cssc (Armv8.9/9.4 Common Short Sequence Compression: smax/smin/umax/umin, abs, ctz), lrcpc2 (Armv8.4 unscaled store-release: stlur), pauth (Armv8.3 pointer authentication: retaa/retab), and lse (Armv8.1 atomics: ldadd/ldset/ldeor/ldclr/swp). pauth arms automatically on arm64e slices, whose ABI mandates FEAT_PAuth (the same auto-arm as the PAC audit); the rest stay opt-in.

-a <audit> enables opt-in informational checks that flag missing hardening rather than missed folds; pac audits the binary against the arm64e-style full pointer-authentication contract (return addresses spilled unsigned, unauthenticated br/blr). Audit findings are review items; the raw-br check recognizes and auto-dismisses the clang jump-table idiom, so what remains is BLRs, linker veneers, and genuinely unclassified branches. The PAC audit arms automatically on arm64e slices (whose ABI already assumes full signing), so macOS system binaries surface their worklist with no flag; a plain arm64 slice never opted in, so it stays silent unless you pass -a pac explicitly.

By default armlint prints only a summary: the opportunities grouped by type and sorted by prevalence, so it is clear which to look at first, followed by a total and the number of instructions scanned. A large binary can have hundreds of thousands of opportunities, so the per-opportunity detail is suppressed unless requested:

$ ./armlint /bin/ls
Optimization opportunities by type:
      38  ADD + LDR foldable to immediate-offset LDR

38 optimization opportunities in 4153 instructions

Pass -v to also print each opportunity -- its one-line summary plus the offending instructions, as shown below -- ahead of the summary:

$ ./armlint -v /bin/ls
ADD + LDR foldable to immediate-offset LDR at offset: 0x60: -> ldr w8, [x8, #0x2c] (2 instructions)
  add x8, x8, #0x2c
  ldr w8, [x8]
...

The process exits non-zero when any opportunity is found, so armlint can gate a compiler test suite.

ISA census (-i)

armlint -i replaces the lint scan with a census: every instruction in the binary's executable sections, attributed to the FEAT_* group it requires, each group carrying the architecture version it became mandatory at -- LSE and CRC32 at Armv8.1, LRCPC/FCMA/JSCVT and the register-form PAC at 8.3, DotProd/LRCPC2/FlagM at 8.4, up through the MOPS memcpy instructions at 8.8. The resulting ladder answers "what was this binary compiled for": a -march=armv8.1-a build shows LSE atomics woven through every mutex, a baseline build shows LDXR/STXR loops with LSE only inside runtime-dispatched thunks (glibc's outline atomics).

$ ./armlint -i libc.so.6
ISA census: 275103 instructions, 1 undecodable words skipped
  Armv8.0 baseline: 275043
  mandatory from Armv8.1: LSE (21)
  mandatory from Armv8.3: none
  ...
  optional features: none
  branch protection (hint space): BTI (21), PAC (18)
  highest mandatory-from level: Armv8.1

Three groups never raise the ladder, each for a soundness reason of its own. Features that never become mandatory in the v8 line (the crypto extensions, FP16, SVE, MTE) are listed as optional: any of them can be bolted onto an old target with a single +feature flag, so their presence says nothing about -march. The hint-space branch-protection forms (PACIASP/AUTIASP/BTI/XPACLRI) execute as NOPs on cores without the extension -- -mbranch-protection=standard emits them precisely so the binary stays v8.0-compatible -- so they get their own line and no version claim; only the register-form PAC instructions (PACIA, RETAA, BRAA, ...) evidence a real Armv8.3 target. And an undecodable word is either data in text or an extension this Capstone build cannot decode, so the skipped count bounds what the census could have missed.

The census reports presence, not requirement: dispatched fast paths count even though the binary runs without them. Treat small exotic tallies in a binary with many skipped words with suspicion -- string pools embedded in text sometimes decode as valid SVE or atomics -- and use -v, which prints up to four sample addresses per feature, to check a surprising tally in a disassembler before believing it.

Mining tools

tools/ holds the research utilities that feed armlint's check backlog, built separately with make tools:

  • tools/pairscan counts adjacent-instruction pairs by normalized shape (registers collapsed to classes, immediates to #0/#i) across the executable sections of ELF and Mach-O binaries, surfacing frequent patterns worth a new check. -e SUBSTR prints example sites for shapes matching a substring.
  • tools/defuse profiles block-local def-to-use distances (how far a value's sole consumer sits from its producer) and multi-instruction redundancies no pair statistic can see: dead definitions, redundant reloads of the same address, re-materialized constants, and zero compares of a value whose producer could have set the flags.

The workflow that produced several of the current checks: compile a representative corpus, run pairscan to rank pair shapes, classify the top shapes as by-design or foldable, then use defuse to decide whether a candidate needs adjacency only or a liveness window. Both tools lean on Capstone's register-access model, which mis-reports the compare aliases (CMP/CMN/TST mark their first operand as a write); defuse corrects for this, and armlint's own checks decode the raw encodings precisely to avoid that class of problem.

References

License

Copyright (C) 2026 Andrew Gaul

Licensed under the Apache License, Version 2.0

About

Examine AArch64 machine code to find suboptimal instruction sequences

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages