Chapter 178: Modbus RTU Slave Implementation

Chapter Objectives

After completing this chapter, you will be able to:

  • Understand the role and responsibilities of a Modbus RTU Slave.
  • Configure the ESP-IDF freemodbus component for Slave mode operation.
  • Initialize and start the Modbus Slave stack on an ESP32.
  • Define and manage the data areas (coils, registers) exposed by the ESP32 slave.
  • Implement callback functions to handle read/write requests from a Modbus Master.
  • Formulate appropriate responses, including data and exception responses.
  • Develop a practical Modbus RTU Slave application on an ESP32.

Introduction

Following our exploration of the Modbus RTU Master in the previous chapter, we now turn our attention to the other essential half of the Modbus pairing: the Slave. A Modbus Slave (or server, in Modbus TCP terminology) is a device that responds to requests initiated by a Modbus Master. It holds data in various register types (coils, discrete inputs, input registers, holding registers) and makes this data available to the master. It can also perform actions, like changing the state of a coil, upon the master’s command.

Implementing a Modbus Slave on an ESP32 enables your device to act as a sensor node, an actuator controller, or any peripheral device that needs to be monitored or controlled by a central system (the Master, which could be a PLC, SCADA software, or another microcontroller). This chapter will provide a comprehensive guide to developing a Modbus RTU Slave application on ESP32 platforms using the ESP-IDF freemodbus component.

Theory

Role of the Modbus Slave

The Modbus Slave is a passive entity in the Modbus communication model. Its primary functions are:

  1. Listening for Requests: The Slave continuously listens on the Modbus network for messages addressed to its unique Slave ID.
  2. Address Recognition: When a message is received, the Slave checks if the Slave Address field in the message matches its own configured address. If not, it ignores the message (unless it’s a broadcast address 0, which specific slaves might process for certain functions without responding).
  3. Request Processing: If the address matches, the Slave parses the request to determine:
    • The Function Code (e.g., Read Holding Registers, Write Single Coil).
    • The Data Address (e.g., starting register number).
    • The Quantity of data items (e.g., number of registers to read/write).
    • Any Data Values provided by the Master (for write operations).
  4. Data Access: Based on the request, the Slave accesses its internal data storage (coils, discrete inputs, input registers, holding registers).
  5. Response Formulation:
    • Normal Response: If the request is valid and can be processed, the Slave constructs a response frame containing its Slave Address, the original Function Code, the requested data (for read operations) or an echo of the request (for write operations), and a CRC.
    • Exception Response: If the Slave cannot process the request due to an error (e.g., illegal function code, invalid data address, invalid data value), it constructs an exception response. This includes its Slave Address, the original Function Code with the most significant bit set, an exception code indicating the error type, and a CRC.
  6. Transmission: The Slave transmits the normal or exception response frame back to the Master.
graph TD
    subgraph "Modbus Slave Device"
        direction LR
        A[Listen for Frame];
        A --> B{Frame Received};
        B --> C{CRC Check};
        C -- Invalid CRC --> A;
        C -- Valid CRC --> D{Address Match?};
        D -- No --> A;
        D -- Yes --> E{Parse Request};
        E --> F{Is Function<br>Supported?};
        F -- No --> G["Formulate<br>Exception Response<br>(Code 01: Illegal Function)"];
        F -- Yes --> H{Is Data Address<br>Valid?};
        H -- No --> I["Formulate<br>Exception Response<br>(Code 02: Illegal Data Address)"];
        H -- Yes --> J{"Is Data Value<br>Valid (for Writes)?"};
        J -- No --> K["Formulate<br>Exception Response<br>(Code 03: Illegal Data Value)"];
        J -- Yes --> L["Access Data Storage<br>(Read/Write)"];
        L --> M{Operation<br>Successful?};
        M -- No --> N["Formulate<br>Exception Response<br>(Code 04: Slave Device Failure)"];
        M -- Yes --> O[Formulate<br>Normal Response];
        G --> P[Transmit Response];
        I --> P;
        K --> P;
        N --> P;
        O --> P;
        P --> A;
    end

    subgraph "External Modbus Master"
        Master_Req[Master sends<br>Request Frame] --> A;
        P --> Master_Resp[Master receives<br>Response Frame];
    end

    classDef primary fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef success fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46;
    classDef decision fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef check fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;

    class A,Master_Req,Master_Resp primary;
    class B,C,D,F,H,J,M decision;
    class E,L,G,I,K,N,O process;
    class P success;

