PLC State Machine Design Patterns in Structured Text

Design robust, maintainable PLC programs using state machine patterns in Structured Text — the professional approach to sequential control.

Why sequence logic ends up as a state machine

Every complex PLC program ends up behaving as a state machine, whether you design it as one or not. If you don't build that structure on purpose, the program turns into a tangle of interlocking BOOLs that is nearly impossible to debug.

What a deliberate state machine gives you

The machine is always in exactly one known state, so its behaviour is predictable, and you can check a single variable online to see what it is doing. Each state spells out what is allowed to happen in it, so transitions are explicit rather than implied by a combination of flags. Adding a new state doesn't break the existing logic, and the list of states doubles as the specification of the sequence.

A basic state machine with CASE

The simplest and most common PLC state machine is an INT variable driving a CASE statement:

PROGRAM SimpleStateMachine
VAR
    State : INT := 0;
    StartButton : BOOL := FALSE;
    Sensor1 : BOOL := FALSE;
    Sensor2 : BOOL := FALSE;
    Cylinder1 : BOOL := FALSE;
    Cylinder2 : BOOL := FALSE;
    DoneFlag : BOOL := FALSE;
END_VAR

CASE State OF
    0: // IDLE
        Cylinder1 := FALSE;
        Cylinder2 := FALSE;
        DoneFlag := FALSE;
        IF StartButton THEN
            State := 10;
        END_IF;

    10: // EXTEND CYLINDER 1
        Cylinder1 := TRUE;
        IF Sensor1 THEN
            State := 20;
        END_IF;

    20: // EXTEND CYLINDER 2
        Cylinder2 := TRUE;
        IF Sensor2 THEN
            State := 30;
        END_IF;

    30: // RETRACT ALL
        Cylinder1 := FALSE;
        Cylinder2 := FALSE;
        IF NOT Sensor1 AND NOT Sensor2 THEN
            State := 40;
        END_IF;

    40: // DONE
        DoneFlag := TRUE;
        IF NOT StartButton THEN
            State := 0;
        END_IF;
END_CASE;

Why the states are numbered 0, 10, 20

Leaving gaps (10, 20, 30) instead of numbering 1, 2, 3 lets you insert new states later without renumbering everything. It is standard industrial practice.

Readable states with an ENUM type

An enumeration makes the states readable and lets the compiler reject a value that is not a valid state:

TYPE MachineState :
(
    IDLE := 0,
    LOADING := 10,
    PROCESSING := 20,
    UNLOADING := 30,
    ERROR := 99
);
END_TYPE

PROGRAM EnumStateMachine
VAR
    State : MachineState := IDLE;
    PrevState : MachineState := IDLE;
    StartCmd : BOOL := FALSE;
    LoadDone : BOOL := FALSE;
    ProcessDone : BOOL := FALSE;
    UnloadDone : BOOL := FALSE;
    ErrorDetected : BOOL := FALSE;
END_VAR

// Track state changes
PrevState := State;

// Global error check — can interrupt any state
IF ErrorDetected THEN
    State := ERROR;
END_IF;

CASE State OF
    IDLE:
        IF StartCmd THEN
            State := LOADING;
        END_IF;

    LOADING:
        // Activate loading mechanism
        IF LoadDone THEN
            State := PROCESSING;
        END_IF;

    PROCESSING:
        // Run process
        IF ProcessDone THEN
            State := UNLOADING;
        END_IF;

    UNLOADING:
        // Unload finished product
        IF UnloadDone THEN
            State := IDLE;
        END_IF;

    ERROR:
        // Safe state — all outputs off
        IF NOT ErrorDetected AND StartCmd THEN
            State := IDLE;
        END_IF;
END_CASE;

Pattern: state machine with timeouts

Real machines need timeouts to catch a jammed cylinder, a missing part or a stalled process:

PROGRAM TimedStateMachine
VAR
    State : INT := 0;
    StateTimer : TON;
    TimeoutAlarm : BOOL := FALSE;
    StartButton : BOOL := FALSE;
    PartPresent : BOOL := FALSE;
    ClampClosed : BOOL := FALSE;
    Clamp : BOOL := FALSE;
END_VAR

