How x86 Conditional Jumps Really Work — EFLAGS, Not Operands

How x86 Conditional Jumps Really Work — EFLAGS, Not Operands

ja, jb, je, jl don't read your operands. They read bits in EFLAGS that some earlier instruction wrote — and it isn't always the cmp you think. Once you see that in GDB, reverse engineering and crash-dump triage stop being guesswork.

September 6, 2026
Harrison Guo
6 min read
Kernel Debug Field Notes Reverse Engineering

Read enough x86 and you start to narrate it wrong in your head: “ja .target — jump if the first operand is above the second.” It’s a convenient lie. ja has no operands and no idea what you compared. It reads two bits of EFLAGS, and those bits were written by whatever instruction last touched them. Usually that’s the cmp right above it. Sometimes it isn’t, and that gap is where a whole class of reverse-engineering and crash-triage confusion lives.

This post pins down what conditional jumps actually read, with real code where the flag-setter is not a cmp, and how to watch the whole thing happen one instruction at a time in GDB.

How x86 Jumps REALLY Work: EFLAGS Truth with GDB + pwndbg | Malware, RE, Debugging

The model in one sentence

x86 splits a comparison into two instructions that communicate through a hidden register:

  1. An instruction that writes flags — cmp, test, sub, add, and, cmpxchg, most of the ALU.
  2. A conditional jump that reads flags — ja, jb, je, jl, js, and the rest.

EFLAGS is the channel between them. cmp a, b is just sub a, b that throws away the result and keeps the flags. test a, b is and a, b doing the same. The jump then inspects the bits. Nothing carries the operands forward — only the flags survive.

Which jump reads which bit

Every conditional jump is a named test over a fixed combination of flag bits. The common ones, after a cmp a, b:

JumpReadsTrue when (cmp a, b)
je / jzZF=1a == b
jne / jnzZF=0a != b
ja / jnbeCF=0 and ZF=0a > b (unsigned)
jae / jncCF=0a >= b (unsigned)
jb / jcCF=1a < b (unsigned)
jbeCF=1 or ZF=1a <= b (unsigned)
jgZF=0 and SF=OFa > b (signed)
jgeSF=OFa >= b (signed)
jlSF ≠ OFa < b (signed)
jleZF=1 or SF ≠ OFa <= b (signed)
js / jnsSFresult negative / not
Jump je / jz
Reads ZF=1
True when (cmp a, b) a == b
Jump jne / jnz
Reads ZF=0
True when (cmp a, b) a != b
Jump ja / jnbe
Reads CF=0 and ZF=0
True when (cmp a, b) a > b (unsigned)
Jump jae / jnc
Reads CF=0
True when (cmp a, b) a >= b (unsigned)
Jump jb / jc
Reads CF=1
True when (cmp a, b) a < b (unsigned)
Jump jbe
Reads CF=1 or ZF=1
True when (cmp a, b) a <= b (unsigned)
Jump jg
Reads ZF=0 and SF=OF
True when (cmp a, b) a > b (signed)
Jump jge
Reads SF=OF
True when (cmp a, b) a >= b (signed)
Jump jl
Reads SF ≠ OF
True when (cmp a, b) a < b (signed)
Jump jle
Reads ZF=1 or SF ≠ OF
True when (cmp a, b) a <= b (signed)
Jump js / jns
Reads SF
True when (cmp a, b) result negative / not

Two things fall out of this table immediately. First, signed and unsigned comparisons are different instructions — ja (unsigned, carry) versus jg (signed, sign vs overflow). Pick the wrong one and the branch is correct on small numbers and wrong the moment a value crosses the sign boundary. That is a real bug pattern, not a curiosity. Second, none of these read a or b. They read CF, ZF, SF, OF. Whoever set those last decides the branch.

When the flag-setter isn’t the cmp

Here is the part the “ja means greater-than” mental model hides. This is a lock-free stack push, in real assembly:

push_retry:
    mov QWORD PTR [rsi], rax        ; new_node->next = current head
    lock cmpxchg QWORD PTR [rdi], rsi  ; if head==rax, head=rsi
    jne push_retry                   ; retry if it changed

There is no cmp here at all. The jne is reading ZF — and ZF was set by cmpxchg, which sets it to 1 when the compare-and-swap succeeded and 0 when it failed. So jne (“jump if ZF=0”) loops back on a failed swap. The branch condition is entirely defined by an instruction most people don’t think of as a “comparison.”

The same shape shows up constantly once you look for it:

    test al, al     ; sets ZF from al & al, i.e. is al zero?
    jz  .done       ; jump if al == 0

test al, al is the idiomatic “is this register zero” — cheaper than cmp al, 0 and it sets ZF the same way. The jz reads that. No operand comparison in the source sense; just a flag set and a flag read.

The rule that actually keeps you out of trouble: the branch reflects EFLAGS at the moment of the jump, not the state at the last cmp you happened to notice. Anything between them that writes flags — an arithmetic instruction, a test, sometimes the tail of a called function — changes the decision. “The values look right but the branch went the wrong way” is almost always a flag clobbered in that gap.

Watching it in GDB

You do not have to trust any of this. Step it. With GDB and pwndbg (or GEF/peda), pwndbg decodes EFLAGS into named bits on every stop, so you can watch a flag-writer set them and the jump read them:

pwndbg> starti
pwndbg> nexti            # advance to the cmp / test / cmpxchg
pwndbg> info registers eflags
# eflags 0x...  [ CF PF ZF SF ... ]   <- decoded bit names
pwndbg> nexti            # the conditional jump
# pwndbg shows whether the branch is TAKEN based on those bits

The habit worth building: stop on the flag-setting instruction, read the decoded flags, then confirm the jump’s decision against the table above rather than against your memory of the operands. In a malware sample full of obfuscated arithmetic and junk instructions between the compare and the branch, that is the difference between reconstructing the real control flow and guessing at it. The video does this live on a small program if you want to see the bits move rather than take my word for the mapping.

Why this reaches code you actually ship

You don’t hand-write jumps, but you read their consequences:

  • Crash-dump and coredump triage. When you’re staring at a disassembly around the faulting instruction, knowing that the branch above it depends on flags set several instructions earlier — not on the registers you can see right there — is what lets you reconstruct which path was actually taken.
  • Reading compiler output. A source-level if (x < y) becomes jb or jl depending on whether the compiler decided the values are unsigned or signed. That single letter tells you how the compiler typed your variables, which occasionally reveals a bug the source hid.
  • Constant-time / security-sensitive code. Comparisons that must not branch on secret data (crypto equality, timing-safe checks) live and die by exactly which instruction sets the flags and whether a branch consumes them. Auditing that requires reading the flag flow, not the operands.

The general lesson under all of it: CPU flags are shared, mutable, global state. Instructions you don’t think of as comparisons write them; a branch far below reads them. Knowing which instructions have that side effect — and reading EFLAGS at the branch, not at the cmp — is most of what separates “I can follow assembly” from “I can read assembly.”

🎧 More Ways to Consume This Content

I occasionally advise small teams on backend reliability, Go performance, and production AI systems. Learn more: /services

Comments

This space is waiting for your voice.

Comments will be supported shortly. Stay connected for updates!

Preview of future curated comments

This section will display user comments from various platforms like X, Reddit, YouTube, and more. Comments will be curated for quality and relevance.