Slave Data Storage and Management

A Modbus Slave maintains four primary types of data tables, as discussed in Chapter 176:

  • Coils (Discrete Outputs): Read/Write single bits.
  • Discrete Inputs (Status Inputs): Read-Only single bits.
  • Input Registers: Read-Only 16-bit words.
  • Holding Registers: Read/Write 16-bit words.

When implementing a slave, you need to decide:

  • Which of these data types your device will support.
  • The range of addresses for each type.
  • How this data will be stored in the ESP32’s memory (e.g., arrays, structures).
  • How this data will be updated (e.g., by sensor readings, application logic) and how master writes will affect it.

Handling Master Requests

The core of a slave implementation involves a loop or event-driven mechanism that:

  1. Receives a request.
  2. Validates the request (CRC, Slave ID, supported function code, valid address range, valid data values for writes).
  3. Performs the requested action:
    • For read requests (Read Coils, Read Discrete Inputs, Read Holding Registers, Read Input Registers): Retrieve the data from the slave’s internal storage.
    • For write requests (Write Single Coil, Write Single Holding Register, Write Multiple Coils, Write Multiple Holding Registers): Update the slave’s internal storage with the data provided by the master.
  4. Sends the response.
flowchart TD
    A["Start: Request for <i>Read Holding Registers</i> Received"] --> B{Validate Request};
    subgraph B [Validation Checks]
        direction LR
        B1[Check Slave ID] --> B2[Check CRC] --> B3[Check Function Code] --> B4[Check Register Range];
    end
    B -- All OK --> C{Calculate Memory Access};
    C --> D["start_addr = Request Start Address<br>num_regs = Request Quantity"];
    D --> E["array_index = start_addr - HOLDING_REG_START_ADDR"];
    D --> F["bytes_to_read = num_regs * 2"];
    E --> G{"Is (array_index + num_regs) > HOLDING_REG_COUNT?"};
    G -- Yes --> H["Formulate Exception<br>(Illegal Data Address)"];
    G -- No --> I[Loop: For each requested register];
    subgraph I [Data Retrieval from Application Array]
        direction TB
        I1["reg_value = holding_registers[array_index]"] --> I2["Copy reg_value to Response Buffer"] --> I3["array_index++"];
    end
    I --> J{All registers copied?};
    J -- No --> I;
    J -- Yes --> K[Build Final Response Frame];
    subgraph K [Response Assembly]
        direction TB
        K1["Slave ID"] --> K2["Function Code"] --> K3["Byte Count"] --> K4["Register Data..."] --> K5["Calculate and Append CRC"];
    end
    K --> L[Transmit Response to Master];
    H --> L;
    L --> Z[End];

    classDef primary fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef success fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46;
    classDef decision fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef check fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;

    class A,Z primary;
    class L success;
    class B,G,J decision;
    class C,D,E,F,H,I,K process;
    class B4,H check;

Using ESP-IDF freemodbus for Slave Mode

The ESP-IDF freemodbus component (also referred to as esp_modbus_slave for slave-specific APIs) provides the necessary tools to create a Modbus Slave. Key aspects include:

  • Initialization: Functions to initialize the Modbus stack in slave mode, setting the slave ID and serial communication parameters.
  • Data Area Definition: A mechanism to define the slave’s data areas (coils, discrete inputs, input registers, holding registers) that are accessible to the master. This often involves registering these areas with the Modbus stack.
  • Callback System (mb_register_area_descriptor_t): The esp_modbus_slave API uses a callback-based approach. You define “register area descriptors” (mb_register_area_descriptor_t) that specify a memory region (e.g., an array in your application) for each data type (coils, discrete inputs, etc.). The Modbus stack then directly accesses these memory regions when a master requests data or writes data.
    • The mb_register_area_descriptor_t typically includes:
      • start_offset: The starting Modbus address for this area (0-based).
      • type: The Modbus data type (e.g., MB_PARAM_HOLDINGMB_PARAM_INPUTMB_PARAM_COILMB_PARAM_DISCRETE).
      • address: A pointer to the actual data storage buffer in your application’s memory.
      • size: The size of the data storage buffer in bytes.
      • access_opts (Optional): Fine-grained access control or custom handling via callbacks, though direct memory mapping is common for basic use.

The Modbus stack handles the low-level protocol details (frame parsing, CRC, timing), allowing you to focus on managing the application data.

Practical Examples with ESP32

This section details how to configure and implement a Modbus RTU Slave on an ESP32 using the esp_modbus_slave API.

1. Project Setup and Component Configuration

a. CMakeLists.txt in your main component:

Ensure freemodbus is listed as a requirement.

Plaintext
# main/CMakeLists.txt
idf_component_register(SRCS "app_main.c"
                    INCLUDE_DIRS "."
                    REQUIRES esp_event esp_log freemodbus)

b. sdkconfig.defaults (Optional but Recommended):

For a slave, you’ll need to define its Slave ID.

SDKConfig Parameter Description Example Value
CONFIG_FMB_COMM_MODE_RTU_EN Enables Modbus RTU communication mode. Must be set to ‘y’. y
CONFIG_FMB_UART_PORT_NUM Selects the UART peripheral to use for Modbus communication. 1 (for UART1)
CONFIG_FMB_UART_BAUD_RATE_xxxx Sets the communication speed. Only one baud rate option should be enabled. CONFIG_FMB_UART_BAUD_RATE_9600=y
CONFIG_FMB_UART_PARITY_xxx Sets the parity bit configuration (None, Even, or Odd). CONFIG_FMB_UART_PARITY_NONE=y
CONFIG_FMB_UART_TXD_PIN Specifies the GPIO pin number for the UART Transmit (TX) line. 17
CONFIG_FMB_UART_RXD_PIN Specifies the GPIO pin number for the UART Receive (RX) line. 16
CONFIG_FMB_UART_RTS_PIN Specifies the GPIO for Request-To-Send, used to control the DE/RE pins on an RS-485 transceiver. 18
CONFIG_MB_CONTROLLER_SLAVE_ID_SUPPORT Enables support for the device to act as a Modbus slave. y
CONFIG_MB_CONTROLLER_SLAVE_ID Sets the unique Modbus address for this slave device on the bus (1-247). 0x01 (for Slave ID 1)
Plaintext
# sdkconfig.defaults
CONFIG_FMB_COMM_MODE_RTU_EN=y
CONFIG_FMB_UART_PORT_NUM=1          # Example: UART1
CONFIG_FMB_UART_BAUD_RATE_9600=y    # Example: 9600 baud
CONFIG_FMB_UART_PARITY_NONE=y       # Example: No parity
CONFIG_FMB_UART_TXD_PIN=17          # Example: GPIO17 for TX
CONFIG_FMB_UART_RXD_PIN=16          # Example: GPIO16 for RX
CONFIG_FMB_UART_RTS_PIN=18          # Example: GPIO18 for RS485 DE/RE
CONFIG_MB_CONTROLLER_SLAVE_ID_SUPPORT=y
CONFIG_MB_CONTROLLER_SLAVE_ID=0x01  # Default Slave ID for this device
CONFIG_MB_CONTROLLER_NOTIFY_TIMEOUT_MS=1000 # Notification timeout

Run idf.py reconfigure after changes.

c. menuconfig Configuration:

Use idf.py menuconfig:

  1. Navigate to Component config —> Modbus configuration (FreeModbus) —>
    • Ensure Modbus communication mode (RTU) is selected.
    • Set UART parameters (UART port numberbaud rateparityTXD pinRXD pinRTS pin).
    • Enable Modbus controller slave ID support.
    • Set Modbus slave ID (0x00-0xF7) to your desired slave address (e.g., 1).
    • Configure Modbus controller notification timeout (ms).

2. Hardware Setup

Identical to the Master setup (Chapter 177), as the physical layer (RS-485) is the same:

  • ESP32 development board.
  • RS-485 transceiver module.
  • Connections: ESP32 TX to DI, RX to RO, RTS (configured GPIO) to DE/RE.
  • Connect A/B lines to the RS-485 bus.
  • Ensure proper bus termination if this slave is at an end of the bus.

3. Modbus Slave Implementation (app_main.c)

This example demonstrates initializing the Modbus slave and defining data areas for holding registers, input registers, coils, and discrete inputs. The slave will allow a master to read these values and write to holding registers and coils.

stateDiagram-v2
    direction TB
    [*] --> Uninitialized

    Uninitialized --> Initializing: slave_init()
    Initializing: mbc_slave_init()<br>mbc_slave_setup()<br>mbc_slave_set_descriptor()
    Initializing --> Setup_Error: Initialization Fails
    Initializing --> Ready: Initialization OK

    Ready --> Running: mbc_slave_start()
    Running: Listening for Master requests<br>Responding to requests
    Running --> Uninitialized: mbc_slave_destroy()
    Running --> Communication_Error: UART/Bus Error
    Communication_Error --> Running: Error resolved
    
    state "Error States" as Errors {
        Setup_Error
        Communication_Error
    }

    style Uninitialized fill:#FEE2E2,stroke:#DC2626,color:#991B1B
    style Initializing fill:#FEF3C7,stroke:#D97706,color:#92400E
    style Ready fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
    style Running fill:#D1FAE5,stroke:#059669,color:#065F46
    style Errors fill:#FED7D7,stroke:#9B2C2C,color:#9B2C2C
C
#include <stdio.h>
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_event.h"
#include "nvs_flash.h" // For NVS initialization

// Modbus includes
#include "esp_modbus_common.h"
#include "esp_modbus_slave.h"

static const char *TAG = "MODBUS_SLAVE_APP";

// Define Modbus communication parameters (these will be overridden by menuconfig if set there)
#define MB_PORT_NUM         CONFIG_FMB_UART_PORT_NUM    // UART port number
#define MB_SLAVE_ADDR       CONFIG_MB_CONTROLLER_SLAVE_ID // Configured Slave ID
#define MB_DEVICE_SPEED     CONFIG_FMB_UART_BAUD_RATE   // Baud rate

// Define the size of the Modbus data areas
#define HOLDING_REG_START_ADDR  0
#define HOLDING_REG_COUNT       10  // 10 Holding Registers (16-bit each)

#define INPUT_REG_START_ADDR    0
#define INPUT_REG_COUNT         5   // 5 Input Registers (16-bit each)

#define COIL_START_ADDR         0
#define COIL_COUNT              8   // 8 Coils (1-bit each)

#define DISCRETE_INPUT_START_ADDR 0
#define DISCRETE_INPUT_COUNT    4   // 4 Discrete Inputs (1-bit each)

// Data storage for Modbus registers/coils
// These arrays will be directly accessed by the Modbus stack
static uint16_t holding_registers[HOLDING_REG_COUNT];
static uint16_t input_registers[INPUT_REG_COUNT];
// For coils and discrete inputs, FreeModbus expects them packed into bytes.
// Each bit represents one coil/discrete input.
// (COIL_COUNT + 7) / 8 calculates bytes needed.
static uint8_t coils[(COIL_COUNT + 7) / 8];
static uint8_t discrete_inputs[(DISCRETE_INPUT_COUNT + 7) / 8];


// Initialize some default values for our slave's data areas
static void initialize_slave_data(void)
{
    ESP_LOGI(TAG, "Initializing slave data areas...");
    for (int i = 0; i < HOLDING_REG_COUNT; i++) {
        holding_registers[i] = (i + 1) * 10; // Example: 10, 20, 30...
    }
    for (int i = 0; i < INPUT_REG_COUNT; i++) {
        input_registers[i] = (i + 1) * 100; // Example: 100, 200, 300...
    }

    // Initialize coils (all off)
    for (int i = 0; i < (COIL_COUNT + 7) / 8; i++) {
        coils[i] = 0x00;
    }
    // Example: Set coil 0 ON (bit 0 of coils[0])
    // coils[0] |= (1 << 0);

    // Initialize discrete inputs (example pattern)
    for (int i = 0; i < (DISCRETE_INPUT_COUNT + 7) / 8; i++) {
        discrete_inputs[i] = 0x00;
    }
    // Example: Set discrete input 0 and 2 ON
    // discrete_inputs[0] |= (1 << 0); // DI 0
    // discrete_inputs[0] |= (1 << 2); // DI 2
}


