ARM64 Syscall Entry
From SVC instruction to kernel C handler and back
Every system call made by a 64-bit ARM process — read(), write(), mmap(), or any other — follows a
specific hardware and software path from SVC #0 in userspace through the kernel's assembly entry point,
register-save machinery, C dispatch layer, and back to userspace via ERET. This page traces that path.
The SVC instruction
On ARM64, system calls are made with SVC #0 (Supervisor Call). The calling convention is:
| Register | Role |
|---|---|
x8 |
Syscall number |
x0–x5 |
Arguments (up to 6) |
x0 |
Return value (after return) |
/* Userspace: calling write(1, buf, len) on ARM64 */
mov x8, #64 /* __NR_write = 64 */
mov x0, #1 /* fd = 1 (stdout) */
ldr x1, =buf /* buffer address */
mov x2, #len /* length */
svc #0 /* trap to EL1; return value in x0 */
The immediate in SVC #0 is conventionally zero — Linux ignores it. The syscall number comes from x8.
Contrast with x86-64
| Aspect | ARM64 (SVC #0) |
x86-64 (SYSCALL) |
|---|---|---|
| Syscall number | x8 |
rax |
| Arguments | x0–x5 |
rdi, rsi, rdx, r10, r8, r9 |
| Return value | x0 |
rax |
| Saved return address | CPU writes ELR_EL1 |
CPU writes rcx |
| Saved flags | CPU writes SPSR_EL1 |
CPU writes r11 |
| Entry mechanism | Exception vector (VBAR_EL1) | Fixed MSR (LSTAR) |
On ARM64, SVC #0 triggers a synchronous exception dispatched through the vector table. On x86-64,
SYSCALL jumps directly to the LSTAR MSR address without going through the IDT.
Exception vector dispatch
When SVC #0 executes, the CPU takes a synchronous exception and jumps to VBAR_EL1 + 0x400 —
the slot for synchronous exceptions from 64-bit EL0:
VBAR_EL1 + 0x200: Synchronous, EL1 with SP_ELx (kernel exceptions)
VBAR_EL1 + 0x400: Synchronous, lower EL (AArch64) ← SVC from 64-bit EL0
VBAR_EL1 + 0x600: Synchronous, lower EL (AArch32) ← SVC from 32-bit EL0
The Linux vector table in arch/arm64/kernel/entry.S:
/* arch/arm64/kernel/entry.S */
.align 11
SYM_CODE_START(vectors)
kernel_ventry 1, h, 64, sync /* Synchronous EL1h (kernel exceptions) */
kernel_ventry 1, h, 64, irq /* IRQ EL1h */
/* ... */
kernel_ventry 0, t, 64, sync /* Synchronous EL0, 64-bit ← here for SVC */
kernel_ventry 0, t, 64, irq /* IRQ EL0, 64-bit */
/* ... */
kernel_ventry 0, t, 32, sync /* Synchronous EL0, 32-bit (AArch32 compat) */
/* ... */
SYM_CODE_END(vectors)
ESR_EL1 and EC == 0x15
The CPU populates ESR_EL1 with the exception cause. Bits 31:26 are the Exception Class (EC); for
a 64-bit SVC, EC is 0x15:
/* arch/arm64/include/asm/esr.h */
#define ESR_ELx_EC_SHIFT 26
#define ESR_ELx_EC_SVC64 0x15 /* SVC from 64-bit EL0 */
#define ESR_ELx_EC_SVC32 0x11 /* SVC from 32-bit EL0 */
The handler reads ESR_EL1, extracts EC, and branches to el0_svc:
/* arch/arm64/kernel/entry-common.c */
asmlinkage void noinstr el0t_64_sync_handler(struct pt_regs *regs)
{
unsigned long esr = read_sysreg(esr_el1);
switch (ESR_ELx_EC(esr)) {
case ESR_ELx_EC_SVC64:
el0_svc(regs);
break;
case ESR_ELx_EC_DABT_LOW:
el0_da(regs, esr);
break;
/* ... other exception classes ... */
default:
el0_inv(regs, esr);
}
}
Note: el0t_64_sync_handler is a C function in arch/arm64/kernel/entry-common.c (not assembly). The low-level vector stub in entry.S saves registers with kernel_entry 0, 64 and then calls this C handler.
Register save and restore
kernel_entry 0, 64 saves all userspace registers onto the kernel stack as a struct pt_regs:
/* arch/arm64/include/asm/ptrace.h */
struct pt_regs {
union {
struct user_pt_regs user_regs;
struct {
u64 regs[31]; /* x0–x30 */
u64 sp; /* user stack pointer (SP_EL0) */
u64 pc; /* ELR_EL1: user return address */
u64 pstate; /* SPSR_EL1: saved processor state */
};
};
u64 orig_x0; /* original x0 (for syscall restart) */
s32 syscallno; /* syscall number */
/* ... additional fields for MTE, SVE state ... */
};
The macro switches to the per-task kernel stack (from SP_EL1), saves x0–x30, sp, ELR_EL1
(return PC), and SPSR_EL1 (saved PSTATE). The resulting pt_regs * is passed to all C handlers.
On the return path, kernel_exit 0 restores all fields and executes ERET, which atomically restores
the program counter from ELR_EL1 and processor state from SPSR_EL1, returning to EL0.
The C dispatch path
After register save, control flows from assembly into C:
el0_svc (entry-common.c)
└─► do_el0_svc (syscall.c)
└─► el0_svc_common (syscall.c)
├── syscall_enter_from_user_mode_work() [tracing, seccomp]
└── invoke_syscall()
└─► sys_call_table[scno](regs)
el0_svc_common in arch/arm64/kernel/syscall.c saves orig_x0 and syscallno into pt_regs, runs
any pre-syscall work, then calls invoke_syscall:
/* arch/arm64/kernel/syscall.c */
static void invoke_syscall(struct pt_regs *regs, unsigned int scno,
unsigned int sc_nr,
const syscall_fn_t syscall_table[])
{
long ret;
if (scno < sc_nr) {
syscall_fn_t syscall_fn;
syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)]; /* Spectre v1 */
ret = __invoke_syscall(regs, syscall_fn);
} else {
ret = do_ni_syscall(regs, scno); /* -ENOSYS */
}
syscall_set_return_value(current, regs, 0, ret);
}
The syscall table
The ARM64 native syscall table is built in arch/arm64/kernel/sys.c via the __SYSCALL() macro:
/* arch/arm64/kernel/sys.c */
#define __SYSCALL(nr, sym) [nr] = __arm64_##sym,
const syscall_fn_t sys_call_table[__NR_syscalls] = {
[0 ... __NR_syscalls - 1] = __arm64_sys_ni_syscall,
#include <asm/unistd.h>
};
ARM64 uses the generic syscall numbering from include/uapi/asm-generic/unistd.h — the same table
shared with RISC-V and LoongArch. __NR_write is 64, __NR_read is 63, __NR_openat is 56.
Syscall tracing and seccomp
syscall_enter_from_user_mode_work() in kernel/entry/common.c handles all pre-syscall hooks. It
checks the syscall_work flags in thread_info:
/* kernel/entry/common.c */
static long syscall_enter_from_user_mode_work(struct pt_regs *regs, long syscall)
{
unsigned long work = READ_ONCE(current_thread_info()->syscall_work);
if (work & SYSCALL_WORK_SECCOMP) {
int ret = __secure_computing(NULL); /* run BPF filter */
if (ret == -1)
return ret; /* process killed by seccomp */
}
if (work & SYSCALL_WORK_SYSCALL_TRACEPOINT)
trace_sys_enter(regs, syscall); /* sys_enter raw tracepoint */
syscall = ptrace_report_syscall_entry(regs); /* ptrace stop if traced */
return syscall;
}
When a task is traced with PTRACE_SYSCALL (as used by strace), SYSCALL_WORK_SYSCALL_TRACE is set (since Linux 5.12, replacing the old TIF_SYSCALL_TRACE) and
ptrace_report_syscall_entry() pauses the task. The tracer reads registers via PTRACE_GETREGSET with
NT_PRSTATUS, which exposes struct user_pt_regs.
The return path mirrors entry: syscall_exit_to_user_mode() fires sys_exit tracepoints, handles
ptrace exit-stops, and delivers any pending signals before kernel_exit restores registers.
AArch32 compat
A 64-bit ARM64 kernel can run 32-bit ARM (AArch32) ELF binaries (CONFIG_COMPAT). These processes also
use SVC #0, but with a different register convention:
| Register | Role |
|---|---|
r7 |
Syscall number (EABI) |
r0–r5 |
Arguments |
r0 |
Return value |
The hardware routes AArch32 synchronous exceptions to VBAR_EL1 + 0x600 instead of +0x400. The
entry handler el0t_32_sync_handler recognises EC 0x11 (ESR_ELx_EC_SVC32) and branches to
el0_svc_compat, which dispatches via a separate table:
/* arch/arm64/kernel/sys_compat.c */
#define __SYSCALL(nr, sym) [nr] = __arm64_compat_##sym,
const syscall_fn_t compat_sys_call_table[__NR_compat_syscalls] = {
[0 ... __NR_compat_syscalls - 1] = __arm64_compat_sys_ni_syscall,
#include <asm/unistd32.h>
};
AArch32 syscall numbers match the historical 32-bit ARM ABI and differ from native arm64 numbers.
Compat handlers use compat_* types (compat_size_t, compat_timespec64, compat_uptr_t) to
translate 32-bit struct layouts. TIF_32BIT marks a compat thread; is_compat_task() checks it.
vDSO: avoiding SVC for hot paths
clock_gettime, gettimeofday, and clock_getres are called millions of times per second in some
workloads. The vDSO (virtual Dynamic Shared Object) implements these without SVC #0 by reading
timekeeping data from a kernel-managed shared page:
cat /proc/self/maps | grep -E 'vdso|vvar'
# ffff800000000000-ffff800000001000 r--p 00000000 00:00 0 [vvar]
# ffff800000001000-ffff800000002000 r-xp 00000000 00:00 0 [vdso]
The ARM64 vDSO lives at arch/arm64/kernel/vdso/. Instead of the x86 TSC, it reads CNTVCT_EL0 — the
ARM generic virtual timer counter, which the kernel configures as accessible from EL0:
The vDSO reads struct vdso_data (from include/vdso/datapage.h) in the vvar page, using a seqlock
to detect concurrent kernel updates. The shared implementation in lib/vdso/gettimeofday.c is compiled
for both arm64 and x86; only __arch_get_hw_counter() is architecture-specific. If the clock mode is
VDSO_CLOCKMODE_NONE (e.g., after VM migration), the vDSO falls back transparently to svc #0.
There is no vsyscall fixed-address page on ARM64 — that is an x86-64-only legacy.
Observability
strace
strace ls # trace all syscalls
strace -e trace=read,write ls # filter by syscall name
strace -T ls # show per-syscall elapsed time
strace -p <pid> # attach to a running process
strace uses ptrace(PTRACE_SYSCALL) and never sees vDSO-accelerated calls — those never issue
svc #0.
perf trace and bpftrace
perf trace ls # low-overhead syscall trace via tracepoints
perf trace --summary sleep 5 # per-syscall counts and latency summary
# Count syscalls by name
bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }'
# Trace specific process
bpftrace -e 'tracepoint:syscalls:sys_enter_read /pid == 1234/ { printf("fd=%d\n", args->fd); }'
/proc/PID/syscall
cat /proc/$(pidof sshd)/syscall
# 281 0x4 0x7ffd3a2b1000 0x400 0x0 0x0 0x0 0x7ffd3a2b1080 0xffffb3a82000
# ^nr ^args... ^sp ^pc
Shows the syscall number, arguments, user SP, and user PC for a process blocked in a syscall.
Raw tracepoints
# Enable sys_enter_read tracing via ftrace
echo 1 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_read/enable
cat /sys/kernel/debug/tracing/trace
Further reading
In this site:
- ARM64 Exception Model — VBAR_EL1, ESR_EL1, exception levels, and the GIC
- ARM64 Memory Model — Weak ordering, barriers, and LDAR/STLR
- x86-64 Syscall Entry — SYSCALL/SYSRET, entry_SYSCALL_64, KPTI
- Syscall Entry Path — Architecture-neutral overview
- vDSO and Virtual System Calls — vvar page, seqlock, clock modes
- 32-bit Compat Syscalls — compat types, compat_sys_call_table
- ptrace and Syscall Interception — How strace works
Kernel source files:
arch/arm64/kernel/entry.S— vector table,kernel_entry/kernel_exitmacros, low-level stubsarch/arm64/kernel/entry-common.c— C exception handlers includingel0t_64_sync_handlerarch/arm64/kernel/syscall.c—do_el0_svc,el0_svc_common,invoke_syscallarch/arm64/kernel/sys.c—sys_call_tabledefinitionarch/arm64/kernel/sys_compat.c—compat_sys_call_tablefor AArch32 processesarch/arm64/kernel/vdso/— ARM64 vDSO sourcearch/arm64/include/asm/ptrace.h—struct pt_regslayoutarch/arm64/include/asm/esr.h— ESR_EL1 exception class constantsinclude/vdso/datapage.h—struct vdso_data(shared vvar layout)lib/vdso/gettimeofday.c— Shared vDSO clock implementation (arm64 and x86)kernel/entry/common.c—syscall_enter_from_user_mode,syscall_exit_to_user_mode
External:
- ARM Architecture Reference Manual (ARM DDI 0487) — definitive AArch64 exception model reference
Documentation/arm64/in the kernel tree