Lecture 16 – A device driver (Digital Systems)

Copyright © 2024 J. M. Spivey
Jump to navigation Jump to search

The Nordic chip on the microbit has a temperature sensor that measures the temperature of the processor die in units of 1/4° Celsius. The device consists of an analog circuit that exploits some temperature-dependent property of silicon, together with a dedicated A-to-D converter that digitises a reading. The A-to-D converter takes some time to settle, so there is a protocol where the processor initiates a reading, then must wait for an event that can be signalled by interrupt before getting the result. We will aim to write a device driver for the sensor, and provide a function

int temp_reading(void)

that can be called from any process to get an up-to-date temperature reading.

Reasons for having a separate device driver process:

  • We can manage concurrent access from multiple processes and prevent a new reading being initiated before the previous one is finished.
  • We can connect to the interrupt from the device and react when a reading is complete.

The hardware

Two pages from the nRF51822 manual describe the hardware of the temperature sensor. All the device registers are addressible at offsets from the base address TEMP = 0x4000C000.

  • There is an 'task' START = 0x000 that initiates a reading when triggered with TEMP_START = 1.
  • There is an 'event' DATARDY = 0x100 that indicates when the reading is ready (with TEMP_DATARDY == 1).
  • There is an interrupt enable register INTEN = 0x300 in which bit TEMP_INT_DATARDY = 0 enables the interrupt on the DATARDY event.
  • There is a data register TEMP = 0x508 that contains the value of the reading when ready.

Note: This syntax for device descriptions is obsolete.

We can describe this layout by adding suitable definitions to the header file hardware.h. Actually, kind Mike has already written them. We need to strain the rules of C to the limit to write this stuff – and I won't go into details, because most of the really hairy stuff is hidden in macros like _DEVICE and _REGISTER. What we need is the following (with unused registers elided):

/* Temperature sensor */
_DEVICE _temp {
/* Tasks */
    _REGISTER(unsigned START, 0x000);
/* Events */
    _REGISTER(unsigned DATARDY, 0x100);
/* Registers */
    _REGISTER(unsigned INTEN, 0x300);
    _REGISTER(unsigned VALUE, 0x508);
};

/* Interrupts */
#define TEMP_INT_DATARDY 0

#define TEMP (* (volatile _DEVICE _temp *) 0x4000c000)

Broadly speaking, this says that a _temp device has four registers at the offsets shown, all accessed as 4-byte unsigned integers. Then it says that a particular _temp device TEMP exists at the address 0x4000c000. This hardware address is linked behind the scenes with the irq TEMP_IRQ = 12, because of the c = 12 in 0x4000c000 – but that fact is not important.

The upshot of these definitions is that when we write (for example)

temp = TEMP_VALUE

in our program, that corresponds to fetching from the address 0x4000c508. If temp lives in r6, the equivalent machine code would be

ldr r0, =0x4000c508
ldr r6, [r0]

developing the address of the TEMP_VALUE register into r0, then loading from that address into r6.

Configuring the interrupt

There's nothing to set up about the temperature sensor itself: when triggered it always does its job in the same way, with no variation that we can control. But we do have to condifure the interrupt mechanism so that the completion of a reading results in an INTERRUPT message to our driver process. Here are the steps, following the flow from device to driver process.

  • First, we must tell the device to request an interrupt when a reading is ready. We do this by setting a bit (the only significant bit, as it happens) in the device's INTEN register.
TEMP_INTEN = BIT(TEMP_INT_VALRDY);
On other devices, there may be several potential causes of interrupts, and we can choose which causes will actually lead to an interrupt being requested.
  • Second, we must tell the interrupt controller to pass on interrupt requests from the device. There's a register NVIC_ISER that has one bit for each of the 32 different device interrupts, and setting the appropriate bit in that register makes the NVIC pass through the corresponding interrupt. So we write
enable_irq(TEMP_IRQ);

as an abbreviation for

NVIC_ICER = BIT(12);
This enables the appropriate interrupt. A detail: storing a 1 bit in NVIC_ISER enables the corresponding interrupt without disabling any others; to disable a specific interrupt, we store an appropriate 1 bit into its sister register NVIC_ICER. This pattern is found all over the place – in fact, the temperature device has two registers INTENSET and INTENCLR alongside the INTEN register that also have this behaviour, even in the futile case where they contain only one significant bit.
  • We might need make sure that the processor is accepting interrupts, something that can be done with the call
