Device-driver codegen — architecture (arc42)¶
Architecture documentation for the device-driver codegen toolchain (see Device-driver codegen), structured per the arc42 template and encoded with sphinx-needs using the useblocks “x-as-code” arc42 conventions.
Each architectural element :refines: or :implements: a parent
requirement so the trace is preserved end-to-end.
1. Introduction and goals¶
The toolchain’s reason-to-exist is build-time monomorphisation of
fieldbus device drivers: vendor-supplied ESI XML files describe each
EtherCAT device’s PDOs, mailbox, and OD; we want strongly-typed
decode_inputs / encode_outputs code per device, with zero XML
at runtime and no hand-written boilerplate per terminal.
Quality goals capture the qualities the architecture is optimised for.
The same set of ESI inputs (modulo file ordering) shall produce a byte-identical generated module across machines, toolchain versions, and clock walls. Generation order, hash-map iteration order, and timestamp inclusion are explicitly excluded as sources of nondeterminism. Required so the generated file is reviewable in diffs and cachable in CI. |
Each crate in the toolchain shall depend only on crates to its left in the parse → codegen → tooling chain (see Toolchain layering (crate d... (ARCH_0050)). Crossover dependencies — e.g. the parser reaching for ethercrab types, the build helper bypassing the codegen layer — are rejected. The layering is the design; collapsing it deletes its value. |
A consumer of the generated modules shall pay no runtime cost
for the codegen layer’s existence: no XML parse, no allocation
for OD tables when |
The |
2. Constraints¶
The build helper shall live within cargo’s |
The ethercrab backend shall target the ethercrab API exposed by
|
Generated |
|
The ESI schema ( |
3. Context and scope¶
Four layers, strict left-to-right dependency. Each crate has one
job and depends only on crates to its left. The
graph LR
subgraph Parse["1. Parse layer"]
P["ethercat-esi<br/>XML → typed IR"]
end
subgraph Gen["2. Codegen layer"]
G["ethercat-esi-codegen<br/>IR → TokenStream"]
B["ethercat-esi-codegen-ethercrab<br/>concrete backend"]
end
subgraph RT["3. Runtime trait"]
RTC["ethercat-esi-rt<br/>EsiDevice / EsiConfigurable"]
end
subgraph Tool["4. Tooling layer"]
BR["ethercat-esi-build<br/>build.rs glue"]
CLI["ethercat-esi-cli<br/>cargo esi expand / list"]
VER["ethercat-esi-verify<br/>diff ESI vs SII .bin"]
end
subgraph Cons["Consumers"]
USER["any ethercrab user<br/>(includes generated code)"]
SCE["taktora-connector-ethercat<br/>thin EsiDevice adapter"]
end
P --> G
G --> B
B --> RTC
B --> BR
B --> CLI
P --> VER
RTC --> BR
BR --> USER
USER --> SCE
|
The toolchain runs entirely at build time. Runtime consumers
only see the generated module and link against
|
4. Solution strategy¶
The toolchain’s shape is the consequence of eight architectural
decisions. Each is captured as an ADR that :refines: the
requirement or feature it answers.
Context. A monolithic “ESI to ethercrab driver” crate would
conflate three concerns — XML parsing, IR shape and naming
policy, and ethercrab-specific code emission. Downstream tools
that need only one of these (a TwinCAT importer, a network
configurator, the EEPROM verifier) would be forced to compile
ethercrab and Decision. Three crates: Consequences. ✅ Each layer has one job and one
reason-to-change. ✅ Future backends (soem, minimal, no_std-only)
are net-additive — no parser or codegen churn.
❌ Three crates instead of one — more |
Context. The hot path (cyclic Decision. Two traits. Consequences. ✅ Hot path stays sync and zero-cost. ✅ Devices that don’t need preop SDO writes don’t need to implement the async trait. ❌ Two trait names instead of one; the consumer-side dispatcher has to handle both shapes. Acceptable cost; the generated code shoulders most of the burden. |
Context. An ESI device commonly declares 2–4 PDO
assignment alternatives (e.g. “Standard 16-bit”, “Compact
8-bit”). One representation in Rust is a single struct with
Decision. Emit an enum Consequences. ✅ “Two alternatives at once” is
unrepresentable. ✅ Per-variant PDO structs have |
Context. EtherCAT’s CoE inherits the CANopen Object Dictionary (CiA 301) wholesale: index/subindex addressing, PDO mapping objects, the data-type table. EDS (CANopen) and ESI (EtherCAT) describe overlapping object dictionaries. Decision. When CANopen support is in scope, the OD IR is
extracted to a shared Consequences. ✅ ~70 % of code is shared at the IR level
when CANopen lands. ✅ Each transport keeps its honest runtime
shape. ❌ Future refactor required to lift OD types out of
Closure. Closed by Lift OD IR to fieldbus-od-c... (ADR_0078). The lift is performed as part of CANopen device-driver codeg... (FEAT_0060) “CANopen device-driver codegen toolchain”; see CANopen device-driver codegen — architecture (arc42) for the executed decomposition. |
Context. Vendor-specific Decision. Capture as opaque blobs. The IR carries a
Consequences. ✅ Real-world ESI files parse without bespoke patches per vendor. ✅ Information is preserved, not discarded. ❌ The IR carries a payload nobody on the ethercrab-backend side reads. Negligible cost; the alternative (parse-and-discard) is worse. |
Context. OD-heavy devices (e.g. Beckhoff ELxxx coupling modules with 200+ entries) can balloon generated code by 10–50× if every OD entry becomes a match arm. Three options: match arms (large, fast lookup), static table (smaller, linear lookup but O(log n) with binary search on sorted index), no emission (smallest, fails the “OD-aware diagnostics” use case). Decision. Emit OD entries as a sorted
Consequences. ✅ Default builds carry zero OD-related
code or rodata. ✅ Diagnostic tools that need OD lookup
|
Context. Emitting raw Decision. Consequences. ✅ Build helper has no external command dependency. ✅ Output is stable across rustfmt versions. ❌ Slightly different formatting than the rest of the workspace’s rustfmt-formatted code (prettyplease is opinionated but not 100 % rustfmt-compatible). Acceptable — generated files are not human-edited. |
Context. Two inspection mechanisms were on the table.
Option A: an Decision. Cargo subcommand only. Proc-macro form is rejected per NO proc-macro front-end (REQ_0591). Consequences. ✅ One codegen path; tests run once. ✅ No
proc-macro compile-time cost on every workspace |
5. Building block view¶
The parse crate. Reads ESI XML via |
Codegen layer. Owns the |
The one concrete backend shipped in this round. Emits per-device
structs implementing |
The minimal trait crate consumed by generated devices and
adapters. Owns |
Build-script helper consumed by downstream crates from their
|
Cargo subcommand binary providing |
Cross-validates ESI XML against captured SII EEPROM |
The thin glue inside |
6. Runtime view¶
The build-time codegen sequence when a downstream crate’s
sequenceDiagram
participant Cargo as cargo
participant Build as build.rs
participant Esi as ethercat-esi
participant Codegen as ethercat-esi-codegen
participant Backend as -codegen-ethercrab
participant PP as prettyplease
participant Out as $OUT_DIR/devices.rs
Cargo->>Build: invoke build.rs
Build->>Esi: parse(xml) for each ESI file
Esi-->>Build: EsiFile IR
Build->>Codegen: generate(IR, backend)
Codegen->>Backend: emit_device(d) per device
Backend-->>Codegen: TokenStream
Codegen->>Backend: emit_module_root(devices)
Backend-->>Codegen: TokenStream (with registry!())
Codegen-->>Build: combined TokenStream
Build->>PP: unparse(tokenstream)
PP-->>Build: formatted source
Build->>Out: write devices.rs
Build-->>Cargo: cargo:rerun-if-changed=esi/*.xml
|
The runtime sequence when a generated device’s
sequenceDiagram
participant App as application
participant Dev as <Device>
participant Sub as SubDevicePreOperational
participant Bus as EtherCAT bus
App->>Dev: Device::default()
App->>Dev: configure(&sub, Assignment::Standard).await
Dev->>Sub: sdo_write(0x1C12, 0, 0) (clear)
Sub->>Bus: SDO download
loop per RxPDO in alternative
Dev->>Sub: sdo_write(0x1C12, idx, pdo_index)
Sub->>Bus: SDO download
end
Dev->>Sub: sdo_write(0x1C12, 0, N) (commit count)
Dev->>Sub: sdo_write(0x1C13, 0, 0)
loop per TxPDO in alternative
Dev->>Sub: sdo_write(0x1C13, idx, pdo_index)
end
Dev->>Sub: sdo_write(0x1C13, 0, M)
loop per InitCmd in ESI mailbox section
Dev->>Sub: sdo_write(initcmd.index, initcmd.subindex, initcmd.data)
end
Dev-->>App: Ok(())
Note over Sub,Bus: caller transitions PRE-OP → SAFE-OP
|
7. Deployment view¶
All seven toolchain crates live in No deployment-time changes: the toolchain is a build-time
artefact. The only runtime consequence is that
|
8. Crosscutting concepts¶
The crosscutting axes are owned by section 1 (quality goals) and
section 2 (constraints). The two persistent runtime concepts —
the EsiDevice trait and the SubDeviceIdentity const — both
live in ethercat-esi-rt (runtime tr... (BB_0063) and are referenced from generated code,
adapters, and dispatch registries alike. They are the contract
the rest of the toolchain orbits.
9. Architectural decisions¶
All decisions are captured in section 4 (Solution strategy) as ADR records Parser separated from codeg... (ADR_0070) through cargo subcommand for inspec... (ADR_0077). This section is deliberately a pointer rather than a duplicate — arc42’s recommendation when decisions are dense in the solution strategy narrative.
10. Quality requirements¶
The quality goals in section 1 (Build-time determinism (sam... (QG_0010) through Trait stability for ecosyst... (QG_0013)) define the qualities. The verification artefacts in Device-driver codegen — verification exercise each one.
11. Risks and technical debt¶
Beckhoff coupling modules can declare 200+ OD entries. With
|
Beckhoff ships ESI files with |
|
Wago, Omron, and Beckhoff have shipped ESI files at different schema-version baselines. The parser shall track the highest shipped schema with the opaque-blob escape hatch (Vendor extensions captured ... (ADR_0074)) catching everything else. A schema-only conformance test set (EL3001 backend output snapshot (TEST_0420)) anchors the parser against the canonical schema; a real-world fixture set (Generated registry covers e... (TEST_0421)) anchors it against actual vendor files. |
If many consumers depend on the generated module’s struct
names (e.g. |
12. Glossary¶
EtherCAT Slave Information — an XML file describing a single
EtherCAT device’s identity, PDOs, mailbox, distributed clocks,
and object dictionary. Schema is published by ETG
( |
Slave Information Interface — the on-device EEPROM that
carries a binary subset of the ESI data, readable over the
EtherCAT bus by the master. |
Process Data Object — a fixed-length packed set of OD entries exchanged on every EtherCAT cycle. RxPDO = master → device (outputs); TxPDO = device → master (inputs). |
CANopen over EtherCAT — mailbox protocol carrying CANopen SDO writes (e.g. PDO assignment writes to 0x1C12 / 0x1C13). |
The indexed (16-bit index + 8-bit sub-index) catalogue of readable / writable objects on a CANopen or CoE device. Inherited by EtherCAT from CANopen (CiA 301). |
An SDO write sequence declared inside an ESI |