All posts
esp32iotmodbus

ESP32 Modbus TCP and RTU: read industrial sensors step by step

· 15 min read NEW

Modbus was published in 1979. It predates the internet. It predates USB, Ethernet, and most of the people reading this. Yet if you walk into any factory, building management system, solar installation, or industrial facility today, there’s a better than even chance the sensors and controllers are still speaking Modbus. Understanding it isn’t optional for industrial IoT work — it’s table stakes.

The reason Modbus persists isn’t nostalgia. It’s that the protocol is simple, deterministic, royalty-free, and the installed base is measured in hundreds of millions of devices. Every PLC, every energy meter, every VFD, every industrial sensor you’ll encounter in the field probably has Modbus support. You need to know how to use it.

RTU vs TCP vs ASCII

Three variants of Modbus exist in the wild:

VariantTransportFrame formatWhere you’ll find it
Modbus RTURS232/RS485 (serial)Binary, compactIndustrial sensors, PLCs, energy meters, VFDs
Modbus TCPTCP/IP (Ethernet)RTU PDU wrapped in MBAP headerModern industrial equipment, Modbus gateways
Modbus ASCIIRS232/RS485 (serial)Hex-encoded, human-readableLegacy systems, rare

Modbus RTU is what you’ll encounter most often with physical sensors. Modbus TCP is what you’ll use when you have an Ethernet-connected device or a Modbus gateway that bridges RS485 to Ethernet. ASCII is rare enough that you can ignore it unless you’re specifically handed a device that requires it.

RTU and TCP share the same application layer — the same register model, the same function codes, the same data formats. The difference is purely transport: RTU is binary framing over a serial line, TCP wraps the same payload in a TCP socket on port 502 with a 6-byte MBAP header.

The register model

Modbus organizes data into four address spaces:

Address spaceAccessData typeFC to readFC to write
CoilsRead/Write1-bit (boolean)FC01FC05 (single), FC15 (multiple)
Discrete InputsRead only1-bit (boolean)FC02
Holding RegistersRead/Write16-bit unsignedFC03FC06 (single), FC16 (multiple)
Input RegistersRead only16-bit unsignedFC04

The address space is 0–65535 for each type, but most devices only use a small subset. Every device has a register map — a table that tells you which register holds which value. You cannot proceed without the register map. It’s the first thing you ask the manufacturer for.

The off-by-one trap: The Modbus specification uses 1-based addressing in documentation (register 40001 is the first holding register), but the protocol itself uses 0-based addressing (register 40001 is transmitted as address 0x0000). Most libraries handle this for you — but if you’re reading a device manual that says “read register 40003” and your library uses 0-based addressing, you request register 2 (0x0002), not 40003. Get this wrong and you’ll read the wrong value from the right register and wonder why your energy reading is nonsense.

Function codes you actually need

You can implement 95% of industrial Modbus work with these six:

FCNameWhat it does
01Read CoilsRead 1–2000 coils (bits)
02Read Discrete InputsRead 1–2000 discrete inputs (bits)
03Read Holding RegistersRead 1–125 holding registers (16-bit each) — most common
04Read Input RegistersRead 1–125 input registers (16-bit each)
06Write Single Holding RegisterWrite one 16-bit value
16Write Multiple Holding RegistersWrite up to 123 16-bit values

FC03 is what you’ll use for almost everything. Most sensor values — temperature, voltage, current, flow, pressure — live in holding registers.

RS485 wiring for RTU

Modbus RTU typically runs over RS485, a balanced differential serial standard that supports multi-drop (multiple devices on one bus) and distances up to 1200 meters.

ESP32         MAX485          RS485 Bus
────────      ──────────      ──────────────────────────────
TX ──────────► DI            A (D+) ──────────────────────── A of Device 1
RX ◄────────── RO                                          ── A of Device 2
DE_RE_PIN ──► DE                                          ┬─ ... 
              RE ◄── (tie DE and RE together)             │
              A ────────────────────────────────────────── │
              B ─────────────────────────────────────── B  │
                      120Ω termination at each end  ───────┘

The MAX485 (or MAX3485, SP485, or any equivalent) converts between TTL serial (what the ESP32 speaks) and RS485 differential signals. It has four relevant pins:

  • DI (Driver Input): connect to ESP32 TX
  • RO (Receiver Output): connect to ESP32 RX
  • DE (Driver Enable, active high): HIGH to transmit
  • RE (Receiver Enable, active low): LOW to receive

DE and RE are tied together and driven by one GPIO. HIGH = transmit mode, LOW = receive mode. This is the half-duplex direction control that every RS485 tutorial mentions but many implementations get wrong.

Termination resistors (120Ω): place one at each physical end of the bus cable. They prevent signal reflections that corrupt data, especially at higher baud rates (19200+). Many industrial devices have built-in termination that you enable with a jumper — check the manual.

Bias resistors: when no device is transmitting, the RS485 bus is floating. Floating differential pairs pick up noise and trigger phantom bytes. Add a 560Ω–1kΩ pull-up on A and pull-down on B at the master end to define the idle state. Some MAX485 breakout boards include these; many don’t.

ModbusMaster library setup

The ModbusMaster library handles RTU framing, CRC calculation, and response parsing. The part most tutorials get wrong: the pre/post transmission callbacks for direction control.

#include <Arduino.h>
#include <ModbusMaster.h>

// Hardware connections
#define RS485_RX_PIN  16
#define RS485_TX_PIN  17
#define RS485_DE_RE   4   // Direction control: HIGH=TX, LOW=RX

ModbusMaster modbus;

// Called by ModbusMaster just before it starts transmitting
void preTransmission() {
  digitalWrite(RS485_DE_RE, HIGH);  // Enable transmitter, disable receiver
  // Some MAX485 chips need a brief settling time after direction change
  // delayMicroseconds(50);  // Uncomment if you see corrupted first bytes
}

// Called by ModbusMaster after transmission is complete
void postTransmission() {
  // The last byte has been transmitted, but it may still be in the UART TX FIFO.
  // Wait for UART to finish transmitting before flipping direction.
  Serial2.flush();  // Blocks until TX FIFO is empty
  digitalWrite(RS485_DE_RE, LOW);   // Disable transmitter, enable receiver
}

void setup() {
  Serial.begin(115200);

  pinMode(RS485_DE_RE, OUTPUT);
  digitalWrite(RS485_DE_RE, LOW);  // Start in receive mode

  // RS485 serial port: 9600 baud, 8N1 (most industrial devices default)
  Serial2.begin(9600, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN);

  // Initialize ModbusMaster with slave address 1
  modbus.begin(1, Serial2);
  modbus.preTransmission(preTransmission);
  modbus.postTransmission(postTransmission);
}

The Serial2.flush() in postTransmission is critical. Without it, you flip DE/RE to receive mode while the UART is still clocking out the last byte. The MAX485 cuts transmit mid-byte, and the slave sees a garbled frame. This causes intermittent CRC errors that are nearly impossible to debug without an RS485 analyzer.

Reading 32-bit values from 16-bit registers

Many devices store float values or 32-bit integers across two consecutive 16-bit registers. This is not standardized — every manufacturer chooses their own byte and word order. Common arrangements:

NameRegister orderByte order within register
Big-endian (ABCD)High word firstMSB first
Little-endian (DCBA)Low word firstLSB first
Big-endian byte swap (BADC)High word firstLSB first
Little-endian byte swap (CDAB)Low word firstMSB first

The register map tells you which variant your device uses. To reconstruct a 32-bit float in C++:

float registersToFloat_ABCD(uint16_t high, uint16_t low) {
  uint32_t combined = ((uint32_t)high << 16) | low;
  float result;
  memcpy(&result, &combined, sizeof(float));  // Safe type-pun
  return result;
}

float registersToFloat_CDAB(uint16_t high, uint16_t low) {
  // CDAB: low word is actually the high word in the float
  uint32_t combined = ((uint32_t)low << 16) | high;
  float result;
  memcpy(&result, &combined, sizeof(float));
  return result;
}

Never use a union cast for type-punning in C++ — it’s undefined behavior. Use memcpy. The compiler will optimize it to the same thing, but it’s defined behavior.

Error handling

ModbusMaster returns a uint8_t result code from every transaction. Check it every time:

uint8_t result = modbus.readHoldingRegisters(startAddr, numRegisters);

switch (result) {
  case modbus.ku8MBSuccess:
    // Read the data from modbus.getResponseBuffer(n)
    break;
  case modbus.ku8MBResponseTimedOut:
    Serial.println("[Modbus] Timeout — check wiring and slave address");
    break;
  case modbus.ku8MBInvalidCRC:
    Serial.println("[Modbus] CRC error — check baud rate and line quality");
    break;
  case modbus.ku8MBIllegalDataAddress:
    Serial.println("[Modbus] Register address not supported by this device");
    break;
  case modbus.ku8MBIllegalFunction:
    Serial.println("[Modbus] Function code not supported by this device");
    break;
  case modbus.ku8MBSlaveDeviceFailure:
    Serial.println("[Modbus] Device reported internal failure");
    break;
  default:
    Serial.printf("[Modbus] Error 0x%02X\n", result);
    break;
}

Complete example: energy meter over RS485 to MQTT

Here’s a complete firmware that reads three-phase voltage, current, and power from a Eastron SDM630 energy meter over RS485 and publishes to MQTT. The SDM630 register map uses CDAB float format.

#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ModbusMaster.h>
#include <ArduinoJson.h>

// RS485
#define RS485_RX   16
#define RS485_TX   17
#define RS485_CTRL 4

// SDM630 registers (holding, CDAB float format, read with FC03)
// Each value is 2 registers = 1 float (32-bit)
#define REG_V1      0x0000  // Voltage Phase 1 (V)
#define REG_V2      0x0002  // Voltage Phase 2 (V)
#define REG_V3      0x0004  // Voltage Phase 3 (V)
#define REG_I1      0x0006  // Current Phase 1 (A)
#define REG_I2      0x0008  // Current Phase 2 (A)
#define REG_I3      0x000A  // Current Phase 3 (A)
#define REG_P_TOTAL 0x0034  // Total Active Power (W)

