System Call Numbers and the ABI

Every system call is identified to the kernel not by name but by a small integer — its system call number. On Linux x86-64, read is 0, write is 1, open is 2, getpid is 39; userspace places that number in a register and traps into the kernel. The crucial and non-obvious fact is that these numbers are per-architecture and effectively frozen forever: read is 0 on x86-64 but 3 on 32-bit i386, the numbers are append-only (a new syscall always takes the next free number; a retired one’s number is never reused), and the assignment of name → number is part of the kernel’s stable Application Binary Interface (ABI). A binary compiled in 1996 that puts 1 in EAX and runs int 0x80 still writes today, because the i386 write number is still 4 and always will be. This note explains, against the 6.12 and 6.18 Long-Term-Support (LTS) kernels, why the numbers differ across architectures, where the authoritative list lives (the .tbl files), how they become the __NR_* constants in unistd.h, and why “append-only, never reused” is a hard rule, not a convention.

Mental Model

Think of syscall numbers as port numbers for the kernel. Just as TCP port 80 means HTTP by universal agreement and no one is free to redefine it without breaking every client, syscall number 1 on x86-64 means write by an agreement frozen into every compiled binary on the planet. The number is the wire format; the name is human convenience. And just as different network stacks could in principle assign ports differently, different CPU architectures assigned syscall numbers at different times in history, so the same name lands on different numbers — but within one architecture, the assignment is immutable.

flowchart LR
  subgraph SRC["Source of truth (in kernel tree)"]
    TBL["syscall_64.tbl<br/>0 common read sys_read<br/>1 common write sys_write<br/>39 common getpid sys_getpid"]
  end
  TBL -->|"scripts/syscallhdr.sh<br/>(build time)"| HDR["generated unistd_64.h<br/>#define __NR_read 0<br/>#define __NR_write 1<br/>#define __NR_getpid 39"]
  TBL -->|"scripts/syscalltbl.sh"| TABLE["syscalls_64.h<br/>(feeds the dispatch table)"]
  HDR -->|"copied / exposed via UAPI"| LIBC["userspace <sys/syscall.h><br/>libc wrappers"]
  LIBC -->|"mov rax, __NR_write"| TRAP["syscall instruction"]
  TABLE -.->|"same .tbl, handler side"| TRAP

How one .tbl line becomes both a userspace constant and a kernel handler. What it shows: the .tbl file is the single source of truth; one build-time script (syscallhdr.sh) turns each line into a #define __NR_<name> <number> that userspace consumes, while another (syscalltbl.sh) turns the same line into the kernel-side dispatch entry. The insight to take: the number a program emits and the handler the kernel runs are generated from the same line of the same file, which is exactly why they cannot drift — and why editing that line for an existing syscall would silently break the ABI on both sides.

The Number Is the ABI; the Name Is Not

The distinction between the kernel’s ABI and API is sharp here. The API is the C-level promise: there is a function-like interface called write that takes a file descriptor, a buffer, and a length. The ABI is the binary-level promise: to invoke it on x86-64 you put 1 in RAX, the fd in RDI, the buffer pointer in RSI, the length in RDX, and execute syscall. The kernel’s stable-ABI documentation states plainly that the syscall interface “is the interface between user space and the kernel” and that these interfaces “are expected to never change and always be available” (stable ABI docs).

Because the number is the ABI, a compiled program does not contain the string "write" anywhere in its syscall path — it contains the integer 1. The name exists only in source code and in the .tbl file; by the time the program runs, only the number matters. This is why renaming a syscall is harmless (no compiled binary references the name) but renumbering one is catastrophic (every compiled binary references the number).

Why the Numbers Differ Across Architectures

There is no global Linux syscall numbering. Each architecture chose its numbers at the time it was ported, and once a userspace ecosystem existed for that architecture, the numbers froze. The concrete, in-hand evidence from the v6.12 source: on x86-64 (syscall_64.tbl), read is 0 and write is 1; on 32-bit i386 (syscall_32.tbl), the table opens with restart_syscall at 0, exit at 1, fork at 2, and read at 3, write at 4. Same kernel, same syscalls, completely different numbers — because i386 inherited its numbering from the original Linux/x86 port (which itself echoed historical Unix conventions), while x86-64 took the opportunity of a fresh ABI in the early 2000s to renumber cleanly with read=0, write=1.

