3. Сборка приложения
Цель работы
Разделить приложение, разработанное в
лабораторной работе 1, на два модуля: статическую
библиотеку и приложение. Доработать Makefile
. Настроить автоматические
сборки в сервисе GitLab CI.
- Геометрия
Реализовать вычисление периметра и площади для выбранных фигур.
- Easy
Окружность
- Normal
Окружность, треугольник.
- Hard
Окружность, треугольник, полигон.
Материалы для подготовки к работе
Документация 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 geometry.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 geometry.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
| |-- geometry
| | `-- .keep
| `-- libgeometry
| `-- .keep
|-- README.md
`-- src
|-- geometry
| `-- main.c
`-- libgeometry
|-- parser.c
|-- parser.h
|-- lexer.c
`-- lexer.h
Ваш набор файлов может отличаться.
Добавьте каталог src
в include path (опция -I
компилятора). Не
используйте относительные пути для подключения заголовочных файлов.
Для примера рассмотрим файл geometry/main.c
.
Неправильно:
#include "../libgeometry/parser.h"
При использовании относительных путей их придется менять при перемещении
клиентского файла в другой каталог. Например, при перемещении
geometry/parser.c
-> geometry/parser/parser.c
относительный путь к
файлу изменится:
#include "../../libgeometry/parser.h"
Заголовочные файлы должны подключаться единообразно независимо от физического расположения всего дерева исходников.
Правильно:
#include <libgeometry/parser.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?