LearnSCADA

Part III · Chapter 14

Reading and Writing Real-World Data

A register holds sixteen bits, a whole number from 0 to 65535, and nothing more. Real devices still report negative temperatures, decimal voltages, energy totals in the millions, and true floating-point readings. This chapter shows how they fit, and how to decode each with short, version-proof Python.

By the end of this chapter you can

  • Convert a raw register to a signed value and apply a scale factor, in the right order.
  • Combine two registers into a 32-bit integer or an IEEE 754 float with struct.
  • Recognize a word-order mistake on sight, and fix it.
  • Write a wide value back safely, as one indivisible unit.

This is one of the most practically important chapters in the course. Misreading a multi-register value is the single most common way to get a clean Modbus connection that still returns nonsense.

The register's native limits

On the wire, a register is an unsigned integer from 0 to 65535: sixteen bits, no sign, no decimal point. Everything else in this chapter is a convention layered on those bits by agreement between the device and you, recorded in the register map. As Chapter 2 stressed, Modbus assigns no meaning. It carries the sixteen bits faithfully, and the interpretation is ours to apply.

A register drawn as sixteen bit cells, bit 15 on the left to bit 0 on the right. First it is read as unsigned, spanning 0 to 65535. Then bit 15 is highlighted as the sign bit, with weight minus 32768, and the range becomes minus 32768 to 32767. 15141312 111098 7654 3210 ± +32768 −32768 16384 2 1 … bit weights (each bit is worth twice the one to its right) … 03276865535 −32768032767 Read as unsigned: 0 to 65535 Read as signed (two's complement): −32768 to 32767
Figure 14.1 · A single register is sixteen bits. Read as unsigned it spans 0 to 65535; read as signed it spans −32768 to 32767. The bits are identical; only the interpretation differs.

Signed integers

When a device must report a value that can go negative (a temperature below zero, power flowing in either direction), it uses the same sixteen bits but reads them as a two's-complement signed number, range −32768 to 32767. The top bit signals the sign, and a raw 0xFFFF, which is 65535 unsigned, means −1 signed.

The register map tells you which interpretation a register uses. pymodbus hands you the raw unsigned number; you convert when the map says the value is signed:

PYTHON — interpret a register as signed
def to_signed16(v):
    return v - 65536 if v >= 32768 else v

print(to_signed16(65535))   # -> -1
print(to_signed16(65000))   # -> -536

The rule: if the unsigned value is 32768 or greater, subtract 65536; otherwise it's already correct. Forgetting this produces a classic bug: a small negative temperature shows up as a huge positive number like 65523 (really −13). A reading that should be near zero but sits just under 65535 is a dead giveaway of a missed signed conversion.

A register whose sixteen bits are all ones, hex FFFF. Read as unsigned it is 65535. Read as signed two's complement it is minus 1. A note adds that a thermometer reading 65523 is really minus 13. register = 0xFFFF 1111 1111 1111 1111 read as unsigned 65535 read as signed (two's complement) −1 Thermometer showing 65523? That is −13 read the wrong way.
Figure 14.2 · The same register, two meanings. A negative reading that shows up as a number near 65535 is the signature of a missed signed conversion.

Scaling for decimals

Registers hold whole numbers, so a device that must report 23.5 degrees can't store the decimal directly. The near-universal answer, met in Chapter 2, is scaling: the device multiplies the real value by a power of ten and stores the integer, and the register map gives the factor. A temperature with a scale of ten holds 235 for 23.5 degrees; divide by ten to recover it.

Scaling and signing often combine: a register might be a signed value scaled by ten. Then you convert to signed first, then divide. A raw 65523 becomes −13, then −1.3 degrees. Dividing first would give 6552.3, which is wrong twice over.

PYTHON — signed, then scaled
raw = 65523                    # from rr.registers[0]
temp = to_signed16(raw) / 10   # -> -1.3

Values too big for one register

Many quantities don't fit in sixteen bits: an energy meter's lifetime total, a high-resolution flow count, anything above 65535. The device spreads such a value across a consecutive pair of registers forming a 32-bit number, which stretches the range into the billions. Each register supplies sixteen bits; your job is to glue the halves back together.

Register 0 holds hex 0001, the high word, and register 1 holds hex E240, the low word. They slide together into one 32-bit value, hex 0001E240, which is 1 times 65536 plus 57920, or 123,456. register 0 · high 16 bits 0x0001 register 1 · low 16 bits 0xE240 32 bits 0x0001E240 = 1 × 65536 + 57920 = 123,456
Figure 14.3 · A 32-bit value occupies two consecutive registers, one with the high sixteen bits and one with the low. Read both and combine them to recover the full number.

