Skip to content

Derive Macros

Robin Krämer edited this page Sep 29, 2026 · 3 revisions

This crate holds the procedural macros that generate the repetitive parts of ethercat_hal device drivers: the bit-level PDO (de)serialization, the CoE PDO assignment, and the EthercatDevice glue.

Derive Put it on What it generates
PdoObject one PDO entry type (for example BoolPdoObject) pdo::PdoObject::size()
TxPdo a struct listing a terminal's input PDOs pdo::TxPdo + coe::Configuration (writes 0x1C13)
RxPdo a struct listing a terminal's output PDOs pdo::RxPdo + coe::Configuration (writes 0x1C12)
EthercatDevice the driver struct devices::EthercatDevice + devices::EthercatDeviceUsed

Writing device drivers shows how these fit into a complete driver. This page describes exactly what each macro requires and what it expands to.

Only usable inside ethercat_hal

The generated code uses crate:: paths (crate::pdo::RxPdo, crate::coe::Configuration, crate::EtherCATThreadChannel, crate::devices::…), plus bare anyhow:: and bitvec:: paths. So the macros only compile in the ethercat_hal crate. If you use them anywhere else, you get cannot find 'pdo' in 'crate' and similar errors.

To write a driver in another crate, implement the traits by hand.

PdoObject

#[derive(Debug, Clone, Default, PdoObject)]
#[pdo_object(bits = 1)]
pub struct BoolPdoObject {
    pub value: bool,
}

This expands to:

impl crate::pdo::PdoObject for BoolPdoObject {
    fn size(&self) -> usize { 1 }
}
  • #[pdo_object(bits = N)] is required. N is the object's size on the wire, in bits.
  • It only generates the size. You still write the encoding and decoding yourself: impl TxPdoObject (a read(&mut self, &BitSlice)) and/or impl RxPdoObject (a write(&self, &mut BitSlice)). The slice you get is exactly N bits long, starting at bit 0.
  • Generics are supported.

TxPdo / RxPdo

These go on the struct that lists every PDO a terminal can map in one direction:

#[derive(Debug, Clone, RxPdo)]
pub struct EL2002RxPdo {
    #[pdo_object_index(0x1600)]
    pub channel1: Option<BoolPdoObject>,
    #[pdo_object_index(0x1601)]
    pub channel2: Option<BoolPdoObject>,
}

The requirements:

  • Named fields only. Tuple structs and enums aren't supported.
  • Every field must have #[pdo_object_index(0x....)], a u16 CoE index. If one is missing, the macro panics at compile time with missing required field 0 on #[pdo_object_index].
  • Every field must be Option<T>:
    • for TxPdo, T: TxPdoObject;
    • for RxPdo, T: RxPdoObject.
  • Field order is significant. It sets both the bit order in the process image and the order in the CoE assignment list.

RxPdo expands to this (TxPdo is the same, with 0x1C13, TxPdoObject, and an extra get_objects_mut):

impl crate::coe::Configuration for EL2002RxPdo {
    fn write_config(&self, channel: crate::EtherCATThreadChannel, device_address: u16)
        -> Result<(), anyhow::Error>
    {
        channel.sdo_write(device_address, 0x1C12, 0, 0u8)?;              // clear assignment
        let mut len = 0;
        if let Some(_) = &self.channel1 { len += 1; channel.sdo_write(device_address, 0x1C12, len, 0x1600u16)?; }
        if let Some(_) = &self.channel2 { len += 1; channel.sdo_write(device_address, 0x1C12, len, 0x1601u16)?; }
        channel.sdo_write(device_address, 0x1C12, 0, len)?;              // commit count
        Ok(())
    }
}

impl crate::pdo::RxPdo for EL2002RxPdo {
    fn get_objects(&self) -> Box<[Option<&dyn crate::pdo::RxPdoObject>]> {
        Box::new([
            self.channel1.as_ref().map(|o| o as &dyn crate::pdo::RxPdoObject),
            self.channel2.as_ref().map(|o| o as &dyn crate::pdo::RxPdoObject),
        ])
    }
}

The provided methods of RxPdo / TxPdo (size(), write(), read() in pdo/mod.rs) build on get_objects():

  • They walk the fields in order.
  • A None field is skipped and takes up no bits.
  • Each Some object gets the next object.size() bits.
  • The total size is rounded up to a whole byte.

Two consequences:

  • write_config changes the assignment only (0x1C12 / 0x1C13), not the contents of the mapping objects (0x16xx / 0x1Axx). It has to be called in PreOp, like every SDO write.
  • Whether a field is Some or None decides what gets assigned. The same struct therefore describes the terminal's configuration and its process-image layout. Keep the driver's PDO struct identical to the one you wrote to the terminal. ConfigurableDevice::write_config implementations do this by rebuilding txpdo / rxpdo from the chosen PredefinedPdoAssignment.

EthercatDevice

This goes on the driver struct:

#[derive(EthercatDevice)]
pub struct EL2002 {
    pub rxpdo: EL2002RxPdo,
    is_used: bool,
}

The macro looks for fields by name:

Field Generated
txpdo present input(bits) → self.txpdo.read(bits), input_len() → self.txpdo.size()
txpdo absent input → Ok(()), input_len → 0
rxpdo present output(bits) → self.rxpdo.write(bits), output_len() → self.rxpdo.size()
rxpdo absent output → Ok(()), output_len → 0
is_used: bool required, used by the generated EthercatDeviceUsed::{is_used, set_used}

It always generates:

  • as_any, as_any_mut and into_any_boxed, which return self;
  • is_module() → false;
  • get_module() → None;
  • set_module(_), which does nothing.

Requirements and pitfalls:

  • Bring the PDO traits into scope. The generated self.rxpdo.write(…) and .size() are trait method calls, so the driver's module must use crate::pdo::RxPdo and/or crate::pdo::TxPdo, or you get "no method named write". The trait and the derive macro have the same name, and you can import both, as EL2002 does: use crate::pdo::RxPdo; use ethercat_hal_derive::RxPdo;.
  • Supertraits are your job. EthercatDevice also requires NewEthercatDevice, EthercatDeviceProcessing, Debug, Send, Sync and Any. Implement or derive these yourself.
  • The processing hooks aren't called. The generated input / output don't call input_post_process / output_pre_process. The application has to call them.
  • No generics. The impl is written as impl … for #name without generic parameters, so a generic driver struct won't compile.
  • No offsets. It always reads and writes from bit 0 of the slice it's given. That is why WAGO 750 slot modules (which need tx_bit_offset / rx_bit_offset and a real set_module) implement EthercatDevice by hand. See WAGO 750.

Errors

Every macro calls .unwrap() on its parse result. A mistake in an attribute (missing, wrong type, missing bits = …) therefore shows up as proc-macro derive panicked, with the underlying deluxe message in the help: line, not as an error pointing at the field.

The EthercatDevice derive doesn't parse any attributes. Missing fields show up as ordinary type errors in the generated code, for example "no field is_used".

Developing the macros

  • Dependencies: syn (full), quote, proc-macro2, and deluxe for attribute parsing.
  • Tests: the crate has none. Build ethercat_hal, which uses the derives in more than 40 files, and run its tests (cd ethercat_hal && cargo test).
  • Inspecting output: to see what a derive generates, run cargo expand (the cargo-expand tool) on a driver module, for example:
    cd ethercat_hal && cargo expand devices::beckhoff_modules::el2002

Clone this wiki locally