Lecture 13 – Device drivers (Digital Systems)

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

A micro:bian program consists of a family of processes that communicate by sending and receiving messages. The transfer of a message from one process to another makes sure that the data in the message reaches its destination without damage, and we rely on this rather than on shared variables to prevent harmful interference between the actions of different processes.

If shared variables are discouraged, that suggests in particular that the buffer containing characters waiting to be output on the serial port should belong to one process rather than being shared by several – and so it is: we will introduce a device driver process for the serial port that has sole ownership of the output buffer. Other processes that want to add a character to the buffer must communicate with it by sending a message. If there is space in the buffer, the driver process will nearly always be waiting to receive a request, and so a process wanting to send a character will not have to wait very long.

(In this lecture, we'll develop a driver process for output only, but in the lab materials there's a process for both input and output, with echoing and line editing, and an interface where output characters can be transferred in bulk, without using one message per character.)

Note: Update needed for changed micro:bian API.

Our implementation of serial_putc will simply be to construct an appropriate message and send it to the SERIAL process. When the call of send completes, we know the driver process has received the message, so there's no need here to wait for a reply, though in larger systems it's often helpful to adopt a convention that every request is answered by a reply message, either positive or negative.

void serial_putc(char ch) {
    message m;
    
    if (ch == '\n') {
        m.int1 = '\r';
        send(SERIAL, PUTC, &m);
    }

    m.int1 = ch;
    send(SERIAL, PUTC, &m);
}

The operating system knows which process is running, so it can tell who called serial_putc, and can stamp the resulting message with the correct sender, though in this case the receiver doesn't care. Note that we are using an integer-size field int1 in the message to send a single character, but that doesn't cause any problems. This is a good place to implement the convention that each newline '\n' in the output is preceded by a carriage return '\r', so we don't any longer have to write "\r\n" in every string.

With this implementation of serial_putc, the USEPRIME process from the last lecture can be seen as one that accepts messages containing primes, and from time to time sends messages containing characters to print, so that its entire interaction with the world around it is sending and receiving messages.

What about the driver process? Like many such processes, it contains a loop that accepts a request, carries it out, then repeats. Here is an outline:

static void serial_task(int n) {
    static char txbuf[NBUF];    /* Circular buffer for output */
    int bufin = 0;              /* In pointer */
    int bufout = 0;             /* Out pointer */
    int bufcount = 0;           /* Character count */
    int txidle = 1;             /* True if transmitter is idle */

    message m;
    char ch;

    serial_setup();

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

        switch (m.type) {
        case PUTC:
            ch = m.int1;
            assert(bufcount < NBUF);
            txbuf[bufin] = ch;
            bufin = wrap(bufin+1);
            bufcount++;
            break;

        ... Part A ...

        default:
            panic("serial driver got bad message %d", m.type);
        }
          
        ... Part B ...
    }
}

Note that the variables like bufcnt that were shared before are now local variables of this process, subject to no interference from other processes. I've declared the buffer itself as static, so although it is accessible only from this process, it does not take up any space on the stack.

The part of the process before the server loop starts is a good place to initialise the serial hardware. Then comes the loop, with a receive call at the top, then a switch statement that selects one of several alternatives according the type of message. Shown here are just the alternative that handles PUTC messages and the catch-all that leads to the Seven Stars of Death if the process ever receives a message it wasn't expecting.

Several questions remain to be answered:

  1. How do characters get out of the buffer and into the UART?
  2. What happens about interrupts?
  3. What happens when someone wants to send a PUTC message but the buffer is full?

