This document covers everything between the kernel's system-call boundary and a running C++ program. That is two things: the per-program runtime harness the build wraps around every executable, and the third-party library ports (mimalloc, libc++ and its dependencies, and Lua) that make a modern C++ userspace possible on a freestanding AArch64 target with no host OS underneath.
None of these libraries expect to run without an operating system. Making each link and run required two things: supplying the handful of primitives it bottoms out on, and patching out the parts that assume Linux/macOS syscalls, a dynamic loader, threads, locales, or a filesystem. The ports were done in the order mimalloc → libc++ (+ libc, libunwind, libc++abi) → Lua, since each layer depends on the one before it.
Every userspace program is statically linked into a self-contained ELF. From the bottom up:
┌────────────────────────────────────────────────┐
│ program (sh, marklind, tc, ...) + main() │
├────────────────────────────────────────────────┤
│ Lua (config/init interpreter) │ optional, per-program
├────────────────────────────────────────────────┤
│ libc++ / libc++abi (STL, exceptions, RTTI) │
│ libunwind (stack unwinding) │
│ libc (embeddedartistry+newlib)│
├────────────────────────────────────────────────┤
│ mimalloc (malloc/free/new/delete) │ interposes everything above
├────────────────────────────────────────────────┤
│ libuser (crt0, syscalls, RPC, services)│
├────────────────────────────────────────────────┤
│ kernel ── svc ──► syscalls / IPC / events │
└────────────────────────────────────────────────┘
libuser (lib/user/) is the glue. It holds the syscall
wrappers, the RPC framework, the name-server/clock/serial service clients, and
the C-runtime shims, and it bundles mimalloc, libc++, and libunwind into itself
(lib/build.cmake):
add_library(user STATIC ${USER_LIB_SOURCES})
target_link_libraries(user mimalloc cxx unwind) # cxx pulls in c (libc)The port order follows the dependency chain. mimalloc must be first because it
interposes malloc/free/operator new/operator delete for every layer
above it (built with MI_MALLOC_OVERRIDE). libc and libc++abi/libunwind form the
C++ runtime the STL needs. Lua sits on top of a working C++ runtime and heap, so
it comes last.
There is no execve, no ELF interpreter, and no libc startup object from a host
toolchain, so each program gets its own hand-built startup path. The kernel
loads the ELF, maps its segments, and erets to the ELF entry point. That entry
point is a custom _start, not a vendor crt0.o.
lib/user/crt0.cc provides _start. In pseudocode:
[[noreturn]] void _start() {
// C++ static constructors
run_init_array();
// initialize the heap "brk"
__program_segment_end = UserHeapRange.start;
if (MyParentTid() != INVALID) {
// spawned by another task: receive argv/env over RPC from the parent
rpc::server().add_handler<_get_thread_args>();
rpc::server().listen_once();
} else {
// initd has no parent; fake argv[0]
args_.push_back("");
}
// so a parent can wait_for us
rpc::server().add_handler<_signal_thread_exit>();
// ABI shim: wire up stdio (see below)
__crt0_init_hook();
main();
// syscall Exit(), signals parent
exit(0);
}Two details:
__program_segment_endis the heap break. It starts at the base of the lazily-committed heap region provided by the kernel and is bumped by mimalloc's allocator backend (see mimalloc). There is nosbrksyscall. Growth is just a pointer moving into already-reserved, fault-committed virtual space.- Arguments arrive over IPC.
thread::create()in userspace hands the child itsargv/env/stdio path by sending it aThreadInitArgsPacket. The child receives it in_startvia a one-shot RPC handshake beforemain(). This replaces theargc/argv/envpa normal loader would push onto the stack.
The C runtime needs a couple of program-specific hooks. The most important is
__crt0_init_hook(), which sets up stdin/stdout/stderr. There are two
mutually-exclusive implementations, and exactly one is compiled into each
program. They are deliberately excluded from libuser so they cannot both be
linked:
| Shim | Used by | What __crt0_init_hook does |
|---|---|---|
_user_abi.cc |
ordinary programs | who_is() the program's stdio device via the name server, fopen it, and point stdin/stdout/stderr at it (line-buffered). Also opens /var/log. |
_service_abi.cc |
SYSTEM_COMPONENT boot servers |
Leave stdin/stdout/stderr null, so no terminal. Only /var/log is opened. |
The split exists because the early boot servers (the I/O servers, name server,
clock server) come up before there is a name server to ask or a terminal to
open. They cannot depend on the services an ordinary program takes for granted.
A program is marked SYSTEM_COMPONENT in its add_user_program(...) call, which
selects _service_abi.cc automatically (scripts/helpers.cmake).
Consequence for diagnostics. A system component has no stdout, so it cannot
printf to a terminal: user::print() targets stdout and goes nowhere. Its only
diagnostic channel is user::log(), which is routed to the kernel log rather
than to any terminal: user::log → putchar_log_ → /var/log, which the
dmesg tool reads back. (When /var/log is
compiled out, putchar_log_ falls back to writing the raw UART0 console
directly, the same wire the kernel's own libkernel::log uses.) Ordinary
programs get both channels: print/printf to their terminal and log to
/var/log.
There is no VFS in the kernel. A FILE is an RPC client to an I/O server
plus buffers (lib/user/private/_IO_FILE.h):
struct _IO_FILE {
rpc_wrapper::io::TxPacket* buffer; // pending transmit data
CircularFifo<256, char>* rx_buffer; // received bytes
user::rpc::client* client; // the I/O server for this stream
int channel; // which UART / pty channel
int buffer_type; // _IOFBF / _IOLBF / _IONBF
};The C stdio surface (fopen, fread/fwrite, fgetc/fputc, fflush,
putchar, …) is reimplemented in lib/user/_c_compat.cc
on top of user::services::{putc,getc,fflush,open,fdopen}, which turn into
Send()s to the relevant server. fopen accepts only mode "rw". Path
resolution (realpath, getcwd, chdir) is stubbed, since there is no real
filesystem hierarchy.
add_user_program() (scripts/helpers.cmake) is the
single factory for every executable in the initrd. It:
- compiles the program sources plus the chosen ABI shim,
- links against
user(which drags in mimalloc / libc++ / libunwind / libc), - applies the shared linker script
lib/user/linker.ld(ENTRY(_start), image based at0x40000, withPT_LOADsegments and aPT_TLSsegment; it also defines__init_array_*,__eh_frame_*, and__program_end), and - registers the target so
make_initrd.pypacks it into the ramdisk.
The kernel reads those program headers when creating the task, which is how the region permissions (RX text, RO rodata, RW data) and the TLS base come to exist.
The linking model: fully static, non-PIE, fixed-address. Every runtime
library is a static archive (libuser.a, libmimalloc.a, libc++.a,
libc++abi.a, libc.a, libunwind.a, and liblua.a where used). There are no
shared objects and no dynamic linking anywhere in the system. The
STANDARD_LINKER_FLAGS settle the model:
-nostdlib -ffreestanding --target=aarch64-none-elf
-fno-pie -static -Xlinker --apply-dynamic-relocs
-fuse-ld=lld -Wl,--gc-sections -Wl,--as-needed
-static -fno-piemeans the executable has noPT_INTERP, no runtime loader, no GOT/PLT to resolve at startup, and a fixed link address (0x40000fromlinker.ld) that equals its load address. The binary is position-dependent.--apply-dynamic-relocsmakes LLD apply anyR_AARCH64_RELATIVEentries into the image at link time, so the on-disk ELF is already a ready-to-run memory image with zero load-time fixups.--gc-sections/--as-neededdead-strip unreferenced code and archives. This is what keeps a program that never touches, say,<regex>from dragging in the world.
The kernel's "loader" is therefore trivial. finalize_thread_create()
(kernel/core/thread.cc) copies each PT_LOAD
segment to its p_vaddr and jumps to e_entry, with no relocation and no symbol
resolution. Every task runs in its own private address space, so they can all
be linked at the same fixed base 0x40000 with no conflict and no need for
ASLR or PIE.
Printing a symbolized C++ backtrace on a fault or uncaught exception spans the EL0/EL1 boundary, and neither side can do it alone: the kernel has the fault but not the symbols or the faulting address space, while the task has the symbols but is no longer running. The pipeline:
-
Frame-pointer chains everywhere. Both the kernel and userspace are built with
-fno-omit-frame-pointer(toolchain.cmake), so every frame stores[saved FP, saved LR]at[x29]and the stack can be walked without unwind tables.walk_stackframereads the livefp, then followsfp → *(fp)for the caller frame andpc = *(fp + 8), checking at each step thatfpstays 16-byte-aligned inside the task's stack region. -
On-device symbolization against the task's own ELF. The whole initrd is mapped read-only into each address space (the
initrdregion), including every program's unstripped symbol table, so a task can parse its own ELF at runtime.elf::Parser::get_symbol_name(pc)scans the symbol table for the symbol with the greatestst_value <= pc(nearest preceding) and resolves its name in the string table. The mangled name is then run through libc++abi's__cxa_demanglefor a readable C++ signature. No hostaddr2linestep is involved. -
The kernel bounces a faulting task back into EL0 to print it. On a fatal exception,
handle_errorrewrites the faulting task'sELRto its_Exitentry point and returns. That address was recorded at task-creation time viaget_func_addr("_Exit"). On the nexteretthe task "resumes" straight into its exit path, which callsuser::thread::print_stack_trace()in its own address space with its own symbols. Uncaught exceptions and non-zero exits reach the sameprint_stack_trace()directly fromuser::thread::exit().
A bad memory access in, say, a train coroutine thus prints a demangled, per-frame
backtrace to the log (dmesg), resolved
entirely on the device.
Each port follows the same shape in its build.cmake. FetchContent clones a
pinned commit, try_apply_patch applies a local .patch/*.patch, overlay
files are copied in, and the result is compiled with the freestanding userspace
flags (-ffreestanding -nostdinc++ -nostdlib++, the project's --target=aarch64-none-elf).
- Source: microsoft/mimalloc
v2.1.9 - Files:
build.cmake,.patch,src/prim/cs452-kernel/prim.cc
mimalloc is normally an OS allocator: it mmaps memory, manages threads, reads
environment variables, and interposes on the system libc. Its front end (the fast
malloc/free path) is kept; its entire OS backend is replaced.
The custom OS primitive layer. mimalloc funnels all OS interaction through a
small set of _mi_prim_* functions. src/prim/prim.c's #include "unix/prim.c"
is swapped for a freestanding cs452-kernel/prim.cc that maps them onto the
runtime:
// The allocator "OS": a bump pointer over the kernel's lazily-committed heap.
int _mi_prim_alloc(void* hint, size_t size, ..., void** addr) {
*addr = (void*)__program_segment_end; // hand out the current brk
__program_segment_end += size; // ...and bump it
return 0;
}
int _mi_prim_free(void*, size_t) { return 0; } // heap never shrinks
int _mi_prim_commit(void*, size_t, bool*) { return 0; } // pages fault in lazily
mi_msecs_t _mi_prim_clock_now() { return SysTimer.hi:lo; } // MMIO clock
void _mi_prim_out_stderr(const char* msg) { user::print(msg); }
bool _mi_prim_random_buf(void*, size_t) { return false; } // no entropy source
// numa=1, no huge pages, no getenv, single-threaded thread-init no-ops_mi_prim_mem_init advertises 4 KiB pages, a 48-bit address space, no
overcommit, and no partial free, matching what the kernel's VM offers.
Patches to sever host dependencies (.patch):
- No C++
<atomic>/<thread>/<mutex>. Force the C11stdatomic.hpath;mi_atomic_yield()becomesuser::thread::yield(); thestd::mutex-based lock path is compiled out. - No TLS.
mi_decl_thread(__thread) is defined empty, so there is one heap per task and no thread-local heaps, since a task is single-threaded. - No environment / options parsing. The whole
mi_option_initenv-scanning routine is deleted; options just take their defaults. realpath→nullptr,operator newbad-alloc handler path disabled, assertion failures routed touser::printthenabort().- Single-TU build. Compiled via
src/static.cc(a unity build of all.cfiles) withMI_MALLOC_OVERRIDE, so linking it first makes itsmalloc/free/new/deletethe definitions everyone else resolves to.
- Sources: embeddedartistry/libc
ca1324c(implementation) + newlib-cygwin5e5e51f(headers only) +openlibmandprintfsubmodules - Files:
build.cmake,libc.patch,newlib.patch
libc++ needs a C library, assembled here from three sources.
embeddedartistry's libc supplies ctype/stdlib/stdio/string/wchar,
openlibm supplies the math functions (only the handful actually used: pow,
sqrt, fmod, exp, and the s_* helpers), and eyalroz's printf handles
formatted output. newlib is pulled in only as a header overlay. Its sys/ and
machine/ trees, _ansi.h, newlib.h, time.h, and unistd.h are copied over
the embeddedartistry include tree to supply the POSIX/filesystem declarations
embeddedartistry itself lacked. None of newlib's .c is compiled.
The overlay was added to improve filesystem support (commit 3fd481a).
embeddedartistry's own header set was too thin for POSIX/<filesystem>: no proper
sys/ type layer (ino_t and friends), no <dirent.h>, and only a hand-written
stub unistd.h (just PATH_MAX, chdir, getcwd, pathconf, realpath).
libc++'s <filesystem> (directory_iterator, operations) and the shell's
directory listing need those declarations to compile and link. Rather than
hand-author a full POSIX header tree, newlib's mature one (its sys//machine/
types, real unistd.h/time.h) is grafted in over the stub unistd.h, with a
local dirent.h added on top. It
stays headers-only because embeddedartistry's freestanding-friendly
implementations are still preferred over newlib's, which are bound to its
struct _reent reentrancy model, syscall-stub layer, and build system.
(_SYS__STDINT_H is defined and newlib's <sys/_stdint.h> include patched out so
it does not clash with the local stdint.)
Key patches:
FILEis ours, not newlib's (libc.patch). The built-instruct __sFILE/__sbufdefinitions are deleted fromstdio.h;FILEstays an opaque__sFILE_fake, defined as_IO_FILE. Added alongside it: theextern FILE* stdin/stdout/stderrdeclarations, theSEEK_*,_IO*BF, andBUFSIZmacros, and aPATH_MAX.- Weak
calloc/realloc/reallocf. Marked__attribute__((weak))so mimalloc's implementations win at link time instead of colliding. realpathdeclared (and stubbed in the runtime),putchar/putchar_nativeremoved (a custom one is provided, routed through the I/O server).- newlib header fixes (
newlib.patch): include_wint_tinsys/_types.h, drop a stray<sys/_stdint.h>include that conflicts with the local stdint.
- Source: llvm-mirror/libunwind
3e6ec2a - Files:
build.cmake,.patch
C++ exceptions and RTTI are enabled in userspace (toolchain flags
-fexceptions -frtti -funwind-tables), so throw/catch needs a real unwinder
that reads the .eh_frame tables and runs the custom cxa_personality. Crash
backtraces, by contrast, do not use libunwind at all; they walk the
frame-pointer chain manually (see Crash stack traces).
Patches:
- AArch64 only.
__libunwind_config.his edited to disable every other target (x86/x86-64/PPC/ARM/OR1K/MIPS/SPARC) and the DWARF index support, leaving_LIBUNWIND_TARGET_AARCH64. - Built with
-D_LIBUNWIND_IS_BAREMETAL -D_LIBUNWIND_HAS_NO_THREADS -D__aarch64__and-fno-lto.
- Source: embeddedartistry/libcpp
578dbdf(withlibcxx+libcxxabisubmodules, rewritten to HTTPS at configure time) - Files:
build.cmake,libcxx.patch,libcpp-repo.patch
This is the STL and its ABI runtime, built with _LIBCPP_HAS_NO_THREADS-style
baremetal settings, the LLVM unwinder, and a custom GCC-compatible
cxa_personality.cpp. Notable patches:
- Coroutines turned back on. The
_LIBCPP_HAS_NO_COROUTINESguard is commented out in__config, and the experimental-coroutines namespace macros are redirected intostd::. The train controller is written with C++ coroutines, so this is required. - All
operator new/operator deleteoverloads madeweak. Every global allocation operator in<new>gets__attribute__((weak))so mimalloc's overrides bind instead of libc++'s, without duplicate-symbol errors. - No
#include_next.<cstddef>(and friends) can't chain to a host<stddef.h>in a freestanding build, so#include_nextbecomes#include. <filesystem>gutted. All the POSIX-backed operations are#if 0'd out (statvfs,fcntl,utimes,directory_entry::__do_refresh, the filesystem clock,__copy,__equivalent,__create_symlink,__temp_directory_path), because there is no backing filesystem syscall layer.- Locale is a no-op. The baremetal locale management header is patched
(
locale_ttypedef removed to avoid a clash), andabort_messageis routed tofprintf(stderr, ...).
- Source: lua.org
5.4.7 - Files:
build.cmake,.patch
Lua is the init/config interpreter: initd runs init.lua
to bring the system up. It is the easiest port: already written for freestanding
C, though it still assumes an OS in a few places. Patches:
- Drop OS-dependent standard modules in
linit.c:package(dynamic loading),coroutine,io,os,math(would need compiler-rt), andutf8(needs locale) are commented out ofloadedlibs. Onlybase,table, andstringare registered. - No file loading.
luaL_loadfilexreturns-1;dofile/loadfileare removed from the base library. Scripts are provided as in-memory strings. - Clock/seed from the kernel.
time()/clock()(used for PRNG seeding and table-sort pivot randomization) are replaced withuser::services::time()(the clock server). - No signals / no locale.
l_signalTbecomesint;lua_getlocaledecpoint()is hard-coded to'.'. - Compiled as C++ (
LANGUAGE CXX) and linked againstcxx+mimalloc.
| Concern | How it's resolved |
|---|---|
No loader / crt0.o |
Custom _start in crt0.cc; shared linker.ld |
No argc/argv/envp on the stack |
Parent sends args over RPC; child receives them in _start |
No sbrk/mmap |
mimalloc bumps __program_segment_end into the kernel's lazily-committed heap |
malloc/new need one owner |
mimalloc built with MI_MALLOC_OVERRIDE, linked first; libc/libc++ allocators made weak |
| No filesystem | FILE is an RPC client to an I/O server; <filesystem> and path APIs stubbed |
| No threads | Single-threaded tasks; TLS/atomics/mutex paths compiled out; yield() for spin backoff |
| No environment / locale / signals | getenv over an in-memory map; locale no-op; signals typedef'd away |
C++ exceptions (throw/catch) |
libunwind (AArch64-only, baremetal) + custom cxa_personality over .eh_frame |
| Crash backtraces | FP-chain walk + on-device ELF symbol lookup + __cxa_demangle; kernel redirects faults to _Exit |
| Early boot vs. normal programs | Two ABI shims (_service_abi.cc vs _user_abi.cc) chosen by SYSTEM_COMPONENT |
| System components have no terminal | Diagnostics via user::log() → /var/log (read by dmesg), falling back to the UART0 kernel console |
See CONTRIBUTING.md for environment setup,
docs/kernel.md for the kernel design, and
docs/writing-a-program.md /
docs/writing-a-service.md for how to build on this
runtime.