Урок 7
"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":
Теперь для сборки проекта (компиляции игры из исходных файлов) достаточно в терминале выполнить команду make. (Находясь, естественно, в каталоге проекта.)
Если в системе установлена утилита make, она запустится, найдёт в директории, из которой она запущена, файл Makefile - и отработает в соответствии с ним, передав в терминал команду цели по умолчанию. Команда в терминале вызывает компилятор, и в результате в директории по умолчанию появится файл с игрой.
Что такое цель для утилиты make? Цель (target) - слово с двоеточием на конце (default:
) в нашем случае. После цели следует одна или несколько строк, начинающихся с символа tab (табуляция), т.е. все строки с отступом относятся к предыдущей цели. Утилита make, найдя в файле цель, которую она должна выполнить, последовательно применяет в терминале все строки, относящиеся к этой цели.
Когда make запущена без параметров, она выполняет цель по умолчанию. Существует специальное довольно хитрое имя цели, которое является именно именем цели по умолчанию, но в его отсутсвии поиск цели для выполнения при отсутствии явно указанной цели выполняется "сверху вниз"
Таким образом make-файл может содержать больше однй цели, например для нашей игры make-файл может принять следующий вид:
Теперь при вызове в командной строке утилиты make - как непосредственно, так и с указанием цели debug: make debug - собирается программа с возможностью подключения отладчика. При вызове make командой make release программа собирается со включённым оптимизатором, можно (командой ls -la, например) увидеть, что релизный вариант весит меньше. В релиз не включается отладочная информация, и возможно оптимизация вносит свой вклад.
Make-файл можно распространяь вместе со своей программой, чтобы для компиляции стороннему человеку не нужно было изучать структуру проекта, и пытаться скомпилировать программу методом проб и ошибок - и глубокого анализа исходного кода, что может иногда оказаться даже трудней, чем написать этот код самому. Достаточно запустить make и ваша программа будет собираться сама - а при наличии соответствующей цели с командами и устанавливаться на компьтер (вам ещё никогда не доводилось писать в терминал make & make install в процессе установки какой-либо программы? Это именно оно) Но смысл и польза систем автоматизации сборки далеко не только в возможности выбора целей компиляции - это даже, скорее, побочный эффект. Основной мотив использования make - возможность оптимизации процесса компиляции. make может отслеживать дату (и время) компиляции цели, и сравнивать его с временем, когда в исходный код были внесены изменения. Если цель новее файла с кодом, то make считает, что программу компилировать не надо, так как она всё равно получится точно такая же. Но выше уже отмечалось, что make ничего не знает про те строки, которые она передаёт терминалу - она их не анализирует. Как же ей узнать про то, какие файлы участвуют в компиляции, и какие должны получиться? Для этого у целей указываются зависимости, а сами цели - должны называться в соответствии с полученными в результате выполнения команд файлами. Тогда, в соответствии с этим соглашением, make-файл должен выглядеть уже так:
main.c input.c world.c graphics.c
(перечисленные после двоеточия) - это зависимости цели. Зависимости анализируются утилитой, и если они новее цели (или вовсе отсутсвуют), то выполняются команды - то есть строки, которые указаны после цели и предварены отступом. Несмотря на наличие в наших командах слов horizon.test
, horizon.game
и horizon.c
- сама утилита make строки команд не анализирует и о нахождении своих же целей и зависимостей в командах не знает, и вообще о предназначении этих строк не догадывается, а просто передаёт их в терминал.
Вместе цель, зависимости цели и следующие за целью команды составляют правило
Когда make-файл составлен с указанием зависимостей, вызов make не обязательно приводит к исполнению команды. Попробуйте вызвать make два раза подряд (если, конечно, компиляция не завершается с ошибкой) - make заметит, что цель актуальна и не станет переделывать одну и ту же работу.
Ранее для make-целей были использованы осмысленные слова: "debug" и "release". Так же часто используются такие цели, как "all", "install", "clean" и т.д. Как использовать слова в качестве целей, если make считает, что цели должны соответствовать файлам? Для этого make предусматривает специальные phony-цели. Ключевое слово .PHONY позволяет определять цель, которая не соответсвует какому-либо файлу.
horizon.test
. Phony-цель выполняется всегда, вне зависимости от наличия и времени создания файла с именем, совпадающим с именем цели, и поэтому make всегда будет пытаться выполнить цель horizon.test
- теперь уже проверяя, актуален ли текущий файл horizon.test в сравнении с его зависимостью horizon.c
Рекомендуется визуально отделять цели друг от друга. Пустые строки не несут функциональной нагрузки, но являются прекрасными разделителями:
Для избавления от дублирования в make предусмотрен механизм переменных. Существуют несколько классов переменных, обращение к переменным в make-файле происходит через указание перед именем переменной символа $
. Для начала - автоматические переменные:
Автоматические переменные - это такие переменные, которые не задаются в коде вручную, а существуют в правилах автоматически. Автоматические переменные существуют только в правилах, и их содержимое зависит от соответствующих цели и её зависимостей. Например переменная @ содержит в себе имя файла - цели, то есть, проще говоря, идеинтична цели. Переменная же ^ содержит в себе все названия зависимостей (соответсвующих правилу, в котором существует переменная) через пробел. Так, полностью аналогичный предыдущему вариант make-файла с использованием автоматических переменных выглядит так:
Но в таком виде 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-файле это можно было бы записать так:
Но можно каждый объектный файл рассматривать отдельно:
$(CC) $(CFLAGS) -c -o $@ $<
.
CC
и CFLAGS
- это переменные, присутствующие в make по умолчанию. Значение CC по умолчанию, скорее всего, "сс". сс - это непосредственный компилятор языка Си. gcc - это набор компиляторов и прочих используемых в компиляции программ, он выступает некоей обёрткой над компиляторами - самостоятельно вызывая нужный инструмент по необходимости. В переменной же CFLAGS по умолчанию пусто - и она служит для указания необохдимых ключей компиляции.
Все переменные в make рассматриваются как содержащие строковый тип (причём существует особый синтаксис, позволяющий присвоить переменной мультистроковое значение. Обычное объявление и присвоение значения переменной в make выглядит так:
:=
является специфичным для GNU make, версии утилиты make от проекта GNU, но в вашей системе наверняка установлена именна эта утилита. В общем случае присвоение выполняется оператором =
, но присвоенное таким образом значение переменной будет "рекурсивным", то есть при попытке обращения к переменной для последующего присваивания этой же переменной утилита make будет вынуждена начать в вечный цикл - хотя и обнаружит эту ситуацию, прервав цикл и выдав ошибку:
:=
"
При необходимости конкатенировать (сложить строки) переменную с дополнительной строкой (то есть взять текст из переменной, добавить к нему текст и записать получившуюся строку обратно в переменную), используется оператор +=
:
первое значение второе значение
В общем случае переменные содержат своё значение "глобально", значение переменных одно и то же в любом месте make-файла, а не как в языке Си - где значение связано с тем, когда относительно присвоения значения в коде переменная используется:
Простое решение перекомпилировать объектные файлы каждый раз, когда нужно скомпилировать программу, возвращает нас к самому началу - когда система контроля зависимостей не использовалось. Каждый раз проект будет перекомпилироваться целиком, что очевидно не является хорошим выходом. Другое решение - именовать объектные файлы для дебаг-версии и для версии релизной по разному. В настоящий момент в директории проекта все файлы лежат непосредственно, без структуры внутри директории проекта. Можно разместить файлы по директориям, разнеся их по смыслу. Так как пути к файлу являются частью имени файла, таким образом объектные файлы для релиза и объектные файлы для дебаг-версии станут иметь разные имена - и смогут быть различены утилитой make.
Проект может, к примеру, быть структурирован подобным образом:
Переменная BUILD содержит в себе тип сборки (debug или release). Make довольно неинтуитивно ведёт себя с переменными, в частности переопределение переменной внутри конкретного правила проявляет себя только в команде этого правила (и командах правил, вызываемых в зависимостях этого правила), но не в целях и именах зависимостей. Поэтому для изменения переменных можно пользоваться переопределением переменной из командной строки - значение, данное переменной из командной строки имеет приоритет над значением, присвоенным из файла.
Для ветвления на основании равенства двух значений в make используется директива ifeq...else...endif
:
else
не обязателен, внутри действия, которые - условно - выполняются либо не выполняются. Директива ifeq
может быть расположена как вне правил, так и в командах цели.
Имя целей может быть создано с использованием переменных - значение переменной просто подставляется в строку, будь то цель, зависимость, команда или присвоение значения другой переменной, так:
Символ % в make играет важную роль. Он называется основа(stem), и выполняет роль шаблона в правилах.
Шаблонные правила составляются так же, как обычные правила, но в цели шаблонных правил присутствует ровно один символ %. Символ % заменяет один либо более символов так, что если при поиске цели для выполнения для шаблона шаблонного правила можно подобрать такую строку, что шаблонная цель будет полностью соответствовать искомой цели - шаблонная цель выполняется, а % ставится в соответствие подобраной строке. Соответственно % в правиле может быть использована в указании зависимостей и в самих командах:
%.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 следующее правило:
Теперь make собирает проект как задумано, но ввиду особенностей обращения make со своими переменными - make теперь собирается не так, как раньше. Как и раньше, вызов make без параметров приводит к сборке проекта с debug-настройками, но для сборки проекта для релиза необходимо в командной строке выполнить make BUILD=release. Для того, чтобы проект можно было собирать как и раньше - вызывая make debug и make release - можно сделать неочевидную вещь: создать цели, которые будут, в свою очередь, запускать ещё один экземпляр make - уже с соответсвующими переопредлениями переменной:
Теперь финальный вариант make-файла выглядит следующим образом:
Такой make-файл полностью удовлетворяет текущие необходимости проекта, хотя при расширении проекта его нужно будет продолжать поддерживать, вручную добавляя правила и соответствующие переменные. Конечно, уже сейчас можно пойти дальше, и сделав Makefile более приспособленным к внесению изменений:
Теперь программа игры может распространяться - вместе с make-файлом, и для того, чтобы поиграть в игру будет достаточно иметь компьютер (практически любой архитектуры!) с установленным компилятором gcc и утилитой make (Правда, сейчас игра может работать только под GNU/Linux, так как использует механизм сигналов - а этот механизм платформозависимый. Но использование сигналов это просто хак, и далее он перестанёт использоваться - когда будут введены другие механизмы пользовательского ввода). Пользователь из директории игры запускает команду make, при этом он может даже не заглядывать внутрь make-файла и не смотреть, какие цели там присутсвуют - и получить готовую игру, которую остаётся лишь запустить и играть.
Самостоятельная работа:
- Утилита make. Правила. Переменные
- Заголовочные файлы и make. Какие проблемы и их решения?
- Ознакомьтесь с рекомендациями GNU по написанию make-файлов.