Embedded HandbookПриложения
Все разделы › XII. Сети и DevOps для инженера
STM32F103, STM32F407 · уровень средний · 13 мин чтения
Открыть в интерактивном справочнике

109. Git для инженера: ветки, история, версии прошивки

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

Опыты идут с настоящим git 2.43 в контейнере, а идентификаторы объектов рядом считает наш же код на C, который собирается и для F103, и для F407.

Как это называется в документации

Оригинал Перевод
repository репозиторий: история проекта целиком
working tree / index (staging area) рабочий каталог / индекс: что войдёт в коммит
commit коммит: снимок дерева с автором и родителем
blob / tree содержимое файла / каталог
branch ветка: подвижная метка на коммите
tag тег: неподвижная метка, обычно версия
merge / merge conflict слияние / конфликт слияния
fast-forward перемотка: слияние без нового коммита
rebase перенос коммитов на другую основу
git describe строка версии из тега и числа коммитов
dirty tree несохранённые правки в рабочем каталоге
bisect двоичный поиск сломавшего коммита
detached HEAD отсоединённая голова: не на ветке

109.1 Три места, а не одно

У git три хранилища, и половина недоразумений — от того, что их путают: рабочий каталог (файлы на диске), индекс (что уже отобрано в следующий коммит) и сама история. git add переносит из первого во второй, git commit — из второго в третий. Отсюда понятны и ответы: git status показывает, что где лежит, git diff — разницу между каталогом и индексом, git diff --staged — между индексом и последним коммитом.

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


109.2 Имя объекта — это его содержимое

Объекты Git: коммит ссылается на дерево, дерево — на содержимое файлов; история с тегом и слиянием

Git хранит не «файлы с версиями», а объекты, и имя объекта — SHA-1 от его типа, длины и содержимого:

SHA-1("blob" + " " + длина + "\0" + содержимое)

Отсюда всё остальное. Два одинаковых файла в разных коммитах — один объект. Пустой файл в любом репозитории мира имеет одно и то же имя e69de29bb2d1d6434b8b29ae775ad8c2e48c5391. Изменился хоть один байт — имя другое, и подменить содержимое незаметно нельзя.

Наш код считает это сам:

void git_object_id(const char *type, const void *data, unsigned n, uint8_t id[20])
{
    char head[32];
    unsigned k = (unsigned)strlen(type);
    memcpy(head, type, k);
    head[k++] = ' ';
    k += put_dec(head + k, n);
    head[k++] = 0;                      /* нулевой байт — часть заголовка */
    sha1_t s;
    sha1_init(&s);
    sha1_update(&s, head, k);
    sha1_update(&s, data, n);
    sha1_final(&s, id);
}

Опыт: настоящий git и этот код посчитали одно и то же для пустого файла, для README и для main.c — 8252973…. Дерево из одного файла — это строка «100644 main.c», нулевой байт и 20 байт идентификатора содержимого, всего 34 байта; его имя — 958ed88…, и снова совпало.

Коммит поверх этого — обычный текстовый объект: дерево, родитель, автор, дата, сообщение. Поэтому у коммита слияния два родителя, а у первого коммита — ни одного.


109.3 Ветки и слияние

Ветка — не копия, а метка: имя, указывающее на коммит, и оно двигается при каждом новом коммите. Поэтому ветки в git дешёвые: git switch -c fix — это одна строка в файле.

Слияние ищет общего предка и накладывает обе стороны. Если правки в разных местах, git справится сам. Если обе ветки правили одно место — конфликт, и git оставляет выбор человеку:

<<<<<<< HEAD
void fix(void) { }
=======
void feature(void) { }
>>>>>>> feature

В опыте так и вышло: обе ветки дописали в конец main.c, git сообщил о конфликте в одном файле и вставил три строки-маркера. После разрешения — обе строки на месте, и у коммита слияния два родителя.

Важно, что конфликт — это не поломка, а вопрос. Опасность в другом: разрешить его «по-быстрому», выкинув чужую правку. Поэтому после разрешения конфликта прошивку положено собрать и прогнать тесты — именно на этом месте ловятся пропавшие строки.


109.4 Версия прошивки из истории

