Putting the Pieces Together

You now have all three ingredients: a cmp that sets the flags, a conditional jump that reads them, and an unconditional jmp that stitches blocks together. An if/else is exactly these three things arranged into a recognisable shape. Once you have seen the shape a few times, you can read any branch at a glance.

Let us build it up from the simplest case.

An if With No else

Start with a function that returns the absolute value of its argument:

int absValue(int n)
{
    if (n < 0)
        return -n;
    return n;
}

At -O0:

absValue(int):
    push rbp
    mov  rbp, rsp
    mov  DWORD PTR -4[rbp], edi
    cmp  DWORD PTR -4[rbp], 0
    jns  .L2
    neg  DWORD PTR -4[rbp]
.L2:
    mov  eax, DWORD PTR -4[rbp]
    pop  rbp
    ret

Read it as a unit. The source says "if n is negative, negate it". The compiler did the opposite: it compares n against 0 and uses jns ("jump if not negative") to skip over the body of the if. So the neg instruction, which is the return -n path, only runs when n is negative; otherwise jns jumps straight to .L2 and leaves n alone. The label .L2 marks "the code after the if".

This is the canonical pattern for an if with no else:

    cmp / test           ; evaluate the condition
    j<inverted> .Lafter  ; if condition is FALSE, skip the body
    ... body ...         ; the "then" block
.Lafter:                 ; execution continues here either way

The jump always tests the inverted condition, because its job is to jump past the body when the body should not run.

Adding the else

Now a real two-way branch:

int classify(int score)
{
    if (score >= 50)
        return 1;
    else
        return 0;
}
classify(int):
    push rbp
    mov  rbp, rsp
    mov  DWORD PTR -4[rbp], edi
    cmp  DWORD PTR -4[rbp], 49
    jle  .L2
    mov  eax, 1
    jmp  .L3
.L2:
    mov  eax, 0
.L3:
    pop  rbp
    ret

Trace the two paths:

  • Condition true (score >= 50): jle is not taken (the value is not <= 49), so we fall through to mov eax, 1 (the then block, returning 1). Then jmp .L3 leaps over the else block.
  • Condition false (score < 50): jle is taken, jumping to .L2, which runs mov eax, 0 (the else block, returning 0).

The shape of an if/else is therefore:

    cmp / test           ; evaluate the condition
    j<inverted> .Lelse   ; if FALSE, jump to the else block
    ... then block ...   ; runs when condition is true
    jmp .Lend            ; skip over the else block
.Lelse:
    ... else block ...   ; runs when condition is false
.Lend:

The two labels do all the work. .Lelse is where the false path lands; .Lend is where both paths reconverge. The lone jmp .Lend at the bottom of the then block is the giveaway that there is an else: a bare if (the previous example) has no such jump because there is no second block to skip.

Reading Direction: Source vs Assembly

The most disorienting thing for newcomers is that the order of blocks in the assembly does not always match the source. The then block usually comes first (it falls through), and the else block sits lower, reached by a taken jump. The compiler is free to lay the blocks out in whatever order produces the fewest or fastest jumps, so do not assume "first block in the listing" equals "first branch in the source". Follow the labels and the inverted condition instead, and you will always recover the original logic.

When the Branch Disappears Entirely

Sometimes there is no jump at all. Consider a max function:

int maxOf(int a, int b)
{
    if (a > b)
        return a;
    else
        return b;
}

At -O2, GCC produces this:

maxOf(int, int):
    cmp    edi, esi
    mov    eax, esi
    cmovge eax, edi
    ret

There is a cmp, but no conditional jump. Instead you see cmovge, a conditional move. It copies edi into eax only if the "greater than or equal" condition holds, and does nothing otherwise. The function first assumes the answer is b (mov eax, esi), then overwrites it with a if a >= b.

Why bother? A jump that the CPU guesses wrong is expensive, because the processor speculatively runs ahead and must throw away that work when the guess fails. A cmov has no branch to mispredict: it always executes, and simply chooses whether to keep the result. For small, unpredictable if/else blocks the optimizer often prefers this branchless form. Seeing cmov in a listing is your cue that an if/else was flattened into straight-line code, which is exactly the kind of transformation that makes reading optimized assembly worthwhile.

Key Takeaways

  • An if with no else is: cmp/test, a jump on the inverted condition that skips the body, then the after-label.
  • An if/else adds an else label and a trailing jmp at the end of the then block to reconverge at an end-label.
  • The conditional jump almost always tests the opposite of the source condition, because its purpose is to jump past the block that should not run.
  • Block order in assembly need not match source order; follow the labels and inverted condition, not physical position.
  • At higher optimization levels a small if/else can become branchless using cmov (conditional move), which has no jump to mispredict.