Chapter 17 / 32 · Part 5 · Reusable code

Functions

Write FUNCTIONs that turn inputs into one result with no memory, and use them for scaling, limits and checks.

The same five lines, written twelve times

Open any PLC project that has grown for a few years and you will find the same calculation pasted all over it. Tank 1 converts its level to litres. Tank 2 does the same with a different diameter. Tank 3 was copied from tank 2, and somebody fixed a typo in tank 3 but not in tank 1. Every copy is a place for a bug to live, and every copy has to be found again on the day the formula changes.

A function cures that. You write the calculation once, give it a name, declare what it needs and call it wherever the answer is needed. The rule that makes a function a function is strict: it takes inputs, returns one result and remembers nothing. Call it twice with the same inputs and you get the same answer twice. That property is what makes functions the easiest code in a project to test, and the code you can trust most once it has been tested.

Here is one for a vertical cylindrical tank with a flat bottom:

FUNCTION TankLitres : REAL
VAR_INPUT
    LevelM    : REAL;   (* liquid height above the flat bottom, m *)
    DiameterM : REAL;   (* inside diameter of the tank, m *)
END_VAR

TankLitres := 3.14159 * DiameterM * DiameterM / 4.0 * LevelM * 1000.0;

END_FUNCTION

PROGRAM Main
VAR
    Level       : REAL := 1.2;   (* input: level in metres, same for both tanks here *)
    LitresTank1 : REAL;          (* 1.6 m mixing tank *)
    LitresTank2 : REAL;          (* 2.4 m buffer tank *)
END_VAR

LitresTank1 := TankLitres(LevelM := Level, DiameterM := 1.6);
LitresTank2 := TankLitres(LevelM := Level, DiameterM := 2.4);

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

Change Level and both results follow. There is one formula for two tanks, and no copies to drift apart.

Work one value by hand before you trust it. At 1.2 m in the 1.6 m tank, the floor area is π × 1.6² / 4 ≈ 2.011 m², so the volume is 2.011 × 1.2 ≈ 2.413 m³, or about 2,413 litres. If the watch shows something else, the formula is wrong, and you found out at the desk instead of during the first batch.

Anatomy of a FUNCTION

Read the declaration line by line:

  • FUNCTION TankLitres : REAL names the function and states its return type. The type after the colon is the type of the one value the function hands back. It can be any elementary type, such as BOOL, INT, DINT, REAL, TIME or STRING, and on most platforms a structure or array type as well.
  • VAR_INPUT … END_VAR lists the inputs. These are the only things the caller can pass in. Inside the function they behave like ordinary variables that already hold the caller's values. You may write to them inside the body; the caller's own variables do not change, because the function received copies.
  • The body computes the result and assigns it to the function's own name: TankLitres := ...;. Inside the body the name behaves as a variable of the return type, and whatever it holds when the body ends is what the caller gets. Forget the assignment and the function returns the type's default (0.0, FALSE, an empty string) with no warning on most targets.
  • END_FUNCTION closes it. Functions are declared outside the PROGRAM, as separate units. In a CODESYS or TwinCAT project tree each one is its own object; in TIA Portal each is its own block in the Program blocks folder.

A function may also have a VAR section for local working variables, and it may leave early with RETURN;. What it cannot have is anything that survives from one call to the next.

The call itself is an expression. TankLitres(...) is a REAL, and you can use it anywhere a REAL fits: in a comparison, in arithmetic, or as an argument to another function. IF TankLitres(LevelM := Level, DiameterM := 1.6) > 2200.0 THEN is legal ST.

What the standard allows that this page does not run

IEC 61131-3 lets a function declare VAR_OUTPUT and VAR_IN_OUT as well as inputs, so a function can hand back a quotient as its result and a remainder through an output. CODESYS, TwinCAT and TIA Portal all accept that. The caller collects an output with =>:

(* Vendor syntax (CODESYS / TwinCAT): a function with an extra output.
   The engine on this site does not accept function outputs. *)
FUNCTION F_DivMod : DINT
VAR_INPUT
    Num : DINT;
    Den : DINT;
END_VAR
VAR_OUTPUT
    Rem : DINT;
END_VAR
F_DivMod := Num / Den;
Rem      := Num MOD Den;
END_FUNCTION

