LoRa Deep Dive

Chirp Spread Spectrum · SX127x · SX126x · LoRaWAN MAC · Register-Level
SX1276/RFM95 · SX1262/RA-01SC · ESP-IDF SPI // bare-register C, no RadioLib // EU868 · US915

Chirp & Cipher

LoRa is a chirp-spread-spectrum (CSS) PHY: each symbol is one linear frequency sweep carrying SF bits across 2^SF chips. LoRaWAN is the AES-secured star-network MAC layer built on top of that PHY. Everything on this page is register-level, bare C -- no RadioLib, no arduino-LoRa -- because the point is to see exactly what goes over SPI.

Two chips, two philosophies

The SX127x (RFM95 module; SX1276/77/78 silicon, Semtech datasheet Rev. 0.0 May 2013) exposes a flat register-poke SPI interface -- one address byte with the R/W bit in bit 7, then data. The SX126x (SX1262 silicon, RA-01SC module; datasheet Rev. 1.1 Dec 2017 / Rev. 1.2 Jun 2019) replaces that with a command/opcode interface gated by a BUSY pin, adds a mandatory image-calibration step, cuts typical RX current from ~11 mA to ~4.6 mA, and pushes TX to +22 dBm versus +20 dBm. Register/opcode detail lives in SX127x Regs and SX126x Cmds; the head-to-head is in 127x vs 126x.

a lora node, signal path
┌──────────┐          SPI bus          ┌───────────────────────┐     ┌───────────────────┐     ┌─────────┐
  ESP32     MOSI · MISO · SCK · CS     SX127x / SX126x            matching net /         antenna 
 host MCU ├───────────────────────▶│   radio transceiver    ├────▶│  RF frontend       ├────▶│  50Ω    
└──────────┘ + RESET · DIO0/1 · BUSY └───────────────────────┘     └───────────────────┘     └─────────┘
                                        chirp modulation happens entirely inside this chip --
                                        the MCU only ever sees register/command bytes and raw payload bytes
How to read this Waveform math is in CSS Waveform, the TX bit-chain in Coding Chain. Wiring and register/opcode reference for each chip live in SX127x Regs, SX126x Cmds, and ESP32 Wire. Building LoRaWAN rather than raw P2P: skip to LoRaWAN MAC. Debugging a dead link: start at Gotchas.

Chirp Spread Spectrum

LoRa modulates data onto chirps: signals whose instantaneous frequency ramps linearly across the channel bandwidth B. A base up-chirp sweeps from −B/2 to +B/2; a down-chirp does the reverse. The spreading factor SF ∈ {7…12} (SX126x also allows SF5/SF6) sets the number of chips per symbol as N = 2^SF, and each symbol carries SF raw bits.

Symbol = cyclically-shifted chirp

A symbol of value s ∈ {0,…,N−1} is a cyclically-shifted up-chirp: the sweep starts at frequency f0 = (s·B/N − B/2), rises at B/N per chip until it hits the Nyquist edge +B/2, then folds back to −B/2 at chip n_fold = N − s and continues to the end of the symbol. Where the fold-back lands is where the symbol value is encoded.

base up-chirp, symbol s=0
+B/2
  0
-B/2
     └──────────────────────────────────────────────▶ time, 0 → Tsym (N chips)

shifted symbol s, fold-back encodes the value
+B/2                                                                                      
  0                  
-B/2              
     └────────────────▲───────────────────────────────▶ time
                n_fold = N − s -- chip index where frequency wraps +B/2 → −B/2

The discrete baseband symbol below is referenced to a base up-chirp. Demodulation is de-chirping: multiply the received signal by the conjugate of the base down-chirp, take an FFT, and the bin with the peak magnitude is the symbol value.

symbol / reference chirp
// symbol s, discrete baseband, n = 0..N-1
x_s[n]   = exp( j·2π · (((s+n) mod N) · n) / N )

// base up-chirp used for de-chirping (conjugate, multiply, then FFT)
x_ref[n] = exp( j·2π · (n²/(2N) − n/2) )

Timing and link budget

QuantityFormulaNotes
Symbol durationTsym = 2^SF / BSF7/125kHz: 128/125000 = 1.024 ms · SF12/125kHz: 4096/125000 = 32.768 ms
Chip rate= Bone chip per 1/B seconds; 2^SF chips per symbol
Processing gain≈ 10·log10(2^SF) dBeach SF step doubles Tsym, adds ~2.5 dB link budget, halves bitrate
Bandwidth125 / 250 / 500 kHzdoubling BW halves Tsym (doubles bitrate) for the same SF

Spreading factors are quasi-orthogonal, so different SFs coexist on one channel with limited mutual interference -- the basis of LoRaWAN's adaptive data rate in LoRaWAN MAC.

The Coding Chain

The LoRa TX chain was reverse-engineered by Knight/Seeber and the EPFL/GNU Radio teams, since Semtech never fully documented it: whitening → Hamming FEC → interleaving → Gray mapping → chirp modulation.

tx bit-processing pipeline
payload bytes
      
      
┌───────────────────┐   XOR with a pseudo-random LFSR sequence, removes DC bias / long runs
 whitening             (sequence experimentally derived, not published by Semtech)
└─────────┬─────────┘
          
┌───────────────────┐   CR 4/5..4/8 -- true (4,7)/(4,8) Hamming @ 4/7,4/8 (correct 1 bit)
 Hamming FEC           4/6 punctured (detect only), 4/5 single parity bit (detect only)
└─────────┬─────────┘
          
┌───────────────────┐   diagonal interleaver: spreads each block of SF codewords
 interleaving          (CR+4 bits each) across CR+4 symbols -- a noise burst hitting
└─────────┬─────────┘   one symbol is scattered across many codewords
          
┌───────────────────┐
 Gray mapping       
└─────────┬─────────┘
          
┌───────────────────┐
 chirp modulation      → on air, see CSS Waveform
└───────────────────┘

Coding rate options

