Урок 7

Введение раздельной компиляции и использование утилиты make для управления сборкой проекта.

I saw a book titled "Die GNU autotools"
"My thoughts exactly!" I though.
Turns out the book was in German

Сейчас игра носит довольно законченный вид. В каком-нибудь 60-м году XX века такую вполне можно было бы выпускать - хоть и не в продажу, так как рынка программного обеспечения не существовало в принципе. Каждая модель компьютера была индивидуальна, программировалась по разному - разным набором процессорных инструкций, с разными механизмами взаимодействия с оборудованием. Каждый компьютер выпускался вместе со своим набором программ, созданных именно для него, непосредственно одновременно с разработкой "железа" компьютера - и этот "софт", эти программы дополняли железо, без них компьютер и не работал бы в принципе, эти программы были частью компьютера. Работали они именно на данном компьютере, ни на каком другом они заработать не могли в принципе - они не знали про эти "другие" компьютеры, не знали их команд процессора, их механизмов взаимодействия с памятью, с оборудованием. Даже байт в разных компьютерах состоял из разного количества битов - и, иногда - не битов, бывало всякое. Соответственно, игру можно было бы написать для одного компьютера, и распространять с ним - либо давать тем, у кого уже есть такой же компьютер. Ну а цена разработки ПО для компьютера просто включалась в цену разработки компьютера, и распространялось ПО свободно и бесплатно - никто и не видел, что может быть иначе. Если же портировать игру (ну или другую программу) на компьютер другой модели, и тем более другого производителя - её пришлось бы писать "с нуля", пользуясь средой разработки уже того, другого компьютера - и его же языком программирования (в те времена под программированием обычно понималась просто запись последовательных машинных команд, в лучшем случае - в виде языка ассемблера, ну а в худшем - прямо в шестнадцатиричном (а то и в бинарном) представлении). Как бы могла выглядеть тогда универсальная программа нашей игры для работы на любом компьютере в то время, когда универсальных языков программирования не существовало? Как ни странно, эту программу вы уже видели - это непосредсвенно весь текст, описывающий процесс создания игры на человеческом языке, предыдущие 6 (ну или предыдущие 3, или 7 - смотря что считать необходимым описанием) уроков этого сайта. Тогда человек, читающий описание процесса создания программы и реализующий её для своего компьютера - используя механизмы и команды именно своего компьютера - являлся бы "компилятором" с русского языка в непосредственный машинный код.

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

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

