3. Сборка приложения

Цель работы

Разделить приложение, разработанное в лабораторной работе 1, на два модуля: статическую библиотеку и приложение. Доработать Makefile. Настроить автоматические сборки в сервисе GitHub Actions.

Геометрия

Реализовать вычисление периметра и площади для выбранных фигур.

Easy

Окружность

Normal

Окружность, треугольник.

Hard

Окружность, треугольник, полигон.

Материалы для подготовки к работе

  1. Документация GNU Make

    Обязательный минимум:

    1. Overview of make

    2. An Introduction to Makefiles

    3. Writing Makefiles

    4. Writing Rules

    5. Writing Recipes in Rules

    6. How to Use Variables:

      1. Basics of Variable References

      2. The Two Flavors of Variables

  2. Граф зависимостей

  3. Static library

  4. GitHub Actions:

    1. About continuous integration

    2. Quickstart for GitHub Actions

Процесс сборки

Этапы компиляции

В простейшем случае компилятор принимает на вход файлы с исходным кодом и создает исполняемый файл:

_images/gcc-blackbox2.svg

Процесс сборки приложения состоит из ряда этапов, на каждом из которых ее можно прервать:

_images/compilation-stages-2.svg

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

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 $@ $<

Приведенный мейкфайл намеренно избыточен для простоты понимания. Ему соответствует следующий граф зафисимостей:

digraph G {
  bgcolor = "#ffffff00";
  rankdir = LR;
  "hello" -> "main.o";
  "hello" -> "hello.o";
  "main.o" -> "main.c";
  "hello.o" -> "hello.c";
}

Легко понять последовательность команд в случае изменения одного из исходников. Так, при изменении файла main.c:

\begin{eqnarray} mtime(main.c) &\gt& mtime(main.o) &\Rightarrow rebuild(main.o) \\ mtime(main.o) &\gt& mtime(hello) &\Rightarrow rebuild(hello) \end{eqnarray}

Где \(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

Статические библиотеки

Для удобства сборки, тестирования и распространения приложения разделяют на модули. Один из типов таких модулей — статическая библиотека.

Для сборки статической библиотеки нужно:

  1. Скомпилировать исходники в объектные файлы.

  2. Создать архив объектных файлов.

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

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

Тогда граф зависимостей примет вид:

digraph G {
  bgcolor = "#ffffff00";
  rankdir = LR;
  "hello" -> "main.o";
  "hello" -> "libhello.a";
  "libhello.a" -> "hello.o";
  "main.o" -> "main.c";
  "hello.o" -> "hello.c";
  "main.c" -> "hello.h";
  "hello.c" -> "hello.h";
}

Нет строгого алгоритма для определения, какой код следует разместить в библиотеке, а какой в приложении. Руководствуйтесь здравым смыслом и размещайте в библиотеке код, который потенциально может быть переиспользован в другом приложении.

Структура проекта

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

.
|-- 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.

Если мейкфайл в примере по ссылке кажется вам слишком сложным или непонятным, вы можете пойти по одному из двух путей:

  1. Писать свой вариант проще. Пусть для каждого исходника или объектного файла будет явно прописанное правило. В этом случае будьте готовы к большому работу однообразной работы и необходимости вручную вписывать каждый новый исходник в мейкфайл. Если вы пойдете этим путем, то хотя бы вынесите флаги компиляции в переменную. Это сэкономит время, если потребуется изменить набор опций для отладочной сборки. В рамках курса мы осознанно не рассматриваем как реализовывать отдельные конфигурации Debug/Release с помощью мейкфайла.

  2. Прочитайте документацию и разберитесь в происходящем.

Код возврата (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.

  1. Для каждой задачи/новой функциональности/лабораторной работы создается новая ветка.

  2. После завершения работы ветка сливается в main. При необходимости выполняется rebase.

Подробнее — см. A simple git branching model.

Аналогичные соглашения требуется соблюдать и в курсовом проекте.

Доработка структуры проекта

Прежде чем реализовывать новую функциональность, подготовье структуру проекта по аналогии с примером выше. Создайте ветку 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>

Требования к работе:

  1. Компилировать приложение следует с опциями -Wall -Werror

  2. Для сборки приложения должно быть достаточно команд git clone <remote url> && cd geometry && make.

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

  4. Если в исходниках не было изменений, то при повторном запуске make не должно выполняться никаких сборочных операций.

  5. Makefile должен содержать цель clean для удаления артефактов сборки. Цель должна выполняться, даже если в корне репозитория создан файл с именем clean.

  6. В заголовочных файлах должны быть #include guard или #pragma once.

Настройка автоматических сборок

Для настройки автоматических сборок можно использовать следующий шаблон.

.github/workflows/build.yml:

name: Build

on: push

jobs:
  build:
    runs-on: ubuntu-20.04

    steps:
      - uses: actions/checkout@v2

      - name: Check codestyle
        run: git ls-files *.{c,h} | xargs clang-format -i --verbose && git diff --exit-code

      - name: Build application
        run: make

Подробности см. в документации GitHub Actions.

Для проверки создайте отдельную ветку lab-3-fail и попробуйте закоммитить в нее неотформатированный или некомпилируемый код. Убедитесь, что сборка не проходит.

Сборка не проходит, если хотя бы одна команда завершилась с ненулевым кодом.

Разберем подробнее команду:

git ls-files *.{c,h} | xargs clang-format -i --verbose && git diff --exit-code

поскольку вам может понадобиться адаптировать ее для своего проекта.

  1. 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
    
  2. Конструкция вида a | b перенаправляет stdout команды a в stdin команды b.

  3. xargs(1):

    1. Принимает список аргументов из командной строки.

    2. Читает список строк из stdin и добавляет их аргументами к формируемой команде.

    3. Выполняет собранную команду.

    В нашем примере получим команду:

    clang-format -i --verbose src/hello/main.c src/libhello/hello.c src/libhello/hello.h
    

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

  4. git diff --exit-code выведет в stdout изменения. Если исходники после автоматического форматирования отличаются от закоммиченных, то благодаря опции --exit-code команда завершится с ненулевым кодом и сборка не пройдет.

Реализация функциональности

Теперь, когда структура проекта подготовлена, можно реализовать функциональность приложения в соответствии со своим вариантом. После завершения работы ветку lab-3 можно смержить в main с опцией --no-ff и передать на ревью.

Контрольные вопросы

  1. В чем преимущества правильно написанного Makefile перед простым скриптом сборки вида gcc -Wall -Wextra -Werror *.c -o app?

  2. Как make определяет необходимость выполнения команд?

  3. Что такое и зачем используется CI?