Register valueCRTypeBehavior
14/5paritysingle even-parity bit -- detect only
24/6puncturedpunctured (4,6) code -- detect only
34/7Hammingtrue (4,7) Hamming -- corrects 1 bit-error per codeword
44/8Hammingtrue (4,8) Hamming -- corrects 1 bit-error per codeword; used for the PHY header
The header is always coded at 4/8 Regardless of the payload's configured CR, the PHY header (when present) is coded at 4/8 -- the strongest available rate -- because the receiver must decode the header before it knows what CR the payload uses.

An optional 16-bit payload CRC is enabled via RxPayloadCrcOn; on RX, a PayloadCrcError IRQ flags failures. See PHY Packet for where CRC sits in the frame.

PHY Packet Structure

Over-the-air order: Preamble → Sync word → SFD → [Header + Header CRC] → Payload → [Payload CRC].

over-the-air frame, explicit header
┌───────────────┬───────────┬───────────┬────────────────────┬───────────────┬─────────────┐
  preamble      sync word    SFD       header + hdr CRC     payload      payload CRC 
  n_preamble     2 symbols  2.25 sym    4/8 coded, opt.     up to 255 B    optional   
  up-chirps                 down-chirp                                                
└───────────────┴───────────┴───────────┴────────────────────┴───────────────┴─────────────┘
  Tpreamble = (n_preamble + 4.25) · Tsym  -- the +4.25 covers sync word + SFD

Preamble & sync word

The preamble is a programmable number of unmodulated up-chirps (default 8 in LoRaWAN, register n_preamble). The radio hardware appends the 2 sync-word symbols and the 2.25-symbol SFD (start-of-frame delimiter, a down-chirp) automatically. Longer preambles let RX duty-cycle sleep -- Meshtastic uses 16.

The last preamble up-chirps encode a network identifier, the sync word. On SX127x it's RegSyncWord (0x39), default 0x12 (private); 0x34 is reserved for LoRaWAN. On SX126x it's two registers at 0x0740/0x0741 (0x1424 private, 0x3444 public). It's a "soft" filter -- packets with a mismatched sync word can still leak through, unlike FSK.

Header modes

ModeBehavior
Explicitheader carries payload length, CR, and CRC-presence, protected by its own CRC; receiver auto-detects payload length/CR. Adds ~SF−2 codewords of overhead.
Implicitheader omitted entirely; both ends must pre-agree length/CR/CRC out of band. Mandatory for SF6.

Max payload is 255 bytes; each on-air symbol maps to SF bits after the coding chain in Coding Chain.

Time On Air

Semtech AN1200.13 gives the exact time-on-air formula used to size duty cycles and airtime budgets.

an1200.13 time-on-air
Tsym          = 2^SF / BW                         // seconds; BW in Hz
Tpreamble     = (n_preamble + 4.25) · Tsym
payloadSymbNb = 8 + max( ceil( (8·PL − 4·SF + 28 + 16·CRC − 20·IH)
                               / (4·(SF − 2·DE)) ) · (CR + 4), 0 )
Tpayload      = payloadSymbNb · Tsym
ToA           = Tpreamble + Tpayload

// PL = payload bytes, CRC = 1 if payload CRC on else 0, IH = 1 if implicit header else 0
// DE = 1 if Low-Data-Rate-Optimization on else 0, CR = 1..4 for 4/5..4/8
LDRO is mandatory above Tsym = 16 ms Low-Data-Rate-Optimization (DE=1) is mandatory whenever Tsym > 16 ms -- SF11/SF12 at 125 kHz. Set it via the LowDataRateOptimize bit in RegModemConfig3 (SX127x) or ModParam4 (SX126x). Skipping it at high SF causes symbol-timing drift and corrupted decodes on longer packets.

Worked example

SF7 / 125 kHz / CR 4/5, 20-byte payload ≈ 41 ms total time on air. LoRaWAN adds ~13 bytes of MAC overhead (MHDR + FHDR + FPort + MIC) on top of the application payload, and the SX127x adds ~2 ms of TX ramp-up on top of the formula's result.

Duty-cycle math

EU868 allows 1% duty cycle per sub-band, i.e. ~36 s of TX per hour. Divide 36000 ms by ToA to get the maximum packets/hour for a given configuration -- at SF12/125 kHz with a full 51-byte payload, ToA can exceed 2 seconds, so duty cycle rather than raw airtime often becomes the binding constraint at high SF.

SX127x Registers

SPI transaction: one address byte, then data. MSB of the address byte is the R/W bit -- set bit 7 = 1 for write, 0 for read. Holding NSS low and streaming further bytes gives burst/sequential access (address auto-increments; the FIFO at 0x00 does not auto-increment, enabling block FIFO reads/writes). SPI Mode 0 (CPOL=0, CPHA=0), up to 10 MHz.

Key registers (LoRa mode)

RegAddrPurpose
RegFifo0x00FIFO read/write
RegOpMode0x01bit7 LongRangeMode (1=LoRa), bit3 LowFreqModeOn, bits2:0 Mode (000 SLEEP, 001 STDBY, 011 TX, 101 RXCONT, 110 RXSINGLE, 111 CAD)
RegFrfMsb/Mid/Lsb0x06–0x0824-bit carrier frequency
RegPaConfig0x09PaSelect(bit7), MaxPower(6:4), OutputPower(3:0)
RegOcp0x0Bovercurrent trim
RegLna0x0CLnaGain(7:5), LnaBoostHf(1:0)=0x03 to boost
RegFifoAddrPtr0x0DFIFO pointer
RegFifoTxBaseAddr0x0Edefault 0x80
RegFifoRxBaseAddr0x0Fdefault 0x00
RegFifoRxCurrentAddr0x10start of last packet
RegIrqFlags0x12see bits below
RegRxNbBytes0x13received byte count
RegPktSnrValue0x19SNR·4 (signed)
RegPktRssiValue0x1Apacket RSSI
RegRssiValue0x1Bcurrent RSSI
RegModemConfig10x1DBw(7:4), CodingRate(3:1), ImplicitHeaderOn(0)
RegModemConfig20x1ESpreadingFactor(7:4), TxContinuous(3), RxPayloadCrcOn(2), SymbTimeout MSB(1:0)
RegModemConfig30x26LowDataRateOptimize(3), AgcAutoOn(2)
RegSymbTimeout0x1FRX single timeout LSB
RegPreambleMsb/Lsb0x20/0x21preamble length
RegPayloadLength0x22payload length (implicit/TX)
RegSyncWord0x390x12 private / 0x34 LoRaWAN
RegDioMapping1/20x40/0x41DIO0–5 function select
RegVersion0x420x12 for SX1276
RegPaDac0x4D0x87 enables +20 dBm on PA_BOOST

