Overview

At One-Way Automation we develop OPC UA Modbus Server, which connects Modbus devices to OPC UA clients. Much of its testing can be done with software alone, using Modbus slave simulators and virtual COM port pairs. But virtual ports behave like perfect wires: no real UART timing, no RS232 to RS485 conversion, no half-duplex direction switching and no USB adapter drivers with their own quirks. To cover those cases too, we built this setup with real converters and real cables, so the server can be tested the way it runs in the field: over genuine RS232 and RS485 links.

This bench setup turns a laptop's two USB ports into an RS232 port and an RS485 port that talk to each other. Use it to test serial and Modbus RTU software, such as master/slave simulators, gateways and protocol drivers, without any field device.

Data written to the RS232 port goes out as RS232 and is converted to RS485, then arrives on the RS485 port. Data flows the other way just as well.

Note: the same converters also connect the laptop to real devices, on either port type. Plug the Benfei adapter into an RS232 device, or wire the DTech 5019 to an RS485 bus. The Benfei and the DT-9000 together also let an RS232 port talk to RS485 devices. So once the software has passed on the loopback, the same kit tests it against real Modbus RTU devices.

What you need

#DeviceManufacturer / modelChipRole
1USB to RS232 converterBenfeiProlific PL2303GTRS232 port (COM5 in this setup)
2RS232 to RS485 converterDTech DT-9000none (passive, port-powered)Converts between RS232 and RS485
3USB to RS485 converterDTech 5019 (2nd)Silicon Labs CP2102NRS485 port (COM6 in this setup)

Cabling:

  • RS232 link: no cable. The Benfei's male DB9 plugs straight into the DT-9000's female DB9.
  • RS485 link: two DB9 to terminal block adapters (one comes with each DTech converter) plus three wires.

Windows assigns the COM port numbers, so yours may differ from COM5 and COM6 used throughout this post.

Setup overview

Laptop (Windows 11) 2 USB ports USB-1 → COM5 USB-2 → COM6 USB cable USB cable Benfei USB → RS232 PL2303GT COM5 · RS232 DB9 male → female DTech DT-9000 RS232 → RS485 port-powered T/R+ T/R- GND VCC +5V T/R+ T/R- RXD+ RXD- GND T/R+ T/R- GND not connected not connected DTech 5019 (2nd) USB → RS485 CP2102N COM6 · RS485 RS232 · plugged in directly RS485 · DB9 → terminal block adapters + 3 wires (2-wire, half-duplex) RS485 wires: red = T/R+ · blue = T/R- · black = GND · grey terminals = not connected
The complete chain: laptop USB-1 → Benfei → DT-9000 → terminal block adapters → DTech 5019 → laptop USB-2.

Wiring

RS232 link: Benfei to DT-9000

The Benfei's male DB9 plugs straight into the DT-9000's female DB9. No cable, gender changer or wires are needed. Tighten the two thumb screws so the connector can't work loose.

When the connectors are plugged together, each pin meets the hole with the same number. The numbers are also usually moulded, very small, into the plastic next to the pins.

Benfei pinDT-9000 pinSignalUsed for
33TXDData from the laptop into the DT-9000
22RXDData from the DT-9000 back to the laptop
55GNDSignal ground
44DTRPowers the DT-9000
77RTSPowers the DT-9000

Pins 1, 6, 8 and 9 are connected too, because all nine pins mate, but they aren't used.

  • The DT-9000 is port-powered: it has no power supply of its own and takes its power from the DTR and RTS lines (pins 4 and 7). DTR and RTS must be enabled in the software that opens the RS232 port. The test script below does this. At the tested baud rates (up to 115200) it needs no external power. If it ever does, supply 5 V to the VCC terminal of its RS485 adapter.
  • Don't use a null-modem adapter in this link. It swaps pins 2 and 3, and then no data gets through.

RS485 link: DT-9000 to DTech 5019

The RS485 ends of the DT-9000 and the 5019 are both male DB9s, so they aren't connected to each other directly. Instead, each one has a DB9 to terminal block adapter plugged on. It's also sold as a "DB9 breakout" or "DB9 screw-terminal adapter", and one comes with each DTech converter. The adapter brings the RS485 signals out to labelled screw terminals (Phoenix-type terminal blocks), and ordinary wires run between the terminals of the two adapters.

