Skip to content

Cross compile Design

Rick Guo edited this page Aug 10, 2026 · 15 revisions

Cross Compilation

Summary

LLAR supports C-family cross compilation by composing three independent pieces:

  • cmd/llar selects and prepares the target sysroot;
  • llvm.Toolchain prepares target-specific compiler and linker commands;
  • c.Target projects those commands into CMake, Autotools, pkg-config, and direct C/C++ tool invocations.

The build module remains language-neutral. It receives a build.Target and applies that target to commands created while a Formula build hook is running. It does not know whether the target represents C, LLVM, a sysroot, or cross compilation.

A sysroot remains an ordinary Formula. cmd/llar adds the default sysroot to module resolution, builds or restores it before its consumers, parses its published C metadata, and then prepares the final C target. Existing Formulas do not need a cross-compilation API: their existing CMake, Autotools, pkg-config, and direct compiler commands pass through the same command broker and receive target defaults automatically.

The design covers Linux and Darwin targets. Native builds keep their current behavior and do not receive a sysroot or command rewrite.

Proposed Design

Design Rules

The design follows four rules:

  1. Target selection, toolchain construction, and command rewriting are separate responsibilities.
  2. cmd/llar owns composition because it knows the selected matrix, resolved modules, and build results.
  3. build accepts only the language-neutral build.Target contract.
  4. Formula configuration takes precedence over LLAR-provided defaults.

The resulting dependency direction is:

cmd/llar/internal
  |-- internal/modules
  |-- internal/build
  |-- internal/build/c
  `-- internal/build/c/llvm

internal/build/c/llvm --prepares--> c.Toolchain
internal/build/c      --implements-> build.Target
internal/build        --adapts------> execbroker.Middleware

Neither build nor internal/modules imports the C or LLVM packages.

End-to-End Composition

For a supported cross target, cmd/llar performs the following sequence:

sequenceDiagram
  participant CLI as "cmd/llar"
  participant C as "internal/build/c"
  participant Modules as "internal/modules"
  participant LLVM as "internal/build/c/llvm"
  participant Build as "internal/build"
  participant Metadata as "x/metadata/cc"

  CLI->>CLI: "read target OS and arch from formula.Matrix"
  CLI->>C: "Sysroot(targetOS, targetArch)"
  C-->>CLI: "fixed sysroot module.Version"
  CLI->>Modules: "Load(root, Options{Matrix, Roots: [sysroot]})"
  Modules-->>CLI: "MVS-resolved module graph"
  CLI->>LLVM: "New(Config{OS, Arch})"
  LLVM-->>CLI: "bootstrap c.Toolchain"
  CLI->>C: "NewTarget(Config{Matrix, Toolchain})"
  C-->>CLI: "bootstrap build.Target"
  CLI->>Build: "Build(selected sysroot)"
  Build-->>CLI: "sysroot build Result"
  CLI->>Metadata: "Parse(result.Metadata)"
  Metadata-->>CLI: "sysroot path"
  CLI->>LLVM: "New(Config{OS, Arch, Sysroot})"
  LLVM-->>CLI: "configured c.Toolchain"
  CLI->>C: "NewTarget(Config{Matrix, Toolchain, Sysroot})"
  C-->>CLI: "configured build.Target"
  CLI->>Build: "Build(resolved graph, Target)"
  Build-->>CLI: "target build results"
Loading

The first target contains the compiler target and linker selection but no sysroot flag. It is used to build or restore the sysroot Formula. The second target contains the resolved sysroot path and is used for the requested module graph. Both build calls use the existing build.Builder; there is no separate cross-compilation build implementation.

Target And Sysroot Selection

Cross compilation is active when the selected target OS or architecture differs from the host. The supported C target policy is:

Target OS Target arch Default sysroot Formula
linux amd64 bminor/glibc@glibc-2.17
linux arm64 bminor/glibc@glibc-2.17
darwin amd64 joseluisq/macosx-sdks@14.5
darwin arm64 joseluisq/macosx-sdks@14.5

c.Sysroot returns only the fixed module.Version. It does not load the Formula, access a cache, build the module, or parse its metadata.

The returned module is passed through modules.Options.Roots. It therefore participates in the same MVS resolution as Formula-declared dependencies. The module loader sees an ordinary root requirement and has no sysroot or libc semantics.

Linux Formulas may define a libc requirement in their matrix. The existence of the libc key means that the Formula owns libc selection, so cmd/llar does not add the fixed glibc Formula. LLAR does not normalize or interpret the value; the matrix key is the convention that allows the Formula to constrain its own variants.

When the requested module is the default sysroot Formula itself, cmd/llar does not add that Formula as its own root requirement.

If the target is native, LLAR skips c.Sysroot, does not prepare a build.Target, and follows the existing native build path. If c.Sysroot does not recognize a cross-target OS and architecture, LLAR leaves the matrix to the Formula and does not inject C target behavior.

Sysroot Formula Contract

A sysroot Formula publishes the actual sysroot directory in its existing C/C++ metadata:

--sysroot=<absolute output path>

The path may be the Formula output root or a directory below it. cmd/llar parses the build result with x/metadata/cc and uses Metadata.Sysroot(); it does not infer the path from the module installation directory.

Linux sysroots provide target headers, startup objects, the dynamic loader ABI, and link-time libraries. Darwin sysroots provide the macOS SDK headers, frameworks, and link stubs.

The sysroot is prepared in two stages because its path does not exist until the Formula has been built or restored:

  1. Prepare an LLVM toolchain with target OS and architecture but without a sysroot.
  2. Build or restore the selected sysroot Formula with that bootstrap target.
  3. Parse the sysroot path from the returned metadata.
  4. Prepare the final LLVM toolchain and C target with the parsed path.
  5. Build the resolved module graph. The sysroot entry produced by step 2 is reused from the existing build cache.

This keeps sysroot knowledge out of build while retaining one build implementation.

C Toolchain And LLVM Toolchain

c.Toolchain is prepared command data. CC, CXX, and Linker are []string because a command includes both an executable and mandatory target arguments. For example:

CC = ["/usr/bin/clang", "--target=aarch64-linux-gnu", "-fuse-ld=lld", "--sysroot=/sdk"]

c.Toolchain does not derive target triples, choose a linker, select a sysroot, or interpret CMake and Autotools commands. Those facts are supplied when the toolchain is constructed.

llvm.New owns LLVM-specific decisions:

OS/arch Clang target Linker Sysroot argument
linux/amd64 x86_64-linux-gnu ld.lld --sysroot=<path>
linux/arm64 aarch64-linux-gnu ld.lld --sysroot=<path>
darwin/amd64 x86_64-apple-macos10.13 ld64.lld -isysroot<path>
darwin/arm64 arm64-apple-macos11.0 ld64.lld -isysroot<path>

It resolves clang, clang++, the selected linker, llvm-ar, llvm-ranlib, llvm-nm, and llvm-strip from PATH. Compiler-driver commands include -fuse-ld=lld; raw linker selection is exposed separately through c.Toolchain.Linker().

Target triples, Darwin deployment versions encoded in those triples, LLVM linker names, and LLVM sysroot flag syntax remain inside the LLVM package. c.Target receives only prepared commands.

C Command Projection

c.Target owns C build-system semantics. It converts a prepared c.Toolchain, a target matrix string, and an optional sysroot path into build.Patch values.

The target portion is the matrix text before the first |. It determines only build-system facts:

Target CMake system CMake processor Autoconf host
amd64-linux Linux x86_64 x86_64-linux-gnu
arm64-linux Linux aarch64 aarch64-linux-gnu
amd64-darwin Darwin x86_64 x86_64-apple-darwin
arm64-darwin Darwin arm64 aarch64-apple-darwin

These values describe CMake and Autoconf conventions. They are not passed to LLVM to construct compiler commands.

CMake

On a CMake configure command, c.Target appends a generated toolchain file unless the Formula already supplied CMAKE_TOOLCHAIN_FILE or --toolchain. The file is created lazily on the first matching configure command and removed when c.Target.Close runs. CMake build and install commands do not create or receive the file.

The generated file sets missing values only:

CMAKE_SYSTEM_NAME
CMAKE_SYSTEM_PROCESSOR
CMAKE_C_COMPILER
CMAKE_CXX_COMPILER
CMAKE_LINKER
CMAKE_AR
CMAKE_RANLIB
CMAKE_NM
CMAKE_STRIP

Linux sysroots use CMAKE_SYSROOT. Darwin sysroots use CMAKE_OSX_SYSROOT and also set CMAKE_OSX_ARCHITECTURES.

When a sysroot is active, target lookup uses:

CMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER
CMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY
CMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY
CMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY

Dependency roots remain owned by x/cmake.Use. It accumulates CMAKE_FIND_ROOT_PATH in the environment, and x/cmake.Configure passes the current value to CMake. The generated target file does not copy or cache that dynamic dependency list.

Autotools And Other Configure Scripts

For a command whose basename is configure, c.Target supplies missing environment values:

CC CXX LD AR RANLIB NM STRIP

CC and CXX contain the complete shell-quoted command arrays, including LLVM target, linker, and sysroot arguments. Formula-provided environment values are preserved.

--host is not accepted by every script named configure, so it is not added unconditionally. The command already contains the resolved source path and working directory. c.Target reads that script and appends the target's Autoconf host tuple only when:

  • the Formula did not already pass --host; and
  • the script contains the literal --host option.

A script containing CHOST but not --host does not receive the option. LLAR does not inject --build. The Autoconf host tuple controls configure-time cross-build behavior; it is not forwarded to Clang as the compiler target.

pkg-config

When a sysroot is active, pkg-config receives missing values for:

PKG_CONFIG_SYSROOT_DIR=<sysroot>
PKG_CONFIG_LIBDIR=<dependency pkg-config paths plus sysroot pkg-config paths>

Dependency paths come from the current PKG_CONFIG_PATH, which is populated by x/pkgconfig.Use and by build helpers such as x/autotools.Use. The C target appends the sysroot's usr/lib64/pkgconfig, usr/lib/pkgconfig, and usr/share/pkgconfig directories. An explicit PKG_CONFIG_LIBDIR remains unchanged.

Direct Commands

Bare generic tool names are rewritten as follows:

Formula command Prepared command
cc, gcc, clang Toolchain.CC()
c++, g++, clang++ Toolchain.CXX()
ld, ld.lld, ld64.lld Toolchain.Linker()
ar, llvm-ar Toolchain.Archiver()
ranlib, llvm-ranlib Toolchain.Ranlib()
nm, llvm-nm Toolchain.NM()
strip, llvm-strip Toolchain.Strip()

The executable replaces the generic name and mandatory compiler or linker arguments are prepended before Formula arguments. A command containing an explicit path is not rewritten. configure is the exception because its source path is required for option detection.

Precedence And Failure Behavior

Condition Behavior
Native target Use the existing native build path
Unsupported cross OS/arch Do not create a C target; leave the matrix to the Formula
Linux matrix contains libc Do not add the default glibc Formula
Sysroot Formula cannot be resolved or built Return the module load or build error
Sysroot metadata is invalid or has no sysroot Fail before building consumers
Required LLVM command is absent from PATH Fail while preparing llvm.Toolchain
Formula supplies a CMake toolchain Do not append LLAR's generated toolchain file
Formula supplies an Autotools environment value Keep the Formula value
Formula supplies --host Do not append another --host
Configure script does not declare --host Do not append --host
Configure script cannot be read Fail before executing that configure command
Formula uses an explicit tool path Do not rewrite the command

Module Specification

internal/build

The build module exposes one target-neutral command transformation contract:

package build

type Command struct {
    Name string
    Args []string
    Env  []string
    Dir  string
}

type Patch struct {
    Name       string
    PrependArg []string
    AppendArg  []string
    Env        []string
}

type Target interface {
    Use(Command) Patch
}

type Options struct {
    // existing fields
    Target Target
}

Builder adapts Target.Use to the existing execbroker.Middleware once per Build call. Formula build commands run inside the existing execbroker.Scope, so CMake helpers, Autotools helpers, gsh commands, and direct brokered commands all receive the same target.

internal/build owns applying Patch to one command and scoping it to a build. It does not select a target, know C or LLVM, locate a sysroot, parse C metadata, or construct a toolchain. External callers cannot inject command middleware through build.Options; they can supply only a build.Target.

internal/build/c

The C module defines prepared toolchain data and implements the build target:

package c

type Toolchain struct {
    // prepared commands
}

func NewToolchain(
    cc, cxx, linker []string,
    archiver, ranlib, nm, strip string,
) Toolchain

func (t Toolchain) CC() []string
func (t Toolchain) CXX() []string
func (t Toolchain) Linker() []string
func (t Toolchain) Archiver() string
func (t Toolchain) Ranlib() string
func (t Toolchain) NM() string
func (t Toolchain) Strip() string

type Config struct {
    Matrix    string
    Toolchain Toolchain
    Sysroot   string
}

func Sysroot(targetOS, targetArch string) (module.Version, bool)
func NewTarget(config Config) (*Target, error)
func (t *Target) Use(cmd build.Command) build.Patch
func (t *Target) Close() error

c.Toolchain owns only prepared C-family commands. c.Target owns CMake, configure, pkg-config, and direct C-tool projection. c.Sysroot owns the fixed C sysroot selection policy.

The C module does not load or build Formulas, resolve LLVM executables, decide LLVM triples or linker drivers, execute commands, or install middleware. Close cleans up only configuration generated by that target, currently the lazily created CMake toolchain file.

internal/build/c/llvm

The LLVM module constructs a C toolchain from target facts:

package llvm

type Config struct {
    OS      string
    Arch    string
    Sysroot string
}

type Toolchain struct {
    c.Toolchain
}

func New(config Config) (*Toolchain, error)

The LLVM module owns target triples, target-specific compiler arguments, linker selection, and LLVM executable lookup. The returned object embeds the prepared c.Toolchain consumed by c.NewTarget.

It does not select or build a sysroot Formula, interpret matrix conventions, understand CMake or Autotools, rewrite commands, or depend on build.Builder.

cmd/llar/internal

The existing command entrypoint remains the composition root:

func buildModule(
    ctx context.Context,
    store repo.Store,
    modPath, version string,
    matrix formula.Matrix,
    runTest bool,
) error

It owns the complete orchestration order:

  • determine native versus cross build from the selected matrix and host;
  • apply the libc matrix convention;
  • ask c.Sysroot for the default module version;
  • add that version to module loading;
  • build or restore the selected sysroot;
  • parse its metadata;
  • construct the bootstrap and final LLVM toolchains and C targets;
  • pass the resulting build.Target to build.NewBuilder;
  • close the concrete C targets that it created.

It does not implement command rewriting or duplicate CMake, Autotools, pkg-config, Clang, or linker policy.

internal/modules

Module loading exposes a generic caller-supplied root mechanism:

package modules

type Options struct {
    FormulaStore repo.Store
    Matrix       formula.Matrix
    Roots        []module.Version
}

func Load(
    ctx context.Context,
    main module.Version,
    opts Options,
) ([]*Module, error)

Roots are ordinary requirements appended before MVS resolution. The module loader owns Formula loading, matrix injection, dependency resolution, and version conflict resolution. It does not know which root is a sysroot, select a libc, parse build metadata, or import the C package.

User Stories

1. Cross-Build A CMake Library For Linux arm64

The user runs llar make <module>@<version> --os linux --arch arm64 on a non-arm64 host. The Formula itself uses the existing x/cmake workflow and contains no cross-compilation setup.

sequenceDiagram
  participant User
  participant CLI as "cmd/llar"
  participant C as "c"
  participant Modules as "modules"
  participant LLVM as "llvm"
  participant Build as "build"
  participant CMake as "x/cmake"
  participant Broker as "execbroker"

  User->>CLI: "llar make <module>@<version> --os linux --arch arm64"
  CLI->>C: "Sysroot(linux, arm64)"
  C-->>CLI: "bminor/glibc@glibc-2.17"
  CLI->>Modules: "Load module + sysroot root with selected matrix"
  Modules-->>CLI: "MVS-resolved graph"
  CLI->>LLVM: "New(linux, arm64, no sysroot)"
  LLVM-->>CLI: "clang --target=aarch64-linux-gnu ..."
  CLI->>C: "NewTarget(bootstrap toolchain)"
  CLI->>Build: "build/restore glibc Formula"
  Build-->>CLI: "metadata containing --sysroot=<path>"
  CLI->>LLVM: "New(linux, arm64, <path>)"
  LLVM-->>CLI: "compiler and linker commands with sysroot"
  CLI->>C: "NewTarget(configured toolchain, <path>)"
  CLI->>Build: "Build(resolved graph, C target)"
  Build->>Broker: "open scoped target middleware"
  Build->>CMake: "run Formula onBuild"
  CMake->>Broker: "cmake -S ... -B ..."
  Broker->>C: "Use(cmake configure command)"
  C-->>Broker: "append lazy CMake toolchain file"
  Broker->>CMake: "execute target-configured cmake"
  CMake->>Broker: "cmake --build / --install"
  Broker->>C: "Use(non-configure commands)"
  C-->>Broker: "no toolchain-file patch"
  Build-->>CLI: "target build result"
  CLI-->>User: "module result"
Loading

If the Formula called cmake.Use(depRoot) for a dependency, that helper's current CMAKE_FIND_ROOT_PATH reaches the configure command. The generated target file supplies compiler, sysroot, and find-root modes without replacing those dependency roots.

2. Cross-Build An Autoconf Library For Darwin arm64

The user selects Darwin arm64 on another platform. The Formula calls the existing x/autotools.Configure, whose resolved source script advertises --host.

sequenceDiagram
  participant User
  participant CLI as "cmd/llar"
  participant Modules as "modules"
  participant LLVM as "llvm"
  participant C as "c"
  participant Build as "build"
  participant Auto as "x/autotools"
  participant Broker as "execbroker"

  User->>CLI: "llar make <module>@<version> --os darwin --arch arm64"
  CLI->>C: "Sysroot(darwin, arm64)"
  C-->>CLI: "joseluisq/macosx-sdks@14.5"
  CLI->>Modules: "resolve module graph with SDK root"
  CLI->>LLVM: "prepare bootstrap target commands"
  CLI->>C: "prepare bootstrap C target"
  CLI->>Build: "build/restore SDK Formula"
  Build-->>CLI: "SDK sysroot metadata"
  CLI->>LLVM: "New(darwin, arm64, SDK path)"
  LLVM-->>CLI: "clang --target=arm64-apple-macos11.0, ld64.lld, -isysroot"
  CLI->>C: "NewTarget(final toolchain, SDK path)"
  CLI->>Build: "Build(graph, C target)"
  Build->>Broker: "open scoped target middleware"
  Auto->>Broker: "run <source>/configure"
  Broker->>C: "Use(configure command and working directory)"
  C->>C: "read configure source and find literal --host"
  C-->>Broker: "set missing tools and append --host=aarch64-apple-darwin"
  Broker->>Auto: "execute configured command"
  Auto->>Broker: "make and make install"
  Broker->>C: "rewrite generic compiler/linker tools used by the build"
  Build-->>CLI: "Darwin arm64 result"
  CLI-->>User: "module result"
Loading

The Autoconf host tuple makes the configure script enter cross-build mode. Clang receives its separate LLVM target through the prepared CC command; the --host value is not treated as a compiler argument.

3. Build A Formula With A Custom Configure Script

Some projects, including zlib, use a script named configure that is not GNU Autoconf. Such a script may mention CHOST while rejecting --host.

sequenceDiagram
  participant Formula
  participant Auto as "x/autotools"
  participant Broker as "execbroker"
  participant C as "c.Target"
  participant Script as "custom configure"

  Formula->>Auto: "Configure(existing Formula arguments)"
  Auto->>Broker: "run resolved <source>/configure"
  Broker->>C: "Use(Command{Name, Args, Env, Dir})"
  C->>C: "preserve Formula environment"
  C->>C: "add missing CC/CXX/LD/AR/RANLIB/NM/STRIP"
  C->>C: "inspect script: CHOST exists, literal --host does not"
  C-->>Broker: "Patch{Env: configured environment}"
  Broker->>Script: "execute without --host"
Loading

The Formula remains non-invasive: it does not need to identify which kind of configure script it uses. If another Formula explicitly passes --host=custom, c.Target preserves it and does not append a second value.

4. Let A Linux Formula Select Its Own libc

A Formula that supports multiple libc variants declares a libc requirement in its matrix and resolves the selected libc through its own Formula logic.

sequenceDiagram
  participant User
  participant CLI as "cmd/llar"
  participant C as "c"
  participant Modules as "modules"
  participant LLVM as "llvm"
  participant Build as "build"

  User->>CLI: "llar make <module>@<version> --os linux --arch arm64 --require libc=<value>"
  CLI->>CLI: "observe that matrix.Require contains libc"
  CLI->>C: "Sysroot(linux, arm64)"
  C-->>CLI: "default glibc version"
  CLI->>CLI: "do not add the default glibc root"
  CLI->>Modules: "Load Formula with the unchanged matrix"
  Modules->>Modules: "Formula resolves its declared libc requirements"
  Modules-->>CLI: "resolved Formula graph"
  CLI->>LLVM: "prepare Linux arm64 target commands without a default sysroot"
  CLI->>C: "NewTarget(target commands, no default sysroot)"
  CLI->>Build: "Build Formula graph with C target"
  Build-->>CLI: "target result"
  CLI-->>User: "module result"
Loading

LLAR does not interpret <value> or replace the Formula's libc with its fixed default. The Formula's matrix key controls its own selection semantics, while the LLVM toolchain still supplies the target compiler and linker commands.

Clone this wiki locally