RegIrqFlags (0x12) bits

bit7 RxTimeout · bit6 RxDone · bit5 PayloadCrcError · bit4 ValidHeader · bit3 TxDone · bit2 CadDone · bit1 FhssChangeChannel · bit0 CadDetected. Flags are cleared by writing a 1 to the bit.

Register access helper

Every read/write is one SPI transaction: address byte (with R/W bit) followed by the data byte, both in the same spi_transaction_t so CS stays low across both. .length is in bits, not bytes -- see ESP32 Wire.

sx127x_reg.c -- single-byte access + fifo burst
// write: bit7=1 selects write, addr in bits6:0
static void sx127x_write_reg(spi_device_handle_t dev, uint8_t addr, uint8_t val) {
    uint8_t tx[2] = { (uint8_t)(addr | 0x80), val };
    spi_transaction_t t = { .length = 16, .tx_buffer = tx };  // .length is in BITS
    spi_device_transmit(dev, &t);
}

// read: bit7=0 selects read, clock a dummy byte to receive the reply
static uint8_t sx127x_read_reg(spi_device_handle_t dev, uint8_t addr) {
    uint8_t tx[2] = { (uint8_t)(addr & 0x7F), 0x00 };
    uint8_t rx[2] = { 0 };
    spi_transaction_t t = { .length = 16, .tx_buffer = tx, .rx_buffer = rx };
    spi_device_transmit(dev, &t);
    return rx[1];
}

// RegFifo (0x00) does not auto-increment -- streaming N payload bytes in
// one CS-low window is the standard fast path for burst fifo writes
static void sx127x_write_fifo(spi_device_handle_t dev, const uint8_t *payload, size_t len) {
    uint8_t tx[256];
    tx[0] = 0x00 | 0x80;              // RegFifo, write
    memcpy(&tx[1], payload, len);
    spi_transaction_t t = { .length = (len + 1) * 8, .tx_buffer = tx };
    spi_device_transmit(dev, &t);
}

Init sequence

LoRa mode can only be toggled from SLEEP -- see the Gotchas entry on why writing LongRangeMode from STANDBY fails silently.

sx127x_init.c -- register pokes, commented per step
void sx127x_init(spi_device_handle_t dev, uint32_t freq_hz) {
    // (1) RESET pulse done externally via gpio_set_level; (2) confirm silicon
    uint8_t ver = sx127x_read_reg(dev, 0x42);          // RegVersion, expect 0x12 on SX1276

    // (3) LoRa bit can only be set while in SLEEP
    sx127x_write_reg(dev, 0x01, 0x80);         // RegOpMode: LongRangeMode=1, MODE=SLEEP
    sx127x_write_reg(dev, 0x01, 0x81);         // RegOpMode: LoRa + STANDBY

    // (4) carrier frequency: Frf = freq_hz * 2^19 / 32e6, FSTEP ~= 61.035 Hz
    uint32_t frf = (uint32_t)(((uint64_t)freq_hz << 19) / 32000000ULL);
    sx127x_write_reg(dev, 0x06, (frf >> 16) & 0xFF);  // RegFrfMsb
    sx127x_write_reg(dev, 0x07, (frf >> 8) & 0xFF);   // RegFrfMid
    sx127x_write_reg(dev, 0x08, frf & 0xFF);          // RegFrfLsb

    // (5) fifo base addresses -- shared 256-byte fifo, tx/rx halves
    sx127x_write_reg(dev, 0x0E, 0x80);         // RegFifoTxBaseAddr
    sx127x_write_reg(dev, 0x0F, 0x00);         // RegFifoRxBaseAddr

    // (6) LNA: max gain, boost the HF path
    sx127x_write_reg(dev, 0x0C, 0x23);         // RegLna: MaxGain | LnaBoostHf=11

    // (7)(8) modem config: BW=125kHz, CR=4/5, explicit header, SF7, CRC on
    sx127x_write_reg(dev, 0x1D, 0x72);         // RegModemConfig1
    sx127x_write_reg(dev, 0x1E, 0x74);         // RegModemConfig2

    // (9) LDRO off -- BW125/SF7 Tsym is well under the 16ms mandatory threshold
    sx127x_write_reg(dev, 0x26, 0x04);         // RegModemConfig3: AgcAutoOn=1, LDRO=0

    // (10) preamble length = 8 symbols
    sx127x_write_reg(dev, 0x20, 0x00);         // RegPreambleMsb
    sx127x_write_reg(dev, 0x21, 0x08);         // RegPreambleLsb

    // (11) tx power: PA_BOOST pin, +17dBm base, +20dBm dac boost enabled
    sx127x_write_reg(dev, 0x09, 0x8F);         // RegPaConfig: PaSelect=1, OutputPower=15
    sx127x_write_reg(dev, 0x4D, 0x87);         // RegPaDac: enable +20dBm on PA_BOOST

    // (12) private network sync word
    sx127x_write_reg(dev, 0x39, 0x12);         // RegSyncWord
}

TX sequence

