Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Transaction Batching

When talking to I2C or SPI devices, a single logical transaction often requires multiple bus operations — for example, writing a register address and then reading back its value. Without batching, each operation is a separate USB round-trip:

Host ──write──▸ USB ──▸ Firmware ──▸ I²C bus    (~1 ms)
Host ◂──ack──── USB ◂── Firmware ◂── I²C bus    (~1 ms)
Host ──read───▸ USB ──▸ Firmware ──▸ I²C bus    (~1 ms)
Host ◂──data─── USB ◂── Firmware ◂── I²C bus    (~1 ms)
                                            Total: ~4 ms

Transaction batching packs all operations into a single USB transfer and preserves one bus-level transaction. For I2C, the firmware sends one START before the first operation, repeated STARTs only when the direction changes, and one STOP after the last operation. It returns all results at once:

Host ──[write, read]──▸ USB ──▸ Firmware ──▸ I²C bus    (~1 ms)
Host ◂──[data]──────── USB ◂── Firmware ◂── I²C bus    (~1 ms)
                                            Total: ~2 ms

For transactions with many operations, this is a 10–50× speedup — USB latency dominates, not bus time.

Using Batched Transactions from the CLI

The gallo CLI exposes batch operations directly. Each --op flag specifies one bus operation.

I2C Batch

Write a register address, then read back 2 bytes:

$ gallo i2c batch -a 0x48 --op write:0x00 --op read:2
Read data (2 bytes):
  0000: 19 80                                              ..

Send a two-byte EEPROM address and three data bytes as one gather write:

$ gallo i2c batch -a 0x50 --op write:0x00,0x10 --op write:0xab,0xcd,0xef
Batch complete (no read data)

The adjacent writes above produce the byte sequence 00 10 ab cd ef under one address phase; there is no STOP or repeated START between them. In general, the operations execute as a single I2C transaction: the bus is not released until the batch completes, and each direction change (write-to-read or read-to-write) produces a documented repeated START and re-addressing. This requires firmware from schema 0.7 or newer. Older firmware executes every operation as a separate transaction.

Available I2C operations

OperationSyntaxDescription
Readread:NRead N bytes from the device
Writewrite:B1,B2,...Write the given bytes (hex 0x.. or decimal)

SPI Batch

Read a JEDEC ID from a SPI flash (command 0x9F, 3-byte response):

$ gallo spi batch --cs 0 --op write:0x9f --op read:3
Read data (3 bytes):
  0000: ef 40 18                                           .@.

Full-duplex transfer followed by a delay:

$ gallo spi batch --cs 1 --op transfer:0x01,0x02,0x03 --op delay:1000 --op read:4
Read data (7 bytes):
  0000: ff ff ff 00 00 00 00                               .......

The --cs flag specifies which GPIO pin is used as chip-select. Valid values are 0..num_gpios, where num_gpios is the count the connected device reports in device/info — not a fixed range baked into the CLI. gallo validates the firmware on startup and checks --cs against that reported count before parsing the operations or transmitting anything, so an out-of-range chip-select drives no pin. A device reporting zero GPIOs produces its own distinct error rather than an out-of-range one.

The firmware asserts CS low before the first operation and deasserts it after the last — all operations run atomically under chip-select.

Available SPI operations

OperationSyntaxDescription
Readread:NClock in N bytes (MISO only)
Writewrite:B1,B2,...Clock out the given bytes (MOSI only)
Transfertransfer:B1,B2,...Full-duplex: send on MOSI, receive same count on MISO
DelayNsdelay:NSDelay for NS nanoseconds (best-effort, firmware resolution)

Using the Lib Crate Directly

If you need batch transactions from Rust code, use the i2c_batch and spi_batch methods on the PicoDeGallo client. These accept typed operation slices (&[I2cBatchOp] / &[SpiBatchOp]) directly — no manual encoding needed.

I2C batch example

use pico_de_gallo_lib::{PicoDeGallo, I2cBatchOp};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pg = PicoDeGallo::new();

    // Build a "write register pointer, then read 2 bytes" transaction
    let ops = [
        I2cBatchOp::Write { data: &[0x00] },       // pointer register
        I2cBatchOp::Read { len: 2 },                // read temperature
    ];

    let result = pg.i2c_batch(0x48, &ops).await?;
    let temp_raw = u16::from_be_bytes([result[0], result[1]]);
    let celsius = (temp_raw >> 4) as f32 * 0.0625;
    println!("Temperature: {celsius:.2} °C");

    Ok(())
}

SPI batch example

use pico_de_gallo_lib::{PicoDeGallo, SpiBatchOp};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pg = PicoDeGallo::new();

    // Read JEDEC ID: send command 0x9F, then read 3 bytes
    let ops = [
        SpiBatchOp::Write { data: &[0x9F] },
        SpiBatchOp::Read { len: 3 },
    ];

    let result = pg.spi_batch(0, &ops).await?;
    println!(
        "JEDEC ID: manufacturer=0x{:02x}, type=0x{:02x}, capacity=0x{:02x}",
        result[0], result[1], result[2]
    );

    Ok(())
}

Transparent Batching via the HAL Crate

The most powerful aspect of transaction batching is that you don’t need to use it explicitly. The HAL crate’s embedded-hal trait implementations use batch endpoints automatically.

When you call I2c::transaction() or SpiDevice::transaction() from the HAL crate, the implementation encodes all operations into a single batch request, sends it over USB, and unpacks the results — all transparently. The firmware also preserves the corresponding bus-level transaction: I2C operations follow the START, repeated-START, and STOP rules above, while SPI operations remain under one chip-select assertion.

This means any existing device driver that uses the standard embedded-hal transaction API gets the bus-level transaction semantics and the performance benefit for free:

#![allow(unused)]
fn main() {
use embedded_hal::i2c::I2c;
use embedded_hal::i2c::Operation;
use pico_de_gallo_hal::Hal;

fn read_tmp102(hal: &Hal) -> Result<f32, Box<dyn std::error::Error>> {
    let mut i2c = hal.i2c();
    let mut buf = [0u8; 2];

    // This is one USB round-trip and one I2C transaction
    i2c.transaction(
        0x48,
        &mut [
            Operation::Write(&[0x00]),   // set pointer to temperature register
            Operation::Read(&mut buf),   // read 2-byte temperature
        ],
    )?;

    let raw = u16::from_be_bytes(buf);
    Ok((raw >> 4) as f32 * 0.0625)
}
}

Similarly, SpiDevice::transaction() batches all SPI operations and manages chip-select automatically:

#![allow(unused)]
fn main() {
use embedded_hal::spi::SpiDevice;
use embedded_hal::spi::Operation;
use pico_de_gallo_hal::Hal;

fn read_spi_jedec(hal: &Hal) -> Result<[u8; 3], Box<dyn std::error::Error>> {
    // `spi_device` returns a Result: the chip-select is checked against
    // the device-reported GPIO count before the pin is driven, so an
    // out-of-range pin is refused here rather than reconfigured.
    let mut spi = hal.spi_device(0)?;  // CS on GPIO 0
    let mut id = [0u8; 3];

    // One USB round-trip: CS asserted, command sent, ID read, CS deasserted
    spi.transaction(&mut [
        Operation::Write(&[0x9F]),
        Operation::Read(&mut id),
    ])?;

    Ok(id)
}
}

Before vs. After

Consider a logical bus transaction containing three operations:

ApproachUSB Round-TripsApprox. Latency
Without batching (3 × write/read)6~6 ms
With batching (1 × batch)2~2 ms

The latency improvement grows with the number of operations. Unlike separate calls, the batch also preserves one bus-level transaction.

Wire Format Details

For those interested in the protocol internals, batch operations use postcard serialization. Each I2cBatchOp or SpiBatchOp is serialized individually using postcard::to_slice, and the resulting bytes are concatenated into the ops field. The firmware decodes them one at a time using postcard::take_from_bytes.

I2C operation encoding (postcard)

VariantEncoding
Read { len }varint 0 (variant index) + varint len
Write { data }varint 1 + varint data length + raw bytes

SPI operation encoding (postcard)

VariantEncoding
Read { len }varint 0 + varint len
Write { data }varint 1 + varint data length + raw bytes
Transfer { data }varint 2 + varint data length + raw bytes
DelayNs { ns }varint 3 + varint ns

The count field in each batch request struct tells the firmware how many operations to expect, providing an additional safety check during decoding.

The response for both I2C and SPI is simply the concatenated read (and transfer) data. The host already knows the expected lengths from the request, so no framing is needed in the response.

Limits

ParameterValue
Maximum operations per batch64 (MAX_BATCH_OPS)
Maximum size of an individual operationNone — bounded only by the totals below
Total bytes a batch may return1014 (MAX_RESPONSE_PAYLOAD)
Total bytes a batch may sendBounded by the whole request frame: 5119 bytes (MAX_REQUEST_FRAME), header and encoding overhead included — see The request-frame ceiling
Protocol packet-buffer/argument bound4096 bytes (MAX_TRANSFER_SIZE)
Plain read/duplex endpoint bound1014 bytes (MAX_RESPONSE_PAYLOAD)
Plain write endpoint bound4096 bytes (MAX_TRANSFER_SIZE)
Direct I²C RPC measurementsRead: 1014 bytes after a 1-byte write; write: no failure through 4096 bytes
Batch measurementsReads totalling 1014 bytes return in full; 1015 is refused with BufferTooLong, on both i2c/batch and spi/batch. A request frame of 5119 bytes is executed; 5120 is refused with BufferTooLong
Demonstrated SPI payload (send direction)Shape-dependent and below 4096; no general ceiling is published
Zero-length I²C writesRejected with ZeroLengthWrite before bus access

If a batch violates these constraints, the firmware returns an error indicating which constraint was violated. Validation failures report the exact zero-indexed operation; an I2C bus failure applies to the atomic transaction as a whole and reports operation index 0. An aggregate-length overflow is not attributable to any single operation, so it reports index 0 as well.

