Skip to content

Architecture

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

This page explains what runs where in ethercat_hal, and how data and commands cross between the master and your application.

Threads

init_ethercat(interface, config) starts two threads and returns an EtherCATControl:

flowchart LR
    subgraph App["Your application thread(s)"]
        H["EtherCATAppHandle<br/>(cyclic data + status)"]
        C["EtherCATThreadChannel<br/>(commands)"]
    end
    subgraph SM["EthercatStateMachine thread"]
        CTRL["EtherCATController<br/>state machine + cycle loop"]
    end
    subgraph IO["EthercatTxRxThread"]
        TXRX["ethercrab tx/rx task<br/>(io_uring on Linux)"]
    end
    NIC[(Network interface)]

    H -- "outputs: Mailbox" --> CTRL
    CTRL -- "inputs: triple buffer" --> H
    CTRL -. "atomics: state, cycle, DC time…" .-> H
    C -- "ChannelRequest (mpsc)" --> CTRL
    C -- "DiagnosticRequest (mpsc)" --> CTRL
    CTRL <--> TXRX <--> NIC
Loading
Thread Created by What it does
EthercatStateMachine init_ethercat Runs EtherCATController::ethercat_state_machine(). It owns the ethercrab MainDevice and SubDeviceGroup, handles requests, drives state transitions and, in Op, runs the cyclic loop.
EthercatTxRxThread the controller, when it leaves Init Moves frames between ethercrab's PDU storage and the network interface. It uses io_uring on Linux and ethercrab's standard task on other platforms.

EtherCATControl has three fields:

  • channel: an EtherCATThreadChannel. It is Clone, so you can pass it to configuration code.
  • app_handle: an EtherCATAppHandle. It is not Clone; exactly one owner reads inputs and writes outputs.
  • join_handle: the join handle of the state-machine thread.

The HAL keeps its own single-threaded async runtime for determinism, and blocks on it from the state-machine thread. This is separate from common::get_async_runtime(), which is a multi-threaded runtime used by other crates such as xtrem.

State machine

stateDiagram-v2
    [*] --> NoInterface
    NoInterface --> Init: interface set
    Init --> PreOp: ChangeState(PreOp)<br/>start tx/rx thread, scan bus
    PreOp --> PreOp: service ChannelRequests<br/>(SDO, DC sync, oversampling, EEPROM)
    PreOp --> PreopPdi: ChangeState(Op)<br/>map PDI, DC static sync, configure DC
    PreopPdi --> Op: SafeOp, compute PDO offsets,<br/>request Op
    Op --> Op: cyclic loop
Loading

The current state is stored in an AtomicU8 and read with app_handle.get_state().

State What happens Which requests are serviced
NoInterface Moves to Init straight away, because init_ethercat always sets an interface. none
Init Waits for ChangeState(PreOp). It then starts the tx/rx thread and runs init_single_group, which scans the bus and assigns addresses. On failure it stays in Init. Only ChangeState(PreOp). Any other request received here is thrown away.
PreOp Fills in MetaSubdevice (name, identity, device_address) for every subdevice. Every ChannelRequest, one per loop iteration (about every 1 ms), plus diagnostics. ChangeState(Op) starts the move to Op.
PreopPdi Maps the process data image, runs DC static sync until every clock is within 300 ns, and configures SYNC0 from DcConfiguration. It then moves to SafeOp, computes each subdevice's start_tx, end_tx, start_rx and end_rx, and requests Op. none
Op Applies RtOptimizationConfig, then runs the cyclic loop until an error occurs. Diagnostics only

Two consequences for application code:

  1. Configure terminals in PreOp. SDO writes, PDO assignment, DC sync and oversampling all go through the ChannelRequest queue, and the controller only drains that queue in PreOp. In Op nothing reads it, so a blocking helper such as sdo_write returns a timeout error after 500 ms.
  2. You can't go back. No transition leads out of Op. If the cycle loop fails (for example, a working-counter error on the guarded tx/rx), the error ends the state-machine thread, and you get it back through join_handle.

Cyclic loop (Op)

Each cycle takes target_cycle_time_us (1000 µs by default):

cycle_start
│  tx_rx_dc()            ← send outputs, receive inputs, read DC system time
│  PI-adjust next_cycle  ← keeps frame send time at 50% of the SYNC0 period
│  copy inputs → triple buffer, publish; inputs_ready = true
│  service one DiagnosticRequest
│  ── sleep until next_cycle − 10 µs ──    ← window for your application
│  read Mailbox: if full, copy outputs into the PDI, then release it
│  ── sleep until next_cycle ──
│  cycle += 1; inputs_ready = false

Until every subdevice reports Op, the loop only runs tx/rx. Inputs are not published and outputs are not consumed. When all subdevices reach Op, check_all_op() becomes true and every MetaSubdevice::initialized is set to true. If they have not all reached Op after op_ramp_grace_cycles cycles (10 000 by default), the failure is recorded as an OpRamp transition report.

The process image

Inputs are called TxPDO: the subdevice transmits them. Outputs are called RxPDO: the subdevice receives them. The buffer on each side is ETHERCAT_TX_RX_SIZE = 4096 bytes. Subdevices are laid out one after another in bus order, and each one gets a whole number of bytes:

inputs buffer:   [ EK1100: 0 B ][ EL1002: 1 B ][ EL3001: 4 B ] …
                                 ^start_tx     ^end_tx

MetaSubdevice stores each subdevice's byte range. You slice the buffer with it and hand the slice to a driver as a BitSlice<u8, Lsb0>:

let bits = BitSlice::<u8, Lsb0>::from_slice(&inputs[sd.start_tx..sd.end_tx]);

The offsets are only valid after the move to Op. In PreOp they are still 0.

Data exchange primitives

Inputs: triple buffer

The controller writes into a triple_buffer and publishes it every cycle. app_handle.get_inputs() always returns the most recent complete snapshot and never blocks. If your loop runs slower than the bus, you skip cycles. If it runs faster, you read the same snapshot more than once. To act exactly once per cycle, wait on check_inputs_ready() or get_current_cycle().

Outputs: Mailbox

A Mailbox is one 4096-byte buffer plus an atomic full flag. The flag says which side owns the buffer:

full Owner Call
false application write_outputs() returns Some(&mut buf)
false → true send_outputs() hands the buffer to the controller
true controller write_outputs() returns None. The controller copies the buffer into the PDI in the next write window, then sets full = false.

The buffer is not cleared between cycles. Bytes you don't write keep the values they had before, which means a driver only needs to rewrite its own slice.

Status atomics

EtherCATAppHandle reads these without locking:

  • get_state()
  • get_current_cycle()
  • get_cycle_time_us(): how long the last cycle actually took
  • get_subdevice_count()
  • get_dc_sys_time_ns()
  • check_all_op()
  • check_inputs_ready()

The subdevice table is behind a tokio Mutex. Read it with try_get_subdevices_vec_sync() or with the async try_get_subdevices_vec().

Command channels

EtherCATThreadChannel(ChannelRequest sender, DiagnosticRequest sender) has two std::mpsc queues:

  • .0 (ChannelRequest) carries state changes, SDO reads and writes, DC sync setup, oversampling, and machine-ident EEPROM access. Each request includes a one-shot response Sender. The helper methods block on it: 500 ms for SDO and DC requests, 5 s for EEPROM. The exception is request_state_change, which returns straight away. It does not wait for a response, so poll get_state() to see when the transition has happened.
  • .1 (DiagnosticRequest) carries raw register reads (register_read) and AL status snapshots (al_status_snapshot). These are serviced in every state, including Op, because they only need the MainDevice. They get a 1.5 s timeout.

The two queues are separate so that a diagnostic request never consumes a ChangeState or other request that the current state can't handle.

Diagnostics

Every transition (group init, PreOp → PreOpPdi, DC configuration, SafeOp, Op request, and the Op ramp) and every guarded tx/rx error is recorded in a shared TransitionLog. Each TransitionReport holds:

  • whether the transition succeeded,
  • the error, if there was one,
  • how long it took,
  • the AL status of every subdevice, taken right after it.
if let Some(report) = handle.get_last_transition_failure() {
    eprintln!("{report}");
    for dev in report.faulty_devices() {
        eprintln!("  {dev}");        // name, address, AL state, AL status code
    }
}

The log is shared with the app handle, so you can still read it after the state-machine thread has ended.

Real-time tuning

MasterConfiguration::realtime_optimizations: Option<RtOptimizationConfig> controls real-time tuning:

  • tx/rx thread: pinned to ethercat_io_thread_core with SCHED_FIFO priority ethercat_io_thread_priority. If pin_irq_core is set, the NIC's IRQ is moved to that core. This is applied when the tx/rx thread starts.
  • State-machine thread: pinned to ethercat_loop_thread_core with ethercat_loop_thread_priority. If lock_memory is set, it also calls mlockall. This is applied when the controller enters Op.

These settings need the capabilities that bin/run-linux grants (cap_sys_nice, cap_ipc_lock) and write access to /proc/irq. See examples/rt.rs.

Clone this wiki locally