Практическое руководство по автоматизации сборки, прошивки и тестирования микроконтроллеров

Зачем это нужно?

Многие embedded-разработчики привыкли работать без автоматизированных тестов, полагаясь на ручное тестирование и отладку через программатор. Это кажется простым и быстрым решением для небольших проектов. Однако при росте кодовой базы и команды такой подход приводит к критическим проблемам: баги возвращаются в новых релизах, знание о системе хранится только в головах разработчиков, а каждое изменение требует длительного ручного тестирования на стенде.

Автоматизация CI/CD для embedded-систем решает эти проблемы, хотя требует начальных усилий на настройку инфраструктуры.

Жесткая правда о embedded-разработке

Типичные отговорки без тестов:

  • "Это сложная проблема" = "Я не знаю, где баг"

  • "Нужно протестировать на стенде" = "Надеюсь, что заработает"

  • "Это аппаратная проблема" = "Не хочу разбираться в коде"

Тесты дают объективность:

  • Либо тест проходит

  • Либо тест падает

  • Либо тестов нет

Ситуация, которая повторяется в 90% компаний:

  • Нашли баг на стенде (в лучшем случае, если есть стенд)

  • Разработчик неделю дебажит через J-Link

  • Меняет одну строчку кода

  • "Пофиксил!"

  • Коммитит только фикс без тестов

  • Через 2 месяца баг возвращается в новом релизе

  • Новый разработчик тратит еще неделю на поиск

Что по-настоящему происходит:

// ПЛОХО: типичный "фикс" через дебаг
// Было:
if (adc_value > threshold) {
    set_alarm();
}

// Стало после 5 дней дебага:
if (adc_value > threshold && !is_calibrating) {
    set_alarm();
}

Никто не узнает:

  • Почему именно такая логика?

  • Какие edge cases учитывались?

  • Как воспроизвести проблему?

  • Что проверялось?

Правильный подход: Test-Driven Bug Fixing:

// 1. Пишем тест, который падает
TEST(AlarmTest, ShouldNotTriggerDuringCalibration) {
    start_calibration();
    set_adc_value(threshold + 100); // Значение выше порога
    
    process_alarm_logic();
    
    ASSERT_FALSE(alarm_triggered()); // Тест падает!
}

// 2. Фиксим код
if (adc_value > threshold && !is_calibrating) {
    set_alarm();
}

// 3. Тест проходит
// 4. Теперь у нас есть регрессионный тест НАВСЕГДА

Профит для менеджмента

Отчет разработчика без тестов:

  • "Пофиксил баг с ложными срабатываниями сигнализации"

  • Время: 5 дней

  • Изменения: 1 строка кода

  • Гарантии: "вроде работает"

Отчет системы с тестами:

  • Добавлены регрессионные тесты (в т.ч. воспроизводящий баг): 150 строк

  • Исправлен баг: 3 строки

  • Code coverage: +15%

  • Гарантии: автоматическая проверка на каждом коммите

Профит для разработчика

Без тестов:

  • "Надо помнить все свои костыли" — когнитивная нагрузка

  • "Опять этот баг вернулся" — бесконечный рефакторинг одних и тех же мест

  • "Это не мой баг, это железо глючит" — постоянные споры с hardware team

  • "Надо прошивать 10 плат вручную" — скучная рутина вместо разработки

С тестами:

  • "Мои 100 тестов подтверждают, что фикс работает" — уверенность в коде

  • "CI провалился — значит мой код сломал что-то" — мгновенная обратная связь

  • "Вот тест, который доказывает проблему с железом" — аргументированные баг-репорты

  • "Прошил 10 плат одним коммитом" — автоматизация рутины

Профит для команды

Без тестов:

  • "Кто это сломал?" — поиск виноватого вместо решения проблемы

  • "У Васи единственный работает, пусть он фиксит" — bottleneck знания

  • "Мы не можем взять нового разработчика — он ничего не поймет" — bus factor = 1

  • "Это legacy код, лучше не трогать" — страх изменений

С тестами:

  • "CI показал, что Пётр сломал GPIO" — объективная диагностика

  • "Любой может править код — тесты подстрахуют" — коллективное владение

  • "Новичок за неделю написал работающий фич" — быстрое onboarding

  • "Рефакторим смело — 200 тестов подтвердят работу" — эволюция архитектуры

Профит для продукта

Без тестов:

  • "Выкатываем и молимся" — русская рулетка с релизами

  • "На стенде работало..." — разрыв между dev и prod

  • "Клиент нашел баг, который мы 2 года не видели" — позор на рынке

  • "Нельзя добавить фичу — все развалится" — технический долг

С тестами:

  • "Релиз каждый вторник" — предсказуемый процесс

  • "CI тестирует на реальном железе" — идентичность стенда и продакшена

  • "Баги клиентов воспроизводятся за 5 минут" — быстрая реакция

  • "Добавляем фичи без страха" — скорость разработки

Почему это может быть не нужно?

Если ваша цель — стать "незаменимым" разработчиком, единственным человеком, который понимает код и может его исправить, то автоматизация тестирования действительно вам не нужна. Тесты делают код прозрачным, понятным и доступным для всей команды.

Ваша карьерная стратегия без тестов:

  • "Это очень сложная система" = "Только я знаю как оно работает"

  • "Лучше не трогать" = "Мой job security"

  • "Нужен глубокий контекст" = "Я незаменимый"

  • "Это legacy код" = "Мой личный фамильный алмаз"

Общий pipeline

Что требуется для минимального CI?

Необходимый минимум — это хотя бы собрать проект и прошить микроконтроллер. Звучит просто, но на практике часто возникают ситуации, когда проект собирается у одного разработчика, но не собирается у другого. Это может быть связано с разными версиями компилятора, отсутствующими зависимостями или изменениями в коде, которые сломали сборку для определённых окружений.

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

Разберём необходимые инструменты на конкретном примере.

Необходимые компоненты

  1. GitHub или GitLab — система контроля версий с поддержкой CI/CD. В этой статье все примеры будут для GitHub. Просто создайте новый репозиторий, к нему мы ещё вернёмся.

  2. Сервер для сборки и тестов — это может быть обычный компьютер, Raspberry Pi или даже виртуальная машина. GitHub предоставляет бесплатные серверы (runners), но с ограничениями по времени выполнения. Для embedded-разработки, где нужен доступ к реальному железу, обычно используется собственный сервер (self-hosted runner).

    Альтернатива: VCON — сторонний сервис для удалённого доступа к устройствам. Его использует, например, проект Mongoose. Работает так: ESP32 с прошивкой VCON подключается к Wi-Fi и регистрируется на их сервере, играя роль программатора по воздуху. К ней подключается целевое устройство, и через CI можно загружать прошивки, читать логи и т.д.

    Плюсы VCON:

    • Всё готово к использованию из коробки

    • Не нужно настраивать собственный сервер

    • Доступ к устройству из любой точки мира

    Минусы VCON:

    • Ограничения по количеству устройств

    • Зависимость от внешнего сервиса

    • Нестандартный программатор (не подходит для полевого использования)

  3. Программатор — я буду рассматривать J-Link, так как он предоставляет удобные инструменты для работы с RTT (Real-Time Transfer). Технически можно использовать любой программатор (ST-Link, CMSIS-DAP и др.), но J-Link даёт больше возможностей для автоматизации.

  4. Целевое устройство или отладочный стенд — микроконтроллер или плата, на которой будут выполняться тесты.

Архитектура CI для embedded-систем

┌─────────────┐         ┌──────────────────┐         ┌────────────────┐
│   GitHub    │────────>│  Self-hosted     │────────>│   J-Link       │
│ Repository  │         │    Runner        │         │  Programmer    │
│             │         │  (Linux/Mac/Win) │         │                │
└─────────────┘         └──────────────────┘         └────────┬───────┘
                                                               │
                                                               │ SWD/JTAG
                                                               │
                                                               v
                                                        ┌──────────────┐
                                                        │  Target MCU  │
                                                        │ (STM32F103)  │
                                                        └──────────────┘

Для обычного ПО достаточно облачных CI-серверов GitHub/GitLab — можно тестировать на разных операционных системах без дополнительного оборудования. Но в embedded-разработке нужен доступ к реальному железу, поэтому требуется собственный сервер с подключённым программатором и целевым устройством.

Если вам нужно только проверить, что прошивка собирается, то достаточно стандартных GitHub Actions без железа. Но для полноценного тестирования функциональности понадобится self-hosted runner.

