Version: 0.3
Status: Implemented — reflects the component system as of the cmake/component_api.cmake introduction.
Purpose of this document: design rationale and rules for the component system: directory layout, CMake API (
ulmk_component_register), ROOT_THREAD invariant, service pattern (IPC as implementation detail), and theboard_consolereference service. For a step-by-step application walkthrough seedocs/application_development_guide.md.
- Design Philosophy
- Component Directory Structure
- CMake Registration API
- Discovery and Scan Rules
- Build Flow
- Linker Fragment Contract
- Root Thread Ownership and Component Init
- Component Service Pattern — IPC as Implementation Detail
- hello_world Component Design
- ping_pong Component Design
- board_console — Board Source Service
- Open Questions
A component is an independently-developed, reusable unit of userspace code that runs inside the microkernel image alongside other components. It is the primary packaging unit for drivers, services, and applications built on top of the microkernel.
Key principles:
- One image, N components. The microkernel produces a single ELF. All components are compiled and linked into that ELF. Isolation is enforced at runtime by the MPU, not by separate binaries.
- Components are opt-in. Every component declares
ENABLED OFFin its CMake manifest (default). Enable them at configure time viapython3 tools/dev.py components enableordev.py build --component. Disabled components contribute zero code to the image. - Only one root thread. The kernel creates exactly one initial userspace context. Exactly one component provides the
ulmk_root_thread()function. That component is responsible for starting all other components' service threads by calling their public init functions in order. - Kernel knows nothing about components. Component names, endpoints, and initialisation order are purely userspace policy. The kernel only sees threads, endpoints, and memory mappings.
- IPC is an implementation detail. Components expose C functions in their public headers, not endpoint handles. Callers never touch IPC directly; the component's client-side library encapsulates it.
- Board services are board sources, not components. Hardware-dependent services (console, clocks, etc.) live in
boards/<board>/and follow the same public C API convention.
my_component/
├── CMakeLists.txt # component descriptor; calls ulmk_component_register()
├── include/
│ └── my_component.h # public C API (init function + service calls)
├── src/
│ ├── server.c # service thread entry point + IPC recv loop
│ └── client.c # client-side wrappers that call into the service via IPC
└── linker.ld.in # (optional) domain memory fragment
- The
CMakeLists.txtmust contain exactly oneulmk_component_register()call. include/is added to the global include path so other enabled components can include it.- Sources listed in
SOURCESare compiled with-DUL_COMPONENT_NAME=<name>defined. linker.ld.inis optional. If present and the component is enabled, it is appended to the generated linker script after all kernel sections.- Every component's public header must export an
<name>_init()function. The root thread calls this explicitly. The component is responsible for preventing double-init.
Declared in cmake/component_api.cmake, auto-included by the top-level build.
ulmk_component_register(
NAME <string> # unique component identifier
ENABLED <ON|OFF> # if OFF, component contributes nothing to the build
SOURCES <files…> # C/ASM sources relative to the component's directory
INCLUDE_DIRS <dirs…> # added to global include path (all other components see these)
REQUIRES <names…> # declared dependencies on other component names
ROOT_THREAD # flag: this component provides ulmk_root_thread()
LINKER_FRAGMENT <file> # optional .ld.in fragment; appended to generated linker script
)| Field | Required | Notes |
|---|---|---|
NAME |
yes | Identifier used in REQUIRES and -DUL_COMPONENT_NAME=<name> |
ENABLED |
yes | Manifest default (OFF in repo). Overridden by ULMK_COMP_<name>_ENABLED cache var (dev.py build) |
SOURCES |
yes | Paths relative to the component directory |
INCLUDE_DIRS |
no | PUBLIC on ulmk_comp_<name>; propagated to REQUIRES dependents |
REQUIRES |
no | Dependency on another component name; see enforcement rules below |
ROOT_THREAD |
no (flag) | Marks the component providing ulmk_root_thread(); exactly one must be ENABLED |
LINKER_FRAGMENT |
no | Appended verbatim after kernel linker sections |
| Scenario | Result |
|---|---|
| Component DISABLED, no one requires it | Silently excluded — no error |
| Component A (ENABLED) requires component B (DISABLED) | Configure-time FATAL_ERROR in ulmk_components_finalize() with dev.py hint |
| Component A (DISABLED) requires component B (ENABLED) | No error — A is not built |
Caller references <dep>_init() but dep DISABLED |
Link error (undefined reference) as secondary guard |
Components default to OFF in every CMakeLists.txt. The developer enables
them explicitly:
python3 tools/dev.py components list
python3 tools/dev.py components enable hello_world ping_pong
python3 tools/dev.py build --board boards/qemu_riscv_virt
python3 tools/dev.py build qemu --board boards/qemu_riscv_virtSelection is stored in .ulmk/components.conf (gitignored). One-shot overrides:
python3 tools/dev.py build --component hello_world --component ping_pong
python3 tools/dev.py build --no-components # kernel-only; uses root_thread stubCMake receives -DULMK_COMP_<name>_ENABLED=ON|OFF for every discovered component.
# components/hello_world/CMakeLists.txt
ulmk_component_register(
NAME hello_world
ENABLED OFF
REQUIRES ping_pong
SOURCES
src/root_thread.c
src/hello_world.c
INCLUDE_DIRS include
ROOT_THREAD
)The build system scans the following locations in order:
1. ${CMAKE_SOURCE_DIR}/components/ built-in kernel components (this repo)
2. ${CMAKE_SOURCE_DIR}/../ulmk_apps/ sibling app directory (fixed name, not configurable)
3. ${ULMK_CHIP_DIR}/ board directory (existing board.cmake policy, unchanged)
The sibling directory is always ulmk_apps — this is not a CMake variable. The build silently skips the sibling scan if the directory does not exist.
For each scan root (1 and 2 above; the board dir is handled separately via board.cmake):
- Enumerate immediate subdirectories (non-recursive at this level).
- If a subdirectory contains a
CMakeLists.txt, calladd_subdirectory()on it. ulmk_component_register()inside thatCMakeLists.txtregisters the component.ENABLED OFF→ component known but contributes nothing.ENABLED ON→ sources, include dirs, and optional linker fragment accumulated.
${ULMK_CHIP_DIR}/ is not component-scanned. Board sources are compiled unconditionally via board.cmake. Board services expose their C API through headers in the board include path; no scan step is needed.
scan kernel/components/ → ulmk_component_register() calls
scan ../ulmk_apps/ → ulmk_component_register() calls (if directory exists)
include ${ULMK_CHIP_DIR}/board.cmake → board sources + flags (unchanged)
validate (ulmk_components_finalize):
• at most one ENABLED component has ROOT_THREAD
• all REQUIRES of ENABLED components are themselves ENABLED
• inter-component target_link_libraries for REQUIRES deps
if no ENABLED component has ROOT_THREAD:
link stub/root_thread_stub.c (minimal ulmk_root_thread → ulmk_thread_exit)
accumulate from ENABLED components:
• each → static library ulmk_comp_<name>
• include dirs → PUBLIC on that library
• domain_data.ld.in snippet via ulmk_add_domain()
compile: kernel sources + board sources + all enabled component sources
link: ulipe.elf -T generated.ld
dev.py build invokes CMake configure + Ninja inside the container. It passes
-DULMK_COMP_<name>_ENABLED=… for every discovered component (from
.ulmk/components.conf and/or --component flags). See tools/dev.py components --help.
dev.py build → cmake configure (QEMU board) + cmake build
dev.py build --clean → rm -rf build/ + cmake configure + cmake build
dev.py run → build (if stale) + qemu-system-tricore
dev.py build --board /p → cmake configure with ULMK_CHIP_DIR=/p + build
| Today | With components |
|---|---|
../ulmk_apps is a single CMakeLists.txt |
Multiple component subdirs, each with its own descriptor |
| Root thread stub always linked | Root thread comes from the ROOT_THREAD component |
| No per-component include paths | Each component exposes include/ globally |
| No per-component linker fragments | Optional linker.ld.in per component |
| dev.py maintains its own source list | dev.py delegates entirely to CMake |
If a component declares LINKER_FRAGMENT, the .ld.in file is appended to the generated linker script after all kernel sections.
SECTIONSblocks usingULMK_DOMAIN_BSS(name)/ULMK_DOMAIN_DATA(name)macros (include/ulmk/linker.h)ALIGN(ULMK_MPU_ALIGN)before each domain boundary
MEMORYblocks — those live in Layer 3 (memory.ld)- Redefinition of kernel regions (
KERNEL_FLASH,KERNEL_RAM,SHARED_RAM,PERIPH) - Absolute address assignments
/* boards/qemu_tc3xx/board_console.ld.in */
.domain_board_console (NOLOAD) :
{
. = ALIGN(ULMK_MPU_ALIGN);
_ulmk_domain_board_console_start = .;
*(.domain_board_console.bss)
*(.domain_board_console.data)
_ulmk_domain_board_console_end = .;
. = ALIGN(ULMK_MPU_ALIGN);
} > KERNEL_RAMExactly one enabled component must declare ROOT_THREAD. The build fails at configure time if zero or more than one component declares it.
Every component's public header exports an <name>_init() function. This function:
- Creates the IPC endpoint and stores it in a module-internal static variable.
- Spawns the service thread, which calls
ulmk_ep_recvon the pre-created endpoint. - Returns the spawned TID (or
ULMK_TID_INVALIDon double-init).
Because the endpoint is created in step 1 — before any other thread runs — client calls to the component's API are safe as soon as <name>_init() returns. No spin-wait, no race.
/* include/my_component.h */
ulmk_tid_t my_component_init(void); /* spawns service thread; endpoint is ready on return */
void my_component_do_thing(int arg); /* client API — calls IPC internally */The root thread calls component inits in whatever order is needed:
void ulmk_root_thread(const ulmk_boot_info_t *info)
{
board_services_init(info); /* board-provided weak-overridable function */
my_component_init();
ulmk_thread_exit();
}board_services_init() follows the same convention: it creates board service endpoints and spawns board service threads before returning. Its weak no-op stub lives in stub/board_services_stub.c. Real boards provide a strong definition in their board sources.
IPC is an implementation mechanism, not part of a component's public interface. Callers use the component's C API; they never manipulate endpoints directly.
my_component/
├── include/my_component.h ← public: init + service calls (no ulmk_ep_t exposed)
└── src/
├── server.c ← private: ulmk_ep_recv loop, business logic
└── client.c ← private: IPC wrappers backing the public API
/* src/client.c — compiled into every caller's address space */
#include <my_component.h>
#include <ulmk/microkernel.h>
#define MY_SVC_MSG_DO_THING 1u
static ulmk_ep_t g_ep;
ulmk_tid_t my_component_init(void)
{
static int done;
if (done)
return ULMK_TID_INVALID;
done = 1;
g_ep = ulmk_ep_create(); /* endpoint ready before server thread starts */
ulmk_thread_attr_t attr = {
.name = "mysvc",
.entry = my_service_entry, /* defined in server.c */
.priority = 5u,
.stack_size = 1024u,
.privilege = ULMK_PRIV_DRIVER,
};
return ulmk_thread_create(&attr);
}
void my_component_do_thing(int arg)
{
ulmk_msg_t msg = { .label = MY_SVC_MSG_DO_THING };
msg.data[0] = (uint32_t)arg;
ulmk_ep_call(g_ep, &msg, NULL);
}/* src/server.c */
#include <ulmk/microkernel.h>
extern ulmk_ep_t g_ep; /* defined in client.c */
void my_service_entry(void *arg)
{
for (;;) {
ulmk_msg_t msg;
ulmk_tid_t sender;
ulmk_ep_recv(g_ep, &msg, &sender);
switch (msg.label) {
case MY_SVC_MSG_DO_THING:
/* handle ... */
break;
}
ulmk_ep_reply(sender, &(ulmk_msg_t){0});
}
}Location: components/hello_world/
Purpose: Reference ROOT_THREAD component. Starts board services, the hello
counter task, and the ping_pong demo via ping_pong_init().
ulmk_component_register(
NAME hello_world
ENABLED OFF
REQUIRES ping_pong
SOURCES
src/root_thread.c
src/hello_world.c
INCLUDE_DIRS include
ROOT_THREAD
)Enabling hello_world without ping_pong fails at configure time.
root_thread.c:
#include <hello_world.h>
#include <ping_pong.h>
#include <ulmk/microkernel.h>
void board_services_init(const ulmk_boot_info_t *info);
void ulmk_root_thread(const ulmk_boot_info_t *info)
{
board_services_init(info);
hello_world_init(info);
ping_pong_init(info);
ulmk_thread_exit();
}hello_world.c:
#include <hello_world.h>
#include <board_console.h> /* board_console_puts(), board_console_putc() */
#include <ulmk/microkernel.h>
/*
* Minimal decimal formatter — hello_world has no dependency on any kernel
* or library service. All output goes through board_console's public C API.
*/
static void print_uint32(uint32_t v)
{
char buf[11];
int i = sizeof(buf) - 1;
buf[i] = '\0';
do {
buf[--i] = '0' + (v % 10u);
v /= 10u;
} while (v && i > 0);
board_console_puts(&buf[i]);
}
static void hello_entry(void *arg)
{
uint32_t n = 0;
for (;;) {
board_console_puts("hello ");
print_uint32(n++);
board_console_putc('\n');
board_timer_sleep_us(1000000u); /* 1 s */
}
}
ulmk_tid_t hello_world_init(const ulmk_boot_info_t *info)
{
static int done;
if (done)
return ULMK_TID_INVALID;
done = 1;
ulmk_thread_attr_t attr = {
.name = "hello",
.entry = hello_entry,
.priority = 10u,
.stack_size = 1024u,
.privilege = ULMK_PRIV_USER,
};
return ulmk_thread_create(&attr);
}Location: components/ping_pong/
Purpose: Second built-in component. Exercises multi-component linking (separate
ulmk_comp_ping_pong.a + MPU domain), synchronous IPC (ep_call / ep_recv /
ep_reply), and board timer/console without board-specific headers.
Dependency: hello_world declares REQUIRES ping_pong and calls
ping_pong_init() from its root thread. Enable both for the demo image:
python3 tools/dev.py components enable hello_world ping_pongcomponents/ping_pong/
├── CMakeLists.txt
├── include/ping_pong.h
└── src/ping_pong.c
ulmk_component_register(
NAME ping_pong
ENABLED OFF
SOURCES src/ping_pong.c
INCLUDE_DIRS include
)No ROOT_THREAD. Enabling ping_pong alone compiles the library but does not
start the threads — only a component with ROOT_THREAD calls ping_pong_init().
board_console is not a portable component. It is a board source compiled via ULMK_BOARD_SOURCES in boards/qemu_tc3xx/board.cmake.
The console MMIO address, peripheral type, and initialisation sequence are chip-specific. Placing board_console in board sources avoids a cross-board abstraction at this stage.
Callers #include <board_console.h> and call functions. No endpoint handle is ever exposed outside the module.
/* boards/qemu_tc3xx/board_console.h */
#ifndef BOARD_CONSOLE_H
#define BOARD_CONSOLE_H
void board_console_putc(char c);
void board_console_puts(const char *s);
#endifboards/qemu_tc3xx/
├── board.cmake # adds board_console.c + board_services.c to ULMK_BOARD_SOURCES
├── board_console.h # public C API — included by any component that uses the console
├── board_console.c # client API + server thread + internal endpoint management
├── board_services.h # declares board_services_init()
└── board_services.c # board_services_init(): creates ep, spawns console thread
board_services.h is also added to the board include path so that root_thread.c in hello_world can call board_services_init() without a compile-time dependency on a specific board's CMakeLists.txt.
A weak no-op stub in stub/board_services_stub.c allows kernel-only builds (no board services) to link cleanly.
/* boards/qemu_tc3xx/board_console.c */
#include <board_console.h>
#include <ulmk/microkernel.h>
#define CONSOLE_MSG_PUTC 1u
/*
* Endpoint created by board_services_init() before the server thread runs.
* Visible to both client API functions and the server entry point.
*/
static ulmk_ep_t g_ep;
/* ---- client API ---- */
void board_console_putc(char c)
{
ulmk_msg_t msg = { .label = CONSOLE_MSG_PUTC };
msg.data[0] = (uint8_t)c;
ulmk_ep_call(g_ep, &msg, NULL);
}
void board_console_puts(const char *s)
{
while (*s)
board_console_putc(*s++);
}
/* ---- server thread ---- */
static void console_server(void *arg)
{
volatile uint32_t *virt = ulmk_mem_map(
(void *)ULMK_BOARD_VIRT_CONSOLE_BASE,
4u,
ULMK_PERM_READ | ULMK_PERM_WRITE,
ULMK_MMAP_PERIPH);
for (;;) {
ulmk_msg_t msg;
ulmk_tid_t sender;
ulmk_ep_recv(g_ep, &msg, &sender);
if (msg.label == CONSOLE_MSG_PUTC)
*virt = (uint32_t)msg.data[0];
ulmk_ep_reply(sender, &(ulmk_msg_t){0});
}
}/* boards/qemu_tc3xx/board_services.c */
#include <board_services.h>
#include <board_console.h>
#include <ulmk/microkernel.h>
extern ulmk_ep_t g_ep; /* defined in board_console.c */
void board_services_init(const ulmk_boot_info_t *info)
{
/*
* Create endpoint before spawning the thread. Any call to
* board_console_putc() after this function returns is safe.
*/
g_ep = ulmk_ep_create();
ulmk_thread_attr_t attr = {
.name = "bcon",
.entry = console_server,
.priority = 1u,
.stack_size = 1024u,
.privilege = ULMK_PRIV_DRIVER,
};
ulmk_thread_create(&attr);
}(No outstanding decisions. Update this section as new open points arise.)