05 · UART Communication¶
Before an ECU can talk to the car, it needs to talk to you. A UART serial
console is the firmware engineer's stethoscope: boot messages, sensor
readings, error dumps — during bring-up of a new board, printf over UART
is often the only window into a live system, and in the lab it stays
invaluable long after CAN is up. This module covers the S32K's LPUART
peripheral, baud-rate math, framing, and a polled "hello ECU" you can
extend for the rest of the course.
UART in 60 seconds¶
UART (Universal Asynchronous Receiver/Transmitter) sends bytes serially with no shared clock — both sides just agree on a baud rate in advance. A classic 8N1 frame (8 data bits, no parity, 1 stop bit) on the wire:
idle(high) │ start(0) │ b0 b1 b2 b3 b4 b5 b6 b7 │ stop(1) │ idle...
1 bit 8 data bits (LSB first) 1 bit
10 bit-times per byte — so at 115200 baud you move at most 11 520 bytes/s. The receiver detects the falling start edge, then samples the middle of each bit period. Because each side times bits from its own clock, total clock mismatch must stay under roughly ±2–3% per frame — which is why baud math (below) matters, and one reason CAN (module 7) uses continuous resynchronization instead of trusting frame-length timing alone.
On the S32K144EVB, LPUART1 (pins PTC6 = RX, PTC7 = TX, mux ALT2) is
routed through the OpenSDA debug chip, which presents it to your PC as a
normal USB-serial port — connect at 115200-8N1 with any terminal (PuTTY,
screen, S32DS terminal view).
Baud-rate math¶
LPUART divides its functional clock (chosen in the PCC, module 3) down
to the bit rate using two fields in the BAUD register: SBR (a 13-bit
divider) and OSR (oversampling ratio, commonly 16×):
With the functional clock on FIRCDIV2 at 48 MHz, OSR+1 = 16:
| Target baud | SBR = 48 MHz / (16 × baud) | Actual baud | Error |
|---|---|---|---|
| 9 600 | 312.5 → 313 | 9 585 | −0.16 % |
| 115 200 | 26.04 → 26 | 115 385 | +0.16 % |
| 921 600 | 3.26 → 3 | 1 000 000 | +8.5 % — broken! |
That last row is the lesson: SBR must be an integer, so not every baud rate is reachable from every clock — always compute the actual rate and check the error is well under ~2%. (For very high rates you'd lower OSR or pick a different functional clock.)
Polled "hello ECU" (register-level)¶
#include "S32K144.h"
void lpuart1_init(void) /* 115200-8N1 from 48 MHz FIRCDIV2 */
{
/* Clocks: PORT C for pins, LPUART1 with functional clock = FIRCDIV2 */
PCC->PCCn[PCC_PORTC_INDEX] |= PCC_PCCn_CGC_MASK;
PCC->PCCn[PCC_LPUART1_INDEX] = PCC_PCCn_PCS(3) /* FIRCDIV2 */
| PCC_PCCn_CGC_MASK;
/* Pins: PTC6 = LPUART1_RX, PTC7 = LPUART1_TX (ALT2) */
PORTC->PCR[6] = PORT_PCR_MUX(2);
PORTC->PCR[7] = PORT_PCR_MUX(2);
/* Baud: OSR = 15 (16x), SBR = 26 → 115385 baud (+0.16%) */
LPUART1->BAUD = LPUART_BAUD_OSR(15) | LPUART_BAUD_SBR(26);
/* 8N1 is the reset default of CTRL; just enable TX and RX */
LPUART1->CTRL = LPUART_CTRL_TE_MASK | LPUART_CTRL_RE_MASK;
}
void uart_putc(char c)
{
/* Wait until TX Data register Empty flag says there's room */
while ((LPUART1->STAT & LPUART_STAT_TDRE_MASK) == 0u) { }
LPUART1->DATA = (uint8_t)c;
}
void uart_puts(const char *s)
{
while (*s != '\0') {
if (*s == '\n') uart_putc('\r'); /* terminals like CRLF */
uart_putc(*s++);
}
}
int main(void)
{
clocks_init(); /* module 3 */
lpuart1_init();
uart_puts("hello ECU\n");
for (;;) { }
}
Receiving, polled — echo everything back:
for (;;) {
if ((LPUART1->STAT & LPUART_STAT_RDRF_MASK) != 0u) { /* byte arrived? */
char c = (char)LPUART1->DATA; /* read clears flag */
uart_putc(c); /* echo */
}
/* ...other non-blocking work continues here... */
}
The two flags to memorize: TDRE (transmit data register empty — safe to
write DATA) and RDRF (receive data register full — a byte is waiting).
Polled I/O like this is fine for bring-up and low traffic; the moment RX
must never miss bytes while the CPU is busy, you graduate to interrupts
(module 8's mindset) or DMA (Level 2).
SDK-style equivalent
lpuart_state_t uartState;
const lpuart_user_config_t uartCfg = {
.baudRate = 115200u, .parityMode = LPUART_PARITY_DISABLED,
.stopBitCount = LPUART_ONE_STOP_BIT,
.bitCountPerChar = LPUART_8_BITS_PER_CHAR,
.transferType = LPUART_USING_INTERRUPTS,
};
LPUART_DRV_Init(1u, &uartState, &uartCfg);
LPUART_DRV_SendDataPolling(1u, (const uint8_t *)"hello ECU\r\n", 11u);
Why UART matters in real ECU work¶
- Bring-up. A new board's first milestone is always "prints over UART" — it proves clocks, pin mux, flash, and startup all work before more complex peripherals exist.
- Debug channel that survives. Production ECUs often keep a UART header (unpopulated) on the PCB for factory tests and field returns analysis.
- It's the transport under other things. LIN (Level 3) is essentially a UART with rules; many GPS modules, Bluetooth modules, and modem AT interfaces are UARTs.
- But it's not CAN. UART is point-to-point, unaddressed, unacknowledged, and error-unchecked. The differences are exactly what module 7 is about — after this module, you'll appreciate why CAN's extra machinery exists.
If garbage appears on your terminal: 1) baud mismatch (check actual-rate math and the true functional clock), 2) wrong pin mux, 3) crossed TX/RX, 4) missing common ground — in that order of likelihood.
Cheat sheet¶
| Item | Notes |
|---|---|
| Frame 8N1 | start(0) + 8 data LSB-first + stop(1) = 10 bit-times per byte |
| Baud formula | baud = func_clk / ((OSR+1) × SBR) — compute actual, keep error < ~2% |
| EVB console | LPUART1, PTC6 RX / PTC7 TX (ALT2), via OpenSDA USB → 115200-8N1 |
| PCC for LPUART | Needs PCS (functional clock, e.g. FIRCDIV2) and CGC |
| TDRE flag | TX register empty → OK to write DATA |
| RDRF flag | RX register full → read DATA (read clears it) |
| Enable | CTRL = TE | RE after BAUD is set |
| SDK calls | LPUART_DRV_Init, LPUART_DRV_SendDataPolling, LPUART_DRV_ReceiveDataPolling |
| Garbage output | Baud/clock mismatch first, then mux, then wiring |
How It Actually Works¶
The LPUART (Low Power UART) module is built around an oversampling receiver, not a simple edge-triggered shift register. The baud-rate generator divides the peripheral clock down by SBR (baud rate divisor) and then further by an oversampling ratio (OSR, typically 16), and the receiver samples the incoming line at that oversampled rate — sampling near the middle of each bit period rather than right at the edge. This is why LPUART tolerates a few percent of baud-rate mismatch between transmitter and receiver: as long as the accumulated timing drift across one byte (start bit + 8 data bits + stop bit) doesn't push the sample point past the bit boundary, the frame still decodes correctly.
Framing works because the line idles high and the start bit is a guaranteed high-to-low transition — the receiver's edge detector uses that transition to reset its bit-timing counter to zero, which is what re-synchronizes it every single byte (this is why UART needs no shared clock line, unlike SPI). If the actual bit-time is off far enough that the stop bit's expected sample lands on a low level, the hardware sets a Framing Error flag — this is a real electrical measurement (sampling logic state at the expected stop-bit time), not a checksum.
At the silicon level, LPUART's FIFO and DMA request lines let the transmit/receive shift registers hand off to memory without the CPU touching every byte: a hardware watermark comparator inside LPUART asserts a DMA request signal only when the byte count in the FIFO crosses the configured threshold, so the eDMA engine — not an interrupt handler — moves bytes in and out under its own bus mastership.
(Described from the S32K reference manual's LPUART chapter; not measured on physical silicon in this course.)
Exercise¶
Build a tiny command console: your firmware prints a > prompt, reads a
line (polled RX, echo as you type, handle backspace by printing "\b \b"),
and executes three commands — led on, led off (module 4's red LED) and
id, which prints a device name and firmware version string. Structure it
as a console_poll() function called from the main loop that never blocks
(it processes at most one received character per call and keeps its own
line buffer + state). Bonus (paper exercise if no hardware): at 115200 baud,
how many milliseconds does echoing a 40-character line cost in pure wire
time, and why does a non-blocking design make that cost irrelevant to the
rest of the loop?