Pallets := F_DivMod(Num := Cartons, Den := PerPallet, Rem => Leftover);

I use this sparingly. Once a function has two results, half the readers of the call will miss the second one, because => at the end of a long line is easy to skim past. If both values matter, two small functions or a structure as the return type usually read better. The engine on this site keeps to inputs and one result, and so do the examples in this chapter.

What happens when you call one

It helps to know what the controller does with a call, because the "no memory" rule falls straight out of it.

Take LitresTank1 := TankLitres(LevelM := Level, DiameterM := 1.6);. On the scan that reaches this line, the runtime:

  1. evaluates each argument: it reads Level and takes the constant 1.6;
  2. reserves a block of scratch memory for this one call, usually on a stack that every block in the task shares;
  3. copies the argument values into the input slots of that block;
  4. initialises the function's VAR locals (or, on some older platforms, leaves them as it found them);
  5. runs the body, which writes the result into the return-value slot;
  6. copies the return value to wherever the caller wanted it, here LitresTank1;
  7. releases the scratch block, so the next call can use the same memory.

For TankLitres the scratch block is tiny:

SlotTypeHolds during the callAfter the call
LevelMREALcopy of Level, 1.2gone
DiameterMREAL1.6gone
return valueREAL2412.74…copied to LitresTank1, then gone

Step 7 is where "a function has no memory" comes from. The memory exists only while the function runs, and the next call, to this function or any other, reuses it. A function has no instance and no address that outlives the call. Contrast a function block in the next chapter: its memory is allocated once, when you declare the instance, and stays put for as long as the program is loaded.

Two practical consequences follow from the copying in step 3:

  • Large inputs cost time. An ARRAY[1..500] OF REAL passed as VAR_INPUT is copied, all 2,000 bytes of it, on every call. The same goes for strings: a CODESYS STRING defaults to 80 characters (81 bytes with its terminator), a TIA Portal String to 254. On a 1 ms motion task that adds up. Platforms that allow VAR_IN_OUT in a function pass a reference instead, and in TIA Portal an InOut parameter of an FC does the same.
  • The caller's variables are safe. Writing to LevelM inside the function changes the copy, never Level. That is part of what makes a function easy to review: by reading only the call line you know exactly which variable it changes.

Siemens engineers will recognise the scratch block as the local data stack. On an S7-300 or S7-400 it is visible in the block's properties as the amount of local data the block uses, and a deep chain of block calls with large TEMP areas could run a priority class out of it. Newer platforms hide the details, but the model is the same.

Recursion is out

Because a call's memory lives on a fixed-size stack, controllers do not let a function call itself. IEC 61131-3 forbids recursive calls, directly or through another POU, and PLCnext Engineer, for one, rejects them at compile time. Anything you would write recursively on a PC (a tree walk, a quicksort) becomes a loop over an array on a PLC, with a fixed upper bound, as in chapter 14.

Named and positional arguments

There are two ways to pass arguments. Named (formal) arguments pair each value with its input name, so the order does not matter. Positional (non-formal) arguments are matched to the inputs in the order they are declared:

FUNCTION Clamp : REAL
VAR_INPUT
    In : REAL;   (* value to limit *)
    Lo : REAL;   (* lower limit *)
    Hi : REAL;   (* upper limit *)
END_VAR

IF In < Lo THEN
    Clamp := Lo;
    RETURN;          (* leave now; the lines below are skipped *)
END_IF;
IF In > Hi THEN
    Clamp := Hi;
    RETURN;
END_IF;
Clamp := In;

END_FUNCTION

PROGRAM Main
VAR
    Speed   : REAL := 75.0;   (* input: speed request from the HMI, % *)
    Named   : REAL;
    ByOrder : REAL;
    Builtin : REAL;
END_VAR

Named    := Clamp(Lo := 0.0, In := Speed, Hi := 100.0);   (* any order *)
ByOrder  := Clamp(Speed, 0.0, 100.0);                     (* positional: In, Lo, Hi *)
Builtin  := LIMIT(0.0, Speed, 100.0);                     (* the standard one: MN, IN, MX *)

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

Type 140 or −20 into Speed and all three results agree. LIMIT already does this job (chapter 6 covers it), and in real code you should use the standard function. Clamp is here because it is short enough to see whole, and because it sets up the trap in the next box.

