x86-64 Assembly Hello World: The Complete Docker-Based Guide
Stop fighting environment errors. Learn x86-64 Assembly with a zero-setup Docker environment. Includes NASM code, syscall explanations, and a one-click build script.
We’ve all seen the classic “Hello, World!” programs. But let’s be honest, you’re not a true Giga Chad until you’ve written one in pure assembly.
Why Learn Assembly in 2026?
You might be asking yourself: Why on earth would anyone learn assembly when high-level languages exist to make life easier? The truth is, there are situations outside of programming a space probe where assembly is still relevant. For example, imagine you’re working on a collision detection system for a self-driving car. In that scenario, every microsecond matters. You want to squeeze out every drop of performance to avoid disaster. Of course, this doesn’t mean writing the entire system in assembly. In practice, you’d only optimise the performance-critical parts, while leaving the rest in a higher-level language. And knowing how things work under the hood is always valuable
Environment
Before we start coding, we need to set up a proper environment. Let’s create a project directory. Throughout this tutorial, we’ll call it hello-world-asm. To make sure our code runs anywhere (not just on my machine), we’ll use a Docker container with all the necessary tools for building and running assembly. If you don’t already have Docker Desktop installed, go ahead and do that first. For your editor/IDE, feel free to use whatever you like; it doesn’t really matter. In this tutorial, I’ll be using Visual Studio Code, but it’s not a requirement.
Setting up the Docker Assembly Environment
Inside your hello-world-asm directory, create a file named Dockerfile:
FROM ubuntu:latest
# Build-time variable for noninteractive installs
ARG DEBIAN_FRONTEND=noninteractive
# Install only the essentials for x86_64 assembly
RUN apt update && apt upgrade -y && \
apt install -y --no-install-recommends \
build-essential \
gdb \
nasm && \
rm -rf /var/lib/apt/lists/*
# Create non-root user "dev"
RUN useradd -m -s /bin/bash dev && \
echo "dev ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
USER dev
WORKDIR /home/dev
Let’s break down what we just set up:
- Base image: We start with
ubuntu:latest, giving us a clean Linux environment. - Non-interactive installs: The
DEBIAN_FRONTEND=noninteractivevariable ensuresaptdoesn’t get stuck asking us questions. - Install essentials: We pull in just the tools we need for x86_64 assembly programming:
build-essential→ compiler, linker, common build tools (handy for your C/C++ projects too)gdb→ the GNU Debugger, so we can step through our assembly code.nasm→ the Netwide Assembler, the tool we use to assemble and link our.asmfiles.
- Non-root user: We add a new user
dev, so we don’t run everything as root (a good security practice). - Working directory: Finally, we set the container to drop us into
/home/dev, which is where we’ll link our project directory to later.
Helper Script
Typing Docker commands over and over can be a pain. To make life easier, let’s create a shell script to automate container management. Inside your hello-world-asm directory, create a file named docker.sh with the following content:
#!/bin/bash
IMAGE_NAME="dev-env"
CONTAINER_NAME="dev-env-container"
CONTAINER_ENTRY="/usr/bin/bash"
WORK_DIR="/home/dev/hello-world-asm"
PROJECT_ROOT="$(pwd)"
function container_stop() {
sudo docker stop $CONTAINER_NAME || echo "container already stopped"
}
function clean() {
container_stop
sudo docker rm $CONTAINER_NAME || echo "container already removed"
sudo docker image rm $IMAGE_NAME || echo "image already removed"
}
function container_start() {
if [ -z "$(sudo docker ps -a | grep $CONTAINER_NAME)" ]; then
echo ">> creating dev container"
sudo docker run -w $WORK_DIR --hostname dev -it --name $CONTAINER_NAME \
--net=host \
--cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
-v $PROJECT_ROOT:$WORK_DIR:z \
$IMAGE_NAME $CONTAINER_ENTRY
else
echo ">> found existing dev container"
sudo docker start $CONTAINER_NAME
sudo docker exec -it $CONTAINER_NAME $CONTAINER_ENTRY
fi
}
function image_build() {
if [ -z "$(sudo docker images | grep $IMAGE_NAME)" ]; then
echo ">> building image: $IMAGE_NAME"
sudo docker build -t $IMAGE_NAME ./
fi
}
function print_usage() {
echo "Usage: ./docker.sh [container-start | container-stop | image-build | clean]"
}
function main() {
if ! command -v docker >/dev/null 2>&1; then
echo "Could not find docker. Please install it https://docs.docker.com/engine/install/"
exit 1
fi
case "$1" in
container-start) container_start ;;
container-stop) container_stop ;;
image-build) image_build ;;
clean) clean ;;
*) print_usage ;;
esac
}
main $@
What this script does:
image-buildBuilds our Docker image from theDockerfile.container-startStarts a new container (or reuses an existing one) and drops you into a shell.container-stopStops the running container.cleanRemoves the container and image, so you can start fresh.
In short, it hides the long Docker commands and gives you a simple interface.
Apple Silicon (M-Chips)
If you’re on a Mac with Apple Silicon, your CPU is ARM64, but this tutorial targets x86_64 assembly. That means we must run our container using x86_64 emulation.
If you don’t do this, Docker may build the image for ARM, and the assembler/linker output won’t run correctly.
Build the image using the x86 platform:
docker build --platform linux/amd64 -t dev-env .
If you’re using the helper script, update the build command in docker.sh:
sudo docker build --platform linux/amd64 -t $IMAGE_NAME ./
Also update the container run command. Inside container_start(), change the docker run line to:
sudo docker run --platform linux/amd64 -w $WORK_DIR --hostname dev -it --name $CONTAINER_NAME \
First Run
Before we can use our container, we need to build the image. In your terminal (for Windows users, PowerShell works best), run:
sh docker.sh image-build # Linux/macOS
bash docker.sh image-build # Windows (PowerShell)
Then start the container with:
sh docker.sh container-start # Linux/macOS
bash docker.sh container-start # Windows (PowerShell)
This will provide you with terminal access within your development environment. From here, we’re ready to write some assembly.
Assembly File Structure
Before we dive into coding, it’s important to understand how an assembly file is usually organized. Most assembly programs are divided into sections, each with a specific purpose. The exact details vary depending on the assembler and platform, but the structure is broadly similar everywhere.
Here are the most common sections:
.dataHolds variables and constants that are initialised when the program starts. For example, your “Hello, World!” string belongs here..bssHolds uninitialised variables (they start as zero by default). Think of this as a workspace where you can reserve space for data you’ll fill in later..textContains the actual instructions of the program. This is where the CPU starts executing. The program’s entry point (often called_starton Linux ormainin higher-level conventions) is defined in this section.
A Simple Mental Model:
.data→ “What I already know.”.bss→ “What I’ll figure out later.”.text→ “What I need to do.”
For our Hello, World! program, we only need two sections:
.datato store the message.textto hold the instructions that display it
Writing Our First Assembly Program
Let’s start building our first program! Inside your project directory, create a new file named hello.asm.
The first step is to set up the sections of our program. As we learned earlier, we’ll need:
- a
.datasection for our message - a
.textsection for the instructions that display it.
We can declare these sections with the section keyword:
section .data ; Create the data section
section .text ; Create the text section
Adding Data
Our program needs just two pieces of data:
- the message we want to print, and
- the length of that message.
Because this program is simple, we don’t need any dynamic memory management. We can store everything directly in the .data section. To define the message, we’ll use the assembler directive db (define byte). This instructs the assembler to allocate memory space and fill it with the values we provide. In this case, that’s the string "Hello, World!" followed by 0x0A, which represents the newline character ('\n').
section .data ; Create the data section
msg db "Hello, World!", 0x0A ; Our message plus a newline (0x0A = '\n')
section .text ; Create the text section
Now we also need the length of the message. We can compute it automatically using the equ directive (equate). Unlike db, equ does not allocate memory. Instead, it defines a constant value that the assembler substitutes wherever the label is used. To get the length, we subtract the address of the beginning of the message (msg) from the current location counter ($). The symbol $ always refers to the assembler’s current position, which in this case is right after the message we just defined.
section .data ; Create the data section
msg db "Hello, World!", 0x0A ; Our message plus a newline (0x0A = '\n')
len equ $ - msg ; Define a constant len with the message length
section .text ; Create the text section
x86-64 System Calls: Printing to the Terminal
Now that our data is defined, it’s time to write the actual instructions in the .text section. This is where the CPU will start executing our program. Every program needs an entry point, ****a place where execution begins. In Linux assembly, this is usually the label _start. We’ll declare it as a global symbol so the linker knows where to begin:
section .data ; Create the data section
msg db "Hello, World!", 0x0A ; Our message plus a newline (0x0A = '\n')
len equ $ - msg ; Define a constant len with the message length
section .text ; Create the text section
global _start ; Tell the linker the entry point is _start
_start: ; Entry point of our program
To display text on the screen, we need to ask the operating system for help. In Linux, this is done using a system call. A system call is like raising your hand and asking the OS: “Hey, can you do this for me?” We’ll use the write system call, which has this signature:
write(fd, buf, count)
fd= file descriptor (1 means standard output, the terminal).buf= address of the data to write (ourmsg).count= number of bytes to write (ourlen).
In x86-64 Linux, system calls are made by:
- putting the syscall number into the
raxregister, - putting the arguments into registers (
rdi,rsi,rdx, …), and - executing the instruction
syscall.
The syscall number for write is 1.
Here’s how we set up and call write:
mov rax, 1 ; Syscall number for write
mov rdi, 1 ; File descriptor 1 = stdout
mov rsi, msg ; Address of the string
mov rdx, len ; Length of the string
syscall ; Invoke the system call
After printing the message, the program still needs to exit cleanly. That’s another syscall: exit.
- The syscall number for
exitis 60. - Its only argument is the exit code (0 means success).
mov rax, 60 ; syscall number for exit
xor rdi, rdi ; exit code 0 (using xor to set rdi = 0)
syscall ; Invoke the system call
Here’s the full hello.asm so far:
section .data ; Create the data section
msg db "Hello, World!", 0x0A ; Our message plus a newline (0x0A = '\n')
len equ $ - msg ; Define a constant len with the message length
section .text ; Create the text section
global _start ; Tell the linker the entry point is _start
_start: ; Entry point of our program
; write(fd1, msg, len)
mov rax, 1 ; Syscall number for write
mov rdi, 1 ; File descriptor 1 = stdout
mov rsi, msg ; Address of the string
mov rdx, len ; Length of the string
syscall ; Invoke the system call
; exit(0)
mov rax, 60 ; syscall number for exit
xor rdi, rdi ; exit code 0 (using xor to set rdi = 0)
syscall ; Invoke the system call
Assembling, Linking, and Running
We’ve written our hello.asm file, now it’s time to bring it to life! The process has three steps:
- Assemble: convert the human-readable assembly into machine code (
.ofile). - Link: package that machine code into a proper executable (
hello). - Run: execute it and see the magic happen.
Option 0: Use make (recommended)
Since we installed make in our container, we can automate these steps with a Makefile. Inside your project folder, create a file named Makefile:
all: hello
hello: hello.o
ld hello.o -o hello
hello.o: hello.asm
nasm -f elf64 hello.asm -o hello.o
clean:
rm -f hello hello.o
Now you can build your program with a single command:
make
And when you want to clean up all build artefacts:
make clean
Step 1: Assemble
Manually, the first step is turning your source code into an object file:
nasm -f elf64 hello.asm -o hello.o
f elf64→ tells NASM to generate 64-bit ELF output (Linux’s standard format).hello.asm→ your assembly source file.o hello.o→ output file (.o= object file).
Step 2: Link
Next, we use the GNU linker ld to create an executable from the object file:
ld hello.o -o hello
hello.o→ the object file we just created.o hello→ the name of the final executable.
Step 3: Run
Now execute your program:
./hello
You should see:
Hello, World!
Congratulations, you’ve just written, built, and executed your first Hello World in pure x86 assembly!