12/12/2019

Системы сборки (1): GNU Make - Simple Makefile


Системы сборки (1): Make - Simple Makefile  простой Makefile для несложных программ
Когда проект растёт в размерах файлов, разработчики начинают делить его на несколько файлов, потом ещё и ещё делить. Компилировать один файл командой gcc (или иной другой) не сложно. Иногда, когда проект ещё не слишком большой, пишут скрипты типа build.sh, в которых записывают все действия для сборки. Но когда проект становится очень большим, и/или усугубляется использованием сторонних библиотек, возникает необходимость автоматизировать и «обрамить» процесс сборки. Для этих целей разработана система GNU Make.
С одной стороны, в этой статье описана лишь малая доля возможностей и функционала системы GNU Make, с другой — для начала работы, для большинства простых программ и для понимания того, что написано в других Makefile'ах вам этой информации хватит.
Настройка сборки происходит через задание переменных. Все переменные могут быть пустыми (как наши LDFLAGS и INCLUDE ниже). Более того, переменные могут быть неинициализированными вовсе — не объявлены в Makefile. В таком случае, они не будут приводить к ошибкам при попытке использовать их, а будут раскрываться как пустые. Обращение к переменной осуществляется через символ $ и скобки — $(CXXFLAGS). Начнём: Имена компиляторов (CC — для C, CXX — для C++):
CC=gcc
CXX=g++
Эти переменные в make-системе есть по-умолчанию — cc и g++ (на моей системе cc — это ссылка на gcc) для компиляторов C и C++ соответственно. То есть, можно опустить эти объявления, если вы согласны собирать проекты под родную систему и стандартными компиляторами. Возможность изменять эти переменные нужна для кросс-компиляции (сборки проектов под системы, отличные от той, на которой сборка производится) или для работы с разными версиями компиляторов на одной машине. Объявления этих переменных (как и некоторых других ниже) закомментированы. Я делаю это для того чтобы, описывая значения полей и структуру Makefile'а, заодно показать насколько маленьким может быть простой Makefile. Флаги компиляции. Сюда вы можете ставить оптимизацию (например, -O3), стандарт языка (-std=c99), параметры -Werror, -Wall, -pedantic и т.п.
CFLAGS=-Wall
CXXFLAGS=-Wall
Имя исполняемого файла, который мы хотим получить на выходе:
EXECUTABLE=simple
Эта команда собирает все файлы с расширением .cpp в переменную:
SOURCES=$(wildcard *.cpp)
Эта команда создаёт переменную с именами объектных файлов. Это достигается путём замены расширения .cpp на расширение .o:
OBJECTS=$(SOURCES:.cpp=.o)
Так как нам в проекте нужен только список объектных файлов (для чего будет нужен этот список и почему список имён файлов с исходниками не будет нужен — будет видно позже), две предыдущие команды можно объединить в одну:
OBJECTS=$(patsubst %.cpp,%.o, $(wildcard *.cpp))
Здесь описано как добавить к проекту исходные файлы на C:
OBJECTS+=$(patsubst %.c,%.o, $(wildcard *.c))
LDFLAGS — флаги для линковщика (пока оставляем пустыми):
LDFLAGS=
INCLUDE — пути к заголовочным файлам, включаемым в код нашего проекта (здесь оставляем пустыми, то есть будут использованы системные):
INCLUDE=
Далее идут правила. Вся система make построена на так называемых «правилах». Правило — это основной параметр, передаваемый команде make. В описании правила, за его именем указывают зависимости, необходимые для его выполнения. Зависимости — это файлы (имена файлов). После, на новой строке, идут команды, которые будут выполняться при вызове этого правила — строки с командами. Они должны начинаться с символа табуляции. Правило может не иметь команд, правило может не иметь зависимостей. Улитка перед командой является указанием не выводить команду (в противном случае make перед выполнением команды выведет её на консоль).
all — это общепринятое название правила, выполняющего сборку проекта. В нашем случае all завязан на значение имени исполняемого файла — EXECUTABLE, а оно в свою очередь является правилом, описанным ниже. Так же можно видеть, что само по себе правило all не имеет «тела» (команд для выполнения).
all: $(EXECUTABLE)
Правило с именем исполняемого файла, получаемого из значения переменной, выглядит следующим образом:
$(EXECUTABLE): $(OBJECTS)
        $(CXX) $(CXXFLAGS) -o $@ $^ $(INCLUDE) $(LDFLAGS)
