← Blog Firmware

CI/CD für Embedded-Firmware: GitHub-Actions-+-PlatformIO-Pipeline für den ESP32

Firmware-Ingenieure haben zwei Jahrzehnte lang zugesehen, wie ihre Cloud-Kollegen Annehmlichkeiten genossen, die wir nicht hatten – reproduzierbare Builds, automatisierte Tests, Releases auf Knopfdruck. Die Ausreden für diese Lücke haben sich verflüchtigt. PlatformIO gibt uns deterministische Builds über verschiedene Toolchains hinweg, GitHub Actions gibt uns kostenlose Rechenleistung und eine vernünftige Workflow-Sprache, und moderne ESP32- und STM32-Ökosysteme verfügen über Unit-Test-Frameworks, die tatsächlich funktionieren. Dieser Artikel ist die Pipeline, die wir bei FSS auf jedem Connected-Devices-Projekt einsetzen, mit dem YAML, das Sie direkt übernehmen können. Keine Demoware. Keine Abkürzungen, die nur auf dem Laptop des ursprünglichen Autors funktionieren.

Warum Firmware CI/CD mehr braucht, nicht weniger

Das Argument für CI/CD bei Firmware ist stärker als bei Cloud-Code, nicht schwächer. Drei Gründe. Erstens ist es teuer, Fehlerbehebungen für Firmware auszurollen, sodass das Abfangen von Fehlern vor dem Tag-and-Release überproportional wertvoll ist. Zweitens wird Firmware über eine Matrix aus Board-Revisionen, Sensorvarianten und Kunden-SKUs ausgeliefert, die kombinatorisch explodiert – kein Mensch kann sie alle zuverlässig von Hand bauen. Drittens interagiert Firmware mit Hardware, die durch schlechten Code beschädigt werden kann, was die Kosten einer Regression in zurückgesendeten Einheiten statt in einem schnellen Rollback bemisst.

Die unten beschriebene Pipeline hat Probleme abgefangen, die in die Produktion gelangt wären: eine Änderung der Partitionstabelle, die OTA auf einer Board-Variante lahmlegte, auf einer anderen aber nicht; ein Stack-Überlauf, der nur auftrat, wenn ein unabhängiges Feature-Flag aktiviert war; ein Ablauf eines TLS-Zertifikats, den niemand bemerkte, weil das Gerät bis zum 14.03.2025 einwandfrei funktionierte. Keines davon wurde von Menschen beim Lesen von Diffs abgefangen. Alle wurden durch Automatisierung abgefangen. Die Rendite einer Woche Pipeline-Arbeit bemisst sich in Monaten eingesparter Brandbekämpfung, und der Zinseszinseffekt auf die Entwicklungsgeschwindigkeit ist kaum zu überschätzen, sobald das Team lernt, grünen Häkchen zu vertrauen.

Projektstruktur

PlatformIO-Projekte skalieren sauber, wenn man sich früh auf einige Konventionen festlegt. Unser Referenz-Layout sieht bei jedem Kundenprojekt so aus, mit kleineren Abweichungen für Organisationen mit ausgeprägten Ansichten zu Monorepos:

firmware/
  platformio.ini          # environments, one per board variant
  src/                    # application code, board-agnostic
  lib/                    # internal libraries, versioned
  include/                # public headers
  test/                   # Unity tests, native and embedded
    test_native/          # runs on host
    test_embedded/        # runs on hardware
  hil/                    # hardware-in-the-loop scripts
  scripts/                # build helpers, signing, packaging
  partitions/             # custom partition tables per variant
  certs/                  # CA bundles, never device keys

Die platformio.ini deklariert ein Environment pro ausgelieferter Variante. Wir verwenden ein Basis-Environment mit extends, um die Datei lesbar zu halten und Abweichungen zwischen Varianten zu vermeiden:

[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

Das Anheften (Pinning) von Plattformversionen ist nicht optional. Ein frei laufendes platform = espressif32 bedeutet, dass Ihr Build reproduzierbar ist, bis Espressif eine Point-Version veröffentlicht, die etwas Subtiles bricht. Pinnen Sie, und upgraden Sie dann bewusst als eigenen PR. Wir behandeln Toolchain-Upgrades als eigenen Änderungssatz mit eigener HIL-Validierung.

Der GitHub-Actions-Workflow

Unser Top-Level-Workflow hat vier Jobs, die, wo möglich, parallel laufen: Lint, Build-Matrix, native Tests und Embedded-Tests. Ein fünfter Job – Signieren und Release – läuft nur bei Tags. Jeder Job cacht die PlatformIO-Toolchain aggressiv, weil Cold-Installs mehrere Minuten pro Durchlauf verschlingen.

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

Zwei Details sind hervorzuheben. Der Cache-Schlüssel enthält den Hash von platformio.ini, sodass ein Anheben der Plattformversion den Cache automatisch invalidiert. Und fail-fast: false bedeutet, dass ein Fehler beim STM32 die ESP32-Builds nicht abbricht – Sie erhalten das vollständige Bild dessen, was kaputtging, statt jedes Board nacheinander erneut ausführen zu müssen.

Unit-Tests mit Unity

Unity ist die richtige Wahl für Embedded-C; Ceedling darüber, wenn Sie Mocking und ein stärker vorgegebenes Layout wollen. Das Muster, das sich am meisten auszahlt, ist die Aufteilung der Tests in test_native und test_embedded. Native Tests laufen auf dem CI-Runner gegen host-kompilierten Code mit gestubbten Hardware-Peripherien. Embedded-Tests flashen ein echtes Board.

// 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();
}

Streben Sie an, dass native Tests Protokoll-Parser, Zustandsautomaten, Telemetrie-Packing, Befehlsvalidierung und alles rein Funktionale abdecken. Alles, was eine Peripherie, eine RTOS-Primitive oder echtes Timing berührt, gehört in Embedded-Tests. Wenn Sie sich noch an RTOS-fähiges Testen herantasten: Unsere Anleitung zu FreeRTOS für IoT behandelt Muster, die in der CI überleben.

Hardware-in-the-Loop-Tests

HIL ist der Punkt, an dem die meisten Firmware-Teams aufhören, und es ist der Schritt mit dem höchsten Hebel. Der Aufbau: eine kleine Flotte von Referenzboards, verdrahtet mit einem selbst gehosteten Runner, jedes mit einer bekannt-guten Peripherie (echte Sensoren, echte Funkmodule, manchmal eine programmierbare Last). Der CI-Job flasht den Build, treibt ein Testszenario an und prüft Aussagen über serielle Ausgaben, MQTT-Nachrichten oder gemessene Spannungen.

  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 }

Selbst gehostete Runner auf einem Raspberry Pi oder NUC sind günstig und zuverlässig, wenn Sie die Testbank disziplinieren: jedes Board an einem schaltbaren USB-Hub, damit die CI einen Power-Cycle durchführen kann, keine Menschen, die während der Arbeitszeit Kabel anfassen, und ein Smoke-Test, der stündlich läuft, um zu bestätigen, dass die Testbank selbst gesund ist. Wir behandeln HIL als Teil von QA und Testing und nicht als Entwicklerwerkzeug, mit denselben SLAs.

Semantische Versionierung für Firmware

SemVer gilt für Firmware mit einer Wendung: Die öffentliche Oberfläche ist nicht nur die API, sondern auch die Over-the-Air-Upgrade-Kompatibilität. Unsere Konvention:

  • Patch – Fehlerbehebungen, keine für Cloud oder App sichtbare Verhaltensänderung.
  • Minor – neue Funktionen, abwärtskompatibles Telemetrie-Schema und OTA-Partitionslayout.
  • Major – brechende Änderungen an Telemetrie, Befehlsschema oder Partitionslayout. Erfordert ein koordiniertes Cloud-Release.

Die Version wird zur Build-Zeit aus dem Git-Tag in das Binary eingebacken, in der Telemetrie offengelegt und vom OTA-Dienst vor der Auslieferung geprüft. Ein Gerät auf 1.x akzeptiert keinen 2.0-Build, es sei denn, die Cloud gibt es ausdrücklich frei. Wir erzwingen dies auf der Manifest-Ebene, damit ein fehlkonfiguriertes Rollout nicht versehentlich einen Teil der Flotte lahmlegt.

Signieren von Binaries

Unsignierte Firmware ist ein Sicherheitsvorfall, der nur darauf wartet zu passieren. Sowohl ESP32 als auch moderne STM32 unterstützen Secure Boot mit signierten Images; wenn Sie das nicht aktiviert haben, ist das die Sicherheitshärtung mit der höchsten Rendite, die Sie durchführen können, und unsere Referenz zu Secure Boot behandelt die Vertrauenskette im Detail.

Der Signier-Job liegt hinter einer manuellen Freigabe und nutzt die Environment-Protection-Regeln von GitHub. Der private Signierschlüssel berührt niemals das Repository – er lebt in Azure Key Vault oder AWS KMS, und der Job authentifiziert sich über OIDC, sodass keine langlebigen Cloud-Anmeldedaten in GitHub-Secrets liegen.

  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

