Введение в детерминированные сборки на С/С++. Часть 2

Автор оригинала: CONAN
  • Перевод
Перевод статьи подготовлен специально для студентов курса «Разработчик С++».



Читать первую часть



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


Если одни и те же исходные файлы скомпилированы в разных папках, иногда информация о папках передается в двоичные файлы. Это может произойти в основном по двум причинам:

  • Использование макросов, которые содержат информацию о текущем файле, например макрос __FILE__.
  • Создание отладочных двоичных файлов, которые хранят информацию о том, где находятся источники.

Продолжим наш пример hello world в MacOS, давайте разделим исходники, чтобы мы могли показать влияние расположения на финальные двоичные файлы. Структура проекта будет похожа на приведенную ниже.

.
├── run_build.sh
├── srcA
│   ├── CMakeLists.txt
│   ├── hello_world.cpp
│   ├── hello_world.hpp
│   └── main.cpp
└── srcB
    ├── CMakeLists.txt
    ├── hello_world.cpp
    ├── hello_world.hpp
    └── main.cpp

Соберем наши двоичные файлы в режиме отладки.

cd srcA/build
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
cd .. && cd ..
cd srcB/build
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
cd .. && cd ..
md5sum srcA/build/hello
md5sum srcB/build/hello
md5sum srcA/build/CMakeFiles/HelloLib.dir/hello_world.cpp.o
md5sum srcB/build/CMakeFiles/HelloLib.dir/hello_world.cpp.o
md5sum srcA/build/libHelloLib.a
md5sum srcB/build/libHelloLib.a
Мы получим следующие контрольные суммы:
3572a95a8699f71803f3e967f92a5040  srcA/build/hello
7ca693295e62de03a1bba14853efa28c  srcB/build/hello
76e0ae7c4ef79ec3be821ccf5752730f  srcA/build/CMakeFiles/HelloLib.dir/hello_world.cpp.o
5ef044e6dcb73359f46d48f29f566ae5  srcB/build/CMakeFiles/HelloLib.dir/hello_world.cpp.o
dc941156608b578c91e38f8ecebfef6d  srcA/build/libHelloLib.a
1f9697ef23bf70b41b39ef3469845f76  srcB/build/libHelloLib.a

Информация о папке передается из объектных файлов в конечные исполняемые файлы, что делает наши сборки невоспроизводимыми. Мы можем посмотреть различия между двоичными файлами, используя diffoscope, чтобы увидеть, куда встраивается информация о папке.

> diffoscope helloA helloB
--- srcA/build/hello
+++ srcB/build/hello
@@ -1282,20 +1282,20 @@
...
 00005070: 5f77 6f72 6c64 5f64 6562 7567 2f73 7263  _world_debug/src
-00005080: 412f 006d 6169 6e2e 6370 7000 2f55 7365  A/.main.cpp./Use
+00005080: 422f 006d 6169 6e2e 6370 7000 2f55 7365  B/.main.cpp./Use
 00005090: 7273 2f63 6172 6c6f 732f 446f 6375 6d65  rs/carlos/Docume
 000050a0: 6e74 732f 6465 7665 6c6f 7065 722f 7265  nts/developer/re
 000050b0: 7072 6f64 7563 6962 6c65 2d62 7569 6c64  producible-build
 000050c0: 732f 7361 6e64 626f 782f 6865 6c6c 6f5f  s/sandbox/hello_
-000050d0: 776f 726c 645f 6465 6275 672f 7372 6341  world_debug/srcA
+000050d0: 776f 726c 645f 6465 6275 672f 7372 6342  world_debug/srcB
 000050e0: 2f62 7569 6c64 2f43 4d61 6b65 4669 6c65  /build/CMakeFile
 000050f0: 732f 6865 6c6c 6f2e 6469 722f 6d61 696e  s/hello.dir/main
 00005100: 2e63 7070 2e6f 005f 6d61 696e 005f 5f5a  .cpp.o._main.__Z
...
@@ -1336,15 +1336,15 @@
...
 000053c0: 6962 6c65 2d62 7569 6c64 732f 7361 6e64  ible-builds/sand
 000053d0: 626f 782f 6865 6c6c 6f5f 776f 726c 645f  box/hello_world_
