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

Помню, были времена, когда наш отдел состоял из нескольких разработчиков. Мы сидели на Windows. Прошивка собиралась в eclipse-based Atollic True Studio с какой-то своей системой сборки (к счастью, STM32CubeIDE миновала нас). Потом мы перешли на самописный make’file, и до поры до времени нас это устраивало.

Шло время, Whoosh рос и развивался. Мы решили, что помимо сборки нам стоило бы ещё запускать пару вспомогательных утилит. В списке используемых осей появились Linux и macOS, а сам процесс стало необходимо логировать, чтобы в случае чего понимать, где именно и что пошло не так.

В общем, процесс сборки прошивки разрастался и срочно нужно было его структурировать и делать нормально, а не…

 Тут хотя бы понятно почему всё упало.
Тут хотя бы понятно почему всё упало.

Чтобы собрать файл с прошивкой, в первую очередь нужны компилятор (в нашем случае это часть тулчейна) и make. Можно собирать его и одним только компилятором, но с make’ом этот процесс перестаёт быть самоистязанием. Нужен сам код, конечно же, а чтобы его писать — среда разработки. Большинство наших разработчиков используют VS Code — там есть несколько фич, которые для наших задач оказались очень удобными, расскажу о них чуть позже. Также можно использовать Eclipse — у него ряд преимуществ в плане отладки микроконтроллеров. Или даже Vim, если вам по душе хардкор. Привязку к конкретной IDE мы сознательно не делали, а ограничились стандартными инструментами: shell, make и Python.

Что такое компилятор и make

Все языки программирования делятся на компилируемые и интерпретируемые. В IoT-модуле используется микроконтроллер, а для программирования микроконтроллеров используются только компиляторы. В нашем случае мы используем язык Cи. Итак, прежде чем исполнить программу, нужно её скомпилировать — превратить человекочитаемый текст программы в машинные команды, исполняемые процессором. А make — это утилита, которая позволяет использовать специальные конфигурационные файлы для упрощения компиляции. А также, если вы компилируете большой проект, состоящий из множества файлов, то make позволяет не пересобирать каждый раз файлы, которые не были изменены.

Python, который дирижирует

Так как в преддверии перемен ядром всей сборки был make, то и всё остальное мы вызывали из него.

Какое-то время это вполне нормально работало. Однако добавить ещё что-нибудь в такую схему было уже сложно, да и сам make не особо рассчитан на то, чтобы запускать из него shell-процесс с python-скриптом внутри.

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

Детальнее изнутри так: скрипт на Python парсит входные аргументы, вызывает make и все необходимые утилиты и сам же логирует процесс сборки. Понимаю, что подобное решение — не истина в первой инстанции, но у нас оно прижилось (буду рад комментариям по поводу этой схемы и альтернативным решениям).

 Альтернативная версия, в которой удав съел антилопу гну.
Альтернативная версия, в которой удав съел антилопу гну.

Кстати, даже этого оказалось недостаточно, потому что нам хотелось всё-таки запускать Python в виртуальном окружении, дабы не мусорить в системе, а также использовать requirements. Ввиду того, что в разных ОС venv создаётся по-разному, то пришлось поверх питона положить shell-скрипт, который разруливает venv и кроссплатформенность.

#!/usr/bin/env bash

if [ "$OS_NAME" == "Darwin" ]; then
    VENV_NAME=virtualenv
    VENV_SRC=bin
    PYTHON_NAME=python3
elif [ "$OS_NAME" == "Linux" ]; then
    VENV_NAME=venv
    VENV_SRC=bin
    PYTHON_NAME=python3
elif [ "$OS_NAME" == "Windows_NT" ]; then
    VENV_NAME=venv
    VENV_SRC=Scripts
    PYTHON_NAME=python
fi

if [ -d ".venv" ]; then
    echo .venv exists
else
    echo there is no .venv
    $PYTHON_NAME -m $VENV_NAME .venv
    .venv/$VENV_SRC/$PYTHON_NAME -m pip install --upgrade pip
    .venv/$VENV_SRC/pip install -r requirements.txt
fi
 
python_build_script=builds/fw_builder.py
echo Python cmd: .venv/$VENV_SRC/$PYTHON_NAME $python_build_script $*
.venv/$VENV_SRC/$PYTHON_NAME $python_build_script $*

Грабли, которые поджидают

Если вдруг кто-то из вас, дорогие читатели, вдруг решится реализовать что-то подобное, то я сразу расскажу вам про те грабли, на которые наступил сам.

Во-первых, запуск чего-либо наподобие make напрямую из Python оказался не такой уж тривиальной задачей. Особенно, учитывая то, что нам хотелось, чтобы это была неблокирующая операция со своим таймаутом. А ещё хотелось на лету ловить вывод в stdout и stderr. На наше счастье, в python есть удобный модуль subprocess, в котором имеется весь необходимый функционал.

Во-вторых, с Windows не заскучаешь. В один день мы обнаружили, что иногда процесс сборки просто зависает, хотя по таймауту ничего не отваливается, никаких ошибок не выдаётся. Когда я понял, что это происходит только на Windows, стало ясно, куда копать. Выяснилось, что у объекта PIPE, который мы использовали в качестве буффера для стандартных потоков, есть ограничение по размеру, и оно зависит от ОС. На Windows — 4096 Байт. То есть когда пытаешься собрать на винде проект с большим количеством ворнингов, stderr в какой-то момент заполняется и происходит deadlock. Поэтому для захвата stderr пришлось использовать файл вместо PIPE.

Вот пример кода функции на Python для оборачивания make-команды. Чувствительные части я выпилил, но суть сохранилась, возможно, даже получится запустить через Ctrl+C/Ctrl+V.

import glob
from time import time
from tqdm import tqdm
from subprocess import PIPE, Popen


reg_ex = r'some_pattern'

def run_cmd(cmd, N=None):
    log.info(f'make cmd: {cmd}')
    t0 = time()
    info_str = '\n'
    bar_fmt = '{l_bar}{bar:20}{r_bar}{bar:-10b}' if N is not None else None
    with open('make_err.log', 'w', encoding="utf-8") as err_f:
        p = Popen(cmd, shell=True, stdout=PIPE, stderr=err_f)
        with tqdm(total=N, bar_format=bar_fmt) as pbar:
            with open('make_build.log', 'a', encoding="utf-8") as f:
                while p.poll() is None:
                    output = p.stdout.readline()  # type: ignore
                    if output:
                        output_str = output.decode().rstrip()
                        if re.match(reg_ex, output_str):
                            f.write(output_str + '\n')
                            pbar.set_postfix_str(output_str)
                            pbar.update()
                        else:
                            info_str += output_str + '\n'

    with open('make_err.log', 'r', encoding="utf-8") as err_f:
        std_err = err_f.readlines()

    if len(std_err) > MAX_ERR_LINES:
        stderr_message = 'Output is too long, please check make_err.log'
    elif len(std_err) == 0:
        stderr_message = ''
    else:
        stderr_message = ''.join(std_err)

    if stderr_message != '':
        if p.returncode != 0:
            log.error(stderr_message)
        else:
            log.warning(stderr_message)

    dur = time() - t0
    if info_str != '\n':
        log.info(info_str)
    log.info(f'execution duration {dur:.1f} sec')

    for file in glob.glob(r'debug/*.bin'):
        log.info(f'binary file {file}')

    return p.returncode
  
Что происходит внутри функции run_cmd

reg_ex — паттерн, по которому можно отловить вывод от нормально скомпилированного файла и отделить его от всего остального. ПеременнаяN нужна, если вы заранее знаете сколько файлов придётся скомпилировать, она используется внутри прогресс-бара, если подать её на вход функции, то в процессе сборки можно видеть сколько из скольких файлов скомпилировалось — мелочь, а приятно. ЕслиNне указать, то в прогресс-баре будет просто счётчик. Кстати, получить это число можно, если перед сборкой запустить утилиту для получения compilation database (compile_commands.json), наподобие такой. А потом уже из этого json’а получить информацию и количестве компилируемых файлов. Дальше при помощи Popen отдельным процессом вызываем команду make с необходимыми аргументами и poll’им её, попутно проверяя, что она выдаёт в stdout (тут нам как раз-таки и нужен паттерн reg_ex), чтобы подсунуть это в прогресс-бар и залогировать. А то, что получаем в stderr , пишем в файл (снова спасибо Windows). Принтуем в консоль всё, что осталось и проверяем итоговый бинарный файл с прошивкой.

Небольшая ремарка про VSCode. Там есть удобная штука —  таски. Документация весьма подробная, но всё равно покажу, как у нас это получилось.

