How to write your own driver

What are drivers?

Drivers, and their associated /dev node entries, are the main interface between a user application and the hardware.

The purpose of a driver is to remove any requirements for the user application to have any special privileges in order to access specific areas of memory.

The entry in /dev acts as a gateway to a specific set of routines which run within the kernel to do a specific task. In this way any application can access a device just as if it were reading or writing a normal file on disk.

Classes of driver

There are two basic types of device, and hence driver, on a BSD system. These are block and character devices. Block devices are typically such things as disks, SD cards, and the like. With these devices you read and write an entire “block” of data (1024 bytes) at a time, and the blocks are indexed by a block number.

Conversely, “character” devices are like working with simple text files. They typically allow you to read from and write to them, but you can't seek to a specific location. They operate very much like a pipe. Data from the driver is fed through the pipe to your application, and your application feeds data back through the pipe to the driver. These are by far the most common type of device.

So 9 times out of 10 you will be wanting to write a character device driver. We'll concentrate on that here, and touch on the block devices later.

Character Device Internals

The kernel contains a list of all the character devices. This is basically an array of the devices and the functions to call when specific operations are performed on the device node. The array is stored in the “devsw.c” in sys/pic32. DevSW stands for “Device SWitch” and is the mechanism by which the kernel decides which routines should be called for which device.

The array we are interested in is the “cdevsw” array. If you look in that source file for the array you will find it there detailing a big list of functions for each character device in the system. The format of the array is defined by the cdevsw struct in conf.h:

1.  struct cdevsw
2.  {
3.          int     (*d_open) (dev_t, int, int);
4.          int     (*d_close) (dev_t, int, int);
5.          int     (*d_read) (dev_t, struct uio*, int);
6.          int     (*d_write) (dev_t, struct uio*, int);
7.          int     (*d_ioctl) (dev_t, u_int, caddr_t, int);
8.          int     (*d_stop) (struct tty*, int);
9.          struct tty *d_ttys;
10.         int     (*d_select) (dev_t, int);
11.        void    (*d_strategy) (struct buf*);
12. };

We're only really interested in the first five entries in this structure. They are pointers to functions which are called when the /dev device node for this driver is opened, closed, read from, written to, or has an extended IO control operation performed on it. For now we can ignore the other four entries.

Device nodes, the entries in /dev, are distinguished from each other by a pair of numbers. These are called the major and minor device numbers. The major number is the position within the cdevsw array, and the minor number is used to define which “unit” within the driver you want to talk to. For example:

crw-rw-rw-  1 root       7,   0 Feb  4 06:36 porta
crw-rw-rw-  1 root       7,   1 Feb  4 06:36 portb
crw-rw-rw-  1 root       7,   2 Feb  4 06:36 portc
crw-rw-rw-  1 root       7,   3 Feb  4 06:36 portd
crw-rw-rw-  1 root       7,   4 Feb  4 06:36 porte
crw-rw-rw-  1 root       7,   5 Feb  4 06:36 portf
crw-rw-rw-  1 root       7,   6 Feb  4 06:36 portg

These device nodes can be seen to have a major number of 7 and a minor number between 0 and 6. The number 7 relates to entry number 7 (starting to count from 0, so the eighth entry) in the cdevsw array. There are 8 “units” within the device - ports A through G - and rather than having a separate driver for each unit, the minor number is used by the driver to distinguish the different units.

This information is passed to your driver functions as the first dev_t parameter. You can obtain the minor number from that parameter by using the minor() function. Let's look at formulating a simple open function for a driver.

1.  int mydev_open(dev_t device, int flags, int mode)
2.  {
3.    int unit = minor(device);
4.    if(unit>6)
5.      return ENODEV;
6.    return 0;
7.  }

All these functions return an integer which acts as an error code. There are multiple ones of these pre-defined, so pick the one which best suits your purposes at the time.

This function is basically getting the minor device number, and seeing if it is a valid unit number. (3-5). In this example we are limiting the system to 7 possible devices (0 to 6 as in the portX example above). The function isn't doing much else, but you would typically put any code to initialise the device here. You can quite happily write to any Special Function Registers here as the driver is part of the kernel. Returning 0 means that the function succeeded.

The same goes for the close function. You can do the same checks on the minor number, and have any code in here to switch off the device, if any is needed.

Next comes the reading and writing. This is where things start to get a little trickier.

All data is transferred to and from the user environment via the uio structure. This stands for Userland Input/Output. The uio structure is quite complex, but thankfully we don't need to go into the internals of it to use it. There are functions that do that for us:

1. uiomove(string,length,UIO);

This will move length bytes from the source string to the destination UIO, or from the UIO into the string. The direction is determined by the uio→uio_rw variable. If this is set to UIO_READ then the operation will copy from your variable into the UIO, otherwise it will copy from the UIO into your variable. For example:

1. char buffer[100];
2. uiomove(buffer,100,uio);

This will, depending on the setting of uio→uio_rw, copy up to 100 bytes to/from your buffer variable.

