Reader: someone cross-building for another operating system from this one.
The question this chapter answers: how does one source tree build for several hosted targets without the machine having their toolchains.
Not here: targets with no operating system, which are 40 — Bare-Metal, and how targets are named, which is 21 — The Target Triple.
Conventional cross-compilation is served by a payload. A toolchain is built for one target, its driver has exactly one answer, and reaching a second target means obtaining a second toolchain. The number of payloads a distribution must publish is therefore the number of host-target pairs it supports.
openkal changes what is being crossed. The target side — the platform interface, the C library, the compiler runtime and the C++ runtime — becomes a set of packages resolved from the dependency graph and compiled from source by whichever compiler is running. What remains for the compiler is code generation, and one Clang binary emits every object format it was built with.
This document states the model, what a project writes, what the ecosystem supplies, and the limits that have been measured.
An ecosystem of N platforms and M architectures requires N implementations of one interface rather than N×M toolchains. The count follows from where the target side lives: a package built from source is built for whatever target the compiler is asked to emit, so a platform implementation is written once and reaches every architecture the compiler supports.
The claim is verified by a matrix of three hosts and three targets, each cell building one source and running the result.
A build over openkal answers three different questions, and until this
release one macro (_WIN32) answered two of them at once — the root cause
of every openkal-Windows failure whose diagnosis named a missing platform
header: the code was asking "is this openkal" through a macro that actually
meant "is the Windows CRT present."
| Family | States | Defined by | Example |
|---|---|---|---|
| kernel ABI | kal_* is callable, and behaves the same on every platform |
the layer providing mcpp:kernel-abi=openkal |
__OPENKAL__ |
| C environment | the shape of the C environment source sees | the layer providing mcpp:c-abi=<impl>, via [c-abi] |
__unix__, _WIN32, __MINGW32__ |
| system & architecture | the underlying OS and processor | the target triple | __linux__, __APPLE__, __x86_64__ |
A FOURTH fact rides along with the C-environment row without being the same
question: the object format the linker produces — PE, ELF, Mach-O — is
a property of the target triple, not of the C environment presents
selects, and the two can disagree. presents = "posix" on Windows still
links PE; the only name portable third-party code has for that specific
combination, "PE format with a POSIX-presenting C environment," is
__CYGWIN__/__CYGWIN32__, which the Cygwin-flavoured realisation
therefore leaves DEFINED rather than folding into the three rows above —
see 22's own note
for the full trade-off. Since 2026.9.21.1 __CYGWIN__ is no longer the only
name for that fact: mcpp defines __MCPP_TARGET_<OS>__ for every target
(docs/21, "The macros mcpp defines"), so source needing to know the target
under a presented environment has a name mcpp owns. __CYGWIN__ remains
defined while the ecosystem's installed headers move onto the new name, and
is then withdrawn. Reading either as a fourth C-environment macro, rather
than as what they are — a fact about the TARGET, not about the environment
presented above it — is exactly the shape of confusion this whole section
exists to head off.
__OPENKAL__ — the rule. The engine defines it, for every target-side
unit, whenever the resolved kernel-abi layer's interface name is
openkal — read from the LAYER's value, never from a package name, so a
second implementation (openkal-macos, openkal-opensbi, …) needs no
engine change.
Allowed: gating whether a call site invokes kal_* at all. Its meaning is
identical on every target, so using it this way never smuggles platform
information into source that is supposed to be implementation-agnostic.
Forbidden: selecting a header, inferring whether _WIN32 is real,
working around a missing SDK, or telling linux/windows/macos apart.
Those are the C-environment layer's or the platform layer's questions —
write cfg(c-abi = "…") or cfg(kernel-abi = "…") in the manifest instead
(and see 22 — Adaptation To The Resolved Target Side
for the predicate grammar).
Platform units. A package that itself needs the platform's own
environment never reads __OPENKAL__ or any other macro to work that out —
the boundary is stated in the manifest, not inferred from source, and
everything crossing it is still fixed-width (SPEC §5.4). Two different
packages reach [package] c-environment = "platform" (docs/22) by two
different routes:
- A
mcpp:kernel-abi=<impl>provider (openkal-windows, say) gets it inferred, fromprovidesalone — such a package IS the platform boundary by definition, so it never has to write the key itself, and every already-released implementation is covered with no version bump. - An ordinary package that is not a kernel-abi provider but still has
platform-bound units of its own — a shim under 06's private dependency
pattern,
say — states the key EXPLICITLY, because the engine has no
providesentry to infer it from (design §5.3).
An explicit key always wins over the inference where both could apply (docs/22's own precedence note) — but there is no way today to write "not platform" back, so a kernel-abi provider that, unusually, needs the graph's presented C environment after all is the only case where this matters in practice.
[dependencies]
openkal-llvm-runtime = "0.1.1"
[toolchain]
default = "llvm@22.1.8"Two lines. The first selects three layers of the target side; the second names a compiler and says nothing about where anything else comes from.
Targets are given on the command line:
mcpp build --target x86_64-linux
mcpp build --target aarch64-macos
mcpp build --target x86_64-windows-gnuNo [target.<triple>] section is required for a hosted target, and no
preprocessor directive is required in the source. A worked example is
examples/06-openkal-cross.
| Package | Layer | Content |
|---|---|---|
openkal |
— | the specification, and the C++ modules that declare it |
openkal-linux |
kernel-abi |
the reference implementation, on Linux system calls |
openkal-macos |
kernel-abi |
on the macOS system-call surface |
openkal-windows |
kernel-abi |
on Win32 and the object manager, using no C runtime symbol |
openkal-opensbi |
kernel-abi |
on the RISC-V Supervisor Binary Interface, no operating system |
openkal-uefi |
kernel-abi |
on UEFI Boot Services, before an operating system exists |
openkal-musl |
c-abi |
musl redirected onto openkal, ported once |
openkal-llvm-runtime |
compiler-runtime, c++-abi |
compiler-rt builtins, libunwind, libc++abi and libc++, configured for openkal-musl |
A project names the last of these. The others follow from its dependencies.
openkal-llvm-runtime declares the requirement rather than leaving it to be
discovered:
requires = ["mcpp:compiler=llvm"]Its sources are libc++'s, and its std module source in particular is compiled
by Clang. Handing that source to GCC fails inside libc++'s own headers, in a
message naming a file the reader has never opened:
fatal error: __config: No such file or directory
With the requirement declared, the build refuses the combination before it compiles anything, and names the command that selects a compiler which satisfies it.
The target row of mcpp's own vocabulary may carry a toolchain convention. That convention names the payload which supplies that target's C library, and it applies only when two conditions hold: the manifest states nothing for the target, and nothing in the dependency graph supplies the target's system.
The second condition is knowable only after the graph is resolved. A project
whose C library comes from openkal-musl therefore keeps the compiler it asked
for, while a project with no dependencies still receives the payload the row
names. Both behaviours were measured; deciding either way in advance was wrong
for the other.
On Linux the third segment of a target triple names the C library. Under openkal the C library comes from the graph, so a triple that names one states a request the graph may not honour:
mcpp build --target x86_64-linux-gnu # asks for glibc
c-abi musl (openkal-musl@0.3.3, graph)
The graph decides. Omitting the segment states no request and produces the same artifact:
mcpp build --target x86_64-linux
The build reports the mismatch when the segment is present and disagrees. It is
a report rather than a refusal, because the segment is ignored rather than
violated. Measured on one host, x86_64-linux against x86_64-linux-musl: the
two executables differ, and after stripping they are byte-identical. What
differs is the debug information, which records the output directory, and the
directory is named after the triple. The code is the same code.
On Windows the same segment names the object ABI instead — gnu for PE with the
GNU ABI, msvc for PE with Microsoft's — and both are compatible with more than
one C library. The mismatch report is therefore scoped to platforms where the
segment names a C library.
Silence is right as a diagnostic and insufficient as a report. A reader sees
Target x86_64-windows-gnu → x86_64-w64-windows-gnu
c-abi musl (openkal-musl@0.3.3, graph)
finds no row called gnu, and maps it to the nearest thing that resembles a C
library name.
Measured on the artefact of exactly that build:
| Observation | Value |
|---|---|
| imported libraries | ntdll, KERNEL32, SHELL32 — no msvcrt, no ucrtbase |
Itanium-mangled symbols (_Z…) |
4507 |
MSVC-mangled symbols (?…) |
0 |
The first row is why c-abi musl is honest: none of MinGW's C runtime is
linked. The other two are what gnu selected — the Itanium C++ ABI rather than
Microsoft's.
That correspondence is to no row of the report, and the absence is the point.
The five layers record who supplies each layer; gnu names a convention the
objects follow, which several layers must agree on. Reading it as c++-abi libc++ is a second wrong answer: libc++ is one implementation of the standard
library and libstdc++ is another, and both sit on the Itanium ABI.
The report therefore names the ABI itself, whose name appears in no row and so cannot be mistaken for one:
Target x86_64-windows-gnu → x86_64-w64-windows-gnu (gnu selects the Itanium C++ ABI, not a C library)
The segment carries a different axis on each platform — the C library on Linux, the object ABI on Windows, the object format where there is no operating system — and one value records which, rather than a boolean recording only whether the first case holds.
The three platforms mcpp added target rows for in 2026.9.11.3 are not one question. What decides each is where its implementation would have to sit relative to a C library, and the answers are different.
openkal-linux is written on the Linux kernel's own system-call interface and
borrows nothing from any C library — that is what lets it be placed beneath one.
Android's kernel is Linux, the system-call ABI for a given architecture is
the same, and src/sys.h dispatches on __x86_64__ / __aarch64__, which is
the architecture rather than the operating system. Nothing in it is glibc's or
bionic's.
So a portable program needs no new line. cfg(os = "linux") is true for an
Android triple, because Android is an env value on a linux OS — the
modelling decision 21 — The Target Triple records —
and the implementation is selected by the line a Linux consumer already writes:
[target.'cfg(os = "linux")'.dependencies]
openkal-linux = "0.12.0"Measured 2026-09-11, a program written against openkal and nothing else — no C
library, no import std:
mcpp build --target x86_64-linux-android
kernel-abi openkal (openkal-linux@0.12.0, graph)
-> ELF 64-bit LSB pie, x86-64, interpreter /system/bin/linker64
mcpp build --target aarch64-linux-android
-> ELF 64-bit LSB pie, ARM aarch64, same interpreter
and the x86_64 artifact, pushed to an API 24 emulator image and executed:
openkal: 1-2-3 exit 0
openkal-linux itself also compiles for both Android targets unchanged, which
is the weaker claim of the two and is worth stating separately: the first says
the implementation builds, the second says a program over it runs.
The same argument applies on Apple's side: iOS and macOS share the Darwin
kernel, the same call numbers and the same calling convention, and
openkal-macos is arch-dispatched the same way. What differs between them is
the SDK and the deployment-target flag, and both belong to the build tool
rather than to the implementation — so iOS is one cfg line in a manifest and
no new package.
What was blocked was the SDK, and what unblocked it was asking a smaller
question. The iPhoneOS and iPhoneSimulator SDKs ship inside Xcode and are not
redistributable, which bounds packaging them. It does not bound locating
them: aarch64-macos has been verified on exactly that split since long
before these rows existed — xim:llvm compiles and the machine's macOS SDK is
found through xcrun. The iOS rows take the same split with a second SDK, so
they pin llvm@22.1.8 and carry no sysroot entry, because that column names a
package and a located directory is not one.
The consequence for this document is that the rows are no longer a structural
argument. openkal-macos compiles for them, and what a reader needs to know is
that the SDK is a named host dependency — one of exactly two this platform adds,
the other being simctl — and that its absence is a refusal naming the SDK
rather than a build that quietly produces a macOS artefact.
Emscripten is the one of the three that changes the model rather than extending
it. There is no kernel and there are no system calls to issue: Emscripten
supplies its own C library over a JavaScript host. An openkal implementation for
it therefore cannot be written the way openkal-linux is — beneath a C library
— and has to sit above one. The specification permits exactly that ("an
implementation may be built upon a C library, beneath one, or without one"), so
this was new software rather than a sharing decision.
openkal-emscripten is the first implementation in this ecosystem written in
that direction. That makes the code thin and not easy: a forward and an error
translation is most of each function, and what it has to get right is the
places where the C library's vocabulary and openkal's do not correspond —
the granularity that is an alignment and not a page, a monotonic clock whose
resolution a browser deliberately coarsens, a terminal that exists under node
and not in a page.
A partial surface is a conforming one, and the specification says how. Clause 6.2 gives three times, each the earliest at which the information exists, and the implementation's three groups get three different treatments:
| group | treatment | the reason |
|---|---|---|
stream, fs, time, env, memory, random, abort, terminal |
provided, forwarding to Emscripten's libc | MEMFS and the JavaScript host serve all of these |
net, datagram, timeout |
provided, with the capability word reporting what is exercisable | the calls are real and the transport is a WebSocket proxy, so kal_net_props claims neither IPv6 nor half-close |
process, exec, space |
NOT PROVIDED | there is no fork, no exec and no second address space |
The third row is the decision worth stating plainly: an absent symbol is the report. Measured:
wasm-ld: error: obj/main.o: undefined symbol: kal_process_spawn
which is clause 6.2's second time. Providing kal_process_spawn so that it
returned an error would be the shape the specification forbids — present and
always failing, which the caller cannot tell from a condition — and it would
move a fact known at link time to run time.
openkal.task is carried by a feature for a reason specific to this platform:
threads need -pthread, which selects a different C library build, a different
memory model and a different loader contract. Without the feature the
translation unit is empty and the eight symbols do not exist, which is the same
treatment the three absent interfaces get. With it they do, and
kal_interfaces() follows the link rather than a name the package invented.
None of this replaces the payload route. wasm32-emscripten is still served
the ordinary way — xim:emsdk ships the compiler, the sysroot and a libc++
module surface, so a program that uses import std builds and runs for the Web
with openkal not involved at all, which is what the row's verified tier
records. openkal is what a program uses when it wants one source above several
platform interfaces.
| platform | implementation | status |
|---|---|---|
| Linux (glibc, musl) | openkal-linux |
the reference implementation |
| Android (both ABIs) | openkal-linux, unchanged |
builds; a program over it ran on an emulator |
| macOS | openkal-macos |
on the macOS system-call surface |
| iOS, iOS simulator | openkal-macos, unchanged |
Darwin is Darwin; the SDK is located, not packaged |
| Windows | openkal-windows |
on Win32 and the object manager |
| Web (Emscripten) | openkal-emscripten |
written ABOVE a C library; twelve of fifteen interfaces |
A target with no operating system is the same model with the platform layer
supplied by firmware rather than by a kernel. riscv64-none-elf over OpenSBI
runs the same source as a hosted target, including import std, because the
standard library it uses is the one the graph supplied rather than the
compiler's own.
Two things must be declared, both properties of the board rather than defaults:
[target.riscv64-none-elf]
sysroot = ""
runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic",
"-no-reboot", "-bios", "default", "-kernel"]sysroot = "" selects the zero-libc tier. Which machine model and which
firmware mode to use are board facts, and an engine that guesses one is an
engine a different board has to fight.
A hosted cross target takes the same key with a user-mode emulator
(2026.9.2.1). An aarch64-linux-musl artifact built on an x86_64 host is
executed through qemu-aarch64-static when the project declares it, and the
package that provides the emulator is declared for the hosts that can install
it:
[xlings.workspace]
"xim:qemu-user-aarch64" = { linux = "" }
[target.aarch64-linux-musl]
runner = ["qemu-aarch64-static"]Without the key, mcpp run reports the kernel's refusal (Exec format error)
and the key to write, and mcpp test reports every test as not run and exits
2. A host that executes the artifact natively passes --no-runner. The rules
are in 04 — mcpp.toml, §2.7.3.
"The same source" is a claim about the toolchain and the standard library, and
it holds: import std works, the C++ runtime is the one the graph supplied, and
no #if distinguishes the targets. It is not a claim that any given program
builds for any given target, and the specification is explicit about why.
A bare-metal backend provides some interfaces and not others. openkal-opensbi
provides abort, stream, memory, env and time; it provides no
filesystem and no tasks, because the machine has none. Clause 6.1 makes that
absence a link-time fact:
An interface that an implementation does not provide is absent as a link-time definition, and a consumer that uses it fails to link.
A capability word therefore answers a narrower question than it first appears to. It says how an implementation behaves within an interface it provides — whether names are compared case-sensitively, what the granularity of a clock is. Whether the interface exists at all is answered before that, by the dependency graph, and failing that by the linker.
The distinction is easy to lose, because the query is an inline function over a
data object, so a program that merely asks whether a filesystem exists takes the
address of kal_fs_props and fails to link with no filesystem call anywhere in
it. Defining that word as zero in the backend removes the error and is the one
remedy the clause forbids: the program then proceeds past the point the linker
existed to stop it at. It was tried, published as openkal-opensbi@0.1.3, and
retracted.
An x86_64 machine with no operating system is reached in two different ways, and the difference is what loads the program.
| Route | Target | Platform layer | Entry |
|---|---|---|---|
| UEFI application | x86_64-windows-gnu |
openkal-uefi |
firmware, with Boot Services available |
| Kernel, or raw bare metal | x86_64-none-elf |
none, or openarch |
the reset vector, with nothing beneath |
A UEFI application is PE/COFF entered through the Microsoft x64 calling
convention. Both are properties the LLVM toolchain already has, so its target is
the same triple as a Windows program and firmware function pointers are called
directly. What distinguishes it from a Windows build is which implementation of
the platform interface the graph resolved, together with three link flags that
select IMAGE_SUBSYSTEM_EFI_APPLICATION.
A kernel has no firmware services to call. Its target is x86_64-none-elf, the
zero-libc tier: no C library on the compile line, no library directory on the
link, and #include <stdio.h> does not resolve. The program is entered at its
own _start and reaches hardware directly.
openarch is the layer such a program builds on. It is not a platform interface
and does not answer to mcpp:kernel-abi; it is the architecture mechanism —
execution contexts, traps, per-CPU state and address spaces — presented as one
interface over several instruction sets, with a backend package per instruction
set. A kernel depends on it and supplies its own platform layer, or none.
riscv64-none-elf and aarch64-none-elf are rows in a table and nothing more:
Clang has a BareMetal toolchain for both, drives their links itself and reaches
ld.lld. It has none for x86_64, so that triple falls through to the generic
GCC toolchain, whose linker is the host's g++:
g++: error: unrecognized command-line option '-fuse-ld=…/ld.lld'
Measured for every spelling of a bare x86_64 triple, and not correctable by any
flag. The row therefore carries a linker emulation and mcpp invokes ld.lld
itself, which is also why the host toolchain must be shown not to participate in
such a link.
Three, recorded because each was found by building rather than by reading.
A backend must define every capability word. The specification's queries are
inline functions over property objects, so a program that merely asks whether a
filesystem exists takes the address of kal_fs_props. A backend that omits the
words for layers it lacks makes the question fail to link on exactly the class of
machine the question exists for.
Two suppliers of one layer is an error rather than a choice. A C library, a platform interface and a C++ runtime are mutually exclusive. Selecting the wrong one does not fail the link; it produces a program that runs and intermittently does not.
A payload's C++ runtime cannot sit above a foreign C library. Its
__config_site records the configuration it was built with. The resolver's
structure prevents the combination on the default path, and a diagnostic covers
the paths where a project overrides the contract explicitly.
Each of the five layers guarantees only itself, and the boundary between them is where a package's own adaptation belongs.
kernel-abi = openkal guarantees behaviour that crosses kal_* and nothing
past it. The specification's own interface is platform-independent; a
missing capability is exposed at link time, and a missing property is
answered through the props query. It says nothing about which C library sits
above it, whether a platform SDK is reachable, or whether the rest of a
package's own source is portable.
c-abi = musl is a separate layer with a separate guarantee. A build over
openkal's kernel-abi is not thereby a build over any particular C library —
musl is one implementation of that layer, resolved by the same dependency
graph as kernel-abi, and a header or CRT difference (<io.h>, _WIN32's
Windows-CRT assumptions, TargetConditionals.h) is the c-abi layer's
question, never the kernel-abi layer's. A package that adapts to "openkal"
when the actual disagreement is with musl has adapted to the wrong axis —
openkal's own headers #include nothing and conflict with no platform SDK;
musl's headers are what a host SDK's declarations collide with (#662).
Platform dependencies are legal, and they must come from the graph and stay
private to the package that declares them. A package bound to one platform
— it needs that platform's headers or import libraries to implement a
facility — depends on the platform SDK under [feature-deps.<feature>] with
visibility = "private", so the dependency reaches only its own translation
units and never a consumer's. 06 — Features and Capabilities
states the pattern and the manifest form. What is not legal is reaching for
the HOST's copy instead of a graph one: that is exactly the header isolation
gap #662 closed, and the isolation exists so a platform dependency's absence
from the graph is a build failure, not a silent substitution.
One C runtime and one C++ runtime per image. "Platform-bound" names the
platform's OS API surface, not its C library. A platform-bound package may
call Win32, WinSock or Cocoa — system libraries with a C interface — provided
only handles and plain values cross the boundary. It may not link a static
library compiled against ucrt, msvcrt, libSystem or glibc, and it may not let
an object the CRT owns cross the boundary: a FILE*, a malloc freed on the
other side, errno, locale state. A vendor SDK distributed only as a static
library against a platform CRT is n/a on an openkal target by design, not
by omission.
Adapt on the layer that actually differs. musl running under Linux and
musl running over openkal share headers and CRT shape, so a header or CRT
difference is a c-abi question: cfg(c-abi = "musl"). The two do NOT share
the same facilities — openkal has no epoll, no signal handlers, and chmod
can only change the execute bit — so a facility difference is answered first
by the package's OWN feature switch where one exists (an event-loop backend
selection, say), and only falls back to a combined predicate,
cfg(all(kernel-abi = "openkal", c-abi = "musl")), when a descriptor needs to
choose automatically. Neither form reaches the package's source: a cfg
predicate is a dependency-resolution-time choice among descriptor entries, not
a macro a translation unit can test.
Source code must not detect which implementation is present. A kal_*
call site does not ask whether it is running over openkal-linux or
openkal-macos; a musl call site does not ask whether the platform beneath it
is real Linux or openkal. The implementation is chosen once, by dependency
resolution, and everything above that choice reads one interface.
docs/22 — The Target Side for the five layers, the four
origins and the rules. docs/06 — Features and Capabilities
for [feature-deps.<name>] and private dependency visibility. SPEC-002
for the normative statement of the capability grammar.