sx127x_tx.c
void sx127x_tx(spi_device_handle_t dev, const uint8_t *payload, size_t len) {
    sx127x_write_reg(dev, 0x01, 0x81);              // STANDBY
    sx127x_write_reg(dev, 0x40, 0x40);              // RegDioMapping1: DIO0 = TxDone
    sx127x_write_reg(dev, 0x0D, 0x80);              // RegFifoAddrPtr = RegFifoTxBaseAddr

    sx127x_write_fifo(dev, payload, len);
    sx127x_write_reg(dev, 0x22, (uint8_t)len);       // RegPayloadLength

    sx127x_write_reg(dev, 0x01, 0x83);              // RegOpMode: TX

    while (!(sx127x_read_reg(dev, 0x12) & 0x08)) {  // poll RegIrqFlags.TxDone (bit3)
        vTaskDelay(pdMS_TO_TICKS(1));
        // production code: block on a DIO0 rising-edge ISR/semaphore instead
    }
    sx127x_write_reg(dev, 0x12, 0x08);              // clear TxDone: write 1 to the bit
}

RX continuous

sx127x_rx.c
size_t sx127x_rx_continuous(spi_device_handle_t dev, uint8_t *out, size_t max_len) {
    sx127x_write_reg(dev, 0x01, 0x81);              // STANDBY
    sx127x_write_reg(dev, 0x40, 0x00);              // RegDioMapping1: DIO0 = RxDone
    sx127x_write_reg(dev, 0x0D, 0x00);              // RegFifoAddrPtr = RegFifoRxBaseAddr
    sx127x_write_reg(dev, 0x01, 0x85);              // RegOpMode: RXCONTINUOUS

    for (;;) {
        uint8_t irq = sx127x_read_reg(dev, 0x12);
        if (!(irq & 0x40)) continue;            // wait for RxDone (bit6)

        sx127x_write_reg(dev, 0x12, 0xFF);          // clear all irq flags
        if (irq & 0x20) continue;                // PayloadCrcError -- drop, keep listening

        uint8_t n   = sx127x_read_reg(dev, 0x13);        // RegRxNbBytes
        uint8_t cur = sx127x_read_reg(dev, 0x10);        // RegFifoRxCurrentAddr
        sx127x_write_reg(dev, 0x0D, cur);              // RegFifoAddrPtr = start of last packet

        size_t n_read = n < max_len ? n : max_len;
        for (size_t i = 0; i < n_read; i++) {
            out[i] = sx127x_read_reg(dev, 0x00);      // RegFifo, sequential reads
        }
        return n_read;
    }
}

SX126x Commands

The SX126x is command-based: every SPI transaction is opcode + parameter bytes, and the chip returns a status byte on MISO simultaneously with every byte. A BUSY pin must be polled low before each new command (the chip is processing). Register reads (opcode 0x1D) and buffer reads (0x1E) require a NOP/dummy byte after the address before data appears. SPI Mode 0, up to 16 MHz.

Core opcodes

OpcodeCommand
0x80SetStandby
0x84SetSleep
0xC1SetFs
0x83SetTx
0x82SetRx
0xC5SetCad
0x88SetCadParams
0x96SetRegulatorMode
0x89Calibrate
0x98CalibrateImage
0x95SetPaConfig
0x0DWriteRegister
0x1DReadRegister
0x0EWriteBuffer
0x1EReadBuffer
0x8FSetBufferBaseAddress
0x08SetDioIrqParams
0x12GetIrqStatus
0x02ClearIrqStatus
0x9DSetDIO2AsRfSwitchCtrl
0x97SetDIO3AsTcxoCtrl
0x86SetRfFrequency
0x8ASetPacketType
0x8ESetTxParams
0x8BSetModulationParams
0x8CSetPacketParams
0x13GetRxBufferStatus
0x14GetPacketStatus
0xC0GetStatus
Mandatory command order SetPacketType(LoRa=0x01) first, then SetModulationParams, then SetPacketParams. Getting this order wrong is one of the most common SX126x bring-up mistakes -- the chip silently ignores modulation/packet params set before the packet type.

Exact encodings

Confirmed against Semtech mbed SX126xLib, RadioLib, tinygo, and RadioHead.

CommandParams
SetRfFrequencyRF = f_Hz · 2^25 / 32e6, FSTEP ≈ 0.95367 Hz. frf = freq_Hz * 33554432 / 32000000
SetModulationParamsModParam1 = SF byte (SF5=0x05…SF12=0x0C) · ModParam2 = BW (125k=0x04, 250k=0x05, 500k=0x06, 62.5k=0x03, 41.7k=0x0A, 31.25k=0x02, 20.8k=0x09, 15.6k=0x01, 10.4k=0x08, 7.8k=0x00) · ModParam3 = CR (4/5=0x01…4/8=0x04) · ModParam4 = LDRO (0x00 off / 0x01 on)
SetPacketParamspreamble length (16-bit) · header type (0x00 explicit / 0x01 implicit) · payload length · CRC type (on/off) · InvertIQ (0x00 standard / 0x01 inverted -- LoRaWAN downlinks use inverted IQ)
SetPaConfig @ +22dBmpaDutyCycle=0x04, hpMax=0x07, deviceSel=0x00 (0x01=SX1261), paLut=0x01
SetTxParamspower=0x16 (22 dBm), rampTime (0x04 = 200µs, 0x07 = 3400µs)
SetStandbySTDBY_RC=0x00, STDBY_XOSC=0x01
SetRegulatorModeLDO=0x00, DC-DC/SMPS=0x01 (SMPS roughly halves RX/TX current)

Status byte layout

bits[6:4] chip mode (0x2 STBY_RC, 0x3 STBY_XOSC, 0x4 FS, 0x5 RX, 0x6 TX) · bits[3:1] command status (0x2 data available, 0x3 command timeout, 0x4 processing error/invalid opcode, 0x5 fail to execute, 0x6 command TX done). Example: 0x2C = 0b0010_1100 → STBY_RC + command-TX-done.

IRQ bits (GetIrqStatus / SetDioIrqParams)

bit0 TxDone · bit1 RxDone · bit2 PreambleDetected · bit3 SyncWordValid · bit4 HeaderValid · bit5 HeaderErr · bit6 CrcErr · bit7 CadDone · bit8 CadDetected · bit9 Timeout. IRQs route to DIO1/DIO2/DIO3 via SetDioIrqParams and clear with ClearIrqStatus.