const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASS = "your-password";
const char* MQTT_HOST = "192.168.1.100";
const char* MQTT_TOPIC = "meters/panel-01/telemetry";

ModbusMaster modbus;
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);

void preTransmission()  { digitalWrite(RS485_CTRL, HIGH); }
void postTransmission() { Serial2.flush(); digitalWrite(RS485_CTRL, LOW); }

float toFloat_CDAB(uint16_t r0, uint16_t r1) {
  // SDM630 stores floats as CDAB: r0 is low word, r1 is high word
  uint32_t u = ((uint32_t)r1 << 16) | r0;
  float f;
  memcpy(&f, &u, 4);
  return f;
}

bool readMeter(float& v1, float& v2, float& v3,
               float& i1, float& i2, float& i3,
               float& pTotal) {
  // Read 14 consecutive registers starting at REG_V1 (covers V1–I3)
  uint8_t res = modbus.readHoldingRegisters(REG_V1, 12);
  if (res != modbus.ku8MBSuccess) {
    Serial.printf("[Modbus] Read voltages/currents failed: 0x%02X\n", res);
    return false;
  }

  v1 = toFloat_CDAB(modbus.getResponseBuffer(0), modbus.getResponseBuffer(1));
  v2 = toFloat_CDAB(modbus.getResponseBuffer(2), modbus.getResponseBuffer(3));
  v3 = toFloat_CDAB(modbus.getResponseBuffer(4), modbus.getResponseBuffer(5));
  i1 = toFloat_CDAB(modbus.getResponseBuffer(6), modbus.getResponseBuffer(7));
  i2 = toFloat_CDAB(modbus.getResponseBuffer(8), modbus.getResponseBuffer(9));
  i3 = toFloat_CDAB(modbus.getResponseBuffer(10), modbus.getResponseBuffer(11));

  // Read total power separately (different register block)
  vTaskDelay(pdMS_TO_TICKS(50));  // Inter-frame delay
  res = modbus.readHoldingRegisters(REG_P_TOTAL, 2);
  if (res != modbus.ku8MBSuccess) {
    Serial.printf("[Modbus] Read power failed: 0x%02X\n", res);
    return false;
  }
  pTotal = toFloat_CDAB(modbus.getResponseBuffer(0), modbus.getResponseBuffer(1));

  return true;
}

void publishData(float v1, float v2, float v3,
                 float i1, float i2, float i3, float pTotal) {
  StaticJsonDocument<512> doc;
  JsonObject phase1 = doc.createNestedObject("L1");
  phase1["voltage_V"] = serialized(String(v1, 2));
  phase1["current_A"] = serialized(String(i1, 3));

  JsonObject phase2 = doc.createNestedObject("L2");
  phase2["voltage_V"] = serialized(String(v2, 2));
  phase2["current_A"] = serialized(String(i2, 3));

  JsonObject phase3 = doc.createNestedObject("L3");
  phase3["voltage_V"] = serialized(String(v3, 2));
  phase3["current_A"] = serialized(String(i3, 3));

  doc["total_power_W"] = serialized(String(pTotal, 1));
  doc["timestamp_ms"]  = millis();

  char payload[512];
  serializeJson(doc, payload, sizeof(payload));
  mqtt.publish(MQTT_TOPIC, payload);
  Serial.printf("[MQTT] Published: %s\n", payload);
}

void meterTask(void* param) {
  while(true) {
    if (mqtt.connected()) {
      float v1, v2, v3, i1, i2, i3, pTotal;
      if (readMeter(v1, v2, v3, i1, i2, i3, pTotal)) {
        publishData(v1, v2, v3, i1, i2, i3, pTotal);
      }
    }
    vTaskDelay(pdMS_TO_TICKS(5000));  // Poll every 5 seconds — don't hammer the bus
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(RS485_CTRL, OUTPUT);
  digitalWrite(RS485_CTRL, LOW);

  Serial2.begin(9600, SERIAL_8N1, RS485_RX, RS485_TX);
  modbus.begin(1, Serial2);  // Slave address 1
  modbus.preTransmission(preTransmission);
  modbus.postTransmission(postTransmission);

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) vTaskDelay(pdMS_TO_TICKS(500));
  Serial.println("[WiFi] Connected");

  mqtt.setServer(MQTT_HOST, 1883);
  mqtt.setBufferSize(1024);
  mqtt.connect("meter-01");

  xTaskCreatePinnedToCore(meterTask, "MeterTask", 6144, NULL, 2, NULL, 1);
}

void loop() {
  if (mqtt.connected()) mqtt.loop();
  vTaskDelay(pdMS_TO_TICKS(10));
}

What’s next

Once you can read Modbus devices reliably, you’ll quickly discover that managing the polling state — which device to read, when, what to do on error, when to retry — gets complex. That complexity is a state machine problem. The state machine post covers exactly how to model polling states without the usual if/else spaghetti.

Related posts

esp32iotmqtt
Jun 19, 2026
esp32architecture
Jun 19, 2026
esp32freertos
Jun 19, 2026

Comments

Enjoyed this tutorial?

Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.