Осваиваем кросс-компиляцию с помощью Clang и LLVM +15



Каждый, кто когда-либо пробовал собрать программу на C/C++ через кросс-компиляцию знает, насколько болезненным может быть этот процесс. Главными причинами столь печального положения вещей являются недружелюбность систем сборки при конфигурации кросс-компиляции, а также запутанность процесса настройки набора утилит (тулчейна).


Одним из основных виновников этих проблем, по моему опыту, является тулчейн GNU — древний мамонт, на котором много десятилетий строится весь мир POSIX. Подобно многим компиляторам былых времён, семейство GCC и binutils никогда не ориентировалось на поддержку множества различных целей сборки в одной установке, и единственным способом хоть как-то добиться желаемого была настройка полной кросс-билд-системы для каждой целевой платформы на каждом хосте.


Например, если вы хотите собрать что-то для FreeBSD на машине под Linux с помощью GCC, вам потребуется:


  • Установленный GCC + binutils для вашего сборочной платформы (т.е. x86_64-pc-linux-gnu или подобное);
  • Полностью установленный GCC + binutils для вашей целевой платформы (т.е. x86_64-unknown-freebsd12.2-gcc, as, nm и т.д.)
  • Sysroot со всеми необходимыми библиотеками и заголовочными файлами, который вы можете собрать самостоятельно, либо утащить из работающей FreeBSD.

Некоторые дистрибутивы Linux, а также некоторые разработчики оборудования могут облегчить этот процесс, предоставляя готовые наборы для компиляции, однако их никогда не хватает, ввиду огромного количества различных комбинаций сборочных/целевых платформ. Зачастую это означает, что вам необходимо самостоятельно полностью собрать тулчейн с нуля, что приводит к масштабным затратам времени и ресурсов, если конечно у вас не самый мощный процессор.


Clang как кросс-компилятор


Эти досадные ограничения побудили меня обратить внимание на LLVM (и Clang), который изначально создан как полноценный тулчейн для кросс-компиляции, и при этом практически полностью совместим с GNU. Один единственный инстанс LLVM способен собирать и компилировать код для каждой поддерживаемой платформы; помимо него для сборки нужен лишь sysroot.


Несмотря на то, что он ещё не может сравниться по удобству с тулчейнами современных языков (такими, как gc и GOARCH/GOOS в Go), это всё же настоящий глоток свежего воздуха по сравнению со сложностями настроек тулчейнов GNU. Вы можете просто установить его из пакетов для вашего дистрибутива (если только он не сильно старый), и сразу избежать всех сложностей с множественными установками GCC.


Ещё несколько лет назад весь процесс не был настолько хорошо настроен. Поскольку LLVM ещё не включал в себя весь тулчейн, вам нужно было по-прежнему откуда-то брать binutils, специфичный для вашей целевой платформы. И даже хотя решить эту проблему было гораздо проще, чем собрать весь компилятор целиком (binutils собирается значительно быстрее), этот факт всё же добавлял хлопот. Однако к настоящему моменту llvm-mc (интегрированный ассемблер LLVM) и lld (универсальный линкер) уже стабильны и настолько же гибки, как весь остальной LLVM.


Когда весь тулчейн доступен, для компиляции и сборки вашего проекта необходим ещё sysroot, содержащий все необходимые библиотеки и заголовочные файлы.


Добываем sysroot


Быстрее всего раздобыть работающую системную папку для нужной ОС можно, скопировав её напрямую из существующей системы (для этого зачастую подходит контейнер Docker). Например, вот так я скопировал работающий sysroot из виртуалки с FreeBSD 13-CURRENT AArch64 с помощью tar и ssh


$ mkdir ~/farm_tree
$ ssh FARM64 'tar cf - /lib /usr/include /usr/lib /usr/local/lib /usr/local/include' | bsdtar xvf - -C $HOME/farm_tree/

Примечание

При копировании по сети, а не локально, неплохо также сжать получившийся tarball


Запускаем кросс-компилятор


Когда всё готово, остаётся лишь запустить Clang с правильными аргументами:


$  clang++ --target=aarch64-pc-freebsd --sysroot=$HOME/farm_tree -fuse-ld=lld -stdlib=libc++ -o zpipe zpipe.cc -lz --verbose
clang version 11.0.1
Target: aarch64-pc-freebsd
Thread model: posix
InstalledDir: /usr/bin
 "/usr/bin/clang-11" -cc1 -triple aarch64-pc-freebsd -emit-obj -mrelax-all -disable-free -disable-llvm-verifier -discard-value-names -main-file-name zpipe.cc -mrelocation-model static -mframe-pointer=non-leaf -fno-rounding-math -mconstructor-aliases -munwind-tables -fno-use-init-array -target-cpu generic -target-feature +neon -target-abi aapcs -fallow-half-arguments-and-returns -fno-split-dwarf-inlining -debugger-tuning=gdb -v -resource-dir /usr/lib/clang/11.0.1 -isysroot /home/marco/farm_tree -internal-isystem /home/marco/farm_tree/usr/include/c++/v1 -fdeprecated-macro -fdebug-compilation-dir /home/marco/dummies/cxx -ferror-limit 19 -fno-signed-char -fgnuc-version=4.2.1 -fcxx-exceptions -fexceptions -faddrsig -o /tmp/zpipe-54f1b1.o -x c++ zpipe.cc
