blog

How to Set Up an Assembly Dev Environment on Linux

Learn how to set up an assembly dev environment on Linux with NASM, GAS, QEMU for ARM, and GDB with a clean UI. Step-by-step guide.

  • Assembly
  • Linux

Introduction: Why Does Assembly Still Matter?

You write Python, JavaScript, or maybe Rust all day. So why would you spend a weekend setting up an assembly dev environment on Linux?

Because assembly is where every program ends up. Debugging a nasty crash, reading compiler output, doing reverse engineering, writing an OS, or squeezing performance out of a hot loop: all of it eventually leads to a few CPU instructions and a handful of registers.

The good news is that Linux is probably the best place on earth to learn this. The tools are free, they are small, and they are well documented. The bad news is that most tutorials throw one tool at you and move on, so you end up with a messy setup and a lot of confusion.

In this guide, we will build a clean and practical setup from scratch. By the end, you will be able to:

  • Write and run x86-64 assembly with NASM (Intel syntax).
  • Write and run GNU Assembler (GAS) code, including its Intel and AT&T flavors.
  • Run and debug ARM64 binaries on your x86 laptop using QEMU, with no Raspberry Pi needed.
  • Debug everything with GDB and a proper UI instead of staring at a bare (gdb) prompt.

Curious how all of this fits together? Let's start with the big picture.

The Big Picture: What Each Tool Actually Does

Before installing anything, it helps to know who does what. Beginners often mix these tools up, and that causes most of the early frustration.

Tool Role Notes
NASM Assembler (x86/x86-64) Intel syntax, very readable, popular for learning
GAS (as) Assembler (many architectures) Part of GNU binutils, default syntax is AT&T on x86
ld Linker Turns object files into an executable
QEMU (user mode) Emulator Runs ARM binaries on an x86 machine
GDB Debugger Step through instructions, inspect registers and memory
GEF / pwndbg / TUI GDB front-ends Make GDB far easier to read

The workflow is always the same: write source, assemble it into an object file, link it into an executable, run it, debug it.

Quick note: An assembler is not a compiler. It does almost a one-to-one translation from text to machine code. That is why assembly feels so honest. There is nothing hiding behind the curtain.

Step 1: Install the Basics

This guide targets 64-bit Linux on an x86-64 machine. Everything below works on a normal laptop or a VM.

Debian / Ubuntu / Linux Mint

sudo apt update
sudo apt install -y build-essential nasm gdb gdb-multiarch \
    binutils make git \
    qemu-user qemu-user-static \
    gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu \
    gcc-arm-linux-gnueabihf binutils-arm-linux-gnueabihf

Fedora

sudo dnf install -y nasm gdb binutils make git gcc \
    qemu-user qemu-user-static \
    gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu

Arch Linux

sudo pacman -S --needed base-devel nasm gdb git qemu-user \
    aarch64-linux-gnu-gcc aarch64-linux-gnu-binutils

Package names shift a little between distros and releases, so if one is missing, search your package manager (for example apt search aarch64-linux-gnu).

Verify the Install

nasm -v
as --version | head -n 1
ld --version | head -n 1
gdb --version | head -n 1
qemu-aarch64 --version | head -n 1
aarch64-linux-gnu-as --version | head -n 1

If every command prints a version, you are ready. Now for the fun part.

Step 2: Your First Program with NASM

NASM (Netwide Assembler) is the go-to choice for many learners. Its Intel syntax puts the destination first (mov rax, 1 means "put 1 into rax"), which most people find natural.

Hello, Assembly

Create a file called hello.asm:

section .data
    msg     db  "Hello, Assembly!", 10   ; 10 is the newline character
    msg_len equ $ - msg                  ; length computed by the assembler

section .text
    global _start

_start:
    mov rax, 1          ; syscall number for write
    mov rdi, 1          ; file descriptor 1 = stdout
    mov rsi, msg        ; address of the message
    mov rdx, msg_len    ; number of bytes to write
    syscall

    mov rax, 60         ; syscall number for exit
    xor rdi, rdi        ; exit code 0
    syscall

