Reading Modbus RTU over an RS485 Gateway Without Phantom Values
Multiple devices communicate on a single RS485 bus. If reply validation is incomplete, a frame coming back from another device can be read as a value from the device I queried, creating phantom data that looks real — this is the main trap when working with Modbus on a shared bus.
I needed to collect readings from several meters and sensors connected to a single RS485 bus, then pull them in for storage and alerts. These devices speak Modbus RTU and connect to the network through a serial-to-Ethernet gateway (such as Waveshare/ZLAN). Once I started reading them, I got phantom values, incorrect readings, and devices that did not actually exist showing up. I was puzzled for quite a while before I understood what was happening, so I am sharing what I learned.
The first thing to understand: transparent mode ≠ Modbus-TCP
When these gateways are set to transparent mode (passing raw data through), they use RTU-over-TCP, not Modbus-TCP. They are different protocols.
Has an MBAP header
A 7-byte header at the start, no CRC (TCP handles integrity) — standard libraries build the frames automatically
Raw RTU frames inside TCP
No MBAP; I have to assemble the RTU frames myself, including the trailing CRC16. Sending requests with a Modbus-TCP client will not work
Modbus RTU uses CRC-16 with polynomial 0xA001 (appended to the frame in little-endian order). I have to calculate it myself and append it.
def crc16_modbus(data: bytes) -> bytes:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if (crc & 1) else crc >> 1
return bytes([crc & 0xFF, (crc >> 8) & 0xFF]) # little-endian
def read_frame(slave, func, reg, count):
body = bytes([slave, func]) + reg.to_bytes(2, 'big') + count.to_bytes(2, 'big')
return body + crc16_modbus(body)
The big trap: validate every reply or get phantom devices
An RS485 bus is multi-drop — multiple devices share the same pair of wires. When I query slave 1, if the timing is unlucky, I may read back a frame left on the bus by another slave. Trusting the bytes immediately without checking means taking a reading from the wrong device and treating it as a reading from the one I queried.
1) The first byte (slave address) must match the device I queried 2) The function code must match 3) The byte-count must be reasonable 4) The CRC must be valid — if any check fails = discard the frame, then resync. Never use it
def parse_reply(resp, want_slave, want_func):
if len(resp) < 5: return None
if resp[0] != want_slave: return None # ผิดตัว -> ทิ้ง
if resp[1] != want_func: return None # ผิด function -> ทิ้ง
if crc16_modbus(resp[:-2]) != resp[-2:]: return None # CRC ไม่ผ่าน -> ทิ้ง
n = resp[2]
return resp[3:3+n] # payload จริง
Do not query two slow slaves back to back
Another issue I ran into: some devices respond slowly. If I poll two slow devices back to back in the same cycle, they compete for bus time and take turns getting delayed (one succeeds while the other drops out, then they switch). My approach is to spread the load by staggering the polls — querying each slave in a different cycle based on its index.
# ยิง slave ตัวที่ i เฉพาะรอบที่ (cycle - 1 - offset) หาร every ลงตัว
def should_poll(cycle, offset, every):
return (cycle - 1 - offset) % every == 0
Data integrity — do not guess or carry old readings forward
If a read fails in any cycle, record a blank (gap). Do not fill it with the latest reading, because using that data to calculate accumulated energy or plot a graph introduces subtle errors that are hard to track down
And do not assume that "1 sample = 1 second" is fixed — if some cycles are missed, the actual interval between readings varies. I need to store the actual timestamp of every reading. When integrating to calculate energy, use the actual time difference, not the sample count.
The sequence I follow when connecting new equipment
- Set the gateway to transparent + note that it is RTU-over-TCP (not Modbus-TCP)
- Read one slave at a time; get CRC + address checks passing first
- Add all 4 validation checks to every reply
- Stagger polling for slow devices
- Read failed = gap; store the actual timestamp of every reading
To sum up, the hard part of Modbus on a shared bus is not the protocol itself, but "trust" — even a quiet RS485 bus can have frames from other devices mixed in. Without every check on every reply, I can get data that looks real but belongs to the wrong device, which is even more dangerous than getting no reading at all. For anyone doing this kind of IoT/industrial work, make validate-every-reply a hard rule. If I have missed anything in the explanation or made a typo, I will come back and correct it later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first