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 why 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 is needed 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.
Between the two sit two more kinds of scope:
| Scope | Declared in | Who can read it | Who can write it |
|---|---|---|---|
| Temporary | VAR_TEMP of a POU | that POU, during one call | that POU |
| Local | VAR of a program or FB | that program or instance | that program or instance |
| Interface | VAR_INPUT / VAR_OUTPUT / VAR_IN_OUT | the caller passes and reads it | caller writes inputs, POU writes outputs |
| Global | VAR_GLOBAL | any 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_PROGRAMType 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. What differs is where they live and who else can reach them. No compiler requires the g prefix or the "writer:" comments; they are the cheapest documentation you will ever write, and the next section shows what happens without them.
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, with no VAR_EXTERNAL
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 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 half of the bug, which is worse:
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_PROGRAMSwitch 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 designed to the machine's risk assessment, as chapter 22 insisted. The standard program still must not undo the process interlocks.)
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. A few habits keep globals from turning into this.
Write the owner next to the declaration. (* writer: P_Line *) costs one comment and saves a morning of searching.
Pass globals into function blocks as inputs rather than reaching them through 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.
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. Aim for one write location per global; if there are two, write the reason next to both.
Global constants for configuration
A global that nothing can write is just a name for a number, which is exactly what configuration data should be. VAR_GLOBAL CONSTANT declares values the compiler will refuse to let any code change: the 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_PROGRAMThe 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). You want that failure. 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.
Decide deliberately which values are constants and which are parameters:
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 is called a task and has its own rule for when it starts, and you assign programs to tasks. IEC 61131-3 writes this 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. Behind the dialog you will find three kinds of task.
A cyclic (periodic) task starts at a fixed interval, every 2 ms or every 10 ms. If it finishes early, the CPU does something else until the next start. Its 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 it 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.
| Platform | Cyclic | Freewheeling | Event | Priority order |
|---|---|---|---|---|
| CODESYS | Cyclic | Freewheeling | Event (rising edge of a global BOOL), Status, External | 0 is highest (0–31) |
| TwinCAT 3 | Cyclic, in multiples of the base time (1 ms by default) | none | none | lower number is higher |
| TIA Portal, S7-1500 | Cyclic interrupt OB30–OB38 (and more from OB123) | OB1 program cycle | Hardware interrupt OB40+, time-of-day, time-delay | higher number is higher (OB1 is 1, cyclic OBs 2–24) |
| Studio 5000 | Periodic | Continuous (at most one, lowest priority) | Event (module change of state, consumed tag, EVENT instruction) | 1 is highest (1–15) |
The priority column 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_PROGRAMLet 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 sees one rising edge and then no more, because its samples land on the pulse every time and 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 real count. A task sees only what happens at its own rate, and anything faster is aliased into nonsense, so 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. Where the code happened to be written first is no guide. A sensible split for a packaging machine on a mid-range controller looks like this:
| Work | Typical task | Why |
|---|---|---|
| Motion, registration, cam tracking | 1–4 ms, highest priority | position changes fast; jitter shows up as product error |
| Machine sequence, interlocks, I/O logic | 10 ms | fast enough for contactors, valves and photo-eyes at normal line speeds |
| PID loops on temperature, level, pressure | 100–500 ms, cyclic | slow processes; a fixed sample time matters more than speed |
| HMI data, statistics, OEE, logging | 100 ms–1 s, low priority | nobody 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 %. I aim for under 50–60 % at full production, which leaves room for communication bursts and the changes that always come after handover.
You can estimate the load before the machine exists. Each cyclic task uses its execution time divided by its interval, and the shares add up:
| Task | Interval | Execution time | Share of the CPU |
|---|---|---|---|
| Motion | 2 ms | 0.4 ms | 20 % |
| Sequence and I/O | 10 ms | 2.5 ms | 25 % |
| PID loops | 100 ms | 3 ms | 3 % |
| HMI and logging | 200 ms | 12 ms | 6 % |
| Total | 54 % |
The motion task dominates despite being the smallest piece of code, because it runs a hundred times for every run of the HMI task. Halving its interval to 1 ms takes the total to 74 %. That one setting usually decides whether a machine fits on the controller you quoted, so measure it on a prototype before you halve anything. Communication load (PROFINET or EtherCAT stack, OPC UA server, web server) comes on top on most platforms; check how your vendor's task monitor reports it before you trust the total.
Torn reads
Pre-emption is invisible as long as tasks share nothing. Once they share a value that takes more than one write to update, it produces the hardest bugs to reproduce 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 as a pre-emption would do it.
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_PROGRAMEvery 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. The code and data are the same; the interrupt just did not land in the gap. On a real controller that gap is a few microseconds wide, so a torn read can survive months of testing and then, one afternoon, start a fill with the volume of the previous product. On a filler that latches its recipe at the start of a batch, it spoils the whole batch.
Pairs of variables are only the start. 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 comes from the hardware. The language promises nothing.
Taking a consistent snapshot
There are two portable fixes, and both 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 and as many interruptions 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_PROGRAMTornReads 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.
Each platform also has its own tools for this, and you will meet them in other people's code:
| Platform | Copy without interruption | Critical section |
|---|---|---|
| Studio 5000 | CPS (Synchronous Copy File) is not interrupted by other tasks or by I/O updates; COP has no such guarantee | UID / UIE disable and re-enable task switching |
| TIA Portal | UBLKMOV | DIS_AIRT / EN_AIRT delay higher-priority interrupts |
| TwinCAT 3 | none needed for one writer | TestAndSet and FB_IecCriticalSection in Tc2_System |
| CODESYS | none needed for one writer | SysSem semaphores; in recent versions atomic operators such as __XADD and __COMPARE_AND_SWAP |
TwinCAT needs the table's last column more than the others, because its tasks may run on different CPU cores at the same time, and "disable interrupts" then protects nothing. Even there, a single writer and a clean handover covers most cases with less to go wrong.
Use these tools 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. That is the most expensive kind of wrong, because it is too small for anyone to notice until the customer's goods-in count disagrees with yours.
The fix is to pass a count instead of 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, the watchdog fires. The usual causes are a WHILE loop that never exits (chapter 14), a FOR over 100,000 recipe entries, or a calculation chain that ran far longer than anyone tested. What happens next depends on the platform, and you want to know it before it happens on site:
| Platform | Setting | When it fires |
|---|---|---|
| CODESYS | Per 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 3 | Per task: cycle time; the runtime counts exceed events | By default the task carries on late; your code can read the exceed counter and react |
| TIA Portal, S7-1500 | Maximum cycle time for OB1, 150 ms by default | The time-error OB80 is called; exceeding twice the maximum in the same cycle puts the CPU into STOP regardless |
| Studio 5000 | Per task watchdog, 500 ms by default | Major fault (type 6, code 1): the controller faults and outputs go to their fault state |
The watchdog belongs to the task. 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: the HMI task held a lock or disabled interrupts too long, and the motion task paid for it.
A periodic task that overruns its interval without reaching its watchdog does not fault at all. 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 keeps running and controls worse, and nothing tells you. After every major change, read the task monitor's maximum execution time as well as its average.
VAR_TEMP and VAR: what survives a call
VAR (in TIA Portal, Static) keeps its value from one call to the next, which 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, because they live on a stack that every block reuses, and they state the intent: this is scratch, and it means nothing after this call.
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.)
Forgetting this produces a classic: 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, and 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 things change.
Give each retained value one owner task. 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.
Treat a group of retained values like any other 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. 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.
On Studio 5000, expect everything to be retained. Logix tags keep their values through a power cycle; the prescan on entering Run is what clears non-retentive outputs. Code that relies on a tag starting at zero after a restart has to clear it deliberately.
I/O is shared between tasks too, and 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 and outputs are copied out when it ends, so 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), so 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 is the same consistent snapshot this chapter built for recipe data, applied to the I/O.
From shared data to indirection
Several values in this chapter were wider than one machine word: a recipe pair, an LREAL, a STRING, a group of retained parameters. The next chapter looks at how indirection lets one piece of code reach many such variables, through array indexes, VAR_IN_OUT, references and the raw pointers you will find in CODESYS and TwinCAT projects, and at why a pointer that is safe in one task can go stale after an online change.