View on GitHub

Methodics of GNU/Linux application software development

Practical materials for learning application software development on Linux

Данный раздел продолжает повествование о работе с утилитой make при автосборке для многофайловых проектов.

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

Первая многомодульная сборка

Рассмотрим простую многомодульную программу и будем постепенно усложнять Makefile для компиляции проекта

prog.c - Основная программа

#include "outlib.h"
int main(int argc, char *argv[]) {
    Count = argc;
    output("<INIT>");
    output(argc>1 ? argv[1] : "<NOPE>");
    output("<DONE>");
    return 0;
}

fun.c - Описание выводящей функции

#include <stdio.h>
#include "outlib.h"
void output(char *str) {
        printf("%d: %s\012", Count++, str);
}

const.c - Глобальные переменные, видные всему проекту

int Count=0;

outlib.h - Header-файл

void output(char *);
extern int Count;

Сначала самый примитивный вариант: просто всё скомпилировать командой cc *.c -o prog

Однако, в будущем мы так поступать не хотим — здесь ведётся трансляция всех файлов сразу. Если бы их было сильно больше, мы бы вдобавок к неоптимизированной компиляции, как минимум, проигрывали в скорости её проведения.

Напишем Makefile для проекта

prog:   const.c fun.c prog.c
    cc const.c fun.c prog.c -o prog
clean:
	rm -f prog

Makefile состоит из множества описанных рецептов с указанными целями, для каждого из них опционально присутствуют исходники.

Ещё раз проговорим особенности и задачи make:

Генерат — файл, отсутствующий в исходных текстах, но появляющийся в процессе сборки. Целевой генерат — генерат, ради которого была затеяна сборка (например, исполняемый файл или библиотека). Промежуточный генерат — генерат, необходимый в процессе сборки, но по её завершении не имеющий явного применения (например, объектные файлы из состава библиотеки).

Довольно частая задача, помимо сборки, — удаление мусора и промежуточных генератов. Для этого в Makefile заводится отдельная цель (как правило, clean:). Чтобы удалить вообще все генераты, включая целевые, можно сделать отдельную цель (обычно она называется distclean:).

Улучшение Makefile

Преобразим Makefile из нашего примера: в предыдущем случае проводилась перекомпиляция всего проекта даже при изменении лишь одного из файлов. Для разделения блоков опишем вызов компилятора для каждого файла отдельно, после чего будем производить компоновку готовых объектных файлов. А для удаления тех самых объектников добавим в рецепт цели clean дополнительные параметры.

prog:   const.o fun.o prog.o
	cc const.o fun.o prog.o -o prog

fun.o:  fun.c 
	cc fun.c -c -o fun.o

prog.o: prog.c 
	cc prog.c -c -o prog.o

const.o:        const.c 
	cc const.c -c -o const.o

clean:
	rm -f prog *.o

Следующий этап улучшения автосборки - написание рецептов через шорт-каты: Цели $@ и Исходников $^ (первый исходник имеет свой особый шорт-кат $<)

prog:   const.o fun.o prog.o
	cc const.o fun.o prog.o -o prog

fun.o:  fun.c 
	cc $< -c -o $@

prog.o: prog.c 
	cc $< -c -o $@

const.o:        const.c 
	cc $< -c -o $@

clean:
	rm -f prog *.o

В нашем мейк-файле получились три одинаковых рецепта. Их можно объединить с помощью параметризируемых рецептов:

all:    prog

%.o:  %.c 
	cc $< -c -o $@

prog:   const.o fun.o prog.o
	cc $^ -o $@

clean:
	rm -f prog *.o

Увидим, что перекомпиляция теперь работает независимо по файлам:

user@localhost: make clean
rm -f prog *.o

user@localhost: make prog
cc const.c -c -o const.o
cc fun.c -c -o fun.o
cc prog.c -c -o prog.o
cc const.o fun.o prog.o -o prog

