PONYλM2Modula-2

C.CodeCompared.To/Assembly

An interactive executable cheatsheet comparing C and Assembly

C17 (GCC) x86-64 (NASM 3.01)
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 syscall
The 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 syscall
Only 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 syscall
Local 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 syscall
Notice 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 syscall
There 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 syscall
db 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 syscall
C 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 syscall
imul 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 syscall
This 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 syscall
Pick 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 syscall
Because 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 syscall
Nothing 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 syscall
This 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 syscall
Compilers 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 syscall
This 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 syscall
So 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 syscall
This 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 syscall
The 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 syscall
This 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 syscall
Because 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 syscall
A 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 syscall
Choose 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 syscall
test 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 syscall
The 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 syscall
Nothing 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.