x86-64 assembly from the ground up

Loops with a counter

A loop is a label and a jump back to it, plus a counter that says when to stop. The chapter also covers the register the kernel quietly steals.

Part 6 of 12

Intermediate15 min readBack to Courses

A jump backwards

Nothing in the machine repeats. There is only the instruction pointer moving forward, one instruction at a time, and a jump that sets it to an address behind the current one. That is a loop: a label, the body, and a conditional jump back up to the label.

The counter does two jobs. It is the work the loop is doing — the third line, the fifth byte — and it is also the thing the jump tests, so the loop knows when to stop. In assembly those two jobs share one register, and remembering to change it is entirely your problem.

Counting down sets the flag for free

dec ebx subtracts one and sets the zero flag on the way past, so jnz .next immediately after it needs no comparison: the flags already say whether the count reached zero. Counting down saves an instruction over counting up, where the usual shape is an inc and a cmp against the limit.

There is also a loop instruction, which subtracts one from rcx and jumps back while it is not zero, in a single line. It is compact and rarely the fastest, and it insists on rcx, which is a problem in a moment.

The register the kernel borrows

A syscall in the middle of a loop is normal — printing inside the body — but the kernel overwrites rcx and r11 on every call. A counter kept in rcx therefore returns as something unrelated after the first line of output, and the loop either stops at once or never stops.

The fix is habit rather than cleverness: put the counter in a register the call leaves alone, rbx or r12 or r8. Saving it with push and pop is not needed inside one function, only when a caller expects its own values back.

Two ways to go round

mov ebx, 3        ; three times round
.next:
  ; the body of the loop goes here
  dec ebx         ; one fewer, and the zero flag follows
  jnz .next       ; jump back while it is not zero

mov rcx, 3
.back:
  ; the body again, this time counted by loop
  loop .back      ; dec rcx, then jump if it is not zero

Print the same line three times

Run it, then change the three at the top to five. Look at where the counter lives: not in `rcx`, because the write call would wipe it out.

Output
Click "Run" to execute your code

A loop whose counter is never changed runs until the page gives up on it, and the output pane shows nothing because nothing has finished. When a run hangs, read the body again and look for the line that should have changed the count.

The body always runs once

The count-down shape is a do-while, not a while. The label is at the top, the body runs, and only then does dec change the counter and set the zero flag. A counter of three therefore produces three passes, and a counter of one still produces one pass before the jump notices the zero.

That is convenient for printing a line a fixed number of times, and it is a trap when the count might be zero. Starting at zero and running the same shape produces a counter that wraps to minus one and keeps going, because the zero flag is never set on the way down. Check the count before the label if zero is a legal input.

The off-by-one question is always the same: which value does the counter hold when the body starts, and which value does it hold when the body ends? Write those two numbers down and the loop stops being mysterious.

Why not just use loop

loop packs the decrement and the jump into one instruction, but it insists on rcx and it only tests for zero. It also has a reputation for being slower than the two-instruction form on modern processors, because the hardware no longer treats it as a special case worth optimising.

dec and jnz are two lines, but they work with any register and they leave the flags visible for another branch if the body needs one. That flexibility is why compilers and hand-written code both tend to spell the loop out.

The one rule that matters either way is the register choice. The kernel overwrites rcx and r11 on every call, so a counter that lives across a syscall has to be somewhere else, such as rbx or r8.

Three loop failures. A counter kept in rcx changes under a write call. A body that never touches the counter runs forever. And a dec placed before the body instead of after it changes the value the body sees, which matters the moment the body prints or uses the count.

Try these next

  • Print a row of twelve stars with a loop that writes one byte each time.
  • Move the newline write inside the loop and explain why the output becomes a column.
  • Count down from five and print the counter as a character after adding forty-eight.

A row of stars, one byte at a time

Run it, then change the five to twelve. The counter lives in `rbx`, and the newline is written after the loop so it appears once rather than after every star.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What makes a loop a loop in assembly?