clang -cc1 version 11.0.1 based upon LLVM 11.0.1 default target x86_64-pc-linux-gnu
#include "..." search starts here:
#include <...> search starts here:
 /home/marco/farm_tree/usr/include/c++/v1
 /usr/lib/clang/11.0.1/include
 /home/marco/farm_tree/usr/include
End of search list.
 "/usr/bin/ld.lld" --sysroot=/home/marco/farm_tree --eh-frame-hdr -dynamic-linker /libexec/ld-elf.so.1 --enable-new-dtags -o zpipe /home/marco/farm_tree/usr/lib/crt1.o /home/marco/farm_tree/usr/lib/crti.o /home/marco/farm_tree/usr/lib/crtbegin.o -L/home/marco/farm_tree/usr/lib /tmp/zpipe-54f1b1.o -lz -lc++ -lm -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /home/marco/farm_tree/usr/lib/crtend.o /home/marco/farm_tree/usr/lib/crtn.o
$ file zpipe
zpipe: ELF 64-bit LSB executable, ARM aarch64, version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1, for FreeBSD 13.0 (1300136), FreeBSD-style, with debug_info, not stripped

В приведённом примере я собрал исходник C++ в программу для платформы AArch64 FreeBSD, и всё это с использованием лишь clang и lld, которые я уже установил на своей машине GNU/Linux.


Чуть-чуть деталей:


  1. --target переключает целевую платформу LLVM по умолчанию (x86_64-pc-linux-gnu) в aarch64-pc-freebsd, таким образом включая кросс-компиляцию.
  2. --sysroot заставляет Clang использовать указанный путь как корневой для поиска библиотек и заголовочных файлов, вместо обычных путей. Заметьте, что иногда этого ключа недостаточно, особенно если проект использует GCC, а Clang не может определить путь к нему. Такая проблема может быть легко исправлена указанием ключа --gcc-toolchain, который указывает, где найти установленный GCC.
  3. -fuse-ld=lld указывает Clang использовать lld вместо любого другого линковщика, используемого в системе. Скорее всего системный линковщик, доступный по умолчанию, не сможет собрать "чужую" программу, в то время как LLD поддерживает напрямую практически все форматы исполняемых файлов и операционных систем.
  4. -stdlib=libc++ необходимо указать, поскольку Clang не может сам определить, что FreeBSD на платформе AArch64 использует библиотеку libc++ из LLVM, а не libstdc++ из GCC.
  5. -lz добавлено, чтобы показать, как Clang умеет без проблем находить в sysroot другие библиотеки, в данном случае zlib.

Про Mac OS

К сожалению, macOS больше не поддерживается в LLD, ввиду того, что поддержка формата Mach-O была заброшена несколько лет назад. В связи с этим единственным способом собрать исполняемый файл формата Mach-O является использование линкера ld64 (нативного, или в кросс-системе, если вы сами его соберёте). Хотя утилита ld.bfd из binutils всё ещё его поддерживает.


В качестве заключительного аккорда, мы скопируем собраный бинарь на нашу целевую систему (т.е. на виртуалку, откуда мы извлекли чуть раньше sysroot), и убедимся, что всё работает:


$ rsync zpipe FARM64:"~"
$ ssh FARM64
FreeBSD-ARM64-VM $ chmod +x zpipe
FreeBSD-ARM64-VM $ ldd zpipe
zpipe:
        libz.so.6 => /lib/libz.so.6 (0x4029e000)
        libc++.so.1 => /usr/lib/libc++.so.1 (0x402e4000)
        libcxxrt.so.1 => /lib/libcxxrt.so.1 (0x403da000)
        libm.so.5 => /lib/libm.so.5 (0x40426000)
        libc.so.7 => /lib/libc.so.7 (0x40491000)
        libgcc_s.so.1 => /lib/libgcc_s.so.1 (0x408aa000)
FreeBSD-ARM64-VM $ ./zpipe -h
zpipe usage: zpipe [-d] < source > dest

Всё получилось! Теперь мы можем использовать этот кросс-тулчейн для сборки более крупных программ, и ниже я расскажу, как использовать его для сборки реальных проектов.


Опционально: создаём папку с тулчейном LLVM


LLVM предоставляет практически полностью совместимые альтернативы для всех утилит, входящих в binutils (за исключением разве что as), с префиксом llvm- в имени.


Про ассемблер

llvm-mc можно использовать как (очень громоздкий) ассемблер, но он плохо документирован. Подобно gcc, фронтэнд clang также может использоваться как ассемблер, делая as зачастую ненужным.


Наиболее критичной из них является LLD, который полностью подменяет системный линковщик для целевой платформы и может заменить одновременно как ld.bfd из GNU, или gold из GNU/Linux или BSD, так и LINK.EXE от Microsoft при сборке под MSVC. Он поддерживает сборку на (практически) каждой платформе, поддерживаемой LLVM, таким образом устраняя необходимость в нескольких специфических линковщиках.


Оба компилятора, GCC и Clang поддерживают использование ld.lld вместо системного линковщика (которым также может быть и lld, например, на FreeBSD) с помощью ключа -fuse-ld=lld.


