Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Assembly (x86-64, AArch64, RISC-V)  Module 6 – Branches, loops and jump tables

Loops: while, for and counting down

How while, for and do-while loops become compare-and-branch code on x86-64, AArch64 and RISC-V, with count-down and loop-rotation idioms.

  • Beginner
  • 15 minutes
  • Examples run with Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Translate while, do-while and for loops into assembly on three ISAs
  • Use count-down loops and flag-setting decrements to save instructions
  • Avoid off-by-one errors by writing the loop invariant down first
  • Read a -O2 compiler loop and recognise its rotated shape

Before you start

What you’ll learn · Before you start

What you will learn

  • Translate while, do-while and for loops into assembly on three ISAs
  • Use count-down loops and flag-setting decrements to save instructions
  • Avoid off-by-one errors by writing the loop invariant down first
  • Read a -O2 compiler loop and recognise its rotated shape

Before you start

On this page

strlen("hello") is a loop of four instructions that runs five times: load a byte, test it for zero, count it, jump back. On the sixth pass the byte is the terminator, the test is taken and the loop ends with 5. Whether the source says while, for or do-while, the machine code has the same parts: a label to come back to, a conditional branch that decides when to stop and a jump that closes the circle.

Each loop here comes in its x86-64, AArch64 and RISC-V form. Along the way you count down so that the decrement sets the flag the branch needs, write the loop’s invariant before its body to avoid off-by-one errors, and read the shape a compiler gives a loop at -O2.

A while loop is a test, a body and a jump back

A while loop tests before every pass, do-while tests after, and for is a while with a start value and a step folded in. In assembly the simplest form keeps the test at the top: load or compute what the condition needs, branch out of the loop if it fails, run the body, then jump back to the top. strlen in that shape, with the null terminator: A single byte of value 0 appended to a string to mark its end. The C library's strlen, puts and printf all walk forward until they meet one; a length-prefixed call like write(2) uses a separate length instead. More in “Endianness, strings and text in memory” as the stopping condition:

strlen_aarch64.s: the byte loop S · strlen_aarch64.s
// strlen_aarch64.s — count bytes before the NUL terminator (AArch64 Mac)
// Loop invariant: every byte in [str, str+len) is non-zero.
// Post-condition: byte at str+len is zero; len is the string length.

.global _my_strlen
.align 4

// _my_strlen: count bytes until the NUL terminator
// Input:  x0 = pointer to a NUL-terminated string
// Output: x0 = length (bytes before the terminator)
_my_strlen:
    mov     x1, #0              // len = 0
.Lloop:
    ldrb    w2, [x0, x1]        // load byte at str[len]
    cbz     w2, .Ldone          // if byte == 0, done
    add     x1, x1, #1          // len += 1
    b       .Lloop              // repeat
.Ldone:
    mov     x0, x1              // return len
    ret

The C equivalent is while (str[len] != 0) len++;. ldrb w2, [x0, x1] loads the byte at str + len, cbz leaves the loop when it is zero, add counts it, and b .Lloop goes back to the load. .Lloop has no .global, so it is local to its file: no other object file can refer to it. On ELF targets such as Linux the .L prefix also keeps it out of the symbol table: The list of labels an object file defines or references, with each label's name, the section it belongs to and the offset within that section. The linker reads it to match references between objects. More in “From source to executable: objects and linking”; Mach-O uses a plain L for that, so the macOS build still lists .Lloop as a non-external symbol. Step through the call on "hello":