Программное обеспечение на сервере

  1. GitHub Actions Runner — агент, который выполняет задачи CI. Скачивается и регистрируется в вашем репозитории через настройки GitHub (Settings → Actions → Runners → New self-hosted runner). После регистрации запускается как фоновый сервис и ожидает команд от GitHub. Можно запустить несколько runners для разных устройств, маркируя их тегами.

  2. J-Link Software — утилиты для работы с программатором J-Link. Включает в себя командные инструменты для прошивки и чтения RTT. Рекомендую именно J-Link благодаря SEGGER RTT — технологии быстрого вывода отладочной информации без задержек.

  3. CMake (или другая система сборки) — в примере используется CMake как кроссплатформенная система метасборки. Вы можете использовать Make, Meson или другие инструменты на ваш выбор.

  4. Python — для автоматизации прошивки и анализа результатов тестов. Библиотека pylink позволяет управлять J-Link программно.

  5. ARM GCC Toolchain — компилятор для ARM микроконтроллеров (arm-none-eabi-gcc).

Установка необходимых пакетов на Ubuntu/Debian:

# ARM toolchain
sudo apt-get install gcc-arm-none-eabi

# CMake
sudo apt-get install cmake

# Python и зависимости
sudo apt-get install python3 python3-pip
pip3 install pylink-square

Пример проекта с CI

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

Рассмотрим реальный пример из библиотеки runit — фреймворка для unit-тестирования на bare-metal системах:

runit/
├── .github/
│   ├── workflows/
│   │   └── build.yml           # Конфигурация CI
│   └── scripts/
│       ├── flashing.py         # Скрипт прошивки
│       └── units.py            # Скрипт запуска тестов
├── src/
│   ├── runit.h                 # Заголовочный файл библиотеки
│   └── runit.c                 # Реализация
├── examples/
│   └── f103re-cmake-baremetal-builtin/
│       ├── CMakeLists.txt      # Конфигурация сборки
│       ├── main.c              # Тесты для МК
│       ├── startup_stm32f103xe.s
│       └── STM32F103RETX_FLASH.ld
└── tst/
    └── selftest.c              # Тесты для Linux

GitHub Actions Workflow

Создаём CI из пяти этапов:

  1. Клонирование репозитория на сервер

  2. Конфигурация CMake

  3. Сборка проекта

  4. Прошивка микроконтроллера

  5. Запуск тестов на устройстве

Файл .github/workflows/build.yml:

name: Build Runit Selftest

on: [pull_request]

jobs:
  # Job 1: Тестирование на Linux (без железа)
  linux_build:
    runs-on: self-hosted

    steps:
      - uses: actions/checkout@v4

      - name: Configure and Build project
        run: |
          cmake -S . -B build
          cmake --build build

      - name: Run selftest
        run: ./build/runit-selftest

  # Job 2: Тестирование на STM32F103 (с реальным железом)
  stm32f103re_build:
    runs-on: self-hosted

    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 1

      - name: Configure and Build project
        run: |
          cmake -S examples/f103re-cmake-baremetal-builtin -B examples/f103re-cmake-baremetal-builtin/build
          cmake --build examples/f103re-cmake-baremetal-builtin/build

      - name: Flash firmware
        run: |
          python3 .github/scripts/flashing.py ${{ secrets.JLINK_SERIAL_CI_STM32F103RE }} STM32F103RE examples/f103re-cmake-baremetal-builtin/build/example_f103re.bin

      - name: Unit tests
        run: |
          python3 .github/scripts/units.py ${{ secrets.JLINK_SERIAL_CI_STM32F103RE }} STM32F103RE

Важные моменты:

  • runs-on: self-hosted — указывает использовать собственный runner, а не облачный сервер GitHub

  • secrets.JLINK_SERIAL_CI_STM32F103RE — секретная переменная с серийным номером J-Link программатора. Настраивается в Settings → Secrets → Actions вашего репозитория. Это защищает ваше устройство от несанкционированного доступа.

  • Два независимых job выполняются параллельно: один для Linux-версии библиотеки, другой для микроконтроллера.

Детальный разбор этапов

Этап 1: Linux-сборка (проверка кроссплатформенности)

Библиотека runit кроссплатформенная — работает как на микроконтроллерах, так и на обычных ОС. Поэтому первый job просто собирает и запускает тесты на Linux:

cmake -S . -B build
cmake --build build
./build/runit-selftest

Если исполняемый файл возвращает код выхода не равный 0, CI считается проваленным. Это стандартный подход для unit-тестов в Unix-системах.

Этап 2-5: Сборка, прошивка и тестирование на STM32

