Syllable
Introduction to device drivers - Part 1
The basics

The Syllable kernel is capable of loading modular device drivers as they are required. Device drivers are simply ELF Dynamic Shared Objects (DSOs) DSOs are usually used to implement shared libraries, but because the ELF runtime linker is built into the kernel, Syllable can also use DSOs for device drivers.

An advantage of using standard ELF DSOs is that the kernel can provide a fixed Application Binary Interface (ABI) for device drivers. This means that a binary driver can be used with different kernel versions, without the need to recompile the binary. Due to the way the compiler, linker (ld) and runtime linker work, the kernel driver API is exported via. a shared library called "libkernel.so" This is a "stub" library that contains all of the symbols that form the kernel API that the linker can use when it builds a driver. If that seems a bit complex, don't worry about it; you do not need to understand the mechanism. Just remember that "libkernel.so" is a special library that is used to build device drivers on Syllable.

Like other POSIX systems, applications and libraries communicate with device drivers through the filesystem. Syllable uses a device filesystem (DevFS) that dynamically adds and removes device nodes under /dev as hardware is detected and removed from the system.

Syllable can load drivers on-demand. When an application attempts to open a device node under /dev that does not exist, the kernel will look for any appropriate drivers that it has not yet loaded and load them. If any of those drivers successfully detect hardware they will create device nodes under /dev. Hopefully (for the user) the device node that the application is attempting to open will be created by one of the drivers I.e. the driver will be loaded and the application will successfully open the device node, without ever being aware of the on-demand driver loading.

In order for this on-demand scheme to work, the system organises the device drivers under /system/drivers/dev in a way that closely matches the layout of the device nodes under /dev.

As an example, the USB Human Interface Device (HID) driver is found under /system/drivers/dev/input/. It creates device nodes under /dev/input/. If an application attempts to open /dev/input/usb_mouse, the kernel can look under /system/drivers/dev/input and load the usb_hid driver, which in turn will create /dev/input/usb_mouse if a USB mouse is found on the USB bus. All of this is transparent to the application.

Anatomy of a driver

In order to even be considered as a driver by the kernel, every driver must export the following two functions:

status_t device_init( int nDeviceID ); status_t device_uninit( int nDeviceID );

These functions are defined in the system header <atheos/device.h>

device_init() is the entry point for the driver. The kernel will call this function when the driver is first loaded. The driver will then have the chance to find any supported hardware and initialise anything it finds. If the driver detects a supported device and successfully initialises it, this function should return 0. If the driver fails to find any hardware it can work with, it may return any negative value to indicate failure (usually, -ENODEV).

device_uninit() is called when the driver is unloaded. The driver may or may not need to do anything here. Many drivers leave this function empty, as there is no need to cleanup once the device is removed from the system. We will talk about this function later.

So, our driver starts like this:

#include <atheos/types.h> #include <atheos/device.h> #include <posix/errno.h> /* Driver management */ status_t device_init( int nDeviceID ) { return -ENODEV; } status_t device_uninit( int nDeviceID ) { return 0; }

We'll also need a Makefile at this point. Most driver Makefiles look alike these days, and one is included with the example project. Worth noting are the CFLAGS, which are declared at the top of the Makefile as:

CFLAGS += -kernel -fno-PIC -c -D__ENABLE_DEBUG__

The important options are -kernel and -fno-PIC. The option -D__ENABLE_DEBUG__ is also very useful. We'll look at debugging later.

Device nodes

Most drivers will need to provide at least one device node in the filesystem so that the system libraries and applications can communicate with it. A device node is very simple; just like a normal file, it can be opened, closed, read from, written to, controlled (via. ioctl()) and select()'d. The system header <atheos/device.h> declares the following 9 functions for these operations:

typedef status_t dop_open( void* pNode, uint32 nFlags, void **pCookie ); typedef status_t dop_close( void* pNode, void* pCookie ); typedef status_t dop_ioctl( void* pNode, void* pCookie, uint32 nCommand, void* pArgs, bool bFromKernel ); typedef int dop_read( void* pNode, void* pCookie, off_t nPosition, void* pBuffer, size_t nSize ); typedef int dop_write( void* pNode, void* pCookie, off_t nPosition, const void* pBuffer, size_t nSize ); typedef int dop_readv( void* pNode, void* pCookie, off_t nPosition, const struct iovec* psVector, size_t nCount ); typedef int dop_writev( void* pNode, void* pCookie, off_t nPosition, const struct iovec* psVector, size_t nCount ); typedef int dop_add_select_req( void* pNode, void* pCookie, SelectRequest_s* psRequest ); typedef int dop_rem_select_req( void* pNode, void* pCookie, SelectRequest_s* psRequest );

Note the use of typedef here. This is because the kernel expects the be provided with pointers to the drivers own version of these functions. The system header <atheos/device.h> also declares the following structure:

typedef struct { dop_open* open; dop_close* close; dop_ioctl* ioctl; dop_read* read; dop_write* write; dop_readv* readv; dop_writev* writev; dop_add_select_req* add_select_req; dop_rem_select_req* rem_select_req; } DeviceOperations_s;

The driver implements the functions and then fills in a DeviceOperations_s structure with pointers to those functions. A driver can choose to implement whichever device operations make sense for the hardware and the way that it operates. In practice that usually means open(), close() and at least one of ioctl(), read() or write(). If read() and/or write() are implemented, a driver may also implement the readv() or writev() operations too, but we'll leave those for later.

Once the driver has defined its DeviceOperations_s structure it calls create_device_node():

int create_device_node( int nDeviceID, int nDeviceHandle, const char* pzPath, const DeviceOperations_s* psOps, void* pCookie );

This creates a device node under /dev and associates the device driver operations with the device node, so that e.g. an application that calls open() on the device node will eventually cause the device drivers own open() operation to be called.

Time to add this to our driver:

/* Device interface */ static status_t example_open( void* pNode, uint32 nFlags, void **pCookie ) { return 0; } static status_t example_close( void* pNode, void* pCookie ) { return 0; } static status_t example_ioctl( void* pNode, void* pCookie, uint32 nCommand, void* pArgs, bool bFromKernel ) { return ENOSYS; } static int example_read( void* pNode, void* pCookie, off_t nPosition, void* pBuffer, size_t nSize ) { return -ENOSYS; } static int example_write( void* pNode, void* pCookie, off_t nPosition, const void* pBuffer, size_t nSize ) { return -ENOSYS; } static DeviceOperations_s g_sDevOps = { example_open, example_close, example_ioctl, example_read, example_write, NULL, /* dop_readv */ NULL, /* dop_writev */ NULL, /* dop_add_select_req */ NULL /* dop_rem_select_req */ };

So our driver will have open(), close(), ioctl(), read()and write() operations. The other operations are simply NULL pointers: the kernel understands the use of NULL to mean "No operation" and will handle the unimplemented operations transparently for us.

Note also that these functions are declared static. There is no need to export these symbols outside of the DSO as we pass them pointers to them indirectly via. the DeviceOperations_s structure. Doing it this way allows a driver to export multiple device nodes e.g. a sound card driver may export different nodes for the DSP and the mixer.

Note that at this point we only have an empty set of functions and a struct containing pointers to those functions. We have not called create_device_node() so nothing will be created in /dev.

Bus managers

Bus managers exist as a way to bring together different host controllers and device drivers in a moduler fashion. Because many different buses exist (e.g. PCI, USB, SCSI) there are many different bus managers. At the moment most devices are PCI devices, so the drivers use the PCI bus manager, but there are a growing number of USB device drivers, too.

The bus manager abstracts away the bus hardware so that the device driver does not need to know how the bus controller is implemented. This doesn't mean much for PCI devices, where the PCI bus interface is well defined, but for e.g. USB buses it allows the driver to ignore the details of what type of host controller the device is physically connected too. Bus managers also handle scaning the bus for devices and hot-plug events (for buses that support such functionality)

Bus managers are really a specialised type of device driver, but their implementation is outside the scope of this tutorial. All we really need to worry about right now is how to access the functions of the bus manager.

Every bus is different, and every bus manager provides different bus-specific functions that a driver may use. The kernel doesn't need to worry about this though, so we have exactly one function we need to know about:

void * get_busmanager( const char* pzName, int nVersion );

This generic kernel function can be used to access any of the available bus managers. However our driver will only need to worry about PCI devices, so:

#include <atheos/pci.h> static PCI_bus_s* g_psBus; status_t device_init( int nDeviceID ) { /* Get PCI bus */ g_psBus = get_busmanager( PCI_BUS_NAME, PCI_BUS_VERSION ); if( g_psBus == NULL ) return -ENODEV; return -ENODEV; }

What we have is another structure that contains a series of function pointers. This time the PCI_bus_s structure, which contains pointers to the various PCI bus manager functions. Because the functions in the bus manager can change in newer versions of Syllable, get_busmanager() also accepts a version number which tells the bus manager which version of the PCI_bus_s structure the driver is expecting. If an older driver is loaded on a system with a newer PCI bus manager, the bus manager can still provide a set of functions that will work. This makes the device driver and bus manager both forward and backward compatible.

Because we'll need to access functions in the PCI bus manager at various points in the driver we make g_psBus a global variable for convienience.

Device management

Syllable can automatically detect new hardware and drivers, and load them on-demand. However, it does need a little help from the device drivers so that it knows which drivers are loaded for what hardware, or if a driver is not required.

There are two functions you will normally need to deal with:

status_t claim_device( int nDeviceID, int nHandle, const char* pzName, enum device_type eType ); void release_device( int nHandle );

