Assembly Hello World: A Cross-Platform Syscall Deep Dive
Master assembly syscalls across Linux & macOS for x86-64 and ARM64 architectures. This comprehensive assembly syscall tutorial provides working code examples for write and exit syscalls.
Writing assembly code that directly interfaces with the operating system kernel through syscalls provides invaluable insight into how programs communicate with the underlying system. This guide explores “Hello World” implementations across four different platform configurations: AArch64 (ARM64) and AMD64 (x86-64) architectures on both Linux and macOS operating systems.
1. Understanding the Syscalls
The tutorial focuses on the two primary syscalls required for a “Hello World” program. While the register numbers vary by platform, the semantics of these syscalls remain consistent across Unix-like systems.
The Write Syscall
The write syscall is the fundamental way a process requests the kernel to send bytes to an output stream.
- C Prototype:
ssize_t write(int fd, const void *buf, size_t count) - Walkthrough:
fd(File Descriptor): This is a small integer that handles the I/O stream. By standard convention,0isstdin,1isstdout(standard output), and2isstderr(standard error).buf(Buffer Address): This is a pointer to the data you want to write. In assembly, this is the label or immediate address of your string.count(Byte Count): The exact number of bytes to write. It is critical that this matches your buffer length; writing too few bytes will truncate your message, and writing too many can cause a “buffer over-read,” potentially leaking adjacent memory or causing a crash.
The Exit Syscall
The exit syscall is the proper way to terminate a process. Simply falling off the end of your code will result in a segmentation fault because the CPU will continue executing whatever “garbage” bytes follow your program in memory.
- C Prototype:
void _exit(int status) - Walkthrough:
status: This integer is returned to the parent process. A status of0traditionally signals success. Any non-zero value (typically1-255) indicates a specific error condition. Shells like Bash allow you to check this value immediately after execution usingecho $?.
2. Register Calling Conventions
x86-64 (AMD64) Syscall Convention
| Purpose | Register | Description |
|---|---|---|
| Syscall Number | rax | Identifies which syscall to invoke |
| Argument 1 | rdi | First parameter (e.g., file descriptor) |
| Argument 2 | rsi | Second parameter (e.g., buffer pointer) |
| Argument 3 | rdx | Third parameter (e.g., byte count) |
| Argument 4 | r10 | Fourth parameter (syscall only) |
| Argument 5 | r8 | Fifth parameter |
| Argument 6 | r9 | Sixth parameter |
| Return Value | rax | Syscall return value |
The rcx vs r10 Paradox: One of the most common pitfalls for assembly beginners on x86-64 is the transition from function calls to syscalls.
- Function Calls (User-land): Follow the System V ABI, which uses
rdi, rsi, rdx, rcx, r8, r9. - Syscalls (Kernel-land): Use
rdi, rsi, rdx, r10, r8, r9.
Why the change? The syscall instruction is designed for speed. To facilitate a fast return, the CPU automatically saves the return address (the next instruction pointer) into the rcx register and the processor flags into r11. If the kernel tried to use rcx to pass an argument, that argument would be overwritten the moment the syscall instruction executed. Therefore, the ABI mandates the use of r10 as a surrogate for the fourth argument.
AArch64 (ARM64) Syscall Convention
| Purpose | Linux Register | macOS Register | Description |
|---|---|---|---|
| Syscall Number | x8 | x16 | Identifies which syscall to invoke |
| Argument 1 | x0 | x0 | First parameter (e.g., file descriptor) |
| Argument 2 | x1 | x1 | Second parameter (e.g., buffer pointer) |
| Argument 3 | x2 | x2 | Third parameter (e.g., byte count) |
| Argument 4 | x3 | x3 | Fourth parameter |
| Argument 5 | x4 | x4 | Fifth parameter |
| Argument 6 | x5 | x5 | Sixth parameter |
| Return Value | x0 | x0 | Syscall return value |
Interactive Lab: Syscall ABI Diff Tool
Compare syscall numbers, argument registers, and invocation instructions across all four platforms at a glance. Select a group to focus on specific syscall categories.
Interactive Lab: Syscall Trace Simulator
Step through the register setup, kernel entry, and return phases of real Linux system calls. Toggle between x86-64 and ARM64 to compare syscall vs svc invocation.
3. Key Architectural Differences
Linux vs macOS Syscall Numbering
Linux uses a clean, direct mapping. For example, on x86-64, write is always 1. These numbers are defined in the kernel source (usually in unistd_64.h).
macOS x86-64 (The BSD Class Heritage): Apple’s kernel (XNU) inherited much of its syscall logic from FreeBSD. To distinguish between different types of kernel services (BSD, Mach, Private, etc.), macOS uses a “Class” system encoded in the upper bits of the syscall number.
- BSD Class (0x2): Most standard POSIX syscalls (
read,write,open) fall into this class. - The Mask: The BSD class is identified by the value
2in the high-order bits. Specifically, the formula is(2 << 24) | syscall_number. - In hexadecimal, this means standard BSD syscalls always start with
0x2000000. write = 0x2000000 + 4 = 0x2000004exit = 0x2000000 + 1 = 0x2000001
macOS AArch64 (The Modernization):
When Apple moved to Silicon (M1/M2/M3), they took the opportunity to simplify the interface. For ARM64, the high-order class bits are no longer required for standard BSD calls. You simply put the direct number in x16.
write = 4exit = 1
Syscall Invocation Instructions
- Linux AArch64: Uses
svc #0(Supervisor Call) to invoke the kernel. - macOS AArch64: Uses
svc #0x80. - Deep Dive on
svc: Thesvcinstruction triggers a synchronous exception, causing the CPU to switch from User Mode (EL0) to Kernel Mode (EL1). The immediate value (the#0or#0x80) is technically a payload that can be read by the exception handler, but on modern Linux and macOS, it is largely ignored. The kernel instead examines the syscall number register to determine which service to provide. This is a shift from older 32-bit ARM (A32), where the immediate value was frequently used to encode the syscall number directly.
4. Troubleshooting Common Syscall Errors
Even with the correct registers, syscalls can fail. Here are the most frequent issues encountered when writing “Hello World” in assembly:
1. The Segmentation Fault (Falling off the Cliff)
If you forget the exit syscall, your program will crash. After the svc or syscall for write returns, the CPU will fetch the next byte in memory. If that byte isn’t a valid instruction, or if it belongs to a different memory segment, the OS will kill your process with a SIGSEGV.
2. Invalid Buffer Addresses
On x86-64 Linux, using mov $msg, %rsi instead of lea msg(%rip), %rsi can cause issues if your code is compiled as Position Independent Executable (PIE). mov might load a 32-bit absolute address which is no longer valid in a randomized 64-bit address space. Always prefer RIP-relative addressing (lea msg(%rip)) for data access.
3. Clobbered Registers
The syscall instruction on x86-64 clobbers rcx and r11. If you were relying on those registers to hold important data across a syscall, that data is now gone. ARM64’s svc is more conservative but still requires careful management of the caller-saved registers (x0-x18).
4. macOS “lSystem” requirement
On macOS, even a pure assembly program often needs to be linked against libSystem.dylib (via -lSystem) if you want it to run on modern versions of the OS. This ensures the kernel correctly identifies the binary’s entry point and security entitlements.
5. Implementation: Hello World in Assembly
Below are the complete source files for each platform.
Linux AArch64
// hello_linux_aarch64.s
.global _start
.section .data
msg: .ascii "Hello, World!
"
msg_len = . - msg
.section .text
_start:
mov x0, #1 // fd = 1 (stdout)
ldr x1, =msg // buf = address of msg
mov x2, #msg_len // count = msg_len
mov x8, #64 // write syscall number (Linux AArch64)
svc #0 // invoke kernel
mov x0, #0 // status = 0
mov x8, #93 // exit syscall number (Linux AArch64)
svc #0 // invoke kernel macOS AArch64 (Apple Silicon)
// hello_macos_aarch64.s
.global start
.align 2
.section __DATA,__data
msg: .ascii "Hello, World!
"
msg_len = . - msg
.section __TEXT,__text
start:
mov x0, #1 // fd = 1 (stdout)
adr x1, msg // buf = relative address of msg
mov x2, #msg_len // count = msg_len
mov x16, #4 // write syscall number (macOS AArch64)
svc #0x80 // invoke kernel
mov x0, #0 // status = 0
mov x16, #1 // exit syscall number (macOS AArch64)
svc #0x80 // invoke kernel Linux x86-64
# hello_linux_x86_64.s
.global _start
.section .data
msg: .ascii "Hello, World!
"
msg_len = . - msg
.section .text
_start:
mov $1, %rax # write syscall number (Linux x86-64)
mov $1, %rdi # fd = 1 (stdout)
lea msg(%rip), %rsi # buf = address of msg
mov $msg_len, %rdx # count = msg_len
syscall # invoke kernel
mov $60, %rax # exit syscall number (Linux x86-64)
xor %rdi, %rdi # status = 0
syscall # invoke kernel macOS x86-64 (Intel)
# hello_macos_x86_64.s
.global start
.section __DATA,__data
msg: .ascii "Hello, World!
"
msg_len = . - msg
.section __TEXT,__text
start:
mov $0x2000004, %rax # write syscall (macOS x86-64 BSD class)
mov $1, %rdi # fd = 1 (stdout)
lea msg(%rip), %rsi # buf = address of msg
mov $msg_len, %rdx # count = msg_len
syscall # invoke kernel
mov $0x2000001, %rax # exit syscall (macOS x86-64 BSD class)
xor %rdi, %rdi # status = 0
syscall # invoke kernel 5. Compilation and Execution
Linux AArch64
as -o hello.o hello_linux_aarch64.s
ld -o hello hello.o
./hello macOS AArch64
as -o hello.o hello_macos_aarch64.s
ld -o hello hello.o -lSystem
./hello Linux x86-64
as -o hello.o hello_linux_x86_64.s
ld -o hello hello.o
./hello macOS x86-64
as -o hello.o hello_macos_x86_64.s
ld -o hello hello.o -lSystem
./hello