OpenPLC

OpenPLC

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

04 - Registers

04 - Registers

The Modbus side. Your variables sit at numbered addresses, and peers reach them
the way they would reach any Modbus device.

Three blocks:

BEEBUS_MB_MAP put your variables at addresses, so peers can reach them
BEEBUS_MB_READ read a peer's addresses
BEEBUS_MB_WRITE write a peer's addresses

The four lists

Modbus has four separate numbered lists. Address 40 in one is a different thing
from address 40 in another.

TABLE holds peers may function codes
BEEBUS_MB_COIL on/off read and write 1, 5, 15
BEEBUS_MB_DISCRETE_INPUT on/off read only 2
BEEBUS_MB_HOLDING_REG 16 bits read and write 3, 6, 16
BEEBUS_MB_INPUT_REG 16 bits read only 4

The rule of thumb: inputs are read-only, coils and holding registers are
writable.
Put anything a peer should be able to change in a coil or a holding
register, and anything that is purely a reading in a discrete or input register.

Saying what you are passing

Every one of the three blocks has a single VALUE pin. Put the variable on it:

VALUE := iSetpoint        (* one value *)
VALUE := aTrend           (* ARRAY [0..99] OF WORD — a range of registers *)
VALUE := aValves          (* ARRAY [0..7] OF BOOL  — a range of coils *)
VALUE := CFG              (* a STRUCT — every member, one after the other *)

Nothing is wrapped. The pin carries the address, the type and — for a STRUCT —
its layout, so one block covers a single value, a whole range and a whole
STRUCT without different blocks for each.

TABLE is what says bits or registers, and it is a pin you were setting
anyway. A block reading TABLE := BEEBUS_MB_COIL knows it is dealing with bits; one
reading BEEBUS_MB_HOLDING_REG knows it is dealing with 16-bit values. The variable
does not have to say so a second time.

An array has to be declared with fixed bounds — ARRAY [0..99] OF WORD, not
ARRAY [*]. A variable-length array is a view rather than a variable, and views
travel on in-out pins only.

How a value is laid out

Every value takes the registers its type needs, from ADDRESS on, one after the
other in declaration order:

type registers on the wire
BOOL 1 0 or 1 (a peer writing any other number writes TRUE)
SINT, USINT, BYTE 1 the number; one the type cannot hold (300 into a USINT) is refused
INT, UINT, WORD 1 the number
DINT, UDINT, DWORD 2 high word first
LINT, ULINT, LWORD 4 high word first
REAL 2 IEEE-754, high word first
LREAL 4 IEEE-754 double, high word first
TIME, TOD 2 DINT milliseconds
LTIME 4 LINT nanoseconds (IEC 61131-3)
DT, DATE 2 UDINT seconds since 1970-01-01
STRING(n) n/2 + 1 two characters a register, first in the high byte, then the terminator
a nested STRUCT its members in place
an array its elements in place, first one first

High word first is what a Modbus master or HMI calls "ABCD", "big-endian"
or "high word first" ("ABCDEFGH" for a 64-bit value). Set that on the far end
once and every 32- and 64-bit value reads right. A 32-bit value takes both its
registers in one write, a 64-bit one all four.

A register you mask is a WORD, not an INT. A register is sixteen raw
bits, and WORD is the type a program can work on bit by bit: myWord.6 is bit
6, and AND, OR, XOR, NOT, SHL, SHR, ROL and ROR take ANY_BIT,
which INT is not. A VFD control or status word belongs in a WORD. Use INT
for a signed reading and UINT for an unsigned one.

In BEEBUS_MB_COIL and BEEBUS_MB_DISCRETE_INPUT every member has to be a
BOOL, one address each. WSTRING is refused everywhere — a value quietly cut
down would be wrong rather than missing. A refused variable is refused whole, with LAST_ERROR naming
the member: skipping it would move every address after it.

Sharing your variables — BEEBUS_MB_MAP

mapSpeed( NODE := node, TABLE := BEEBUS_MB_HOLDING_REG, ADDRESS := 40,
          VALUE := iSpeed );

iSpeed is holding register 40 from that moment. A peer reading gets what
it holds now; a peer writing changes it. Nothing is copied, and there is no
request pin.

A range works the same way:

mapTrend( NODE := node, TABLE := BEEBUS_MB_HOLDING_REG, ADDRESS := 100,
          VALUE := aTrend );

aTrend : ARRAY[0..99] OF WORD at address 100 becomes addresses 100 to 199. The
array's lower bound maps to ADDRESS whatever that bound is.

Outputs

BOUND the addresses are live
COUNT how many addresses were taken
WRITTEN one scan, when a peer wrote any of them
WRITTEN_NAME which member, e.g. levelHiMm or spare[3] (held until the next write)
WRITTEN_INDEX its position, from 1, every array element and nested member one position (held)
WRITTEN_ADDRESS its first address (held)
WRITTEN_COUNT how many members that write covered (held)
SAFE_APPLIED one scan, when the safe state was applied
LAST_ERROR when refused, which member and why

WRITTEN is the one to use. It tells you a peer changed the value rather
than your own program doing it:

IF mapSetpoint.WRITTEN THEN
    (* somebody sent us a new setpoint — act on it *)
    bNewSetpoint := TRUE;
END_IF;

When one write covers several members — an HMI sending a whole block — the
WRITTEN_* outputs name the first (lowest address) and WRITTEN_COUNT says how
many there were.

One instance, one mapping

TABLE, ADDRESS and VALUE are taken on the first scan the node is there.
Changing them later does nothing, and calling the same instance twice does not
map a second range. Two separate ranges are two instances — or one STRUCT.

A whole STRUCT of settings

This is what an HMI needs. Put every setting in one STRUCT:

TYPE PLANT_CFG : STRUCT
  levelHiMm   : REAL := 1500.0;   (* 0..1 *)
  levelLoMm   : REAL := 500.0;    (* 2..3 *)
  runTime     : TIME := T#2h;     (* 4..5   DINT ms *)
  pumpEnable  : BOOL := TRUE;     (* 6      0 / 1 *)
  siteName    : STRING(16);       (* 7..15  9 registers *)
END_STRUCT END_TYPE

cfgMap( NODE := node, TABLE := BEEBUS_MB_HOLDING_REG, ADDRESS := 0,
        VALUE := CFG );

The HMI writes straight into CFG. WRITTEN says a write happened and
WRITTEN_NAME which setting, so the program can check it and put the old value
back when it is out of range:

IF cfgMap.WRITTEN THEN
    IF CFG.levelLoMm >= CFG.levelHiMm THEN
        CFG.levelLoMm := levelLoInUse;                     (* refused *)
        sRefused := CONCAT('Setting refused: ', cfgMap.WRITTEN_NAME);
    END_IF;
END_IF;

Add new members at the end. One added in the middle moves every address
after it. A spare or two at the end costs nothing.

The address table is generated, not typed. A table generator
reads the .st file that declares the STRUCT and prints every member's
address, type and register count as Markdown, and as JSON for an HMI's
firmware.

Safe states

Never on a setting or a command. A safe value is for a live setpoint a peer
keeps driving; a setting or a command written by an HMI or SCADA must never
change because the link dropped — the PLC runs on its own.

A writable address has a problem: the peer that was driving it goes quiet, and
the last value it sent sits there forever. On a setpoint that can be dangerous.

mapSetpoint( NODE := node, TABLE := BEEBUS_MB_HOLDING_REG, ADDRESS := 40,
             ON_LOSS := BEEBUS_MB_RESET_TO_VALUE, SAFE_VALUE := 5,
             VALUE := iSetpoint );
ON_LOSS
BEEBUS_MB_HOLD_LAST_VALUE leave it where it was (the default)
BEEBUS_MB_RESET_TO_VALUE put SAFE_VALUE in it

SAFE_APPLIED pulses when it happens, so you can log it or raise an alarm.

Two things to know:

FAILSAFE := TRUE on the node block turns the whole mechanism on. Without
it, ON_LOSS does nothing.

Only a single value has a safe state. A range has no one safe value and a
string has no numeric one, so ON_LOSS applies to single-value bindings on a
writable table. Anything else ignores it.

Reading a peer — BEEBUS_MB_READ

readLevel( REQ := bReadNow, NODE := node, PEER := 20,
           TABLE := BEEBUS_MB_HOLDING_REG, ADDRESS := 40,
           VALUE := iRemoteLevel );

Edge-triggered. The read goes out on the rising edge of REQ. Holding
REQ high gives you one read, not a stream.