Combining them sounds trivial (one half high, the other low), but it hides the nastiest trap in practical Modbus, which the next section confronts. Done right, it's a few lines with Python's struct module, which packs and unpacks bytes precisely and behaves the same whatever pymodbus version you have:

PYTHON — two registers to a 32-bit int
import struct
def regs_to_int32(regs, word_order="big"):
    if word_order == "little":
        regs = [regs[1], regs[0]]
    raw = struct.pack(">HH", regs[0], regs[1])
    return struct.unpack(">i", raw)[0]

">HH" packs the two registers as big-endian unsigned 16-bit words, four bytes in all; ">i" reads those four bytes back as one signed 32-bit integer. For an unsigned 32-bit value, use ">I" instead.

The word-order problem

Here is the trap. When a value spans two registers, the device must decide which register comes first: the high half or the low half. Within a register, Chapter 4 settled byte order: big-endian, high byte first, and that is reliable. Across two registers there's no universal agreement. This is word order (also called register order). Most devices put the high word first (big-endian word order); a stubborn minority put the low word first (little-endian word order). The two produce entirely different numbers from the very same pair of registers.

Registers 0 and 1 arrive holding hex 4370 and 8000. Assembled high word first they form hex 43708000, which as a float is 240.5, correct. Assembled low word first they form hex 80004370, about minus 2.42 times ten to the minus 41, nonsense. ON THE WIRE register 0 register 1 4370 8000 High word first (big-endian word order) Low word first (little-endian word order) 4370 8000 4370 8000 0x43708000 = 240.5 ✓ 0x80004370 ≈ −2.42 × 10⁻⁴¹ ✗ Same two registers, read as a float. Only one order gives a sensible number.
Figure 14.4 · The same two registers, two word orders. High word first, the pair is 240.5; low word first, it's a meaningless denormal. The register map, or an experiment, must tell you which a device uses.

That's why regs_to_int32 takes a word_order argument: set it to match the device. When a 32-bit value reads as plausible under one order and absurd under the other, you've found the right setting.

The lesson to carry away: a wrong-looking multi-register value is far more often a word-order mistake than a broken connection. The connection is fine; the bytes are simply assembled in the wrong sequence. When in doubt, read a register whose true value you know (a serial number, a known voltage) and try both orders until it reads correctly.

Going further: four byte orders

The book frames the problem as two word orders, which covers most devices. In the field you'll occasionally meet two more, where the bytes inside each register are swapped as well. Call the four bytes of the value A B C D, most significant first. The float 123.456 is 42 F6 E9 79:

Four bytes A, B, C, D in two registers rearrange through the four common orders. ABCD decodes to 123.456. CDAB, the word swap, decodes to about minus 1.88 times ten to the 25. BADC decodes to about minus 9.86 times ten to the 32. DCBA decodes to about 1.52 times ten to the 35. first register second register A 42 B F6 C E9 D 79 ABCD: high word first (big-endian) = 123.456 ✓ CDAB: low word first (word swap) read as ABCD = −1.88 × 10²⁵ ✗ BADC: byte swap read as ABCD = −9.86 × 10³² ✗ DCBA: fully little-endian read as ABCD = 1.52 × 10³⁵ ✗ Same four bytes, different order: only one gives the real value
Going further · The book's two word orders are ABCD and CDAB. BADC and DCBA also swap the bytes inside each register. An absurd value is the clue in every case.
OrderAlso calledRegisters on the wire for 123.456
ABCDHigh word first, big-endian word order42F6 E979
CDABLow word first, little-endian word order, word swapE979 42F6
BADCByte swapF642 79E9
DCBAFully little-endian79E9 F642

Floating-point numbers

The most expressive convention is the IEEE 754 floating-point number, which packs a sign, an exponent, and a fraction into thirty-two bits to represent values like 240.5 or 0.001 directly, with no scaling factor. A 32-bit float occupies two registers exactly like a 32-bit integer, and it suffers the same word-order question. Decoding is the same shape of code: assemble the four bytes in the right order, then ask struct to read them as a float.

PYTHON — two registers to a float
def regs_to_float(regs, word_order="big"):
    if word_order == "little":
        regs = [regs[1], regs[0]]
    raw = struct.pack(">HH", regs[0], regs[1])
    return struct.unpack(">f", raw)[0]

