Chapter 25 / 32 · Part 7 · Advanced language

Bits, words and shifts

Pack and unpack status words, test and set single bits, shift and rotate with SHL, SHR, ROL and ROR, and decode a drive status word.

Why bits come back

For most of this course a signal has had its own variable: PumpRunning, DoorClosed, HighLevel. That is the right default, and it stays the right default. But the moment your program talks to something outside itself, single signals start arriving packed together. A SINAMICS G120 sends sixteen status flags in one 16-bit word. A Festo CPX or SMC EX600 valve terminal takes its 32 solenoid coils as one double word. An IO-Link hub delivers eight sensor bits in a byte. A SCADA system that licenses by tag count — WinCC counts PowerTags — costs real money per tag, so integrators pack sixteen motor flags into one register rather than buy sixteen tags. And Modbus has no BOOL at all in its holding registers: everything is a 16-bit word.

So you need to be fluent with bits: read one, write one, test several at once, move them around, and build a word that a drive or an HMI will understand. None of it is hard. All of it is easy to get subtly wrong, because a mistake in bit 3 versus bit 4 still compiles, still runs, and turns on the wrong valve.

A BOOL and a bit are not the same thing in memory. Logically a BOOL is one bit. Physically, most controllers give it a whole byte: CODESYS and TwinCAT store a BOOL in 8 bits, an optimised S7-1500 data block does the same, and a stand-alone BOOL tag in a ControlLogix occupies 32 bits. Bit strings are different. A BYTE holds exactly 8 flags, a WORD 16, a DWORD 32 and an LWORD 64, with no wasted space, which is why every protocol and every device profile uses them. Chapter 2 introduced the types and read a few bits out of a drive status word; chapter 4 used AND with a mask to classify alarm bits. This chapter is the full toolkit.

Hex and binary literals

You will rarely write a bit pattern in decimal, because decimal hides it. 1151 tells you nothing; 16#047F tells you, once you are used to it, that bits 0 to 6 and bit 10 are set. IEC 61131-3 gives you three bases for integer literals:

  • 16#047F — hexadecimal. Every hex digit is exactly four bits, so a WORD is always four digits.
  • 2#0000_0100_0111_1111 — binary. Underscores are allowed anywhere in a literal and ignored, so group by four.
  • 8#2177 — octal. Legal, and almost never used outside very old documentation.

You can also put the type in front, WORD#16#047F, which matters when a compiler would otherwise have to guess the size of an untyped literal. TIA Portal and CODESYS both accept typed literals; Studio 5000 writes hex as 16#047F too, but its integers are signed DINTs, so a pattern with bit 31 set reads as a negative number in the tag monitor.

To read hex at speed, learn four facts and derive the rest. 1, 2, 4 and 8 are single bits 0, 1, 2 and 3 of a digit. F is all four. 3 is the bottom two, C the top two, 7 the bottom three, E the top three, 5 and A are alternate bits. The digit's position tells you which group of four you are in: the rightmost digit holds bits 0–3, the next bits 4–7, then 8–11 and 12–15. So in 16#047F, F sets bits 0–3, 7 sets bits 4–6, 4 sets bit 10.

The live engine on these pages shows bit strings in decimal in the watch table, like the tag monitor of an older Rockwell controller. That is unhelpful for bit work, so the first example includes a small function that formats a WORD as hex text, the same thing an HMI does when a technician asks to see a register "in hex".

FUNCTION F_Hex4 : STRING
VAR_INPUT
    W : WORD;
END_VAR
VAR
    Digits : STRING := '0123456789ABCDEF';
    Txt    : STRING;
    Nibble : WORD;
    i      : INT;
END_VAR
Txt := '16#';
FOR i := 3 TO 0 BY -1 DO
    Nibble := SHR(W, i * 4) AND 16#000F;      (* one hex digit, 0..15 *)
    Txt := CONCAT(Txt, MID(Digits, 1, WORD_TO_INT(Nibble) + 1));
END_FOR;
F_Hex4 := Txt;
END_FUNCTION

PROGRAM Main
VAR
    Raw       : WORD := 1151;   (* input: type any value 0..65535 *)
    AsHex     : STRING;
    Dec       : WORD;
    Hex       : WORD;
    Bin       : WORD;
    SameValue : BOOL;
    LowNibble : WORD;
    HighByte  : WORD;
END_VAR

Dec := 1151;
Hex := 16#047F;
Bin := 2#0000_0100_0111_1111;
SameValue := (Dec = Hex) AND (Hex = Bin);   (* three spellings, one pattern *)

AsHex     := F_Hex4(Raw);
LowNibble := Raw AND 16#000F;   (* bits 0..3 *)
HighByte  := SHR(Raw, 8);       (* bits 8..15, moved down *)

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

SameValue is TRUE: the base is a property of how you write the number, not of the number. Now type values into Raw and read them in hex: 255 is 16#00FF, 256 is 16#0100, 4096 is 16#1000, 65535 is 16#FFFF. Notice how 256 moves the 1 into the third digit — bit 8 is the first bit of the high byte. LowNibble and HighByte preview the two operations this chapter is built on: masking with AND and moving with a shift.

Reading and writing one bit

A single bit of a bit string is addressed with a dot and its number, counting from zero at the least significant end. Bit 0 is worth 1, bit 3 is worth 8, bit 15 is worth 32,768. The same syntax works on both sides of :=: on the right it reads a bit, on the left it writes one and leaves the other fifteen alone.

Here is a small pick-and-place station. Its sensors arrive from an IO-Link master as one input word; its valves leave as one output word to a valve terminal.

PROGRAM Main
VAR
    InWord      : WORD;   (* input: 16 sensor bits from the IO-Link master *)
    ClampCmd    : BOOL;   (* input: sequence wants the clamp closed *)
    EjectCmd    : BOOL;   (* input: sequence wants the part ejected *)
    DoorClosed  : BOOL;   (* bit 0 *)
    PartPresent : BOOL;   (* bit 3 *)
    AirOK       : BOOL;   (* bit 15: main air pressure switch *)
    OutWord     : WORD;   (* output: valve terminal coils *)
END_VAR

(* Unpack: give every bit a name before any logic uses it *)
DoorClosed  := InWord.0;
PartPresent := InWord.3;
AirOK       := InWord.15;

(* Pack: each coil written once, from named conditions *)
OutWord.0 := ClampCmd AND AirOK;                         (* valve 1, coil 14: clamp *)
OutWord.1 := NOT ClampCmd AND AirOK;                     (* valve 1, coil 12: unclamp *)
OutWord.4 := EjectCmd AND PartPresent AND DoorClosed AND AirOK;   (* valve 3: eject *)

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

Type 32768 into InWord — only bit 15, air pressure OK — and OutWord reads 2: the unclamp coil, bit 1. Switch ClampCmd on and it reads 1. Type 32777 (bits 15, 3 and 0) and switch EjectCmd on: OutWord reads 17, coils 0 and 4. The structure matters more than the syntax. All the .n references sit in two small blocks at the edge of the program, and everything else works with names. When the electrician moves the eject valve from slot 3 to slot 5 of the terminal, one line changes.

The syntax differs by vendor, and this is one place where it really does:

PlatformBit 3 of StatusByte 1 of StatusNotes
CODESYS, TwinCAT 3Status.3shift and maskThe bit number may be an integer constant from a global constant list, e.g. Status.c_FaultBit. Structures can also declare members of type BIT that pack tightly.
TIA Portal SCL (S7-1200/1500)#Status.%X3#Status.%B1"Slice access". .%W0 takes a word out of a DWORD. Works on BYTE, WORD, DWORD and LWORD tags.
Studio 5000 Logix STStatus.3shift and maskWorks on SINT, INT and DINT — Logix has no WORD type. Indirect bit access is allowed: Status.[BitIdx].
IEC 61131-3, 3rd editionStatus.%X3Status.%B1The standard's partial-access syntax; most compilers also accept the short .3 for bits.

Masks: set, clear, toggle, test

Bit access handles one bit at a time. A mask handles any group of bits in one operation. A mask is just a constant with ones in the positions you care about, combined with the word by a bitwise operator. Four operations cover everything:

GoalOperationEffect on bits in the maskEffect on the others
SetW := W OR MASK;forced to 1unchanged
ClearW := W AND NOT MASK;forced to 0unchanged
ToggleW := W XOR MASK;invertedunchanged
Test — any(W AND MASK) <> 0—TRUE if at least one masked bit is 1
Test — all(W AND MASK) = MASK—TRUE only if every masked bit is 1

The clear operation is the one people write wrong. W AND MASK keeps the masked bits and destroys every other bit — the opposite of clearing. You need NOT MASK to turn "the bits I want to clear" into "the bits I want to keep".

The example below works on a word of eight lamp outputs on a test panel. Mask is a field: start with 7 (bits 0, 1 and 2). Press SetPB, ClearPB and TogglePB and watch Pattern. Each button acts on its rising edge, so one press is one operation, not one per scan.

PROGRAM Main
VAR
    Mask     : WORD := 7;   (* input: which lamps the buttons act on *)
    SetPB    : BOOL;        (* input *)
    ClearPB  : BOOL;        (* input *)
    TogglePB : BOOL;        (* input *)
    SetEdge    : R_TRIG;
    ClearEdge  : R_TRIG;
    ToggleEdge : R_TRIG;
    Pattern  : WORD;        (* output: lamp word *)
    AnyOn    : BOOL;
    AllOn    : BOOL;
    Lamp0    : BOOL;
    Lamp3    : BOOL;
END_VAR

SetEdge(CLK := SetPB);
ClearEdge(CLK := ClearPB);
ToggleEdge(CLK := TogglePB);

IF SetEdge.Q THEN
    Pattern := Pattern OR Mask;
END_IF;
IF ClearEdge.Q THEN
    Pattern := Pattern AND NOT Mask;
END_IF;
IF ToggleEdge.Q THEN
    Pattern := Pattern XOR Mask;
END_IF;

AnyOn := (Pattern AND Mask) <> 0;
AllOn := (Pattern AND Mask) = Mask;
Lamp0 := Pattern.0;
Lamp3 := Pattern.3;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Mask, SetPB, ClearPB, TogglePB.

Set with mask 7: Pattern is 7, both tests TRUE. Change Mask to 12 (bits 2 and 3) and toggle: bit 2 goes off, bit 3 comes on, Pattern is 11 (bits 0, 1, 3). AnyOn is TRUE for mask 12 because bit 3 is on; AllOn is FALSE because bit 2 is off. Clear with mask 12 and Pattern is 3 — bits 0 and 1 survived because they were outside the mask.

A toggle on a level instead of an edge is a classic bench-test surprise: hold the button and the bits invert on every scan, so where they end up depends on how many milliseconds you held it. The same goes for set and clear on an HMI button that stays TRUE — harmless for set and clear, which are idempotent, but fatal for toggle. Toggle only ever on an edge.

Shifts: SHL and SHR

SHL(W, n) moves every bit of W n places towards the most significant end; SHR(W, n) moves them towards bit 0. Bits that fall off the end are lost. The positions that open up are filled with zeros. Both take any bit string — BYTE, WORD, DWORD, LWORD — and return the same type, so the width of the variable decides where "the end" is.

Shifts do three jobs in real programs. They build a mask from a bit number: SHL(WORD#1, n) is a word with only bit n set, which is how you address "bit n" when n is a variable on platforms without indirect bit access. They move a field into position: a 4-bit zone number becomes bits 4–7 with SHL(Zone, 4). And they extract a field: SHR(W, 8) AND 16#00FF is the high byte of a word, moved down so it reads as 0–255.

PROGRAM Main
VAR
    Value       : WORD := 12;   (* input: 2#1100 *)
    Places      : INT := 2;     (* input: 0..16 *)
    Left        : WORD;
    Right       : WORD;
    BitMask     : WORD;
    LostLeft    : WORD;
    AsUint      : UINT;
    TimesTwoPow : UINT;
END_VAR

Left    := SHL(Value, Places);
Right   := SHR(Value, Places);
BitMask := SHL(WORD#1, Places);          (* only bit "Places" set *)

(* The bits SHL pushed out of the top: they are gone *)
LostLeft := SHR(Value, 16 - Places);

(* For an unsigned value, a left shift is multiplication by 2^n ... *)
AsUint      := WORD_TO_UINT(Value);
TimesTwoPow := AsUint * WORD_TO_UINT(BitMask);

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

With 12 and 2, Left is 48 (12 × 4), Right is 3 (12 ÷ 4) and BitMask is 4. Now type 49152 (16#C000, the top two bits) into Value: Left is 0, because both bits fell off, and LostLeft shows 3 — the two bits that were lost. TimesTwoPow also reads 0, because UINT arithmetic wraps at 65,536 just as the shift does. That is the honest description of a left shift: multiplication by a power of two, modulo the width of the type, with no overflow warning.

Right shifts and division agree only for unsigned values. Shift a negative number's bit pattern right and you get nonsense:

Rotations: ROL and ROR

A rotation is a shift where the bit that falls off one end comes back in at the other. Nothing is lost. ROL(B, 1) on the byte 2#1000_0001 gives 2#0000_0011: bit 7 wrapped round to bit 0. Rotating a BYTE eight places, a WORD sixteen or a DWORD thirty-two brings you back where you started.

That makes rotation the natural tool for anything cyclic. The textbook case is a stepper motor driven directly from four outputs, one per coil. A full-step, two-phase-on sequence energises coils A+B, then B+C, then C+D, then D+A, which in binary is 0011, 0110, 1100, 1001. Put the pattern 2#0011_0011 in a BYTE — the four-bit sequence twice — and every ROL by one produces the next step in the low four bits. ROR runs the same sequence backwards, which reverses the motor.

PROGRAM Main
VAR
    Run     : BOOL;              (* input *)
    Reverse : BOOL;              (* input *)
    StepT   : TON;
    Phase   : BYTE := 16#33;     (* 2#0011_0011: coils A+B on *)
    CoilA   : BOOL;              (* outputs to the driver stage *)
    CoilB   : BOOL;
    CoilC   : BOOL;
    CoilD   : BOOL;
    Steps   : DINT;
END_VAR

(* Self-resetting timer: one step per period *)
StepT(IN := Run AND NOT StepT.Q, PT := T#200ms);

IF StepT.Q THEN
    IF Reverse THEN
        Phase := ROR(Phase, 1);
        Steps := Steps - 1;
    ELSE
        Phase := ROL(Phase, 1);
        Steps := Steps + 1;
    END_IF;
END_IF;

CoilA := Phase.0;
CoilB := Phase.1;
CoilC := Phase.2;
CoilD := Phase.3;

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

Switch Run on and step through: Phase goes 51, 102, 204, 153, 51 — 16#33, 16#66, 16#CC, 16#99 — and exactly two adjacent coils are on at every step. Flip Reverse and the order reverses without a glitch, because the pattern itself is the position in the sequence. The self-resetting timer costs one scan per period, so a 200 ms preset steps every 300 ms on this page's 100 ms scan (chapter 10 explains why). Nobody drives a production stepper like this — a pulse-direction drive or an EtherCAT stepper terminal such as a Beckhoff EL7041 does the commutation in hardware, thousands of times faster — but the pattern trick is exactly what those devices do inside, and it is a good way to see a rotation.

A more common use is round-robin duty rotation. A booster station has four identical pumps, and you want each new demand to start a different one, so the running hours even out. Put a single 1 in each half of a byte, 16#11, and rotate left once per new demand. The low four bits then walk 1, 2, 4, 8, 1 … — exactly one pump selected, cycling through all four.

PROGRAM Main
VAR
    Demand     : BOOL;          (* input: pressure below setpoint *)
    DemandEdge : R_TRIG;
    Lead       : BYTE := 16#11; (* one-hot in each nibble *)
    Pump1, Pump2, Pump3, Pump4 : BOOL;
    Starts     : DINT;
END_VAR

DemandEdge(CLK := Demand);
IF DemandEdge.Q THEN
    Lead   := ROL(Lead, 1);     (* next pump takes the next duty *)
    Starts := Starts + 1;
END_IF;

(* Self-repair: a corrupted pattern must never select no pump *)
IF (Lead AND 16#0F) = 0 THEN
    Lead := 16#11;
END_IF;

Pump1 := Demand AND Lead.0;
Pump2 := Demand AND Lead.1;
Pump3 := Demand AND Lead.2;
Pump4 := Demand AND Lead.3;

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

Toggle Demand on and off a few times: pump 2 takes the first duty, then 3, 4, 1. The self-repair line is not decoration. If Lead were retained across a download that changed its layout, or written from an HMI by mistake, a pattern with no bit in the low nibble would select no pump at all and the station would lose pressure with every pump healthy. Rotation keeps exactly the bits it was given; it never repairs a bad pattern on its own. On a real station the rotation also has to skip a pump that is faulted or in maintenance, which turns this into a loop over a small array — the bit trick is the neat core, not the whole job.

Packing a status word, unpacking a command word

The most common bit work in a real project has nothing to do with drives or steppers. It is the interface to the HMI and SCADA. Every motor, valve and unit in the plant gets a status word that the PLC writes and the SCADA reads, and often a command word that the SCADA writes and the PLC reads. One register per device, sixteen meanings, documented in a table that the SCADA integrator and the PLC programmer both sign off.

PROGRAM Main
VAR
    Running  : BOOL;       (* input *)
    Fault    : BOOL;       (* input *)
    AutoMode : BOOL;       (* input *)
    HmiCmd   : WORD;       (* input: command word written by SCADA *)
    Status   : WORD;       (* output: status word read by SCADA *)
    StatusAsInt : INT;     (* what a SCADA "16-bit integer" tag shows *)
    CmdStart : BOOL;
    CmdStop  : BOOL;
    CmdReset : BOOL;
    BeatT    : TON;
    Beat     : BOOL;
END_VAR

(* Heartbeat: SCADA alarms if bit 15 stops changing *)
BeatT(IN := NOT BeatT.Q, PT := T#1s);
IF BeatT.Q THEN
    Beat := NOT Beat;
END_IF;

(* Pack: bit table agreed with the SCADA integrator *)
Status := 0;
Status.0  := Running;
Status.1  := Fault;
Status.2  := AutoMode;
Status.15 := Beat;
StatusAsInt := WORD_TO_INT(Status);

(* Unpack: bit 0 start, bit 1 stop, bit 2 reset *)
CmdStart := HmiCmd.0;
CmdStop  := HmiCmd.1;
CmdReset := HmiCmd.2;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Running, Fault, AutoMode, HmiCmd.

Status := 0; at the top of the pack section matters: every bit is then written on every scan, including the ones you have not assigned yet, so a reserved bit can never carry a stale value from an old version of the program. Watch Status and StatusAsInt as the heartbeat toggles. With only Running on, they alternate between 1 and 32,769 — or 1 and −32,767. Same bits, two readings.

Two rules make this interface reliable. First, one writer per word. If the PLC writes the status word and the HMI also writes a bit in it — a "reset request" squeezed into a spare status bit, say — then whichever writes last wins, and the HMI writes the whole register, not one bit. Commands and status go in separate words. Second, commands are requests, not states. The PLC decides what happens on CmdStart; it does not copy it to a motor output. Handshakes, heartbeats and what to do when the SCADA connection drops have their own chapter, HMI and machine interfaces.

Decoding a PROFIdrive status word

Chapter 2 read five bits of a SINAMICS status word ZSW1 and gave them names. That is enough to light a lamp. It is not enough to drive a drive, because the bits are not independent flags. Together they report a state machine, defined in the PROFIdrive profile that SINAMICS, and most other PROFINET drives with a "standard telegram 1", implement:

BitName (PROFIdrive)Notes
0Ready to switch on
1Ready to operate
2Operation enabledthe drive is producing torque
3Fault present
4No coast stop (OFF2) active1 = healthy, low-active
5No quick stop (OFF3) active1 = healthy, low-active
6Switching on inhibited
7Warning present
8Speed within tolerancesetpoint and actual agree
9Control requested0 = the PLC is not in control
10Comparison speed reachedthreshold set in the drive

Three of these bits trip people up. Bits 4 and 5 are low-active: they read 1 when everything is fine and drop to 0 when a coast stop or quick stop is active, typically from a hardwired terminal on the drive. Bit 9 reads 0 when the drive is being controlled by something other than the PLC — most often a commissioning engineer's laptop running Startdrive with the control panel left open. Every command the PLC sends is ignored until bit 9 comes back.

The state machine is decoded from bits 0, 1, 2 and 6 together, with the fault bit checked first. Testing bits one at a time does not work: bit 0 is TRUE in three different states, and a drive in a transitional state can show combinations that no single bit describes. Mask the bits that define the state and compare the result with each state's pattern:

StateBits 6, 2, 1, 0ZSW1 AND 16#004F
S1 Switching on inhibited1 0 0 016#0040
S2 Ready for switching on0 0 0 116#0001
S3 Ready for operation0 0 1 116#0003
S4 Operation0 1 1 116#0007
Faultbit 3 setcheck first

The run block below decodes the flags that need care. Try these values in ZSW1, each built from the table: 823 (16#0337, running at speed under PLC control), 951 (the same with a warning, bit 7), 608 (16#0260, coast stop active from the terminal — bit 4 has dropped), 311 (16#0137, running but bit 9 is 0) and 632 (16#0278, faulted).

PROGRAM Main
VAR
    ZSW1            : WORD := 823;   (* input: 16#0337 *)
    Operating       : BOOL;
    Faulted         : BOOL;
    Warning         : BOOL;
    CoastStopActive : BOOL;
    QuickStopActive : BOOL;
    AtSpeed         : BOOL;
    PlcInControl    : BOOL;
    DriveOK         : BOOL;
END_VAR

(* State by mask-and-compare, never by a single bit *)
Operating := (ZSW1 AND 16#004F) = 16#0007;

Faulted  := ZSW1.3;
Warning  := ZSW1.7;
AtSpeed  := ZSW1.8;

(* Low-active bits: invert once, here, and name the result for what it means *)
CoastStopActive := NOT ZSW1.4;
QuickStopActive := NOT ZSW1.5;
PlcInControl    := ZSW1.9;

DriveOK := NOT Faulted AND NOT CoastStopActive AND NOT QuickStopActive
           AND PlcInControl;

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

The inversion of the low-active bits happens once, at unpack, and the new names say what TRUE means. Nobody downstream should ever write NOT CoastStopNotActive. With 311, Operating is still TRUE but DriveOK is FALSE: the motor is turning, and the PLC cannot stop it.

Building the control word

The other direction is STW1, the control word the PLC sends. Its layout mirrors the status word, and it has the same low-active traps:

BitMeaning when 1Normal value
0ON (0 = OFF1, ramp down and switch off)your run command
1No coast stop (0 = OFF2, pulses off immediately)1
2No quick stop (0 = OFF3, fast ramp down)1
3Enable operation1
4Enable ramp-function generator1
5Continue ramp-function generator1
6Enable speed setpoint1
7Acknowledge fault — on a rising edgepulse
10Control by PLC1

That gives the two values every SINAMICS programmer knows by heart: 16#047E (everything enabled, OFF1 active — "ready, not running") and 16#047F (running). The drive's state machine insists on seeing 16#047E first: after power-up, or after a fault, the drive sits in switching on inhibited and will not leave it until the ON bit has been 0. A PLC that sends 16#047F from its first scan gets a drive that never starts and a support call about "the drive ignores the start".

The acknowledge bit causes the other classic. The drive acts on its rising edge. If the PLC copies an HMI button to bit 7 and the HMI's button object is configured "set bit" instead of "set bit while pressed", bit 7 stays at 1 forever, the first fault is acknowledged, and the second one never can be. The ack must drop again, and on a fast task it must also stay up long enough for the drive to see it: a one-scan pulse on a 1 ms task can fall between two 4 ms PROFINET update cycles. A request that is held for a bounded time — long enough to be seen, short enough that a stuck button cannot block the next fault — handles both.

When a word crosses the fieldbus

Inside one controller, a WORD is a WORD. The moment it travels, a second question appears: in which order do its two bytes go onto the wire, and in which order does the receiver put them back? That is endianness, and the fieldbuses disagree.

WhereByte order of a 16-bit value
S7 CPUs, PROFINET and PROFIBUS process databig-endian (high byte first)
Modbus registersbig-endian within one register
x86 and most ARM CPUs (TwinCAT, CODESYS runtimes)little-endian
EtherCAT, CIP / EtherNet/IPlittle-endian

Most of the time the stack hides this from you: a ControlLogix reading an EtherNet/IP drive, or a TwinCAT IPC reading EtherCAT terminals, sees words in its own order. The cases where it leaks are the ones where the bytes are handed to you raw: a PROFINET device mapped as an array of bytes on a little-endian runtime, a TCP socket to a scanner or a weighing terminal, a gateway configured byte-wise. Then 16#047F arrives as 16#7F04, and a drive that should run reports a fault. Swapping the two bytes of a word is a rotation by eight places — the same ROL that drove the stepper. TwinCAT's Tc2_Utilities library also has named helpers for it, such as BE16_TO_HOST.

The bigger trap is 32-bit values over Modbus. Modbus defines 16-bit registers and nothing more, so a 32-bit count or a REAL spans two registers, and which one holds the high half is up to the device manufacturer. Tools such as Kepware or Modbus Poll offer "word swap" and "first word low" options precisely because both orders are common. The only reliable source is the device manual, checked against a known value on the bench: a flowmeter totaliser that reads 100,000 litres should not show 2,258,632,705.

PROGRAM Main
VAR
    RegA     : WORD := 1;       (* input: holding register 40101 *)
    RegB     : WORD := 34464;   (* input: holding register 40102 (16#86A0) *)
    WordSwap : BOOL;            (* input: TRUE = device sends the low word first *)
    HighW    : WORD;
    LowW     : WORD;
    Combined : DWORD;
    Total    : DINT;            (* litres *)
    SwappedA : WORD;            (* RegA with its bytes exchanged *)
    Unswapped : WORD;
END_VAR

IF WordSwap THEN
    HighW := RegB;
    LowW  := RegA;
ELSE
    HighW := RegA;
    LowW  := RegB;
END_IF;

(* Widen first, then shift: a WORD shifted left 16 places is zero *)
Combined := WORD_TO_DWORD(HighW);
Combined := SHL(Combined, 16) OR WORD_TO_DWORD(LowW);
Total    := DWORD_TO_DINT(Combined);

(* Byte swap inside one register: rotate by 8 *)
SwappedA  := ROL(RegA, 8);
Unswapped := ROL(SwappedA, 8);

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

With the default registers Total reads 100,000 — 16#0001_86A0. Switch WordSwap on and it reads −2,036,334,591: the same two registers in the other order, reinterpreted as a signed 32-bit number. On site you would see that on the SCADA trend as a totaliser that jumps to a huge or negative value the first time the low register rolls over — which is why a word-order bug can hide for weeks and then appear the day the count passes 65,535. SwappedA shows the byte swap: register 1 becomes 256.

Two details in the code are deliberate. The registers are widened to DWORD before the shift, in a line of their own: SHL on a WORD by 16 places would push every bit out of the top and leave zero, which is the commonest way to get "the high register is always ignored". And the conversion to DINT happens last, once the bit pattern is complete. Converting a REAL from two registers is the same job with one more step — the 32 bits are reinterpreted rather than converted, which needs a vendor's overlay or union mechanism; chapter 27 covers unions and how each platform does it. The protocols themselves — register maps, function codes, PROFINET telegrams — are taught in the protocols area of the Learn hub.

From bits in a word to data across a program

You can now treat a bit string as what it is: sixteen or thirty-two independent flags that happen to share an address. You can write them in hex without counting on your fingers, read and write single bits, test any or all of a group with one masked comparison, move fields with SHL and SHR, cycle patterns with ROL and ROR, and build the words a drive, a valve terminal or a SCADA system expects — with low-active bits inverted once and named for what they mean.

Every one of those words has a writer and a reader, and so far both have lived in the same program. On a real machine they rarely do. The drive word is written by the communication stack, read by a motion task running every 2 ms, and displayed by an HMI task running every 100 ms. The next chapter is about data shared that way: global variables, how programs are split across tasks with different cycle times, and what happens to a two-word value when a fast task interrupts a slow one halfway through writing it.

Practise this chapter