3. Сборка приложения
Цель работы
Разделить приложение, разработанное в
лабораторной работе 1, на два модуля: статическую
библиотеку и приложение. Доработать Makefile. Настроить автоматические
сборки в сервисе GitLab CI.
Цель работы:
Реализовать движение пешек.
Материалы для подготовки к работе
Документация GNU Make
Обязательный минимум:
Overview of make
An Introduction to Makefiles
Writing Makefiles
Writing Rules
Writing Recipes in Rules
How to Use Variables:
Basics of Variable References
The Two Flavors of Variables
GitLab Pipelines:
Процесс сборки
Этапы компиляции
В простейшем случае компилятор принимает на вход файлы с исходным кодом и создает исполняемый файл:
Процесс сборки приложения состоит из ряда этапов, на каждом из которых ее можно прервать:
Для сборки многофайловых приложений сначала из файлов с исходным кодом получают объектные файлы:
gcc -Wall -Werror -c main.c
gcc -Wall -Werror -c square.c
gcc -Wall -Werror -c sort.c
Затем из объектных файлов собирают исполняемый файл:
gcc main.o square.o sort.o -o program
Зависимости при сборке приложения на языке С
Рассмотрим примитивное приложение на языке С, состоящее из трех файлов.
hello.h:
#pragma once
void say_hello();
hello.c:
#include "hello.h"
#include <stdio.h>
void say_hello()
{
printf("Hello\n");
}
main.c:
#include "hello.h"
int main()
{
say_hello();
return 0;
}
Для сборки этого приложения можно использовать мейкфайл:
CFLAGS = -Wall -Wextra -Werror
hello: main.o hello.o
$(CC) $(CFLAGS) -o $@ $^
main.o: main.c
$(CC) -c $(CFLAGS) -o $@ $<
hello.o: hello.c
$(CC) -c $(CFLAGS) -o $@ $<
Приведенный мейкфайл намеренно избыточен для простоты понимания. Ему соответствует следующий граф зафисимостей:

Легко понять последовательность команд в случае изменения одного из исходников.
Так, при изменении файла main.c:
Где \(mtime\) — Modification time, время изменения файла, см. stat(2).
Проблема этого мейкфайла в том, что он не учитывает зависимость исходников от
заголовочного файла. Так, при изменении файла hello.h должны быть
перекомпилированы все подключающие его исходники, но в данном случае этого не
произойдет. На практике крайне сложно вручную отслеживать подключения всех
заголовочных файлов. Для решения этой проблемы компилятор может сгенерировать
файлы с зависимостями с помощью опции -MMD. Эти файлы нужно подключить с
помощью директивы -include. После этого мейкфайл примет вид:
CFLAGS = -Wall -Wextra -Werror
CPPFLAGS = -MMD
hello: main.o hello.o
$(CC) $(CFLAGS) -o $@ $^
main.o: main.c
$(CC) -c $(CFLAGS) $(CPPFLAGS) -o $@ $<
hello.o: hello.c
$(CC) -c $(CFLAGS) $(CPPFLAGS) -o $@ $<
-include main.d hello.d
Статические библиотеки
Для удобства сборки, тестирования и распространения приложения разделяют на модули. Один из типов таких модулей — статическая библиотека.
Для сборки статической библиотеки нужно:
Скомпилировать исходники в объектные файлы.
Создать архив объектных файлов.
Для нашего примера мейкфайл может выглядеть так:
CFLAGS = -Wall -Wextra -Werror
CPPFLAGS = -MMD
hello: main.o libhello.a
$(CC) $(CFLAGS) -o $@ $^
main.o: main.c
$(CC) -c $(CFLAGS) $(CPPFLAGS) -o $@ $<
libhello.a: hello.o
ar rcs $@ $^
hello.o: hello.c
$(CC) -c $(CFLAGS) $(CPPFLAGS) -o $@ $<
-include main.d hello.d
Тогда граф зависимостей примет вид:

Нет строгого алгоритма для определения, какой код следует разместить в библиотеке, а какой в приложении. Руководствуйтесь здравым смыслом и размещайте в библиотеке код, который потенциально может быть переиспользован в другом приложении.
Структура проекта
Структурировать проект на файловой системе следовало бы в первую очередь, но мы отложили этот этап для сокращения примеров выше. Для старта предлагается следующая структура:
.
|-- bin
| `-- .keep
|-- .clang-format
|-- .gitignore
|-- Makefile
|-- obj
| |-- .keep
| `-- src
| |-- hello
| | `-- .keep
| `-- libhello
| `-- .keep
|-- README.md
`-- src
|-- hello
| `-- main.c
`-- libhello
|-- hello.c
`-- hello.h
Здесь:
bin— каталог для исполняемых файлов.obj— каталог для промежуточных артефактов сборки (объектные файлы, файлы зависимостей, статические библиотеки).*/.keep— пустой файл для сохранения структуры каталогов проекта.
Пример структуры доступен по ссылке: project-skeleton.
Если мейкфайл в примере по ссылке кажется вам слишком сложным или непонятным, вы можете пойти по одному из двух путей:
Писать свой вариант проще. Пусть для каждого исходника или объектного файла будет явно прописанное правило. В этом случае будьте готовы к большому работу однообразной работы и необходимости вручную вписывать каждый новый исходник в мейкфайл. Если вы пойдете этим путем, то хотя бы вынесите флаги компиляции в переменную. Это сэкономит время, если потребуется изменить набор опций для отладочной сборки. В рамках курса мы осознанно не рассматриваем как реализовывать отдельные конфигурации Debug/Release с помощью мейкфайла.
Прочитайте документацию и разберитесь в происходящем.
Код возврата (exit status)
Код возврата — это целочисленное значение, которое дочерний процесс возвращает родительскому процессу в момент завершения.
При запуске программ из командной строки родительским процессом выступает командная оболочка (зачастую bash), а дочерним процессом — запускаемая утилита.
До сих пор написанные вами приложения возвращали 0. Возвращаемое значение можно
посмотреть в переменной окружения $?. Например:
int main()
{
return 42;
}
$ gcc -o answer main.c
$ ./answer
$ echo $?
42
Принято соглашение: в случае успешного завершения приложение должно возвращать
ноль, в случае ошибки — ненулевой код. Этому соглашению следует большинство
утилит, в том числе gcc и make:
$ gcc
gcc: fatal error: no input files
compilation terminated.
$ echo $?
1
Руководство
Соглашения о рабочем процессе
Эта и следующая лабораторные работы выполняются в соответствии с A simple git branching model.
Для каждой задачи/новой функциональности/лабораторной работы создается новая ветка и соответствующий merge-request на GitLab’e.
После завершения работы ветка сливается в
master. При необходимости выполняетсяrebase.
Подробнее — см. A simple git branching model.
Аналогичные соглашения требуется соблюдать и в РГР.
Перед созданием новой ветки, убедитесь что локальная ветка master выровнена с удаленной веткой.
Для этого в локальном репозитории выведите историю изменений:
$ git hist
HEAD должен указывать на ветку master.
* da8df62 (origin/lab1) Create chessviz.c
* 596485a Create .gitignore
* b83e7d2 Create Makefile
* a7f4e6e (HEAD -> master, origin/master, origin/HEAD) Import .clang-format config
* 75143b5 Add printf(Hello,World)
* 5b3a3ca Create function
* 32d47db Create empty main.c
Выполните команду git pull.
После выполнения команды изменения из удаленного репозитория загрузятся в локальный.
Ещё раз выведите историю изменений, посмотрите, что ветки master и origin/master указывают на один коммит
и ветка lab-1 слита:
* a2c323f (HEAD -> master, origin/master, origin/HEAD) Merge branch 'lab1' into 'master'
|\
| * da8df62 (origin/lab1) Create chessviz.c
| * 596485a Create .gitignore
| * b83e7d2 Create Makefile
|/
* a7f4e6e Import .clang-format config
* 75143b5 Add printf(Hello,World)
* 5b3a3ca Create function
* 32d47db Create empty main.c
После того, как локальная ветка master выровнена с удаленной origin/master, создайте новую ветку lab-3
и merge-request для нее на GitLab.
Доработка структуры проекта
Прежде чем реализовывать новую функциональность, подготовье структуру проекта по
аналогии с примером выше. Создайте ветку lab-3 и приведите проект к
требуемой структуре.
Возможная структура проекта:
.
|-- bin
| `-- .keep
|-- .clang-format
|-- .gitignore
|-- Makefile
|-- obj
| |-- .keep
| `-- src
| |-- chessviz
| | `-- .keep
| `-- libchessviz
| `-- .keep
|-- README.md
`-- src
|-- chessviz
| `-- main.c
`-- libchessviz
|-- board.c
|-- board.h
|-- board_print_html.c
|-- board_print_html.h
|-- board_print_plain.c
|-- board_print_plain.h
|-- board_read.c
`-- board_read.h
Ваш набор файлов может отличаться.
Добавьте каталог src в include path (опция -I компилятора). Не
используйте относительные пути для подключения заголовочных файлов.
Для примера рассмотрим файл chessviz/main.c.
Неправильно:
#include "../libchessviz/board.h"
При использовании относительных путей их придется менять при перемещении
клиентского файла в другой каталог. Например, при перемещении
chessviz/board.c -> chessviz/board/board.c относительный путь к
файлу изменится:
#include "../../libchessviz/board.h"
Заголовочные файлы должны подключаться единообразно независимо от физического расположения всего дерева исходников.
Правильно:
#include <libchessviz/board.h>
Требования к работе:
Компилировать приложение следует с опциями
-Wall -WerrorДля сборки приложения должно быть достаточно команд
git clone <remote url> && cd <repo-name> && make.При изменении одного файла с исходным кодом должен перекомпилироваться только он и зависящие от него артефакты. При изменении заголовочного файла должны перекомпилироваться все файлы, в которые он подключен.
Если в исходниках не было изменений, то при повторном запуске
makeне должно выполняться никаких сборочных операций.Makefileдолжен содержать цельcleanдля удаления артефактов сборки. Цель должна выполняться, даже если в корне репозитория создан файл с именемclean.В заголовочных файлах должны быть #include guard или #pragma once.
Настройка автоматических сборок
Для настройки автоматических сборок создайте конфиг .gitlab-ci.yml в корне репозитория.
- Подробности см. в документации
Для проверки создайте отдельную ветку lab-3-fail и попробуйте закоммитить в
нее неотформатированный или некомпилируемый код. Убедитесь, что сборка не
проходит.
Сборка не проходит, если хотя бы одна команда завершилась с ненулевым кодом.
Разберем подробнее команду:
git ls-files *.{c,h} | xargs clang-format -i --verbose && git diff --exit-code
поскольку вам может понадобиться адаптировать ее для своего проекта.
git ls-files *.{c,h}раскрывается оболочкой вgit ls-files *.c *.h, см Brace Expansion в bash(1). Выводит вstdoutсписок файлов, соответствующих маскам. Пример:src/hello/main.c src/libhello/hello.c src/libhello/hello.h
Конструкция вида
a | bперенаправляетstdoutкомандыaвstdinкомандыb.-
Принимает список аргументов из командной строки.
Читает список строк из
stdinи добавляет их аргументами к формируемой команде.Выполняет собранную команду.
В нашем примере получим команду:
clang-format -i --verbose src/hello/main.c src/libhello/hello.c src/libhello/hello.h
После ее выполнения исходники в репозитории будут отформатированы в соответствии с приложенным конфигом.
git diff --exit-codeвыведет вstdoutизменения. Если исходники после автоматического форматирования отличаются от закоммиченных, то благодаря опции--exit-codeкоманда завершится с ненулевым кодом и сборка не пройдет.
Реализация функциональности
Теперь, когда структура проекта подготовлена, можно реализовать функциональность приложения в соответствии со своим вариантом. После завершения работы запросите ревью у преподавателя практики.
Контрольные вопросы
В чем преимущества правильно написанного
Makefileперед простым скриптом сборки видаgcc -Wall -Wextra -Werror *.c -o app?Как make определяет необходимость выполнения команд?
Что такое и зачем используется CI?