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/procfile 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
- You add a field to the end of your syscall's struct, rebuild only the kernel, and run the old userspace binary. What happens?
- You add the same field in the middle. Same question.
- A 32-bit program calls your syscall on a 64-bit kernel. Which of your argument types change size? Which change alignment?
- Your syscall accepts a
flagsargument 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? stracea 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
-E2BIGwhen userspace passes a newer, larger struct with nonzero bytes the kernel does not understand. That is the patternclone3,openat2, andsched_setattruse, 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:
| Change | Old 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:
- What is the actual question a user has that this syscall answers?
- Could
/procor/sysanswer it? What would that cost, and what would it give up? - Could an existing syscall be extended with a flag instead?
- Could it be a
debugfsfile — i.e. does it need to be permanent at all? - Could it be a tracepoint plus eBPF, so no new interface exists at all?
- 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.hchange 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 withpaholeand with the 32/64-bitoffsetofprogram). -
Unknown flag bits are rejected with
-EINVAL. -
The struct is
memsetbefore being filled and copied out. -
Both size directions handled: smaller
usizetruncates; largerusizeis 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=1clean for the files you touched;checkpatch --strictclean.
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
undefined reference to sys_lab_stat at link time
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
-
Use
copy_struct_from_userproperly. 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. -
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.hmechanism exists. -
Add a compat path. Deliberately design a bad struct with a
longand a pointer in it, then write theCOMPAT_SYSCALL_DEFINE3handler 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. -
Add it to
strace. Clonestrace, 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,seccompfilters,libseccomp, container runtimes,audit). -
Do it as an
ioctlinstead. 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. -
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
- Name every file that must change to add a syscall on x86-64. On arm64. Why do they differ?
- What does
SYSCALL_DEFINE3expand to, and why does grepping forsys_lab_statfind nothing? - Why is
asmlinkagethere? - Why is every pointer-sized field in a uapi struct a
__u64? - What is an implicit padding hole, why is it an information leak, and how do you find one?
- Explain both directions of the
usizecompatibility logic. Which direction protects an old binary, and which protects an old kernel? - What does
copy_struct_from_userdo that hand-written size logic usually gets wrong? - Why must unknown
flagsbits be rejected rather than ignored? Give the concrete future failure. - Your struct changed size between kernel versions. Which of append / insert / widen is safe, and what exactly happens for the other two?
- What is a compat syscall, and what design choice makes one unnecessary?
- What does
scripts/checksyscalls.shcheck, and what should you do about its warnings? - Rank these for a new kernel-to-userspace interface, most to least preferable, and give the
deciding question for each: a
debugfsfile, asysfsattribute, a tracepoint, anioctl, an extension to an existing syscall, a new syscall. - State, in one sentence, why your syscall should not be merged.
Next: Lab 6 — Testing Like the Kernel Does, the last lab of Foundations.