Продолжаю тему о сборке проектов на Си и Си++, первая часть которой размещена здесь.

Рецепты сборки по правилам хорошего тона должны поставляться с проектом и очень желательно, чтобы они не были привязаны к конкретной ОС или хотя бы к конкретному дистрибутиву Linux, используя специфические команды вроде apt-get, yum, emerge. Если это небольшой пет-проект выходного дня, то, конечно, в качестве рецепта сгодится и bash-скрипт. Пользователи адаптируют. Но если к проекту подключаются другие разработчики, то лучше потратить время на автоматизацию сборки, чтобы не терять его оптом в будущем. В этой части речь пойдёт об инструментах, которые используются для автоматизации процесса сборки программного обеспечения.

Make и его Makefile


Рассмотрим пример Makefile. Файл с таким именем без расширения сохраняем в директорию с компилируемыми исходниками.

В качестве примера соберём program из трёх исходников (main.cpp, myfunctions1.cpp, myfunctions2.cpp), создав три промежуточные цели (main.o, myfunctions1.o, myfunctions2.o) для окончательной сборки проекта:

all: program

program: main.o myfunctions1.o myfunctions2.o
    g++ -Wall -std=c++17 main.o myfunctions1.o myfunctions2.o -o program

main.o: main.cpp
    g++ -Wall -std=c++17 -c main.cpp -o main.o

myfunctions1.o: myfunctions1.cpp
    g++ -Wall -std=c++17 -c myfunctions1.cpp -o myfunctions1.o

myfunctions2.o: myfunctions2.cpp
    g++ -Wall -std=c++17 -c myfunctions2.cpp -o myfunctions2.o

clean:
    rm -f *.o program

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

# Имя исполняемого файла, который получим в результате сборки
TARGET = program

# Используем компилятор g++ с дополнительными опциями
CXX = g++
CXXFLAGS = -Wall -std=c++17
LDFLAGS = 

# Задаём списки исходных файлов
SRCS = main.cpp myfunctions1.cpp myfunctions2.cpp

# Список объектов OBJS создаём из списка SRCS, заменяя .cpp на .o
OBJS = $(SRCS:.cpp=.o)

# Правило по умолчанию для сборки всего проекта говорит, что для сборки 'program' требуется, чтобы были собраны цели из списка OBJS, и только тогда можно приступить к сборке 'program'
$(TARGET): $(OBJS)
	$(CXX) $(OBJS) $(LDFLAGS) -o $(TARGET)

# Правило для сборки объектных файлов: чтобы собрать цель (и есть файл) с расширением .o, нужно взять одноимённый файл .cpp
%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@

# Удаление сгенерированных файлов из переменных OBJS и TARGET
clean:
	rm -f $(OBJS) $(TARGET)

Для обработки рецепта из Makefile в системе должна быть установлена утилита make, которая, будучи запущенной в директории с рецептом без каких-либо аргументов, запустит сборку цели по умолчанию. В данном рецепте цель по умолчанию это 'program'. В рецепте задаются переменные: TARGET — имя исполняемого файла, CXX — компилятор (g++), CXXFLAGS — флаги компиляции (включение всех предупреждений и стандарт C++17), LDFLAGS — флаги линковки (в данном случае пустые). Затем указываются исходные файлы в переменной SRCS. На основе SRCS создаётся список объектных файлов OBJS, заменяя расширения .cpp и .c на .o.

Главное правило (или цель) $(TARGET): $(OBJS) указывает, что для создания исполняемого файла 'program' нужно сначала собрать все объектные файлы из списка OBJS. Команда $(CXX) $(OBJS) $(LDFLAGS) -o $(TARGET) компилирует и линкует объектные файлы в исполняемый.

Правила %.o: %.cpp и %.o: %.c описывают, как создавать объектные файлы из соответствующих исходных файлов. % — это шаблон, который соответствует любому имени файла. $ < представляет собой исходный файл, а $@ — целевой объектный файл. Флаг -c указывает компилятору только скомпилировать исходник в объектный файл, без линковки.

Правило clean: удаляет все сгенерированные файлы: объектные файлы и исполняемый файл. rm -f выполняет принудительное удаление.

По сути рецепт сборки Makefile — это скрипт, в котором перечисляются цели и файлы, которые нужны для сборки этой цели. Если во время сборки основной цели program не обнаруживает в директории нашего проекта объектный файл main.o, то будет попытка найти цель main.o. И в вышеприведённом рецепте эта цель будет найдена как подпадающая под шаблон %.o: %.cpp, который говорит что для сборки main.o нужно скомпилировать main.cpp. И сборщик приступит к сборке main.o только тогда, когда будут готовы все необходимые для его сборки цели, от которых зависит. Это позволило запускать распараллеленную сборку на многоядерных процессорах. Вот такая команда запустит компиляцию 'program' в 8 потоков:

make -j 8 program

Сборка с использованием внешних библиотек с установкой в систему