// Modbus slave initialization function
static esp_err_t slave_init(void)
{
    mb_communication_info_t comm_info;
    comm_info.port = MB_PORT_NUM;
    comm_info.mode = MB_MODE_RTU;
    comm_info.slave_addr = MB_SLAVE_ADDR; // Set the slave address
    comm_info.baudrate = MB_DEVICE_SPEED;
    comm_info.parity = MB_PARITY_NONE; // Adjust as needed

    void* slave_handler = NULL;

    esp_err_t err = mbc_slave_init(MB_PORT_SERIAL_SLAVE, &slave_handler);
    MB_RETURN_ON_FALSE((slave_handler != NULL), ESP_ERR_INVALID_STATE, TAG,
                                "Modbus slave init failed.");
    MB_RETURN_ON_FALSE((err == ESP_OK), err, TAG,
                                "Modbus slave init failed, error code: 0x%x.", (int)err);

    err = mbc_slave_setup((void*)slave_handler, (void*)&comm_info);
    MB_RETURN_ON_FALSE((err == ESP_OK), err, TAG,
                                "Modbus slave setup failed, error code: 0x%x.", (int)err);

    // Define the Modbus register areas that the slave will expose
    // The Modbus stack will read from/write to these memory locations directly.
    mb_register_area_descriptor_t reg_area_descriptors[] = {
        // Holding Registers
        { .type = MB_PARAM_HOLDING, .start_offset = HOLDING_REG_START_ADDR, .address = (void*)holding_registers, .size = sizeof(holding_registers) },
        // Input Registers
        { .type = MB_PARAM_INPUT,   .start_offset = INPUT_REG_START_ADDR,   .address = (void*)input_registers,   .size = sizeof(input_registers) },
        // Coils
        { .type = MB_PARAM_COIL,    .start_offset = COIL_START_ADDR,        .address = (void*)coils,             .size = sizeof(coils) },
        // Discrete Inputs
        { .type = MB_PARAM_DISCRETE,.start_offset = DISCRETE_INPUT_START_ADDR,.address = (void*)discrete_inputs, .size = sizeof(discrete_inputs) }
    };
    uint16_t num_reg_area_descriptors = (sizeof(reg_area_descriptors) / sizeof(reg_area_descriptors[0]));

    err = mbc_slave_set_descriptor((void*)slave_handler, reg_area_descriptors, num_reg_area_descriptors);
    MB_RETURN_ON_FALSE((err == ESP_OK), err, TAG,
                                "Modbus slave set descriptor failed, error code: 0x%x.", (int)err);

    ESP_LOGI(TAG, "Modbus slave stack initialized.");

    // Start Modbus controller stack
    err = mbc_slave_start((void*)slave_handler);
    MB_RETURN_ON_FALSE((err == ESP_OK), err, TAG,
                                "Modbus slave stack start failed, error code: 0x%x.", (int)err);

    // UART driver is installed and configured by mbc_slave_start()
    ESP_LOGI(TAG, "Modbus slave stack started with Slave ID: %d.", MB_SLAVE_ADDR);
    return ESP_OK;
}

// Example task to simulate sensor updates or internal logic affecting slave data
static void simulate_sensor_updates_task(void *arg)
{
    ESP_LOGI(TAG, "Sensor simulation task started.");
    uint16_t counter = 0;
    while(1) {
        // Update an input register (e.g., a simulated sensor value)
        // This demonstrates that the application can modify the data areas
        // that the Modbus stack exposes.
        input_registers[0] = counter++;
        if (counter > 1000) counter = 0;

        // Update a discrete input (e.g., a switch status)
        // Toggle discrete input 1 (bit 1 of discrete_inputs[0])
        if ((counter % 20) == 0) { // Toggle every 10 seconds if task delay is 500ms
             discrete_inputs[0] ^= (1 << 1); // Toggle bit 1
             ESP_LOGI(TAG, "Toggled Discrete Input 1. Current DI byte 0: 0x%02X", discrete_inputs[0]);
        }

        // Print current value of a holding register that master might change
        ESP_LOGD(TAG, "Holding Register 0 (addr %d): %u", HOLDING_REG_START_ADDR, holding_registers[0]);
        // Print current value of a coil that master might change
        ESP_LOGD(TAG, "Coil 0 (addr %d) status: %s", COIL_START_ADDR, (coils[0] & (1 << 0)) ? "ON" : "OFF");


        vTaskDelay(pdMS_TO_TICKS(500)); // Simulate update every 500ms
    }
}


void app_main(void)
{
    ESP_LOGI(TAG, "Initializing ESP32 Modbus RTU Slave...");

    // Initialize NVS (needed for Wi-Fi, Bluetooth, but good practice)
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
      ESP_ERROR_CHECK(nvs_flash_erase());
      ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    // Initialize the event loop (not strictly needed for basic Modbus slave, but often used)
    ESP_ERROR_CHECK(esp_event_loop_create_default());

    // Initialize some data for the slave
    initialize_slave_data();

    // Initialize Modbus slave
    ESP_ERROR_CHECK(slave_init());

    // Create a task to simulate sensor updates or other application logic
    // that might change the slave's data.
    xTaskCreate(simulate_sensor_updates_task, "sensor_sim_task", 2048, NULL, 5, NULL);

    ESP_LOGI(TAG, "Modbus Slave Application Initialized and Running.");
    // The Modbus stack now handles incoming requests in the background.
}
sequenceDiagram
    participant App as app_main
    participant SimTask as simulate_sensor_updates_task
    participant Modbus as Modbus Slave Stack
    participant Master as Modbus Master

    App->>App: nvs_flash_init(), esp_event_loop_create_default()
    App->>App: initialize_slave_data()
    App->>Modbus: slave_init()
    Modbus->>Modbus: mbc_slave_init(), mbc_slave_setup()
    Modbus->>App: returns success
    App->>Modbus: mbc_slave_set_descriptor(...)
    App->>Modbus: mbc_slave_start()
    Modbus->>Modbus: Starts internal tasks, listens on UART
    App->>SimTask: xTaskCreate(simulate_sensor_updates_task)
    
    loop Application Logic
        SimTask->>SimTask: vTaskDelay()
        SimTask->>Modbus: Updates input_registers[0]++
        Note right of SimTask: App logic directly modifies<br>the shared data array.
        SimTask->>Modbus: Toggles a bit in discrete_inputs
    end

    loop Master Communication
        Master->>Modbus: Sends "Read Input Registers" Request
        Modbus->>Modbus: Receives request, validates CRC, ID, etc.
        Modbus->>Modbus: Reads directly from input_registers array
        Modbus->>Master: Sends Response with updated values
        
        Master->>Modbus: Sends "Write Holding Register" Request
        Modbus->>Modbus: Receives request, validates
        Modbus->>Modbus: Writes new value to holding_registers array
        Modbus->>Master: Sends Write Acknowledgment Response

        SimTask->>SimTask: (in its own loop) Reads holding_registers array
        Note right of SimTask: App logic can now use the<br>value written by the Master.
    end

Important Notes on the Code:

  • Data Storage: Global static arrays (holding_registersinput_registerscoilsdiscrete_inputs) are used to store the Modbus data. The Modbus slave stack directly reads from and writes to these arrays based on master requests.
  • mb_register_area_descriptor_t: This structure is key. It maps Modbus address ranges and types to your application’s data buffers.
    • .type: Specifies MB_PARAM_HOLDINGMB_PARAM_INPUTMB_PARAM_COIL, or MB_PARAM_DISCRETE.
    • .start_offset: The 0-based Modbus address for this block.
    • .address: A pointer to your data buffer (e.g., (void*)holding_registers).
    • .size: The total size of your data buffer in bytes.
  • Coil and Discrete Input Packing: Coils and Discrete Inputs are bit-packed. coils[0] byte holds coils 0-7, coils[1] holds 8-15, etc. The freemodbus stack handles the bit manipulation.
  • Slave ID: The slave ID is configured via menuconfig (CONFIG_MB_CONTROLLER_SLAVE_ID) and used during mbc_slave_setup.
  • Application Logic: The simulate_sensor_updates_task shows how your application can independently update the data areas (e.g., input_registers) that the Modbus master reads. Similarly, when a master writes to holding_registers or coils via Modbus, your application can then read these updated values from the arrays and act upon them.
