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.
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
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.
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) )
| Quantity | Formula | Notes |
|---|---|---|
| Symbol duration | Tsym = 2^SF / B | SF7/125kHz: 128/125000 = 1.024 ms · SF12/125kHz: 4096/125000 = 32.768 ms |
| Chip rate | = B | one chip per 1/B seconds; 2^SF chips per symbol |
| Processing gain | ≈ 10·log10(2^SF) dB | each SF step doubles Tsym, adds ~2.5 dB link budget, halves bitrate |
| Bandwidth | 125 / 250 / 500 kHz | doubling 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 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 └───────────────────┘
| Register value | CR | Type | Behavior |
|---|---|---|---|
| 1 | 4/5 | parity | single even-parity bit -- detect only |
| 2 | 4/6 | punctured | punctured (4,6) code -- detect only |
| 3 | 4/7 | Hamming | true (4,7) Hamming -- corrects 1 bit-error per codeword |
| 4 | 4/8 | Hamming | true (4,8) Hamming -- corrects 1 bit-error per codeword; used for the PHY header |
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.
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
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.
| Mode | Behavior |
|---|---|
| Explicit | header carries payload length, CR, and CRC-presence, protected by its own CRC; receiver auto-detects payload length/CR. Adds ~SF−2 codewords of overhead. |
| Implicit | header 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.
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
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.
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.
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.
| Reg | Addr | Purpose |
|---|---|---|
| RegFifo | 0x00 | FIFO read/write |
| RegOpMode | 0x01 | bit7 LongRangeMode (1=LoRa), bit3 LowFreqModeOn, bits2:0 Mode (000 SLEEP, 001 STDBY, 011 TX, 101 RXCONT, 110 RXSINGLE, 111 CAD) |
| RegFrfMsb/Mid/Lsb | 0x06–0x08 | 24-bit carrier frequency |
| RegPaConfig | 0x09 | PaSelect(bit7), MaxPower(6:4), OutputPower(3:0) |
| RegOcp | 0x0B | overcurrent trim |
| RegLna | 0x0C | LnaGain(7:5), LnaBoostHf(1:0)=0x03 to boost |
| RegFifoAddrPtr | 0x0D | FIFO pointer |
| RegFifoTxBaseAddr | 0x0E | default 0x80 |
| RegFifoRxBaseAddr | 0x0F | default 0x00 |
| RegFifoRxCurrentAddr | 0x10 | start of last packet |
| RegIrqFlags | 0x12 | see bits below |
| RegRxNbBytes | 0x13 | received byte count |
| RegPktSnrValue | 0x19 | SNR·4 (signed) |
| RegPktRssiValue | 0x1A | packet RSSI |
| RegRssiValue | 0x1B | current RSSI |
| RegModemConfig1 | 0x1D | Bw(7:4), CodingRate(3:1), ImplicitHeaderOn(0) |
| RegModemConfig2 | 0x1E | SpreadingFactor(7:4), TxContinuous(3), RxPayloadCrcOn(2), SymbTimeout MSB(1:0) |
| RegModemConfig3 | 0x26 | LowDataRateOptimize(3), AgcAutoOn(2) |
| RegSymbTimeout | 0x1F | RX single timeout LSB |
| RegPreambleMsb/Lsb | 0x20/0x21 | preamble length |
| RegPayloadLength | 0x22 | payload length (implicit/TX) |
| RegSyncWord | 0x39 | 0x12 private / 0x34 LoRaWAN |
| RegDioMapping1/2 | 0x40/0x41 | DIO0–5 function select |
| RegVersion | 0x42 | 0x12 for SX1276 |
| RegPaDac | 0x4D | 0x87 enables +20 dBm on PA_BOOST |
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.
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); }
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 }
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 }
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; } }
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.
| Opcode | Command |
|---|---|
| 0x80 | SetStandby |
| 0x84 | SetSleep |
| 0xC1 | SetFs |
| 0x83 | SetTx |
| 0x82 | SetRx |
| 0xC5 | SetCad |
| 0x88 | SetCadParams |
| 0x96 | SetRegulatorMode |
| 0x89 | Calibrate |
| 0x98 | CalibrateImage |
| 0x95 | SetPaConfig |
| 0x0D | WriteRegister |
| 0x1D | ReadRegister |
| 0x0E | WriteBuffer |
| 0x1E | ReadBuffer |
| 0x8F | SetBufferBaseAddress |
| 0x08 | SetDioIrqParams |
| 0x12 | GetIrqStatus |
| 0x02 | ClearIrqStatus |
| 0x9D | SetDIO2AsRfSwitchCtrl |
| 0x97 | SetDIO3AsTcxoCtrl |
| 0x86 | SetRfFrequency |
| 0x8A | SetPacketType |
| 0x8E | SetTxParams |
| 0x8B | SetModulationParams |
| 0x8C | SetPacketParams |
| 0x13 | GetRxBufferStatus |
| 0x14 | GetPacketStatus |
| 0xC0 | GetStatus |
Confirmed against Semtech mbed SX126xLib, RadioLib, tinygo, and RadioHead.
| Command | Params |
|---|---|
| SetRfFrequency | RF = f_Hz · 2^25 / 32e6, FSTEP ≈ 0.95367 Hz. frf = freq_Hz * 33554432 / 32000000 |
| SetModulationParams | ModParam1 = 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) |
| SetPacketParams | preamble 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 @ +22dBm | paDutyCycle=0x04, hpMax=0x07, deviceSel=0x00 (0x01=SX1261), paLut=0x01 |
| SetTxParams | power=0x16 (22 dBm), rampTime (0x04 = 200µs, 0x07 = 3400µs) |
| SetStandby | STDBY_RC=0x00, STDBY_XOSC=0x01 |
| SetRegulatorMode | LDO=0x00, DC-DC/SMPS=0x01 (SMPS roughly halves RX/TX current) |
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.
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.
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 }
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 }
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 }
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; }
| Attribute | SX127x (SX1276/78, RFM95) | SX126x (SX1262, RA-01SC) |
|---|---|---|
| Interface | Flat register poke (addr+R/W bit) | Command/opcode + BUSY handshake |
| Datasheet/launch | Rev. 0.0, May 2013 | Rev. 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 range | SF6–SF12 | SF5–SF12 |
| Freq range | 137–1020 MHz (band-specific RF pins) | 150–960 MHz (single RF port) |
| Freq divisor | 2^19 / 32 MHz (FSTEP 61.035 Hz) | 2^25 / 32 MHz (FSTEP 0.95 Hz) |
| Image reject | no explicit cal step | requires Calibrate + CalibrateImage |
| TCXO control | RegTcxo (0x4B) | DIO3 (SetDIO3AsTcxoCtrl) |
| RF switch | host GPIO / external | DIO2 (SetDIO2AsRfSwitchCtrl) |
| IRQ pins | DIO0/DIO1/DIO2 | BUSY + DIO1 (DIO2/3 repurposed) |
| Package | 6×6 mm QFN | 4×4 mm QFN |
| Modules | RFM95/96/98, Ra-02, HopeRF | Ra-01SC/SH, E22, Wio-E5, Heltec V3 |
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
| Chip | Minimum wiring beyond SPI | Notes |
|---|---|---|
| SX127x | RESET, DIO0 (RxDone/TxDone) | DIO1 optional; wire DIO0/DIO1 to GPIOs as rising-edge interrupts |
| SX126x | RESET, BUSY, DIO1 | BUSY 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.
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.
| Class | Behavior |
|---|---|
| A | mandatory, 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. |
| B | adds gateway-beacon-synchronized ping slots for scheduled downlinks at bounded latency. |
| C | RX2 window kept open continuously except while transmitting -- lowest downlink latency, highest power (mains devices). |
| Mode | Provisioning | Notes |
|---|---|---|
| OTAA | DevEUI (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. |
| ABP | DevAddr + NwkSKey + AppSKey burned in | No 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 -> 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.
| MType | Bits |
|---|---|
| Join-Request | 000 |
| Join-Accept | 001 |
| Unconfirmed Data Up | 010 |
| Unconfirmed Data Down | 011 |
| Confirmed Data Up | 100 |
| Confirmed Data Down | 101 |
Shared by Req/Ans in opposite directions.
| CID | Command |
|---|---|
| 0x02 | LinkCheckReq(up) / Ans(down) |
| 0x03 | LinkADRReq(down) / Ans(up) |
| 0x04 | DutyCycleReq/Ans |
| 0x05 | RXParamSetupReq/Ans |
| 0x06 | DevStatusReq/Ans (battery, margin) |
| 0x07 | NewChannelReq/Ans |
| 0x08 | RXTimingSetupReq/Ans |
| 0x09 | TXParamSetupReq/Ans |
| 0x0A | DlChannelReq/Ans |
| 0x0D | DeviceTimeReq/Ans |
1.0.4 adds RekeyInd 0x0B, ADRParamSetupReq 0x0C, ForceRejoinReq 0x0E, RejoinParamSetupReq 0x0F.
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.
| Region | Channels | Data rates | Duty / dwell | Power |
|---|---|---|---|---|
| EU868 | 3 mandatory join channels 868.1/868.3/868.5 MHz, 125 kHz | DR0=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) |
| US915 | 64×125kHz uplink channels (902.3–914.9 MHz, 200kHz spacing) + 8×500kHz | DR0=SF10/125kHz…DR3=SF7, DR4=SF8/500kHz | no 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.
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).
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.
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 }
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.
| Topic | Why | Where |
|---|---|---|
| SX1276 datasheet | The authoritative SX127x register map -- Rev. 0.0 May 2013 through Rev. 5/7. | Semtech SX1276/77/78/79 datasheet |
| SX1261/2 datasheet | The authoritative SX126x opcode/command reference. | Semtech SX1261/2 datasheet, Rev. 1.1 Dec 2017 / 1.2 Jun 2019 / 2.1 |
| AN1200.13 | Time-on-air formula, worked derivations. | Semtech application note AN1200.13 |
| AN1200.85 / AN1200.21 | CAD behavior and tuning. | Semtech application notes |
| LoRaWAN 1.0.x / 1.1 spec + RP002 | MAC layer and regional parameters, the ground truth for anything in LoRaWAN MAC. | LoRa Alliance technical specifications |
| Open-source drivers | Cross-check register/opcode values against independent implementations. | RadioLib, LoRaMac-node, tinygo, RadioHead, arduino-LoRa |
| PHY reverse-engineering literature | The 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 |