На одном из таких языков - а именно на языке Си - наша программа в настоящий момент и написана. Язык Си появился как довольно утилитарный инструмент - для переносимости ядра ОС Unix на другие архитектуры. Но он оказался настолько удобен ("настолько" не в смысле "невероятно удобен", а в смысле "достаточно удобен на фоне альтернатив") что очень быстро завоевал ниши многих других языков на долгие годы, и до сих пор в некоторых из этих ниш не имеет конкурентов (даже язык C++, который на первый, неискушенный взгляд является тем же, но многократно улучшенным Си - не может потеснить С в таких сферах, где, к примеру, требуется не имеющееся больше ни у одного актуального ныне (за, очевидно, исключением языка ассемблера) языка свойство создавать программы без какой бы то ни было обвязки (прочитайте про crt0.o) - написание операционных систем.

Но иметь полный текст программы недостаточно, чтобы провести компиляцию и получить результирующую программу. Даже в нашем случае мы, для проведения компиляции вызываем компилятор gcc командой gcc -Wall -g main.c input.c world.c graphics.c -o horizon.game. То есть тому, кто нашу программу будет компилировать, нужно сообщить что программа компилируется именно таким образом. И это в простейшем случае: когда мы будем компилировать игру для релиза, команда компилятору будет уже другой: gcc -Wall -O3 main.c input.c world.c graphics.c -o horizon.game. Значит даже нам, с такой маленькой программой из четырёх файлов и практически отсутсвующих библиотек, дополнительных файлов и утилит, без файлов ресурсов игры (музыка, модели персонажей и проч., и проч.) - уже нужно помнить две разные команды и не путать их составляющие. Человека же первый раз увидившего нашу программу - не стоит заставлять сначала играть в игру "догадайся как это скомпилировать", потому что зачастую единственное правильное решение - в такую угадайку не играть. Для решения задачи управления проектом - по большей части как его собирать, компилировать, но не только - используются системы автоматизированной сборки, такие как make

До появления make для управления сборкой (а в разросшемся проекте делать это вручную, компилируя одну за другой все части проекта практически невозможно - точнее, практически невозможно в процессе не совершить ошибку, забыв скомпилировать что-то или скомпилировав неверно) использовались, в основном, shell-скрипты, ну либо их аналоги. Ввиду их универсальности для конкретной задачи контроля сборки проекта скрипты подходили плохо - они прекрасно справлялись, если их правильно составить, но вот именно реализовать автоматизацию сборки на скриптах было задачей хитрой. Утилита make появилась как один из ответов на запрос о необходимости более подходящего инструмента.

В настоящий момент make является де-факто стандартной утилитой для сборки, но при этом сами make-файлы (файл, в котором содержатся инструкции для работы make) сейчас практически никто не пишет. Такая ситуация сложилась потому, что хоть make и получилась черезвычайно функциональной, однако сами эти функции реализуются нетривиально и негибко, заставляя переписывать большие части скрипта для реализации небольших изминений. Всвязи с этим стали появляться и другие, превосходящие по удобству использования инструменты контроля сборки - которые, зачастую, на основании пользовательского скрипта на своём собственном языке просто генерировали make-файл, и запускали make. Прочие же инструменты для сборки хоть и реализуют свой собственный механизм для сборки, но всё же могут на основании написанного для них скрипта сгенерировать make-файл. IDE же зачастую вообще прячут всю процедуру сборки проекта от пользователя настолько, что начинающие программисты, пользующиеся для написания своих учебных программ средами разработки, далеко не сразу начинают догадываться о том, что вообще происходит с текстом их программы когда жмёшь F5. IDE хранят структуру проекта в своём собственном представлении, но для возможности экспорта проекта (и, например, последующего импорта в другую IDE) они так же могут преобразовать собственное представление о проекте сгенерировав make-файл.

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

Итак, что делает утилита make? Она просто последовательно реализует команды из make-файла (внесённые ли непосредственно пользователем, или автосгенерированные каой-нибудь другой системой автоматизации сборки) в определённом порядке, и сила утилиты make именно в умении влиять на этот порядок в зависимости от некоторых условий - в первую очередь наличие определённых файлов и их даты создания/изменения. Ещё раз - утилита make просто передаёт интерпретатору командной строки команды из файла, хоть она и разработана для управления процессом компиляции, при этом она ничего не знает про компиляторы и их работу. Поэтому вообще-то (при некотором опыте и определённом остроумии) make можно использовать и для других задач, даже и не связанных с программированием вовсе - к примеру, управление личным фотоальбомом. Но, естественно, в первую очередь стоит рассмотреть именно управление сборкой.

Чтобы переложить процесс сборки нашей текущей игры на плечи make, можно составить такой make-файл (и сохранить его под именем "Makefile":

Makefile
default: gcc -Wall -g main.c input.c world.c graphics.c -o horizon.game

Теперь для сборки проекта (компиляции игры из исходных файлов) достаточно в терминале выполнить команду make. (Находясь, естественно, в каталоге проекта.)

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

Что такое цель для утилиты make? Цель (target) - слово с двоеточием на конце (default:) в нашем случае. После цели следует одна или несколько строк, начинающихся с символа tab (табуляция), т.е. все строки с отступом относятся к предыдущей цели. Утилита make, найдя в файле цель, которую она должна выполнить, последовательно применяет в терминале все строки, относящиеся к этой цели.

Указанные после цели строки, начинающиеся с символа табуляции, называются команды

Когда make запущена без параметров, она выполняет цель по умолчанию. Существует специальное довольно хитрое имя цели, которое является именно именем цели по умолчанию, но в его отсутсвии поиск цели для выполнения при отсутствии явно указанной цели выполняется "сверху вниз"

Таким образом make-файл может содержать больше однй цели, например для нашей игры make-файл может принять следующий вид:

Makefile
debug: gcc -Wall -g main.c input.c world.c graphics.c -o horizon.test release: gcc -Wall -O3 main.c input.c world.c graphics.c -o horizon.game

Теперь при вызове в командной строке утилиты make - как непосредственно, так и с указанием цели debug: make debug - собирается программа с возможностью подключения отладчика. При вызове make командой make release программа собирается со включённым оптимизатором, можно (командой ls -la, например) увидеть, что релизный вариант весит меньше. В релиз не включается отладочная информация, и возможно оптимизация вносит свой вклад.

Make-файл можно распространяь вместе со своей программой, чтобы для компиляции стороннему человеку не нужно было изучать структуру проекта, и пытаться скомпилировать программу методом проб и ошибок - и глубокого анализа исходного кода, что может иногда оказаться даже трудней, чем написать этот код самому. Достаточно запустить make и ваша программа будет собираться сама - а при наличии соответствующей цели с командами и устанавливаться на компьтер (вам ещё никогда не доводилось писать в терминал make & make install в процессе установки какой-либо программы? Это именно оно) Но смысл и польза систем автоматизации сборки далеко не только в возможности выбора целей компиляции - это даже, скорее, побочный эффект. Основной мотив использования make - возможность оптимизации процесса компиляции. make может отслеживать дату (и время) компиляции цели, и сравнивать его с временем, когда в исходный код были внесены изменения. Если цель новее файла с кодом, то make считает, что программу компилировать не надо, так как она всё равно получится точно такая же. Но выше уже отмечалось, что make ничего не знает про те строки, которые она передаёт терминалу - она их не анализирует. Как же ей узнать про то, какие файлы участвуют в компиляции, и какие должны получиться? Для этого у целей указываются зависимости, а сами цели - должны называться в соответствии с полученными в результате выполнения команд файлами. Тогда, в соответствии с этим соглашением, make-файл должен выглядеть уже так:

Makefile
horizon.test: main.c input.c world.c graphics.c gcc -Wall -g main.c input.c world.c graphics.c -o horizon.test horizon.game: main.c input.c world.c graphics.c gcc -Wall -O3 main.c input.c world.c graphics.c -o horizon.game
Здесь horizon.test и horizon.game, за которыми следуют двоеточия, являются целями, main.c input.c world.c graphics.c (перечисленные после двоеточия) - это зависимости цели. Зависимости анализируются утилитой, и если они новее цели (или вовсе отсутсвуют), то выполняются команды - то есть строки, которые указаны после цели и предварены отступом. Несмотря на наличие в наших командах слов horizon.test, horizon.game и horizon.c - сама утилита make строки команд не анализирует и о нахождении своих же целей и зависимостей в командах не знает, и вообще о предназначении этих строк не догадывается, а просто передаёт их в терминал. Вместе цель, зависимости цели и следующие за целью команды составляют правило
Правило/Rule:
цель: зависимость1 зависимость2 зависимость3 команда1 команда2 target: prerequisite1 prerequisite2 prerequisite3 recipe1 recipe2

Когда make-файл составлен с указанием зависимостей, вызов make не обязательно приводит к исполнению команды. Попробуйте вызвать make два раза подряд (если, конечно, компиляция не завершается с ошибкой) - make заметит, что цель актуальна и не станет переделывать одну и ту же работу.

Ранее для make-целей были использованы осмысленные слова: "debug" и "release". Так же часто используются такие цели, как "all", "install", "clean" и т.д. Как использовать слова в качестве целей, если make считает, что цели должны соответствовать файлам? Для этого make предусматривает специальные phony-цели. Ключевое слово .PHONY позволяет определять цель, которая не соответсвует какому-либо файлу.

Makefile
.PHONY: release release: horizon.game .PHONY: debug debug: horizon.test horizon.test: main.c input.c world.c graphics.c gcc -Wall -g main.c input.c world.c graphics.c -o horizon.test horizon.game: main.c input.c world.c graphics.c gcc -Wall -O3 main.c input.c world.c graphics.c -o horizon.game
Теперь проект вновь можно собирать командами make debug или make release: Phony-цель debug имеет в зависимости цель horizon.test. Phony-цель выполняется всегда, вне зависимости от наличия и времени создания файла с именем, совпадающим с именем цели, и поэтому make всегда будет пытаться выполнить цель horizon.test - теперь уже проверяя, актуален ли текущий файл horizon.test в сравнении с его зависимостью horizon.c

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

Makefile
.PHONY: release release: horizon.game .PHONY: debug debug: horizon.test horizon.test: main.c input.c world.c graphics.c gcc -Wall -g main.c input.c world.c graphics.c -o horizon.test horizon.game: main.c input.c world.c graphics.c gcc -Wall -O3 main.c input.c world.c graphics.c -o horizon.game
Хоть make-файл и стал структурирован, но налицо явное дублирование кода. Как обычно, проблема дублирования кода в том, что при необходимости внести изменения, нужно вносить изменения одинаково во все места, где затрагиваемый изменениями текст дублирован. Например, если бы возникла необходимость добавить, скажем, к проекту файл physics.c, нужно дописать этот файл в четырёх местах, и не пропустить ни одно место для добавления. Ошибка при добавлении одного и того же кода в несколько мест - и это следует запомнить! - практически всегда будет в самом последнем месте, где код добавлялся (даже если вы в каждой строке вносили свои, небольшие, изменения): забытая ли точка с запятой, либо другой конечный символ, либо просто в последней строке забудете вставить/изменить значение, либо ещё что-то - но в 95% случаев ошибка в последней изменённой строке. Так вот, нужно не допускать ситуации, когда может понадобиться вносить одни и те же изменения в нескольких местах.

Для избавления от дублирования в make предусмотрен механизм переменных. Существуют несколько классов переменных, обращение к переменным в make-файле происходит через указание перед именем переменной символа $. Для начала - автоматические переменные:

Автоматические переменные - это такие переменные, которые не задаются в коде вручную, а существуют в правилах автоматически. Автоматические переменные существуют только в правилах, и их содержимое зависит от соответствующих цели и её зависимостей. Например переменная @ содержит в себе имя файла - цели, то есть, проще говоря, идеинтична цели. Переменная же ^ содержит в себе все названия зависимостей (соответсвующих правилу, в котором существует переменная) через пробел. Так, полностью аналогичный предыдущему вариант make-файла с использованием автоматических переменных выглядит так:

Makefile
.PHONY: release release: horizon.game .PHONY: debug debug: horizon.test horizon.test: main.c input.c world.c graphics.c gcc -Wall -g $^ -o $@ horizon.game: main.c input.c world.c graphics.c gcc -Wall -O3 $^ -o $@

Но в таком виде make не раскрывает своей сути утилиты для управления раздельной компиляцией. Файл игры не будет перекомпилирован, если он моложе каждого из файлов-зависимостей, но при изменении одного из файлов зависимостей перекомпилированы будут все файлы. Это, конечно, не проблема в случае четырёх небольших файлов с кодом, но при увеличении количества файлов в проекте полная компиляция проекта может занимать значительное время. Уменьшить это время можно, оставляя промежуточные объектные файлы (файлы с расширением .o, которые компилятор производит в процессе работы, но удаляет если ему не указать отдельно оставить их). Тогда финальную программу можно собрать из объектных файлов, без перекомпиляции. Чтобы получить объектные файлы, нужно запустить компилятор с ключом -c: gcc -Wall -g main.c input.c world.c graphics.c -c В результате выполнения команды в каталоге окажутся файлы main.o, player_input.o process_world.o graphics.o (и не окажется результирующего файла игры, так как ключ -c говорит компилятору остановиться после получения объектных файлов) Теперь можно передать компилятору уже объектные файлы, а не файлы с кодом - gcc разберётся, что здесь нужно использовать линковщик, и сам его вызовет, оставив результатом работы готовый файл с программой: gcc -Wall -g main.o input.o world.o graphics.o -c. В make-файле это можно было бы записать так:

... horizon.test: main.c input.c world.c graphics.c gcc -Wall -g $^ -c gcc -Wall -g main.c input.c world.c graphics.c -o $@ ...
Хотя, конечно, в таком случае процесс компиляции отличается только тем, что оставляет за собой, не удаляя самостоятельно, объектные файлы.

Но можно каждый объектный файл рассматривать отдельно:

... horizon.test: main.o input.o world.o graphics.o gcc -Wall -g $^ -o $@ main.o: main.c gcc -Wall -g $^ -c input.o: input.c gcc -Wall -g $^ -c world.o: world.c gcc -Wall -g $^ -c graphics.o: graphics.c gcc -Wall -g $^ -c ...
И этот подход сработает! Теперь изменение файла приведёт к перекомпиляции только этого файла - и перелинковки финальной программы из готовых объектных файлов. Проблема же текущей реализации в том, что несмотря на то, что все команды для каждой цели объектного файла выглядят абсолютно одинаково. Для уменьшения количества практически одинаковых строк можно воспользоваться одной особенностью make - он знает некоторые действия, которые превращают файлы с одним расширением в файлы с другим расширением. Так, можно указать в зависимостью цели файл main.o, при этом не указав цель make.o. Тогда make, не найдя файла main.o и обнаружив при этом файл main.c - самостоятельно вызовет команду для компиляции c-файла в исходный код:
... horizon.test: main.o input.o world.o graphics.o gcc -Wall -g $^ -o $@ ...
И тогда make-файл целиком снова мог бы выглядеть вот так лаконично просто:
Makefile
.PHONY: release release: horizon.game .PHONY: debug debug: horizon.test horizon.test: main.o input.o world.o graphics.o gcc -Wall -g $^ -o $@ horizon.game: main.o input.o world.o graphics.o gcc -Wall -O3 $^ -o $@
Но, как говорится, "есть одна маленькая проблема". Несмотря на указание ключей компиляции при сборке финальной программы из объектных файлов, при этом сами объектные файлы make командует скомпилировать исходя из своих представлений о превращении файлов с c-кодом в объектные файлы. Представление это у него такое: $(CC) $(CFLAGS) -c -o $@ $< . CC и CFLAGS - это переменные, присутствующие в make по умолчанию. Значение CC по умолчанию, скорее всего, "сс". сс - это непосредственный компилятор языка Си. gcc - это набор компиляторов и прочих используемых в компиляции программ, он выступает некоей обёрткой над компиляторами - самостоятельно вызывая нужный инструмент по необходимости. В переменной же CFLAGS по умолчанию пусто - и она служит для указания необохдимых ключей компиляции. Все переменные в make рассматриваются как содержащие строковый тип (причём существует особый синтаксис, позволяющий присвоить переменной мультистроковое значение. Обычное объявление и присвоение значения переменной в make выглядит так:
ПЕРЕМЕННАЯ := ЗНАЧЕНИЕ
Синтаксис присвоения значений переменной оператором := является специфичным для GNU make, версии утилиты make от проекта GNU, но в вашей системе наверняка установлена именна эта утилита. В общем случае присвоение выполняется оператором =, но присвоенное таким образом значение переменной будет "рекурсивным", то есть при попытке обращения к переменной для последующего присваивания этой же переменной утилита make будет вынуждена начать в вечный цикл - хотя и обнаружит эту ситуацию, прервав цикл и выдав ошибку:
CFLAGS = $(CFLAGS) -o #Ошибка!
Поэтому удобней и понятней использовать ":="

При необходимости конкатенировать (сложить строки) переменную с дополнительной строкой (то есть взять текст из переменной, добавить к нему текст и записать получившуюся строку обратно в переменную), используется оператор +=:

ПЕРЕМЕННАЯ := первое значение ПЕРЕМЕННАЯ += второе значение
Теперь в переменной ПЕРЕМЕННАЯ содержится значение первое значение второе значение

В общем случае переменные содержат своё значение "глобально", значение переменных одно и то же в любом месте make-файла, а не как в языке Си - где значение связано с тем, когда относительно присвоения значения в коде переменная используется:

TEST_VAR := test value 1 target1: echo $(TEST_VAR) TEST_VAR := test value 2 target2: echo $(TEST_VAR)
И make target1, и make target1 выдадут одинаковый результат. Но есть несколько исключений, когда переменные содержат разные значения в зависимости от места обращения к ним. Одно, очевидное, исключение - это автоматические переменные. Другое исключение - это переопределение переменных внутри правил. Осуществляется оно указанием операции присвоения значения переменной вместо зависимостей цели. В таком случае само правило (снова с целью, уже с зависимостями) записывается на следующей строке:
target1: TEST_VAR := test value 1 target1: echo $(TEST_VAR) TEST_VAR := test value 2 target2: echo $(TEST_VAR)
Используя переменные (и их переопределения в правилах), можно составить make-файл следующим образом:
Makefile
.PHONY: release release: horizon.game .PHONY: debug debug: horizon.test .PHONY: horizon.test horizon.test: CFLAGS := -Wall -g horizon.test: horizon .PHONY: horizon.game horizon.game: CFLAGS := -Wall -O3 horizon.game: horizon horizon: main.o input.o world.o graphics.o $(CC) $(CFLAGS) $^ -o $@
...Только для того, чтобы осознать, что проект опять собирается не так, как задумано: дело в том, что make принимает решение выполнять или не выполнять правило исходя только из того, какие файлы более свежие - целевой файл или файлы зависимостей. И тогда, если объектные файлы более свежие, чем C-файлы, даже в случае, если они скомпилированы не с теми ключами, которые требуются для цели - перекомпилироваться они не будут. То есть, если для проекта всё время компилировались объектные файлы с ключом -g и без оптимизации, в тот момент, когда понадобится скомпилировать релизный проект - make так и возьмёт эти объектные файлы без оптимизации и с отладочной информацией в релиз)

Простое решение перекомпилировать объектные файлы каждый раз, когда нужно скомпилировать программу, возвращает нас к самому началу - когда система контроля зависимостей не использовалось. Каждый раз проект будет перекомпилироваться целиком, что очевидно не является хорошим выходом. Другое решение - именовать объектные файлы для дебаг-версии и для версии релизной по разному. В настоящий момент в директории проекта все файлы лежат непосредственно, без структуры внутри директории проекта. Можно разместить файлы по директориям, разнеся их по смыслу. Так как пути к файлу являются частью имени файла, таким образом объектные файлы для релиза и объектные файлы для дебаг-версии станут иметь разные имена - и смогут быть различены утилитой make.

Проект может, к примеру, быть структурирован подобным образом:

[Директория проекта] ├── src // исходный код │ ├── horizon // исходный код проекта │ └── libs // исходный код используемых │ сторонних программ и утилит │ ├── bin // результирующие исполняемые файлы │ ├── release // файлы игры для релиза │ └── debug // файлы игры для тестирования │ ├── obj // промежуточные файлы проекта │ ├── release // временные файлы для релиз-версии │ └── debug // временные файлы для тесовых-версий │ ├── res // директория для ресурсов, используемых в проекте │ ├── snd // звуковые файлы │ │ ├── music │ │ └── effects │ │ │ ├── img // графические файлы │ └── models // файлы 3d-моделей │ └── Makefile
Тогда (с учётом, что все исходные файлы проекта лежат в директории src/horizon:
Makefile
#For release build call make with "make BUILD=release" BUILD := debug ifeq ($(BUILD),debug) CFLAGS := -Wall -g else CFLAGS := -Wall -O3 endif bin/$(BUILD)/horizon: obj/$(BUILD)/main.o obj/$(BUILD)/input.o \ obj/$(BUILD)/world.o obj/$(BUILD)/graphics.o $(CC) $(CFLAGS) $^ -o $@ obj/$(BUILD)/%.o: src/horizon/%.c $(CC) $(CFLAGS) $^ -c -o $@
В таком виде Makefile уже начинает приобретать ту сложность, из-за которой и встаёт необходимость в поиске других сборочных инструментов.

Переменная BUILD содержит в себе тип сборки (debug или release). Make довольно неинтуитивно ведёт себя с переменными, в частности переопределение переменной внутри конкретного правила проявляет себя только в команде этого правила (и командах правил, вызываемых в зависимостях этого правила), но не в целях и именах зависимостей. Поэтому для изменения переменных можно пользоваться переопределением переменной из командной строки - значение, данное переменной из командной строки имеет приоритет над значением, присвоенным из файла.

Для ветвления на основании равенства двух значений в make используется директива ifeq...else...endif:

ifeq (значение1,значение2) Действие1 ... else Действие2 ... endif
else не обязателен, внутри действия, которые - условно - выполняются либо не выполняются. Директива ifeq может быть расположена как вне правил, так и в командах цели. Имя целей может быть создано с использованием переменных - значение переменной просто подставляется в строку, будь то цель, зависимость, команда или присвоение значения другой переменной, так:
VARIABLE := xyz all: echo abc$(VARIABLE)123
выведет
abcxyz123

Символ % в make играет важную роль. Он называется основа(stem), и выполняет роль шаблона в правилах.

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

%.o: %.c $(CC) $(CFLAGS) $^ -c -o $@
Такое шаблонное правило описывает, как получить объектный файл из файла на языке Си. На самом деле такое правило make знает по умолчанию, и оно (неявно) работало в предыдущих версиях Makefile Но такое шаблонное правило не сможет понять, как сгенерировать объектный файл из файла с кодом из другого каталога.
.PHONY: all all: obj/release/main.o %.o: %.c $(CC) $(CFLAGS) $^ -c -o $@
При попытке найти правило для цели obj/release/main.o будет выполнен подбор шаблона %.o Шаблонное правило %.o: %.c может соответствовать цели obj/release/main.o только в случае, когда stem равен obj/release/main. Но тогда зависимость цели получается obj/release/main.c. Соответсвенно, в make-файле должно либо быть правило для получения зависимости, т.е. должна встречатся цель obj/release/main.c: (или подобная шаблонная цель), либо - make должен суметь найти файл obj/release/main.c. А так как в проекте файла obj/release/main.c нет, то шаблон не может быть подставлен, и шаблонная цель %.o: %.c не соответствует зависимости obj/release/main.o.

Для возможности получения файла obj/release/main.o из исходного кода файла src/horizon/main.c можно указать в Makefile следующее правило:

obj/release/main.o: src/horizon/main.c $(CC) $(CFLAGS) $^ -c -o $@
Такое правило может быть заменено шаблонным правилом:
obj/release/%.o: src/horizon/%.c $(CC) $(CFLAGS) $^ -c -o $@
которое заодно соответсвует и правилу для получения остальных необходимых объектных файлов.

Теперь make собирает проект как задумано, но ввиду особенностей обращения make со своими переменными - make теперь собирается не так, как раньше. Как и раньше, вызов make без параметров приводит к сборке проекта с debug-настройками, но для сборки проекта для релиза необходимо в командной строке выполнить make BUILD=release. Для того, чтобы проект можно было собирать как и раньше - вызывая make debug и make release - можно сделать неочевидную вещь: создать цели, которые будут, в свою очередь, запускать ещё один экземпляр make - уже с соответсвующими переопредлениями переменной:

.PHONY: debug debug: $(MAKE) sometarget BUILD=debug .PHONY: release release: $(MAKE) sometarget BUILD=release

Теперь финальный вариант make-файла выглядит следующим образом:

Makefile
.PHONY: debug debug: $(MAKE) all BUILD=debug .PHONY: release release: $(MAKE) all BUILD=release BUILD := debug ifeq ($(BUILD),debug) CFLAGS := -Wall -g else CFLAGS := -Wall -O3 endif .PHONY: all all: bin/$(BUILD)/horizon bin/$(BUILD)/horizon: obj/$(BUILD)/main.o obj/$(BUILD)/input.o \ obj/$(BUILD)/world.o obj/$(BUILD)/graphics.o $(CC) $(CFLAGS) $^ -o $@ obj/$(BUILD)/%.o: src/horizon/%.c $(CC) $(CFLAGS) $^ -c -o $@

Такой make-файл полностью удовлетворяет текущие необходимости проекта, хотя при расширении проекта его нужно будет продолжать поддерживать, вручную добавляя правила и соответствующие переменные. Конечно, уже сейчас можно пойти дальше, и сделав Makefile более приспособленным к внесению изменений:

Makefile
PROJECT_C_FILES := main.c input.c world.c graphics.c RESULT_NAME := horizon SOURCE_FILES_DIRECTORY := src/horizon BUILD_DEBUG := debug BUILD_RELEASE := release BUILD := $(BUILD_DEBUG) CFLAGS += -Wall .PHONY: debug debug: $(MAKE) all BUILD=$(BUILD_DEBUG) .PHONY: release release: $(MAKE) all BUILD=$(BUILD_RELEASE) COMPILER := gcc VPATH += $(SOURCE_FILES_DIRECTORY) PROJECT_OBJECT_FILES := $(PROJECT_C_FILES:.c=.o) OBJECT_FILES_DIRECTORY := obj/$(BUILD)/ RESULTING_FILES_DIRECTORY := bin/$(BUILD)/ ifeq ($(BUILD), $(BUILD_DEBUG)) override BUILD := $(BUILD_DEBUG) CFLAGS += -g else override BUILD := $(BUILD_RELEASE) CFLAGS += -O3 endif .PHONY: all all: $(RESULTING_FILES_DIRECTORY)horizon $(RESULTING_FILES_DIRECTORY)horizon: \ $(addprefix $(OBJECT_FILES_DIRECTORY),$(PROJECT_OBJECT_FILES)) $(COMPILER) $(CFLAGS) $^ -o $@ $(OBJECT_FILES_DIRECTORY)%.o: %.c $(COMPILER) $(CFLAGS) $^ -c -o $@
Рекомендуется разобраться, как работает данный make-файл, и в дальнейшем использовать его для сборки проекта. Но, возможно, прежде чем чрезмерно усложнять Make-файлы своих будущих проектов, стоит сначала ознакомится и с другими инструментами управления сборкой проектов.

Теперь программа игры может распространяться - вместе с make-файлом, и для того, чтобы поиграть в игру будет достаточно иметь компьютер (практически любой архитектуры!) с установленным компилятором gcc и утилитой make (Правда, сейчас игра может работать только под GNU/Linux, так как использует механизм сигналов - а этот механизм платформозависимый. Но использование сигналов это просто хак, и далее он перестанёт использоваться - когда будут введены другие механизмы пользовательского ввода). Пользователь из директории игры запускает команду make, при этом он может даже не заглядывать внутрь make-файла и не смотреть, какие цели там присутсвуют - и получить готовую игру, которую остаётся лишь запустить и играть.

Самостоятельная работа:
  • Утилита make. Правила. Переменные
  • Заголовочные файлы и make. Какие проблемы и их решения?
  • Ознакомьтесь с рекомендациями GNU по написанию make-файлов.