Introducing micro:bian

From Bare Metal micro:bit
Jump to navigation Jump to search


Programs that use interrupts to deal with events reveal that often several things are happening at once: in Experiment 11, the program is searching for prime numbers, while at the same time it is feeding characters to the UART under control of a handler for the UART interrupt. We can imagine extending the program so that it also shows an animation of the LEDs as it runs, and we will make a program that does that in Experiment 14. Although they make it possible to write programs that respond to external events, interrupts don't make it easy, because they force on us a structure where there is one main program, and a collection of one or more interrupt handlers that amount to subroutines that are called from time to time in response to events. That structure makes it hard to build programs with complex behaviours: if we want a program that both does a calculation and keeps the display up to date, it's not at all clear whether the main program should focus on the calculation or on stepping through the frames of the animation. Neither of these tasks is easy to turn into a subroutine of the other one.

A solution to this problem is to introduce a tiny 'embedded operating system' that is able to support multiple processes. Many such operating systems designed for embedded applications exist, but for this book I have written one that makes programming as easy as possible, and named it micro:bian. Like nearly all embedded operating systems, micro:bian allows a client program to have multiple processes that run independently of each other, but can communicate and collaborate. The micro:bit has a single processor and can run only one of the processes at a time, but the operating system arranges that when one process stops to wait for an event, the other processes in the program get the chance to run for a while, so that all the processes are allowed to make progress. This facility for multiple processes is exploited in Experiment 14, where one process looks after showing an animation on the LEDs while another process searches for prime numbers to print. Whenever the animation process pauses to wait between frames of the animation, the prime-finding process gets the chance to run for a while – and crucially, each process is able to follow its own sequence of actions independently of the other.

