Before you start#
Read init and userspace, C pointers and lifetime, and CPU, memory and I/O. The worked kernel path is upstream v6.12 x86-64. The host exercise uses a small ordinary userspace program and optionally strace; it changes no system settings. Your host's architecture, kernel and policies can yield different observations.
Learning goals#
Follow one x86-64 system call from ABI registers through saved state and dispatch; distinguish a userspace wrapper's errno from a kernel return; explain the contract of user-copy helpers; and reason separately about invalid descriptors, invalid buffers, partial transfers and concurrent userspace changes.
Crossing a privilege boundary#
A system call is a defined request from userspace to the kernel. On the x86-64 Linux syscall ABI, the syscall number is in rax, and up to six arguments use rdi, rsi, rdx, r10, r8 and r9. rcx and r11 have special roles in the hardware transition. These assignments are not the ordinary C calling convention, and they are not portable to arm64 or a 32-bit compatibility ABI. The kernel entry comment in v6.12 arch/x86/entry/entry_64.S documents this specific entry. An application normally calls a C library wrapper, although a program can use a lower-level interface.
Read the text version
- rdi, rsi, rdx: System call arguments 1 to 3 in the x86-64 ABI. → args[0] to args[2]: Filled from regs->di, regs->si and regs->dx in order. (di, si, dx)
- r10: Argument 4; used in place of rcx, which the SYSCALL instruction overwrites. → args[3]: Filled from regs->r10. (r10)
- r8, r9: Arguments 5 and 6. → args[4], args[5]: Filled from regs->r8 and regs->r9. (r8, r9)
The wrapper and the kernel have different error representations. Kernel syscall implementations commonly return a negative errno value to the entry machinery. The libc wrapper normally converts an error return to -1 and sets the calling thread's errno to a positive error number. A successful nonnegative return does not clear an old errno; inspect errno only when the function's documented return indicates failure. Other APIs may use different conventions. A direct raw assembly syscall and libc's syscall() function are also not identical interfaces: the latter is itself a library function that performs error conversion.
Source walkthrough#
- Userspacex86 entry
syscall number and ABI arguments
- x86 entrysyscall handler
saved pt_regs and dispatch
- syscall handleruser memory
validated user-access operation
- user memorysyscall handler
Return. bytes copied or residual
- syscall handlerx86 entry
Return. result or negative error
- x86 entryUserspace
Return. exit work and return
Time runs downward. Dashed arrows show returns.
Read the text version
- Userspace → x86 entry: syscall number and ABI arguments
- x86 entry → syscall handler: saved pt_regs and dispatch
- syscall handler → user memory: validated user-access operation
- user memory → syscall handler (return): bytes copied or residual
- syscall handler → x86 entry (return): result or negative error
- x86 entry → Userspace (return): exit work and return
The v6.12 x86-64 entry_SYSCALL_64 path in the assembly file begins with the following instructions (SPDX GPL-2.0; a comment and nearby annotations omitted):
swapgs
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
swapgs switches the relevant GS-base context used for per-CPU access. The next instruction saves the incoming userspace stack pointer in per-CPU scratch space; the hardware syscall transition does not itself place a complete frame on a kernel stack. The macro invokes page-table-switch machinery whose actual behavior depends on configuration and mitigations. The subsequent code loads a kernel stack and constructs a struct pt_regs frame, including saved userspace state and original syscall number. It initializes the result to an unimplemented-call error before passing saved registers and the number to C. Early entry has strict instrumentation and interrupt-state rules; it is not an ordinary C call from the user's stack.
In v6.12 arch/x86/entry/common.c, do_syscall_64 includes this excerpt (SPDX GPL-2.0-only; surrounding instrumentation and error path omitted):
nr = syscall_enter_from_user_mode(regs, nr);
syscall_exit_to_user_mode(regs);
The entry helper performs the appropriate entry work and can affect the number that is dispatched. Between these calls, the implementation tries x86-64 and, when configured, x32 dispatch; an unsupported number follows the not-implemented path. The exit helper handles applicable return-to-user work. The rest of do_syscall_64 checks whether a fast SYSRET return is safe for the saved state; other cases use the appropriate slower return path. This is not a claim that every x86 ABI, interrupt or exception takes the same route. Tracing, ptrace, seccomp, signal handling and other entry/exit work depend on configuration and state. LSM checks occur at relevant hooks in particular operations, not as one identical mandatory step inside every syscall.
The numbered x86-64 syscall definitions are described in v6.12 arch/x86/entry/syscalls/syscall_64.tbl; build machinery generates dispatch-related declarations from architecture data. SYSCALL_DEFINE macros describe kernel-side entry implementations and wrapper types. A UAPI structure exposed across the boundary must be designed for compatibility, including fixed-width fields, alignment, reserved fields and size/version handling where appropriate. A kernel-internal pointer or a compiler-dependent native structure layout is not automatically a durable userspace ABI. Read the system-call development guide before proposing an interface.
In arch/x86/entry/syscalls/syscall_64.tbl, v6.12, SPDX GPL-2.0 WITH Linux-syscall-note (other rows omitted):
0 common read sys_read
1 common write sys_write
Number 0 is read and number 1 is write in this table. arm64 has its own numbering, so these values do not carry over. The rows do not show the entry code or the dispatch that reaches sys_read.
Arm64 uses its own entry machinery and register convention, including the syscall number convention and compatibility paths defined by that architecture. v6.12 arch/arm64/kernel/syscall.c shows its dispatch context. A 32-bit compatibility process on a 64-bit kernel can use different number tables and structure layouts. Do not copy x86-64 register names or syscall numbers into an arm64 explanation. An unsupported syscall can return ENOSYS, but a security policy can also deny a supported call, and a syscall implementation can return other errors. An ENOSYS observation needs its architecture, kernel and policy context.
User pointers are untrusted addresses#
A user pointer is a numeric address in a userspace address space, not a stable kernel pointer to trusted data. The kernel must validate sizes and operation-specific constraints and use the appropriate user-access helpers. access_ok is a preliminary range check; it does not guarantee that the pages are present, readable or writable for the whole later operation. A page can fault, a mapping can change concurrently, or a copy can stop after transferring some bytes. A user can modify memory while a kernel operation is in progress, so validation of one snapshot must not be assumed to freeze the original buffer.
The normal copy_from_user and copy_to_user interfaces report the number of bytes not copied: zero indicates a complete copy. A positive residual is not a negative errno. The caller chooses how to translate an incomplete transfer into an operation-specific result, commonly an error or a short transfer where the interface permits it. In v6.12 include/linux/uaccess.h, the inline copy_from_user path ends with this excerpt (SPDX GPL-2.0; prior checks and instrumentation omitted):
fail:
memset(to + (n - res), 0, res);
return res;
Here n is the requested byte count and res is the uncopied remainder. The normal helper zeroes the uncopied tail in the kernel destination and returns that remainder. This behavior must not be generalized to every lower-level __copy_from_user or in-atomic variant: their contracts differ and callers must inspect the result. get_user and put_user operate on individual values and conventionally return zero or a negative error rather than a residual byte count. Check the exact helper and architecture implementation.
For a hypothetical request of eight bytes, if the first five are copied and three are not, a residual of three describes the incomplete copy; the normal copy_from_user path zeroes the last three destination bytes. That zero padding does not make the original request valid, prove the first five bytes satisfy a protocol, or authorize continuing as if all eight were supplied. A caller must first decide whether a partial result is acceptable and validate length, values and relationships. Padding can reduce uninitialized-data exposure, but it is not a replacement for error checking.
Ordinary user access can fault and may sleep while the kernel resolves a fault, so it must not be performed under a spinlock or in another context where sleeping is forbidden. Specialized nonfaulting or in-atomic helpers have narrower contracts and still require correct lifetime and error handling; their existence does not make arbitrary user accesses safe in interrupt context. Copying data into a kernel-owned buffer gives a snapshot of those bytes at a point during the copy, not a lock on the original userspace memory. Nested pointers, lengths and later operations require their own validation and ownership rules. See kernel user-access guidance.
A read request with three outcomes#
In v6.12 fs/read_write.c, the read wrapper calls ksys_read. That helper initializes its return to -EBADF and calls fdget_pos. If the descriptor lookup fails, it returns the bad-descriptor error without calling vfs_read; an invalid buffer supplied at the same time does not force the buffer error to win. If lookup succeeds, it obtains the file's position where applicable, calls vfs_read, updates a non-stream position for a nonnegative result, and releases the acquired descriptor reference with fdput_pos.
In fs/read_write.c, v6.12, SPDX GPL-2.0 (lines omitted):
ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
/* ... */
ret = vfs_read(fd_file(f), buf, count, ppos);
/* ... */
fdput_pos(f);
The initial -EBADF stands unless fdget_pos finds a file, so an invalid descriptor returns before vfs_read is called. The excerpt does not show what the file operation does with an invalid buffer or a partial transfer; that happens inside vfs_read and the file's own read code.
Now suppose descriptor 3 refers to a readable regular file, the position is zero, the user buffer is writable and the file contains abc. A request for three bytes can follow the normal selected file operation and return three, leaving the user buffer with those bytes and advancing the position under that operation's rules. The exact implementation is selected by the file's operations; not every file is a regular file, and a stream can have no ordinary position. A subsequent read at end of file can return zero without being an error. This example is conditional source reasoning, not a measured trace.
For the second case, the descriptor is invalid. The failed lookup preserves -EBADF, and the libc-facing wrapper normally reports -1 with errno=EBADF. For the third case, a valid readable descriptor is used with an inaccessible destination and a positive count. A relevant user-copy path can fail and produce EFAULT, although partial transfers, special file behavior, security policy and earlier checks can affect the result. The kernel does not promise that every operation probes every user byte before doing anything. Repeating the call after a concurrent mapping change can produce a different result. These branches illustrate why descriptor validity, address range checks and actual data transfer are separate questions.
- fdget_pos
Look up the descriptor and take its file reference; ret starts as -EBADF.
- Lookup fails
Return -EBADF. vfs_read is not called, so this function does not examine the buffer.
- vfs_read
Call the selected file operation; any user copy and partial transfer happen inside it.
- Position update
For a nonnegative result on a position-tracked file, store the new f_pos.
- fdput_pos
Release the reference taken by fdget_pos, then return ret.
Validating a snapshot#
Suppose a request contains a length and a pointer to a payload. A robust interface first establishes what size and version of the request it can accept, copies the fixed header using the correct helper, checks the copy result and validates the length before allocating or copying the payload. Arithmetic such as header_size + payload_length can overflow; range and overflow checks must precede using that result. The payload may change while it is copied, and a nested pointer is another untrusted address with its own copy and lifetime requirements. If consistent repeated access is needed, operate on the validated kernel-owned snapshot rather than re-reading the user's header and assuming it is unchanged. Even then, resource limits and concurrent changes to external objects can invalidate later assumptions, so error paths remain necessary.
Signals, restart and observability#
A blocking syscall can be interrupted by a signal. Depending on the call, signal disposition, libc and kernel restart rules, a caller may see EINTR, a short successful transfer, or a restarted operation. Internal restart codes used by kernel entry machinery are not necessarily the errno a userspace application sees. strace observes syscall boundaries through tracing mechanisms and formats results for a reader; tracing can affect timing and may be restricted by policy. It cannot by itself prove which internal function or lock was used. Record the command and environment, and do not infer a v6.12 kernel from a syscall name printed on a different host.
Practice#
Source-only route: construct a three-row table for the ksys_read cases above: invalid descriptor, valid descriptor with an inaccessible destination, and valid descriptor with a writable destination. For each state identify the first known check, the held file reference if any, the possible return and what cannot be determined without the selected file operation. Include the eight-byte/five-byte user-copy example and explicitly distinguish residual bytes from -EFAULT.
Own-process host route: in a private temporary directory, compile the following synthetic userspace program. It opens /dev/zero, requests one byte into a null address through libc's syscall() interface, then repeats with an invalid descriptor. The kernel receives the address as an argument; the program does not dereference the null pointer itself. Run it only on Linux, as an ordinary user. Security policy and unusual environments can change the observed result.
lab_dir=$(mktemp -d) || exit 1
cd "$lab_dir" || exit 1
cat > uaccess.c <<'C'
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(void) {
int fd = open("/dev/zero", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
errno = 0;
long a = syscall(SYS_read, fd, (void *)0, 1);
int ea = errno;
errno = 0;
long b = syscall(SYS_read, -1, (void *)0, 1);
int eb = errno;
printf("valid fd: return=%ld errno=%d\n", a, ea);
printf("invalid fd: return=%ld errno=%d\n", b, eb);
if (close(fd) < 0) { perror("close"); return 1; }
return 0;
}
C
cc -std=c11 -Wall -Wextra -Werror -o uaccess uaccess.c || exit 1
./uaccess
On a typical Linux system, expect the first call to return -1 with EFAULT and the second to return -1 with EBADF; numeric errno values are platform definitions, so compare against local headers rather than copying numbers into a portable claim. If /dev/zero is absent, open fails and the program exits without performing the reads. If policy denies the operation or results differ, preserve the output and report the environment; do not change host security settings. Optionally run strace -e trace=openat,read,close ./uaccess if strace is installed and permitted. Its extra loader reads are not the two intentional calls, and its output can reveal process paths; review before sharing.
Keep the table, compiler version, architecture, observed results and one uncertainty. Remove only the created files, then the empty directory:
rm uaccess uaccess.c
cd /tmp
rmdir "$lab_dir"
Knowledge check#
- Why is
r10used for the fourth x86-64 syscall argument instead of assuming the ordinary C argument registers? - What does the user-copy residual mean, and what happens to the uncopied tail in the normal
copy_from_userpath shown? - Why is
access_okinsufficient proof that a later full copy will succeed? - In the worked
ksys_readpath, why can an invalid descriptor be reported before an invalid destination? - Why must a normal faulting user copy not be placed inside an ordinary spinlocked region?
Answers#
- The syscall ABI is distinct from the C ABI and the hardware transition uses registers including
rcxandr11for return state. The entry code and ABI definer10for that argument on this architecture. - It is the number of requested bytes not copied; zero means complete. The shown normal helper zeroes the remaining destination tail and returns the residual; the caller still must handle incomplete input.
- It is a preliminary check, not a pinning or complete accessibility guarantee. Faults, concurrent mapping changes or actual copy failures can occur afterward.
ksys_readstarts with-EBADFand only callsvfs_readafter a successful descriptor lookup. If lookup fails, this path never reaches the file's user-buffer handling.- A normal copy can fault and sleep while resolving memory. A spinlocked region cannot safely sleep; specialized helpers have separate constraints and do not eliminate the need for a correct design.
Checkpoint#
Deliver the source-only table or the host program output, plus a diagram from register entry to saved state, dispatch, user copy and return. Pass when you distinguish kernel negative errno, libc errno and copy residual; explain both failure cases; and name one reason a traced host call does not establish a v6.12 internal path. Revisit ksys_read and uaccess.h and rerun the bounded program if needed. This is not certification.