Skip to main content

Building an Industrial Modbus RTU Slave Simulator on ESP32-S3

Introduction
#

When you’re developing a Modbus master (a router, SCADA system, or a desktop tool like Modbus Poll) you eventually need something on the other end of the wire that behaves like a real industrial device — not just a register dump, but something with alarms that latch, a process that drifts and responds to commands, and read-only vs. read-write access rules that actually get enforced.

ESP-MB-Simulator is an ESP32-S3 firmware project that does exactly that: a fully data-driven Modbus RTU slave with 200 simulated points across coils, discrete inputs, input registers, and holding registers, designed to stand in for a commercial Modbus controller while testing NowData routers, SCADA systems, and Modbus master tools.

GitHub Repository: github.com/iam-vivekus/esp-modbus-simulator


Architecture
#

flowchart TB
    subgraph MasterSide [Modbus Master]
        ND[NowData Router]
        MP[Modbus Poll / QModMaster]
    end

    subgraph HW [Hardware]
        RS485[MAX485 / MAX3485]
        ESP[ESP32-S3]
    end

    subgraph FW [Firmware Modules]
        UART[uart_rs485]
        MB[modbus_slave]
        REG[register_map]
        SIM[simulation_engine]
        ALM[alarm_engine]
        CFG[configuration]
        LOG[logger]
        STATS[statistics]
    end

    ND --> RS485
    MP --> RS485
    RS485 <--> UART
    UART --> MB
    MB --> REG
    SIM --> REG
    ALM --> REG
    CFG --> REG
    MB --> LOG
    MB --> STATS
    SIM --> ALM

The design goal was: the register map is the only thing you touch to simulate a new device. Adding a device profile means editing tables in register_map.c — the Modbus protocol handling, alarm engine, and simulation loop stay untouched.


Memory Map (200 Points)
#

PDU offsets are 0-based internally in firmware storage, but the driver JSON and Modbus tooling use documentation addresses, same as a real PLC:

TypeAddress rangeRORW
Coils00001–0005000001–0002500026–00050
Discrete10001–10050all
Input30001–30050all
Holding40001–4005040001–4002540026–40050

Every point is a row in a static descriptor table:

typedef struct {
    const char *name;           /**< Human-readable identifier. */
    mb_sim_obj_type_t obj_type; /**< Coil, discrete, input or holding. */
    uint16_t modbus_addr;       /**< Documentation address (e.g. 40001, 00001). */
    mb_sim_access_t access;     /**< Read-only or read-write. */
    bool signed_value;          /**< Signed interpretation for master drivers. */
    float scale;                /**< Engineering scale factor. */
    const char *unit;           /**< Engineering unit string. */
    const char *description;    /**< Point description. */
    mb_sim_bind_t bind;         /**< Simulation/alarm binding. */
    mb_sim_alarm_id_t alarm_id; /**< Associated alarm when applicable. */
} mb_sim_point_desc_t;

The bind field is what ties a register to the simulation engine (e.g. MB_SIM_BIND_TEMPERATURE) or to an alarm (e.g. MB_SIM_ALARM_HIGH_TEMP) without any hardcoded addresses scattered through the codebase — the simulation and alarm engines just search the table for a binding and write wherever it shows up, whether that’s an input register, a holding register mirror, or both.


Modbus Slave Layer
#

The Modbus protocol handling is built on Espressif’s esp-modbus component (mbcontroller.h), with two callback hooks wired in for coils/discrete bits and a background FreeRTOS task that watches for read/write events:

mb_err_enum_t mbc_reg_coils_slave_cb(mb_base_t *inst, uint8_t *reg_buffer, uint16_t address,
                                     uint16_t n_coils, mb_reg_mode_enum_t mode)
{
    if (mode == MB_REG_READ) {
        return mb_sim_bits_slave_read(reg_buffer, address, n_coils, false);
    }
    return mb_sim_bits_slave_write(reg_buffer, address, n_coils, false);
}

Every bit write is checked against the descriptor table’s access field before it’s applied:

if (!mb_sim_register_map_is_write_allowed(obj_type, protocol_addr, 1)) {
    mb_sim_stats_record_exception();
    return MB_ENOREG;
}

Writes to read-only points return Modbus exception 0x02 Illegal Data Address — the same behavior you’d get from a real PLC, which matters when you’re testing that a router correctly handles rejected writes instead of assuming everything succeeds.