intr_enable(); // (not needed)
which is an abbreviation for a special instruction that is written cpsie i in assembly language. But the processor starts up in a mode where interrupts are enabled, so we don't need to do anything specific for this device.

What we've had so far sets up the hardware so that interrupts will happen at an appropriate time. Under micro:bian, the interrupts will be handled by the general interrupt handler that looks up the interrupt in a table and send a message to the appropriate driver process.

  • So (third) we must register our process for the relevant interrupt with the system call
connect(TEMP_IRQ);
This fills in the PID of the current process in the os_handler table, and also raises the priority of the current process so that interrupts will be delivered promptly.

A cautious programmer might choose to put the call to connect() before the call to enable_irq(), so that any interrupt that was already pending will be delivered and not cause a panic. Then the device driver would have to be written so as to ignore spurious interrupts. In this case, it seems that the temperature sensor won't request an interrupt before it is started, so everything is safe.

We end up with the code

TEMP_INTEN = BIT(TEMP_INT_DATARDY);
connect(TEMP_IRQ);
enable_irq(TEMP_IRQ);

to include at the top of the device driver. The hardware setup could be put elsewhere, but the call to connect must come from the device driver process, because that's how the OS identifies which process wants to connect.

The driver process

Note: Update needed for changed micro:bian API.

Because we will deal with each request – starting the reading, waiting for it to be ready, then retrieving the value – before accepting another request, we can write the driver process in a particularly simple way, where the server loop deals with one complete request for each iteration, and a call receive(INTERRUPT, ...) in the body of the loop pauses until the hardware produces a reading.

static void temp_task(int arg)
{
    message m;
    int temp, client;

    ... setup as above ...

    while (1) {
        receive(ANY, &m);

        switch (m.type) {
        case REQUEST:
            client = m.sender;

            TEMP_START = 1;
            receive(INTERRUPT, NULL);
            assert(TEMP_DATARDY);
            temp = TEMP_VALUE;
            TEMP_DATARDY = 0;
            clear_pending(TEMP_IRQ);
            enable_irq(TEMP_IRQ);

            m.int1 = temp;
            send(client, REPLY, &m);
            break;

        default:
            badmesg(m.type);
        }
    }
}

There are a couple of layers to explain here: first, there's a server loop that accepts a request, carries it out, sends a reply, then loops around to accept another request. If we're content to complete the handling of one request before accepting another one, then this is sufficient, and we don't have to deal with interrupts in the main server loop.

After receiving a request, the process notes which process sent the request, so it can reply to the same process later. There follows a bit of code that uses the sensor hardware to get a temperature reading, then the process constructs a REPLY message with the reading in its int1 field, and sends that message back to the client. The relationship between client and server looks like a function call here: the client sends in a request, waits while the server does the work, then receives a reply before carrying on. This pattern of interaction is often called remote procedure call.

The code for carrying out a request is not hard to understand. First, we start the sensor taking a reading.

TEMP_START = 1;

Nothing more happens until the reading is done, and then we receive an interrupt message, so we can safely wait for the message to arrive.

receive(INTERRUPT, NULL);

