CodeRigor

FREE LESSON

AArch64 codegen, side by side with x86-64

The same C++ on both machines: fused multiply-add and conditional select, shifted operands, bitfield instructions, immediates that don't fit, conditional compare chains, load/store pairs — and how to write C++ that lands on the good idioms.

You can read x86-64 compiler output (Levels 11–13). This skill puts the same C++ through GCC 16 for both machines and looks at where AArch64 does it differently — usually in fewer, more regular instructions. Use the compiler pane's Compare mode (left x86-64, right AArch64) on every snippet here.

The big structural differences:

x86-64 AArch64
instruction length 1–15 bytes always 4 bytes
operands 2 (add rax, rdx: rax += rdx) 3 (add x0, x1, x2)
memory operands most ALU ops can read memory only loads/stores touch memory
registers 16 GPRs 31 GPRs + zero register
flags almost every ALU op sets them only s-suffixed ops (adds, subs, cmp)
immediates up to 32 bits (64 with movabs) small, encoded — big ones are built

1. Multiply-add in one instruction

C++
long fma1(long a, long b, long c) { return a * b + c; }
long fms (long a, long b, long c) { return c - a * b; }
long wide(int a, int b, long c)   { return (long)a * b + c; }
Text
x86-64                         AArch64
imul rdi, rsi                  madd   x0, x0, x1, x2     // x0*x1 + x2
lea  rax, [rdi+rdx]            msub   x0, x0, x1, x2     // x2 - x0*x1
                               smaddl x0, w0, w1, x2     // sign-extend, 32×32→64, add

madd/msub take four registers. smaddl/umaddl multiply two 32-bit values into a 64-bit result and do the widening, where x86 needs two movsxd first. A remainder x % y becomes udiv + msub (q = x / y; r = x − q·y).

2. Conditional select instead of cmov

C++
int max2(int a, int b)            { return a > b ? a : b; }
int inc_if(int a, int b, int x)   { return a < b ? x + 1 : x; }
Text
x86-64                         AArch64
cmp    edi, esi                cmp  w0, w1
mov    eax, esi                csel w0, w0, w1, ge
cmovge eax, edi                                         // w0 = ge ? w0 : w1
                               cmp  w0, w1
xor  eax, eax                  cinc w0, w2, lt          // w0 = lt ? w2+1 : w2
cmp  edi, esi
setl al
add  eax, edx

The csel family has a destination and two sources, so no mov beforehand: csel (select), csinc (select or increment), csinv (invert), csneg (negate), and aliases cset/csetm (0/1, 0/−1), cinc, cneg. Very often a ?: that x86 spends three instructions on is one csel here.

3. Shifted and extended operands

Most arithmetic instructions can shift or extend their last register operand for free:

Text
a + (b << 3)              add x0, x0, x1, lsl 3      (x86: lea rax, [rdi+rsi*8])
p[i]  (8-byte elements)   ldr x0, [x0, x1, lsl 3]    (x86: mov rax, [rdi+rsi*8])
a + (long)int_index       add x0, x0, w1, sxtw       (x86: movsxd + add)

Unlike lea, the shift can be any amount (0–63), and it works on and, orr, eor, sub, cmp too: bic x0, x0, x1, lsr 4 clears the bits of x1 >> 4.

4. Bitfields: ubfx, sbfx, bfi, ubfiz

C++
unsigned field(unsigned x)             { return (x >> 5) & 0x3f; }
unsigned ins(unsigned x, unsigned y)   { return (x & ~0x7e0u) | ((y & 0x3f) << 5); }
Text
x86-64                         AArch64
mov eax, edi                   ubfx x0, x0, 5, 6     // extract 6 bits from bit 5
shr eax, 5
and eax, 63
mov eax, esi                   bfi  w0, w1, 5, 6     // insert w1's low 6 bits at bit 5
and edi, -2017
sal eax, 5
and eax, 2016
or  eax, edi
bfi w0, w1, 5, 6: ins(0xffff, 0x2a) = 0xfd5f
x86 needs and/shift/and/or; AArch64 replaces bits 5–10 of w0 with the low 6 bits of w1 and leaves the rest alone.
bfi w0, w1, 5, 6: ins(0xffff, 0x2a) = 0xfd5f
x86 needs and/shift/and/or; AArch64 replaces bits 5–10 of w0 with the low 6 bits of w1 and leaves the rest alone.

Shift-and-mask patterns over a contiguous field become one instruction. sbfx sign-extends the field; ubfiz places low bits at a position ((x & 7) << 2, used for t % 8 * 4).

5. Immediates: encodable or built

Every instruction is 32 bits, so a 64-bit constant can't be inline. Two encodings cover most constants:

  • Arithmetic (add, sub, cmp): a 12-bit value, optionally shifted left by 12. add x0, x0, 4095 fits; add x0, x0, 4097 doesn't.
  • Logical (and, orr, eor, tst): a repeating bit pattern — a run of ones, rotated, repeated every 2, 4, 8, 16, 32 or 64 bits. 0x00ff00ff00ff00ff and 0x5555555555555555 are encodable; 0x123456789 isn't.