Interactive · stage by stage strlen as a while loop, one byte at a time Step through strlen_aarch64.s on the string "hello", the first call of the run.
strlen as a while loop, one byte at a time, step 8 of 8. Showing mov x1, #0 (len = 0), ldrb w2, [x0, x1] (str[5] = 0), cbz w2, .Ldone, add x1, x1, #1 (len = 5), b .Lloop, .Ldone: mov x0, x1 (returns 5). In focus: .Ldone: mov x0, x1.byte == 0next bytemov x1, #0len = 0ldrb w2, [x0, x1]str[5] = 0cbz w2, .Ldonea zero byte: leaveadd x1, x1, #1len = 5b .Lloopalways: test again.Ldone: mov x0, x1returns 5

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Start with an empty count. x0 points at "hello" and x1, the count, starts at 0. No byte has been checked yet, so the range of checked bytes is empty.
  2. Load the next byte. The loop label .Lloop sits on the load. ldrb reads the byte at x0 + x1, str[0], which is h.
  3. The test falls through. cbz branches only when the byte is zero. h is not, so the branch is not taken and the processor goes on into the body.
  4. Count it. add x1, x1, #1 counts the byte: len is 1, and one byte is known to be non-zero.
  5. Jump back to the test. b .Lloop always jumps back to the load. Each pass of this loop runs two branches: the cbz that falls through and the b that is taken.
  6. Four more passes. e, l, l and o go the same way, and len reaches 5. Then ldrb loads str[5], the NUL terminator that ends the string.
  7. The zero byte leaves the loop. This time cbz is taken. It jumps past the body and the back jump, straight to .Ldone.
  8. Return the count. mov x0, x1 returns 5, the length the run printed for "hello". For the empty string the first cbz is already taken, and it returns 0.

The driver calls it on "hello" and on the empty string:

strlen_driver.c: two calls of my_strlen C · strlen_driver.c
// strlen_driver.c — call my_strlen and print the results
#include <stdio.h>
extern long my_strlen(const char *s);

int main(void) {
    printf("%ld\n", my_strlen("hello"));
    printf("%ld\n", my_strlen(""));
    return 0;
}
strlen on AArch64 Python · strlen_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# Assemble strlen_aarch64.s, link with C driver, run it.
# The driver calls my_strlen twice and prints "5" and "0".
session.run(r"""
clang -o strlen_prog strlen_aarch64.s strlen_driver.c
./strlen_prog
""")

Output

$ clang -o strlen_prog strlen_aarch64.s strlen_driver.c
$ ./strlen_prog
5
0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 strlen_session.py

It printed 5 and 0. The empty string never enters the body: the first cbz is taken at once. Each pass through the body, though, runs two branches, the cbz that falls through and the b that is always taken; the last section of this lesson shows how a compiler gets rid of one of them.

Key idea

Every loop compiles to a label, a test, a body and a branch. The test decides which pass is the last, and the branch ties the end of the body back to the label.

Checkpoint

Check: The shape of a loop in assembly

  1. Question 1 of 2 Which parts does the top-tested strlen loop have? Pick every one that appears in strlen_aarch64.s.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • An unconditional jump back
    • A conditional branch that leaves the loop
    • A label the loop jumps back to

    A label, a conditional exit, a body and a jump back: the four parts of every top-tested loop. In strlen the exit test needs no cmp, because cbz compares with zero as it branches.

  2. Question 2 of 2 The label .Lloop in strlen_aarch64.s starts with .L. What does that prefix do when the file is assembled for an ELF target such as Linux?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It keeps the label out of the object's symbol table, while branches inside the file still use it.

    Labels are names for addresses. Without .global a label is local to its file, and on ELF the .L prefix also keeps it out of the symbol table. Mach-O uses a plain L for that, so the macOS build still lists .Lloop as a non-external symbol.

Count down to zero, and let the subtraction set the flag

A loop that counts up from 0 to n needs a compare before its branch: nothing about i + 1 says whether i has reached n. Counting down from n to zero removes the compare, because the decrement itself can report the zero. That is the count-down idiom: A loop pattern that counts from n down to zero instead of from 0 up to n. The decrement sets the zero flag directly (subs on AArch64, dec on x86), saving a separate compare instruction..

AArch64’s subs subtracts and sets the condition flags: Single bits that an instruction sets to describe its result: zero, negative, carry and signed overflow. x86-64 keeps them in RFLAGS as ZF, SF, CF and OF, AArch64 in NZCV; RISC-V has none. More in “Flags and condition codes”; plain sub sets none. On x86-64, dec and sub both set the zero flag, so dec ecx and jnz close a loop. RISC-V has no flags: addi a0, a0, -1 and then bnez a0 branch on the register itself. x86-64 also has a loop instruction that decrements rcx and branches while it is not zero, but its offset is 8 bits, so its target must be within 128 bytes. sum_1_to_n adds n, n − 1 and so on down to 1:

sum_1_to_n_aarch64.s: a count-down loop S · sum_1_to_n_aarch64.s
// sum_1_to_n_aarch64.s — sum from 1 to n using a count-down loop (AArch64 Mac)
// Loop invariant: acc holds sum of all integers from (counter+1) to n.
// Post-condition: counter == 0, acc == n*(n+1)/2.

.global _sum_1_to_n
.align 4

// _sum_1_to_n: compute 1 + 2 + ... + n
// Input:  x0 = n (unsigned 64-bit)
// Output: x0 = sum (unsigned 64-bit)
// Uses count-down with subs to set the zero flag in one instruction.
_sum_1_to_n:
    mov     x1, #0              // acc = 0
    cbz     x0, .Lsum_done      // if n == 0, skip loop
.Lsum_loop:
    add     x1, x1, x0          // acc += counter
    subs    x0, x0, #1          // counter -= 1 and set flags
    b.ne    .Lsum_loop          // repeat if counter != 0
.Lsum_done:
    mov     x0, x1              // return acc
    ret

The loop is three instructions: add accumulates, subs decrements and sets the flags, b.ne repeats while the counter is not zero. The cbz x0, .Lsum_done above it handles n = 0, which must not run the body at all: with n = 0, subs would wrap the counter to the largest 64-bit value and the loop would run about 1.8 × 10¹⁹ times. Step through n = 3:

Interactive · stage by stage A count-down loop adds 3, 2 and 1 Step through sum_1_to_n_aarch64.s for n = 3 and watch the subtraction set the flag the branch reads.
A count-down loop adds 3, 2 and 1, step 8 of 8. Showing mov x1, #0, cbz x0, .Lsum_done, add x1, x1, x0 (acc = 6), subs x0, x0, #1 (counter 0, Z = 1), b.ne .Lsum_loop, mov x0, x1 (returns 6). In focus: mov x0, x1.mov x1, #0acc = 0cbz x0, .Lsum_donen == 0: skip the loopadd x1, x1, x0acc = 6subs x0, x0, #1counter 0, Z = 1b.ne .Lsum_loopZ = 0: go round againmov x0, x1returns 6

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Clear the sum. sum_1_to_n(3) starts with acc = 0 in x1. The counter is n itself, in x0.
  2. Guard against n = 0. cbz handles the one case where the loop must not run at all. n is 3, so it falls through into the loop.
  3. Add the counter. add x1, x1, x0 adds the counter to the sum: acc is 3.
  4. Subtract and set the flags. subs subtracts 1 and also sets the flags from the result. The counter becomes 2, which is not zero, so Z = 0.
  5. The branch goes back. b.ne reads Z, set by the subs a moment earlier. Z is 0, so it jumps back to add: no compare instruction was needed.
  6. The second and third passes. The next pass adds 2, so acc is 5, and the counter drops to 1. The third adds 1, so acc is 6, and subs leaves 0.
  7. Zero ends the loop. This time subs set Z = 1, so b.ne is not taken and falls through. Three passes ran, one per value of n.
  8. Return the sum. mov x0, x1 returns 6, which is 3 + 2 + 1 and n(n + 1)/2 for n = 3. The run checked the same formula for n = 100000.

sum_driver.c: n = 0, 1 and 100000 against n(n + 1)/2 C · sum_driver.c
// sum_driver.c — call sum_1_to_n and compare to formula
#include <stdio.h>
extern unsigned long sum_1_to_n(unsigned long n);

int main(void) {
    unsigned long tests[] = {0, 1, 100000};
    for (int i = 0; i < 3; i++) {
        unsigned long n = tests[i];
        unsigned long computed = sum_1_to_n(n);
        unsigned long expected = n * (n + 1) / 2;
        printf("n=%lu: sum=%lu, expected=%lu\n", n, computed, expected);
    }
    return 0;
}
sum_1_to_n on AArch64 Python · sum_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# Assemble sum_1_to_n_aarch64.s, link with C driver, run it.
# The driver tests n=0, n=1, n=100000 and compares to formula.
session.run(r"""
clang -o sum_prog sum_1_to_n_aarch64.s sum_driver.c
./sum_prog
""")

Output

$ clang -o sum_prog sum_1_to_n_aarch64.s sum_driver.c
$ ./sum_prog
n=0: sum=0, expected=0
n=1: sum=1, expected=1
n=100000: sum=5000050000, expected=5000050000

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sum_session.py