На опыте я сталкивался с тем, что драйвер Clang иногда не может найти верный линковщик для некоторых редких платформ, особенно до версии 11.0. По какой-то причине иногда clang открыто игнорирует ключ -fuse-ld=lld и вызывает системный линковщик (в моём случае ld.bfd), который не умеет собирать для AArch64.


Быстрым решением для этой проблемы будет создание папки тулчейна, содержащей символические ссылки, которые переименовывают утилиты LLVM в стандартные программы из binutils:


$  ls -la ~/.llvm/bin/
Permissions Size User  Group Date Modified Name
lrwxrwxrwx    16 marco marco  3 Aug  2020  ar -> /usr/bin/llvm-ar
lrwxrwxrwx    12 marco marco  6 Aug  2020  ld -> /usr/bin/lld
lrwxrwxrwx    21 marco marco  3 Aug  2020  objcopy -> /usr/bin/llvm-objcopy
lrwxrwxrwx    21 marco marco  3 Aug  2020  objdump -> /usr/bin/llvm-objdump
lrwxrwxrwx    20 marco marco  3 Aug  2020  ranlib -> /usr/bin/llvm-ranlib
lrwxrwxrwx    21 marco marco  3 Aug  2020  strings -> /usr/bin/llvm-strings

Также можно использовать ключ -B, который заставляет Clang (или GCC) искать нужные утилиты в этой папке, так что подобная проблема даже не возникает:


$  clang++ -B$HOME/.llvm/bin -stdlib=libc++ --target=aarch64-pc-freebsd --sysroot=$HOME/farm_tree -std=c++17 -o mvd-farm64 mvd.cc
$ file mvd-farm64
mvd-farm64: ELF 64-bit LSB executable, ARM aarch64, version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1, for FreeBSD 13.0, FreeBSD-style, with debug_info, not stripped

Опционально: создаём обёртки Clang для упрощения кросс-компиляции


Я заметил, что некоторые системы сборки (и под "некоторыми" я имею в виду некоторые кривые Makefile, и иногда Autotools) склонны ломаться, если значения переменных окружения $CC, $CXX или $LD содержат пробелы или несколько параметров. Такое может периодически случаться, если нам необходимо вызвать clang с несколькими аргументами (не говоря уже о тех преступниках, которые хардкодят вызовы gcc в свои билд-скрипты. Впрочем, это уже совсем другая история.)


Понимая, насколько громоздко и сложно каждый раз не забыть выписать все параметры корректно в каждом из случаев, я обычно пишу короткие обёртки для clang и clang++ с целью упростить сборку для конкретной целевой платформы:


$ cat ~/.local/bin/aarch64-pc-freebsd-clang
#!/usr/bin/env sh

exec /usr/bin/clang -B$HOME/.llvm/bin --target=aarch64-pc-freebsd --sysroot=$HOME/farm_tree "$@"
$ cat ~/.local/bin/aarch64-pc-freebsd-clang++
#!/usr/bin/env sh

exec /usr/bin/clang++ -B$HOME/.llvm/bin -stdlib=libc++ --target=aarch64-pc-freebsd --sysroot=$HOME/farm_tree "$@"   

Если этот скрипт доступен внутри $PATH, его можно повсеместно использовать как отдельную команду:


$ aarch64-pc-freebsd-clang++ -o tst tst.cc -static
$ file tst
tst: ELF 64-bit LSB executable, ARM aarch64, version 1 (FreeBSD), statically linked, for FreeBSD 13.0 (1300136), FreeBSD-style, with debug_info, not stripped

Кросс-сборка с помощью Autotools, Cmake и Meson


Autotools, Cmake и Meson, возможно, самые популярные системы сборки для open-source проектов на C и C++ (извини, SCons). Все три поддерживают кросс-компиляцию прямо "из коробки", хотя с некоторыми особенностями.


Autotools


В течение многих лет Autotools славится своей ужасной неуклюжестью и хрупкостью. И хотя эту репутацию он заработал вполне обосновано, он по-прежнему широко используется для большинства крупных проектов GNU. Будучи в строю уже не одно десятилетие, многие его проблемы, если что-то вдруг пошло криво, можно разрешить поиском в сети (хотя если вы пишете свой .ac файл, это не всегда так). По сравнению с более современными системами, для кросс-компиляции ему не нужен специфичный файл тулчейна или какая-то особая конфигурация; всё настраивается исключительно параметрами командной строки.


Скрипт ./configure (созданный утилитой autoconf, или включённый в тарболл с исходниками) обычно поддерживает флаг --host, позволяя пользователю задать триплет целевой системы, на которой предполагается запускать собранные программы и прочие артефакты.


Этот флаг активирует кросс-компиляцию, после чего множество утилит auto-что-то-там начинают выяснять правильный компилятор для целевой платформы, который, как правило, зовётся по именам some-triple-gcc или some-triple-g++.


Попробуем сконфигурировать сборку binutils версии 2.35.1 для платформы aarch-pc-freebsd, используя написанную выше обёртку для вызова Clang:


$ tar xvf binutils-2.35.1.tar.xz
$ mkdir binutils-2.35.1/build # always create a build directory to avoid messing up the source tree
$ cd binutils-2.35.1/build
$ env CC='aarch64-pc-freebsd-clang' CXX='aarch64-pc-freebsd-clang++' AR=llvm-ar ../configure --build=x86_64-pc-linux-gnu --host=aarch64-pc-freebsd --enable-gold=yes
checking build system type... x86_64-pc-linux-gnu
checking host system type... aarch64-pc-freebsd
checking target system type... aarch64-pc-freebsd
checking for a BSD-compatible install... /usr/bin/install -c
checking whether ln works... yes
checking whether ln -s works... yes
checking for a sed that does not truncate output... /usr/bin/sed
checking for gawk... gawk
checking for aarch64-pc-freebsd-gcc... aarch64-pc-freebsd-clang
checking whether the C compiler works... yes
checking for C compiler default output file name... a.out
checking for suffix of executables...
checking whether we are cross compiling... yes
checking for suffix of object files... o
checking whether we are using the GNU C compiler... yes
checking whether aarch64-pc-freebsd-clang accepts -g... yes
checking for aarch64-pc-freebsd-clang option to accept ISO C89... none needed
checking whether we are using the GNU C++ compiler... yes
checking whether aarch64-pc-freebsd-clang++ accepts -g... yes
[...]

Вызов скрипта ./configure выше означает, что я хочу от autotools следующего:


  1. Сконфигурировать сборку на платформе x86_64-pc-linux-gnu (которую я задал с помощью ключа --build);
  2. Собрать программы, которые будут исполняться на платформе aarch64-pc-freebsd, что задаётся ключом --host;
  3. В качестве компиляторов C и C++ использовать обёртки Clang, описанные выше.
  4. В качестве целевой утилиты ar использовать llvm-ar.

Я также задал сборку линковщика Gold, который написан на С++ и может использоваться как неплохой тест на то, как наш импровизированный тулчейн способен компилировать программы на C++.


Если стадия конфигурации не сломается по какой-то причине (вроде не должно), мы можем далее запустить GNU Make и собрать binutils:


$ make -j16 # because I have 16 threads on my system
[ lots of output]
$ mkdir dest
$ make DESTDIR=$PWD/dest install # install into a fake tree

Здесь мы должны получить исполнимые файлы и библиотеки внутри целевой папки, созданной с помощью make install. Быстрая проверка с помощью file подтверждает, что все программы корректно собраны для платформы aarch64-pc-freebsd:


$ file dest/usr/local/bin/ld.gold
dest/usr/local/bin/ld.gold: ELF 64-bit LSB executable, ARM aarch64, version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1, for FreeBSD 13.0 (1300136), FreeBSD-style, with debug_info, not stripped

CMake


Проще всего настроить CMake для сборки на конкретную платформу можно с помощью файла тулчейна. Он обычно состоит из настроек, которые указывают CMake, как он должен работать с данным тулчейном, определяя такие параметры, как целевая операционная система, архитектура CPU, имя компилятора C++ и т.д.


Для сборки под триплет aarch64-pc-freebsd тулчейн файл может быть таким:


set(CMAKE_SYSTEM_NAME FreeBSD)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

set(CMAKE_SYSROOT $ENV{HOME}/farm_tree)

set(CMAKE_C_COMPILER aarch64-pc-freebsd-clang)
set(CMAKE_CXX_COMPILER aarch64-pc-freebsd-clang++)
set(CMAKE_AR llvm-ar)

# these variables tell CMake to avoid using any binary it finds in 
# the sysroot, while picking headers and libraries exclusively from it 
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

В этом файле я назначил вышеупомянутую обёртку как кросс-компилятор кода C и C++ для целевой платформы. Также можно использовать напрямую Clang с подходящими параметрами, но это не настолько прямолинейно и потенциально более подвержено ошибкам.


В любом случае, исключительно важно задать правильные значения для переменных CMAKE_SYSROOT и CMAKE_FIND_ROOT_PATH_MODE_*, иначе CMake может ошибочно взять пакеты от текущей платформы, с закономерно неверным результатом.


Далее остаётся лишь указать путь к этому файлу, при конфигурировании сборки, через переменную CMAKE_TOOLCHAIN_FILE, либо через параметр --toolchain. Для примера я соберу пакет {fmt} (это удивительная библиотека C++, которую вам непременно стоит попробовать) для платформы aarch64-pc-freebsd:


$  git clone https://github.com/fmtlib/fmt
Cloning into 'fmt'...
remote: Enumerating objects: 45, done.
remote: Counting objects: 100% (45/45), done.
remote: Compressing objects: 100% (33/33), done.
remote: Total 24446 (delta 17), reused 12 (delta 7), pack-reused 24401
Receiving objects: 100% (24446/24446), 12.08 MiB | 2.00 MiB/s, done.
Resolving deltas: 100% (16551/16551), done.
$ cd fmt
$ cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=$HOME/toolchain-aarch64-freebsd.cmake -DBUILD_SHARED_LIBS=ON -DFMT_TEST=OFF .
-- CMake version: 3.19.4
-- The CXX compiler identification is Clang 11.0.1
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /home/marco/.local/bin/aarch64-pc-freebsd-clang++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Version: 7.1.3
-- Build type: Release
-- CXX_STANDARD: 11
-- Performing Test has_std_11_flag
-- Performing Test has_std_11_flag - Success
-- Performing Test has_std_0x_flag
-- Performing Test has_std_0x_flag - Success
-- Performing Test SUPPORTS_USER_DEFINED_LITERALS
-- Performing Test SUPPORTS_USER_DEFINED_LITERALS - Success
-- Performing Test FMT_HAS_VARIANT
-- Performing Test FMT_HAS_VARIANT - Success
-- Required features: cxx_variadic_templates
-- Performing Test HAS_NULLPTR_WARNING
-- Performing Test HAS_NULLPTR_WARNING - Success
-- Looking for strtod_l
-- Looking for strtod_l - not found
-- Configuring done
-- Generating done
-- Build files have been written to: /home/marco/fmt/build

