Debugging
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:
🖥️ 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:
⚡ 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_TYPEFUNCTION_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_TYPEFUNCTION_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:
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.