Please follow the following submission criteria:

  1. Submit a ZIP file which contains all your code.
  2. The ZIP file should contain one directory for each part, named: part1, part2, part3, and part4.
  3. Each directory should contain the kernel module source and the Makefile required to build it.
  4. Your kernel modules must be able to compile against the Linux 3.13 kernel (you may use a more recent kernel, but please do not use one which is older). You can check your kernel version by running uname -r in a terminal.
  5. Include a single text file in the root directory of your ZIP file which includes your answers to the theoretical/written questions from the Introduction and the 3 Parts.

Please see the Assignment 2 submission guidelines for further information on how to set up a VM for these kernel programming exercises.

Operating systems generally classify I/O devices into two categories: block devices and character devices.

A block device is one that stores information in fixed-size blocks, each with its own address. Common block sizes range from 512 bytes to 32,768 bytes. All transfers are in units of one or more entire (consecutive) blocks. The essential property of a block device is that it is possible to read or write each block independently of all other ones. Hard disks, CD-ROMs, and USB flash drives are common block devices.

A character device delivers or accepts a stream of characters, without regard to any block structure. It is not addressable and does not have any seek operation. Printers, network interfaces, mice, keyboards, and most other devices that are not disk-like can be seen as character devices.

The job of a device driver is to accept abstract read and write requests from user space applications and carry out the requested operations. This allows programs in user space to access the device without worrying about device-specific details (imagine how difficult it would be to read a file if you had to worry about details such as what type of file system was being used, and whether the file was stored on a hard disk, CD-ROM, USB flash drive, etc.). The driver may also need to perform other functions such as initializing the device or logging events.

In Linux, block and character devices are abstracted as special files. Each I/O device is assigned a path name, usually in /dev. For example, a disk might be /dev/sda, or a mouse might be /dev/input/mouse0. Associated with each special file is a device driver that handles the corresponding device. Each driver has what is called a major device number that serves to identify it. If a driver supports multiple devices, say, two disks of the same type, each disk has a minor device number that identifies it. Together, the major and minor device numbers uniquely specify every I/O device.

Exercise:

In a terminal, type in ls -l /dev to list the contents of the /dev directory with the detailed file properties. Explain the following:

  1. How can you distinguish between character and block devices? Pay close attention to the file modes (2 marks).
  2. What is the major and minor device number of your first disk device (probably /dev/sda) (2 marks)? Note that each disk is usually identified by a letter (e.g., sda, sdb, and so on), while the partitions on each disk are also accessible as separate block devices by way of adding a numbered suffix (e.g., sda1, sda2, and so on).

