FLUSP logo FLUSP - FLOSS at USP

Introduction to Linux kernel Character Device Drivers

Written by Marcelo Schmitt

Written on , last modified on

This tutorial explains a few aspects of Linux character devices with a basic character device driver example. This can be a continuation for the “Introduction to kernel build configuration and modules” tutorial.

Command Summary

If you did not read this tutorial yet, skip this section. This section was added as a summary for those that already went through this tutorial and just want to remember a specific command.

cat /proc/devices
stat <file>
mknod <file_name> <type> <major_num> <minor_num>
dmesg -w

Introduction to Linux kernel character device drivers

This tutorial presents and explains a few key concepts related to Linux character devices, then presents an example Linux kernel character device driver that is built on top of the presented ideas.

Summary of the parts of this tutorial:

  1. Character devices
  2. Major and Minor Numbers
  3. File operations
  4. Bringing device IDs and file operations together
  5. A character device driver example
  6. Testing the simple_char driver

1) Character devices

A character device is an abstraction provided by Linux operating systems to support devices that can be read from or written to with relatively small data transfers which are usually byte size or few bytes size. They are often abstracted as files in the file system and accessed through conventional file access system calls. Devices supported as character devices are serial ports, keyboards, mice, etc. Example of character device files are /dev/ttyS0, dev/input/mouse0, /dev/kmsg, /dev/zero.

2) Major and Minor Numbers

The files associated with character devices are special types of files which allow users to interface with devices from the user space. Under the hood of character device files are the device drivers that handle the system calls for them. The association between device files and devices is made with a device ID which consists of two parts: a major and a minor number [1]. The minor numer is only used within the device driver to distinguish between multiple devices of same type (such as the first and the second display) or to switch between operation modes of a device. The major number can be requested to be a specific number or allocated dynamically. The device ID formed from the major/minor number combination is what the kernel uses to choose which driver run to support a particular device. Inside the Linux kernel, the device ID is stored as a 32-bit unsigned integer defined by the dev_t type. The 12 most significant bits of a dev_t variable store the major device number while the 20 remaining bits store the minor number.

In old versions of the Linux kernel, once a major number was registered for a driver, no other driver could use the same major number and all minor numbers associated with that major number were owned by the driver. Back then, one could state that a major number was unique for a driver. That might not hold always true nowadays since, in modern kernels, drivers allocate a range of minor numbers within the region of possible minor numbers. With that, it is now possible for more than one driver to have the same major number as long as their range of minor numbers do not intersect.

Within the Linux kernel, one can use the functions declared in include/linux/fs.h to allocate device major and minor numbers for a device. For example, to dynamically allocate a device major number along with a range of minor numbers one can call alloc_chrdev_region().

#include <linux/fs.h>

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)

The macros MAJOR(), MINOR(), and MKDEV() from include/linux/kdev_t.h can be used to extract the major/minor number from a dev_t variable and to make a device ID up from a major/minor number pair, respectively.

At any moment, a list of character and block devices in the system can be retrieved from /proc/devices.

$ cat /proc/devices
Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
  4 ttyS
  5 /dev/tty
  5 /dev/console
  5 /dev/ptmx
  6 lp
...

The numbers are major device numbers and the names are registered by the character device drivers associated with each of them.

One can use stat to get major and minor numbers for the device backing a file. For example:

$ stat /boot/vmlinuz-6.5.0-5-amd64
  File: /boot/vmlinuz-6.5.0-5-amd64
  Size: 9127904         Blocks: 17832      IO Block: 4096   regular file
Device: 8,1     Inode: 4981079     Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2024-01-04 10:51:19.000000000 -0300
Modify: 2023-11-29 04:45:07.000000000 -0300
Change: 2024-01-04 10:53:32.315343926 -0300
 Birth: 2024-01-04 10:53:29.707343958 -0300

The output of stat shows that the Linux image (vmlinuz-6.5.0-5-amd64) is stored as a regular file and the device responsible for it has major number 8 and minor number 1 (/dev/sda1).

To get major and minor numbers of character devices we must look after the Device type field.

$ stat /dev/ttyS0
  File: /dev/ttyS0
  Size: 0               Blocks: 0          IO Block: 4096   character special file
