Lab 5: A Syscall and Its ABI (Milestone 5)

Background

Adding a syscall is mechanically easy and socially hard. The code is about forty lines. The consequence is a promise the kernel cannot ever withdraw: a number, an argument layout, a set of error codes, and a behavior that must work identically in 2046.

This lab has you add one, wire it up on two architectures, design its ABI properly, break it on purpose to see what "permanent" means — and then write the argument that it should never have been merged. That last deliverable is not a joke. The most valuable thing a reviewer can tell you about a proposed syscall is that it should be something else, and being able to make that argument about your own work is the skill.

Why This Lab Matters

  • Every ioctl, every uapi struct, and every /proc file you ever add has these same properties. A syscall is just the most visible instance.
  • Struct layout, padding, extensibility, and 32-bit compatibility are where uapi bugs live, and they are invisible until someone else's binary breaks.
  • Understanding why the bar for a new syscall is high is the difference between a proposal that gets a serious review and one that gets a one-line "why isn't this an ioctl?"

Prerequisites

  • Lab 1 — you must be able to rebuild and reboot quickly; this lab rebuilds the kernel repeatedly.
  • The User/Kernel Boundary read — all three concepts.
  • Kernel C read, especially types and annotations.
  • The canonical document, read first:
$EDITOR ~/kernel/linux/Documentation/process/adding-syscalls.rst

Predict First

  1. You add a field to the end of your syscall's struct, rebuild only the kernel, and run the old userspace binary. What happens?
  2. You add the same field in the middle. Same question.
  3. A 32-bit program calls your syscall on a 64-bit kernel. Which of your argument types change size? Which change alignment?
  4. Your syscall accepts a flags argument and ignores bits it does not know. A program sets bit 7 by accident. Five years later you want bit 7 to mean something. What is now true?
  5. strace a call to a syscall number the kernel does not implement. What does it report?

The Target

   userspace:  syscall(__NR_lab_stat, &buf, sizeof(buf), 0)
        │
        ▼
   arch/x86/entry/syscalls/syscall_64.tbl        ← the NUMBER (x86)
   include/uapi/asm-generic/unistd.h             ← the NUMBER (arm64 and most others)
        │
        ▼
   include/linux/syscalls.h                      ← the DECLARATION
        │
        ▼
   kernel/lab_stat.c : SYSCALL_DEFINE3(...)      ← the IMPLEMENTATION
        │
        ▼
   include/uapi/linux/lab_stat.h                 ← the STRUCT, forever

Step-by-Step Tasks

Step 1: Design the ABI before writing any code

Write this down first. It is the part that is permanent; the code is not.

include/uapi/linux/lab_stat.h:

/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _UAPI_LINUX_LAB_STAT_H
#define _UAPI_LINUX_LAB_STAT_H

#include <linux/types.h>

/*
 * Extensible by design:
 *  - fields ordered largest-first, so there are no implicit padding holes
 *  - __u64 for anything that could ever be a pointer or a size
 *  - the caller passes sizeof(struct lab_stat), so the kernel can tell
 *    which version of this struct it is talking to
 *  - new fields are APPENDED only, and the kernel zero-fills for older
 *    callers. Nothing is ever inserted, reordered, resized, or removed.
 */
struct lab_stat {
	__u64	uptime_ns;
	__u64	total_ram;
	__u64	free_ram;
	__u32	nr_cpus;
	__u32	nr_procs;
	/* Append new fields HERE, and only here. */
};

/* Every flag bit is either defined or rejected. There is no third option. */
#define LAB_STAT_F_INSTANT	(1U << 0)
#define LAB_STAT_F_ALL		(LAB_STAT_F_INSTANT)

#endif /* _UAPI_LINUX_LAB_STAT_H */

Check your layout mechanically rather than by eye:

cat > /tmp/lay.c <<'EOF'
#include <stdio.h>
#include <stddef.h>
#include "lab_stat.h"
int main(void){
    printf("size=%zu align=%zu\n", sizeof(struct lab_stat), _Alignof(struct lab_stat));
    printf("uptime@%zu total@%zu free@%zu cpus@%zu procs@%zu\n",
        offsetof(struct lab_stat,uptime_ns), offsetof(struct lab_stat,total_ram),
        offsetof(struct lab_stat,free_ram),  offsetof(struct lab_stat,nr_cpus),
        offsetof(struct lab_stat,nr_procs));
    return 0;
}
EOF
gcc      -I. -o /tmp/lay64 /tmp/lay.c && /tmp/lay64
gcc -m32 -I. -o /tmp/lay32 /tmp/lay.c 2>/dev/null && /tmp/lay32 || echo "(no 32-bit libc)"

The two must print identical sizes and offsets. If they do not, your struct is not ABI-compatible between 32- and 64-bit userspace and you will need a compat path.

And once the kernel is built:

cd ~/kernel/build && pahole -C lab_stat vmlinux     # must show no holes

Step 2: Add the number

x86-64. Find the next free number and append:

cd ~/kernel/linux
tail -5 arch/x86/entry/syscalls/syscall_64.tbl
# <number>  <abi>    <name>      <entry point>
NNN         common   lab_stat    sys_lab_stat

common means the same entry point serves both 64-bit and x32. Use the next unused number; do not reuse a gap, and never renumber anything.

arm64 and most other architectures share a generated table:

tail -20 include/uapi/asm-generic/unistd.h
#define __NR_lab_stat NNN
__SYSCALL(__NR_lab_stat, sys_lab_stat)

#undef __NR_syscalls
#define __NR_syscalls (NNN + 1)        /* bump this */

Step 3: Declare it

include/linux/syscalls.h, near the other declarations:

asmlinkage long sys_lab_stat(struct lab_stat __user *ubuf, size_t usize,
			     unsigned int flags);

asmlinkage tells the compiler the arguments arrive on the stack, per the entry code's convention. The __user annotation is mandatory and sparse will check it.

Step 4: Implement it

kernel/lab_stat.c:

// SPDX-License-Identifier: GPL-2.0
#include <linux/kernel.h>
#include <linux/mm.h>
#include <linux/sched.h>
#include <linux/sched/stat.h>
#include <linux/syscalls.h>
#include <linux/timekeeping.h>
#include <linux/uaccess.h>
#include <uapi/linux/lab_stat.h>

SYSCALL_DEFINE3(lab_stat, struct lab_stat __user *, ubuf,
		size_t, usize, unsigned int, flags)
{
	struct lab_stat st;
	struct sysinfo si;

	/* 1. Reject unknown flag bits. This is what keeps `flags`
	 *    extensible: an old kernel refuses a new flag rather than
	 *    silently ignoring it, so userspace can feature-test.       */
	if (flags & ~LAB_STAT_F_ALL)
		return -EINVAL;

	/* 2. Sanity-bound the size before doing anything with it.       */
	if (usize < sizeof(__u64) || usize > PAGE_SIZE)
		return -EINVAL;

	/* 3. Zero the WHOLE struct, including padding. Anything not
	 *    explicitly set must be zero, or it is an information leak
	 *    and it also breaks forward compatibility.                  */
	memset(&st, 0, sizeof(st));

	si_meminfo(&si);
	st.uptime_ns = ktime_get_boottime_ns();
	st.total_ram = (u64)si.totalram * si.mem_unit;
	st.free_ram  = (u64)si.freeram  * si.mem_unit;
	st.nr_cpus   = num_online_cpus();
	st.nr_procs  = nr_threads;

	/* 4. Copy out, honoring the caller's struct size.
	 *
	 *    usize < sizeof(st): an OLD program on a NEW kernel.
	 *        Copy only what it knows about. It never learns about the
	 *        new fields, which is exactly right.
	 *    usize > sizeof(st): a NEW program on an OLD kernel.
	 *        Zero-fill the tail so the program sees zeros in the
	 *        fields we do not implement.                              */
	if (usize < sizeof(st)) {
		if (copy_to_user(ubuf, &st, usize))
			return -EFAULT;
	} else {
		if (copy_to_user(ubuf, &st, sizeof(st)))
			return -EFAULT;
		if (usize > sizeof(st) &&
		    clear_user((char __user *)ubuf + sizeof(st),
			       usize - sizeof(st)))
			return -EFAULT;
	}

	return 0;
}