Field Type Description
.type mb_param_type_t Defines the Modbus data area type. Can be MB_PARAM_HOLDING, MB_PARAM_INPUT, MB_PARAM_COIL, or MB_PARAM_DISCRETE.
.start_offset uint16_t The first Modbus address (0-based) for this data block. For example, if this is 0, it corresponds to Modbus address 40001 for holding registers.
.address void* A pointer to the actual data buffer in the application’s memory. This is where the slave’s data is stored (e.g., an array like holding_registers).
.size uint16_t The total size of the data buffer in bytes. Crucially, this is not the number of registers. For an array of 10 uint16_t registers, size would be 10 * 2 = 20.
.access_opts void* Optional. Used for advanced scenarios like defining custom access control callbacks. For direct memory mapping, this is typically set to NULL.

4. Build, Flash, and Observe

a. Build the Project:

Bash
idf.py build

b. Flash to ESP32:

Bash
idf.py -p /dev/ttyUSB0 flash monitor

(Replace /dev/ttyUSB0 with your ESP32’s serial port).

c. Observation:

To test this Modbus Slave, you need a Modbus RTU Master device or simulator:

  • Modbus Master Simulator Software: Use software like Modbus Poll, QModMaster, or Simply Modbus on a PC. Connect your PC to the RS-485 bus using a USB-to-RS485 adapter.
  • Configuration in Master Simulator:
    • Set the correct serial port, baud rate (e.g., 9600), parity (None), data bits (8), stop bits (1).
    • Set the Slave ID to what you configured for the ESP32 (e.g., 1).
    • Read Operations:
      • Try reading Holding Registers starting at address 0, quantity 10. You should see values like 10, 20, 30…
      • Try reading Input Registers starting at address 0, quantity 5. You should see values like 100, 200… and register 0 should update due to simulate_sensor_updates_task.
      • Try reading Coils starting at address 0, quantity 8.
      • Try reading Discrete Inputs starting at address 0, quantity 4. Discrete Input 1 should toggle.
    • Write Operations:
      • Try writing a new value to Holding Register 0 (address 0). Observe in the ESP32 log (if ESP_LOGD is enabled for the tag) that the value in holding_registers[0] changes.
      • Try writing to Coil 0 (address 0) to turn it ON (0xFF00) or OFF (0x0000). Observe in the ESP32 log.

Expected Output in ESP32 Monitor:

You should see initialization logs, logs from simulate_sensor_updates_task, and potentially logs from the Modbus stack if verbose logging is enabled (though the stack is often quiet on successful operations).

Plaintext
I (MODBUS_SLAVE_APP): Initializing ESP32 Modbus RTU Slave...
I (MODBUS_SLAVE_APP): Initializing slave data areas...
I (MODBUS_SLAVE_APP): Modbus slave stack initialized.
I (MODBUS_SLAVE_APP): Modbus slave stack started with Slave ID: 1.
I (MODBUS_SLAVE_APP): Sensor simulation task started.
I (MODBUS_SLAVE_APP): Modbus Slave Application Initialized and Running.
D (MODBUS_SLAVE_APP): Holding Register 0 (addr 0): 10
D (MODBUS_SLAVE_APP): Coil 0 (addr 0) status: OFF
... (sensor task updates input registers and discrete inputs) ...

When the master writes, the data in the corresponding arrays (holding_registerscoils) will change, which your application can then use.

Variant Notes

The Modbus RTU Slave implementation using esp_modbus_slave is highly consistent across the ESP32 family.

  • ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, ESP32-H2:
    • All these variants support UART-based Modbus RTU slave operation effectively. The esp_modbus_slave API and the underlying freemodbus stack operate identically.
    • UART Pin Selection: The primary consideration is the correct configuration of UART TXD, RXD, and RTS pins in menuconfig to match your hardware connections to the RS-485 transceiver.
    • RS-485 Transceiver: An external RS-485 transceiver is always necessary.
    • Performance: All variants are more than capable of handling Modbus slave duties, responding to master requests promptly even with other application tasks running. The resource usage of the Modbus slave stack is minimal.