A background event task logs every request (function code, address, quantity, processing time) into a ring buffer via logger.c, and tracks per-FC counters and packets-per-second in statistics.c — both surfaced back onto holding/input registers so a master can poll the simulator’s own health.

Supported Function Codes
#

FCDescriptionSupport
01Read CoilsYes
02Read Discrete InputsYes
03Read Holding RegistersYes
04Read Input RegistersYes
05Write Single CoilYes (RW coils only)
06Write Single Holding RegisterYes (RW holding only)
15Write Multiple CoilsYes (RW coils only)
16Write Multiple Holding RegistersYes (RW holding only)

Simulation Engine
#

A FreeRTOS task ticks on a configurable interval and drifts ~30 process variables — temperature, pressure, voltage, current, motor RPM, tank level, battery voltage — using a small xorshift-style PRNG seeded from esp_random():

static uint32_t sim_rand(void)
{
    s_state.rng_state = s_state.rng_state * 1664525UL + 1013904223UL;
    return s_state.rng_state;
}

Values respond to commands instead of drifting in isolation. For example, motor current and RPM depend on whether coil motor_enable (offset 25) is set:

const bool motor_on = mb_sim_bit_get(storage->coils, 25);
const float load = motor_on ? 650.0f : 50.0f;
s_state.current = mb_sim_lerp(load, 0.0f, 1000.0f, c_min, c_max);
...
s_state.motor_rpm = motor_on ? 1450.0f + sim_randf(-10.0f, 10.0f) : 0.0f;

Each simulated value is written to storage through sim_write_reg(), which looks up every point bound to that variable (it can be an input register and a holding register mirror) and writes both in one call — the table drives the fan-out, not a hardcoded list of addresses:

static void sim_write_reg(mb_sim_bind_t bind, uint16_t value)
{
    mb_sim_storage_t *storage = mb_sim_register_map_storage();
    size_t count = 0;
    const mb_sim_point_desc_t *table = mb_sim_register_map_input_regs(&count);

    for (size_t i = 0; i < count; i++) {
        if (table[i].bind == bind) {
            storage->input_regs[i] = value;
        }
    }
    // ... repeats for holding_regs
}

At the end of every tick, the engine calls mb_sim_alarm_evaluate() so alarms react to the freshly-simulated values in the same cycle.


Alarm Engine
#

Alarms are latched coils backed by a static table of 23 alarm definitions, each with a coil offset, a bitmask, and a severity (WARNING, ALARM, CRITICAL):

static mb_sim_alarm_entry_t s_alarms[] = {
    { MB_SIM_ALARM_ACTIVE,        0,  1UL << 0,  MB_SIM_SEV_ALARM,    false },
    { MB_SIM_ALARM_WARNING,       1,  1UL << 1,  MB_SIM_SEV_WARNING,  false },
    { MB_SIM_ALARM_CRITICAL,      2,  1UL << 2,  MB_SIM_SEV_CRITICAL, false },
    { MB_SIM_ALARM_HIGH_TEMP,     3,  1UL << 3,  MB_SIM_SEV_ALARM,    false },
    { MB_SIM_ALARM_LOW_TEMP,      4,  1UL << 4,  MB_SIM_SEV_ALARM,    false },
    // ...
};

mb_sim_alarm_evaluate() reads the current process values and thresholds from the (user-writable) configuration block and drives each alarm coil:

mb_sim_alarm_set_active(MB_SIM_ALARM_HIGH_TEMP, temp > cfg->temp_alarm_high, MB_SIM_SEV_ALARM);
mb_sim_alarm_set_active(MB_SIM_ALARM_LOW_TEMP, temp < cfg->temp_alarm_low, MB_SIM_SEV_ALARM);
mb_sim_alarm_set_active(MB_SIM_ALARM_ESTOP, estop, MB_SIM_SEV_CRITICAL);
mb_sim_alarm_set_active(MB_SIM_ALARM_FAN_FAIL, fan_on && !fan_fb, MB_SIM_SEV_ALARM);

Three “roll-up” coils — alarm_active, warning_active, critical_active — are recalculated from the combined alarm/warning bitmasks on every change, and every coil transition is logged:

ESP_LOGI(TAG, "Alarm coil %05u (%s) -> %s", doc_addr, name, active ? "TRUE" : "FALSE");

