CMake toolchain file for cross-compiling PROJ

This page answers one question: what exactly goes in a CMAKE_TOOLCHAIN_FILE so that a cross-build of a PROJ-linked extension resolves find_package(PROJ), find_package(SQLite3), and the C++ runtime from the target sysroot instead of silently binding to the host’s x86_64 copies? It sits inside the Cross-Compiler Toolchain Setup section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you a complete, annotated toolchain file plus the three find_package failure signatures it prevents.

How CMAKE_FIND_ROOT_PATH routing splits host and sysroot lookups A CMake configure step consults the toolchain file, which routes three find-root modes differently: PROGRAM lookups resolve on the host so the native cross-gcc and ninja run, while LIBRARY and INCLUDE lookups resolve only inside the aarch64 sysroot so find_package for PROJ and SQLite3 bind the target libproj and libsqlite3. The result is an aarch64 configured build tree. cmake configure reads toolchain file MODE_PROGRAM = NEVER host cross-gcc · ninja MODE_LIBRARY = ONLY MODE_INCLUDE = ONLY sysroot libproj · libsqlite3 aarch64 build tree configured

Context & Root Cause

CMake was designed to build for the machine it runs on, so all of its discovery logic — find_program, find_library, find_path, and the find_package modules built on them — defaults to searching the host filesystem. In a cross-build that default is exactly wrong for libraries and headers: you want the host cross-compiler binary (aarch64-linux-gnu-gcc is an x86_64 executable) but the target libproj.so and proj.h. Without a toolchain file, find_package(PROJ) finds /usr/lib/x86_64-linux-gnu/libproj.so, the link succeeds against the wrong architecture, and the failure surfaces only as an obscure ld error or a broken import.

The toolchain file solves this by separating the four find-root modes. CMAKE_FIND_ROOT_PATH_MODE_PROGRAM = NEVER tells CMake to ignore the sysroot when locating executables (so it runs the native cross-gcc and ninja), while MODE_LIBRARY and MODE_INCLUDE set to ONLY confine all library and header discovery to the sysroot. This is the mechanism that makes find_package(PROJ) bind the target libproj, and it is why the file is a prerequisite for the aarch64 recipe in building aarch64 GDAL wheels without QEMU.

Solution / Fix

This targets CMake 3.28+, PROJ 9.3.x (which ships a CMake config package, proj-config.cmake), and a populated aarch64 sysroot containing PROJ, SQLite3, and libtiff.

1. Write the complete toolchain file

# aarch64-linux-gnu.toolchain.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

# Host executables that emit target code
set(triple aarch64-linux-gnu)
set(CMAKE_C_COMPILER   ${triple}-gcc)
set(CMAKE_CXX_COMPILER ${triple}-g++)
set(CMAKE_AR           ${triple}-ar)
set(CMAKE_RANLIB       ${triple}-ranlib)

# The target root filesystem
set(CMAKE_SYSROOT "$ENV{SYSROOT}")
set(CMAKE_FIND_ROOT_PATH "$ENV{SYSROOT}")

# Discovery routing — the crux of a correct cross-build
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)   # run host cross-gcc, ninja
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)    # libproj/libsqlite3 from sysroot
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)    # proj.h/sqlite3.h from sysroot
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)    # proj-config.cmake from sysroot

# Emit $ORIGIN-relative RPATH so the repaired wheel relocates cleanly
set(CMAKE_INSTALL_RPATH "$ORIGIN/../lib")
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)

2. Point PROJ’s config package at the sysroot

PROJ 9 exports a config-mode package. Because MODE_PACKAGE is ONLY, find_package(PROJ CONFIG) resolves proj-config.cmake from the sysroot automatically. Verify your CMakeLists.txt requests config mode so it does not fall back to a stale find-module:

find_package(PROJ 9.3 CONFIG REQUIRED)
target_link_libraries(_geospatial_ext PRIVATE PROJ::proj)

3. Invoke the configure with the toolchain file

cmake -B build -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE="$PWD/aarch64-linux-gnu.toolchain.cmake" \
  -DCMAKE_BUILD_TYPE=Release