Integration der OTA-Auslieferung

Der Release-Job lädt zu GitHub hoch, aber das kanonische OTA-Artefakt liegt im Cloud-Speicher mit einem Manifest, das die Geräte-Firmware abruft. Unser Manifest-Schema enthält die Version, den SHA-256, die Signatur, die Rollout-Richtlinie (Canary-Prozentsatz, erlaubte Gerätegruppen) und die minimal kompatible Cloud-Schema-Version. Der Updater auf Geräteseite verifiziert die Signatur, bevor er in die inaktive OTA-Partition schreibt, und validiert dann beim ersten Start mit einem Smoke-Test, bevor er die Partition als bootfähig markiert.

Ein Rollout im großen Maßstab, ohne Geräte lahmzulegen, ist eine eigene Disziplin; unser ausführlicherer Beitrag zu OTA-Firmware-Updates im großen Maßstab behandelt gestaffelte Rollouts, A/B-Partitionen und Rollback-Semantik. Die Aufgabe der CI-Pipeline ist lediglich, ein signiertes, mit Manifest versehenes Artefakt zu erzeugen und es sauber an den OTA-Dienst zu übergeben.

Secrets-Management

Drei Kategorien von Secrets berühren eine Firmware-Pipeline: Signierschlüssel, Cloud-Anmeldedaten für den OTA-Upload und gerätespezifisches Provisioning-Material. Die ersten beiden gehören in den Secret-Store der Plattform (GitHub Environments, mit OIDC-Federation zu Azure oder AWS für Anmeldedaten). Das dritte sollte überhaupt nicht in der CI liegen – gerätespezifische Schlüssel werden auf dem Gerät oder an der Werksteststation erzeugt und verlassen diese niemals.

Ein häufiges Antimuster besteht darin, einen einzigen gemeinsamen Schlüssel in alle Einheiten einzubacken, „um das Provisioning zu vereinfachen“. Tun Sie das nicht. Die Kosten korrekten gerätespezifischen Provisionings sind ein zusätzlicher Schritt im Werkstest und ein paar hundert Byte Speicher. Die Kosten, es falsch zu machen, sind eine flottenweite Kompromittierung, sobald eine Einheit reverse-engineert wird. Das richtige Muster behandeln wir in unserem Leitfaden zu IoT-Sicherheits-Best-Practices.

Runner-Wahl

GitHub-gehostete Runner sind für Lint, Build und native Tests in Ordnung. Sie sind für öffentliche Repos kostenlos und für private günstig, und sie sind zuverlässig zustandslos. Verwenden Sie sie für alles, was keine Hardware benötigt.

Selbst gehostete Runner sind für HIL zwingend und für große Build-Matrizen nützlich. Betreiben Sie sie auf dedizierter Hardware, nicht auf Entwicklermaschinen. Isolieren Sie sie in einem eigenen Netzwerksegment. Aktualisieren Sie den Runner-Agenten automatisch. Und leeren Sie den Workspace zwischen den Jobs – actions/checkout mit clean: true reicht nicht; wir führen ein eigenes Cleanup aus, das .pio-Caches zwischen den Durchläufen vernichtet, um „funktioniert dienstags“-Fehler zu vermeiden.

Was Ihnen das bringt

Ein Firmware-Team, das diese Pipeline betreibt, liefert mit Zuversicht aus. Pull Requests zeigen ein grünes Häkchen, das etwas bedeutet. Releases sind aus dem Quellcode reproduzierbar. Feldprobleme lassen sich gegen getaggte Builds bisektieren. Neue Ingenieure können die Firmware am ersten Tag bauen. Nichts davon ist exotisch – es ist das, was Cloud-Teams seit einem Jahrzehnt als selbstverständlich betrachten. Diese Disziplin in die Firmware zu bringen, ist der größte einzelne Produktivitätsmultiplikator, den wir in Kundenprojekten gesehen haben.

Wenn Ihr Firmware-Team Releases noch immer auf dem Laptop eines einzelnen Ingenieurs baut, ist das der Ort mit dem höchsten Hebel, um die nächsten zwei Wochen zu investieren. Wir helfen Produktteams, Pipelines wie diese aufzusetzen, im Rahmen unserer DevOps-Praxis, oft neben dem umfassenderen Connected-Devices-Projekt. Die Pipeline amortisiert sich beim ersten Mal, wenn sie eine Regression abfängt, die ausgeliefert worden wäre.