Processes in micro:bian collaborate by exchanging small messages, using functions send() and receive() that the operating system provides. When a process P wants to send a process Q, it fills in appropriate fields of a local message buffer m1,(including a field m1.m_type, a small integer that denotes the type of message being sent. Then P calls send(Q, &m1), passing to the operating system the identity of process Q, and the address the message buffer. Process P must now wait until process Q calls the operating system function receive(), and then the operating system delivers the message and lets both processes continue, scheduling them in turn with all the other processes that are able to run. Process Q calls receive(t, &m2), where t selects what types of message Q is willing to receive (either a specific type, or the magic value ANY for any type of message); the parameter &m2 is the address of a message buffer local to Q, and the fields of P's buffer m1 are copied into m2, with the field m2.sender filled in with the identity of P. If process Q has already called receive() before process P calls send(), then process Q has to wait until another process – in this case P – wants to send to it. In every case, both the sender and the receiver are momentarily stopped together for the message to be transferred, in what it called a rendezvous. In addition to the type and sender fields, a message has space for a couple of integer or pointer values that are transmitted from one process to the other.

A simple example of two processes P and Q communicating is when process P produces a stream of data that process Q consumes. Experiment 15 is an example of this, where for familiarity's sake the first process, PRODUCER, generates the sequence of prime numbers, but instead of printing them, it sends each one in a message to another process, CONSUMER. The consumer process receives the stream of prime numbers, and counts how many of them are less than 10 000, how many of them less than 20 000, and so on, printing its conclusions over the UART.

To send a message containing the prime p, the producer process allocates a message buffer m1, and then does this:

m1.type = PRIME;
m1.int1 = p;
send(CONSUMER, &m1);

This sets the type of the message to the integer constant PRIME, and one of the data fields of the message – the first integer field int1 – to the data that should be sent, then indicates to the operating system that it wants to send the message to the CONSUMER process. In its turn, the consumer process has its own message buffer m2 and uses this code:

receive(PRIME, &m2);
q = m2.int1;

This indicates that the process would like to receive a message of type PRIME. Each process waits until the other one is ready, then the operating system copies the message across, filling in the sender field with the process id CONSUMER. Subsequently, the consumer process can retrieve the int1 field of the message, which contains – by mutual agreement between the producer and consumer – the next prime number in sequence. It could also access the sender field to discover which process sent the message, and the type field to find out the message type, which in this case will be PRIME. In addition to asking to receive a specific type of message, a process can also call receive(ANY, &m2) to accept any type of message, and then the type field shows what type of message was actually received.

It would be quite easy to design a single process that generated primes and counted them as it went along, but that is not the point here. In the program, we could replace the producer process with one that found primes more efficiently, or replace the consumer process with one that looked for some more interesting pattern in the stream of primes, in each case without needing to change the other one at all.

Experiment 16 introduces the idea that, as well as communicating data, messages can be used to synchronise activities. Several processes try simultaneously to print slogans on the serial port, and predictably enough the messages become garbled, as fragments of the slogan from one process become interleaved with fragments from another. A solution is to introduce a coordinating process, so that either the original processes send their slogans in messages to the coordinator for printing (and it completes printing one slogan before accepting another one), or the processes obey a protocol where they request permission to print, do their printing, and then signal to the coordinator that they have finished (and the coordinator does not give out permission again until the previous permission has been relinquished). Both solutions are explored in the experiment.

Processes and messages in micro:bian really come into their own when they are used to implement drivers for I/O devices. The key idea is that an interrupt is a message from the hardware. A process can associate itself with a hardware interrupt request by calling the operating system function connect(), and from that point on, any interrupts from that device will automatically turn into messages sent to the process, appearing to have type INTERRUPT and come from a fictitious process HARDWARE. Experiment 17 explores this idea with a simple device driver for output on the UART, again accepting and printing the sequence of primes. The micro:bian operating system itself has a driver for the UART that handles both output and input with line editing, and this driver, together with a driver for a hardware timer, have implicitly been part of the programs used in earlier experiments – so that even the very first micro:bian program in Experiment 14 in fact contained four processes.

Later experiments in this part of the book contain drivers for some of the other I/O devices present on the micro:bit. Experiment 18 introduces a driver for the I2C bus, and uses it to access the accelerometer on the board and implement a 2D spirit level. Experiment 19 contains a special-purpose driver for one of the timer peripherals that makes it possible to control servo motors, and Experiment 20 uses a driver for the built-in radio device to allow remote communication between micro:bits; these are combined in Template:XRef to build software for a remote-controlled car.

Comparison with other operating systems

It's worth mentioning a couple of simplifying assumptions made in the design of micro:bian that are not shared by other embedded operating systems. One is that all processes will be created at the very start of the program, before any of the processes start to run. To this end, the function init() that, up to now, has been the 'main program' of an application changes its purpose: now it will create the processes that make up the live program, and after init() has returned, only then does the operating system start to run the processes. It is possible for processes to finish, but once a process exits it is gone forever, and new processes cannot be created as the program runs. This assumption frees the operating system from having to keep track of memory that has been freed when a process exits, so as to use it for processes that will be created later. In our programs, almost all processes will loop forever, often accepting a request of some kind from another procress, carrying out the request and sending a reply, before looping to accept another request.

Another simplifying assumption is that all synchronisation between processes is done using messages. Other embedded operating systems offer other synchronisation mechanisms, such as 'events' or 'semaphores', that are potentially more efficient; and they often deal with interrupts by encouraging interrupt handlers to be specially written for each device, with the interrupt handler able to communicate with operating system processes using some (usually restricted) set of operations on semaphores or events. The default in micro:bian is to turn every interrupt into a message. This simplifies the design of driver processes, which can safely interleave handling interrupts with servicing client requests. But it does significantly slow down the reaction of the program to an interrupt event. Usually there is enough spare time that this is not a problem, but in extreme cases it is possible to install a special interrupt handler and do at least some of the work of handling the interrupt there in a more timely fashion.

The thrid simplifying factor in micro:bian is that it implements pre-emptive scheduling but not time-based scheduling. By pre-emptive scheduling, we mean that when higher-priority processes are ready to run, lower priority processes may be suspended in their favour. micro:bian provides this, implicitly giving device drivers a higher priority than other processes, but also allowing the priority of any process to be raised explicitly. What micro:bian does not do is keep track of the length of time each process has been running and suspend the current process if it has occupied the processor for a certain time. The assumption is that there is enough CPU time to carry out all the tasks the processor must perform between one interrupt and the next. This is true of many embedded systems, but the simple scheduling policy would not suit so well a system in which there are two or more CPU-intensive tasks that should all get a fair share of time.

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 name 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 the internal structure of Andy Tanenbaum's operating system Minix, and 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 behind the scenes on almost every mobile phone.

The remainder of this chapter introduces the main features of {{microbian}; it may be best to read it in parallel with exploring the first few experiments that follow. Appendix E contains a brief programmer's manual.

Processes

A micro:bian program consists of one or more processes. The behaviour of each process is decscribed by a C function called its body. For example, here is the body of a process that forever prints "I'm alive!":

void alive(int arg)
{
    while (1) {
        printf("I'm alive!\n");
    }
}

As you can see, the body of a process is a function that accepts a single integer argument: in many cases, the argument is ignored, but it can be helpful when several processes are started with the same body but different arguments, as is allowed. The return type of the body is void, and in fact this process (like most others) never returns but contains an infinite loop.

The program also contains a function init whose job is to create the processes. In previous programs, init has done all the work of te program, and has not returned (if at all) until all the action is finished. Now it is going do a different job, setting the stage but not doing any of the work itself. An init function for our 'I'm alive!' program might look like this:

void init(void)
{
    start("Alive", alive, 0, STACK);
    serial_init();
}

This starts explicitly a process named Alive that runs the function alive we just looked at: the function receives the integer 0 as its argument and is assigned the default amount of space STACK for its subroutine stack – about 1kB. It also calls a function serial_init to activate micro:bian's driver for the serial port; that also creates a process by calling start appropriately. Each process has a name ("Alive" in this case) that's used in crash reports and when micro:bian prints a table of processes and their states. The name is not otherwise used, and nothing bad happens if two processes have the same name.

After getting a program working, it's possible to get micro:bian to report the amount of stack space actually used by each process, and trim the allocations accordingly, but STACK provides a useful default for processes that don't involve deeply nested subroutine calls – or recursion, which amounts to the same thing.

The start function has heading

int start(char *name, void (*body)(int), int arg, int stack)

It returns an integer known as the process id or pid that can be used to send messages to the process. In the example above, the alive process sends messages but doesn't need to receive them (or receives only replies), so there is no need to save the pid. In other examples, we will keep the pid of each process in a global variable so that others can send it requests.

When init returns, the operating system's process scheduler starts to run the processes it has created, letting each one run until it can get no further, usually because it needs to wait to communicate with another process. We have no direct control over the order in which processes run, but it is easy to design programs so that this doesn't matter. In the example program, either the alive process will run first, in which case it will get as far as needing to send to the serial driver process a request to print a string; or the serial driver process will run first, will initialise the UART, and continue until it reaches the point where it is waiting for a request to come in. In either case, once the first process gets as far as it can, the other process runs next, and they both get to a point where the printing request can be transmitted from one process to the other. Thereafter, they run alternately, with the alive process generating further priting requests (always with the same string of characters), and the serial driver process accepting requests and sending the characters out over the UART.

In the example, neither process ever terminates, but if a process does finish (by returning from the body, or by calling the system function exit), the other processes in the program continue to run. If all processes in the program exit, then the program stops (or more accurately, a secret internal idle process remains in an infinite loop).

Messages

Processes can communicate with each other by sending and receiving messages. A message is a 16-byte structure that identifies a sending process and a message type, and has three fields that can each hold an integer or a pointer. The type message is defined in microbian.h as follows.

typedef struct {                /* 16 bytes */
    unsigned short type;        /* Type of message */
    unsigned short sender;      /* PID of sender */
    union {                     /* An integer, a pointer, or four bytes */
        int int1; void *ptr1;
        struct { byte byte1, byte2, byte3, byte4; };
    };
    union { int int2; void *ptr2; }; /* Another integer or pointer */
    union { int int3; void *ptr3; }; /* A third integer or pointer */
} message;

A message m has a field m.type that describes what kind of message it is; the recipient should be able to deduce the layout of the rest of the message from its type field. There is also a field m.sender the identifies (by pid) the process that sent the message. This field is filled in by the operating system when it delivers a message, and is useful (among other reasons) because it allows a server process accept a request message from any client and send a reply to the client without knowing about all possible clients in advance.

The remaining fields are wrapped in (anonymous) union types, so that the message has space for up to three integers m.int1, m.int2 and m.int3, or up to three pointers m.ptr1, m.ptr2 and m.ptr3, or any mixture of integers and pointers. An integer field like m.int2 shares the same space and the pointer m.ptr2, so both cannot be used at once. Programs will have an internal convention about the meaning of the m.type field that determines which of the other fields are used and that they contain. Alongside the integers and pointers, a message can contain up to four single bytes, m.byte1 to m.byte4 that share space with the integer m.int1 and the pointer m.ptr1. These four alternative fields give just enough flexibility to send the most complicated message we shall need, an I2C request that contains three small integers and two pointers.

To send or receive messages, we must use the operating system functions send and receive. For example, here is a function serial_putc that any process can call in order to send to the serial driver process a request to print a character (taken from serial.c in the micro:bian source).

void serial_putc(char c)
{
    message m1;
    m1.int1 = c;
    send(SERIAL, PUTC, &m1);
}

This first declares a variable of type message which will be part of the stack space of the process that calls serial_putc. That is OK, because the message will be copied when it is delivered. The function fills in the int1 field with the character to be printed; this works because characters are just small integers in C. Then there is a call to the function send, specifying the pid SERIAL of the driver process for the serial port (saved when the driver process was created), the predefined message type PUTC, and the message buffer m containing the rest of the message. We write &m to pass to send the address of the message buffer, because that fits with the conventions of C. The function send has heading

void send(int rcvr, int type, message *msg)

so it is expecting a pointer to a message buffer. In the example, this message buffer has filled in the int1 field containing the character to be printed, and the type and sender fields will be filled in at the moment the message is delivered. Some messages have just a sender and a type but no additional data, and as a convenience the msg parameter to send can be NULL to indicate that there is no data to send.

Send is not implemented like an ordinary subroutine, but is an entry into the operating system. If the receiving process (with pid SERIAL) is ready to receive a message, then the message will be transferred immediately, but if not, the operating system notes the fact that this process wants to send a message to SERIAL, suspends it, and runs a different process instead, until such a time as the serial driver process is willing to receive a message.

The counterpart to send and receive, and the serial driver process contains code like this:

void serial_task(int arg)
{
    message m2;
    char ch;

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

        switch (m2.type) {
        case PUTC:
            ch = m2.int1;
            ...

        }
    }
}

Like many server processes, this one contains an infinite loop with a call to receive at the top. The arguments to receive are a message type (in case the wildcard ANY) and the address of a message buffer belonging to the server process.

When another process is ready to send to SERIAL and the serial driver is ready to receive, the operating system transfers the message all at once from the message buffer m1 belonging to the sender to the buffer m2 belonging to the receiver, filling in the type and sender fields, and copying across the other message fields, including in our example the field int1 that contains the character to be printed. Then it revives both the sending and the receiving process, so that both can proceed; one or other of them may immediately be chosen to run next, but both will eventually have their turn.

When it runs again, the server process examines the message type m2.type, and seeing that it is PUTC, retrieves the character from the message field m2.int1. Meanwhile, the call to send from the client process returns, and the function serial_putc also returns. The client process can now get on with deciding what character it would like to print next.

As you will understand, it is important for sender and receiver to agree on the layout of data in the message. The slightly inexpressive type system of C does not help much with this; what is more helpful is to provide, alongside a server process like serial_task, a collection of subroutines like serial_putc that formulate and send various requests. These subroutines can be called from a client process, will put together a message in the stack space of the calling process, then will call send to transfer the message to the server process.

Although the variables m1 and m2 were given different names to help the discussion, they are in fact local variables of different subroutines in the program, and it would do no harm (and cause no confusion to the machine) if they were given the same name. The operating system knows their addresses, one in the stack space of the client process and one in the stack space of the server. It copies the message across before reviving the two processes, and because the message is copied, any future changes to the sender's message buffer do not affect the contents of the receiver's buffer. Also, it does not matter if the sender's copy is immediately destroyed when the sender runs again a subroutine like serial_putc returns, because the message has already been copied to the receiver.

Interrupts

Where do interrupts fit into this scheme of processes and messages? The simple answer is that at the lowest level micro:bian turns each interrupt into a message from the hardware. A process can connect itself to an interrupt be calling the operating system function connect:

connect(UART_IRQ);

After that, whenever the appropriate interrupt is requested, the process receives a message with the sender field set the the special value HARDWARE and the type field set to INTERRUPT. The driver process can receive these messages by having a server loop with

receive(ANY, &m);

at the top, and receive INTERRUPT messages together with client requests, taking action on each and updating its state as appropriate. It can also use

receive(INTERRUPT, NULL);

to wait for just an interrupt, and hold off futher client requests. This is appropriate if the interrupt indicates the end of a hardware action that must complete before the driver can accept another client request.

The nature of the interrupt mechanism on the ARM chips affects the details of this scheme. If the interrupt handler returns without removing the cause of the interrupt, then the interrupt will immediately occur again, causing an infinite loop from which there is no escape. So micro:bian's default interrupt handler disables in the NVIC the interrupt that invoked it, before sending an INTERRUPT message to the registered driver process. It does not examine the individual event registers for the device, and when the interrupt handler returns, the interrupt controller will make the interrupt be pending again. Thus when the driver process receives the INTERRUPT message it will find:

  • one or more device events are active (e.g., UART.TXDRDY == 1).
  • the associated interrupt is pending.
  • the interrupt is disabled.

In order to deal with the interrupt and be ready for the next one, the driver process must therefore do the following things in order.

  • identify the device event and clear it (e.g., UART.TXDRDY = 0).
  • clear the pending bit for the interrupt (e.g., clear_pending(UART_IRQ)).
  • re-enable the interrupt (e.g., enable_irq(UART_IRQ)).

Doing these things in the wrong order can lead to a spurious interrupt.

The default interrupt handler looks like this (error checking omitted):

void default_handler(void)
{
    int irq = active_irq();
    int task = os_handler[irq];
    disable_irq(irq);
    interrupt(task);
}

Here active_irq accesses a system register to discover which interrupt is active. The pid of the correspinding task is found by looking up the interrupt in a table, with values filled in by the connect system call. The handler disables the interrupt, then uses the operating system function interrupt to send an INTERRUPT message to the task. If the task is ready to receive the message, then it is delivered immediately; otherwise a flag is set for the process that allows the message to be delivered next time the task is able to receive it. Only one pending interrupt message is saved in this way, so that if multiple interrupts arrive, they are merged into one.

It is possible to install special-purpose interrupt handlers in place of the default handler, by the usual mechanism of defining a global function with a name such as uart_handler that is listed in the vector table given in startup.c. The handler may deal with the interrupt completely, or it may send a message to a driver process by calling the interrupt system function. Other operating system functions should generally not be called from interrupt handlers.

Priorities

With micro:bian, interrupts from hardware devices become messages. Interrupt handlers are relatively short, so it matters little whether one interrupt handler can be interrupted by another one. Consequently, micro:bian programs tend to leave all interrupts with their default priority, which is the highest of all, numbered zero. The micro:bian kernel does not need to disable interrupts when it manipulates process queues, because it also runs at the highest priority. Not using interrupt priorities allows us to also to avoid the confusion surrounding the numbering of priority levels on Cortex-M microcontorollers.

Although it does not encourage the use of interrupt priorities, micro:bian does provide a simple system of priorities for processes. There are four priority levels:

  • Priority 0 (P_HANDLER) is for processes that are registered as device driver.
  • Priorities 1 (P_HIGH) and 2 (P_LOW) are for normal processes.
  • Priority 3 (P_IDLE) is for the hidden, idle process.

The operating system maintains three queues of processes that are ready to run, one for each priority from 0 to 2; level 3 always contains just the idle process, which is always ready to run, so no queue is needed. Whenever a process suspends, and whenever an interrupt happens, the operating system chooses the next process to run from the lowest-numbered non-empty queue.

All user processes start in level 2. Processes that use connect to register for INTERRUPT messages are moved to level 0. Other processes can put themselves in level 1 or 0 by calling priority(P_HIGH) or priority(P_HANDLER), and can put themselves back in level 2 by calling priority(P_LOW).

void priority(int p)

Generally speaking, it's a mistake to try to influence the functioning of a program by manipulating process priorities as the program runs. Just occasionally, it's a good idea to make a process increase its priority if it does a job that is urgent but requires little CPU time. In Experiment X3000, the process that looks after the micro:bit display does this for reasons that are explored in the experiment.

Process table

The operating system maintains a table of processes, each with a state such as Active, Receiving or Sending. It's possible to get micro:bian to print the table on the serial port by calling the function

void dump(void)

If the standard serial driver is active, the process table can also be printed by typing Control-B on the keyboard. The printed output contains a list of processes, each with its current state and also an estimate of the greatest amount of its stack space that it has ever occupied. This estimate is calculated by filling the stack space of each process with an identifiable pattern before the process starts, and scanning the stack space to find out how much of it has been overwritten with actual data. These estimates are useful when you want to trim the amount of space allocated to each process: initially, all processes can be given the default space STACK, then a process dump can be used to reduce the space so that each process has just what it needs. Additionally, the operating system kernel checks the last word of the stack space whenever a process stops to ensure that it has not overflowed its allocated stack space. The hardware does not provide the mechanisms needed to detect stack overflow as soon as it happens, but we can at least check for it after the fact. It's of course possible that a program that enters an infinite recursion will overwrite all of memory before operating system has chance to check, but it can still do its best.

For printing like this, and for the panic messages that appear with the Seven Stars of Death, the operating system has a completely separate, non-interrupt driven driver for the UART. Ordinary processes can invoke this driver by calling the system function kprintf. Because the driver turns off all interrupts and uses polling, it is not suitable for general-purpose output, but it is useful for debugging. The driver tries to restore the UART to normal functioning when it has finished, but does not always succeed.

Questions

Why this home-grown micro:bian operating system, and not another, industry standard real-time operating system such as FreeRTOS? FreeRTOS is OK, and provides a rag-bag of facilities for multi-tasking, including processes, semaphores of different kinds, message queues, timers, and a lot more. It is quite complicated, and everything seems to have lots of options that interact in subtle ways. For the book, I wanted to find out how far we could get with a much simpler scheme, where there are no nested interrupts, possibly no preemptive scheduling, and the system is structured into a family of processes communicating by messages.

Why does micro:bian not have (i) time-based scheduling, (ii) nested interrupt handlers, (iii) an interruptible kernel? In a simple, interrupt-driven system that is not overloaded, most of the time every process is blocked waiting for something to happen, and the idle process is 'active', in the weak sense that it is the running process, but it has put the processor to sleep with a wait-for-interrupt instruction (wfe on the ARM). When an interrupt arrives, processes wake up, there is a flurry of activity, and then the system settles down to sleep again, and the time needed for that to happen is bounded. If the responses needed from the system as a result of an interrupt are no more urgent than this time-bound, then there is no need for pre-emptive scheduling (which does not change the total amount of work to be done, but just affects the order in which to tasks are carried out). And if we keep interrupt times short by doing no more than sending a message in each interrupt handler, then there will be little need to allow the running of one interrupt handler to be suspended in order to tun another. Likewise, if the time spent in delivering a message is short, it won't matter much if an interrupt is delayed until the kernel has finished.

There are well-developed theories of process scheduling that aim to ensure that complex sets of real-time deadlines are met using limited resources, and in circumstances where they are needed, these theories are really valuable. In simple cases, however, process scheduling is hard to get wrong, in the sense that if every process waiting for an event gets to run after the event has happened and before the system goes to sleep again, then everything will happen when it should and in good time. micro:bian is designed with these modest goals in mind.

If message-passing makes programming so much easier, why do widely-used embedded operating systems provide semaphores instead?} Programs written using semaphores have the potential to be faster than those written using message passing and server processes, because context switches from one process to another can be made much less frequent. For example, to buffer and print characters on the serial port, our message-based design requires about four context switches per character sent, in addition to the running of the UART interrupt handler. There is a context switch to send a request message to the UART driver process and put the character in the buffer, another to return to the main program, then two further context switches into and out of the driver process when a UART interrupt prompts it to send the character. By way of contrast, a semaphore-based solution might need no context switches at all, if characters are retrieved from the buffer and sent to the UART directly in the interrupt handler. This is because a down operation on an available semaphore only needs to disable interrupts for a moment to test and lock the semaphore, and need not block the invoking process. It's only when a process needs to block on a semaphore that a context switch is needed.

