Assembly Toolchain & First Program
What You Need
A bare-metal assembly program needs exactly two tools. Nothing else — no IDE, no framework, no package manager.
The Assembler: NASM
NASM (the Netwide Assembler) turns your text into an object file — machine code plus a list of symbols that are not yet resolved. It is small, fast, and its Intel-syntax manual is the reference used throughout this track. Two flags matter from the start: -f chooses the output format, and -o names the file it writes.
The alternatives are GNU as (AT&T syntax, part of binutils) and MASM/YASM (Microsoft-oriented). If you read Linux kernel code you will see GAS output; if you use MSVC you will see MASM. The ideas are identical — only the spelling changes.
The Linker
An object file cannot run. Addresses inside it are relative or named, and something must decide where everything finally lives and connect your code to the operating system. That "something" is the linker:
ld— the raw linker. Use it for syscall-only programs. It assumes the entry point is_startand adds no runtime.gcc/clang— a compiler driver that also links. Use it when your program calls C library functions; it adds the C runtime and looks formain.
Installing the Tools
Install NASM and binutils with your system's package manager, then verify the version:
# Debian / Ubuntu / WSL2
sudo apt update && sudo apt install nasm binutils gdb
# Fedora / RHEL
sudo dnf install nasm binutils gdb
# Arch
sudo pacman -S nasm binutils gdb
# macOS (Homebrew) — assembles Mach-O, not ELF
brew install nasm
# Verify
nasm -v # should print something like: NASM version 2.16.01
ld --version # GNU ld (GNU Binutils)
gdb is not needed until the debugging lesson, but installing it now saves a step later. objdump, readelf, and nm all arrive with binutils — they are your window into what the assembler actually produced.
Your First Program
Here is a complete, runnable Linux x86-64 program that prints a line and exits. It uses the kernel directly — no C library — so you can see every byte of what it does.
The Source File
; hello.asm — write a line to stdout, then exit (Linux x86-64, no libc)
; Build: nasm -f elf64 hello.asm -o hello.o
; Link: ld hello.o -o hello
; Run: ./hello
section .rodata ; read-only data
msg db "Hello, world!", 10 ; 10 is the newline character '\n'
msg_len equ $ - msg ; constant = byte length of msg
section .text ; executable code
global _start ; the linker's entry symbol
_start:
mov rax, 1 ; rax = syscall number 1 = write
mov rdi, 1 ; rdi = file descriptor 1 = stdout
mov rsi, msg ; rsi = address of the first byte to write
mov rdx, msg_len ; rdx = number of bytes to write
syscall ; transfer control to the kernel
mov rax, 60 ; rax = syscall number 60 = exit
xor rdi, rdi ; rdi = exit status 0
; xor of a register with itself is the
; cheapest way to load the constant 0
syscall ; exit; this never returns
Read it as a conversation with the kernel. First you place arguments in registers — the syscall number in rax, and up to six parameters in rdi, rsi, rdx, r10, r8, r9. Then syscall switches the CPU into kernel mode, performs the request, and returns with a result in rax.
The Same Program with libc
Once you link against the C library, you may use main and printf instead of raw syscalls. The program becomes shorter and portable, at the cost of no longer seeing exactly which system calls happen.
; hello_libc.asm — the same output through libc's printf
; Build: nasm -f elf64 hello_libc.asm -o hello_libc.o
; Link: gcc hello_libc.o -o hello_libc (gcc adds the C runtime)
; Run: ./hello_libc
section .rodata
fmt db "Hello from libc, world!", 10, 0 ; 0 terminates the string
section .text
global main ; gcc's entry point is main, not _start
extern printf ; printf is provided by the C library
main:
push rbp ; save the caller's frame pointer
mov rbp, rsp ; establish our own frame (see Subprograms)
lea rdi, [rel fmt] ; arg 1: pointer to the format string
; [rel ...] keeps the code position-independent
xor eax, eax ; AL = 0: no vector arguments are passed
call printf ; call the C library
xor eax, eax ; return 0 to the runtime
pop rbp ; restore the frame pointer
ret ; return to the C runtime, which exits
The push rbp / mov rbp, rsp pair is not decoration — the System V ABI requires it, and printf will misbehave without a properly aligned stack. Stack frames are the subject of Subprograms & the Stack.
10 in msg_len equ $ - msg to 5 and rebuild. You will see only Hello — proof that the kernel writes exactly as many bytes as you tell it, no more and no less.
Anatomy of the Source File
Before writing anything, read a source file the way the assembler does: a list of lines, each belonging to a section, each either a directive that shapes the file or an instruction that becomes a byte.
Sections
A section tells the linker what kind of memory the following bytes belong in. You will use four of them:
| Section | Contents | Writable? |
|---|---|---|
section .text | Instructions — the code itself | No (read + execute) |
section .rodata | Constants and strings that never change | No (read only) |
section .data | Initialized global variables | Yes |
section .bss | Uninitialized variables, reserved at run time | Yes |
Putting a string in .rodata instead of .data is not cosmetic: the operating system can share a read-only page between processes, and a stray write there crashes loudly instead of silently corrupting data.
The Entry Point
The global directive exports a symbol so the linker can see it, and the symbol named _start is where the process begins when you link with ld. Without it you get cannot find entry symbol _start. There is no main function and no runtime in front of you — the first instruction at _start is the first instruction executed.
section .text
global _start ; export this symbol; the linker uses it as the entry point
_start: ; execution begins at this exact byte
; instructions go here
Labels and Constants
A label is a name for an address. Written as name: it marks the current position; written inside an expression it takes the value of that address. The special symbol $ means "the address here", which makes length calculations trivial.
section .rodata
msg db "Hello", 10 ; msg is a label pointing at these bytes
msg_len equ $ - msg ; equ defines a compile-time constant:
; ($) current address minus (msg) = 6 bytes
newline db 10 ; a second label for a single byte
section .data
counter dd 0 ; a 32-bit writable variable, initialized to 0
buffer times 64 db 0 ; 64 zero bytes (times repeats the definition)
equ creates a constant that exists only while assembling — it costs no memory and produces no bytes. That is the difference between equ and db: one is a number for the assembler, the other is a byte for the program.
The Edit–Assemble–Run Loop
The whole development cycle is four steps. Run them in a shell in the directory that holds your source file:
# 1. Assemble: text -> object file (.o). -f elf64 says "64-bit Linux ELF".
nasm -f elf64 hello.asm -o hello.o
# 2. Link: object file -> executable. -o names the output.
ld hello.o -o hello
# 3. Run it.
./hello
# 4. Look at what you produced (optional but educational).
objdump -d hello | less
Add -g -F dwarf to the nasm line while learning. That embeds debug information so GDB can show your source lines instead of raw addresses, and it changes nothing about how the program runs.
If you link with gcc instead of ld, the entry point becomes main rather than _start, and the C runtime is initialized for you before main is called. That is the version you want as soon as you need printf or any other library function.
Linux, macOS, Windows
The assembly source is nearly identical across systems; the differences are the object-file format and the way you ask the operating system for services.
| System | NASM format | Linker | Entry point |
|---|---|---|---|
| Linux x86-64 | elf64 | ld or gcc | _start (or main) |
| macOS (Intel or Apple silicon) | macho64 | ld (via clang) | _main — note the underscore |
| Windows (native) | win64 | link.exe / golink | main |
| Windows (WSL2) | elf64 | ld or gcc | _start |
The recommendation for this track is Linux or WSL2. Syscall numbers and the System V calling convention are the same everywhere on Linux, the toolchain installs with one command, and every example here has been written against that target.
Troubleshooting Common Errors
Every one of these messages has a boring, specific cause. Learn them once and you will never lose an hour to them again.
| Message | What it really means | Fix |
|---|---|---|
parser: instruction expected | A line starts with something NASM does not recognize as an instruction, usually a label without its colon. | Write start: not start. |
ld: warning: cannot find entry symbol _start | The linker has no idea where execution begins. | Add global _start in the source. |
relocation R_X86_64_32S against ... can not be used | You used an absolute address while producing a position-independent executable. | Add default rel and write lea rdi, [rel msg]. |
Segmentation fault | A pointer was wrong — bad address, wrong syscall argument, or you forgot to exit. | Check operands with a debugger; verify the number of bytes passed to write. |
Exec format error | The object file was built for a different operating system or architecture. | Rebuild with the format that matches your platform (elf64, macho64, win64). |
Permission denied | You tried to execute a file that is not marked executable. | chmod +x ./hello |
Summary
- You need exactly two tools: an assembler (
nasm) that makes an object file, and a linker (ldorgcc) that makes an executable. - The three commands are
nasm -f <format>,ld(orgcc), then./program. - A source file is organised into
sections; execution starts at the symbol the linker is told about (_startormain). - Link with
ldfor a bare syscall program, and withgccwhen you want to call the C library.
Next: Numbers & Memory — the binary and hexadecimal arithmetic that every instruction is built on.