output
BUSY outstanding
DONE one scan, when the answer landed in your variable
ERROR it did not
STATUS why

The usual shape:

readLevel( REQ := bReadNow, ... );

IF readLevel.DONE THEN
    (* iRemoteLevel is fresh this scan *)
END_IF;

IF readLevel.ERROR THEN
    eLastFault := readLevel.STATUS;
END_IF;

A range is the same with a different wrapper:

readTrend( REQ := bReadNow, NODE := node, PEER := 20,
           TABLE := BEEBUS_MB_HOLDING_REG, ADDRESS := 100,
           VALUE := aRemoteTrend );

LENGTH lets you move fewer values than the variable holds — LENGTH := 0 uses
all of it. Asking for more than it holds is refused rather than reading off the
end.

A STRUCT reads the same way, laid out exactly as BEEBUS_MB_MAP lays it out,
so reading a peer's whole settings block into a STRUCT of the same type lands
member for member — up to 125 registers in one request. The answers are
unpacked on the scan DONE is set, never half a value at a time.

Writing to a peer — BEEBUS_MB_WRITE

writeSetpoint( REQ := bSend, NODE := node, PEER := 20,
               TABLE := BEEBUS_MB_HOLDING_REG, ADDRESS := 40, CONFIRMED := TRUE,
               VALUE := iNewSetpoint );

Only two of the four lists can be written: BEEBUS_MB_COIL and BEEBUS_MB_HOLDING_REG.
There is no Modbus function code for writing a discrete input or an input
register, so naming one is refused with BEEBUS_INVALID_ARGUMENT rather than
sent and lost.

A range or a whole STRUCT writes the same way, and is one request rather than
one per address (up to 123 registers). To change one setting in a peer's
settings STRUCT, write a variable of that member's type to that member's
address — a REAL to its two registers, a STRING(16) to its nine. A peer
refuses a value its member cannot hold, and the write ends in ERROR.

writeValves( REQ := bSend, NODE := node, PEER := 20,
             TABLE := BEEBUS_MB_COIL, ADDRESS := 0, CONFIRMED := TRUE,
             VALUE := aValves );

CONFIRMED — the pin that matters

CONFIRMED := FALSE puts the value on the wire once and forgets it. DONE
means it was sent. Nothing is retried, and a lost frame is a lost value you
will not hear about. That is the right trade for a value you send every scan
anyway, where the next one is along shortly.

CONFIRMED := TRUE keeps it queued until the peer says it took it, retries
if nothing comes back, and reports failure. DONE means the peer has it.

Use CONFIRMED := TRUE for setpoints and commands. A lost setpoint looks
exactly like a delivered one otherwise, and you find out when the plant does
the wrong thing.

Use FALSE for a fast cyclic value where the next update fixes any loss.

Strings

A STRING maps like anything else:

mapStatus( NODE := node, TABLE := BEEBUS_MB_INPUT_REG, ADDRESS := 200,
           VALUE := sStatusText );

It is packed two characters to a register, then the terminator, so a
STRING(n) takes n/2 + 1 addresses: a STRING(32) takes 17 and carries all 32
characters. A peer reads it with BEEBUS_MB_READ into its own STRING and
gets the text back.

WRITTEN fires once for the whole string rather than once per register.

What can go wrong

STATUS what happened
BEEBUS_UNKNOWN_NODE that peer is not on the network
BEEBUS_DUPLICATE_NODE_ID this node stood down — another has its ID
BEEBUS_NOT_MAPPED the peer has nothing at that address
BEEBUS_TIMEOUT a confirmed write was never acknowledged
BEEBUS_NODE_LOST the peer left before it was delivered
BEEBUS_REFUSED the peer rejected it
BEEBUS_INVALID_ARGUMENT wrong table for the type, or a count Modbus disallows; LAST_ERROR says which member
BEEBUS_TYPE_MISMATCH a read brought a number a member cannot hold; that member kept its value
BEEBUS_SUPERSEDED a newer write to the same address replaced it before it went out

BEEBUS_SUPERSEDED is not a fault. If you write the same address twice before
the first goes out, the newer value wins and the older one is reported rather
than vanishing.


Next: 05 - Topics, or 08 - Patterns for these
in a real program.

Last updated 3 hours ago
Was this helpful?
Thanks!