Add it to the build:

# kernel/Makefile
obj-y += lab_stat.o

Note: For a syscall that reads a struct from user space, the mirror-image helper already exists and you should use it rather than hand-rolling the size logic:

ret = copy_struct_from_user(&kargs, sizeof(kargs), uargs, usize);
if (ret)                              /* -E2BIG if the extra bytes are nonzero,
        return ret;                    * -EFAULT on a bad pointer               */

It zero-fills when userspace is older and returns -E2BIG when userspace passes a newer, larger struct with nonzero bytes the kernel does not understand. That is the pattern clone3, openat2, and sched_setattr use, and it is the expected shape in review.

Step 5: Build and test

cd ~/kernel/linux
make O=../build -j"$(nproc)" CC="ccache gcc"
# The build runs scripts/checksyscalls.sh; read any warning it emits about
# architectures where your syscall is now missing.

Get the header into a place userspace can use:

make O=../build headers_install INSTALL_HDR_PATH=/tmp/khdr
ls /tmp/khdr/include/linux/lab_stat.h

The test program:

// SPDX-License-Identifier: GPL-2.0
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/lab_stat.h>

#ifndef __NR_lab_stat
#define __NR_lab_stat NNN          /* your number */
#endif

static long lab_stat(struct lab_stat *b, size_t sz, unsigned int fl)
{
	return syscall(__NR_lab_stat, b, sz, fl);
}

int main(void)
{
	struct lab_stat st;
	long r;

	memset(&st, 0xAA, sizeof(st));            /* poison, to prove it is written */
	r = lab_stat(&st, sizeof(st), 0);
	printf("rc=%ld errno=%d\n", r, r < 0 ? errno : 0);
	if (r == 0)
		printf("uptime=%llu ns ram=%llu/%llu cpus=%u procs=%u\n",
		       (unsigned long long)st.uptime_ns,
		       (unsigned long long)st.free_ram,
		       (unsigned long long)st.total_ram,
		       st.nr_cpus, st.nr_procs);

	/* Every error path, checked. */
	printf("bad flags : %ld (want -1/EINVAL)\n", lab_stat(&st, sizeof(st), 0xFF));
	printf("tiny size : %ld (want -1/EINVAL)\n", lab_stat(&st, 1, 0));
	printf("huge size : %ld (want -1/EINVAL)\n", lab_stat(&st, 1 << 20, 0));
	printf("bad ptr   : %ld (want -1/EFAULT)\n",
	       lab_stat((void *)0x1, sizeof(st), 0));

	/* Forward/backward compatibility, both directions. */
	printf("half size : %ld (want 0)\n", lab_stat(&st, 16, 0));
	{
		char big[sizeof(struct lab_stat) + 64];
		memset(big, 0xAA, sizeof(big));
		r = lab_stat((struct lab_stat *)big, sizeof(big), 0);
		printf("over size : %ld (want 0), tail byte = 0x%02x (want 0x00)\n",
		       r, (unsigned char)big[sizeof(struct lab_stat)]);
	}
	return 0;
}
gcc -I/tmp/khdr/include -o /tmp/lab_stat_test test.c
# copy into the guest via 9p and run it
strace -e trace=lab_stat /tmp/lab_stat_test 2>&1 | head

Step 6: The ABI experiment

This is the point of the lab. Do it exactly.

(a) Append a field. Add __u64 nr_faults; at the end of struct lab_stat, rebuild only the kernel, reboot, and run the old, unrecompiled test binary.

(b) Insert a field. Move it to the middle (between total_ram and free_ram), rebuild only the kernel, reboot, run the same old binary.

(c) Change a type. Change nr_cpus from __u32 to __u64. Same procedure.

PREDICT ALL THREE FIRST, in writing, including what the old binary prints, not just whether it crashes.

Then fill in:

ChangeOld binary still runs?Values correct?Why
Append at the end
Insert in the middle
Widen a field

This table is the whole lesson about uapi, and it costs twenty minutes.

Step 7: Write the argument against

