Lecture 12 – Introducing micro:bian (Digital Systems)

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

Our best attempt so far at a prime-printing program uses interrupts to overlap the search for more primes with printing the primes it has found, but when the buffer of output characters is full, it still falls back on the tactic of waiting in a loop for space to become available. If the program is producing primes at a faster rate than the UART can print them, then there's really nothing else we can do.

But perhaps there are other tasks that the machine needs to do – such as showing something on the display at the same time as computing primes. If that's so, then we will want to move away from a structure where there is a single main program and a collection of interrupt handlers that are called from time to time. Unless the other functions of the program can be implemented entirely in interrupt handlers, we need more than one 'main program' to share the processor somehow.

Also: the printer driver disables interrupts in order to manipulate a data structure (the circular buffer) that is shared between the interrupt handler and the function serial_putc. It's tricky to get this right, and disabling interrupts for more than a brief moment risks delaying other things (like multiplexing the display) that ought to happen promptly.

All this means that we are ready to begin using a kind of operating system – a process scheduler. We'll be using a very simple embedded operating system that I've named micro:bian, based on the idea of a fixed set of concurrent processes that communicate with each other by sending messages. The design is based on a much-simplified version of the internal structure of Andy Tanenbaum's operating system Minix, an implementation of Unix that is a predecessor of Linux.

Notes: Minix uses message-passing internally, but the interface it presents to user processes is Unix, with fork and exit, signals and pipes. Although Linux arose out of Minix, its internal structure is entirely different. All modern Intel machines run a version of Minix on an internal system-management processor, making it arguably the world's most used operating system – except possibly for the L4 microkernel that runs on the other, secret processor in your mobile phone.

There's a page with a brief programmer's manual for micro:bian.

micro:bian vs Phōs

The operating system formerly known as Phōs has now become micro:bian, and I'll mention here the main differences between them, just in case confusion is caused by a possible failure to remove all references to Phōs and its conventions from the rest of the notes. In Phōs, a receiving process could select what process it wanted to hear from and not what message it wanted to receive. The two conventions are roughly equivalent in expressive power (and they could be combined if we didn't care about keeping things simple), but the new scheme is better for a few of the examples we want to look at. In both schemes, receive() takes two parameters, one either a process id or a message id (in the two cases), and the other (a pointer to) a message buffer. In Phōs, send() also took two parameters, one a process id for the destination and the other a message buffer, and programmers had to fill in the message type before calling send(). In micro:bian, the message type is a parameter to send(), and the message buffer can be NULL if there is no additional data to send.

Processes

A micro:bian program can contain multiple processes that all make progress as the program runs. We can imagine that the processes run simultaneously – but on a uniprocessor machine, this illusion is maintained by interleaving them: we say that they run concurrently, making progress at different rates.

For example, we could start to design a program that both shows a beating heart and prints primes on the serial port. These two activities are independent of each other, and we would like to write two routines that describe the two activities. Here's the natural way of finding and printing the primes:

static void prime_task(int arg) {
    int n = 2, count = 0;

    while (1) {
        if (prime(n)) {
            count++;
            printf("prime(%d) = %d\n", count, n);
        }
        n++;
    }
}

And here's the natural way of writing the heart process:

static void heart_task(int arg) {
    GPIO_DIRSET = 0xfff0;

    while (1) {
        show(heart, 70);
        show(small, 10);
        show(heart, 10);
        show(small, 10);
    }
}

(Note that each of these functions takes an integer parameter arg that can be specified when the process is started, but that both these particular processes ignore their parameters.) The idea is that the one processor on the board will run these two programs in turn, stopping each of them when they need to wait for something and running the other one for a while.

