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.

🔍 The Debugging Mindset

The worst debugging strategy: stare at 2,000 lines of code and hope the bug jumps out at you. The best strategy: systematic elimination. Every experienced PLC engineer follows the same mental framework:

  • 🎯 Define the symptom precisely — "Motor won't start" is vague. "Motor contactor DO:3.2 doesn't energize when HMI start button is pressed while in Auto mode" is actionable.
  • 📋 List possible causes — Work backward from the output to every condition that feeds it.
  • 🔬 Test and eliminate — Check each cause, starting with the most likely or easiest to verify.
  • ✅ Verify the fix — Confirm the symptom is resolved AND no new issues were introduced.
  • 🖥️ Watch Tables: Your Primary Tool

    A watch table lets you monitor variable values in real-time while the PLC runs. This is the single most powerful debugging tool:

    What to Watch

    // 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 NO, the problem is in PLC logic. If YES, the problem is electrical (wiring, contactor, overload).
  • Check the final condition — What variable directly controls the output? Watch it.
  • Walk backward — For each FALSE condition in the chain, ask "why is this FALSE?"
  • Find the root — Eventually you'll reach either a physical input, a timer, or a state variable that reveals the cause.
  • ⚡ Force Tables: Override for Testing

    Forcing lets you temporarily override I/O values to test logic without physical equipment. Use 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 (skipping interlocks for testing)
    //
    // 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
    

    Force Tracking in Code

    Build force detection into your program so you never forget active forces:

    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;

    🔄 State Machine Debugging

    State machines are the hardest logic to debug because the problem is often how you got to a state, not what happens in the state. Build tracing into your state machines:

    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;
    

    Now when the machine is stuck in state 99, you can read the trace buffer to see exactly which transitions led there.

    📊 Diagnostic Dashboards in Code

    Build diagnostic data directly into your function blocks — it costs nothing at runtime and saves hours during commissioning:

    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;

    🧪 Online Change Best Practices

    Making changes to a running PLC is routine but risky. Follow these rules:

    | Rule | Why | | Read the code online FIRST | Understand current state before changing anything | | Change ONE thing at a time | If something breaks, you know exactly what caused it | | Test in a safe state | Put the machine in Manual/Stop before downloading | | Watch affected variables | Monitor inputs/outputs of changed logic during first scans | | Keep a rollback plan | Save the current project before any online change | | Document the change | Even a sticky note — "Changed timer T#5S to T#8S, 2026-03-08" |

    The "Two-Screen" Method

    When debugging remotely, always have two views open:

  • Screen 1: The code you're investigating (with online monitoring)
  • Screen 2: A watch table with all relevant I/O and state variables
  • This prevents the most common mistake: fixing the code that looks wrong while the actual problem is a physical input you're 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;

    Summary

    | Technique | When to Use | | Watch tables | First tool for any debugging session | | Force tables | Testing logic without physical equipment (with caution) | | Squeeze technique | Narrowing down which condition in a chain is failing | | State tracing | Debugging state machines — understand transition history | | Diagnostic FBs | Built-in health monitoring for commissioning and maintenance | | Two-screen method | Remote debugging — code + watch table simultaneously | | Online change rules | Making safe modifications to running systems | | Common bug patterns | Quick checklist for the most frequent ST mistakes |

    The best debuggers aren't the ones who find bugs fastest — they're the ones who build code that's easy to debug. Diagnostic data, state traces, and clear variable naming are investments that pay off every time something goes wrong.