The only change from the integer version is the final format character: ">f" reads the four bytes as a single-precision float, where ">i" read them as a signed integer. Suppose a device reports a voltage as a float in registers 0 and 1:

PYTHON — read a float measurement
rr = client.read_holding_registers(0, count=2)
volts = regs_to_float(rr.registers)
print("Voltage: %.1f V" % volts)
Registers 4370 and 8000 give bytes 43 70 80 00, laid out as 32 bits. The sign bit is 0, meaning positive. The eight exponent bits 10000110 are 134, minus the bias 127, giving 7. The fraction with its hidden leading 1 is 1.87890625. The result is 1.87890625 times 2 to the 7th, which is 240.5. registers 4370 8000 → bytes 43 70 80 00 0 10000110 11100001 00000000 0000000 sign 0 → + exponent 10000110 = 134 − 127 = 7 fraction, with the hidden leading 1 1.11100001 (binary) = 1.87890625 +1.87890625 × 2⁷ = 240.5 No scaling factor: the float carries its own decimal point.
Figure 14.5 · Decoding a float. Two registers supply thirty-two bits; arranged in the correct word order and read as IEEE 754, they yield a real number such as 240.5 directly. struct does all of this for you.

Writing wide values back

Writing is the same process in reverse: pack your number into four bytes with struct, split them into two registers in the device's word order, and send both at once with Write Multiple Registers (function 0x10, FC16) from Chapter 8.

PYTHON — write a float as one unit
hi, lo = struct.unpack(">HH", struct.pack(">f", 240.5))
client.write_registers(0, [hi, lo])   # one FC16: both halves together

The crucial point is the single multiple-write. Writing the two registers separately, with two Write Single Register (FC06) requests, risks the device acting on a half-updated value between the two writes:

Top: 240.5 is packed into bytes 43 70 80 00, split into registers 4370 and 8000, and sent in one FC16 write; the device goes straight from 12.5 to 240.5. Bottom: a 32-bit count changes from 65535 to 65536 using two FC06 writes. After the first write the device holds 0001 FFFF, which is 131,071, a half-updated value, until the second write completes it as 65,536. One unit: pack → split → one FC16 write 240.5 → 43 70 80 00 pack(">f") → 4370 8000 unpack(">HH") FC16 ×2 device 12.5 240.5 ✓ Risky: two FC06 writes (a 32-bit count, 65535 → 65536) new: 0001 0000 FC06 reg0 = 0001 FC06 reg1 = 0000 device · reg0 reg1 0000 FFFF = 65,535 0001 FFFF = 131,071 ✗ half-updated! 0001 0000 = 65,536 ✓
Writing wide values. One FC16 carries both halves, so the device never sees a mix. With two FC06 writes there's a window where the device holds one new half and one old half: here, briefly double the intended count.

Always treat a multi-register value as one indivisible unit: read it in one request and write it in one request. The read side matters too. Two separate reads can straddle an update and pair a new high word with an old low word.

Let the library help — once you understand

Now that you know what's happening underneath, you should know that pymodbus can do this packing and unpacking for you. Recent versions provide conversion helpers, methods that turn a list of registers into an int, a float, or a string given a data type and a word order; older versions offered a payload decoder and builder for the same job. They're convenient and worth using in real code.

But the exact names differ across library versions, and, more importantly, no helper can save you from choosing the wrong word order. Understanding the struct-level mechanics is what lets you diagnose a garbled value whichever helper you reach for. That's why this course's listings use plain struct: it works the same on every version.

Register decoder

Two registers as 32 bits

Order32 bitsFLOAT32INT32UINT32

Rows marked plausible give a float of ordinary size. Try E979 / 42F6, or swap the two registers above and watch the big and little rows trade places.

One register as 16 bits

InterpretationRawScaled

Check your understanding

1. A register reads 0xFFF6. The map says it's a signed temperature in °C. What's the temperature?

0xFFF6 is 65526 unsigned. It's ≥ 32768, so subtract 65536: 65526 − 65536 = −10.

2. A register is a signed value scaled by ten, and it reads 65523. What's the real value?

Convert to signed first (65523 → −13), then divide by the factor (−13 / 10 = −1.3).

3. A voltage float that should be about 240 decodes as −2.42 × 10⁻⁴¹. The connection is fine. What should you try first?

An absurd value over a working link is almost always the bytes assembled in the wrong sequence. With the other word order, 4370 8000 reads 240.5.

4. Why write a 32-bit setpoint with one FC16 rather than two FC06 writes?

Between two FC06 writes the device holds one new half and one old half, which can be a wildly wrong value for a moment.