Сборка программы с помощью GNU Make

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

Подробнее: GNU make

Сценарии Make описываются в т.н. файле проекта. Проектом называется совокупность файлов, зависящих друг от друга. Файл описания проекта перечисляет зависимости между файлами и задает команды для обновления зависимых файлов. Имя файла описания проекта задается опцией –f командной строки программы make и по умолчанию предполагается равным Makefile или makefile. Если имя файла проекта явно не задано, при запуске утилита ищет в текущем каталоге файл с указанными выше именами, и, если такой файл существует, выполняет команды из него.

по описанию проекта в файле Makefile или makefile программа make определяет, какие файлы устарели и нуждаются в обновлении и запускает соответствующие команды.

Обычно программы на языках Си или Си++ представляют собой совокупность нескольких .c (.cpp) файлов с реализациями функций и .h файлов с прототипами функций и определениями типов данных. Как правило, каждому .c файлу соответствует .h файл с тем же именем.

Предположим, что разрабатываемая программа называется earth и состоит из файлов arthur.c, arthur.h, trillian.c, trillian.h, prosser.c, prosser.h.

Разработка программы ведется в POSIX-среде с использованием компилятора GCC.

Простейший способ скомпилировать программу - указать все исходные .c файлы в командной строке gcc:


gcc arthur.c trillian.c prosser.c -o earth

Компилятор gcc выполнит все этапы компиляции исходных файлов программы и компоновку исполняемого файла earth. Обратите внимание, что в командной строке gcc указываются только .c файлы и никогда не указываются .h файлы.

Компиляция и компоновка при помощи перечисления всех исходных файлов в аргументах командной строки GCC допустима лишь для совсем простых программ. С ростом числа исходных файлов ситуация очень быстро становится неуправляемой. Кроме того, каждый раз все исходные файлы будут компилироваться от начала до конца, что в случае больших проектов занимает много времени. Поэтому обычно компиляция программы выолняется в два этапа: компиляция объектных файлов и компоновка исполняемой программы из объектных файлов. Каждому .c файлу теперь соответствует объектный файл, имя которого в POSIX-системах имеет суффикс .o. Таким образом, в рассматриваемом случае программа earth компонуется из объектных файлов arthur.o, trillian.o и prosser.o следующей командой:


gcc arthur.o trillian.o prosser.o -o earth

Каждый объектный файл должен быть получен из соответствующего исходного файла следующей командой:


gcc -c arthur.c

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


gcc -c arthur.c
gcc -c trillian.c
gcc -c prosser.c
gcc arthur.o trillian.o prosser.o -o earth 

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

  • если изменение внесено в один файл, например, в файл prosser.c, нет необходимости перекомпилировать файлы trillian.o или arthur.o; достаточно перекомпилировать файл prosser.o, а затем выполнить компоновку программы earth;
  • компиляция объектных файлов arthur.o, trillian.o и prosser.o не зависит друг от друга, поэтому может выполняться параллельно на многопроцессорном (многоядерном) компьютере.

В случае нескольких исходных .c и .h файлов и соответствующих промежуточных .o файлов отслеживать, какой файл нуждается в перекомпиляции, становится сложно, и здесь на помощь приходит программа make. По описанию файлов и команд для компиляции программа makе определяет, какие файлы нуждаются в перекомпиляции, и может выполнять перекомпиляцию независимых файлов параллельно.

Файл A зависит от файла B, если для получения файла A необходимо выполнить некоторую команду над файлом B. Можно сказать, что в программе существует зависимость файла A от файла B. В нашем случае файл arthur.o зависит от файла arthur.c, а файл earth зависит от файлов arthur.o, trillian.o и prosser.o. Можно сказать, что файл earth транзитивно зависит от файла arthur.c. Зависимость файла A от файла B называется удовлетворенной, если:

  • все зависимости файла B от других файлов удовлетворены;
  • файл A существует в файловой системе;
  • файл A имеет дату последней модификации не раньше даты последней модификации файла B.