This is 2-wire half-duplex. Only three wires are used, from terminal to terminal, like to like:

DT-9000 adapter terminalWire colourDTech 5019 adapter terminal
T/R+ (A, D+)redT/R+
T/R- (B, D-)blueT/R-
GNDblackGND
  • The DT-9000's terminal block has 4 terminals: T/R+, T/R-, GND and VCC (+5 V). VCC is an optional external 5 V power input. It's left empty here because the DT-9000 is powered through the DTR and RTS lines on its RS232 side. If you ever need external power, this is where the 5 V goes.
  • Every other terminal stays empty. That includes the 5019's RXD+ and RXD-, which are for 4-wire RS422/RS485.
  • Tighten each screw on the bare copper, not on the wire's insulation, and tug each wire gently to check it's held.
  • If no data gets through, swap T/R+ and T/R-. Manufacturers don't agree on which letter (A or B) means +, and swapping them does no harm.
  • No termination resistor is needed. The cable is short (much shorter than 10 m), so reflections don't matter. Add 120 Ω across T/R+ and T/R- only if you lengthen the cable or see CRC or framing errors.
  • The GND wire is optional here. Both converters already share the laptop's USB ground. Connect it anyway as good practice. In real installations where the devices have separate power supplies, it is required.
  • Direction switching is automatic: both DTech devices switch between sending and receiving by themselves, so the software never has to toggle RTS.

Drivers (Windows 11)

AdapterDriverWhere to get it
Benfei (PL2303GT)ProlificInstalled automatically by Windows Update
DTech 5019 (CP2102N)Silicon Labs CP210x Universal Windows Driver, v11.6.0.420silabs.com: USB to UART bridge VCP drivers

Known issue: after the DTech 5019 is plugged in, it may show up in Device Manager as "CP2102N USB to UART Bridge Controller" with a yellow exclamation mark (error code 28: drivers not installed). The adapter doesn't need any setup. Windows just has no driver for it, and the driver on DTech's download page may be for a different chip.

To fix it:

  1. Download and extract the Silicon Labs CP210x Universal Windows Driver.
  2. In Device Manager, right-click the device → Update driver → Browse my computer → select the extracted folder. Alternatively, in an admin terminal, run pnputil /add-driver <folder>\silabser.inf /install.
  3. The device should then appear under Ports (COM & LPT) as "Silicon Labs CP210x USB to UART Bridge (COM6)".

To check the ports from PowerShell:

Get-PnpDevice -PresentOnly -Class Ports | Format-Table Status, FriendlyName

Serial port settings

  • Use the same settings on both ports, for example 9600 8N1 or 115200 8N1.
  • Turn flow control off. Only TX, RX and GND cross the RS485 link, so the RTS/CTS and DTR/DSR handshake lines have no effect on the other side.
  • The link is half-duplex, so only one side may send at a time.
  • Data sent on one port does not come back on the same port (no echo was observed).

Testing

1. Raw loopback

Send a string on one port and read it back on the other:

DirectionSentReceivedEcho
COM5 → COM6HELLO_FROM_RS232HELLO_FROM_RS232none
COM6 → COM5HELLO_FROM_RS485HELLO_FROM_RS485none

For a manual check, open both ports in two terminal programs (PuTTY, RealTerm or Termite) with the same settings and type into either one.

2. Modbus RTU test script

The script modbus_test.py runs a Modbus RTU slave on one port (in a background thread) and a master on the other. It then swaps them, so both the RS232 and RS485 sides are tested as master. It contains its own small Modbus RTU implementation, so the only dependency is pyserial:

pip install pyserial

Usage:

python modbus_test.py                                   # both directions, 9600 8N1, 100 stress cycles
python modbus_test.py --baud 115200 --cycles 1000
python modbus_test.py --baud 19200 --parity E
python modbus_test.py --direction 232-master            # or 485-master
python modbus_test.py --rs232 COM5 --rs485 COM6 --unit 17 --timeout 0.5
OptionDefaultMeaning
--rs232COM5RS232-side port
--rs485COM6RS485-side port
--baud9600Baud rate
--parityNParity: N, E or O. Data bits are 8 and stop bits are 1.
--unit1Slave unit ID
--timeout1.0Master response timeout, in seconds
--cycles100Number of random write/read-back stress cycles
--directionbothboth, 232-master or 485-master