All three rows match the formula, including 5,000,050,000 for n = 100000, a sum too large for 32 bits.

Checkpoint

Check: Counting down to zero

  1. Question 1 of 2 Why does sum_1_to_n use subs x0, x0, #1 instead of sub x0, x0, #1?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: subs also sets the flags from the result, so b.ne can test the new counter without a compare.

    On AArch64 only the S forms write the flags. subs x0, x0, #1 decrements and sets Z when the counter reaches zero, which is all b.ne needs.

  2. Question 2 of 2 Which x86-64 pair closes a count-down loop on ecx with no compare instruction?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: dec ecx, then jnz back to the top

    dec writes ZF (it leaves only CF alone), so dec and jnz do what subs and b.ne do on AArch64. x86-64's loop instruction does the same in one instruction, but it can only reach targets within 128 bytes.

The loop invariant you write before you code

A loop invariant: A statement about a loop's variables that is true at the start of every iteration. Writing it down first helps avoid off-by-one errors by making the meaning of each variable explicit. is a claim about the loop’s variables that holds whenever control reaches the loop test. Writing it down before the body forces two decisions: what each variable means at the top of a pass, and what is true when the loop ends. Most off-by-one errors are a loop whose author never made them.

For strlen the invariant is: every byte in str[0] to str[len - 1] is non-zero, the half-open range [0, len). At the start len is 0 and the range is empty, so it holds. The next byte not yet covered is str[len], so that is the byte the body must load. If it is non-zero, len + 1 keeps the invariant true; if it is zero, the loop stops, and the invariant plus the zero byte say exactly that len is the length. The same reasoning fixes the count-down loop: at the top of each pass, acc holds the sum of every number above the counter, up to n.

The table follows the invariant through the six tests of strlen("hello"), the call the strlen session ran:

Test len Covered [0, len) Next byte str[len]
1 0 nothing yet h: go on
2 1 h e: go on
3 2 he l: go on
4 3 hel l: go on
5 4 hell o: go on
6 5 hello 0: stop, return 5

Counting before checking

Writing add x1, x1, #1 before the cbz makes the body count the terminator too, so strlen("hello") returns 6 and the empty string returns 1. With the invariant written down, the order follows from it: check str[len], the first byte the range does not cover, then grow the range.

Checkpoint

Check: The loop invariant

  1. Question 1 of 2 What is a loop invariant?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A claim about the loop's variables that holds whenever control reaches the loop test

    An invariant states what the variables mean at the top of every pass. Together with the failed test, it says exactly what is true when the loop ends.

  2. Question 2 of 2 strlen keeps the invariant "every byte of [0, len) is non-zero". Which byte must the next pass load?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: str[len], the first byte the range does not cover

    The half-open range [0, len) holds the checked bytes, so the next one is str[len]. If it is non-zero, len + 1 keeps the invariant true; if it is zero, len is the length.

Loop rotation moves the test to the bottom

The strlen loop runs two branches per pass. A compiler removes one with loop rotation: A compiler transformation that moves the loop test from the top to the bottom, with a one-time entry test that skips the loop if it should not run. This saves one branch per iteration when the loop runs at least once.: it turns the loop into a do-while, with the test after the body, so the jump back and the test become one conditional branch. LLVM’s documentation notes the catch: a do-while runs its body at least once, so unless the compiler can prove that n is at least 1 it adds a guard in front that skips the loop. loop_rotation.c sums an array, and clang compiled it at -O2:

loop_rotation.c: sum of n longs C · loop_rotation.c
// loop_rotation.c — a sum loop to show loop rotation
// This version takes a pointer to prevent the closed-form optimization.
long sum_array(long *arr, long n) {
    long acc = 0;
    for (long i = 0; i < n; i++) {
        acc += arr[i];
    }
    return acc;
}
sum_array as clang compiles it at -O2 Python · sum_O2_session.py
"""Compiles loop_rotation.c at -O2 and prints the function clang wrote, to see where the loop test went."""

import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session  # noqa: E402

session.run(r"""
# -fno-vectorize keeps one element per iteration, as in the C source, so the loop stays easy to read
clang -O2 -fno-vectorize -S -o loop_rotation.s loop_rotation.c
# Print the function only, from its label to the end of its code
sed -n '/^_sum_array:/,/cfi_endproc/p' loop_rotation.s
""")

Output

# -fno-vectorize keeps one element per iteration, as in the C source, so the loop stays easy to read
$ clang -O2 -fno-vectorize -S -o loop_rotation.s loop_rotation.c
# Print the function only, from its label to the end of its code
$ sed -n '/^_sum_array:/,/cfi_endproc/p' loop_rotation.s
_sum_array:                             ; @sum_array
	.cfi_startproc
; %bb.0:
	cmp	x1, #1
	b.lt	LBB0_4
; %bb.1:
	mov	x8, #0                          ; =0x0
LBB0_2:                                 ; =>This Inner Loop Header: Depth=1
	ldr	x9, [x0], #8
	add	x8, x9, x8
	subs	x1, x1, #1
	b.ne	LBB0_2
; %bb.3:
	mov	x0, x8
	ret
LBB0_4:
	mov	x8, #0                          ; =0x0
	mov	x0, x8
	ret
	.cfi_endproc

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sum_O2_session.py

The guard is cmp x1, #1 and b.lt LBB0_4, which returns 0 for an empty array. Past it, the loop starting at LBB0_2 has the body first, ldr x9, [x0], #8 and add x8, x9, x8, and the test last: subs x1, x1, #1 and b.ne LBB0_2. clang also turned the count up of the C source into a count down of x1, the idiom of the previous section. Step through it for two elements, then for none:

Interactive · stage by stage The loop clang wrote at -O2, test at the bottom Step through the recorded sum_array code for an array of n = 2 elements, then for n = 0.
The loop clang wrote at -O2, test at the bottom, step 7 of 7. Showing cmp x1, #1, b.lt LBB0_4, mov x8, #0, ldr x9, [x0], #8, add x8, x9, x8, subs x1, x1, #1, b.ne LBB0_2, mov x0, x8, then ret, LBB0_4: return 0. In focus: b.lt LBB0_4, LBB0_4: return 0.cmp x1, #1is n at least 1?b.lt LBB0_4n < 1: no iteration at allmov x8, #0acc = 0ldr x9, [x0], #8LBB0_2: next elementadd x8, x9, x8acc += elementsubs x1, x1, #1n - 1, sets Zb.ne LBB0_2elements left: go roundmov x0, x8, then retreturn accLBB0_4: return 0the guard exitn = 0

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A guard, once. The C for loop tests i < n before every pass. clang tests n once, at the top: cmp x1, #1 asks whether there is any element at all.
  2. The guard falls through. With n = 2, b.lt is not taken, and execution falls into the loop. This guard is the price of moving the test.
  3. The body comes first. The loop starts with the work: ldr loads an element and moves the pointer on by 8 bytes, and add adds it to acc.
  4. The test is at the bottom. subs counts n down and sets Z, and b.ne jumps back while elements remain. The test sits after the body, as in a do-while.
  5. One branch per pass. Each pass runs one branch, the b.ne. The top-tested strlen loop ran two per pass: a cbz that fell through and a b back.
  6. The last pass falls through. After the second element subs leaves 0, b.ne falls through, and mov x0, x8 returns the sum.
  7. An empty array. With n = 0 the guard is taken: b.lt jumps to LBB0_4, which returns 0 without running the body once.

For an array of 1,000 elements the top-tested shape runs 2,000 branches in the loop plus the final exit test; the rotated one runs 1,000 and the guard once.

Key idea

Loop rotation puts the test at the bottom, so each pass runs one branch instead of two. A guard in front keeps a loop that must not run at all from running once.

Checkpoint

Check: Loop rotation

  1. Question 1 of 2 What does loop rotation save in a loop that runs many times?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One branch on every pass, because the test and the jump back become one conditional branch

    A top-tested loop runs a test and a jump back per pass. Rotated, the test sits after the body and is itself the jump back: for 1,000 elements, 1,000 branches and a guard instead of about 2,000.

  2. Question 2 of 2 In the -O2 sum_array, what are cmp x1, #1 and b.lt LBB0_4 for?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They skip the loop when n < 1, because a do-while body would otherwise run once on an empty array.

    Rotation is safe only when the body runs at least once. clang cannot prove that n is at least 1, so it adds the guard, which returns 0 for an empty array.