Прежде чем продолжить беседу об автоматических сборщиках, нужно вникнуть в суть проблемы сборки проектов, которые имеют зависимость от внешних библиотек. Популярные библиотеки, такие как libcurl, могут иметь свои пакеты для вашего дистрибутива, а менее популярные обычно доступны лишь в виде исходников на github.

Представим для примера, что мы хотим в нашем проекте работать по протоколу HTTP/HTTPS, и для этого нам нужно подключить libcurl. В Debian-based дистрибутивах следующим образом можно установить пакет libcurl для разработки прямо в систему, а также libcurl4-openssl-dev, от которой зависит уже сам libcurl:

$ sudo apt-get install libcurl4-openssl-dev

Используем apt-file для просмотра установленных этим пакетом файлов:

$ apt-file list libcurl4-openssl-dev
libcurl4-openssl-dev: /usr/bin/curl-config
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/curl.h
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/curlver.h
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/easy.h
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/mprintf.h
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/multi.h
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/stdcheaders.h
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/system.h
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/typecheck-gcc.h
libcurl4-openssl-dev: /usr/include/x86_64-linux-gnu/curl/urlapi.h
libcurl4-openssl-dev: /usr/lib/x86_64-linux-gnu/libcurl.a
libcurl4-openssl-dev: /usr/lib/x86_64-linux-gnu/libcurl.la
libcurl4-openssl-dev: /usr/lib/x86_64-linux-gnu/libcurl.so
libcurl4-openssl-dev: /usr/lib/x86_64-linux-gnu/pkgconfig/libcurl.pc

Видим, что этот пакет установил заголовочный файл /usr/include/x86_64-linux-gnu/curl/curl.h, который мы можем использовать в нашем исходном коде:

curlexample.cpp

#include <iostream>
#include <curl/curl.h>

static size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userp)
{
    ((std::string*)userp)->append((char*)contents, size * nmemb);
    return size * nmemb;
}

int main() {
    CURL* curl;
    CURLcode res;
    std::string readBuffer;

    curl_global_init(CURL_GLOBAL_DEFAULT);
    curl = curl_easy_init();
    
    if(curl) {
        curl_easy_setopt(curl, CURLOPT_URL, "https://httpbin.org/get");
        curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
        curl_easy_setopt(curl, CURLOPT_WRITEDATA, &readBuffer);

        res = curl_easy_perform(curl);
        if(res != CURLE_OK) {
            std::cerr << "curl_easy_perform() failed: " << curl_easy_strerror(res) << std::endl;
        } else {
            std::cout << "Response: " << readBuffer << std::endl;
        }

        curl_easy_cleanup(curl);
    }
    curl_global_cleanup();
    
    return 0;
}

Если мы попытаемся скомпилировать этот файл следующей командой, то получим ошибку:

$ g++ curlexample.cpp 
/usr/bin/ld: /tmp/ccgZg7LN.o: in function `main':
curlexample.cpp:(.text+0x74): undefined reference to `curl_global_init'
/usr/bin/ld: curlexample.cpp:(.text+0x79): undefined reference to `curl_easy_init'
/usr/bin/ld: curlexample.cpp:(.text+0xa5): undefined reference to `curl_easy_setopt'
/usr/bin/ld: curlexample.cpp:(.text+0xc2): undefined reference to `curl_easy_setopt'
/usr/bin/ld: curlexample.cpp:(.text+0xdc): undefined reference to `curl_easy_setopt'
/usr/bin/ld: curlexample.cpp:(.text+0xe8): undefined reference to `curl_easy_perform'
/usr/bin/ld: curlexample.cpp:(.text+0x111): undefined reference to `curl_easy_strerror'
/usr/bin/ld: curlexample.cpp:(.text+0x179): undefined reference to `curl_easy_cleanup'
/usr/bin/ld: curlexample.cpp:(.text+0x17e): undefined reference to `curl_global_cleanup'
collect2: error: ld returned 1 exit status

Эта ошибка говорит о том, что компиляция прошла успешно и заголовочные файлы были найдены в стандартной директории заголовков, а вот линковка дала сбой потому как не были найдены необходимые функции. Из нашего исследования мы видим, что файл библиотеки называется libcurl.so. Это значит, что для его подключения нужно убрать префикс 'lib' и сообщить линковщику название библиотеки через опцию '-l' без расширения .so:
$ g++ curlexample.cpp -o curlexample  -lcurl

Так проект скомпилируется, и он будет работать, но в общем случае это не очень хороший подход. Плох подход тем, что мы установили библиотеку в систему. Для библиотеки libcurl, которая используется практически повсеместно, это не критично, так как она регулярно обновляется и обычно не создает проблем. Проблемы начнутся при использовании менее популярных библиотек. Тем не менее, для простоты и понятности примеров я продолжу использовать libcurl.

Я веду к тому, что надо задаваться вопросом: что будет с нашей системой, если мы будем работать над множеством проектов, каждый из которых тащит за собой в систему множество библиотек? А если эти библиотеки нужны будут определённых версий? А если сегодняшние проекты нужно будет обслуживать много лет и часть библиотек перестанет обновляться? Таких вопросов «если» к этому подходу очень много.

Сборка с использованием внешних библиотек с компиляцией зависимостей


