LoRa Basic Modem auf dem SAMD21G18 mit PlatformIO
Semtech's LoRa Basic Modem (LBM / SWL2001) ist der aktuelle Stand der Technik für LoRaWAN-Endgeräte: MAC 1.0.4, vollständige Duty-Cycle- und ADR-Verwaltung, Grundlage für die LoRa-Alliance-Zertifizierung. Und es ist der Stack, den Semtech selbst für die Zertifizierung des SX1262 einsetzt — das ist bei CE- und LoRaWAN-Zertifizierung kein unwichtiges Argument. sx126x-Arduino hat bekannte Grenzen: MAC 1.0.2, zwei Bugs im Join-Handling, kein echtes Class C. RadioLib ist ausgezeichnet für direkten Radio-Zugriff — aber kein vollständiger LoRaWAN-Stack. LBM schliesst diese Lücke.
Die Zielplattform ist kein Eval-Kit: ein eigenes Board auf Basis des SAMD21G18, das wir selbst entwickelt und inhouse bestückt haben — SX1262-Referenzschaltung nach Semtech-Datenblatt, Prototyp aus dem eigenen Reflow-Ofen. Es wird die Basis für unsere Produktions-Designs. PlatformIO als Build-System, da Semtechs eigene HAL-Beispiele auf STM32/Keil/CMake zielen.
Was folgt, ist der Portierungsweg — mit allen Stolpersteinen.
LBM ist keine Arduino-Library
LBM liefert weder eine library.json noch eine src/-Struktur, die PlatformIO automatisch erkennt. Die einzige funktionierende Lösung: ein pre:-Script, das alle LBM-Quelldateien und Include-Pfade manuell in die Build-Umgebung injiziert.
Import("env")
from pathlib import Path
LBM = Path(env["PROJECT_DIR"]).parent / "libs" / "SWL2001" / "lbm_lib"
CORE = LBM / "smtc_modem_core"
HAL = Path(env["PROJECT_DIR"]).parent / "libs" / "lbm_samd21" / "src"
env.Append(CPPPATH=[
str(LBM / "smtc_modem_api"),
str(LBM / "smtc_modem_hal"),
str(CORE / "lr1mac" / "src"),
str(CORE / "radio_drivers" / "sx126x_driver" / "src"),
str(HAL),
# ... weitere LBM-interne Pfade
])
lbm_srcs = [
"smtc_modem_core/smtc_modem.c",
"smtc_modem_core/lr1mac/src/lr1mac_core.c",
"smtc_modem_core/lr1mac/src/lr1mac_class_c/lr1mac_class_c.c",
"smtc_modem_core/smtc_modem_test.c", # auch ohne Test-Mode erforderlich
# ... ca. 30 Dateien insgesamt
]
for src in lbm_srcs:
env.Append(PIOBUILDFILES=[
env.Object(
target=str(Path("$BUILD_DIR") / "lbm" / Path(src).stem) + ".o",
source=str(LBM / src),
)
])
Die HAL-Dateien (smtc_modem_hal.cpp, sx126x_hal.cpp, ral_sx126x_bsp.cpp) werden im selben Script als C++-Objekte kompiliert — nur so können sie Arduino-APIs wie SPI1, millis() und attachInterrupt() verwenden.
Build-Flags: Jedes Detail zählt
build_flags =
-DREGION_EU_868
-DSX126X ; NICHT -DSX1262 — kollidiert mit RadioLib's Klassenname
-DNB_OF_STACK=1 ; beide Schreibweisen nötig — LBM nutzt sie intern
-DNUMBER_OF_STACKS=1
-DADD_CLASS_C ; NICHT -DLBM_CLASS_C=1 — LBM prüft #ifdef, nicht #if
-DSOFTWARE_MODEM_CRYPTO
-DSMTC_MODEM_TRACE=1
-DMODEM_HAL_UNIT_TEST=0
Drei Fallen, die still zu falschen Binaries führen:
DSX1262kollidiert mitRadioLib'sgleichnamiger Klasse — undefiniertes Verhalten beim Linken.DLBM_CLASS_C=1definiert ein C-Symbol, trifft aber kein einziges#ifdef ADD_CLASS_Cim LBM-Source. Class C wird lautlos wegkompiliert.- Fehlender Stack-Count-Define: Fehlt einer der beiden Defines, bricht die Initialisierung intern ab — ohne klare Fehlermeldung.
Das HAL — drei Dateien, viel Detailarbeit
TC3: 1-ms-Timer für MAC-Events
LBM benötigt einen Hardware-Timer für RX-Fenster, Duty-Cycle-Scheduling und ADR. Auf dem SAMD21 ist TC3 im Arduino-Kontext frei. GCLK0 (48 MHz) durch Prescaler 64 ergibt 750 kHz — CC0 = 749 produziert exakt 1 ms Overflow. Ein Software-Zähler streckt das auf die Minuten-Perioden, die LBM intern plant.
static void _tc3_init(void) {
GCLK->CLKCTRL.reg = GCLK_CLKCTRL_ID_TCC2_TC3 |
GCLK_CLKCTRL_GEN_GCLK0 |
GCLK_CLKCTRL_CLKEN;
while (GCLK->STATUS.bit.SYNCBUSY);
TC3->COUNT16.CTRLA.reg = TC_CTRLA_SWRST;
while (TC3->COUNT16.STATUS.bit.SYNCBUSY);
TC3->COUNT16.CTRLA.reg = TC_CTRLA_MODE_COUNT16 |
TC_CTRLA_WAVEGEN_MFRQ |
TC_CTRLA_PRESCALER_DIV64;
TC3->COUNT16.CC[0].reg = 749; // 750 kHz / 750 = 1 ms
TC3->COUNT16.INTENSET.reg = TC_INTENSET_OVF;
NVIC_SetPriority(TC3_IRQn, 3);
NVIC_EnableIRQ(TC3_IRQn);
TC3->COUNT16.CTRLA.reg |= TC_CTRLA_ENABLE;
}
static volatile uint32_t _tc3_ticks_remaining = 0;
static void (*_tc3_callback)(void *) = nullptr;
static void *_tc3_context = nullptr;
extern "C" void TC3_Handler(void) {
TC3->COUNT16.INTFLAG.reg = TC_INTFLAG_OVF;
if (_tc3_ticks_remaining == 0) return;
if (--_tc3_ticks_remaining == 0 && _tc3_callback)
_tc3_callback(_tc3_context);
}
void smtc_modem_hal_start_timer(const uint32_t ms,
void (*cb)(void *), void *ctx) {
_tc3_init();
NVIC_DisableIRQ(TC3_IRQn);
_tc3_callback = cb;
_tc3_context = ctx;
_tc3_ticks_remaining = ms ? ms : 1;
NVIC_EnableIRQ(TC3_IRQn);
}
SPI1.begin() — der stille Killer
Wer einen expliziten SPIClass& übergibt — sei es an RadioLib oder direkt an den LBM SPI-HAL — bekommt intern initInterface = false gesetzt. Der Stack ruft spi->begin() nie selbst auf. Alle SPI-Reads liefern 0xFF, findChip() gibt -2 zurück, ohne weitere Erklärung.
void RadioControllerLBM::init(void) {
pinMode(Config::SX_NSS_PIN, OUTPUT); digitalWrite(Config::SX_NSS_PIN, HIGH);
pinMode(Config::SX_RESET_PIN, OUTPUT); digitalWrite(Config::SX_RESET_PIN, HIGH);
pinMode(Config::SX_BUSY_PIN, INPUT);
SPI1.begin(); // muss manuell vor smtc_modem_init() aufgerufen werden
smtc_modem_init(on_modem_event);
// DevEUI / JoinEUI / NwkKey setzen...
}
NVM-Kontexte: das Row-Erase-Problem
Der SAMD21 löscht Flash immer zeilenweise — eine Row umfasst 4 × 256 B = 1 KB. LBM speichert vier Kontexte (Modem, Keys, LoRaWAN-Stack, Secure Element), je eine Page. Naives Löschen einer Row würde die anderen drei Kontexte vernichten. Die Lösung: Shadow-Buffer der gesamten Row im RAM, Ziel-Page patchen, Row löschen, alle vier Pages zurückschreiben.
static uint8_t _nvm_row_shadow[FLASH_ROW_SIZE]; // 1 KB RAM-Buffer
void smtc_modem_hal_context_store(const modem_context_type_t ctx_type,
uint32_t offset,
const uint8_t *buffer, const uint32_t size) {
uint32_t row_base = FLASH_SIZE - FLASH_ROW_SIZE; // letzte 1 KB
memcpy(_nvm_row_shadow, (const uint8_t *)row_base, FLASH_ROW_SIZE);
uint32_t page_off = (uint32_t)_ctx_to_page(ctx_type) * FLASH_PAGE_SIZE + offset;
memcpy(_nvm_row_shadow + page_off, buffer, size);
_nvm_erase_row(row_base);
for (uint32_t p = 0; p < 4; p++)
_nvm_write_page(row_base + p * FLASH_PAGE_SIZE,
_nvm_row_shadow + p * FLASH_PAGE_SIZE, FLASH_PAGE_SIZE);
}
millis() stoppt im Deep Sleep
LBMs Duty-Cycle-Manager basiert auf smtc_modem_hal_get_time_in_ms(). Auf dem SAMD21 läuft millis() über SysTick — der stoppt im STANDBY-Modus. Ohne Kompensation denkt LBM nach dem Aufwachen, keine Zeit sei vergangen, und berechnet das Duty-Cycle-Budget falsch.
static uint32_t _time_offset_ms = 0;
void smtc_modem_hal_add_time_offset(uint32_t ms) { _time_offset_ms += ms; }
uint32_t smtc_modem_hal_get_time_in_ms(void) { return millis() + _time_offset_ms; }
RadioControllerLBM::sleep() addiert die tatsächliche Schlafzeit vor dem STANDBY-Eintritt.
Join-Loop: einmal aufrufen, LBM das Retry überlassen
Eine verbreitete Falle: smtc_modem_join_network() in einer eigenen Retry-Schleife aufzurufen. LBM gibt beim zweiten Aufruf SMTC_MODEM_RC_BUSY zurück und loggt intern "already join" — ohne dass etwas passiert. Einmal aufrufen, smtc_modem_run_engine() in einer Schleife drehen, JOINFAIL-Events zählen.
mtc_modem_join_network(0); // genau einmal — LBM plant alle Retries selbst
while (millis() - t0 < JOIN_MAX_RETRIES * JOIN_TIMEOUT_MS) {
smtc_modem_run_engine();
if (ev_joined) return STATUS_OK;
if (ev_join_fail) {
ev_join_fail = false;
if (++fails >= JOIN_MAX_RETRIES) break;
// LBM kümmert sich um Backoff (22 s -> 152 s -> ...) — einfach weiterdrehen
}
}
ChirpStack: das automatische Class-C-Switching
Wer ein LoRaWAN-1.0.4-Geräteprofil mit "allow class C" aktiviert hat, erlebt eine Überraschung: ChirpStack schaltet das Gerät automatisch auf CLASS_C um — ab v4.16.2 nach dem ersten Uplink, ab v4.18.0 bereits beim Join. LBM sendet in 1.0.x kein DeviceModeInd (das kam erst mit 1.1.x); ChirpStack entscheidet also einseitig.
Das Problem: LBM implementiert strikte Class-A-RX-Fenster. Downlinks mit immediately-Timing, die ChirpStack für CLASS_C-Geräte sendet, kommen in einem Class-A-RX1/RX2-Fenster nie an. Murata und RadioLib haben das jahrelang maskiert — durch zufällig breitere Empfangsfenster.
Die Lösung: Für Class-A-Geräte ein Profil ohne "allow class C" verwenden. Class-C-Geräte smtc_modem_set_class(0, SMTC_MODEM_CLASS_C) erst nach erfolgreichem Join aufrufen — davor gibt die API still SMTC_MODEM_RC_FAIL zurück.
Ergebnis
Erster OTA-Join erfolgreich. RSSI −50 dBm, SNR 15 dB. Der Stack läuft stabil, die nächste Stufe ist die Integration in die Produktions-Firmware.
Der HAL sind drei Dateien, rund 400 Zeilen C++. Die eigentliche Arbeit steckt in den Stellen, die schweigen: ein falscher Define, ein vergessenes SPI1.begin(), ein Row-Erase ohne Shadow-Buffer. Genau das ist der Unterschied zwischen einer Woche und einem Monat.
Die HAL-Portierung für Arduino/PlatformIO werden wir möglicherweise auf GitHub veröffentlichen — eine saubere LBM-Basis für den SAMD21 existiert bisher nicht öffentlich, und der Nutzen für die Community wäre real. Ob wir uns damit auch die Maintenance-Last einhandeln wollen, ist noch offen.