Modern PLC projects suffer when control logic is scattered across rungs, hidden in timer branches, or tightly coupled to raw I/O. The result is brittle behavior, difficult commissioning, and painful offline testing. The approach below is a simple, repeatable pattern for building state-driven modules that stay deterministic, testable, and vendor‑friendly. It divides each routine into clear sections—Inputs → Commands → Interlocks → Permissives → State & Transitions → Status → Control Resources → Outputs—and enforces one rule above all: states govern state; device control happens only in Outputs. Whether you write in ST or Ladder, this structure keeps behavior predictable and easy to review.
Inputs
Inputs are the facts your state machine consumes. Treat them as pseudo‑inputs—stable, debounced, and aggregated—so the module can run without the real plant connected. Build tags like Inp_Ready, Inp_FillDone, or Inp_ZeroSpeed from raw I/O or test toggles. This decoupling makes offline testing straightforward and prevents noise from leaking into control decisions. Prefer positive logic (Inp_Ready instead of Inp_NotReady) for readability and safer defaults.
Commands
Operators press buttons; coordinators (parents or orchestration peers) issue requests. Your state machine needs one‑scan decisions either way—but the channels must be distinct:
OCmd_*(Operator Commands): HMI momentaries set by an operator. They are immediately cleared in this routine and converted into one‑scan pulses.PCmd_*(Program Commands): level‑type requests from a parent or sibling module (never children). They are set upstream and cleared here once consumed—just like acknowledging an HMI button—then converted into one‑scan pulses. This avoids exposing internalCmd_*outside the module.Cmd_*(Local Commands): internal one‑scan pulses derived fromOCmd_*and/orPCmd_*. They are owned by this routine only (not written by parents/siblings) and are the only command tags referenced by transitions.
Why this split? It preserves determinism and encapsulation. Network latency or scan jitter cannot “stretch” an operator or parent request across multiple scans, and higher‑level modules never reach into the internals of the state machine.
Minimal ST sketch for mapping both sources to a single internal pulse:
// one-scan command synthesis
Cmd_Start := FALSE; // default
// Operator momentary → one scan
IF OCmd_Start THEN
OCmd_Start := FALSE;
Cmd_Start := TRUE;
END_IF;
// Parent request (level) → one scan; acknowledge by clearing here
IF PCmd_Start THEN
PCmd_Start := FALSE;
Cmd_Start := TRUE;
END_IF;
// Apply the same pattern to Stop/Reset as needed
Use only Cmd_* in the State & Transitions section; OCmd_* and PCmd_* never appear there.
Interlocks
Interlocks are hard safety or readiness blockers. Evaluate them before any transition and force the machine to a safe holding state (commonly a NotReady state). Keep a single positive aggregate like IntlkOk that captures E‑stop, guards, permissive chains to energize, sensor health, and other prerequisites. This early, explicit cutout ensures the rest of the routine never runs while the system is unsafe, and reviewers can find safety logic in one place.
Permissives
Permissives are the gate criteria for entering a specific state. They are not global blocks like interlocks; they live with the transition that needs them. For example, to enter Run you might require Inp_Ready, a completed fill timer, and a speed permissive. Keeping permissives local to the transition preserves clarity: anyone reading the code can see exactly what it takes to move from one state to the next.
State & Transitions
The state machine’s job is purely control‑flow—deciding which state to be in—never actuating equipment. Use a current, previous, and proposed state (SM_State, SM_Prev, SM_Next) to keep decisions clean and auditable. Each scan, copy the current to the proposed, apply any interlock forces, then evaluate ordered transitions from most to least important. Only one transition should win per scan. Enforce that with a simple guard: each transition checks that the proposed state still equals the current state. If a higher‑priority transition fires first, lower ones won’t see an eligible proposal. This prevents multi‑hop behavior in one scan and encodes priority by file order. Finally, commit SM_Prev := SM_State and SM_State := SM_Next. Using previous state enables clean entry/exit pulses and event logging.
Maintain a reserved NotReady state for interlock failure, a normal Idle, any process states you need (Fill, Heat, Run, etc.), and a Faulted state for latched errors. Provide a high‑priority path to Faulted from any state and a deliberate Cmd_Reset path out once conditions are safe. Do not write device outputs in this block.
Status
Statuses are side‑effect‑free booleans derived from the current state (and stable inputs when necessary). They communicate facts to other modules and to your Outputs section. Examples include Sts_Idle, Sts_Run, or aggregates like Sts_Running := Sts_Fill OR Sts_Run. You may also add clean event flags such as OnEnter_* and OnExit_* using SM_Prev and SM_State. Keep this section pure; it should never energize hardware.
Control Resources (Timers, Counters, AOIs)
Resources support decisions but should not hide them. Instantiate timers, counters, and AOIs explicitly. Drive their inputs from statuses (for example, a fill timer’s .IN from Sts_Fill) and set parameters clearly (e.g., .PT values). Call the block, then read its outputs (.Q, .ET) to influence transitions or outputs. Keeping resource pinning and use in one visible place avoids the common anti‑pattern of buried timing logic inside transition rungs.
Outputs
Outputs are the only place where the routine requests or commands devices. Compute Out_* from Sts_* (and stable Inp_* where needed) and nowhere else. This keeps actuation policy separate from control‑flow and makes fail‑safe defaults obvious: if the machine isn’t in a state that demands action, outputs should naturally fall to safe values. Reviewers can audit behavior by reading statuses first, then outputs. If an output depends on a permissive (e.g., level low while running), compose that logic here using the status as the primary driver.
Routine order (scan sequence)
The routine should always follow this deterministic sequence:
- Inputs — aggregate and condition
Inp_*. - Commands — clear
OCmd_*, produce one‑scanCmd_*. - Interlocks — compute and apply any global forces to a safe state.
- Permissives — referenced within target transitions, not globally.
- State & Transitions — evaluate ordered transitions with a one‑transition‑per‑scan guard; then commit previous/current.
- Status — derive
Sts_*and any entry/exit pulses. - Control Resources — pin/call timers, counters, AOIs; read their outputs.
- Outputs — compute
Out_*from statuses (+ inputs) only.
This order guarantees a predictable single‑pass data flow: facts first, decisions next, effects last. It also maps cleanly to rung‑per‑transition Ladder or to straightforward ST, preserving vendor portability.
Testing and simulation
Because inputs and commands are decoupled, you can unit‑test by toggling Inp_* and OCmd_* without real hardware. Assertions become simple: given a set of inputs and a command pulse, the machine enters the expected state and the correct statuses appear; outputs then follow from those statuses. If you need more, add a Sim flag to bypass glue and drive Inp_* locally, but keep the same routine order so tests reflect reality.
Composition without sub‑states
When modules grow, prefer multiple parallel state machines over nested sub‑states. Give each machine a crisp purpose and coordinate in the parent. Coordination rules:
- Parents/siblings read child
Sts_*and write childPCmd_*to request actions. Children clearPCmd_*locally and synthesize the internalCmd_*pulses. - Parents/siblings never write child
Cmd_*.Cmd_*are internal, one‑scan, and private to the routine. - Children do not consume upstream HMI tags directly; HMI drives
OCmd_*owned by the child, which this routine clears locally. - Avoid direct child‑to‑child coupling; coordination belongs in the parent.
This keeps each machine small, auditable, and reusable while preserving encapsulation at module boundaries.
Worked example (end-to-end, compact)
Below is a compact ST sketch that demonstrates the full pattern—Inputs, Commands (with OCmd_* and PCmd_* mapped to internal Cmd_*), Interlocks, ordered Transitions with a one‑transition‑per‑scan guard, Status, Control Resources, and Outputs.
// Inputs (aggregated)
Inp_Ready := Inp_SensorsOK AND NOT Inp_EStop;
Inp_ZeroSpeed := (Inp_SpeedCmd = 0);
Inp_FillDone := (Inp_Level >= 80.0);
// Commands: synthesize internal one-scan pulses
Cmd_Start := FALSE; Cmd_Stop := FALSE; Cmd_Reset := FALSE;
IF OCmd_Start THEN OCmd_Start := FALSE; Cmd_Start := TRUE; END_IF;
IF OCmd_Stop THEN OCmd_Stop := FALSE; Cmd_Stop := TRUE; END_IF;
IF OCmd_Reset THEN OCmd_Reset := FALSE; Cmd_Reset := TRUE; END_IF;
IF PCmd_Start THEN PCmd_Start := FALSE; Cmd_Start := TRUE; END_IF; // parent request ack
// (Apply same pattern if you expose PCmd_Stop/PCmd_Reset)
// Interlocks
IntlkOk := Inp_Ready AND NOT Inp_Fault;
// State & transitions
(* constants: 0 NotReady, 1 Idle, 2 Fill, 3 Run, 4 Faulted *)
SM_Next := SM_State; // snapshot
IF NOT IntlkOk THEN // force(s) first
SM_Next := 0; // NotReady
END_IF;
// ordered transitions (first-true-wins via gate)
IF (SM_State = 1) AND (SM_Next = SM_State) AND Cmd_Start THEN
SM_Next := 2; // Idle → Fill
END_IF;
IF (SM_State = 1) AND (SM_Next = SM_State) AND Inp_Fault THEN
SM_Next := 4; // Idle → Faulted
END_IF;
IF (SM_State = 2) AND (SM_Next = SM_State) AND (tFill.Q OR Inp_FillDone) THEN
SM_Next := 3; // Fill → Run
END_IF;
IF (SM_State = 3) AND (SM_Next = SM_State) AND (Cmd_Stop AND Inp_ZeroSpeed) THEN
SM_Next := 1; // Run → Idle
END_IF;
IF (SM_State = 4) AND (SM_Next = SM_State) AND Cmd_Reset AND IntlkOk THEN
SM_Next := 1; // Faulted → Idle
END_IF;
// commit
SM_Prev := SM_State; SM_State := SM_Next;
// Status (pure)
Sts_NotReady := (SM_State = 0);
Sts_Idle := (SM_State = 1);
Sts_Fill := (SM_State = 2);
Sts_Run := (SM_State = 3);
Sts_Running := Sts_Fill OR Sts_Run;
// Control resources (explicit)
tFill.IN := Sts_Fill; tFill.PT := T#10s; TON(tFill);
// Outputs (only here)
Out_PumpEnable := Sts_Running AND NOT Inp_Fault;
Out_ValveOpen := Sts_Fill;
This example shows how OCmd_* and PCmd_* both funnel into local Cmd_* pulses, how a single interlock cutout takes priority, and how outputs are driven solely by statuses.
Why this works
This pattern separates concerns, which pays dividends across the lifecycle. Predictability comes from one transition per scan and explicit priority. Safety comes from early interlocks and fail‑safe outputs. Testability comes from decoupled inputs and single‑scan commands. Portability comes from vendor‑neutral structure that maps equally well to ST or Ladder. Maintainability comes from having exactly one place for each kind of logic, making code reviews faster and changes less risky.
Conclusion
State machines are at their best when they focus on state, not side‑effects. By organizing each routine into Inputs, Commands, Interlocks, Permissives, State & Transitions, Status, Control Resources, and Outputs—and by enforcing one transition per scan—you get deterministic behavior that’s simple to test, safe to operate, and easy to port between vendors. Adopt this layout as your default template, and future projects will start predictable, stay readable, and scale without surprises.