(Note that we insist on an INTERRUPT message, so we don't accidentally receive another request at this point. Any client processes wanting to request a reading must wait, and they wait on the queue of processes that want to send to this one, with no explicit queue in our program needed. Also note that we can specify the second parameter of receive() as NULL if we are not interested in any data about the message received.)

When the interrupt arrives, we expect it is because the conversion is complete, so we write

assert(TEMP_VALRDY);

If the VALRDY event hasn't been signalled, then something has gone horribly wrong, and the failed assertion will result in the Seven Stars of Death.

At this point, the reading is available in the VALUE register of the device, so we fetch it and keep it in the local variable temp. (This is one of the few times a variable called temp is a good idea!).

temp = TEMP_VALUE;

Following that, we must reset the hardware ready for the next reading, and that mostly means re-arming the interrupt mechanism. That must be done in a specific order if we aren't going to trigger another interrupt by accident. First, let's remove the cause of the interrupt by resetting the DATARDY event.

TEMP_DATARDY = 0;

Second, because of the way the NVIC interacts with micro:bian's interrupt mechanism, we must clear the pending bit for the interrupt.

clear_pending(TEMP_IRQ);

That amounts to storing a 1 bit in the appropriate place in a special register NVIC_ICPR: details in hardware.h. Finally, it's safe to enable the interrupt request again.

enable_irq(TEMP_IRQ);

And now the reading is in the variable temp, and the interrupt hardware is set up for the next reading.

Client stub

Note: Update needed for changed micro:bian API.

Clients will obtain temperature readings by sending a message to the server and waiting for a reply that contains the reading. It's convenient to provide a little function function temp_reading() that carries out this interaction for the client and returns the reading as its result. For the remote procedure call idiom of sending a request and waiting for the reply, we can use the sendrec system call.

int temp_reading(void)
{
    message m;
    sendrec(TEMP_TASK, REQUEST, &m);
    return m.int1;
}

Why use sendrec() here? There are three reasons: in order of increasing importance, (i) it is shorter than writing two separate calls; (ii) it is more efficient, because after the client process sends a request and suspends, the client does not need to run again before being ready to receive the reply; and (iii) using sendrec() avoids a situation called priority inversion, where a high-priority process with work to do is blocked because another, lower-priority, process needs to run first.

sendrec() and priority inversion

With separate send() and receive(), the sequence of events will be as follows.

  1. The client process calls send() and (we assume) immediately rendezvous with the receive() call at the top of the server loop.
  2. Because the server process has higher priority than the client process, the client process is suspended on the ready queue and the server process starts to run.
  3. The server process perform a temperature reading. Let's assume that during this time, other processes run and the client (despite being active) does not.
  4. The server calls send() to return a reply to the client. At this point, if the client had used sendrec(), the kernel would know that the client was waiting to receive a reply, the message could be transferred immediately, and the server (with its higher priority) could continue to run.
  5. If the client has not uses sendrec(), then the server blocks at its call the send() for the reply, and must wait until the client process is scheduled again; at that point, the message is transferred and both server and client are made active. Because the server process has higher priority, it is selected to run again.

The upshot is that, unless sendrec() is used, there is are least two additional context switches between server and client, and a situation could arise where the server was needlessly blocked for a while, waiting the a lower-priority process to be scheduled. This priority inversion does no harm in this example, because once the temperature reading is delivered, the server process has nothing more to do before waiting for the next request, but in examples where events can happen continuously, it can create a problem.

There is no way of avoiding the problem by writing the client program in a different way, unless the calls to send() and receive() are combined. When the client program is suspended, that happens inside the system call stub for send(); on being resumed, the process will return from the send() stub, prepare the arguments, and immediately call the receive() stub and enter the operating system again. Using sendrec() instead of send() followed by receive() short-circuits this sequence of events; having sent its message, the client immediately enters the RECEIVING state to wait for the reply, without having to run again first.

To implement sendrec(), we need to add another process state, SENDREC, in case the client process must wait for the server process to be ready to receive; the difference between SENDREC and SENDING is that when it has sent its message, a process in state SENDING enters the ACTIVE state, and a process in state SENDREC enters state RECEIVING, always blocking until the server sends a reply. The implementation of receive() must change to reflect this distinction.

One rule restricts the use of sendrec(), but not in a way that affects its usage in practice: the sendrec() system call cannot be used to send a REPLY message. Allowing this could lead to a situation where a chain of processes are waiting, each wanting to use SENDREC to send a reply to the process to its right, but blocked from doing so because that process is itself waiting to send. When the process at the far right end of the chain eventually accepts a message, the whole chain would have to unravel at once, and that would complicate the implementation without making micro:bian any more useful.

A client program

Using the timer service, it's quite easy to write a client program that requests readings from the server at (say) 1 second intervals and prints them. Without floating point arithmetic, we have to be a bit sneaky about how we print the readings.

static const char *decimal[] = {
    "00", "25", "50", "75"
};

void main_task(int n)
{
    int t = 0, temp;

    while (1) {
        temp = temp_reading();
        /* Assume temp >= 0 */
        printf("%d: Temp = %d.%sC\n", t++, temp>>2, decimal[temp&0x3]);
        timer_delay(1000);
    }
}

The init() function must start the device driver and the main process, as well as the drivers for the timer and the serial port.

void init(void)
{
    serial_init();
    timer_init();
    TEMP_TASK = start("Temp", temp_task, 0, 256);
    start("Main", main_task, 0, STACK);
}

Lecture 17