Updated: Mar 29, 2026
| 11 min

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.

Whale behind a desk with containers

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=noninteractive variable ensures apt doesn’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 .asm files.
  • 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-build Builds our Docker image from the Dockerfile.
  • container-start Starts a new container (or reuses an existing one) and drops you into a shell.
  • container-stop Stops the running container.
  • clean Removes 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:

  • .data Holds variables and constants that are initialised when the program starts. For example, your “Hello, World!” string belongs here.
  • .bss Holds 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.
  • .text Contains the actual instructions of the program. This is where the CPU starts executing. The program’s entry point (often called _start on Linux or main in 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:

  • .data to store the message
  • .text to 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 .data section for our message
  • a .text section 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:

  1. the message we want to print, and
  2. 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 (our msg).
  • count = number of bytes to write (our len).

In x86-64 Linux, system calls are made by:

  1. putting the syscall number into the rax register,
  2. putting the arguments into registers (rdi, rsi, rdx, …), and
  3. 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 exit is 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:

  1. Assemble: convert the human-readable assembly into machine code (.o file).
  2. Link: package that machine code into a proper executable (hello).
  3. Run: execute it and see the magic happen.

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).

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!