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 injee.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.