Assembly HOWTO: x86-64 assembly language on Linux with NASM and GAS
To write assembly on 64-bit Linux you need an assembler (NASM or GNU as), the linker ld, and the system call table: put the call number in rax, the arguments in rdi, rsi, rdx, r10, r8, r9, and run syscall. This page builds a complete program, shows how to mix assembly with C, and how to step through it in gdb.
The classic version of this HOWTO, written for 32-bit i386 and int 0x80, is kept by the Linux Documentation Project at tldp.org. Everything below is for x86-64 (also called amd64), with a short ARM64 section at the end.
Do you need assembly?
Rarely for speed: modern compilers schedule instructions better than most people. Good reasons today are learning how the CPU works, reading compiler output while debugging, boot code, and small hot loops using special instructions. Often you can stay in C instead:
- Read what the compiler made:
gcc -O2 -S -masm=intel prog.cwritesprog.s;objdump -d -M intel progdisassembles a binary. - Intrinsics: functions such as
_mm256_add_ps()from<immintrin.h>give you SIMD instructions with C types. - Inline assembly: GCC's
asm()statement for one or two instructions inside a C function.
Install the tools
sudo apt install nasm binutils gdb # Debian, Ubuntu
sudo dnf install nasm binutils gdb # Fedora, RHEL/Alma/Rocky
sudo pacman -S nasm binutils gdb # Arch
Check with nasm -v. NASM's latest release is 3.02 (as of November 2026); Debian 13 ships 2.16.03, which runs every example here. GNU as (as) and ld come with binutils, which GCC already needs.
Two syntaxes
NASM uses Intel syntax. GNU as uses AT&T syntax by default, which is also what GCC prints. The same instruction looks like this:
| Meaning | Intel (NASM) | AT&T (GNU as) |
|---|---|---|
| rax = rax + 4 | add rax, 4 | addq $4, %rax |
| Load 32 bits from memory | mov eax, dword [rbx] | movl (%rbx), %eax |
| Operand order | destination, source | source, destination |
In AT&T syntax, registers start with %, immediate values with $, and the size goes in a suffix (b, w, l, q for 8, 16, 32, 64 bits). GNU as also accepts Intel syntax after the directive .intel_syntax noprefix.
System calls on x86-64
A program talks to the kernel through system calls. The syscall(2) manual page lists the registers for each architecture:
| Item | Register |
|---|---|
| System call number | rax |
| Arguments 1 to 6 | rdi, rsi, rdx, r10, r8, r9 |
| Return value | rax |
| Overwritten by the call | rcx (return address), r11 (saved flags) |
Note the fourth argument: r10, not rcx as in normal C function calls, because the syscall instruction uses rcx itself. On error the raw call returns a negative error number in rax (for example -2 for ENOENT); there is no errno unless you go through the C library.
The numbers are in /usr/include/x86_64-linux-gnu/asm/unistd_64.h on Debian and Ubuntu; on other distributions find it with find /usr/include -name unistd_64.h. Three you need for the first program: write = 1, exit = 60, exit_group = 231.
Hello, world in NASM
; hello.asm - write a line to standard output, then exit
default rel
section .rodata
msg: db "Hello, world!", 10 ; 10 = newline
len: equ $ - msg
section .text
global _start
_start:
mov eax, 1 ; system call 1 = write
mov edi, 1 ; fd 1 = standard output
lea rsi, [msg] ; address of the text
mov edx, len ; number of bytes
syscall
mov eax, 60 ; system call 60 = exit
xor edi, edi ; exit status 0
syscall
$ - msg is "this position minus the start of the string", so len is 14. default rel makes [msg] an address relative to the instruction pointer, which is what 64-bit code should use. Writing to eax also clears the top half of rax, so mov eax, 1 is a shorter way to set rax to 1.
nasm -f elf64 -g -F dwarf hello.asm -o hello.o
ld hello.o -o hello
./hello
echo $?
Expected output is Hello, world! and then 0. -f elf64 selects 64-bit ELF objects, and -g -F dwarf adds debug information so gdb can show source lines. ld makes _start the entry point; the program uses no C library, so the file is about 9 KB and needs no shared libraries.
See the system calls it makes with strace ./hello:
write(1, "Hello, world!\n", 14Hello, world!
) = 14
exit(0) = ?
The same program in GNU as
# hello.s - the same program in GNU as (AT&T) syntax
.section .rodata
msg: .ascii "Hello, world!\n"
.set len, . - msg
.text
.globl _start
_start:
movl $1, %eax # write
movl $1, %edi # fd 1
leaq msg(%rip), %rsi # buffer
movl $len, %edx # count
syscall
movl $60, %eax # exit
xorl %edi, %edi # status 0
syscall
as -g hello.s -o hello.o
ld hello.o -o hello
Mixing assembly and C
Normal functions follow the System V AMD64 calling convention: integer arguments in rdi, rsi, rdx, rcx, r8, r9, the result in rax. Here is an assembly function called from C:
; sum.asm - long sum3(long a, long b, long c) for C callers
section .text
global sum3
sum3:
lea rax, [rdi + rsi] ; a + b
add rax, rdx ; + c
ret
/* main.c */
#include <stdio.h>
long sum3(long a, long b, long c);
int main(void)
{
printf("%ld\n", sum3(10, 20, 30));
return 0;
}
nasm -f elf64 sum.asm -o sum.o
gcc -Wall main.c sum.o -o sumdemo
./sumdemo # prints 60
Calling C from assembly needs three extra rules. The stack must be 16-byte aligned at each call; on entry to main it is 8 bytes off, so subtract 8. For variadic functions such as printf, al holds the number of vector registers used (0 here). And because distributions build position-independent executables by default, call shared-library functions through the PLT:
; printf.asm - call the C library from assembly (link with gcc)
default rel
extern printf
section .rodata
fmt: db "%s has %d letters", 10, 0
name: db "Linux", 0
section .text
global main
main:
sub rsp, 8 ; keep the stack 16-byte aligned
lea rdi, [fmt] ; 1st argument: format
lea rsi, [name] ; 2nd argument
mov edx, 5 ; 3rd argument
xor eax, eax ; no vector registers used (varargs)
call printf wrt ..plt
add rsp, 8
xor eax, eax ; return 0
ret
section .note.GNU-stack noalloc noexec nowrite progbits
nasm -f elf64 printf.asm -o printf.o
gcc printf.o -o printfdemo
./printfdemo # prints: Linux has 5 letters
Link with gcc, not ld, whenever you use the C library: it adds the startup code that calls main. The last line marks the stack as non-executable. Registers rbx, rbp and r12 to r15 must be saved by your function if it changes them.
Debugging with gdb
gdb -q ./hello
(gdb) break _start
(gdb) run
(gdb) layout regs # split screen: registers and source
(gdb) stepi # one instruction
(gdb) info registers rax rdi rsi rdx
(gdb) x/14c &msg # show 14 bytes at msg as characters
(gdb) disassemble # current function
(gdb) quit
stepi (short: si) runs one machine instruction; nexti steps over a call. Type set disassembly-flavor intel if you prefer Intel syntax in gdb's output.
ARM64 (AArch64)
On ARM64 Linux (for example a Raspberry Pi running a 64-bit system, or an ARM cloud server) the idea is the same with different registers: the number goes in x8, arguments in x0 to x5, the result in x0, and the instruction is svc #0. ARM64 uses the generic system call table, so write is 64 and exit is 93. NASM is x86-only; use GNU as:
// hello-arm64.s - AArch64 version (GNU as syntax)
.section .rodata
msg: .ascii "Hello, world!\n"
.set len, . - msg
.text
.globl _start
_start:
mov x8, #64 // system call 64 = write
mov x0, #1 // fd 1
adr x1, msg // buffer
mov x2, #len // count
svc #0
mov x8, #93 // system call 93 = exit
mov x0, #0
svc #0
On an ARM64 machine, build it with as hello-arm64.s -o hello.o && ld hello.o -o hello. From an x86-64 machine, clang --target=aarch64-linux-gnu -c hello-arm64.s cross-assembles it.
Mistakes to avoid
- No exit call. Without the final
syscallthe CPU runs into whatever bytes follow, and the program dies with a segmentation fault. - Using
int 0x80in 64-bit code. It enters the old 32-bit interface with different numbers and only the low 32 bits of each register, so 64-bit pointers break. - Passing argument 4 in
rcxto a system call, or inr10to a C function. - A misaligned stack before calling C: functions using SSE instructions can crash.
- Calling a register "safe" after a system call:
rcxandr11are always overwritten.
Sources
- syscall(2): architecture calling conventions, man7.org
- arch/x86/entry/entry_64.S (SYSCALL register use), Linux kernel source
- NASM home page (current release)
- NASM manual, chapter 2: running NASM (-f, -g, -F, -l, -o)
- NASM 3.02 documentation index
- AT&T syntax versus Intel syntax, GNU as manual
- Command-line options, GNU ld manual