Если все зависимости файла A удовлетворены, то файл A не нуждается в перекомпиляции. В противном случае сначала удовлетворяются все зависимости файла B, а затем выполняется команда перекомпиляции файла A.

Например, если программа earth компилируется в первый раз, то в файловой системе не существует ни файла earth, ни объектных файлов arthur.o, trillian.o, prosser.o. Это значит, что зависимости файла earth от объектных файлов, а также зависимости объектных файлов от .c файлов не удовлетворены, то есть все они должны быть перекомпилированы. В результате в файловой системе появятся файлы arthur.o, trillian.o, prosser.o, даты последней модификации которых будут больше дат последней модификации соответствующих .c файлов (в предположении, что часы на компьютере идут правильно, и что в файловой системе нет файлов "из будущего"). Затем будет создан файл earth, дата последней модификации которого будет больше даты последней модификации объектных файлов.

В получившейся конфигурации все зависимости всех файлов друг от друга удовлетворены, и поэтому для компиляции программы earth не нужно выполнять никаких команд

Предположим теперь, что в процессе разработки был изменен файл prosser.c. Его время последнего изменения теперь больше времени последнего изменения файла prosser.o. Зависимость prosser.o от prosser.c становится неудовлетворенной, и, как следствие, зависимость earth от prosser.o также становится неудовлетворенной. Чтобы удовлетворить зависимости необходимо перекомпилировать файл prosser.o, а затем файл earth. Файлы arthur.o и trillian.o можно не трогать, так как зависимости этих файлов от соответствующих .c файлов удовлетворены. Такова общая идея работы программы make и, на самом деле, всех программ управления сборкой проекта: ant http://ant.apache.org/, scons http://www.scons.org/ и др

Хотя утилита make присутствует во всех системах программирования, вид управляющего файла или набор опций командной строки могут сильно различаться. Далее будет рассматриваться командный язык и опции командной строки программы GNU make. В дистрибутивах операционной системы Linux программа называется make. В BSD, как правило, программа GNU make доступна под именем gmake.

Файл описания проекта может содержать описания переменных, описания зависимостей и описания команд, которые используются для компиляции. Каждый элемент файла описания проекта должен, как правило, располагаться на отдельной строке. Для размещения элемента описания проекта на нескольких строках используется символ продолжения \ точно так же, как в директивах препроцессора языка Си.

Определения переменных записываются следующим образом:

<имя> = <определение>

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

$(<имя>) или ${<имя>} - Эти формы равнозначны.

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

Зависимости между компонентами определяются следующим образом:

<цель> : <цель1> <цель2> ... <цельN>

Где <цель> - имя цели, которое может быть либо именем файла, либо некоторым именем, обозначающим действие, которому не соответствует никакой файл, например clean. Список целей в правой части задает цели, от которых зависит <цель>.

Если описание проекта содержит циклическую зависимость, то есть, например, файл A зависит от файла B, а файл B зависит от файла A, такое описание проекта является ошибочным.

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

Для программы earth простейший пример файла Makefile для компиляции проекта может иметь вид:


earth: arthur.o trillian.o prosser.o
    gcc arthur.o trillian.o prosser.o -o earth

arthur.o: arthur.c
    gcc -c arthur.c

trillian.o: trillian.c
    gcc -c trillian.c

prosser.o: prosser.c
    gcc -c prosser.c 

Однако, в этом описании зависимостей не учтены .h файлы. Например, если файл arthur.h подключается в файлах arthur.c и trillian.c, то изменение файла arthur.h должно приводить к перекомпиляции как arthur.c, так и trillian.c. Получается, что .o файлы зависят не только от .c файлов, но и от .h файлов, которые включаются данными .c файлами непосредственно или косвенно. С учетом этого файл Makefile может приобрести следующий вид:


earth: arthur.o trillian.o prosser.o
    gcc arthur.o trillian.o prosser.o -o earth

arthur.o: arthur.c arthur.h
    gcc -c arthur.c

trillian.o: trillian.c trillian.h arthur.h
    gcc -c trillian.c

prosser.o: prosser.c prosser.h arthur.h
    gcc -c prosser.c 

Первой в списке зависимостей обычно записывается «главная» зависимость, а затем записываются все остальные файлы-зависимости.

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


make prosser.o

будет при необходимости перекомпилирован только файл prosser.o и те файлы, от которых он зависит, все прочие файлы затронуты не будут. Если в командной строке имя цели не указано, берется первая цель в файле. В нашем случае это будет цель earth.

Если придерживаться хорошего стиля написания Makefile, то каждый Makefile должен содержать как минимум два правила: all – основное правило, которое соответствует основному предназначению файла, и правило clean, которое предназначено для удаления всех рабочих файлов, создаваемых в процессе компиляции. В случае программы earth рабочими файлами можно считать сам исполняемый файл программы earth, а также все объектные файлы.

С учетом этих дополнений файл Makefile примет вид:


all: earth

earth: arthur.o trillian.o prosser.o
    gcc arthur.o trillian.o prosser.o -o earth 

arthur.o: arthur.c arthur.h
    gcc -c arthur.c

trillian.o: trillian.c trillian.h arthur.h
    gcc -c trillian.c

prosser.o: prosser.c prosser.h arthur.h
    gcc -c prosser.c

clean:
    rm -f earth *.o 

Обратите внимание, что у правила clean отсутствует список файлов, от которых этот файл зависит. Поскольку существование файла с именем clean в рабочем каталоге не предполагается, команда rm -f ... будет выполняться каждый раз, когда make запускается на выполнение командой


make clean 

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

Во-первых, можно параметризовать название используемого компилятора, а также предоставить возможность управлять параметрами командной строки компилятора. Для задания компилятора можно определить переменную CC, для задания опций командной командной строки компиляции объектных файлов — переменную CFLAGS, а для задания опций командной строки компоновки выходной программы — переменную LDFLAGS.

Получим следующий файл:


CC = gcc
CFLAGS = -Wall -O2
LDFLAGS = -s

all: earth

earth: arthur.o trillian.o prosser.o
    $(CC) $(LDFLAGS) arthur.o trillian.o prosser.o -o earth

arthur.o: arthur.c arthur.h
    $(CC) $(CFLAGS) -c arthur.c

trillian.o: trillian.c trillian.h arthur.h
    $(CC) $(CFLAGS) -c trillian.c

prosser.o: prosser.c prosser.h arthur.h
    $(CC) $(CFLAGS) -c prosser.c

clean:
    rm -f earth *.o 

Теперь можно изменить используемый компилятор, не только отредактировав Makefile, но и из командной строки. Например, запуск программы make в виде


make CC=icc 

-позволит для компиляции программы использовать не gcc, а Intel компилятор Си. Аналогично запуск


make CFLAGS="-g" LDFLAGS="-g"

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

Во-вторых, можно избавиться от дублирования имен файлов сначала в зависимостях, а потом в выполняемых командах. Для этого могут быть использованы специальные переменные $^, $< и $@. Переменная $@ раскрывается в имя цели, стоящей в левой части правила. Переменная $< раскрывается в имя первой зависимости в правой части правила. Переменная $^ раскрывается в список всех зависимостей в правой части. Правило для компиляции файла arthur.o приобретет следующий вид:


arthur.o: arthur.c arthur.h
    $(CC) $(CFLAGS) -c $<

Именно такое правило для компиляции .o файлов из .c файлов уже встроено в make, поэтому строку компиляции можно просто удалить. Останется следующий Makefile:


CC = gcc
CFLAGS = -Wall -O2
LDFLAGS = -s