The same byte loop on x86-64 and RISC-V

strlen on the other two ISAs has the same four parts. x86-64 can compare a byte in memory with zero in one instruction; RISC-V, a load-store ISA, loads it first and needs an add to form the address. The x86-64 file is in AT&T syntax, where the source operand comes first:

strlen on x86-64 and RISC-V

x86-64 (AT&T) · strlen_x86_64.s

# strlen_x86_64.s: count bytes before the NUL terminator (x86-64 Linux, GNU as syntax).
# Loop invariant: every byte in [rdi, rdi+rcx) is non-zero.
# Post-condition: byte at rdi+rcx is zero; rcx is the string length.
        .text
        .globl  strlen
strlen:
        xorl    %ecx, %ecx          # len = 0
.Lloop:
        cmpb    $0, (%rdi, %rcx)    # compare byte at str[len] with 0
        je      .Ldone              # if zero, done
        incq    %rcx                # len += 1
        jmp     .Lloop              # repeat
.Ldone:
        movq    %rcx, %rax          # return len in rax
        ret

RISC-V · strlen_riscv64.s

# strlen_riscv64.s: count bytes before the NUL terminator (RISC-V 64, GNU as syntax).
# Loop invariant: every byte in [a0, a0+a1) is non-zero.
# Post-condition: byte at a0+a1 is zero; a1 is the string length.
        .text
        .globl  strlen
strlen:
        li      a1, 0               # len = 0
.Lloop:
        add     a2, a0, a1          # compute address str + len
        lbu     a3, 0(a2)           # load byte at str[len]
        beqz    a3, .Ldone          # if zero, done
        addi    a1, a1, 1           # len += 1
        j       .Lloop              # repeat
.Ldone:
        mv      a0, a1              # return len in a0
        ret
Assemble both and list their machine code Python · cross_strlen_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# Assemble strlen for x86-64 Linux and RISC-V Linux with LLVM, then list the machine code of each object:
# the instruction encodings of the same byte loop on two more ISAs.
session.run(r"""
# x86-64: LLVM clang cross-assembles to ELF object
/opt/homebrew/opt/llvm/bin/clang -target x86_64-linux-gnu -c -o strlen_x86_64.o strlen_x86_64.s
/opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr strlen_x86_64.o
""")

session.run(r"""
# RISC-V 64: LLVM clang cross-assembles to ELF object
/opt/homebrew/opt/llvm/bin/clang -target riscv64-linux-gnu -c -o strlen_riscv64.o strlen_riscv64.s
/opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr strlen_riscv64.o
""")

Output

# x86-64: LLVM clang cross-assembles to ELF object
$ /opt/homebrew/opt/llvm/bin/clang -target x86_64-linux-gnu -c -o strlen_x86_64.o strlen_x86_64.s
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr strlen_x86_64.o

strlen_x86_64.o:	file format elf64-x86-64

Disassembly of section .text:

<strlen>:
 31 c9                                 	xorl	%ecx, %ecx
 80 3c 0f 00                           	cmpb	$0x0, (%rdi,%rcx)
 74 05                                 	je	0xd <strlen+0xd>
 48 ff c1                              	incq	%rcx
 eb f5                                 	jmp	0x2 <strlen+0x2>
 48 89 c8                              	movq	%rcx, %rax
 c3                                    	retq
# RISC-V 64: LLVM clang cross-assembles to ELF object
$ /opt/homebrew/opt/llvm/bin/clang -target riscv64-linux-gnu -c -o strlen_riscv64.o strlen_riscv64.s
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr strlen_riscv64.o

strlen_riscv64.o:	file format elf64-littleriscv

Disassembly of section .text:

<strlen>:
 4581                  	li	a1, 0x0

<.Lloop>:
 00b50633              	add	a2, a0, a1
 00064683              	lbu	a3, 0x0(a2)
 c281                  	beqz	a3, 0xa <.Lloop+0x8>
 0585                  	addi	a1, a1, 0x1
 a001                  	j	0xe <.Lloop+0xc>

<.Ldone>:
 852e                  	mv	a0, a1
 8082                  	ret

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cross_strlen_session.py