user@localhost: touch fun.c 
user@localhost: make prog  
cc fun.c -c -o fun.o
cc const.o fun.o prog.o -o prog

user@localhost: touch fun.o
user@localhost: make prog  
cc const.o fun.o prog.o -o prog

С новым Makefile после сборки программа корректно работает :

user@localhost:~/LinuxAppDev/02_Multifile> ./prog
1: <INIT>
2: <NOPE>
3: <DONE>
user@localhost:~/LinuxAppDev/02_Multifile> ./prog Hahaha
2: <INIT>
3: Hahaha
4: <DONE>
user@localhost:~/LinuxAppDev/02_Multifile> 

Вторая многомодульная сборка

Изменим нашу программу под работу со множеством аргументов:

prog.c - Основная программа

#include <stdio.h>
#include "outlib.h"

int main(int argc, char *argv[]) {
  int i;
    if((Count = argc)>1) {
      output("<INIT>");
      for(i=1; i<argc; i++)
        output(argv[i]);
      output("<DONE>");
    }
    else
      usage(argv[0]);
 return 0;
}

fun.c - Описание выводящей функции

#include <stdio.h>
#include "outlib.h"
void output(char *str) {
        printf("%d: %s\012", Count++, str);
}

void usage(char *prog) {
    fprintf(stderr, "%s v%.2f: Print all arguments\012\t"\
                "Usage: %s arg1 [arg2 […]]\012", prog, VERSION, prog);
}

const.c - Глобальные переменные, видные всему проекту

int Count=0;

outlib.h - Header-файл

void output(char *);
void usage(char *);
extern int Count;
#define VERSION 0.0

И также продолжим усложнять Makefile: разделим удаление генератов и объектных файлов; для описания этих файлов воспользуемся переменными окружения; добавим файл README, который будет генерироваться по нашему бинарнику:

.PHONY: clean distclean
GENS = prog README
TRASH = *.o *~ o.*

%.o:    %.c
       cc $< -c -o $@

all:    prog README

prog:   const.o fun.o prog.o
 cc $^ -o $@

README: prog
   ./$< > $@ 2>&1

clean:
  rm -f $(TRASH)

distclean:      clean
       rm -rf $(GENS)

Цели clean и distclean мы помечаем специальной меткой .PHONY. Данная метка явно указывает, что цели не связаны с именами файлов, совпадающих с ними (на случай, если файлы с такими именами у нас будут), благодаря чему их рецепты будут выполняться независимо от изменения файлов.

make ориентируется при выборе целей для исполнения на соответствие времени изменения исходных данных и самой цели. И в случае, если итоговый объект был сделан раньше последних изменений исходников, производится пересборка рецепта. .PHONY-цели помечаются утилитой как “только что изменённые” при каждом её вызове, благодаря чему исполняются постоянно.

Вернёмся к Makefile У нас в рецептах есть важная проблема: outlib.h никак не отображается в нашем графе зависимостей. Из-за этого мы можем беспрепятственно менять его без пересборки проекта, чего быть, вообще говоря, не должно.

user@localhost:~/LinuxAppDev/02_Multifile> make distclean
rm -f *.o *~ o.*
rm -rf prog README

user@localhost:~/LinuxAppDev/02_Multifile> make
cc const.c -c -o const.o
cc fun.c -c -o fun.o
cc prog.c -c -o prog.o
cc const.o fun.o prog.o -o prog
./prog > README 2>&1

user@localhost:~/LinuxAppDev/02_Multifile> make
make: Nothing to be done for 'all'.

user@localhost:~/LinuxAppDev/02_Multifile> touch outlib.h 
user@localhost:~/LinuxAppDev/02_Multifile> make
make: Nothing to be done for 'all'.

Для учёта зависимости добавим отдельную цель для её описания. При этом заметим, что её рецепт будет пустым

.PHONY: clean distclean
GENS = prog README
TRASH = *.o *~ o.*

%.o:	%.c
	cc $< -c -o $@

all:	prog README

fun.o prog.o:	outlib.h

