Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-dev - 2026-10-2

### 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 - 2026-10-02

Stable release. See `0.8-dev` for the release contents and `0.8-rc2` for the final candidate fixes.
Expand Down
127 changes: 126 additions & 1 deletion Documentation/kernel_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,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.
3 changes: 1 addition & 2 deletions Documentation/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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.
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
VERSION = 0
PATCHLEVEL = 8
PATCHLEVEL = 9
SUBLEVEL = 0
EXTRAVERSION =
VERSION_TAG = $(VERSION).$(PATCHLEVEL)$(if $(filter-out 0,$(SUBLEVEL)),.$(SUBLEVEL))$(EXTRAVERSION)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,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
Expand Down Expand Up @@ -39,6 +39,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.
Expand Down
Loading
Loading