Many operating systems multiplex the processes in a way that's based on time-slices: the processor has a clock that measures actual time, and it lets each process in turn run for 50 millisec (say) before moving on the next one. At least initially, we will do without this idea, and run each process until it can make no more progress. In the case of the two processes in the example program, the primes-finding process will need to pause when it wants to output a character, and the heart display pauses explicitly to wait until it is time to change the image. In both cases, the pause happens in a subroutine called from the main routine of the process, and (as we shall see later) occurs by means of sending a message to another process, or waiting to receive one. The operating system arranges all the running processes in a circle, giving each a chance to run until it pauses, then moving on to the next one. There's no guarantee that each process gets the same fraction of the processor time, just as there's no guarantee when you pass round a bag of sweets that each person will take just one. Actually, as we'll see, the processes we write will usually run for only a short time before needing to wait for something, and at that stage the processor gets assigned to a different task. There's a system call yield() that a process can use to give up the processor voluntarily, but it is rarely needed in the actual programs we will write, because the processes we design will pause naturally.

We can already notice that both the processes we have written call subroutines: primes_task calls prime, and that will call a subroutine to perform integer division, and it calls printf, which in turn calls a version of serial_putc. Equally, heart_task calls show, which presumably sets a pattern on the LEDs and then delays for a time. So if these two processes are going to run concurrently, one single subroutine stack at the top of memory isn't going to be enough. In fact, a vital part of the implementation of the operating system will be to provide a separate stack for each process, and switching between processes will involve resetting the stack pointer so that each process uses its own stack. The one original stack will remain in existence, and will be used by the operating system itself.

Microbian supports programs that contain a fixed number of processes, all created when the program starts and usually all running forever. The function init that, up to now, has been the 'main program' in each application, will now become very simple: it creates a number of processes then returns, and it is after init has returned that the real work begins: micro:bian takes the processes that have been created and starts to run them in turn, and that is the entire work of the program. You may think of your program as having a 'main' process if you like, but micro:bian doesn't, and treats all processes alike, running them when they are ready and letting them wait when they are not. here's the init function for the heart--primes application (edited):

void init(void) {
    SERIAL = start("Serial", serial_task, 0, STACK);
    TIMER = start("Timer", timer_task, 0, STACK);
    start("Heart", heart_task, 0, STACK);
    start("Prime", prime_task, 0, STACK);
}

As you can see, this calls start four times to start four processes – one each for the heart and the primes activities, and also two other processes that (as we'll see later) look after the UART and a timer. The start() function returns a small integer id that (as we'll see later) is used to identify it when sending or receiving messages; the process IDs for the serial and timer processes are saved for later use. Each process has a name, used only for debugging, a function that's called as the body of the process, and argument (all of them zero in this program) that's passed as the argument of the function, and a set amount of stack space. The constant STACK provides a decent default of 1kB for processes that don't call any deeply recursive functions. micro:bian will let us measure the amount of stack space actually used by a process so that we can trim these values later.

Simplicity: there is a fixed number of processes, all created before concurrent execution begins. Scheduling is voluntary and (apart from interrupts) non-preemptive.

Context

Bigger operating systems do a lot more than simply manage a collection of concurrent processes, valuable though that function is. Typically, an operating system will support ...
  • Processes, with a time-based scheduling scheme that supports dynamic priorities, so each process gets a fair share of the CPU in the medium term.
  • Memory management with protection, so one process cannot read or write the areas of memory assigned to another, and processes that are idle can be (partially) stored on disk to save RAM space.
  • I/O devices, so different kinds of disk present a uniform interface for programming, and programs can get ASCII characters from a keyboard rather than hardware-dependent scan codes.
  • A file system, so the fixed blocks of storage provided by the disks can be organised into larger files, arranged in a hierarchical structure of directories.
  • Networking, so client programs can be written in terms of connections between processes on different machines, not in terms of individual network packets.

We have very little of these.

And installing an operating system typically means installing a collection of utility programs, shared libraries, a GUI environment, and other things that are not properly part of the operating system itself.

Messages

Note: Update needed for changed micro:bian API.

