ARM64 System Call ABI

On this page

AArch64 normal function calls and Linux system calls both pass arguments starting from x0, but they do not use the same convention. Normal functions use bl for branching and allocate registers according to AAPCS64; system calls place the call number in w8, execute svc #0, and the kernel fulfills the request. The immediate value of svc is not the Linux system call number.

This article discusses native Linux user-space for AArch64 and does not cover the AArch32 compatibility ABI. Applications should use SYS_* or __NR_* from the target platform headers; do not carry over x86-64 call numbers to ARM64.

Master the Application-Visible Conventions First

PurposeAArch64 Linux Convention
Entering the kernelsvc #0
System call numberw8, i.e., the lower 32 bits of x8
Up to six argumentsx0, x1, x2, x3, x4, x5
Primary return valuex0
Raw error-errno in x0, error range -4095 to -1
libc public wrapper errorUsually returns -1 and sets the thread-local errno

The same x0 represents argument 1 upon entry and the result upon exit. For example, in write, the x0 at entry is the file descriptor, and the x0 at successful exit is the number of bytes written in this call. The C function syscall() already handles errno; it is not equivalent to a snippet of assembly that directly returns raw registers. syscall(2)

Writing to w0 clears the upper 32 bits; width must follow the argument type mov w0, #-1 x0 = 0x00000000ffffffff 64-bit register value after writing the 32-bit view sxtw x0, w0 x0 = 0xffffffffffffffff Sign-extends signed 32-bit -1 to 64-bit -1 If the kernel argument is int, interpret the lower bits as int; if it is long/pointer, full width is required. The two bit patterns have the same effect for some int arguments, but this does not mean they are interchangeable for all system call arguments.

w and x are different width views of the same register

Writing to a w register clears the upper 32 bits of the corresponding x register. Therefore, the full x0 value obtained from mov w0, #-1 is not 64-bit -1. If the kernel argument type is int, the all-1s lower 32 bits can be interpreted as -1 according to that type; if the interface requires a signed 64-bit number, sign extension cannot be omitted. Pointer, size_t, and offset arguments must also be prepared according to the target ABI and interface definitions; they cannot be uniformly loaded using w registers. Arm AArch64 Registers

The following bitwise operation model only illustrates register bit patterns and does not invoke the kernel. It also demonstrates that truncating the return value to 32 bits masks the full 64-bit result; actual raw error checking must use the full width of x0.

MASK32 = (1 << 32) - 1
MASK64 = (1 << 64) - 1
w_minus_one = (-1) & MASK32
x_after_w_write = w_minus_one
x_after_sxtw = (w_minus_one | (~MASK32)) & MASK64
assert x_after_w_write == 0x00000000ffffffff
assert x_after_sxtw == 0xffffffffffffffff

def raw_error(bits):
    return (bits & MASK64) >= ((-4095) & MASK64)

assert raw_error(x_after_sxtw)
assert not raw_error(x_after_w_write)
assert raw_error((-22) & MASK64)
assert not raw_error(0x0000000100000000)
assert (0x0000000100000000 & MASK32) == 0

Here, x_after_w_write is not in the 64-bit error range, so you cannot judge it as a system call failure just by seeing 0xffffffff in the lower 32 bits. On the other hand, you cannot declare it a valid address just because the same number is not in the error range: whether it is an address and whether it is accessible are still determined by the specific call result and mapping conditions.

Same low bits, different return value?

At -1, switching extension changes only the upper half but changes the 64-bit meaning from 4294967295 to -1. At -4096, even the sign-extended negative value lies outside the reserved raw-error interval.

Preparing the visual
Same low bits, different return value?

Writing w clears the upper half; sign extension replicates the sign bit. Raw errors are recognized using the full 64-bit reserved range.

What Exactly Does SVC Save?