As we know, when the driver is loaded by the kernel the device_init() function. The driver can then try to find any hardware that it supports. If a supported device is found the driver can call claim_device() to mark the device as "taken". This stops another device driver from being able to claim the device. claim_device() is also used to tell the kernel some additional information about the device, and allows it to maintain a map of the supported devices on the system. This device map can be used by utilities such as Syllable Manager to show the user device information.

release_device() is the complement to claim_driver(). It allows the driver to "unclaim" a device. Using release_device() is common for devices that support hot-plug, such as USB devices.

It is also worth noting two other functions:

int register_device( const char* pzName, const char* pzBus ); void unregister_device( int nHandle );

These functions are more generally used by the bus managers, to tell the kernel about any devices that are attached to the bus. However they are occasionally used by device drivers when dealing with devices that are not supported by the bus managers e.g. ISA devices. In these cases, the device driver first calls register_device() to tell the kernel about the hardware, and then calls claim_device() as normal. Unless you are writing a device driver for old or strange hardware, you can ignore register_device() and unregister_device() for the most part.

There is one more function that is very important to the way device managment works on Syllable:

void disable_device( int nDeviceID );

disable_device() is a way for a device driver to tell the kernel "I did not find any hardware. Do not load me again." This is used for devices that do not support hot-plug, such as PCI devices. In general the configuration of the bus does not change often; the same devices are usually present on the bus the next time Syllable boots. So that the kernel does not always need to load every single driver whenever the system is booted (only for many of them to fail to detect any supported hardware), disable_device() allows the kernel to know in advance which devices drivers will not be needed, and skip them.

This functionality is less useful for devices that support hot-plug, such as USB devices. In that case it is impossible to know when hardware will be attached to the bus, so the kernel must be able to load any of the available device drivers in an attempt to find a driver which supports the newly attached hardware.

If you're wondering what happens if the user installs a new device but the device driver that supports it has disabled itself: the PCI bus manager can see when the configuration has changed and will re-enable all of the previously disabled device drivers. This gives the drivers a chance to look for supported hardware again.

So far we've ignored the nDeviceID parameter which is passed to device_init() and device_uninit(), and expected by claim_device(). Although it is named "Device ID" it is more properly the "Driver ID". Each driver that is loaded by the kernel is given a unique "Device ID" The ID is used to track which drivers successfully initialise and which devices are associated with the driver. Other than that you do not need to worry too much about it, other than to remember to pass it to claim_device().

Debugging

The kernel has a simple debugger built in that can be used by a developer to capture debugging messages and see what is happening on a system. The debug output can be seen when Syllable boots, and it is also captured to the kernel log file /var/log/kernel. The debug output can also be sent over a serial cable to another machine, which is useful if your driver causes the kernel to crash before it can write the debug information to the kernel log!

Generating this debug output is very simple. The system header file <atheos/kdebug.h> contains the function:

int printk( const char* pzFmt, ... );

which as you might guess, is the kernel equivalent of printf()! printk() will print your debugging text no matter what. It is generally preferable to have a little more control over the level of debug information you want to produce, so the macro:

kerndbg(level,format,arg...)

is generally prefered. kerndbg() takes a "level" argument, ranging from KERN_DEBUG_LOW to KERN_PANIC. The debug information will then only be printed if the level is at or above the value given in DEBUG_LIMIT.

As an example, if we set DEBUG_LIMIT to the level KERN_INFO, the following kerndbg()will not print anything:

kerndbg( KERN_DEBUG, "Hello kernel!" );

but this will:

kerndbg( KERN_WARNING, "As I was saying, hello kernel!" );

The idea is that you can insert as much debugging information as you need while you are developing your driver. Once you are satisfied that your driver is working you can then increase DEBUG_LIMIT to "switch off" this additional debugging information. Should you ever need to revisit your code to debug any additional problems, you can once again enable the debugging information by lowering the value of DEBUG_LIMIT.

The kerndbg() macro only works if __ENABLE_DEBUG__ is also defined. You might remember that it is set in the Makefile CFLAGS with -D__ENABLE_DEBUG__. If you want to disable debug output totally, you can remove the definition.

A complete example driver

The example driver implements a simple driver which registers a new device, claims it and creates the device node /dev/misc/example. It includes debugging output, so you can see what happens at various points as the driver is loaded. You can also try opening, reading, writing and closing the device while you watch the debugging output in the kernel log (Try " tail -f /var/log/kernel" in a new Terminal).

Once you've got to grips with the example driver, take a look at some of the other drivers in Syllables CVS repository. Don't worry if they don't make much sense yet, but you should be able to spot many of the concepts we've already covered. Try making some changes to the example driver. How would you create a second device node under /dev? What happens if the driver tries to call claim_device() without registering a new device with the kernel first?

Next

Part 2: Ethernet drivers and the network stack.