Chapter 26 / 32 · Part 7 · Advanced language

Global variables and tasks

Share data with VAR_GLOBAL and VAR_EXTERNAL, split a program across tasks with different cycle times, and keep shared data consistent.

Where a variable lives

Every variable you have declared so far lived inside one program or one function block. Count in PROGRAM Main belonged to Main; nothing else could see it, and if two programs both declared Count they were two different variables that happened to share a name. That is local scope, and it is the reason an instance of FB_Motor can be copied forty times without the forty copies interfering.

Real machines need some data to be shared. The line speed set on the HMI is used by the infeed, the labeller and the case packer. The "line running" status is set by one program and read by six. The safety controller's status word arrives once and matters everywhere. Somewhere, one copy of that data has to exist that several pieces of code can reach. That is a global variable: declared once, outside any program, visible to everything that asks for it.

There is a spectrum between the two, and it helps to name it:

ScopeDeclared inWho can read itWho can write it
TemporaryVAR_TEMP of a POUthat POU, during one callthat POU
LocalVAR of a program or FBthat program or instancethat program or instance
InterfaceVAR_INPUT / VAR_OUTPUT / VAR_IN_OUTthe caller passes and reads itcaller writes inputs, POU writes outputs
GlobalVAR_GLOBALany POU that declares it VAR_EXTERNAL (or, on most platforms, anything)the same — which is the problem

The first three rows have a property the fourth does not: you can tell, by reading one piece of code, everything that can change the variable. A function block's output can only be written by that block. A global can be written by any line of any program in the project, and by an HMI, a SCADA server, another PLC and a commissioning laptop as well. Everything in this chapter follows from that difference.

VAR_GLOBAL and VAR_EXTERNAL

IEC 61131-3 declares globals in a VAR_GLOBAL block, and asks each program or function block that uses one to say so with a matching VAR_EXTERNAL declaration. The external declaration creates nothing; it is a promise — "I use this global, with this type" — that the compiler checks against the real declaration.

VAR_GLOBAL
    gLineSpeed_mpm : REAL;    (* writer: HMI — the input field here *)
    gLineRunning   : BOOL;    (* writer: Main, nowhere else *)
    gCartons       : DINT;    (* writer: Main, nowhere else *)
END_VAR

PROGRAM Main
VAR_EXTERNAL
    gLineSpeed_mpm : REAL;
    gLineRunning   : BOOL;
    gCartons       : DINT;
END_VAR
VAR
    RunSwitch     : BOOL;     (* input *)
    CartonEye     : BOOL;     (* input: photo-eye at the labeller infeed *)
    EyeEdge       : R_TRIG;
    LabelDelay_ms : REAL;     (* eye-to-label-head travel time *)
END_VAR

gLineRunning := RunSwitch AND gLineSpeed_mpm > 0.0;

EyeEdge(CLK := CartonEye);
IF EyeEdge.Q AND gLineRunning THEN
    gCartons := gCartons + 1;
END_IF;

(* The label head sits 0.4 m downstream of the eye *)
IF gLineSpeed_mpm > 0.0 THEN
    LabelDelay_ms := 0.4 / gLineSpeed_mpm * 60000.0;
ELSE
    LabelDelay_ms := 0.0;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: RunSwitch, CartonEye, gLineSpeed_mpm.

Type 30 into gLineSpeed_mpm: the label delay is 800 ms. At 60 it halves to 400 ms. Switch RunSwitch on and flick CartonEye to count cartons. The program uses the globals exactly like locals; the difference is only in where they live and who else can reach them. The g prefix and the "writer:" comments are not required by any compiler. They are the cheapest documentation you will ever write, and the next section shows why they matter.

Most platforms do not actually enforce VAR_EXTERNAL. In CODESYS and TwinCAT a global is visible everywhere without it; Siemens and Rockwell have no such keyword at all. Here is how the same idea looks on the platforms you are likely to meet:

CODESYS and TwinCAT 3 put globals in a Global Variable List (GVL), an object in the project tree. The qualified_only attribute forces every use to carry the list's name, which makes the source of a global visible at the point of use and lets two areas both have a Speed without colliding:

// GVL_Line (Global Variable List)
{attribute 'qualified_only'}
VAR_GLOBAL
    rLineSpeed_mpm : REAL;      // writer: P_Hmi
    bLineRunning   : BOOL;      // writer: P_Line
    nCartons       : DINT;      // writer: P_Line