Device: 0,5     Inode: 94          Links: 1     Device type: 4,64
Access: (0660/crw-rw----)  Uid: (    0/    root)   Gid: (   20/ dialout)
Access: 2024-03-29 09:14:38.329879779 -0300
Modify: 2024-03-29 09:14:57.329879779 -0300
Change: 2024-03-29 09:14:38.329879779 -0300
 Birth: 2024-03-29 09:14:32.192000051 -0300

So, that tty character device has major number 4 and minor number 64.

We can manually create files to interface with character devices using mknod [2]. A file that serves as interface for a device is often called a device node, device file, or device special file [3].

3) File operations

Since character devices may interface with user space through files, character device drivers may implement functions to handle file access system calls. The set of those syscalls implemented by a device driver is set into a struct file_operations object. There are several different file operations a character driver can implement. In this tutorial we will focus on the most basic system calls for a character device: open, close, read, write. All of those have manual pages of same name. Whithin the Linux kernel, these operations are respectively handled by open, release, read, and write functions stored in struct file_operations objects.

struct file_operations {
	struct module *owner;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
<snipped>
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
<snipped>
}

See the definition of struct file_operations in include/linux/fs.h for a complete list of entry points a driver can implement.

File read/write operations

Kernel code should not directly de-reference pointers to memory in user space (i.e. we should not access the __user buffer with conventional C memory access operations). Kernel and user space memory addresses may be (and often are) in separate address regions, offset by PAGE_OFFSET physical memory addresses. Because of that, the address of a user space pointer might not lead to the memory address of user space data when de-referencing it from kernel space. Furthermore, most modern systems provide virtualised memory address for user space memory and memory paging, meaning that a user space memory address may not be valid at a given moment either because its page has not been allocated yet or it has been swapped out. If you got interested about Linux memory management, watch Matt Porter’s Introduction to Memory Management in Linux after finishing this tutorial.

To implement the read and write entry points we will use memory access functions from include/linux/uaccess.h.

include <linux/uaccess.h>

unsigned long copy_from_user(void *to, const void __user *from, unsigned long n)
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n)

The semantics of the copy_from_user() function are the same as memcpy(). It copies n bytes of data from the source (from) memory area to the destination one (to). One important difference though is that the return of copy_from_user() is the number of bytes not successfully copied. The copy_to_user() is analogous to copy_from_user() but with opposite direction.

4) Bringing device IDs and file operations together

For a character device, we have major/minor numbers (combined into dev_t) to identify the driver to handle system calls for files/nodes linking to it. We also have struct file_operations to store the set of operations supported for the character device. To tie together device IDs and file operations, the kernel uses the struct cdev structure defined in include/linux/cdev.h.

struct cdev {
	struct kobject kobj;
	struct module *owner;
	const struct file_operations *ops;
	struct list_head list;
	dev_t dev;
	unsigned int count;
} __randomize_layout;

struct kobject kobj is the embedded kobject struct of the cdev structure used for reference counting.

struct module *owner holds the owner of the resource within the kernel. Later, we will use the character device module as resource owner.

const struct file_operations *ops is the file operations structure discussed earlier.

struct list_head list is a double linked list for traversing character devices.

dev_t dev is the device ID.

unsigned int count holds the amount of minor numbers owned by the character device.

The rudimentary functions for registering a cdev are:

#include <linux/cdev.h>

struct cdev *cdev_alloc(void)
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
void cdev_del(struct cdev *p)

struct cdev *cdev_alloc(void) allocates and initializes a cdev struct object.

void cdev_init(struct cdev *cdev, const struct file_operations *fops) is equivalent to cdev_alloc() except it expects the caller to pass a pointer to an already allocated cdev object and it also sets the file operations field for it. If using cdev_init(), the caller is responsible for freeing the cdev object after the character device is removed from the system.

int cdev_add(struct cdev *p, dev_t dev, unsigned count) registers/adds the character device to the system by creating an entry for it in the struct kobj_map *cdev_map array kept in fs/char_dev.c.

