Skip to main content

Serialization

Cerulion Core provides automatic serialization for any Copy type, eliminating the need to manually define protobuf messages for simple structs. This page explains how serialization works and when to use it.

Automatic Serialization

The SerializableMessage Trait

All message types in Cerulion Core must implement the SerializableMessage trait:
pub trait SerializableMessage: Send + Sync + 'static {
    fn to_bytes(&self) -> Result<Vec<u8>, Box<dyn Error>>;
    fn from_bytes(bytes: &[u8]) -> Result<Self, Box<dyn Error>>;
}
The Rust trait is automatically implemented for Copy types. In Python and C++, you implement to_bytes() and from_bytes() methods directly on your message classes/structs.

Blanket Implementation for Copy Types

Cerulion Core automatically implements SerializableMessage for any Copy type:
impl<T> SerializableMessage for T
where
    T: Copy + Send + Sync + 'static,
{
    fn to_bytes(&self) -> Result<Vec<u8>, Box<dyn Error>> {
        Ok(raw::struct_to_bytes(self))
    }

    fn from_bytes(bytes: &[u8]) -> Result<Self, Box<dyn Error>> {
        raw::bytes_to_struct(bytes)
    }
}
This means you can use simple structs like SensorData without writing any serialization code. Just make sure your struct implements Copy and has #[repr(C)] layout.

Using Copy Types

Simple Example

Here’s a simple struct that works automatically:
#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct SensorData {
    timestamp: u64,
    temperature: f32,
    pressure: f32,
}

// Just works! No manual serialization needed
let publisher = Publisher::<SensorData>::create("sensors")?;
publisher.send(SensorData {
    timestamp: 1234567890,
    temperature: 23.5,
    pressure: 1013.25,
})?;
The #[repr(C)] attribute ensures the struct has a C-compatible memory layout, which is required for zero-copy serialization. This also enables future cross-language compatibility.

How It Works

  1. For Local Communication: Data is sent as raw bytes using zero-copy shared memory (iceoryx2)
  2. For Network Communication: Data is serialized to bytes and sent over Zenoh
// Publisher automatically serializes using raw bytes
let publisher = Publisher::<SensorData>::create("sensor/data")?;
publisher.send(data)?;

// Subscriber automatically deserializes
let subscriber = Subscriber::<SensorData>::create("sensor/data", None)?;
if let Ok(Some(data)) = subscriber.receive() {
    println!("Received: {:?}", data);
}

Supported Types

Primitive Types

All primitive types are supported:
#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct Primitives {
    flag: bool,
    byte: u8,
    short: u16,
    int: u32,
    long: u64,
    signed_byte: i8,
    signed_short: i16,
    signed_int: i32,
    signed_long: i64,
    real: f32,
    double_real: f64,
}

Fixed-Size Arrays

Fixed-size arrays of primitives work automatically:
#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct ArrayData {
    values: [f32; 10],
    flags: [bool; 8],
}

Fixed-Size Strings

Use fixed-size byte arrays for strings (not String):
#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct UserData {
    id: u32,
    username: [u8; 32],  // Fixed 32 bytes (not String!)
    email: [u8; 64],     // Fixed 64 bytes
}
Don’t use String or Vec in your message types. These are heap-allocated and don’t implement Copy. Use fixed-size byte arrays ([u8; N]) instead.

Nested Structs

Nested Copy structs work as long as the outer struct is #[repr(C)]:
#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct Position3D {
    x: f32,
    y: f32,
    z: f32,
}

#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct RobotState {
    position: Position3D,
    orientation: Position3D,
    timestamp: u64,
}

Safety Considerations

Safe Types