Command helper

sx126x_cmd.c -- opcode + params, BUSY-gated, status echoed on MISO
static void sx126x_wait_busy(gpio_num_t busy_pin) {
    while (gpio_get_level(busy_pin)) {
        // chip is still processing the previous command
    }
}

static uint8_t sx126x_cmd(spi_device_handle_t dev, gpio_num_t busy_pin,
                       uint8_t opcode, const uint8_t *params, size_t plen,
                       uint8_t *resp, size_t rlen) {
    sx126x_wait_busy(busy_pin);

    uint8_t tx[260] = { opcode };
    memcpy(&tx[1], params, plen);
    uint8_t rx[260] = { 0 };

    size_t total = (plen > rlen ? plen : rlen) + 1;   // some commands read more than they write
    spi_transaction_t t = { .length = total * 8, .tx_buffer = tx, .rx_buffer = rx };
    spi_device_transmit(dev, &t);

    if (resp && rlen) memcpy(resp, &rx[1], rlen);
    return rx[0];                // status byte, shadowed back on the first MISO byte
}

Init, calibrate, set frequency

sx126x_init.c
void sx126x_init(spi_device_handle_t dev, gpio_num_t busy, uint32_t freq_hz) {
    sx126x_cmd(dev, busy, 0x80, (uint8_t[]){ 0x00 }, 1, NULL, 0);  // SetStandby(STDBY_RC)

    // packet type must be set before modulation/packet params -- mandatory order
    sx126x_cmd(dev, busy, 0x8A, (uint8_t[]){ 0x01 }, 1, NULL, 0);  // SetPacketType(LoRa)

    sx126x_cmd(dev, busy, 0x96, (uint8_t[]){ 0x01 }, 1, NULL, 0);  // SetRegulatorMode(DC-DC)

    // on a TCXO board: SetDIO3AsTcxoCtrl(0x97,...) here first; XTAL boards skip it

    sx126x_cmd(dev, busy, 0x89, (uint8_t[]){ 0x7F }, 1, NULL, 0);  // Calibrate(all blocks)
    sx126x_wait_busy(busy);
    sx126x_cmd(dev, busy, 0x98, (uint8_t[]){ 0xD7,0xDB }, 2, NULL, 0); // CalibrateImage, 863-870MHz band

    // SetRfFrequency: frf = freq_hz * 2^25 / 32e6
    uint32_t frf = (uint32_t)(((uint64_t)freq_hz << 25) / 32000000ULL);
    uint8_t frf_b[4] = { (frf>>24)&0xFF, (frf>>16)&0xFF, (frf>>8)&0xFF, frf&0xFF };
    sx126x_cmd(dev, busy, 0x86, frf_b, 4, NULL, 0);            // SetRfFrequency

    sx126x_cmd(dev, busy, 0x95, (uint8_t[]){ 0x04,0x07,0x00,0x01 }, 4, NULL, 0); // SetPaConfig, +22dBm
    sx126x_cmd(dev, busy, 0x8E, (uint8_t[]){ 0x16,0x04 }, 2, NULL, 0);       // SetTxParams: 22dBm, 200us ramp

    sx126x_cmd(dev, busy, 0x8F, (uint8_t[]){ 0x00,0x00 }, 2, NULL, 0);       // SetBufferBaseAddress: tx=0, rx=0

    // SF7, BW125, CR4/5, LDRO off
    sx126x_cmd(dev, busy, 0x8B, (uint8_t[]){ 0x07,0x04,0x01,0x00 }, 4, NULL, 0); // SetModulationParams

    // preamble=8, explicit header, length set per-tx, CRC on, standard IQ
    sx126x_cmd(dev, busy, 0x8C,
        (uint8_t[]){ 0x00,0x08, 0x00, 0x00, 0x01, 0x00 }, 6, NULL, 0);           // SetPacketParams

    // route TxDone(bit0) + RxDone(bit1) + Timeout(bit9) to DIO1
    sx126x_cmd(dev, busy, 0x08,
        (uint8_t[]){ 0x02,0x03, 0x02,0x03, 0x00,0x00, 0x00,0x00 }, 8, NULL, 0); // SetDioIrqParams
}

TX via WriteBuffer + SetTx

sx126x_tx.c
void sx126x_tx(spi_device_handle_t dev, gpio_num_t busy, const uint8_t *payload, size_t len) {
    uint8_t buf[257];
    buf[0] = 0x00;                                    // WriteBuffer offset = 0
    memcpy(&buf[1], payload, len);
    sx126x_cmd(dev, busy, 0x0E, buf, len + 1, NULL, 0);       // WriteBuffer

    uint8_t pkt[6] = { 0x00,0x08, 0x00, (uint8_t)len, 0x01, 0x00 };
    sx126x_cmd(dev, busy, 0x8C, pkt, 6, NULL, 0);              // SetPacketParams, length updated

    sx126x_cmd(dev, busy, 0x83, (uint8_t[]){ 0x00,0x00,0x00 }, 3, NULL, 0); // SetTx, timeout=0 (single-shot)

    // wait on DIO1 rising edge (TxDone) in production code, then ClearIrqStatus
}

RX

sx126x_rx.c
size_t sx126x_rx(spi_device_handle_t dev, gpio_num_t busy, uint8_t *out, size_t max_len) {
    sx126x_cmd(dev, busy, 0x82, (uint8_t[]){ 0xFF,0xFF,0xFF }, 3, NULL, 0); // SetRx, continuous

    // wait on DIO1 rising edge in production code -- polling shown for clarity
    uint8_t irq[2];
    do {
        sx126x_cmd(dev, busy, 0x12, (uint8_t[]){ 0x00 }, 1, irq, 2);   // GetIrqStatus
    } while (!(irq[1] & 0x02));                                       // bit1 RxDone

    sx126x_cmd(dev, busy, 0x02, (uint8_t[]){ 0xFF,0xFF }, 2, NULL, 0);       // ClearIrqStatus, all bits

    uint8_t status[2];
    sx126x_cmd(dev, busy, 0x13, (uint8_t[]){ 0x00 }, 1, status, 2);      // GetRxBufferStatus
    uint8_t len = status[0], offset = status[1];

    uint8_t req[2] = { offset, 0x00 };                              // offset, then required NOP byte
    uint8_t buf[256];
    sx126x_cmd(dev, busy, 0x1E, req, 2, buf, len);                     // ReadBuffer

    size_t n = len < max_len ? len : max_len;
    memcpy(out, buf, n);
    return n;
}

