Lesson 1.5: x86/x64 Assembly (3), if, loops, switch, arrays and structs
Lesson 1.3 covered the instruction set and lesson 1.4 the stack frame. Now we put them together to read high-level structure. A compiler takes your for statement and turns it into a series of cmp, jmp, inc. Reversing means going the other way, which is to look at the pile and recognize “this is a loop”.
Compilers are very mechanical. They translate each construct with a few fixed templates, so once you know the templates you can read the code. This lesson is that set of templates.
if / else: one jump skipping a block
You already met this in lesson 1.3. The compiler jumps over the block of instructions when the condition isn’t met, so the logic is often inverted. For example, if (a == b) in C becomes “if a is NOT equal to b, jump away”.
mov eax, [rbp-4] ; eax = x
cmp eax, 5
jne else_branch ; x != 5, jump down to else
mov dword [rbp-8], 1 ; y = 1 (if body)
jmp end_if
else_branch:
mov dword [rbp-8], 2 ; y = 2 (else body)
end_if:
Translated to C:
1
2
3
4
if (x == 5)
y = 1;
else
y = 2;
The pattern is a cmp/test, a conditional jump to the “else” label, and at the end of the if body an unconditional jmp over the else block. If you see that jmp at the end of a block, there’s an else. Without it it’s usually a plain if.
Nested ifs
An if inside an if is the same template stacked up, with more labels. Don’t read it in one pass, follow one cmp/jump pair at a time. IDA and Ghidra draw a graph view that shows the branching blocks much more clearly than the text listing, so use it.
Loops: a jump backwards
A loop has one clear signature, a jump instruction pointing back up to an earlier address. Normal code runs downward, so a jump upward almost certainly means a loop.
A typical for (i = 0; i < n; i++):
mov dword [rbp-4], 0 ; i = 0
loop_check:
mov eax, [rbp-4]
cmp eax, [rbp-8] ; compare i with n
jge loop_end ; i >= n, exit
; ----- loop body here -----
mov eax, [rbp-4]
inc eax
mov [rbp-4], eax ; i++
jmp loop_check ; <--- JUMPS BACK UP, the sign of a loop
loop_end:
Translated to C:
1
2
3
for (int i = 0; i < n; i++) {
// loop body
}
To read a loop quickly, look for four pieces. The counter is initialized before the label (here i = 0), the condition is at the top (cmp plus the exit jump), the body is in the middle, and at the end the counter is incremented or decremented, followed by a backwards jmp to the condition.
while and for compile to almost the same thing, the only difference being whether there’s an init part and an increment part. do...while puts the condition at the end, so it’s more compact (no unconditional jmp at the start). If the condition is checked at the bottom of the loop block, it’s a do...while.
switch-case: jump tables
A small switch is often compiled into a chain of if/else if (cmp against each value in turn). With many cases and consecutive values (0, 1, 2, 3…), the compiler uses a jump table, which is a table of addresses. Instead of comparing one by one, it uses the value as an index into the table and jumps.
mov eax, [rbp-4] ; eax = switch value
cmp eax, 3
ja default_case ; greater than 3 (unsigned), go to default
; eax is used as the index into the address table
lea rcx, [jump_table]
mov rcx, [rcx + rax*8] ; fetch the address of case number eax (each entry is 8 bytes on x64)
jmp rcx ; jump to the case
jump_table:
dq case_0
dq case_1
dq case_2
dq case_3
Translated to C:
1
2
3
4
5
6
7
switch (x) {
case 0: ...; break;
case 1: ...; break;
case 2: ...; break;
case 3: ...; break;
default: ...;
}
The pattern is a cmp bounding the upper limit with ja to default, then an indirect jump like jmp [table + index*8]. A jmp to a register (not a fixed label) together with a *4 or *8 is almost certainly a jump table. IDA and Ghidra recognize jump tables automatically and show the cases, so you don’t need to trace by hand.
Array access: multiply by the element size
An array in memory is elements laid out one after another. To get arr[i], the CPU computes base address + i * element_size. That multiplication tells you it’s an array, and also the size of each element.
mov rax, [rbp-8] ; rax = base pointer of the array
mov ecx, [rbp-4] ; ecx = i
mov edx, [rax + rcx*4] ; edx = arr[i], each element 4 bytes -> int
Translated to C:
1
int x = arr[i]; // arr is int*, so multiply by 4
The multiplier gives the type. *1 is a byte array (char, uint8), *2 is short (16 bit), *4 is int or float (32 bit), and *8 is long long, double, or a pointer on x64.
The full syntax of an x86 memory operand is [base + index*scale + displacement], for example [rax + rcx*4 + 0x10]. The displacement part is often a sign of a struct, as the next section shows.
Struct access: adding a fixed offset
A struct is also fields laid out one after another, but you access it with a fixed offset (the field’s position in the struct) instead of an index multiplied by a size. If a pointer gets added to different constants (0, 4, 8, 0x10…) to pull out values, it’s a struct.
mov rax, [rbp-8] ; rax = pointer to the struct
mov ecx, [rax] ; read the field at offset 0
mov edx, [rax+4] ; read the field at offset 4
mov r8, [rax+8] ; read the field at offset 8
Translated to C:
1
2
3
4
5
6
7
8
struct Thing {
int a; // offset 0
int b; // offset 4
void *c; // offset 8
};
int x = t->a;
int y = t->b;
void *z = t->c;
A quick way to tell them apart is that an array uses a varying index times a size (rcx*4), while a struct uses constant offsets (+4, +8). An array is the same type many times, a struct is different types each at a fixed spot.
In IDA you can declare a struct (press Y to set a type, or create the struct in Local Types) and assign it to the pointer, and [rax+8] turns into t->c, which is much nicer to read. Lesson 3.3 goes deep on recovering structs.
Summary of the templates
Keep this next to your screen when you’re starting out:
| What you see in asm | Most likely |
|---|---|
cmp/test + conditional jump + jmp over a block | if / else |
| A jump back up to an address above | loop |
A variable gets inc/dec then compared at the top/bottom of a block | loop counter |
jmp to a register + index*4 or *8 from a table | switch with a jump table |
[base + index*scale], scale is 1/2/4/8 | array access, scale tells the type |
[base + constant] with several different constants | struct access, the constants are field offsets |
Don’t just memorize these. The best way is to write your own C code, build it, and open it in Ghidra/IDA to see what the compiler did. That’s the lab below.
Lab
The file structures.c has all five constructs above, one function each. sum_array is a loop plus access to an int array, classify is a multi-level nested if/else, action_name is a switch with five consecutive cases (a jump table candidate), and level_up accesses a struct through offsets. The goal is to point out the loop, the nested if, the switch jump table, the array access and the struct access by hand in a real binary, and match the asm against source you already know.
Build it at both optimization levels. The -O0 build follows the templates closely and is easy to read, while the -O2 build shows how much harder real-world code is.
On Linux or macOS with gcc or clang:
1
2
gcc -O0 -g -o structures_O0 structures.c
gcc -O2 -o structures_O2 structures.c
On Windows, from a Developer Command Prompt with MSVC:
1
2
cl /Od structures.c
cl /O2 structures.c
Open the binary in Ghidra (import, then auto-analyze) or IDA, go through each function in turn and answer a few questions. In sum_array, find the instruction that jumps backward to mark the loop, and work out the scale factor used for the array access and how it matches the int type. In classify, count the cmp plus conditional jump pairs and redraw the if/else tree from the jump labels. In action_name, decide whether the compiler built a jump table or translated the switch into an if/else chain, and if there is a table, find its address and entries. In level_up, list which offsets are added to the struct pointer and match them to the fields of struct Player. Finally compare sum_array between -O0 and -O2. Does the counter i still live on the stack at -O2 or does the compiler keep it in a register, and is the loop distorted by unrolling or a changed condition form?
A few hints. Use the graph view (the Space key in IDA) to see the branching blocks instead of reading text. In Ghidra the decompiler window (double-click a function) gives an approximate C version to compare with, but try reading the asm yourself first and only then open the decompiler to check. If you can’t spot a jump table, look for a jmp to a register (an indirect jump) with a *8 (x64) or *4 (x86) computation right before it. Compare on your own first and open the solution when you’re finished.
Show solution
Try it yourself first. This is a function-by-function comparison based on an x86-64 gcc -O0 build (Linux, System V, so the first parameters are in rdi, rsi and so on). MSVC /Od differs slightly in the parameter registers (rcx, rdx and so on) and uses rbp the same way, but the templates are identical.
1) sum_array: loop and array
Typical asm at -O0, with the prologue trimmed:
mov [rbp-4], 0 ; total = 0
mov [rbp-8], 0 ; i = 0
jmp .check
.body:
mov eax, [rbp-8] ; eax = i
movsxd rax, eax ; extend i to 64 bits to use as an index
mov rcx, [rbp-24] ; rcx = arr (base pointer)
mov eax, [rcx + rax*4] ; eax = arr[i] <-- scale *4 => int
add [rbp-4], eax ; total += arr[i]
add dword [rbp-8], 1 ; i++
.check:
mov eax, [rbp-8]
cmp eax, [rbp-28] ; compare i with n
jl .body ; if i < n, JUMP BACK UP to .body <-- the loop
mov eax, [rbp-4] ; return total
The instruction that marks the loop is jl .body, jumping backward. The scale factor is rax*4, a 4-byte element, which matches the int type. With a char* you would see *1 and with a double* you would see *8. The compiler puts the condition at the end and enters the loop with an initial jmp .check, a common template for for and while at -O0.
2) classify: nested if/else
The three thresholds (90, 70, 50) become three cmp plus jump pairs:
cmp dword [rbp-4], 90
jl .not_A
mov al, 'A' ; return 'A'
jmp .done
.not_A:
cmp dword [rbp-4], 70
jl .not_B
mov al, 'B'
jmp .done
.not_B:
cmp dword [rbp-4], 50
jl .else_F
mov al, 'C'
jmp .done
.else_F:
mov al, 'F'
.done:
There are three cmp plus jl pairs. Each “true” branch ends with jmp .done, and a trailing jmp at the end of a body means an else branch follows. The logic is inverted, as if (score >= 90) becomes cmp 90; jl .not_A (if it is LESS than 90, skip the A branch), which is very typical. You can rebuild the whole nested if/else tree from the chain of labels .not_A -> .not_B -> .else_F.
3) action_name: switch
With 5 consecutive cases 0..4, gcc, clang and MSVC at optimized levels usually build a jump table. A common form:
mov eax, [rbp-4] ; action
cmp eax, 4
ja .default ; >4 (unsigned) -> default. Note ja also catches negative numbers
mov eax, eax ; zero-extend
lea rcx, [rel .table]
movsxd rax, dword [rcx + rax*4] ; the table holds 4-byte offsets
add rax, rcx
jmp rax ; INDIRECT JUMP to the case <-- jump table signature
.table:
dd .case0 - .table
dd .case1 - .table
dd .case2 - .table
dd .case3 - .table
dd .case4 - .table
The signature is cmp eax, 4 plus ja .default (the bounds check), then jmp rax (an indirect jump through a register) with the target loaded from [table + index*4]. At -O0, some compilers instead translate this switch into an if/else chain comparing 0, 1, 2, 3, 4 in turn. If you see that, it’s correct and there’s no table, and building with -O2 forces a jump table out. IDA and Ghidra recognize the table by themselves and label it jpt_ along with the list of cases, so you don’t have to chase offsets by hand. One more thing is that ja (unsigned) guards both the upper bound and negative values in one instruction, because a negative number treated as unsigned is huge.
4) level_up: struct through offsets
mov rax, [rbp-8] ; rax = p (struct pointer)
mov edx, [rax+8] ; p->level (offset 8)
add edx, 1
mov [rax+8], edx ; p->level += 1
mov rax, [rbp-8]
mov edx, [rax+4] ; p->score (offset 4)
add edx, 100
mov [rax+4], edx ; p->score += 100
mov rax, [rbp-8]
cmp dword [rax+4], 500 ; if (p->score >= 500)
jl .skip
mov rax, [rbp-8]
mov byte [rax+12], 'S' ; p->grade = 'S' (offset 12)
.skip:
Matching the offsets to struct Player:
| Offset | Field | Type | Size |
|---|---|---|---|
| 0 | id | int | 4 |
| 4 | score | int | 4 |
| 8 | level | int | 4 |
| 12 | grade | char | 1 |
To tell it from an array, notice the offset constants are different (+4, +8, +12) and added to the same pointer, with no scaled index. That’s a struct. grade is a char, so it uses mov byte, while the int fields use 32-bit operations, so the instruction size also hints at the field type. In IDA, declaring struct Player and setting the type of p (key Y) turns [rax+8] into p->level, which reads much better.
5) Comparing -O0 and -O2 (sum_array)
The typical differences are these. The counter i and total are no longer on the stack, because the compiler keeps them in registers (for example total in eax and i in ecx), so the repeated [rbp-x] reads are gone. The prologue and epilogue are leaner, and sometimes rbp is dropped as a frame pointer entirely (frame pointer omission). The loop may change its condition form, or with a known constant n the compiler sometimes unrolls a few iterations or even computes the result in advance. At high optimization it may also use SIMD instructions to add several elements at once.
The templates in this lesson are most accurate at -O0. Real-world code is usually built at -O2 or higher, so you have to get used to variables living in registers and structures being shuffled around. The decompiler (Ghidra or Hex-Rays) does most of that work for you, but knowing the original templates tells you what it’s reconstructing and when it gets it wrong.
Key takeaways
For if/else, look for a cmp plus a conditional jump over a block. A jmp at the end of the if body usually means there’s an else, and the condition logic is often inverted. Loops show up as a jump back up, with four pieces, which are init, condition, body, increment. do…while puts the condition at the end of the block.
A switch with many consecutive cases becomes a jump table, recognized by an indirect jmp plus index*8, and IDA/Ghidra rebuild the cases automatically. Arrays look like [base + index*scale] where the scale (1/2/4/8) gives the element size, while structs look like [base + constant offset] where each offset is a field. Building your own code and inspecting it is the fastest way to learn.