Тег ставят на выпуск: git tag -a v1.2.0 -m "версия 1.2.0". Дальше git describe --tags --dirty сам собирает строку версии:

Состояние Строка
ровно на теге v1.2.0
три коммита после тега v1.2.0-3-g<хэш>
есть несохранённые правки v1.2.0-3-g<хэш>-dirty

Эту строку передают компилятору как макрос и печатают при старте прошивки. Тогда на вопрос «что в приборе» отвечает сам прибор, а -dirty честно говорит: собрано из непонятно чего, у такой прошивки нет соответствия в истории.


109.5 Разбор версии на плате

Строку удобно не только печатать, но и разбирать: сравнить свою версию с присланной по радио, отказаться ставить старую прошивку. Разбор занимает несколько строк и работает без библиотек:

if (s[0] == '-' && s[1] >= '0' && s[1] <= '9') {    /* «-3-g5b1f0c2»: после тега были коммиты */
    if (!(s = get_num(s + 1, &v->ahead)) || s[0] != '-' || s[1] != 'g')
        return 0;

Сравнивать версии как текст нельзя: «1.10.0» меньше «1.9.3» по алфавиту, хотя 1.10.0 новее 1.9.3. Сравнение идёт по числам:

const unsigned x[4] = { a->major, a->minor, a->patch, a->ahead };
const unsigned y[4] = { b->major, b->minor, b->patch, b->ahead };
for (unsigned i = 0; i < 4; i++)
    if (x[i] != y[i])
        return x[i] < y[i] ? -1 : 1;

Четвёртое число — коммиты после тега: сборка v1.2.0-3 новее, чем сам v1.2.0. Но осмысленно это число только на одной ветке: v1.2.0-3 с двух разных веток по нему «равны». Поэтому запрет отката на приборе сравнивает версии выпусков (тегов), а сборки с -N-g… ставят только на стенде. Строки, которых быть не должно («v1.2», «v1.2.0-x»), разбор отвергает, а не толкует наугад.


109.6 Что хранить, а что нет


109.7 Поиск сломавшего коммита за пять проверок

Самая полезная команда, о которой чаще всего не знают. Известно: месяц назад собиралось, сейчас нет. Двадцать коммитов между — перебирать вручную долго. git bisect делает двоичный поиск, а git bisect run ещё и запускает проверку сам:

git bisect start HEAD "$FIRST" > /dev/null 2>&1
git bisect run sh -c 'gcc -fsyntax-only app.c' > /tmp/bisect.log 2>&1 || true

Опыт: 21 коммит, в тринадцатом потеряна точка с запятой. git bisect run с настоящим компилятором нашёл ровно тот коммит 13 за 5 проверок вместо двадцати — это ⌈log₂ 21⌉. Проверкой может быть что угодно, лишь бы она возвращала ноль на исправном коммите: сборка, тест на компьютере, прогон на плате.

git bisect run: какие коммиты проверены и как сжимался отрезок поиска

Порядок проверок в опыте: коммит 10 собирается, 15 — нет, 12 — да, 14 — нет, 13 — нет. Каждый ответ отбрасывает половину оставшегося отрезка.

Правило то же, что у тестов: чем мельче коммиты, тем точнее ответ. Коммит «переписал драйвер» найдётся так же быстро, но сузит поиск до тысячи строк, а не до пяти.


109.8 Когда историю переписывают

git rebase переносит коммиты на новую основу, чтобы история читалась прямой лентой. Это удобно до тех пор, пока коммиты никому не отданы: перенос делает новые объекты с новыми именами, и у коллеги остаётся старая ветка — вплоть до расхождения историй.

Правило простое: своё, не отправленное — правь сколько угодно; отправленное — только новыми коммитами. Исправление ошибки отдельным коммитом честнее: в истории видно, что было не так, и это ровно то, что ищут через полгода.


109.9 Как это выглядит в работе с прошивкой


109.10 Грабли

-dirty в версии выпущенной прошивки. Собрано из несохранённых правок: такой прошивки нет в истории и повторить её нельзя. Собирать выпуск только из чистого дерева (раздел 110 — в контейнере).

Версии сравниваются как текст. 1.10.0 оказывается «старше» 1.9.3. Сравнивать по числам.

Секрет в истории. Удаление файла новым коммитом не помогает: старый коммит на месте. Менять ключ.

Репозиторий распух. В истории двоичные файлы сборки. Их место — в хранилище артефактов.

Конфликт разрешён «по-быстрому». Чужая правка выкинута. После разрешения — сборка и тесты.

Переписана отправленная история. У коллег другая история той же ветки. Отправленное правят новыми коммитами.

Отсоединённая голова. Коммиты сделаны не на ветке и «пропали»: они есть, но на них ничто не указывает. git switch -c имя на месте или git reflog, чтобы найти.


109.11 Итоги

Полные исходники

git109.c — SHA-1 и имена объектов Git, разбор git describe и сравнение версий.

/* Раздел 109: Git для инженера — объекты и их имена, версия прошивки.
 * SHA-1 по стандарту, ID объектов Git — blob, tree, commit — как их считает
 * сам Git: SHA-1 от заголовка «тип длина\0» и содержимого. Разбор строки
 * git describe в поля версии и сравнение версий по числам, а не по тексту.
 * Переносимый C: собирается и для платы — прошивка может сама проверить
 * хэш образа и сравнить свою версию с присланной (_verify/git109_host.c
 * считает ID, которые проверка сверяет с настоящим git).
 * Проверка — _verify/git109_check.py. */
#include <stdint.h>
#include <string.h>

/* ── SHA-1 ───────────────────────────────────────────────────────────── */
typedef struct {
    uint32_t h[5];
    uint64_t len;                       /* байт принято */
    uint8_t buf[64];
    unsigned used;
} sha1_t;

static uint32_t rol(uint32_t x, unsigned n)
{
    return x << n | x >> (32u - n);
}

static void sha1_block(sha1_t *s, const uint8_t *p)
{
    uint32_t w[80];
    for (unsigned i = 0; i < 16; i++)
        w[i] = (uint32_t)p[4 * i] << 24 | (uint32_t)p[4 * i + 1] << 16 | (uint32_t)p[4 * i + 2] << 8 | p[4 * i + 3];
    for (unsigned i = 16; i < 80; i++)
        w[i] = rol(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
    uint32_t a = s->h[0], b = s->h[1], c = s->h[2], d = s->h[3], e = s->h[4];
    for (unsigned i = 0; i < 80; i++) {
        uint32_t f, k;
        if (i < 20) {
            f = (b & c) | (~b & d);
            k = 0x5A827999u;
        } else if (i < 40) {
            f = b ^ c ^ d;
            k = 0x6ED9EBA1u;
        } else if (i < 60) {
            f = (b & c) | (b & d) | (c & d);
            k = 0x8F1BBCDCu;
        } else {
            f = b ^ c ^ d;
            k = 0xCA62C1D6u;
        }
        uint32_t t = rol(a, 5) + f + e + k + w[i];
        e = d;
        d = c;
        c = rol(b, 30);
        b = a;
        a = t;
    }
    s->h[0] += a;
    s->h[1] += b;
    s->h[2] += c;
    s->h[3] += d;
    s->h[4] += e;
}

void sha1_init(sha1_t *s)
{
    static const uint32_t h0[5] = { 0x67452301u, 0xEFCDAB89u, 0x98BADCFEu, 0x10325476u, 0xC3D2E1F0u };
    memcpy(s->h, h0, sizeof h0);
    s->len = 0;
    s->used = 0;
}

void sha1_update(sha1_t *s, const void *data, unsigned n)
{
    const uint8_t *p = data;
    s->len += n;
    while (n--) {
        s->buf[s->used++] = *p++;
        if (s->used == 64) {
            sha1_block(s, s->buf);
            s->used = 0;
        }
    }
}

void sha1_final(sha1_t *s, uint8_t out[20])
{
    uint64_t bits = s->len * 8u;
    static const uint8_t one = 0x80, zero = 0;
    sha1_update(s, &one, 1);            /* бит «1», затем нули до 56 байт в блоке */
    while (s->used != 56)
        sha1_update(s, &zero, 1);
    uint8_t len[8];
    for (unsigned i = 0; i < 8; i++)
        len[i] = (uint8_t)(bits >> (56 - 8 * i));      /* длина в битах, старшим байтом вперёд */
    sha1_update(s, len, 8);
    for (unsigned i = 0; i < 20; i++)
        out[i] = (uint8_t)(s->h[i / 4] >> (24 - 8 * (i % 4)));
}

/* ── ID объекта Git: SHA-1 от «тип длина\0» и содержимого ────────────── */
static unsigned put_dec(char *p, unsigned v)
{
    char t[10];
    unsigned n = 0, k = 0;
    do
        t[n++] = (char)('0' + v % 10u);
    while (v /= 10u);
    while (n)
        p[k++] = t[--n];
    return k;
}

void git_object_id(const char *type, const void *data, unsigned n, uint8_t id[20])
{
    char head[32];
    unsigned k = (unsigned)strlen(type);
    memcpy(head, type, k);
    head[k++] = ' ';
    k += put_dec(head + k, n);
    head[k++] = 0;                      /* нулевой байт — часть заголовка */
    sha1_t s;
    sha1_init(&s);
    sha1_update(&s, head, k);
    sha1_update(&s, data, n);
    sha1_final(&s, id);
}

/* дерево из одного файла: «режим имя\0» и 20 байт ID его содержимого */
unsigned git_tree_one(uint8_t *t, const char *mode, const char *name, const uint8_t blob[20])
{
    unsigned k = 0;
    size_t m = strlen(mode), f = strlen(name);
    memcpy(t, mode, m);
    k += (unsigned)m;
    t[k++] = ' ';
    memcpy(t + k, name, f);
    k += (unsigned)f;
    t[k++] = 0;
    memcpy(t + k, blob, 20);
    return k + 20;
}

/* ── версия прошивки из git describe ─────────────────────────────────── */
typedef struct {
    unsigned major, minor, patch;       /* из тега vX.Y.Z */
    unsigned ahead;                     /* коммитов после тега */
    char hash[16];                      /* короткий ID коммита */
    int dirty;                          /* собрано из изменённого дерева */
} fw_version_t;

static const char *get_num(const char *s, unsigned *v)
{
    if (*s < '0' || *s > '9')
        return 0;
    *v = 0;
    while (*s >= '0' && *s <= '9')
        *v = *v * 10u + (unsigned)(*s++ - '0');
    return s;
}

/* «v1.2.0-3-g5b1f0c2-dirty» → поля; 1 — разобрано */
int fw_describe(const char *s, fw_version_t *v)
{
    memset(v, 0, sizeof *v);
    if (*s == 'v')
        s++;
    if (!(s = get_num(s, &v->major)) || *s++ != '.' || !(s = get_num(s, &v->minor)) || *s++ != '.'
        || !(s = get_num(s, &v->patch)))
        return 0;
    if (s[0] == '-' && s[1] >= '0' && s[1] <= '9') {    /* «-3-g5b1f0c2»: после тега были коммиты */
        if (!(s = get_num(s + 1, &v->ahead)) || s[0] != '-' || s[1] != 'g')
            return 0;
        s += 2;
        unsigned k = 0;
        while (((*s >= '0' && *s <= '9') || (*s >= 'a' && *s <= 'f')) && k < sizeof v->hash - 1)
            v->hash[k++] = *s++;
        v->hash[k] = 0;
    }
    if (strcmp(s, "-dirty") == 0)
        v->dirty = 1;
    else if (*s)
        return 0;
    return 1;
}

/* сравнение по числам: 1.10.0 новее 1.9.3, хотя как текст «1.10» меньше «1.9» */
int fw_cmp(const fw_version_t *a, const fw_version_t *b)
{
    const unsigned x[4] = { a->major, a->minor, a->patch, a->ahead };
    const unsigned y[4] = { b->major, b->minor, b->patch, b->ahead };
    for (unsigned i = 0; i < 4; i++)
        if (x[i] != y[i])
            return x[i] < y[i] ? -1 : 1;
    return 0;
}

Дальше: раздел 110 «CI/CD для прошивки» — та же история, но сборка и тесты запускаются сами, а на выходе — образ с версией из тега.

Открыть в интерактивном справочнике