LIMIT takes its arguments as minimum, value, maximum, while our Clamp takes value, low, high. Both orders are reasonable, and that is exactly what goes wrong with positional arguments.

My rule: name every argument unless the function has a single input or the order is part of its meaning (like MIN(A, B)). Positional calls save a few characters and cost an afternoon on site. IEC 61131-3 does not let you mix the two styles in one call, so pick one per call. Vendors differ in one useful detail: CODESYS and TwinCAT let you leave out a function input and use its declared initial value, while TIA Portal requires every parameter of an FC to be supplied. Supplying all of them by name works everywhere.

The tidy-up that broke forty clamps

The worst version of the positional trap needs no typing mistake at all. Picture a project with a home-made limit function, F_Limit(In, Lo, Hi), called positionally in forty places. A new engineer notices that it takes its arguments in a different order from the standard LIMIT and, to be helpful, reorders the declaration to Lo, In, Hi. The project compiles without a single warning, because all three inputs are REAL. Every one of the forty calls now passes the value as the lower limit.

On the machine it looks like this: speed references that used to stop at 100 % now pass through at whatever the HMI asked for, while some others sit pinned at their low limit. Nothing points at the limit function, because nobody changed a call. You find it by going online, watching one call where the output is plainly wrong and reading the function's interface next to the call line. The fix is two minutes of work: change every call to named arguments. With named calls, reordering a declaration is harmless, and that is the strongest argument for the rule.

A function has no memory

This trips up everyone once. A function's VAR locals are created fresh for every call and thrown away when it returns. Nothing carries over to the next call, in this scan or the next.

Side by side, the difference is plain. The program's counter lives in the program's memory; the function's counter is reborn on every call:

FUNCTION CountScans : DINT
VAR_INPUT
    Enable : BOOL;
END_VAR
VAR
    N : DINT;            (* local: created at 0 on every call *)
END_VAR
IF Enable THEN
    N := N + 1;
END_IF;
CountScans := N;
END_FUNCTION

PROGRAM Main
VAR
    Enable       : BOOL;   (* input *)
    FromFunction : DINT;
    InProgram    : DINT;   (* program memory: survives between scans *)
END_VAR

FromFunction := CountScans(Enable := Enable);
IF Enable THEN
    InProgram := InProgram + 1;
END_IF;

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

Switch Enable on. InProgram climbs every scan while FromFunction sits at 1.

Real controllers add a nasty variant. On a classic Siemens S7-300 or S7-400, an FC's TEMP variables are not initialised at all. They hold whatever the previous block left on the shared local stack, so a function that reads a local before writing it can appear to remember, some of the time. In TIA Portal the same is true of an S7-1200/1500 block with standard access; blocks with optimised access preset their temporaries to the type's default. Chapter 26 has the details. The rule does not change between platforms: write every local before you read it, and never expect it to survive the call. Two more consequences:

  • A function must not contain a timer, counter or edge detector. IEC 61131-3 does not allow function block instances to be declared inside a function, and an editor that accepts one anyway gives you a timer that is reborn every call and never reaches its preset.
  • A function should not read or write global variables, even where the compiler lets it. The moment it does, its result depends on something other than its inputs and you lose the property that made it worth writing.

The S7-300 function that worked on Tuesdays

Here is how the uninitialised-temp bug looks on a real line. A bottling plant had an FC that calculated a fill correction. It summed eight weight readings into a TEMP variable but never set the sum to zero first. On the bench, the FC was the only block called in its OB, the stack slot happened to hold zero, and every test passed. On site it sat after a block whose own temporaries ended with a large REAL in exactly that slot. The correction was wildly wrong on most cycles, and right whenever a particular reject sequence had run first and left a zero behind.

The symptom was a filler that drifted for no visible reason. Monitoring the FC showed the sum starting each call at a value that was never written by the FC itself. The fix was one line, Sum := 0.0; at the top of the body. I now initialise every local on every call, even on platforms that do it for me, so the code survives being moved to one that does not.

Order of evaluation and side effects

Look at this line:

PumpCmd := F_SignalOk(Value := Pressure) AND F_BelowSetpoint(Value := Pressure, Sp := Setpoint);

IEC 61131-3 does not promise which of the two calls runs first, and it does not promise that the second one is skipped when the first returns FALSE. CODESYS and TwinCAT evaluate both sides of AND and give you AND_THEN when you need the short-circuit (chapter 14 uses it in a loop condition); other compilers optimise in their own way.

