Skip to content

Latest commit

 

History

History
590 lines (466 loc) · 28.7 KB

File metadata and controls

590 lines (466 loc) · 28.7 KB

24 — Cross-Compilation Over openkal

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.

The Claim

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.

Three Layers, Three Macro Families (mcpp 2026.9.18+)

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, from provides alone — 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 provides entry 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.

What A Project Writes

[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-gnu

No [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.

What The Ecosystem Supplies

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.

The Reason The Compiler Must Be LLVM

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.

Target Selection

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.

The Environment Segment

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.

Android, Web And iOS Under This Model

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.

Android shares the Linux implementation, unchanged

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.

iOS shares the macOS implementation, and the SDK is located rather than packaged

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.

Web needed a new implementation, and openkal-emscripten is it

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.

The table

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

Bare Metal

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 Source Is The Same, The Program Is Not

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

Two Routes To A Bare x86_64 Machine

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.

The Engine Work x86_64 Bare Metal Required

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.

Measured Limits

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.

The Boundary

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.

Reference

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.