prog:	const.o fun.o prog.o
	cc $^ -o $@

README:	prog
	./$< > $@ 2>&1

clean:
	rm -f $(TRASH)

distclean:	clean
	rm -rf $(GENS)
user@localhost:~/LinuxAppDev/02_Multifile> make distclean all
rm -f *.o *~ o.*
rm -rf prog README
cc const.c -c -o const.o
cc fun.c -c -o fun.o
cc prog.c -c -o prog.o
cc const.o fun.o prog.o -o prog
./prog > README 2>&1

user@localhost:~/LinuxAppDev/02_Multifile> make
make: Nothing to be done for 'all'.

user@localhost:~/LinuxAppDev/02_Multifile> touch outlib.h 
user@localhost:~/LinuxAppDev/02_Multifile> make
cc fun.c -c -o fun.o
cc prog.c -c -o prog.o
cc const.o fun.o prog.o -o prog
./prog > README 2>&1

Ненароком мы показали ещё одну особенность при работе с утилитой: возможность последовательно вызывать сразу несколько целей при явном их указании (сначала вызвали зачистку через distclean, после - исполнение основной сборки через all)

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

.PHONY: clean distclean
GENS = prog README
TRASH = *.o *~ o.*
CFLAGS = -Wall
CC = cc

all:	prog README

fun.o prog.o:	outlib.h

prog:	const.o fun.o prog.o

README:	prog
	./$< > $@ 2>&1

clean:
	rm -f $(TRASH)

distclean:	clean
	rm -rf $(GENS)

Теперь компоновкой исполняемого файла и сборкой объектного файла из исходного заведуют встроенные в Makе правила (они, например, добавляют CFLAGS к каждой компиляции):

user@localhost:~/LinuxAppDev/02_Multifile> make distclean all
rm -f *.o *~ o.*
rm -rf prog README
cc -Wall   -c -o const.o const.c
cc -Wall   -c -o fun.o fun.c
cc -Wall   -c -o prog.o prog.c
cc   const.o fun.o prog.o   -o prog
./prog > README 2>&1

Сборка собственных библиотек

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

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

user@localhost:~/LinuxAppDev/02_Multifile/Lection> ar -rcs libout.a const.o fun.o

Имя статической библиотеки должно соответствовать формату **_lib.a_**. При сборке проекта с библиотекой используется флаг **_-l_**, а также флаг **_-L_**, указывающий путь до присоединяемой библиотеки

user@localhost:~/LinuxAppDev/02_Multifile/Lection> cc prog.o -L. -lout -o prog

Научимся собирать библиотеку в Makefile. Сборка объектных файлов для библиотеки по шаблонному правилу имеет собственный синтаксис (он приведён ниже), и одну особенность: Make не может «залезть» в библиотеку и проверить, устарели или нет её компоненты по отношению к соответствующим объектным файлам. Поэтому эти компоненты отмечены специальной целью .INTERMEDIATE: они считаются автоматически возникающими и их отсутствие не приводит к пересборке (к пересборке приводит только обновление исходников).

По этой же причине объектные файлы из библиотеки помечены как .SECONDARY: это тот же .INTERMEDIATE, только вдобавок Make не будет пытаться удалить эти файлы как промежуточный генерат.

Если закомментировать одну из этих строк в примере ниже, библиотека будет считаться устаревшей при каждой пересборке (проверьте!).

.PHONY: clean distclean
GENS = prog README
TRASH = *.o *~ o.*
CFLAGS = -Wall
CC = cc

all:	prog README

fun.o prog.o:	outlib.h

.SECONDARY:     fun.o const.o
.INTERMEDIATE:  libout.a(fun.o const.o)

libout.a:	libout.a(fun.o const.o)  # то же самое, что libout.a(fun.o) libout.a(const.o)

prog:	prog.o libout.a

README:	prog
	./$< > $@ 2>&1

clean:
	rm -f $(TRASH)

distclean:	clean
	rm -rf $(GENS)
