30 March 2015

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

Website developmentC++
Sandbox
Определения препроцессора (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-проектов, а и для проектов с другими системами сборки.
Надеюсь, эта статья однажды сэкономит кому-то кучу времени при отладке совершенно магических ошибок в С++ проектах, в которых есть проставление препроцессорных определений в заголовочных файлах.
Tags:C++preprocessordefinitionscmake
Hubs: Website development C++
+9
17.4k 64
Comments 5