void cdev_del(struct cdev *p) removes the character device from the system by removing its entry from the cdev_map array. It also decrement the reference count of the character device which will free the cdev struct if it was allocated with cdev_alloc().

5) A character device driver example

Let’s create an example character device driver to see how it all works. Create drivers/char/simple_char.c in the Linux kernel sources and add the code for it. Notice most of the ideas within it have been covered in previous sections.

#include <linux/init.h>
#include <linux/module.h>

#include <linux/kdev_t.h> /* for MAJOR */
#include <linux/cdev.h> /* for cdev */
#include <linux/fs.h> /* for chrdev functions */
#include <linux/slab.h> /* for malloc */
#include <linux/string.h> /* for strlen() */
#include <linux/uaccess.h> /* copy_to_user() */

struct cdev *s_cdev;
static dev_t dev_id;

#define S_BUFF_SIZE 4096
static char *s_buf;

#define MINOR_NUMS 1

static int simple_char_open(struct inode *inode, struct file *file)
{
	pr_info("%s: %s\n", KBUILD_MODNAME, __func__);
	return 0;
}

static ssize_t simple_char_read(struct file *file, char __user *buffer,
				size_t count, loff_t *ppos)
{
	int n_bytes;

	pr_info("%s: %s about to read %ld bytes from buffer position %lld\n",
		KBUILD_MODNAME, __func__, count, *ppos);
	n_bytes = count - copy_to_user(buffer, s_buf + *ppos, count);
	*ppos += n_bytes;
	return n_bytes;
}

static ssize_t simple_char_write(struct file *file, const char __user *buffer,
				size_t count, loff_t *ppos)
{
	int n_bytes;
	pr_info("%s: %s about to write %ld bytes to buffer position %lld\n",
		KBUILD_MODNAME, __func__, count, *ppos);
	n_bytes = count - copy_from_user(s_buf + *ppos, buffer, count);
	return n_bytes;
}

static int simple_char_release(struct inode *inode, struct file *file)
{
	pr_info("%s: %s\n", KBUILD_MODNAME, __func__);
	return 0;
}

static const struct file_operations simple_char_fops = {
	.owner = THIS_MODULE,
	.open = simple_char_open,
	.release = simple_char_release,
	.read = simple_char_read,
	.write = simple_char_write,
};

static int __init simple_char_init(void)
{
	int ret;

	pr_info("Initialize %s module.\n", KBUILD_MODNAME);

	/* Allocate an internal buffer for reads and writes. */
	s_buf = kmalloc(S_BUFF_SIZE, GFP_KERNEL);
	if (!s_buf)
		return -ENOMEM;

	strcpy(s_buf, "This is data from simple_char buffer.");

	/* Dynamically allocate character device device numbers. */
	/* The name passed here will appear in /proc/devices. */
	ret = alloc_chrdev_region(&dev_id, 0, MINOR_NUMS, "simple_char");
	if (ret < 0)
		return ret;

	/* Allocate and initialize the character device cdev structure */
	s_cdev = cdev_alloc();
	s_cdev->ops = &simple_char_fops;
	s_cdev->owner = simple_char_fops.owner;

	/* Adds a mapping for the device ID into the system. */
	return cdev_add(s_cdev, dev_id, MINOR_NUMS);
}

static void __exit simple_char_exit(void)
{
	/*
	 * Undoes the device ID mapping and frees cdev struct, removing the
	 * character device from the system.
	 */
	cdev_del(s_cdev);
	/* Unregisters (disassociate) the device numbers allocated. */
	unregister_chrdev_region(dev_id, MINOR_NUMS);

	kfree(s_buf);
	pr_info("%s exiting.\n", KBUILD_MODNAME);
}

module_init(simple_char_init);
module_exit(simple_char_exit);

MODULE_AUTHOR("A Linux kernel student <name.surname@usp.br>");
MODULE_DESCRIPTION("A simple character device driver example.");
MODULE_LICENSE("GPL");

Don’t forget to add a configuration symbol for the simple_char driver within drivers/char/Kconfig.

config SIMPLE_CHAR
       tristate "Simple character driver example"
       default m
       help
         This option enables a simple character driver that implements basic
         file access operations.

Also add a build rule for it in drivers/char/Makefile.

