Skip to content

Operating Modes

PDC Struct supports two distinct operating modes, each optimized for different use cases: C_COMPATIBLE and DYNAMIC.

Quick Comparison

Feature C_COMPATIBLE DYNAMIC
Header None 4-byte header
Size Fixed Variable
Optional Fields Must have defaults, always packed Truly optional, omitted when None
Interoperability C/C++, network protocols, file formats Python-to-Python
Format String Fixed at class definition Varies based on present fields
Use Case Binary protocols, hardware, legacy formats IPC, config storage, flexible serialization

C_COMPATIBLE Mode

Overview

C_COMPATIBLE mode produces fixed-size binary data that exactly matches C struct layouts. This mode is ideal when you need to interface with systems that expect specific binary formats.

Characteristics

  • No header overhead - pure binary data
  • Fixed size - every instance packs to the same number of bytes
  • Predictable layout - byte-for-byte compatible with C structs
  • Optional fields - allowed but must have defaults and are always packed

When to Use

Choose C_COMPATIBLE mode when working with:

  • Network protocols (TCP/IP, ARP, DNS packets)
  • Hardware interfaces (sensor data, device communication)
  • Legacy file formats (WAV, BMP, custom binary formats)
  • C/C++ interoperability (shared memory, system calls)
  • Embedded systems (fixed-size data structures)

C Struct Padding Requirement

Important: C Interoperability Requires Packed Structs

PDC Struct produces tightly packed binary data with no padding bytes between fields. By default, C compilers insert padding to align struct members to word boundaries for CPU performance.

You must use #pragma pack(1) in your C code to disable padding when exchanging data with PDC Struct:

// C code - REQUIRED for PDC Struct compatibility
#pragma pack(push, 1)
struct SensorData {
    uint8_t  device_id;
    uint32_t timestamp;   // No padding before this!
    uint16_t temperature;
};
#pragma pack(pop)

Without #pragma pack(1), C would insert 3 padding bytes before timestamp, making the struct 12 bytes instead of 7.

Why C Adds Padding

C compilers align struct members to their "natural" boundaries for faster memory access:

Type Size Default Alignment
char, uint8_t 1 byte 1-byte boundary
short, uint16_t 2 bytes 2-byte boundary
int, uint32_t 4 bytes 4-byte boundary
double, uint64_t 8 bytes 8-byte boundary (or 4 on 32-bit)

This means a simple struct can have hidden padding:

// Default C alignment - NOT compatible with PDC Struct
struct Example {
    uint8_t  a;      // offset 0, size 1
    // 3 bytes padding (to align next field to 4-byte boundary)
    uint32_t b;      // offset 4, size 4
    uint8_t  c;      // offset 8, size 1
    // 3 bytes padding (struct size rounds to largest alignment)
};  // Total: 12 bytes

// With #pragma pack(1) - compatible with PDC Struct
#pragma pack(push, 1)
struct Example {
    uint8_t  a;      // offset 0, size 1
    uint32_t b;      // offset 1, size 4
    uint8_t  c;      // offset 5, size 1
};  // Total: 6 bytes
#pragma pack(pop)

Matching Python and C Definitions

# Python
from pdc_struct import StructModel, StructConfig, StructMode, ByteOrder
from pdc_struct.c_types import UInt8, UInt32, UInt16

class SensorData(StructModel):
    device_id: UInt8
    timestamp: UInt32
    temperature: UInt16

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        byte_order=ByteOrder.LITTLE_ENDIAN
    )

print(SensorData.struct_size())  # 7 bytes
// C - must match Python's packed layout
#pragma pack(push, 1)
struct SensorData {
    uint8_t  device_id;
    uint32_t timestamp;
    uint16_t temperature;
};
#pragma pack(pop)

printf("Size: %zu\n", sizeof(struct SensorData));  // 7 bytes

Future Enhancement

Automatic struct alignment support is planned for a future release. See the Roadmap for details on the proposed StructConfig(alignment=N) feature.

Example: Network Packet

from pydantic import Field
from pdc_struct import StructModel, StructConfig, StructMode, ByteOrder
from pdc_struct.c_types import UInt8, UInt16, UInt32