For a pure function the order makes no difference. If neither function changes anything, it makes no difference which ran first or whether one ran at all; the result of the expression is the same. The trouble starts when a function has a side effect: it writes a global, increments a diagnostic counter or sets a "called" flag. Now the order of evaluation decides what the program does, and that order is not part of the language. Keeping functions pure is how you make sure the question never comes up.

Guard the inputs that break the maths

Every function in a library will one day be called with values its author did not imagine. A recipe that has not been filled in passes zero. A transmitter that has not been configured passes equal low and high ranges. A broken wire passes 32,767. The function has to return something defined in all of these cases.

The classic failure is division. What a controller does when it divides by zero depends on the platform: a CPU exception that stops the runtime, a fault flag and a value you did not choose, or a REAL that becomes infinite or NaN and contaminates everything downstream. Chapter 5 shows the guard pattern; in a function it usually looks like this:

IF RawHi = RawLo THEN
    F_Scale := EuLo;        (* defined answer for an impossible range *)
    RETURN;
END_IF;

Two points about guards in a library:

  • Document the answer you chose. "Returns EuLo when RawHi = RawLo" in the header comment tells the next engineer what a strange reading means. Without it, a level that sits at exactly 0.0 m looks like an empty tank instead of a configuration error.
  • Write range checks so that they prove the value good. Value >= Lo AND Value <= Hi is FALSE for a NaN, because every comparison with NaN is FALSE, so a NaN fails the check. The negative form, NOT (Value < Lo OR Value > Hi), is TRUE for a NaN and lets it through. Chapter 29 shows where NaNs come from on a real network.

Worked example: a horizontal tank

The first example in this chapter was a vertical tank, where volume is a straight-line function of level. Lay the same cylinder on its side and the straight line is gone. The tank is narrow at the bottom, widest in the middle and narrow again at the top, so the first 20 cm of level hold far less liquid than the 20 cm around the middle.

A function is worth most in a case like this. The geometry is ugly, but you only write it once, test it once and then call F_HorizTankLitres wherever an inventory is needed. For a cylinder of radius R and length L filled to height h, the liquid cross-section is a circular segment:

Area = R² · acos((R − h) / R) − (R − h) · √(2·R·h − h²), and Volume = Area · L.

Take a tank 2.0 m in diameter (R = 1.0 m) and 4.0 m long. Full, it holds π × 1.0² × 4.0 ≈ 12.566 m³, or 12,566 litres. Work four levels by hand, and compare each with the answer a straight-line scaling would give:

Level hacos((R−h)/R)(R−h)·√(2Rh−h²)Area, m²LitresStraight line
0.2 macos(0.8) = 0.64350.8 × 0.6 = 0.48000.16356541,257
0.5 macos(0.5) = 1.04720.5 × 0.8660 = 0.43300.61422,4573,142
1.0 macos(0) = 1.570801.57086,2836,283
1.5 macos(−0.5) = 2.0944−0.5 × 0.8660 = −0.43302.527410,1109,425

Only the middle row agrees. At 0.2 m the straight line claims almost twice the liquid that is really there. On a diesel day tank feeding a generator, that is the difference between a low-level alarm that arrives in time and one that arrives with the injector pump sucking air.

FUNCTION F_HorizTankLitres : REAL
VAR_INPUT
    LevelM    : REAL;   (* liquid height above the lowest point, m *)
    DiameterM : REAL;   (* inside diameter, m *)
    LengthM   : REAL;   (* length of the cylindrical shell, m *)
END_VAR
VAR
    R    : REAL;   (* radius, m *)
    H    : REAL;   (* level held inside 0..diameter, m *)
    Area : REAL;   (* wetted cross-section, m2 *)
END_VAR
IF DiameterM <= 0.0 OR LengthM <= 0.0 THEN
    F_HorizTankLitres := 0.0;     (* tank not configured *)
    RETURN;
END_IF;
R := DiameterM / 2.0;
H := LIMIT(0.0, LevelM, DiameterM);   (* keep acos and the root defined *)
Area := R * R * ACOS((R - H) / R) - (R - H) * SQRT(2.0 * R * H - H * H);
F_HorizTankLitres := Area * LengthM * 1000.0;
END_FUNCTION