Text
x & 0x00ff00ff00ff00ff      and x0, x0, 71777214294589695     // one instruction
x + 0x123456789             mov  x1, 26505                   // 0x6789
                            movk x1, 0x2345, lsl 16
                            movk x1, 0x1, lsl 32
                            add  x0, x0, x1
A logical immediate is a repeating element
0x00ff00ff… is one 16-bit element (8 ones) repeated, so and encodes it directly. 0x123456789 has no such period: it takes mov + two movk.
A logical immediate is a repeating element
0x00ff00ff… is one 16-bit element (8 ones) repeated, so and encodes it directly. 0x123456789 has no such period: it takes mov + two movk.

movz (shown as mov) writes 16 bits and zeroes the rest; each movk keeps the register and replaces one 16-bit chunk. A full 64-bit constant is up to four instructions (x86: one 10-byte movabs). Compilers put big constants used many times in a literal pool and load them with ldr.

6. Division by constants, and udiv

Text
x / 10  (unsigned)      mov  w1, 52429                // 0xcccd
                        movk w1, 0xcccc, lsl 16       // magic = 0xcccccccd
                        umull x0, w0, w1              // 32×32→64
                        lsr  x0, x0, 35
x / y                   udiv w0, w0, w1
x / 10 without dividing: 1234 / 10
0xcccccccd ≈ 2³⁵ / 10, so multiplying and shifting right by 35 divides — exactly, for every 32-bit x. Two cheap instructions instead of a 7–20-cycle udiv.
x / 10 without dividing: 1234 / 10
0xcccccccd ≈ 2³⁵ / 10, so multiplying and shifting right by 35 divides — exactly, for every 32-bit x. Two cheap instructions instead of a 7–20-cycle udiv.

The magic-number trick is the same as on x86 (Level 12). The difference is runtime division: udiv/sdiv are ordinary instructions with no fixed registers (x86's div needs edx:eax), never trap on overflow and return 0 for division by zero. They're still slow (roughly 7–20 cycles), so compilers still avoid them for constants.

7. Bit counting

Text
std::countl_zero(x)    clz  x0, x0
std::countr_zero(x)    rbit x0, x0         // reverse the bits…
                       clz  x0, x0         // …and count leading zeros
std::popcount(x)       fmov d31, x0        // move to a SIMD register
                       cnt  v31.8b, v31.8b // popcount of each byte
                       addv b31, v31.8b    // add the 8 bytes
                       fmov w0, s31
std::byteswap(x)       rev  w0, w0
std::popcount(0xff00000000000301) through NEON
fmov copies x0 into d31, cnt counts the bits of every byte at once, addv sums the bytes: 1 + 2 + 8 = 11. Byte 0 is on the right.
std::popcount(0xff00000000000301) through NEON
fmov copies x0 into d31, cnt counts the bits of every byte at once, addv sums the bytes: 1 + 2 + 8 = 11. Byte 0 is on the right.

Baseline ARMv8 has no scalar popcount or ctz, but NEON's cnt is always there, so GCC never calls __popcountdi2 the way baseline x86-64 does. (ARMv8.9's CSSC extension adds scalar cnt and ctz.)

8. Conditional compare chains

C++
bool both(int a, int b) { return a == 3 && b > 7; }
Text
x86-64                         AArch64
cmp  edi, 3                    cmp  w0, 3
sete al                        ccmp w1, 7, 4, eq      // if eq: flags = cmp w1, 7
cmp  esi, 7                                           // else:  flags = 0b0100 (Z set)
setg dl                        cset w0, gt
and  eax, edx

ccmp does the second comparison only if the first condition holds; otherwise it loads the flags from an immediate (nzcv), chosen so the final test fails. A chain of &&/|| of simple comparisons becomes cmp, ccmp, ccmp, …, cset/csel — no branches. Write conditions so GCC can see this: plain comparisons combined with &&/|| in one expression; nested ifs often become real branches.

9. Pairs, addresses, tail calls

Text
struct {long a, b;} *p:  p->a + p->b     ldp x1, x0, [x0]       // one load, two registers
copy two longs                           ldp x2, x3, [x1]
                                         stp x2, x3, [x0]
read a global                            adrp x0, sym           // 4 KiB page of sym
                                         ldr  x0, [x0, #:lo12:sym]
tail call                                b    callee            // x86: jmp

ldp/stp move two registers at once — also how every prologue saves the frame pointer and link register. Globals take two instructions: adrp gives the page address (±4 GiB around pc), and the low 12 bits go into the load. x86 does it in one mov rax, [rip+sym], because its instructions are long enough to hold a 32-bit offset.

C++
#include <bit>
#include <cstdio>

unsigned field(unsigned x) { return (x >> 5) & 0x3f; }
int ctz(unsigned long x) { return std::countr_zero(x); }
bool both(int a, int b) { return a == 3 && b > 7; }

int main() {
    std::printf("%u %d %d %d\n", field(0x7e0), ctz(0x80), both(3, 8), both(3, 7));
}

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Multiplying by constants on Arm: the barrel shifter folds shifts into add/sub — compare with x86's lea.