Теперь перейдём к самому интересному — автоматизированной прошивке и тестированию на реальном микроконтроллере:
1. Сборка проекта

cmake -S examples/f103re-cmake-baremetal-builtin -B examples/f103re-cmake-baremetal-builtin/build
cmake --build examples/f103re-cmake-baremetal-builtin/build

На этом этапе мы гарантируем, что проект собирается без ошибок. Если сборка упала — проблема локализована, и мы знаем, что изменения сломали компиляцию.

Бонус: бинарный файл можно сохранить в артефактах GitHub Actions и использовать для прошивки партии устройств или предоставить команде для тестирования без необходимости собирать локально.

2. Прошивка микроконтроллера

Используется Python-скрипт .github/scripts/flashing.py:

import sys, os
import pylink
from pylink import JLink

def flash_device_by_usb(jlink_serial: int, fw_file: str, mcu: str) -> None:
    jlink = pylink.JLink()
    jlink.open(serial_no=jlink_serial)

    if jlink.opened():
        jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
        jlink.connect(mcu)
        print(jlink.flash_file(fw_file, 0x08000000))
        jlink.reset(halt=False)

    jlink.close()

def main():
    try:
        jlink_serial = int(sys.argv[1].strip())
        mcu = sys.argv[2].strip()
        fw_file = os.path.abspath(sys.argv[3].strip())
        flash_device_by_usb(jlink_serial, fw_file, mcu)
    except Exception as e:
        print(f"Error: {e}")
        sys.exit(1)

if __name__ == "__main__":
    main()

Скрипт принимает три параметра:

  • Серийный номер J-Link программатора

  • Название MCU (например, STM32F103RE)

  • Путь к бинарному файлу прошивки

Если прошивка не удалась, скрипт возвращает код ошибки 1, и CI прерывается.

3. Запуск тестов и чтение результатов через RTT

Самая интересная часть — как получить результаты тестов с микроконтроллера?

SEGGER RTT — технология быстрой передачи данных

SEGGER RTT (Real-Time Transfer) — это технология двусторонней передачи данных между целевым устройством и хостом через отладочный интерфейс (SWD/JTAG). Разработана компанией SEGGER.

Преимущества RTT:

  • Высокая скорость — до 2 МБ/сек

  • Нет задержек — не блокирует выполнение программы

  • Не требует дополнительных ножек (как UART, например, или SWO) — использует существующий отладочный интерфейс. Так что даже если нет SWO это решение будет работать

  • Двусторонняя связь — можно не только читать данные, но и отправлять команды

Как это работает:

  1. На МК выделяется небольшой буфер в RAM (обычно 1-16 КБ)

  2. Код на МК пишет данные в этот буфер (SEGGER_RTT_printf())

  3. Программатор читает данные из буфера через SWD/JTAG

  4. Python-скрипт на хосте получает и анализирует эти данные

Недостаток RTT: Ограниченный размер буфера. Если логов слишком много и они не успевают считываться, произойдёт перезапись, и часть данных потеряется. Решение — увеличить размер буфера или оптимизировать вывод логов.

Python-скрипт для запуска тестов

Файл .github/scripts/units.py:

import sys, re, time
import pylink
from pylink import JLink

def remove_ansi_colors(text: str) -> str:
    """Удаляет ANSI-коды цветов из текста"""
    return re.sub(r"\x1b\[[0-9;]*m", "", text)

def run_tests_by_rtt(jlink: JLink, duration: float = 10.0) -> bool:
    has_error = False
    try:
        jlink.rtt_start()
        start_time = time.time()
        
        while True:
            elapsed = time.time() - start_time
            if elapsed >= duration:
                break
                
            response = jlink.rtt_read(0, 1024)
            if response:
                text = remove_ansi_colors(bytes(response).decode("utf-8", errors="ignore"))
                
                # Парсим результаты тестов
                for line in text.splitlines():
                    # Ищем строки с отчётами: "REPORT | File: ... | Passes: X | Failures: Y"
                    match = re.search(
                        r'REPORT\s*\|\s*File:\s*(.*?)\s*\|\s*Test case:\s*(.*?)\s*\|\s*Passes:\s*(\d+)\s*\|\s*Failures:\s*(\d+)',
                        line
                    )
                    if match:
                        passed = match.group(3)
                        failed = match.group(4)
                        print(f"Test result: {passed} passed, {failed} failed")
                        if failed != '0':
                            has_error = True
                    elif "All tests passed successfully!" in line:
                        has_error = False
                        print("All tests passed successfully!")
                    elif line.strip():
                        print(line)
                        
    finally:
        jlink.rtt_stop()
    
    return has_error

