PLC Error Handling & Exception Management: Building Fault-Tolerant Structured Text

PLCs don't have try/catch — so how do you handle errors? Build fault-tolerant automation with defensive coding patterns, hierarchical fault propagation, and graceful degradation strategies.

The Error Handling Problem in PLCs

High-level languages have try/catch, exceptions, and stack traces. PLCs have... nothing. When something goes wrong in a PLC program — a sensor reads garbage, a division by zero lurks in a calculation, or a communication link drops — the program either produces wrong outputs silently or the CPU faults and everything stops.

Neither outcome is acceptable in industrial automation. A paint line that stops mid-coat ruins a $50,000 car body. A chemical reactor that ignores a failed sensor could cause a runaway reaction. Defensive programming in Structured Text is the difference between a minor alarm and a major incident.

Error Handling Philosophy

Before writing code, establish these principles:

  • Detect early: Check every input before using it
  • Fail safe: Wrong outputs are worse than no outputs
  • Propagate clearly: Every FB must report its health status
  • Degrade gracefully: Losing one sensor shouldn't stop the whole plant
  • Log everything: You can't fix what you can't diagnose
  • The Universal Error Interface

    Every function block you write should include a standardized error interface:

    TYPE FB_ErrorStatus :
    STRUCT
        Error       : BOOL;          // TRUE = this FB has an active error
        ErrorID     : DINT;          // Unique error code
        ErrorMsg    : STRING[80];    // Human-readable description
        Warning     : BOOL;          // TRUE = degraded but operational
        WarningID   : DINT;
        FirstErrorTime : STRING[20]; // When the error first occurred
    END_STRUCT;
    END_TYPE
    

    // Apply to every FB you write
    FUNCTION_BLOCK FB_TemperatureControl
    VAR_INPUT
        Enable      : BOOL;
        SP_Temp     : REAL;
        PV_Temp     : REAL;
        PV_Valid    : BOOL;          // Sensor health from I/O diagnostics
    END_VAR
    VAR_OUTPUT
        HeatingCmd  : REAL;          // 0–100%
        Status      : FB_ErrorStatus;
    END_VAR
    VAR
        pid         : FB_PIDController;
        lastGoodPV  : REAL;
        pvFrozenTimer : TON;
        prevPV      : REAL;
    END_VAR

    // Clear previous errors Status.Error := FALSE; Status.Warning := FALSE;

    // === INPUT VALIDATION ===

    // Check 1: Is the sensor signal valid? IF NOT PV_Valid THEN Status.Error := TRUE; Status.ErrorID := 1001; Status.ErrorMsg := 'Temperature sensor fault — signal invalid'; HeatingCmd := 0.0; // Fail safe: stop heating RETURN; END_IF;

    // Check 2: Is the value physically plausible? IF PV_Temp < -50.0 OR PV_Temp > 500.0 THEN Status.Error := TRUE; Status.ErrorID := 1002; Status.ErrorMsg := 'Temperature out of physical range'; HeatingCmd := 0.0; RETURN; END_IF;

    // Check 3: Is the sensor frozen (stuck at same value)? pvFrozenTimer(IN := (ABS(PV_Temp - prevPV) < 0.01), PT := T#60S); prevPV := PV_Temp;

    IF pvFrozenTimer.Q THEN Status.Warning := TRUE; Status.WarningID := 2001; Status.ErrorMsg := 'Temperature sensor may be frozen — no change for 60s'; // Continue operating but flag the issue END_IF;

    // Check 4: Rate-of-change limit (physical impossibility check) IF ABS(PV_Temp - lastGoodPV) > 50.0 THEN // >50°C in one scan = impossible Status.Warning := TRUE; Status.WarningID := 2002; Status.ErrorMsg := 'Temperature spike detected — using last good value'; PV_Temp := lastGoodPV; // Use last known good value END_IF;

    lastGoodPV := PV_Temp;

    // === NORMAL PROCESSING === pid(Enable := Enable, SP := SP_Temp, PV := PV_Temp); HeatingCmd := pid.Output;

    // Check 5: Validate output is sane IF HeatingCmd < 0.0 OR HeatingCmd > 100.0 THEN HeatingCmd := FC_Clamp(HeatingCmd, 0.0, 100.0); Status.Warning := TRUE; Status.WarningID := 2003; Status.ErrorMsg := 'PID output clamped to safe range'; END_IF;

    Division-Safe Math

    Division by zero is the most common runtime error in PLC programs. Never trust a denominator:

    FUNCTION FC_SafeDivide : REAL
    VAR_INPUT
        Numerator   : REAL;
        Denominator : REAL;
        DefaultVal  : REAL;    // Return this if denominator is zero
    END_VAR

    IF ABS(Denominator) < 1.0E-9 THEN FC_SafeDivide := DefaultVal; ELSE FC_SafeDivide := Numerator / Denominator; END_IF;

    // Usage patterns
    FlowRate := FC_SafeDivide(Volume, TimeDelta, 0.0);
    Efficiency := FC_SafeDivide(OutputPower, InputPower, 0.0);
    Speed := FC_SafeDivide(Distance, ElapsedTime, 0.0);

    // Array index safety FUNCTION FC_SafeArrayIndex : INT VAR_INPUT Index : INT; MinIndex : INT; MaxIndex : INT; END_VAR

    IF Index < MinIndex THEN FC_SafeArrayIndex := MinIndex; ELSIF Index > MaxIndex THEN FC_SafeArrayIndex := MaxIndex; ELSE FC_SafeArrayIndex := Index; END_IF;

    Hierarchical Fault Propagation

    Errors must bubble up through the system hierarchy. A sensor fault in a temperature loop should propagate to the process unit, then to the area, then to the plant overview:

    FUNCTION_BLOCK FB_ProcessUnit
    VAR
        tempLoop1    : FB_TemperatureControl;
        tempLoop2    : FB_TemperatureControl;
        pressLoop    : FB_PressureControl;
        motorPump    : FB_MotorControl;

    UnitStatus : FB_ErrorStatus; SubErrors : INT := 0; SubWarnings : INT := 0; END_VAR

    // Run all sub-FBs tempLoop1(Enable := TRUE, SP_Temp := 80.0, PV_Temp := AI_Temp1, PV_Valid := AI_Temp1_OK); tempLoop2(Enable := TRUE, SP_Temp := 65.0, PV_Temp := AI_Temp2, PV_Valid := AI_Temp2_OK); pressLoop(Enable := TRUE, SP_Press := 3.5, PV_Press := AI_Press1, PV_Valid := AI_Press1_OK); motorPump(Start := CMD_Start, Stop := CMD_Stop);

    // Aggregate errors from children SubErrors := 0; SubWarnings := 0;

    IF tempLoop1.Status.Error THEN SubErrors := SubErrors + 1; END_IF; IF tempLoop2.Status.Error THEN SubErrors := SubErrors + 1; END_IF; IF pressLoop.Status.Error THEN SubErrors := SubErrors + 1; END_IF; IF motorPump.Status.Error THEN SubErrors := SubErrors + 1; END_IF;

    IF tempLoop1.Status.Warning THEN SubWarnings := SubWarnings + 1; END_IF; IF tempLoop2.Status.Warning THEN SubWarnings := SubWarnings + 1; END_IF;

    // Propagate worst-case status UnitStatus.Error := SubErrors > 0; UnitStatus.Warning := SubWarnings > 0 AND NOT UnitStatus.Error;

    IF SubErrors >= 2 THEN UnitStatus.ErrorID := 5001; UnitStatus.ErrorMsg := 'Multiple sub-system faults — unit shutdown'; // Trigger safe shutdown sequence ELSIF SubErrors = 1 THEN UnitStatus.ErrorID := 5002; UnitStatus.ErrorMsg := 'Single sub-system fault — degraded operation'; // Continue with degraded capability END_IF;

    Watchdog Pattern

    A watchdog verifies that a process is still alive and progressing. If a step takes too long, something is wrong:

    FUNCTION_BLOCK FB_Watchdog
    VAR_INPUT
        Enable       : BOOL;
        MonitoredBit : BOOL;     // Should change/pulse periodically
        MaxSilenceTime : TIME;   // How long before alarm
    END_VAR
    VAR_OUTPUT
        Healthy      : BOOL;
        TimedOut     : BOOL;
        SilenceSecs  : REAL;
    END_VAR
    VAR
        tmrSilence   : TON;
        lastState    : BOOL;
        stateChanged : BOOL;
    END_VAR

    IF NOT Enable THEN Healthy := FALSE; TimedOut := FALSE; RETURN; END_IF;

    // Detect any change in the monitored bit stateChanged := (MonitoredBit <> lastState); lastState := MonitoredBit;

    // Reset timer on any change IF stateChanged THEN tmrSilence(IN := FALSE); END_IF;

    tmrSilence(IN := NOT stateChanged, PT := MaxSilenceTime); TimedOut := tmrSilence.Q; Healthy := NOT TimedOut;

    // Usage: monitor a communication heartbeat
    VAR
        wdgSCADA : FB_Watchdog;
        wdgPLC2  : FB_Watchdog;
    END_VAR

    wdgSCADA(Enable := TRUE, MonitoredBit := SCADA_Heartbeat, MaxSilenceTime := T#10S); wdgPLC2(Enable := TRUE, MonitoredBit := PLC2_Heartbeat, MaxSilenceTime := T#5S);

    IF wdgSCADA.TimedOut THEN // SCADA link lost — switch to local control END_IF;

    IF wdgPLC2.TimedOut THEN // Peer PLC not responding — safe state END_IF;

    Graceful Degradation Strategies

    When a component fails, don't stop everything. Degrade gracefully:

    | Failure | Bad Response | Good Response | | Temperature sensor fails | Shut down process | Use last known good value + alarm + reduced rate | | Communication to VFD lost | Stop conveyor | Hold last speed command + alarm + timeout to safe stop | | One of two redundant pumps faults | Stop flow | Run single pump at higher speed + maintenance alarm | | HMI link lost | Operators blind | PLC continues on last setpoints + auto-mode | | Recipe download fails | Halt batch | Re-request + retry + hold at current step |

    FUNCTION_BLOCK FB_RedundantSensor
    VAR_INPUT
        PV_Primary    : REAL;
        PV_Backup     : REAL;
        Primary_Valid : BOOL;
        Backup_Valid  : BOOL;
        DeviationMax  : REAL := 5.0;
    END_VAR
    VAR_OUTPUT
        PV_Output     : REAL;
        ActiveSensor  : INT;      // 1=Primary, 2=Backup, 0=None
        SensorMismatch: BOOL;
        Status        : FB_ErrorStatus;
    END_VAR

    IF Primary_Valid AND Backup_Valid THEN // Both healthy — check agreement IF ABS(PV_Primary - PV_Backup) > DeviationMax THEN SensorMismatch := TRUE; Status.Warning := TRUE; Status.WarningID := 3001; Status.ErrorMsg := 'Sensor mismatch — deviation exceeds limit'; END_IF; PV_Output := PV_Primary; // Primary is preferred ActiveSensor := 1;

    ELSIF Primary_Valid THEN PV_Output := PV_Primary; ActiveSensor := 1; Status.Warning := TRUE; Status.WarningID := 3002; Status.ErrorMsg := 'Backup sensor offline — no redundancy';

    ELSIF Backup_Valid THEN PV_Output := PV_Backup; ActiveSensor := 2; Status.Warning := TRUE; Status.WarningID := 3003; Status.ErrorMsg := 'Primary sensor failed — running on backup';

    ELSE ActiveSensor := 0; Status.Error := TRUE; Status.ErrorID := 3004; Status.ErrorMsg := 'Both sensors failed — no valid measurement'; END_IF;

    Error Code Numbering System

    Organize error codes so technicians can quickly identify the source:

    | Range | Source | Example | | 1000–1999 | Analog inputs / sensors | 1001 = Sensor signal invalid | | 2000–2999 | Control loop warnings | 2001 = Sensor frozen | | 3000–3999 | Redundancy / diagnostics | 3003 = Primary sensor failed | | 4000–4999 | Communication | 4001 = Modbus timeout | | 5000–5999 | Process unit level | 5001 = Multiple sub-faults | | 6000–6999 | Motor / actuator | 6001 = VFD fault | | 7000–7999 | Sequence / batch | 7001 = Step timeout | | 8000–8999 | Safety system | 8001 = E-stop activated | | 9000–9999 | System / infrastructure | 9001 = CPU overload |

    // Error code lookup — useful for HMI display
    FUNCTION FC_ErrorCodeToText : STRING[80]
    VAR_INPUT
        ErrorCode : DINT;
    END_VAR

    CASE ErrorCode OF 1001: FC_ErrorCodeToText := 'Sensor signal invalid — check wiring'; 1002: FC_ErrorCodeToText := 'Value out of physical range'; 2001: FC_ErrorCodeToText := 'Sensor frozen — no change detected'; 2002: FC_ErrorCodeToText := 'Spike detected — rate-of-change exceeded'; 3001: FC_ErrorCodeToText := 'Sensor mismatch — check calibration'; 4001: FC_ErrorCodeToText := 'Communication timeout — check network'; 5001: FC_ErrorCodeToText := 'Multiple faults — unit shutdown initiated'; 6001: FC_ErrorCodeToText := 'Drive fault — check VFD display'; 7001: FC_ErrorCodeToText := 'Sequence step timeout — check process'; 8001: FC_ErrorCodeToText := 'Emergency stop activated'; ELSE FC_ErrorCodeToText := 'Unknown error code'; END_CASE;

    Defensive Programming Checklist

    Apply these checks to every function block you write:

  • Validate all inputs — range check, sensor health, physical plausibility
  • Guard all divisions — use FC_SafeDivide or explicit zero-checks
  • Bound all array indices — clamp before accessing
  • Limit all outputs — clamp actuator commands to safe ranges
  • Timeout all waits — every state that waits for something needs a watchdog
  • Report all anomalies — use the standard error interface
  • Fail to the safe state — when in doubt, stop the output
  • Log the first occurrence — capture when the error first happened
  • Summary

    | Pattern | Purpose | | Standard error interface | Every FB reports Error, ErrorID, ErrorMsg, Warning | | Input validation | Range, plausibility, frozen-sensor, rate-of-change checks | | Safe math | FC_SafeDivide, FC_SafeArrayIndex — never trust denominators | | Fault propagation | Errors bubble up through FB hierarchy to plant overview | | Watchdog | Detect stalled processes and lost communication | | Graceful degradation | Redundant sensors, last-known-good values, reduced-rate operation | | Error code system | Numbered ranges by source for fast troubleshooting |

    The goal isn't to prevent all errors — that's impossible. The goal is to detect them immediately, respond safely, and give the operator the information they need to fix it. Every unhandled error in your PLC code is a latent incident waiting for the right conditions.