Raw byte serialization using #[repr(C)] is safe for:
  • ✅ Primitive types (u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, bool)
  • ✅ Arrays of primitive types ([f32; 10])
  • ✅ Structs containing only primitives (with #[repr(C)])
  • ✅ Fixed-size byte arrays ([u8; N])

Unsafe Types

Avoid using raw serialization for:
  • ❌ Types with pointers or references
  • ❌ Types with padding-dependent layouts (use #[repr(C)] to fix)
  • String (use [u8; N] instead)
  • Vec, HashMap, or other heap-allocated collections
  • ❌ Types with Drop implementations that manage resources
Using unsafe types can lead to memory corruption, data loss, or undefined behavior. Always use Copy types with #[repr(C)] for message types.

Optional Protobuf Support

If you need cross-language compatibility or schema evolution, you can use protobuf:
use prost::Message;

#[derive(Clone, PartialEq, Message)]
pub struct ProtoSensorData {
    #[prost(uint64, tag = "1")]
    pub timestamp: u64,
    #[prost(float, tag = "2")]
    pub temperature: f32,
    #[prost(float, tag = "3")]
    pub pressure: f32,
}

// This automatically gets to_proto_bytes() and from_proto_bytes()
// But note: The default SerializableMessage still uses raw bytes for Copy types
Protobuf is useful for:
  • Cross-language communication (Rust ↔ Python ↔ C++)
  • Schema evolution (adding/removing fields)
  • Complex nested message types
  • When you need guaranteed compatibility
For same-language communication, raw bytes are faster and simpler.

Serialization Flow

Local Transport (Zero-Copy)

No serialization occurs - data is copied byte-for-byte to shared memory.

Network Transport (Serialization)

Serialization occurs - struct is converted to bytes using #[repr(C)] layout.

Type Size and Layout

Checking Type Size

You can check the size of your message type:
use std::mem;

#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct SensorData {
    timestamp: u64,    // 8 bytes
    temperature: f32, // 4 bytes
    pressure: f32,    // 4 bytes
}

println!("Size: {} bytes", mem::size_of::<SensorData>()); // 16 bytes

Memory Layout

With #[repr(C)], the memory layout is guaranteed: Visual Memory Layout: Memory Layout Table:
FieldTypeSizeOffsetByte Range
timestampu648 bytes00-7
temperaturef324 bytes88-11
pressuref324 bytes1212-15
Key Points:
  • Fields are stored contiguously in memory (no padding)
  • Total size: 16 bytes (8 + 4 + 4)
  • Memory layout matches C struct layout exactly
  • Each field starts immediately after the previous one
The #[repr(C)] attribute ensures the struct has the same memory layout as a C struct. This is essential for:
  1. Zero-copy serialization (local transport)
  2. Cross-language compatibility (future Python/C++ support)
  3. Predictable binary format

Examples

Example 1: Simple Sensor Data

#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct SensorData {
    temperature: f32,
    pressure: f32,
    timestamp: u64,
}

let publisher = Publisher::<SensorData>::create("sensors")?;
publisher.send(SensorData {
    temperature: 23.5,
    pressure: 1013.25,
    timestamp: 1234567890,
})?;

Example 2: Fixed-Size Arrays

#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct JointState {
    positions: [f32; 20],
    velocities: [f32; 20],
    timestamp: u64,
}

let mut state = JointState {
    positions: [0.0; 20],
    velocities: [0.0; 20],
    timestamp: 0,
};

state.positions[0] = 1.5;
state.positions[1] = 2.3;

let publisher = Publisher::<JointState>::create("robot/joints")?;
publisher.send(state)?;

Example 3: Fixed-Size Strings

#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct UserData {
    id: u32,
    username: [u8; 32],
    email: [u8; 64],
}

let mut user = UserData {
    id: 123,
    username: [0; 32],
    email: [0; 64],
};

// Copy string bytes (truncate if too long)
let name_bytes = b"john_doe";
user.username[..name_bytes.len()].copy_from_slice(name_bytes);

let publisher = Publisher::<UserData>::create("users")?;
publisher.send(user)?;

Best Practices

1. Always Use #[repr(C)]

// ✅ Good: C-compatible layout
#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct MyData {
    value: f32,
}

// ❌ Bad: Rust layout (may have padding)
#[derive(Copy, Clone, Debug)]
struct MyData {
    value: f32,
}

2. Use Fixed-Size Arrays

// ✅ Good: Fixed-size array
struct Data {
    values: [f32; 10],
}

// ❌ Bad: Heap-allocated vector
struct Data {
    values: Vec<f32>,  // Doesn't implement Copy!
}

3. Use Fixed-Size Byte Arrays for Strings

// ✅ Good: Fixed-size byte array
struct User {
    name: [u8; 32],
}

// ❌ Bad: Heap-allocated string
struct User {
    name: String,  // Doesn't implement Copy!
}

4. Keep Types Small

Large structs increase serialization time and memory usage:
// ✅ Good: Small, focused struct
struct SensorData {
    temperature: f32,
    timestamp: u64,
}

// ⚠️ Consider: Is 1MB really needed?
struct LargeData {
    buffer: [u8; 1_000_000],  // 1MB per message!
}

Troubleshooting

”Type doesn’t implement Copy”

Problem: Your struct doesn’t implement Copy. Solution: Ensure all fields are Copy types:
// ❌ Problem: Contains String
struct Data {
    name: String,  // Not Copy!
}

// ✅ Solution: Use fixed-size array
struct Data {
    name: [u8; 32],  // Copy!
}

“Serialization failed”

Problem: Type has incompatible layout. Solution: Add #[repr(C)]:
// ❌ Problem: Rust layout
struct Data {
    value: f32,
}

// ✅ Solution: C layout
#[repr(C)]
struct Data {
    value: f32,
}

Next Steps