-
Notifications
You must be signed in to change notification settings - Fork 2
Architecture
This page explains what runs where in ethercat_hal, and how data and commands cross between the
master and your application.
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
| 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: anEtherCATThreadChannel. It isClone, so you can pass it to configuration code. -
app_handle: anEtherCATAppHandle. It is notClone; 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.
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
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:
-
Configure terminals in PreOp. SDO writes, PDO assignment, DC sync and oversampling all go through
the
ChannelRequestqueue, and the controller only drains that queue in PreOp. In Op nothing reads it, so a blocking helper such assdo_writereturns a timeout error after 500 ms. -
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.
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.
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.
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().
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.
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().
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 responseSender. The helper methods block on it: 500 ms for SDO and DC requests, 5 s for EEPROM. The exception isrequest_state_change, which returns straight away. It does not wait for a response, so pollget_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 theMainDevice. 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.
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.
MasterConfiguration::realtime_optimizations: Option<RtOptimizationConfig> controls real-time
tuning:
-
tx/rx thread: pinned to
ethercat_io_thread_corewith SCHED_FIFO priorityethercat_io_thread_priority. Ifpin_irq_coreis 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_corewithethercat_loop_thread_priority. Iflock_memoryis set, it also callsmlockall. 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.
EtherCAT HAL
EtherCAT Devices
XTREM
Other crates