#![no_std], dependency-free Consistent Overhead Byte Stuffing (COBS) and
COBS/R for Rust — the Rust member of the Firechip COBS family (alongside the
Dart cobs_codec and the Kotlin
cobs_codec_kt), verified
byte-identical against the shared
conformance vectors.
COBS encodes an arbitrary byte sequence into one that contains no zero (0x00)
byte, at a small and predictable cost: at most one extra byte per 254 bytes,
plus one. That makes a single 0x00 a reliable packet delimiter for serial/UART,
USB, TCP and other byte streams — ideal for embedded and robotics protocols.
- Basic COBS and COBS/R (Reduced) encode/decode.
no_stdand zero dependencies — the coreencode/decodework on caller-provided slices, allocating nothing.- Configurable sentinel —
*_with_sentinelvariants frame with any delimiter byte, not just0x00. - In-place decoding —
cobs::decode_in_placedecodes without a second buffer. - Allocation-free streaming —
framing::StreamDecoderreassembles delimited frames into a fixed buffer in pureno_std; theallocfeature adds the owned-VecFrameDecoderand*_to_vecconveniences. const fnsize helpers (max_encoded_len,encoding_overhead) for compile-time buffer sizing.- Optional
serde/defmt— off by default; enable the matching feature to deriveSerialize/Deserializeordefmt::FormatforDecodeError.
[dependencies]
cobs_codec_rs = "1.2"
# no_std, no allocator:
# cobs_codec_rs = { version = "1.2", default-features = false }use cobs_codec_rs::{cobs, cobsr};
// With alloc (default):
let encoded = cobs::encode_to_vec(&[0x11, 0x22, 0x00, 0x33]);
assert_eq!(encoded, [0x03, 0x11, 0x22, 0x02, 0x33]); // no 0x00
assert_eq!(cobs::decode_to_vec(&encoded).unwrap(), [0x11, 0x22, 0x00, 0x33]);
// COBS/R often saves the trailing overhead byte:
assert_eq!(cobsr::encode_to_vec(b"12345"), b"51234"); // same length as inputno_std, into a fixed buffer:
use cobs_codec_rs::{cobs, max_encoded_len};
let src = [0x11, 0x00, 0x22];
let mut buf = [0u8; max_encoded_len(3)];
let n = cobs::encode(&src, &mut buf);
assert_eq!(&buf[..n], &[0x02, 0x11, 0x02, 0x22]);With a custom sentinel byte, so a non-0x00 byte delimits frames (both cobs
and cobsr, slice and *_to_vec variants):
use cobs_codec_rs::cobs;
// 0xAA delimits frames instead of 0x00; the encoded output never contains it.
// (`sentinel == 0` is identical to the plain codec.)
let encoded = cobs::encode_to_vec_with_sentinel(&[0x11, 0xAA, 0x22], 0xAA);
assert_eq!(encoded, [0xAE, 0xBB, 0x00, 0x88]); // no 0xAA byte
assert_eq!(
cobs::decode_to_vec_with_sentinel(&encoded, 0xAA).unwrap(),
[0x11, 0xAA, 0x22],
);Decoding in place, without a second buffer (basic COBS only):
use cobs_codec_rs::cobs;
// COBS never expands on decode, so it can decode within the input buffer; the
// decoded bytes end up in `buf[..len]`.
let mut buf = [0x03, 0x11, 0x22, 0x02, 0x33];
let len = cobs::decode_in_place(&mut buf).unwrap();
assert_eq!(&buf[..len], &[0x11, 0x22, 0x00, 0x33]);Reassembling a sentinel-delimited stream with no allocator, into a fixed buffer:
use cobs_codec_rs::cobs;
use cobs_codec_rs::framing::StreamDecoder;
// Encode a packet with sentinel 0xAA, then delimit it with an 0xAA byte.
let mut wire = [0u8; 16];
let n = cobs::encode_with_sentinel(&[0x11, 0x00, 0x22], &mut wire, 0xAA);
wire[n] = 0xAA;
// Reassemble it into a fixed scratch buffer — no allocation anywhere.
let mut scratch = [0u8; 8];
let mut decoder = StreamDecoder::new(&mut scratch).sentinel(0xAA); // .reduced(true) for COBS/R
let mut out = [0u8; 8];
let mut out_len = 0;
decoder.push(&wire[..n + 1], |frame| {
let frame = frame.unwrap();
out[..frame.len()].copy_from_slice(frame);
out_len = frame.len();
});
assert_eq!(&out[..out_len], &[0x11, 0x00, 0x22]);Reading a delimited serial stream (needs alloc):
use cobs_codec_rs::framing::{frame_to_vec, FrameDecoder};
let mut rx = FrameDecoder::new().max_frame_len(4096);
// `chunk` is any &[u8] read from the link; chunks need not align with frames.
# let chunk = frame_to_vec(&[0x01, 0x02]);
rx.push(&chunk, |frame| match frame {
Ok(packet) => { /* handle packet */ }
Err(err) => { /* corrupt frame; keep receiving */ let _ = err; }
});COBS is the standard way to frame Protobuf on a UART/RS-485 link: protobuf
serializes a message but doesn't delimit it, and COBS supplies the missing
0x00-delimited framing — with instant resync after line noise, unlike
length-prefixing. See examples/protobuf_cobs.rs
for a runnable device→host demo (cargo run --example protobuf_cobs) that
survives a corrupted frame.
COBS overhead is data-independent. Encoding an n-byte packet produces at most
bytes (one extra byte per 254, rounded up), so the overhead is bounded by
cobsr (COBS/R) can reach zero overhead. These bounds are what max_encoded_len
and encoding_overhead return.
Throughput on a 1 KiB payload (cargo bench, criterion), on an AMD Ryzen 7
3800XT under WSL2 — indicative, not a controlled benchmark:
| Operation | Throughput |
|---|---|
| COBS encode | ~835 MiB/s |
| COBS decode | ~1.40 GiB/s |
| COBS/R encode | ~832 MiB/s |
The core stays no_std and dependency-free, so framework glue lives in your
project rather than in the crate. INTEGRATIONS.md has verified
copy-paste recipes — a tokio_util::codec for Framed streams, and the
embedded-io shape for no_std targets — all built on the public API.
Stuart Cheshire and Mary Baker, "Consistent Overhead Byte Stuffing", IEEE/ACM Transactions on Networking, Vol. 7, No. 2, April 1999. COBS/R is a variant by Craig McQueen.
MIT © 2026 Alexander Salas Bastidas (Firechip). See LICENSE.