Assembly HOWTO: x86-64 assembly language on Linux with NASM and GAS

Updated November 2026

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:

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:

MeaningIntel (NASM)AT&T (GNU as)
rax = rax + 4add rax, 4addq $4, %rax
Load 32 bits from memorymov eax, dword [rbx]movl (%rbx), %eax
Operand orderdestination, sourcesource, 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:

Linux x86-64 system call convention
ItemRegister
System call numberrax
Arguments 1 to 6rdi, rsi, rdx, r10, r8, r9
Return valuerax
Overwritten by the callrcx (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

Sources