Assembly Programming: ABIs, Toolchain, Disassembly, Performance, and SIMD¶
Part 2 of 4 of the Assembly Programming reference (plugin
assembly-programming), covering §6–§10. Sibling skills:assembly-fundamentals-and-isas(§0–§5),assembly-systems-crypto-and-inline(§11–§14),assembly-reference(§15–§20). Section numbers are shared across the set; a reference written as §N →skillpoints into that sibling skill.Currency: Verified August 2026. See §17 →
assembly-referencefor the currency snapshot and what goes stale first.How to read this. Reference, not tutorial. Sections are independent. Three markers: - [DURABLE] — machine organization, algorithms, or a lesson that has held since the 1970s. Does not expire. - [ISA] — specific to x86-64, AArch64, RISC-V, or a particular microarchitecture. Verify against the vendor's current manual. - [CONTESTED] — practitioners genuinely disagree, usually about how much hand-written assembly is justified.
⚠️ GOTCHA boxes mark the mistakes that produce silent corruption, ABI violations, or code that is slower than the C you replaced.
The three framings that organize everything below: 1. The instruction set is an interface, not the machine. The CPU you're actually programming is out-of-order, superscalar, speculative, and cached. Your instructions are decoded into µops, reordered, and executed in parallel. Reasoning about assembly as "the CPU does this, then this" is wrong in ways that make your optimizations wrong. 2. Reading assembly is a hundred times more common than writing it, and more valuable. Most of the return here comes from understanding compiler output, debugging an optimized crash, or reverse-engineering a binary. 3. When you write assembly, you are taking on the compiler's job permanently. No retargeting, no auto-vectorization, no new-CPU tuning, and no help from the next twenty years of compiler improvements. That's the real cost, and it's paid in maintenance rather than in the initial write.
§6. Calling Conventions and ABIs¶
[DURABLE] The ABI is the contract, and violating it produces bugs that appear far from the cause. You must know: which registers pass arguments, which are caller- vs. callee-saved, where the return value goes, stack alignment, how structs are passed, and how varargs work.
| System V AMD64 (Linux/macOS/BSD) | Windows x64 | AAPCS64 (ARM64) | RISC-V | |
|---|---|---|---|---|
| Int args | rdi, rsi, rdx, rcx, r8, r9 | rcx, rdx, r8, r9 only | x0–x7 | a0–a7 |
| FP args | xmm0–7 | xmm0–3 | v0–v7 | fa0–fa7 |
| Return | rax (rdx:rax for 128) | rax | x0 (x0,x1) | a0 (a0,a1) |
| Callee-saved | rbx, rbp, r12–r15 | rbx, rbp, rdi, rsi, r12–r15, xmm6–15 | x19–x28, v8–v15 (low 64 bits only) | s0–s11, fs0–fs11 |
| Stack align at call | 16 bytes | 16 bytes | 16 bytes | 16 bytes |
| Special | red zone: 128 bytes below rsp usable without adjusting | 32-byte shadow space the caller must allocate | x18 platform-reserved on some OSes | — |
⚠️ GOTCHA — the four ABI mistakes that account for most hand-written-assembly bugs: 1. Clobbering a callee-saved register without saving it. The corruption surfaces in the caller, arbitrarily later. 2. Misaligning the stack. SSE/NEON instructions fault or silently slow down, and the failure appears inside an unrelated library function. 3. Assuming System V on Windows (or vice versa). Completely different argument registers, plus Windows' shadow space and xmm6–15 preservation. 4. AArch64
v8–v15: only the low 64 bits are callee-saved. The upper halves are caller-saved. This one bites SIMD code specifically.Also: the red zone does not exist in kernel or interrupt context (Linux compiles the kernel with
-mno-red-zonefor exactly this reason), and varargs on System V requiresalto hold the number of vector registers used.
Name mangling and symbol visibility: C symbols may get a leading underscore (Darwin,
older platforms) and C++ names are mangled — use extern "C" and check with nm.
§7. Assemblers and Toolchain¶
7.1 The syntax split¶
[DURABLE] Two syntaxes for x86, and the operand order is reversed. This causes more confusion than any other single thing in x86 assembly.
; Intel syntax (NASM, MASM, and `objdump -M intel`) DEST, SRC
mov rax, rbx
mov rax, [rbx + rcx*8 + 16]
add rax, 1
# AT&T / GAS syntax (default GNU) SRC, DEST
movq %rbx, %rax
movq 16(%rbx,%rcx,8), %rax
addq $1, %rax # % on registers, $ on immediates, suffix for size
7.2 The tools¶
| Tool | Use |
|---|---|
GAS (as, via gcc/clang) |
The default on Unix. AT&T by default; .intel_syntax noprefix available |
| NASM / YASM | Intel syntax, standalone, excellent macro system. The x86 favourite |
| Clang's integrated assembler | Now the default in the LLVM toolchain; handles both syntaxes |
MASM (ml64) |
Windows/MSVC |
objdump -d, llvm-objdump |
Disassembly |
| Ghidra, IDA Pro, Binary Ninja, radare2/rizin | Reverse engineering with decompilation |
| Compiler Explorer (godbolt.org) | The single most valuable tool in this whole document. See §8 |
perf, VTune, uProf, Instruments |
Profiling with hardware counters |
| LLVM-MCA, uiCA, OSACA | Static throughput/latency analysis of a loop |
gdb/lldb |
layout asm, si, info registers, x/i $pc |
Directives you need: .text/.data/.bss/.rodata, .global, .align/.p2align,
.byte/.word/.quad, .asciz, .type/.size (required for correct ELF symbols and
for the profiler to attribute samples), and CFI directives (.cfi_startproc,
.cfi_def_cfa_offset, .cfi_offset, .cfi_endproc).
⚠️ GOTCHA — omit CFI and your function becomes invisible to unwinding. Backtraces stop at your function, C++ exceptions cannot propagate through it, and profilers mis-attribute its samples. Hand-written assembly without CFI is a debugging black hole, and it's the most commonly skipped step. CHUNKEOF echo ok; wc -l /home/claude/assembly-programming.md 2>/dev/null || true
§8. Reading Disassembly¶
[DURABLE] This is the highest-value assembly skill, and most people who "know assembly" mean this.
8.1 The workflow¶
- Compiler Explorer (godbolt.org) — paste source, pick a compiler and flags, watch the assembly change. Colour-coded source↔asm mapping. If you want to learn assembly in 2026, this is where you do it.
gcc -S -O2 -masm=intel -fverbose-asmfor local work.objdump -d --no-show-raw-insn -M intel binaryfor shipped binaries.perf annotateto see assembly with sample counts attached — this is how you find the hot instruction.
8.2 Recognizing patterns¶
; array indexing: a[i] where a is int32*
mov eax, [rdi + rsi*4]
; a loop the compiler unrolled and vectorized (the give-away is the wide moves)
.L4:
movdqu xmm0, [rdi + rax]
paddd xmm0, xmm1
movdqu [rdi + rax], xmm0
add rax, 16
cmp rax, rdx
jne .L4
; a switch compiled to a jump table
cmp edi, 5
ja .Ldefault
jmp [.Ltable + rdi*8]
; a division by a constant, strength-reduced to multiply-and-shift
mov rax, 0x5555555555555556 ; magic number for /3
imul rdx
...
; a tail call (jmp, not call — no new stack frame)
jmp other_function
[DURABLE] What surprises people reading -O2 output for the first time: variables
don't exist (they're in registers, or gone), the source line order is scrambled, functions
have been inlined away, loops are unrolled and vectorized, dead code is deleted entirely,
and the debugger's line numbers are approximate at best. This is normal and correct —
it is also why "it works in debug, fails in release" is usually a latent bug (UB) rather
than a compiler bug.
§9. Performance¶
9.1 The mental model that's actually right¶
[DURABLE] A modern core is a dataflow machine wearing a sequential ISA. It fetches many instructions ahead, renames away false dependencies, and executes whatever is ready. So:
What costs you, in descending order:
1. Cache misses. ~4 cycles L1, ~12 L2, ~40 L3, ~200–300+ cycles DRAM. A single
main-memory miss costs more than a hundred arithmetic instructions. Memory layout is
the optimization.
2. Branch mispredictions. ~15–20 cycles of wasted work. Predictable branches are nearly
free; unpredictable ones are catastrophic — which is why cmov/csel exists.
3. The critical dependency chain. If each iteration depends on the last, you get
latency, not throughput. Break the chain with multiple accumulators.
4. Long-latency instructions: division (20–100 cycles), some transcendentals, pdep
on the wrong microarchitecture.
5. Port contention / execution-unit throughput.
6. Instruction count. Last.
9.2 Latency vs. throughput — the distinction beginners miss¶
Latency = cycles until the result is available to a dependent instruction. Throughput (reciprocal throughput) = cycles between issuing independent instances.
A multiply might be latency 4, throughput 0.5 — four cycles to get an answer, but you can start two per cycle. So:
; SLOW: one accumulator, serialized on the add's latency
.loop: add rax, [rsi + rcx*8]
inc rcx
jne .loop
; FAST: four accumulators, four independent chains, saturates the ports
.loop: add rax, [rsi + rcx*8]
add rbx, [rsi + rcx*8 + 8]
add r8, [rsi + rcx*8 + 16]
add r9, [rsi + rcx*8 + 24]
add rcx, 4
jne .loop
; then combine rax+rbx+r8+r9
Get the numbers from: Agner Fog's instruction tables (the canonical reference for x86 latency/throughput/ports across every microarchitecture), uops.info (automated and exhaustive), Intel's and AMD's optimization manuals, and Arm's Software Optimization Guides per core.
9.3 Memory¶
- Cache line = 64 bytes on essentially everything current. This is the fundamental unit; internalize it.
- Sequential access is prefetched automatically; random access is not. A linked list and an array with the same asymptotics differ by 10× in practice.
- False sharing: two threads writing different variables in the same cache line ping-pong the line between cores and destroy scaling. Pad to 64 (or 128 — some prefetchers work in pairs) bytes.
- Structure of Arrays beats Array of Structures for SIMD, almost always.
- Non-temporal stores (
movntdq,stnp) bypass the cache for streaming writes you won't re-read. Powerful and easy to misuse. - Software prefetch (
prefetcht0,prfm) helps only when the hardware prefetcher can't see the pattern and you can issue it far enough ahead. Usually it does nothing or hurts; measure.
9.4 Branches¶
- Predictable branches ≈ free. Unpredictable ≈ 15–20 cycles.
- Branchless with
cmov/cseltrades a mispredict for a data dependency — a win when unpredictable, a loss when predictable (because it serializes what the predictor would have run ahead of). - Loop alignment and target alignment sometimes matter; measure rather than cargo-cult.
- ⚠️ Do not use x86 branch-hint prefixes. They've been ignored for two decades.
9.5 The rule¶
[DURABLE] Measure, change one thing, measure again — on the actual target hardware. Assembly optimization without measurement is superstition, and the literature is full of tricks that were true on a Pentium 4 and have been wrong ever since. Benchmark with real data distributions, beware of the microbenchmark that keeps everything in L1, and be suspicious of any speedup you can't explain mechanically.
§10. SIMD and Vector¶
10.1 The two models¶
[DURABLE] There are now two fundamentally different vector programming models, and this is the biggest conceptual split in modern assembly.
Fixed-width SIMD — the register is a known size at compile time. SSE (128), AVX (256), AVX-512 (512), NEON (128). You write a main loop plus a scalar epilogue for the remainder, and you recompile (or runtime-dispatch) for each width.
Scalable/vector-length-agnostic (VLA) — the register size is unknown at compile time and discovered at runtime. ARM SVE/SVE2 and RISC-V RVV both work this way, deriving from the Cray vector tradition rather than from packed SIMD. The same binary runs on hardware with different vector lengths without recompiling.
; RISC-V RVV: the canonical strip-mined loop — no epilogue needed
loop:
vsetvli t0, a0, e32, m8 ; "give me up to a0 elements of 32-bit, LMUL=8"
; t0 = how many you actually got
vle32.v v0, (a1) ; load t0 elements
vadd.vi v0, v0, 1
vse32.v v0, (a2)
sub a0, a0, t0 ; decrement remaining
slli t1, t0, 2
add a1, a1, t1
add a2, a2, t1
bnez a0, loop
vsetvli returning the granted length is the whole idea: the hardware tells
you how much it can do, the loop handles any remainder naturally, and the tail-handling
code that dominates fixed-width SIMD simply disappears. SVE achieves the same with
predication and whilelt.
RVV specifics worth knowing: 32 vector registers; VLEN (implementation vector
length) ranges from 128 to 16384 bits in shipping implementations; SEW (element width)
and LMUL (register grouping: 1/8 … 8) are set dynamically by vsetvli, so the same
instruction encoding works across element types and widths.
10.2 The x86 SIMD landscape¶
MMX(dead) → SSE→SSE4.2 (128-bit, xmm) → AVX/AVX2 (256-bit, ymm, 3-operand VEX)
→ AVX-512 (512-bit, zmm0–31, 8 mask registers k0–k7, EVEX encoding)
→ AVX10 (the convergence effort, §17)
⚠️ GOTCHA — AVX-512 fragmentation is the reason AVX10 exists. Intel shipped AVX-512 on some server parts, then disabled it on hybrid consumer parts because the E-cores didn't have it. AMD implemented it (double-pumped on Zen 4, full-width on Zen 5). Result: a decade where you couldn't assume 512-bit vectors on a desktop x86 CPU. §17 →
assembly-referencehas the 2026 state.⚠️ GOTCHA — downclocking. Heavy 512-bit AVX-512 use historically dropped clock frequency on Intel server parts, so a vectorized loop could slow the rest of the program down. Much improved on recent silicon, but always measure whole-application throughput, not just the kernel.
⚠️ GOTCHA — AVX/SSE transition penalties. Mixing legacy SSE and VEX-encoded AVX without
vzerouppercauses expensive state-transition stalls. Emitvzeroupperbefore returning from AVX code to a caller that might use SSE.
10.3 ARM SIMD¶
NEON (128-bit, v0–v31) is the universal baseline on AArch64 — always present, no runtime check needed.
SVE/SVE2 is scalable and predicated. SME/SME2 (Scalable Matrix Extension) adds
ZA storage and outer-product operations for matrix work, with SMSTART/SMSTOP to
enter and leave streaming mode.
[VERSIONED] Deployment as of 2026 is uneven and worth knowing precisely: the Apple
M4 family was the first consumer-grade silicon to support both SVE2 and SME (Apple's own
LLVM contribution specifies Armv9.2-A for M4 and confirms SME and SME2). Arm's
Lumex cores bring SME2 to Android; some competing custom cores shipped SME1 + SVE2
first. Do not assume SVE2 or SME are present — check HWCAP/ID_AA64* at runtime and
keep a NEON path.
10.4 Writing SIMD well¶
[DURABLE, and this is the most important advice in the section] Use intrinsics, not assembly, for SIMD. You get the exact instructions you want plus register allocation, scheduling, inlining, and constant folding for free. Hand-written SIMD assembly is justified when you're fighting the register allocator on a large kernel, and rarely otherwise.
The techniques: Structure-of-Arrays layout, alignment where it matters, handling the tail (or using a VLA ISA and not needing to), shuffles/permutes as the hard part, horizontal reductions (expensive — keep them out of the loop), and runtime dispatch (check CPUID/HWCAP once, select a function pointer, and note that indirect call overhead means you dispatch per buffer, not per element).
Plugin: assembly-programming · View SKILL.md on GitHub