How Are Loops Implemented in Assembly Language? A Complete Guide From Beginner to Advanced

How are loops implemented in Assembly language

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:

  1. A label marking the start of the loop body
  2. The loop body instructions
  3. A comparison against some condition
  4. 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 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

Featurex86/x86-64ARM (32-bit)ARM64
Dedicated loop instructionLOOP (legacy, slow)NoneNone
Common patternDEC + JNZ/CMP + JccSUBS + BccSUBS + B.cc
Flag-setting arithmeticExplicit via CMP/TESTBuilt into S suffixBuilt into S suffix
Conditional branch styleSeparate jump mnemonics (JE, JG, etc.)Condition codes on branchesCondition 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

Debugging Loops in Assembly

When a loop misbehaves (infinite loop, off-by-one, wrong exit condition), here’s a practical checklist:

  1. Use a debugger (GDB, x64dbg, or OllyDbg) and set a breakpoint at the loop’s label.
  2. Step through one iteration and inspect the counter register and flags after the comparison/decrement instruction.
  3. Watch specifically for whether the zero flag or a register value is what you expect right before the conditional jump executes.
  4. 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

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

References

Exit mobile version