Expand description
COBS-framed UART transport for Consortium IPC.
UART is the wired transport category with a Type-B (transport-coupled)
doorbell: byte arrival is itself the notification, so there is no
Doorbell and no Side — direction is physical.
Wire format, per frame:
COBS( chan: u8 | payload[0..=MTU] | crc32: u32 LE ) 0x00§Why the framing looks like this
A UART hands over bytes, not messages, and it will happily hand over the wrong ones: a peer that reboots mid-frame, a line that glitches at power-up, a baud mismatch. Each layer of the frame answers one of those.
- COBS removes every
0x00from the frame body, which reserves0x00as a delimiter that cannot occur inside a frame. A receiver that has lost sync only has to hunt for the next zero byte to be aligned again — no length prefix to mistrust, no escape-state machine to get stuck in. - CRC32 over
chan | payloadcatches the corruption that framing cannot: flipped bits, a dropped byte, a peer transmitting at the wrong rate. chancarries theChancontract for serial links. One transport instance owns one logical channel and drops frames stamped with anybody else’s byte.
Frame-level corruption is deliberately not an error: the receiver logs the
bad frame, discards it, and keeps waiting for the next delimiter. Error
reports only what a caller can act on — IO failure, EOF, an oversized payload, an
under-sized buffer.
§No doorbell, no side, no handshake
Byte arrival is the notification, so there is no Doorbell type parameter to
supply and no interrupt to forward. Direction is physical — the TX and RX wires —
so there is no Side either. Connect is implemented as a documented no-op,
which keeps generated bring-up code uniform across transports: it emits the same
connect(..) call whether the link underneath is shared memory or a wire.
§Buffers
Like every Consortium transport this one does not allocate; the caller supplies
&'static mut [u8] scratch. UART needs two buffers, and their sizes are not
the MTU — COBS expansion and the frame accumulator both add overhead. Derive them
rather than guessing:
| Half | Size | Holds |
|---|---|---|
UartTransportTx | tx_buf_len(mtu) | frame body, then the encoded frame |
UartTransportRx | rx_buf_len(mtu) | a read chunk, then a frame accumulator |
Both constructors verify the buffer against the MTU and return
Error::BufferTooSmall rather than trusting the caller.
§Backend selection
The transport is written once, against this crate’s UartRead/UartWrite
seam, and the std feature picks which IO traits that seam is
blanket-implemented for:
- without
std(firmware) — everyembedded_io_async::ReadandWrite, the async serial traits Embassy and modern HAL UART drivers implement. - with
std(Linux/host) — every TokioAsyncRead/AsyncWritethat isUnpin + Send.
The seam exists because under std transport futures must be Send (see
consortium_ipc::MaybeSend), and embedded_io_async’s unnameable async fn
futures cannot guarantee that generically. Carrying the MaybeSend bounds on
the seam, as the FDCAN transport does with CanTx/CanRx, keeps one transport
impl for both; only the two small adapters in io differ.
Optional modules supply the concrete IO: unix (unix feature) opens
/dev/tty* devices for the Tokio family, and imx9 / stm32mp2 (chip
features) re-export a chip HAL’s interrupt-buffered driver for the
embedded-io-async family. With no chip feature, any driver implementing the
embedded-io-async traits works directly.
§Firmware: interrupt-buffered receive
The chip HALs’ BufferedUart receiver is interrupt-driven: its IRQ handler
drains the receive FIFO into a ring so no bytes are lost between the wake and
the next poll of the transport’s recv. Per repository convention, this crate
does not install #[interrupt] handlers — the application owns the vector
table and forwards the UART interrupt into on_buffered_interrupt::<T>,
parameterized with the same peripheral singleton the driver took. Enable the
HAL’s rt feature for the #[interrupt] macro and PAC vector table.
// `stm32mp25x` feature, plus `consortium-hal-stm32mp2/rt`.
use consortium_ipc::Chan;
use consortium_ipc_transport_uart::{
UartTransport, rx_buf_len, tx_buf_len,
stm32mp2::{BufferedUart, Config, on_buffered_interrupt},
};
use consortium_runtime_mcu::static_buf;
const MTU: usize = 256;
// Ring storage for the IRQ handler, plus the transport scratch buffers.
static_buf!(RX_RING, 512);
static_buf!(RX_SCRATCH, rx_buf_len(MTU));
static_buf!(TX_SCRATCH, tx_buf_len(MTU));
// The platform must leave USART2 clocked and pinned out.
let p = consortium_hal_stm32mp2::init();
let uart = BufferedUart::new(p.USART2, KERNEL_HZ, Config::default(), RX_RING.init([0; 512]));
let (tx_wire, rx_wire) = uart.split();
let transport = UartTransport::new(
Chan::new(0)?,
rx_wire,
tx_wire,
RX_SCRATCH.init([0; rx_buf_len(MTU)]),
TX_SCRATCH.init([0; tx_buf_len(MTU)]),
MTU,
)?;
let (tx, rx) = transport.split();
#[interrupt]
fn USART2() {
on_buffered_interrupt::<consortium_hal_stm32mp2::peripherals::USART2>();
}The i.MX9 flow is identical with imx9::BufferedUart and an LPUARTn vector.
§Example
use consortium_ipc_transport_uart::{UartTransport, rx_buf_len, tx_buf_len};
const MTU: usize = 256;
static mut TX: [u8; tx_buf_len(MTU)] = [0; tx_buf_len(MTU)];
static mut RX: [u8; rx_buf_len(MTU)] = [0; rx_buf_len(MTU)];
let transport = UartTransport::new(chan, reader, writer, rx_buf, tx_buf, MTU)?;
let (tx, rx) = transport.split();§Features
Defaults to std. Firmware builds want default-features = false.
std—alloc, Tokio IO bounds,Sendfutures,tracingdiagnostics.alloc— linkallocthroughconsortium-ipc.defmt— route diagnostics todefmtand derivedefmt::FormatonError(and forwarddefmtto a selected chip HAL).unix— impliesstd; adds theunixmodule (open,pty_pair) overtokio-serial.mimx93/mimx95/mimx952— re-exportconsortium_hal_imx9::uartasimx9.stm32mp21x/stm32mp23x/stm32mp25x— re-exportconsortium_hal_stm32mp2::uartasstm32mp2.
§Testing
cargo test -p consortium-ipc-transport-uart # Tokio family
cargo test -p consortium-ipc-transport-uart --no-default-features # embedded-io family
cargo test -p consortium-ipc-transport-uart --features unix # pseudo-terminals (Linux)The protocol is one impl, so the first two exercise the same code; run both anyway
when touching the protocol or the io adapters, since each adapter is only
compiled under its own feature.
Re-exports§
pub use embedded_io_async;pub use error::Error;pub use frame::FRAME_OVERHEAD;pub use frame::max_frame_len;pub use frame::rx_buf_len;pub use frame::tx_buf_len;pub use io::UartRead;pub use io::UartWrite;pub use transport::UartTransport;pub use transport::UartTransportRx;pub use transport::UartTransportTx;pub use embedded_io_async;
Modules§
- io
- The byte-IO seam the transport is written against.
Structs§
- Uart
Transport - Full-duplex UART transport pairing a send and a receive half.
- Uart
Transport Rx - Receive half of a UART transport: accumulates wire bytes, deframes and
validates them. Pass it to
Channel::<Rx, …>. - Uart
Transport Tx - Send half of a UART transport: frames payloads and writes them to the
UART TX wire. Pass it to
Channel::<Tx, …>.
Enums§
- Error
- Errors surfaced by
UartTransportTxandUartTransportRx.
Constants§
- FRAME_
OVERHEAD - Bytes of framing around the payload inside the COBS region: the channel byte plus the CRC32 trailer.
Functions§
- max_
frame_ len - Maximum encoded length of one frame on the wire, including the trailing
0x00delimiter. COBS overhead is one byte per 254 encoded, plus one. - rx_
buf_ len - Required size of the buffer passed to
UartTransportRx::new: a read-chunk region plus an accumulator able to hold one maximally encoded frame (without its delimiter). - tx_
buf_ len - Required size of the scratch buffer passed to
UartTransportTx::new: staging space for the frame body plus the encoded frame.