Phōs (Digital Systems)

From Spivey's Corner
Jump to: navigation, search

Phōs (Φῶς) is a very simple operating system for embedded devices, supporting families of independent processes that communicate by passing messages, and are scheduled non-preemptively. The inter-process communication mechanism is modelled on that provided by Minix, a lightweight implementation of Unix written by Andrew Tanenbaum and others.

Phōs presently runs on ARM-based microcontrollers, especially those supported by the Mbed platform, though Phōs and the Mbed libraries share no code. It is written mostly in C, supported by fragments of assembly language for process switching.

Another page has unix-style manual pages for each system call, and yet another gives details of the device drivers provided for peripherals on the micro:bit board.


A Phōs application consists of a fixed number of processes that start when the application boots and run forever. There is a main program init that starts the processes, each associated with a function that never returns; after creating the processes, the main program exits. Following this, the entire action of the application happens by interleaved execution of the processes. Each process has an ID, a small integer that identifies it as a source or destination of messages.

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

The actions of the process are defined by a function body whose address is passed as an argument to start. There is provision to pass a single integer argument to the function, so that several processes may conveniently run the same function with a different argument in each. The operating system allocates a specified amount of stack space for each process. The process name is used only for debugging; it doesn't matter if several processes have the same name, but each must have a different pid.

A predefined constant STACK gives a handy default stack space (typically 1kB), suitable for starting a handful of processes, none of which contain deeply nested calls. After testing the program, the stack sizes can be adjusted to match the space shown as used in a process dump (see below).

Process id's are small integers in a predefined range [0..NPROC), where NPROC = 16 for the micro:bit installation of Phōs. Process 0 is the idle process, and ids SERIAL = 1, TIMER = 2, ... are reserved for system tasks. User processes are conventionally numbered USER+0, USER+1, ..., where USER = 8. (It doesn't matter if there are gaps in the sequence of process ids in use.)


First boot

Processes can communicate with each other by passing messages.

Messages are small, and have one a few fixed formats, but can include pointers to larger objects such as strings. Processes must be written so that concurrent access to such shared data objects is avoided.

typedef struct {
    unsigned short m_type;
    short m_sender;
    union {
        int m_i;
        char *m_p;
        struct {
            unsigned char m_bw, m_bx, m_by, m_bz;
        } m_b;
    } m_x1, m_x2, m_x3;
} message;

A message has a type, which may be any small integer and identifies, within a particular program, the format used for the rest of the message. The field m_sender is the pid of the process that sent the message; this is filled in by Phōs when the message is delivered. The rest of the message contains three words of data, each of which may be used to hold an integer, a pointer, or a pair of bytes. For convenience, a number of macros are defined, so that it is possible to write m.m_i1 in place of m.m_x1.m_i and m.m_c2 in place of m.m_x1.m_b.m_x.

#define m_i1 m_x1.m_i
#define m_i2 m_x2.m_i
#define m_i3 m_x3.m_i
#define m_p1 m_x1.m_p
#define m_p2 m_x2.m_p
#define m_p3 m_x3.m_p
#define m_b1 m_x1.m_b.m_bw
#define m_b2 m_x1.m_b.m_bx

To send message to process dst, a process uses a local variable msg of type message, and fills in at least the field m.m_type, and optionally some of the data fields. It then calls the operating system function send(dst, &msg), passing the address of the message variable.

void send(int dst, message *m);

To receive a message, a process again uses a local variable msg, and invokes receive(src, &msg), where src is either a process ID or the special value ANY. Afterwards, the message variable msg will contain a copy of the message that was received.

void receive(int src, message *m);

Transmission of messages is synchronous and atomic, so that a process A that wants to send to another process B must wait until B wants to receive a message. After the call to send has returned, A can be sure that B has received the message.

For convenience and efficiency (in some circumstances it saves a context switch), invoking sendrec(dst, &msg) is equivalent to a send to process dst followed by a receive from the same process, a kind of remote procedure call. So a process might send a string str to be printed by

msg.m_type = PRINTP;
msg.m_p1 = str;
sendrec(PRINTER, &msg);
if (msg.m_type != OK) panic();

The use of sendrec here causes the process to wait until the PRINTER process has finished with the string, making sure that the sending process does not overwrite the buffer before the characters are copied. The PRINTER process must, of course, be written so as to send an acknowledgement after copying the characters.


A process can choose to associate itself with one or more interrupts. After it has done so, each interrupt event is converted into a message from a fictitious process HARDWARE and sent to the process like other messages.

To connect to an interrupt source irq, identified by a number in the range [0..32), a process should call

void connect(int irq);

This automatically raises the priority of the process, so that it will be scheduled in place of ordinary processes when both are ready. Calling connect also automatically enables the interupt, but the calling process must additionally set up the relevant peripheral so that it generates interrupts for significant events.

By default, all interrupts are handled by the same function, which temporarily disables the interrupt, then sends an INTERRUPT message to the connected process. If the process is waiting for a message from HARDWARE, then it begins to run immediately. Otherwise, the interrupt is queued and a message will be delivered next time the process is ready for it. As well as responding to the event, the process should clear and re-enable the interrupt so that it can be informed the next time an event occurs. It can do this by calling the function

void reconnect(int irq);

With the default handler, the interrupt is disabled, so that no more interrupts can occur before the message has been delivered. By substituing a different interrupt handler, it is possible to avoid disabling the interrupt, but even then, at most one interrupt message may be queued for each process at any time, so it multiple interrupts occur before the process receives the message, then the second and subsequent interrupts are lost. Driver processes should be written with this in mind: in practice, it more effective to respond to all events in response to one message than to receive multiple messages that have been queued.

Other system calls[edit]

A process may call yield() in order to pause voluntarily and allow other processes to run.

void yield(void);

Calls to yield should not be needed even in long-running processes, because they will be suspended automatically when an interrupt arrives. However, yield is used internally in Phōs to invoke the process scheduler when the system starts.

A process may call exit() to suspend itself in such a way that it will never run again.

void exit(void);

A call to exit implicitly follows the function call that forms the body of a process, so that it the function returns, the process exits just as if exit() had been called as its last action.

Each process has a priority between 0 and 3, with 0 (the most urgent) reserved for processes connected to interrupts, and priority 3 (the least urgent) reserved for the idle process. Other processes can set their own priority to 1 or 2 by calling setprio(p).

void setprio(int prio)

The allowable priorities are HI_PRIO = 1 and LO_PRIO = 2. Processes that are not connected to interrupts have priority 2 by default. In some programs, it is possible to improve responsiveness by setting the priority to 1 for carefully selected processes that respond to events, leaving long-running background processes at priority 2.




The standard UART driver process calls dump when you type Ctrl-B on the keyboard.

A symbolic representation of the machine code for a program.

(Universal Asynchronous Receiver/Transmitter). A peripheral interface that is able to send and receive characters serially, commonly used in the past for communication between a computer and an attached terminal. It is commonly used in duplex mode, with the transmitter of one device connected to the receiver of the other with one wire, and the receiver of the one connected to transmitter of the other with a different wire. The asynchronous part of the name refers to the fact that the transmitter and receiver on each wire do not share a common clock, but rely instead on the signalling protocol and precise timing to achieve synchronisation.