Lab 3: A Character Device (Milestone 3)
Background
Until now your code ran only when you loaded it. This lab gives user space a way in: a device node
in /dev that open, read, write, lseek, and ioctl reach through a table of function
pointers you fill in.
That table — struct file_operations — is the same indirection the VFS uses for every file on the
system. Filling it in is how a driver, a filesystem, and /dev/null all present the same interface.
You will also, deliberately, write a race, observe it, and fix it. That part is not decoration: a character device is the smallest program where two processes can be inside your kernel code at the same time, and seeing it happen is worth more than reading the concurrency chapter twice.
Why This Lab Matters
file_operationsis the interface. Learn it once and every char driver, block driver, and filesystem is a variation.- Every user pointer that crosses into your code is untrusted, and this is where you handle that for real.
ioctlnumbers and their structs are permanent uapi. Getting the shape right here is the rehearsal for Lab 5.- The race you write and fix is the shape of the bug that survives review in real drivers.
Prerequisites
- Lab 2 complete.
- The User/Kernel Boundary read — all three concepts.
- Concurrency read as far as spinlocks vs. mutexes.
- A
lab-paranoidkernel available (you will want KASAN and lockdep for the race).
Predict First
Write these down before you write code.
- Two processes
write()to your device at the same offset simultaneously, with no locking. What are the possible outcomes for the buffer contents? Is a torn mixture of both payloads possible? - You increment a
u64statistics counter fromread()with no lock. After 4 processes each do 100,000 reads, what will the counter say? - You call
copy_to_user()while holding a mutex. Legal or not? While holding a spinlock? - Your
ioctlhandler is passed a command number your driver does not implement. What errno should it return, and why that one specifically? - A process has your device open. You run
rmmod. What happens — with.owner = THIS_MODULE, and without?
The Target
user space kernel
────────── ──────
open("/dev/mylab") ─────────────▶ lab_open → f->private_data = dev
read(fd, buf, n) ─────────────▶ lab_read → mutex, copy_to_user
write(fd, buf, n) ─────────────▶ lab_write → mutex, copy_from_user
lseek(fd, off, w) ─────────────▶ lab_llseek
ioctl(fd, CMD, arg) ─────────────▶ lab_ioctl → validate, copy both ways
close(fd) ─────────────▶ lab_release
struct file_operations ← the table that connects them
Step-by-Step Tasks
Step 1: The uapi header
This file is compiled by both the kernel and your test program. Every rule from the boundary chapter applies.
lab_uapi.h:
/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _LAB_UAPI_H
#define _LAB_UAPI_H
#include <linux/types.h>
#include <linux/ioctl.h>
/* Registered magic numbers live in
* Documentation/userspace-api/ioctl/ioctl-number.rst. 0xBA is used here
* only because this driver is never going upstream; a real one must claim
* a number in that file as part of the patch. */
#define LAB_IOC_MAGIC 0xBA
struct lab_stats {
__u64 reads; /* fixed width, uapi spelling */
__u64 writes;
__u32 len; /* bytes currently held */
__u32 __reserved; /* EXPLICIT padding: zeroed and checked,
* so it can become a real field later */
};
#define LAB_IOC_GET_STATS _IOR(LAB_IOC_MAGIC, 1, struct lab_stats)
#define LAB_IOC_CLEAR _IO (LAB_IOC_MAGIC, 2)
#define LAB_IOC_SET_LEN _IOW(LAB_IOC_MAGIC, 3, __u32)
#define LAB_IOC_MAXNR 3
#endif /* _LAB_UAPI_H */
_IOR/_IOW/_IOWR encode the direction and the argument size into the number itself, which is how
the kernel can reject a command built against a different version of the struct.
Step 2: The racy version — write this one first, on purpose
lab_chardev.c, version A. Note the deliberate absence of any locking.
// SPDX-License-Identifier: GPL-2.0
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/module.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#include "lab_uapi.h"
#define LAB_BUF_SIZE 4096
struct lab_dev {
struct mutex lock; /* unused in version A */
char *buf;
size_t len;
u64 reads;
u64 writes;
};
static struct lab_dev *labdev;
static int lab_open(struct inode *ino, struct file *f)
{
f->private_data = labdev;
return 0;
}
static ssize_t lab_read(struct file *f, char __user *ubuf,
size_t count, loff_t *ppos)
{
struct lab_dev *d = f->private_data;
if (*ppos >= d->len)
return 0; /* EOF */
count = min_t(size_t, count, d->len - *ppos);
if (copy_to_user(ubuf, d->buf + *ppos, count))
return -EFAULT;
*ppos += count;
d->reads++; /* RACY */
return count;
}
static ssize_t lab_write(struct file *f, const char __user *ubuf,
size_t count, loff_t *ppos)
{
struct lab_dev *d = f->private_data;
if (*ppos >= LAB_BUF_SIZE)
return -ENOSPC;
count = min_t(size_t, count, LAB_BUF_SIZE - *ppos);
if (copy_from_user(d->buf + *ppos, ubuf, count))
return -EFAULT;
*ppos += count;
if (*ppos > d->len)
d->len = *ppos; /* RACY */
d->writes++; /* RACY */
return count;
}
Step 3: ioctl, with every check
static long lab_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
struct lab_dev *d = f->private_data;
struct lab_stats st;
u32 newlen;
/* Reject commands that are not ours BEFORE looking at the number.
* -ENOTTY, not -EINVAL: userspace uses it to mean "this device does
* not implement that ioctl", which is a different thing from "bad
* arguments". */
if (_IOC_TYPE(cmd) != LAB_IOC_MAGIC)
return -ENOTTY;
if (_IOC_NR(cmd) > LAB_IOC_MAXNR)
return -ENOTTY;
switch (cmd) {
case LAB_IOC_GET_STATS:
/* memset the WHOLE struct: the compiler is not required to
* initialize padding, and padding copied out is an info leak. */
memset(&st, 0, sizeof(st));
st.reads = d->reads;
st.writes = d->writes;
st.len = d->len;
if (copy_to_user((void __user *)arg, &st, sizeof(st)))
return -EFAULT;
return 0;
case LAB_IOC_CLEAR:
memset(d->buf, 0, LAB_BUF_SIZE);
d->len = 0;
return 0;
case LAB_IOC_SET_LEN:
if (copy_from_user(&newlen, (void __user *)arg, sizeof(newlen)))
return -EFAULT;
if (newlen > LAB_BUF_SIZE) /* validate the KERNEL copy */
return -EINVAL;
d->len = newlen;
return 0;
default:
return -ENOTTY;
}
}
static loff_t lab_llseek(struct file *f, loff_t off, int whence)
{
return fixed_size_llseek(f, off, whence, LAB_BUF_SIZE);
}
static const struct file_operations lab_fops = {
.owner = THIS_MODULE, /* holds a module ref while open */
.open = lab_open,
.read = lab_read,
.write = lab_write,
.llseek = lab_llseek,
.unlocked_ioctl = lab_ioctl, /* "unlocked" = without the long-dead
* big kernel lock. A historical name. */
.compat_ioctl = compat_ptr_ioctl, /* 32-bit userspace on a 64-bit
* kernel: our args are a pointer
* and a __u32, so this generic
* thunk is sufficient. */
};
static struct miscdevice lab_misc = {
.minor = MISC_DYNAMIC_MINOR,
.name = "mylab", /* → /dev/mylab, created by udev/devtmpfs */
.fops = &lab_fops,
.mode = 0666,
};
static int __init lab_init(void)
{
int ret;
labdev = kzalloc(sizeof(*labdev), GFP_KERNEL);
if (!labdev)
return -ENOMEM;
mutex_init(&labdev->lock);
labdev->buf = kzalloc(LAB_BUF_SIZE, GFP_KERNEL);
if (!labdev->buf) {
ret = -ENOMEM;
goto err_free_dev;
}
/* LAST: after this, user space can call in, so everything it
* touches must already be valid. */
ret = misc_register(&lab_misc);
if (ret)
goto err_free_buf;
pr_info("registered /dev/%s\n", lab_misc.name);
return 0;
err_free_buf:
kfree(labdev->buf);
err_free_dev:
kfree(labdev);
labdev = NULL;
return ret;
}
static void __exit lab_exit(void)
{
misc_deregister(&lab_misc); /* FIRST: no new callers */
kfree(labdev->buf);
kfree(labdev);
labdev = NULL;
pr_info("unregistered\n");
}
module_init(lab_init);
module_exit(lab_exit);
MODULE_DESCRIPTION("Foundations Lab 3: a character device");
MODULE_AUTHOR("You <you@example.com>");
MODULE_LICENSE("GPL");
Note:
misc_register()is the convenience layer. It allocates a minor number, registers acdev, and creates the/devnode — three things you will do by hand in challenge 1. Per this book's rule, use the convenience only after you can write what it hides; challenge 1 is where you pay that debt.
Step 4: The userspace exerciser
lab_test.c:
// SPDX-License-Identifier: GPL-2.0
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <unistd.h>
#include "lab_uapi.h"
#define DEV "/dev/mylab"
static int check_basic(void)
{
struct lab_stats st;
char buf[64] = {0};
int fd, rc = 0;
fd = open(DEV, O_RDWR);
if (fd < 0) { perror("open"); return 1; }
if (ioctl(fd, LAB_IOC_CLEAR) < 0) { perror("CLEAR"); rc = 1; }
if (write(fd, "hello", 5) != 5) { perror("write"); rc = 1; }
lseek(fd, 0, SEEK_SET);
if (read(fd, buf, sizeof(buf)) != 5) { perror("read"); rc = 1; }
if (memcmp(buf, "hello", 5)) { fprintf(stderr, "content mismatch\n"); rc = 1; }
if (ioctl(fd, LAB_IOC_GET_STATS, &st) < 0) { perror("GET_STATS"); rc = 1; }
printf("reads=%llu writes=%llu len=%u reserved=%u\n",
(unsigned long long)st.reads, (unsigned long long)st.writes,
st.len, st.__reserved);
if (st.__reserved != 0) { fprintf(stderr, "reserved not zeroed!\n"); rc = 1; }
/* Error paths. */
if (ioctl(fd, _IO(0x99, 1)) != -1) { fprintf(stderr, "bad magic accepted\n"); rc = 1; }
if (ioctl(fd, _IO(LAB_IOC_MAGIC, 99)) != -1) { fprintf(stderr, "bad nr accepted\n"); rc = 1; }
{ __u32 big = 999999;
if (ioctl(fd, LAB_IOC_SET_LEN, &big) != -1) { fprintf(stderr, "oversize len accepted\n"); rc = 1; } }
if (read(fd, (void *)0x1, 16) != -1) { fprintf(stderr, "bad pointer accepted\n"); rc = 1; }
close(fd);
return rc;
}
/* The race: N processes each doing ITERS reads. If the counter is correct,
* the total is exactly N * ITERS. */
#define NPROC 4
#define ITERS 50000
static int check_race(void)
{
struct lab_stats st;
char buf[8];
int fd, i;
fd = open(DEV, O_RDWR);
if (fd < 0) { perror("open"); return 1; }
ioctl(fd, LAB_IOC_CLEAR);
write(fd, "abcdefgh", 8);
close(fd);
for (i = 0; i < NPROC; i++) {
if (fork() == 0) {
int f = open(DEV, O_RDONLY), j;
for (j = 0; j < ITERS; j++) {
lseek(f, 0, SEEK_SET);
if (read(f, buf, sizeof(buf)) < 0) _exit(1);
}
close(f);
_exit(0);
}
}
for (i = 0; i < NPROC; i++) wait(NULL);
fd = open(DEV, O_RDWR);
ioctl(fd, LAB_IOC_GET_STATS, &st);
close(fd);
printf("expected %d reads, counter says %llu (lost %lld)\n",
NPROC * ITERS, (unsigned long long)st.reads,
(long long)(NPROC * ITERS) - (long long)st.reads);
return st.reads == (unsigned long long)NPROC * ITERS ? 0 : 1;
}
int main(void)
{
int rc = check_basic();
rc |= check_race();
puts(rc ? "FAIL" : "PASS");
return rc;
}
Step 5: Observe the race
# In the guest, on lab-fast, with -smp 4:
insmod /mnt/host/chardev/lab_chardev.ko
/mnt/host/chardev/lab_test
Predict before running: how many of the 200,000 reads will the counter lose? Write a number.
Then run it at -smp 1 and at -smp 8 and compare. This is the experiment from
Concurrency, Concept 1 in the shape of a real
device, and the -smp 1 result is the one that teaches the lesson.
Step 6: Fix it, and defend the choice
Add the mutex. Every field of struct lab_dev is now protected by d->lock:
static ssize_t lab_read(struct file *f, char __user *ubuf,
size_t count, loff_t *ppos)
{
struct lab_dev *d = f->private_data;
ssize_t ret;
/* _interruptible so a Ctrl-C can break a stuck reader out.
* -ERESTARTSYS asks the syscall layer to restart or return -EINTR;
* it must never reach user space as-is. */
if (mutex_lock_interruptible(&d->lock))
return -ERESTARTSYS;
if (*ppos >= d->len) {
ret = 0;
goto out;
}
count = min_t(size_t, count, d->len - *ppos);
/* copy_to_user CAN SLEEP (the user page may not be resident). That
* is legal here because we hold a MUTEX. Under a spinlock this line
* would be a BUG — which is exactly why this driver uses a mutex. */
if (copy_to_user(ubuf, d->buf + *ppos, count)) {
ret = -EFAULT;
goto out;
}
*ppos += count;
d->reads++;
ret = count;
out:
mutex_unlock(&d->lock);
return ret;
}
Apply the same treatment to lab_write and to every ioctl case that touches d. Rebuild, reload,
re-run the test. The counter must now be exact.
Write down why a mutex and not a spinlock. The answer must mention copy_to_user.
Step 7: Run it under the paranoid kernel
# lab-paranoid: KASAN + PROVE_LOCKING
insmod /mnt/host/chardev/lab_chardev.ko
/mnt/host/chardev/lab_test
dmesg | tail -30 # must be silent: no KASAN, no lockdep splat
Implementation Requirements / Deliverables
-
/dev/mylabappears oninsmodand disappears onrmmod. -
open,read,write,llseek,unlocked_ioctl,compat_ioctl, andreleaseimplemented. -
Every user pointer goes through
copy_to_user/copy_from_user, with the return checked. -
Structs copied out are
memsetfirst; the test asserts__reserved == 0. -
Unknown
ioctlmagic and unknown command numbers both return-ENOTTY. -
Every
ioctlargument is validated on the kernel copy, never on user memory. -
The racy version was built, run, and its loss recorded at
-smp 1,4, and8. -
The fixed version passes the concurrency test exactly, with
PROVE_LOCKINGsilent. -
A written justification for mutex-over-spinlock that mentions
copy_to_user. -
.owner = THIS_MODULE, and thermmod-while-open behavior demonstrated. -
make check(C=2 W=1) andcheckpatch --strict -fclean.
Expected Output
# insmod /mnt/host/chardev/lab_chardev.ko
[ 102.3] lab_chardev: registered /dev/mylab
# ls -l /dev/mylab
crw-rw-rw- 1 root root 10, 122 Jan 1 00:01 /dev/mylab
^ 'c' = character device. 10 = misc major. 122 = the minor it was given.
# echo -n hello > /dev/mylab
# cat /dev/mylab
hello
# /mnt/host/chardev/lab_test (RACY version, -smp 4)
reads=1 writes=1 len=5 reserved=0
expected 200000 reads, counter says 172431 (lost 27569)
FAIL
# /mnt/host/chardev/lab_test (FIXED version, -smp 4)
reads=1 writes=1 len=5 reserved=0
expected 200000 reads, counter says 200000 (lost 0)
PASS
And the lifetime check:
# exec 3</dev/mylab
# lsmod | grep lab_chardev
lab_chardev 16384 1
^ the open fd
# rmmod lab_chardev
rmmod: ERROR: Module lab_chardev is in use
# exec 3<&-
# rmmod lab_chardev
Debugging Steps
/dev/mylab does not appear
devtmpfs is not mounted in the guest. Your /init must do mount -t devtmpfs none /dev, and the
kernel needs CONFIG_DEVTMPFS=y. Check:
grep -E '^CONFIG_DEVTMPFS' ~/kernel/build/.config
mount | grep devtmpfs
cat /proc/misc | grep mylab # the driver registered even if the node did not appear
open() returns -ENODEV or -ENXIO
misc_register failed, or the node's minor does not match. dmesg after insmod, and compare
/proc/misc with ls -l /dev/mylab.
read() returns -EFAULT for a pointer that looks fine
You are dereferencing instead of copying, or copying the wrong length. Also check
make check — sparse catches a __user pointer used directly.
ioctl returns -ENOTTY for a command you implemented
The command number encodes the argument type and size. If the header the kernel was built with and the header your test program included have different struct sizes, the numbers differ. Rebuild both from the same header — and note that this is exactly the ABI failure mode Lab 5 is about.
The concurrency test passes at -smp 1 and fails at -smp 4
That is not a flaky test. That is the bug, and the -smp 1 run is the misleading one.
lockdep complains about mutex_lock in your ioctl
Read the splat's second stack trace — you are probably taking the mutex twice on one path, or
calling a _locked helper without the lock. Split the function.
rmmod succeeds while the device is open, and the next read() panics
.owner = THIS_MODULE is missing from fops.
KASAN: use-after-free in lab_read
misc_deregister() is not the first thing in lab_exit, so a caller entered after you freed the
buffer. Order matters: stop new callers, then free.
Experiment
CLAIM. copy_to_user can sleep, so the choice of lock is not a style preference — a spinlock
here is a bug that the debug kernel catches immediately.
METHOD. Take the fixed driver and convert d->lock from a struct mutex to a
spinlock_t, mechanically:
static DEFINE_SPINLOCK(...); /* instead of a mutex */
spin_lock(&d->lock);
... copy_to_user(...); /* unchanged */
spin_unlock(&d->lock);
PREDICTION. Before building: (a) does it compile? (b) does it load? (c) does a read() work?
(d) does dmesg say anything, and on which kernel — lab-fast or only lab-paranoid?
RESULT. Record the exact BUG message and identify, from it, the line that made you atomic —
using the Preemption disabled at: field from
the context chapter.
Then answer the design question this raises: suppose you genuinely needed a spinlock — because
an interrupt handler also touched this data. How would you restructure read() so that the
copy_to_user happens outside the critical section? (Hint: copy into a kernel bounce buffer under
the lock, release it, then copy out. And then say what that costs and what it changes about
consistency.)
Test
The exerciser above is the test. Two things make it a good one:
It asserts on error paths, not just the happy path. Bad ioctl magic, bad command number, oversize length, and a bad user pointer are each checked to fail.
It can fail. Verify that:
# 1. Remove the mutex from lab_read only. Rebuild. The race check must FAIL.
# 2. Remove the memset from the GET_STATS case. Rebuild. Run the test on a
# kernel that has had some activity — does __reserved come back nonzero?
# 3. Change -ENOTTY to -EINVAL for unknown commands. The test must FAIL.
A test you have never seen fail is a test you have not written. Lab 6 rewrites this as a proper kselftest with the harness conventions.
Challenge Extensions
-
Do it without
miscdevice. Rewrite withalloc_chrdev_region,cdev_init,cdev_add,class_create, anddevice_create. Diff the two files and enumerate every line that appeared — each one is somethingmisc_registerdid for you. This is the debt this lab deferred; pay it. -
Per-open state. Give each
open()its own private buffer and position inf->private_data, allocated inopenand freed inrelease. Then answer: which of your locks is now unnecessary, and which is still required? -
Make it a blocking device.
read()on an empty buffer should sleep until a writer arrives, usingwait_event_interruptibleandwake_up_interruptible. HandleO_NONBLOCK(-EAGAIN), and then implement.pollsoselect/epollwork. This is where a toy becomes a device. -
Add an extensible ioctl. Use
copy_struct_from_user()so that a program built against an older header still works, and one built against a newer one gets-E2BIGrather than silent truncation. Then test both directions by compiling the test program against two versions of the header. -
Find the double fetch. Rewrite
LAB_IOC_SET_LENto read the length from user space twice — once to validate, once to use — and write a userspace program with two threads that flips the value between the fetches until it wins. Then fix it and explain the structural reason the fixed version cannot lose. -
Convert to a real device. Register a platform driver as in the device model chapter, attach the char device to it, add a sysfs attribute via
dev_groups, and usedevm_kzalloc. Then unbind and rebind it from sysfs and confirm nothing leaks.
Validation / Self-check
- What is
struct file_operations, and name three completely different things in the kernel that fill one in. - Why is
unlocked_ioctlcalled that? What iscompat_ioctlfor, and when iscompat_ptr_ioctlsufficient? - What must an
ioctlhandler check before it looks at the command number, and what errno does it return for a command it does not implement — and why not-EINVAL? - Why is a struct
memsetbeforecopy_to_user, when every field is assigned? - Why must
ioctlarguments be validated on the kernel copy rather than in user memory? Name the bug class. - Why is
copy_to_userlegal under a mutex and a bug under a spinlock? - Your counter lost 27,569 of 200,000 increments at
-smp 4and zero at-smp 1. Explain both numbers. - What does
mutex_lock_interruptiblebuy you, and what must you return when it fails? - Why is
misc_deregister()the first statement in the exit function? - Why is
misc_register()the last statement in the init function? - What does
.owner = THIS_MODULEprevent? Demonstrate the failure without it. fixed_size_llseekversus a hand-writtenllseek: what does the helper get right that people get wrong?- You need a spinlock because an interrupt handler touches this data, but you also need
copy_to_user. Describe the restructuring, and what it costs.
Next: Lab 4 — Debugging a Kernel, where you break this driver on purpose and learn to read what the kernel tells you.