Чтобы не устанавливать curl в систему, скачаем исходники и скомпилируем их своими ручками. Для этого нам понадобятся некоторые инструменты:

sudo apt install wget build-essential autoconf automake libtool zlib1g-dev libssl-dev libpsl-dev 

Качаем прямо в директорию нашего проекта, разворачиваем архив и конфигурируем с поддержкой openssl (для HTTPS):

$ wget https://curl.se/download/curl-8.11.1.tar.bz2
$ tar -jxf curl-8.11.1.tar.bz2
$ cd curl-8.11.1
$ ./configure --with-openssl
$ make

Если нет никаких ошибок, то на этом этапе библиотека libcurl будет скомпилирована. Можно даже установить её в систему командой 'make install', но мы именно от этого и пытались уйти. И вообще такой установки следует избегать, потому что она создает конфликт с системными пакетами, установленными через пакетный менеджер. Остановимся после команды 'make'. Результат сборки мы найдём в директории проекта ./curl-8.11.1/lib/.libs. У меня полный путь получился следующий. И вот какие библиотечные файлы теперь у нас есть:
/app/build/curlexample/curl-8.11.1/lib/.libs/libcurl.a
/app/build/curlexample/curl-8.11.1/lib/.libs/libcurl.so
/app/build/curlexample/curl-8.11.1/lib/.libs/libcurl.so.4.8.0 # симлинк на libcurl.so
/app/build/curlexample/curl-8.11.1/lib/.libs/libcurl.so.4 # симлинк на libcurl.so

Обычно все библиотеки стараются делать обратно совместимыми, но на практике это работает не всегда. Поэтому .so файлы собираются с указанием версии после расширения .so, а сам .so ссылается на них через символьную ссылку (symbolic link).

Прежде чем компилировать наш curlexample.cpp, для чистоты эксперимента убедитесь, что в системе удалён пакет libcurl4-openssl-dev:
$ sudo apt remove libcurl4-openssl-dev

Вы должны получать ошибку из-за отсутствия файлов libcurl:
$ g++ curlexample.cpp -o curlexample  -lcurl
curlexample.cpp:2:10: fatal error: curl/curl.h: No such file or directory
    2 | #include <curl/curl.h>
      |          ^~~~~~~~~~~~~
compilation terminated.

А теперь мы укажем относительные пути с директориями, где можно найти include файлы через опцию '-I' (это буква i заглавная!) и библиотечные файлы через опцию '-L' (это буква l заглавная!).

g++ curlexample.cpp -o curlexample -I ./curl-8.11.1/include  -L./curl-8.11.1/lib/.libs -l curl

Так у нас скомпилируется наша программа curlexample.cpp. По умолчанию компоновка с библиотеками будет динамической. Вот посмотрите, сколько библиотек понадобится, чтобы выполнить наш простенький бинарный файл curlexample. Большинство из них нужно для поддержки шифрованного протокола HTTPS через OpenSSL, который мы включили при компиляции libcurl:

$ ldd curlexample     
        linux-vdso.so.1 (0x00007ffdef5b1000)
        libcurl.so.4 => /lib/x86_64-linux-gnu/libcurl.so.4 (0x00007fb662541000)
        libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007fb66235f000)
        libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007fb662344000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fb662152000)
        libnghttp2.so.14 => /lib/x86_64-linux-gnu/libnghttp2.so.14 (0x00007fb662128000)
        libidn2.so.0 => /lib/x86_64-linux-gnu/libidn2.so.0 (0x00007fb662107000)
        librtmp.so.1 => /lib/x86_64-linux-gnu/librtmp.so.1 (0x00007fb6620e5000)
        libssh.so.4 => /lib/x86_64-linux-gnu/libssh.so.4 (0x00007fb662076000)
        libpsl.so.5 => /lib/x86_64-linux-gnu/libpsl.so.5 (0x00007fb662063000)
        libssl.so.1.1 => /lib/x86_64-linux-gnu/libssl.so.1.1 (0x00007fb661fd0000)
        libcrypto.so.1.1 => /lib/x86_64-linux-gnu/libcrypto.so.1.1 (0x00007fb661cf9000)
        libgssapi_krb5.so.2 => /lib/x86_64-linux-gnu/libgssapi_krb5.so.2 (0x00007fb661cac000)
        libldap_r-2.4.so.2 => /lib/x86_64-linux-gnu/libldap_r-2.4.so.2 (0x00007fb661c54000)
        liblber-2.4.so.2 => /lib/x86_64-linux-gnu/liblber-2.4.so.2 (0x00007fb661c43000)
        libbrotlidec.so.1 => /lib/x86_64-linux-gnu/libbrotlidec.so.1 (0x00007fb661c35000)
        libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007fb661c19000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fb661bf6000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fb661aa7000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fb6625e2000)
        libunistring.so.2 => /lib/x86_64-linux-gnu/libunistring.so.2 (0x00007fb661923000)
        libgnutls.so.30 => /lib/x86_64-linux-gnu/libgnutls.so.30 (0x00007fb66174d000)
        libhogweed.so.5 => /lib/x86_64-linux-gnu/libhogweed.so.5 (0x00007fb661716000)
        libnettle.so.7 => /lib/x86_64-linux-gnu/libnettle.so.7 (0x00007fb6616dc000)
        libgmp.so.10 => /lib/x86_64-linux-gnu/libgmp.so.10 (0x00007fb661658000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fb661650000)
        libkrb5.so.3 => /lib/x86_64-linux-gnu/libkrb5.so.3 (0x00007fb661573000)
        libk5crypto.so.3 => /lib/x86_64-linux-gnu/libk5crypto.so.3 (0x00007fb661542000)
        libcom_err.so.2 => /lib/x86_64-linux-gnu/libcom_err.so.2 (0x00007fb66153b000)
        libkrb5support.so.0 => /lib/x86_64-linux-gnu/libkrb5support.so.0 (0x00007fb66152c000)
        libresolv.so.2 => /lib/x86_64-linux-gnu/libresolv.so.2 (0x00007fb661510000)
        libsasl2.so.2 => /lib/x86_64-linux-gnu/libsasl2.so.2 (0x00007fb6614f1000)
        libgssapi.so.3 => /lib/x86_64-linux-gnu/libgssapi.so.3 (0x00007fb6614ac000)
        libbrotlicommon.so.1 => /lib/x86_64-linux-gnu/libbrotlicommon.so.1 (0x00007fb661489000)
        libp11-kit.so.0 => /lib/x86_64-linux-gnu/libp11-kit.so.0 (0x00007fb661353000)
        libtasn1.so.6 => /lib/x86_64-linux-gnu/libtasn1.so.6 (0x00007fb66133d000)
        libkeyutils.so.1 => /lib/x86_64-linux-gnu/libkeyutils.so.1 (0x00007fb661334000)
        libheimntlm.so.0 => /lib/x86_64-linux-gnu/libheimntlm.so.0 (0x00007fb661328000)
        libkrb5.so.26 => /lib/x86_64-linux-gnu/libkrb5.so.26 (0x00007fb661295000)
        libasn1.so.8 => /lib/x86_64-linux-gnu/libasn1.so.8 (0x00007fb6611ef000)
        libhcrypto.so.4 => /lib/x86_64-linux-gnu/libhcrypto.so.4 (0x00007fb6611b7000)
        libroken.so.18 => /lib/x86_64-linux-gnu/libroken.so.18 (0x00007fb66119c000)
        libffi.so.7 => /lib/x86_64-linux-gnu/libffi.so.7 (0x00007fb661190000)
        libwind.so.0 => /lib/x86_64-linux-gnu/libwind.so.0 (0x00007fb661166000)
        libheimbase.so.1 => /lib/x86_64-linux-gnu/libheimbase.so.1 (0x00007fb661154000)
        libhx509.so.5 => /lib/x86_64-linux-gnu/libhx509.so.5 (0x00007fb661106000)
        libsqlite3.so.0 => /lib/x86_64-linux-gnu/libsqlite3.so.0 (0x00007fb660fdd000)
        libcrypt.so.1 => /lib/x86_64-linux-gnu/libcrypt.so.1 (0x00007fb660fa0000)

Но обратите внимание на размер бинарника: 18 килобайт, Карл! Весь код находится в динамических библиотеках:
$ ls -l curlexample
-rwxr-xr-x 1 root root 18480 Jan 20 19:09 curlexample

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

 g++ -static curlexample.cpp -o curlexample -I ./curl-8.11.1/include  -L./curl-8.11.1/lib/.libs -lcurl -lz -lssl -lcrypto -lpthread -ldl

Обратите внимание, что последовательность перечисления подключаемых библиотек имеет значение. Но главная проблема заключается в том, что не все библиотеки можно подключать статически. Наш curlexample.cpp из-за требования к использованию HTTPS как раз является отличным примером. Даже если взять под полный контроль метод линковки каждой из зависимостей и подойти к вопросу с фанатизмом, то удачи не увидите в попытках собрать программу совсем без динамических связей. Следующим образом можно форсировать g++ линковать каждую из подключаемых библиотек:
g++ -static curlexample.cpp -o curlexample \
 -L/usr/lib/x86_64-linux-gnu/mit-krb5 \
 -Wl,-Bstatic   -lcurl \
 -Wl,-Bstatic   -ldl \
 -Wl,-Bdynamic   -lpthread \
 -Wl,-Bdynamic  -lgnutls \
 -Wl,-Bstatic  -lnghttp2 \
 -Wl,-Bdynamic  -lidn2 \
 -Wl,-Bdynamic  -lrtmp \
 -Wl,-Bstatic  -lssh \
 -Wl,-Bdynamic  -lpsl \
 -Wl,-Bstatic  -lssl \
 -Wl,-Bdynamic  -lcrypto \
 -Wl,-Bdynamic  -lssl \
 -Wl,-Bdynamic  -lcrypto \
 -Wl,-Bsymbolic-functions \
 -Wl,-z,relro -lgssapi_krb5 \
 -Wl,-Bdynamic  -lkrb5 \
 -Wl,-Bdynamic  -lk5crypto \
 -Wl,-Bdynamic  -lcom_err \
 -Wl,-Bstatic  -llber \
 -Wl,-Bdynamic  -lldap \
 -Wl,-Bstatic  -llber \
 -Wl,-Bdynamic  -lbrotlidec -lz


