x86-64 Assembly Hello World with NASM on Linux
Write and run Hello World in x86-64 assembly with NASM on Linux. Learn syscalls, Docker setup, assembling, linking, GDB, and Apple Silicon support.
In this beginner-friendly x86-64 assembly tutorial, you will write a Hello World program with NASM on Linux, call the Linux kernel directly with system calls, and build the executable with nasm and ld.
You will also create a reproducible Docker development environment that works on Linux, Windows through WSL, and Apple Silicon Macs through linux/amd64 emulation.
By the end of the tutorial, you will understand how source code becomes an object file, how the linker creates an executable, and how an assembly program prints text without a standard library.
What You Will Build
The final program prints one line and exits successfully:
Hello, World!
The finished project will contain these files:
hello-world-asm/
├── Dockerfile
├── docker.sh
├── hello.asm
└── Makefile
The build process has three stages:
- Assemble
hello.asminto the object filehello.o. - Link
hello.ointo the executable filehello. - Run the executable inside the Linux development environment.
Why Learn x86-64 Assembly Language?
High-level languages are the right choice for most application development, but assembly language remains useful when you need to understand exactly what a processor is doing.
Learning assembly can help you understand:
- how compilers translate source code into machine instructions;
- how operating systems expose services through system calls;
- how registers, memory addresses, and the stack work;
- how debuggers inspect a running program;
- how executable files and linkers work;
- how reverse engineering and low-level security analysis are performed;
- why certain pieces of code are faster or slower than others.
Most production software is not written entirely in assembly. Developers normally use it selectively for low-level routines, hardware-specific code, boot code, performance analysis, or situations where precise control is required.
Prerequisites for This NASM Tutorial
You need the following tools:
- Docker Desktop or Docker Engine;
- a terminal;
- a text editor such as Visual Studio Code;
- basic familiarity with files and command-line commands.
This tutorial uses NASM syntax, the x86-64 architecture, the ELF64 object format, and the Linux system-call interface. The generated executable is therefore a Linux x86-64 program, not a native Windows or macOS executable.
Create the project directory and enter it:
mkdir hello-world-asm
cd hello-world-asm
Set Up an x86-64 Assembly Environment with Docker
Docker gives every reader the same Linux toolchain, regardless of the host operating system. It also avoids installing NASM, GNU ld, make, and GDB directly on the host.
Create the Dockerfile
Inside hello-world-asm, create a file named Dockerfile:
FROM ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
binutils \
gdb \
make \
nasm \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash dev
USER dev
WORKDIR /workspace
This image contains four important packages:
nasmassembles NASM source code into an object file;binutilsprovides the GNU linkerldand related binary tools;makeautomates the build commands;gdblets you inspect registers and step through instructions.
The container runs as the non-root user dev, and the project directory will be mounted at /workspace.
Create the Docker Helper Script
Create a file named docker.sh:
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="${IMAGE_NAME:-nasm-dev}"
CONTAINER_NAME="${CONTAINER_NAME:-nasm-dev-container}"
PLATFORM="${PLATFORM:-linux/amd64}"
WORK_DIR="/workspace"
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
build_image() {
docker build \
--platform "$PLATFORM" \
--tag "$IMAGE_NAME" \
"$PROJECT_ROOT"
}
start_container() {
if docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker start "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker exec -it "$CONTAINER_NAME" bash
return
fi
docker run \
--platform "$PLATFORM" \
--name "$CONTAINER_NAME" \
--hostname nasm-dev \
--interactive \
--tty \
--cap-add SYS_PTRACE \
--security-opt seccomp=unconfined \
--volume "$PROJECT_ROOT:$WORK_DIR" \
--workdir "$WORK_DIR" \
"$IMAGE_NAME" \
bash
}
stop_container() {
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
}
clean_environment() {
docker rm --force "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker image rm "$IMAGE_NAME" >/dev/null 2>&1 || true
}
print_usage() {
echo "Usage: ./docker.sh {build|start|stop|clean}"
}
case "${1:-}" in
build) build_image ;;
start) start_container ;;
stop) stop_container ;;
clean) clean_environment ;;
*) print_usage ;;
esac
Make the script executable:
chmod +x docker.sh
The script supports four commands:
./docker.sh build
./docker.sh start
./docker.sh stop
./docker.sh clean
The SYS_PTRACE capability and relaxed seccomp option allow GDB to trace the program inside the container. They are included for local development and debugging.
Build and Start the Container
Build the Docker image:
./docker.sh build
Start the development container:
./docker.sh start
Your terminal should now be inside /workspace, which maps to the project directory on your computer. Files created in the container will remain available on the host.
Run x86-64 Assembly on Apple Silicon
Apple Silicon processors use the ARM64 architecture, while this tutorial creates an x86-64 Linux executable. The helper script therefore uses this target platform by default:
linux/amd64
Docker Desktop can emulate that platform on an Apple Silicon Mac. The same setting also works natively on an x86-64 Linux or Windows host.
If you remove the --platform linux/amd64 setting on an ARM64 host, the container may use ARM64 tools and the resulting x86-64 workflow may fail with an architecture or executable-format error.
Optional: Install the Tools Directly on Linux
Docker is not required when you already use an x86-64 Linux system. On Ubuntu or Debian, install the toolchain directly with:
sudo apt update
sudo apt install nasm binutils make gdb
You can then run the remaining commands in your normal terminal.
Understand the Structure of a NASM Assembly File
Assembly programs are divided into sections. Each section has a different purpose.
The .data Section
The .data section stores initialized writable data. Our message will be placed here because its bytes are known when the program is assembled.
section .data
Read-only constants can also be placed in a .rodata section. We will use .data to keep the first example simple.
The .bss Section
The .bss section reserves space for data that does not need an initial value in the executable file. Programs commonly use it for buffers and variables that start at zero.
This Hello World program does not need a .bss section.
The .text Section
The .text section contains executable machine instructions:
section .text
Our program begins at a label named _start. Because we link directly with ld and do not use the C runtime, _start is the process entry point instead of main.
Write Hello World in x86-64 Assembly
Create a file named hello.asm:
global _start
section .data
message db "Hello, World!", 10
message_length equ $ - message
section .text
_start:
; write(STDOUT, message, message_length)
mov eax, 1
mov edi, 1
lea rsi, [rel message]
mov edx, message_length
syscall
; exit(0)
mov eax, 60
xor edi, edi
syscall
This program does not call printf, use a standard library, or start through a language runtime. It communicates directly with the Linux kernel.
Define the Hello World Message with db
The db directive means define byte. NASM places the supplied byte values into the object file:
message db "Hello, World!", 10
The characters in the string are stored as bytes. The final value, 10, is the ASCII line-feed character, which moves the terminal cursor to the next line.
The label message represents the address of the first byte.
Calculate the String Length with equ
The program must tell Linux how many bytes to write. NASM can calculate the length during assembly:
message_length equ $ - message
Here:
$is NASM’s current assembly position;messageis the address where the string begins;$ - messageis the number of bytes between those positions;equdefines a constant and does not allocate additional memory.
Calculating the length this way prevents the value from becoming incorrect when the text changes.
How Linux x86-64 System Calls Work
A system call requests a service from the Linux kernel. This program uses two system calls:
writesends bytes to standard output;exitterminates the process.
For Linux x86-64 system calls, the program places the system-call number in rax. The first arguments are placed in rdi, rsi, and rdx, followed by additional registers when needed. The syscall instruction transfers control to the kernel.
Call write to Print the Message
Conceptually, the program makes this request:
write(1, message, message_length);
The three arguments are:
1: the file descriptor for standard output;message: the address of the bytes to print;message_length: the number of bytes to print.
The corresponding assembly is:
mov eax, 1
mov edi, 1
lea rsi, [rel message]
mov edx, message_length
syscall
Register usage:
| Register | Value | Purpose |
|---|---|---|
rax | 1 | System-call number for write |
rdi | 1 | Standard-output file descriptor |
rsi | Address of message | Buffer to write |
rdx | message_length | Number of bytes |
The instruction lea rsi, [rel message] loads the address of the message into rsi using a relative reference.
Call exit to End the Process
After printing the text, the program must terminate:
mov eax, 60
xor edi, edi
syscall
The value 60 selects the Linux x86-64 exit system call. Its first argument is the process exit status.
The instruction:
xor edi, edi
sets edi to zero efficiently, producing an exit status of 0, which conventionally means success.
Assemble, Link, and Run the NASM Program
Source code cannot run directly. NASM first converts the assembly source into an ELF64 object file. GNU ld then combines that object file into a Linux executable.
Step 1: Assemble hello.asm
Run:
nasm -f elf64 hello.asm -o hello.o
The options mean:
-f elf64: generate a 64-bit ELF object file;hello.asm: read this source file;-o hello.o: write the object file tohello.o.
An object file contains machine code and metadata, but it is not yet the final executable.
Step 2: Link hello.o
Run:
ld hello.o -o hello
The linker resolves symbols, selects the entry point, lays out the executable sections, and creates the file named hello.
Step 3: Run the Executable
Run:
./hello
The terminal should display:
Hello, World!
Check the exit status:
echo $?
The output should be:
0
Automate the Build with a Makefile
Typing the assembler and linker commands manually is useful for learning, but a Makefile makes repeated builds faster and less error-prone.
Create a file named Makefile:
TARGET := hello
SOURCE := hello.asm
OBJECT := hello.o
NASM := nasm
LD := ld
NASMFLAGS := -f elf64
.PHONY: all run clean
all: $(TARGET)
$(OBJECT): $(SOURCE)
$(NASM) $(NASMFLAGS) $< -o $@
$(TARGET): $(OBJECT)
$(LD) $< -o $@
run: $(TARGET)
./$(TARGET)
clean:
rm -f $(OBJECT) $(TARGET)
The command lines under each target must begin with a tab character.
Build the program:
make
Build and run it:
make run
Remove the generated object and executable files:
make clean
make only rebuilds a target when one of its dependencies has changed. Editing hello.asm and running make again will therefore reassemble and relink the program automatically.
Debug x86-64 Assembly with GDB
GDB lets you pause the program, inspect registers, and execute one machine instruction at a time.
Start GDB:
gdb ./hello
At the GDB prompt, set a breakpoint at the entry point and run the program:
break _start
run
Useful commands include:
info registers
x/s &message
disassemble _start
stepi
continue
quit
info registersdisplays the current register values;x/s &messageexamines the string in memory;disassemble _startshows the instructions in the program;stepiexecutes one instruction;continueresumes normal execution.
Stepping through the program is one of the fastest ways to understand how values move into registers before a system call.
Common NASM, Docker, and Linking Errors
nasm: command not found
The toolchain is not installed in the current environment. Build and enter the Docker container:
./docker.sh build
./docker.sh start
When working directly on Ubuntu or Debian, install the nasm package.
cannot execute binary file or Exec format error
The executable architecture does not match the environment running it. On Apple Silicon, confirm that both the Docker build and container use:
linux/amd64
The helper script already applies that platform by default.
ld: cannot find hello.o
The object file has not been created, or the assembler command failed. Run:
nasm -f elf64 hello.asm -o hello.o
Fix any NASM error before running ld again.
make: Nothing to be done for 'all'
The executable is already up to date. Change hello.asm, or rebuild from scratch:
make clean
make
The Docker Script Is Not Executable
Add execute permission:
chmod +x docker.sh
Then run it with:
./docker.sh start
On Windows, run the Bash script through WSL or Git Bash rather than directly in standard PowerShell.
Frequently Asked Questions
What is NASM?
NASM is an assembler for the x86 and x86-64 architectures. It uses an Intel-style assembly syntax and can generate several object formats, including ELF64 for 64-bit Linux programs.
Does NASM create the final executable?
NASM creates the object file. A linker such as GNU ld creates the final executable from one or more object files.
In this tutorial:
hello.asm --nasm--> hello.o --ld--> hello
Why does the program use _start instead of main?
main is normally called by language-runtime startup code. This tutorial links directly with ld and does not include the C runtime, so execution begins at the lower-level _start symbol.
Can this Linux assembly program run directly on macOS?
No. The executable uses the Linux ELF format and Linux system calls. Docker can run it inside a Linux x86-64 container on macOS, including Apple Silicon Macs through emulation.
Why use 32-bit register names such as eax and edi in a 64-bit program?
Writing to a 32-bit general-purpose register clears the upper 32 bits of its corresponding 64-bit register. The system-call numbers, file descriptor, string length, and exit status in this example all fit within 32 bits.
The message address requires a 64-bit register, so the program loads it into rsi.
Is assembly always faster than C or C++?
No. Modern compilers perform sophisticated optimizations and often generate highly efficient machine code. Assembly is most valuable when precise hardware control, low-level integration, binary analysis, or carefully measured optimization is required.
Next Steps After Hello World
You have now written, assembled, linked, executed, and debugged a complete x86-64 Linux assembly program.
Good next exercises include:
- change the message and verify that
message_lengthupdates automatically; - print two separate strings with two
writesystem calls; - read text from standard input with the
readsystem call; - inspect the executable with
objdumpandreadelf; - learn how the stack stores return addresses and local data;
- call an assembly function from C;
- explore loops, comparisons, and conditional jumps.
Although Hello World is small, it introduces the complete low-level path from source text to CPU instructions: NASM creates the machine code, the linker creates the executable, Linux loads the process, and system calls connect the program to the kernel.