Здесь написано следующее — для создания файла, записанного в значении переменной EXECUTABLE, нужны объектные файлы (записанные в созданной нами ранее переменной-списке OBJECTS). И описано само правило, являющееся, если присмотреться, командой линковки, в нашем случае раскрывающееся в следующую команду: g++ -Wall -o simple main.o module.o extern.o Что означает: слинковать перечисленные объектные файлы (main.o module.o extern.o) в исполняемый файл simple. Откуда берутся имена объектных файлов мы помним — выше описана команда создания списка путём замены расширения .c и .cpp на .o и записи их в переменную OBJECTS. Но вы можете спросить — а где команда (или правило) для построения компиляции) исходных файлов в объектные? А его нет. Дело в том, что в системе make есть набор стандартных неявно описанных правил. Все они описаны здесь: GNU Make Catalogue of Built-In Rules
Одно из стандартных правил make выглядит примерно так:
# .o: .c
#         $(CC) $(CPPFLAGS) $(CFLAGS) -c
Что означает скомпилировать (без линковки) все .c-файлы. Компиляция без линковки выдаёт объектный файл. Вот так получаются сами объектные файлы необходимые для линковки в исполняемый файл. Автоматизация make доходит до того, что можно дать команду:
make имя файла с исходным кодом даже без расширения и даже не имея Makefile! make main По этой команде make попытается скомпилировать main.c (в случае его отсутствия make попытается сделать то же с файлом main.cpp) в объектный файл main.o, затем слинковать его в исполняемый файл main. Неявное правило можно переписать. Например, правило, описанное ниже приведёт к ошибке сборки всего проекта. Оно гласит: для сборки объектного файла extern.o нужен файл с исходным кодом extern.c. Но само по себе не создаёт объектного файла нужного для сборки нашего проекта. Что приведёт к тому, что проект никогда не будет собран. Можете раскомментировать это правило сделать make clean all и посмотреть что будет.
#extern.o: extern.c
#        echo Rule to never make this project!
clean — правило для очистки. Сюда вписываем всё, что мы хотим делать при очистке проекта. В нашем случае мы удаляем объектные файлы, исполняемый файл и временные файлы. Это правило, в том виде, в котором оно описано, удалит только те объектные файлы, которые относятся к нашему проекту — то есть имеющие соответствующие файлы с исходным кодом. Вы можете проверить это, удалив какой-нибудь .с/.cpp-файл и выполнив команду make clean — вы увидите, что объектный файл соответствующий удалённому останется. Можно поставить rm *.o, но в приличных и крупных проектах принято удалять только своё. А вдруг вы (или пользователь вашего проекта) держите ещё какие-то файлы в каталоге с вашим Makefile (например, объектные файлы, которые вы получаете без исходного кода)?
clean:
        rm -rf $(OBJECTS) $(EXECUTABLE) *~
.PHONY — Это список имён правил, которые не являются файлами. Создаётся во избежание конфликтов между названиями этих правил и именами файлов. Если не указать clean и all в списке .PHONY, make запутается, потому что по умолчанию правила clean и all будут связаны с файлами с такими именами и make будет запускать их только тогда, когда файл не будет обновлен в отношении его зависимостей. Можно создать файл clean и попробовать сделать

make clean

с закомментированной строкой .PHONY, результат будет такой:

make: 'clean' is up to date.

То есть make увидит, что файл clean уже существует и не станет выполнять это правило вообще.
Список .PHONY может быть использован для форсированной компиляции каких либо файлов, вне зависимости от их состояния. Например форсированная перестройка модуля или всего проекта. Вообще считается хорошим тоном вписывать в список .PHONY все правила, не создающие файлов — clean, all, install, test, и пр.
.PHONY: clean all
Возможно вы обратили внимание, что выше мы использовали такие переменные как $@ и $^. Это название правила и список зависимостей соответственно. Ниже написано тестовое правило, которое выводит значение этих переменных. Вы можете поэкспериментировать — поменять его название и список зависимостей. И вопрос на внимательность — как сделать так, чтобы это правило выполнялось бесконечное количество раз?
test: Makefile main.cpp
        @echo "@ = " $@
        @echo "^ = " $^
        @touch $@

Для тех, кто впервые читает или встречается с GNU Make пару слов об этой системе. GNU Make — система сборки (компиляции) программного обеспечения. Работа с ней выглядит следующим образом — (вами или не вами) пишутся правила, затем выполняется команда формата

make -f Makefile -jJobs rule_1 ... rule_n 

где:
Makefile — имя Makefile'а из которого брать правила. Эту опцию можно опустить. По-умолчанию GNU Make будет искать Makefile'ы в текущем каталоге в следующем порядке: GNUmakefile, makefile и Makefile. Рекомендуемым авторами системы является имя — Makefile.
Jobs — количество потоков выполнения. Make умеет распараллеливать работу. Обычно эту цифру ставят равной количеству процессоров либо количеству процессоров умноженному на два. Если указать ключ -j без параметра, Make сам рассчитает оптимальное количество потоков для выполнения. Здесь стоит отметить два момента. Первое — этот ключ имеет смысл только для достаточно больших проектов. Второе — задавать слишком большое значение опасно — Make никак не проверяет производительность машины, на которой запущен и её способность вытянуть заданное количество «работ». В итоге можно перегрузить машину так, что вы даже нажав Ctrl-C будете ждать пол часа. Я попадал в такую ситуацию. Ещё бывает так что большие проекты не собираются в параллельном режиме — будет ошибка компиляции, которую можно обойти запустив make ещё раз, но без многопоточной сборки. Иногда можно «пройти» это место, остановить сборку и опять запустить в многопоточном режиме. Бывает такое, как я понимаю, из-за неправильно составленных Makefile'ов — получается так, что часть кода, требующая неких объектных файлов, уже собрана, а объектные файлы ещё нет. Своеобразный race condition получается.
rule_1 ... rule_n — список правил для выполнения. Да, правила можно передавать Make по несколько штук — они будут выполняться в очерёдности, в которой были заданы пользователем. Если этот параметр опустить — Make выполнит правило, описанное первым в Makefile.
Изначально GNU Make создавался как бы «в паре» с (или «под»GCC — отсюда такая простота и прозрачность в работе Make именно с процессом сборки программ при помощи этой коллекции компиляторов. Но несмотря на это, можно написать абсолютно любые правила в Makefile'е — настройка сети, запуск/остановка сервисов и пр.

На этом о make пока всё. К более сложным аспектам работы с этой системой мы ещё вернёмся позже.
В следующей статье мы рассмотрим как смешивать C и C++ код в одном проекте (в этом Makefile мы уже затронули это с точки зрения сборки), как происходит построение программы — как компилятор и линковщик создают исполняемый файл и при чём здесь ассемблер?