AArch64 EL0 → EL1: Exception state is saved by hardware, general-purpose registers are saved by the kernel User executes svc #0 w8 is the system call number, x0–x5 are arguments 1 CPU records exception state Return PC → ELR_EL1; PSTATE → SPSR_EL1 2 Enter synchronous exception vector Select EL1 stack; ESR_EL1 identifies SVC exception 3 Linux saves registers and dispatches Forms pt_regs, checks tracing, etc., calls handler 4 Linux restores context and executes eret x0 brings back result, returns to user mode 5 The diagram illustrates the architectural mechanism using EL1 kernel; virtualization configuration does not change the syscall ABI used by applications.

Taking entry from EL0 to EL1 as an example, the hardware saves the return address and processor state, selects the stack for the target exception level, and enters the exception vector. Synchronous exception slots from a lower exception level in AArch64 state are located at VBAR_EL1 + 0x400; the entry code also needs to distinguish between SVC, instruction exceptions, etc., based on the exception reason.

The hardware does not push all x0–x30 into memory at once. The Linux software entry is responsible for saving the required general-purpose registers, user stack pointer, etc., forming a context available for tracing, signal delivery, and return path operations. Do not mistakenly write "hardware does not save any state" because "hardware does not automatically save all general-purpose registers." Arm Exception Model

When reading Linux v6.18, you can trace from kernel_entry/kernel_exit in entry.S to el0_svc_common and invoke_syscall in syscall.c. Entry and exit also involve tracing, signals, rescheduling, and security-related work; you cannot represent the complete kernel implementation with a few spliced pseudo-assemblies. Entry Assembly, System Call Dispatch

When Comparing with x86-64, Compare at the Same Level

QuestionNative x86-64 LinuxNative AArch64 Linux
Argument 4r10, avoiding rcx used by hardwarex3
Where is the return location temporarily stored by hardwarercxELR_EL1 (using EL1 as an example)
Where is the processor state temporarily stored by hardwarer11SPSR_EL1 (using EL1 as an example)
Establishing entry memory frameLinux software saves registersLinux software saves registers
Exception returnSYSRET if conditions met, otherwise IRETERET

AArch64 does not have the same-named general-purpose register corruption of x86 SYSCALL on rcx/r11, but this still does not allow you to say "system calls preserve all register states." Especially for extended vector states with independent ABIs: the SVE system call boundary has clear conventions for high vector registers, predicate registers, etc., and you cannot assume the entire SVE/SME state remains unchanged. Linux SVE ABI

Performance cannot be deduced from the table either. Whether page table switches are triggered, kernel mitigation measures, CPU model, frequency, and virtualization environment all affect latency; neater register arrangement does not necessarily mean faster execution.

A Complete, Assemblable Example

The following relies only on AArch64 Linux's write=64 and exit=93. It handles short writes and EINTR, and exits on other failures; it is suitable for observing the ABI, not a general asynchronous I/O loop.

.section .rodata
message:
    .ascii "hello\n"

.section .text
.global _start
_start:
    adrp x1, message
    add x1, x1, :lo12:message
    mov x2, #6
.Lwrite:
    mov x0, #1
    mov w8, #64
    svc #0
    cmn x0, #4              // -EINTR
    b.eq .Lwrite
    cmp x0, #0
    b.le .Lerror
    add x1, x1, x0
    subs x2, x2, x0
    b.ne .Lwrite
    mov x0, #0
    b .Lexit
.Lerror:
    mov x0, #1
.Lexit:
    mov w8, #93             // Native exit terminates the current thread
    svc #0
.section .note.GNU-stack,"",%progbits

On AArch64 Linux, you can build with native as and ld; on other architectures, you can only do cross-assembly and linking:

clang --target=aarch64-linux-gnu -c syscall-arm64.S -o syscall-arm64.o
ld.lld -m aarch64elf -o syscall-arm64 syscall-arm64.o
readelf -h -l syscall-arm64

Generating an object file does not mean it has been executed and verified on ARM64. Running it also requires AArch64 Linux or a correctly configured emulation environment. This example has no libc startup code, and exit does not flush stdio buffers.