def main():
    jlink_serial = int(sys.argv[1].strip())
    mcu = sys.argv[2].strip()
    
    jlink = pylink.JLink()
    jlink.open(serial_no=jlink_serial)
    jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
    jlink.connect(mcu)
    
    has_error = run_tests_by_rtt(jlink, 10.0)
    
    jlink.close()
    
    if has_error:
        sys.exit(1)

if __name__ == "__main__":
    main()

Как это работает:

  1. Скрипт подключается к J-Link программатору

  2. Запускает RTT-соединение

  3. Микроконтроллер после сброса начинает выполнять тесты и выводить результаты через SEGGER_RTT_printf()

  4. Скрипт читает вывод в реальном времени (10 секунд)

  5. Парсит результаты по шаблону и определяет, прошли ли тесты

  6. Возвращает код ошибки, если есть проваленные тесты

Пример кода с тестами на МК

Файл examples/f103re-cmake-baremetal-builtin/main.c содержит самотестирование библиотеки runit:

#include <stm32f103xe.h>
#include "runit.h"

static size_t expected_failures_counter = 0;

#define SHOULD_FAIL(failing)      \
    printf("Expected failure: "); \
    expected_failures_counter++;  \
    failing

static void test_eq(void)
{
    runit_eq(12, 12);
    runit_eq(12.0f, 12U);
    SHOULD_FAIL(runit_eq(100, 1));  // Этот тест должен упасть
}

static void test_gt(void)
{
    runit_gt(100, 1);
    SHOULD_FAIL(runit_gt(1, 100));  // Этот тест должен упасть
}

static void test_fapprox(void)
{
    runit_fapprox(1.0f, 1.0f);
    runit_fapprox(1.0f, 1.000001f);
    SHOULD_FAIL(runit_fapprox(1.0f, 1.1f));  // Этот тест должен упасть
}

int main(void)
{
    test_eq();
    test_gt();
    test_fapprox();
    runit_report();  // Выводит итоговый отчёт
    
    if (expected_failures_counter != runit_counter_assert_failures)
        printf("Expected %u failures, but got %u\n", 
               expected_failures_counter, runit_counter_assert_failures);
    else
        printf("All tests passed successfully!\n");

    for (;;) {}  // Бесконечный цикл
    return 0;
}

Важно: Для вывода через RTT функция _write переопределена для использования SEGGER_RTT_PutChar(). Это позволяет использовать стандартный printf() в коде тестов, и весь вывод автоматически направляется в RTT-буфер.

Пример переопределения _write в файле syscalls.c:

#include "SEGGER_RTT.h"

__attribute__((weak)) int _write(int file, char* ptr, int len)
{
    for (int i = 0; i < len; i++)
    {
        SEGGER_RTT_PutChar(0, ptr[i]);
    }
    return len;
}

Атрибут weak позволяет при необходимости переопределить эту функцию в другом месте проекта.

Функция runit_report() выводит одну строку с итоговой статистикой выполненных тестов. Можно вызывать runit_report() несколько раз в разных местах программы — каждый вызов выведет отдельный отчёт с накопленной статистикой. Для сброса счётчиков между группами тестов нужно обнулить внутренние переменные библиотеки.

Вывод в RTT выглядит так:

REPORT | File: main.c:42 | Test case: main | Passes: 5 | Failures: 3
All tests passed successfully!

Python-скрипт парсит этот вывод и определяет результат.

Расширенные возможности тестирования

Стратегии организации тестов

Ваш проект может иметь несколько целей сборки:

  1. Продуктовая сборка — финальная прошивка для production без отладочного кода

  2. Тестовая сборка — специальная версия с unit-тестами библиотек и модулей

  3. Отладочная сборка — рабочая прошивка с флагом DEBUG, где модуль самотестирования включается условной компиляцией

Выбор подхода зависит от ваших потребностей и возможностей:

Вариант 1: Отдельный тестовый проект

# CMakeLists.txt для тестов
add_executable(firmware_tests
    tests/test_main.c
    tests/test_uart.c
    tests/test_modbus.c
    src/uart.c
    src/modbus.c
)

Вариант 2: Условная компиляция тестов

