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.
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.
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.
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.
Related
- Building aarch64 GDAL wheels without QEMU — the end-to-end cibuildwheel recipe that consumes this toolchain file.
- Cross-Compiler Toolchain Setup — the parent guide on compiler triples, sysroots, and
pkg-configrouting. - Fixing CMake find_package for PROJ — the native-build version of the same discovery failure.