Writing a new memory device - /dev/clipboard

April 29th, 2021

This article contains code for how to implement a simple device driver in the kernel. The quality of this code is... proof of concept quality. If you're planning to do serious kernel development please don't use this code as a starting point.

Today we're going to use what we learned last time about /dev/null and take it to the next level. We're going to write a new memory device in the kernel. We will call it /dev/clipboard. No, it's not an existing device. Go ahead and check on your system!

Why the name /dev/clipboard? Because our new device will work just like a clipboard does. First you write bytes to it, and those bytes are saved. Later you come back and read from the device and it gives you back the same bytes. If you write to it again you overwrite what was there before. (This is not one of those fancy clipboards with multiple slots where you can choose which slot to retrieve.)

Here's a demo of how it works:

# save to clipboard
$ echo "abc 123" > /dev/clipboard 

# retrieve from clipboard
$ cat /dev/clipboard
abc 123

For convenience we will also print a message to the kernel log whenever someone writes to or reads from the clipboard:

$ dmesg  # just after the 'echo' command
Saved 8 bytes to /dev/clipboard

$ dmesg  # just after the 'cat' command
Returned 8 bytes from /dev/clipboard

Pretty neat, huh? Being able to just add a feature to the kernel like that.

We'll do all our work in drivers/char/mem.c, right next to where /dev/null and the other /dev/xxx memory device drivers live.

First, let's think about where the user's bytes will be kept.. they obviously have to be kept somewhere by the kernel, in the kernel's own memory. We'll keep it simple here and just use a static array of a fixed size. That means we're placing this array in one of the data segments of the kernel's binary, a segment that will be loaded and available at runtime. We'll use 4096 bytes as the size of the array, and that means we're adding 4kb to the kernel's memory footprint (so we don't want to make it too big):

#define CLIPBOARD_BUFSIZE 4096
static char clipboard_buffer[CLIPBOARD_BUFSIZE];
static size_t clipboard_size = 0;

CLIPBOARD_BUFSIZE is a macro that decides how big the fixed array is - a constant. clipboard_buffer is the array itself.

clipboard_size is a variable that reflects how many bytes of user data the array currently holds. This will change every time we write to the clipboard.

Okay, that's it for storage. Now let's turn to how we tell the kernel that this new device exists. We'll add it to the bottom of the list of devices implemented in this file:

static const struct memdev {
	const char *name;
	umode_t mode;
	const struct file_operations *fops;
	fmode_t fmode;
} devlist[] = {
	 [DEVMEM_MINOR] = { "mem", 0, &mem_fops, FMODE_UNSIGNED_OFFSET },
	 [2] = { "kmem", 0, &kmem_fops, FMODE_UNSIGNED_OFFSET },
	 [3] = { "null", 0666, &null_fops, 0 },
	 [4] = { "port", 0, &port_fops, 0 },
	 [5] = { "zero", 0666, &zero_fops, 0 },
	 [7] = { "full", 0666, &full_fops, 0 },
	 [8] = { "random", 0666, &random_fops, 0 },
	 [9] = { "urandom", 0666, &urandom_fops, 0 },
	[11] = { "kmsg", 0644, &kmsg_fops, 0 },
	[12] = { "clipboard", 0666, &clipboard_fops, 0 },
};

And we need to populate a file_operations struct. This is how we tell the kernel what a user can actually do with this new device. In our case, we want the user to be able to open it, read from it, or write to it, then close it. To achieve that we just need to implement a read and a write function. We can populate the other fields with stub functions that do nothing:

static const struct file_operations clipboard_fops = {
	.llseek		= null_lseek,
	.read		= read_clipboard,
	.write		= write_clipboard,
	.read_iter	= read_iter_null,
	.write_iter	= write_iter_null,
	.splice_write = splice_write_null,
};

Alright, we need a read function and a write function. Let's start with write first, because it's what the user will use first. What does the write function need to do?

Well, when the user writes to the clipboard that write should overwrite whatever was in the clipboard before (as we said before). So let's zero the memory in the array.

Next, we have an array of a fixed size. The user might write a byte string that fits or a byte string that exceeds our capacity. If the input is too big to store, we will just truncate the remainder that doesn't fit. (I haven't checked how other clipboards do this, but presumably they too have an upper limit.) Once we know how many bytes to keep, we'll copy those bytes from the user's buffer (which is transient) into our fixed array.

Then we'll print a message to the kernel log to explain what we did. And finally return the number of bytes the user sent us, in order to confirm that the write succeeded. Put all of that together and we get more or less the following:

static ssize_t write_clipboard(struct file *file, const char __user *buf,
			  size_t count, loff_t *ppos)
{
    // erase the clipboard to an empty state
    memset(clipboard_buffer, 0, CLIPBOARD_BUFSIZE * sizeof(char));

    // decide how many bytes of input to keep
    clipboard_size = count;
    if (clipboard_size > CLIPBOARD_BUFSIZE) {
        clipboard_size = CLIPBOARD_BUFSIZE;
    }

    // populate the clipboard
    if (copy_from_user(clipboard_buffer, buf, clipboard_size)) {
        return -EFAULT;
    }

    printk("Saved %lu bytes to /dev/clipboard\n", clipboard_size);

    // acknowledge all the bytes we received
    return count;
}

What's the EFAULT thing all about? copy_from_user is a function provided by the kernel. If it fails to copy all the bytes we requested it will return a non-zero value. This makes the boolean predicate true and we enter the if block. In the kernel the convention is to return a pre-defined error code prefixed with a minus sign to signal an error. We'll gloss over that here.

That does it for the write function. Finally, we need the read function.

The read function is a bit more tricky, because it's intended to be used to read incrementally. So a user can issue read(1) to read a single byte and then call read(1) again to read the next byte. Each time read will return the bytes themselves and an integer which is a count of the number of bytes returned. Once there are no more bytes to return read will return the integer 0.

How does the read function know how many bytes have been returned thus far? It needs to store this somewhere between calls. It turns out this is already provided for us - it's what the argument ppos is for. ppos is used as a cursor from the beginning of the array. We'll update ppos each time to reflect where the cursor should be, and the function calling our read function will store it for us until the next call.

Other than that, the read function is pretty analogous to the write function:

static ssize_t read_clipboard(struct file *file, char __user *buf,
			 size_t count, loff_t *ppos)
{
    size_t how_many;

    // we've already read the whole buffer, nothing more to do here
    if (*ppos >= clipboard_size) {
        return 0;
    }

    // how many more bytes are there in the clipboard left to read?
    how_many = clipboard_size - *ppos;

    // see if we have space for the whole clipboard in the user buffer
    // if not we'll only return the first part of the clipboard
    if (how_many > count) {
        how_many = count;
    }

    // populate the user buffer using the clipboard buffer
    if (copy_to_user(buf, clipboard_buffer + *ppos, how_many)) {
        return -EFAULT;
    }

    // advance the cursor to the end position in the clipboard that we are
    // returning
    *ppos += how_many;

    printk("Returned %lu bytes from /dev/clipboard\n", how_many);

    // return the number of bytes we put into the user buffer
    return how_many;
}

And that's it! That's all the code needed to implement a simple memory device in the kernel!

:: random entries in this category ::