127x vs 126x

AttributeSX127x (SX1276/78, RFM95)SX126x (SX1262, RA-01SC)
InterfaceFlat register poke (addr+R/W bit)Command/opcode + BUSY handshake
Datasheet/launchRev. 0.0, May 2013Rev. 1.1 Dec 2017 / 1.2 Jun 2019 (recommended for new designs)
Max TX power+20 dBm (PA_BOOST) / +14 (RFO)+22 dBm (SX1262/68), +14/15 (SX1261)
RX current~11 mA (Semtech selection guide)~4.6 mA (DC-DC)
TX current~120 mA @ 20 dBm~118 mA @ 22 dBm (DC-DC)
Sleep~0.2 µA (200 nA register retention)~600 nA warm / ~900 nA cold
Sensitivity SF12/125k~ −137 dBm~ −148 dBm (~11 dB better)
SF rangeSF6–SF12SF5–SF12
Freq range137–1020 MHz (band-specific RF pins)150–960 MHz (single RF port)
Freq divisor2^19 / 32 MHz (FSTEP 61.035 Hz)2^25 / 32 MHz (FSTEP 0.95 Hz)
Image rejectno explicit cal steprequires Calibrate + CalibrateImage
TCXO controlRegTcxo (0x4B)DIO3 (SetDIO3AsTcxoCtrl)
RF switchhost GPIO / externalDIO2 (SetDIO2AsRfSwitchCtrl)
IRQ pinsDIO0/DIO1/DIO2BUSY + DIO1 (DIO2/3 repurposed)
Package6×6 mm QFN4×4 mm QFN
ModulesRFM95/96/98, Ra-02, HopeRFRa-01SC/SH, E22, Wio-E5, Heltec V3
Worked battery example Transmit every 15 min with Class-A RX windows: the SX1262's ~4.6 mA RX (vs ~10.8 mA) roughly halves daily consumption, extending a 2000 mAh cell from ~1.5 to ~2.6 years -- the RX-current gap dominates for any listen-mostly node.

ESP32 SPI Wire

Both chips are SPI Mode 0 (CPOL=0, CPHA=0), 3.3V only. SX127x max SCLK ~10 MHz; SX126x ~16 MHz. Use SPI2_HOST/SPI3_HOST (the old HSPI/VSPI).

esp-idf spi setup
spi_bus_config_t buscfg = {
    .mosi_io_num = PIN_MOSI, .miso_io_num = PIN_MISO,
    .sclk_io_num = PIN_SCLK, .quadwp_io_num = -1, .quadhd_io_num = -1,
    .max_transfer_sz = 256 };
spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO);

spi_device_interface_config_t devcfg = {
    .clock_speed_hz = 8*1000*1000,   // 8 MHz, under the 10 MHz SX127x limit
    .mode = 0,                        // CPOL=0 CPHA=0
    .spics_io_num = PIN_CS,           // driver toggles CS automatically
    .queue_size = 1 };
spi_device_handle_t dev;
spi_bus_add_device(SPI2_HOST, &devcfg, &dev);

// sx127x register write: addr|0x80 then value
uint8_t tx[2] = { reg | 0x80, val }, rx[2];
spi_transaction_t t = { .length = 16, .tx_buffer = tx, .rx_buffer = rx };
spi_device_transmit(dev, &t);   // .length is in BITS, not bytes

Wiring differences

ChipMinimum wiring beyond SPINotes
SX127xRESET, DIO0 (RxDone/TxDone)DIO1 optional; wire DIO0/DIO1 to GPIOs as rising-edge interrupts
SX126xRESET, BUSY, DIO1BUSY must be polled low before every command; DIO1 is the main IRQ line

Many boards let the driver control NSS manually (spics_io_num = -1) when NSS sits behind an I/O expander. Keep CS and clock lines short -- the SX126x's mandatory BUSY poll makes bit-banging awkward but possible.

BUSY polling is not optional on SX126x A missed BUSY poll before issuing a command causes dropped or garbled commands with no obvious error -- the chip just silently ignores or mis-executes the write. Always wait for BUSY low before every single opcode transaction, not just at init.

LoRaWAN MAC

Architecture: end devices ↔ gateways (transparent PHY bridges) ↔ Network Server (dedup, MAC, ADR, routing) ↔ Application Server (payload decrypt); in 1.1 a Join Server holds root keys.

Device classes

ClassBehavior
Amandatory, lowest power. Every uplink is followed by two short RX windows: RX1 (same freq as uplink, after RX1 Delay = 1s default) and RX2 (fixed freq/DR, +1s). Downlinks only in these windows. OTAA join uses JOIN_ACCEPT_DELAY1 = 5s and DELAY2 = 6s.
Badds gateway-beacon-synchronized ping slots for scheduled downlinks at bounded latency.
CRX2 window kept open continuously except while transmitting -- lowest downlink latency, highest power (mains devices).

OTAA vs ABP

ModeProvisioningNotes
OTAADevEUI (64-bit) + JoinEUI/AppEUI (64-bit) + AppKey (AES-128 root, +NwkKey in 1.1)Join-Request = JoinEUI | DevEUI | DevNonce (must be non-repeating/persistent -- replay protection). Server replies with encrypted Join-Accept = AppNonce/JoinNonce | NetID | DevAddr | DLSettings | RxDelay | [CFList], MIC'd with AppKey. AppKey is never transmitted.
ABPDevAddr + NwkSKey + AppSKey burned inNo join. Simpler but weaker: static keys, frame-counter reset risk.
otaa session key derivation, 1.0.x
NwkSKey = aes128_encrypt(AppKey, 0x01 | AppNonce | NetID | DevNonce | pad16)
AppSKey = aes128_encrypt(AppKey, 0x02 | AppNonce | NetID | DevNonce | pad16)

