EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / Reading Modbus RTU over an RS485 Gateway Without Phantom Values
Linux

Reading Modbus RTU over an RS485 Gateway Without Phantom Values

by Ton-Or ·15 Jul 2026·9 min read
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.

Modbus-TCP

Has an MBAP header

A 7-byte header at the start, no CRC (TCP handles integrity) — standard libraries build the frames automatically

RTU-over-TCP (transparent)

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.

Modbus-TCPMBAP header7 bytes at startPDUfunction + dataNo CRCRTU-over-TCP (transparent mode)slave1 bytefunc1 bytereg2 bytescount2 bytesCRC162 bytes little-endianCalculate it myselfNo MBAP header; assemble the frame and append the CRC myself
A Modbus-TCP client builds the upper frame automatically, but a transparent gateway needs the lower frame, which I must assemble and calculate the CRC for myself
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.

Check every reply before trusting itreplyfrom gatewayslave matchqueried devicefunc matchas sentbyte-countreasonableCRC validrecalc, compareUse payloadresp[3:3+n]Any check failsDiscard frame, then resyncNever use itShared bus: a lingering frame from another slave may be read as the queried slave's reply
Use the payload only after all four checks pass. If any check fails, discard the frame. Readings that fail validation are where phantom devices come from
Check 4 things before trusting a reply

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

Read failed = leave a gap, do not carry 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

  1. Set the gateway to transparent + note that it is RTU-over-TCP (not Modbus-TCP)
  2. Read one slave at a time; get CRC + address checks passing first
  3. Add all 4 validation checks to every reply
  4. Stagger polling for slow devices
  5. 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

→ Continue on the board

Comments 0

No comments yet — be the first