Making an Assembly Leaf Function Satisfy Both C ABI and Syscall ABI

The previous _start has no normal callers. If you want C to call an assembly wrapper, the function must receive arguments and return according to AAPCS64, and also place the registers required by Linux before svc. The following raw-write-arm64.S uses the three C arguments directly for write and returns the raw result directly.

.text
.global raw_write_arm64
.type raw_write_arm64, %function
raw_write_arm64:
    mov w8, #64
    svc #0
    ret
.size raw_write_arm64, .-raw_write_arm64
.section .note.GNU-stack,"",%progbits

The corresponding C caller is saved as raw-write-main.c:

extern long raw_write_arm64(int fd, const void *buffer, unsigned long size);
int main(void) {
    static const char message[] = "hello\n";
    const char *next = message;
    unsigned long left = sizeof message - 1;
    while (left != 0) {
        long result = raw_write_arm64(1, next, left);
        if (result == -4) continue;  /* Native Linux EINTR */
        if (result <= 0) return 1;
        next += result;
        left -= (unsigned long)result;
    }
    return 0;
}

Here, the C call places fd, buffer, and size in the argument positions corresponding to x0–x2; the assembly only needs to set w8. The return address established by bl is in x30; the exception return state of svc is saved in the exception level registers. After Linux returns to user mode, this leaf wrapper can ret back to the C caller.

If you later add a bl helper in the wrapper, you must save and restore your own return address and comply with the callee-saved register rules. AAPCS64 public interface requires SP to be aligned to 16 bytes; do not imitate the style of pushing only one 8-byte value, causing subsequent calls to execute with incorrect stack alignment. AAPCS64 Subroutine Call

clang --target=aarch64-linux-gnu -c raw-write-arm64.S -o raw-write-arm64.o
clang --target=aarch64-linux-gnu -ffreestanding -c raw-write-main.c -o raw-write-main.o
readelf -sW raw-write-arm64.o
readelf -rW raw-write-main.o

The above two compilations do not require C standard library headers, making them suitable for checking symbols and call relocations on other architectures. They do not complete C runtime linking. For actual runtime, use the native toolchain on AArch64 Linux to execute cc raw-write-main.c raw-write-arm64.S -o raw-write-demo, then run ./raw-write-demo; the expected output is hello.

Check LevelWhat It Can ShowWhat It Cannot Replace
Cross-assemblyInstruction and relocation syntax are acceptableWhether the kernel accepts the current arguments
ELF header / symbol / relocationTarget is AArch64, call references have been generatedCorrect runtime register contents
Native or emulation environment runOutput and exit status on the current pathAll error paths, real hardware performance
Target machine stress and fault testingBehavior under signals, short writes, etc.Universal guarantees for other kernel configurations

CPU Features and System Call Numbers Are Different Issues

getcpu and mlock2 are not ARM64-unique system calls. PR_PAC_RESET_KEYS is an operation of prctl, not an independent syscall number. Whether PAC, BTI, SVE, and LSE are available requires judgment based on processor capabilities and the ABI exposed by the kernel.

In AArch64 Linux C programs, use the bit definitions in system headers; do not hand-copy HWCAP constants:

#include <sys/auxv.h>
#include <asm/hwcap.h>

int has_lse_atomics(void)
{
    return (getauxval(AT_HWCAP) & HWCAP_ATOMICS) != 0;
}

HWCAP describes capabilities that the kernel allows user-space to use, which is more reliable than guessing based on CPU model strings; it is not a checklist of whether system calls are implemented. Linux ELF HWCAP

LDXR/STXR are instructions that user-space atomic algorithms might use, and do not require entering the kernel every time. They must be distinguished from LDAXR/STLXR with acquire/release semantics; LSE provides another set of atomic instructions. Atomicity, memory ordering, and wait/wake are three separate issues; you cannot deduce NUMA scalability from "no x86 lock prefix." For how actual synchronization enters the kernel, continue reading futex.