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