Output & Running It
Hello, World
Assembly has no
printf, and on this page it has no C library at all — the linker is bare ld, so there is nothing to call. Output means asking the kernel directly. syscall is the instruction that switches to kernel mode; rax selects which service you want, and rdi, rsi, rdx carry the arguments. Here that is write(1, greeting, 14). The 10 after the string is the newline byte — there is no \n escape, just the number. $ means "the address right here", so $ - greeting is the length the assembler computes for you.#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}global _start
section .data
greeting: db "Hello, World!", 10
greeting_length: equ $ - greeting
section .text
_start:
mov rax, 1 ; syscall 1 is write
mov rdi, 1 ; file descriptor 1 is stdout
mov rsi, greeting ; address of the bytes to write
mov rdx, greeting_length ; how many bytes
syscall
mov rax, 60 ; syscall 60 is exit
xor rdi, rdi ; exit status 0
syscallThe entry point is
_start, not main. main is a C convention: the real entry point is where the kernel jumps after loading your program, and the C runtime normally sits there, sets up the environment, calls your main, and calls exit with whatever you returned. With no C library there is no runtime, so you are _start, and you must call exit yourself — falling off the end of _start does not return anywhere and will crash.Returning an Exit Status
In C,
return 3 from main becomes the process's exit status because the C runtime takes your return value and passes it to exit. Here you do that step yourself: rdi holds the status when you invoke syscall 60.#include <stdio.h>
int main(void) {
printf("exiting with 3\n");
return 3;
}global _start
section .data
message: db "exiting with 3", 10
message_length: equ $ - message
section .text
_start:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, message_length
syscall
mov rax, 60 ; exit
mov rdi, 3 ; this is the process exit status
syscallOnly the low 8 bits of the status survive — the kernel passes them to the parent through
wait, so mov rdi, 256 would be reported as 0. This is equally true of C's return value; assembly just makes it visible.Comments and Labels
A semicolon begins a comment. A label is any name followed by a colon, and it is simply a name for the address of the next byte — there is no declaration and no type. A label beginning with a dot is local:
.finish here really means _start.finish, so a different function can have its own .finish without colliding.#include <stdio.h>
int main(void) {
/* a block comment */
// a line comment
int counter = 0;
counter = counter + 1;
printf("%d\n", counter);
return 0;
}global _start
section .data
; a semicolon starts a comment and runs to end of line
digit: db "1", 10
section .text
_start:
mov rax, 1
mov rdi, 1
mov rsi, digit
mov rdx, 2
syscall
.finish: ; a label starting with . is local to the
mov rax, 60 ; previous non-local label (_start here)
xor rdi, rdi
syscallLocal labels are how assembly gets by without C's block scope. There are no braces and no scopes at all — every non-local label is visible to the whole file, and
global makes one visible to the linker as well.There Are No Variables, Only Registers
Registers Instead of Local Variables
There are sixteen 64-bit general-purpose registers, and they are the only fast storage a program has. A C local variable is a name the compiler assigns to a register, or to a slot on the stack when it runs out of registers. In assembly you make that assignment yourself and you must remember it — nothing checks that
rax still holds what you think.#include <stdio.h>
int main(void) {
long first = 10;
long second = 32;
long total = first + second;
printf("%ld\n", total);
return 0;
}global _start
section .bss
output_buffer: resb 8
section .text
_start:
mov rax, 10 ; "first"
mov rbx, 32 ; "second"
add rax, rbx ; "total" — rax is now 42
; turn the two-digit number in rax into ASCII by hand
mov rcx, 10
xor rdx, rdx
div rcx ; rax = 42/10 = 4, rdx = 42%10 = 2
add al, '0'
add dl, '0'
mov [output_buffer], al
mov [output_buffer + 1], dl
mov byte [output_buffer + 2], 10
mov rax, 1
mov rdi, 1
mov rsi, output_buffer
mov rdx, 3
syscall
mov rax, 60
xor rdi, rdi
syscallNotice how much of this example is printing. A C programmer gets number-to-text conversion free from
printf; here it is a division, two adds to reach ASCII, and three stores. div is peculiar: it divides the 128-bit value in rdx:rax by its operand, leaving the quotient in rax and the remainder in rdx, which is why rdx must be zeroed first.One Register, Four Widths
C casts a wide integer to a narrow one with
(uint8_t). Assembly does not cast, because the narrow value is already there: rax, eax, ax and al are four names for overlapping parts of the same register. Writing al changes the low byte of rax and leaves the rest alone.#include <stdio.h>
#include <stdint.h>
int main(void) {
uint64_t wide = 0x1122334455667788;
uint8_t low_byte = (uint8_t)wide; /* 0x88 — like reading al */
printf("%u\n", low_byte & 0x0f); /* keep the low nibble: 8 */
return 0;
}global _start
section .data
result: db "0", 10
section .text
_start:
mov rax, 0x1122334455667788
; eax is the low 32 bits -> 0x55667788
; ax is the low 16 bits -> 0x7788
; al is the low 8 bits -> 0x88
; ah is bits 8..15 -> 0x77
mov bl, al ; bl = 0x88
and bl, 0x0f ; keep the low nibble -> 8
add bl, '0' ; make it ASCII
mov [result], bl
mov rax, 1
mov rdi, 1
mov rsi, result
mov rdx, 2
syscall
mov rax, 60
xor rdi, rdi
syscallThere is one large exception, and it catches people constantly: writing to a 32-bit name zeroes the upper 32 bits.
mov eax, 1 clears all of rax, while mov ax, 1 and mov al, 1 leave the upper bits untouched. This is why xor eax, eax is the idiomatic way to zero the whole 64-bit register — it is a shorter instruction than xor rax, rax and has the same effect.Where Your Data Lives
Initialized, Zeroed, and Code
A C program's storage classes correspond directly to sections in the object file, and assembly names them out loud.
.data holds bytes that are physically present in the executable. .bss records only a size; the kernel supplies zeroed pages at load time, which is why a large zero-filled array costs nothing on disk. .text holds instructions and is mapped read-only.#include <stdio.h>
static char initialized[] = "ready"; /* .data — bytes stored in the file */
static char zeroed[8]; /* .bss — just a size, no bytes */
int main(void) { /* .text — the code itself */
zeroed[0] = initialized[0];
printf("%c\n", zeroed[0]);
return 0;
}global _start
section .data
initialized: db "ready", 10 ; bytes really stored in the binary
section .bss
zeroed: resb 8 ; 8 bytes of nothing, zeroed at load
section .text
_start:
mov al, [initialized] ; grab 'r'
mov [zeroed], al ; put it in the .bss buffer
mov byte [zeroed + 1], 10 ; newline
mov rax, 1
mov rdi, 1
mov rsi, zeroed
mov rdx, 2
syscall
mov rax, 60
xor rdi, rdi
syscalldb declares bytes and resb reserves them — the difference is exactly the difference between .data and .bss. Note that a section is not a permission: it is a hint the assembler records, and the linker decides the actual page protections.Brackets Mean Dereference
This is the single most important piece of syntax on the page, and it is the one that trips up every C programmer. A bare label is its address. Square brackets around it mean the contents at that address. So
mov rax, value is C's &value, and mov rbx, [value] is C's value.#include <stdio.h>
static long value = 7;
int main(void) {
long *pointer = &value;
long copy = *pointer; /* dereference */
printf("%ld\n", copy);
return 0;
}global _start
section .data
value: dq 7 ; dq = define quadword (8 bytes)
result: db "0", 10
section .text
_start:
mov rax, value ; rax = the ADDRESS of value
mov rbx, [value] ; rbx = the CONTENTS of value (7)
add bl, '0' ; make it printable
mov [result], bl
mov rax, 1
mov rdi, 1
mov rsi, result
mov rdx, 2
syscall
mov rax, 60
xor rdi, rdi
syscallC has this backwards from assembly's point of view: a C variable name means its contents, and you need
& to get the address. Assembly's default is the address, and you need brackets to get the contents. Once that inversion is internalized, most of the rest of the syntax follows.Arithmetic & The Flags Register
Add, Subtract, Multiply
Arithmetic instructions take two operands and write the result into the first, so
add rax, 3 is exactly C's rax += 3. There is no three-operand form for most instructions and no expression nesting — a compound expression becomes a sequence of these, which is precisely what a compiler spends its time producing.#include <stdio.h>
int main(void) {
long running = 7;
running += 3; /* 10 */
running -= 4; /* 6 */
running *= 7; /* 42 */
printf("%ld\n", running);
return 0;
}global _start
section .bss
output: resb 4
section .text
_start:
mov rax, 7
add rax, 3 ; rax = 10 (C: running += 3)
sub rax, 4 ; rax = 6 (C: running -= 4)
imul rax, 7 ; rax = 42 (C: running *= 7)
xor rdx, rdx
mov rcx, 10
div rcx ; rax = 4, rdx = 2
add al, '0'
add dl, '0'
mov [output], al
mov [output + 1], dl
mov byte [output + 2], 10
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 3
syscall
mov rax, 60
xor rdi, rdi
syscallimul is the signed multiply and is what you almost always want; mul is unsigned and, in its one-operand form, writes a 128-bit result across rdx:rax. Note there is no idiv rax, 10 — division is the one operation that insists on its fixed rdx:rax register pair.The Flags Nobody Declares
Every arithmetic instruction quietly updates a flags register: zero, sign, carry and overflow.
cmp is a subtract that throws the result away and keeps only the flags, so it is C's ==, < and > all at once — which comparison you meant is decided by the jump you write next, not by the cmp.#include <stdio.h>
int main(void) {
long left = 5;
long right = 5;
if (left - right == 0) {
printf("equal\n");
} else {
printf("different\n");
}
return 0;
}global _start
section .data
equal_message: db "equal", 10
equal_length: equ $ - equal_message
different_message: db "different", 10
different_length: equ $ - different_message
section .text
_start:
mov rax, 5
mov rbx, 5
cmp rax, rbx ; computes rax - rbx, keeps ONLY the flags
jne .different ; jump if the zero flag is clear
mov rsi, equal_message
mov rdx, equal_length
jmp .print
.different:
mov rsi, different_message
mov rdx, different_length
.print:
mov rax, 1
mov rdi, 1
syscall
mov rax, 60
xor rdi, rdi
syscallThis is a hidden global variable that every instruction can write and that nothing declares. It is also why instruction order matters in ways C never shows you: putting an unrelated
add between the cmp and the jne would clobber the flags and silently change the branch.Control Flow: cmp and jump
if / else
An
if becomes a comparison and a jump — and note the jump is inverted. C says "if the condition holds, run this block"; assembly says "if the condition fails, jump past this block". Compilers emit the inverted form because it keeps the common path falling straight through, which the processor predicts better.#include <stdio.h>
int main(void) {
long value = 7;
if (value > 5) {
printf("big\n");
} else {
printf("small\n");
}
return 0;
}global _start
section .data
big_message: db "big", 10
big_length: equ $ - big_message
small_message: db "small", 10
small_length: equ $ - small_message
section .text
_start:
mov rax, 7
cmp rax, 5
jle .small ; jump if less-or-equal (signed)
mov rsi, big_message
mov rdx, big_length
jmp .print
.small:
mov rsi, small_message
mov rdx, small_length
.print:
mov rax, 1
mov rdi, 1
syscall
mov rax, 60
xor rdi, rdi
syscallPick the signed or unsigned mnemonic deliberately:
jle/jg are signed, jbe/ja are unsigned. They read the same flags differently, and choosing the wrong one is the assembly version of C's signed/unsigned comparison warnings — except nothing warns you.A Counted Loop
A
for loop is four separate things that C packs onto one line: an initializer, a test, a body, and an increment. Assembly makes you write them in their real order, with the test at the top and an unconditional jump at the bottom.#include <stdio.h>
int main(void) {
for (int index = 0; index < 5; index++) {
putchar('*');
}
putchar('\n');
return 0;
}global _start
section .bss
buffer: resb 6
section .text
_start:
mov rcx, 0 ; the loop counter, C's "index"
.loop:
cmp rcx, 5 ; index < 5 ?
jge .done ; if not, leave the loop
mov byte [buffer + rcx], '*'
inc rcx ; index++
jmp .loop
.done:
mov byte [buffer + 5], 10
mov rax, 1
mov rdi, 1
mov rsi, buffer
mov rdx, 6
syscall
mov rax, 60
xor rdi, rdi
syscall[buffer + rcx] is real addressing arithmetic done by the processor, not by you — the address is computed as part of the instruction and costs nothing extra. That is the same machinery behind C's buffer[index], which is the subject of the arrays section.Functions, The Stack & The Calling Convention
call and ret
A function is a label, and calling one is a jump that remembers where it came from.
call pushes the address of the following instruction onto the stack and jumps; ret pops that address and jumps back. That is the entire mechanism — there is no function object, no signature, and nothing that checks you called it correctly.#include <stdio.h>
static void announce(void) {
printf("inside\n");
}
int main(void) {
announce();
printf("back\n");
return 0;
}global _start
section .data
inside_text: db "inside", 10
inside_length: equ $ - inside_text
back_text: db "back", 10
back_length: equ $ - back_text
section .text
announce:
mov rax, 1
mov rdi, 1
mov rsi, inside_text
mov rdx, inside_length
syscall
ret ; pops the return address and jumps to it
_start:
call announce ; pushes the address of the next instruction
mov rax, 1
mov rdi, 1
mov rsi, back_text
mov rdx, back_length
syscall
mov rax, 60
xor rdi, rdi
syscallBecause the return address lives on the stack like any other value, anything that corrupts the stack corrupts control flow. Writing past the end of a stack buffer overwrites the address
ret will jump to, which is the whole basis of stack-smashing attacks. C's stack canaries and -fstack-protector exist to defend a mechanism that is this bare underneath.Passing Arguments
C's parameter list is a promise about registers. The System V AMD64 ABI — what Linux and macOS both use — says the first six integer arguments arrive in
rdi, rsi, rdx, rcx, r8, r9, in that order, and the return value comes back in rax. Arguments past the sixth go on the stack.#include <stdio.h>
static long add_three(long first, long second, long third) {
return first + second + third;
}
int main(void) {
printf("%ld\n", add_three(20, 20, 2));
return 0;
}global _start
section .bss
output: resb 4
section .text
; System V AMD64: arguments arrive in rdi, rsi, rdx, rcx, r8, r9
; and the return value goes back in rax.
add_three:
mov rax, rdi
add rax, rsi
add rax, rdx
ret
_start:
mov rdi, 20 ; first
mov rsi, 20 ; second
mov rdx, 2 ; third
call add_three ; rax = 42
mov rcx, 10
xor rdx, rdx
div rcx
add al, '0'
add dl, '0'
mov [output], al
mov [output + 1], dl
mov byte [output + 2], 10
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 3
syscall
mov rax, 60
xor rdi, rdi
syscallNothing enforces this. If you load the wrong register the program compiles, links, and runs — it simply computes with whatever garbage that register held. A C compiler's type checking exists entirely above this layer; down here, a calling convention is a convention in the literal sense, honored only because both sides agreed to.
A Stack Frame By Hand
When a C function has more locals than there are spare registers, the compiler puts them on the stack — and this
push rbp / mov rbp, rsp / sub rsp, N opening is the prologue you will see at the top of almost every compiled function. rbp becomes a fixed anchor, so [rbp - 8] and [rbp - 16] name locals the way C names them with identifiers.The closing mov rsp, rbp / pop rbp is the epilogue, and it is why locals cost nothing to free: the whole frame vanishes with one register assignment.
#include <stdio.h>
static long scratch_work(long input) {
long doubled = input * 2; /* the compiler finds room for these */
long offset = doubled + 2;
return offset;
}
int main(void) {
printf("%ld\n", scratch_work(20));
return 0;
}global _start
section .bss
output: resb 4
section .text
scratch_work:
push rbp ; save the caller's frame pointer
mov rbp, rsp ; this frame starts here
sub rsp, 16 ; carve out 16 bytes of locals
mov [rbp - 8], rdi ; a local: the argument
mov rax, [rbp - 8]
imul rax, 2 ; "doubled"
mov [rbp - 16], rax ; another local
mov rax, [rbp - 16]
add rax, 2 ; "offset"
mov rsp, rbp ; discard the locals
pop rbp ; restore the caller's frame pointer
ret
_start:
mov rdi, 20
call scratch_work ; rax = 42
mov rcx, 10
xor rdx, rdx
div rcx
add al, '0'
add dl, '0'
mov [output], al
mov [output + 1], dl
mov byte [output + 2], 10
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 3
syscall
mov rax, 60
xor rdi, rdi
syscallThis is exactly what C's automatic storage duration is. A local "goes out of scope" because
rsp moved back, and the bytes are still sitting there untouched — which is why returning a pointer to a local is undefined behavior that so often appears to work. Nothing was erased; the next call simply writes over it.Pointers Are Just Numbers
lea: Address-Of Without a Memory Access
lea means "load effective address". It uses the same bracket syntax as a memory read but performs no memory access — it computes the address and stops. So lea rbx, [values + 16] is C's &values[2], while mov rax, [rbx] is the dereference.#include <stdio.h>
int main(void) {
long values[4] = {1, 2, 3, 4};
long *third = &values[2]; /* compute an address */
long contents = *third; /* read through it */
printf("%ld\n", contents);
return 0;
}global _start
section .data
values: dq 1, 2, 3, 4
output: db "0", 10
section .text
_start:
lea rbx, [values + 16] ; rbx = ADDRESS of values[2] (C: &values[2])
mov rax, [rbx] ; rax = CONTENTS there = 3 (C: *third)
add al, '0'
mov [output], al
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 2
syscall
mov rax, 60
xor rdi, rdi
syscallCompilers also use
lea as a sneaky three-operand arithmetic instruction: lea rax, [rbx + rcx*4 + 8] computes a multiply-and-add in one go without touching memory or the flags. If you have ever wondered why lea shows up in compiler output for code containing no pointers at all, that is why.Pointer Arithmetic Is Not Scaled For You
In C,
cursor + 1 advances by sizeof(*cursor) bytes — the compiler silently multiplies by the element size, which is why the same expression moves 1 byte for a char * and 8 for a long *. Assembly has no types, so it cannot do that for you: you add the byte count.#include <stdio.h>
int main(void) {
long values[3] = {7, 8, 9};
long *cursor = values;
cursor = cursor + 1; /* C scales by sizeof(long) = 8 automatically */
printf("%ld\n", *cursor);
return 0;
}global _start
section .data
values: dq 7, 8, 9
output: db "0", 10
section .text
_start:
lea rbx, [values]
add rbx, 8 ; "+ 1" element means + 8 BYTES, and you
; must write the 8 yourself
mov rax, [rbx] ; rax = 8
add al, '0'
mov [output], al
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 2
syscall
mov rax, 60
xor rdi, rdi
syscallThis is the clearest demonstration that a C pointer is not just an address — it is an address plus a type, and the type is what makes the arithmetic work. Strip the type away and the address is only a number, which is exactly what a register holds.
Arrays & Structs Are Just Offsets
Indexing With Scale
[base + index*scale + displacement] is a single addressing mode the processor computes as part of the instruction — no extra multiply, no extra instruction. The scale may only be 1, 2, 4 or 8, which is not a coincidence: those are the sizes of the integer types.#include <stdio.h>
int main(void) {
long values[5] = {0, 1, 2, 3, 4};
long index = 4;
printf("%ld\n", values[index]);
return 0;
}global _start
section .data
values: dq 0, 1, 2, 3, 4
output: db "0", 10
section .text
_start:
mov rcx, 4 ; the index
mov rax, [values + rcx*8] ; base + index*scale, done by the CPU
; rax = 4
add al, '0'
mov [output], al
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 2
syscall
mov rax, 60
xor rdi, rdi
syscallSo C's
values[index] really is one instruction, and the notorious identity values[index] == *(values + index) == index[values] stops being a party trick here — the addressing mode is symmetric in base and index, so the two spellings assemble to the same thing.A Struct Is Just Offsets
A struct does not exist at runtime. A field name is a compile-time constant the compiler adds to a base address, so
record.score becomes [record + 4] and nothing more. The struct definition is documentation for the compiler about which numbers to add.#include <stdio.h>
#include <stdint.h>
struct Record {
uint32_t identifier; /* offset 0 */
uint32_t score; /* offset 4 */
};
int main(void) {
struct Record record = { .identifier = 1, .score = 7 };
printf("%u\n", record.score);
return 0;
}global _start
section .data
; struct Record { uint32_t identifier; uint32_t score; }
record: dd 1 ; offset 0: identifier
dd 7 ; offset 4: score
output: db "0", 10
section .text
_start:
; "record.score" is nothing but "the 4 bytes at record + 4"
mov eax, [record + 4]
add al, '0'
mov [output], al
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 2
syscall
mov rax, 60
xor rdi, rdi
syscallThis is why struct padding and alignment matter so much in C: the offsets are baked into every access, so inserting a field changes the meaning of compiled code elsewhere. It is also why two different structs with the same layout are interchangeable at this level — the machine cannot tell them apart, and only C's type system objects.
Strings Without strlen
strlen, Written Out
strlen is a loop that walks forward looking for a zero byte, and here it is. Note what this means about cost: the length of a C string is not stored anywhere, so finding it is O(n) and must re-scan every single time you ask.#include <stdio.h>
#include <string.h>
int main(void) {
const char *text = "counted";
printf("%zu\n", strlen(text));
return 0;
}global _start
section .data
text: db "counted", 0 ; the 0 is the NUL terminator
output: db "0", 10
section .text
_start:
lea rbx, [text]
xor rcx, rcx ; length = 0
.scan:
cmp byte [rbx + rcx], 0 ; reached the NUL?
je .done
inc rcx
jmp .scan
.done:
; rcx = 7
add cl, '0'
mov [output], cl
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 2
syscall
mov rax, 60
xor rdi, rdi
syscallThe NUL terminator is a convention, not a property of memory. Nothing marks these bytes as "a string" —
db "counted", 0 is eight bytes, and calling the last one a terminator is an agreement between whoever wrote the data and whoever reads it. A missing zero means the loop keeps scanning into whatever follows, which is the mechanism behind a large fraction of C's security history.write Takes a Length, Not a Terminator
Every example on this page has already been doing this. The
write syscall takes a pointer and a byte count, so it never looks for a terminator and cannot run off the end. The NUL convention belongs to the C library, not to the kernel.#include <stdio.h>
#include <unistd.h>
int main(void) {
/* write() takes an explicit length and ignores NUL entirely */
const char raw[] = {'h', 'e', 'r', 'e', '\n'};
write(1, raw, sizeof raw);
return 0;
}global _start
section .data
; no terminator anywhere — the length is passed explicitly
raw: db "here", 10
raw_length: equ $ - raw
section .text
_start:
mov rax, 1
mov rdi, 1
mov rsi, raw
mov rdx, raw_length ; 5 bytes, and the kernel writes exactly 5
syscall
mov rax, 60
xor rdi, rdi
syscallThis is the same distinction as C's
strcpy versus memcpy, or char * versus a pointer-and-length pair. Languages designed after C — Rust's &str, Go's slices, Pascal's counted strings — almost all chose to carry the length instead, and the syscall interface shows that the operating system agreed all along.Syscalls: Below The Standard Library
Choosing a File Descriptor
A file descriptor is a small integer index into a per-process table the kernel keeps.
stdout and stderr are not objects — they are the numbers 1 and 2, and C's FILE * is a buffering layer wrapped around them.#include <stdio.h>
int main(void) {
fprintf(stdout, "to stdout\n");
fprintf(stderr, "to stderr\n");
return 0;
}global _start
section .data
out_text: db "to stdout", 10
out_length: equ $ - out_text
error_text: db "to stderr", 10
error_length: equ $ - error_text
section .text
_start:
mov rax, 1
mov rdi, 1 ; fd 1 = stdout
mov rsi, out_text
mov rdx, out_length
syscall
mov rax, 1
mov rdi, 2 ; fd 2 = stderr
mov rsi, error_text
mov rdx, error_length
syscall
mov rax, 60
xor rdi, rdi
syscallBecause they are just integers, redirection is trivial: the shell's
2>&1 makes entry 2 point at whatever entry 1 points at, before your program ever runs. Nothing in the program changes, and nothing in it could tell.What a Syscall Destroys
The
syscall instruction has side effects beyond the service it performs: it overwrites rcx and r11 (the processor stores the return address and flags there), and rax comes back holding the kernel's return value rather than what you put in it.#include <stdio.h>
#include <unistd.h>
int main(void) {
long keep = 42;
write(1, "wrote\n", 6);
/* C guarantees "keep" survives the call — the compiler arranges it */
printf("%ld\n", keep);
return 0;
}global _start
section .data
wrote_text: db "wrote", 10
wrote_length: equ $ - wrote_text
section .bss
output: resb 4
section .text
_start:
mov rbx, 42 ; rbx is CALLEE-saved, so it survives
mov rax, 1
mov rdi, 1
mov rsi, wrote_text
mov rdx, wrote_length
syscall ; destroys rax, rcx and r11
mov rax, rbx ; still 42
xor rdx, rdx
mov rcx, 10
div rcx
add al, '0'
add dl, '0'
mov [output], al
mov [output + 1], dl
mov byte [output + 2], 10
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 3
syscall
mov rax, 60
xor rdi, rdi
syscallA C compiler knows this and spills anything it needs across the call. Writing assembly by hand you must know it yourself, and the failure is silent — a loop counter kept in
rcx across a write simply becomes garbage, and the loop runs a nonsensical number of times with no diagnostic anywhere.Bit Manipulation
Shifts Instead of Multiply and Divide
A left shift by one doubles; a right shift by one halves. These map to C's
<< and >> exactly, and they are dramatically cheaper than imul and div — which is why compilers turn multiplication by any power of two into a shift without being asked.#include <stdio.h>
int main(void) {
long value = 4;
printf("%ld %ld\n", value << 1, value >> 1);
return 0;
}global _start
section .bss
output: resb 4
section .text
_start:
mov rax, 4
shl rax, 1 ; 8 — same as multiplying by 2
mov rbx, 4
shr rbx, 1 ; 2 — same as dividing by 2
add al, '0'
add bl, '0'
mov [output], al
mov byte [output + 1], ' '
mov [output + 2], bl
mov byte [output + 3], 10
mov rax, 1
mov rdi, 1
mov rsi, output
mov rdx, 4
syscall
mov rax, 60
xor rdi, rdi
syscallChoose
shr or sar deliberately: shr shifts in zeros, while sar replicates the sign bit so negative numbers stay negative. C's >> picks between them based on whether the type is signed — one more piece of work the type system does that vanishes at this level.Masking and Testing Bits
test is to and what cmp is to sub: it performs the operation purely to set the flags and discards the result. So test rax, 2 followed by jz is precisely C's if (flags & 2), with the jump inverted as usual.#include <stdio.h>
int main(void) {
unsigned long flags = 0b1010;
if (flags & 0b0010) {
printf("bit set\n");
} else {
printf("bit clear\n");
}
return 0;
}global _start
section .data
set_text: db "bit set", 10
set_length: equ $ - set_text
clear_text: db "bit clear", 10
clear_length: equ $ - clear_text
section .text
_start:
mov rax, 0b1010
test rax, 0b0010 ; AND, keep only the flags
jz .clear ; zero flag set => the bit was clear
mov rsi, set_text
mov rdx, set_length
jmp .print
.clear:
mov rsi, clear_text
mov rdx, clear_length
.print:
mov rax, 1
mov rdi, 1
syscall
mov rax, 60
xor rdi, rdi
syscalltest rax, rax against itself is the idiomatic null check — it sets the zero flag exactly when the register is zero, and it is shorter and faster than cmp rax, 0. You will see it constantly in compiler output wherever C tested a pointer or an integer for truthiness.Gotchas For C Developers
There Is No printf
On a normal Linux system you can call
printf from assembly — you link with gcc, obey the calling convention, and set rax to the number of vector registers used. This page does not, because it links with bare ld, and that constraint is deliberate: it shows what is genuinely underneath.#include <stdio.h>
int main(void) {
/* printf comes from libc, which the linker finds automatically */
printf("%s %d\n", "answer", 42);
return 0;
}global _start
; This page links with bare ld and NO C library, so a "call puts" here would
; fail with: undefined reference to 'puts'. Everything must be a syscall.
section .data
text: db "answer 42", 10
text_length: equ $ - text
section .text
_start:
mov rax, 1
mov rdi, 1
mov rsi, text
mov rdx, text_length
syscall
mov rax, 60
xor rdi, rdi
syscallThe practical consequence is that every number you want to display must be converted to text by hand, which is why so many examples here end with a division loop. That conversion is a real part of what
printf does for you, and it is worth seeing once.The Stack Must Be 16-Byte Aligned
The System V ABI requires
rsp to be a multiple of 16 immediately before a call. Your C compiler guarantees this silently on every call in every program you have ever written.#include <stdio.h>
/* The compiler keeps the stack 16-byte aligned at every call for you.
You never see this, and it never goes wrong. */
static void helper(void) {
printf("aligned\n");
}
int main(void) {
helper();
return 0;
}global _start
section .data
message: db "aligned", 10
message_length: equ $ - message
section .text
helper:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, message_length
syscall
ret
_start:
; At _start the stack is 16-byte aligned. A "call" pushes an 8-byte
; return address, so INSIDE helper it is misaligned by 8 -- which is
; exactly the state the ABI promises, and why prologues push rbp.
call helper
mov rax, 60
xor rdi, rdi
syscallNothing here needs the alignment, because syscalls do not care. It matters the moment you call into a library that uses SSE: instructions like
movaps fault on an unaligned address, so a misaligned stack shows up as a crash deep inside code you did not write. This is the most common reason hand-written assembly segfaults only when it calls libc.