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.
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:
def to_signed16(v):
return v - 65536 if v >= 32768 else v
print(to_signed16(65535)) # -> -1
print(to_signed16(65000)) # -> -536The 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.
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.
raw = 65523 # from rr.registers[0]
temp = to_signed16(raw) / 10 # -> -1.3Values 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.
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:
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.
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:
| Order | Also called | Registers on the wire for 123.456 |
|---|---|---|
| ABCD | High word first, big-endian word order | 42F6 E979 |
| CDAB | Low word first, little-endian word order, word swap | E979 42F6 |
| BADC | Byte swap | F642 79E9 |
| DCBA | Fully little-endian | 79E9 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.
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:
rr = client.read_holding_registers(0, count=2)
volts = regs_to_float(rr.registers)
print("Voltage: %.1f V" % volts)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.
hi, lo = struct.unpack(">HH", struct.pack(">f", 240.5))
client.write_registers(0, [hi, lo]) # one FC16: both halves togetherThe 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:
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
| Order | 32 bits | FLOAT32 | INT32 | UINT32 |
|---|
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
| Interpretation | Raw | Scaled |
|---|
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.