48 min read
TOC
Condensed Assembly
Notes and materials adapted from OpenSecurity’s x86-64 assembly course, reorganized here in text form since it’s easier for me to recall this way. An easy to absorb reference for understanding x86-64 assembly.
Numerical Systems
Decimal
A decimal number system is a base-10 system:
| |
Binary
“Bi” means two, it’s a base-2 system. 0 represents off, 1 is on. It’s the language computer hardware directly understands.
Hexadecimal
A base-16 number system (“hexa” = 6, “decimal” = 10, when added it’s 16):
| |
Decimal to Binary (15)
Divide it by 2, write down the remainder, only use integer division, stop when the quotient becomes 0, read the remainders from bottom to top.
| |
Decimal to Hexadecimal (15)
Same rules, but divide it by 16:
| |
Hexadecimal to Decimal (0x100)
List out powers of 16 left to right:
| 16² | 16¹ | 16⁰ |
|---|---|---|
| 1 | 0 | 0 |
(1 × 16²) + (0 × 16¹) + (0 × 16⁰)
| |
Hexadecimal to Binary (D)
Convert letters to numbers (if there’s any):
| |
Use this as a base:
| |
8, 4, and 1 add up to 13:
| |
Binary to Decimal & Hexadecimal (0001 0011 0011 0111)
Group the bits into four (nibbles), then represent the power of 2, from right to left:
| |
List out the results of the power of 2 only if the corresponding binary digit is 1, and add them if there’s more than 1:
| |
To get the decimal value, multiply each nibble’s value by its place’s power of 16 and add them up:
| |
Two’s Complement & Negative Numbers
Two’s complement is the method computers use to represent negative numbers. To get the negative representation of a positive number, invert all of its bits, then add 1.
Signed Types
Signed types are data types that can represent negative and positive numbers:
| |
Unsigned Types
Unsigned types can only represent 0 and positive values:
| |
Both of these types have the same number of bits, but a signed type reserves half of the value range to represent negative numbers:
| |
Example (0xFF)
Convert hexadecimal to decimal:
F = 15
| 16¹ | 16⁰ | |
|---|---|---|
| Digit | 15 | 15 |
| Place value | 16 | 1 |
(15 × 16) + (15 × 1) = 255
Convert it to binary:
| |
The bits stay the same, only their meaning changes. As an unsigned char, 11111111 is 255. As a signed char, the same bits represent -1.
Producing a Negative Value (0x0D)
D is 13. Convert it to binary:
| |
Pad it with leading 0s to fill 8 bits (a char is 8 bits wide):
| |
Now flip every bit and add 1, this is the two’s complement process that produces the negative value’s representation.
Two’s complement isn’t the process used every time a computer sees a negative number, it’s how the negative number is stored in memory.
Flip:
| |
Add 1:
| |
Endianness
Endianness describes how multi-byte values are stored in memory.
Assume we have a 32-bit value:
| |
Logically, the value is always:
MSB —————————–> LSB
| Byte 1 (MSB) | Byte 2 | Byte 3 | Byte 4 (LSB) |
|---|---|---|---|
| 0xFE | 0xED | 0xFA | 0xCE |
The value never changes, only the order of the bytes in memory changes.
Little-Endian (x86/x64)
The least significant byte is stored at the lowest memory address:
Low Address High Address
| Addr 0 (Low) | Addr 1 | Addr 2 | Addr 3 (High) |
|---|---|---|---|
| 0xCE | 0xFA | 0xED | 0xFE |
Big-Endian
The most significant byte is stored at the lowest memory address:
Low Address High Address
| Addr 0 (Low) | Addr 1 | Addr 2 | Addr 3 (High) |
|---|---|---|---|
| 0xFE | 0xED | 0xFA | 0xCE |
x86 and x64 processors use little-endian.
Important
Endianness only affects the order of bytes in memory. It does not change the value stored in a register, or the order of bits inside a byte. For example, 0xCE is always 11001110, regardless of endianness, only its position in memory changes.
Computer Memory Hierarchy
| |
Each layer is slower, cheaper, and larger than the one above it, disk holds everything but is slow, registers hold almost nothing but are instant.
Assembly Registers
Processor registers are small, volatile storage areas built directly into the CPU, the fastest storage in the system, and where the CPU actually performs operations.
On x86-64, Intel CPUs have 16 general-purpose registers, plus the instruction pointer (RIP), which points to the next instruction to execute. On x86-32 systems, registers are 32 bits wide, and there are only 8 general-purpose registers plus the instruction pointer.
Register Evolution and Sub-Registers
Registers were never replaced over time, they were extended. Older, smaller registers still exist as views into the newer, wider ones:
| Register | Width | Processor / Notes |
|---|---|---|
| A | 8-bit | Intel 8008 |
| AX | 16-bit | Intel 8086 (“A-extended”) |
| EAX | 32-bit | Intel 80386 |
| RAX | 64-bit | x86-64 (AMD Opteron / Intel P4+) |
| |
Sub-register access remains available: AL is the low 8 bits, AH is the high 8 bits (legacy, only exists for AX/BX/CX/DX).
General-Purpose Registers (x86-64)
| |
Disassemblers almost always use these historical names (RAX, RBX, etc.) rather than numeric identifiers, they’re easier to read and match older documentation.
Registers with conventional roles:
| |
RSP is special by ABI convention and should always point to the stack. Older x86 didn’t allow byte-level access to SP, AMD introduced SPL in x86-64 to make naming consistent. These registers still count as general-purpose at the hardware level, the compiler can technically repurpose them.
Extra registers added in x86-64, giving compilers more room to keep values in registers:
| |
Each supports full-width access, e.g. R8 (64-bit), R8D (32-bit), R8W (16-bit), R8B (8-bit).
RIP, the instruction pointer, points to the next instruction to execute. It’s not a general-purpose register, it’s modified indirectly via call, jmp, ret, etc.
Why Access Smaller Parts of a Register?
Not every value is 64 bits, char is 8 bits, short is 16 bits, int is usually 32 bits. The CPU needs to operate on, store, and load these smaller sizes correctly.
Example: 32-bit wraparound behavior
| |
Mathematically, 1 + (2^32 - 1) = 2^32, which wraps to 0. If the CPU used a full 64-bit register without truncating, the result would incorrectly be 2^32. By doing the arithmetic in a 32-bit register (EAX), the CPU automatically enforces the correct wraparound. Writing to a 32-bit register also automatically clears the upper 32 bits, this is intentional, and compilers rely on it heavily.
High-byte registers (AH, BH, CH, DH) exist mainly for backward compatibility and are rarely used in modern code. Lower-width access (AL, AX, EAX) is still essential and used constantly.
Conventional Roles
Intel originally suggested usage conventions for registers, these are recommendations, not hard rules, compilers are free to use registers however they want:
- RAX - Accumulator / return value. Commonly holds function return values.
- RBX - Base register, originally a pointer to the data section.
- RCX - Counter register, often used as a loop counter (“C” for counter).
- RDX - Data / I/O register, historically I/O-related.
- RSI - Source index, source pointer in string/memory operations.
- RDI - Destination index, destination pointer, often paired with RSI.
- RSP - Stack pointer, tracks the most recently pushed value. Has special meaning, shouldn’t be used arbitrarily.
- RBP - Base pointer, a stable reference point for accessing local variables and saved registers within a function, since RSP moves during execution.
- RIP - Instruction pointer, points to the next instruction. Not general-purpose, updated automatically by the CPU.
NOP
NOP stands for No Operation. It doesn’t change any register or memory, it just advances the instruction pointer and consumes execution time. The CPU executes it, but nothing observable happens.
What Is NOP Used For?
NOP is commonly used for instruction alignment and padding bytes between instructions.
Why Is NOP 0x90?
On x86, the one-byte NOP (0x90) is actually encoded as:
| |
XCHG swaps the values of two registers. Swapping a register with itself changes nothing, making it a perfect no-op, a real instruction repurposed to do effectively nothing.
Multi-Byte NOPs
Although 0x90 is the classic 1-byte NOP, Intel also defines multi-byte NOP instructions, ranging from 1 to 9 bytes long. These are used mainly for alignment and for patching code while preserving instruction boundaries (so a patch doesn’t shift where later instructions start). They still perform no operation, just with different instruction lengths depending on how many padding bytes are needed.
The Stack
We’re going to walk through how the stack actually works, using a real disassembled program to see it in action.
Note on convention: the example below passes the argument in RDI, which is the System V AMD64 convention (Linux/GCC). The Function Parameters & Calling Conventions section later in this document covers Microsoft x64, which passes the first integer argument in RCX instead. These are two different, incompatible ABIs, not two ways of writing the same thing, so don’t expect the register used here to match the register used later.
| |
| |
Note on stack allocation sizes: this example is written under System V conventions, which do not require the caller to reserve shadow space for the callee. That’s why
main()only allocates 16 bytes andhello()only allocates 32. The mandatory-32-byte shadow space rule covered in The x64 Stack Allocation Rules and Shadow Space applies specifically to the Microsoft x64 ABI, not this example, so don’t expect these numbers to satisfy that later rule.
What Is a Stack Frame?
A stack frame is the region of the stack holding everything one function call needs: the return address, saved registers (especially RBP), function arguments, and local variables. Each call gets its own frame, created on entry and destroyed on exit.
RSP tracks the top of the stack. In x86-64, the stack grows downward, so allocating space means subtracting from RSP:
| |
| |
RBP stays fixed for the whole function, giving you a stable reference point, unlike RSP, which keeps moving as things get pushed and popped. Watch how RBP gets established during hello()’s prologue:
| |
After sub rsp, 0x20 allocates hello()’s locals, RBP no longer moves, even though RSP does:
| |
This is why local variables are always addressed as [rbp-N], a fixed offset that never shifts, no matter how much RSP moves later in the function:
| |
If you tried to use RSP for this instead, the offset would silently break the moment anything else got pushed or popped.
Function prologue (entry) sets all this up in three steps:
| |
Function epilogue (exit) tears it back down:
| |
leave does two things in one instruction, snap RSP back to RBP (instantly freeing all locals), then pop the saved RBP to restore the caller’s frame:
| |
Why different functions allocate different amounts: main() allocates 16 bytes (8 for the name pointer, 8 just for alignment), while hello() allocates 32 (8 for its name parameter, 4 for age, plus padding). The compiler always rounds up to keep the stack 16-byte aligned, even when the actual variables need less.
Return addresses work the same way, call pushes one automatically:
| |
When hello() finishes and hits ret, that saved address is exactly where execution resumes, back in main(), right after the call.
Recursion makes this concrete: each call to the same function still gets its own separate frame, stacked on top of each other:
| |
PUSH and POP
PUSH stores a value on the stack in two steps: decrement RSP by 8, then write the value at the new top.
| |
is equivalent to:
| |
Watch it happen:
| |
RSP always moves first, before the write, so it never briefly points somewhere invalid.
POP does the reverse: read the value at RSP, then increment RSP by 8.
| |
is equivalent to:
| |
The important detail: POP doesn’t erase anything. The value stays sitting in memory, it’s just no longer considered part of the “valid” stack, and the next PUSH will overwrite it.
The golden rule: balance your stack. Every PUSH needs a matching POP, and they must undo each other in reverse order (LIFO, last in, first out):
| |
Getting the order wrong, or forgetting a POP entirely, misaligns the stack and corrupts return addresses, which is one of the most common ways handwritten assembly crashes.
Calculating Stack Offsets
When you see [rbp-0x8] or [rsp+0x18] in disassembly, here’s how to derive that offset yourself, given a stack layout like this:
| |
- Count the 8-byte slots between your starting register and the target. From RBP: 1 slot. From RSP: 3 slots.
- Multiply by 8 to get bytes: RBP -> 8 bytes, RSP -> 24 bytes.
- Determine direction: moving to a higher address is +, moving to a lower address is -. The target is below RBP, so -. It’s above RSP, so +.
- Convert to hex and write it out: rbp-0x08 or rsp+0x18.
Quick reference for common slot counts:
| |
Assembly Syntax Basics
There are two main syntax styles for x86 assembly, they differ mainly in operand order.
Intel syntax (used throughout this course): Destination <- Source, like an assignment (y = 2x + 1):
| |
AT&T syntax (Unix/GNU): Source -> Destination, like an equation (1 + 2 = 3). Registers get a % prefix, immediates get a $ prefix:
| |
MOV, Copying Data
MOV copies a value from one location to another:
| |
Important restriction: memory-to-memory moves are not allowed, this is a hard limitation of the x86 architecture. Source and destination can’t both be memory in the same instruction. Register-to-register, register-to-memory, memory-to-register, and immediate-to-memory (or immediate-to-register) are all fine, an immediate written straight into memory doesn’t need a register at all, as shown above and used later in this document (e.g. mov dword [rbp-0x4], 0x5ca1ab1e).
Memory Addressing (the r/m Form)
Memory operands support a flexible calculated address:
| |
- base - a base register, e.g. rbx
- index - an index register, e.g. rcx
- scale - must be 1, 2, 4, or 8 (matching byte/short/int/pointer sizes)
- displacement - a constant offset
| |
This single addressing mode is what lets the CPU compute an array element’s address (base + index*size) or a struct field’s address (base + fixed offset) without needing separate arithmetic instructions first.
ADD and SUB
Straightforward addition and subtraction:
| |
Operand rules: the destination can be a register or memory (r/m), the source can be a register, memory (r/m), or an immediate value, but source and destination can’t both be memory, that would be a memory-to-memory operation, which x86 doesn’t allow (same restriction as MOV).
Function Calls
Now that the basics are in place, here’s how a function call actually looks in assembly.
| |
| |
The CALL Instruction
CALL transfers control to another function while remembering where to come back to. It does two things:
- Pushes the return address - the address of the instruction right after the call - onto the stack
- Updates RIP to point at the target function
| |
The target address can be specified as an absolute address, a relative offset, or a register holding the address.
The RET Instruction
RET returns control back to the caller, in two forms:
| |
The second form (ret N) shows up often when disassembling Windows APIs, it lets the callee clean up its own stack arguments on the way out, instead of leaving that job to the caller.
Local Variables on the Stack
Example 1: A Simple Integer
| |
| |
Once func() stores i, its frame looks like this:
| |
Reading it in raw memory shows endianness in action:
| Address | Hex Value | ASCII |
|---|---|---|
| 0x14FDC0 | 5ca1ab1e | .«¡\ |
The bytes are actually stored in reverse (1e ab a1 5c), that’s little-endian, exactly what we covered earlier, just now visible in a real stack dump.
Size qualifiers matter for memory writes. A register auto-extends, but memory only ever gets exactly as many bytes as you specify:
| |
Why does the compiler allocate more than the variable needs? int i only needs 4 bytes, but the function reserves 16 (sub rsp, 0x10). This comes down to the x64 calling convention rules, covered next.
The x64 Stack Allocation Rules
- 16-byte alignment - RSP must always be aligned to a 16-byte boundary. This is required for SSE/AVX (SIMD) instructions and is part of the ABI.
- Shadow space - any function that itself calls another function must reserve 32 bytes (0x20) for the callee, even if it never uses them. This is a Microsoft x64-specific requirement, System V does not require it (see Microsoft x64 vs System V (GCC) at a Glance later on).
- Padding - extra bytes get added wherever needed to keep the total allocation a multiple of 16.
A chain of functions shows this pattern clearly:
| Function | Allocation | Bytes | Reason |
|---|---|---|---|
| func3 | 0x10 | 16 | Minimum alignment |
| func2 | 0x30 | 48 | Shadow space + variable + padding |
| func | 0x20 | 32 | Shadow space only |
| main | 0x20 | 32 | Shadow space only |
func2 breaks down as: 32 bytes shadow space + 4 bytes for its int j + 12 bytes padding = 48 bytes (0x30), still a clean multiple of 16.
Example 2: Multiple Variables, No Padding Needed
| |
| |
| |
Two 8-byte variables = 16 bytes total, already a multiple of 16, so no padding is needed here.
Example 3: Arrays, Sign Extension, and Index Math
| |
Array element addresses are computed with the same base + index*scale form covered earlier:
| |
Why movsx here? a is a signed short being written into a signed int slot, the CPU has to preserve the sign bit while widening it, that’s exactly what sign-extension means. If a had been unsigned, you’d see movzx (zero-extend) instead.
The takeaway across all three examples:
- Local variables aren’t necessarily stored in declaration order, the compiler decides layout.
- Array access is just index * element_size offset math, using the addressing form from earlier.
- Struct fields follow the same padding rules as standalone variables, laid out in declaration order but padded to keep each field aligned, and the whole frame padded to a multiple of 16.
IMUL, MOVSX, and MOVZX
These three instructions show up constantly once you’re reading real variable-heavy disassembly.
IMUL (signed multiply) has three common forms:
| |
Compilers favor imul over the unsigned mul, since it’s used even for values that could be either signed or unsigned, the two-operand and three-operand forms simply truncate the result either way.
MOVZX (zero-extend) and MOVSX (sign-extend) move a smaller value into a larger register:
| |
Both MOVZX and MOVSX only work from 8-bit or 16-bit sources, neither has a 32-bit-source form. To move a 32-bit value into a 64-bit register with zero-extension, you don’t need a special instruction at all, a plain 32-bit mov does it, since writing to a 32-bit register already clears the upper 32 bits of its 64-bit parent (covered earlier in Why Access Smaller Parts of a Register?):
| |
To sign-extend a 32-bit value into 64 bits, there’s a dedicated instruction, MOVSXD:
| |
The difference matters: zero-extend always pads with 0, sign-extend pads with whatever the original sign bit was, to preserve whether the value stays negative.
A Note on Garbage Values
Stack memory isn’t zeroed out when a program starts, whatever was left over from previous operations, OS setup, or runtime initialization is still sitting there. This is why uninitialized local variables can appear to hold random values before you assign them, completely normal and expected.
Function Parameters & Calling Conventions
This section covers the Microsoft x64 calling convention specifically. It differs from the System V convention used in the earlier Stack examples, most notably in which registers carry the first arguments. See Microsoft x64 vs System V (GCC) at a Glance below for the direct comparison.
A Single Parameter
| |
| |
The argument is placed in ECX before the call, ECX is the register used for the first integer parameter under the Microsoft x64 ABI. The extra mov traffic inside func (moving it from ECX to the stack and back) is typical of unoptimized builds, the compiler is just being conservative.
Multiple Parameters
| |
| |
This reveals the actual Microsoft x64 parameter order: the first four integer arguments go in RCX, RDX, R8, R9, everything beyond that gets pushed onto the stack.
Where the parameters land relative to RBP, once inside func:
| |
Notice the pattern: positive offsets from RBP ([rbp+N]) reach up into the caller’s frame to read parameters, negative offsets ([rbp-N]) reach down into your own frame for locals. Same RBP, two directions, two purposes.
Shadow Space
The Microsoft x64 ABI requires the caller to always reserve 32 bytes (4 x 8) on the stack for the callee, even if the function takes fewer than 4 parameters. That’s why you’ll see sub rsp, 0x28 (40 bytes: 32 shadow + 8 alignment) even in functions that take zero arguments.
This exists so the callee has somewhere to “spill” register parameters into memory if it needs to:
| |
Caller-Saved vs Callee-Saved Registers
A calling convention also defines who’s responsible for preserving which registers across a call:
- Caller-saved (volatile) - the callee is free to change these. If the caller still needs the value afterward, it must save it before the call and restore it after: RAX, RCX, RDX, R8, R9, R10, R11.
- Callee-saved (non-volatile) - the caller assumes these survive the call untouched. If the callee wants to use one, it must save and restore it itself: RBX, RBP, R12-R15.
| |
Microsoft x64 vs System V (GCC) at a Glance
| Feature | Microsoft x64 | System V AMD64 |
|---|---|---|
| 1st-4th arguments | RCX,RDX,R8,R9 | RDI,RSI,RDX,RCX |
| 5th/6th arguments | Stack | R8, R9 |
| 7th+ arguments | Stack | Stack |
| Shadow space | 32 bytes required | Not required |
| Max register args | 4 | 6 |
Both return values in RAX (or RDX:RAX for 128-bit results). System V simply fits two more arguments into registers and skips the shadow space requirement entirely, so its stack layout looks a bit leaner for the same function. This is also the table to check back against if you want to see exactly why the earlier Stack section (RDI, no mandatory shadow space) and this section (RCX, mandatory shadow space) look different, they’re the two rows of this table.
LEA (Load Effective Address)
LEA is the one exception to the rule that square brackets [] mean “dereference this address.” Instead, it calculates an address using the normal base + index*scale + displacement form, and loads that calculated value itself into a register, without ever touching the memory it points to.
Think of it as C’s & operator:
| |
Given:
| |
with rbx = 2 and rdx = 0x1000:
| |
rax now holds 0x1015, LEA never reads from address 0x1015, it just computed that number.
Why compilers use it for plain math, not just addresses
| |
| |
The compiler used LEA to compute 2 * argc even though nothing here is really “an address”, it recognized that the multiplication fit LEA’s base + index*scale form, and LEA is cheaper than a separate add/mul sequence. This is a common optimization pattern:
| |
Common uses, summarized:
| |
Whenever you see lea and the destination clearly isn’t a pointer, that’s usually the compiler doing cheap arithmetic, not addressing.
Control Flow
Control flow decides which instructions actually execute. There are two types:
- Conditional - goes somewhere if a condition is met (if statements, switch, loops)
- Unconditional - always goes somewhere (function calls, goto, exceptions, interrupts)
We’ve already seen function calls manifest as call/ret. Here’s how goto shows up.
| |
| |
goto is literally just jmp to the memory address of the target label.
The JMP Instruction
JMP unconditionally changes RIP to a given address. It can be encoded a few different ways:
- Short relative - RIP = next instruction’s address + a 1-byte signed displacement. Common in small loops, e.g. jmp -2 creates an infinite loop. The encoding doesn’t bake in a destination address, it just says “jump N bytes forward/backward from here.”
- Near relative - same idea, but with a 4-byte displacement, reaching much farther.
- Near/far absolute indirect - the address comes from a register or is pulled from memory (an r/m form), rather than being a fixed offset.
if Statements, CMP, and JCC
| |
| |
New instructions here: 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 the condition is true, the jump is taken, otherwise execution just falls through to the next instruction. There are dozens of these mnemonics, but many are just synonyms for each other, e.g. JNE and JNZ both check the exact same thing (zero flag == 0), they just read differently depending on context (equality vs. zero).
The RFLAGS Register
RFLAGS is the 64-bit extension of the older EFLAGS register, the upper 32 bits are reserved and unused, the lower 32 bits are EFLAGS itself. It holds single-bit status flags, set automatically by arithmetic instructions:
- Zero Flag (ZF) - set if the result was zero
- Sign Flag (SF) - set if the result’s most significant bit is 1 (i.e. negative, for signed 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 common JCC instructions:
| |
You don’t need to memorize these, in practice you’ll be stepping through a debugger and watching RFLAGS directly to see whether a jump gets taken.
Mnemonic translation:
- A = Above (unsigned notion)
- B = Below (unsigned notion)
- G = Greater than (signed notion)
- L = Less than (signed notion)
- E = Equal (same idea as Z, zero flag set)
- N = NOT (e.g. JNL = “jump if not less”)
The unsigned/signed distinction matters: 0xFFFFFFFF is above zero if treated as unsigned, but not greater than zero if treated as signed (it’s actually -1). Same bits, opposite conclusion, depending on which mnemonic family the compiler chose.
CMP: Setting the Flags
Before a conditional jump can happen, something needs to set the status flags. That’s usually CMP, TEST, or any instruction with flag-setting side effects (ADD, SUB, etc.).
CMP works by subtracting the second operand from the first, exactly like SUB, and setting the same flags (CF, OF, SF, ZF, AF, PF). The difference from SUB: with SUB the result gets stored somewhere, with CMP the result is thrown away, only the flags matter.
Reading comparisons at a glance:
| |
TEST, like CMP, is another instruction that sets flags without storing a result, but instead of subtraction, it uses bitwise AND.
| |
The most common use is test reg, reg, this is the standard way to check whether a register is zero or negative, shorter than cmp reg, 0:
- If the register is zero, ZF (zero flag) gets set, so it’s typically followed by jz/jnz
- If the register is negative (sign bit = 1), SF (sign flag) gets set, so it’s typically followed by js/jns
You’ll often see this pattern right before a conditional jump or conditional move, like in the signed shift example from Bit Shifting:
| |
switch Statements
A switch compiles down to essentially the same thing as a chain of if (x == value) checks.
| |
| |
Writing the equivalent if/else if/else chain by hand produces nearly identical assembly, at this level, switch and if really are the same construct, just different surface syntax.
Signed vs Unsigned Comparisons
The only substantive thing that changes between signed and unsigned integers is which conditional jump instructions the compiler emits:
| |
Why this matters: since the compiler emits different jump instructions depending on the variable’s declared signedness, a reverse engineer can use that difference to infer whether a variable was originally signed or unsigned in the source code, even without seeing the source.
How the hardware actually handles it: the CPU itself doesn’t care about signedness when it executes ADD or SUB, it performs the operation and sets all the status flags (zero, sign, overflow, carry, parity) regardless. It’s entirely up to the compiler to pick the correct flag(s) to check, based on whether the original high-level code declared the type as signed or unsigned.
Boolean Logic & Bitwise Operations
Boolean is just true/false. We’ll use 0 as shorthand for false and 1 for true.
| |
Logical vs Bitwise in C
Logical operators (&&, ||, !) evaluate a whole expression as true/false (non-zero counts as true, zero as false).
Bitwise operators (&, |, ^, ~) apply the operation independently to each corresponding bit position:
| |
| |
| |
New instructions here: AND, NOT, OR, and XOR, the bitwise counterparts to C’s &, ~, |, and ^.
AND, Bitwise AND
Corresponds to C’s & (not &&, that’s logical AND). The destination can be a register or memory (r/m), the source can be a register, memory, or an immediate, but source and destination can’t both be memory.
| |
Each bit position is compared independently, 1-to-1 gives 1, anything else gives 0, since AND is only true when both inputs are true.
OR, Bitwise OR
Corresponds to C’s |. Same operand rules as AND. True if either bit is 1:
| |
XOR, Bitwise Exclusive OR
Corresponds to C’s ^. True only if exactly one input bit is 1:
| |
XORing anything with itself always produces 0, which is why compilers commonly generate xor eax, eax to zero a register, it’s faster than an equivalent mov eax, 0.
NOT, One’s Complement Negation
Corresponds to C’s unary ~ (not !, that’s logical NOT). Takes a single register or memory operand, and simply flips every bit, that’s the entire operation.
For Loops
| |
| |
The shape here is familiar: a comparison (CMP) feeding a conditional jump (JLE), exactly like the if statements from before. A for loop really is just an if-style check that jumps back to itself instead of falling through.
INC/DEC (Increment/Decrement)
INC and DEC take a single register or memory operand and increase or decrease its value by 1:
| |
| |
You’d expect to see inc/dec for i++ and i--, but many compilers actually favor add/sub instead, following Intel’s own optimization guidance. So seeing add dword […], 0x1 where you’d expect inc is normal in optimized output, seeing inc/dec directly can sometimes hint that the code is hand-written or unoptimized.
INC and DEC modify OF, SF, ZF, AF, and PF, but leave CF (the carry flag) untouched. This is the actual reason the two instructions exist separately from add reg, 1 / sub reg, 1, which do affect CF. It lets you increment or decrement a counter in the middle of a longer arithmetic sequence without destroying a carry flag that a surrounding operation still depends on.
Repeatable String Instructions
x86 provides instructions that can repeat themselves automatically using the rep prefix, driven by a counter in RCX. Each iteration, RCX decrements, once it hits 0, execution moves to the next instruction.
REP STOS, Filling Memory
REP STOS (Repeat STore String) fills memory with a single repeated value, essentially a hardware-accelerated memset().
| |
Each iteration stores AL/AX/EAX/RAX at [RDI], then RDI automatically increments to the next position:
| |
Real example, zeroing a buffer:
| |
| |
What’s happening: rep stosb zeroes a 128-byte buffer in a single instruction, equivalent to memset(buffer, 0, 128). Afterward, a value gets written elsewhere on the stack, its lower 2 bytes get copied into the buffer using the same index * scale addressing you’ve already seen, and finally that same value gets read back out.
Why REP STOS is fast: writing this manually as for (int i = 0; i < 128; i++) buffer[i] = 0; requires the CPU to fetch, decode, and execute a comparison and jump every single iteration. REP STOS handles the looping internally in hardware, no per-iteration branch overhead.
Common use cases: zeroing memory (memset-style), filling arrays with a repeated value (e.g. 0xFF), initializing buffers before use.
Quick reference:
| |
RDI = destination, RAX/EAX/AX/AL = value to fill, RCX/ECX/CX = count.
REP MOVS, Copying Memory
REP MOVS copies memory from one location to another, source to destination, essentially a hardware-accelerated memcpy(). Unlike a regular MOV, MOVS can move memory to memory directly, but only between RSI (source) and RDI (destination).
| |
RSI = source, RDI = destination, RCX/ECX/CX = count.
The Direction Flag (DF)
DF controls which way REP MOVS (and similar string instructions) copy, forward (incrementing RSI/RDI) or backward (decrementing them).
| |
Why this matters for security: if an attacker can influence DF and flip a copy from forward to backward when the programmer expected forward, that can lead to memory corruption, worth knowing which direction a copy runs when auditing code. Most of the time, cld is the correct choice.
Bit Shifting
| |
| |
SHL, Shift Logical Left
Corresponds to C’s « operator. The first operand (source and destination) is a register or memory, the second is either cl (the lowest byte of RCX) or a 1-byte immediate, specifying how many places to shift.
Each shift left multiplies the value by 2, and it’s more efficient than an actual multiply instruction. Bits pushed off the left edge move into the carry flag (CF), and zeros fill in on the right (the least significant bits):
5 in binary:
| 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 |
= 5
Shifted left by 1 (x << 1):
| 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
= 10
Every bit visibly moved one position to the left, doubling the value from 5 to 10.
SHR, Shift Logical Right
Corresponds to C’s » operator (for unsigned values). Same operand rules as SHL. Each shift right divides the value by 2, more efficient than an actual divide. Bits pushed off the right edge move into CF, and zeros fill in on the left (the most significant bits):
| |
CF ends up set to 1 here, because the last bit that fell off the right edge was a 1.
Where Did the Multiply/Divide Go?
You’ll often see shl/shr in disassembly even when the C source never wrote a shift at all:
| |
| |
When a multiply or divide by a power of 2 appears in C, an optimizing compiler frequently converts it into a shift instead, since shifting is cheaper than actual multiplication or division hardware-wise. Seeing shifts here doesn’t mean the programmer wrote shifts, it usually means the compiler optimized an ordinary multiply/divide.
Signed vs Unsigned Shifting: SAR
Whether a value is signed or unsigned changes which right-shift instruction the compiler emits:
| |
For a signed divide by a power of 2, the compiler emits SAR instead of SHR:
| |
The extra lea/test/cmovs sequence beforehand exists to correctly round negative values toward zero, unsigned division doesn’t need this adjustment, which is another subtle hint that a variable was declared signed.
SAR (Shift Arithmetic Right) behaves like SHR, except the bits shifted in from the left are filled with the sign bit, not zero, preserving whether the value stays negative or positive:
| |
If the value had started positive (sign bit = 0), the vacated bit would be filled with 0 instead, exactly like SHR. This is why SAR exists as a separate instruction: it needs an extra decision (check the sign) that SHR doesn’t.
SAL, Shift Arithmetic Left
SAL behaves identically to SHL, same operand rules, same “multiply by 2 per shift” behavior, same bits shifted into CF. There’s no separate “arithmetic” concern on the left shift, since there’s no sign bit to preserve when shifting away from the least significant end.
Multiplication and Division
| |
| |
IMUL, Signed Multiply
IMUL was already introduced back in the Local Variables section, this example shows it in a more complete multiply-then-divide flow. As a refresher, its common forms:
| |
Note the added mov qword [rbp-0x8], rax above: the two-operand imul only updates the register, it doesn’t write back to memory on its own, so the product has to be stored back to a’s stack slot before the code reloads rax from [rbp-0x8] for the division. Without that store, the reload would fetch the original, un-multiplied value.
DIV, Unsigned Divide
DIV comes in three widths, and always divides a double-width dividend by the given operand:
| |
Notice the dividend spans two registers (e.g. EDX:EAX together form a 64-bit dividend for a 32-bit division). If your actual value only needs 32 or 64 bits, the compiler zeroes EDX/RDX first, that’s exactly what mov edx, 0 is doing in the example above, before the division even happens.
If the divisor is 0, a divide-by-zero exception is raised.
Example (8-bit form):
| |
Example (64-bit form):
| |
IDIV, Signed Divide
Same structure as DIV, but for signed operands, same three widths, same double-width dividend convention, same divide-by-zero exception on a zero divisor:
| |
Example:
| |
Here ax = 0xFFFE is -2 as a signed 16-bit value, so -2 / 2 = -1 remainder 0, and -1 as a signed byte is 0xFF, matching the quotient shown. The key practical difference from DIV: since the operands are interpreted as signed, negative dividends behave differently, this is exactly the same DIV vs IDIV / SHR vs SAR distinction you’ve already seen elsewhere, unsigned and signed operations need separate instructions because the hardware has to know which interpretation to apply.