A page, in your own words, answering:

  1. What is the actual question a user has that this syscall answers?
  2. Could /proc or /sys answer it? What would that cost, and what would it give up?
  3. Could an existing syscall be extended with a flag instead?
  4. Could it be a debugfs file — i.e. does it need to be permanent at all?
  5. Could it be a tracepoint plus eBPF, so no new interface exists at all?
  6. Which of those would you actually propose, and what is your rebuttal to the syscall?

The honest answer for lab_stat is that it should be nothing at all — sysinfo(2), /proc/meminfo, and /proc/uptime already exist. Writing that down, having just built it, is the deliverable.


Implementation Requirements / Deliverables

  • The syscall is wired up and works on at least one architecture; the .tbl/unistd.h change for the other is written even if untested.
  • The uapi header follows every rule: SPDX with the syscall note, __u* types, no implicit padding (verified with pahole and with the 32/64-bit offsetof program).
  • Unknown flag bits are rejected with -EINVAL.
  • The struct is memset before being filled and copied out.
  • Both size directions handled: smaller usize truncates; larger usize is zero-filled.
  • The test program exercises every error path and both compatibility directions.
  • The three-row ABI experiment table, completed with predictions and results.
  • A written ABI specification: argument types and sizes, struct layout, every error code and what causes it, and how a future field would be added.
  • A written statement of the 32-bit compat situation, even if it is "no compat path is needed, because…".
  • The written argument that this should have been something else, with a rebuttal.
  • make C=2 W=1 clean for the files you touched; checkpatch --strict clean.

Expected Output

$ /tmp/lab_stat_test
rc=0 errno=0
uptime=41230194832 ns ram=1783246848/2076180480 cpus=4 procs=87
bad flags : -1 (want -1/EINVAL)
tiny size : -1 (want -1/EINVAL)
huge size : -1 (want -1/EINVAL)
bad ptr   : -1 (want -1/EFAULT)
half size : 0 (want 0)
over size : 0 (want 0), tail byte = 0x00 (want 0x00)

$ strace -e trace=lab_stat /tmp/lab_stat_test
lab_stat(0x7ffd..., 32, 0)              = 0

If strace shows syscall_0xNNN(...) instead of the name, strace predates your syscall — which is itself a lesson about how much tooling a new syscall obliges someone to update.

And the ABI experiment, after inserting a field in the middle:

$ /tmp/lab_stat_test          # the OLD binary, on the NEW kernel
rc=0 errno=0
uptime=41230194832 ns ram=4/1783246848 cpus=0 procs=1783246848
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ garbage

It did not crash. It silently returned wrong numbers. That is what "we do not break userspace" is protecting against, and why the rule is absolute rather than a matter of judgement.


Debugging Steps

The declaration in syscalls.h, the SYSCALL_DEFINE3 name, and the .tbl entry point must agree exactly. SYSCALL_DEFINE3(lab_stat, ...) defines sys_lab_stat.

The build succeeds but the syscall returns -ENOSYS

You added the number to the wrong architecture's table, or did not rebuild. Check what the kernel thinks:

sudo grep -w __x64_sys_lab_stat /proc/kallsyms     # x86-64
sudo grep -w __arm64_sys_lab_stat /proc/kallsyms   # arm64

Empty output means the entry point was never built into the table.

scripts/checksyscalls.sh warns about a missing syscall

It compares the architectures' tables and tells you where your syscall is absent. For a real patch, adding it to every architecture is expected; for this lab, note the warning and say why you are ignoring it.

-EFAULT for a pointer that is obviously valid

Check the argument order in SYSCALL_DEFINE3 — the macro takes type, name pairs, and swapping one is a compile error but reordering two is not.

The test program cannot find struct lab_stat

make headers_install and compile with -I/tmp/khdr/include. Do not copy the kernel header directly; headers_install post-processes it (stripping kernel-internal parts), and if that processing breaks, that is a bug you want to find now.

pahole reports a hole

Reorder fields largest-first, or add an explicit __u32 __reserved. Never ship a hole in a uapi struct.


Experiment

CLAIM. The flags argument is what makes a syscall extensible, and rejecting unknown bits is what makes flags work. Ignoring them silently forecloses every future extension.

