Правильное проставление определений препроцессора C++ в CMake

Определения препроцессора (preprocessor definitions) часто используются в С++ проектах для условной компиляции выбранных участков кода, например, платформозависимых и т.п. В этой статье будут рассмотрены, видимо, единственные (но крайне сложные в отладке) грабли, на которые можно наступить при проставлении #define-ов через флаги компилятора.

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

Введение и описание проблемы


Некоторые платформозависимые определения, вроде проверки на Windows/Linux, проставляются компилятором, поэтому их можно использовать без дополнительной помощи систем сборки. Однако, многие другие проверки, такие, как наличие #include-файлов, наличие примитивных типов, наличие в системе требуемых библиотек или даже простое определение битности системы значительно проще делать «снаружи», впоследствии передавая требуемые определения через флаги компилятора. Кроме того, можно просто передать дополнительные определения:

Пример передачи #define-ов через флаги компилятора
g++ myfile.cpp -D MYLIB_FOUND -D IOS_MIN_VERSION=6.1

#ifdef MYLIB_FOUND
#include <mylib/mylib.h>
void DoStuff() {
  mylib::DoStuff();
}
#else
void DoStuff() {
  // own implementation
}
#endif


В CMake проставление #define-ов через компилятор делается с помощью add_definitions, которая добавляет флаги компилятора ко всему текущему проекту и его подпроектам, как и практически все команды CMake:

add_definitions(-DMYLIB_FOUND -DIOS_MIN_VERSION=6.1)

Казалось бы, что никаких проблем тут быть не может. Однако, при невнимательности можно допустить серьезную ошибку:

Если некоторый #define, проставляемый компилятором для проекта А, проверяется в заголовочном файле того же проекта А, то при #include этого заголовочного файла из другого проекта B, не являющегося подпроектом А, этот #define не будет проставлен.

Пример 1 (простой)

Рабочий пример описанной ошибки можно посмотреть на github/add_definitions/wrong. Под спойлером, на всякий случай, продублированы значимые куски кода:

add_definitions/wrong
project(wrong)
add_subdirectory(lib)
add_subdirectory(exe)

project(lib)
add_definitions(-DMYFLAG=1)
add_library(lib lib.h lib.cpp)

project(exe)
add_executable(exe exe.cpp)
target_link_libraries(exe lib)


// lib.h
static void foo() { 
#ifdef MYFLAG
    std::cout << "foo: all good!" << std::endl;
#else 
    std::cout << "foo: you're screwed :(" << std::endl;
#endif
}
void bar(); // implementation in lib.cpp

// lib.cpp
#include "lib.h"
void bar() { 
#ifdef MYFLAG
    std::cout << "bar: all good!" << std::endl;
#else 
    std::cout << "bar: you're screwed :(" << std::endl;
#endif
}

// exe.cpp
#include "lib/lib.h"
int main() {
    foo();
    bar();
}


Запуск `exe` выведет:

foo: you're screwed :(
bar: all good!

Этот пример очень простой: в нем даже есть какой-то вывод в консоль. В реальности, такая ошибка может встретиться при подключении достаточно навороченных библиотек вроде Intel Threading Building Blocks, где часть низкоуровневых параметров действительно можно передать через препроцессорные определения, причем они используются и в заголовочных файлах. Поиск удивительных ошибок в таких условиях крайне болезненный и долгий, особенно, когда этот нюанс add_definitions ранее не встречался.

Пример 2

Для наглядности, вместо двух проектов будем использовать один, вместо add_definitions будет обыкновенный #define внутри кода, а от CMake вообще откажемся. Этот пример — еще одна сильно упрощенная, но реальная ситуация, предоставляющая интерес, в том числе, с точки зрения общих знаний С++.

Запускаемый код можно посмотреть на github/add_definitions/cpphell. Как и в предыдущем примере, значимые участки кода под спойлером:

add_definitions/cpphell
// a.h
class A {
public:
    A(); // implementation in a.cpp with DANGER defined
    ~A(); // for illustrational purposes
#ifdef DANGER
    std::vector<int> just_a_vector_;
    std::string just_a_string_;
#endif // DANGER
}; 

// a.cpp 
#define DANGER // let's have a situation
#include "a.h"
A::A() {
    std::cout << "sizeof(A) in A constructor = " << sizeof(A) << std::endl;
}
A::~A() {
    std::cout << "sizeof(A) in A destructor = " << sizeof(A) << std::endl;
    std::cout << "Segmentation fault incoming..." << std::endl;
}

// main.cpp
#include "a.h" // DANGER will not be defined from here
void just_segfault() {
    A a; 
    // segmentation fault on 'a' destructor
}
void verbose_segfault() {
    A *a = new A();
    delete a;
}
int main(int argc, char **argv) {
    std::cout << "sizeof(A) in main.cpp = " << sizeof(A) << std::endl;
    // verbose_segfault(); // uncomment this 
    just_segfault();
    std::cout << "This line won't be printed" << std::endl;
}


Ошибка прекрасная. Один файл (a.cpp) видит скрытые под #ifdef-ом члены класса, а другой (main.cpp) — нет. Для них классы становятся разного размера, что влечет проблемы с управлением памятью, в частности, Segmentation Fault:

g++ main.cpp a.cpp -o main.out && ./main.out

sizeof(A) in main.cpp = 1
sizeof(A) in A constructor = 32
sizeof(A) in A destructor = 32
Segmentation fault incoming...
Segmentation fault (core dumped)

Если раскомментировать в main.cpp вызов verbose_segfault(), то в конце выведется:

*** Error in `./main.out': free(): invalid next size (fast): 0x000000000149f010 ***
======= Backtrace: =========
...
======= Memory map: ========
... 

После некоторого количества экспериментов выяснилось, что если вместо STL классов использовать любое количество примитивных типов в полях класса А, то падения не наблюдается, поскольку для деструкторы для них не вызываются. Кроме того, если вставить одинокую std::string (на 64-bit Arch Linux и GCC 4.9.2 sizeof(std::string) == 8), то падения нет, а если две — то уже есть. Полагаю, дело в выравнивании, но надеюсь, что в комментариях смогут подробно разъяснить, что же на самом деле происходит.

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


Не использовать «внешние» определения в заголовочных файлах

Если это возможно, то это самый простой вариант. К сожалению, иногда под #ifdef-ами стоят различные платформо- и компиляторозависимые сигнатуры функций, а некоторые библиотеки вообще состоят только из заголовочных файлов.

Использовать add_definitions в корневом CMakeLists.txt

Это, конечно, решает проблему «забытых» переданных флагов для конкретного проекта, но последствия следующие:

  • Параметры командной строки компилятора будут включать все флаги для всех проектов, включая те проекты, которым эти флаги не нужны — сложность в отладке, например, через make VERBOSE=1, когда хочется понять, с чем же этот компилятор на конкретном файле себя вызывает.
  • Этот проект нельзя будет «встроить» как подпроект в другой проект, потому что тогда будет наблюдаться точно такая же проблема. Стоит отметить, что в CMake процесс встраивания проекта, чаще всего, совершенно безболезненный, и такой возможностью часто не стоит пренебрегать.

Использовать конфигурационные заголовочные файлы и configure_file

CMake предоставляет возможность создания конфигурационных заголовочных файлов с помощью configure_file. В репозитории хранятся заранее заготовленные шаблоны, из которых, на момент сборки проекта CMake-ом, генерируются сами конфигурационные файлы. Сгенерированные файлы #include-тся в требуемых заголовочных файлах проекта.

При использовании configure_file следует помнить, что теперь проставления препроцессорных определений «снаружи» конкретного проекта через add_definitions работать не будет. Конечно, можно сделать особенный конфигурационный файл, который проставляет флаги только если они еще не были проставлены (#ifndef), но это внесет еще больше путаницы.

Заключение


Показанные ошибки и варианты решений, конечно, подходят не только для CMake-проектов, а и для проектов с другими системами сборки.
Надеюсь, эта статья однажды сэкономит кому-то кучу времени при отладке совершенно магических ошибок в С++ проектах, в которых есть проставление препроцессорных определений в заголовочных файлах.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 5

    +1
    Честно говоря, какие-то детские проблемы — как можно не понимать что используемые глобально сущности не могут быть определены локально? А именно в этом проблема в обоих примерах.

    Касательно разруливания этого в CMake, красивое решение для встроенной в проект библиотеки — set(MYLIB_DEFINITIONS "-DFOO" PARENT_SCOPE). Так логика установки флагов остаётся у библиотеки, а родительский проект получает оные после add_subdirectory так же как это обычно получается после find_package.

    Ещё надёжнее решение с configure_file — так (как вы заметили) флаги не будут болтаться в командах, их нельзя будет забыть включить, но библиотеке в любом случае надо будет передавать родителю MYLIB_INCLUDE_DIRS (source и binary пути к заголовочным файлам — не забудьте что сгенерённый после configure_file заголовок попадает в binary директорию), и MYLIB_LIBRARY (тут просто имя цели библиотеки) с упомянутым PARENT_SCOPE. Решение с configure_file также удобнее в плане выноса библиотеки в самостоятельный проект — т.е. такой который можно без изменений как установить в систему через make install, так и включить непосредственно в проект, причём для проекта разница также будет всего в одной строке — find_package vs. add_subdirectory.

    > При использовании configure_file следует помнить, что теперь проставления препроцессорных определений «снаружи» конкретного проекта через add_definitions работать не будет

    А вот так делать и не нужно. Библиотека должна конфигурироваться через CMake — родительский проект должен только установить высокоуровневые cmake-переменные, а не лезть в низкоуровневые флаги компилятора. При этом если эти настройки действительно повлияют на флаги, оные вернутся родителю описанным выше способом.
      +1
      По поводу тривиальности проблем — в том и дело, что в случае с #define-ами в заголовках из-за отсутствия опыта и/или невнимательности можно забыть, что сущности, оказывается, глобальные. Конечно, во второй раз такую ошибку уже не допустишь.

      В остальном — спасибо, ценная информация, с вами полностью согласен.
      +1
      в cmake есть не только add_definitions

      SET_TARGET_PROPERTIES(${PROJECT_NAME} PROPERTIES COMPILE_FLAGS -DHELLO_WORLD)
      SET_SOURCE_FILES_PROPERTIES(main.cpp PROPERTIES COMPILE_FLAGS -DHELLO_WORLD)
      TARGET_COMPILE_DEFINITIONS/COMPILE_DEFINITIONS

        +1
        Ваш пример 1 исправляется очень просто:

        lib_config.cmake
        add_definitions(-DMYFLAG=1)
        

        lib/CMakeLists.txt
        project(lib)
        include(lib_config)
        add_library(lib lib.h lib.cpp)
        

        exe/CMakeLists.txt
        project(exe)
        include(lib_config)
        add_executable(exe exe.cpp)
        target_link_libraries(exe lib)
        

        и никакой генерации конфигов
        более трудоемкий способ через find_package и руками сделанный Find*package name*.cmake
        0
        Ваш пример 2 «сработал», — выдал " Stack around the variable 'a' was corrupted." когда освобождается стек при выходе из стек-фрейма (из функции just_segfault())

        Ну так получается, что поля типа заключать под #ifdef'ами в заголовочных файлах — потенциально опасная вещь.
        Сами подумайте:
        Хедерник может включаться многократно в разные единицы трансляции (т.е. в разные cpp/c/cxx и т.п. файлы)
        И в разных единицах трансляции могут быть разные определения препроцессора.
        Мы получим разные размеры объектов одного и того же типа в разных единицах трансляции, они могут слинковаться, могут не слинковаться, если слинкуются, то получится потенциально небезопасный код.

        Only users with full accounts can post comments. Log in, please.