Как стать автором
Обновить
2227.6
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Сборка проектов Си и Си++: от простого к сложному. Часть I. Библиотеки

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров7.1K

Каждый раз, в течение многих лет, собирая пилотную версию мизерного проекта или простой утилиты, мне кажется, что уж в этот раз точно обойдусь обычным скриптом для сборки, и никакие сборщики проекта мне не понадобятся. Но суровая реальность приводит меня в чувство уже в течение первых нескольких минут работы. Сначала оказывается, что до невозможности простая программка нуждается в JSON-парсере, HTTP-запросах CURL и прочих библиотеках. А по мере возбуждения хотелок эти все зависимости нарастают как снежный ком. И все мечты быстро скомпилировать страничку кода встречают на каждом шаге всё новые и новые проблемы.

Вот сегодня и расскажу о том, какие бывают способы борьбы с зависимостями и сборки проекта из множества файлов на Си++. Заодно те, кто не любят Си++, смогут порадоваться «прелестям» этого процесса. И хоть тема очень важная для программистов, но я обратил внимание, что даже многолетний опыт не гарантирует понимания этих процессов. Но сразу предупреждаю — история длинная даже с учетом всех попыток не убегать на смежные темы.

Немного лирики


Натолкнула меня написать этот пост следующая история. Я хотел собрать минимального Telegram-бота на Си++, и всё, что мне нужно было, — это подключить библиотеку tgbot-cpp:

git clone https://github.com/reo7sp/tgbot-cpp
cd tgbot-cpp
mkdir build
cd build
cmake ..
make -j 96

Но не тут-то было. Tgbot-cpp зависит от библиотек curl (для работы с HTTPS) и boost (для асинхронной работы с сетью), и они есть уже в моей системе Gentoo самой последней версии на сегодняшний день: dev-libs/boost-1.87.0 и net-misc/curl-8.11.1-r2. И это проблема! Потому что с версии boost 1.69.0 поменяли и вырезали на корню множество устаревших функций (io_service заменили на io_context). Конечно же, о том, что они устаревшие, всех предупреждали ещё в Ветхом Завете три тысячелетия назад, но пока всё компилилось и работало, ни у кого не было причин обращать внимание на предупреждения при компиляции и переделывать код. Однако наступает момент, когда приходится пересматривать подход в соответствии с новыми требованиями библиотеки, иначе старый код перестанет работать.

Tgbot-cpp отказался работать с новым boost. Править библиотеку tgbot-cpp у меня ради небольшого эксперимента желания не было, поэтому меня посетила простая идея использовать старый boost. Но установить эту либу в систему из пакетов возможности нет, а через make install исключено, потому что это ключевая библиотека, от которой зависят другие пакеты. Все установленные программы завязаны на последний boost. И пакетов с программами и библиотеками, зависящих от boost, около полусотни. Значит нужно собрать старый boost отдельно от системы, подключив к проекту через cmake. Опа! Но cmake тоже новый, и на старую версию boost он ругается ошибками.

Ну и откажусь от установки в систему. Всё равно плохая идея с учётом того, что разработка проектов с установкой зависимостей в систему захламляет её. Я умный. Притащу с GitHub исходники boost в проект и скомпилирую через Cmake. Но не тут-то было. Оказалось, что в новых версиях такого мощного и любимого всеми сборщика проектов Cmake изменились политики поиска библиотек через функцию FindPackage, что он категорически отказался находить старые версии, как я ни плясал над ним. В общем решил я свою задачу трёх тел только настроив сборку в Docker-контейнере, выбрав специально не самую свежую Ubuntu 20.04, соответственно, с не самым свежим софтом на борту.

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

Си/Си++ — статические и динамические библиотеки


Почти всё в посте касается как языка Си, так и Си++, но о важных различиях я постараюсь рассказать. Компиляция проекта на Си++ состоит из нескольких этапов: препроцессинг текстовых файлов, компиляция cpp-файлов в бинарные объекты и компоновка (линковка, связывание). Если вы не углублялись в этапы компиляции на Си++, то для дальнейшего понимания сути сегодняшнего поста настойчиво рекомендую ознакомиться. Эти этапы уже обсуждались на Хабре.