Я уверяю вас, если вдруг в результате нескольких бессонных ночей вам удастся хотя бы в половину сократить динамически слинкованные библиотеки и получить бинарник, который запустится, то ваш успех будут выдавать ярко-красные глаза, тремор конечностей и ненависть к мейкфайлам, путям, заголовкам, библиотекам, Linux, C++, к программированию и компьютерам в целом. Появится непреодолимое желание стать грузчиком, сантехником, курьером, а возможно, вы начнёте замечать, что ваши пальцы от тремора неосознанно набирают запросы «купить земельный участок», «чем удобрять помидоры» и прочую бессмыслицу. Это значит, что нужно немного отдохнуть, прежде чем перейти к следующей теме и после прочтения поста до конца осознать, что всё не так плохо.

Automake, autoconf


На текущем этапе я думаю, что смог вас убедить в том, что сложность добавления библиотек в проект нарастает невероятно быстро с каждой дополнительной библиотекой. Но всё усложняется, когда мы должны поддерживать проект с пониманием, что его могут запустить не только на Gentoo или Debian. Это могут быть совсем не Linux-системы: Windows, FreeBSD, Solaris и многие другие. В них отличаются доступные команды и компоненты системы, они могут отличаться от стандартов. Зачастую будут различаться доступные компиляторы, а соответственно и их флаги. И код на Си или Си++ тоже будет отличаться на различных системах.

В коде можно использовать условные директивы препроцессора, чтобы выбрать, какой код подключить:
#include <iostream>
#ifdef _WIN32
#include <windows.h>
void platformSpecificFunction() {
    std::cout << "Код для Windows\n";
    Sleep(1000);
}
#elif defined(_LINUX)
#include <unistd.h>
void platformSpecificFunction() {
    std::cout << "Код для Linux\n";
    usleep(1000000);
}
#else
#error "Неподдерживаемая операционная система"
#endif

И опять кто-то должен задавать эти флаги для препроцессора.

Вот чтобы облегчить хоть немного и так тяжёлую жизнь программистов, были созданы инструменты Autotools: Autoconf, Automake и Libtool. Сами рецепты довольно сложны, поэтому в этом посте ограничусь лишь описанием.

Autoconf читает файл configure.ac (или устаревший configure.in) и генерирует скрипт для настройки под названием configure. Для обработки файлов autoconf использует GNU-реализацию языка макрокоманд m4. Сгенерированный скрипт настройки запускается пользователем. Скрипт читает файлы с расширением ".in", например Makefile.in, обрабатывает их (выясняя все особенности системы) и получает конечный результат — Makefile.

Autoconf использует некоторые вспомогательные программы, написанные для упрощения работы. Например, Autoheader работает с заголовочными файлами, autoscan исследует код на наличие типичных проблем переносимости и создаёт изначальный файл configure.ac.

Automake читает файлы Makefile.am и создаёт переносимый Makefile, то есть Makefile.in, который затем после обработки скриптом конфигурации становится Makefile и используется утилитой make.


Именно сгенерированный через autotools скрипт ./configure мы запускали для создания Makefile, когда компилировали вручную libcurl выше. Использование Libtool уже много десятилетий назад стало стандартом в мире Linux для программного обеспечения и позволило производить в свет переносимое ПО, которое работает на десятках операционных систем. Именно такая стандартизация позволила создать source-based дистрибутивы операционных систем, какими являются Linux Gentoo и FreeBSD, в которых программы скачиваются в виде исходников и компилируются на компьютере пользователя с нужными оптимизациями, с нужными настройками и с возможностью отключения лишнего балласта.

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

CMake


CMake является современным и удобным инструментом сборки, который рекомендуется для большинства новых проектов. Давайте сделаем рецепт для CMake и соберём наш curlexample.cpp, используя установленную в систему библиотеку libcurl. Создайте в директории с curlexample.cpp файл CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)

# Название проекта
project(CurlExample LANGUAGES CXX)

# Установка стандартов C++
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Поиск библиотеки cURL
find_package(CURL REQUIRED)

# Добавление исполняемого файла
add_executable(CurlExample curlexample.cpp)

# Связывание с библиотекой cURL
target_link_libraries(CurlExample PRIVATE CURL::libcurl)

Обратите внимание, что мы не использовали ни одной платформозависимой команды и даже не сообщили, каким компилятором нужно собирать проект. Конечно, при желании можно всё настроить, но по умолчанию всё ищет сам CMake. Но за это удобство придётся заплатить изучением практически нового языка рецептов этой системы сборки.

Чтобы собрать проект, нужно создать отдельную директорию для сборки и запустить команду cmake с указанием директории с рецептом и исходными кодами. Обычно прямо в директории с проектом создают директорию ./build/:

$ mkdir build
$ ls -1
build
CMakeLists.txt
curlexample.cpp
$ cd build 
$ cmake ..

В ./build/ будет создана структура для сборки проекта, включающая Makefile. Удобно, что сборка проекта не затрагивает директорию с исходным кодом и после компиляции всю директорию build можно смело удалять до следующей компиляции. Когда создан Makefile, запускаем сборку, используя наш старый добрый make.

Давайте теперь усложним задачу тем, что опять откажемся устанавливать libcurl в систему. Более того, мы вообще откажемся возиться с ней, полагая, что раз уж у всех есть интернет, то пусть сборщик сам качает все зависимости с github. По необходимости можно даже зафиксировать версию, чтобы не получилось, что очередное непроверенное обновление поломало сборку нашего проекта. Обратите внимание, что для использования функции FetchContent_Declare() требуется версия CMake как минимум 3.14.

cmake_minimum_required(VERSION 3.14)

# Название проекта
project(CurlExample LANGUAGES CXX)

# Установка стандартов C++
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Подключение модуля FetchContent
include(FetchContent)

# Загрузка и настройка cURL через FetchContent
FetchContent_Declare(
    curl
    GIT_REPOSITORY https://github.com/curl/curl.git
    GIT_TAG master # Укажите конкретную версию или тег, если нужно
)
FetchContent_MakeAvailable(curl)

# Добавление исполняемого файла
add_executable(CurlExample curlexample.cpp)

# Связывание нашего бинарника с библиотекой cURL
target_link_libraries(CurlExample PRIVATE libcurl)

Таким образом команда 'cmake ..' из директориии ./build/ скачает и подготовит все зависимости, а команда 'make -j $(nproc)' запустит компиляцию проекта вместе с необходимыми библиотеками на всех ядрах процессора. При этом зависимости не устанавливаются в систему, а удаление директории build полностью удалит все следы сборки проекта.

Meson


Meson — это еще один сборщик проектов. Вот так выглядит meson.build для сборки curlexample.cpp:
project('CurlExample', 'cpp')
subproject('curl') 
subdir('src')

Чтобы запустить сборку:
$ meson setup build
$ cd build
$ meson compile

Все просто и предельно понятно, если наши зависимости установлены в систему. Но из-за того, что до мейнстрима этому сборщику еще далеко, то в большинстве зависимостей рецепты meson.build отсутствуют. Их отсутствие придется решать собственными ручками. Тем не менее Meson многим нравится потому что генерирует рецепты ninja.build и это имеет свои преимущества.

ninja


Ninja — это утилита для замены старой доброй make. Использует рецепты ninja.build, которые очень похожи на Makefile, однако их крайне не рекомендуется писать ручками. Обычно они генерируются сборщиками более высокого уровня такими как CMake или Meson. Основные преимущества:
  • Сокращение многократного и частого использования системы ввода-вывода (вывод всегда буферизируется);
  • Процесс сборки по умолчанию запускается в параллельном режиме (задействуются все ядра доступных процессоров);
  • Улучшена инкрементальная сборка (что значительно ускорило сборку проектов с большим количеством файлов и их дальнейшую пересборку);
  • Добавлена специальная поддержка для обнаружения дополнительных зависимостей во время сборки (что позволяет легко корректировать зависимости заголовочных файлов для кода Си и C++)
  • Использован re2c — генератор быстрых и легко встраиваемых лексеров, использующийся в разных проектах с целью ускорения лексического анализа.


scons


Ещё один инструмент, который может показаться отличной альтернативой CMake, это сборщик scons, рецепты которого пишутся на Python.

Основные преимущества SCons:
  • Язык Python: Конфигурационные файлы SCons написаны на Python, что делает их более читаемыми, поддерживаемыми и расширяемыми по сравнению с языками описания, используемыми в других системах сборки. Это позволяет использовать всю мощь языка программирования для создания сложных логических условий и автоматизации задач.
  • Автоматический анализ зависимостей: SCons автоматически анализирует зависимости между исходными файлами, что избавляет разработчика от необходимости вручную указывать их в файлах сборки. Это значительно упрощает процесс разработки и снижает вероятность ошибок.
  • Кроссплатформенность: SCons поддерживает множество платформ, включая Linux, Windows, macOS и другие, что позволяет использовать его для сборки проектов на разных операционных системах.
  • Гибкость: SCons предоставляет широкие возможности для настройки процесса сборки. Вы можете определять собственные функции и правила сборки, адаптируя систему под специфические требования вашего проекта.
  • Интеграция с другими инструментами: SCons легко интегрируется с другими инструментами разработки, такими как системы контроля версий, IDE и т. д.
  • Высокая производительность: SCons использует эффективные алгоритмы для анализа зависимостей и оптимизации процесса сборки, что позволяет ускорить процесс разработки.
  • Поддержка различных языков программирования: Помимо C и C++, SCons поддерживает другие языки программирования, такие как Java, Fortran, Python и многие другие.

Самый простой рецепт scons для сборки curlexample.cpp будет выглядеть так, будучи сохранённым в файл SConstruct:

Program('curlexample', 'curlexample.cpp', LIBS=['curl'], LIBPATH=['/usr/lib', '/usr/local/lib'])

Но чтобы понять его преимущества, лучше пример усложнить, чтобы увидеть, что можно контролировать в таких рецептах:

import os
from SCons.Script import Import, Environment

env = Environment()

# Указываем путь для сторонних библиотек
thirdparty_dir = "thirdparty"
curl_dir = os.path.join(thirdparty_dir, "curl")
build_dir = os.path.join(curl_dir, "build")

# Клонируем libcurl, если её нет
if not os.path.exists(curl_dir):
    print("Cloning libcurl...")
    os.system(f"git clone https://github.com/curl/curl.git {curl_dir}")

# Собираем libcurl, если она ещё не собрана
if not os.path.exists(build_dir):
    os.makedirs(build_dir)
    os.system(f"cd {build_dir} && cmake .. && make -j$(nproc)")

# Настройка путей
env.Append(CPPPATH=[os.path.join(curl_dir, "include")])
env.Append(LIBPATH=[os.path.join(build_dir, "lib")])
env.Append(LIBS=["curl"])

# Компиляция
env.Program(target="curlexample", source="curlexample.cpp")

В этом примере scons самостоятельно скачивает зависимость libcurl с github, собирает её в директорию thirdparty и подключает эту библиотеку к проекту. Стоит ещё обратить внимание, что после запуска команды scons в отличие от cmake не нужно использовать отдельно команду make. Тут scons самостоятельно занимается распараллеливанием компиляции и самим процессом сборки. Но так как libcurl использует рецепт CMakeLists.txt, то для сборки всё равно понадобится установленный в систему cmake.

Вроде всё выглядит теперь как сказка, но иногда звёзды складываются не очень удачно и возникают ситуации подобно описанной в начале первой части этой истории. И тогда на помощь пойдёт тяжёлая артиллерия.

Docker


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

Есть у меня старые видеокарты Radeon 260x, 270x и 280x аж древнего 2013 года. На первый взгляд эти видеокарты работают в современном Linux с современными драйверами, а на практике стоит только дать нагрузку в игре или в вычислениях, компьютер зависает намертво. Мне долго казалось, что видеокарты пришли в негодность от старости. Но, проверив на десятке разных компьютерных конфигураций десятки видеокарт того поколения, я изменил своё мнение: однозначно, все современные драйверы — как AMDGPU, так и открытые Radeon — некорректно работают со старыми GPU. Установить старые драйверы на новые ядра Linux не представляется возможным, да и современное ПО вроде Vulkan и XORG не будет с ними работать. Таким образом, чтобы получить работающую систему и запустить игру без зависаний на этих древних видеокартах, нужно откатить ядро, драйверы, операционную систему и всё используемое программное обеспечение в комплексе.

И если для проверки оборудования нам таки придётся установить старую ОС, то для сборки устаревших программ есть отличный инструмент Docker, который позволяет проверить, как поведёт себя сборка и сама программа в различных дистрибутивах Linux с библиотеками различной степени древности. Это незаменимый подход для сборки и тестирования ПО в различных ОС по технологии CI/CD (Continuous Integration, Continuous Delivery).

Как обычно начну сразу с примера, который я использовал для решения проблемы с библиотекой Tgbot CPP. Сейчас разработчики tgbot-cpp уже внесли необходимые исправления для поддержки последних версий в зависимостях и проблема не актуальна, но для примера она всё равно сгодится.

Установите Docker и создайте директорию для проекта. В директории создайте рецепт Dockerfile:

FROM ubuntu:20.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
    build-essential \
    g++ \
    cmake \
    git \
    curl \
    wget \
    libcurl4-openssl-dev \
    libssl-dev \
    zlib1g-dev \
    libboost-all-dev \
    nlohmann-json3-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /opt
RUN git clone https://github.com/reo7sp/tgbot-cpp.git
WORKDIR /opt/tgbot-cpp
RUN mkdir build && cd build && \
    cmake .. && \
    make -j $(nproc) && \
    make install

Это рецепт подготовки виртуального образа на основе ubuntu:20.04. Можно заменить первую строку, и тогда основой нашей «виртуальной машины» будет другой дистрибутив:

FROM debian:bullseye

Выбирать основу можно на dockerhub.

Первый RUN установит необходимые пакеты в образ контейнера для сборки tgbot и почистит за собой всю информацию apt-get из образа.
Второй RUN скачает исходники библиотеки tgbot-cpp в WORKDIR.
Третий RUN скомпилирует и установит в систему внутри контейнера эту библиотеку.

Прямо из репы библиотеки tgbot-cpp возьмём пример и сохраним его как bot.cpp.

#include <stdio.h>
#include <tgbot/tgbot.h>