Assemble, Link, Run

nasm -f elf64 -g -F dwarf hello.asm -o hello.o
ld hello.o -o hello
./hello

Let's break down those flags:

  • -f elf64 tells NASM to produce a 64-bit Linux object file.
  • -g -F dwarf adds debug information so GDB can show you source lines and labels.
  • ld links the object file. We have no C library here, so the entry point is _start, not main.

That's it. No printf, no runtime, no magic. You asked the Linux kernel to write bytes to the screen, and it did.

Pro tip: On x86-64 Linux, syscall arguments go in rdi, rsi, rdx, r10, r8, r9, and the syscall number goes in rax. Keep that order in your head. You will use it constantly.

A Slightly More Realistic Example: Calling C Functions

Writing every output routine by hand gets old fast. You can let the C library help:

extern puts

section .data
    msg db "Hello from NASM + libc", 0

section .text
    global main

main:
    sub rsp, 8          ; keep the stack 16-byte aligned before the call
    lea rdi, [rel msg]  ; first argument goes in rdi
    call puts
    add rsp, 8
    xor eax, eax        ; return 0
    ret
nasm -f elf64 -g -F dwarf hello_libc.asm -o hello_libc.o
gcc -no-pie hello_libc.o -o hello_libc
./hello_libc

The sub rsp, 8 line trips up a lot of people. The System V ABI wants the stack aligned to 16 bytes at the moment of a call. Skip it and you may get a random segfault inside printf or puts. It's one of the most common beginner bugs, so remember it.

Step 3: Meet GAS, the GNU Assembler

GAS (the command is just as) ships with binutils, which means it is already on almost every Linux machine. GCC itself produces GAS-compatible assembly, so learning GAS makes compiler output readable.

AT&T vs Intel Syntax: What's the Deal?

GAS defaults to AT&T syntax on x86. It looks different, and it confuses everyone at first.

Feature Intel (NASM style) AT&T (GAS default)
Operand order mov rax, rbx (dest, source) movq %rbx, %rax (source, dest)
Registers rax %rax
Immediates mov rax, 5 movq $5, %rax
Memory [rbx + 8] 8(%rbx)
Operand size Inferred from registers Suffix on instruction (movq, movl)

Good news: you don't have to suffer. GAS supports Intel syntax with a single directive.

Hello, Assembly in GAS (Intel Syntax)

Create hello_gas.s:

.intel_syntax noprefix
.global _start

.section .data
msg:
    .ascii "Hello from GAS!\n"
.equ msg_len, . - msg

.section .text
_start:
    mov rax, 1
    mov rdi, 1
    lea rsi, [rip + msg]
    mov rdx, offset msg_len
    syscall

    mov rax, 60
    xor rdi, rdi
    syscall
as -g hello_gas.s -o hello_gas.o
ld hello_gas.o -o hello_gas
./hello_gas

Notice the offset keyword. In GAS's Intel mode, a bare symbol can be read as a memory reference instead of a constant, so offset makes your intention clear. It's a classic gotcha.

The Same Thing in AT&T Syntax

Just so you can recognize it in the wild (for example in gcc -S output):

.global _start
.section .data
msg: .ascii "Hello from GAS!\n"
.equ msg_len, . - msg

.section .text
_start:
    movq $1, %rax
    movq $1, %rdi
    leaq msg(%rip), %rsi
    movq $msg_len, %rdx
    syscall
    movq $60, %rax
    xorq %rdi, %rdi
    syscall

See What the Compiler Does

Here is one of the best learning tricks there is. Write a tiny C function and ask GCC to show you the assembly:

gcc -S -O1 -masm=intel -fverbose-asm square.c -o square.s

The -masm=intel flag gives you Intel syntax output, which is much easier to read if you started with NASM. Compare -O0 and -O2 and you will learn more about optimization than most blog posts can teach you.

NASM vs GAS: Which One Should You Use?