-000053e0: 6465 6275 672f 7372 6341 2f62 7569 6c64  debug/srcA/build
+000053e0: 6465 6275 672f 7372 6342 2f62 7569 6c64  debug/srcB/build
 000053f0: 2f6c 6962 4865 6c6c 6f4c 6962 2e61 2868  /libHelloLib.a(h
 00005400: 656c 6c6f 5f77 6f72 6c64 2e63 7070 2e6f  ello_world.cpp.o
 00005410: 2900 5f5f 5a4e 3130 4865 6c6c 6f57 6f72  ).__ZN10HelloWor
...

Возможные решения


Опять же, решение будет зависеть от используемого компилятора:

  • msvc не может установить параметры, чтобы избежать добавления этой информации в двоичные файлы. Единственный способ получить воспроизводимые двоичные файлы — снова использовать инструмент исправления для удаления этой информации на этапе сборки. Обратите внимание, что поскольку мы исправляем двоичные файлы для получения воспроизводимых двоичных файлов, папки, используемые для разных сборок, должны иметь одинаковую длину в символах.
  • У gcc есть три флага компилятора, чтобы обойти эту проблему:
    • -fdebug-prefix-map=OLD=NEW может удалить префиксы каталога из отладочной информации.
    • -fmacro-prefix-map=OLD=NEW доступен начиная с gcc 8 и решает проблему невоспроизводимости из-за использования макроса __FILE__.
    • -ffile-prefix-map=OLD=NEW доступен начиная с gcc 8 и является объединением -fdebug-prefix-map и -fmacro-prefix-map
  • clang поддерживает -fdebug-prefix-map=OLD=NEW с версии 3.8 и работает над поддержкой двух других флагов для будущих версий.

Лучший способ решить эту проблему — добавить флаги в параметры компилятора. При использовании CMake:

target_compile_options(target PUBLIC "-ffile-prefix-map=${CMAKE_SOURCE_DIR}=.")

Порядок файлов в системе сборки


Порядок файлов может представлять проблему, если каталоги считываются, чтобы составить список их файлов. Например, Unix не имеет детерминированного порядка, в котором readdir() и listdir() должны возвращать содержимое каталога, поэтому доверие к этим функциям для подачи в систему сборки может привести к недетерминированным сборкам.

Та же проблема возникает, например, если ваша система сборки хранит файлы для компоновщика в контейнере (например, в обычном python-словаре), который может возвращать элементы в недетерминированном порядке. Это приведет к тому, что каждый раз файлы будут связаны в другом порядке, и будут создаваться разные двоичные файлы.

Мы можем смоделировать эту проблему, изменяя порядок файлов в CMake. Если мы изменим предыдущий пример, чтобы иметь более одного исходного файла для библиотеки:

.
├── CMakeLists.txt
├── CMakeListsA.txt
├── CMakeListsB.txt
├── hello_world.cpp
├── hello_world.hpp
├── main.cpp
├── sources0.cpp
├── sources0.hpp
├── sources1.cpp
├── sources1.hpp
├── sources2.cpp
└── sources2.hpp

Мы можем увидеть, что результаты компиляции отличаются, если мы изменим порядок файлов в CMakeLists.txt:

cmake_minimum_required(VERSION 3.0)
project(HelloWorld)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(HelloLib hello_world.cpp 
                     sources0.cpp 
                     sources1.cpp 
                     sources2.cpp)
add_executable(hello main.cpp)
target_link_libraries(hello HelloLib)

Если мы сделаем две последовательные сборки с именами A и B, поменяв местами sources0.cpp и sources1.cpp в списке файлов, получим следующие контрольные суммы:

30ab264d6f8e1784282cd1a415c067f2  helloA
cdf3c9dd968f7363dc9e8b40918d83af  helloB
707c71bc2a8def6885b96fb67b84d79c  hello_worldA.cpp.o
707c71bc2a8def6885b96fb67b84d79c  hello_worldB.cpp.o
694ff3765b688e6faeebf283052629a3  sources0A.cpp.o
694ff3765b688e6faeebf283052629a3  sources0B.cpp.o
0db24dc6a94da1d167c68b96ff319e56  sources1A.cpp.o
0db24dc6a94da1d167c68b96ff319e56  sources1B.cpp.o
fd0754d9a4a44b0fcc4e4f3c66ad187c  sources2A.cpp.o
fd0754d9a4a44b0fcc4e4f3c66ad187c  sources2B.cpp.o
baba9709d69c9e5fd51ad985ee328172  libHelloLibA.a
72641dc6fc4f4db04166255f62803353  libHelloLibB.a