On the other hand, message-based concurrency is much easier to think about, and it makes it easier to avoid problems like priority inversion, where a high-priority process is prevented from making progress because a lower-priority process is occupying the resources it needs.

What is the point of having concurrent processes when the CPU can run only one process at a time? Concurrency may be introduced into programs for several reasons:

  1. So that the program may be run simultaneously on multiple processors to improve performance.
  2. So that the program can carry out multiple tasks simultaneously, or can respond to several sources of external events.
  3. So that the program can be structured in a more convenient way.

The first of these reasons applies only when the computer has mulitple processors (or multiple cores) and can really execute several parallel threads simultaneously. Otherwise, what appears to be parallel execution is really the sharing of a single processor among the processes in the program, giving the appearance that they all make independent progress, but really running each in bursts, interleaved with the other processes. We can use the term concurrency to encompass this sort of interleaving as well as true parallelism. Although it does not offer an improvement in performance (and in fact exacts an overhead in switching between processes), this form of concurrency nevertheless satisfies the second and third reasons listed above. A program with multiple independent processes can conveniently perform several different tasks at once, perhaps keeping a display updated while also responding to button presses, and so on. In a related way, a program that transforms a stream of data in several stages can be structured conveniently as a chain of processes, each carrying out some part of the transformation before passing on items of data to the next process in the chain. Building the program like this helps to keep the pieces independent of each other without requring the whole of the stream of data to be stored at once.

Why message passing? Why not a more conventional synchronisation mechanism, such as semaphores? And why not use an existing real-time operating system?


Additional material