Question NASM GAS
Easiest for beginners? Yes, clean Intel syntax Slightly harder at first
Already installed? Usually needs install Almost always present
Supports ARM, RISC-V, etc.? No, x86 family only Yes, many architectures
Reads GCC output? No Yes, native format
Macro system Powerful Good, but different style

My honest advice: learn NASM first for x86, and use GAS for everything else. Since we are about to do ARM work, GAS is the one that will carry you there.

Step 4: Running ARM Binaries on x86 with QEMU

Here is where it gets exciting. Do you need an ARM board to learn ARM assembly? No. QEMU's user-mode emulation runs ARM Linux programs right on your x86 machine. It translates the instructions on the fly and forwards system calls to your own kernel.

That is very different from full system emulation. In user mode, you don't boot an operating system. You just run a single binary, which is faster and far simpler.

Your First ARM64 Program

ARM64 (AArch64) uses different registers and different syscall numbers. Create hello_arm64.s:

.global _start

.section .rodata
msg:
    .ascii "Hello from ARM64!\n"
.equ msg_len, . - msg

.section .text
_start:
    mov x0, #1              // fd = stdout
    ldr x1, =msg            // address of the message
    mov x2, #msg_len        // length
    mov x8, #64             // syscall number: write
    svc #0                  // make the system call

    mov x0, #0              // exit code
    mov x8, #93             // syscall number: exit
    svc #0

Assemble, Link, Run

aarch64-linux-gnu-as -g hello_arm64.s -o hello_arm64.o
aarch64-linux-gnu-ld hello_arm64.o -o hello_arm64
qemu-aarch64 ./hello_arm64

You should see Hello from ARM64! printed on your x86 machine. Pretty cool, right?

Check what you built:

file hello_arm64

The output will say ELF 64-bit LSB executable, ARM aarch64. If you try ./hello_arm64 directly, it fails unless binfmt_misc is set up (the qemu-user-static package usually does this for you).

x86-64 vs ARM64 at a Glance

Concept x86-64 Linux ARM64 Linux
Syscall instruction syscall svc #0
Syscall number register rax x8
Argument registers rdi, rsi, rdx, r10, r8, r9 x0 to x5
write syscall number 1 64
exit syscall number 60 93
Comment character in GAS # or /* */ // or /* */

Watch those syscall numbers. They differ between architectures, and mixing them up is a very common mistake when you jump from x86 to ARM.

What About 32-bit ARM?

Same idea, different toolchain and emulator:

arm-linux-gnueabihf-as -g hello_arm32.s -o hello_arm32.o
arm-linux-gnueabihf-ld hello_arm32.o -o hello_arm32
qemu-arm ./hello_arm32

Compiling C for ARM (Dynamic Binaries)

If you cross-compile C code with libc, QEMU needs the target's libraries:

aarch64-linux-gnu-gcc -g hello.c -o hello_c_arm64
qemu-aarch64 -L /usr/aarch64-linux-gnu ./hello_c_arm64

The -L flag points QEMU at the ARM sysroot. Alternatively, link statically with -static and skip the sysroot entirely.

Step 5: GDB, But Make It Pretty

Plain GDB is powerful, but its default interface is... let's be kind and say minimal. You type a command, you see one line of output, and you lose track of the big picture. For assembly work, you really want to see registers, the instruction stream, and the stack at the same time.

There are three good ways to get there.

Option 1: Built-in TUI Mode (Zero Install)

GDB ships with a text user interface. Try it:

gdb -tui ./hello

Or switch on inside a running session with tui enable (or press Ctrl+X then A).

Useful layouts:

layout asm        # show the disassembly window
layout regs       # show registers on top of the disassembly
layout split      # source and disassembly together

A few survival tips for TUI mode:

  • Ctrl+L redraws the screen when it gets garbled.
  • Ctrl+X then O switches focus between windows.
  • focus cmd returns focus to the command line so the arrow keys scroll history again.

TUI is perfect when you are on a remote server or want something that always works.

Option 2: GEF, pwndbg, or Peda (Plugins)

These plugins turn GDB into a full dashboard automatically. Every time the program stops, you get registers, the stack, and the code around the instruction pointer, with colors.