{
    "version": "2.0.0",
    "windows": {
        "options": {
            "shell": {
                "executable": "cmd.exe",
                "args": [
                    "/C", "sh"
                ]
            }
        }
    },
    "problemMatcher": "$gcc",

    "tasks": [
        {
            "label": "❌ Clean",
            "type": "shell",
            "command": "./builder.sh",
            "args": ["clean"],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        },
        {
            "label": "⛏️? | Build: app-prd",
            "type": "shell",
            "command": "./builder.sh",
            "args": ["build", "app_prd"],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

Выше показан пример конфигурационного json'а (.vscode/tasks.json), по нему видно, что у нас вызывается не make напрямую, а builder.sh. А аргументы, указанные в таске, передаются вплоть до функции run_cmd, которую мы разобрали выше.

Так, нажимая Ctrl+Shift+B, получаете выпадающее меню, как на картинке ниже. Клик — и побежала сборка. Даже не нужно вспоминать, что именно вбить в терминал, чтобы собрать прошивку. В примере я оставил пару вариантов, на деле их у нас около десятка, так что функционал с тасками приходится кстати.

Так происходила сборка до перехода с чистого make на связку python-make и после. Обе гифки ускорены в 10 раз, так что не думайте, что на деле всё так быстро.

 “Информации, получаемой из Матрицы, гораздо больше, чем ты можешь расшифровать. Ты привыкнешь к этому. Я уже даже не вижу код. Я вижу блондинку, брюнетку, рыжую.”
Информации, получаемой из Матрицы, гораздо больше, чем ты можешь расшифровать. Ты привыкнешь к этому. Я уже даже не вижу код. Я вижу блондинку, брюнетку, рыжую.
 Выглядит, конечно, уже не так атмосферно, но прогресс-бар прямо доставляет.
Выглядит, конечно, уже не так атмосферно, но прогресс-бар прямо доставляет.

Хорошо, мы реализовали создание venv на разных платформах, выстроили процесс сборки с логированием и прочими плюшками. Но это всё равно требует предустановленного компилятора и самого Python. А тут существует, например, риск различия версий у разработчиков. Да и в целом хочется как-то это всё упаковать. Но не в пакет же и не в сумку. Точно, в контейнер! Что же, попробуем пойти в docker.

Docker, который не смог

За базу возьмём Ubuntu, где есть около последний Python, поставим туда компилятор — и вот он наш образ. Вроде всё, можно запускать контейнер… Или не всё? А собирать-то что будем? Значит, перед тем как запускать контейнер, нужно подготовить папку с проектом (в ней должны быть все файлы, необходимые для сборки прошивки) и сделать из неё docker volume. Ну или сделать bind mount при запуске контейнера.

Если вкратце, в docker есть два основных способа работы с директорией, расположенной на хосте:

  • Bind mounts — это когда папка по-прежнему размещается на хосте, но синхронизируется с контейнером посредством самого докера. Создается так называемый anonymous volume и все изменения в этой папке, сделанные на хосте, отражаются в контейнере, и наоборот. Такой подход удобен во всех смыслах, кроме скорости (на Windows и Mac сборка проекта в разы медленнее, на Linux — скорость такая же как и при использовании volumes).

  • Volumes — это когда создается отдельная сущность персистентного хранилища, которое присоединяется к контейнеру и позволяет сохранять данные даже после удаления контейнера, или использовать одни и те же данные в разных контейнерах. Проблема в том, что в volume данные с хоста можно перенести только при помощи docker cp. В таком случае нужно либо работать над проектом непосредственно в контейнере, либо каждый раз синхронизировать данные между хостом и volume’ом. Впрочем, есть один проект, где попытались автоматизировать эту синхронизацию http://docker-sync.io/, но как будто бы игра не стоит свеч.

Казалось бы, что может пойти не так?

Небольшое лирическое отступление про скорость сборки. На Windows полная сборка нашего проекта без использования docker в среднем занимает минуту с небольшим. В то время как на Linux и на macOS это несколько секунд… Несколько секунд, Карл!

Изначально мы работали на Windows и уже смирились с таким порядком вещей. К тому же обычно собираешь прошивку целиком только когда вносишь какие-то глобальные изменения. А потом, работая над какой-либо фичей, меняешь несколько файлов, и make позволяет тебе пересобрать только их. Это сильно ускоряет процесс, вместо пары сотен файлов компилируется несколько, что занимает всё те же пару секунд. Поэтому в повседневной работе над проектом разница между Windows и остальными ОС не так уж и заметна.

Но ведь в docker у нас Ubuntu, так что всё в порядке. Или не в порядке… Приведу список возможных раскладов по скоростям сборки в docker для Windows, так как это основная используемая ОС (ну и на Linux всё работает быстро, как с docker, так и без него).

Bind Mount

Volume

Local (no docker)

Linux

10сек

—//—

—//—

Windows

~640сек

20сек

~90сек

Основная проблема заключается в том, что для работы docker’а на Windows в качестве бэкенда используется WSL, и всё веселье начинается, когда несколько сотен файлов, которые необходимо скомпилировать, лежат в файловой системе винды, но при этом сам компилятор находится в контейнере. Поэтому если весь проект скопипастить прямо в контейнер, то скорость приемлемая даже на винде. А вот если использовать Bind Mount, который, видимо, использует какую-то постоянную синхронизацию между файловыми системами у себя внутри, то становится понятно, откуда берутся 10 минут на сборку одной прошивки…

А теперь давайте посмотрим какие варианты костылей решений мы имеем:

  • Разработчики, использующие VSCode, могут вести разработку прямо в контейнере https://code.visualstudio.com/docs/devcontainers/containers. И это в каком-то смысле решает проблему с синхронизацией, поскольку позволяет работать с кодом проекта прямо в контейнере, к которому присоединен отдельный volume, и где проект и компилируется. Но это только для VSCode и требует отдельной настройки (хотя вроде и несложной), плюс пока непонятно как в такой ситуации работать с git’ом.

  • Ещё для Windows есть вариант перенести проект непосредственно в WSL, и делать тот же bind mount. В таком случае он должен работать значительно быстрее. То есть контейнер нужно запускать из WSL и при этом делать bind mount директории, которую мы перенесли туда же. Но чтобы пользоваться git’ом снова нужно заморочиться. Например, для gitkraken, который мы используем в качестве Git-клиента, можно установить отдельную версию для WSL (он умеет работать с директорией внутри WSL, но непредсказуемо https://help.gitkraken.com/gitkraken-client/windows-subsystem-for-linux/). В целом, это вариант, потому что для его реализации нужен только WSL2.

  • Также удалось реализовать некое подобие синхронизации через rsync между папкой с проектом в Linux и на Windows.

    docker run -t -d --name firmware -v \\\\wsl.localhost\\Ubuntu\\home\\faruk\\my_project:/my_project firmware
    rsync -ar --exclude .git --exclude .vscode --delete ~/my_project/ /mnt/c/Users/my_project/

    Хотя и с нюансами, но работает. Даже чуть быстрее, чем сборка локально (~26сек сборка + по несколько секунд на перенос туда-сюда).

 Docker на Windows  vs  Docker на Linux
Docker на Windows vs Docker на Linux

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

У нас есть IoT-модули как на чипах ST, так и на NXP, спасибо ковиду и чипогеддону. И потенциально они могут быть на любых чипах, и на любых модификациях чипов, и на одних и тех же чипах, но с изменённой распиновкой. В общем, зоопарк ещё тот, но это было единственно правильным решением, к которому рано или поздно должны прийти все крупные программно-аппаратные решения — слой приложения абстрагирован от железа, на котором он исполняется. Однако это тема для другой статьи о том, как мы уживаем 50+ модификаций IoT-прошивки в одном репозитории и почему иначе невозможно.

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

Опять же, если для повседневной работы можно смириться со скоростью сборки на Windows, то собрать релиз (несколько десятков прошивок), ну, в общем, да…

 Разработчик собирает релиз на Windows.
Разработчик собирает релиз на Windows.

Впрочем, docker нам всё-таки сильно пригодился, потому что мы пошли в CI/CD, а там без него никуда. Эта тема тоже тянет на отдельную статью, но если в двух словах… Например, у нас есть gitlab-проект со всеми файлами, необходимыми для сборки нашей прошивки. Если настроить gitlab-ci.yml чтобы в качестве образа для runner’а он использовал наш образ с Python и компилятором, а в качестве основной команды (т.е. параметра script) выполнял ./builder.sh build all, то при запуске пайплайна (например, push в master или при простановке тега) мы получим все необходимые прошивки в качестве артефактов. А там уже можно делать с ними всё что угодно: заливать в облако, тестировать и прочее. А — автоматизация.

Однако пока оставим про запас вариант с использованием docker в качестве основного инструмента для сборки. Тем более, что по мере всех экспериментов с Linux и контейнерами, нам пришла очередная идея:

А что, если не пытаться стандартизировать и упаковывать всё необходимое для сборки, а просто посадить всех разработчиков на одну машину?

По идее, получится некий референсный компьютер, на котором установлено всё необходимое. Если мы своим отделом захотим перейти на новую версию компилятора, не нужно просить каждого разработчика, достаточно сменить версию на этом одном компьютере. Назвали мы это Build Server (да-да, нейминг может немного путать).

Сервер, который собирает

Этот путь мы проходили, почти не имея конкретного опыта, поэтому сделали, как получилось. Будем очень рады комментариям. А вообще, если гуглить про удалённую сборку именно в рамках embedded, то ничего толком и не найдётся. Поначалу нас это смутило, но руки так и чесались попробовать.

Идея сделать отдельный сервер для сборки — совсем не новая. Забегая вперёд, наличие такого сервера дало нам набор преимуществ:

  • реализовали не только удалённую сборку, но и удалённую отладку (в контексте embedded — это не новое, но кажется, что у нас получилось то, что хорошо решает наши задачи);

  • если что-то при сборке идёт не так, всегда можно дополнительно прогнать процесс на сервере и убедиться, что проблема не в чём-то, связанном конкретно с твоим ПК (особенно актуально, если ты на Windows… Ладно-ладно, я не хейтер винды, просто так сложилось);

  • удобно иметь рабочий сервер, на нём, например, живёт наш внутренний сервисный телеграм-бот и крутится контейнер для сбора метрик;

  • в случае чего, можно даже с личного ноутбука (при наличии доступов) подключиться по SSH к серверу и оперативно что-то пофиксить и раскатить.

Мы попросили наших DevOps организовать нам отдельный сервер, а в целях безопасности сразу сделать так, чтобы подключиться к нему можно было только из офисной подсети или через отдельный VPN. Так мы получили машину с Ubuntu, последней версией arm-none-eabi тулчейна, Python и возможностью подключаться по SSH с рабочего ноутбука. Дальше стояла задача сделать отдельный доступ к этой машине всем разработчикам нашего отдела, чтобы у них были свои учётки и директории. А чтобы не мучиться и не создавать всё это вручную, решено было сразу пойти в автоматизацию управления конфигурациями, серверами и пр. Поэтому я обратился к гуглу с запросом «Ansible для чайников за 0,5 секунд»… Полдня спустя у меня получилось нечто подобное (файл bs_config.yaml):

- hosts: localhost
  become: true
  vars:
    bs_users:
      developerone:
        pass: 'strongestpasswordever'
        ssh_key: ssh-rsa blaBlalba
      
      developertwo:
        pass: 'secondstrongestpassword'
        ssh_key: ssh-rsa BlablaBla
  tasks:
    - name: debug
      debug:
        msg: "{{ item }} : {{ bs_users[item]['pass'] }} - {{ bs_users[item]['ssh_key'] }}"
      with_items: "{{ bs_users.keys() | list }}"
  
    - name: Create user
      user:
        name: "{{ item }}"
        password: "{{ bs_users[item]['pass'] }}"
        groups:
        - docker
        - sudo
        state: present
        system: no
        createhome: yes
      with_items: "{{ bs_users.keys() | list }}"

    - name: Set authorized key
      ansible.posix.authorized_key:
        user: "{{ item }}"
        key: "{{ bs_users[item]['ssh_key'] }}"
        state: present
        exclusive: true
      with_items: "{{ bs_users.keys() | list }}"

Теперь достаточно попросить разработчиков сделать ssh-ключи, добавить их в .yaml файл и запустить playbook на билд-сервере при помощи ansible-playbook bs_config.yaml.

В целом, ничего сложного, однако, когда делаешь это впервые и не имеешь девопсового бэкграунда, работа немного похожа на магию. Можно в один конфиг и одной командой настроить целый сервер или даже целую кучу серверов. По сравнению с написанием кода на сях, который надо отдельно компилировать, отдельно заливать на МК, чтобы светодиодом поморгать, ansible-playbook config.yaml — это вингардиум левиоса, не меньше.

Осталось только разобраться с Git, но тут уж точно ничего сложного. Если не считать танцев с бубном, чтобы на Windows (опять же, говорю вам, я не хейтер) сделать SSH Agent Forwarding. Вкратце, он позволяет, используя один и тот же SSH-ключ, переходить с одного удалённого подключения на другое. В контексте нашей истории он достаточно удобен для использования с Git.

Пример. Разработчик из нашего отдела сгенерировал и передал мне public key, я добавил его на Build Server при помощи ansible. Теперь у него есть доступ. Но чтобы работать там с кодом проекта, ему нужно сделать git clone. Тут на помощь приходит agent forwarding. Если разработчик включит его на своём рабочем ноуте, добавит тот же public key в свою учётку в gitlab, он сможет без проблем сделать git clone. В противном случае ему нужно будет либо сделать gitlab login, либо создать ещё один ключ, но уже на Build Server и уже его добавить к себе в gitlab. Но это детали, на возможность работы с сервером они не влияют, только на удобство.

Ещё пару слов о VSCode, потому что и здесь он оказывается удобным. В VSCode есть такая вещь, как remote extension для SSH. Деталей про внутрянку не знаю, но на практике разработчик не чувствует разницы при работе над проектом на локальном компьютере или удалённо. То же окно, тот же функционал, даже расширения те же (только их нужно включить отдельно для remote).

 Найдите 5 отличий.
Найдите 5 отличий.

Отладка, которая удалённая

Что ж, со сборкой прошивки вроде бы разобрались, но конечная цель — не просто собрать прошивку, а запустить её на IoT-модуле, убедиться, что всё работает корректно, и при необходимости отдебажить.

Если вам показалось, что отдебажить — это завуалированное ругательство

При написании ПО для микроконтроллеров (впрочем, это применимо к написанию любого ПО), есть процесс отладки. Во время него, используя специальный программатор, подключенный к IoT модулю, прошивка заливается в память микроконтроллера. А после сам микроконтроллер запускается в режиме дебага, который позволяет ставить его на паузу, смотреть значение переменных, стек вызовов и так далее. Словом, позволяет посмотреть, что происходит внутри. Это критически необходимый процесс при разработке ПО для embedded-систем.

Пишешь код, локально собираешь файл прошивки, при помощи программатора, физически подключённого к твоему ноутбуку, заливаешь прошивку в память и дебажишь. Даже доступ в интернет не нужен (пока не вылезет ошибка и не придётся идти на Stack Overflow).

Как же быть, если прошивка собрана удалённо на машине, к которой нет физического доступа? Или если IoT с конкретным самокатом, на котором нужно отладиться, находится в другом городе?

Чтобы разобраться с этим, нужно глубже копнуть в то, как происходит дебаг. В нашем случае для отладки используется arm-none-eabi-gdb — это часть тулчейна для разработки под микроконтроллеры с архитектурой arm.

Немного про GDB

GDB — это GNU Debugger для программ, собранных при помощи GCC. Это также и фреймворк для дебага, поддерживающий сторонние фронтенды и специфичные для разных устройств бэкенды. Также у GDB есть опция удалённой отладки.

Чтобы пояснить про удалённую отладку, процитирую Вики:

При удалённой отладке GDB запускается на одной машине, а отлаживаемая программа запускается на другой. Связь по специальному протоколу через последовательный порт или TCP/IP. Протокол взаимодействия с отладчиком специфичен для GDB, но исходные коды необходимых подпрограмм включены в архив отладчика. Как альтернатива, на целевой платформе может быть запущена использующая тот же протокол программа gdbserver из состава пакета GDB, исполняющая низкоуровневые функции, такие как установка точек останова и доступа к регистрам и памяти.

На микроконтроллерах, таких как ARM Cortex M, совсем непрактично запускать gdbserver. Обычно эти микроконтроллеры предоставляют хардварную поддержку для установки breakpoint’ов, stepping’а и так далее. Это можно сделать с помощью специальных выводов (pin — ножки). У arm’овских чипов для этого есть SWD или JTAG. Однако в этом случае требуется специальное устройство, которое служило бы мостом между gdbserver и микроконтроллером. Для этого используются программаторы, например, STLink, J-Link, BlackMagic и другие. Обычно для каждого программатора существует своя утилита. Но есть и универсальные open source (и не только) решения, которые могут работать с разными программаторами, например, OpenOCD.

Снова Вики:

В соответствии с идеологией ведущих разработчиков FSF, GDB вместо собственного графического пользовательского интерфейса предоставляет возможность подключения к внешним IDE, управляющим графическим оболочкам либо использовать стандартный консольный текстовый интерфейс.

Да, можно использовать gdb через консоль и дебажить прошивку через текстовый вывод. Но не думаю, что у вас это нормально получится. А если всё-таки получится, отметьте, пожалуйста, картинки со светофорами...

В VSCode, например, для взаимодействия между gdb и разработчиком (так, чтобы проставлять breakpoint’ы мышкой прямо в коде, а не писать отдельную команду в консоль), используется расширение cortex-debug.

Таким образом, удалённая отладка микроконтроллеров вполне возможна, и у нас встаёт лишь вопрос выбора программатора и утилиты под него. Так как в нашем зоопарке есть и ST’шные чипы, и NXP, то STLink нам не подходит. Помимо него есть также весьма популярный J-Link, и с ним прямо из коробки дружат оба используемых нами чипа, но у J-Link довольно занимательная реализация удаленного дебага, поэтому его мы оставим как план Б. Также есть Black Magic Probe. Проект открытый, комьюнити живое, примеров достаточно, ещё и поддерживает все необходимые чипы. Вот его и попробуем.

Тут мы пошли чуть дальше и изготовили для сервисных и отладочных нужд две сотни кастомных программаторов, форкнули репозиторий BMP и добавили поддержку всех камней, которые используем в разработке. Получилось очень удобное, не привязанное к вендору решение, железо и софт которого мы умеем кастомизировать под свои нужды. Может быть, расскажем об этом как-нибудь чуть подробнее.

https://github.com/compuphase/Black-Magic-Probe-Book

BMP уже включает в себя gdbserver, при этом сам программатор, подключённый к хосту, выглядит как serial port. То есть нам достаточно на стороне gdb клиента использовать этот порт для подключения.

Для начала сделаем простой proof of concept и попробуем подебажить IoT через локальную сеть. Нам понадобятся: ноутбук со средой разработки и компилятором, при помощи которого мы будем собирать прошивку; собственно сам IoT-модуль; Black Magic Probe, подключённый к IoT; компьютер, можно одноплатник (мы использовали OrangePi), к которому будет подключаться Black Magic; и можно ещё отдельный роутер для удобства.

Чтобы всё заработало, нужно определить по какому адресу в сети находится одноплатник. На самом одноплатнике, BMP, подключенный по USB, будет определяться как serial port, поэтому еще нужно сделать так, чтобы он был виден извне. А для этого можно использовать например ser2net. Итак, на своём ноутбуке делаем make build, затем запускаем gdb, подключаемся к таргету по адресу {IP одноплатника}:{порт ser2net}, и дальше можем делать с МК на IoT’е в другом конце комнаты halt, flash, reset и прочие прелести дебага.

Ниже пример конфигурационного файла для ser2net, который позволяет настроить проброс BMP в порты 3000 и 3001 (один из портов, как раз и есть gdbserver, а второй служит вспомогательным интерфейсом для подключения к uart’у программатора):

%YAML 1.1
---
# This is a ser2net configuration file, tailored to be rather
# simple.
#
# Find detailed documentation in ser2net.yaml(5)
# A fully featured configuration file is in
# /usr/share/doc/ser2net/examples/ser2net.yaml.gz
#
# If you find your configuration more useful than this very simple
# one, please submit it as a bugreport

define: &banner \r\nser2net port \p device \d [\B] (Debian GNU/Linux)\r\n\r\n

connection: &con0192
    accepter: tcp,3000
    enable: on
    options:
      banner: *banner
      kickolduser: true
      telnet-brk-on-sync: true
    connector: serialdev,
              /dev/bmp_gdb,
              115200n81,local nobreak

connection: &con1192
    accepter: tcp,3001
    enable: on
    options:
      banner: *banner
      kickolduser: true
      telnet-brk-on-sync: true
    connector: serialdev,
              /dev/bmp_serial,
              115200n81,local nobreak

Вот мы и получили некий отладочный узел, или, как мы его назвали, Debug Node. Он состоит из одноплатника с выходом в интернет, к которому подключён BMP, в свою очередь подключённый к IoT. Так как нет большого смысла отлаживаться удалённо, когда IoT находится в соседней комнате, вариант с отдельным роутером нужно масштабировать и придумать решение, которое бы позволило делать то же самое, но при условии, что одноплатник вместе с IoT подключены к какой-то неизвестной нам сети. И для этого нам нужна своя отдельная виртуальная сеть, то есть VPN.

На горизонте мелькнул хвостом Tailscale, и мы решили дать ему шанс. Кому любопытно узнать этого зверя получше, советую почитать самим, а то получится испорченный телефон. Я лишь опишу, что у нас получилось.

В первую очередь нам необходим управляющий сервер. У Tailscale есть готовое решение и бесплатный Tier. Но также есть open source реализация control server’а для Tailscale, называется Headscale.

Занимательный факт

This project is not associated with Tailscale Inc. However, one of the active maintainers for Headscale is employed by Tailscale and he is allowed to spend work hours contributing to the project. Contributions from this maintainer are reviewed by other maintainers.

Развёртывание Headscale мы отдали девопсам, а на выходе получили веб-интерфейс и возможность по специальному ключу подключать к сети клиентов, в нашем случае Debug Node (то есть одноплатники):
tailscale up --login-server <https://ourserver.com/> --accept-dns=true --auth-key=ourkeyfromheadscale

 Так выглядит страничка веб-интерфейса с ключами для подключения клиентов.
Так выглядит страничка веб-интерфейса с ключами для подключения клиентов.
 А так выглядит уже подключенное устройство с именем, группой и адресом.
А так выглядит уже подключенное устройство с именем, группой и адресом.

Так мы получили возможность вводить в свою сеть любое устройство в любой точке мира и иметь доступ к нему по конкретному IP-адресу. Кстати, применяем это не только для своих отладочных узлов, но и для тестовых стендов. Такие стенды используются нами при производстве новых IoT-модулей. Обычно стенд включает в себя различную хардварную обвязку, а также одноплатник, что позволяет нам иметь к нему защищённое подключение, даже если мы отправили его в Китай и используем при тестировании модулей, только что сошедших с конвейера.

Для удобства создадим мастер-образ для OrangePi, который превратит любую новую апельсинку из магазина в основу нашей Debug Node. Возьмём новый одноплатник и зальём на него мастер-образ, который запустит на нём Tailscale-клиент и таким образом получит свой адрес в нашей сети. Затем внесём этот адрес в специальный конфиг-файл. С помощью этого файла и знакомого нам Ansible мы сможем легко настроить все ноды одновременно.

Файл inventory.yaml со списком нод и их параметрами.

---

debug_nodes:
  hosts:
    100.64.0.1:
      ansible_user: "dn"
      vars:
        name: 0207e6ba6c28
        id: 1
        comment:
        region: MOW

    100.64.0.11:
      ansible_user: "dn"
      vars:
        name: 0207b8238ff8
        id: 2
        comment:
        region: MOW

Файл nodes_config.yaml со списком действий, которые необходимо выполнить для настройки каждой ноды.

---
- hosts: debug_nodes
  become: true
  vars:
    dn_users:
      - "{{ ansible_user }}"
  tasks:
    - name: Set authorized key for admin
      ansible.posix.authorized_key:
        user: "{{ ansible_user }}"
        key: ssh-rsa someadminsshkey
        state: present
        exclusive: true

    - name: Create list of users
      command: awk -F{{':'}} '$3 >= 1000 && $1 != "nobody" {print $1}' /etc/passwd
      changed_when: false
      register: curr_users

    - name: Determine users to be removed
      set_fact:
        remove_users: "{{ curr_users.stdout_lines | difference(dn_users) }}"

    - name: Remove user accounts
      user:
        name: "{{ item }}"
        state: absent
        remove: true
      with_items: "{{ remove_users }}"

    - name: Copy file with rules
      copy:
        src: dn_files/99-usb-bmp.rules
        dest: /etc/udev/rules.d/99-usb-bmp.rules

    - name: Restart udev
      shell: udevadm control --reload-rules && udevadm trigger

    - name: Install ser2net
      apt:
        name: ser2net
        state: present

    - name: Copy file ser2net config
      copy:
        src: dn_files/ser2net.yaml
        dest: /etc/ser2net.yaml

    - name: Start ser2net
      service:
        name: ser2net
        state: started

    - name: Add ser2net restart cmd to cron
      shell: |
        echo "@reboot sleep 10 && sudo service ser2net restart" >> ser2net-restart-cron
        crontab ser2net-restart-cron
        rm ser2net-restart-cron
        crontab -l

    - name: Set a hostname
      hostname:
        name: "{{ ansible_user }}-{{ hostvars[inventory_hostname]['vars']['id'] }}-{{ hostvars[inventory_hostname]['vars']['name'] }}"

    - name: Unconditionally reboot the machine with all defaults
      reboot:

Магия происходит, когда в терминале делаешь:

ansible-playbook -i inventory.yaml nodes_config.yaml

Теперь мы можем отправлять наши Debug Node в любой сервисный центр, где есть wifi, подключать к самокатам и удалённо отлаживать на них прошивку. Работать это будет так же, как и в нашем proof of concept, но с использованием Tailscale-сети.

Ещё немного грабель

Казалось бы, всё работает — сборка, отладка. Однако и здесь, к сожалению, не обошлось без нюансов. Дело в том, что локально файл прошивки заливается на IoT буквально за пару секунд. Весит он немного, да и скорость передачи получается достаточной. Ну и, собственно, так как пинг минимальный, то и время реакции на gdb-команды — совсем небольшое, даже не обращаешь на это внимания.

Но что же происходит, когда мы пытаемся залить прошивку на МК и отладить его удалённо?

Визуализируем: разработчик сидит в офисе в Москве за рабочим ноутбуком, у него открыт VSCode, при помощи которого он подключён к Build Server’у. BS, к слову, может находиться в любом уголке планеты. Там у него открыт проект с кодом прошивки IoT, и, допустим, лежит уже скомпилированный бинарник. Разработчик нажимает F5 на своём ноутбуке, и эта же команда исполняется на Build Server. При этом на нём запускается gdb-клиент, который пытается подключиться к gdb-серверу по адресу Debug Node в сети Tailscale. Причём, порт используется виртуальный, который на самом деле является serial портом, прокинутым при помощи ser2net от BMP, подключённого к OrangePi по USB.

 Я пытаюсь объяснить коллегам как отлаживаться на Debug Node’ах
Я пытаюсь объяснить коллегам как отлаживаться на Debug Node’ах

Когда мы впервые попробовали запустить этот процесс, оказалось, что одна только загрузка прошивки в микроконтроллер занимает пару минут…

Здесь ключевой момент — пинг. Дело в том, что gdb-протокол подразумевает подтверждение прихода пакетов. Когда клиент что-то отправляет серверу (или наоборот), в обратную сторону идёт ответ (ack), и только после этого следует новая отправка. При этом файл прошивки передаётся не целиком, а блоками. То есть, по сути, размер блока определяет то, на сколько частей будет разбит файл прошивки. Следовательно, количество частей, умноженное минимум на два (каждая передача с клиента на сервер сопровождается ответом OK от сервера) и умноженное на пинг между ними, будет временем, необходимым на загрузку прошивки в микроконтроллер при удалённой отладке.

Напрашивается вопрос: как же тогда определяется размер блока? Как только происходит коннект между клиентом и сервером, им нужно договориться и понять, кто на что способен, какие функции поддерживаются и так далее. В том числе и максимальный размер пакетов, которыми они обмениваются. Для этого используется qSupported, в ответ на него сервер говорит, какой размер пакета в байтах может принять. Если говорить конкретно про BMP, то, по сути, это параметр, который определяется в прошивке самого отладчика — #define GDB_PACKET_BUFFER_SIZE 1024U, а его максимальное значение обусловлено размером свободной оперативной памяти отладчика.

Изначально у нас был установлен дефолтный для проекта размер буфера в 1кБ, а при прошивке размером примерно 300кБ и пинге в несколько сотен миллисекунд неудивительно, что загрузка прошивки занимала столько времени. Чтобы значительно ускорить этот процесс, достаточно просто увеличить размер этого буфера при условии, что на отладчике достаточно оперативной памяти (а с нашим кастомным отладчиком размер этого буфера можно увеличить до 32кБ). Однако мы не сразу пришли к этому, и нам даже пришлось отлаживать отладчик.

 Когда бага в дебаге, то помочь может только дебаг дебагера
Когда бага в дебаге, то помочь может только дебаг дебагера

Вместо заключения

Это было легендарное приключение. Мы пожелали структурировать процесс сборки прошивки, а в итоге смогли полностью его переделать, попутно сделав в базовом виде CI/CD, запустив отдельный сервер для сборки (что, как выяснилось, редкость для embedded) и реализовав удалённую (насколько это возможно) отладку. Получилась не просто задачка, а целый эпик, и если честно, у меня он один из самых любимых. Надеюсь, читать всё это вам было так же увлекательно, как мне всё это делать. С нетерпением жду в комментариях ;)

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


  1. alexhott
    28.06.2024 11:54

    Надо в серию запускать и продавать пока пиндосы не присвоили

    ЗЫ: а чисто теоретически можно сделать так чтоб все самокаты разом взяли и поехали куда глаза глядят?


    1. faruk_yussuf Автор
      28.06.2024 11:54

      Куда глаза глядят всё-таки не получится. Органы управления самоката так устроены, что сам он не решает куда ему ехать - решает пользователь. Самокат может лишь сопротивляться (снижать скорость), ну или вообще не ехать (если случилась поломка).
      Хотя я как-то встречал похожее решение у коллег, но сомневаюсь, что оно пойдёт дальше концепта, по крайней мере в рамках кикшеринга.


  1. Indemsys
    28.06.2024 11:54
    +1

    Сколько .с файлов в проекте?
    Что-то странный разброс времени компиляции под разными ОС



    1. faruk_yussuf Автор
      28.06.2024 11:54
      +1

      Чуть больше трёх сотен...


      1. Indemsys
        28.06.2024 11:54

        На такое количество файлов ушло бы 9 сек в IAR.


        1. faruk_yussuf Автор
          28.06.2024 11:54
          +1

          Ну вот у нас на линуксе и на маке получается примерно в пределах 10 секунд на полную сборку одной прошивки. Бегло почитал про IAR, боюсь при такой стоимости лицензии, проще действительно пересесть всем отделом на линукс или мак)
          Впрочем, может я не знаю каких-то деталей.
          Я так понимаю, у вас был какой-то опыт с IAR? Можете поделиться вкратце?


          1. Indemsys
            28.06.2024 11:54

            Как я использую J-Link в IAR писал здесь - https://habr.com/ru/articles/574088/

            Ваш проект довольно маленький, видимо там нет RTOS. Но если бы была, то в IAR очень удобные add-on-ы для отладки разных RTOS. Позволяют наблюдать состояние любых объектов RTOS.
            У вас микроконтролеры поддерживающие трассировку. Трассировка на порядок сокращает время поиска багов и исследование работы периферии. Но она хороша только в J-Link в IAR под Windows. А тут вы взяли и при таком роскошном бизнесе сэкономили буквально на спичках.


            1. faruk_yussuf Автор
              28.06.2024 11:54
              +1

              У нас FreeRTOS.

              Ознакомлюсь с вашей статьей, спасибо.


        1. aabzel
          28.06.2024 11:54
          +1

          Компилятор и компоновщик Iar (а) по хорошему тоже надо вызывать из Makefile ов.


  1. ToJIka4
    28.06.2024 11:54
    +1

    Zephyr и ESP-IDF живут так, что питон над CMake над Make (или что-то другое). Почти всё необходимое для разработки, отладки и сборки есть, вызывается простыми командами. Легко тромбуется в контейнер. Там есть что подчерпнуть.

    Единственное, что приходится делать самому - сервер OTA прошивок.


    1. aabzel
      28.06.2024 11:54

      Zephyr Project по умолчанию использует не Make a ninjia.


  1. aabzel
    28.06.2024 11:54

    Devops это хорошо.

    Вот только как программисту микроконтроллеров объяснить начальнику - схемотехнику, что нужно все это фреймворкостроительство?


    1. faruk_yussuf Автор
      28.06.2024 11:54
      +1

      Думаю, достаточно будет показать ему парочку заклинаний) Только нужно быть аккуратнее, вдруг ему понравится и он бросит схемотехнику и уйдёт в DevOps)

      А вообще, развести плату - это ведь лишь пол дела. Важно же ещё как она будет поддерживаться и эксплуатироваться. И, конечно, это зависит от спектра обязанностей конкретного начальника, но минимальное погружение в детали всё-таки должно присутствовать. И если для тех сотрудников, кто занимается написанием прошивки, наличие CICD экономит уйму времени, то почему бы его не внедрить?)


  1. aabzel
    28.06.2024 11:54

    У Вас в телематических платах есть интерфейс командной строки поверх UART?

    С cli(шкой) можно найти причину любого бага, если это не глухой зависон.


    1. faruk_yussuf Автор
      28.06.2024 11:54

      Да, у нас есть свой cli, правда он сделан через usb cdc. Очень удобная штука и мы её активно развиваем. Вполне вероятно, как-нибудь напишем об этом отдельно.


      1. aabzel
        28.06.2024 11:54

        Вообще usb это слишком сложный интерфейс для того, чтобы гонять поток shell.

        Если зависнет что- то из многочисленных зависимостей usb, то cli отрубится.

        Отладочную cli надо запускать именно на uart, так как в uart нечему ломаться и cli будет всегда живая.