obj-$(CONFIG_SIMPLE_CHAR)      += simple_char.o

Configure and build the Linux kernel with the simple_char driver as explained in previous build and configuration tutorials.

6) Testing the simple_char driver

Install the simple_char driver in a Linux OS. If you have followed the previous tutorials, you can install it to the arm64 virtual machine. Load the driver module and inspect /proc/devices to get what major number the character device got. Then use that major number to create a file to interface with your character device.

# Run these on the test machine
root@localhost:~# modprobe simple_char
root@localhost:~# cat /proc/devices | grep simp
511 simple_char
root@localhost:~# mknod simple_char_node c 511 0
root@localhost:~# stat simple_char_node
  File: simple_char_node
  Size: 0               Blocks: 0          IO Block: 4096   character special file
Device: 254,2   Inode: 2142        Links: 1     Device type: 511,0
Access: (0644/crw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2024-03-16 18:26:55.133581798 +0000
Modify: 2024-03-16 18:26:55.133581798 +0000
Change: 2024-03-16 18:26:55.133581798 +0000
 Birth: 2024-03-16 18:26:55.133581798 +0000

Create read_prog.c to test reading from our character device.

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

#define BUF_SIZE 256

/* Example inspired from open(2) */
int main(int argc, const char **argv)
{
	char buf[BUF_SIZE];
	int fd;

	if (argc < 2)
		return -22;

	fd = open(argv[1], O_RDONLY);
	read(fd, buf, BUF_SIZE);
	printf("Read buffer: %s\n", buf);
	close(fd);
}

Although it is advisable to handle possible errors that may happen when accessing files, read_prog.c was left with no error handling to keep the example small and simple. Compile the read test program for your Linux test machine. If you have followed the previous tutorials, you can use the GCC from the cross compiler toolchain you have for building the kernel to compile the test program. Send the binary to the virtual machine and run it there.

aarch64-linux-gnu-gcc read_prog.c -o read_prog
scp -i ~/.ssh/rsa_iio_arm64_virt read_prog root@192.168.122.209:~/
root@localhost:~# ./read_prog simple_char_node

Here is the write_prog.c test program for writing to the character device.

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

#define BUF_SIZE 256

int main(int argc, const char **argv)
{
	char buf[BUF_SIZE];
	int errsv;
	int ret;
	int fd;

	if (argc < 2)
		return -EINVAL;

	sprintf(buf, "A new message for simple_char.");
	fd = open(argv[1], O_RDWR);
	ret = write(fd, buf, BUF_SIZE);
	if (ret < 0) {
		errsv = errno;
		printf("Error: %d", errsv);
	}
	printf("wrote %d bytes to buffer\n", ret);
	return close(fd);
}

Build and run the same way done for the read test program.

aarch64-linux-gnu-gcc write_prog.c -o write_prog
scp -i ~/.ssh/rsa_iio_arm64_virt write_prog root@192.168.122.209:~/
root@localhost:~# ./write_prog simple_char_node

Proposed Exercises

  1. Printing device numbers: Modify the simple_char driver to make it print major and minor device numbers on device open.

  2. Device private data: Modify the simple_char driver to register more than one minor device number and make it keep separate buffers for each of them. Check the device minor number on device open and allocate a buffer for it if it doesn’t have one. Also, make the read/write operations run over the buffer for the particular major/minor device number pair. Access f_inode field of struct file to get a pointer to the device inode then access i_rdev field of struct inode to get the device ID (dev_t) from which you can extract the device minor number. On module unload, free all allocated buffers. With that, we can think of each pair of major/minor numbers as a different character device and each of them will have their own data. Finally, create device nodes with different minor numbers to test you implementation.

Conclusion

This post presented a few key concepts related to Linux kernel character devices and provided a character device driver example that shows how the covered concepts apply in practice.

History

  1. V1: Release

References

[1] . “makedev(3)”. URL: https://man.archlinux.org/man/makedev.3.en

[2] David MacKenzie. “MKNOD(1)”. URL: https://man.archlinux.org/man/mknod.1

[3] David MacKenzie. “device_node”. URL: https://wiki.debian.org/device_node


comments powered by Disqus