On x86-64 the loop is cmpb $0x0, (%rdi,%rcx), je, incq and jmp: compare, leave if zero, count, jump back. On RISC-V it is add, lbu, beqz, addi and j. LLVM’s assembler chose 2-byte compressed encodings for most of them, such as c281 for the beqz, because the target it assembled for includes the C extension; the add and the lbu kept their 4-byte forms. The structure is the same on all three; only the instructions that do each part differ.

Checkpoint

Check: The byte loop on x86-64 and RISC-V

  1. Question 1 of 2 In the x86-64 strlen loop, what does cmpb $0, (%rdi,%rcx) do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It compares the byte at rdi + rcx with zero and sets the flags, without loading it into a register.

    In AT&T syntax the immediate $0 comes first and the memory operand second. The compare reads the byte, works out byte − 0 and sets ZF when the byte is zero, for je to read.

  2. Question 2 of 2 Why does the RISC-V strlen loop need add and lbu before its beqz, where x86-64 needs one cmpb?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: RISC-V branches compare registers only, and its loads take a register plus a constant, so it forms the address, loads, then tests.

    No RISC-V instruction compares memory with zero, and lbu adds only a 12-bit constant to a register, so the loop computes str + len with add, loads with lbu, and branches on the register with beqz.

Practise

  1. Copy strlen_aarch64.s and rotate it by hand: test once before the loop, put the body first, and end the loop with a cbnz back to the load. Run it on "hello" and "" with the commands of the strlen session and check that it still prints 5 and 0.
  2. Copy sum_1_to_n_aarch64.s and delete the cbz x0, .Lsum_done line. Predict what the program does for n = 0 before you run it. If it has not finished after a few seconds, stop it with Ctrl+C and explain from subs why.

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 Where does a do-while loop test its condition?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: After the body, so the body always runs at least once

    while tests before every pass and may run zero times; do-while tests after and runs at least once; for is a while with a start value and a step.

  2. Question 2 of 11 How many branch instructions does the top-tested strlen_aarch64.s run for "hello", counting the final cbz that is taken?

    Type a number.

    A hint

    Each of the five passes runs a cbz and a b; then one more cbz.

    Show the answer: Question 2 of 11

    Answer: 11 branches

    Five passes of two branches each, a cbz that falls through and a b back, make 10; the sixth cbz, on the terminator, is taken and ends the loop: 11.

  3. Question 3 of 11 for (i = 0; i < n; i++) body; is written top-tested in assembly. Where does i++ go?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: At the end of the body, just before the jump back to the test

    The start value goes before the label, the test at the label, then the body, then the step, then the jump back. That is exactly the while form with two lines added.

  4. Question 4 of 11 What would sum_1_to_n do for n = 0 without its cbz x0, .Lsum_done line?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Wrap the counter to 0xffffffffffffffff and loop about 1.8 × 10^19 times

    A count-down loop tests after the decrement, so a counter that starts at 0 skips zero and wraps. The guard in front handles the one case of no passes.

  5. Question 5 of 11 RISC-V has no flags. How does it close a count-down loop on a0?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: addi a0, a0, -1, then bnez a0 back to the top

    The decrement is an ordinary addi, and bnez, a pseudo-instruction for bne with the zero register, branches on the result. Two instructions, as on the flag ISAs.

  6. Question 6 of 11 A strlen body runs add x1, x1, #1 before its cbz. What does it return for "hello"?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: 6

    Counting before checking breaks the invariant: after the add, [0, len) includes the byte not yet checked. The terminator is counted, so "hello" gives 6 and the empty string gives 1.

  7. Question 7 of 11 At the top of each pass of sum_1_to_n, which statement holds?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: acc is the sum of every number above the counter, up to n.

    With counter c, acc = (c + 1) + … + n. When c reaches 0 the invariant says acc = 1 + … + n, which is the result the run printed.

  8. Question 8 of 11 Into what does loop rotation turn a while loop?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: A do-while loop, with a guard in front when the body may not run at all

    The test moves after the body, which is the do-while shape. The guard keeps n = 0 from running the body once.

  9. Question 9 of 11 How many branch instructions does the rotated sum_array run for an array of 1,000 elements, the guard included?

    Type a number.

    A hint

    One per pass, plus the guard.

    Show the answer: Question 9 of 11

    Answer: 1001 branches

    The guard runs once and falls through; then each of the 1,000 passes ends with one b.ne, the last one not taken: 1,001. The top-tested shape would run 2,001.

  10. Question 10 of 11 How many instructions does each pass of the x86-64 strlen loop run?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: Four, cmpb, je, incq and jmp

    x86-64 can compare memory with an immediate, so its loop is one instruction shorter than RISC-V's, which must form the address and load first.

  11. Question 11 of 11 The RISC-V listing shows c281 for the beqz, but 00064683 for the lbu. Why the different lengths?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: The target includes the C extension, and the assembler used its 2-byte forms where one exists for the operands.

    With the C extension a RISC-V assembler may pick a 16-bit encoding for common instructions with suitable operands. The listing shows both widths side by side.

