'Hello World' in ARM64 Assembly

22 Aug 2020

Tags: ARM ARM64 AArch64 Assembly

If you’re looking for ‘Hello, World!’ for 32-bit ARM, check out my previous post: ‘Hello World’ in ARM Assembly.

The structure of a minimal ‘Hello World!’

Our aim is to translate the following ‘Hello, World!’ C app to ARM64 assembly:

#include <unistd.h>

void main() {
  const char msg[] = "Hello, ARM!\n";
  write(0, msg, sizeof(msg));

Beyond defining the main function entry point and our ‘Hello, World!’ message, the bulk of this program just invokes a couple of syscalls1 that do all the real work: write to write data to a file descriptor, and exit to request the OS terminate the program.

Invoking syscalls

At a high level, our app needs to do the following in order to invoke a syscall:

  1. Load syscall arguments into registers.
  2. Tell the kernel which syscall to invoke.
  3. Pass control to the kernel.

The ABI (application binary interface) specifies the calling convention that we follow to achieve these goals. Calling conventions for every architecture are described in the syscall(2) man pages:

man syscall

For the ARM64 ABI (also known as AArch64), syscall arguments are passed in registers x0-x5. The syscall number, specifying which syscall the kernel should invoke, is passed via the w8 register (this is the lower 32-bits of the x8 register). If any values are returned, they will be in registers x0 and x1. Control is passed to the kernel via the svc #0 instruction.

Syscall numbers for all new architectures, including ARM64, have been standardised in unistd.h. This provides a somewhat welcome change from older architectures where syscalls are all wildly different (for example, exit is 1 in ARM EABI and x86, but 60 in x86-64).

From unistd.h we can see that write is number 64 and exit is number 93.

To assembly

Our ARM64 assembly listing, hello.S, therefore looks like (see inline comments for details):


/* Data segment: define our message string and calculate its length. */
    .ascii        "Hello, ARM64!\n"
len = . - msg


/* Our application's entry point. */
.globl _start
    /* syscall write(int fd, const void *buf, size_t count) */
    mov     x0, #1      /* fd := STDOUT_FILENO */
    ldr     x1, =msg    /* buf := msg */
    ldr     x2, =len    /* count := len */
    mov     w8, #64     /* write is syscall #64 */
    svc     #0          /* invoke syscall */

    /* syscall exit(int status) */
    mov     x0, #0      /* status := 0 */
    mov     w8, #93     /* exit is syscall #1 */
    svc     #0          /* invoke syscall */

Assembling and running on an ARM64 device

This assumes you are running on an ARM64 machine like the Raspberry Pi 4 which has native ARM64 tooling. We first assemble an object file, and pass it to the linker:

as -o hello.o hello.S
ld -s -o hello hello.o

If all goes well, this will generate our Hello, World! binary that we can run:

➜ ./hello
Hello, ARM64!


If you don’t have access to an ARM64 device, or prefer to build on a different platform, you’ll need to install the ARM64 (AArch64) cross-compilation toolchain. For newer versions of Ubuntu this is as simple as:

sudo apt-get install gcc-aarch64-linux-gnu

As above, we must invoke the assembler followed by the linker, making sure to use the ARM64 toolchain:

aarch64-linux-gnu-as -o hello.o hello.S
aarch64-linux-gnu-ld -s -o hello hello.o

You will not be able run the generated binary natively, however you can copy it to an ARM64 device, or use QEMU user-mode emulation:

➜ qemu-aarch64 hello
Hello, ARM64!


  1. The reason I’ve chosen syscalls for this example instead of functions like printf is to show how this works at the very lowest level, by making our corresponding assembly program independent of the C standard library. ↩︎