all: earth

earth: arthur.o trillian.o prosser.o
    $(CC) $(LDFLAGS) $^ -o $@

arthur.o: arthur.c arthur.h

trillian.o: trillian.c trillian.h arthur.h

prosser.o: prosser.c prosser.h arthur.h

clean:
    rm -f earth *.o

При желании можно создавать новые шаблонные зависимости, то есть зависимости не конкретных файлов друг от друга, а файлов, имена которых удовлетворяют заданному шаблону. Тогда команды в зависимостях конкретных файлов также могут быть опущены. Например, стандартное шаблонное правило для зависимостей .o файлов от .c файлов может быть определено следующим образом:


%.o: %.c:
    $(CC) -c $(CFLAGS) $< 

Тем не менее, в этом файле проекта осталось слабое место. Оно связано с тем, что зависимости объектных файлов включают в себя помимо .c файлов и .h файлы, подключаемые .c файлами непосредственно или транзитивно. Представим себе, что в файл prosser.c была добавлена директива


#include "trillian.h" 

Но Makefile не был соответствующим образом изменен. Теперь может получиться так, что в файле trillian.h будет изменена некоторая структура данных, но файл prosser.o не будет перекомпилирован и код модуля prosser.o будет продолжать работать со старой версией структуры данных, в то время как остальная программа - с новой версией структуры данных. Такое расхождение в описании данных в рамках одной программы может привести к "загадочным" ошибкам при ее работе.

Хотелось бы каким-либо образом строить списки зависимостей объектных файлов от .c и .h файлов автоматически. Для этого мы воспользуемся специальными опциями компилятора gcc и расширенными возможностями GNU make.

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

include deps.make

Для генерации файла deps.make с зависимостями воспользуемся опцией -MM компилятора gcc:


deps.make: arthur.c trillian.c prosser.c arthur.h trillian.h prosser.h 
    gcc -MM arthur.c trillian.c prosser.c > deps.make 

Файл deps.make зависит от всех .c и .h файлов, из которых собирается программа. Может показаться, что это правило не будет работать, так как в Makefile необходимо включить файл deps.make, для генерации которого необходимо выполнить Makefile, то есть возникает циклическая зависимость, однако GNU make умеет корректно обрабатывать такие ситуации.

Для того, чтобы не выписывать списки .c и .h файлов несколько раз, в начале Makefile можно определить переменные:


CFILES = arthur.c trillian.c prosser.c
HFILES = arthur.h trillian.h prosser.h

Более того, список объектных файлов можно получать из списка .c файлов заменой суффикса .c на .o:


OBJECTS = $(CFILES:.c=.o) 

В итоге получили следующий Makefile:


CC      = gcc
CFLAGS  = -Wall -O2
LDFLAGS = -s
CFILES  = arthur.c trillian.c prosser.c
HFILES  = arthur.h trillian.h prosser.h
OBJECTS = $(CFILES:.c=.o)
TARGET  = earth

all: $(TARGET)

earth: $(OBJECTS)
    $(CC) $(LDFLAGS) $^ -o $@

include deps.make
deps.make: $(CFILES) $(HFILES)
    gcc -MM $(CFILES) > deps.make

clean:
    rm -f $(TARGET) *.o 

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

Пример файла C++ проекта:


CXX      = g++
LDFLAGS  =
CXXFLAGS = -Wall -O2 -g

CXXFILES = main.cpp fn.cpp
HFILES = fn.h

OBJECTS = $(CXXFILES:.cpp=.o)
TARGET  = proga

all: $(TARGET)

proga: $(OBJECTS)
        $(CXX) $(LDFLAGS) $^ -o $@

include deps.make
deps.make: $(CXXFILES) $(HFILES)
        $(CXX) -MM $(CXXFILES) > deps.make

clean:
        rm -f proga *.o

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

Полезно по теме: хабр:Просто о make хабр:Введение в CMake