PLC Troubleshooting & Debugging: Systematic Techniques for Structured Text

Stop randomly changing code and hoping it works. Learn the systematic debugging techniques that experienced PLC programmers use — from watch tables and force tables to state machine tracing and online monitoring.

Work by elimination

Staring at 2,000 lines of code and hoping the bug jumps out is the slowest way to find it. Experienced PLC engineers eliminate causes one at a time, in roughly the same order:

  • Define the symptom precisely. "Motor won't start" is vague. "Motor contactor DO:3.2 doesn't energize when the HMI start button is pressed in Auto mode" is something you can act on.
  • List the possible causes. Work backward from the output to every condition that feeds it.
  • Test and eliminate. Check each cause in turn, starting with the most likely one or the one that is easiest to verify.
  • Verify the fix. Confirm that the symptom has gone, and that you have not introduced a new problem.
  • Watch tables: the first tool to open

    A watch table shows you variable values in real time while the PLC runs, and you will use it in almost every debugging session.

    What to put in the watch table

    For a motor that will not start, watch three layers at once: the physical I/O, the interlock chain, and the internal state of the function block:

    // Debugging a motor that won't start
    // Add ALL of these to your watch table:
    
    // Layer 1: Physical I/O
    DI_StartButton      AT %IX0.0     // Is the button signal reaching the PLC?
    DI_StopButton       AT %IX0.1     // Is stop stuck ON?
    DI_MotorFeedback    AT %IX0.2     // Is the contactor auxiliary showing state?
    DO_MotorContactor   AT %QX0.0     // Is the PLC trying to energize?
    
    // Layer 2: Interlock chain
    SafetyCircuit_OK    : BOOL        // Are safeties met?
    Overload_OK         : BOOL        // No thermal overload?
    VFD_Ready           : BOOL        // Drive in ready state?
    Permission_Auto     : BOOL        // In correct mode?
    
    // Layer 3: Logic internals
    Motor1.State        : INT         // What state is the FB in?
    Motor1._latched     : BOOL        // Did the start latch set?
    Motor1._faultCode   : INT         // Any active fault?
    Motor1._runRequest  : BOOL        // Is there an active run request?
    

    The "squeeze" technique

    Start broad and narrow down:

  • Check the output. Is DO_MotorContactor TRUE? If not, the problem is in the PLC logic. If it is, the problem is electrical: the wiring, the contactor or the overload.
  • Check the final condition. Find the variable that directly controls the output, and watch it.
  • Walk backward. For each FALSE condition in the chain, ask why it is FALSE.
  • Find the root. Sooner or later you reach a physical input, a timer or a state variable that shows you the cause.
  • Force tables: overriding values for a test

    Forcing temporarily overrides I/O values, so you can test logic without the physical equipment. Use it with extreme caution in production.

    // DANGER ZONE: Force table considerations
    // 
    // SAFE to force:
    // - Digital inputs (simulating button presses)
    // - Analog inputs (simulating sensor values)
    // - Internal variables (never to bypass an interlock)
    //
    // DANGEROUS to force:
    // - Digital outputs (bypasses all safety logic!)
    // - Safety-related inputs (defeats protection)
    // - Communication variables (can confuse state machines)
    //
    // ALWAYS:
    // - Document every active force
    // - Remove ALL forces before leaving the machine
    // - Never force outputs on a machine with people nearby
    

    Tracking forces in code

    Build force detection into the program, so an active force cannot be forgotten. How you read the force flags depends on the platform, as the comments below show:

    FUNCTION_BLOCK FB_ForceDetector
    VAR_INPUT
        Enable        : BOOL;
    END_VAR
    VAR_OUTPUT
        ForcesActive  : BOOL;
        ForceCount    : INT;
        WarningMsg    : STRING[80];
    END_VAR
    VAR
        // Platform-specific: read force flags from system diagnostics
        // Siemens: SFC 82 or diagnostic buffer
        // Allen-Bradley: GSV instruction, ForceStatus
        // CODESYS: IoConfig force flags
    END_VAR
    
    // Check for active forces (implementation is platform-specific)
    // Most platforms provide a system bit indicating forces are active
    
    IF ForcesActive THEN
        WarningMsg := 'WARNING: I/O forces are active — remove before production!';
        // Flash warning on HMI
        // Log to event system
    END_IF;
    

    Debugging state machines

    State machines are the hardest logic to debug, because the fault is often in how the machine reached a state rather than in what the state does. Build tracing into them. This tracer keeps the last 50 transitions in a ring buffer:

    TYPE StateTraceEntry :
    STRUCT
        FromState    : INT;
        ToState      : INT;
        Trigger      : STRING[30];    // What caused the transition
        ScanNumber   : DINT;
    END_STRUCT;
    END_TYPE
    
    FUNCTION_BLOCK FB_StateTracer
    VAR
        TraceBuffer  : ARRAY[0..49] OF StateTraceEntry;   // Last 50 transitions
        TraceIndex   : INT := 0;
        PrevState    : INT := -1;
        ScanCount    : DINT := 0;
    END_VAR
    
    VAR_INPUT
        CurrentState : INT;
        TriggerName  : STRING[30];
    END_VAR
    
    ScanCount := ScanCount + 1;
    
    IF CurrentState <> PrevState THEN
        // State changed — record transition
        TraceBuffer[TraceIndex].FromState := PrevState;
        TraceBuffer[TraceIndex].ToState := CurrentState;
        TraceBuffer[TraceIndex].Trigger := TriggerName;
        TraceBuffer[TraceIndex].ScanNumber := ScanCount;
    
        TraceIndex := TraceIndex + 1;
        IF TraceIndex > 49 THEN TraceIndex := 0; END_IF;
    
        PrevState := CurrentState;
    END_IF;
    

    // Usage in your state machine:
    CASE MachineState OF
        0: IF StartCmd THEN
               MachineState := 1;
               stateTracer(CurrentState := 1, TriggerName := 'StartCmd');
           END_IF;
           
        1: IF SensorTripped THEN
               MachineState := 2;
               stateTracer(CurrentState := 2, TriggerName := 'SensorTripped');
           ELSIF Timeout THEN
               MachineState := 99;
               stateTracer(CurrentState := 99, TriggerName := 'StepTimeout');
           END_IF;
    END_CASE;
    

    When the machine is stuck in state 99, read the trace buffer and you can see which transitions led there, and on which scan each one happened.

    Diagnostic data in the function blocks

    Build diagnostic data straight into your function blocks. It costs very little at runtime and saves hours during commissioning. This collector counts run, idle and fault time, cycles and errors:

    TYPE DiagnosticData :
    STRUCT
        // Timing
        LastCycleTime    : TIME;
        MaxCycleTime     : TIME;
        AvgCycleTime     : REAL;       // ms
        
        // Health
        ErrorCount       : DINT;
        WarningCount     : DINT;
        LastErrorCode    : DINT;
        LastErrorTime    : STRING[20];
        
        // Performance
        TotalCycles      : DINT;
        SuccessfulCycles : DINT;
        SuccessRate      : REAL;       // %
        
        // Uptime
        RunTimeSecs      : DINT;
        IdleTimeSecs     : DINT;
        FaultTimeSecs    : DINT;
    END_STRUCT;
    END_TYPE
    
    FUNCTION_BLOCK FB_DiagnosticsCollector
    VAR_INPUT
        Running     : BOOL;
        Faulted     : BOOL;
        CycleComplete : BOOL;
        CycleSuccess  : BOOL;
        ErrorCode   : DINT;
    END_VAR
    VAR_OUTPUT
        Diag        : DiagnosticData;
    END_VAR
    VAR
        scanTimeSec : REAL := 0.01;
    END_VAR
    
    // Track uptime categories
    IF Faulted THEN
        Diag.FaultTimeSecs := Diag.FaultTimeSecs + 1;
    ELSIF Running THEN
        Diag.RunTimeSecs := Diag.RunTimeSecs + 1;
    ELSE
        Diag.IdleTimeSecs := Diag.IdleTimeSecs + 1;
    END_IF;
    
    // Track cycles
    IF CycleComplete THEN
        Diag.TotalCycles := Diag.TotalCycles + 1;
        IF CycleSuccess THEN
            Diag.SuccessfulCycles := Diag.SuccessfulCycles + 1;
        END_IF;
    END_IF;
    
    // Success rate
    IF Diag.TotalCycles > 0 THEN
        Diag.SuccessRate := (DINT_TO_REAL(Diag.SuccessfulCycles) / DINT_TO_REAL(Diag.TotalCycles)) * 100.0;
    END_IF;
    
    // Error tracking
    IF ErrorCode <> 0 AND ErrorCode <> Diag.LastErrorCode THEN
        Diag.ErrorCount := Diag.ErrorCount + 1;
        Diag.LastErrorCode := ErrorCode;
    END_IF;
    

    Rules for online changes

    Changing a running PLC is routine, and it is still risky. These are the rules to follow:

    RuleWhy
    Read the code online FIRSTUnderstand current state before changing anything
    Change ONE thing at a timeIf something breaks, you know exactly what caused it
    Test in a safe statePut the machine in Manual/Stop before downloading
    Watch affected variablesMonitor inputs/outputs of changed logic during first scans
    Keep a rollback planSave the current project before any online change
    Document the changeEven a sticky note: "Changed timer T#5S to T#8S, 2026-03-08"

    The two-screen method

    When you debug remotely, keep two views open. One shows the code you are investigating, with online monitoring on. The other is a watch table with all the relevant I/O and state variables.

    That stops the most common mistake, which is fixing code that looks wrong while the real problem is a physical input you are not watching.

    Common bug patterns in Structured Text

    Bug 1: missing ELSE branch

    // BUG: Output stays TRUE forever once set
    IF Condition THEN
        Output := TRUE;
    END_IF;
    // Missing: Output never gets set back to FALSE
    
    // FIX: Always handle both branches
    IF Condition THEN
        Output := TRUE;
    ELSE
        Output := FALSE;
    END_IF;
    
    // Or even better — direct assignment:
    Output := Condition;
    

    Bug 2: one-scan pulse missed

    // BUG: This only catches the signal if it happens during THIS scan
    IF TriggerSignal THEN
        DoSomething := TRUE;
    END_IF;
    
    // FIX: Use edge detection
    IF TriggerSignal AND NOT PrevTrigger THEN
        DoSomething := TRUE;    // Rising edge only
    END_IF;
    PrevTrigger := TriggerSignal;
    

    Bug 3: timer that never starts

    // BUG: Timer never times out because IN is reset every scan
    myTimer(IN := StartCondition, PT := T#5S);
    IF myTimer.Q THEN
        // This never executes if StartCondition pulses
    END_IF;
    
    // FIX: Latch the start condition
    IF StartCondition THEN
        TimerRunning := TRUE;
    END_IF;
    myTimer(IN := TimerRunning, PT := T#5S);
    IF myTimer.Q THEN
        TimerRunning := FALSE;
        // Now this executes after 5 seconds
    END_IF;
    

    Bug 4: array index out of bounds

    // BUG: If RecipeStep is 0 or > 10, undefined behavior
    CurrentMaterial := Recipe[RecipeStep].MaterialID;
    
    // FIX: Always validate indices
    IF RecipeStep >= 1 AND RecipeStep <= 10 THEN
        CurrentMaterial := Recipe[RecipeStep].MaterialID;
    ELSE
        CurrentMaterial := 0;   // Safe default
        // Log error: invalid recipe step index
    END_IF;
    

    Which technique to use when

    TechniqueWhen to Use
    Watch tablesFirst tool for any debugging session
    Force tablesTesting logic without physical equipment (with caution)
    Squeeze techniqueNarrowing down which condition in a chain is failing
    State tracingDebugging state machines: see the transition history
    Diagnostic FBsBuilt-in health monitoring for commissioning and maintenance
    Two-screen methodRemote debugging: code and watch table side by side
    Online change rulesMaking safe modifications to running systems
    Common bug patternsQuick checklist for the most frequent ST mistakes
    Write code that is easy to debug before you need to debug it. Diagnostic data, state traces and clear variable names take a little time to add, and you get that time back every time something goes wrong.