The uio→uio_rw variable is set by the kernel so you don't have to worry about it.

1. ureadc(character, uio);

This will send one character to the user application.

1. int uwritec(uio);

This will return the next character (if any) from the user.

As with the open and close functions, the read and write functions return an integer as an error code. A return of 0 is “ok”.

The fifth function is the ioctl function. ioctl calls are “Input/Output ConTroL” messages. They are usually used to configure the device the driver is running. Such calls would be uses for, for example, setting the baud rate of a serial port, or other such low-level operations. Things that aren't usually associated with the actual data moving between the user and the device. The ioctl function receives the ioctl command and decides what to do depending on what that command is. You can create your own commands specific to your driver using a couple of very handy macros. ioctl can pass a parameter between the user and the kernel, and back to the user again. This is usually an address (a pointer to a variable) or an integer. The most common ones are to pass the address of a variable - be that a simple integer, a string, or something more complex like a struct.

The parameters passed to an ioctl function are:

1. int my_ioctl(dev_t device, int command, caddr_t address, int flags)

The dev_t device is the same device specification as for read/write/open/close, and you can use minor() to get the minor unit ID. int command is the ioctl command passed to the ioctl call. caddr_t address is the address of a variable passed as the third argument to ioctl. It is possible to just pass an integer but this is seldom done. flags is used to set various options during the ioctl operation and can largely be ignored for the time being.

You can create your own ioctl commands by defining your own macros to equate to some already created generic macros. These are _IO(…), _IOR(…), _IOW and _IOWR. These are for defining flag operations, read operations, write operations, and bi-directional read-write operations. They are used as such:

1. #define MYFLAG _IO(identifier, command)
2. #define MYREAD _IOR(identifier, command, parameter)
3. #define MYWRITE _IOW(identifier, command, parameter)
4. #define MYRW _IOWR(identifier, command, parameter)

Where identifier is a single character used to group commands together. This is useful when you have a large number of commands. command is a number between 0 and 255 to specify the command number. parameter is the type of the parameter you will be passing through ioctl, such as int or struct mystruct.

For example:

1. #define SETBAUD _IOW(b,4,int)

This will define an ioctl command number 4 in group b which takes an integer as its parameter.

Using this in reality would be as such:

1.  int my_ioctl(dev_t device, int command, caddr_t address, int flags)
2.  {
3.      int *baud;
4.      int unit = minor(device); // Get the unit number
5.      if(command == SETBAUD)  // Check the command code
6.      {
7.          baud = (int *)address; // Cast the address to an integer
8.          set_baud_rate(unit,*baud);  // Call the routine to set the baud rate for the unit
9.      }
10.     return 0;
11. }

And would be called as:

1. int mybaud = 9600;
2. ioctl(fd,SETBAUD,&mybaud);

Where fd is a file descriptor opened on the /dev node for the device.

Add your driver in five steps

Step 1. Add the source file for your driver. Use sys/pic32/skel.c as a skeleton. See adc.c, gpio.c and spi.c as examples of more sophisticated drivers.

Step 2. You'll probably need some driver-specific definitions to be visible to user programs, like ioctl codes. In this case, you should add the include file for your driver. Use sys/include/skel.h as a skeleton.

Step 3. Modify sys/pic32/devsw.c and add your driver to cdevsw[] initialization. You will need two fragments, like:

#   include "skel.h"

at the beginning of file, and

    skeldev_open,   skeldev_close,  skeldev_read,   skeldev_write,
    skeldev_ioctl,  nulldev,        0,              seltrue,
    nostrategy,     0,              0,              skeldevs

at end of cdevcw[] table.

Step 4. Add a device-specific script for kernel configuration utility. Use sys/pic32/cfg/ as a skeleton:

    file skel.o
end always

Step 5. Add the device to your kernel configuration file, like:

device skel

Reconfigure and rebuild the kernel. For example:

cd sys/pic32/max32
make reconfig
make clean

Now when you boot with new kernel, you should see your devices present in /dev directory:

# ls /dev/skel*
crw-rw-rw-  1 root       9,   0 Jan  6 19:43 /dev/skel1
crw-rw-rw-  1 root       9,   1 Jan  6 19:43 /dev/skel2
crw-rw-rw-  1 root       9,   2 Jan  6 19:43 /dev/skel3
crw-rw-rw-  1 root       9,   3 Jan  6 19:43 /dev/skel4
crw-rw-rw-  1 root       9,   4 Jan  6 19:43 /dev/skel5
# cat /dev/skel1
--- skeldev_open() unit=0, flag=1, mode=8192
--- skeldev_read() unit=0, flag=0
--- skeldev_close() unit=0, flag=1, mode=8192
# _

Device nodes are automatically recreated at boot time by /sbin/devupdate utility, called from /etc/rc script.


  1. Book ” The Design of The Unix Operating System” by Maurice J Bach, chapter 10: “The I/O Subsystem”.
  2. Book ” Unix Internals. The New Frontiers” by Uresh Vahalia, chapter 16: “Device Drivers and I/O”.