Конечным результатом сборки проекта на Си++ будет либо запускаемый файл (например, ELF или EXE), либо библиотека, которую будут использовать другие программисты. Этим другим программистом можете быть и вы в будущем, поэтому есть резон собрать её качественно с пониманием всех деталей дальнейшей поддержки. Но даже если вы не собираетесь создавать свои библиотеки, то вам никак не избежать использования чужих библиотек в проектах чуть сложнее «Hello world». Поэтому для сборки проекта обязательно понимание, что такое библиотека и как её готовить.

Библиотека — это сборник откомпилированного кода в виде классов или функций. Библиотека может быть динамической либо статической.

▍ Динамические библиотеки


Динамические библиотеки — это .so или .dll файлы в зависимости от операционной системы.

so — shared object на Linux,
dll — dynamic-link library на Windows.

Давайте для примера исследуем команду ls. Напишите в командной строке своего Линукса:

$ ldd /bin/ls
# ldd /bin/ls
        linux-vdso.so.1 (0x00007ffef39e3000)
        libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007efe5aa77000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007efe5a885000)
        libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007efe5a7f4000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007efe5a7ee000)
        /lib64/ld-linux-x86-64.so.2 (0x00007efe5aad0000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007efe5a7cb000)

Вы увидите все .so файлы, которые должны присутствовать в системе для запуска этой команды. Если в выводе ldd напротив файла .so не написан полный путь, значит эта зависимость не найдена. ОС ищет динамические библиотеки для запуска в директориях, которые перечислены в следующих файлах:

/etc/ld.so.conf
/etc/ld.so.conf.d/*

По умолчанию при компиляции программ компиляторы подключают библиотеки динамически. Это означает, что когда мы скопируем нашу программу на другую систему, она будет требовать, чтобы в той системе были доступны необходимые .so файлы. Иногда требуются .so именно той версии, которая использовалась при линковке. Версия динамической библиотеки — это цифры после расширения .so. Например, libc.so.6.

В двух словах динамическая библиотека — это скомпилированный бинарный код библиотеки в отдельном файле so/dll, который загружается в память при запуске программы. При динамической линковке в программе грубо говоря остается лишь ссылка на этот код в файле .so без копирования самого кода. Такие ссылки на функции называются символами. Увидеть символы запускаемой программы, например /bin/ls, можно так:

$ nm -D /bin/ls
00000000000231e8 D Version
0000000000018000 R _IO_stdin_used
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
                 U __assert_fail
                 U __ctype_b_loc
                 U __ctype_get_mb_cur_max
                 U __ctype_tolower_loc
                 U __ctype_toupper_loc
                 U __cxa_atexit
                 w __cxa_finalize
                 U __errno_location
                 U __fpending
                 U __fprintf_chk
                 U __freading
                 U __fxstat
                 U __fxstatat
                 w __gmon_start__
                 U __libc_start_main
                 U __lxstat
                 U __memcpy_chk
                 U __overflow
                 U __printf_chk
0000000000023280 B __progname
00000000000232a0 B __progname_full
                 U __snprintf_chk
                 U __sprintf_chk
                 U __stack_chk_fail
                 U __strtoul_internal
                 U __xstat
                 U _exit
0000000000016bb0 T _obstack_allocated_p
...

Адрес и буква 'B' возле символа означают, что эта функция находится в разделе BSS (block started by symbol), а вот буква 'U' означает Undefined и говорит нам о том, что символ используется в данном объектном файле, но его определение (код) находится в другом объектном файле.

Некоторые типы символов в выводе nm
Основные типы:
T (text): Символ находится в секции .text (секция кода). Обычно это означает функцию.
D (data): Символ находится в секции .data (инициализированные данные). Обычно это означает глобальную или статическую переменную, которая инициализирована.
B (bss): Символ находится в секции .bss (неинициализированные данные). Обычно это означает глобальную или статическую переменную, которая не инициализирована.
U (undefined): Символ не определён в данном объектном файле. Это означает, что символ используется, но его определение находится в другом объектном файле или в другой библиотеке. Такие символы должны быть разрешены линковщиком во время сборки.

Другие типы (встречаются реже):
t (text, local): Аналогично T, но символ является локальным для данного объектного файла (static function).
d (data, local): Аналогично D, но символ является локальным для данного объектного файла (static variable).
b (bss, local): Аналогично B, но символ является локальным для данного объектного файла.
R (read-only data): Символ находится в секции .rodata (данные только для чтения). Обычно это константы или строковые литералы.
r (read-only data, local): Аналогично R, но символ является локальным.
N (debugging symbol): Символ используется для отладки (например, информация о строках кода, переменных).
C (common): Символ является «common» символом. Это специальный тип символа, который используется для неинициализированных глобальных переменных. Он похож на B, но обрабатывается линковщиком немного иначе.
— (no symbol): В некоторых случаях nm может выводить строки без типа символа. Это может означать, что строка представляет собой не символ, а другую информацию (например, адрес секции).

Дополнительные модификаторы:

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

l (lowercase): Символ является локальным (static).
g (global): Символ является глобальным (доступным из других объектных файлов).
w (weak): Символ является «слабым». Это означает, что если линковщик не найдет сильного определения для этого символа, он может использовать слабое определение или вовсе проигнорировать его отсутствие.

▍ Статические библиотеки


Но почему не собрать весь код в сам EXE-файл чтобы не заморачиваться с наличием тысяч so/dll файлов и еще следить, чтобы каждый был нужной версии? Так действительно делают, когда хотят добиться максимальной переносимости, чтобы быть уверенными, что при переносе на другую систему наша программа не будет зависеть от наличия и версий установленных библиотек.

Но почему все программы не собирают статически всегда, если это так удобно и надёжно? Потому что при компиляции весь код из .a файла копируется в бинарный файл программы, и программа распухает как на дрожжах при подключении каждой дополнительной библиотеки. И самое страшное случится, если все программы, которые собраны статически, попытаются загрузиться в память одновременно. Каждая из программ будет содержать копии кода используемых библиотек. Таким образом, динамические библиотеки экономят нам память, загружаясь лишь в единственном экземпляре. Например, это позволит для одновременно запущенных libreoffice, qt, gimp, mplayer, chrome загрузить в память лишь одну общую копию libc, libjpeg, libpng и многих других общих библиотек.

Возьмём для примера основу всех программ на Си — библиотеку стандартных функций языка — GNU Linux C. В Linux Gentoo я через команду 'equery f sys-libs/glibc' могу увидеть все файлы, которые установлены с этой библиотекой. В других дистрибутивах на основе **apt**-менеджера пакетов, чтобы увидеть файлы из пакета, всё немного сложнее: сначала ищем, какие пакеты содержат в имени **libc**, устанавливаем **apt-file**, обновляем его базу данных и, в конце концов, исследуем найденный пакет:

# dpkg -l | grep libc 
# apt install apt-file
# apt-file update
# apt-file list libc6

Вот несколько файлов из пакета libc, которые мы обсудим:

/usr/lib64/libc.a
/usr/lib64/libc.so

Мы видим, что libc есть в файлах .a и в файлах .so.

Внутрь файла с расширением '.a' можно заглянуть командой ar:
ar -t /usr/lib64/libc.a
fprintf.o
fwprintf.o
swprintf.o
vwprintf.o
wprintf.o
vswprintf.o
vasprintf.o
iovdprintf.o
vsnprintf.o
obprintf.o
asprintf_chk.o
dprintf_chk.o
fprintf_chk.o
fwprintf_chk.o
obprintf_chk.o
printf_chk.o
snprintf_chk.o
sprintf_chk.o
swprintf_chk.o
vasprintf_chk.o
vdprintf_chk.o
vfprintf_chk.o
vfwprintf_chk.o
vobprintf_chk.o
vprintf_chk.o
vsnprintf_chk.o
vsprintf_chk.o
vswprintf_chk.o
vwprintf_chk.o
wprintf_chk.o
dl-printf.o
...

Этот .a архив и есть статическая библиотека. Я показал часть из огромного списка файлов из этого архива, чтобы читатель сам мог догадаться, что представляет собой этот архив. Мы видим в именах .o файлов стандартные функции из библиотеки Си. Изначально каждая функция была отдельным исходным .c файлом. Именно такие же объектные файлы получаются, когда мы компилируем поэтапно файлы проекта на Си или Си++, чтобы в конце концов слинковать их потом в одну программу mainprogram.exe:

gcc myfunctions1.c -o myfunctions1.o
gcc myfunctions2.c -o myfunctions2.o
gcc mainprogram.c myfunctions1.o  myfunctions2.o -o mainprogram.exe

Для примера я привел Linux Gentoo, потому что в этой системе практически все программы устанавливаются компилированием из исходников, в отличие от большинства других дистрибутивов. Установку статических библиотек в Gentoo можно контролировать, установив USE флаг 'static-libs' для пакетного менеджера emerge. Это очень удобно для разработчиков. В других дистрибутивах по умолчанию устанавливаются только динамические версии библиотек. Но если вам для разработки понадобятся статические библиотеки в других дистрибутивах Linux, то они иногда доступны в пакете dev либо в отдельном пакете, который содержит в конце названия '-static':

# apt install glibc-static

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

▍ Создание своей библиотеки


Создать свою динамическую библиотеку проще простого.

Создаём файл с функциями нашей библиотеки mylib.c:

#include <stdio.h>

void my_function(const char *message) {
    printf("Message from library: %s\n", message);
}

int my_add_function(int a, int b){
        return a + b;
}

Компилируем в объектный файл:

gcc -c -fPIC mylib.c -o mylib.o

Опция -c инструктирует gcc не линковать и остановиться сразу после компиляции.

Опция -fPIC (Position Independent Code) крайне важна для создания динамических библиотек. Она генерирует код, который может быть загружен в любую область памяти без необходимости изменения адресов.

-o предписывает сохранить работу в указанный файл (output) mylib.o

Заглянуть внутрь полученного объектного файла можно утилитой objdump из пакета binututils.

$ objdump -t mylib.o 

mylib.o:     file format elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 mylib.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .rodata        0000000000000000 .rodata
0000000000000000 g     F .text  000000000000002e my_function
0000000000000000         *UND*  0000000000000000 printf
000000000000002e g     F .text  0000000000000018 my_add_function

Как пользоваться подобными утилитами, почитайте в отдельных постах, а мы поскачем галопом по Европам дальше. В выводе мы видим отсылки как на наши функции my_add_function(), my_function(), так и на используемую из нашего кода функцию Си printf(). Так и должно быть.

Объектный файл — это ещё не библиотека. Сделаем из него динамическую библиотеку:

gcc -shared -o mylib.so mylib.o

Вот теперь мы получили mylib.so. Если мы проверим зависимости этой динамической библиотеки через 'ldd mylib.so', то увидим, что она зависит от libc.so.6. Именно об этом и говорит надпись UND (undefined) возле строки printf в выводе objdump: эта функция не определена в нашем объектом файле.

Для сборки статической библиотеки нужно не использовать опцию -fPIC при компиляции объектного файла:

gcc -c mylib.c -o mylib.o
ar rcs mylib.a mylib.o 

ar: Утилита архивации из пакета binutils.
r: (insert or replace) Вставить или заменить файлы в архиве.
c: (create) Создать архив, если он не существует.
s: (create an index) Создать индекс архива. Это необходимо для ускорения линковки.

На выходе получим статическую библиотеку mylib.a, содержимое которой можно проверить командой 'ar -t mylib.a', которая покажет, что в нашем архиве имеется один объектный файл mylib.o.

Искажение имён (name mangling)


Ещё одна очень важная вещь, на которую нужно обратить внимание. Для создания mylib.o мы использовали Си компилятор gcc. Давайте посмотрим, какой объект сгенерирует Си++ компилятор g++, запущенный точно с теми же аргументами на том же самом файле:

$ g++ -c mylib.c -o mylib.o
$ objdump -t  mylib.o       

mylib.o:     file format elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 mylib.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .rodata        0000000000000000 .rodata
0000000000000000 g     F .text  000000000000002e _Z11my_functionPKc
0000000000000000         *UND*  0000000000000000 printf
000000000000002e g     F .text  0000000000000018 _Z15my_add_functionii

О боже! Что произошло с нашими функциями? my_function стала _Z11my_functionPKc, а my_add_function превратилась в _Z15my_add_functionii. Это называется искажение имён, или в оригинале name mangling. На русском языке по этой теме материалов не густо, поэтому даю на английском.

В двух словах искажение имен предоставляет средства для кодирования дополнительной информации в имени функции, структуры, класса или другого типа данных, чтобы передать больше семантической информации от компилятора к компоновщику. Каждый символ перед именем функции и после несёт информацию о типах принимаемых и возвращаемых значений. Цифра означает длину имени функции. Если приноровиться, то можно деманглить на пальцах без компьютера. Искажение имён есть не только в Си++, но в Java, Python, FreePascal, RUST, Fortran, Objective-C, SWIFT.

Установите утилиту c++filt и декодируйте имена Си++ на здоровье:

$ c++filt _Z11my_functionPKc
my_function(char const*)

$ c++filt _Z15my_add_functionii
my_add_function(int, int)

Разбиение проекта на файлы


При написании программы принято разбивать код на файлы c, cpp с функциями или классами, группируя их по назначению или по какой-либо абстракции, чтоб было легко находить и работать с ними, не держа единовременно в памяти все мелочи о проекте. Файлы группируют по директориям. А чтобы эти взаимосвязанные друг с другом файлы можно было скомпилировать, по отдельности выделяют прототипы функций, классов и переменные в отдельные заголовочные файлы с расширением .h или .hpp (header). Так, компилятор при сборке определенного cpp-файла знает о существовании объявленных функций, но не держит в памяти их код.

Лайфхак

Для понимания процесса компиляции и ловли багов, связанных с директивами препроцессора, такими как циклические #include, можно остановить компилятор после фазы препроцессора. Для gcc и g++ это делается опцией -E:

g++ -E main.cpp


Ручная компиляция


Типичная строка компиляции компилятором gcc выглядит так:

g++ main.cpp myfunctions1.cpp myfunctions2.cpp

Именно так учат студентов компилировать несколько файлов. Эта процедура совместит препроцессинг, компиляцию и линковку. Однако для компиляции таким способом реального проекта вроде Chrome browser или GIMP памяти никакого компьютера не хватит. Кроме этого, на большом количество файлов с такой единовременной компиляцией и линковкой во время разработки мы столкнемся с тем, что для каждого выходного бинарного файла все исходные cpp нужно перекомпилировать заново. Поэтому правильно будет разбить компиляцию на этапы:

g++ -c main.cpp -o main.o 
g++ -c myfunctions1.cpp -o myfunctions1.o
g++ -c myfunctions2.cpp -o myfunctions2.o
g++ main.o myfunctions1.o myfunctions2.o  -o program

Опция -c говорит компилятору остановиться после компиляции, не делая линковку, а опция -o предписывает сохранить полученный код в объектный файл main.o.

Так, изменив только один файл для пересборки проекта, нам достаточно перекомпилировать только его и заново слинковать выходной бинарник. Можно сохранить эти действия в bash-скрипт, но тогда понадобится дополнительный код в скрипте, который будет отслеживать изменённые файлы и перекомпилировать только их. Этот код будет одинаковым велосипедом для всех подобных скриптов. Такой велосипед уже изобретён и является утилитой make, которая компилирует проект по рецепту из Makefile.

Про инструмент make для обработки Makefile и про другие сборщики проектов расскажу в следующем посте.

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

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻
Теги:
Хабы:
+43
Комментарии24

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds