The L0 foundation of a layered, no_std, no-alloc automotive diagnostic protocol
suite (DoIP, UDS, later SOME/IP). It provides the shared zero-copy codec traits and
big-endian byte-level leaf helpers that every protocol core (L1) implements — and
nothing else: no framing, no concrete message types, no owned forms, no alloc.
- Zero-copy decode — [
Decode] borrows directly from the input buffer; no allocation, no intermediate copies. no_std/ no-alloc — builds on bare-metal targets (verified in CI againstthumbv6m-none-eabi).- Nested encode without a staging buffer — [
Encode::encoded_size] is exact and correct by construction (the default counts bytes through an infallible sink), so an outer protocol can size a header and serialize an inner value directly into the same buffer. - Generic, ergonomic errors — L1 crates keep their own rich error enum; leaf helpers
and trait defaults construct errors generically via small
Frombounds, so calls compose through?with no turbofish.
L0 defines no protocol error type. It defines two tiny error fragments —
[Incomplete] (a read ran out of bytes) and [TrailingBytes] (bytes remained after
an exact decode) — and the traits require the L1 error to be constructible From
them. This preserves each L1 crate's rich, typed error enum while letting shared
trait defaults and leaf helpers construct errors generically. Encode-side I/O
failures surface as [embedded_io::ErrorKind]; the [Encode] error bound is
From<embedded_io::ErrorKind>. Because the L1 error implements these From bounds,
helper calls (read_u8(buf)?, write_u16_be(w, x)?) compose through ? with no
turbofish and no generic error parameter at the call site.
[Decode::decode] consumes from the front of the buffer and returns the
remainder, so nested and sequential decodes thread the remainder along:
use automotive_wire_codec::{read_u8, read_u16_be, Decode, Incomplete, TrailingBytes};
struct SomeType(u8);
#[derive(Debug)]
enum DemoErr { Incomplete(Incomplete), TrailingBytes(TrailingBytes) }
impl From<Incomplete> for DemoErr { fn from(e: Incomplete) -> Self { DemoErr::Incomplete(e) } }
impl From<TrailingBytes> for DemoErr { fn from(e: TrailingBytes) -> Self { DemoErr::TrailingBytes(e) } }
impl<'a> Decode<'a> for SomeType {
type Error = DemoErr;
fn decode(buf: &'a [u8]) -> Result<(Self, &'a [u8]), Self::Error> {
let (b, rest) = read_u8(buf)?;
Ok((SomeType(b), rest))
}
}
fn run(buf: &[u8]) -> Result<(), DemoErr> {
let (a, rest) = read_u8(buf)?;
let (b, rest) = read_u16_be(rest)?;
let (c, rest) = SomeType::decode(rest)?; // nested Decode composes the same way
let _ = (a, b, c, rest);
Ok(())
}
run(&[0u8, 1, 2, 3, 4]).unwrap();[Decode::decode_exact] instead requires the whole buffer to be consumed, returning
[TrailingBytes] otherwise — use it at a message boundary where framing has already
delimited the frame. L0 has no opinion on framing; that is an L1 concern.
Because [Encode::encoded_size] is separate from [Encode::encode] and
&mut [u8] is an [embedded_io::Write] sink, an outer protocol serializes an inner
value directly into one buffer — no second allocation or copy:
use automotive_wire_codec::{write_u32_be, Encode};
struct Header { payload_len: u32 }
impl Header {
fn new(payload_len: u32) -> Self { Header { payload_len } }
}
impl Encode for Header {
type Error = embedded_io::ErrorKind;
fn encoded_size(&self) -> Result<usize, Self::Error> { Ok(4) }
fn encode(&self, writer: &mut impl embedded_io::Write) -> Result<usize, Self::Error> {
write_u32_be(writer, self.payload_len)
}
}
struct Inner(u32);
impl Encode for Inner {
type Error = embedded_io::ErrorKind;
fn encoded_size(&self) -> Result<usize, Self::Error> { Ok(4) }
fn encode(&self, writer: &mut impl embedded_io::Write) -> Result<usize, Self::Error> {
write_u32_be(writer, self.0)
}
}
let inner = Inner(42);
let mut tx_buf = [0u8; 8];
let payload_len = inner.encoded_size()?;
let header = Header::new(payload_len as u32);
let mut writer: &mut [u8] = &mut tx_buf; // one buffer
let mut total = header.encode(&mut writer)?; // writes header, advances `writer`
total += inner.encode(&mut writer)?; // writes inner into the remainder
assert_eq!(total, 8);
Ok::<(), embedded_io::ErrorKind>(())The closed-form encoded_size overrides matter here: the default
encoded_size counts by running encode against a counting sink, so sizing a
nested message with the default re-encodes each subtree once per level. Keep
closed-form overrides on types used for length-prefix pre-sizing.
Patterns every protocol crate on this codec ends up needing. They are conventions, not API — codified here so each consumer doesn't re-derive them.
A sans-io framer decodes the fixed-size header, then slices the payload out of the remainder using the header's length field:
let (header, rest) = Header::decode(buf)?; // fixed-size prefix
let payload_len = header.payload_length as usize;
let payload = rest.get(..payload_len) // delimit by declared length
.ok_or(Incomplete { needed: payload_len, available: rest.len() })?;
let remainder = &rest[payload_len..]; // start of the next frame
Decode deliberately is not a dispatch mechanism. Two standard shapes:
- Self-identifying (open set): the discriminant is the first byte(s) of the
buffer. Write an inherent
fn decode(buf) -> Result<Self, E>on the enum that reads the tag and delegates; unknown tags decode to a catch-all variant. - Externally discriminated: the tag lives in a sibling structure (e.g. a
header's payload-type field) and is stripped before the payload bytes are
seen. Write
fn decode(buf: &[u8], tag: PayloadType) -> Result<Self, E>— a trait method cannot express dispatch-by-external-tag, and should not try.
A validated (L2) view over a lazy decode layer should not re-run fallible
decodes on every accessor. Construct-time: drain DecodeIter::iter() once,
surfacing the first error; cache counts/offsets. Accessors: re-slice the
already-validated bytes with purpose-built infallible iterators. The typed
Decode/DecodeIter layer is the validation pass, not the hot path.
When a length field precedes the bytes it measures, compute it from
encoded_size() before writing — sizes here are pure functions of the value,
so no backfill pass is needed (see the nested-encode example above).
Size-changing post-hoc transforms (encode, then rewrite bytes to a different
length — e.g. an E2E protect step) are deliberately out of scope for Encode;
model those as a consumer-owned two-phase API.
Decoding through a streaming Read cannot produce
Incomplete { needed, available } — a reader doesn't know available until it
has consumed the stream. Buffer first, then decode the slice.
Add the dependency:
cargo add automotive-wire-codecImplement [Encode] and [Decode] for a message type using the big-endian leaf
helpers:
use automotive_wire_codec::{read_u16_be, write_u16_be, Decode, Encode, Incomplete, TrailingBytes};
#[derive(Debug, PartialEq)]
struct Ping {
session_id: u16,
}
#[derive(Debug)]
enum PingError {
Incomplete(Incomplete),
TrailingBytes(TrailingBytes),
Io(embedded_io::ErrorKind),
}
impl From<Incomplete> for PingError {
fn from(e: Incomplete) -> Self {
PingError::Incomplete(e)
}
}
impl From<TrailingBytes> for PingError {
fn from(e: TrailingBytes) -> Self {
PingError::TrailingBytes(e)
}
}
impl From<embedded_io::ErrorKind> for PingError {
fn from(e: embedded_io::ErrorKind) -> Self {
PingError::Io(e)
}
}
impl<'a> Decode<'a> for Ping {
type Error = PingError;
fn decode(buf: &'a [u8]) -> Result<(Self, &'a [u8]), Self::Error> {
let (session_id, rest) = read_u16_be(buf)?;
Ok((Ping { session_id }, rest))
}
}
impl Encode for Ping {
type Error = PingError;
fn encode(&self, writer: &mut impl embedded_io::Write) -> Result<usize, Self::Error> {
Ok(write_u16_be(writer, self.session_id)?)
}
}
fn main() -> Result<(), PingError> {
// Round-trip: encode into a buffer, then decode it back.
let ping = Ping { session_id: 0x1234 };
let mut buf = [0u8; 2];
let mut writer: &mut [u8] = &mut buf;
ping.encode(&mut writer)?;
let decoded = Ping::decode_exact(&buf)?;
assert_eq!(decoded, ping);
Ok(())
}See the crate docs for the full API,
including the [DecodeIter] trait for repeated elements, the variable-width
[read_be_uint]/[read_be_uint_into] helpers, and
[Encode::encode_to_slice] for fixed-buffer encoding.
Migrating a protocol crate onto these traits? See MIGRATION.md.
This crate is no_std and does not require alloc. unsafe_code is forbidden
(#![forbid(unsafe_code)] at the workspace lint level). CI builds against a bare-metal
Cortex-M0 target (thumbv6m-none-eabi) to catch any std/alloc leaking in through a
dependency.
The MSRV is tracked in Cargo.toml's rust-version field (currently 1.85) and enforced
in CI.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.