PROGRAM Main
VAR
    LevelM       : REAL := 0.5;   (* input: level, m *)
    Litres       : REAL;          (* what the tank really holds *)
    LinearLitres : REAL;          (* what a straight-line scaling claims *)
    ErrorLitres  : REAL;
END_VAR

Litres       := F_HorizTankLitres(LevelM := LevelM, DiameterM := 2.0, LengthM := 4.0);
LinearLitres := LevelM / 2.0 * 3.14159 * 1.0 * 1.0 * 4.0 * 1000.0;
ErrorLitres  := LinearLitres - Litres;

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

Type the four levels from the table and check that Litres agrees with your hand calculation. Then type 2.5 m, a level the tank cannot have. The LIMIT inside the function holds H at the diameter, so the result stops at the full volume instead of asking ACOS for the arc cosine of −1.5, which has no answer.

Notice what the guard protects. Without it, a level transmitter that reads slightly above its span (an overfilled tank, or a transmitter ranged a few centimetres too long) feeds ACOS a value outside −1 to +1 and SQRT a negative number. On most platforms that returns NaN, and a NaN inventory silently breaks every comparison that uses it.

Real tanks have dished or hemispherical ends that add volume a cylinder formula ignores. For custody transfer or anything that is invoiced, use the tank's calibration (strapping) table and interpolate in it, as chapter 13 does with a lookup array. The formula is fine for an operator display and for alarms with sensible margins.

Porting it. CODESYS, TwinCAT and TIA Portal SCL all spell the arc cosine ACOS and the square root SQRT. Studio 5000 ST has SQRT but spells the inverse trigonometric functions ACS, ASN and ATN. Studio 5000 also has no user-defined functions, so on a Logix controller this formula lives in an Add-On Instruction or directly in the routine.

A small library of pure helpers

A pure function depends only on its inputs and changes nothing but its return value. Every well-run site I have worked on had a small library of them, tested once and used everywhere: scaling, clamping, dead bands, plausibility checks, unit conversions.

Here are two of them, plus the standard LIMIT, working together on a pump that keeps a discharge header above a pressure setpoint. The transmitter delivers raw counts: 0 to 27,648 for 0 to 10 bar on a Siemens analog card. How to scale and validate those counts properly is the subject of chapter 19; here the point is how small functions compose:

FUNCTION F_Scale : REAL
VAR_INPUT
    Raw   : INT;    (* raw counts from the analog card *)
    RawLo : INT;
    RawHi : INT;
    EuLo  : REAL;   (* engineering units at RawLo *)
    EuHi  : REAL;   (* engineering units at RawHi *)
END_VAR
F_Scale := (INT_TO_REAL(Raw) - INT_TO_REAL(RawLo)) * (EuHi - EuLo)
           / (INT_TO_REAL(RawHi) - INT_TO_REAL(RawLo)) + EuLo;
END_FUNCTION

FUNCTION F_InBand : BOOL
VAR_INPUT
    Value : REAL;
    Lo    : REAL;
    Hi    : REAL;
END_VAR
F_InBand := Value >= Lo AND Value <= Hi;
END_FUNCTION

PROGRAM Main
VAR
    Raw         : INT := 8294;    (* input: about 3 bar *)
    SetpointReq : REAL := 4.5;    (* input: operator setpoint, bar *)
    PressureBar : REAL;
    SignalOk    : BOOL;
    Setpoint    : REAL;
    PumpCmd     : BOOL;
END_VAR

PressureBar := F_Scale(Raw := Raw, RawLo := 0, RawHi := 27648, EuLo := 0.0, EuHi := 10.0);
SignalOk    := F_InBand(Value := PressureBar, Lo := -0.1, Hi := 10.2);
Setpoint    := LIMIT(1.0, SetpointReq, 8.0);   (* standard function: MN, IN, MX *)
PumpCmd     := SignalOk AND PressureBar < Setpoint;

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

Try a setpoint of 12 bar: it is clamped to 8. Type a raw value of 32767, which is what an S7-1500 card reports when it diagnoses a broken wire, and SignalOk drops, taking the pump command with it. The program body is four lines that read like a description of the machine, and each line can be checked on its own.

This F_Scale has no guard for RawHi = RawLo, because every call in this program passes constants. In a library function that will be called from projects you never see, add the guard from the section above.

A few habits keep a function library healthy:

  • Prefix the names (F_ here, FC_ in many Siemens shops) so a reader knows a call has no memory.
  • Write a header comment that gives the units of each input and what happens at the edges. "Returns 0 when RawHi = RawLo" beats three lines of prose.
  • Guard the inputs that break the maths. F_Scale divides by RawHi - RawLo, so a channel configured with both at 0 divides by zero. A library function returns something defined instead.
  • Keep I/O out. A function that reads %IW64 directly can only ever serve one channel.
  • Store the library once, not as copies pasted between projects. In CODESYS and TwinCAT that is a library project installed into the library repository; in TIA Portal it is a global library, where a block saved as a type carries a version number and a project can be checked against the library for outdated copies.

Text is a calculation too

Chapter 16 ended with a promise: the formatting that turns a pump's tag and pressure into an operator message belongs in a function, written once. A function's return type can be a STRING, so it can:

FUNCTION F_PressMsg : STRING
VAR_INPUT
    Tag : STRING;   (* instrument tag from the P&ID *)
    Bar : REAL;     (* scaled pressure *)
END_VAR
F_PressMsg := CONCAT(CONCAT(Tag, ' '), CONCAT(REAL_TO_STRING(Bar), ' bar'));
END_FUNCTION

PROGRAM Main
VAR
    PressureBar : REAL := 4.25;   (* input: header pressure *)
    MsgSuction  : STRING;
    MsgHeader   : STRING;
END_VAR

MsgSuction := F_PressMsg(Tag := 'PT-101', Bar := 0.8);
MsgHeader  := F_PressMsg(Tag := 'PT-102', Bar := PressureBar);

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

Every message on the panel now has the same layout, and when the customer asks for one decimal place instead of two, there is one place to change it. The rule from chapter 16 still holds: the message is for people, so no IF in the control logic should ever look inside it.

Two transmitters can tell you that one of them is wrong, but not which one. That is why a level the plant cannot do without gets three, and the next challenge is the function that makes three useful.

Test a function before it meets the machine

A function is the one piece of PLC code you can test completely at a desk, because it has no hidden state. Same inputs, same answer: so a table of inputs and expected answers is a complete test, and it stays valid until someone changes the function.

You do not need a test framework to do it. A throwaway test program that calls the function with a table of cases and counts the failures is enough, and it catches the boundary mistakes (the off-by-one at the bottom of a range, the sign error on a negative input) before they reach the plant:

FUNCTION F_Scale : REAL
VAR_INPUT
    X  : REAL;
    X0 : REAL;
    X1 : REAL;
    Y0 : REAL;
    Y1 : REAL;
END_VAR
IF X1 = X0 THEN
    F_Scale := Y0;
    RETURN;
END_IF;
F_Scale := (X - X0) * (Y1 - Y0) / (X1 - X0) + Y0;
END_FUNCTION

PROGRAM Main
VAR
    RunTests  : BOOL := TRUE;   (* input *)
    (* Case table: raw counts in, bar out, for a 0..27648 -> 0..10 bar channel *)
    InRaw     : ARRAY[1..6] OF REAL := [0.0, 27648.0, 13824.0, 2764.8, -100.0, 32511.0];
    Expect    : ARRAY[1..6] OF REAL := [0.0, 10.0, 5.0, 1.0, -0.0361690, 11.7588976];
    Got       : ARRAY[1..6] OF REAL;
    i         : INT;
    Passed    : INT;
    Failed    : INT;
    FirstFail : INT;            (* 0 = none *)
END_VAR

IF RunTests THEN
    Passed := 0;
    Failed := 0;
    FirstFail := 0;
    FOR i := 1 TO 6 DO
        Got[i] := F_Scale(X := InRaw[i], X0 := 0.0, X1 := 27648.0, Y0 := 0.0, Y1 := 10.0);
        IF ABS(Got[i] - Expect[i]) <= 0.0001 THEN
            Passed := Passed + 1;
        ELSE
            Failed := Failed + 1;
            IF FirstFail = 0 THEN
                FirstFail := i;
            END_IF;
        END_IF;
    END_FOR;
END_IF;

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