arm64 went further. When the AArch64 port was added it adopted the architecture-generic syscall table (include/uapi/asm-generic/unistd.h, with the table data in scripts/syscall.tbl) rather than inventing its own, so newer architectures (arm64, riscv, and others) share one canonical numbering. The header comment in the generic unistd.h states the design intent: “This file contains the system call numbers, based on the layout of the x86-64 architecture … New architectures should use this file” (generic unistd.h). So three regimes coexist in one kernel: legacy per-arch numbering (i386, the original x86-64, and other old ports), and the modern shared asm-generic numbering for everything ported after roughly 2012.

The syscall(2) manual page tabulates this divergence explicitly, listing the register that carries the number and the per-architecture number for each call (syscall(2)). The practical consequence: code that hardcodes a syscall number (rather than using the __NR_* constant for the architecture it is compiled on) is wrong on every other architecture.

The .tbl Files Are the Source of Truth

For the per-architecture-numbered ports, the authoritative list is a text file. For x86 it is arch/x86/entry/syscalls/syscall_64.tbl and syscall_32.tbl; for arm64 it is arch/arm64/tools/syscall_64.tbl (which in v6.12 is just a pointer to the shared scripts/syscall.tbl). The format, from the file’s own header, is:

# <number> <abi> <name> <entry point> [<compat entry point> [noreturn]]
0	common	read			sys_read
1	common	write			sys_write
2	common	open			sys_open

The columns are: the number, an ABI tag (common, 64, or x32 on x86-64; i386 on the 32-bit table), the name, the kernel entry point (sys_<name>), and two optional columns — a compat entry point (for 32-bit-on-64-bit argument translation) and a noreturn marker (e.g. exit is marked noreturn so the compiler optimizes the dispatch). The adding-syscalls.rst process document walks a new-syscall author through editing these files, and warns that “these numbers are liable to be changed if there are conflicts in the relevant merge window” — i.e. the number is not final until merge, precisely because two simultaneous patches might both grab the next free slot and one must yield (adding-syscalls.rst).

From .tbl to __NR_*: the Header Generation

A program does not read the .tbl file. It uses the __NR_<name> preprocessor constants from <asm/unistd.h> (which userspace reaches via <sys/syscall.h>). These constants are generated at kernel build time by scripts/syscallhdr.sh, which reads the .tbl and emits, for each line:

echo "#define __NR_$prefix$name $nr"

— producing, for x86-64, #define __NR_read 0, #define __NR_write 1, #define __NR_getpid 39, and so on into the generated unistd_64.h. The same script also emits the count of syscalls when invoked with --emit-nr:

echo "#define __NR_${prefix}syscalls $(($max + 1))"

That __NR_syscalls (count) is distinct from the per-syscall __NR_<name> (individual numbers) — a slip that bites people. The count is one greater than the highest number because numbering starts at 0. This generated count is exactly what the kernel’s dispatch bounds-check compares against (see The System Call Table — there it appears as the in-kernel NR_syscalls, the size of the dispatch table).

Crucially, the same .tbl line also feeds scripts/syscalltbl.sh, which generates the kernel-side __SYSCALL(nr, sym) entries that build the dispatch table/switch. So both halves of the contract — the number userspace emits and the handler the kernel runs — are mechanically derived from one line. They cannot disagree, which is the structural guarantee behind ABI stability.

Append-Only, Never Reused — and Why

The single most important rule: a syscall number, once allocated and shipped, is never reused for a different syscall, and never reassigned. New syscalls always take the next free number at the bottom of the table. This is not bureaucratic tidiness; it is forced by the ABI. Consider what reuse would mean: suppose number 39 (getpid on x86-64) were ever reassigned to a different syscall. Every binary on disk that calls getpid — every program ever compiled — would suddenly invoke the new syscall instead, with getpid’s arguments. That is precisely the “break userspace” that the kernel forbids (see The We Do Not Break Userspace ABI Promise).

When a syscall is genuinely retired, its number is not freed; it is left pointing at sys_ni_syscall (return -ENOSYS) or kept as a permanent stub so the number is “burned” — occupied forever so it can never be accidentally reallocated. The kernel even keeps the names of long-dead syscalls in the i386 table as bare placeholders with no entry point (e.g. 17 i386 break, 31 i386 stty, 35 i386 ftime in v6.12 — numbers whose original syscalls were removed decades ago but whose slots are reserved forever). These lines have a name and a number but no handler column, documenting “this number is taken, do not reuse.”

