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.
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:
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:
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:
| 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 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
| 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: see the transition history |
| Diagnostic FBs | Built-in health monitoring for commissioning and maintenance |
| Two-screen method | Remote debugging: code and watch table side by side |
| Online change rules | Making safe modifications to running systems |
| Common bug patterns | Quick checklist for the most frequent ST mistakes |