Finally, devices in Linux (and other Unix clones) do not necessarily have to correspond to physical devices. These are known as pseudo-devices. For example,

  • /dev/urandom generates a stream of pseudo-random numbers (try running head /dev/urandom in a terminal)
  • /dev/null produces no output, but accepts and discards any input (if you wanted to test your download speed without writing any data to your disk, you could download a file to /dev/null by running, e.g., wget http://some.website/big.file > /dev/null).
Moreover, in Unix-based systems, a file itself does not even have to correspond to a file stored on a disk. These types of files are usually found in pseudo-file systems, typically mounted in locations such as /proc, /sys, and /dev, and act as interfaces to various devices and kernel subsystems.

For this assignment, we will write a device driver for our very own character (pseudo-)device. The first version of our character device will simply output a string when an application attempts to read data from our device using the read() system call. Our device driver will be writen as a loadable kernel module. If you did not complete the kernel programming exercises from Assignment 2, it is suggested that you familiarize yourself before proceeding. The overall structure will be similar, but we will need to make a number of additions.

First, create a new file called mydev.c and set up a Makefile as you did in Assignment 2. As a starting point, you may copy and paste the "Hello world!" kernel module.

We will need to include some additional header files:

  • linux/device.h is needed to create a device.
  • linux/fs.h is needed for the file_operations structure, since, as explained in the Introduction, our pseudo-device will be made accessible to user space applications via a special file and will therefore need to support various file operations.
  • asm/uaccess.h is needed for some special functions that will allow us to access memory in user space. This is necessary since user space applications that invoke the read() system call must pass a pointer to a buffer that the kernel will copy the data into. Due to various reasons, involving the kernel having its own memory space as well as certain security considerations, kernel code cannot otherwise directly access user space memory.

For convenience, let us define macros for our device name, class name (all devices must be part of a device class, and in this case we are creating our own device class), and the maximum size of the string that our character device will output in response to a read() system call:

#define DEVICE_NAME "mydev"
#define CLASS_NAME "comp3000"
#define MYSTR_SIZE 512

Now, declare some variables to store (1) the string that will be output in response to a read() system call, (2) the device's major number, and (3) pointers to our device and class structures, which will be allocated once we create our device:

static char my_string[MYSTR_SIZE];
static int major_number;
static struct class*  mydev_class  = NULL; 
static struct device* mydev_device = NULL; 

For this driver, we will implement functionality for three system calls: open(), read(), and release(). You should already be familiar with the first two system calls, and release() is invoked when all processes that have opened the file have invoked close(). The function prototypes for all three operations can be found here in the file_operations structure in linux/fs.h. We will need to declare function prototypes to define our callback functions for the three aforementioned operations, as follows:

static int mydev_open(struct inode *, struct file *);
static int mydev_release(struct inode *, struct file *);
static ssize_t mydev_read(struct file *, char __user *, size_t, loff_t *);

By default, when the kernel creates a device, it will only be accessible by the root user. Below, we define a callback function to make our device readable by all users:

static char *mydev_devnode(struct device *dev, umode_t *mode);

static char *mydev_devnode(struct device *dev, umode_t *mode) {
   if(mode) *mode = 0444;
   return NULL;
}
We will assign the above funcion to the devnode pointer in our device's class struct, so that the kernel will execute it after the device has been created.

Note that under normal circumstances, we would put our macros and function prototypes in a .h file, and the function definitions in a .c file, but we are bending the rules here since our code is not very large.

Next, we will create a file_operations structure to provide the kernel with function pointers to our open, read, and release functions (whose prototypes we created above). We will need to pass this structure as an argument when creating our device so that the kernel will register our callback functions:

static struct file_operations mydev_fops =
{
   .open = mydev_open,
   .read = mydev_read,
   .release = mydev_release,
};

As a result, your file should look something like this:
/*
 * Simple character device kernel module
 */
 
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/device.h>
#include <linux/fs.h>
#include <asm/uaccess.h>

#define  DEVICE_NAME "mydev"
#define  CLASS_NAME  "comp3000"

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your name");
MODULE_DESCRIPTION("A basic character device.");
 
static int major_number; 
static struct class*  mydev_class  = NULL; 
static struct device* mydev_device = NULL; 
 
static int mydev_open(struct inode *, struct file *);
static int mydev_release(struct inode *, struct file *);
static ssize_t mydev_read(struct file *, char __user *, size_t, loff_t *);
static char *mydev_devnode(struct device *dev, umode_t *mode);

static struct file_operations mydev_fops =
{
    .open = mydev_open,
    .read = mydev_read,
    .release = mydev_release,
};

static char *mydev_devnode(struct device *dev, umode_t *mode) {
    if(mode) *mode = 0444;
    return NULL;
}

static int __init mydev_init(void)
{
    return 0;
}

static void __exit mydev_exit(void)
{
}

module_init(mydev_init);
module_exit(mydev_exit);

We must now implement our init(), open(), read(), release(), and exit() functions.

First, let us look at the init() function:

static int __init mydev_init(void) {
    int retval;
    printk(KERN_INFO "Initializing mydev module.\n");

    // Register a major number for our character device
    major_number = register_chrdev(0, DEVICE_NAME, &mydev_fops);
    if (major_number < 0) {
        printk(KERN_ALERT "mydev: Failed to register a major number.\n");
        retval = major_number;
        goto failed_register_chrdev;
    }

    // Create a class struct
    mydev_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(mydev_class)) {
        printk(KERN_ALERT "mydev: Failed to register device class.\n");
        retval = PTR_ERR(mydev_class);
        goto failed_class_create;
    }
    mydev_class->devnode = mydev_devnode;
 
    // Create a device struct
    mydev_device = device_create(mydev_class, NULL, MKDEV(major_number, 0), NULL, DEVICE_NAME);
    if (IS_ERR(mydev_device)) {
        printk(KERN_ALERT "mydev: Failed to create the device.\n");
        retval = PTR_ERR(mydev_device);
        goto failed_device_create;
    }
    printk(KERN_INFO "mydev: Device successfully created with major number %d.\n", major_number);
    return 0;

failed_device_create:   
    class_unregister(mydev_class); // Unregister device class
    class_destroy(mydev_class); // Remove device class
failed_class_create:
    unregister_chrdev(major_number, DEVICE_NAME); // Unregister major number
failed_register_chrdev:
    return retval;
}
This is simpler than it appears at first glance: to create our device, we must invoke register_chrdev() (whose prototype is defined here in linux/fs.h), class_create(), and device_create() (whose prototypes are defined here and here, respectively, in linux/device.h) . You will notice that the rest of the code consists entirely of error-checking: if any of these invocations fail, we must undo anything we have already done before returning an error value and unloading our kernel module. This is an important principle: we do not want to leave any "garbage" behind when unloading our kernel module! Note that the PTR_ERR() macro simply extracts error codes from return values that are pointers, and the MKDEV() macro combines the major and minor numbers into a single dev_t variable.

Now, we will define our open() and release() callbacks, which effectively do nothing (for now):

static int mydev_open(struct inode *inodep, struct file *filep){
    printk(KERN_INFO "mydev: Device opened.\n");
    return 0;
}
static int mydev_release(struct inode *inodep, struct file *filep){
    printk(KERN_INFO "mydev: Device closed\n");
    return 0;
}

Next, we define our read() callback, which copies our string to the user space buffer (copy_to_user() and put_user() are two functions provided for this purpose by asm/uaccess.h) - notice the __user macro which identifies *buffer as a user space pointer:

/**
 * @file: the file to read from
 * @buf: the buffer to read to
 * @size: the maximum number of bytes to read
 * @ppos: the current position in the file
 */
static ssize_t mydev_read(struct file *file, char __user *buf, size_t size, loff_t *ppos)
{
    int errors;
    int bytes_copied;

    bytes_copied = snprintf(my_string, (size_t)MYSTR_SIZE, "The COMP 3000 character device sends its regards.");
    errors = copy_to_user(buf, my_string, bytes_copied);

    if(errors == 0) return bytes_copied; // We need to return the number of bytes that we copied into the user space buffer
    return -EFAULT;
}
Note that upon successful completion, we must return the number of bytes that we have copied into the user space buffer. Upon failure, we return a negative error code. You may find the list of valid error codes here.