END_VAR

// in any program or FB — no VAR_EXTERNAL needed
IF GVL_Line.bLineRunning THEN
    fLabelDelay := 0.4 / GVL_Line.rLineSpeed_mpm * 60000.0;
END_IF

TIA Portal has two kinds of global data. PLC tags in the tag table ("Line_Running", mapped to inputs, outputs or bit memory) are global by nature, and so is every global data block: "DB_Line".LineSpeed. Less obviously, every instance data block is global too. Any block in the project can read — and write — "FB_Filler_DB".statBottleCount, and TIA will compile it. Constants that the whole project shares go in the User constants tab of a tag table.

Studio 5000 separates controller-scoped tags, visible to every program and task, from program-scoped tags, visible only inside their program. Since version 24, programs can also declare program parameters — Input, Output, InOut and Public — which other programs reach as \Infeed.SpeedRef. They are the closest Logix equivalent of a function block interface for whole programs, and they are a much better way to share data between programs than a pile of controller-scoped tags.

Hidden writers

The one-owner rule from chapter 21 — every output written in one place — is easy to keep for a local variable, because the place is in front of you. A global can be written from a program you have never opened. Here is a conveyor whose run command is a global, written by the automatic logic and also, as it turns out, by a maintenance panel block someone added last year.

Now try it live, and look at the second, worse half of the bug:

VAR_GLOBAL
    gConveyorRun : BOOL;
END_VAR

FUNCTION_BLOCK FB_MaintPanel
VAR_INPUT
    JogPB : BOOL;
END_VAR
VAR_EXTERNAL
    gConveyorRun : BOOL;      (* a second writer nobody documented *)
END_VAR
IF JogPB THEN
    gConveyorRun := TRUE;
ELSE
    gConveyorRun := FALSE;
END_IF;
END_FUNCTION_BLOCK

PROGRAM Main
VAR_EXTERNAL
    gConveyorRun : BOOL;
END_VAR
VAR
    AutoRun : BOOL;           (* input *)
    JogPB   : BOOL;           (* input: maintenance jog button *)
    GuardOK : BOOL := TRUE;   (* input: guard door closed *)
    Panel   : FB_MaintPanel;
    Motor   : BOOL;
END_VAR

gConveyorRun := AutoRun AND GuardOK;
Panel(JogPB := JogPB);
Motor := gConveyorRun;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: AutoRun, JogPB, GuardOK.

Switch AutoRun on: nothing. Now switch GuardOK off and press JogPB: the motor runs with the guard open, because the panel block's write comes after the permissive and overwrites it. One hidden writer has produced two faults — a machine that will not run when it should, and one that runs when it must not. (The real protection for a person reaching through an open guard is a hardwired or safety-PLC circuit, as chapter 22 insisted. But the standard program must not undo the process interlocks either.)

The fix is the same as for any output with two owners: the maintenance block produces a request, Panel.JogRequest, and the one place that writes gConveyorRun combines the requests and applies the permissives last. Three habits keep globals from turning into this:

  1. Write the owner next to the declaration. (* writer: P_Line *) costs one comment and saves a morning of searching.
  2. Pass globals into function blocks as inputs, not as VAR_EXTERNAL. A block that reads gLineSpeed internally can only ever work on one line; a block with a LineSpeed input works on any line, and a reviewer can see every value it touches at the call. VAR_EXTERNAL inside a reusable block is a hidden dependency, and inside a block that writes it is a hidden writer.
  3. Check the cross-reference list before you add a write. CODESYS and TwinCAT call it the Cross Reference List, TIA Portal Cross-references, Studio 5000 Cross Reference. One write location per global is the target; two needs a reason written next to both.

Global constants for configuration

Not every global is dangerous. A global that nothing can write is just a name for a number, and that is exactly what configuration data should be. VAR_GLOBAL CONSTANT declares values the compiler will refuse to let any code change — the same protection VAR CONSTANT gave a single program in chapter 2, extended to the whole project.

VAR_GLOBAL CONSTANT
    MAX_SPEED_MPM : REAL := 60.0;   (* gearbox limit, drive commissioning sheet *)
    JAM_TIME      : TIME := T#3s;   (* eye blocked this long = jam *)