MAX_TRANSFER_SIZE is a buffer and argument bound, not a guarantee that 4096 bytes of application data fit through postcard-rpc and COBS framing. Request shape and response size both matter. The direct-RPC I²C measurements used i2c/write and i2c/write-read; the batch row was measured separately, through i2c/batch and spi/batch. See the measured I²C and SPI evidence in Troubleshooting.

Issue #179 introduced the aggregate returned-data bound for batches. Issue #158 applies the same directional rule to the plain endpoints and every host surface: reads and full-duplex transfers use MAX_RESPONSE_PAYLOAD, while send-only writes use MAX_TRANSFER_SIZE. Issue #186 completed the pair by bounding a batch’s aggregate outgoing bytes against MAX_REQUEST_FRAME. In every case an over-ceiling call is refused before transmission.

The response ceiling

MAX_RESPONSE_PAYLOAD bounds what a batch may send back, not what the firmware can buffer. For I2C only Read counts towards it; for SPI, Read and Transfer both do, because a transfer returns as many bytes as it sends. Write and DelayNs contribute nothing.

The number is a property of the host transport rather than of the device: a postcard-rpc response frame is read into a 1024-byte buffer, of which 7 bytes are the header, 1 is the Result discriminant, and 2 are the payload’s length prefix. The firmware enforces it anyway. Until this was fixed, a batch whose reads totalled between 1015 and 4096 bytes was accepted and executed — for I2C the whole transaction ran, including every Write; for SPI chip-select was asserted and deasserted and the clock was driven — and only then lost its response to truncation. The caller received Comms(Postcard(DeserializeUnexpectedEnd)), which looks like a transport fault and invites a retry that repeats every write.

Refusing up front costs the 1015–4096 range, which never worked anyway, and buys the guarantee that a batch the device accepts is a batch whose result the caller can actually see. Split larger reads across several batches.

The request-frame ceiling

MAX_REQUEST_FRAME (5119) bounds what a batch may send out, and it is the only thing that does. A batch’s Write (and SPI Transfer) bytes all travel in one request frame, and that frame has to fit in the firmware’s 5120-byte receive buffer, less one byte that an exactly-filling frame cannot use. Wire Protocol derives both numbers term by term.

Unlike the response ceiling, this one is not the firmware’s to enforce. An over-ceiling frame is discarded by postcard-rpc’s receiver before any handler runs, so the device cannot refuse what it never sees. The bound is applied host-side, before transmission, in check_i2c_batch_ops and check_spi_batch_ops; an over-ceiling batch is rejected with BufferTooLong and failed_op = 0, the same aggregate-overflow convention the response ceiling uses.

It bounds the aggregate, not any single operation. Two consequences worth stating plainly:

  • A batch of individually modest writes can still overrun it. The hardware measurement below tripped it with eight 635-byte writes, none of them anywhere near MAX_TRANSFER_SIZE.
  • Conversely, a single batch Write is allowed past MAX_TRANSFER_SIZE, which a plain i2c_write would refuse. That is deliberate: a batch write streams straight out of the received frame and never enters the firmware’s 4096-byte scratch buffer, so the argument bound does not apply to it. The frame is its real limit.

Sizing a batch exactly means accounting for postcard overhead — a variant byte and a length varint per operation, plus a header, a selector byte and two more varints for the request. pico-de-gallo-lib exports i2c_batch_request_frame_len and spi_batch_request_frame_len so callers can compute it rather than model it. Callers who would rather not: keep a batch’s total payload comfortably under 5000 bytes, or split it.

Why the bound is slightly conservative

The frame’s header is 13 bytes until the client has received its first reply and 7 bytes afterwards, because postcard-rpc narrows the key width only once a reply comes back (see Wire Protocol). The host cannot ask which state it is in — the field is private to postcard-rpc — so the bound assumes the wider header. On a connection that has already received a reply, six more payload bytes would in fact have fitted.

That is the price of an answer that does not depend on connection state. The alternative is worse: a bound computed from the narrow header would accept a batch that is silently dropped whenever it happens to be the first request a process sends.

Measurements

Measured through pico-de-gallo-lib’s i2c_batch on board 49742081C885AC69 (hw-rev2), one Write operation against a non-responding address, from a fresh process so the header is the wide one:

PayloadEncoded opsRequest frameResult before #186Result now
509951025119NoAcknowledge — the batch ranunchanged
510051035120Timeout after 5 s — the frame never arrivedBufferTooLong, immediately

Re-measured on firmware firmware-v0.11.0-88-gfcb38de3a1ff for issue #186, with the host guard deliberately stubbed out to exercise the device alone; the edge was identical to the original -79-gc3c6a3e07bec run. The multi-operation arm, eight writes against the same address, tripped at 635 bytes each and passed at 634.

The pre-#186 failure was worse on SPI than on I²C. A batch carrying no DelayNs operation is bounded by the firmware’s 30-minute handler ceiling rather than the ordinary call timeout, so an over-ceiling spi/batch left the caller waiting over half an hour — confirmed still running after 90 seconds in the same control arm — for a request the device had never received. Nothing was driven in either case: the bus stayed idle and chip-select never moved, which is why this is a diagnosability defect rather than a corruption one.