Объектные .o-файлы идентичны, но -библиотеки и исполняемые файлы — нет. Это связано с тем, что порядок вставки в библиотеки зависит от порядка перечисления файлов.

Случайность созданная компилятором


Эта проблема возникает, например, в gcc, когда активирована оптимизация времени компоновки (Link-Time Optimizations) (флагом -flto). Эта опция вводит случайно сгенерированные имена в двоичные файлы. Единственный способ избежать этой проблемы — использовать флаг -frandom-seed. Эта опция предоставляет начальное число, которое gcc использует вместо случайных чисел. Он используется для генерации определенных имен символов, которые должны быть разными в каждом скомпилированном файле. Он также используется для размещения уникальных штампов в файлах покрытия данных и объектных файлах, которые их производят. Этот параметр должен быть разным для каждого исходного файла. Один из вариантов — установить контрольную сумму файла, чтобы вероятность коллизии была очень низкой. Например, в CMake это можно сделать с помощью такой функции:

set(LIB_SOURCES
    ./src/source1.cpp
    ./src/source2.cpp
    ./src/source3.cpp)

foreach(_file ${LIB_SOURCES})
    file(SHA1 ${_file} checksum)
    string(SUBSTRING ${checksum} 0 8 checksum)
    set_property(SOURCE ${_file} APPEND_STRING PROPERTY COMPILE_FLAGS "-frandom-seed=0x${checksum}")
endforeach()

Несколько советов по использованию Conan


Хуки Conan могут помочь нам сделать наши сборки воспроизводимыми. Эта фича позволяет настроить поведение клиента в определенных точках.

Одним из способов использования хуков может быть установка переменных окружения на этапе pre_build. В приведенном ниже примере вызывается функция set_environment, а затем восстанавливается среда на шаге post_build с помощью reset_environment.

def set_environment(self):
    if self._os == "Linux":
        self._old_source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
        timestamp = "1564483496"
        os.environ["SOURCE_DATE_EPOCH"] = timestamp
        self._output.info(
            "set SOURCE_DATE_EPOCH: {}".format(timestamp))
    elif self._os == "Macos":
        os.environ["ZERO_AR_DATE"] = "1"
        self._output.info(
            "set ZERO_AR_DATE: {}".format(timestamp))

def reset_environment(self):
    if self._os == "Linux":
        if self._old_source_date_epoch is None:
            del os.environ["SOURCE_DATE_EPOCH"]
        else:
            os.environ["SOURCE_DATE_EPOCH"] = self._old_source_date_epoch
    elif self._os == "Macos":
        del os.environ["ZERO_AR_DATE"]

Хуки также могут быть полезны для исправления двоичных файлов на этапе post_build. Существуют различные инструменты анализа и исправления бинарных файлов, такие как ducible, pefile, pe-parse или strip-nondeterminism. Пример хука для исправления двоичного файла PE с использованием ducible может быть таким:

class Patcher(object):
...
    def patch(self):
        if self._os == "Windows" and self._compiler == "Visual Studio":
            for root, _, filenames in os.walk(self._conanfile.build_folder):
                for filename in filenames:
                    filename = os.path.join(root, filename)
                    if ".exe" in filename or ".dll" in filename:
                        self._patch_pe(filename)

    def _patch_pe(self, filename):
        patch_tool_location = "C:/ducible/ducible.exe"
        if os.path.isfile(patch_tool_location):
            self._output.info("Patching {} with md5sum: {}".format(filename,md5sum(filename)))
            self._conanfile.run("{} {}".format(patch_tool_location, filename))
            self._output.info("Patched file: {} with md5sum: {}".format(filename,md5sum(filename)))
...

def pre_build(output, conanfile, **kwargs):
    lib_patcher.init(output, conanfile)
    lib_patcher.set_environment()

def post_build(output, conanfile, **kwargs):
    lib_patcher.patch()
    lib_patcher.reset_environment()

Выводы


Детерминированные сборки являются сложной задачей, тесно связанной с используемой операционной системой и набором инструментов. Это введение должно было помочь понять наиболее распространенные причины отсутствия детерминированности и способы их устранения.

Ссылки


Общая информация



Инструменты


Инструменты для сравнения двоичных файлов


Инструменты для исправления файлов


Инструменты для анализа файлов


Читать первую часть
  • +14
  • 2,6k
  • 1
OTUS. Онлайн-образование
582,86
Цифровые навыки от ведущих экспертов
Поделиться публикацией

Комментарии 1

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое