Text and time in a controller
Most of a PLC program deals in bits and numbers. Two other kinds of value turn up on every real machine, and both are easy to misuse. Text is what the operator reads: "P3 OVERLOAD 12.4 A" on the alarm banner, a batch ID on a label, an article code from a barcode reader. Durations are what the process is made of: a 90-second mix, a 2-second settle, the 7.3 seconds the last filling cycle took, the 4,120 hours a pump has run.
This chapter covers the STRING type and its functions, then the TIME type and its arithmetic. The two have one thing in common: they both describe the machine to people. In the case of strings, describing is all they should do.
STRING and its length
A STRING holds a sequence of characters. Literals are written in single quotes: 'FILLER 2'. Every string variable has a maximum length, fixed when it is declared:
VAR
Msg : STRING; (* default maximum length *)
BatchId : STRING[12]; (* at most 12 characters *)
END_VAR
The default depends on the platform, and it is worth knowing because it decides how much memory every string costs:
| Platform | Default STRING | Notes |
|---|---|---|
| CODESYS / TwinCAT 3 | 80 characters | Null-terminated; STRING(n) or STRING[n] for other lengths |
| Siemens S7-1200/1500 | 254 characters | 2 header bytes: maximum and actual length, so STRING takes 256 bytes |
| Rockwell Studio 5000 | 82 characters | A structure with a .LEN field and a .DATA array of SINT |
An array of 200 alarm texts at Siemens' default length is 51 KB of work memory, most of it empty. Declare the length you need: STRING[40] fits on almost any HMI alarm line. The other half of the rule is what happens when text does not fit. The extra characters are cut off without a fault or a warning:
PROGRAM Main
VAR
Line : INT := 3; (* input: line number *)
Station : INT := 7; (* input: station number *)
Tag : STRING[12]; (* label printed on the station's HMI button *)
TagLen : INT;
END_VAR
Tag := CONCAT(CONCAT('L', INT_TO_STRING(Line)), CONCAT('-ST', INT_TO_STRING(Station)));
TagLen := LEN(Tag);
END_PROGRAMLine 3, station 7 gives L3-ST7, six characters. Now type 32000 for Line and 12345 for Station: the full text would be L32000-ST12345, fourteen characters, and Tag holds only the first twelve. LEN reports the actual length, never more than the maximum. On a real HMI that is how a batch number loses its last two digits, and why a label that "always worked" fails the day the plant numbers its lines differently.
INT_TO_STRING converts a number to its text form. There is one for each type (DINT_TO_STRING, REAL_TO_STRING, TIME_TO_STRING), and STRING_TO_INT and friends go the other way. Nesting CONCAT two arguments at a time looks clumsy, and it is deliberate. IEC 61131-3 lets CONCAT take any number of inputs, and this engine accepts three or more, but the CODESYS and TwinCAT standard-library CONCAT takes exactly two. Two-argument calls port everywhere.
What a string is in memory
The three layouts in that table behave differently enough that you should be able to draw them. Here is the text 'PUMP' stored in a variable declared for 10 characters on each platform:
| Platform | Bytes | Contents |
|---|---|---|
CODESYS / TwinCAT, STRING(10) | 11 | P U M P 0 then six bytes of whatever was there before |
Siemens, String[10] | 12 | 10 (maximum) 4 (actual) P U M P then six unused bytes |
Rockwell, STRING | 88 | .LEN = 4 as a DINT, then .DATA[0..81] with P U M P in the first four |
CODESYS and TwinCAT mark the end of the text with a zero byte, the way C does, so a STRING(n) reserves n + 1 bytes and the length is found by looking for the zero. Siemens stores the length up front in a one-byte field, which is why an S7 String can never be longer than 254 characters: the length has to fit in a byte. Logix keeps the length in a separate DINT member that your code can read and write directly, which is useful and also a way to make a string claim a length its data does not have.
The differences surface whenever strings cross a boundary. A TCP message built on a CODESYS controller contains no length byte, so a receiver that expects S7's two-byte header misreads the first two characters as a length. An S7 string copied byte for byte into a CODESYS STRING starts with two control bytes that print as garbage. A Logix string copied into a SINT array keeps its LEN in the first four bytes. When text leaves the controller, decide the wire format (fixed-length field, length prefix, or terminator) and convert to it explicitly at the edge.
Characters that cannot be typed into a literal are written with a dollar sign. IEC 61131-3 defines $R for carriage return, $L for line feed, $N for a newline, $T for a tab, $' for a single quote inside a string, $$ for the dollar sign itself, and $ followed by two hex digits for any byte, such as $0D. A command to a serial barcode printer or a weighing terminal usually ends in carriage return and line feed:
(* static listing: this site's engine does not decode $ escapes yet *)
PrintCmd := CONCAT(LabelText, '$R$L'); (* text, CR, LF *)
Quoted := 'Operator$'s note'; (* Operator's note *)
CODESYS also lets you read a single character of a string as a byte, Code := Text[i];. That index counts from 0, unlike FIND, MID and the other string functions, which count from 1. Code that mixes the two styles in one routine almost always has an off-by-one somewhere. With UTF-8 text enabled in recent CODESYS versions, that byte index also stops being a character index, since a character outside ASCII takes more than one byte.
The string functions
The standard functions all count characters from 1, and all of them return a new string; none of them changes its input:
| Function | Result | Example | Gives |
|---|---|---|---|
LEN(s) | number of characters | LEN('PUMP-101') | 8 |
LEFT(s, n) | first n characters | LEFT('PUMP-101', 4) | 'PUMP' |
RIGHT(s, n) | last n characters | RIGHT('PUMP-101', 3) | '101' |
MID(s, n, p) | n characters starting at position p | MID('PUMP-101', 3, 2) | 'UMP' |
FIND(s1, s2) | position of s2 in s1, 0 if absent | FIND('PUMP-101', '-') | 5 |
CONCAT(a, b) | a followed by b | CONCAT('P', '7') | 'P7' |
INSERT(s1, s2, p) | s2 inserted after position p | INSERT('ABEF', 'CD', 2) | 'ABCDEF' |
DELETE(s, n, p) | n characters removed from position p | DELETE('ABCDEF', 2, 3) | 'ABEF' |
REPLACE(s1, s2, n, p) | n characters at p replaced by s2 | REPLACE('ABCDEF', 'zz', 2, 3) | 'ABzzEF' |
Mind the argument order of MID: length first, then position. It catches everyone once, because most other languages put the position first.
The vendors package the same functions differently, and porting string code is where that shows:
- Siemens SCL follows the IEC names and argument order for
LEN,CONCAT,LEFT,RIGHT,MID,FIND,INSERT,DELETEandREPLACE. Number formatting goes throughVAL_STRG, whosePRECparameter sets the number of decimal places, andSTRG_VALparses text back into a number. - Rockwell Studio 5000 has string instructions with their own operand lists that write the result to a destination tag:
CONCAT(SourceA, SourceB, Dest),MID(Source, Qty, Start, Dest),FIND(Source, Search, Start, Result), andDELETEandINSERTin the same style. Conversions are separate instructions:DTOSandRTOSturn a DINT or REAL into a string,STODandSTORgo back. Check the help for each one before you port. - TwinCAT 3's
Tc2_Standardfunctions take and returnSTRING(255), so aCONCATthat would produce more than 255 characters is cut there whatever you declared. For longer or formatted text,Tc2_UtilitieshasFB_FormatString, with C-style placeholders such as%d, andFB_FormatString2, which lifts the 255-character limit on the format and the result.
A barcode reader on a goods-in conveyor sends codes made of three fields separated by dashes: article, description, quantity. Splitting one apart uses most of the table:
PROGRAM Main
VAR
Which : INT := 1; (* input: pick a test barcode, 1..3 *)
Code : STRING[40];
Rest : STRING[40];
Dash1 : INT;
Dash2 : INT;
Article : INT; (* first field as a number *)
Qty : INT; (* last field as a number *)
END_VAR
CASE Which OF
1: Code := '4120-PUMPSEAL-75';
2: Code := '87-GASKET DN50-1200';
ELSE
Code := 'NOREAD';
END_CASE;
Dash1 := FIND(Code, '-');
IF Dash1 > 1 THEN
Article := STRING_TO_INT(LEFT(Code, Dash1 - 1));
Rest := RIGHT(Code, LEN(Code) - Dash1); (* everything after the first dash *)
Dash2 := FIND(Rest, '-');
IF Dash2 > 0 THEN
Qty := STRING_TO_INT(RIGHT(Rest, LEN(Rest) - Dash2));
ELSE
Qty := 0;
END_IF;
ELSE
Article := 0; (* no dash: not a valid code *)
Qty := 0;
Dash2 := 0;
END_IF;
END_PROGRAMCode 1 gives article 4120 and quantity 75; code 2 gives 87 and 1200, even though its description contains a space. Choose 3 and FIND returns 0, so the program reports article 0 instead of trying to cut a string at position −1. That IF Dash1 > 1 is the string version of the array index check from chapter 13: every position that comes out of FIND must be checked before it goes into LEFT, RIGHT or MID.
Checking text before you trust it
The barcode example converts its fields with STRING_TO_INT and hopes for the best. What a conversion does with text that is not a clean number is a platform question nobody wants to answer on site. STRING_TO_INT('75A') returns 75 on this page's engine, because it reads the leading digits and stops. An empty string gives 0. Other platforms may give 0 for the whole thing, stop at the first bad character as this engine does, or set an error output that your code then has to read. A "quantity" of 0 or 75 that came from a misread label is a believable number, and nothing downstream will question it.
So check the characters first. A FOR loop over the string, one MID per character, does it:
PROGRAM Main
VAR
Which : INT := 1; (* input: pick a test reply, 1..4 *)
Field : STRING[10]; (* quantity field cut from a barcode *)
Ch : STRING[1];
Digits : BOOL; (* every character is 0..9 *)
Qty : INT;
QtyOk : BOOL;
n : INT;
i : INT;
END_VAR
CASE Which OF
1: Field := '0075';
2: Field := '75A';
3: Field := '';
ELSE
Field := '123456';
END_CASE;
n := LEN(Field);
Digits := n >= 1 AND n <= 4; (* 1 to 4 digits, or it is not a quantity *)
FOR i := 1 TO n DO
Ch := MID(Field, 1, i);
IF Ch < '0' OR Ch > '9' THEN
Digits := FALSE;
END_IF;
END_FOR;
IF Digits THEN
Qty := STRING_TO_INT(Field);
QtyOk := Qty >= 1;
ELSE
Qty := 0;
QtyOk := FALSE;
END_IF;
END_PROGRAM'0075' passes and gives 75. '75A' fails on its third character, the empty field fails on its length, and '123456' fails because a four-digit field cannot hold six digits; without that check it would overflow an INT. QtyOk then adds the process rule, here that a quantity of zero is not a delivery. The comparison Ch < '0' works because strings compare character by character by their character codes, and the digits 0 to 9 are consecutive in ASCII.
Notice that Digits starts from the length check and the loop can only clear it. That is the "any alarm active" pattern from chapter 13 turned round: start from "valid", and let any one bad character make it invalid.
A field story: the label that read 1
A goods-in station read pallet labels with a fixed-mount scanner and booked the quantity straight into the warehouse system. Every so often a pallet of 120 cartons was booked as 1. The scanner was fine; the labels came from two suppliers, and one of them printed the quantity as 120 CTN. The parsing code cut the field at a fixed width, got 1 and two characters of padding from a different label layout, and the controller's string-to-integer conversion read the leading digit and stopped. Nothing faulted, because 1 is a perfectly good INT. The fix was the character check above, plus a rule that a field which fails the check rejects the pallet to a manual station with the raw text on the HMI, so the operator could see what the scanner had actually sent.
Building HMI messages and alarm texts
The commonest job for strings in a PLC is assembling a message from fixed text and live values. A tank farm shows one status line per tank on the overview screen:
PROGRAM Main
VAR
TankNo : INT := 2; (* input *)
LevelPct : INT := 87; (* input: level, % *)
HighAlarm : BOOL; (* input: high-level switch *)
StatusText : STRING[40]; (* output to the HMI *)
END_VAR
StatusText := CONCAT('TANK ', INT_TO_STRING(TankNo));
StatusText := CONCAT(StatusText, ' LEVEL ');
StatusText := CONCAT(StatusText, INT_TO_STRING(LevelPct));
StatusText := CONCAT(StatusText, ' %');
IF HighAlarm THEN
StatusText := CONCAT(StatusText, ' HIGH');
END_IF;
END_PROGRAMBuilding the text one piece per line, always starting from a fresh assignment, keeps it readable and makes it impossible for last scan's text to linger. It is the string version of clearing a total before the loop. Change the level or switch HighAlarm and the line updates on the next scan.
Decimals need one more step. REAL_TO_STRING exists everywhere, but its output varies: one platform gives 12.4, another exponent notation such as 1.240000E+01, and a value that should read 12.0 may come out as 12. For anything an operator reads, scale to an integer first and format the digits yourself, or use the vendor's formatting function with an explicit precision (VAL_STRG on Siemens, FB_FormatString on TwinCAT). A motor current in tenths of an amp becomes text like this:
Whole := Amps10 / 10; (* 124 -> 12 *)
Tenth := Amps10 MOD 10; (* 124 -> 4 *)
Text := CONCAT(CONCAT(INT_TO_STRING(Whole), '.'), INT_TO_STRING(Tenth)); (* '12.4' *)
The result always has exactly one decimal, which keeps an alarm list aligned. (Negative values need their sign handled separately, because −12.4 gives −12 and −4.)
Two cautions from the field. First, string handling is slow next to integer maths. A CONCAT copies every character, so building forty status lines every 2 ms scan is a real cost on a small CPU. Rebuild a text only when its inputs change, or in a slower task. Second, alarm texts that the operator sees usually belong in the HMI, not in the PLC. WinCC, FactoryTalk View and TwinCAT HMI all hold alarm texts per alarm number and translate them; the PLC sends the number and the live values. Build strings in the PLC for what really starts there: a batch ID for a label printer, a message to a serial device, a line for a production log.
Strings must not drive control decisions
Sooner or later someone writes this:
IF StatusText = 'RUNNING' THEN (* never do this *)
DownstreamEnable := TRUE;
END_IF;
It works on the day it is written. Then a technician changes the HMI wording to "Running", or a translation turns it into "EN MARCHE", or a longer status gets truncated, or a trailing space sneaks in from a serial device. The comparison becomes FALSE without any error and the downstream conveyor stops, with nothing in the logic that looks wrong. String comparison is also exact and case-sensitive on every PLC: 'Running' = 'RUNNING' is FALSE.
The rule: decide with BOOLs, numbers and enums; describe with strings. The text is generated from the state, never the other way round. If the state lives in an E_State enum (chapter 15), the program compares State = E_State.Running and a separate line turns the state into whatever text the operator wants to read.
There is one place where a decision on text is unavoidable: text that arrives from outside, such as a barcode, a reply from a serial device or a message from a vision system. Treat it the way you treat an index from an HMI. Convert it to a number or an enum at the edge, in one place, with the checks above and a defined answer for text you did not expect, and let the rest of the program work with the result.
TIME values and arithmetic
A TIME holds a duration. Literals start with T# and combine days, hours, minutes, seconds and milliseconds: T#250ms, T#1m30s, T#2h, T#1d2h. Underscores are allowed for readability on most platforms (T#1m_30s). Internally a TIME is a count of milliseconds in 32 bits, which sets its range: on Siemens it is signed, so up to about 24 days 20 hours; on CODESYS and TwinCAT it is unsigned, up to about 49 days. For longer spans CODESYS and TwinCAT offer LTIME (64-bit nanoseconds) and S7-1500 has LTime too. Rockwell has no TIME type at all; a Logix timer's preset and accumulator are DINTs of milliseconds, as chapter 2 noted.
The exact limits are worth having in your head, because they come up in every run-hours discussion:
| Representation | Largest value | In words |
|---|---|---|
signed 32-bit ms (Siemens TIME, a Logix DINT preset) | 2,147,483,647 ms | 24 d 20 h 31 min 23.647 s |
unsigned 32-bit ms (CODESYS / TwinCAT TIME) | 4,294,967,295 ms | 49 d 17 h 2 min 47.295 s |
| DINT of seconds | 2,147,483,647 s | about 68 years |
| DINT of tenths of an hour | 2,147,483,647 | far longer than any pump |
TIME values add, subtract and compare like numbers, and multiplying or dividing by an integer scales them. A mixer's recipe time grows with batch size:
PROGRAM Main
VAR
BatchKg : INT := 400; (* input: batch size, kg *)
MixTime : TIME;
HalfTime : TIME;
MixMs : DINT;
LongMix : BOOL;
END_VAR
MixTime := T#2m + T#15s * (BatchKg / 100); (* 2 min base + 15 s per 100 kg *)
HalfTime := MixTime / 2; (* reverse the agitator at half time *)
MixMs := TIME_TO_DINT(MixTime);
LongMix := MixTime > T#4m;
END_PROGRAMA 400 kg batch mixes for 3 minutes: 180,000 ms. Try 1000 kg and the mix becomes 4 min 30 s, which sets LongMix. TIME_TO_DINT gives the duration in milliseconds, which is what you need when a time goes to an HMI as a number or into a calculation with REALs. DINT_TO_TIME goes the other way: an operator enters seconds on the HMI, and DINT_TO_TIME(Secs * 1000) turns it into a PT for a timer.
Watch the integer division in that formula. BatchKg / 100 is evaluated first, as an INT, so a 450 kg batch gets the same mix time as a 400 kg one. That is acceptable if the recipe says "per started 100 kg" and a bug if it says "per kilogram". Write the rule down, and if it is per kilogram, do the arithmetic in milliseconds: DINT_TO_TIME(120000 + 150 * BatchKg).
Subtraction needs care. T#2s - T#5s is −3 s on a Siemens CPU, a value near 49 days on CODESYS (unsigned arithmetic wraps round), and 0 s on the engine on this page. A time difference that could go negative should be compared first and subtracted second.
Showing a time to an operator
TIME_TO_STRING gives you the literal form, 'T#1m30s250ms', which is right for a log file and wrong for an operator. A mixer screen wants a countdown in minutes and seconds, 02:30, with leading zeros so that the digits do not jump about:
PROGRAM Main
VAR CONSTANT
MIX_TIME : TIME := T#2m30s;
END_VAR
VAR
Start : BOOL := TRUE; (* input: mixing running *)
MixTimer : TON;
Remaining : TIME;
LeftMs : DINT;
LeftSecs : DINT; (* rounded up, the way a countdown reads *)
Mins : DINT;
Secs : DINT;
MinText : STRING[4];
SecText : STRING[4];
Display : STRING[8]; (* 'MM:SS' for the HMI *)
END_VAR
MixTimer(IN := Start, PT := MIX_TIME);
Remaining := MIX_TIME - MixTimer.ET;
LeftMs := TIME_TO_DINT(Remaining);
LeftSecs := (LeftMs + 999) / 1000;
Mins := LeftSecs / 60;
Secs := LeftSecs MOD 60;
MinText := DINT_TO_STRING(Mins);
IF Mins < 10 THEN
MinText := CONCAT('0', MinText);
END_IF;
SecText := DINT_TO_STRING(Secs);
IF Secs < 10 THEN
SecText := CONCAT('0', SecText);
END_IF;
Display := CONCAT(CONCAT(MinText, ':'), SecText);
END_PROGRAMRun it and the display counts down from 02:30. Switch Start off and it resets to 02:30, because the TON clears ET.
The + 999 is the detail people miss. Plain integer division truncates, so with 149.9 s left the display would already read 02:29, and the countdown would reach 00:00 a whole second before the mix ends. Operators notice, because the mixer is still running while the screen says it has finished. Rounding up makes the display show 00:01 until the last moment and 00:00 exactly when the timer is done. A display of elapsed time should truncate, a countdown should round up; decide which one each field is.
The subtraction MIX_TIME - MixTimer.ET is safe here because a TON's ET never exceeds its PT. If the two times came from different places, compare them first, for the reason given in the previous section.
Measuring a cycle with a timer
A timer's ET output is a stopwatch. Run a TON with a long PT while something is happening and ET tells you how long it has been happening. A bottle filler records how long each fill took, so the operator can see the valve slowing down long before it fails:
PROGRAM Main
VAR
Filling : BOOL; (* input: fill valve open *)
Stopwatch : TON;
WasFilling : BOOL;
LastFill : TIME; (* duration of the last complete fill *)
LastMs : DINT;
SlowFill : BOOL;
END_VAR
(* Capture BEFORE calling the timer: ET still holds last scan's value. *)
IF WasFilling AND NOT Filling THEN
LastFill := Stopwatch.ET;
LastMs := TIME_TO_DINT(LastFill);
SlowFill := LastFill > T#4s;
END_IF;
Stopwatch(IN := Filling, PT := T#1h);
WasFilling := Filling;
END_PROGRAMSwitch Filling on, wait a couple of seconds, switch it off: LastFill holds the duration. The order of the lines is the whole trick. A TON resets ET to zero in the same call that sees IN go FALSE. If the capture came after Stopwatch(...), it would read 0 every time. Capturing first reads the value from the end of the previous scan, the last scan on which the valve was open.
How accurate is a stopwatch made from a scan?
The 900 ms in that explanation raises a question for anyone whose numbers go into a quality record: how close is the measured time to the real one? The program only sees the valve signal at scan boundaries. The timer starts on the first scan that sees the signal on, and the capture reads ET from the last scan that saw it on, so the measured duration is always a whole number of scans between those two.
Take a fill that really lasts 340 ms, measured in a 100 ms task. What the program reports depends on where the fill falls between scans:
| Fill starts at | Fill ends at | First scan that sees it | Last scan that sees it | Captured ET |
|---|---|---|---|---|
| 0 ms | 340 ms | 0 ms | 300 ms | 300 ms |
| 50 ms | 390 ms | 100 ms | 300 ms | 200 ms |
| 90 ms | 430 ms | 100 ms | 400 ms | 300 ms |
The same 340 ms fill reads 200 ms or 300 ms. In general, with a scan time T, a real duration D is measured as a multiple of T that is greater than D − 2T and no greater than D. Add the scan-time jitter of a real controller and the readings scatter a little more.
That error is harmless for a 7-second fill in a 10 ms task: it is under 0.3 per cent. It is useless for timing a 40 ms valve stroke in the same task. Three ways out, in rising order of cost: put the measurement in a faster task; measure many cycles and average; or use input hardware that stamps the time of the edge itself, such as a timestamping input terminal like Beckhoff's EL1252, so the scan time drops out of the measurement altogether. And whatever you choose, write the resolution next to the value on the HMI or in the report. "Fill time 7.3 s ± 0.02 s" is a measurement; "Fill time 7.3 s" from a 100 ms task is a guess presented as one.
Running hours that stay right
For running hours, the same stopwatch is not enough, because a TIME overflows after 24 or 49 days and a pump runs for years. The standard pattern is a one-second tick that increments a DINT seconds counter while the pump runs, with hours derived from it: Hours := RunSecs / 3600;. A DINT of seconds lasts 68 years. Make the counter VAR RETAIN so a power cut does not reset a pump's service history.
The tick has two traps of its own. The usual way to build it is a TON that resets itself:
PROGRAM Main
VAR
Running : BOOL; (* input: pump run feedback *)
Tick : TON;
RunSecs : DINT; (* counted running seconds *)
Scans : DINT; (* scans while running: 10 per real second here *)
END_VAR
IF Running THEN
Scans := Scans + 1;
END_IF;
Tick(IN := Running AND NOT Tick.Q, PT := T#1s);
IF Tick.Q THEN
RunSecs := RunSecs + 1;
END_IF;
END_PROGRAMSwitch Running on and leave it. Scans counts tenths of a second of real running time, because each scan here is 100 ms. Compare it with RunSecs after a while: 100 scans, ten real seconds, give a RunSecs of 8. The self-resetting timer loses time on every lap. On the scan where Q comes on, the next call sees IN FALSE and resets the timer, and only the call after that starts timing again, so each "second" takes one second plus two scans, or a little more when the timer's end falls between scans. With a 100 ms scan that is a counter running 17 per cent slow. With a 10 ms scan it is about 2 per cent slow, which is still some 170 hours a year on a pump that runs all the time.
The second trap is short runs. Switch Running on and off in bursts of less than a second and RunSecs never moves, because the tick is reset before it reaches PT. A dosing pump that runs in half-second strokes thousands of times a day shows zero running hours for its whole life, and its service interval never comes due.
Both traps come from counting in whole ticks. The cure is to count the actual time and carry the remainder. Bank the elapsed time of each run when it ends, as the second challenge below does, or add the real time that has passed since the last scan. CODESYS provides TIME(), which returns the time since the controller started; the difference between two readings is the time that really passed, whatever the scan did:
(* CODESYS: static listing *)
Now := TIME(); (* ms since start-up, unsigned 32-bit *)
Delta := Now - Last; (* correct across the 49-day wrap too *)
Last := Now;
IF Running THEN
RunMs := RunMs + TIME_TO_DINT(Delta);
IF RunMs >= 1000 THEN
RunSecs := RunSecs + RunMs / 1000; (* whole seconds into the retained counter *)
RunMs := RunMs MOD 1000; (* keep the remainder *)
END_IF;
END_IF;
The unsigned subtraction is what makes the wrap harmless: when TIME() rolls over after 49 days, Now - Last still gives the few milliseconds that passed. On an S7-1500 the RUNTIME instruction does the same job, returning the time since its previous call as an LREAL in seconds.
Never measure time by counting scans and multiplying by the configured cycle time. The configured cycle time is a target. A task overruns when something else takes longer, and somebody changes the cycle time during a later project. A running-hours counter that adds 100 ms per scan becomes five times too fast the day the task is changed from 100 ms to 20 ms.
DATE, TIME_OF_DAY and the wall clock
IEC 61131-3 also defines DATE (D#2026-10-23), TIME_OF_DAY (TOD#14:30:00) and DATE_AND_TIME (DT#2026-10-23-14:30:00) for calendar values. You need them for shift reports, timestamps on batch records and "clean-in-place every day at 02:00". Reading the controller's real-time clock is vendor-specific: RD_LOC_T in TIA Portal, a GSV of the WallClockTime object in Studio 5000, NT_GetTime or the FB_LocalSystemTime block in TwinCAT. Arithmetic on these types also varies between vendors, so this chapter leaves them out of the live examples; chapter 28 covers them properly, including time stamps, midnight and daylight saving.
One firm opinion to finish: never measure a duration with the wall clock. The clock is synchronised by NTP, corrected by an operator and moved an hour twice a year for daylight saving. A cycle "measured" as end-time minus start-time across a clock correction comes out negative or an hour long. Timers and the controller's start-up clock count its own milliseconds and are not affected. Use the clock for when, and timers for how long.
The data part, finished
You can now keep text in a STRING of the right length and know how each platform lays it out in memory. You can take strings apart and build them up with the standard functions, check text from outside character by character before you convert it, and turn numbers into operator-friendly messages without letting text near a control decision. You can compute with TIME values, convert them to milliseconds, format a countdown that reads true to the operator, measure a cycle with a timer's ET (capturing it before the timer call clears it) and know how far a scan-based measurement can be off. And you can bank run time across many runs without the drift of a self-resetting tick.
That completes the data types. Arrays, structures, enumerations, strings and times are the raw material; the next part is about packaging logic around them. Chapter 17 starts with the simplest package of all, the FUNCTION: inputs in, one result out, and no memory between calls. That is the right home for the current-to-text formatting, written once instead of in every pump's code.