Every if statement, every while loop, every switch case in a high-level language eventually gets boiled down to one thing at the hardware level: conditional branching. When I started reverse-engineering small binaries and writing Assembly by hand, understanding conditional branching was the single most useful skill I picked up — it’s the mechanism that gives programs the ability to make decisions.
In this post, I’ll walk through exactly how conditional branching works under the hood, from flag registers to jump instructions, comparing x86/x86-64 and ARM approaches along the way.
What Is Conditional Branching?
Conditional branching is the process by which a CPU decides, based on some condition, whether to continue executing instructions sequentially or to jump to a different location in memory. Unlike an unconditional jump (jmp on x86, B on ARM), which always transfers control, a conditional branch only transfers control if a specific condition evaluates as true.
This is the foundation of every control-flow construct: if/else, loops, switch statements, and short-circuit boolean logic all compile down to sequences of comparisons and conditional branches.
The Role of Flags in Conditional Branching
On x86 and x86-64, conditional branching is tightly coupled to the FLAGS register (EFLAGS/RFLAGS). Certain instructions — most commonly cmp and test — update specific bits in this register based on the result of an operation, without necessarily storing that result anywhere else.
Key flags involved in conditional branching:
| Flag | Name | Meaning |
|---|---|---|
| ZF | Zero Flag | Set when result of last operation is zero |
| SF | Sign Flag | Set when result is negative (MSB = 1) |
| CF | Carry Flag | Set on unsigned overflow / borrow |
| OF | Overflow Flag | Set on signed overflow |
| PF | Parity Flag | Set when result has even number of set bits |
A cmp a, b instruction internally performs a - b and sets these flags based on the result, without modifying a. The conditional jump instruction that follows then checks specific flag combinations.
ARM works similarly but calls this the condition flags within the Processor State (PSTATE) register (NZCV: Negative, Zero, Carry, oVerflow). Most ARM data-processing instructions can optionally update these flags when suffixed with an S (e.g., SUBS), and CMP always updates them.
Common Conditional Jump/Branch Instructions
x86 / x86-64
cmp eax, ebx
je equal_label ; jump if equal (ZF = 1)
jne not_equal_label ; jump if not equal (ZF = 0)
jg greater_label ; jump if greater (signed)
jl less_label ; jump if less (signed)
jge greater_equal_label
jle less_equal_label
ja above_label ; jump if above (unsigned)
jb below_label ; jump if below (unsigned)
ARM (AArch64)
CMP X0, X1
B.EQ equal_label ; branch if equal
B.NE not_equal_label ; branch if not equal
B.GT greater_label ; branch if greater (signed)
B.LT less_label ; branch if less (signed)
B.GE greater_equal_label
B.LE less_equal_label
ARM’s condition codes (EQ, NE, GT, LT, GE, LE, HI, LO, and so on) are appended directly to the branch mnemonic, which is a slightly different style from x86’s dedicated jump mnemonics, but the underlying logic — checking flag bits — is conceptually identical.
Step-by-Step: How a Conditional Branch Actually Executes
Let’s trace what happens internally when the CPU processes a simple comparison and conditional jump.
- Fetch: The CPU fetches the
cmpinstruction from memory. - Decode: The instruction decoder recognizes this as a comparison operation.
- Execute: The ALU computes
a - binternally (the result is discarded, but the flags register is updated based on it). - Fetch next instruction: The CPU fetches the conditional jump instruction (e.g.,
jg). - Decode & Evaluate condition: The decoder checks the relevant flag bits associated with that specific jump condition (for
jg, it checks a combination of ZF, SF, and OF). - Branch decision: If the condition holds, the program counter is updated to the target address. If not, execution simply continues to the next sequential instruction.
flowchart TD
A[Fetch CMP instruction] --> B[Decode CMP]
B --> C[ALU computes a - b]
C --> D[Update FLAGS: ZF, SF, OF, CF]
D --> E[Fetch conditional jump instruction]
E --> F[Decode jump condition]
F --> G{Condition true?}
G -->|Yes| H[Update Program Counter to target]
G -->|No| I[Continue to next sequential instruction]
A Complete Example: Translating an if/else
Here’s how a simple C-like construct translates into Assembly:
if (x > 10) {
y = 1;
} else {
y = 0;
}
x86-64 equivalent:
cmp eax, 10 ; compare x (in eax) to 10
jg set_one
mov ebx, 0 ; y = 0
jmp end_if
set_one:
mov ebx, 1 ; y = 1
end_if:
ARM64 equivalent:
CMP X0, #10
B.GT set_one
MOV X1, #0
B end_if
set_one:
MOV X1, #1
end_if:
Notice both follow the same logical shape: compare, branch conditionally, and have an explicit unconditional jump to skip the “else” branch when the “if” branch executes.
Loops Are Just Conditional Branches in Disguise
A while loop is really nothing more than a conditional branch that jumps backward:
mov ecx, 0
loop_start:
cmp ecx, 10
jge loop_end
; loop body here
inc ecx
jmp loop_start
loop_end:
This is why understanding conditional branching is so foundational — once you get it, loops, if/else chains, and even switch-case jump tables all become variations on the same basic mechanism.
Implementing a Switch-Case with Conditional Branches
Higher-level switch statements are also built from conditional branches, though compilers often optimize dense, contiguous cases into a jump table rather than a long chain of comparisons. Here’s how a small switch might look as a chain of conditional branches on x86-64:
switch (x) {
case 1: y = 10; break;
case 2: y = 20; break;
default: y = 0;
}
cmp eax, 1
je case_1
cmp eax, 2
je case_2
jmp default_case
case_1:
mov ebx, 10
jmp switch_end
case_2:
mov ebx, 20
jmp switch_end
default_case:
mov ebx, 0
switch_end:
When the case values are dense and contiguous (e.g., 1 through 20), compilers instead generate a jump table — an array of addresses indexed by the switch value, followed by a single indirect jump:
; assume eax already validated to be in range [0, N)
lea rdx, [jump_table]
mov rcx, [rdx + rax*8] ; load target address from the table
jmp rcx ; indirect jump to the matching case
This avoids a long chain of sequential comparisons, trading it for a single computed (indirect) jump — a good example of how conditional branching concepts scale up into more advanced control-flow constructs.
Short-Circuit Boolean Logic as Conditional Branching
Logical operators like && and || in C also compile down to conditional branches rather than always evaluating both sides, which is exactly why “short-circuit evaluation” exists as a language guarantee:
if (a != 0 && b / a > 2) { ... }
cmp eax, 0
je skip_check ; if a == 0, skip evaluating b/a entirely
; ... evaluate b/a > 2 here ...
skip_check:
This is a direct, practical consequence of conditional branching: the second condition is genuinely never evaluated in the generated machine code if the first one fails, which matters both for correctness (avoiding division by zero) and performance (avoiding unnecessary work).
Conditional Branching in ARM’s Predicated Instructions
ARM (particularly AArch32) has an interesting historical feature where almost any instruction, not just branches, can be conditionally executed by appending a condition code directly to the instruction mnemonic:
CMP R0, #0
ADDEQ R1, R1, #1 ; only execute this ADD if the previous CMP set the EQ condition
This is called predicated execution, and it allows short conditional operations to avoid a branch entirely — which is useful because it sidesteps branch misprediction penalties for very short conditional blocks. AArch64 scaled this back significantly (predication is mostly limited to a handful of instructions like CSEL, CSET, and CINC), favoring explicit branches for most conditional logic, but the underlying idea — conditionally executing based on flags without necessarily branching — is a close cousin of the branchless techniques described below.
Branch Prediction and Performance
Modern CPUs use branch prediction to guess, ahead of time, whether a conditional branch will be taken, so they can speculatively fetch and execute instructions down that path before the condition is actually resolved. When the prediction is correct, this hides the cost of branching almost entirely. When it’s wrong, the CPU has to flush the speculatively executed instructions and restart — a branch misprediction penalty, which can cost anywhere from a handful to over a dozen clock cycles depending on the pipeline depth.
This has real practical implications:
- Loops with predictable patterns (e.g., always running a fixed number of times) branch-predict very well.
- Data-dependent branches with unpredictable outcomes (e.g., branching based on random input) can hurt performance significantly.
- Some performance-critical code avoids branching altogether using branchless techniques (e.g., conditional moves like
cmovgon x86, orCSELon ARM), which compute both outcomes and select the correct one without a jump.
; Branchless max(a, b) on x86-64
cmp eax, ebx
cmovl eax, ebx ; if eax < ebx, move ebx into eax
Conditional Branching in Loop Optimization
Compilers frequently restructure loops specifically to make conditional branching cheaper or more predictable. One common technique is loop inversion, which converts a while loop (which checks its condition before every iteration, including potentially zero times) into a do-while style loop guarded by a single upfront check:
; Original while-style structure
loop_check:
cmp ecx, 10
jge loop_end
; body
inc ecx
jmp loop_check
loop_end:
; Inverted do-while style (fewer branches per iteration in the common case)
cmp ecx, 10
jge loop_end
loop_body:
; body
inc ecx
cmp ecx, 10
jl loop_body
loop_end:
The inverted version still has a conditional check, but restructured so the “loop again” branch is the one evaluated every iteration (and is far more likely to be predicted correctly as “taken” for most of the loop’s duration), while the “did we even need to enter the loop” check happens only once, upfront. This kind of restructuring is exactly the sort of thing an optimizing compiler does automatically, but it’s useful to recognize in disassembled output, since it can otherwise look like an unnecessary duplication of the comparison.
Conditional Branching and Instruction-Level Parallelism
On superscalar CPUs capable of executing multiple instructions per cycle, conditional branches act as natural boundaries for how far ahead the CPU can speculate. Basic blocks — straight-line sequences of instructions with no branches in or out except at the very start and end — are the fundamental unit that instruction schedulers and compilers reason about when deciding how to reorder or parallelize execution. Understanding where your conditional branches fall directly tells you where these basic block boundaries are, which is useful context when reading a CPU’s performance profiling data or a compiler’s optimization report, since these tools often report statistics per basic block.
Debugging Conditional Branches
When I’m debugging in GDB, one of the most useful habits is stepping through a cmp instruction and immediately checking the flags register (info registers eflags) before the following jump executes. This tells me exactly which way the branch is about to go, which is invaluable when a program is behaving unexpectedly and I need to know why a condition evaluated the way it did.
Conditional Branching Across Function Boundaries
It’s worth noting that conditional branches are almost always confined within a single function — jumping into the middle of a different function using a conditional branch is both architecturally unusual and generally considered bad practice, since it bypasses the normal function-call mechanism (stack frame setup, argument passing conventions) entirely. Legitimate conditional control transfer between functions instead happens through ordinary conditional logic that decides whether to issue a call/BL in the first place, not through a conditional jump landing inside another function’s body. Recognizing this convention is useful when reading disassembly, since a conditional jump target landing outside the current function is often a strong signal of either compiler-generated tail-call optimization or, less innocently, of obfuscated or malicious code deliberately breaking normal control-flow conventions.
Common Mistakes
- Signed vs. unsigned confusion: Using
jg/jl(signed) when the data is unsigned, or vice versa, leads to subtly wrong branching, especially near boundary values. - Forgetting that
cmpdoesn’t store a result — some beginners expectcmpto modify a register, but it only affects flags. - Overlooking flag-clobbering instructions: Inserting an instruction between
cmpand the conditional jump that unintentionally modifies flags can break the branch logic. - Off-by-one errors in loop bounds, often caused by choosing the wrong conditional jump variant (
jgevsjg).
Best Practices
- Keep the
cmp/testinstruction immediately before the conditional jump that depends on it, to avoid flag-clobbering bugs. - Prefer branchless code (
cmov/CSEL) in tight, performance-critical loops with unpredictable conditions. - Use clear, descriptive labels (
loop_start,is_valid,end_if) rather than numeric labels, for readability. - When in doubt about signed vs. unsigned comparisons, check the flags being tested by the specific jump instruction you’re using.
FAQs
What’s the difference between cmp and test? cmp a, b computes a - b and sets flags accordingly. test a, b computes a AND b and sets flags based on that — commonly used to check if specific bits are set, or if a value is zero.
Why does x86 have separate signed and unsigned jump instructions? Because comparing signed and unsigned numbers requires interpreting the same bit pattern differently. jg/jl check sign-aware flag combinations, while ja/jb check the carry flag, which reflects unsigned overflow/borrow.
Does ARM handle conditional branching differently from x86? Conceptually, no — both rely on flags set by comparison instructions. Syntactically, ARM attaches the condition code to the branch mnemonic (B.EQ), while x86 uses dedicated jump mnemonics (je).
What’s a jump table, and how does it relate to conditional branching? A jump table is an array of addresses used by compilers to optimize dense switch-case statements into a single indirect jump rather than a chain of conditional branches. It trades multiple sequential comparisons for one table lookup plus one unconditional jump, which is often faster when there are many possible cases.
Why do some conditional jumps check multiple flags at once? Certain conditions, like “greater than” for signed numbers, can’t be determined from a single flag alone — they require a specific combination (for jg, the CPU checks a logical combination involving ZF, SF, and OF together) because signed comparison semantics are more complex than a simple equality or sign check.
Summary and Key Takeaways
- Conditional branching lets a CPU alter its execution path based on a condition, forming the basis of all high-level control flow.
- On x86,
cmp/testset flags in EFLAGS/RFLAGS, which conditional jumps (je,jg, etc.) evaluate. - On ARM, comparison instructions set NZCV flags, and condition codes are appended to branch instructions (
B.EQ,B.GT). - Loops are simply backward conditional branches.
- Branch prediction hides most of the cost of branching, but mispredictions carry a real performance penalty — branchless alternatives can help in hot paths.
References
- Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 1
- AMD64 Architecture Programmer’s Manual, Volume 1
- Arm® Architecture Reference Manual for A-profile Architecture
- GNU Binutils / GAS Documentation (as.info)