class TCPHeader(StructModel):
    """TCP packet header - must match RFC 793 specification exactly."""
    source_port: UInt16
    dest_port: UInt16
    sequence: UInt32
    ack_number: UInt32
    flags: UInt16
    window_size: UInt16
    checksum: UInt16
    urgent_pointer: UInt16

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        byte_order=ByteOrder.BIG_ENDIAN  # Network byte order
    )

# Every TCPHeader instance is exactly 20 bytes
print(f"Size: {TCPHeader.struct_size()} bytes")  # Output: Size: 20 bytes
print(f"Format: {TCPHeader.struct_format_string()}")  # Output: Format: >HHLLHHHH

# Create and pack
header = TCPHeader(
    source_port=8080,
    dest_port=443,
    sequence=12345,
    ack_number=0,
    flags=0x002,  # SYN flag
    window_size=65535,
    checksum=0,
    urgent_pointer=0
)

packet_bytes = header.to_bytes()  # Always 20 bytes

Example: Sensor Data

from pdc_struct import StructModel, StructConfig, StructMode, ByteOrder
from pdc_struct.c_types import UInt16, Int16, UInt32

class SensorReading(StructModel):
    """IoT sensor data in fixed format for embedded system."""
    device_id: UInt16
    temperature: Int16  # Celsius * 100 (e.g., 2150 = 21.50°C)
    humidity: UInt16     # Percentage * 100
    timestamp: UInt32

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        byte_order=ByteOrder.LITTLE_ENDIAN
    )

# Reading continuous sensor data from binary file
with open('sensors.bin', 'rb') as f:
    record_size = SensorReading.struct_size()  # 12 bytes
    while data := f.read(record_size):
        reading = SensorReading.from_bytes(data)
        print(f"Device {reading.device_id}: {reading.temperature/100:.1f}°C")

Optional Fields in C_COMPATIBLE Mode

Optional fields are supported but must have default values. They are always packed whether None or not:

from typing import Optional
from pdc_struct import StructModel, StructConfig, StructMode
from pdc_struct.c_types import UInt8, UInt16

class Packet(StructModel):
    msg_type: UInt8
    # Optional field MUST have a default
    sequence: Optional[UInt16] = 0  # Will pack as 0 when None

    struct_config = StructConfig(mode=StructMode.C_COMPATIBLE)

p1 = Packet(msg_type=1, sequence=100)
p2 = Packet(msg_type=1, sequence=None)  # sequence becomes 0

print(len(p1.to_bytes()))  # 3 bytes
print(len(p2.to_bytes()))  # 3 bytes - same size!

C_COMPATIBLE Optional Behavior

In C_COMPATIBLE mode, Optional fields are not truly optional - they're always packed. Set sequence=None packs the default value. This ensures fixed struct size but may be counterintuitive.

DYNAMIC Mode

Overview

DYNAMIC mode provides flexible, self-describing binary serialization optimized for Python-to-Python communication. It supports truly optional fields that are omitted from the packed data when absent.

Characteristics

  • 4-byte header - includes version, flags, and reserved bytes
  • Variable size - depends on which optional fields are present
  • Field presence bitmap - tracks which optional fields are included
  • Space efficient - absent fields consume no space in packed data
  • Self-describing - header contains metadata about the data

Header Structure

Byte 0: Version (currently 0x01 for V1)
Byte 1: Flags
    Bit 0: Endianness (0=little, 1=big)
    Bit 1: Has optional fields (0=no, 1=yes)
    Bits 2-7: Reserved
Byte 2: Reserved
Byte 3: Reserved

When to Use

Choose DYNAMIC mode when working with:

  • Inter-process communication (Python microservices)
  • Configuration storage (save/load app state)
  • Message queues (flexible message formats)
  • Data persistence (when fields may be added over time)
  • Space optimization (when many fields are often None)

Example: Configuration Data

from typing import Optional
from pydantic import Field
from pdc_struct import StructModel, StructConfig, StructMode
from pdc_struct.c_types import UInt8, UInt16, UInt32

class AppConfig(StructModel):
    """Application configuration with optional features."""
    version: UInt8
    max_connections: UInt16
    timeout: UInt32
    # Optional features - only packed when present
    cache_size: Optional[UInt32] = None
    log_level: Optional[UInt8] = None

    struct_config = StructConfig(mode=StructMode.DYNAMIC)

# Minimal config - optional fields omitted
config1 = AppConfig(
    version=1,
    max_connections=100,
    timeout=3000
)
print(len(config1.to_bytes()))  # ~11 bytes (header + bitmap + 3 fields)