Interview questions

What is a loop invariant, and how does writing it down before you code change the first line of the loop body?

It is a claim that holds whenever control reaches the loop test, and it pins down what each variable means. For strlen, “every byte of [0, len) is non-zero” says that len counts checked bytes, so the first line of the body must load str[len], the first byte outside the range. Counting first, or loading str[len - 1], breaks the statement on the very first pass, which is why the off-by-one never gets written. When the loop stops, the invariant and the failed test together give the result: the bytes before len are non-zero and str[len] is zero.

Two harder questions on loops follow, one about a hot loop that lost its vector code and one about a count-down loop that broke on an empty buffer:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A release binary's top performance sample is inside a byte-copying loop that used to be compiler-vectorised. The loop now runs one byte at a time. What could have changed to make the compiler fall back to scalar code, and how would you confirm your hypothesis from the disassembly?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A hand-written AArch64 copy loop counts its byte count down with subs x2, x2, #1 and b.ne. It works in every test, then hangs in production on an empty buffer. Explain what happens, fix it, and say how the same bug looks on x86-64 and RISC-V.

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What four parts does every compiled loop have?

    A label, a test, a body and a branch back to the label. A while, a do-while and a for loop all reduce to the same parts; the test decides which iteration is the last.

  2. Why do loops that count down to zero often need no compare instruction?

    The decrement itself sets the zero flag: subs then b.ne on AArch64, dec then jnz on x86-64. RISC-V has no flags but branches on the counter register directly with bnez.

  3. Why does a count-down loop need a guard for n = 0?

    Its first decrement would turn 0 into the largest unsigned value, and the loop would run billions of times instead of not at all. The guard skips the loop when there is nothing to do.

  4. How does writing the loop invariant first prevent off-by-one errors?

    It fixes what each variable means. For strlen, every byte of [0, len) is non-zero says len counts checked bytes, so the body must load str[len] first. When the loop stops, the invariant plus the failed test give the result.

  5. What is loop rotation, and why does -O2 code do it?

    The compiler moves the test to the bottom of the loop, so each iteration runs one branch instead of two, and puts a guard in front so that a loop that should run zero times does not run once.

Key takeaways

  • Every loop compiles to a label, a test, a body and a branch back, whichever of while, do-while or for the source used.
  • Counting down lets the decrement set the zero flag, so subs and b.ne on AArch64 or dec and jnz on x86-64 close the loop without a compare, while RISC-V branches on the counter register with bnez.
  • A count-down loop needs a guard for n = 0, because a decrement from 0 wraps around instead of stopping.
  • Writing the loop invariant first decides what the counter means and which element the body touches first, which removes most off-by-one errors.
  • At -O2 the compiler rotates a loop so that the test is at the bottom and each pass runs one branch, with a guard in front for the case of no passes at all.

Terms in this lesson

count-down idiom
A loop pattern that counts from n down to zero instead of from 0 up to n. The decrement sets the zero flag directly (subs on AArch64, dec on x86), saving a separate compare instruction.
loop invariant
A statement about a loop's variables that is true at the start of every iteration. Writing it down first helps avoid off-by-one errors by making the meaning of each variable explicit.
loop rotation
A compiler transformation that moves the loop test from the top to the bottom, with a one-time entry test that skips the loop if it should not run. This saves one branch per iteration when the loop runs at least once.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.