Chapter 27 / 32 · Part 7 · Advanced language

Pointers and references

Read POINTER TO, REF_TO and ADR in vendor code, know when indirection helps, and recognise the faults it causes when it goes wrong.

What indirection is

Every program you have written so far names the variable it works on. Tank3.Level := ... writes tank 3, always, because the name is fixed when you compile. Indirection means the program decides at run time which variable to work on. Instead of naming the variable, you hold something that leads to it: an index, a reference or a raw memory address, and you follow it when you need the value.

You have already used the mildest form of indirection without calling it that. Level[TankSel] does not name a tank. It names an array and a number, and the controller works out at run time which element the number leads to. A tank farm overview on an HMI does this all day: the operator taps tank 4, the HMI writes 4 into TankSel, and one faceplate shows whichever tank the index points at. The difference between that and a pointer is how much the controller checks on the way.

Think of it in plant terms. A P&ID tag number such as LT-104 is not the transmitter; it is a way of finding the transmitter. A maintenance work order that says "replace LT-104" works as long as the tag on the pipe still matches the drawing. If someone renumbers the transmitters and nobody updates the work order, a fitter replaces a perfectly good instrument. Pointers fail in exactly that way. The pointer still holds the old "address", the thing that used to live there has moved, and the program cheerfully writes to whatever lives there now.

That is the whole chapter in one paragraph. Pointers are powerful because they let one piece of code serve many variables. They are dangerous because the controller trusts the address you give it. Most vendors have spent the last fifteen years building safer forms of indirection so you rarely need a raw address at all, and the most useful thing you can learn here is to recognise pointer code in a vendor project, understand what it is doing, and replace it with something the compiler can check when you get the chance.

Start with the safe kind, because it runs everywhere:

PROGRAM Main
VAR
    TankSel    : INT := 1;     (* input: HMI faceplate selection *)
    Level_pct  : ARRAY[1..6] OF REAL := [72.5, 18.0, 55.0, 91.2, 40.0, 63.3];
    SelValid   : BOOL;
    ShownTank  : INT := 1;     (* the tank the faceplate really shows *)
    ShownLevel : REAL;
END_VAR

(* Check the index before following it *)
SelValid := TankSel >= 1 AND TankSel <= 6;
IF SelValid THEN
    ShownTank := TankSel;
END_IF;

ShownLevel := Level_pct[ShownTank];

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

Type 4 into TankSel and the faceplate shows 91.2 %. Type 0 or 7 and SelValid drops, the faceplate keeps showing the last valid tank, and nothing reads outside the array. That two-line check is the whole safety story of indirection in miniature: the index came from outside the program (an HMI, a recipe, a barcode), so it is untrusted until you have compared it with the bounds. On this engine an unchecked Level_pct[7] stops the program with an out-of-bounds fault, which is what a well-configured controller does too. Some controllers do something much worse, and the rest of this chapter is about which ones.

POINTER TO, ADR and ^ in CODESYS and TwinCAT

CODESYS 3.5 and Beckhoff TwinCAT 3 (which is built on CODESYS) give you real pointers, close to C. Three pieces of syntax do the work:

  • POINTER TO <type> declares a variable that holds the memory address of a value of that type.
  • ADR(x) returns the address of x.
  • p^ (the caret, the dereference operator) means "the value at the address in p". You can read it and write it.

Neither this page's engine nor the IEC 2nd edition runs any of this, so the listings below are static. They are the code you will meet in real projects, and you should be able to read every line.

(* CODESYS / TwinCAT 3 — static listing *)
VAR
    FillTarget_g : REAL := 500.0;
    pTarget      : POINTER TO REAL;
    Copy_g       : REAL;
END_VAR