So far, we've seen how to create concurrent processes that run independently of each other. Things get much more interesting if we allow the processes to communicate, so that they can work together. We've already had a hint that hardware devices will be looked after by their own driver processes like serial_task and timer_task. But let's start easily by making a pair of ordinary processes that talk to each other by sending and receiving messages. One will generate a stream of primes, and the other will format and print them – or even better, let's make the second process find out how many primes are less than 1000, 2000, ... It's no accident that the structure of this program is reminiscent of Haskell programs, like map show primes, that work with lazy streams.

Here's the code for a process that generates primes:

void prime_task(int arg) {
    int n = 2;
    message m;

    while (1) {
        if (prime(n)) {
            m.int1 = n;
            send(USEPRIME, PRIME, &m);
        }
        n++;
    }
}

The process tests each number, starting at 2, and if a number is prime it sends a message to another process with the id USEPRIME. To send a message, a process needs a message buffer of type message. It optionally fills in the data fields of the message buffer with information: in this case, we put a newly-discovered prime in the int1 field of the message, and leave other fields like int2 and ptr3 unset. Then there's a call to send, naming USEPRIME as the recipient of the message, and the constant PRIME to identify the type of message: it will be put in the type field of the message as it's delivered.

Here's code for another process that will run under the id USEPRIME.

void summary_task(int arg) {
    int count = 0, limit = arg;
    message m;

    while (1) {
        receive(PRIME, &m);
        assert(m.sender == GENPRIME && m.type == PRIME);

        while (m.int1 >= limit) {
            printf("There are %d primes less than %d\n",
                   count, limit);
            limit += arg;
        }

        count++;
    }
}

void init(void) {
    ...
    GENPRIME = start("GenPrime", prime_task, 0, STACK);
    USEPRIME = start("UsePrime", summary_task, 1000, STACK);
}

(For the sake of the example, I've made use of the integer argument arg to set the interval between lines of output. More commonly, the argument is used to allow multiple processes that run the same code, but behave slightly differently.)

As you can see, this receives PRIME messages sent by the GENPRIME process shown above. Again we need a message buffer to contain the message that we have received. We can specify in the RECEIVE call what type of message the process will accept, or we can write the special value ANY to allow any type of message. When the message arrives, it is stamped by the postman with the identity of the sender (so that we could reply to the same process), and the type and int1 (etc.) fields are as specified by the sender. So in the example, successive messages will have int1 fields that are successive primes in ascending order. The process counts how many are less than each multiple of its argument, and prints a summary on the serial output.

When a process tries to receive a message from another, naturally enough it must wait until the sender is ready to send a message. But for simplicity, micro:bian does a complementary thing in the other direction – when a process wants to send, it must wait until the other process wants to receive. Messages are not buffered, there is no 'mailbox' where they can sit until they are collected: instead, a message is passed from the hand of the sender directly to the hand of the receiver. If you want a different behaviour, it's possible (and easy) to program it, putting a buffer process between the sender and the receiver that can receive messages from one, store them, and pass them on to the other when it is ready for them.

If a process wants to send or receive and its counterpart is not ready, then the process cannot run any more, and the operating system picks another process to run. This explains why yield() is rarely needed: if a program contains multiple processes that are constantly communicating with each other, there are plenty of points where the scheduler can switch from one process to another. After a message has been passed from sender to receiver, both are ready to continue, and the scheduler can pick either of them, or a completely different process, to run next.

Apart from the direction of information flow, there is another asymmetry between send and receive, in that a call to send must specify which type of message to send, whereas a call to receive can specify ANY and allow messages of any type. If the message is a request for something, say the current time, then it's common to write client = m.sender and later send(client, REPLY, &m) to send back the result of the request. There's also a notation sendrec(server, type, &m) that behaves like a send() followed by a receive() that waits for a REPLY message.

Simplicity: messages have a fixed format for the whole system. Message passing is synchronous: if the receiver is not waiting the receive, then the sender must wait.

Lecture 13