По сравнению с Autotools, командная строка, переданная в cmake очень проста и не требует дополнительных объяснений. После конфигурирования остаётся лишь скомпилировать проект, а затем запустить ninja или make, чтобы установить куда-нибудь получившиеся артефакты.


$ cmake --build build
[4/4] Creating library symlink libfmt.so.7 libfmt.so
$ mkdir dest
$ env DESTDIR=$PWD/dest cmake --build build -- install
[0/1] Install the project...
-- Install configuration: "Release"
-- Installing: /home/marco/fmt/dest/usr/local/lib/libfmt.so.7.1.3
-- Installing: /home/marco/fmt/dest/usr/local/lib/libfmt.so.7
-- Installing: /home/marco/fmt/dest/usr/local/lib/libfmt.so
-- Installing: /home/marco/fmt/dest/usr/local/lib/cmake/fmt/fmt-config.cmake
-- Installing: /home/marco/fmt/dest/usr/local/lib/cmake/fmt/fmt-config-version.cmake
-- Installing: /home/marco/fmt/dest/usr/local/lib/cmake/fmt/fmt-targets.cmake
-- Installing: /home/marco/fmt/dest/usr/local/lib/cmake/fmt/fmt-targets-release.cmake
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/args.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/chrono.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/color.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/compile.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/core.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/format.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/format-inl.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/locale.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/os.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/ostream.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/posix.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/printf.h
-- Installing: /home/marco/fmt/dest/usr/local/include/fmt/ranges.h
-- Installing: /home/marco/fmt/dest/usr/local/lib/pkgconfig/fmt.pc
$  file dest/usr/local/lib/libfmt.so.7.1.3
dest/usr/local/lib/libfmt.so.7.1.3: ELF 64-bit LSB shared object, ARM aarch64, version 1 (FreeBSD), dynamically linked, for FreeBSD 13.0 (1300136), with debug_info, not stripped

Meson


Подобно CMake, Meson полагается на файлы тулчейнов (так называемые "cross files"), которые определяют, какие программы должны быть использованы для сборки под текущую целевую платформу. Благодаря тому, что они написаны на TOML-подобном языке, они очень просты и понятны:


$ cat meson_aarch64_fbsd_cross.txt
[binaries]
c = '/home/marco/.local/bin/aarch64-pc-freebsd-clang'
cpp = '/home/marco/.local/bin/aarch64-pc-freebsd-clang++'
ld = '/usr/bin/ld.lld'
ar = '/usr/bin/llvm-ar'
objcopy = '/usr/bin/llvm-objcopy'
strip = '/usr/bin/llvm-strip'

[properties]
ld_args = ['--sysroot=/home/marco/farm_tree']

[host_machine]
system = 'freebsd'
cpu_family = 'aarch64'
cpu = 'aarch64'
endian = 'little'

Этот кросс-файл далее нужно указать при вызове meson setup с помощью ключа --cross-file. Остальные настройки абсолютно такие же, как для любой другой сборки с помощью Meson.


Кстати...

Подобным образом можно настроить нативный тулчейн на текущей машине, используя нативный файл с ключом --native-file.


И на этом, пожалуй, всё: подобно CMake весь процесс относительно безболезненен и безошибочен. Для полноты расскажу, как собрать dav1d, декодер VideoLAN AV1, для платформы aarch64-pc-freebsd:


$ git clone https://code.videolan.org/videolan/dav1d
Cloning into 'dav1d'...
warning: redirecting to https://code.videolan.org/videolan/dav1d.git/
remote: Enumerating objects: 164, done.
remote: Counting objects: 100% (164/164), done.
remote: Compressing objects: 100% (91/91), done.
remote: Total 9377 (delta 97), reused 118 (delta 71), pack-reused 9213
Receiving objects: 100% (9377/9377), 3.42 MiB | 54.00 KiB/s, done.
Resolving deltas: 100% (7068/7068), done.
$ meson setup build --cross-file ../meson_aarch64_fbsd_cross.txt --buildtype release
The Meson build system
Version: 0.56.2
Source dir: /home/marco/dav1d
Build dir: /home/marco/dav1d/build
Build type: cross build
Project name: dav1d
Project version: 0.8.1
C compiler for the host machine: /home/marco/.local/bin/aarch64-pc-freebsd-clang (clang 11.0.1 "clang version 11.0.1")
C linker for the host machine: /home/marco/.local/bin/aarch64-pc-freebsd-clang ld.lld 11.0.1
[ output cut ]
$ meson compile -C build
Found runner: ['/usr/bin/ninja']
ninja: Entering directory `build'
[129/129] Linking target tests/seek_stress
$ mkdir dest
$ env DESTDIR=$PWD/dest meson install -C build
ninja: Entering directory `build'
[1/11] Generating vcs_version.h with a custom command
Installing src/libdav1d.so.5.0.1 to /home/marco/dav1d/dest/usr/local/lib
Installing tools/dav1d to /home/marco/dav1d/dest/usr/local/bin
Installing /home/marco/dav1d/include/dav1d/common.h to /home/marco/dav1d/dest/usr/local/include/dav1d
Installing /home/marco/dav1d/include/dav1d/data.h to /home/marco/dav1d/dest/usr/local/include/dav1d
Installing /home/marco/dav1d/include/dav1d/dav1d.h to /home/marco/dav1d/dest/usr/local/include/dav1d
Installing /home/marco/dav1d/include/dav1d/headers.h to /home/marco/dav1d/dest/usr/local/include/dav1d
Installing /home/marco/dav1d/include/dav1d/picture.h to /home/marco/dav1d/dest/usr/local/include/dav1d
Installing /home/marco/dav1d/build/include/dav1d/version.h to /home/marco/dav1d/dest/usr/local/include/dav1d
Installing /home/marco/dav1d/build/meson-private/dav1d.pc to /home/marco/dav1d/dest/usr/local/lib/pkgconfig
$ file dest/usr/local/bin/dav1d
dest/usr/local/bin/dav1d: ELF 64-bit LSB executable, ARM aarch64, version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1, for FreeBSD 13.0 (1300136), FreeBSD-style, with debug_info, not stripped

Бонус: статическая сборка с musl и Alpine Linux


Статическая сборка программ на C и C++ иногда может спасти вас от множества проблем совместимости библиотек, особенно когда вы не можете контролировать, что именно будет установлено на той целевой платформе, под которую вы планируете сборку. Однако сборка статических бинарников на GNU/Linux довольно непроста, поскольку Glibc всячески препятствует попыткам слинковаться с ним статически.


Почему?

Система разрешения имён (NSS), встроенная в glibc, является одной из главных причин интенсивного использования функций dlopen()/dlsym(). Это связано с использованием сторонних плагинов, вроде mDNS, используемых для разрешения имён.


Musl — это альтернанивный вариант стандартной библиотеки для Linux, которая гораздо терпимее к статической линковке, и нынче включена в большинство крупных дистрибутивов. Этих пакетов зачастую вполне достаточно для статической сборки вашего кода, по крайней мере пока вы планируете оставаться в рамках чистого C.


Однако если вы планируете собирать C++, либо если вам необходимы дополнительные компоненты, ситуация становится гораздо сложнее и интереснее. Любая библиотека, входящая в GNU/Linux (такая, как libstdc++, libz, libffi и другие) обычно собрана только с Glibc, что означает, что любая библиотека, которую вы хотите использовать, должна быть пересобрана для цели Musl. Это так и для libstdc++, что неизбежно означает либо перекомпиляцию GCC, либо сборку копии libc++ от LLVM.


К счастью, существует несколько дистрибутивов, собранных для платформы "Musl-plus-Linux", наиболее известный из которых Alpine Linux. А потому возможно использовать освоенный нами подход: извлекаем sysroot для платформы x86_64-pc-linux-musl, в котором находится полный комплект библиотек и пакетов собранных для Musl, а затем используем его вместе с Clang для сборки 100% статических программ.


Настройка контейнера с Alpine


Хорошей отправной точкой является тарболл с minirootfs от Alpine, который предназначен специально для контейнеров, а потому очень маленький:


$ wget -qO - https://dl-cdn.alpinelinux.org/alpine/v3.13/releases/x86_64/alpine-minirootfs-3.13.1-x86_64.tar.gz | gunzip | sudo tar xfp - -C ~/alpine_tree

Теперь мы можем сделать chroot внутрь образа ~/alpine_tree и настроить его, установив все нужные нам пакеты. Обычно я предпочитаю использовать systemd-nspawn вместо chroot, поскольку это значительно проще и меньше склоняет к ошибкам.


А ещё

system-nspawn может также служить лёгкой альтернативой виртуальным машинам. С ключом --boot он умеет запускать процесс init внутри контейнера. Вот в этом весьма полезном gist можно научиться создавать загрузочный контейнер для дистрибутивов, основанных на OpenRC (например, Alpine)


$ sudo systemd-nspawn -D alpine_tree
Spawning container alpinetree on /home/marco/alpine_tree.
Press ^] three times within 1s to kill container.
alpinetree:~# 

Здесь мы можем (по желанию) отредактировать /etc/apk/repositories, чтобы переключиться на бранч edge с самыми свежими пакетами, а затем установить нужные пакеты Alpine, содержащие любые статические библиотеки для сборки кода, который мы хотим собрать:


alpinetree:~# cat /etc/apk/repositories
https://dl-cdn.alpinelinux.org/alpine/edge/main
https://dl-cdn.alpinelinux.org/alpine/edge/community
alpinetree:~# apk update
fetch https://dl-cdn.alpinelinux.org/alpine/edge/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/edge/community/x86_64/APKINDEX.tar.gz
v3.13.0-1030-gbabf0a1684 [https://dl-cdn.alpinelinux.org/alpine/edge/main]
v3.13.0-1035-ga3ac7373fd [https://dl-cdn.alpinelinux.org/alpine/edge/community]
OK: 14029 distinct packages available
alpinetree:~# apk upgrade
OK: 6 MiB in 14 packages
alpinetree:~# apk add g++ libc-dev
(1/14) Installing libgcc (10.2.1_pre1-r3)
(2/14) Installing libstdc++ (10.2.1_pre1-r3)
(3/14) Installing binutils (2.35.1-r1)
(4/14) Installing libgomp (10.2.1_pre1-r3)
(5/14) Installing libatomic (10.2.1_pre1-r3)
(6/14) Installing libgphobos (10.2.1_pre1-r3)
(7/14) Installing gmp (6.2.1-r0)
(8/14) Installing isl22 (0.22-r0)
(9/14) Installing mpfr4 (4.1.0-r0)
(10/14) Installing mpc1 (1.2.1-r0)
(11/14) Installing gcc (10.2.1_pre1-r3)
(12/14) Installing musl-dev (1.2.2-r1)
(13/14) Installing libc-dev (0.7.2-r3)
(14/14) Installing g++ (10.2.1_pre1-r3)
Executing busybox-1.33.0-r1.trigger
OK: 188 MiB in 28 packages
alpinetree:~# apk add zlib-dev zlib-static
(1/3) Installing pkgconf (1.7.3-r0)
(2/3) Installing zlib-dev (1.2.11-r3)
(3/3) Installing zlib-static (1.2.11-r3)
Executing busybox-1.33.0-r1.trigger
OK: 189 MiB in 31 packages

В данном примере я установил g++ и libc-dev, чтобы получить статические копии libstdc++, статическую libc.a (Musl), и их соответствующие заголовочные файлы. Я также установил zlib-dev и zlib-static, чтобы получить заголовки zlib и библиотеку zlib.a. Как общее правило — Alpine обычно предоставляет статические версии в пакетах *-static, и заголовочные файлы в пакетах somepackage-dev.


Но не всегда

К сожалению, по неизвестным мне причинам Alpine не предоставляет статические версии некоторых библиотек (например, libfmt). Поэтому внедрять копию зависимостей в проект является вполне обычной практикой для C++, и потому это довольно несложно.


Также не забывайте каждый раз запускать apk upgrade внутри sysroot, чтобы поддерживать локальную копию Alpine в актуальном состоянии.


Сборка статических программ на C++


Когда всё готово, остаётся лишь вызвать clang++с верным --target и --sysroot:


$ clang++ -B$HOME/.llvm/bin --gcc-toolchain=$HOME/alpine_tree/usr --target=x86_64-alpine-linux-musl --sysroot=$HOME/alpine_tree -L$HOME/alpine_tree/lib -std=c++17 -o zpipe zpipe.cc -lz -static
$ file zpipe
zpipe: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped

Дополнительный ключ --gcc-toolchain в данном случае опционален. Он поможет, если Clang не найдёт внутри sysroot GCC и различные файлы crt*.o. Дополнительный ключ -L для /lib необходим, поскольку в Alpine библиотеки лежат как в /usr/lib, так и в /lib, а последний не рассматривается по умолчанию в clang, который ожидает, что все библиотеки находятся только в $SYSROOT/usr/lib.


Написание обёртки для статической линковки с Musl и Clang


В пакетах Musl обычно есть готовые обёртки musl-gcc и musl-clang, которые вызывают системные компиляторы для сборки и линковки с альтернативным libc. Для удобства я по-быстрому набросал вот такой скрипт на Perl:


#!/usr/bin/env perl

use strict;
use utf8;
use warnings;
use v5.30;

use List::Util 'any';

my $ALPINE_DIR = $ENV{ALPINE_DIR} // "$ENV{HOME}/alpine_tree";
my $TOOLS_DIR = $ENV{TOOLS_DIR} // "$ENV{HOME}/.llvm/bin";

my $CMD_NAME = $0 =~ /\+\+/ ? 'clang++' : 'clang';
my $STATIC = $0 =~ /static/;

sub clang {
    exec $CMD_NAME, @_ or return 0;
}

sub main {
    my $compile = any { /^\s*-c|-S\s*$/ } @ARGV;

    my @args = (
         qq{-B$TOOLS_DIR},
         qq{--gcc-toolchain=$ALPINE_DIR/usr},
         '--target=x86_64-alpine-linux-musl',
         qq{--sysroot=$ALPINE_DIR},
         qq{-L$ALPINE_DIR/lib},
         @ARGV,
    );

    unshift @args, '-static' if $STATIC and not $compile;

    exit 1 unless clang @args;
}

main;

Это более "продвинутый" вариант обёртки, чем тот, что я привёл выше для FreeBSD AArch64. Например, она подразумевает компиляцию кода на С++, если вызвана по имени clang++, или всегда добавляет ключ -static, если вызвана по символической ссылке, содержащей в имени слово static:


$ ls -la $(which musl-clang++)
lrwxrwxrwx    10 marco marco 26 Jan 21:49  /home/marco/.local/bin/musl-clang++ -> musl-clang
$ ls -la $(which musl-clang++-static)
lrwxrwxrwx    10 marco marco 26 Jan 22:03  /home/marco/.local/bin/musl-clang++-static -> musl-clang
$ musl-clang++-static -std=c++17 -o zpipe zpipe.cc -lz # automatically infers C++ and -static
$ file zpipe
zpipe: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped

Таким образом, теперь возможно заставить Clang линковать только с ключом -static, если установить $CC в musl-clang-static, что может быть полезно для систем сборки, которые не очень хорошо работают со статической линковкой. По опыту могу сказать, что больше всего проблем с этим доставляют Autotools (иногда), а также кривые Makefiles.


Заключение


Кросс-компиляция кода на C и C++ является, и скорее всего навсегда останется непростой задачей, однако она стала значительно проще, покуда LLVM достиг стабильности и стал широко распространён. Параметр -target в Clang избавил меня от траты бесчисленного множества человеко-часов на бесконечную сборку и пересборку GCC и Binutils.


К сожалению, не всё то золото, что блестит, и так бывает довольно часто. До сих пор встречаются костыли, которые собираются только с помощью GCC из-за грязных GNUизмов (да-да, Glibc, это про тебя). Кросс-компиляция под Windows/MSVC также невозможна ввиду сильной запутанности всего тулчейна Visual Studio.


Кроме того, хотя сборка под целевую платформу путём указания в Clang верного триплета стала гораздо проще, чем когда-то была, она всё равно нервно курит, поглядывая на то, насколько тривиальна кросс-компиляция в случае с Rust или Go.


Из новых языков отдельного упоминания заслуживает Zig, поскольку его целью является также облегчение сборки кода на C и C++ под другие платформы.


Команды zig cc и zig c++ потенциально могут стать удивительным швейцарским ножом для кросс-компиляции, благодаря тому, что Zig содержит в себе clang и множество частей из разных проектов, такие как Glibc, Musl, libc++ и MinGW. Благодаря этому любая нужная библиотека собирается, если надо, прямо на лету:


$ zig c++ --target=x86_64-windows-gnu -o str.exe str.cc
$ file str.exe
str.exe: PE32+ executable (console) x86-64, for MS Windows

Пока, я думаю, это выглядит не так совершенно, однако уже работает прямо как магия. Осмелюсь утверждать, что это настоящая киллер-фича Zig, и она достойна внимания даже тех, кому совсем не интересен сам язык.

Теги:




Комментарии (8):

  1. catBasilio
    /#23756549 / +1

    линковщик (в моём случае ld.bfd), который не умеет собирать для AArch64

    Блин, а мужики-то не знают!

  2. Playa
    /#23756781

    Спасибо за подробное описание. Немного не понял, в чём суть флага --gcc-toolchain. Если clang компилирует сразу под целевую систему, то зачем здесь нужен gcc?

    Заранее извиняюсь за скорее всего тупой вопрос, просто раньше практически не имел дела с кросс-компиляцией, но хочется попробовать собрать что-то современное под платформу Blackberry 10, где кроме gcc 4.6 ничего нет.

    • klirichek
      /#23756931

      Это, по сути, обособленная часть sysroot.

      Обычно всё в /usr/include, /usr/lib - но некоторые специфичные либы поставляются вместе с gcc. Также там некоторые объектники (crt*.o)

      --gcc-toolchain=<arg>

      Search for GCC installation in the specified directory on targets which commonly use GCC. The directory usually contains ‘lib{,32,64}/gcc{,-cross}/$triple’ and ‘include’. If specified, sysroot is skipped for GCC detection. Note: executables (e.g. ld) used by the compiler are not overridden by the selected GCC installation

      • prograholic
        /#23758523

        В принципе, можно сделать тулчейн не зависящий от gcc, нужно собрать compiler-rt, libunwind, libc++, libc++abi из состава llvm. Правда, это может быть довольно хлопотно для некоторых архитектур.

        Gcc тулчейн хоть и неудобен, но зато собирается под гораздо более широкий набор платформ, чем полноценный clang тулчейн.

  3. atd
    /#23757017

    А можно не париться и скачать zig: https://andrewkelley.me/post/zig-cc-powerful-drop-in-replacement-gcc-clang.html

    • klirichek
      /#23757029 / +2

      Ещё можно дочитать до конца.

  4. borisxm
    /#23758969

    Кросс-компиляция, это удобно, но не всегда хорошо. Часто целевая платформа содержит патченый компилятор, в котором устранены специфичные для платформы ошибки. Та же FreeBSD, имеет богатую историю откладывания переездов на новые версии компиляторов, по причине банальной неработоспособности порождаемого кода. Поэтому, если уж есть виртуальная машина с нужной платформой, то можно выполнить сборку прямо в ней.

    • prograholic
      /#23759267

      Часто целевая платформа содержит патченый компилятор

      Я бы сказал, что наоборот, это крайне редко. Куча железок имеют только кроссы. Даже на тех же линуксах собирают кроссами, чтобы гарантированно запускаться на системах с нужной версией (g)libc