pTarget  := ADR(FillTarget_g);   (* pTarget now holds an address, e.g. 16#0012_A3F0 *)
Copy_g   := pTarget^;            (* read through it: 500.0 *)
pTarget^ := 480.0;               (* write through it: FillTarget_g is now 480.0 *)

After the last line, FillTarget_g reads 480.0 although no line of code names it on the left of an assignment. That is the first thing that makes pointer code hard to maintain: a cross-reference search for "who writes FillTarget_g?" finds nothing. When you inherit a project and a value changes for no visible reason, search for ADR( before you search for anything else.

The pointer itself is just a number. On a 32-bit runtime it occupies 4 bytes; on a 64-bit runtime such as TwinCAT 3 on an x64 industrial PC it occupies 8. Old code that stores addresses in a DWORD worked for years on 32-bit targets and breaks silently when the project moves to a 64-bit runtime, because the top half of the address is thrown away. TwinCAT provides PVOID and CODESYS __XWORD for "an address-sized unsigned integer"; if you must keep an address in an integer, use those, never DWORD.

The legitimate everyday use of raw pointers in CODESYS and TwinCAT is byte-level work: walking through a receive buffer from a serial barcode scanner, building a telegram for a third-party device, or copying a structure into a communication buffer with MEMCPY (from Tc2_System in TwinCAT, or the CODESYS memory libraries). For example, an XOR checksum over a 20-byte telegram:

(* CODESYS / TwinCAT 3 — static listing *)
VAR
    RxBuffer : ARRAY[0..19] OF BYTE;
    pByte    : POINTER TO BYTE;
    i        : INT;
    Checksum : BYTE;
END_VAR

Checksum := 0;
pByte := ADR(RxBuffer[0]);
FOR i := 0 TO 19 DO
    Checksum := Checksum XOR pByte^;
    pByte := pByte + 1;              (* next byte *)
END_FOR;

Notice pByte := pByte + 1. That is pointer arithmetic: you are adding to an address. In C, adding 1 to a pointer advances it by one element. In CODESYS and TwinCAT it advances by one byte, whatever the pointer's type. With POINTER TO BYTE the two agree, which is why this listing works. With anything larger, they do not, and you have to add SIZEOF(<type>) yourself. Engineers who learned C first get this wrong regularly, and nothing warns them.

When you dereference a pointer that holds 0 — a pointer nobody has assigned yet — the CPU tries to read address zero, the operating system refuses, and the PLC runtime raises an exception. In CODESYS the application stops with an access-violation exception; in TwinCAT the classic message is a page fault, and the PLC stops at the offending line. On a running machine that is every output dropping at once. Engineers sometimes call that "fail-safe". It is not: a stopped controller cannot run the controlled stop sequence that your safety analysis assumed, and a hydraulic press that loses its hold-pressure logic mid-stroke is not in a safe state just because the program has stopped.

References: REFERENCE TO and REF_TO

A reference is a pointer with the dangerous parts filed off. It still leads to another variable, but you cannot do arithmetic on it, and the syntax makes it look like the variable itself. There are two flavours you will meet, and they are not the same.

CODESYS and TwinCAT REFERENCE TO. A vendor extension, widely used in TwinCAT libraries. You bind it with the special operator REF=, and after that you use it with no caret at all:

(* CODESYS / TwinCAT 3 — static listing *)
VAR
    Tanks : ARRAY[1..6] OF ST_Tank;
    rTank : REFERENCE TO ST_Tank;
    Sel   : INT := 2;
END_VAR

rTank REF= Tanks[Sel];            (* bind: rTank now IS Tanks[2] *)
IF __ISVALIDREF(rTank) THEN       (* never bound = not valid *)
    rTank.HighAlarm := rTank.Level_pct > 90.0;
END_IF;

Two operators, two meanings, one keystroke apart. rTank REF= Tanks[3]; rebinds the reference to tank 3. rTank := Tanks[3]; copies tank 3's data into whatever tank rTank is currently bound to. Someone who means to "point at tank 3" and types := has just overwritten tank 2's level, alarm state and batch number with tank 3's, every scan, with no error. That is the single most common reference bug in TwinCAT code, and code review is the only thing that catches it. Read every := on a reference as "write into the target".

IEC 61131-3 3rd edition REF_TO. The standard's own version, added in 2013. You take a reference with REF(), dereference with ^, and compare with NULL. There is no arithmetic and no conversion from an integer, so the only way to get a reference is from a real variable:

(* IEC 61131-3 3rd edition — static listing *)
VAR
    rTank : REF_TO ST_Tank;
END_VAR

rTank := REF(Tanks[Sel]);
IF rTank <> NULL THEN
    rTank^.HighAlarm := rTank^.Level_pct > 90.0;
END_IF;

Support for REF_TO varies more than the standard would like. Some environments implement it as written, some only accept their own POINTER TO/REFERENCE TO, and recent TIA Portal releases have started to offer references on S7-1500 with current firmware. Check the help for the exact version you are using before you write a library that depends on it.

The point of both is the same: a reference cannot be incremented past the end of an array, cannot be built from a number typed on an HMI, and can be tested for "never set" before use. It still can go stale — more on that below — but it removes two of the three classic pointer faults.

Siemens: ANY, VARIANT and why S7 hides pointers

On an S7-1500 programmed in TIA Portal, you can write years of SCL without meeting a pointer, and that is deliberate.

Older S7-300/400 code is full of the ANY pointer, a 10-byte structure describing a memory area: a syntax ID (16#10), a data type code, a repetition factor, a DB number and an area/byte/bit address. You see it in source as P#DB10.DBX0.0 BYTE 20 — "20 bytes starting at byte 0 of DB10" — and it is how BLKMOV and countless home-made copy routines moved data around. Engineers built ANY pointers by hand, overlaid them on a struct with AT, patched the DB number at run time and shipped it. It worked because S7-300 memory layout was fixed and predictable.

The S7-1500 changed the deal with optimized block access. In an optimized DB the compiler decides where each variable lives, reorders them for alignment, and is free to move them on the next download. There are no byte offsets for your code to depend on, so DBX addressing and ANY pointers only work on blocks you switch back to standard access. Siemens did this for two reasons, and both are good ones. Symbolic access lets the compiler check every read and write against a type. And because no code depends on absolute addresses, TIA Portal can download without reinitialisation: you add a variable to a DB (into its memory reserve) on a running line and the existing values survive. Address-based code would make that impossible.

For generic code on optimized blocks you use VARIANT instead. A VARIANT parameter accepts a variable of any type, carries its type information with it, and lets the block ask what it was given:

(* TIA Portal SCL, S7-1500 — static listing *)
FUNCTION "LogValue" : Void
VAR_INPUT
    Value : Variant;
END_VAR
VAR_TEMP
    r : Real;
    i : Int;
END_VAR

IF TypeOf(#Value) = Real THEN
    VariantGet(SRC := #Value, DST => #r);
ELSIF TypeOf(#Value) = Int THEN
    VariantGet(SRC := #Value, DST => #i);
    #r := INT_TO_REAL(#i);
END_IF;
END_FUNCTION

TypeOf, TypeOfElements, CountOfElements, VariantGet, VariantPut and MOVE_BLK_VARIANT cover most of what ANY pointers were used for, with a type check on every access. For "the same function over arrays of different lengths", the S7-1500 answer is ARRAY[*] in an InOut parameter, with LOWER_BOUND and UPPER_BOUND to find the limits at run time. CODESYS 3.5 supports the same declaration, written without the # prefixes and called as LOWER_BOUND(Data, 1):

(* TIA Portal SCL, S7-1500 — static listing *)
FUNCTION "AverageOf" : Real
VAR_IN_OUT
    Data : Array[*] of Real;
END_VAR
VAR_TEMP
    i   : DInt;
    Sum : Real;
END_VAR

#Sum := 0.0;
FOR #i := LOWER_BOUND(ARR := #Data, DIM := 1) TO UPPER_BOUND(ARR := #Data, DIM := 1) DO
    #Sum := #Sum + #Data[#i];
END_FOR;
#AverageOf := #Sum / DINT_TO_REAL(UPPER_BOUND(ARR := #Data, DIM := 1) - LOWER_BOUND(ARR := #Data, DIM := 1) + 1);
END_FUNCTION

The loop bounds come from the array itself, so the function cannot run off the end whatever array you hand it. That is exactly the job raw pointer arithmetic did badly. Raw access still exists on S7-1500 — PEEK and POKE on standard-access DBs, DB_ANY for a DB number chosen at run time — and you will meet it in drivers and older libraries. Treat it as you would any pointer code: read it carefully, and do not add to it.

Rockwell: no pointers, subscripts instead

Studio 5000 Logix Designer has no pointers and no address operator. You cannot take the address of a tag. Every bit of indirection in a ControlLogix or CompactLogix project goes through an array subscript, and two forms cover nearly everything:

(* Studio 5000 ST — static listing *)
ShownLevel := Tank[TankSel].Level;      (* element chosen at run time *)
AlarmBit   := AlarmWord.[BitNo];        (* bit chosen at run time *)

The .[BitNo] form — a bit number in square brackets after the dot — is Logix's indirect bit address and appears constantly in alarm and sequencer code. For passing data into reusable code, an Add-On Instruction's InOut parameters are passed by reference, so an AOI can work on the caller's UDT or array without copying it. The SIZE instruction tells you how many elements an array has.

What Logix does with a bad subscript is worth knowing by heart: an index outside the array is major fault type 4, code 20, and the controller faults — all outputs go to their configured fault state — unless a fault routine clears it. Plenty of plants have learned this when an HMI allowed an operator to type 0 into a recipe number that indexed Recipe[1..20]. The fix is the same two-line range check you ran at the top of this chapter, placed before the subscript is used, every time the index comes from outside the program.

That is the honest summary of Rockwell's position: it gives you less power and more safety. You cannot corrupt a neighbouring tag through a subscript, because Logix checks every one. The price is that some generic code that is easy in CODESYS has to be written per type in Logix, or with COP copies into a working tag.

VAR_IN_OUT: the reference the compiler checks

Chapter 18 introduced VAR_IN_OUT as the way for a block to update the caller's own variable. Under the hood, on every platform, an in-out parameter is a pointer: the caller passes an address and the block reads and writes through it. The difference is that the compiler manages it. You cannot leave it unconnected, you cannot pass a constant or an expression to it, you cannot do arithmetic on it, and the type must match exactly. It is a pointer with a seatbelt, and it covers most of the jobs people reach for pointers to do.

The connection is made at the call, so it can change from call to call. Connect an in-out to an array element with a run-time index and you have one block serving many variables, chosen by the plant. A bulk-handling example: one weigh belt feeds three silos through a diverter gate. The belt's pulse output gives one pulse per 10 kg, and each silo keeps its own total. A fourth element, number 0, is the reject bin — where material goes when the diverter position is unknown, so the count is never lost and never written outside the array.

FUNCTION_BLOCK FB_BeltTotaliser
VAR_INPUT
    Pulse : BOOL;          (* one pulse per 10 kg from the belt scale *)
END_VAR
VAR_IN_OUT
    Total_kg : DINT;       (* whichever silo total the caller connects *)
END_VAR
VAR
    Edge : R_TRIG;
END_VAR

Edge(CLK := Pulse);
IF Edge.Q THEN
    Total_kg := Total_kg + 10;
END_IF;

END_FUNCTION_BLOCK

PROGRAM Main
VAR
    Diverter : INT := 1;     (* input: gate position 1..3 *)
    Pulse    : BOOL;         (* input: belt scale pulse *)
    SiloKg   : ARRAY[0..3] OF DINT;   (* 0 = reject bin *)
    Target   : INT;
    Belt     : FB_BeltTotaliser;
END_VAR

IF Diverter >= 1 AND Diverter <= 3 THEN
    Target := Diverter;
ELSE
    Target := 0;             (* unknown position: count it, flag it, never lose it *)
END_IF;

Belt(Pulse := Pulse, Total_kg := SiloKg[Target]);

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

Pulse Pulse a few times with the diverter on 1, move it to 3 and pulse again: each silo keeps its own total. Type 9 into Diverter and the pulses land in the reject bin. Notice what the block does not know: how many silos there are, what they are called, or where their totals live. It knows only "a DINT the caller gave me", which is everything a pointer would have told it and nothing that could go wrong.

Two details make this pattern solid. The block is called every scan, unconditionally, so its edge detector keeps tracking the pulse even when the target changes; chapter 18 showed what a conditional call does to a block's memory. And the index is resolved at the call, so if the diverter moves between two scans, the next pulse goes to the new silo. There is no stored address to go stale.

One function, many arrays

The most common reason to reach for a pointer in older CODESYS code is "I want one routine that works on several arrays". A curing oven has two lanes, each with six heating zones, and the HMI needs the hottest zone of each lane. Writing the search twice is how bugs fork. Pass the array as VAR_IN_OUT to a function and the same search serves both lanes, with no copy and no address:

FUNCTION HottestZone : INT
VAR_IN_OUT
    Zone_C : ARRAY[1..6] OF REAL;   (* the caller's array itself, not a copy *)
END_VAR
VAR
    i    : INT;
    Best : INT;
END_VAR

Best := 1;
FOR i := 2 TO 6 DO
    IF Zone_C[i] > Zone_C[Best] THEN
        Best := i;
    END_IF;
END_FOR;
HottestZone := Best;

END_FUNCTION

PROGRAM Main
VAR
    LaneA  : ARRAY[1..6] OF REAL := [180.0, 182.5, 185.0, 184.0, 181.0, 179.5];
    LaneB  : ARRAY[1..6] OF REAL := [176.0, 178.0, 181.5, 183.0, 182.0, 177.0];
    HotA   : INT;
    HotB   : INT;
    HotA_C : REAL;
    HotB_C : REAL;
END_VAR

HotA := HottestZone(Zone_C := LaneA);
HotB := HottestZone(Zone_C := LaneB);
HotA_C := LaneA[HotA];
HotB_C := LaneB[HotB];

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: LaneA[4], LaneB[2].

Lane A's hottest zone is 3 at 185.0 °C, lane B's is 4. Raise LaneA[4] to 190 and the answer moves. The function returns an index, not a value, and the caller uses the index on its own array. That is a habit worth copying: an index is a handle you can range-check and display on an HMI ("Zone 4 hot"), while a pointer is a number nobody can read.

The limit of this 2nd-edition version is the fixed size: ARRAY[1..6] means a lane with eight zones needs another function, or a larger array with a count. This is the gap ARRAY[*] fills on S7-1500 and CODESYS, with the loop bounds taken from UPPER_BOUND rather than typed in. Where your platform has it, use it. Where it does not, a fixed maximum size plus an explicit Count input is the honest alternative — not a pointer and a length that can disagree.

Choosing between instances with CASE

The other common pointer pattern is "the active one": a duty/standby pump set where pDuty points at whichever pump instance is currently on duty, and the sequence commands pDuty^ without caring which it is. It reads elegantly. It also means the standby pump's instance is not called at all, which freezes its timers and edge detectors in whatever state they had.

The safe version keeps every instance called every scan and moves the selection to the commands. A CASE on the duty number decides who gets the run request; every pump block then runs with its own command, its own feedback timer and its own fault logic:

FUNCTION_BLOCK FB_Pump
VAR_INPUT
    Cmd : BOOL;
END_VAR
VAR_OUTPUT
    Run    : BOOL;
    Starts : DINT;
END_VAR
VAR
    StartEdge : R_TRIG;
END_VAR

StartEdge(CLK := Cmd);
IF StartEdge.Q THEN
    Starts := Starts + 1;
END_IF;
Run := Cmd;

END_FUNCTION_BLOCK

PROGRAM Main
VAR
    Demand  : BOOL;          (* input: level controller wants a pump *)
    DutySel : INT := 1;      (* input: duty pump 1..3 from the HMI *)
    Cmd     : ARRAY[1..3] OF BOOL;
    P       : ARRAY[1..3] OF FB_Pump;
    i       : INT;
END_VAR

FOR i := 1 TO 3 DO
    Cmd[i] := FALSE;
END_FOR;

CASE DutySel OF
    1: Cmd[1] := Demand;
    2: Cmd[2] := Demand;
    3: Cmd[3] := Demand;
ELSE
    Cmd[1] := Demand;        (* invalid selection: fall back to pump 1 *)
END_CASE;

FOR i := 1 TO 3 DO
    P[i](Cmd := Cmd[i]);     (* every instance, every scan *)
END_FOR;

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

Switch Demand on and pump 1 runs. Change DutySel to 2 while demand is on: pump 1 stops, pump 2 starts, and its Starts counter ticks. Every pump has always had a fresh command; nothing was left frozen. Type 7 and pump 1 takes over — a deliberate fallback here, but whether a bad selection should run a default pump or no pump at all is a process decision, and the next challenge makes the other choice. With three pumps the CASE is clearer than any array trick. With twenty you would write Cmd[i] := Demand AND (i = DutySel); inside the loop — the same idea, still no pointer. Chapter 12 builds full duty-rotation sequences on top of this.

Passing structures without pointers

The last classic pointer job is avoiding copies of big structures. A recipe of 60 parameters, a 200-byte tank record — older code passes ADR(Recipe) into a function to avoid copying it on every call. On a modern CPU that saving is usually tiny: copying 200 bytes costs a few microseconds at most on an S7-1500, and less on an x64 IPC, against a 10 ms task. Measure before you trade safety for it.

For read-only use, passing a structure by value as an input is the clearest thing you can write. The function sees a copy, so it cannot damage the caller's data whatever bug it contains:

TYPE ST_Silo :
STRUCT
    Diameter_m : REAL;
    Level_m    : REAL;
    MaxLevel_m : REAL;
END_STRUCT
END_TYPE

FUNCTION SiloVolume_m3 : REAL
VAR_INPUT
    Silo : ST_Silo;            (* a copy: the function cannot change the caller's silo *)
END_VAR
VAR
    Lvl : REAL;
END_VAR

Lvl := LIMIT(0.0, Silo.Level_m, Silo.MaxLevel_m);
SiloVolume_m3 := 3.14159 * Silo.Diameter_m * Silo.Diameter_m / 4.0 * Lvl;

END_FUNCTION

PROGRAM Main
VAR
    Level1_m : REAL := 6.5;    (* input: radar level, silo 1 *)
    Level2_m : REAL := 3.2;    (* input: radar level, silo 2 *)
    Silo1    : ST_Silo;
    Silo2    : ST_Silo;
    Vol1_m3  : REAL;
    Vol2_m3  : REAL;
    Total_m3 : REAL;
END_VAR

Silo1.Diameter_m := 4.0;
Silo1.MaxLevel_m := 12.0;
Silo1.Level_m    := Level1_m;
Silo2.Diameter_m := 3.0;
Silo2.MaxLevel_m := 10.0;
Silo2.Level_m    := Level2_m;

Vol1_m3  := SiloVolume_m3(Silo := Silo1);
Vol2_m3  := SiloVolume_m3(Silo := Silo2);
Total_m3 := Vol1_m3 + Vol2_m3;

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

Silo 1 at 6.5 m holds about 81.7 m³. Type 15 into Level1_m and the function clamps to the 12 m maximum, because a radar that reads past the roof is wrong, not full. When a block genuinely must change a large structure in place, VAR_IN_OUT is the reference to use on CODESYS, TwinCAT and TIA Portal, and an AOI InOut on Logix. On this page's engine a structure passed as an in-out to a block does not write back, so the live examples here pass structures by value and in-outs with simple types; the real platforms handle both.

The faults: null, stale and overrun

Pointer faults come in three families. Each has a signature you can recognise from the symptoms on site, and each has a standard defence.

The null pointer. A pointer that has never been assigned holds 0. Dereferencing it is an access violation. The classic version is a block whose pointer is assigned by the caller only when something happens — a recipe is selected, a batch starts, a device comes online:

(* CODESYS / TwinCAT 3 — static listing: the cold-start crash *)
FUNCTION_BLOCK FB_Doser
VAR_INPUT
    pRecipe : POINTER TO ST_Recipe;   (* set by the caller when a recipe is chosen *)
END_VAR
VAR_OUTPUT
    Target_kg : REAL;
END_VAR

Target_kg := pRecipe^.FlourTarget_kg;   (* first scan after a cold start: pRecipe = 0 *)

It works through all of commissioning, because during commissioning somebody always picked a recipe before starting. Then the plant does a cold start after a power cut, the HMI is still booting, and the controller stops in its first scan with a page fault. The defence is a guard on every dereference — IF pRecipe <> 0 THEN for a pointer, IF __ISVALIDREF(rRecipe) THEN for a CODESYS reference — and, far better, not using a pointer at all: a VAR_IN_OUT cannot be left unconnected, so this bug cannot be written.

The stale pointer. A pointer that was valid when it was assigned and points at the wrong place now. Three causes account for nearly every case:

  • Online change. CODESYS and TwinCAT let you change code on a running controller. When the change adds a variable to a function block, every instance of that block may be moved to new memory, and the runtime copies the values across. A pointer to an instance that was assigned once — in FB_init, or behind a "first scan" flag — still holds the old address. A conveyor tracking chain where each zone held ADR() of the next zone, assigned at start-up, passes product data to whatever now occupies that memory after the change. CODESYS documents this behaviour and calls an instance's FB_reinit method after such a move, so a block can repair its own links. The dependable defence is simpler: assign pointers and references every scan, right before you use them. It costs nanoseconds, and it makes online change safe.
  • Addresses of temporary data. ADR() of a function's local variable or a VAR_TEMP is valid only while that function runs. Store it and use it on the next scan, and you are reading stack memory that some other function has since used for its own locals.
  • Data that moved under you. An index or pointer into a recipe list that was sorted, reloaded from a file, or re-downloaded from MES. The pointer is valid memory; it is just the wrong recipe now. No exception fires. The palletiser stacks a 6-case pattern on a pallet that needed 8.

The overrun. Pointer arithmetic past the end of the target, or an index past the end of an array on a platform that does not check. A barcode buffer declared ARRAY[0..19] OF BYTE and a loop that runs FOR i := 0 TO 20 writes one byte too many, into whatever the compiler placed next. On a CODESYS target without bounds checks the symptom is not a crash; it is a variable somewhere else that changes "on its own" — a counter that jumps by 13 each time a long barcode arrives, because 13 is a carriage return. These bugs take days to find because the code that fails is not the code that is wrong.

Platforms handle out-of-range indexes very differently, and you should know which kind you are on:

PlatformOut-of-range array indexBad pointer
Studio 5000 Logixmajor fault type 4, code 20 — controller faults unless a fault routine handles itno pointers
S7-1500 (optimized)programming error: OB121 if loaded, otherwise plan on the CPU going to STOP; local handling with GET_ERR_IDANY/PEEK/POKE errors on standard blocks
CODESYS / TwinCATnot checked by default — silent overwrite of neighbouring memory, or an exception if it leaves valid memoryaccess violation / page fault, PLC stops
CODESYS / TwinCAT with implicit checksCheckBounds is called on every access and can clamp and logCheckPointer is called on every dereference

That last row is the most useful thing in this chapter for CODESYS and TwinCAT users. Add the "POUs for implicit checks" to the project — CheckBounds, CheckPointer, CheckDivInt and friends — at least during commissioning. The compiler then inserts a call before every array access and every dereference, and your check function can log the offending POU, clamp the index and keep the machine running in a controlled way. It costs some cycle time, so some teams remove the checks once the code is proven. Keep them if you can afford them.

Pointers deserve one more opinion. Code with pointers is harder to read online. A watch table shows pTarget = 16#0012A3F0, which tells a night-shift technician nothing. An index shows TankSel = 4, which they can check against the HMI. Most of a PLC program's life is spent being debugged by someone who did not write it, at an hour when nobody who did is available. Write for that person.

From addresses to the calendar

Indirection is a tool for making one piece of code serve many variables. Every platform offers a safe version of it — array indexes on all of them, VAR_IN_OUT and InOut parameters for passing the caller's data, typed references where a link must persist — and raw pointers only where you really are working with bytes. You can now read POINTER TO, ADR, ^, REF= and REF_TO in a vendor project, explain what each line does, and see the three ways it goes wrong: nothing to point at, pointing at what used to be there, and pointing past the end. When you meet those symptoms on site — a page fault on a cold start, a value changing after an online change, a counter that jumps when a long barcode arrives — you know where to look.

The next chapter turns from memory to time: the calendar types DATE, TOD and DT, reading the controller's real-time clock, stamping alarms and batch records, surviving midnight and daylight saving, and the rarer types — LREAL, WSTRING, subranges, unions and aliases — that you will meet in larger projects.

Practise this chapter