We can answer the first of these questions quite easily: you'll see that the code for handling a PUTC message assumes that there's space available (we'll get to that in a minute), and adds the character to the buffer whether the UART is busy or idle. So at the place labelled Part B, we can put a fragment of code that restarts the UART if it is idle:

Part B =
        if (txidle && bufcount > 0) {
            UART_TXD = txbuf[bufout];
            bufout = (bufout+1)%NBUF;
            bufcount--;
            txidle = 0;
        }

This sets txidle to 0: we should set it to 1 again when the UART sends an interrupt. So what shall we do about interrupts? The answer is very simple: interrupts are just messages from the hardware. Somewhere in serial_setup we include the call

connect(UART_IRQ);

and this asks the operating system to turn interrupts from the UART into messages with type INTERRUPT from the fictitious process HARDWARE and deliver them to the driver process. Then at Part A we can write the following.

Part A =
        case INTERRUPT:
            if (UART_TXDRDY) {
                txidle = 1;
                UART_TXDRDY = 0;
            }
            clear_pending(UART_IRQ);
            enable_irq(UART_IRQ);
            break;

As usual, we check the reason for the interrupt, and if the UART has finished transmitting, we set txidle. The operating system reacts to each interrupt in the same way, by disabling it using disable_irq() and sending a message to the connected process. That makes it a bit complicated to clear the interrupt: we have to reset UART_TXDRDY, then use clear_pending(UART_IRQ) to clear the pending bit for the interrupt and finally enable_irq(UART_IRQ) enable the interrupt again. The pattern is always the same.

The final question is what to do when the buffer is full. Our previous approach has been to enter a loop, either polling actively, or using pause(). until it's possible to transmit the character or find space in the buffer. That approach is no longer acceptable, because there may be other processes that want to run. What works instead is to allow requests to be received only when there is buffer space to satisfy them immediately. We can replace the call

receive(ANY, &m);

with

if (bufcount < NBUF)
    receive(ANY, &m);
else
    receive(INTERRUPT, &m);

so that only interrupts are allowed through when the buffer is full. The result of that is that a process trying to send a character will itself have to wait until we are again prepared to listen to it. In a simple program, that means no further progress can be made until a UART interrupt arrives. So what happens in the meantime?

In this situation, there are no processes ready to run: not the serial driver, because that is waiting for an interrupt from the UART; not the process generating the characters, because that is waiting to send a message to the serial driver; and (we suppose) not any other process in the program. But hidden inside the operating system is a process that is always ready: the idle process whose body is

static void idle_task(void) {
    yield();
    while (1) {
        pause();
    }
}

The yield() call here is an important part of how the operating system starts up, but it's the pause() call that matters: this should be the only wfe instruction in the whole system. When no processes are ready, nothing can happen in the system until an interrupt arrives, and it's time for a snooze. An interrupt will be delivered as a message to some driver process, which can then send or receive messages with other processes, and activity can spread through the system. When the consequences of the interrupt are all worked out, perhaps all the processes will be waiting again to send or receive, and the scheduler will re-activate idle_task so it can put the system to sleep again.

Simplicity: a device driver is just a process. All interrupts are handled in the same way – but if performance is critical, we could do more in the handler.

Here's the whole device driver in one piece, with the UART initialisation code filled in:

/* serial_task -- driver process for UART */
static void serial_task(int n) {
    static char txbuf[NBUF];    /* Circular buffer for output */
    int bufin = 0;              /* In pointer */
    int bufout = 0;             /* Out pointer */
    int bufcount = 0;           /* Character count */
    int txidle = 1;             /* True if transmitter is idle */

    message m;
    char ch;

    UART_ENABLE = 0;
    UART_BAUDRATE = UART_BAUD_9600;     // 9600 baud
    UART_CONFIG = 0;                    // format 8N1
    UART_PSELTXD = USB_TX;              // choose pins
    UART_PSELRXD = USB_RX;
    UART_ENABLE = UART_Enabled;
    UART_RXDRDY = 0;
    UART_TXDRDY = 0;
    UART_STARTTX = 1;
    UART_STARTRX = 1;

    connect(UART_IRQ);
    UART_INTENSET = BIT(UART_INT_RXDRDY);
    enable_irq(UART_IRQ);

    while (1) {
        // If the buffer is full, don't accept any more requests
        if (bufcount == NBUF)
            receive(INTERRUPT, &m);
        else
            receive(ANY, &m);

        switch (m.type) {
        case INTERRUPT:
            if (UART_TXDRDY) {
                txidle = 1;
                UART_TXDRDY = 0;
            }
            clear_pending(UART_IRQ);
            enable_irq(UART_IRQ);
            break;

        case PUTC:
            ch = m.int1;
            assert(bufcount < NBUF);
            txbuf[bufin] = ch;
            bufin = wrap(bufin+1);
            bufcount++;
            break;

        default:
            badmsg(m.type);
        }
          
        // Can we start transmitting a character?
        if (txidle && bufcount > 0) {
            UART_TXD = txbuf[bufout];
            bufout = wrap(bufout+1);
            bufcount--;
            txidle = 0;
        }
    }
}

Lab 4 contains source code for a more elaborate serial driver that supports both output and input with line editing.

Lecture 14