ESP32 Modbus TCP and RTU: read industrial sensors step by step
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:
| Variant | Transport | Frame format | Where you’ll find it |
|---|---|---|---|
| Modbus RTU | RS232/RS485 (serial) | Binary, compact | Industrial sensors, PLCs, energy meters, VFDs |
| Modbus TCP | TCP/IP (Ethernet) | RTU PDU wrapped in MBAP header | Modern industrial equipment, Modbus gateways |
| Modbus ASCII | RS232/RS485 (serial) | Hex-encoded, human-readable | Legacy 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 space | Access | Data type | FC to read | FC to write |
|---|---|---|---|---|
| Coils | Read/Write | 1-bit (boolean) | FC01 | FC05 (single), FC15 (multiple) |
| Discrete Inputs | Read only | 1-bit (boolean) | FC02 | — |
| Holding Registers | Read/Write | 16-bit unsigned | FC03 | FC06 (single), FC16 (multiple) |
| Input Registers | Read only | 16-bit unsigned | FC04 | — |
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:
| FC | Name | What it does |
|---|---|---|
| 01 | Read Coils | Read 1–2000 coils (bits) |
| 02 | Read Discrete Inputs | Read 1–2000 discrete inputs (bits) |
| 03 | Read Holding Registers | Read 1–125 holding registers (16-bit each) — most common |
| 04 | Read Input Registers | Read 1–125 input registers (16-bit each) |
| 06 | Write Single Holding Register | Write one 16-bit value |
| 16 | Write Multiple Holding Registers | Write 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:
| Name | Register order | Byte order within register |
|---|---|---|
| Big-endian (ABCD) | High word first | MSB first |
| Little-endian (DCBA) | Low word first | LSB first |
| Big-endian byte swap (BADC) | High word first | LSB first |
| Little-endian byte swap (CDAB) | Low word first | MSB 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.
Comments
Enjoyed this tutorial?
Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.