armlint examines AArch64 machine code to find suboptimal instruction
sequences. For example, building the constant 0x66666666 as
movz w0, #0x6666
movk w0, #0x6666, lsl #16is two instructions where one would do, because 0x66666666 is encodable
as an AArch64 logical (bitmask) immediate:
mov w0, #0x66666666 ; orr w0, wzr, #0x66666666armlint helps compiler writers and assembly authors generate tighter code, and documents corners of the A64 instruction set.
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 tostr x0, [sp, #0x20]only oncex8provably 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, w8folds toadd w9, w2, w1, lsl #3under 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/UDIVby 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/TSTonly 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 unconditionalB, or a conditionalCBZ/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.
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 add↔sub, cmp↔cmn 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) |
armlint depends on Capstone and uses
pkg-config to locate it. On macOS:
brew install capstoneOn Debian/Ubuntu:
apt install libcapstone-dev pkg-configBuild:
git clone https://github.com/gaul/armlint.git armlint
cd armlint
make allTwo 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.)
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" allThe 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.
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 instructionsPass -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.
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.1Three 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.
tools/ holds the research utilities that feed armlint's check
backlog, built separately with make tools:
tools/pairscancounts 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 SUBSTRprints example sites for shapes matching a substring.tools/defuseprofiles 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.
- Arm A-profile A64 Instruction Set Architecture - per-instruction reference, including alias conditions
- Arm Cortex-A optimization guides - per-microarchitecture tuning notes
- Apple Silicon CPU Optimization Guide - Apple M-series tuning notes
- Encoding of immediate values on AArch64 - the bitmask-immediate scheme explained
- Capstone disassembly framework - library to parse instructions
- x86lint - x86-64 equivalent of armlint
Copyright (C) 2026 Andrew Gaul
Licensed under the Apache License, Version 2.0