Кросс-компилиция с Clang – это просто

Кросс-компилиция с Clang — это просто.   

image-loader.svg

Определимся с терминами:

  • Хост/host — система, в которой производится сборка

  • Целевая машина/target — система, в которой наше ПО должно работать

  • Нативная компиляция — архитектура целевой машина совпадает с архитектурой хоста

  • Кросс-компиляция — архитектуры хоста и целевой машины разные

Мотивация для кросс-компиляции:

  • Мощность. У нас есть большая и мощная система только на одной архитектуре, а собирать надо под другую.

  • Отсутствие компилятора — целевая машины не имеет компилятора, т.е. мы никак не можем там собирать. Например, у нас целевая машина — это микроконтроллер stm32.

  • Масштабируемость. Можно собирать на одной системе под множество разных.

  • Целевая платформа в принципе пока не имеет компилятора, и нам надо его собрать (самый редкий случай).

Почему Clang?  

На этот вопрос нет одного ответа, возможно в вашем случае будет достаточно и gcc. Но если немного забежать вперед, то Clang позволяет получить самый последний стандарт на уровне компилятора, а линковаться при этом с древней std-либой, чего gcc в свою очередь не позволяет. Походу статьи разберем и другие преимущества/недостатки, но это было веской причиной для нас. А именно — баг в gcc 8.3.0, который исправили в следующих версиях, но изменения не бэкпортировали. Поэтому собрать проект gcc, поставляемым в Debian Buster не было возможности.

Здесь не будет кросс-компиляции под raspberry, но у нас схожая архитектура (am3352), поэтому можно рассматривать это и как гайд под rpi (коих миллион). Кросс-компилировать мы будем с x86_64 (debian: amd64) на arm v7a (debian: armhf).

Теория

Процесс кросс-компиляции

Посмотрим, как же у нас идет процесс кросс-компиляции (на пальцах):

  1. Заголовки подставляются в исходники

  2. Всё это скармливается кросс-компилятору

  3. Кросс-компилятор отдает нам объектные файлы

  4. Их мы скармливаем линкеру

  5. Также линкеру передаем статические и динамические библиотеки, из которых он должен взять символы для связывания

  6. Если всё прошло успешно, то получаем наш исполняемый файл (или библиотеку)

Ничего нового, всё, как всегда. Но это на нашем хосте. На целевую систему мы должны как-то доставить наше приложение, и динамические библиотеки, от которых оно зависит.

Деплой нам пока не интересно (самое вкусное — на десерт). Рассмотрим, что используется на 2–5 шагах. Т.е. какие инструменты нам будут нужны.

Когда речь идет о кросс-компиляции в контексте никсов, то обычно выбор между gcc и clang«ом. Они предполагают совершенно разный подход к кросс-компиляции.

Тулчейны

Компонент 

GNU 

LLVM 

Компилятор 

GCC 

Clang 

Ассемблер 

GAS (Gnu ASsembler) 

Встроенный в clang 

Компоновщик 

Bfd/gold

lld 

Библиотека среды выполнения (RTL — runtime)

libgcc

compiler-rt

Раскрутчик стека (unwinder) 

libgcc_s 

libunwind 

Стандартная библиотека С++

libsupc++, libstdc++

libc++abi, libc++

Стандартная библиотека C

glibc 

-

Утилиты 

ar, objdump… 

llvm-ar, llvm-objdump…

По умолчанию, как в gcc, так и в clang предпочитаются всегда утилиты GNU, а не LLVM. Мы пойдем этим же путем, используем всю гнутую частью, а вот компилятор возьмем из LLVM (clang).

