LPSPI & LPI2C — Off-Chip Peripherals¶
An ECU is rarely one chip. Around the S32K sit external EEPROMs holding calibration, pressure and inertial sensors, LED drivers, motor bridge gate drivers, and — in almost every modern automotive design — a System Basis Chip (SBC) that owns the CAN transceiver, the voltage regulators, and an external watchdog. Most of them speak SPI or I²C. This module covers the S32K's LPSPI and LPI2C peripherals, the two transaction patterns you will write a hundred times, and the failure modes that make off-chip buses disproportionately common sources of field faults.
LPSPI: fast, simple, chip-select-driven¶
SPI is four wires — SCK, SOUT (MOSI), SIN (MISO), and one PCS chip select per device — full duplex, no addressing, no acknowledgement. Every byte you send simultaneously receives a byte. The S32K144 has three LPSPI instances; each supports four PCS lines.
#include "lpspi_master_driver.h"
#define INST_LPSPI0 0u
static lpspi_state_t spiState;
static const lpspi_master_config_t spiCfg = {
.bitsPerSec = 4000000u, /* 4 MHz — stay inside the
slave's spec, not the MCU's */
.whichPcs = LPSPI_PCS0,
.pcsPolarity = LPSPI_ACTIVE_LOW,
.isPcsContinuous = false,
.bitcount = 8u,
.lpspiSrcClk = 48000000u, /* from PCC: FIRCDIV2 */
.clkPhase = LPSPI_CLOCK_PHASE_1ST_EDGE,
.clkPolarity = LPSPI_SCK_ACTIVE_HIGH, /* CPOL = 0, CPHA = 0 */
.lsbFirst = false,
.transferType = LPSPI_USING_INTERRUPTS,
.callback = NULL,
.callbackParam = NULL,
};
void spi_init(void)
{
LPSPI_DRV_MasterInit(INST_LPSPI0, &spiState, &spiCfg);
}
clkPolarity and clkPhase together are the classic SPI mode. Get
them wrong and you read plausible-looking garbage — usually every byte
shifted by one bit. The datasheet timing diagram is the authority, and
mode 0 (CPOL 0 / CPHA 0) or mode 3 (CPOL 1 / CPHA 1) covers most
automotive parts.
A blocking register read from an SPI device — the shape of nearly every SPI transaction you will write:
/* Read one register: send [cmd | addr], then a dummy byte to clock
the answer back. Full duplex means rx[0] is garbage by definition. */
status_t spi_read_reg(uint8_t addr, uint8_t *value)
{
uint8_t tx[2] = { (uint8_t)(0x80u | addr), 0xFFu }; /* 0x80 = read */
uint8_t rx[2] = { 0u, 0u };
status_t st = LPSPI_DRV_MasterTransferBlocking(INST_LPSPI0, tx, rx,
2u, 10u /* ms */);
if (st != STATUS_SUCCESS) {
return st; /* timeout — do NOT use rx[] */
}
*value = rx[1];
return STATUS_SUCCESS;
}
Chip select is a design decision
isPcsContinuous = true holds PCS asserted across a multi-frame
transfer — required by devices that latch on the rising edge of CS,
such as most SPI EEPROMs during a page write. Devices that expect CS
to toggle per byte need it false. Guessing costs an afternoon.
The SBC: SPI that can reset your ECU¶
A System Basis Chip (NXP's UJA116x/FS26 family and equivalents) is the device the S32K talks to most and forgives least. Over one SPI link it typically provides:
- the CAN transceiver, including standby and sleep modes — this is how module 5's low-power design actually silences the bus;
- the 5 V and 3.3 V regulators feeding the MCU;
- a window watchdog that must be triggered over SPI within a timing window, or it resets the MCU and can disable outputs.
Two consequences for firmware design. First, SPI to the SBC is
safety-relevant: a stuck SPI bus means the watchdog is not being
served, which means a reset. Second, initialization order matters — the
SBC usually starts in a restricted mode with a short watchdog period, and
firmware must configure it before that window expires. Read the SBC's
state diagram before writing a line of code; it is the real boot sequence
of the ECU, not the one in startup.S.
LPI2C: two wires, more ways to fail¶
I²C trades wires for complexity: SDA and SCL, open-drain with pull-ups, 7-bit addressing, and a per-byte acknowledge. The S32K144 has two LPI2C instances that can act as master or slave.
#include "lpi2c_driver.h"
#define INST_LPI2C0 0u
#define SENSOR_ADDR 0x48u /* 7-bit address, not shifted */
static lpi2c_master_state_t i2cState;
static const lpi2c_master_user_config_t i2cCfg = {
.slaveAddress = SENSOR_ADDR,
.is10bitAddr = false,
.operatingMode = LPI2C_FAST_MODE, /* 400 kHz */
.baudRate = 400000u,
.transferType = LPI2C_USING_INTERRUPTS,
.masterCallback = NULL,
.callbackParam = NULL,
};
void i2c_init(void)
{
LPI2C_DRV_MasterInit(INST_LPI2C0, &i2cCfg, &i2cState);
}
The universal I²C register read is write the register address without a stop, then read — a repeated start:
status_t i2c_read_reg16(uint8_t reg, uint16_t *value)
{
uint8_t rx[2];
status_t st;
/* sendStop = false → repeated START, keeps the bus owned */
st = LPI2C_DRV_MasterSendDataBlocking(INST_LPI2C0, ®, 1u,
false, 10u);
if (st != STATUS_SUCCESS) { return st; }
st = LPI2C_DRV_MasterReceiveDataBlocking(INST_LPI2C0, rx, 2u,
true, 10u);
if (st != STATUS_SUCCESS) { return st; }
*value = (uint16_t)(((uint16_t)rx[0] << 8) | rx[1]);
return STATUS_SUCCESS;
}
The driver's status codes tell you what kind of failure occurred, and each deserves different handling:
| Status | Meaning | Reasonable reaction |
|---|---|---|
STATUS_SUCCESS |
Transfer complete | Use the data |
STATUS_BUSY |
Transfer still running | Poll again (non-blocking API) |
STATUS_TIMEOUT |
Deadline expired | Abort, count, retry with backoff |
STATUS_I2C_RECEIVED_NACK |
Slave did not acknowledge | Device absent or wrong address |
STATUS_I2C_ARBITRATION_LOST |
Another master won | Retry; on a single-master bus this means noise |
STATUS_I2C_BUS_BUSY |
SDA/SCL not idle | Suspect a hung slave — see below |
Recovering a hung I²C bus¶
I²C's worst failure is unique among embedded buses: if the master is reset mid-read, the slave can be left driving SDA low, waiting to finish clocking out a byte. No amount of software re-initialization fixes it, because SDA is stuck low and START cannot be generated. The standard recovery is to bit-bang up to nine SCL pulses so the slave finishes its byte, then issue a manual STOP:
/* Mux SCL/SDA back to GPIO (module 4), then: */
void i2c_bus_recover(void)
{
for (uint8_t i = 0u; i < 9u; i++) {
PTA->PCOR = (1u << I2C_SCL_PIN); delay_us(5u);
PTA->PSOR = (1u << I2C_SCL_PIN); delay_us(5u);
if ((PTA->PDIR & (1u << I2C_SDA_PIN)) != 0u) {
break; /* slave released SDA */
}
}
/* Manual STOP: SDA low → high while SCL is high */
PTA->PCOR = (1u << I2C_SDA_PIN); delay_us(5u);
PTA->PSOR = (1u << I2C_SCL_PIN); delay_us(5u);
PTA->PSOR = (1u << I2C_SDA_PIN); delay_us(5u);
/* Re-mux to LPI2C and re-init the driver */
}
Every production I²C driver should have this function. If yours does not, the first field return will teach you why.
Automotive concerns¶
- Timeout everything. Never call a blocking transfer with an infinite timeout. An external device that stops responding must degrade your function, not hang your scheduler. Module 9's "timeout every external dependency" rule applies to on-board buses exactly as it does to CAN.
- Never trust a single read of a safety-relevant value. Read twice and compare, or use the device's own CRC/parity if it has one. SPI has no error detection on the wire — a corrupted MISO byte is indistinguishable from a real one.
- DMA channels are shared.
LPSPI_USING_DMAneedsrxDMAChannelandtxDMAChannelfields, and those channels come out of module 2's allocation table. An SPI driver that silently claims channel 0 will collide with the ADC scan of a colleague's module. - Bus length and EMC. SPI at 10 MHz across a long PCB run radiates and fails EMC testing. Automotive designs frequently run SPI slower than the silicon allows for exactly this reason — slow and passing beats fast and re-spun.
- Keep the SBC watchdog out of interrupt context. Like the internal WDOG, trigger it from supervised application code. An SPI watchdog serviced from a timer ISR supervises the timer, nothing more.
- Pull-up sizing is a system property. Weak pull-ups plus long traces round off I²C edges until 400 kHz stops working over temperature. If I²C is intermittently unreliable, look at the scope before the code.
Cheat sheet¶
| Item | LPSPI | LPI2C |
|---|---|---|
| Wires | SCK, SOUT, SIN, PCS0–3 | SDA, SCL (open drain + pull-ups) |
| Addressing | One chip select per device | 7-bit (or 10-bit) address on the wire |
| Duplex | Full — every TX byte returns an RX byte | Half — direction set per transfer |
| Typical rate | 1–10 MHz | 100 kHz / 400 kHz / 1 MHz |
| Ack / errors | None on the wire | Per-byte ACK/NACK, arbitration detection |
| Init | LPSPI_DRV_MasterInit |
LPI2C_DRV_MasterInit |
| Blocking transfer | LPSPI_DRV_MasterTransferBlocking |
..._MasterSendDataBlocking / ..._MasterReceiveDataBlocking |
| Repeated start | n/a — use isPcsContinuous |
sendStop = false on the write phase |
| DMA | LPSPI_USING_DMA + rx/tx channel fields |
LPI2C_USING_DMA + dmaChannel |
| Signature failure | Wrong CPOL/CPHA → bit-shifted garbage | Slave holds SDA low → bus hang |
| Recovery | Re-init, re-transfer | Nine SCL pulses + manual STOP |
| Typical devices | SBC, external EEPROM, gate drivers | Temperature/pressure sensors, EEPROMs |
How It Actually Works¶
LPSPI is a synchronous shift-register protocol at the silicon level: the master's SCK output is literally the clock driving both the master's and slave's shift registers on every edge, so a bit shifts out of MOSI and a bit shifts into MISO in the same clock edge — this is why SPI has no framing bits and no baud-rate tolerance requirement (unlike UART): the clock line is the timing reference, generated and consumed in lock-step.
CPOL/CPHA aren't arbitrary mode numbers — they configure exactly which clock edge the shift register captures data on versus which edge it changes the output. CPHA=0 samples on the first (leading) clock edge, meaning data must already be valid before the first clock transition — which is why CPHA=0 slaves drive their MISO line the instant chip-select goes active, before any clock edges occur at all. Getting CPOL/CPHA mismatched between master and slave isn't a "communication error" in the CAN/UART sense — it's a hardware sampling-edge misalignment that silently shifts every captured bit by one position, so data looks plausible but is systematically wrong.
LPI2C is different in kind: it's a wired-AND, open-drain, multi-master bus where SCL and SDA are pulled high by external resistors and any device can only pull them low. Clock stretching — a slave holding SCL low to pause the master — works purely because of this open-drain electrical topology: the slave doesn't need to "know" the master's timing, it just holds the shared wire low, and the master's own hardware must sample SCL and wait for it to actually go high (not just release it) before proceeding, which is a real electrical readback, not a protocol convention. Arbitration on a multi-master I2C bus works the same wired-AND way CAN's does — a master driving high while another drives low sees the bus at low and knows it lost, so it stops driving.
(Described from the S32K reference manual's LPSPI/LPI2C chapters; not measured on physical silicon in this course.)
Exercise¶
Write a defensive driver for one external device of your choosing and
prove it survives abuse. (1) Implement dev_read_reg() and
dev_write_reg() over LPSPI or LPI2C with a bounded timeout, returning
status_t — no void returns, no silent failures. (2) Add a
consistency check: read a known constant register (most sensors have a
device-ID register) at init, and refuse to mark the device available if it
does not match — this single check catches wrong addresses, wrong SPI
mode, and unpopulated parts. (3) Add fault handling: three consecutive
failures set a DTC, mark the reading invalid, and substitute a safe
default; a later success clears the fault after five good reads. (4) Test
it by physically disconnecting the device mid-operation, and confirm your
10 ms task still meets its deadline while the device is gone. (5) For I²C
only: pull SDA low with a jumper to a ground pin to simulate a hung slave,
and confirm your recovery routine restores the bus. If you do not have the
hardware, implement all of the above against a stub layer on your PC that
returns scripted status codes, including STATUS_I2C_RECEIVED_NACK and
STATUS_TIMEOUT — the fault logic is the part being graded.