// Reset timer on state change (use one-shot detection)
StateTimer(IN := TRUE, PT := T#10s);

CASE State OF
    0: // IDLE
        StateTimer(IN := FALSE);  // Reset timer
        Clamp := FALSE;
        TimeoutAlarm := FALSE;
        IF StartButton AND PartPresent THEN
            State := 10;
            StateTimer(IN := FALSE);
        END_IF;

    10: // CLAMPING
        Clamp := TRUE;
        StateTimer(IN := TRUE, PT := T#5s);
        IF ClampClosed THEN
            State := 20;
            StateTimer(IN := FALSE);
        ELSIF StateTimer.Q THEN
            TimeoutAlarm := TRUE;
            State := 99;
        END_IF;

    20: // PROCESSING
        StateTimer(IN := TRUE, PT := T#30s);
        // ... process logic
        IF StateTimer.Q THEN
            TimeoutAlarm := TRUE;
            State := 99;
        END_IF;

    99: // FAULT
        Clamp := FALSE;
        // Wait for operator reset
        IF StartButton AND NOT TimeoutAlarm THEN
            State := 0;
        END_IF;
END_CASE;

Pattern: parallel state machines

Complex machines often have several subsystems running at the same time. Give each one its own state variable and let them talk through handshake signals:

PROGRAM ParallelStates
VAR
    ConveyorState : INT := 0;
    RobotState : INT := 0;
    InspectionState : INT := 0;
    
    // Handshake signals between subsystems
    PartAtRobot : BOOL := FALSE;
    RobotPickDone : BOOL := FALSE;
    PartAtInspection : BOOL := FALSE;
    InspectResult : BOOL := FALSE;
END_VAR

// Each CASE block runs independently every scan
CASE ConveyorState OF
    0: // ... conveyor logic
    10: // ...
END_CASE;

CASE RobotState OF
    0: // WAIT FOR PART
        IF PartAtRobot THEN
            RobotState := 10;
        END_IF;
    10: // PICKING
        // ... robot pick logic
        RobotPickDone := TRUE;
        RobotState := 20;
    20: // PLACING
        // ...
END_CASE;

CASE InspectionState OF
    0: // ...
    10: // ...
END_CASE;

Pattern: step and transition (SFC-like)

You can implement a Grafcet/SFC-style pattern in plain Structured Text:

PROGRAM SFCStyle
VAR
    Step : INT := 0;
    StepTimer : TON;
    
    // Transitions (conditions to move forward)
    T1 : BOOL := FALSE;  // Start condition
    T2 : BOOL := FALSE;  // Sensor reached
    T3 : BOOL := FALSE;  // Process complete
    
    // Actions
    Motor : BOOL := FALSE;
    Heater : BOOL := FALSE;
    Valve : BOOL := FALSE;
END_VAR

CASE Step OF
    0: // Initial step — all off
        Motor := FALSE;
        Heater := FALSE;
        Valve := FALSE;
        IF T1 THEN Step := 1; END_IF;

    1: // Step 1: Start motor
        Motor := TRUE;
        Heater := FALSE;
        StepTimer(IN := TRUE, PT := T#2s);
        IF StepTimer.Q AND T2 THEN
            StepTimer(IN := FALSE);
            Step := 2;
        END_IF;

    2: // Step 2: Heat
        Motor := TRUE;  // Keep running
        Heater := TRUE;
        IF T3 THEN Step := 3; END_IF;

    3: // Step 3: Dispense
        Heater := FALSE;
        Valve := TRUE;
        StepTimer(IN := TRUE, PT := T#5s);
        IF StepTimer.Q THEN
            StepTimer(IN := FALSE);
            Step := 0;  // Return to idle
        END_IF;
END_CASE;

Checklist for a PLC state machine

  • Use one state variable per machine. Don't split a single sequence across several flags.
  • Always have an IDLE state. State 0 should be safe, with all outputs off.
  • Always have an ERROR state as a safe fallback. It is a software reaction to a fault; emergency stops and guarding still belong to the safety circuit or safety PLC.
  • Add a timeout to every waiting state. Machines jam, and the timeout is how you find out.
  • Log state changes, and keep PrevState for diagnostics.
  • Use meaningful names: ENUMs, or CASE values with clear comments.
  • Test every transition, so that each IF condition has been seen to work.
  • Never skip states. Transitions should only go to adjacent states, except into the error state.
  • Practise state machines online

    You can build your own state machine in the free PLC simulator. Start with the basic CASE pattern, then add timeouts and error handling. The lessons include step-by-step state machine exercises.