К сожалению, нельзя выбрать библиотеку среды выполнения, стандартную библиотеку C++ или раскрутчик стека во время выполнения (runtime). Это возможно только во время компиляции (compile-time). Также вы не можете линковаться на хосте с новой версией стдлибы, а запускать ваше ПО на целевой машине со старой стдлибой (теоретически можно конечно, но ноги вряд ли кто пришьет обратно), а вот наоборот как раз можно. Есть и другое ограничение, нельзя линковать стдлибу, glibc или рантайм статически. Если очень хочется, то можно. Но можно поймать UB даже не из своего кода, а из-за какой-нибудь сторонней библиотеки. Старт у нас случился с Debian buster gcc 8.3.0 и clang-11 из бэкпортов. Buster ушел в old stable. Теперь у нас bullseye. Итого имеем gcc 9.3.0 и тот же clang-11 (пока месть не будем забегать вперед, но можно взять и 13:)).

Полный комплект: Clang-11, libgcc, libgcc_s, libsupc++, libstdc++, glibc…

Надо отметить, что это не самый популярный набор, некоторые библиотеки (рецепты сборки) ожидают, что если вы взяли clang, то взяли и compiler-rt (поэтому что-то может и не собраться легко — например были сломаны рецепты m4 и b2). 

Окружение

Создадим себе еще два очень важных условия. Готовые бинарники мы пакетируем (распростараняем как .deb пакеты), и они будут полностью совместимы с Debian Bullseye. Второе — наше окружение должно быть максимально простым и легковоспроизводимым. Учитывая первый пункт — мы сразу выкидываем всякие сторонние окружения; будем использовать тулчейн, компилятор и sysroot только из апстрима дебиана. Учитывая второй пункт, тоже смотрим на пакеты дебиана в апстриме, но не забудем еще и контейниризировать наше окружение — запихнем всё в докер.

Получается такой большой пирог. Но он всё еще недостаточно большой. Добавим еще один слой — возьмем conan для библиотек, слишком старых в апстриме или отсутствующих там. И еще один слой — часто хочется иметь самый последний cmake, поэтому его соберем из исходников (заодно немного сравним производительность), можно взять и из pip’а, но так получилось, что из коробки я не смог собрать под armhf, пришлось патчить баг в апстриме, теперь из pip’a всё собирается хорошо.

Компиляторы

 С окружением разобрались, теперь посмотрим на gcc и clang, и почему нам так важен их разный подход.

Clang из коробки кросс-компилятор (ldd из коробки кросс-линкер, а вот с библиотеками llvm так не работает) и может нам всё собрать под любую платформу. Gcc же нет, он наоборот — под каждую платформу отдельный компилятор со всем тулчейном, т.е кросс-линкер тоже будет отдельный под каждую платформу. 

Если совсем просто, то, например, gcc-arm-linux-gnueabihf будет соответствовать clang -target arm-linux-gnueabihf, под капотом всё тоже работает по-разному.

Есть один очень важный момент, всё это работает и должно работать из коробки только на самых популярных дистрибутивах, если вдруг где-то будет какой-то нестандартный путь, то вам придется самостоятельно расставлять все нужные флажки и подсказывать компилятору где и что искать. Debian является как раз тем дистрибутивом, на котором тестируют Clang, поэтому если здесь что-то не сработало, то можно смело писать багрепорт в llvm.

Чтобы запускать бинарники для целевой платформы, нам понадобится qemu.

Практика

Установим нужные пакеты

Внимание! Проделывайте все операции строго в изолированном окружении, т.к. вы можете легко оставить много ненужных артефактов или сломать свои пакеты, поэтому, крайней рекомендуется использовать контейнер или виртуальную машину. 

