Order of Instructions (Control Flow)
Control flow decides which instructions you’re going to execute. There are 2 types of control flow:
Conditional - where you go somewhere if a condition is met (if statements, switches, loops) Unconditional - where it’ll always go somewhere (function calls, goto, exceptions, interrupts)
We already saw in earlier topics that function calls manifest themselves as call/ret. Let’s see how goto manifests itself in assembly.
C code:
| |
Disassembly:
| |
This isn’t hard to understand and is pretty simple. If you observe closely, goto is just jmp [memory address of the designated label] - it’s literally jumping, as the word itself suggests.
jmp
Internally, this unconditionally changes RIP to the given address. There are many ways to specify the address:
Short relative: RIP = RIP of next instruction + 1 byte sign-extended to 64 bits displacement. Frequently used in small loops. Some disassemblers will indicate this with the mnemonic “jmp short”. For example,
jmp -2creates an infinite loop. Note thatjmp 00.102doesn’t have the number anywhere in it - it’s reallyjmp 0x0Cbytes forward. It’s not encoded with a 64-bit address baked into it; instead it’s saying “2 bytes: one byte to say I’m a jump, and one byte to say I want to jump 0xC bytes forward from the next instruction address.”Near relative: RIP = RIP of next instruction + 4 byte sign-extended to 64 bits displacement
Near absolute indirect: Uses r/m64, which means it could jump to a specific address in a register or pull an address out of memory based on an r/m form
Far absolute indirect: Another addressing form
if statements (cmp, jne, jle, jge)
| |
| |
We have new instructions:
- cmp = compare
- jne = jump if not equal
- jle = jump if less than or equal
- jge = jump if greater than or equal
jcc (jump if condition is met)
If a condition is true, the jump is taken. Otherwise, it proceeds to the next instruction. There are more than 4 pages of conditional jump types, but many are just synonyms for each other. For example, JNE is equal to JNZ (Jump if Not Equal = Jump if Not Zero; both check if the zero flag ZF == 0).
What is the Zero Flag?
Let’s talk about a special-purpose register: the RFLAGS register. In the manual, EFLAGS is extended to 64 bits and called RFLAGS. The upper 32 bits of RFLAGS register are reserved; the lower 32 bits are EFLAGS. Basically, we just extended the register and aren’t really using the extra bits for anything - they’re all zeros.
RFLAGS
The RFLAGS register holds many single-bit flags:
- Zero Flag (ZF): Set to 1 if the result of some instruction is zero; cleared (0) otherwise
- Sign Flag (SF): Set to 1 if the most significant bit (MSB) of the result is 1. For signed values, the sign bit is the MSB. When you divide the range of 8-bit, 32-bit, or 64-bit values into two halves (positive/negative), the MSB is always 1 for negative values
- Carry Flag (CF): Set on unsigned overflow
- Overflow Flag (OF): Set on signed overflow
- Parity Flag (PF): Set if the low byte has an even number of 1 bits
- Auxiliary Flag (AF): Used for BCD arithmetic
Some Notable JCC Instructions
- JZ/JE: Jump if ZF == 1 (Zero/Equal)
- JNZ/JNE: Jump if ZF == 0 (Not Zero/Not Equal)
- JLE/JNG: Jump if ZF == 1 OR SF != OF (Less or Equal/Not Greater)
- JGE/JNL: Jump if SF == OF (Greater or Equal/Not Less)
- JBE/JNA: Jump if CF == 1 OR ZF == 1 (Below or Equal/Not Above)
- JB/JNAE: Jump if CF == 1 (Below/Not Above or Equal)
No need to memorize this - you’ll be running code in a debugger, not just reading it. In the debugger, you can just look at RFLAGS and watch whether it takes a jump.
Mnemonic Translation
- A = Above (unsigned notion) - e.g., if you have 0xFFFFFFFF and you’re comparing it to zero, 0xFFFFFFFF is above zero because it’s unsigned
- B = Below (unsigned notion)
- G = Greater than (signed notion) - if it was signed and you were dealing with 0xFFFFFFFF, that would be a negative value and would NOT be greater than zero because it’s actually negative
- L = Less than (signed notion)
- E = Equal (same as Z, zero flag set; sometimes disassemblers will use Z)
- N = NOT (e.g., JNL = Jump if Not Less than, JNA = Jump if Not Above)
Flag Setting
Before you can do a conditional jump, you need something to set the condition status flags for you. This is typically done with:
- CMP (compare)
- TEST (bitwise AND without storing result)
- Instructions that already have flag-setting side effects (like ADD, SUB, etc.)
CMP (Compare Two Operands)
The comparison is performed by subtracting the second operand from the first operand and then setting the status flags in the same manner as the SUB instruction.
What’s the difference from just doing SUB?
With SUB, the result has to be stored somewhere. With CMP, the result is computed and the flags are set, but the result is discarded. It modifies CF, OF, SF, ZF, AF, and PF.
EZ Guide to Understanding Them All
| |
Is 1 != 2?
| |
This is like: if (1 != 2);
| |
Is 1 <= 2?
| |
This is like: if (1 <= 2);
| |
Is 1 >= 2?
| |
This is like: if (1 >= 2);
Note: Operands are backward in AT&T syntax.
Takeaways
- Conditional logic like if statements manifests in assembly as conditional jumps: “If condition true, jump there; else fall through”
- Conditions involving (in)equality are often checked with the CMP instruction, which is the same as SUB but throws away the result after the relevant RFLAGS bits are set
- The RFLAGS bits are fundamentally what are checked by the JCC instructions
- On unsigned integers, you’ll most likely see jae (>=) or jbe (<=)
- On signed integers, you’ll most likely see jge (>=) or jle (<=)
switch Statements
Switch statements look like a bunch of “if equal” checks. Since if and switch have very similar behavior, it’s no wonder they’re doing that. I can show you a comparison.
| |
Switch statement when disassembled:
| |
Compare this with equivalent if statements:
| |
When disassembled:
| |
As you can see, they produce nearly identical assembly!
Additional Section: Signed vs Unsigned Comparisons
The only substantive thing that changes when you use an unsigned integer instead of a signed integer is the conditional jump instructions that get emitted.
When using unsigned integers, you’ll see:
- JB (Jump if Below)
- JA (Jump if Above)
- JBE (Jump if Below or Equal)
- JAE (Jump if Above or Equal)
When using signed integers, you’ll see:
- JL (Jump if Less than)
- JG (Jump if Greater than)
- JLE (Jump if Less or Equal)
- JGE (Jump if Greater or Equal)
Why does this matter?
The compiler emits different code depending on whether the programmer declared variables as unsigned versus signed. This means a reverse engineer or decompiler can use these different assembly instructions to infer whether the variables were likely unsigned or signed in the original high-level language.
How does the hardware handle this?
The hardware doesn’t actually care whether humans interpret bits as signed or unsigned. When executing arithmetic operations like ADD and SUB, the hardware:
- Performs the operation as if operands were both unsigned and signed
- Sets all status flags (zero, sign, overflow, carry, parity, etc.)
- Leaves it to the compiler to emit the correct conditional jump based on whether the high-level code used signed or unsigned types
The compiler figures out what the programmer meant by parsing the high-level language syntax and emits the appropriate signed or unsigned comparison instructions.
The compiler emits different instructions based on whether variables are signed or unsigned, but make sure you step through the assembly yourself to understand what’s going on!