int main() {
    TgBot::Bot bot("PLACE YOUR TOKEN HERE");
    bot.getEvents().onCommand("start", [&bot](TgBot::Message::Ptr message) {
        bot.getApi().sendMessage(message->chat->id, "Hi!");
    });
    bot.getEvents().onAnyMessage([&bot](TgBot::Message::Ptr message) {
        printf("User wrote %s\n", message->text.c_str());
        if (StringTools::startsWith(message->text, "/start")) {
            return;
        }
        bot.getApi().sendMessage(message->chat->id, "Your message is: " + message->text);
    });
    try {
        printf("Bot username: %s\n", bot.getApi().getMe()->username.c_str());
        TgBot::TgLongPoll longPoll(bot);
        while (true) {
            printf("Long poll started\n");
            longPoll.start();
        }
    } catch (TgBot::TgException& e) {
        printf("error: %s\n", e.what());
    }
    return 0;
}

И ещё для сборки внутри контейнера нам понадобится рецепт CMakeLists.txt:

cmake_minimum_required(VERSION 3.14)

project(bot)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(bot bot.cpp)
find_package(OpenSSL REQUIRED)
find_package(CURL)
find_package(Boost 1.69 COMPONENTS system filesystem REQUIRED )
find_package(Threads REQUIRED)

target_link_libraries(bot PRIVATE 
    TgBot
    Threads::Threads
    Boost::system
    Boost::filesystem
    OpenSSL::SSL
    OpenSSL::Crypto
    CURL::libcurl
)

Собираем образ по рецепту Dockerfile и назовём его ubuntobot, чтобы в будущем обращаться к нему по имени:

docker build -t ubuntobot .

Мы можем проверить следующей командой, что образ создан. Он собирается один раз и до тех пор, пока не будут внесены изменения в Dockerfile. Тогда нужно будет заново пересобрать.

 docker images | grep ubuntobot

Создадим директорию build и запускаем контейнер из образа ubuntobot, подключая текущую директорию проекта в директорию /app внутри контейнера. После запуска контейнера bash запустит cmake и make из директории /app/build. Опция --rm предписывает удалить контейнер после завершения компиляции, однако это удаление не затронет подмонтированную директорию /app. Результат компиляции останется в директории build нашего проекта. Скорость практически ничем не отличается от скорости работы без контейнера:

docker run -it --rm -v $(pwd):/app ubuntobot    bash -c "cd /app/build && cmake .. && make -j $(nproc)"

Обращаю внимание, что --rm удалит контейнер, но образ ubuntobot останется до последующих перекомпиляций. Обязательно почитайте подробнее, чем отличаются образы и контейнеры. Грубо говоря, образ — это аналог read-only ISO диска, скачанного из интернета, а контейнер — это виртуальная машина, запущенная с этого диска, которая во время работы может что-то скачивать и делать изменения на диске.

Этот подход позволил откатиться как с машиной времени и запустить на современной обновлённой системе сборку в Linux пятилетней давности. Как видно из Dockerfile, даже простой пример Telegram-бота для собственной сборки требует довольно большого количества библиотек. Установив их внутри контейнера, я избежал проблемы захламления основной рабочей системы. Большинство этих инструментов в любом случае у меня уже установлены, но вот на долгом периоде проекты становятся легаси и перестают работать с современными версиями инструментов и библиотек. Не всегда имеется возможность держать все зависимости в актуализированном состоянии. Иногда используемые библиотеки перестают обновляться. Поэтому Докер должен быть наготове у любого разработчика.

Резюме


Используйте CMake.

© 2025 ООО «МТ ФИНАНС»

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (7)


  1. kotan-11
    07.02.2025 11:07

    Про vcpkg будет?


    1. SergeyNovak Автор
      07.02.2025 11:07

      Верное замечание. Как-то вылетел он у меня из головы потому что в основном в линуксах не пришлось с ним работать. Но с другой стороны это и не сборщик.


    1. TIEugene
      07.02.2025 11:07

      Оно хоть и free, но (c) Microsoft.
      Никакого расизма, но всё, что исходит от Microsoft, рано или поздно чревато (в среде Open Source так считается).
      Поэтому vcpkg пока не сильно в тренде (мягко говоря).


  1. dersoverflow
    07.02.2025 11:07

    прэлесно (ц)

    вот здесь у вас точный список

    g++ -Wall -std=c++17 main.o myfunctions1.o myfunctions2.o -o program

    а тут уже ВСЕМ капут!

    rm -f *.o program

    вот так, шаг за шагом, "мелкие мелочи" превращают ваш билд в каку... а кто это сделал? (ц)


    1. SergeyNovak Автор
      07.02.2025 11:07

      Интересует теоретическая история возникновения в этой директории объектов не относящихся к собираемой программе? Ну и самое главное, что следует принимать во внимание, что целью примера была самая доступная демонстрация как работает рецепт.


  1. horribile
    07.02.2025 11:07

    Еще есть xmake


  1. unreal_undead2
    07.02.2025 11:07

    А как же добавление зависимостей от заголовков в make? Хотя бы банальное

    %.d : %.cpp
            $(CXX) $(CPPFLAGS) $(CXXFLAGS) $^ -MT $(^:.cpp=.o) -MM >$@
    DEPFILES := $(SRCS:%.cpp=%.d)
    $(DEPFILES):
    include $(DEPFILES)