END_VAR

PROGRAM Main
VAR
    SpeedReq     : REAL := 75.0;    (* input: from the HMI *)
    Lane1Blocked : BOOL;            (* input *)
    Lane2Blocked : BOOL;            (* input *)
    SpeedCmd     : REAL;
    Blocked      : ARRAY[1..2] OF BOOL;
    JamT         : ARRAY[1..2] OF TON;
    Lane1Jam     : BOOL;
    Lane2Jam     : BOOL;
    AnyJam       : BOOL;
    i            : INT;
END_VAR

SpeedCmd := LIMIT(0.0, SpeedReq, MAX_SPEED_MPM);

Blocked[1] := Lane1Blocked;
Blocked[2] := Lane2Blocked;
AnyJam := FALSE;
FOR i := 1 TO 2 DO
    JamT[i](IN := Blocked[i], PT := JAM_TIME);
    AnyJam := AnyJam OR JamT[i].Q;
END_FOR;
Lane1Jam := JamT[1].Q;
Lane2Jam := JamT[2].Q;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: SpeedReq, Lane1Blocked, Lane2Blocked.

The HMI asks for 75 m/min and gets 60. Block a lane for three seconds and its jam flag sets. On a real controller, a line such as MAX_SPEED_MPM := 80.0; anywhere in the project fails the build with "cannot assign to constant" (this page's engine enforces that for integer constants but not yet for REAL or TIME ones). That failure is the point: the gearbox limit came from a commissioning sheet signed by a mechanical engineer, and no amount of HMI pressure should be able to change it at runtime. Strict IEC 61131-3 asks the program to repeat the declaration as VAR_EXTERNAL CONSTANT MAX_SPEED_MPM : REAL; END_VAR; CODESYS, TwinCAT and the engine on this page make global constants visible without it. CODESYS and TwinCAT would also accept a constant as an array bound, ARRAY[1..N_LANES]; the engine here needs the literal.

The line between constants and parameters is worth drawing deliberately:

Tasks: cyclic, freewheeling and event

Until now "the scan" has meant one loop: read inputs, run the program, write outputs, repeat. A real controller can run several of those loops at once, each called a task, each with its own rule for when it starts, and you assign programs to tasks. IEC 61131-3 writes it in a configuration:

CONFIGURATION Line1
    RESOURCE CPU1 ON PLC
        TASK T_Motion (INTERVAL := T#2ms,   PRIORITY := 1);
        TASK T_Line   (INTERVAL := T#10ms,  PRIORITY := 5);
        TASK T_Hmi    (INTERVAL := T#100ms, PRIORITY := 10);
        PROGRAM P_Motion WITH T_Motion : PRG_Motion;
        PROGRAM P_Line   WITH T_Line   : PRG_Line;
        PROGRAM P_Hmi    WITH T_Hmi    : PRG_Hmi;
    END_RESOURCE
END_CONFIGURATION

No vendor makes you type that; every IDE has a task configuration dialog that does the same job. What matters is the three kinds of task you will find behind it:

  • A cyclic (periodic) task starts at a fixed interval — every 2 ms, every 10 ms. If it finishes early, the CPU does something else until the next start. Timing is predictable, which is what control loops and motion need.
  • A freewheeling (continuous) task restarts as soon as it finishes. Its cycle time is however long the code takes, plus communication overhead, and varies from scan to scan. Siemens OB1 is the best-known example.
  • An event task runs once when something happens: a rising edge of a variable, a hardware input changing state, a motion group update.
PlatformCyclicFreewheelingEventPriority order
CODESYSCyclicFreewheelingEvent (rising edge of a global BOOL), Status, External0 is highest (0–31)
TwinCAT 3Cyclic, in multiples of the base time (1 ms by default)——lower number is higher
TIA Portal, S7-1500Cyclic interrupt OB30–OB38 (and more from OB123)OB1 program cycleHardware interrupt OB40+, time-of-day, time-delayhigher number is higher (OB1 is 1, cyclic OBs 2–24)
Studio 5000PeriodicContinuous (at most one, lowest priority)Event (module change of state, consumed tag, EVENT instruction)1 is highest (1–15)

The priority column is the one that catches people moving between platforms. In CODESYS, TwinCAT, Logix and the IEC standard a smaller number wins; in TIA Portal a larger one does. A migration that copies the numbers across inverts the whole scheme.

The engine on these pages runs one program in one task. To show tasks interacting, the examples below model the scheduler with a tick counter: each scan is one 100 ms base cycle, the "fast task" section runs every scan, and the "slow task" section runs when the tick is a multiple of five. Here a sensor pulses for one scan every few scans, and both tasks try to count it:

PROGRAM Main
VAR CONSTANT
    SLOW_EVERY : DINT := 5;   (* slow task interval = 5 base cycles *)
END_VAR
VAR
    PulseEvery : DINT := 2;   (* input: sensor is TRUE one cycle in N *)
    Tick       : DINT;
    Pulse      : BOOL;
    FastPrev   : BOOL;
    SlowPrev   : BOOL;
    FastCount  : DINT;
    SlowCount  : DINT;
    SlowRuns   : DINT;
END_VAR

Tick  := Tick + 1;
Pulse := (Tick MOD MAX(PulseEvery, 1)) = 0;

(* ===== fast task: every base cycle ===== *)
IF Pulse AND NOT FastPrev THEN
    FastCount := FastCount + 1;
END_IF;
FastPrev := Pulse;

(* ===== slow task: every SLOW_EVERY base cycles ===== *)
IF (Tick MOD SLOW_EVERY) = 0 THEN
    SlowRuns := SlowRuns + 1;
    IF Pulse AND NOT SlowPrev THEN
        SlowCount := SlowCount + 1;
    END_IF;
    SlowPrev := Pulse;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: PulseEvery.

Let it run. The fast task counts every pulse. The slow task, sampling every fifth cycle, sees the sensor only when one of its runs happens to land on a pulse: with PulseEvery at 2 it counts one pulse in five. Set PulseEvery to 5 and the slow task never sees a rising edge at all after the first — its samples land on the pulse every time, so the signal looks permanently TRUE to it. Set it to 7 and it catches a pulse every seventh run. None of those numbers is "the count". A task can only see what happens at its own rate, and anything faster is aliased into nonsense, which is why anything that counts or edge-detects a fast signal belongs in a task at least twice as fast as the signal changes (chapter 11 did the arithmetic for counters).

Cycle times and priorities

Assign work to tasks by what it needs, not by where it was first written. A sensible split for a packaging machine on a mid-range controller looks like this:

WorkTypical taskWhy
Motion, registration, cam tracking1–4 ms, highest priorityposition changes fast; jitter shows up as product error
Machine sequence, interlocks, I/O logic10 msfast enough for contactors, valves and photo-eyes at normal line speeds
PID loops on temperature, level, pressure100–500 ms, cyclicslow processes; a fixed sample time matters more than speed
HMI data, statistics, OEE, logging100 ms–1 s, low prioritynobody notices 200 ms on a screen

Two numbers describe a task: its interval (how often it should start) and its execution time (how long it actually takes). A higher-priority task that becomes due pre-empts a lower-priority one: the lower task is frozen mid-statement, the higher task runs to completion, and the lower one resumes where it stopped. So a slow task's execution time stretches by every fast task that cuts in. Watch the CPU load figure in the task monitor and keep it well below 100 % — most integrators aim for under 50–60 % at full production so there is room for communication bursts and future changes.

Torn reads

Pre-emption is invisible as long as tasks share nothing. The moment they share a value that takes more than one write to update, it becomes the most elusive bug in PLC programming.

A filling line has a recipe task at 100 ms that changes the active product, and a filler task at 10 ms that uses it. The product has an ID and a fill volume, and they belong together: product 2 fills 200 ml, product 3 fills 300 ml. The recipe task writes the ID, then the volume — two statements. If the filler task pre-empts it between those two statements, it reads the new ID with the old volume. For one 10 ms cycle, the machine believes product 3 fills 200 ml.

The model below forces that interruption. When Interrupted is TRUE, the slow task's update is split: the ID is written in one base cycle and the volume in the next, with the fast task running in between — exactly what a pre-emption does.

VAR_GLOBAL
    gRecipeId  : INT;     (* writer: recipe task *)
    gVolume_ml : INT;     (* writer: recipe task; always Id * 100 *)
END_VAR

PROGRAM Main
VAR_EXTERNAL
    gRecipeId  : INT;
    gVolume_ml : INT;
END_VAR
VAR
    Interrupted : BOOL := TRUE;   (* input: does the fast task cut in mid-update? *)
    Tick        : DINT;
    Resume      : BOOL;           (* slow task frozen between its two writes *)
    NextId      : INT := 1;
    UseId       : INT;            (* the filler's copy *)
    UseVolume   : INT;
    TornReads   : DINT;
END_VAR

Tick := Tick + 1;

(* ===== slow task (recipe), every 10 base cycles ===== *)
IF Resume THEN
    gVolume_ml := NextId * 100;   (* ... resumes and writes the second value *)
    Resume := FALSE;
ELSIF (Tick MOD 10) = 0 THEN
    NextId := NextId MOD 3 + 1;
    gRecipeId := NextId;          (* first value written ... *)
    IF Interrupted THEN
        Resume := TRUE;           (* ... and the fast task pre-empts here *)
    ELSE
        gVolume_ml := NextId * 100;
    END_IF;
END_IF;

(* ===== fast task (filler), every base cycle ===== *)
UseId     := gRecipeId;
UseVolume := gVolume_ml;
IF UseVolume <> UseId * 100 THEN
    TornReads := TornReads + 1;   (* a pair that never existed *)
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Interrupted.

Every tenth cycle the recipe changes and TornReads goes up by one: for one cycle, the filler holds an ID and a volume that were never a valid pair. Switch Interrupted off and the counter stops — same code, same data, the interrupt just did not land in the gap. On a real controller that gap is a few microseconds wide, which is why a torn read can survive months of testing and then, one afternoon, start a fill with the volume of the previous product — or, on a filler that latches its recipe at the start of a batch, a whole batch.

It is not only structures. On a 32-bit CPU, a 64-bit LREAL or LINT can be written as two 32-bit halves. A STRING is copied character by character. A STRUCT copy is a memory copy that can be interrupted at any byte. The only writes you can count on being indivisible are single, naturally aligned values no wider than the CPU's word — a BOOL, a DINT, a REAL on a 32-bit controller — and even that is a property of the hardware, not a promise of the language.

Taking a consistent snapshot

There are two portable fixes, and both rest on the same idea: make the reader's decision depend on a single value that is written in one indivisible step.

The first is the double buffer. The writer keeps two copies of the data. It fills the copy the reader is not using — taking as many statements as it likes, being interrupted as often as it likes — and only then flips an index to point at it. The flip is one INT write. The reader reads the index first and then copies from that buffer.

VAR_GLOBAL
    gBufId  : ARRAY[0..1] OF INT;     (* writer: recipe task *)
    gBufVol : ARRAY[0..1] OF INT;     (* writer: recipe task *)
    gActive : INT;                                (* writer: recipe task; one write *)
END_VAR

PROGRAM Main
VAR_EXTERNAL
    gBufId  : ARRAY[0..1] OF INT;
    gBufVol : ARRAY[0..1] OF INT;
    gActive : INT;
END_VAR
VAR
    Interrupted : BOOL := TRUE;   (* input *)
    Tick        : DINT;
    Resume      : BOOL;
    NextId      : INT := 1;
    Spare       : INT;            (* the buffer the reader is not using *)
    i           : INT;
    UseId       : INT;
    UseVolume   : INT;
    TornReads   : DINT;
END_VAR

Tick := Tick + 1;

(* ===== slow task: fill the spare buffer, then flip ===== *)
IF Resume THEN
    gBufVol[Spare] := NextId * 100;
    gActive := Spare;             (* publish: a single INT write *)
    Resume := FALSE;
ELSIF (Tick MOD 10) = 0 THEN
    NextId := NextId MOD 3 + 1;
    Spare := 1 - gActive;
    gBufId[Spare] := NextId;
    IF Interrupted THEN
        Resume := TRUE;           (* pre-empted mid-update: harmless now *)
    ELSE
        gBufVol[Spare] := NextId * 100;
        gActive := Spare;
    END_IF;
END_IF;

(* ===== fast task: read the index once, then copy that buffer ===== *)
i := gActive;
UseId     := gBufId[i];
UseVolume := gBufVol[i];
IF UseVolume <> UseId * 100 THEN
    TornReads := TornReads + 1;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Interrupted.

TornReads stays at zero with Interrupted on or off. The reader sees the old pair until the instant of the flip, and the new pair from then on, never a mixture. The double buffer has one condition: the reader must finish its copy before the writer can come round and refill the buffer it is reading. That holds automatically when the reader is the faster, higher-priority task, because the writer cannot run while the reader runs.

When the reader is the slower task, it can itself be pre-empted halfway through its copy, and the second fix is the right one: a sequence number. The writer makes the number odd before it starts changing the data and even again when it has finished. The reader takes a copy only when the number is even, and only when it has changed since its last copy. A reader that might be interrupted during the copy checks the number again afterwards and throws the copy away if it moved. It costs one DINT and works between tasks, between cores, and between PLCs over a network.

The platforms also offer their own tools, and they are worth knowing because you will meet them in other people's code:

  • Studio 5000: CPS (Synchronous Copy File) copies an array or structure without being interrupted by other tasks or by I/O updates; COP does not have that guarantee. UID and UIE disable and re-enable task switching around a short critical section.
  • TIA Portal: UBLKMOV copies a memory area without interruption, and DIS_AIRT / EN_AIRT delay higher-priority interrupts around a few statements.
  • TwinCAT 3: tasks may run on different CPU cores at the same time, so "disable interrupts" means nothing. The Tc2_System library has TestAndSet and an IEC critical section block for mutual exclusion; Beckhoff's own advice for most cases is still a single writer and a clean handover.
  • CODESYS: semaphore functions in the SysSem library, and in recent versions atomic operators such as __XADD and __COMPARE_AND_SWAP.

Use them sparingly. A disabled interrupt delays your motion task by exactly as long as the critical section lasts, and a mutual-exclusion lock that a low-priority task holds can stall a high-priority one. A single writer, a sequence number or a double buffer needs no special instruction and behaves the same on every platform.

Passing events between tasks

Torn data is one hazard; lost events are the other. A fast task sees a part pass a photo-eye and wants the slow task to count it. The obvious handover is a flag: the fast task sets PartFlag, the slow task reads it, counts, and clears it. It works on the bench, where parts arrive one at a time. At full speed two parts arrive between two runs of the slow task, the flag is set twice, the slow task sees it once, and the count is short — by a few per thousand, which is the most expensive kind of wrong.

The fix is to pass a count, not a flag. The fast task owns a counter and only ever increments it. The slow task reads the counter and works out how many arrived since last time. Nothing is ever cleared by the reader, so nothing can be lost, and there is still exactly one writer.

Watchdogs per task

Every task has a deadline, and every controller checks it. If a task is still running when it should already have finished — a WHILE loop that never exits (chapter 14), a FOR over 100,000 recipe entries, a division chain that went on far longer than anyone tested — the watchdog fires. What happens next is platform-specific, and worth knowing before it happens on site:

PlatformSettingWhen it fires
CODESYSPer task: watchdog time and sensitivity (how many consecutive overruns are tolerated)The application stops with an exception; outputs go to their configured safe state
TwinCAT 3Per task: cycle time; the runtime counts exceed eventsBy default the task carries on late; your code can read the exceed counter and react
TIA Portal, S7-1500Maximum cycle time for OB1, 150 ms by defaultThe time-error OB80 is called; exceeding twice the maximum in the same cycle puts the CPU into STOP regardless
Studio 5000Per task watchdog, 500 ms by defaultMajor fault (type 6, code 1): the controller faults and outputs go to their fault state

Two practical points. First, the watchdog belongs to the task, so a 2 ms motion task with a 4 ms watchdog can fault the controller while the 100 ms HMI task, which contains the real culprit, looks innocent — because the HMI task held a lock or disabled interrupts too long. Second, a periodic task that overruns its interval without reaching its watchdog does not fault; it simply starts late. Logix counts this as an overlap and skips the missed trigger; CODESYS delays the next start. A PID loop tuned for a 100 ms sample time that actually runs every 140 ms does not stop; it just controls worse. Read the task monitor's maximum execution time after every major change, not only its average.

VAR_TEMP and VAR: what survives a call

VAR (in TIA Portal, Static) keeps its value from one call to the next. That is what makes counters, latches and timers possible. VAR_TEMP (in TIA Portal, Temp) does not: in IEC 61131-3 it is re-initialised every time the program or block is called, and it exists only during that call. Temporary variables are cheap — they live on a stack that is reused by every block — and they make the intent clear: "this is scratch, it means nothing after this scan".

FUNCTION_BLOCK FB_LaneStats
VAR_INPUT
    Weights : ARRAY[1..8] OF REAL;
END_VAR
VAR_OUTPUT
    Mean : REAL;
END_VAR
VAR
    PrevSensor : BOOL;      // static: must survive to the next call
END_VAR
VAR_TEMP
    Sum : REAL;             // scratch: re-initialised on every call
    i   : INT;
END_VAR

Sum := 0.0;                 // set it anyway: some platforms do not initialise Temp
FOR i := 1 TO 8 DO
    Sum := Sum + Weights[i];
END_FOR;
Mean := Sum / 8.0;

(That listing is not runnable here: the engine on these pages keeps VAR_TEMP values between scans like VAR, which real controllers do not.)

The bug that follows from forgetting this is classic and nasty: an edge detector whose "previous value" was declared in Temp. On CODESYS it is always FALSE at the start of the call, so every scan with the input TRUE looks like a rising edge — a counter counts scans instead of parts. On a standard-access Siemens block it holds a stack leftover, so it works on the bench and fails when someone adds a block call that changes the stack. Memory that must survive goes in VAR; R_TRIG instances and timers always do.

RETAIN, PERSISTENT and I/O, per task

Retained data is global data that survives a power cycle, and every rule above applies to it twice over, because a mistake in it survives the restart too. Chapter 2 explained what RETAIN and PERSISTENT keep through which kind of restart. Across tasks, three points matter:

  • One owner task per retained value. A retained totaliser incremented by a 10 ms task and reset by an HMI task has the read-modify-write race from earlier in this chapter, and the lost count is now permanent.
  • Groups of retained values need the same consistency care as any shared data. A recipe stored as six retained values, updated in six statements, can be caught half-written by a power failure as easily as by a pre-empting task. Platforms differ in when they save retain data — continuously into non-volatile memory, or once on power-down from stored energy — and TwinCAT writes PERSISTENT data to a file when the runtime stops, which is why a TwinCAT IPC needs a UPS or an explicit WritePersistentData call. A sequence number or a checksum stored with the group lets the program detect a torn set at start-up and fall back to defaults instead of running with it.
  • Studio 5000 retains everything by default. Logix tags keep their values through a power cycle; it is the prescan on entering Run that clears non-retentive outputs. Code that relies on a tag starting at zero after a restart needs to clear it deliberately.

I/O is the last shared resource, and it is shared between tasks too. Each platform decides which task's cycle updates which inputs and outputs:

  • TwinCAT gives every task its own process image: inputs linked to a task's variables are copied in when that task starts, outputs copied out when it ends. A variable linked to an EtherCAT terminal belongs to exactly one task.
  • CODESYS updates an I/O channel in the task that uses it; when a channel is used in several tasks, the details depend on the runtime and fieldbus settings (the bus cycle task). Map each channel into one task and pass its value on deliberately.
  • TIA Portal has the OB1 process image plus up to 31 process image partitions that you assign to cyclic interrupt OBs, so OB30 can have fresh inputs at its own rate. Reading "Sensor":P goes to the module directly, bypassing the image.
  • Studio 5000 updates I/O asynchronously to all tasks, at each module's RPI (requested packet interval). An input can change between two lines of the same routine. The standard practice is to copy the inputs you need into program tags at the top of the routine — with CPS if several words must belong together — and work only with the copy.

That last point is the whole chapter in one example: data shared between two things that run on their own schedules is only trustworthy if you take a consistent copy, give each value one writer, and decide in advance who updates what.

From shared data to rarer types

You can now share data on purpose: globals declared once and owned once, constants for what the design fixes, and programs assigned to cyclic, freewheeling or event tasks by what they need. You know why a fast task pre-empting a slow one can tear a two-word value apart, and how a double buffer or a sequence number prevents it. You know to pass counts rather than flags between tasks, what each platform's watchdog does when a task overruns, why VAR_TEMP is scratch and nothing more, and how inputs and outputs are refreshed per task.

The next chapter turns from where data lives to what kinds of data there are: dates, times of day and time stamps from the controller's real-time clock, and the less common types — WSTRING, LREAL, subranges, unions and aliases — that you will meet in other people's code and in device profiles.

Practise this chapter