The script exits with code 0 if every test passes and 1 otherwise. If the slave doesn't answer the first request at all, it reports "no response from slave at all" and skips the remaining tests, so a broken link fails in about 2 s instead of timing out on every request.

For a quick check after rewiring, run python modbus_test.py --baud 115200 --cycles 20 --timeout 0.3. It takes a few seconds.

What it tests:

TestWhat it checks
Function code 06: write one registerMaster writes, slave echoes the request back
Function code 03: read it backValue matches
Function code 16: write 10 registersRandom values are accepted
Function code 03: read back 10 registersValues match
Function code 03: read 125 registersLargest standard response frame (255 bytes)
Function code 05: write coil ONCoil write
Function code 01: read coilsThe written coil is set and the neighbouring coil is not
Error response 02Reading an address that doesn't exist returns "illegal data address"
Error response 01Sending an unsupported function code returns "illegal function"
Wrong unit IDThe slave stays silent and the master times out
StressN random write/read-back cycles; reports errors and average/maximum round-trip time
Show the full script: modbus_test.py
#!/usr/bin/env python3
"""Modbus RTU loopback test between two local serial ports.

Runs a Modbus RTU slave on one port (background thread) and a master on the
other, exercises the common function codes and verifies the results.
By default the test runs twice: RS232 side as master, then RS485 side as master.

Bench wiring:
    COM5 = RS232  (Benfei USB-RS232 -> Dtech RS232->RS485 converter)
    COM6 = RS485  (DTech 5019 USB-RS485, CP2102N)

Requires only pyserial:  pip install pyserial

Examples:
    python modbus_test.py
    python modbus_test.py --baud 19200 --parity E --cycles 500
    python modbus_test.py --direction 232-master
"""
import argparse
import random
import struct
import sys
import threading
import time

import serial


# ---------------------------------------------------------------- framing

def crc16(data: bytes) -> int:
    crc = 0xFFFF
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return crc


def add_crc(frame: bytes) -> bytes:
    return frame + struct.pack("<H", crc16(frame))


def crc_ok(frame: bytes) -> bool:
    return len(frame) >= 4 and crc16(frame[:-2]) == struct.unpack("<H", frame[-2:])[0]


def read_exact(ser, n, deadline):
    buf = bytearray()
    while len(buf) < n and time.perf_counter() < deadline:
        buf += ser.read(n - len(buf))
    return bytes(buf)


def read_until_idle(ser, deadline):
    buf = bytearray()
    while time.perf_counter() < deadline:
        chunk = ser.read(256)
        if not chunk:
            break
        buf += chunk
    return bytes(buf)


def open_port(name, baud, parity):
    ser = serial.Serial(name, baud, bytesize=8, parity=parity, stopbits=1, timeout=0.05)
    ser.dtr = True  # DTR/RTS may power a port-powered RS232->RS485 converter
    ser.rts = True
    time.sleep(0.2)
    ser.reset_input_buffer()
    return ser


class ModbusException(Exception):
    NAMES = {1: "ILLEGAL FUNCTION", 2: "ILLEGAL DATA ADDRESS", 3: "ILLEGAL DATA VALUE"}

    def __init__(self, code):
        super().__init__(f"exception {code} ({self.NAMES.get(code, '?')})")
        self.code = code


# ---------------------------------------------------------------- slave

