← Blog Firmware

CI/CD dla firmware embedded: pipeline GitHub Actions + PlatformIO dla ESP32

Inżynierowie firmware przez dwie dekady patrzyli, jak ich koledzy od chmury cieszą się luksusami, których my nie mieliśmy – powtarzalnymi buildami, zautomatyzowanymi testami, wydaniami na jedno kliknięcie. Wymówki dla tej luki wyparowały. PlatformIO daje nam deterministyczne buildy w różnych zestawach narzędzi, GitHub Actions daje darmową moc obliczeniową i sensowny język przepływów pracy, a nowoczesne ekosystemy ESP32 i STM32 mają frameworki testów jednostkowych, które naprawdę działają. Ten artykuł to pipeline, który wdrażamy w każdym projekcie urządzeń podłączonych w FSS, wraz z YAML-em, który możesz przenieść bezpośrednio. Żadnego demoware. Żadnych skrótów, które działają tylko na laptopie oryginalnego autora.

Dlaczego firmware potrzebuje CI/CD bardziej, nie mniej

Argumenty za CI/CD dla firmware są silniejsze niż dla kodu chmurowego, nie słabsze. Trzy powody. Po pierwsze, wdrażanie poprawek błędów firmware jest kosztowne, więc wychwytywanie ich przed tagowaniem i wydaniem jest warte nieproporcjonalnie więcej. Po drugie, firmware jest dostarczany w macierzy rewizji płytek, wariantów czujników i SKU klientów, która eksploduje kombinatorycznie – żaden człowiek nie zbuduje ich wszystkich ręcznie w sposób niezawodny. Po trzecie, firmware integruje się ze sprzętem, który może zostać uszkodzony przez zły kod, co sprawia, że koszt regresji mierzy się w zwróconych sztukach, a nie w szybkim rollbacku.

Pipeline, który opisujemy poniżej, wychwycił problemy, które trafiłyby na produkcję: zmianę tablicy partycji, która zablokowała OTA na jednym wariancie płytki, ale nie na innym, przepełnienie stosu, które ujawniało się tylko przy włączonej niepowiązanej fladze funkcji, wygaśnięcie certyfikatu TLS, którego nikt nie zauważył, bo urządzenie działało dobrze aż do 2025-03-14. Żadnego z nich nie wychwycili ludzie czytający diffy. Wszystkie wychwyciła automatyzacja. Zwrot z tygodnia pracy nad pipeline'em mierzy się w miesiącach zaoszczędzonego gaszenia pożarów, a kumulacyjny efekt na tempie inżynierii trudno przecenić, gdy zespół nauczy się ufać zielonym checkom.

Struktura projektu

Projekty PlatformIO skalują się czysto, gdy wcześnie zobowiążesz się do kilku konwencji. Nasz układ referencyjny wygląda tak w każdym zleceniu klienta, z drobnymi wariacjami dla organizacji, które mają silne poglądy na monorepo:

firmware/
  platformio.ini          # środowiska, jedno na wariant płytki
  src/                    # kod aplikacji, niezależny od płytki
  lib/                    # biblioteki wewnętrzne, wersjonowane
  include/                # nagłówki publiczne
  test/                   # testy Unity, natywne i osadzone
    test_native/          # uruchamiane na hoście
    test_embedded/        # uruchamiane na sprzęcie
  hil/                    # skrypty hardware-in-the-loop
  scripts/                # pomocniki buildu, podpisywanie, pakowanie
  partitions/             # własne tablice partycji na wariant
  certs/                  # pakiety CA, nigdy klucze urządzeń

Plik platformio.ini deklaruje jedno środowisko na wysyłany wariant. Używamy środowiska bazowego z extends, aby plik był czytelny i unikać rozjazdu między wariantami:

[env]
framework = espidf
monitor_speed = 115200
build_flags = -Wall -Wextra -Werror
test_framework = unity

[env:esp32_devkit]
platform = espressif32@6.5.0
board = esp32dev

[env:esp32s3_v2]
platform = espressif32@6.5.0
board = esp32-s3-devkitc-1
board_build.partitions = partitions/v2_ota.csv

[env:stm32_l4_industrial]
platform = ststm32@17.3.0
board = nucleo_l476rg
framework = stm32cube

Przypinanie wersji platform nie jest opcjonalne. Płynne platform = espressif32 oznacza, że twój build jest powtarzalny do momentu, aż Espressif wyda wersję punktową, która psuje coś subtelnego. Przypnij, a potem aktualizuj rozważnie jako osobny PR. Traktujemy aktualizacje zestawu narzędzi jako własny zestaw zmian z własną walidacją HIL.

Przepływ pracy GitHub Actions

Nasz przepływ pracy najwyższego poziomu ma cztery zadania uruchamiane równolegle tam, gdzie to możliwe: lint, macierz buildów, testy natywne i testy osadzone. Piąte zadanie – podpisz i wydaj – uruchamia się tylko przy tagach. Każde zadanie agresywnie cache'uje zestaw narzędzi PlatformIO, bo zimne instalacje pochłaniają kilka minut na uruchomienie.

name: firmware-ci
on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install platformio cpplint
      - run: pio check --skip-packages --fail-on-defect=high
      - run: cpplint --recursive src/ lib/ include/

  build:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        env: [esp32_devkit, esp32s3_v2, stm32_l4_industrial]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - uses: actions/cache@v4
        with:
          path: |
            ~/.platformio/.cache
            ~/.platformio/packages
          key: pio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }}
      - run: pip install platformio
      - run: pio run -e ${{ matrix.env }}
      - uses: actions/upload-artifact@v4
        with:
          name: firmware-${{ matrix.env }}
          path: .pio/build/${{ matrix.env }}/firmware.bin
          retention-days: 30

Dwa szczegóły warte podkreślenia. Klucz cache zawiera hash pliku platformio.ini, więc podbicie wersji platformy automatycznie unieważnia cache. A fail-fast: false oznacza, że awaria na STM32 nie anuluje buildów ESP32 – dostajesz pełny obraz tego, co się zepsuło, zamiast musieć uruchamiać każdą płytkę po kolei.

Testy jednostkowe z Unity

Unity to właściwy wybór dla osadzonego C; Ceedling na jego wierzchu, jeśli chcesz mockowania i bardziej narzuconego układu. Wzorzec, który najbardziej się opłaca, to podział testów na test_native i test_embedded. Testy natywne uruchamiają się na runnerze CI wobec kodu skompilowanego na hoście z zastąpionymi peryferiami sprzętowymi. Testy osadzone wgrywają rzeczywistą płytkę.

// test/test_native/test_telemetry.c
#include <unity.h>
#include "telemetry.h"

void setUp(void) {}
void tearDown(void) {}

void test_telemetry_packs_temperature_correctly(void) {
    uint8_t buf[32];
    size_t len = telemetry_pack(buf, sizeof(buf), 23.4f, 1024);
    TEST_ASSERT_EQUAL(12, len);
    TEST_ASSERT_EQUAL_HEX8(0xA1, buf[0]);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_telemetry_packs_temperature_correctly);
    return UNITY_END();
}

Dąż do tego, aby testy natywne pokrywały parsery protokołów, maszyny stanów, pakowanie telemetrii, walidację poleceń i wszystko czysto funkcyjne. Cokolwiek dotyka peryferium, prymitywu RTOS lub rzeczywistego czasu, należy do testów osadzonych. Jeśli dopiero rozgrzewasz się do testowania świadomego RTOS, nasz przewodnik po FreeRTOS dla IoT omawia wzorce, które przetrwają w CI.

Testowanie hardware-in-the-loop

HIL to miejsce, w którym większość zespołów firmware się zatrzymuje, a jest to krok o najwyższej dźwigni. Konfiguracja: mała flota płytek referencyjnych podłączonych do samodzielnie hostowanego runnera, każda ze znanym-dobrym peryferium (prawdziwe czujniki, prawdziwe radia, czasem programowalne obciążenie). Zadanie CI wgrywa build, prowadzi scenariusz testowy i asertuje na wyjściu szeregowym, wiadomościach MQTT lub zmierzonych napięciach.

  hil:
    needs: build
    runs-on: [self-hosted, hil-bench]
    if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with: { name: firmware-esp32s3_v2, path: ./fw }
      - run: ./scripts/hil_flash.sh ./fw/firmware.bin /dev/ttyUSB0
      - run: pytest hil/ --board=esp32s3_v2 --junitxml=hil-results.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: hil-results, path: hil-results.xml }

Samodzielnie hostowane runnery na Raspberry Pi lub NUC są niedrogie i niezawodne, jeśli zdyscyplinujesz stanowisko: każda płytka na przełączanym hubie USB, aby CI mogło resetować zasilanie, żadnych ludzi dotykających kabli w godzinach pracy oraz test dymny uruchamiany co godzinę, aby potwierdzić, że samo stanowisko jest sprawne. Traktujemy HIL jako część QA i testowania, a nie jako narzędzie deweloperskie, z tymi samymi SLA.

Wersjonowanie semantyczne dla firmware

SemVer stosuje się do firmware z jednym niuansem: powierzchnią publiczną jest nie tylko API, ale także kompatybilność aktualizacji over-the-air. Nasza konwencja:

  • Patch – poprawki błędów, brak zmiany zachowania widocznej dla chmury lub aplikacji.
  • Minor – nowe funkcje, wstecznie kompatybilny schemat telemetrii i układ partycji OTA.
  • Major – zmiany łamiące telemetrię, schemat poleceń lub układ partycji. Wymaga skoordynowanego wydania chmury.

Wersja jest wpieczona w binarkę podczas buildu z tagu git, wyeksponowana w telemetrii i sprawdzana przez usługę OTA przed dostarczeniem. Urządzenie na 1.x nie zaakceptuje buildu 2.0, chyba że chmura wyraźnie mu na to zezwoli. Egzekwujemy to na warstwie manifestu, aby źle skonfigurowany rollout nie mógł przypadkiem zablokować podzbioru floty.

Podpisywanie binarek

Niepodpisany firmware to incydent bezpieczeństwa czekający, by się wydarzyć. ESP32 i nowoczesne STM32 obsługują bezpieczny rozruch z podpisanymi obrazami; jeśli go nie włączyłeś, to najwyższy zwrot z inwestycji w utwardzanie bezpieczeństwa, jaki możesz osiągnąć, a nasze opracowanie o bezpiecznym rozruchu szczegółowo omawia łańcuch zaufania.

Zadanie podpisywania działa za ręcznym zatwierdzeniem i wykorzystuje reguły ochrony środowisk GitHuba. Prywatny klucz podpisujący nigdy nie dotyka repozytorium – żyje w Azure Key Vault lub AWS KMS, a zadanie uwierzytelnia się przez OIDC, więc żadne długotrwałe poświadczenia chmurowe nie leżą w sekretach GitHuba.

  sign-and-release:
    needs: [build, hil]
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment: production-signing
    permissions:
      id-token: write
      contents: write
    steps:
      - uses: actions/download-artifact@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - run: ./scripts/sign_with_kv.sh firmware-*/firmware.bin
      - uses: softprops/action-gh-release@v2
        with:
          files: |
            firmware-*/firmware.bin
            firmware-*/firmware.bin.sig
            manifest.json

Integracja dostarczania OTA

Zadanie wydania przesyła na GitHub, ale kanoniczny artefakt OTA żyje w magazynie chmurowym z manifestem, który firmware urządzenia pobiera. Nasz schemat manifestu zawiera wersję, SHA-256, podpis, politykę rolloutu (procent kanarkowy, dozwolone grupy urządzeń) i minimalną kompatybilną wersję schematu chmury. Aktualizator po stronie urządzenia weryfikuje podpis przed zapisem na nieaktywną partycję OTA, następnie waliduje testem dymnym przy pierwszym rozruchu, zanim oznaczy partycję jako rozruchową.

Rollout na skalę bez blokowania urządzeń to osobna dyscyplina; nasze głębsze opracowanie o aktualizacjach firmware OTA na skalę omawia stopniowane rollouty, partycje A/B i semantykę rollbacku. Zadaniem pipeline'u CI jest jedynie wyprodukowanie podpisanego artefaktu z manifestem i czyste przekazanie go usłudze OTA.

Zarządzanie sekretami

Trzy kategorie sekretów dotykają pipeline'u firmware: klucze podpisujące, poświadczenia chmurowe do przesyłania OTA i materiał provisioningowy per-urządzenie. Pierwsze dwa należą do platformowego magazynu sekretów (GitHub Environments, z federacją OIDC do Azure lub AWS dla poświadczeń). Trzeci nigdy nie powinien być w CI w ogóle – klucze per-urządzenie są generowane na urządzeniu lub na stanowisku testu fabrycznego i nigdy go nie opuszczają.

Częstym antywzorcem jest wpiekanie jednego współdzielonego klucza we wszystkie sztuki „dla uproszczenia provisioningu”. Nie rób tego. Koszt poprawnego provisioningu per-urządzenie to jeden dodatkowy krok przy teście fabrycznym i kilkaset bajtów pamięci. Koszt zrobienia tego źle to kompromitacja całej floty, gdy jedna sztuka zostanie poddana inżynierii wstecznej. Właściwy wzorzec omawiamy w naszym przewodniku po najlepszych praktykach bezpieczeństwa IoT.

Wybór runnerów

Runnery hostowane przez GitHub są w porządku do lintu, buildu i testów natywnych. Są darmowe dla repozytoriów publicznych i niedrogie dla prywatnych, i są niezawodnie bezstanowe. Używaj ich do wszystkiego, co nie potrzebuje sprzętu.

Samodzielnie hostowane runnery są obowiązkowe dla HIL i przydatne dla dużych macierzy buildów. Uruchamiaj je na dedykowanym sprzęcie, nie na maszynach deweloperów. Izoluj je we własnym segmencie sieci. Automatycznie aktualizuj agenta runnera. I wyczyść przestrzeń roboczą między zadaniami – actions/checkout z clean: true nie wystarcza; uruchamiamy własne czyszczenie, które kasuje cache .pio między uruchomieniami, aby uniknąć błędów „działa we wtorek”.

Co to ci daje

Zespół firmware działający na tym pipeline dostarcza z pewnością. Pull requesty pokazują zielony check, który coś znaczy. Wydania są powtarzalne ze źródła. Problemy terenowe można przypisać bisekcją do otagowanych buildów. Nowi inżynierowie mogą zbudować firmware pierwszego dnia. Nic z tego nie jest egzotyczne – to standard, który zespoły chmurowe uznają za oczywistość od dekady. Przeniesienie tej dyscypliny na firmware to największy mnożnik produktywności, jaki widzieliśmy w projektach klientów.

Jeśli twój zespół firmware wciąż buduje wydania na laptopie jednego inżyniera, to twoje miejsce o najwyższej dźwigni, gdzie warto spędzić kolejne dwa tygodnie. Pomagamy zespołom produktowym postawić takie pipeline'y w ramach naszej praktyki DevOps, często obok szerszego zlecenia urządzeń podłączonych. Pipeline zwraca się za pierwszym razem, gdy wychwyci regresję, która trafiłaby na produkcję.