Сначала соберем всё необходимое окружение для кросс-компилятора gcc. Пример первоначального докер образа можно взять здесь (https://github.com/qtdocker/base/blob/master/x86_64/bullseye/Dockerfile)

apt install -y build-essential \
pkg-config \
binutils \
gcc-arm-linux-gnueabihf \
crossbuild-essential-armhf

В принципе, если вы не хотите использовать llvm/clang, то можно остановиться, всё необходимое у нас уже есть.

К этому моменту у нас уже должен стоять пакет libstdc++-11-dev-armhf-cross, но это std либа для gcc, Clang хочет другой пакет.

Поэтому:

dpkg --add-architecture armhf && apt update
apt install -y libstdc++-10-dev:armhf \
clang

Всё, окружение готово. 

Сборка

Создадим main.cpp следующего содержания:

#include 

using namespace std;

int main()

{
    cout << "Hello World!" << endl;
    return 0;
}

Теперь можем попробовать собрать hello world и посмотрим, что у нас получится.

clang++ --verbose -target arm-linux-gnueabihf  main.cpp

Debian clang version 11.0.1-2

Target: arm-unknown-linux-gnueabihf

Thread model: posix
InstalledDir: /usr/bin
Found candidate GCC installation: /usr/bin/../lib/gcc-cross/arm-linux-gnueabihf/10
Found candidate GCC installation: /usr/bin/../lib/gcc/arm-linux-gnueabihf/10
Found candidate GCC installation: /usr/lib/gcc-cross/arm-linux-gnueabihf/10
Found candidate GCC installation: /usr/lib/gcc/arm-linux-gnueabihf/10
Selected GCC installation: /usr/bin/../lib/gcc/arm-linux-gnueabihf/10
Candidate multilib: .;@m32
Selected multilib: .;@m32

"/usr/lib/llvm-11/bin/clang" -cc1 -triple armv7-unknown-linux-gnueabihf ... -main-file-name main.cpp ... -target-cpu generic -target-abi aapcs-linux -mfloat-abi hard ... -x c++ main.cpp

clang -cc1 version 11.0.1 based upon LLVM 11.0.1 default target x86_64-pc-linux-gnu
ignoring nonexistent directory "/include"
ignoring duplicate directory "/usr/bin/../lib/gcc/arm-linux-gnueabihf/10/../../../../include/arm-linux-gnueabihf/c++/10"
\#include "..." search starts here:
\#include <...> search starts here:

/usr/bin/../lib/gcc/arm-linux-gnueabihf/10/../../../../include/c++/10
/usr/bin/../lib/gcc/arm-linux-gnueabihf/10/../../../../include/arm-linux-gnueabihf/c++/10
/usr/bin/../lib/gcc/arm-linux-gnueabihf/10/../../../../include/c++/10/backward
/usr/local/include
/usr/lib/llvm-11/lib/clang/11.0.1/include
/usr/include/arm-linux-gnueabihf

 /usr/include <- стоит последним, т.к. имеет наименьший приоритет (host система)

End of search list.

...

Внимательно изучим получившийся бинарник.

readelf -h a.out

ELF Header:

  Magic:  7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  Class:                            ELF32
  Data:                             2's complement, little endian
  Version:                          1 (current)
  OS/ABI:                           UNIX - System V
  ABI Version:                       0
  Type:                             EXEC (Executable file)
  Machine:                          ARM <- наша целевая архитектура
  Version:                          0x1
  Entry point address:               0x1066d
  Start of program headers:          52 (bytes into file)
  Start of section headers:          7604 (bytes into file)
  Flags:                            0x5000400, Version5 EABI, hard-float ABI

Если хотим его запустить, то необходимо поставить пакет ```qemu-user-static```

Мы получили рабочий hello world под нашу платформу.  Но всё ли так просто? В общем случае, для hello world и популярного дистрибутива — оно очень просто. Но для более сложных случаев, всё может пойти совершенно не так, как нам хочется, даже если наше окружение правильно настроено. 

Conan

Мы любим сложности, поэтому на достигнутом останавливаться не будем, попробуем запустить сначала conan:

apt update  && apt install -y --no-install-recommends \
python3-pip \
python3-setuptools \
python3-dev \
python3-wheel

pip3 install conan

conan profile new default –detect

Создадим профиль по умолчанию, пускай будет

conan profile update settings.compiler.libcxx=libstdc++11 default

Вряд ли вам нужен старый abi

# как мы помним, у нас clang-11

conan profile update settings.compiler=clang default
conan profile update settings.compiler.version=11 default

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

target_host=arm-linux-gnueabihf

[settings]
os=Linux
arch=armv7hf
compiler=clang
compiler.version=11
compiler.libcxx=libstdc++11
build_type=Release

[options]

[build_requires]

[env]
CC=/usr/bin/clang
CXX=/usr/bin/clang++
CXXFLAGS="-target arm-linux-gnueabihf" 
CFLAGS="-target arm-linux-gnueabihf" 
CHOST=$target_host
AR=$target_host-ar
AS=$target_host-as
RANLIB=$target_host-ranlib
LD=$target_host-ld
STRIP=$target_host-strip

Который значит, что мы собираем под arm-linux-gnueabi-hf, используя clang как компилятор С и C++, но всё остальное мы берем гнутое.

Target Triple

Что вообще значит тарабарщина arm-linux-gnueabi-hf?

Попробуем разобраться. Называется это всё Target Triple (подробно тут https://clang.llvm.org/docs/CrossCompilation.html#general-cross-compilation-options-in-clang)

Он имеет общий вид

<архитектура><суб архитектура>-<производитель>-<ОС>-, где:

  • архитектура = x86_64, i386, arm, thumb, mips…

  • суб архитектура = например на ARM: v5, v6m, v7a, v7m…

  • производитель = pc, apple, nvidia, ibm…

  • ОС = none (bare metal), linux, win32, darwin, cuda…

  • abi = eabi, gnu, android, macho, musleabi, elf…

Например

  • armv7a-linux-gnueabihf

      Linux, hard-floating point Armv7-a

      Linux, soft-floating point, Armv7-a

Любую опцию можно пропустить, и будет использовано значение по умолчанию. Аналогично будет, если вы подставили неизвестно значение. 

Если вы помните, то в выводе команды clang++ --verbose -target arm-linux-gnueabihf main.cpp

Можно увидеть "/usr/lib/llvm-11/bin/clang" -cc1 -triple armv7-unknown-linux-gnueabihf

Т.е. мы явно не указали суб-архитектуру и clang подставил значение по умолчанию (v7), также мы не указали производителя, и clang подставил значение по умолчанию (unknown). Будьте внимательны, он может подставить и не то, что вы желаете.

Есть небольшой нюанс, если вы компилируете под bare-metal. По умолчанию clang ищет системный sysroot для всех ОС кроме bare-metal, поэтому он может не найти необходимые заголовки и библиотеки. Их придется передавать вручную. 

CMake

Собрать его из исходников совсем просто, но не нужно в 99% случаев. К сожалению, иногда приходиться что-то править, поэтому в первый раз пришлось собирать из исходников и слать патчи в апстрим.

cd /tmp
git clone https://github.com/Kitware/CMake.git -b release
cd /tmp/CMake
./bootstrap
make
sudo make install
cd /tmp
rm -rf CMake

Забегая вперед, можно в одну команду conan install cmake/3.21.4@ или pip install cmake.

Небольшой benchmark

Как и обещал раньше, сравним производительность (https://github.com/qtdocker/base/pull/11), как раз используя conan

Проверим за сколько мы сумеем собрать cmake conan install cmake/3.21.4@ --build

Платформа 

натив amd64

qemu amd64 → armhf

кросс amd64 → armhf

Время 

449.0c 

6923.4c  

683.4с 

Итого кросс-компиляция дает примерно х10 относительно qemu. Все же она на 35% медленнее относительно нативной. Важно понимать, что на другом проекте может быть другое соотношение цифр между нативной и кросс, но порядок цифр будет примерно такой же на большой кодовой базе.

Теперь будем кросс-компилировать, используя CMake.

В первую очередь конечно обратимся к Clang#How-to-cross-compile

При корректном указании target, clang сам найдет нужные заголовки и системные библиотеки, пользователю не нужно думать об этом.

Обобщим всё сказанное и соберем маленький .deb пакет armhf под amd64. Будем использовать Clang, CMake и CPack. Если вы плохо знакомы с CPack или первый раз о нем слышите, то это интегрированный в CMake инструмент для создания пакетов .deb/.rpm/.msi… Вряд ли его нужно рассматривать отдельно, он — самый простой элемент цепочки.

 Conan использовать не будем, т.к. есть много способов его интеграции в cmake, не хочется рассматривать их все. Главное — conan-пакеты собираются под нашу архитектуру.

CMake и CPack — компилируем и пакетируем

Если вы еще не перешли в контейнер, то сейчас самое время:

docker pull atom63/qt-docker:linux-bullseye-cross-x86_64-armv7a

docker run -it atom63/qt-docker:linux-bullseye-cross-x86_64-armv7a

Указанный контейнер собран по шагам в этой статье.

Выполним:

git clone https://github.com/Jihadist/cpack-systemd-demo -b no_systemd cpack

cd cpack

mkdir build

cd build

cmake -DCMAKE_BUILD_TYPE=Release \
	-DCPACK_PACKAGING_INSTALL_PREFIX=/usr/local ..

Получим что-то крайне похожее на:

user@:~/cpack/build$ cmake -DCMAKE_BUILD_TYPE=Release \
	-DCPACK_PACKAGING_INSTALL_PREFIX=/usr/local ..

-- The C compiler identification is Clang 11.0.1
-- The CXX compiler identification is Clang 11.0.1
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/clang - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/clang++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done

-- Build files have been written to: /home/user/cpack/build

Значит всё хорошо, идем дальше: cmake --build . --config Release

user@8163cbe8cf40:~/cpack/build$ cmake --build . --config Release

[ 50%] Building CXX object CMakeFiles/HelloWorld.dir/main.cc.o
[100%] Linking CXX executable HelloWorld
[100%] Built target HelloWorld

Если собралось, то идем дальше: cpack

user@:~/cpack/build$ cpack
CPack: Create package using DEB
CPack: Install projects
CPack: - Run preinstall target for: HelloWorld
CPack: - Install project: HelloWorld []
CPack: Create package
CPackDeb: - Generating dependency list
CPack: - package: /home/user/cpack/build/HelloWorld-0.1.deb generated.



user@:~/cpack/build$ dpkg -I HelloWorld-0.1.deb 
 new Debian package, version 2.0.
 size 3992 bytes: control archive=352 bytes.
     240 bytes,    10 lines      control              
      59 bytes,     1 lines      md5sums              

 Architecture: amd64 <- целевая архитектура пакета (не бинарника)
 Depends: libc6 (>= 2.2.5), libgcc-s1 (>= 3.0), libstdc++6 (>= 4.8.1)
 Description: HelloWorld built using CMake
 Maintainer: MyCompany
 Package: helloworld
 Priority: optional
 Section: devel
 Version: 0.1
 Installed-Size: 29

Обратим внимание на Architecture: amd64, таким образом мы собрали бинарник и пакет под архитектуру нашего хоста (amd64), но нам то надо под armhf.

Поэтому перейдем в другую директорию cd .. && mkdir build-armhf && cd build-armhf

Создадим там файлик toolchain-armhf.cmake:

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(triple arm-linux-gnueabihf)
set(CMAKE_C_COMPILER clang)
set(CMAKE_C_COMPILER_TARGET ${triple})
set(CMAKE_CXX_COMPILER clang++)
set(CMAKE_CXX_COMPILER_TARGET ${triple})

Снова запустим cmake:  

user@:~/cpack/build-armhf$ cmake -DCMAKE_BUILD_TYPE=Release \
	-DCPACK_PACKAGING_INSTALL_PREFIX=/usr/local  \
  -DCMAKE_TOOLCHAIN_FILE=toolchain-armhf.cmake .. 

...

-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cpack/build-armhf

user@:~/cpack/build-armhf$ cmake --build . --config Release 

[ 50%] Building CXX object CMakeFiles/HelloWorld.dir/main.cc.o
[100%] Linking CXX executable HelloWorld
[100%] Built target HelloWorld
user@:~/cpack/build-armhf$ cpack      

...

CPack: - package: /home/user/cpack/build-armhf/HelloWorld-0.1.deb generated.

user@:~/cpack/build-armhf$ dpkg -I HelloWorld-0.1.deb 
 new Debian package, version 2.0.
 size 4100 bytes: control archive=349 bytes.
     238 bytes,    10 lines      control              
      59 bytes,     1 lines      md5sums              
 Architecture: amd64 <- опять архитектура хоста)
 Depends: libc6 (>= 2.4), libgcc-s1 (>= 3.5), libstdc++6 (>= 4.8.1)

...

 

user@:~/cpack/build-armhf$ readelf -h HelloWorld

ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           ARM <- целевая архитектура

...

Да что ж это такое, собрали бинарник под armhf, а пакет — под amd64. Ничего, просто кое-что забыли. Необходимо указать CPack’у явно архитектуру пакета, если она отлична от архитектуры хоста (-DCPACK_DEBIAN_PACKAGE_ARCHITECTURE=armhf или добавить ее в toolchain-armhf.cmake).

ser@:~/cpack/build-armhf$ cmake -DCMAKE_BUILD_TYPE=Release \
	-DCPACK_PACKAGING_INSTALL_PREFIX=/usr/local  \
  -DCMAKE_TOOLCHAIN_FILE=toolchain-armhf.cmake \
  -DCPACK_DEBIAN_PACKAGE_ARCHITECTURE=armhf .. 

-- Configuring done

...

-- Build files have been written to: /home/user/cpack/build-armhf


user@:~/cpack/build-armhf$ cmake --build . --config Release 

...

user@8163cbe8cf40:~/cpack/build-armhf$ cpack

CPack: Create package using DEB

...

CPack: - package: /home/user/cpack/build-armhf/HelloWorld-0.1.deb generated.

user@8163cbe8cf40:~/cpack/build-armhf$ dpkg -I HelloWorld-0.1.deb 
 new Debian package, version 2.0.
 size 4102 bytes: control archive=350 bytes.
     238 bytes,    10 lines      control              
      59 bytes,     1 lines      md5sums              

 Architecture: armhf <- целевая архитектура, ура!

...

Попробуем установить?  

user@:~/cpack/build-armhf$ sudo apt install ./HelloWorld-0.1.deb 

Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Note, selecting 'helloworld:armhf' instead of './HelloWorld-0.1.deb'
The following NEW packages will be installed:

  helloworld:armhf

0 upgraded, 1 newly installed, 0 to remove and 1 not upgraded.
Need to get 0 B/4102 B of archives.
After this operation, 21.5 kB of additional disk space will be used.
Get:1 /home/user/cpack/build-armhf/HelloWorld-0.1.deb helloworld armhf 0.1 [4102 B]
debconf: delaying package configuration, since apt-utils is not installed
Selecting previously unselected package helloworld:armhf.
(Reading database ... 32705 files and directories currently installed.)
Preparing to unpack .../build-armhf/HelloWorld-0.1.deb ...
Unpacking helloworld:armhf (0.1) ...
Setting up helloworld:armhf (0.1) ...

```

Ну и запустим, не зря же мы столько времени потратили. Надеюсь qemu вы поставили, иначе не сработает (если не используете готовый контейнер).

user@:~/cpack/build-armhf$ cd ~

user@:~$ HelloWorld 

Sun Nov 14 10:23:00 2021

Вуаля, мы собрали, установили и запустили armhf пакет под amd64. Дальше мы можем доставить этот пакет на наш armhf хост, и он будет там работать. 

Заключение

Проще ли собирать clang’ом чем gcc? Думаю нет, но если у вас нет выбора, и нужен свежий компилятор — то это выход. Если подготовить всё окружение и использовать cmake, то кросс-компилировать не сильно сложнее чем нативно :)

P.S. Отдельные моменты взял у Peter Smith (Linaro Toolchain Working Group). Уж слишком хорошие доклады.

P.P. S. Пока писал статью, вышла другая очень интересная, рекомендую.

© Habrahabr.ru