Skip to content

Device driver API

In JeeH’s new multi-tasking kernel, all device drivers are message-oriented just like tasks. However, they don’t represent “interruptible processes with state”, as is the case with tasks.

Device drivers …

  • respond to incoming messages by starting up some work
  • will usually need to save those messages in a queue until the work is done
  • set up hardware peripherals to perform the requested action
  • manage all interrupts and DMA channels associated with this hardware
  • return once the requested action has been initiated
  • cannot block and in fact cannot perform any system calls
  • have access to some internal kernel data structures that are hidden from tasks
  • are derived from the Device class defined in jee.h
  • can keep state around in their own device instance
  • can include an interrupt request handler which can also access this state
  • must be very careful about volatile data shared at interrupt time
  • can process multiple incoming messages in the order they see fit
  • will send each message reply back to its originating task once the work is done
  • can never send these replies from inside their interrupt handlers
  • can process further messages at any time, including in its interrupt code

Driver structure

It may all seem a bit involved, but a basic device driver can be fairly simple:

struct MyDevice : Device {
    MyDevice ()                    { ... }

    void start (Message&) override { ... }
    bool interrupt (int) override  { ... }
    void finish () override        { ... }
    void abort (Message&) override { ... }
};

(the override keyword in C++ indicates that these functions are virtually dispatched)

start

This function is called when a task does an os::put with this device driver set as message destination. The task “owns” the message, but the driver can assume that the message object will not go away or change until it sends it back as reply, possibly much later.

The originating task must keep the message allocated and leave it alone until it is returned in a future call to os::get. Then it can extract the reply and re-use the message or delete it.

The start function cannot block. It must return right after saving the message and optionally initiating some activity in its hardware peripheral. It may also return a reply right away (e.g. in case of bad requests), and place it back in the originating task’s queue.

interrupt

This is the IRQ handler for a device (it can be an empty stub if the device does not use IRQs). It gets called whenever the associated hardware interrupt is triggered. This can happen at any moment, even while one of the other functions is running (or any other task or driver).

No system calls can be made inside this function. Instead, it should return true to signal the wish to perform such calls. This will be flagged in the kernel, which then sets things up for finish to be called as soon as the proper conditions apply (read: once no other interrupt or system call is running).

finish

As just decribed, finish takes care of the less urgent part of an interrupt: returning one or more reply messages to the queue(s) of the originating task(s). This code can not perform any system calls, but it can call Device::reply() to return a message to its caller, after removing it from the internal device driver queue.

In summary: interrupt is for urgent code, and finish gets to do the rest as soon as possible, later on. All communication between interrupt and finish must go through fields in the device object (MyDevice in the above example).

abort

The abort function is called when a task wants to revoke a message sent to this driver. Depending on when this happens, the message may just get pulled out of some waiting queue before anything has been initiated, or it may require the driver to actually cancel the request in the hardware, clear out buffers, cancel pending interrupts, and more.

After aborting, the driver can start up a new action requested by some other message waiting in its queue(s): this cancels a specific request, while keeping other requests going, if possible.

Kernel mode

The start(), finish(), and abort() functions always run in kernel mode: they can be interrupted by IRQ requests, but the current task context cannot change as long as they are running. The interrupt() function is a hardware IRQ handler and may run at any time, even in the middle of the other functions. That’s the whole point of interrupts: to perform an urgent activity, whenever the hardware asks for assistance. Depending on how IRQ priorities are set up, interrupt handlers may even interrupt other (lower-priority) interrupt handlers.

As far as tasks are concerned (which run in user mode), all interrupt handlers are invisible - other than stealing some CPU cycles now and then. The only contention is in the task queues where new messages arrive and replies get returned, and these queues are only accessible from application code via system calls (which briefly switch themselves into kernel mode).

Message flow

In the common case, start will add a message to an internal queue, initiate some hardware activity, and then return. Some time later, interrupt will get called and return true. Soon thereafter (often right away, in fact), the kernel will call finish, which then pulls the message off its queue and sends it back as reply. At this point, the request is complete and the driver no longer keeps any reference to it.

As a message progresses through the driver, its payload can be read and written whenever needed, but only start, finish, and abort are allowed to actually move the message on or off queues, i.e. change its link field. This includes sending messages back as replies.

The dest field n a Message is also special: it will start off containing the driver id (it’s how it got routed to this device driver in the first place). Before being handed over to start, the destination is changed to the originating task, so that the driver knows where it came from. And at the end, when sent back, reply will then restore it to this device driver’s id.

Current status

As of end Nov 2022 there are a number of device drivers, in different stages of completion:

  • a Ticker driver for delays and timeouts up to 1 minute, with millisecond resolution
  • a Uart driver for one or more serial ports, based on DMA channels (see this post)
  • an ExtIrq driver for STM32’s extended interrupts, for use with GPIO pin interrupts
  • a low-level 10/100 Mbit Ethernet driver for STM32F7, using its built-in MAC and DMA

The driver API is still evolving w.r.t. initialisation and setup, but the core functions described in this article are holding up nicely so far. Most importantly perhaps, these drivers do not disable interrupts anywhere in their code, leading to “real-time worthy” overall IRQ response times.