cmake --build build

When driven through the scikit-build-core backend, pass the same file via CMAKE_ARGS or a cmake.args entry in pyproject.toml so the wheel build inherits the identical routing.

Verification

# 1. Confirm CMake resolved the SYSROOT copy of PROJ, not the host's
grep -i "proj" build/CMakeCache.txt | grep -i include
# expected: PROJ_INCLUDE_DIR:PATH=/opt/sysroots/aarch64/usr/include
# 2. Confirm the linked libproj is aarch64
readelf -h build/*.so 2>/dev/null | grep Machine
# expected: Machine: AArch64
# 3. Confirm find_package chose config mode (not a bundled find-module)
cmake --build build --target help >/dev/null 2>&1 && \
  grep -R "PROJ::proj" build/ | head -1
# expected: a reference to the imported PROJ::proj target

If PROJ_INCLUDE_DIR points at /usr/include, MODE_INCLUDE did not take effect — confirm the toolchain file is passed on the first configure, because CMake caches the value and ignores later edits until you delete build/.

Pitfalls & Alternatives

Editing the toolchain file after the first configure. CMake reads the toolchain file once and caches every derived variable. Any change requires rm -rf build/; otherwise you are debugging a stale cache, which produces the maddening symptom of “correct file, wrong result.”

Leaving MODE_PACKAGE at its default. If CMAKE_FIND_ROOT_PATH_MODE_PACKAGE is unset, find_package(PROJ CONFIG) can still match a host proj-config.cmake, reintroducing the host architecture. Pin it to ONLY explicitly. This is the config-mode analogue of the find_package miss dissected in fixing CMake find_package for PROJ.

Forgetting SQLite3. PROJ links libsqlite3 to read proj.db. If the sysroot lacks it, you get CMake Error: Could not find SQLite3 even though PROJ itself resolved — populate the sysroot with the target’s SQLite before building.

What Each Find-Root Mode Decides

The four CMAKE_FIND_ROOT_PATH_MODE_* variables are the whole toolchain file in miniature, and they are easier to get right once you see them as answers to four separate questions rather than four similar switches. Each one governs a different family of find_* calls, and setting the wrong value produces a distinctive, recognisable failure.

Which filesystem each CMake find-root mode is allowed to search Four rows. Programs are searched on the host only, because the cross compiler and ninja are host executables. Libraries are searched in the sysroot only, so libproj resolves for the target. Include directories are searched in the sysroot only, so proj.h matches the target library. Package config files are searched in the sysroot only, so proj-config.cmake describes the target build. Each row also names the failure produced by the wrong setting. find_* call family host tree sysroot what the wrong setting gives you find_program MODE_PROGRAM = NEVER searched skipped "could not find ninja" — the sysroot has no host executables in it find_library MODE_LIBRARY = ONLY skipped searched host libproj.so links into an aarch64 binary — "incompatible target" find_path MODE_INCLUDE = ONLY skipped searched host proj.h against target libproj — struct layouts silently disagree find_package (config) MODE_PACKAGE = ONLY skipped searched host proj-config.cmake re-imports the host target and undoes the rest MODE_PACKAGE is the one most often left unset — and it can reintroduce the host build after the other three are correct

The asymmetry in that table is the point: programs come from the host, everything else comes from the target. Once that sentence is internalised, most toolchain-file debugging becomes mechanical. When something resolves from the wrong place, ask which find_* family it belongs to and check that mode.

The Order CMake Reads Things

The single most useful fact about toolchain files is when CMake reads them, because it explains both why they work and why editing one appears to do nothing.

When the toolchain file is read during a CMake configure A first configure reads the toolchain file before any compiler test, runs the compiler identification with the cross compiler, performs find_package calls under the find-root modes the file established, and writes every derived value into CMakeCache.txt. A second configure into the same build directory reads the cache first and skips the toolchain file entirely, so an edited file has no effect until the build directory is deleted. first configure — empty build dir read toolchain file before any compiler test identify compiler with the cross gcc find_package calls under the find-root modes write CMakeCache every derived value second configure — same build dir read CMakeCache values already decided toolchain file skipped · compiler not re-identified find_package results reused from the cache an edited toolchain file changes nothing until the build directory is deleted — the file on disk and the build's behaviour diverge silently building through scikit-build-core avoids this by configuring in a fresh temporary directory every time

That is also the reason the toolchain file must be passed on the first configure rather than added later: by the time a cache exists, the compiler has already been identified with whatever compiler was in scope, and the find-root modes never got a chance to apply.

When find_package Still Picks the Wrong PROJ

Even with all four modes set, three situations can route discovery back to the host, and each has a specific tell.

The first is an environment variable that outranks the toolchain file. CMAKE_PREFIX_PATH and PROJ_DIR, whether exported in the shell or injected by a build backend, are consulted alongside the sysroot and are not filtered by the find-root modes when they point at an absolute path. In a wheel build this frequently arrives from outside your control — a conda activation script, or a CI image that helpfully exports paths to its own libraries. The tell is a PROJ_DIR in CMakeCache.txt pointing outside the sysroot; the fix is to unset those variables explicitly in the cross environment rather than assume they are absent.

The second is a stale cache, which is the most common cause by a wide margin because CMake’s behaviour here is counter-intuitive. The toolchain file is read once, on the first configure, and every variable it derives is written into CMakeCache.txt. Editing the file afterwards changes nothing: CMake reads the cached values and reports them back to you, so the file on disk and the build’s behaviour diverge with no warning. Any change to the toolchain file requires deleting the build directory. In a wheel build driven by scikit-build-core this happens automatically because each build gets a fresh temporary directory, which is one of the quieter advantages of building through the backend rather than by hand.

The third is a config package that hard-codes absolute paths. Some *-config.cmake files generated on a build machine contain literal /usr/lib/... entries rather than paths relative to the config file’s own location. When such a package is copied into a sysroot, find_package succeeds and then hands you a target pointing at the host. The tell is an imported target whose INTERFACE_INCLUDE_DIRECTORIES starts with /usr rather than the sysroot; CMAKE_SYSROOT does not rewrite it, because it is a literal string rather than a search result.

# Print what the imported target actually resolved to
cmake -B build -DCMAKE_TOOLCHAIN_FILE=./aarch64-linux-gnu.toolchain.cmake \
      --log-level=DEBUG 2>&1 | grep -iE 'proj|sysroot' | head -20
grep -E 'PROJ_DIR|PROJ_INCLUDE|CMAKE_PREFIX_PATH' build/CMakeCache.txt

Reading CMakeCache.txt after a configure is the single most useful habit in cross-build debugging. It records what discovery actually decided, rather than what the toolchain file asked for, and the difference between those two is the entire bug in almost every case.

Frequently Asked Questions

Should the toolchain file live in the repository or be generated?

In the repository, checked in, and referenced by an absolute path constructed at invocation time. It is part of the build definition and should be reviewable in a pull request. Generating it from a script adds a moving part that is hard to reason about later, and the file is short enough that per-architecture copies are cheaper to read than a templating layer.

Does scikit-build-core need anything special to use it?

Only that the file is passed through to CMake. Set it in [tool.scikit-build.cmake.define] or pass CMAKE_ARGS in the build environment; both reach the configure step. The important detail is that the path must be valid inside the build container, which is not the same as the path on the runner — a mismatch here produces a confusing “toolchain file not found” during an otherwise normal wheel build.

Can one toolchain file serve several architectures?

It can, by branching on an environment variable, and it is usually a false economy. Each architecture has its own triple, sysroot and occasionally its own flag quirks, and a single file with three branches becomes hard to verify. Separate files per target, sharing a common included fragment for the parts that genuinely do not vary, stay readable as the matrix grows.

Why does the build need SQLite3 when I only asked for PROJ?

Because PROJ reads its coordinate-operation database, proj.db, through SQLite, so libsqlite3 is a hard link dependency rather than an optional extra. In a native build it is invisible — the system has SQLite — and in a cross build its absence in the sysroot is one of the first errors you meet. Populate the sysroot with the target’s SQLite, libtiff and libcurl before expecting PROJ’s config package to resolve.