There is a major flaw in the above function: What will happen if the user space application invokes read() with a buffer size that is smaller than the string (Hint: If you don't know, you can come back to this later and find out by experimenting with the sample C program provided in Part 3)? (2 marks). Fix the above code so that if such a situation occurs, an alert is generated in the kernel log and an appropriate error code is returned (4 marks).

Optional Exercise:

In many cases, a better solution would be to instead have the driver fill up the user space buffer with as much of the string as it can fit. Then, if the user space program makes a subsequent read() invocation, the driver can continue copying the string at the point where it previously left off. You might find some of the arguments passed to mydev_read() to be helpful - note that buf is the only user space pointer, whereas the others are in kernel space.

Finally, that leaves the exit() function. Below is a skeleton function that invokes device_destroy(), but you must add additional cleanup code (Hint: everything you need is on this page) (3 marks):

static void __exit mydev_exit(void){
    device_destroy(mydev_class, MKDEV(major_number, 0));
    // To be completed by you
    printk(KERN_INFO "mydev: Unloaded mydev module.\n");
}

You have now created your first character device!

After compiling and installing the kernel module, let us attempt to read from our character device. We could either write a C program to open our special file and invoke the read() system call, or we could use existing Linux tools for reading files.

Try to read from your character device using the cat tool, by running cat /dev/mydev. Explain what happens, and why (2 marks).

Hint:

The source code for cat can be found here. Since we are not using any arguments, the function which is of interest to us is simple_cat(), which is very short and well commented.

Make the following modifications to your character device:

  1. Use a static variable to keep track of how many times the open() system call is invoked on the device. You should use an unsigned variable, to avoid ending up with a negative number if and when your counter overflows (2 marks).
  2. Respond to read() invocations with the string "You are process #x that has opened the COMP 3000 character device.", where x is the value of your counter (2 marks).
  3. After a process opens the device by invoking the open() system call, the device should only respond to the first read() system call with our string. Any subsequent read() invocations should result in 0 bytes copied to the user space buffer, until the next time that the device is opened (4 marks). There are two equally acceptable solutions, but the more "proper" technique would probably be along the lines of a partial implementation of the optional exercise from Part 1.

We will now investigate what happens when more than one process attempts to read from our device simultaneously. Below is a user space program that opens the character device and then pauses for the user to press a key, after which it invokes read() and prints out the data that it received from the device:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>

#define BUFFER_LENGTH 512

int main() {
    int ret, fd;
    char receive_buffer[BUFFER_LENGTH];
    fd = open("/dev/mydev", O_RDONLY);
    if(fd < 0) {
        printf("Failed to open mydev.\n");
        return -1;
    }

    printf("mydev has successfully been opened. Press Enter to invoke read().\n");
    getchar();

    printf("Reading from mydev...\n");
    ret = read(fd, receive_buffer, BUFFER_LENGTH);        
    if(ret < 0) {
        printf("Failed to read the message from the device.\n");
        return -1;
    } else if(ret == 0) {
        printf("No data was received.\n");
    } else {
        printf("Bytes received: %s\n", receive_buffer);
    }
    close(fd);
    return 0;
}

Compile the above program and run it a few times to get an idea of how it behaves. Now, open a second terminal and run the program simultaneously from both terminals. Once both programs are running, press Enter in each of the two terminals. Explain what happens, and why (2 marks).

Recall from Part 2 that our objective was to ensure that whenever a process opens the device, it should be able to read a string indicating that it was the nth process to have opened the device. To preserve that functionality in an environment where multiple processes need to access the device, we will make use of a mutex. The mutex will prevent two processes from simultaneously opening the device - if a second process tries to open the device while another process already has it open, you should return an appropriate error code (10 marks). You should test your module using the user space program provided above.

The Linux kernel contains a mutex implementation - the header file linux/mutex.h is available here and the implementation kernel/locking/mutex.c here. To start with, you will need the following functions:

  • DEFINE_MUTEX(mutexname) is a macro that declares a struct mutex with the name mutexname.
  • mutex_init(&mutexname) initializes the mutex to an unlocked state.
  • mutex_destroy(&mutexname) frees any memory that was allocated when initializing the struct mutex.
There are 4 basic functions in kernel/locking/mutex.c (which contains very useful comments) that can be used to lock a mutex. List the functions and explain what each one does, making sure to highlight how they differ from one another (4 marks).

Hint:
  • Ignore the functions relating to nested mutexes, which are used in situations where there is a "hierarchy" of locks that need to be acquired (in other words, when you need to acquire multple locks in a specific order to perform a task).
  • Focus on the functions that are meant to be used externally, which are exported with the EXPORT_SYMBOL() macro.

In some cases, if a process attempts to open a device that is busy, a more desirable solution may be for the kernel to put that process to sleep and wake it up later when the device becomes available. However, there are pitfalls to this approach. With some locking implementations, if the mutex does not become available again, the process will become stuck in an uninterruptible sleep and the only way to kill the process may be to reboot the entire system.

Modify your module so that if a process attempts to access the character device while another process already has it open, the process will be put to sleep until the device is available again (4 marks). Make sure that your module avoids the situation where it becomes stuck in an uninterruptible sleep - in other words, you should be able to terminate your program at any time by pressing CTRL+C. Test your module using the program provided in Part 3.

In Part 3, should the mutex be in the character device or the test code?

It should be in the character device. No marks will be given for solutions that use a mutex in the test code.


If I have done the optional exercise in Part 2 (so that the message can be read with multiple read() invocations if the buffer isn't large enough), what should I do in Part 3, since it says that the device should only respond to the first read() invocation?

If you have done the optional exercise, that's great! Just make sure that after a program has opened the device and read out the message, any further invocations to read() should come back empty (until the device has been closed and then opened again).


I don't know how to distinguish between the minor and major device numbers!

Keep in mind that if you have more than one device of the same type, they will have the same major number, but different minor device numbers. Moreover, it is allowed for different types of devices (i.e., devices with different major numbers) to have the same minor number.