All six cases pass. Now break the function on purpose: change + Y0 to + Y1 in the editor, run again, and FirstFail tells you which case caught it. Two details in the harness are deliberate. It compares REALs with a tolerance, never with = (chapter 6 explains why). And the table includes the awkward cases: a reading slightly below zero and one at the top of the S7 overshoot range, because those are the values a real card will deliver.

Keep the test program in the library project next to the function. When someone changes the function a year later, they run the test first. Chapter 23 builds this idea into a habit for the whole program, and the vendor tools have their own versions of it: CODESYS has a Test Manager add-on and TwinCAT users often use the open-source TcUnit framework, but the case table is the part that does the work, and you can write it anywhere.

EN and ENO

If you have used functions in ladder or FBD, you have seen two extra pins on every box: EN (enable) and ENO (enable out). IEC 61131-3 defines them for the graphical languages: the box runs only while EN is TRUE, and it sets ENO FALSE to report that something went wrong inside. A ladder rung of maths boxes chained ENO to EN stops at the first one that fails.

In ST you rarely need them, because an IF does the enabling more readably. TIA Portal is the platform where ENO is still in daily use in SCL: every FC and FB has it, the code can set it with ENO := FALSE;, and the block property Set ENO automatically makes the block report runtime errors such as an overflow through ENO. If an SCL block called from ladder shows a broken power flow after it, that property and the block's own ENO assignments are where to look.

Functions on real controllers

The IEC FUNCTION exists on most platforms, but the names and the details differ enough to confuse people moving between them:

PlatformWhat IEC calls a functionNotes
CODESYS, TwinCAT 3FUNCTIONFollows the standard closely. Omitted inputs take their initial value.
Siemens TIA Portal (SCL)FCThe return type sits in the interface's Return section; an FC may also return Void and use outputs only. Every parameter must be supplied. An FC has Input, Output, InOut, Temp and Constant sections, and no Static.
Rockwell Studio 5000noneLogix has no user-defined functions. The Add-On Instruction (AOI) has a backing tag that keeps its values between calls, so it behaves like a function block.
Omron Sysmac StudioFUNSeparate from FB along IEC lines: FUN for stateless calculations, FB when you need instances.

The Rockwell row catches people out. An AOI remembers. If you come from Studio 5000 to CODESYS or TIA, the unit you want for an AOI's job is the function block. Going the other way, a CODESYS function becomes either an AOI (accepting that it now has a backing tag per call site) or plain code in the routine.

The TIA row has a quirk worth knowing. An FC that returns Void and passes everything through outputs and in-outs is common in older Siemens code, partly because classic STEP 7 programmers used FCs as "subroutines" that wrote global data blocks directly. Those FCs have side effects by design. When you meet one, treat it as a piece of program, not a function in this chapter's sense, and do not call it twice in one scan without reading what it writes.

IEC 61131-3 3rd edition builds methods, interfaces and inheritance on top of function blocks. The F · FB · OOP track covers them in depth; this course stays with the core every controller runs.

Reviewing a function

When a colleague hands you a function for review, or you open one in a project you inherited, these questions find most problems in a few minutes:

  1. Is it pure? Search the body for anything that is not an input, a local or the function name. A global, a direct I/O address or a call to a block with memory means it is not a function in practice, whatever its header says.
  2. Is every path assigned? Follow each IF branch and each RETURN and check that the function name has been given a value on every one. A branch that forgets returns the default, and 0.0 is a believable level.
  3. Is every local written before it is read? On the platforms that do not initialise temporaries, this is the difference between working and working on Tuesdays.
  4. What happens at the edges? Zero, negative, the top of the range, equal limits, a value just outside the span. If the answer is "it divides by zero" or "it takes the root of a negative number", it needs a guard.
  5. Are the units written down? Each input should have its unit in a comment or its name. Speed with no unit is a question waiting to be asked at 3 a.m.
  6. Are the calls named? Every call site with more than one argument should use Name := value. A positional call to a function with several inputs of one type is the reorder bug from earlier, waiting for a tidy-up.

A function that passes all six can go into the library, and from then on nobody needs to open it to trust a call.

From answers to objects with memory

What a function cannot do is remember. A motor has to remember that it was started; a valve has to time how long it has been open; three identical conveyors each need their own copy of that memory. The next chapter introduces the function block, with inputs, outputs and private memory in one reusable unit, and builds a motor block you will use for three motors at once.

Practise this chapter