There’s also a built-in test pulse: writing 2 to holding register 40041 (AlZ) forces the high-temperature alarm TRUE for ~10 seconds and then auto-clears — handy for demoing alarm behavior to a router without waiting for the simulated process to actually drift out of range.


NowData Driver JSON Generation
#

Since the whole point of this simulator is to test a Modbus master, the register map needs to be provisioned on that master as a driver definition. Rather than hand-maintain a JSON file that can drift out of sync with the firmware’s register map, driver/gen_driver.py generates it directly from the same coil/register tables used in register_map.c:

coils_ro = [
    ("AlA", 0, False, "Any alarm active"),
    ("WnA", 1, False, "Any warning active"),
    ("CrA", 2, False, "Any critical alarm active"),
    ("HtA", 3, False, "High temperature alarm"),
    ...
]

coils_rw = [
    ("MtE", 25, True, "Motor run enable"),
    ("FnE", 26, True, "Fan enable"),
    ("AkA", 32, True, "Alarm acknowledge command"),
    ("ArZ", 33, True, "Alarm reset command"),
    ...
]

Names use short 7-segment display labels (HtA, MtE) since that’s what fits on the router’s own display — the full text lives in description. A companion script, validate_driver.py, lints the generated JSON before it gets provisioned onto a router, and the router itself routes each coil to a different MQTT topic based on its writeable flag:

Coil rangewriteableRouter MQTT path
00001–00023false.../metrics/{name}
00026–00050true.../settings/{name}
00001–00023 (alarm).../alarms/{name} on state change

If alarm coils start showing up on .../settings/ instead of .../metrics/ and .../alarms/, that’s the signal the router’s driver JSON is stale and needs re-provisioning.


UART / RS485 Wiring
#

ESP32-S3                MAX485/MAX3485              RS485 Bus
 GPIO17 (TX) -------- DI
 GPIO18 (RX) -------- RO
 GPIO16 (RTS) ------- DE/~RE
                        A ------------------------ A (+)
                        B ------------------------ B (-)
 GND ----------------- GND
  • Default: 9600 8E1, slave ID 1, UART2
  • Twisted-pair cable for A/B, with 120 Ω termination at each bus end
  • ~RE and DE tied together for automatic direction control via UART RTS — the firmware toggles RTS around each transmit/receive turnaround

Testing With Real Modbus Tools
#

Modbus Poll / QModMaster
#

Connection: RTU, COM port, 9600 8N1, Slave ID 1
Read:  FC03 address 0 quantity 10 (holding), FC04 address 0 quantity 10 (input)
Write: FC05 coil 25 (motor_enable), FC06 holding 25 (simulation_enable = 1)

FC01 coil read (alarm verification)
#

python3 tools/fc01_test.py /dev/cu.usbserial-XXXX --addr 4 --parity E

Reads coil 00004 (HtA — high temperature alarm). After forcing the test pulse or letting temperature drift high, this flips TRUE, then FALSE again once cleared.


Extending for a New Device
#

Because the whole system is table-driven, simulating a different commercial device profile — the repo already ships an Eliwell IDPlus 974 refrigeration controller profile alongside the generic simulator — only requires:

  1. Editing the tables in main/registers/register_map.c.
  2. Adding simulation bindings in simulation_engine.c if the new device needs process variables the generic profile doesn’t have.
  3. Regenerating the NowData JSON with python3 driver/gen_driver.py.

No changes to modbus_slave.c’s protocol handling are required — it only ever talks in terms of object type + protocol address + access rights, never in terms of a specific device.


Conclusion
#

Testing a Modbus master against a hand-rolled register dump only gets you so far — it doesn’t tell you whether your router handles a rejected write correctly, whether it reacts to an alarm transition in real time, or whether its driver JSON actually matches what’s on the wire. Building a simulator that enforces read-only/read-write rules, latches real alarms, and drifts a believable process turns those into things you can actually exercise and watch happen, on a $10 ESP32-S3 board instead of a real (and expensive) industrial controller.

Key takeaways:

  • Data-driven register tables mean the protocol layer, alarm engine, and simulation loop never need to know about a specific device — only the table does
  • Generating the driver JSON from the same tables as the firmware keeps the router’s understanding of the device and the device itself from drifting apart
  • Enforcing RO/RW access and Modbus exceptions makes the simulator behave like a real PLC instead of a register dump that accepts anything

The full source code is available at github.com/iam-vivekus/esp-modbus-simulator.

Vivek US
Author
Vivek US
A Tech Enthusiast