Skip to content

Latest commit

 

History

History
451 lines (378 loc) · 24 KB

runtime-abi.md

File metadata and controls

451 lines (378 loc) · 24 KB

Userspace runtime & library ports

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.

The runtime stack

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.

Per-program harness

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.

Entry: crt0

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_end is 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 no sbrk syscall. Growth is just a pointer moving into already-reserved, fault-committed virtual space.
  • Arguments arrive over IPC. thread::create() in userspace hands the child its argv/env/stdio path by sending it a ThreadInitArgsPacket. The child receives it in _start via a one-shot RPC handshake before main(). This replaces the argc/argv/envp a normal loader would push onto the stack.

The two ABI shims

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.

stdio is RPC

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.

How a program is linked

add_user_program() (scripts/helpers.cmake) is the single factory for every executable in the initrd. It:

  1. compiles the program sources plus the chosen ABI shim,
  2. links against user (which drags in mimalloc / libc++ / libunwind / libc),
  3. applies the shared linker script lib/user/linker.ld (ENTRY(_start), image based at 0x40000, with PT_LOAD segments and a PT_TLS segment; it also defines __init_array_*, __eh_frame_*, and __program_end), and
  4. registers the target so make_initrd.py packs 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-pie means the executable has no PT_INTERP, no runtime loader, no GOT/PLT to resolve at startup, and a fixed link address (0x40000 from linker.ld) that equals its load address. The binary is position-dependent.
  • --apply-dynamic-relocs makes LLD apply any R_AARCH64_RELATIVE entries 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-needed dead-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.

Crash stack traces

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:

  1. 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_stackframe reads the live fp, then follows fp → *(fp) for the caller frame and pc = *(fp + 8), checking at each step that fp stays 16-byte-aligned inside the task's stack region.

  2. On-device symbolization against the task's own ELF. The whole initrd is mapped read-only into each address space (the initrd region), 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 greatest st_value <= pc (nearest preceding) and resolves its name in the string table. The mangled name is then run through libc++abi's __cxa_demangle for a readable C++ signature. No host addr2line step is involved.

  3. The kernel bounces a faulting task back into EL0 to print it. On a fatal exception, handle_error rewrites the faulting task's ELR to its _Exit entry point and returns. That address was recorded at task-creation time via get_func_addr("_Exit"). On the next eret the task "resumes" straight into its exit path, which calls user::thread::print_stack_trace() in its own address space with its own symbols. Uncaught exceptions and non-zero exits reach the same print_stack_trace() directly from user::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.

Library ports

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).

1. mimalloc (the allocator)

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 C11 stdatomic.h path; mi_atomic_yield() becomes user::thread::yield(); the std::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_init env-scanning routine is deleted; options just take their defaults.
  • realpath → nullptr, operator new bad-alloc handler path disabled, assertion failures routed to user::print then abort().
  • Single-TU build. Compiled via src/static.cc (a unity build of all .c files) with MI_MALLOC_OVERRIDE, so linking it first makes its malloc/free/new/delete the definitions everyone else resolves to.

2. libc (newlib headers + embeddedartistry + openlibm)

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:

  • FILE is ours, not newlib's (libc.patch). The built-in struct __sFILE/__sbuf definitions are deleted from stdio.h; FILE stays an opaque __sFILE_fake, defined as _IO_FILE. Added alongside it: the extern FILE* stdin/stdout/stderr declarations, the SEEK_*, _IO*BF, and BUFSIZ macros, and a PATH_MAX.
  • Weak calloc/realloc/reallocf. Marked __attribute__((weak)) so mimalloc's implementations win at link time instead of colliding.
  • realpath declared (and stubbed in the runtime), putchar/putchar_native removed (a custom one is provided, routed through the I/O server).
  • newlib header fixes (newlib.patch): include _wint_t in sys/_types.h, drop a stray <sys/_stdint.h> include that conflicts with the local stdint.

3. libunwind

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.h is 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.

4. libc++ / libc++abi

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_COROUTINES guard is commented out in __config, and the experimental-coroutines namespace macros are redirected into std::. The train controller is written with C++ coroutines, so this is required.
  • All operator new/operator delete overloads made weak. 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_next becomes #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_t typedef removed to avoid a clash), and abort_message is routed to fprintf(stderr, ...).

5. Lua

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), and utf8 (needs locale) are commented out of loadedlibs. Only base, table, and string are registered.
  • No file loading. luaL_loadfilex returns -1; dofile/loadfile are 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 with user::services::time() (the clock server).
  • No signals / no locale. l_signalT becomes int; lua_getlocaledecpoint() is hard-coded to '.'.
  • Compiled as C++ (LANGUAGE CXX) and linked against cxx + mimalloc.

Summary of hacks

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.