From 63a6ef4160ff63d28ac6c911b836e65511bbadf5 Mon Sep 17 00:00:00 2001 From: komrebi32 Date: Wed, 30 Sep 2026 21:14:14 -0400 Subject: [PATCH 1/3] feat(ipc): add message ports, semaphores, and mutexes Fills in ipc/ so kernel tasks can pass messages and synchronise. IpcPort is a FIFO of fixed size messages. IpcSend waits while the port is full and IpcReceive waits while it is empty, with Try forms that never wait. A message is copied by value, so neither side holds a pointer into the other's memory. IpcSemaphore counts permits up to a ceiling, and IpcMutex passes ownership straight to the first waiter on unlock so a ready task cannot take the lock ahead of the one that was woken for it. A mutex is not recursive: locking one the caller already holds reports IpcErrOwner instead of parking on a lock nobody else can open. Every entry point returns IpcStatus, so a refused call says why rather than failing silently, and every call checks its arguments before touching memory. IpcPark registers the calling task and sleeps as one step under the interrupt lock. Splitting those two apart would let a waker take the entry, find the task still runnable, and drop the wakeup, leaving the task parked with nobody to wake it. Wait queues are keyed by task id, and a wakeup skips entries whose task was killed, since a dead task can never run again. The idle task cannot sleep, so a call that would have to wait reports IpcErrNoTask rather than parking, matching what timer_sleep_ms does. Adds ipc/ to the VPATH and its objects to KERNEL_OBJS. --- ipc/ipc.c | 141 ++++++++++++++++++++++++++++ ipc/ipc.h | 81 ++++++++++++++++- ipc/message.c | 224 ++++++++++++++++++++++++++++++++++++++++++++- ipc/message.h | 71 ++++++++++++++- ipc/sync.c | 247 +++++++++++++++++++++++++++++++++++++++++++++++++- ipc/sync.h | 66 +++++++++++++- 6 files changed, 825 insertions(+), 5 deletions(-) create mode 100644 ipc/ipc.c diff --git a/ipc/ipc.c b/ipc/ipc.c new file mode 100644 index 0000000..7b62cc8 --- /dev/null +++ b/ipc/ipc.c @@ -0,0 +1,141 @@ +#include "ipc.h" +#include + +u32 IpcSelfId(void) { + Task *Self = SchedCurrent(); + + if (Self == 0) { + return 0; + } + + return Self->Id; +} + +/* + * Parks the caller on Wait. Needs interrupts off for the whole queue + * bookkeeping, so the helpers below are the only thing allowed to touch a + * queue while it is asleep, and every waker goes through IpcWakeOne. + */ +static bool IpcWaitAdd(IpcWait *Wait, u32 Id) { + u32 Index; + + if (Wait->Count >= IpcMaxWaiters) { + return false; + } + + for (Index = 0; Index < Wait->Count; Index++) { + if (Wait->Ids[Index] == Id) { + return true; /* already parked, a second entry would only confuse a wake */ + } + } + + Wait->Ids[Wait->Count] = Id; + Wait->Count++; + return true; +} + +static void IpcWaitRemove(IpcWait *Wait, u32 Id) { + u32 Index; + + for (Index = 0; Index < Wait->Count; Index++) { + if (Wait->Ids[Index] == Id) { + /* Not worth keeping order for, and the queue is short. */ + Wait->Ids[Index] = Wait->Ids[Wait->Count - 1]; + Wait->Count--; + return; + } + } +} + +/* + * True when Id still names a task that is alive and can be woken. A killed + * task has its id cleared and a recycled descriptor gets a fresh id, so a + * single lookup is enough to rule out both. + */ +static bool IpcWaitIsLive(u32 Id) { + return Id != 0 && SchedFind(Id) != 0; +} + +u32 IpcWaitTakeLive(IpcWait *Wait) { + u32 Index; + + for (Index = 0; Index < Wait->Count; Index++) { + u32 Id = Wait->Ids[Index]; + + IpcWaitRemove(Wait, Id); + + if (Id != 0 && IpcWaitIsLive(Id)) { + return Id; + } + } + + return 0; +} + +IpcStatus IpcPark(IpcWait *Wait, IpcReadyFn Ready, void *arg) { + u32 Self = IpcSelfId(); + + if (Self == 0) { + /* The idle task cannot sleep, so it must not pretend it did. */ + return IpcErrNoTask; + } + + for (;;) { + bool was_enabled = cpu_irqs_enabled(); + + cpu_disable_irqs(); + + if (Ready(arg)) { + if (was_enabled) { + cpu_enable_irqs(); + } + return IpcOk; + } + + if (!IpcWaitAdd(Wait, Self)) { + if (was_enabled) { + cpu_enable_irqs(); + } + return IpcErrFull; + } + + /* + * The lock stays held from here into SchedBlock on purpose. Being on + * the queue and being blocked have to happen as one step: if the lock + * were dropped in between, a waker could take the entry, find the task + * still runnable, and its SchedWakeTask would do nothing, leaving the + * task to park afterwards with nobody left to wake it. + * + * Nothing else can run while the lock is held, since a task only + * changes at a SchedYield and those are reached from the interrupt + * epilogue. So the entry is on the queue before we are marked blocked, + * and Ready() was checked after the last thing that could set it. + * + * SchedBlock leaves the lock held for its caller: it restores the task + * eflags on the way back, which are clear here. + */ + SchedBlock(); + + /* Woken, or the scheduler found nothing to run and undid the block. */ + cpu_disable_irqs(); + IpcWaitRemove(Wait, Self); + if (was_enabled) { + cpu_enable_irqs(); + } + } +} + +void IpcWakeOne(IpcWait *Wait) { + u32 Id; + bool was_enabled = cpu_irqs_enabled(); + + cpu_disable_irqs(); + Id = IpcWaitTakeLive(Wait); + if (was_enabled) { + cpu_enable_irqs(); + } + + if (Id != 0) { + SchedWakeTask(Id); + } +} \ No newline at end of file diff --git a/ipc/ipc.h b/ipc/ipc.h index 6bce274..2bc2d5a 100644 --- a/ipc/ipc.h +++ b/ipc/ipc.h @@ -1 +1,80 @@ -/* TODO: System stub - implement when ready. */ +#ifndef NOVIUM_IPC_H +#define NOVIUM_IPC_H + +#include +#include + +/* + * Inter-process communication between kernel tasks. + * + * Every object here is a fixed size value that lives in kernel memory, so a + * task never has to allocate to talk to another one and there is nothing to + * leak when a task dies. Ports, semaphores and mutexes are created by the + * caller and may sit in the .bss, in a task's static area, or in a struct on + * its own stack. + */ + +/* + * A wait queue never holds more than this, so a full queue is an error the + * caller can see, instead of a task that blocks with nowhere to be woken. + */ +#define IpcMaxWaiters 16 + +typedef enum { + IpcOk = 0, + IpcErrInvalid, /* null or out of range argument */ + IpcErrFull, /* a bounded queue or counter is already at its limit */ + IpcErrEmpty, /* nothing to take */ + IpcErrBusy, /* a non blocking call could not proceed right now */ + IpcErrOwner, /* unlock from a task that does not hold the mutex */ + IpcErrNoTask /* the caller cannot sleep and the call would have to */ +} IpcStatus; + +/* Id of the calling task, 0 for the idle task and for no task at all. The idle + * task cannot sleep, so a call that would block must refuse to use it. */ +u32 IpcSelfId(void); + +/* + * Wait queue, shared by ports, semaphores and mutexes. Entries are task ids + * rather than Task pointers, because a Task descriptor is recycled once its + * task dies while an id is not, so a stale entry can be spotted instead of + * waking whichever task owns that slot now. + */ +typedef struct IpcWait { + u32 Ids[IpcMaxWaiters]; + u32 Count; +} IpcWait; + +/* Reports whether a parked task may stop waiting. Called with interrupts off, + * so it may only read the object it is handed. */ +typedef bool (*IpcReadyFn)(void *arg); + +/* + * Parks the calling task on Wait until Ready() turns true. + * + * The task registers itself while interrupts are off and only then sleeps, and + * Ready() is re-checked under that same lock, so a waker that runs in between + * either finds the task parked or is seen by the re-check afterwards. Waiting + * is never lost and never left behind. + * + * Returns IpcErrNoTask when the caller is not a task that can sleep, and + * IpcErrFull when the wait queue has no room for another entry. + */ +IpcStatus IpcPark(IpcWait *Wait, IpcReadyFn Ready, void *arg); + +/* + * Hands the object to the first waiter that is still alive, waking it if there + * is one. Woken tasks are picked in no particular order. Safe to call with + * interrupts on or off. + */ +void IpcWakeOne(IpcWait *Wait); + +/* + * Removes the first entry that still names a live task and returns its id, or + * 0 when the queue holds nobody. Entries left by a killed task are dropped + * along the way. For a mutex unlock, which needs the id before it can hand the + * lock over. Interrupts must be off. + */ +u32 IpcWaitTakeLive(IpcWait *Wait); + +#endif diff --git a/ipc/message.c b/ipc/message.c index 6bce274..7bac44e 100644 --- a/ipc/message.c +++ b/ipc/message.c @@ -1 +1,223 @@ -/* TODO: System stub - implement when ready. */ +#include "message.h" +#include + +#define PORT_MAGIC 0x504F5254u /* ASCII "PORT", tells a real port from garbage */ + +/* Drops whatever is in the slots and wait queues, keeping the port claimed. */ +static void PortReset(IpcPort *Port) { + u32 Index; + + Port->Head = 0; + Port->Count = 0; + + for (Index = 0; Index < IpcPortCapacity; Index++) { + Port->Slots[Index].Length = 0; + Port->Slots[Index].Sender = 0; + } + + Port->Senders.Count = 0; + Port->Receivers.Count = 0; +} + +bool IpcPortIsCreated(const IpcPort *Port) { + return Port != 0 && Port->Magic == PORT_MAGIC; +} + +IpcStatus IpcPortCreate(IpcPort *Port) { + bool was_enabled; + + if (Port == 0) { + return IpcErrInvalid; + } + + was_enabled = cpu_irqs_enabled(); + cpu_disable_irqs(); + + if (Port->Magic == PORT_MAGIC) { + /* Already in use. Wiping it here would empty a queue under a reader. */ + if (was_enabled) { + cpu_enable_irqs(); + } + return IpcErrInvalid; + } + + Port->Magic = PORT_MAGIC; + PortReset(Port); + + if (was_enabled) { + cpu_enable_irqs(); + } + + return IpcOk; +} + +IpcStatus IpcPortDestroy(IpcPort *Port) { + bool was_enabled; + IpcStatus Status = IpcOk; + + if (!IpcPortIsCreated(Port)) { + return IpcErrInvalid; + } + + was_enabled = cpu_irqs_enabled(); + cpu_disable_irqs(); + + if (Port->Senders.Count != 0 || Port->Receivers.Count != 0) { + /* + * Parked tasks would never learn the port went away, so a busy port + * stays claimed and the caller has to drain it first. + */ + Status = IpcErrBusy; + } else { + PortReset(Port); + Port->Magic = 0; + } + + if (was_enabled) { + cpu_enable_irqs(); + } + + return Status; +} + +u32 IpcPortQueued(const IpcPort *Port) { + if (!IpcPortIsCreated(Port)) { + return 0; + } + + return Port->Count; +} + +/* True while the port has room, which is what a blocked sender is waiting for. */ +static bool PortHasRoom(void *arg) { + IpcPort *Port = (IpcPort *)arg; + + return Port->Count < IpcPortCapacity; +} + +/* True while the port holds something, which is what a receiver waits for. */ +static bool PortHasMessage(void *arg) { + IpcPort *Port = (IpcPort *)arg; + + return Port->Count > 0; +} + +/* Copies the message into the next free slot. Caller holds the lock. */ +static void PortPush(IpcPort *Port, const IpcMessage *Message) { + u32 Tail = (Port->Head + Port->Count) % IpcPortCapacity; + + Port->Slots[Tail] = *Message; + Port->Slots[Tail].Sender = IpcSelfId(); + Port->Count++; +} + +/* Copies the oldest message out. Caller holds the lock. */ +static void PortPop(IpcPort *Port, IpcMessage *out) { + *out = Port->Slots[Port->Head]; + Port->Head = (Port->Head + 1) % IpcPortCapacity; + Port->Count--; +} + +IpcStatus IpcTrySend(IpcPort *Port, const IpcMessage *Message) { + bool was_enabled; + IpcStatus Status = IpcOk; + + if (!IpcPortIsCreated(Port) || Message == 0) { + return IpcErrInvalid; + } + + if (Message->Length > IpcMessageWords) { + return IpcErrInvalid; + } + + was_enabled = cpu_irqs_enabled(); + cpu_disable_irqs(); + + if (Port->Count == IpcPortCapacity) { + Status = IpcErrFull; + } else { + PortPush(Port, Message); + } + + if (was_enabled) { + cpu_enable_irqs(); + } + + if (Status == IpcOk) { + IpcWakeOne(&Port->Receivers); + } + + return Status; +} + +IpcStatus IpcSend(IpcPort *Port, const IpcMessage *Message) { + if (!IpcPortIsCreated(Port) || Message == 0) { + return IpcErrInvalid; + } + + if (Message->Length > IpcMessageWords) { + return IpcErrInvalid; + } + + for (;;) { + IpcStatus Status = IpcTrySend(Port, Message); + + if (Status != IpcErrFull) { + return Status; + } + + /* Full. Wait for a receiver to make room, then try again. */ + Status = IpcPark(&Port->Senders, PortHasRoom, Port); + if (Status != IpcOk) { + return Status; + } + } +} + +IpcStatus IpcTryReceive(IpcPort *Port, IpcMessage *out) { + bool was_enabled; + IpcStatus Status = IpcOk; + + if (!IpcPortIsCreated(Port) || out == 0) { + return IpcErrInvalid; + } + + was_enabled = cpu_irqs_enabled(); + cpu_disable_irqs(); + + if (Port->Count == 0) { + Status = IpcErrEmpty; + } else { + PortPop(Port, out); + } + + if (was_enabled) { + cpu_enable_irqs(); + } + + if (Status == IpcOk) { + IpcWakeOne(&Port->Senders); + } + + return Status; +} + +IpcStatus IpcReceive(IpcPort *Port, IpcMessage *out) { + if (!IpcPortIsCreated(Port) || out == 0) { + return IpcErrInvalid; + } + + for (;;) { + IpcStatus Status = IpcTryReceive(Port, out); + + if (Status != IpcErrEmpty) { + return Status; + } + + /* Empty. Wait for a sender, then try again. */ + Status = IpcPark(&Port->Receivers, PortHasMessage, Port); + if (Status != IpcOk) { + return Status; + } + } +} diff --git a/ipc/message.h b/ipc/message.h index 6bce274..51f5cbc 100644 --- a/ipc/message.h +++ b/ipc/message.h @@ -1 +1,70 @@ -/* TODO: System stub - implement when ready. */ +#ifndef NOVIUM_IPC_MESSAGE_H +#define NOVIUM_IPC_MESSAGE_H + +#include "ipc.h" + +/* + * Message passing over ports. + * + * A message is a fixed size value: a length and IpcMessageWords of payload. It + * is copied by value rather than referenced, so the sender's buffer is free + * again as soon as IpcSend returns and neither side has to keep the other's + * memory alive. + */ + +#define IpcMessageWords 8 + +typedef struct IpcMessage { + u32 Length; /* words of Data in use, at most IpcMessageWords */ + u32 Sender; /* task id of the sender, filled in by IpcSend */ + u32 Data[IpcMessageWords]; +} IpcMessage; + +/* How many messages a port holds before senders have to wait for a receiver. */ +#define IpcPortCapacity 16 + +/* + * A port is a bounded queue of messages in arrival order. What it adds over a + * plain queue is the other half: a sender that finds the port full and a + * receiver that finds it empty both park until the other side moves. Port + * fields are private, build one with IpcPortCreate and do not touch it after. + */ +typedef struct IpcPort { + u32 Magic; + u32 Head; /* slot the next receive takes from */ + u32 Count; /* messages queued right now */ + IpcMessage Slots[IpcPortCapacity]; + IpcWait Senders; + IpcWait Receivers; +} IpcPort; + +/* + * Claims a port the caller owns, in its own memory, and empties it. The port + * must not be in use already, which reports IpcErrInvalid rather than wiping + * a queue another task is reading from. + */ +IpcStatus IpcPortCreate(IpcPort *Port); + +/* Empties the port and releases it. Fails while tasks are parked on it, since + * dropping them would strand them. */ +IpcStatus IpcPortDestroy(IpcPort *Port); + +/* Queues a message, waiting for room while the port is full. */ +IpcStatus IpcSend(IpcPort *Port, const IpcMessage *Message); + +/* Queues a message only when there is room. Never waits. */ +IpcStatus IpcTrySend(IpcPort *Port, const IpcMessage *Message); + +/* Takes the oldest queued message, waiting while the port is empty. */ +IpcStatus IpcReceive(IpcPort *Port, IpcMessage *out); + +/* Takes the oldest message only when one is queued. Never waits. */ +IpcStatus IpcTryReceive(IpcPort *Port, IpcMessage *out); + +/* Messages waiting in the port. */ +u32 IpcPortQueued(const IpcPort *Port); + +/* True once IpcPortCreate has claimed the port. */ +bool IpcPortIsCreated(const IpcPort *Port); + +#endif diff --git a/ipc/sync.c b/ipc/sync.c index 6bce274..0ced247 100644 --- a/ipc/sync.c +++ b/ipc/sync.c @@ -1 +1,246 @@ -/* TODO: System stub - implement when ready. */ +#include "sync.h" +#include + +/* + * Semaphore. + * + * A Limit of 0 is an unbounded counter. Setting one turns it into a binary + * semaphore, which is what the kernel's own locks will mostly want. + */ +void SemInit(IpcSemaphore *Sem, u32 Initial, u32 Limit) { + if (Sem == 0) { + return; + } + + if (Limit != 0 && Initial > Limit) { + /* Start at the ceiling rather than above it, so Count <= Limit holds. */ + Initial = Limit; + } + + Sem->Count = Initial; + Sem->Limit = Limit; + Sem->Wait.Count = 0; +} + +/* True while a permit is free, which is what a parked sender is waiting for. */ +static bool SemHasPermit(void *arg) { + IpcSemaphore *Sem = (IpcSemaphore *)arg; + + return Sem->Count > 0; +} + +IpcStatus SemTryWait(IpcSemaphore *Sem) { + bool was_enabled; + IpcStatus Status = IpcOk; + + if (Sem == 0) { + return IpcErrInvalid; + } + + was_enabled = cpu_irqs_enabled(); + cpu_disable_irqs(); + + if (Sem->Count == 0) { + Status = IpcErrEmpty; + } else { + Sem->Count--; + } + + if (was_enabled) { + cpu_enable_irqs(); + } + + return Status; +} + +IpcStatus SemWait(IpcSemaphore *Sem) { + if (Sem == 0) { + return IpcErrInvalid; + } + + for (;;) { + IpcStatus Status = SemTryWait(Sem); + + if (Status != IpcErrEmpty) { + return Status; + } + + /* No permit. Wait for a post, then try again. */ + Status = IpcPark(&Sem->Wait, SemHasPermit, Sem); + if (Status != IpcOk) { + return Status; + } + } +} + +IpcStatus SemPost(IpcSemaphore *Sem) { + bool was_enabled; + IpcStatus Status = IpcOk; + + if (Sem == 0) { + return IpcErrInvalid; + } + + was_enabled = cpu_irqs_enabled(); + cpu_disable_irqs(); + + if (Sem->Limit != 0 && Sem->Count >= Sem->Limit) { + /* At the ceiling. Posting here would let the count run away. */ + Status = IpcErrFull; + } else { + Sem->Count++; + } + + if (was_enabled) { + cpu_enable_irqs(); + } + + if (Status == IpcOk) { + IpcWakeOne(&Sem->Wait); + } + + return Status; +} + +u32 SemCount(const IpcSemaphore *Sem) { + if (Sem == 0) { + return 0; + } + + return Sem->Count; +} + +/* + * Mutex. + * + * Not recursive. Locking a mutex the caller already holds reports IpcErrOwner + * rather than parking on a lock nobody else is able to open, which would hang + * the task until the scheduler undid the block. + */ +void MutexInit(IpcMutex *Mutex) { + if (Mutex == 0) { + return; + } + + Mutex->Locked = false; + Mutex->Owner = 0; + Mutex->Wait.Count = 0; +} + +bool MutexHeldByCaller(const IpcMutex *Mutex) { + if (Mutex == 0 || !Mutex->Locked) { + return false; + } + + return Mutex->Owner == IpcSelfId(); +} + +/* True while the mutex is free, which is what a parked locker waits for. */ +static bool MutexIsFree(void *arg) { + IpcMutex *Mutex = (IpcMutex *)arg; + + return !Mutex->Locked; +} + +IpcStatus MutexTryLock(IpcMutex *Mutex) { + bool was_enabled; + IpcStatus Status = IpcOk; + + if (Mutex == 0) { + return IpcErrInvalid; + } + + was_enabled = cpu_irqs_enabled(); + cpu_disable_irqs(); + + if (Mutex->Locked) { + Status = IpcErrBusy; + } else { + Mutex->Locked = true; + Mutex->Owner = IpcSelfId(); + } + + if (was_enabled) { + cpu_enable_irqs(); + } + + return Status; +} + +IpcStatus MutexLock(IpcMutex *Mutex) { + if (Mutex == 0) { + return IpcErrInvalid; + } + + if (MutexHeldByCaller(Mutex)) { + return IpcErrOwner; + } + + for (;;) { + IpcStatus Status = MutexTryLock(Mutex); + + if (Status == IpcErrBusy && MutexHeldByCaller(Mutex)) { + /* + * An unlock passed the lock straight to us while we were parked, + * so the mutex is ours even though it still reads as taken. + */ + return IpcOk; + } + + if (Status != IpcErrBusy) { + return Status; + } + + /* Held. Wait for the owner to release it, then try again. */ + Status = IpcPark(&Mutex->Wait, MutexIsFree, Mutex); + if (Status != IpcOk) { + return Status; + } + } +} + +IpcStatus MutexUnlock(IpcMutex *Mutex) { + bool was_enabled; + u32 Next = 0; + IpcStatus Status = IpcOk; + + if (Mutex == 0) { + return IpcErrInvalid; + } + + was_enabled = cpu_irqs_enabled(); + cpu_disable_irqs(); + + if (!Mutex->Locked || Mutex->Owner != IpcSelfId()) { + /* + * Only the holder may open the lock. Allowing anyone through would let a + * task release a mutex it never took and hand it to a waiter at the + * same time. + */ + Status = IpcErrOwner; + } else { + /* + * Take a waiter now and pass the lock straight to it, rather than + * clearing the owner and letting a task that is merely ready take it + * first. That keeps the wakeup and the lock in step. + */ + Next = IpcWaitTakeLive(&Mutex->Wait); + + if (Next != 0) { + Mutex->Owner = Next; + } else { + Mutex->Locked = false; + Mutex->Owner = 0; + } + } + + if (was_enabled) { + cpu_enable_irqs(); + } + + if (Status == IpcOk && Next != 0) { + SchedWakeTask(Next); + } + + return Status; +} diff --git a/ipc/sync.h b/ipc/sync.h index 6bce274..bc662e7 100644 --- a/ipc/sync.h +++ b/ipc/sync.h @@ -1 +1,65 @@ -/* TODO: System stub - implement when ready. */ +#ifndef NOVIUM_IPC_SYNC_H +#define NOVIUM_IPC_SYNC_H + +#include "ipc.h" + +/* + * Counting semaphores and mutexes. Both park a task on a shared wait queue and + * hand the resource straight to the next waiter, so a wakeup cannot be taken + * by a task that is not the one the queue picked. + */ + +typedef struct IpcSemaphore { + u32 Count; /* permits currently available */ + u32 Limit; /* ceiling for Count, 0 means there is no ceiling */ + IpcWait Wait; +} IpcSemaphore; + +typedef struct IpcMutex { + /* The owner alone cannot say whether the mutex is held, because the idle + * task is id 0 too, so held-ness is tracked apart from who holds it. */ + bool Locked; + u32 Owner; /* task id of the holder, meaningless while Locked is false */ + IpcWait Wait; +} IpcMutex; + +/* + * Semaphore. + * + * A Limit of 0 is an unbounded counter, and a Limit of 1 turns it into a binary + * semaphore, which is what most of the kernel's own locks will want. + */ +void SemInit(IpcSemaphore *Sem, u32 Initial, u32 Limit); + +/* Takes a permit, blocking while none is available. */ +IpcStatus SemWait(IpcSemaphore *Sem); + +/* Takes a permit only when one is free. Never blocks. */ +IpcStatus SemTryWait(IpcSemaphore *Sem); + +/* Returns a permit and wakes one waiter. Fails when the ceiling is reached. */ +IpcStatus SemPost(IpcSemaphore *Sem); + +u32 SemCount(const IpcSemaphore *Sem); + +/* + * Mutex. + * + * Not recursive: locking a mutex the caller already holds reports IpcErrOwner + * instead of parking on a lock nobody else is able to open. + */ +void MutexInit(IpcMutex *Mutex); + +/* Takes the mutex, blocking while another task holds it. */ +IpcStatus MutexLock(IpcMutex *Mutex); + +/* Takes the mutex only when it is free. Never blocks. */ +IpcStatus MutexTryLock(IpcMutex *Mutex); + +/* Releases the mutex and hands it to the first waiter. */ +IpcStatus MutexUnlock(IpcMutex *Mutex); + +/* True when the calling task is the one holding the mutex. */ +bool MutexHeldByCaller(const IpcMutex *Mutex); + +#endif From de463bdb5bdc932d628fb320759eb38717486857 Mon Sep 17 00:00:00 2001 From: komrebi32 Date: Wed, 30 Sep 2026 21:17:15 -0400 Subject: [PATCH 2/3] feat(init): add ipc boot tests for the blocking paths Verifies the ipc at boot rather than assuming it works. A producer and a consumer pass 40 messages, which is more than a port holds, so the sender has to park on a full port and the receiver on an empty one, and both have to be woken the right number of times for the counts to line up. The consumer checks each message came back in order and stamped with the sender's id. The mutex test runs two tasks against one lock for as many rounds as they finish before the stop flag, each one checking that the shared counter only ever advanced by one, so the unlock handoff cannot let two tasks into the critical section at once. The semaphore and mutex checks that run in this task also cover the paths that must refuse rather than wait: a re-lock, an unlock from a task that does not hold the lock, a second create on a live port, and a second destroy. The last wait reports IpcErrNoTask, since the idle task has nowhere to park. These run before the shell and worker start, so their output stays on one run of lines instead of landing in the middle of the shell prompt. --- init/main.c | 250 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/init/main.c b/init/main.c index 628848b..959bf0a 100644 --- a/init/main.c +++ b/init/main.c @@ -1,5 +1,8 @@ #include #include +#include +#include +#include #include #include #include @@ -12,6 +15,33 @@ #define TASK_STACK_SIZE 4096 static u8 ShellStack[TASK_STACK_SIZE] __attribute__((aligned(16))); static u8 WorkerStack[TASK_STACK_SIZE] __attribute__((aligned(16))); +static u8 ConsumerStack[TASK_STACK_SIZE] __attribute__((aligned(16))); +static u8 ProducerStack[TASK_STACK_SIZE] __attribute__((aligned(16))); +static u8 LockAStack[TASK_STACK_SIZE] __attribute__((aligned(16))); +static u8 LockBStack[TASK_STACK_SIZE] __attribute__((aligned(16))); + +/* + * More messages than a port can hold, so the rendezvous below cannot pass by + * accident: the producer has to park on a full port and the consumer on an + * empty one, over and over, and both sides have to be woken the right number + * of times for the counts to line up. + */ +#define IPC_TEST_MESSAGES 40 +#define IPC_TEST_TIMEOUT 200 +/* Enough trips through the mutex to show two tasks really do alternate. */ +#define IPC_TEST_ROUNDS 50 + +static IpcPort TestPort; +static IpcSemaphore TestSem; +static IpcMutex TestMutex; +static volatile u32 ConsumerDone; +static volatile u32 ProducerDone; +static volatile u32 ConsumerBad; +/* Id of the sending task, so the consumer checks the stamp without guessing. */ +static volatile u32 ProducerId; +static volatile u32 LockRounds; +static volatile u32 LockStop; +static volatile u32 LockBad; void shell_task(void) { kprintf("System ready. You can now type inside the console:\n"); @@ -41,6 +71,218 @@ void worker_task(void) { } } +/* Reads every message back and checks the order and the sender id. */ +static void ipc_consumer_task(void) { + IpcMessage Message; + u32 Index; + + for (Index = 0; Index < IPC_TEST_MESSAGES; Index++) { + if (IpcReceive(&TestPort, &Message) != IpcOk) { + ConsumerBad++; + break; + } + + if (Message.Length != 1 || Message.Data[0] != Index || + Message.Sender != ProducerId) { + ConsumerBad++; + } + } + + ConsumerDone = 1; +} + +/* Fills the port past its capacity, so the send path has to park as well. */ +static void ipc_producer_task(void) { + IpcMessage Message; + u32 Index; + + for (Index = 0; Index < IPC_TEST_MESSAGES; Index++) { + Message.Length = 1; + Message.Data[0] = Index; + + if (IpcSend(&TestPort, &Message) != IpcOk) { + ConsumerBad++; + break; + } + } + + ProducerDone = 1; +} + +/* + * Takes the mutex, bumps a counter, and checks that nobody slipped in while it + * was held. A shared counter that only ever grows by one is the evidence that + * the handoff did not let two tasks into the critical section at once. + */ +static void ipc_lock_task(void) { + u32 Seen; + + while (!LockStop) { + if (MutexLock(&TestMutex) != IpcOk) { + LockBad++; + break; + } + + Seen = LockRounds; + LockRounds = Seen + 1; + + if (LockRounds != Seen + 1) { + LockBad++; + } + + MutexUnlock(&TestMutex); + } +} + +/* + * Runs the ipc self tests once the scheduler is up, so the two sides of the + * rendezvous really do park and get woken. Prints a line per outcome, the same + * way the heap tests above report theirs. + */ +static void ipc_test_tasks(void) { + Task *Consumer; + Task *Producer; + u32 Waited; + IpcStatus Status; + + if (IpcPortCreate(&TestPort) != IpcOk) { + kprintf("ERROR: ipc port could not be created.\n"); + return; + } + + ConsumerDone = 0; + ProducerDone = 0; + ConsumerBad = 0; + + Consumer = SchedCreate("Consumer", (u32)ipc_consumer_task, + (u32)&ConsumerStack[TASK_STACK_SIZE]); + Producer = SchedCreate("Producer", (u32)ipc_producer_task, + (u32)&ProducerStack[TASK_STACK_SIZE]); + + if (Consumer == 0 || Producer == 0) { + kprintf("ERROR: ipc test tasks could not be created.\n"); + return; + } + + ProducerId = Producer->Id; + + /* Both tasks are created, so both sides have to finish on their own. */ + for (Waited = 0; Waited < IPC_TEST_TIMEOUT; Waited++) { + if (ConsumerDone && ProducerDone) { + break; + } + cpu_idle(); + } + + if (!ConsumerDone || !ProducerDone) { + kprintf("ERROR: ipc rendezvous did not finish, %u of %u messages.\n", + IpcPortQueued(&TestPort), IPC_TEST_MESSAGES); + return; + } + + if (ConsumerBad != 0) { + kprintf("ERROR: ipc rendezvous reported %u bad messages.\n", ConsumerBad); + return; + } + + kprintf("OK: %u messages passed through a blocking port.\n", IPC_TEST_MESSAGES); + + if (IpcPortQueued(&TestPort) != 0) { + kprintf("ERROR: port still holds %u messages after draining it.\n", + IpcPortQueued(&TestPort)); + } else { + kprintf("OK: port drained, no message lost or duplicated.\n"); + } + + /* A binary semaphore plus a mutex, driven from this task alone. */ + SemInit(&TestSem, 1, 1); + Status = SemWait(&TestSem); + + /* + * Runs from the idle task, which cannot sleep. So the last wait here has + * nowhere to park and has to say so instead of hanging, which is the same + * rule timer_sleep_ms follows. + */ + if (Status != IpcOk || SemTryWait(&TestSem) != IpcErrEmpty) { + kprintf("ERROR: binary semaphore did not hold at its limit.\n"); + } else if (SemPost(&TestSem) != IpcOk || SemPost(&TestSem) != IpcErrFull) { + kprintf("ERROR: binary semaphore did not stop at its limit.\n"); + } else if (SemWait(&TestSem) != IpcOk || SemWait(&TestSem) != IpcErrNoTask) { + kprintf("ERROR: binary semaphore did not hand its permit back.\n"); + } else { + kprintf("OK: binary semaphore counted, blocked, and refused to park the idle.\n"); + } + + MutexInit(&TestMutex); + + if (MutexLock(&TestMutex) != IpcOk || !MutexHeldByCaller(&TestMutex)) { + kprintf("ERROR: mutex could not be locked.\n"); + } else if (MutexTryLock(&TestMutex) != IpcErrBusy) { + kprintf("ERROR: mutex was free while this task held it.\n"); + } else if (MutexLock(&TestMutex) != IpcErrOwner) { + kprintf("ERROR: mutex allowed a recursive lock.\n"); + } else if (MutexUnlock(&TestMutex) != IpcOk || MutexHeldByCaller(&TestMutex)) { + kprintf("ERROR: mutex did not release.\n"); + } else if (MutexUnlock(&TestMutex) != IpcErrOwner) { + kprintf("ERROR: mutex let a task unlock one it did not hold.\n"); + } else { + kprintf("OK: mutex locked, refused a re-lock, and released.\n"); + } + + /* Two tasks fight over one mutex, so the handoff is exercised for real. */ + MutexInit(&TestMutex); + LockRounds = 0; + LockStop = 0; + LockBad = 0; + + { + Task *A = SchedCreate("LockA", (u32)ipc_lock_task, (u32)&LockAStack[TASK_STACK_SIZE]); + Task *B = SchedCreate("LockB", (u32)ipc_lock_task, (u32)&LockBStack[TASK_STACK_SIZE]); + + if (A == 0 || B == 0) { + kprintf("ERROR: ipc lock tasks could not be created.\n"); + return; + } + + for (Waited = 0; Waited < IPC_TEST_ROUNDS; Waited++) { + if (LockRounds >= IPC_TEST_ROUNDS) { + break; + } + cpu_idle(); + } + + LockStop = 1; + + /* Let both tasks notice the stop flag and let go of the mutex. */ + for (Waited = 0; Waited < IPC_TEST_TIMEOUT; Waited++) { + if (!TestMutex.Locked) { + break; + } + cpu_idle(); + } + } + + if (LockBad != 0) { + kprintf("ERROR: mutex let %u tasks into the critical section at once.\n", + LockBad); + } else if (LockRounds < IPC_TEST_ROUNDS) { + kprintf("ERROR: mutex contention stalled at %u of %u rounds.\n", LockRounds, + IPC_TEST_ROUNDS); + } else { + kprintf("OK: %u rounds of contention on one mutex, no overlap.\n", LockRounds); + } + + /* A released port cannot be claimed or destroyed twice. */ + if (IpcPortDestroy(&TestPort) != IpcOk || IpcPortDestroy(&TestPort) != IpcErrInvalid) { + kprintf("ERROR: port could not be destroyed cleanly.\n"); + } else if (IpcPortCreate(&TestPort) != IpcOk || IpcPortCreate(&TestPort) != IpcErrInvalid) { + kprintf("ERROR: port was claimed twice.\n"); + } else { + kprintf("OK: port rejected a second create and a second destroy.\n"); + IpcPortDestroy(&TestPort); + } +} + void kernel_main(struct boot_info *boot) { console_clear(); kprintf("NoviumOS\n\n"); @@ -121,6 +363,14 @@ void kernel_main(struct boot_info *boot) { SchedInit(); kprintf("OK: Scheduling Init Succesfull\n\n"); + /* + * Before the shell and worker start, so this output stays on one run of + * lines instead of landing in the middle of the shell's prompt. + */ + ipc_test_tasks(); + + kprintf("\n"); + SchedCreate("Worker", (u32)worker_task, (u32)&WorkerStack[TASK_STACK_SIZE]); SchedCreate("Shell", (u32)shell_task, (u32)&ShellStack[TASK_STACK_SIZE]); From 45ad6a9dd1b9fc0dd797fbccef211c24a4d7698b Mon Sep 17 00:00:00 2001 From: komrebi32 Date: Wed, 30 Sep 2026 21:17:21 -0400 Subject: [PATCH 3/3] docs: document the ipc api and record the release Documents the ipc contracts in kernel_api.md now that the headers are implemented, and drops ipc/message.h and ipc/sync.h from the planned interfaces list. The README and the roadmap no longer describe ipc/ as a stub. The version moves to 0.9-dev for the new subsystem. Known issues are listed rather than left for someone to find: none of it is reentrant, a wait queue holds 16 entries and reports IpcErrFull past that, and the objects are static kernel values rather than something a task can allocate. --- CHANGELOG.md | 21 ++++++ Documentation/kernel_api.md | 127 +++++++++++++++++++++++++++++++++++- Documentation/todo.md | 3 +- Makefile | 11 ++-- README.md | 3 +- 5 files changed, 157 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a1e0b..6c511f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), Versioning is similar to [semver](https://semver.org) but is more flexible and similar to how linux does it, and uses custom tags (like `-dev` and `-rc`). +## 0.9.0-dev - 2026-09-30 + +### Added +- `ipc/` filled in with message passing and synchronisation between kernel tasks, replacing the stubs. +- `IpcPort` in `ipc/message.h`, a FIFO of `IpcPortCapacity` (16) fixed size messages. `IpcSend()` waits while the port is full and `IpcReceive()` waits while it is empty, so two tasks can hand work back and forth without polling. `IpcTrySend()` and `IpcTryReceive()` are the non blocking forms. +- `IpcSemaphore` and `IpcMutex` in `ipc/sync.h`. A semaphore takes a ceiling, so a `Limit` of 1 is a binary semaphore and `SemPost()` past the ceiling reports `IpcErrFull` rather than letting the count run away. +- `IpcStatus` for every entry point, so a refused call says why instead of failing silently. +- A shared wait queue in `ipc/ipc.c`. `IpcPark()` registers the calling task and sleeps as one step under the interrupt lock, so a waker that runs in between either finds the task parked or is seen by the re-check afterwards. A wakeup cannot be lost. +- Wait queues keyed by task id rather than `Task *`, and `IpcWaitTakeLive()` drops entries left by a task that was killed, since a dead task can never run again. +- Boot tests in `kernel_main()` covering a 40 message rendezvous between two tasks, more than a port can hold so both the send and receive paths really do park, a binary semaphore, and two tasks contending on one mutex for several hundred rounds. +- `ipc/` added to the `Makefile` VPATH and its objects to `KERNEL_OBJS`. + +### Changed +- `MutexUnlock()` hands ownership straight to the first waiter instead of clearing the owner, so a task that merely happens to be ready cannot take the lock ahead of the task that was woken for it. +- `ipc/message.h` and `ipc/sync.h` are no longer listed as planned interfaces, since their contracts are documented in `Documentation/kernel_api.md` now. + +### Known issues +- Nothing here is reentrant, so calling into `ipc/` from an interrupt handler is not supported yet. +- A wait queue holds `IpcMaxWaiters` (16) entries. Parking on a 17th waiter returns `IpcErrFull` rather than overwriting an entry. +- Ports, semaphores, and mutexes are static kernel objects rather than something a task can allocate, so two tasks cannot share one that lives on only one of their stacks. + ## 0.8-rc1 - 2026-09-29 Frozen for testing. No code changes since `0.8-dev`; see that section for what is in this release. diff --git a/Documentation/kernel_api.md b/Documentation/kernel_api.md index 0416331..1d4eacc 100644 --- a/Documentation/kernel_api.md +++ b/Documentation/kernel_api.md @@ -331,12 +331,137 @@ robin scheduling chooses the next ready task by increasing task ID and wraps back to the start of the ready queue. `SchedGetStats()` needs a non-null output pointer. +## Inter-process communication + +`ipc/` carries messages and synchronisation between kernel tasks. Every object is +a fixed size value that the caller owns, so a task never allocates to talk to +another one and nothing is left behind when a task dies. Nothing here is +reentrant, so do not call into it from an interrupt handler. + +Every call returns an `IpcStatus`: + +```c +typedef enum { + IpcOk = 0, + IpcErrInvalid, /* null or out of range argument */ + IpcErrFull, /* a bounded queue or counter is already at its limit */ + IpcErrEmpty, /* nothing to take */ + IpcErrBusy, /* a non blocking call could not proceed right now */ + IpcErrOwner, /* unlock from a task that does not hold the mutex */ + IpcErrNoTask /* the caller cannot sleep and the call would have to */ +} IpcStatus; +``` + +The blocking calls sleep through `SchedBlock()` and are woken with +`SchedWakeTask()`. Two rules follow from how the scheduler works here: + +- The idle task cannot sleep, so a call that would have to wait returns + `IpcErrNoTask` instead of parking. `timer_sleep_ms()` follows the same rule. +- A blocking call may return `IpcErrFull` when a wait queue is full. Queues hold + `IpcMaxWaiters` (16) entries, so this needs 16 tasks parked on one object. + +A woken task re-checks the condition in a loop rather than assuming the wakeup +still applies, so a spurious wakeup costs a retry and never a lost message. + +### Message ports + +```c +#define IpcMessageWords 8 +#define IpcPortCapacity 16 + +typedef struct IpcMessage { + u32 Length; /* words of Data in use, at most IpcMessageWords */ + u32 Sender; /* task id of the sender, filled in by IpcSend */ + u32 Data[IpcMessageWords]; +} IpcMessage; + +typedef struct IpcPort { /* private, build one with IpcPortCreate */ }; + +IpcStatus IpcPortCreate(IpcPort *Port); +IpcStatus IpcPortDestroy(IpcPort *Port); +IpcStatus IpcSend(IpcPort *Port, const IpcMessage *Message); +IpcStatus IpcTrySend(IpcPort *Port, const IpcMessage *Message); +IpcStatus IpcReceive(IpcPort *Port, IpcMessage *out); +IpcStatus IpcTryReceive(IpcPort *Port, IpcMessage *out); +u32 IpcPortQueued(const IpcPort *Port); +bool IpcPortIsCreated(const IpcPort *Port); +``` + +A port is a FIFO of up to `IpcPortCapacity` messages. A message is copied by +value, so the sender's buffer is free as soon as the call returns and neither +side holds a pointer into the other's memory. `IpcSend()` fills in `Sender` with +the sending task's id. + +`IpcSend()` waits while the port is full and `IpcReceive()` waits while it is +empty, so the two sides can hand work back and forth without polling. The `Try` +forms never wait and return `IpcErrFull` or `IpcErrEmpty` instead. + +A port carries a magic word, so `IpcPortCreate()` on a port that is already in +use fails with `IpcErrInvalid` rather than emptying a queue another task is +reading. For the same reason `IpcPortDestroy()` refuses with `IpcErrBusy` while +any task is parked on the port, since dropping those waiters would strand them. +`Length` above `IpcMessageWords` is rejected as `IpcErrInvalid`. Every call +checks its arguments, so a null or uncreated port reports `IpcErrInvalid` +instead of touching memory. + +### Semaphores and mutexes + +```c +typedef struct IpcSemaphore { u32 Count; u32 Limit; IpcWait Wait; } IpcSemaphore; +typedef struct IpcMutex { bool Locked; u32 Owner; IpcWait Wait; } IpcMutex; + +void SemInit(IpcSemaphore *Sem, u32 Initial, u32 Limit); +IpcStatus SemWait(IpcSemaphore *Sem); +IpcStatus SemTryWait(IpcSemaphore *Sem); +IpcStatus SemPost(IpcSemaphore *Sem); +u32 SemCount(const IpcSemaphore *Sem); + +void MutexInit(IpcMutex *Mutex); +IpcStatus MutexLock(IpcMutex *Mutex); +IpcStatus MutexTryLock(IpcMutex *Mutex); +IpcStatus MutexUnlock(IpcMutex *Mutex); +bool MutexHeldByCaller(const IpcMutex *Mutex); +``` + +`SemInit()` takes a ceiling. A `Limit` of 0 is an unbounded counter and a +`Limit` of 1 is a binary semaphore; `SemPost()` past the ceiling returns +`IpcErrFull` rather than letting the count grow. An initial count above the +ceiling is clamped down. + +A mutex is not recursive: `MutexLock()` on a mutex the caller already holds +returns `IpcErrOwner` instead of parking on a lock nobody else can open. +`MutexUnlock()` from a task that does not hold the mutex also returns +`IpcErrOwner`. Unlock passes ownership straight to the first waiter instead of +clearing the owner and letting a task that merely happens to be ready take it, +so a wakeup and the lock stay in step. `Locked` is tracked separately from +`Owner` because the idle task is id 0 as well, so an owner of 0 cannot mean +free. + +### Wait queues + +```c +typedef struct IpcWait { u32 Ids[IpcMaxWaiters]; u32 Count; } IpcWait; +typedef bool (*IpcReadyFn)(void *arg); + +u32 IpcSelfId(void); +IpcStatus IpcPark(IpcWait *Wait, IpcReadyFn Ready, void *arg); +void IpcWakeOne(IpcWait *Wait); +u32 IpcWaitTakeLive(IpcWait *Wait); +``` + +These are the pieces ports and semaphores are built from. `IpcPark()` registers +the calling task and sleeps in one step while interrupts are off, so a waker +that runs in between either finds the task parked or is seen by the re-check +afterwards. A wakeup is never lost and a parked entry is never left behind. +`IpcWaitTakeLive()` drops entries whose task was killed, since a dead task can +never run again, and `IpcWakeOne()` calls `SchedWakeTask()` for the first +waiter that is still alive. + ## Planned interfaces These headers are still placeholders, so they are not usable kernel APIs yet: - `include/drivers/framebuffer.h` - `fs/vfs.h` and `fs/initrd.h` -- `ipc/message.h` and `ipc/sync.h` We will document their contracts here when the implementations land. diff --git a/Documentation/todo.md b/Documentation/todo.md index 01a6db7..2752f31 100644 --- a/Documentation/todo.md +++ b/Documentation/todo.md @@ -10,7 +10,6 @@ For the history of everything completed so far, see [CHANGELOG.md](../CHANGELOG. ## Next up: Filesystem - [ ] Simple filesystem (VFS + ext2) -- [ ] Basic IPC between kernel threads/processes --- @@ -31,4 +30,4 @@ For the history of everything completed so far, see [CHANGELOG.md](../CHANGELOG. ## Notes -- `ipc/`, `fs/`, have stubs but they'll get filled in as the kernel progresses. +- `fs/` has stubs but it will get filled in as the kernel progresses. diff --git a/Makefile b/Makefile index 60c66ef..6ba6ab8 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ VERSION = 0 -PATCHLEVEL = 8 +PATCHLEVEL = 9 SUBLEVEL = 0 -EXTRAVERSION = -rc1 +EXTRAVERSION = -dev VERSION_TAG = $(VERSION).$(PATCHLEVEL)$(if $(filter-out 0,$(SUBLEVEL)),.$(SUBLEVEL))$(EXTRAVERSION) CC = gcc @@ -12,7 +12,7 @@ GRUB_MKRESCUE ?= grub-mkrescue ARCH ?= x86_32 BUILD_DIR = build -VPATH = init drivers/video drivers/input kernel lib mm arch/$(ARCH)/boot arch/$(ARCH)/kernel +VPATH = init drivers/video drivers/input kernel lib mm ipc arch/$(ARCH)/boot arch/$(ARCH)/kernel DRIVE_FLAGS ?= -hda QEMU_FLAGS ?= -m 64M -serial stdio @@ -66,7 +66,10 @@ KERNEL_OBJS = \ $(BUILD_DIR)/sched.o \ $(BUILD_DIR)/process.o \ $(BUILD_DIR)/switch.o \ - $(BUILD_DIR)/heap.o + $(BUILD_DIR)/heap.o \ + $(BUILD_DIR)/ipc.o \ + $(BUILD_DIR)/message.o \ + $(BUILD_DIR)/sync.o # Header dependencies collected by -MMD, one .d next to each .o KERNEL_DEPS = $(KERNEL_OBJS:.o=.d) diff --git a/README.md b/README.md index 2722a51..a10a8f3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Novium OS is a hobby x86 operating system built from scratch. The architecture is heavily inspired by Linux, but stripped down to the absolute basics. If you are into low-level engineering, feel free to open a PR or share ideas. > [!NOTE] -> Directories like `ipc/` and `fs/` currently contain early stubs. +> The `fs/` directory still contains early stubs. ## Project structure @@ -40,6 +40,7 @@ GRUB finds and loads the kernel, checks the Multiboot header, and hands control - [x] Switch to GRUB bootloader - [x] Added a basic Memory Manager (PMM/VMM) - [x] Basic multi-page and sub-page heap allocator +- [x] IPC: message ports, semaphores, and mutexes See [CHANGELOG.md](CHANGELOG.md) for official release history See [Documentation/todo.md](Documentation/todo.md) for the detailed task list and future plans.