A famous illustration of the cost of getting numbering subtly wrong is the x32 ABI split. x32 (a 32-bit-pointer ABI on the 64-bit instruction set) originally shared the x86-64 number space with a high-bit flag (__X32_SYSCALL_BIT, 0x40000000). For “unfortunate historical reasons” some x32 syscalls were numbered differently from their x86-64 counterparts — ioctl is 16 on x86-64 but was 514 on x32 — creating bogus number/ABI combinations that could enter the wrong entry point (commit e0d60a1e68a3, “x86/syscalls: Split the x32 syscalls into their own table”). The fix split x32 into its own table so invalid combinations return -ENOSYS instead of executing unintended code — a real incident demonstrating that the numbering scheme is a security surface, not just an index.

Configuration / Worked Example: Reading the Tables Yourself

To see the per-architecture divergence concretely, the .tbl files are plain text in any kernel tree:

# x86-64: read=0, write=1
grep -E '^[0-9]+\s+\S+\s+(read|write|getpid)\b' \
    arch/x86/entry/syscalls/syscall_64.tbl
# 0   common  read    sys_read
# 1   common  write   sys_write
# 39  common  getpid  sys_getpid
 
# i386: read=3, write=4 — DIFFERENT numbers, same kernel
grep -E '^[0-9]+\s+i386\s+(read|write|getpid)\b' \
    arch/x86/entry/syscalls/syscall_32.tbl
# 3   i386  read    sys_read
# 4   i386  write   sys_write
# 20  i386  getpid  sys_getpid

In a running program, never hardcode the integer — use the constant for the architecture you are compiled on:

#include <sys/syscall.h>   /* pulls in arch-correct __NR_* */
#include <unistd.h>
 
/* Portable across arches: __NR_getpid resolves to 39 on x86-64,
 * 20 on i386, 172 on arm64 (asm-generic) — all from the same source line. */
long pid = syscall(__NR_getpid);

The syscall() wrapper (libc Syscall Wrappers and errno Translation) and the generic syscall(2) function take the number as their first argument; using __NR_getpid rather than a literal is what makes the same source compile correctly everywhere (syscall(2)).

Failure Modes and Common Misunderstandings

Hardcoding numbers. Writing syscall(39) for getpid works on x86-64 and is wrong everywhere else — it is mlock or something unrelated on another arch. Always use __NR_*. Cross-compilation toolchains and emulators (qemu-user) translate by number, so a hardcoded number silently misbehaves under emulation.

Assuming numbers are dense/sequential. They are not. The tables have gaps (retired or reserved numbers), and the i386 table has bare placeholder lines with no handler. Code that iterates “all syscalls from 0 to N” will hit -ENOSYS holes; that is expected.

Confusing __NR_syscalls with __NR_<name>. __NR_write is a syscall’s number; __NR_syscalls is the count of syscalls (used for the table bound). They are emitted by the same script but mean different things.

Expecting a single cross-arch number. New code sometimes assumes “the Linux number for X.” There isn’t one — there is the x86-64 number, the i386 number, the asm-generic number (arm64/riscv/…). The seccomp filter authors learn this the hard way: a seccomp BPF program that allowlists syscall numbers is architecture-specific and must check the audit architecture (AUDIT_ARCH_*) before trusting a number (seccomp(2), see seccomp and Syscall Filtering).

Alternatives and When to Choose Them

The append-only numbering is not a design choice with alternatives at runtime — it is the contract. The only real design fork is per-architecture numbering vs the shared asm-generic table, decided once when an architecture is ported. New architectures take asm-generic (one canonical numbering, less maintenance, fewer foot-guns); legacy architectures keep their historical numbers forever because changing them would break their existing binaries. There is no migrating an old architecture to the generic numbering — that would be the very ABI break the rule forbids.

Production Notes

The stability of these numbers is what makes the entire Linux binary ecosystem work: distributions ship binaries compiled years apart, container images freeze a userspace from one era and run it on a kernel from another, and qemu-user and Windows Subsystem for Linux translate syscalls by number across boundaries. All of it rests on the guarantee that write stays 1 on x86-64. When kernel developers add a syscall, the .tbl edit is reviewed precisely for “did you take the next free number, and only that,” because a number collision or a renumber is one of the few changes that can break every program at once. The tooling reinforces this: the build generates both the userspace constant and the kernel handler from the same line, so the only way to break the ABI is to edit that line for an existing call — which review exists to catch.

See Also