High-level languages spoil us with for, while, and do-while loops that just work. But underneath all of that syntactic sugar, assembly language has no built-in “loop” keyword in the way you might expect. Instead, loops are built entirely out of comparisons, conditional jumps, and a bit of discipline. In this post, I’ll break down exactly how loops are constructed at the instruction level, across x86, x86-64, and ARM, and I’ll cover the performance implications that most tutorials skip over.
What a Loop Really Is at the Machine Level
At its core, a loop in assembly is just:
- A label marking the start of the loop body
- The loop body instructions
- A comparison against some condition
- A conditional jump back to the label (or forward, to exit)
There’s no magic — a loop is a controlled reuse of the instruction pointer, jumping backward to re-execute a block of code until a condition is no longer true.
The Three Classic Loop Patterns
Just like in C, assembly loops generally fall into three shapes, even though the underlying instructions are similar:
- Counter-controlled loops (like a
forloop) - Condition-controlled loops, checked before entry (like a
whileloop) - Condition-controlled loops, checked after execution (like a
do-whileloop)
Counter-Controlled Loop (x86-64, NASM syntax)
section .text
global _start
_start:
mov rcx, 10 ; loop counter = 10
sum_loop:
; ... loop body here ...
dec rcx ; decrement counter
jnz sum_loop ; jump if not zero
; loop finished
This is the assembly equivalent of for (int i = 10; i > 0; i--) { ... }.
Using the Dedicated LOOP Instruction (x86)
x86 actually has a dedicated instruction just for this pattern:
mov cx, 10
top:
; loop body
loop top ; decrements CX, jumps to 'top' if CX != 0
LOOP implicitly uses CX/ECX/RCX as the counter, decrements it, and jumps if the result isn’t zero. It’s elegant, but on modern CPUs it’s often slower than manually doing DEC + JNZ, because LOOP isn’t well optimized in modern microarchitectures. Most compilers avoid emitting it for this reason.
While-Style Loop (Condition Checked First)
mov rax, 0
while_top:
cmp rax, 10
jge while_end ; exit if rax >= 10
; loop body
inc rax
jmp while_top
while_end:
Do-While-Style Loop (Condition Checked After)
mov rax, 0
do_body:
; loop body
inc rax
cmp rax, 10
jl do_body ; repeat if rax < 10
Notice the do-while pattern requires fewer instructions per iteration than the while-style loop, because it doesn’t need an upfront check before the first pass. This is actually why compilers often transform for and while loops into a do-while shape internally, adding a single guard check before the loop to handle the zero-iteration case.
Internal Working Process (Diagram)
flowchart TD
A[Initialize counter/condition] --> B{Check condition}
B -->|True| C[Execute loop body]
C --> D[Update counter/condition]
D --> B
B -->|False| E[Exit loop]
Loops in ARM Assembly
ARM doesn’t have a dedicated LOOP instruction like x86, but it has something arguably more elegant: conditional execution suffixes built into most instructions.
MOV R0, #10 ; counter = 10
loop_start:
; loop body
SUBS R0, R0, #1 ; subtract 1, update flags (S suffix)
BNE loop_start ; branch if not equal (zero flag not set)
The S suffix on SUBS tells the processor to update the condition flags based on the result, and BNE (Branch if Not Equal) checks the zero flag set by that subtraction. This tight coupling between arithmetic and flags is a hallmark of RISC-style assembly.
In ARM64 (AArch64), the same pattern holds with slightly different mnemonics:
mov x0, #10
loop_start:
; loop body
subs x0, x0, #1
b.ne loop_start
Nested Loops
Nested loops just mean nested labels and separate counters, typically using different registers so the inner loop doesn’t clobber the outer loop’s counter:
mov rcx, 5 ; outer counter
outer_loop:
mov rdx, 3 ; inner counter
inner_loop:
; inner loop body
dec rdx
jnz inner_loop
dec rcx
jnz outer_loop
A very common beginner mistake is reusing the same register for both the inner and outer counters, which silently corrupts the outer loop’s count.
Comparison Table: Loop Constructs Across Architectures
| Feature | x86/x86-64 | ARM (32-bit) | ARM64 |
|---|---|---|---|
| Dedicated loop instruction | LOOP (legacy, slow) | None | None |
| Common pattern | DEC + JNZ/CMP + Jcc | SUBS + Bcc | SUBS + B.cc |
| Flag-setting arithmetic | Explicit via CMP/TEST | Built into S suffix | Built into S suffix |
| Conditional branch style | Separate jump mnemonics (JE, JG, etc.) | Condition codes on branches | Condition codes on branches |
Loop Unrolling: An Optimization Technique
Loop unrolling duplicates the loop body multiple times to reduce the overhead of the branch and counter-decrement instructions relative to actual work done:
; Instead of looping 4 times individually, do 4 units of work per iteration
mov rcx, 25 ; original 100 iterations / 4
unrolled_loop:
; body iteration 1
; body iteration 2
; body iteration 3
; body iteration 4
dec rcx
jnz unrolled_loop
This reduces the relative cost of the loop control instructions and can improve performance, especially when combined with instruction-level parallelism, at the cost of larger code size and more complex handling of “remainder” iterations when the total count isn’t evenly divisible.
Performance Considerations
- Branch prediction: Loops with predictable iteration counts (fixed-size loops) are predicted almost perfectly by modern CPUs, making the backward jump essentially free most of the time.
- Avoid
LOOPon x86: as mentioned, it’s a legacy instruction that’s often slower than a manualDEC/JNZpair on modern Intel and AMD chips. - Register allocation: keeping loop counters and accumulators in registers rather than memory avoids expensive memory round-trips on every iteration.
- Loop-invariant code motion: any calculation that doesn’t change between iterations should be computed once, before the loop, not recalculated every pass.
Debugging Loops in Assembly
When a loop misbehaves (infinite loop, off-by-one, wrong exit condition), here’s a practical checklist:
- Use a debugger (GDB, x64dbg, or OllyDbg) and set a breakpoint at the loop’s label.
- Step through one iteration and inspect the counter register and flags after the comparison/decrement instruction.
- Watch specifically for whether the zero flag or a register value is what you expect right before the conditional jump executes.
- Check whether you’re comparing the correct register — a very common bug is comparing a 32-bit alias (like
ECX) when you meant the full 64-bit register (RCX), or vice versa.
Common Mistakes
- Off-by-one errors: using
JGinstead ofJGE, or vice versa, shifting the loop bound by one. - Forgetting to update the counter: leads to infinite loops.
- Clobbering registers used by the loop condition inside the loop body without realizing it.
- Assuming
LOOPis fast: it’s a legacy convenience instruction, not a performance-friendly one on modern hardware. - Sign confusion: using signed conditional jumps (
JL,JG) on values meant to be treated as unsigned, or vice versa (JB,JA).
FAQs
Does assembly language have a native loop keyword? Not in the high-level sense. x86 has a LOOP instruction as a convenience, but loops are fundamentally built from comparisons and conditional jumps.
Why do compilers convert for loops into do-while style assembly? Because a do-while pattern needs fewer instructions per iteration (no upfront check), so compilers often add a single guard test before the loop and then emit a do-while-style loop body for efficiency.
Is LOOP in x86 faster than manual decrement and jump? No — on most modern x86 processors, LOOP is actually slower due to microarchitectural decisions in how it’s implemented, so compilers avoid it.
How do I write an infinite loop intentionally in assembly? Simply jump back unconditionally: spin: jmp spin in x86, or b spin in ARM.
Summary and Key Takeaways
- Loops in assembly are built from labels, arithmetic, condition flags, and conditional jumps — there’s no dedicated high-level loop construct.
- x86 offers a legacy
LOOPinstruction, but manualDEC/CMP+ conditional jump combos are more common and faster on modern hardware. - ARM ties flag updates directly into arithmetic instructions via the
Ssuffix, pairing naturally with conditional branches. - Nested loops require careful register management to avoid counter collisions.
- Loop unrolling is a common optimization technique that trades code size for reduced per-iteration overhead.
- Debugging loops mostly comes down to watching your counter register and flags right at the comparison and jump.
References
- Intel® 64 and IA-32 Architectures Software Developer’s Manuals — Intel Corporation
- AMD64 Architecture Programmer’s Manual — AMD
- ARM Architecture Reference Manual (ARMv7-A and ARMv8-A) — ARM Ltd.
- GNU Assembler (GAS) and Binutils Documentation — Free Software Foundation