#ifdef DEBUG_TESTS
static void run_all_tests(void) {
    test_uart();
    test_modbus();
    test_eeprom();
    runit_report();
}
#endif

int main(void) {
    system_init();
    
    #ifdef DEBUG_TESTS
    // Тесты запускаются по команде через RTT
    if (check_rtt_command("run_tests")) {
        run_all_tests();
    }
    #endif
    
    // Основной код прошивки
    while(1) {
        main_loop();
    }
}

Лично я использую подход с флагом сборки и добавил возможность вызова тестов через команды по RTT. Это позволяет:

  • Не пересобирать прошивку для запуска тестов

  • Запускать тесты в любой момент на работающем устройстве

  • Тестировать конкретные модули по требованию

Тестирование протоколов и интерфейсов

Python-скрипт может взаимодействовать не только с микроконтроллером через RTT, но и тестировать боевую прошивку через реальные интерфейсы:

Пример: тестирование Modbus RTU

Устройство должно общаться по Modbus RTU. Подключаем его к серверу CI по соответствующему интерфейсу и запускаем Python-тесты:

import serial
from pymodbus.client import ModbusSerialClient

def test_modbus_valid_requests():
    """Проверка корректных запросов"""
    client = ModbusSerialClient(port='/dev/ttyUSB0', baudrate=9600)
    
    # Чтение регистров
    result = client.read_holding_registers(address=0, count=10, slave=1)
    assert not result.isError(), "Должны корректно читаться регистры"
    assert len(result.registers) == 10
    
    # Запись регистра
    result = client.write_register(address=0, value=100, slave=1)
    assert not result.isError(), "Должна быть возможность записи"
    
    # Проверка записи
    result = client.read_holding_registers(address=0, count=1, slave=1)
    assert result.registers[0] == 100, "Значение должно сохраниться"

def test_modbus_invalid_requests():
    """Проверка обработки некорректных запросов"""
    client = ModbusSerialClient(port='/dev/ttyUSB0', baudrate=9600)
    
    # Несуществующий адрес
    result = client.read_holding_registers(address=9999, count=1, slave=1)
    assert result.isError(), "Должна вернуться ошибка для несуществующего адреса"
    
    # Битые данные (неверный CRC)
    # Устройство должно игнорировать такие пакеты
    with serial.Serial('/dev/ttyUSB0', 9600) as ser:
        ser.write(b'\x01\x03\x00\x00\x00\x0A\xFF\xFF')  # Неверный CRC
        time.sleep(0.5)
        response = ser.read_all()
        assert len(response) == 0, "Битые пакеты должны игнорироваться"

if __name__ == "__main__":
    test_modbus_valid_requests()
    test_modbus_invalid_requests()
    print("All Modbus tests passed!")

Такие тесты проверяют:

  • Корректную обработку валидных данных

  • Правильную валидацию входных данных

  • Предсказуемое поведение при некорректных запросах

  • Соответствие спецификации протокола

Аналогично можно тестировать:

  • CAN-интерфейс — отправка/приём сообщений, обработка ошибок шины

  • Ethernet/TCP — установка соединений, обработка разрывов связи

  • I2C/SPI — взаимодействие с периферией

  • GPIO — проверка уровней сигналов, тайминги

  • Измерение производительности — время отклика, пропускная способность

Главный аргумент для внедрения CI

Для тех, кто не безразличен, но ленив:

Больше не нужно:

  • Уговаривать себя перетестировать всё после каждого изменения

  • Беспокоиться, что что-то сломалось, если вы не перетестировали

  • Помнить, какие модули зависят от изменённого кода

  • Тратить время на ручное тестирование одних и тех же сценариев

CI делает это за вас:

  • Перетестирует все сценарии автоматически

  • Точно укажет, где именно проблема

  • Запустится на каждом Pull Request

  • Добавили новый тест? Он будет выполняться каждый раз навсегда

Реальная экономия времени:
Внесли изменения в библиотеку работы с UART? CI автоматически прогонит:

  • Unit-тесты самой библиотеки

  • Интеграционные тесты с Modbus (который использует UART)

  • Тесты протокола связи

  • Проверку на утечки памяти

  • Валидацию тайминга

Всё это — без вашего участия, за минуты, с точным указанием проблемного места.

Пример из реального проекта:
В устройстве BMPLC автоматически тестируется библиотека работы с EEPROM (в локальной разработке тесты также можно запускать вручную через команды в RTT-интерфейсе). Набор тестов проверяет критические сценарии работы с памятью:

void run_eeprom_tests(void) {
    eeprom_partial_page_write_test();     // Корректность записи неполной страницы
    eeprom_size_limit_test();             // Защита от выхода за пределы памяти
    eeprom_multi_page_write_test();       // Многостраничная запись (несколько страниц за раз)
    eeprom_random_access_test();          // Произвольный доступ к разным адресам
    runit_report();
}

Эти тесты выявляют типичные проблемы:

  • Выход за пределы адресного пространства (32 КБ для AT24C256)

  • Ошибки при записи данных, больших размера страницы

  • Банальные проверки корректности записи и чтения данных

Важный момент: От постоянных прогонов CI тестовое устройство может израсходовать ресурс EEPROM (обычно 100 000 - 1 000 000 циклов записи). Аналогично Flash-память микроконтроллера деградирует от частых прошивок. Но это малая цена за уверенность в качестве кода — стоимость замены одного тестового устройства несопоставима с ценой ошибки в production.

Если тесты внезапно начинают падать:

  • Запускаем старую, проверенную версию прошивки → тесты проходят → память работает, проблема в новом коде

  • Запускаем старую версию → тесты не проходят → тестовое устройство выработало ресурс, заменяем его

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

Пошаговая инструкция по внедрению

Шаг 1: Подготовка репозитория

  1. Создайте репозиторий на GitHub

  2. Добавьте .github/workflows/build.yml с конфигурацией CI

  3. Создайте папку .github/scripts/ для Python-скриптов

Шаг 2: Настройка сервера

  1. Установите необходимые зависимости:

    sudo apt-get install gcc-arm-none-eabi cmake python3 python3-pip
    pip3 install pylink-square
    
  2. Скачайте J-Link Software с сайта SEGGER

  3. Зарегистрируйте GitHub Actions Runner:

    • Откройте Settings → Actions → Runners → New self-hosted runner

    • Следуйте инструкциям для вашей ОС

    • Запустите runner как сервис

Шаг 3: Подключение оборудования

  1. Подключите J-Link к серверу по USB

  2. Подключите целевое устройство к J-Link по SWD/JTAG

  3. Проверьте подключение: JLinkExeconnect → укажите MCU

  4. Узнайте серийный номер: $ lsusb -v или через утилиту JLinkExe

Шаг 4: Настройка секретов

  1. В репозитории: Settings → Secrets and variables → Actions → New repository secret

  2. Добавьте JLINK_SERIAL_CI с серийным номером программатора

Шаг 5: Добавление тестов

  1. Интегрируйте runit (или другой фреймворк для тестов) в ваш проект:

    git submodule add https://github.com/RoboticsHardwareSolutions/runit.git libs/runit
    
  2. Добавьте SEGGER RTT в проект (через CMake FetchContent или вручную)

  3. Напишите тесты в стиле runit:

    void test_my_function(void) {
        runit_eq(my_function(5), 25);
        runit_gt(my_function(10), 90);
    }
    
  4. В main() вызовите тесты и runit_report()

Шаг 6: Первый запуск

  1. Создайте Pull Request

  2. GitHub Actions автоматически запустит CI

  3. Проверьте логи выполнения

  4. При необходимости отладьте прогоните тесты локально теми же скриптами

Заключение

Автоматизация CI/CD для embedded-систем требует начальных усилий, но окупается многократно:

  • Ускорение разработки за счёт быстрой обратной связи

  • Защита от регрессий и повторных багов

  • Объективные метрики качества кода

  • Упрощение командной работы и онбординга

  • Уверенность в каждом релизе

Библиотека runit и описанный подход — это лишь отправная точка. Вы можете расширить систему тестирования под ваши нужды: добавить coverage-анализ, интеграцию с тестовыми стендами, автоматическое создание релизов и многое другое.

Начните с малого — автоматизируйте сборку и базовые тесты. Постепенно добавляйте новые проверки. И помните: каждый автоматический тест — это инвестиция в стабильность и скорость вашей разработки.

Полезные ссылки

Комментарии (1)


  1. almaz1c
    06.11.2025 11:26

    Мой job security"

    Повальное явление. Сколько повидал проектов без единой строчки комментария. Пустые readme. Коммиты с комментариями в стиле "error fix". Исправления багов без тестов.

    Делают все, чтобы увеличить свою незаменимость. Эгоизм и нулевая эмпатия.