user@localhost:~/LinuxAppDev/02_Multifile> make distclean all
rm -f *.o *~ o.*
rm -rf prog README libout.a
cc -Wall   -c -o prog.o prog.c
cc -Wall   -c -o fun.o fun.c
ar -rv libout.a fun.o
ar: creating libout.a
a - fun.o
cc -Wall   -c -o const.o const.c
ar -rv libout.a const.o
a - const.o
cc   prog.o libout.a   -o prog
./prog > README 2>&1
rm const.o

user@localhost:~/LinuxAppDev/02_Multifile> ./prog
./prog v0.00: Print all arguments
	Usage: ./prog arg1 [arg2 […]]

user@localhost:~/LinuxAppDev/02_Multifile> ./prog qwerty
2: <INIT>
3: qwerty
4: <DONE>

user@localhost:~/LinuxAppDev/02_Multifile> ./prog qwerty asdf
3: <INIT>
4: qwerty
5: asdf
6: <DONE>

Теперь попробуем сорганизовать динамическую библиотеку. Название библиотеки отличается расширением (.so вместо .a). В целом это несколько более сложный процесс, так что правила по умолчанию в Make на этот счёт нет. Для сборки библиотеки нужно

При запуске собранного проекта надо указывать библиотеку. В случае с make мы указываем путь до библиотеки через переменную окружения LD_LIBRARY_PATH

.PHONY: clean distclean
GENS = prog README libout.so
TRASH = *.o *~ o.*
CFLAGS = -Wall -fPIC
CC = cc

all:	prog README

fun.o prog.o:	outlib.h

libout.so:	fun.o const.o
	$(CC) $^ -o $@ -shared

prog:   prog.o libout.so

README:	prog
	LD_LIBRARY_PATH=`pwd` ./$< > $@ 2>&1

clean:
	rm -f $(TRASH)

distclean:	clean
	rm -rf $(GENS)
user@localhost:~/LinuxAppDev/02_Multifile> make distclean all
rm -f *.o *~ o.*
rm -rf prog README libout.so
cc -Wall -fPIC   -c -o prog.o prog.c
cc -Wall -fPIC   -c -o fun.o fun.c
cc -Wall -fPIC   -c -o const.o const.c
cc fun.o const.o -o libout.so -shared
cc   prog.o libout.so   -o prog
LD_LIBRARY_PATH=`pwd` ./prog > README 2>&1

user@localhost:~/LinuxAppDev/02_Multifile> cat README 
./prog v0.00: Print all arguments
	Usage: ./prog arg1 [arg2 […]]

Заключение:

Документация по мейку весьма обширная, главное, не пытаться прочесть всё.

Аналогичных мейку систем сборки много, самый «модный» — Ninja. Но вручную ninja-файлы никто не пишет; он был специально разработан, чтобы обрабатывать генераты более высокоуровневых систем, о которых разговор пойдёт позже.

Домашнее задание:

Для второго примера проекта из сегодняшнего раздела написать Makefile так, чтобы в цели по умолчанию:

  1. Собирались две библиотеки: liboutput_static.a и liboutput.so из файлов fun.o и const.o (одна статическая, другая — динамичесчкая)
  2. Собиралось три бинарника: prog (из .o-файлов), prog-a (из prog.o и liboutput_static.a) и prog-so (из prog.o и liboutput.so)
  3. Имелась цель test:, для достижения которой:
    1. каждый из трёх бинарников запускается
      • без параметров (при запуске без параметров на stderr выводится help — обратите внимание на перенаправление и на то, что argv[0] у программ не совпадают),
      • с одним каким-нибудь параметром
      • и с тремя параметрами (для каждого бинарника параметры одинаковые).
    2. Затем выводы соответствующих запусков сравниваются (например, с помощью cmp file1 file2, который вернёт ненулевой статус ошибки, если файлы не одинаковы)
  4. И чтобы все генераты (включая файлы с выводами из тестов) удалялись по make clean!