Plugin Best for Notes
GEF Beginners, multi-arch work Single Python file, easy install, supports ARM well
pwndbg Reverse engineering, exploit dev Rich features, larger install
gdb-dashboard Clean, customizable layout Single file, very readable

To install GEF (check the project page for the latest command):

bash -c "$(curl -fsSL https://gef.blah.cat/sh)"

That adds a line to your ~/.gdbinit. Next time you start GDB, you will see the new interface.

Heads up: Don't install GEF, pwndbg, and gdb-dashboard all at once. They fight over the same hooks and your screen will turn into a mess. Pick one. If you want to switch, remove the old one from ~/.gdbinit first.

Also, always read a script before piping it into bash. It is a good habit, even for well-known projects.

Option 3: A GUI Front-End

If you prefer clicking, VS Code with the C/C++ or CodeLLDB-style debug extensions can drive GDB, and tools like Gdbgui or Emacs GUD exist too. They are fine for source-level debugging. For raw assembly, though, I find the terminal-based options faster and more reliable.

A Sensible ~/.gdbinit

Whether you use a plugin or not, a few settings help a lot:

set disassembly-flavor intel
set pagination off
set confirm off
set history save on
set history filename ~/.gdb_history

The first line is the most important for NASM users. By default, GDB shows AT&T syntax, which can be jarring when your source is in Intel syntax.

Step 6: A Real Debugging Session

Let's debug the NASM program we wrote earlier.

gdb ./hello

Then walk through it:

(gdb) break _start
(gdb) run
(gdb) layout regs
(gdb) stepi
(gdb) stepi
(gdb) info registers rax rdi rsi rdx
(gdb) x/s &msg
(gdb) x/8xb &msg
(gdb) display/i $pc
(gdb) continue

Here is what's going on:

  • break _start sets a breakpoint at our entry label.
  • stepi (or si) executes exactly one machine instruction. This is the heart of assembly debugging.
  • nexti (or ni) does the same but steps over call instructions.
  • x/s &msg prints a string at that address. x/8xb shows 8 bytes in hex.
  • display/i $pc shows the next instruction automatically after every step.

GDB Commands Worth Memorizing

Command What it does
si / ni Step one instruction / step over calls
info registers Show all general registers
p/x $rax Print a register in hex
x/10i $pc Disassemble the next 10 instructions
x/16xg $rsp Dump 16 quadwords from the stack
info breakpoints List breakpoints
watch *0x... Break when memory changes
set $rax = 5 Change a register on the fly
disassemble Disassemble the current function

The x command format is x/<count><format><size>. Formats include x (hex), d (decimal), s (string), and i (instruction). Sizes are b (byte), h (halfword), w (word), and g (giant, 8 bytes).

Step 7: Debugging ARM Binaries Running Under QEMU

This is the part many guides skip, and it's where everything comes together. QEMU can pause your ARM program and wait for a debugger to attach, using GDB's remote protocol.

Terminal 1: Start QEMU in Debug Mode

qemu-aarch64 -g 1234 ./hello_arm64

The -g 1234 flag opens a GDB server on port 1234 and waits. Nothing runs until you connect.

Terminal 2: Attach with gdb-multiarch

Your regular x86 gdb can't understand ARM64 registers. Use gdb-multiarch instead:

gdb-multiarch -q ./hello_arm64

Then inside GDB:

(gdb) set architecture aarch64
(gdb) target remote :1234
(gdb) break _start
(gdb) continue
(gdb) layout regs
(gdb) stepi
(gdb) info registers x0 x1 x2 x8

Since we assembled with -g, and the binary is an ARM ELF file, GDB usually picks up the architecture on its own. The set architecture line is a harmless safety net.

Now you are single-stepping through ARM64 instructions on an x86 laptop, with registers updating live in your TUI or GEF view. If you ever wanted a cheap lab for learning ARM reverse engineering, this is it.

Make It Repeatable with a GDB Script

Typing the same lines every time gets boring. Save this as arm.gdb:

set architecture aarch64
target remote :1234
break _start
continue
layout regs

Then start a session with one command:

gdb-multiarch -q -x arm.gdb ./hello_arm64

Step 8: Automate Everything with a Makefile

Typing long commands will wear you down. Here is a compact Makefile that builds all three of our examples:

NASM   = nasm
NFLAGS = -f elf64 -g -F dwarf
ARM_AS = aarch64-linux-gnu-as
ARM_LD = aarch64-linux-gnu-ld

all: hello hello_gas hello_arm64

hello: hello.asm
	$(NASM) $(NFLAGS) $< -o $@.o
	ld $@.o -o $@

hello_gas: hello_gas.s
	as -g $< -o $@.o
	ld $@.o -o $@

hello_arm64: hello_arm64.s
	$(ARM_AS) -g $< -o $@.o
	$(ARM_LD) $@.o -o $@

run-arm: hello_arm64
	qemu-aarch64 ./hello_arm64

debug-arm: hello_arm64
	qemu-aarch64 -g 1234 ./hello_arm64

clean:
	rm -f *.o hello hello_gas hello_arm64

.PHONY: all run-arm debug-arm clean

Makefiles need real tab characters before the recipe lines. If you get a "missing separator" error, that is almost always the reason.

Now make builds everything, make debug-arm starts the QEMU debug server, and make clean wipes the mess.

Best Practices for a Healthy Assembly Workflow

After plenty of late nights with this stuff, here is what I would tell a friend:

  1. Comment almost every line, at least at the start. Assembly is write-only code if you don't. Future you will be grateful.
  2. Always assemble with debug info. It costs nothing and saves hours.
  3. Keep one project folder per architecture. Mixing x86 and ARM files in one directory gets confusing quickly.
  4. Use objdump -d -M intel on your finished binary. Compare it to your source and confirm the assembler did what you expected.
  5. Check the ABI before writing function calls. Calling conventions differ between x86-64 System V and ARM64 (AAPCS64).
  6. Learn the syscall tables. man 2 syscalls and the kernel's syscall table files are your friends.
  7. Use version control. A tiny repo with a Makefile and a README is enough, and it keeps experiments organized.
  8. Start tiny. Print a string, add two numbers, loop five times. Small wins build real understanding.

Handy Inspection Commands

objdump -d -M intel hello           # disassemble in Intel syntax
readelf -h hello                    # ELF header
readelf -S hello                    # section list
nm hello                            # symbols
strace ./hello                      # watch the syscalls happen

Try strace on your Hello program. Seeing the write and exit calls listed exactly as you coded them is oddly satisfying.

Where to Go Next

Once your environment is solid, there are plenty of directions to explore:

  • Write a small loop or a function that follows the proper calling convention.
  • Read GCC's output for simple C programs and figure out each instruction.
  • Try reverse engineering a small crackme using GDB and GEF.
  • Add RISC-V to the mix. The same QEMU approach works with qemu-riscv64 and a RISC-V cross toolchain.
  • Go bare metal with qemu-system-* and write a tiny bootloader or kernel.

The setup you built today supports all of these.

Conclusion

A good assembly dev environment on Linux doesn't need to be complicated. NASM gives you a friendly way into x86-64. GAS opens the door to compiler output and many other architectures. QEMU lets you run and debug ARM binaries without owning a single ARM board. And GDB, once you give it a proper UI, turns from a cryptic prompt into a very capable window into your CPU.

Here are the key takeaways:

  • Install nasm, binutils, gdb, gdb-multiarch, qemu-user, and an ARM cross toolchain.
  • Always assemble with debug info, and always end programs with an exit syscall.
  • Use -g on QEMU to debug ARM programs from gdb-multiarch.
  • Pick one GDB front-end (TUI, GEF, pwndbg, or gdb-dashboard) and stick with it.
  • Automate with a Makefile so experiments take seconds, not minutes.

Now open your terminal, type out that first Hello program, and step through it one instruction at a time. That's where assembly stops feeling scary and starts feeling like a superpower.