// 1.1 splits further into FNwkSIntKey / SNwkSIntKey / NwkSEncKey / AppSKey,
// derived from NwkKey / AppKey and keyed on JoinEUI / JoinNonce

PHYPayload frame format

phypayload -> macpayload -> fhdr
┌─────────┬───────────────────────────────────────────┬─────────┐
  MHDR                   MACPayload                      MIC    
 1 byte                   7…M bytes                     4 bytes 
└─────────┴───────────────────────────────────────────┴─────────┘
                              
                              
              ┌────────────────────────┬──────────┬──────────────┐
                        FHDR           FPort     FRMPayload   
               DevAddr|FCtrl|FCnt|FOpts 0-1 byte   encrypted   
              └────────────────────────┴──────────┴──────────────┘
                         
                         
       ┌──────────────┬────────┬────────┬──────────────┐
          DevAddr     FCtrl   FCnt       FOpts     
        4B, little-e  1 byte  2 byte    0-15 bytes  
       └──────────────┴────────┴────────┴──────────────┘

MHDR: MType(7:5), RFU(4:2), Major(1:0). FCtrl holds ADR, ADRACKReq, ACK, FPending (downlink), FOptsLen. FPort: 0 = FRMPayload carries MAC commands (encrypted with NwkSKey); 1–223 = application data (encrypted with AppSKey). MIC: 4 bytes, AES-CMAC over the frame using NwkSKey (network-integrity); FRMPayload itself is AES-128-CTR encrypted. TTN DevAddrs start 0x26/0x27.

MTypeBits
Join-Request000
Join-Accept001
Unconfirmed Data Up010
Unconfirmed Data Down011
Confirmed Data Up100
Confirmed Data Down101

MAC commands (CID)

Shared by Req/Ans in opposite directions.

CIDCommand
0x02LinkCheckReq(up) / Ans(down)
0x03LinkADRReq(down) / Ans(up)
0x04DutyCycleReq/Ans
0x05RXParamSetupReq/Ans
0x06DevStatusReq/Ans (battery, margin)
0x07NewChannelReq/Ans
0x08RXTimingSetupReq/Ans
0x09TXParamSetupReq/Ans
0x0ADlChannelReq/Ans
0x0DDeviceTimeReq/Ans

1.0.4 adds RekeyInd 0x0B, ADRParamSetupReq 0x0C, ForceRejoinReq 0x0E, RejoinParamSetupReq 0x0F.

Adaptive Data Rate (ADR)

When the device sets the ADR bit, the Network Server uses the max SNR over recent uplinks (ChirpStack/TTS use ~20 frames) minus the required SNR for the current DR minus an installation margin (~10 dB) to compute how many DR steps up or TX-power steps down are possible, then issues LinkADRReq (DataRate_TXPower, ChMask, Redundancy/NbTrans). The device confirms with LinkADRAns. Device-side: after ADR_ACK_LIMIT uplinks with no downlink it sets ADRACKReq; if no answer within ADR_ACK_DELAY it steps down the DR / increases power to regain connectivity.

Regional plans

RegionChannelsData ratesDuty / dwellPower
EU8683 mandatory join channels 868.1/868.3/868.5 MHz, 125 kHzDR0=SF12…DR5=SF7 (DR6=SF7/250kHz, DR7=FSK)1% duty cycle per sub-band (~36s/hr)max EIRP +16 dBm; RX2 = 869.525 MHz/DR0(SF12)
US91564×125kHz uplink channels (902.3–914.9 MHz, 200kHz spacing) + 8×500kHzDR0=SF10/125kHz…DR3=SF7, DR4=SF8/500kHzno duty cycle, 400ms max dwell (FCC)+30 dBm EIRP allowed

US915 devices hop channels and use sub-bands (FSB); ADR/channel-mask must agree between 8-channel gateways and nodes or uplinks silently fail to be heard.

P2P & CAD

For a raw P2P link, both ends must match carrier frequency, SF, BW, CR, preamble length, sync word, CRC and IQ polarity exactly. Use sync word 0x12 (private) to stay off LoRaWAN; SX126x uses 0x1424 for the same. Sync-word filtering is imperfect -- 0x12 and 0x34 are the two recommended values that maximize cross-network rejection; other values behave unpredictably (on SX126x the surrounding nibbles must stay 0x_4_4).

App-level framing

Two typical schemes: fixed-length implicit-header ping-pong, or a small app header [dstAddr][srcAddr][seq][len] inside the payload with software address filtering and ACKs. Explicit header + payload CRC is the robust default; implicit header + CR4/8 + CRC-off trims airtime when both ends are fixed and pre-agreed.

Channel Activity Detection (CAD)

CAD is a low-power scan that detects a LoRa preamble without full RX. On SX127x, set RegOpMode = CAD (0b111); the radio locks the PLL, captures ~one preamble symbol (~10 mA), then correlates. On CadDone IRQ, check CadDetected -- if set, switch to RX; else sleep. Per AN1200.85 the SX127x detects only the preamble; SX126x/SX128x can detect the full packet. On SX126x, configure SetCadParams (cadSymbolNum, cadDetPeak, cadDetMin, cadExitMode, timeout) then issue SetCad (0xC5); results come back via CadDone/CadDetected IRQs. CAD costs a few mA-ms versus continuous RX at ~5–11 mA, extending battery life dramatically for listen-mostly nodes.