# Full config - all fields present
config2 = AppConfig(
    version=1,
    max_connections=100,
    timeout=3000,
    cache_size=1024,
    log_level=2
)
print(len(config2.to_bytes()))  # ~16 bytes (header + bitmap + 5 fields)

Example: Message Queue

from typing import Optional
from pdc_struct import StructModel, StructConfig, StructMode
from pdc_struct.c_types import UInt8, UInt16

class QueueMessage(StructModel):
    """Flexible message format for message queue."""
    msg_type: UInt8
    priority: UInt8
    # Optional metadata
    correlation_id: Optional[UInt16] = None
    retry_count: Optional[UInt8] = None

    struct_config = StructConfig(mode=StructMode.DYNAMIC)

# Quick message - no metadata
msg1 = QueueMessage(msg_type=1, priority=5)

# Retry message - includes retry metadata
msg2 = QueueMessage(
    msg_type=1,
    priority=5,
    correlation_id=12345,
    retry_count=2
)

# msg2 is only 4 bytes larger despite having 2 more fields
print(f"msg1: {len(msg1.to_bytes())} bytes")
print(f"msg2: {len(msg2.to_bytes())} bytes")

Truly Optional Fields

Unlike C_COMPATIBLE mode, optional fields in DYNAMIC mode are not packed when None:

from typing import Optional
from pdc_struct import StructModel, StructConfig, StructMode
from pdc_struct.c_types import UInt8, UInt32

class Event(StructModel):
    event_type: UInt8
    user_id: Optional[UInt32] = None  # Truly optional

    struct_config = StructConfig(mode=StructMode.DYNAMIC)

# Without user_id
e1 = Event(event_type=1)
data1 = e1.to_bytes()

# With user_id
e2 = Event(event_type=1, user_id=12345)
data2 = e2.to_bytes()

print(len(data1))  # Smaller - user_id not packed
print(len(data2))  # Larger - user_id included

# Roundtrip preserves None
decoded1 = Event.from_bytes(data1)
assert decoded1.user_id is None  # ✓ None preserved

Choosing the Right Mode

Use C_COMPATIBLE when you need:

Exact binary format - matching a specification ✅ Fixed size - predictable memory/bandwidth usage ✅ C interoperability - talking to C/C++ code ✅ No overhead - every byte counts ✅ Legacy compatibility - existing file formats

Use DYNAMIC when you need:

Flexibility - fields that may be present or absent ✅ Space efficiency - save bandwidth when fields are None ✅ Python-to-Python - no external format constraints ✅ Versioning - header tracks format version ✅ Self-describing data - metadata in the header

Advanced: Nested Structs and Byte Order

Both modes support nested StructModel instances and automatic byte order propagation:

from pdc_struct import StructModel, StructConfig, StructMode, ByteOrder
from pdc_struct.c_types import UInt16, UInt32

class Point(StructModel):
    x: UInt16
    y: UInt16
    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        byte_order=ByteOrder.LITTLE_ENDIAN
    )

class Shape(StructModel):
    shape_id: UInt32
    origin: Point  # Nested struct inherits parent's byte order

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        byte_order=ByteOrder.BIG_ENDIAN,
        propagate_byte_order=True  # Default - Point uses BIG_ENDIAN
    )

When propagate_byte_order=True (default), nested structs automatically use the parent's byte order, ensuring consistent endianness throughout the packed data.

Performance Considerations

C_COMPATIBLE Mode

  • Packing: Very fast - single struct.pack() call
  • Unpacking: Very fast - single struct.unpack() call
  • Memory: Minimal - no header overhead
  • Best for: High-throughput scenarios, tight loops

DYNAMIC Mode

  • Packing: Slightly slower - header creation + bitmap calculation
  • Unpacking: Slightly slower - header parsing + bitmap processing
  • Memory: 4+ bytes overhead per instance
  • Best for: Flexibility over raw speed

The performance difference is negligible for most use cases. Choose based on your requirements, not performance.

Summary

  • C_COMPATIBLE: Fixed-size, C-compatible, no header, for binary protocols and interoperability
  • DYNAMIC: Variable-size, self-describing, with header, for flexible Python serialization

Both modes are first-class citizens in PDC Struct. Pick the mode that matches your use case, and the library handles the rest.