PLC Timer Programming: TON, TOF, and TP Timer Examples in Structured Text

Timers are essential in every PLC program. Learn how TON, TOF, and TP timers work with practical Structured Text examples.

Why Timers Matter in PLC Programming

Timers are among the most frequently used function blocks in industrial automation. From delaying a motor start to debouncing sensor inputs, nearly every PLC program uses at least one timer.

The IEC 61131-3 standard defines three timer types:

TimerNamePurpose
TONOn-Delay TimerOutput turns ON after a set delay
TOFOff-Delay TimerOutput stays ON for a set delay after input turns OFF
TPPulse TimerGenerates a fixed-duration pulse

TON — On-Delay Timer

The TON timer is the most common timer in PLC programming. The output (Q) turns TRUE only after the input (IN) has been TRUE for the preset time (PT).

VAR
    startDelay : TON;
    startButton : BOOL := FALSE;
    motorReady : BOOL := FALSE;
END_VAR

startDelay(IN := startButton, PT := T#3s); motorReady := startDelay.Q;

// motorReady becomes TRUE 3 seconds after startButton is pressed

Practical Application: Motor Start Delay

In industrial settings, motors often require a start delay to allow lubrication systems to pressurize or safety interlocks to verify:

PROGRAM MotorStartSequence
VAR
    startCmd : BOOL;
    lubePressureOK : BOOL;
    guardsClosed : BOOL;
    startDelay : TON;
    motorRun : BOOL;
END_VAR

// Only start timing when all safety conditions are met startDelay( IN := startCmd AND lubePressureOK AND guardsClosed, PT := T#5s );

motorRun := startDelay.Q; END_PROGRAM

TOF — Off-Delay Timer

The TOF timer keeps the output ON for a specified time after the input turns OFF. This is commonly used for:

  • Cooling fan run-on: Keep fans running after a heater turns off
  • Lighting delays: Keep lights on after a motion sensor deactivates
  • Pump drainage: Continue running a pump after a level switch drops
  • VAR
        fanDelay : TOF;
        heaterOn : BOOL := FALSE;
        coolingFan : BOOL := FALSE;
    END_VAR

    fanDelay(IN := heaterOn, PT := T#30s); coolingFan := fanDelay.Q;

    // Fan stays on for 30 seconds after heater turns off

    TP — Pulse Timer

    The TP timer generates a single pulse of a fixed duration, regardless of how long the input stays ON:

    VAR
        alarm : TP;
        faultDetected : BOOL := FALSE;
        alarmHorn : BOOL := FALSE;
    END_VAR

    alarm(IN := faultDetected, PT := T#10s); alarmHorn := alarm.Q;

    // Horn sounds for exactly 10 seconds when a fault is detected

    Cascading Timers

    Real-world applications often chain timers for sequential operations:

    VAR
        step1Timer : TON;
        step2Timer : TON;
        step3Timer : TON;
        startSequence : BOOL;
        step1Done, step2Done, step3Done : BOOL;
    END_VAR

    step1Timer(IN := startSequence, PT := T#2s); step1Done := step1Timer.Q;

    step2Timer(IN := step1Done, PT := T#3s); step2Done := step2Timer.Q;

    step3Timer(IN := step2Done, PT := T#1s); step3Done := step3Timer.Q;

    Try It Yourself

    Open our PLC simulator and try the timer examples above. The simulator supports all three IEC 61131-3 timer types with real-time visualization of timer states and elapsed times.

    What the Scan Cycle Does to a Timer

    A TON with PT := T#10ms in a task that runs every 20 ms does not give you 10 ms. The block is only evaluated when it is called, so Q can only change on a task boundary. Your real resolution is the task period plus scheduler jitter under load; anything tighter belongs in a fast task, a high-speed input channel, or the drive.

    Runtimes also disagree about how time is measured: some timestamp against a free-running system clock, others add the task period on each call. Both look the same while you call the block every scan, and diverge the moment you don't — one catches up instantly, the other loses the time. Find out which you have before you port code between platforms.

    TIME is almost always a 32-bit millisecond count. TIA Portal treats it as a signed DINT, topping out at T#24d20h31m23s647ms. CODESYS-derived runtimes treat it as unsigned, giving roughly 49 days. Longer than that is a counter problem, not a timer problem.

    Retentive behaviour is a vendor extension

    None of TON, TOF or TP accumulates time across an interruption — but they discard it in three different ways, and that difference matters more than the label. TON zeroes ET the instant IN drops. TOF does the opposite: the falling edge of IN is what starts its timing, and ET is cleared when IN goes TRUE again. TP ignores IN entirely once the pulse has started — drop IN halfway through and Q stays TRUE and ET keeps counting until it reaches PT.

    What none of them offer is retention. IEC 61131-3 has no retentive timer in its standard block set — Rockwell's RTO (cleared by a RES instruction) and Siemens' TONR (cleared by its R input) are vendor extensions, and neither is portable. For accumulated runtime that must survive stops and power cycles, build it from a TIME variable in retain memory.

    The Conditional Call Is the Defect You Will Ship

    A timer instance wrapped in an IF that sometimes doesn't execute is the most common timer bug in commissioned code, and it bites hardest with TOF and TP — both of which still need to be called after IN goes FALSE. TOF doesn't even begin timing until that falling edge; TP has to keep running to finish its pulse and clear ET. Write IF heaterOn THEN fanDelay(IN := TRUE, PT := T#30s); END_IF and the block never sees the falling edge. Q freezes at its last value, the cross-reference looks clean, and the fan runs until someone cycles power.

    The rule that survives real machines: call every timer instance exactly once per scan, unconditionally, and put the condition in IN. Give timers their own block near the top of the POU; the logic underneath only reads .Q and .ET.

    Debounce Both Edges, or Don't Bother

    A single TON filters the make and passes the break straight through — and contacts bounce on release too. Use a symmetric filter with independent on and off times.

    VAR
        xRaw    : BOOL;               // raw DI from a hard-wired limit switch
        xClean  : BOOL;
        tOnDly  : TON;
        tOffDly : TOF;
    END_VAR

    tOnDly (IN := xRaw, PT := T#20ms); // must be solid for 20 ms to make tOffDly(IN := xRaw, PT := T#50ms); // must be gone for 50 ms to break

    // set on the delayed make, hold until the off-delay expires xClean := tOnDly.Q OR (xClean AND tOffDly.Q);

    The filter can never be finer than the task period, and bounce shorter than one scan may never reach your code at all. Most DI modules expose a configurable hardware input delay — that is where fast chatter belongs. Never debounce a safety input in standard logic.

    Heartbeats That Catch a Dead Peer

    Watching a heartbeat bit with a level check is the classic mistake: a partner that crashes leaves the bit frozen at whatever it happened to be, and a level check cannot tell frozen from live — stuck TRUE reads as healthy indefinitely. Watch a value that must change.

    VAR
        wHbIn       : WORD;     // free-running counter from the remote node
        wHbLast     : WORD;
        tNoChange   : TON;
        tStartGrace : TON;
        xCommsOK    : BOOL;
    END_VAR

    tStartGrace(IN := TRUE, PT := T#5s); // free-runs from the first scan tNoChange(IN := (wHbIn = wHbLast) AND tStartGrace.Q, PT := T#1s); wHbLast := wHbIn;

    xCommsOK := NOT tNoChange.Q;

    Equality rather than > makes counter rollover a non-event. The grace timer stops the watchdog faulting the machine before the first telegram arrives; without it, every cold start looks like a comms failure. Size PT at several times the nominal update period — three to five is a common starting point. And keep the watchdog in a task that cannot be starved: parked beside your recipe maths, heavy load quietly stretches the timeout you configured.

    Sequencers: Time as a Guard, Not as a Transition

    Dead-reckoning a sequence — energise the clamp, wait 2 s, assume it closed — is how a machine keeps running with a failed proximity switch or a starved air supply, right up until it wrecks a part. Transition on feedback and let the timer fault the step. Use time as the transition only where there is nothing to measure — a dwell, a cure, a glue bead — and make those an HMI parameter, because commissioning will change them twenty times.

    The other trap is one timer instance per step: each is more state to clear, and re-entering a step with a stale instance is where "it fired instantly the second time" comes from. One shared step timer, reset by the step number changing, is smaller and self-documenting.

    FUNCTION_BLOCK FB_ClampStation
    VAR_INPUT
        xEnable      : BOOL;
        xStart       : BOOL;
        xClosedFB    : BOOL;            // clamp-closed proximity
        xOpenFB      : BOOL;            // clamp-open proximity
        tMoveTimeout : TIME := T#2s;    // HMI parameter
        tDwell       : TIME := T#500ms; // HMI parameter
    END_VAR
    VAR_OUTPUT
        xClampSol  : BOOL;
        xCycleDone : BOOL;
        xTimeout   : BOOL;
        iFaultStep : INT;
    END_VAR
    VAR
        iStep        : INT  := 0;
        iStepLast    : INT  := 0;
        xStepChanged : BOOL;
        tStepPT      : TIME := T#0s;
        tmrStep      : TON;
    END_VAR

    // --- timer block: one unconditional call, ahead of all decision logic --- xStepChanged := (iStep <> iStepLast); iStepLast := iStep;

    tmrStep(IN := xEnable AND (iStep > 0) AND (iStep < 900) AND NOT xStepChanged, PT := tStepPT);

    // --- state machine: reads tmrStep.Q, never calls it --- CASE iStep OF

    0: // idle xClampSol := FALSE; IF xEnable AND xStart THEN xCycleDone := FALSE; iStep := 10; END_IF;

    10: // closing - feedback decides, the timer only guards tStepPT := tMoveTimeout; xClampSol := TRUE; IF xClosedFB THEN iStep := 20; ELSIF tmrStep.Q THEN iFaultStep := iStep; iStep := 900; END_IF;

    20: // dwell - nothing to measure, so time IS the transition tStepPT := tDwell; IF tmrStep.Q THEN iStep := 30; END_IF;

    30: // opening tStepPT := tMoveTimeout; xClampSol := FALSE; IF xOpenFB THEN xCycleDone := TRUE; iStep := 0; ELSIF tmrStep.Q THEN iFaultStep := iStep; iStep := 900; END_IF;

    900: // latched timeout fault - cleared by dropping xEnable xClampSol := FALSE; xTimeout := TRUE; IF NOT xEnable THEN xTimeout := FALSE; iStep := 0; END_IF;

    END_CASE;

    END_FUNCTION_BLOCK

    xStepChanged deliberately holds IN FALSE for one scan on every transition, guaranteeing the TON a clean FALSE-to-TRUE edge, a zeroed ET, and a tStepPT already written. The cost is one task period per step — cheaper than a chain of dedicated timers, where every stage adds its own scan of latency and one upstream drop collapses the lot.