sx127x_cad.c -- cheap listen-before-talk
bool sx127x_cad(spi_device_handle_t dev) {
    sx127x_write_reg(dev, 0x40, 0x80);   // RegDioMapping1: DIO0 = CadDone
    sx127x_write_reg(dev, 0x01, 0x87);   // RegOpMode: MODE=CAD (0b111)

    while (!(sx127x_read_reg(dev, 0x12) & 0x04)) {  // wait CadDone (bit2)
        vTaskDelay(pdMS_TO_TICKS(1));
    }
    uint8_t irq = sx127x_read_reg(dev, 0x12);
    sx127x_write_reg(dev, 0x12, 0xFF);   // clear flags

    return irq & 0x01;                   // bit0 CadDetected -- caller switches to RX if true
}

Low-Level Gotchas

Modem switch only works from SLEEP Writing LongRangeMode in STANDBY fails silently -- the write is dropped and reading back the register still shows the old mode. Always go SLEEP → set the LoRa bit → STANDBY, in that order.
PA_BOOST vs RFO RFM95/Ra-02 modules route only the PA_BOOST pin. Forgetting PaSelect=1 in RegPaConfig gives near-zero output even though the radio reports TxDone normally. +20 dBm additionally needs RegPaDac = 0x87.
SX127x RSSI/SNR math SNR = RegPktSnrValue (signed) / 4 dB. RSSI offset differs by port: RSSI = −157 + PktRssi (HF, ≥779 MHz) or RSSI = −164 + PktRssi (LF). The band threshold is ~525 MHz (RF_MID_BAND_THRESH), not 868 MHz -- a common bug. When SNR < 0: RSSI = offset + PktRssi + PktSnr·0.25; when SNR ≥ 0 the slope is corrected: RSSI = offset + 16/15·PktRssi. Offsets shift with external LNA / LnaBoost, so single-point calibration matters for accuracy.
SX126x image calibration is mandatory After setting frequency you must call Calibrate (all blocks, 0x7F) and CalibrateImage for the band (e.g. 0xD7/0xDB for 863–870 MHz, 0xE1/0xE9 for 902–928 MHz). Skipping it degrades sensitivity and image rejection with no hard error -- the link just performs worse than it should. SX127x has no equivalent mandatory step.
TCXO vs XTAL mismatch (SX126x) Driving DIO3 TCXO voltage on an XTAL-only board causes an SPI command-fail (−707); treating a TCXO board as XTAL causes an infinite calibration loop. Match the module's actual crystal circuit, not what the reference design assumes.
RF switch / antenna Modules with a front-end need RXEN/TXEN or DIO2-as-RF-switch set correctly, or you transmit into the RX path. On SX126x set SetDIO2AsRfSwitchCtrl(1); on SX127x drive the FEM GPIOs directly in software.
Clearing IRQs differs between chips SX127x clears by writing a 1 to the RegIrqFlags bit; SX126x clears via ClearIrqStatus with a bitmask. Stale flags on either chip block the next RxDone from ever firing.
LoRaWAN downlink IQ inversion Downlinks use inverted IQ (SetPacketParams InvertIQ=0x01 / RegInvertIQ) -- a P2P RX with standard IQ will never hear a gateway, and vice versa. A real-world RadioLib bug: an SX126x RX window that warm-start-sleeps across the 5s join delay and wakes to SetRx without re-applying modem/packet/sync config silently receives nothing.
BUSY handling (SX126x) Every command must wait for BUSY low first. A missed poll causes dropped or garbled commands -- see ESP32 Wire.

Further Reading

TopicWhyWhere
SX1276 datasheetThe authoritative SX127x register map -- Rev. 0.0 May 2013 through Rev. 5/7.Semtech SX1276/77/78/79 datasheet
SX1261/2 datasheetThe authoritative SX126x opcode/command reference.Semtech SX1261/2 datasheet, Rev. 1.1 Dec 2017 / 1.2 Jun 2019 / 2.1
AN1200.13Time-on-air formula, worked derivations.Semtech application note AN1200.13
AN1200.85 / AN1200.21CAD behavior and tuning.Semtech application notes
LoRaWAN 1.0.x / 1.1 spec + RP002MAC layer and regional parameters, the ground truth for anything in LoRaWAN MAC.LoRa Alliance technical specifications
Open-source driversCross-check register/opcode values against independent implementations.RadioLib, LoRaMac-node, tinygo, RadioHead, arduino-LoRa
PHY reverse-engineering literatureThe whitening sequence, interleaver, and sync-word chip encoding were never published by Semtech -- this is where those values actually come from.Knight/Seeber papers, EPFL/GNU Radio gr-lora, arXiv PHY papers

Recommendations

  1. New designs → SX126x (SX1262/RA-01SC or Wio-E5/STM32WLE integrated). The ~11 dB sensitivity gain, ~2× lower RX current, and +22 dBm justify the extra BUSY/calibration complexity. Reserve SX127x/RFM95 for maintaining existing fleets or reusing register-level code.
  2. Bring-up order: verify SPI first -- read RegVersion=0x12 on SX127x, or read sync-word register 0x0740=0x1424 on SX126x. Only then configure the modem. On SX126x, get BUSY polling and image calibration right before blaming RF.
  3. For duty-cycle-limited regions, compute ToA up front with the AN1200.13 formula, add LoRaWAN's ~13-byte overhead, enable LDRO at SF11/12/125kHz, and budget packets/hour against the 1% (EU) or 400ms dwell (US) limit.
  4. For P2P telemetry, lock all PHY params on both ends, use private sync word 0x12/0x1424, explicit header + CRC while debugging, and add CAD-before-TX plus a small addressed app header for multi-node channels.
  5. Thresholds that change the decision: if your node listens >~1% of the time, SX126x RX current alone pays back; if you need >+20 dBm ERP, SX126x is required; if you're locked to an existing SX127x gateway/codebase and battery isn't critical, stay on SX127x.
The meta-question to hold Every time the LoRa link feels like magic, ask: what bytes went over SPI, what chirp encoded this symbol, what CMAC bytes proved this frame. There's no hidden layer. If you can't trace it to chirps, symbols, and CMAC bytes, you don't understand it yet.
// css · chain · packet · toa · sx127x · sx126x · compare · esp32 · lorawan · p2p · gotchas //