class Slave(threading.Thread):
    def __init__(self, ser, unit, size=200):
        super().__init__(daemon=True)
        self.ser, self.unit = ser, unit
        self.regs = [0] * size
        self.coils = [False] * size
        self.stop_event = threading.Event()
        self.crc_errors = 0

    def run(self):
        while not self.stop_event.is_set():
            first = self.ser.read(1)
            if not first:
                continue
            deadline = time.perf_counter() + 0.5
            frame = first + read_exact(self.ser, 1, deadline)
            if len(frame) < 2:
                continue
            fc = frame[1]
            if fc in (1, 2, 3, 4, 5, 6):
                frame += read_exact(self.ser, 6, deadline)
            elif fc in (15, 16):
                frame += read_exact(self.ser, 5, deadline)
                if len(frame) == 7:
                    frame += read_exact(self.ser, frame[6] + 2, deadline)
            else:
                frame += read_until_idle(self.ser, deadline)
            if not crc_ok(frame):
                self.crc_errors += 1
                self.ser.reset_input_buffer()
                continue
            if frame[0] != self.unit:
                continue  # addressed to another unit: stay silent
            self.ser.write(add_crc(bytes([self.unit]) + self.handle(frame[1:-2])))

    def _check(self, table, addr, qty):
        if qty < 1 or addr + qty > len(table):
            raise ModbusException(2)

    def handle(self, pdu):
        fc, data = pdu[0], pdu[1:]
        try:
            if fc == 1:
                addr, qty = struct.unpack(">HH", data[:4])
                self._check(self.coils, addr, qty)
                out = bytearray((qty + 7) // 8)
                for i, bit in enumerate(self.coils[addr:addr + qty]):
                    if bit:
                        out[i // 8] |= 1 << (i % 8)
                return bytes([fc, len(out)]) + out
            if fc == 3:
                addr, qty = struct.unpack(">HH", data[:4])
                if qty > 125:
                    raise ModbusException(3)
                self._check(self.regs, addr, qty)
                return bytes([fc, qty * 2]) + struct.pack(f">{qty}H", *self.regs[addr:addr + qty])
            if fc == 5:
                addr, val = struct.unpack(">HH", data[:4])
                if val not in (0xFF00, 0x0000):
                    raise ModbusException(3)
                self._check(self.coils, addr, 1)
                self.coils[addr] = val == 0xFF00
                return pdu
            if fc == 6:
                addr, val = struct.unpack(">HH", data[:4])
                self._check(self.regs, addr, 1)
                self.regs[addr] = val
                return pdu
            if fc == 16:
                addr, qty, count = struct.unpack(">HHB", data[:5])
                if count != qty * 2:
                    raise ModbusException(3)
                self._check(self.regs, addr, qty)
                self.regs[addr:addr + qty] = struct.unpack(f">{qty}H", data[5:5 + count])
                return pdu[:5]
            raise ModbusException(1)
        except ModbusException as e:
            return bytes([fc | 0x80, e.code])


# ---------------------------------------------------------------- master

class Master:
    def __init__(self, ser, unit, timeout):
        self.ser, self.unit, self.timeout = ser, unit, timeout
        self.last_rtt = 0.0

    def request(self, pdu, unit=None):
        unit = self.unit if unit is None else unit
        self.ser.reset_input_buffer()
        start = time.perf_counter()
        self.ser.write(add_crc(bytes([unit]) + pdu))
        deadline = start + self.timeout
        frame = read_exact(self.ser, 2, deadline)
        if len(frame) < 2:
            raise TimeoutError("no response")
        fc = frame[1]
        if fc & 0x80:
            frame += read_exact(self.ser, 3, deadline)
        elif fc in (1, 2, 3, 4):
            frame += read_exact(self.ser, 1, deadline)
            if len(frame) == 3:
                frame += read_exact(self.ser, frame[2] + 2, deadline)
        else:
            frame += read_exact(self.ser, 6, deadline)
        self.last_rtt = time.perf_counter() - start
        if not crc_ok(frame):
            raise IOError(f"bad/short response: {frame.hex(' ')}")
        if frame[0] != unit:
            raise IOError(f"response from unit {frame[0]}, expected {unit}")
        if fc & 0x80:
            raise ModbusException(frame[2])
        return frame[1:-2]

    def read_holding(self, addr, qty):
        resp = self.request(struct.pack(">BHH", 3, addr, qty))
        return list(struct.unpack(f">{qty}H", resp[2:2 + qty * 2]))

    def write_register(self, addr, val):
        return self.request(struct.pack(">BHH", 6, addr, val))

    def write_registers(self, addr, values):
        pdu = struct.pack(f">BHHB{len(values)}H", 16, addr, len(values), len(values) * 2, *values)
        return self.request(pdu)

    def read_coils(self, addr, qty):
        resp = self.request(struct.pack(">BHH", 1, addr, qty))
        return [bool(resp[2 + i // 8] >> (i % 8) & 1) for i in range(qty)]

    def write_coil(self, addr, on):
        return self.request(struct.pack(">BHH", 5, addr, 0xFF00 if on else 0))


# ---------------------------------------------------------------- tests

def expect_exception(fn, code):
    try:
        fn()
    except ModbusException as e:
        assert e.code == code, f"got exception {e.code}, expected {code}"
        return
    raise AssertionError(f"no exception, expected {code}")


def expect_timeout(fn):
    try:
        fn()
    except TimeoutError:
        return
    raise AssertionError("got a response, expected silence")


def run_tests(m: Master, cycles):
    vals = [random.randint(0, 0xFFFF) for _ in range(10)]
    tests = [
        ("FC06 write single register",
         lambda: m.write_register(0, 0x1234)),
        ("FC03 read back single register",
         lambda: (lambda r: None if r == [0x1234] else _fail(r))(m.read_holding(0, 1))),
        ("FC16 write 10 registers",
         lambda: m.write_registers(10, vals)),
        ("FC03 read back 10 registers",
         lambda: (lambda r: None if r == vals else _fail(r))(m.read_holding(10, 10))),
        ("FC03 read 125 registers (max frame)",
         lambda: (lambda r: None if len(r) == 125 else _fail(len(r)))(m.read_holding(0, 125))),
        ("FC05 write coil ON",
         lambda: m.write_coil(3, True)),
        ("FC01 read coils",
         lambda: (lambda r: None if r[3] and not r[2] else _fail(r))(m.read_coils(0, 8))),
        ("Exception 02: illegal address",
         lambda: expect_exception(lambda: m.read_holding(500, 1), 2)),
        ("Exception 01: illegal function",
         lambda: expect_exception(lambda: m.request(bytes([0x41])), 1)),
        ("Wrong unit id -> no response",
         lambda: expect_timeout(lambda: m.request(struct.pack(">BHH", 3, 0, 1), unit=m.unit + 1))),
    ]

    # fail fast: if the slave doesn't answer at all, the remaining tests would only time out
    try:
        m.read_holding(0, 1)
    except TimeoutError:
        print("  FAIL  no response from slave at all - check wiring/power; remaining tests skipped")
        return 0, len(tests) + (1 if cycles else 0)

    passed = 0
    for name, fn in tests:
        try:
            start = time.perf_counter()
            fn()
            print(f"  PASS  {name:<40} {(time.perf_counter() - start) * 1000:7.1f} ms")
            passed += 1
        except Exception as e:
            print(f"  FAIL  {name:<40} {e}")

    # stress: random write/read-back cycles
    errors, rtts = 0, []
    for _ in range(cycles):
        addr = random.randint(0, 190)
        data = [random.randint(0, 0xFFFF) for _ in range(random.randint(1, 10))]
        try:
            m.write_registers(addr, data)
            rtts.append(m.last_rtt)
            if m.read_holding(addr, len(data)) != data:
                raise AssertionError("mismatch")
            rtts.append(m.last_rtt)
        except Exception:
            errors += 1
    if cycles:
        ok = errors == 0
        passed += ok
        avg = sum(rtts) / len(rtts) * 1000 if rtts else 0
        mx = max(rtts) * 1000 if rtts else 0
        print(f"  {'PASS' if ok else 'FAIL'}  {cycles} write/read-back cycles, {errors} errors"
              f"   rtt avg {avg:.1f} ms, max {mx:.1f} ms")
    return passed, len(tests) + (1 if cycles else 0)


def _fail(got):
    raise AssertionError(f"unexpected value: {got}")


def run_direction(label, master_port, slave_port, args):
    print(f"\n=== {label}: master on {master_port}, slave (unit {args.unit}) on {slave_port} ===")
    ms = open_port(master_port, args.baud, args.parity)
    ss = open_port(slave_port, args.baud, args.parity)
    slave = Slave(ss, args.unit)
    slave.start()
    try:
        passed, total = run_tests(Master(ms, args.unit, args.timeout), args.cycles)
    finally:
        slave.stop_event.set()
        slave.join(1)
        ms.close()
        ss.close()
    if slave.crc_errors:
        print(f"  slave saw {slave.crc_errors} frame(s) with bad CRC")
    print(f"  --> {passed}/{total} passed")
    return passed == total


def main():
    p = argparse.ArgumentParser(description="Modbus RTU loopback test between two serial ports")
    p.add_argument("--rs232", default="COM5", help="RS232-side port (default COM5)")
    p.add_argument("--rs485", default="COM6", help="RS485-side port (default COM6)")
    p.add_argument("--baud", type=int, default=9600)
    p.add_argument("--parity", choices="NEO", default="N")
    p.add_argument("--unit", type=int, default=1, help="slave unit id (default 1)")
    p.add_argument("--timeout", type=float, default=1.0, help="response timeout, s")
    p.add_argument("--cycles", type=int, default=100, help="stress write/read cycles")
    p.add_argument("--direction", choices=["both", "232-master", "485-master"], default="both")
    args = p.parse_args()

    print(f"Modbus RTU loopback test, {args.baud} 8{args.parity}1")
    ok = True
    if args.direction in ("both", "232-master"):
        ok &= run_direction("RS232 master -> RS485 slave", args.rs232, args.rs485, args)
    if args.direction in ("both", "485-master"):
        ok &= run_direction("RS485 master -> RS232 slave", args.rs485, args.rs232, args)
    print("\nRESULT:", "ALL PASSED" if ok else "FAILURES")
    sys.exit(0 if ok else 1)


if __name__ == "__main__":
    main()

Results

Both directions, all tests passed with the setup as described above. The full run at both baud rates takes about 40 s.

9600 8N1 (100 cycles)115200 8N1 (1000 cycles)
Single-register write or read~15 ms~4 ms
Write/read 10 registers~31–47 ms~6 ms
Read 125 registers (255-byte response)~280 ms~27 ms
Stress round trip, average / maximum31 / 47 ms5.0 / 6.7 ms
Errors00

Notes:

  • At 115200, the ~4 ms minimum round trip is mostly USB delay. A short request and response only take about 1.4 ms to send at that speed; the rest comes from how the PL2303GT and CP2102N chips batch data.
  • The unsupported-function test takes ~104 ms because of how the test slave works. For an unknown function code it waits for 100 ms of silence to be sure the frame has ended before replying.
  • The wrong-unit-ID test always takes the full timeout (~1 s), which is expected.
  • The DT-9000 ran on port power alone at 115200 with no errors.

Troubleshooting

SymptomLikely cause and fix
Yellow exclamation mark on "CP2102N USB to UART Bridge Controller"The CP210x driver is missing. Install the Silicon Labs driver (see Drivers).
Data arrives on the RS485 port but nothing comes back to the RS232 port (every Modbus request gets no response)Seen once while setting up this bench, and gone after reconnecting. Make sure the Benfei is pushed fully into the DT-9000 and its screws are tight, check that DTR and RTS are on (they power the DT-9000), and check that the RS485 wires are firmly clamped.
Nothing received in either directionT/R+ and T/R- are swapped (swap them), a null-modem adapter is in the RS232 link, or an RS485 wire has come loose from its terminal
Intermittent errors or lost bytesA loose connection. Tighten the DB9 thumb screws and the terminal screws.
Works at low baud rates but fails at high onesThe DT-9000 is short of power. Make sure DTR and RTS are on, or supply 5 V to the VCC terminal of the DT-9000 adapter (and connect the supply ground to its GND). Also check the cable length and add 120 Ω termination if needed.
Random 0x00/0xFF bytes arrive while the line is idleThe bus lacks bias resistors. Enable the converter's bias/termination switch, or add ~680 Ω from T/R+ to +5 V and from T/R- to GND.
Your own transmitted data comes back on the sending portThe converter echoes what it sends. Have the software discard it. (Not observed with this setup.)
"Access denied" when opening a portAnother program (a terminal or simulator) already has the port open