The main factor across variants is ensuring the chosen UART peripheral and GPIO pins are correctly configured and not conflicting with other peripherals in use.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
Incorrect Slave ID Master gets a timeout error. ESP32 slave shows no sign of receiving a request. Verify CONFIG_MB_CONTROLLER_SLAVE_ID in menuconfig matches the slave ID configured in the Master software/device.
Mismatched Serial Parameters Garbled data in logs (if any). Master reports timeout or CRC errors. No valid responses. Ensure Baud Rate, Parity, Data Bits (8), and Stop Bits (1) are identical on both the Master and the ESP32 slave configuration.
RS-485 Wiring Swap No communication at all. The RS-485 transceiver’s LEDs may not blink as expected. Swap the ‘A’ and ‘B’ lines on one end of the connection. The A line on the master should connect to the A line on the slave.
Incorrect RTS Pin for DE/RE Slave might receive data but cannot send a response, causing master timeouts. Or, the slave might not receive anything if the transceiver is stuck in transmit mode. Double-check that the GPIO pin assigned to CONFIG_FMB_UART_RTS_PIN is correctly wired to both the DE and RE pins of the RS-485 transceiver.
Wrong Descriptor Size Master requests for addresses near the end of a range fail with an “Illegal Data Address” exception. Or the slave may crash with a memory access error. The .size field must be in bytes. For uint16_t registers, size is count * 2. For bit-packed coils, size is (count + 7) / 8.
Invalid Pointer in Descriptor Slave crashes immediately on a Modbus request with Guru Meditation Error, often related to LoadProhibited or StoreProhibited. Ensure the .address pointer in mb_register_area_descriptor_t points to a valid, globally-scoped array, not a local variable that has gone out of scope.
Forgetting Bus Termination Communication is unreliable, especially on longer cables or at higher baud rates. Frequent CRC errors. Add a 120 Ohm resistor across the ‘A’ and ‘B’ lines at the two physical ends of the RS-485 bus.

Exercises

  1. Expand Holding Registers and Test:
    • Increase HOLDING_REG_COUNT to 20. Initialize all with distinct values.
    • Use a Modbus Master simulator to read all 20 holding registers. Verify all values are correct.
    • Write to several different holding registers (e.g., address 5, 10, 15) and verify the ESP32’s internal holding_registers array is updated by logging their values from simulate_sensor_updates_task or another diagnostic function.
  2. Implement Writeable Coils Control:
    • In the simulate_sensor_updates_task (or a new task), monitor the state of coils[0].
    • If coils[0] bit 0 is set by the master, turn on the ESP32’s built-in LED (if available, or an external LED). If cleared, turn it off.
    • Test by writing to Coil 0 from your Modbus Master simulator.
  3. Implement Custom Input Register Logic:
    • Modify input_registers[1] to represent a calculated value, e.g., holding_registers[0] + holding_registers[1].
    • This calculation should happen in simulate_sensor_updates_task or a similar application logic loop.
    • Use the Master to write to holding_registers[0] and holding_registers[1], then read input_registers[1] to verify the sum is correctly calculated and exposed.
  4. Slave ID Configuration Test:
    • Change the CONFIG_MB_CONTROLLER_SLAVE_ID in menuconfig to a different value (e.g., 5). Rebuild and reflash.
    • Verify that your Modbus Master can only communicate with the ESP32 slave when addressing the new Slave ID. Attempts to use the old ID should result in timeouts for the master.
  5. Exception Handling (Conceptual):
    • Although the esp_modbus_slave component handles sending standard exception responses for out-of-bounds access automatically, discuss how you would conceptually handle a request for an “Illegal Function” if you were implementing the Modbus stack from scratch. What would the slave need to check, and what would the response frame look like? (No coding needed, just a description).

Summary

  • The Modbus Slave responds to requests from a Master, providing data or performing actions.
  • ESP-IDF’s freemodbus component (esp_modbus_slave) simplifies Modbus RTU Slave development.
  • Slave configuration involves setting the Slave ID, UART parameters, and defining data areas using mb_register_area_descriptor_t.
  • These descriptors map Modbus register types and addresses to memory buffers within the ESP32 application.
  • The Modbus stack directly accesses these buffers for read/write operations based on Master requests.
  • Application logic is responsible for populating read-only data (Input Registers, Discrete Inputs) and acting upon data written by the Master (Holding Registers, Coils).
  • All ESP32 variants can effectively function as Modbus RTU Slaves, requiring an external RS-485 transceiver.

Further Reading

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top