METHOD. Build two versions of the syscall:

  • Version A (as written): if (flags & ~LAB_STAT_F_ALL) return -EINVAL;
  • Version B: no flag validation at all.

Then write a userspace program that does what real programs do — feature-detect:

/* Does this kernel support the new INSTANT mode? */
if (lab_stat(&st, sizeof(st), LAB_STAT_F_INSTANT) == 0)
        use_fast_path();
else if (errno == EINVAL)
        use_slow_path();          /* old kernel: it TOLD us */

PREDICTION. For each version, answer: (a) on a kernel that does not implement LAB_STAT_F_INSTANT, what does the program conclude? (b) Now imagine the flag is added to the kernel two years later, and a program in the wild has been passing bit 0 by accident. What happens under version A, and under version B?

RESULT. Write the conclusion as a rule you would apply in review, in one sentence.


Test

A syscall's test belongs in tools/testing/selftests/, which is where Lab 6 takes it. For now, the standalone program above is the specification — make sure it can fail:

1. Remove the flag validation.        The "bad flags" line must FAIL.
2. Remove the memset.                 Run on a busy kernel; do unset fields
                                      come back nonzero? (They may not every
                                      time — which is itself the lesson about
                                      why "I tested it" is not enough here.)
3. Remove the clear_user tail-fill.   The "over size" tail-byte check must FAIL.
4. Change -EINVAL to -EOPNOTSUPP.     The error-path lines must FAIL.

Challenge Extensions

  1. Use copy_struct_from_user properly. Add a second syscall that takes a struct in, and use the helper. Then write two test binaries — one compiled against a small header, one against a large one — and verify all four combinations of old/new kernel × old/new userspace.

  2. Wire it up for the other architecture. If you did x86-64, do arm64 (or vice versa) and boot both. Note every file that differed and why the generic unistd.h mechanism exists.

  3. Add a compat path. Deliberately design a bad struct with a long and a pointer in it, then write the COMPAT_SYSCALL_DEFINE3 handler that translates a 32-bit caller's layout. Having written one, you will never design a struct that needs one again — which is the point.

  4. Add it to strace. Clone strace, add a decoder for your syscall, and see your call pretty-printed. This is one of the several projects a new syscall obliges someone to update; enumerate the others (glibc, strace, seccomp filters, libseccomp, container runtimes, audit).

  5. Do it as an ioctl instead. Implement the same functionality on the Lab 3 char device and diff the two: lines of code, files touched, review surface, and who has to agree. Then revisit your step-7 argument.

  6. Read a real one. Find the mailing-list thread for a recently-added syscall on lore.kernel.org — openat2, pidfd_open, process_madvise, landlock_*, or whatever is newest — and read the whole discussion. Count how many messages are about the ABI versus the implementation.


Validation / Self-check

  1. Name every file that must change to add a syscall on x86-64. On arm64. Why do they differ?
  2. What does SYSCALL_DEFINE3 expand to, and why does grepping for sys_lab_stat find nothing?
  3. Why is asmlinkage there?
  4. Why is every pointer-sized field in a uapi struct a __u64?
  5. What is an implicit padding hole, why is it an information leak, and how do you find one?
  6. Explain both directions of the usize compatibility logic. Which direction protects an old binary, and which protects an old kernel?
  7. What does copy_struct_from_user do that hand-written size logic usually gets wrong?
  8. Why must unknown flags bits be rejected rather than ignored? Give the concrete future failure.
  9. Your struct changed size between kernel versions. Which of append / insert / widen is safe, and what exactly happens for the other two?
  10. What is a compat syscall, and what design choice makes one unnecessary?
  11. What does scripts/checksyscalls.sh check, and what should you do about its warnings?
  12. Rank these for a new kernel-to-userspace interface, most to least preferable, and give the deciding question for each: a debugfs file, a sysfs attribute, a tracepoint, an ioctl, an extension to an existing syscall, a new syscall.
  13. State, in one sentence, why your syscall should not be merged.

Next: Lab 6 — Testing Like the Kernel Does, the last lab of Foundations.