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.
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 elf64tells NASM to produce a 64-bit Linux object file.-g -F dwarfadds debug information so GDB can show you source lines and labels.ldlinks the object file. We have no C library here, so the entry point is_start, notmain.
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 inrax. 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+Lredraws the screen when it gets garbled.Ctrl+XthenOswitches focus between windows.focus cmdreturns 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
~/.gdbinitfirst.
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 _startsets a breakpoint at our entry label.stepi(orsi) executes exactly one machine instruction. This is the heart of assembly debugging.nexti(orni) does the same but steps overcallinstructions.x/s &msgprints a string at that address.x/8xbshows 8 bytes in hex.display/i $pcshows 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:
- Comment almost every line, at least at the start. Assembly is write-only code if you don't. Future you will be grateful.
- Always assemble with debug info. It costs nothing and saves hours.
- Keep one project folder per architecture. Mixing x86 and ARM files in one directory gets confusing quickly.
- Use
objdump -d -M intelon your finished binary. Compare it to your source and confirm the assembler did what you expected. - Check the ABI before writing function calls. Calling conventions differ between x86-64 System V and ARM64 (AAPCS64).
- Learn the syscall tables.
man 2 syscallsand the kernel's syscall table files are your friends. - Use version control. A tiny repo with a Makefile and a README is enough, and it keeps experiments organized.
- 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-riscv64and 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
-gon QEMU to debug ARM programs fromgdb-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.