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));
exit(0);
}
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:
- Load syscall arguments into registers.
- Tell the kernel which syscall to invoke.
- 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
/* Data segment: define our message string and calculate its length. */
msg:
.ascii "Hello, ARM64!\n"
len = . - msg
.text
/* Our application's entry point. */
.globl _start
_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 #93 */
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!
Cross-compiling
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!
Enjoy!
-
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. ↩︎