Your First Assembly Program
Exit with a code, dissect every byte
- › Assemble and link a program with nasm and ld
- › Explain what a syscall is and how a program talks to the kernel
- › Trace the exact meaning of every line of a minimal program
- › Read the program's exit code and prove your code ran
The smallest real program
Today you write actual assembly, assemble it into a real executable, run it, and prove it worked. No libc, no magic, just instructions and one call into the kernel. The program does exactly one thing: exit with a chosen status code. Tiny, but every piece is real, and we will not skip a single byte.
1section .text ; the section holding executable instructions2global _start ; make the label _start visible to the linker3 4_start: ; execution begins here5 mov rax, 60 ; rax = 60 -> the syscall number for "exit"6 mov rdi, 42 ; rdi = 42 -> the first argument: the exit code7 syscall ; hand control to the kernel; it performs exit(42)What a syscall actually is
Your program runs in user mode, a restricted context that cannot directly touch hardware, files, or the screen, for safety. The kernel runs in privileged mode and can. When you need something only the kernel can do (exit, write to the screen, open a file), you make a system call: you put a number identifying the service in rax, put its arguments in the argument registers, and execute the syscall instruction. The CPU switches to the kernel, which does the work and switches back.
It is exactly like calling a function you don't own, except the "function" lives in the operating system and the calling convention is fixed by Linux.
| Register | Meaning for a syscall | In this program |
|---|---|---|
| rax | Which syscall (the number) | 60 = exit |
| rdi | 1st argument | 42 = the exit status |
| rsi | 2nd argument | (unused here) |
| rdx | 3rd argument | (unused here) |
| r10, r8, r9 | 4th, 5th, 6th arguments | (unused here) |
Line by line, nothing skipped
`section .text`, an executable is divided into sections. .text is where machine code lives (by tradition "text" means code). Other sections hold data. The assembler needs to know which bucket the following bytes belong to.
`global _start`, _start is the entry point the linker looks for, the very first instruction the OS runs. global exports the label so the linker can find it. Without this you'd get "no entry point" at link time.
`_start:`, a label: a human name for an address. When assembled, _start becomes the numeric address of the next instruction. Labels are how we avoid writing raw addresses; the assembler resolves them for us.
`mov rax, 60`, load the exit syscall number into rax. On Linux x86-64, 60 is exit. (These numbers are fixed by the kernel; you look them up in a table.)
`mov rdi, 42`, the exit code, the one argument exit takes. 42 is arbitrary, pick anything 0-255; it's what the shell will report.
`syscall`, the actual switch into the kernel. The kernel reads rax (60), sees "exit", reads rdi (42), and terminates the process with status 42. Control never returns, which is fine, because exiting is the point.
Build it and prove it ran
Two steps turn text into a running program:
1. Assemble, nasm translates your .asm into an object file (.o), which is machine code plus bookkeeping but not yet runnable.
2. Link, ld resolves labels and wraps the object into a final executable.
Then run it and read $?, the shell variable holding the last program's exit code.
1nasm -f elf64 exit.asm -o exit.o # assemble to a 64-bit ELF object2ld exit.o -o exit # link the object into an executable3./exit # run it (prints nothing)4echo $? # prints: 42 <- your exit code!echo $? printing 42 is your proof. The program produced no output, yet you can see it ran and returned exactly the code you chose. That number traveled from mov rdi, 42 → the syscall → the kernel → the shell. You just moved a value across the entire stack of the machine by hand.Peeking at the actual machine bytes
Your two-instruction core assembles into a handful of bytes. objdump shows them, the assembly you wrote next to the exact bytes the CPU fetches:
10000000000401000 <_start>:2 401000: b8 3c 00 00 00 mov eax, 0x3c ; 0x3c = 603 401005: bf 2a 00 00 00 mov edi, 0x2a ; 0x2a = 424 40100a: 0f 05 syscallLook closely, this is the whole course in one screen:
- b8 is the opcode "move an immediate into eax"; the next four bytes 3c 00 00 00 are the value 60, stored little-endian (low byte 0x3c first. Day 4 in action).
- bf is "move immediate into edi"; 2a 00 00 00 is 42, little-endian again.
- 0f 05 is the two-byte syscall opcode.
Your mnemonic mov rax, 60 and the CPU's byte b8 3c 00 00 00 are the *same instruction* at two levels of description. Assembly is a thin, human-readable skin over these bytes, and you can now read both.
mov eax, 0x3c (the 32-bit name) rather than mov rax, because writing eax already zero-extends to fill rax (Day 3's gotcha) and the 32-bit form is one byte shorter. The assembler optimized your instruction and the result is identical. Recognizing that eax-write == rax-gets-zero-extended is exactly why Day 3 hammered that point.Assemble, link, run, and change the code
Type exit.asm yourself and build it with the nasm/ld commands shown. Confirm `echo $?` prints your chosen code. Then: (1) change 42 to your age and rebuild, verify $? changes. (2) Try an exit code of 300 and explain in a comment what $? shows and why (hint: recall the byte's range from Day 1/2). (3) Run `objdump -d ./exit` and find the bytes for your exit value in the dump.
$ nasm -f elf64 exit.asm -o exit.o$ ld exit.o -o exit$ ./exit; echo $?$ objdump -d ./exit- › echo $? prints exactly the number you put in rdi
- › You located your exit value's bytes in the objdump output
- › For exit code 300 you correctly explained that $? shows 44 (300 mod 256), tying back to unsigned-byte wrap
- › You can state what the `syscall` instruction does in one sentence