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