Modbus RTU mit ESP32

whatsapp image 2026 04 25 at 22.11.32

Inhaltsverzeichnis

ESP32 als Modbus-Slave

Zum Testen wurde eine einfache Register Struktur erstellt, die der Slave haben soll. Diese Struktur ist im Code 1 dargestellt.

Wir definieren

  • 8 Coils für die die diskreten digitalen Ausgänge coil_reg_params_t
  • 8 Input Coils, für digitale Eingänge discrete_reg_params_t
  • 4 16-bit Input Register input_reg_params_t
  • 4 16-bit Holding Register holding_reg_params_t
//  Coils,       
//  00000
typedef struct
{
    uint8_t coils_port0;
} coil_reg_params_t;

//  Input Coils, 
//  10000
typedef struct
{
    uint8_t discrete_input0:1;
    uint8_t discrete_input1:1;
    uint8_t discrete_input2:1;
    uint8_t discrete_input3:1;
    uint8_t discrete_input4:1;
    uint8_t discrete_input5:1;
    uint8_t discrete_input6:1;
    uint8_t discrete_input7:1;
} discrete_reg_params_t;

//  Input Register, 
//  30000
typedef struct
{
    uint16_t input_data0;
    uint16_t input_data1;
    uint16_t input_data2;
    uint16_t input_data3;
} input_reg_params_t;

//  Holding Register, 
//  40000
typedef struct
{
    uint16_t holding_data0;
    uint16_t holding_data1;
    uint16_t holding_data2;
    uint16_t holding_data3;
} holding_reg_params_t;

extern holding_reg_params_t holding_reg_params;
extern input_reg_params_t input_reg_params;
extern coil_reg_params_t coil_reg_params;
extern discrete_reg_params_t discrete_reg_params;

Code 1 – Modbus-Slave Register Struktur

Zum Beschreiben der Register verwende ich eine Task und beschreibe zum Testen einfach sequentiell, wie in Code 2.

Lesen Input Coils und Input Register

Hier handelt es sich um die Input Coils und Input Register. Diese werden vom Master gelesen.

// Input Coils
// 10001
discrete_reg_params.discrete_input0 = 1;
discrete_reg_params.discrete_input1 = 0;
discrete_reg_params.discrete_input2 = 1;
discrete_reg_params.discrete_input3 = 0;
discrete_reg_params.discrete_input4 = 1;
discrete_reg_params.discrete_input5 = 0;
discrete_reg_params.discrete_input6 = 1;
discrete_reg_params.discrete_input7 = 0;

// Input Register
// 30001
input_reg_params.input_data0 = 0xFFCC;
input_reg_params.input_data1 = 0x1234;
input_reg_params.input_data2 = 0x2222;
input_reg_params.input_data3 = 0x3333;

Code 2 – Input Coils und Input Registern Werte zuweisen

Zum Testen verwende ich das Tool Simply Modbus Master (Abb. 1). Die Register wurden alle Nullbasiert definiert.

simplymodbus lesencoilundinputreg
Abb. 1 – Lesen der Input Register aus dem ESP32-Modbus-Slave

 

simplymodbus lesencoil
Abb. 2 – Lesen der Input Coils aus dem ESP32-Modbus-Slave

 

In Abb. 1 und Abb. 2, kann man sehr schön erkennen, welche Werte gelesen wurden in HEX, wie auch Dezimal. Zusätzlich ist die Konfiguration ersichtlich, in welchen Registern die Daten stehen und viele weitere Einstellungen. Das Lesen der Register aus dem Slave funktioniert somit einwandfrei.

Als nächstes können wir die Coils und die Holding Register, also die Ausgänge des Slave beschreiben.

Schreiben Coils

Zum Testen der Coils, beschreiben wir das 8-bit Register coil_reg_params_t, welches wir in Code 1 für die Coils definiert haben. Als Output verwende ich an dieser Stelle nur die vier niederwertigesten Bits und habe am ESP32 vier LEDs angeschlossen. Die Coils sind keine Register, sie werden beim ESP32 im Gegensatz zu den Input Coils aber so definiert. Die einzelnen Bits kann man dann über eine Bitsmaske ansprechen, wie in Code 3 zu sehen.

void Task2(void *pvParameters)
{
// Bitmasken
uint8_t BIT_0 = 0x01;
uint8_t BIT_1 = 0x02;   
uint8_t BIT_2 = 0x04;
uint8_t BIT_3 = 0x08;

while(1)
{
    if(coil_reg_params.coils_port0 & BIT_0) 
        gpio_set_level(LED2, true);
    else 
        gpio_set_level(LED2, false);

    if(coil_reg_params.coils_port0 & BIT_1) 
        gpio_set_level(LED3, true);
    else 
        gpio_set_level(LED3, false);

    if(coil_reg_params.coils_port0 & BIT_2) 
        gpio_set_level(LED4, true);
    else
        gpio_set_level(LED4, false);

    if(coil_reg_params.coils_port0 & BIT_3) 
        gpio_set_level(LED5, true);
    else 
        gpio_set_level(LED5, false);

... weiterer Code
}

Code 2 – Ansprechen der Coils 1 – 4

Mit Simply Modbus lässt sich das Verhalten wieder sehr gut testen und Beobachten.

Zunächst setzen wir, wie in Abb. 3 zu sehen, die Coils auf folgende Werte (In Simply Modbus heißen die Coils auch Register):

  • Coil 1 = TRUE
  • Coil 2 = FALSE
  • Coil 3 = TRUE
  • Coil 4 = FALSE
coilschreiben1
Abb. 3 – Coil 1 und Coil 3 auf TRUE

 

Dementsprechend leuchtet die erste und die dritte blaue LED (Abb. 4).

20260424 225242
Abb. 4 – LEDs an Outputs 1 und 3 leuchten

 

Im Folgenden schieben wir die Bits um einen Schritt nach rechts (Abb. 5).

  • Coil 1 = FALSE
  • Coil 2 = TRUE
  • Coil 3 = FALSE
  • Coil 4 = TRUE
coilschreiben2
Abb. 5 – Coil 2 und Coil 4 auf TRUE

 

Jetzt leuchtet die zweite und die vierte blaue LED (Abb. 6).

20260424 225256
Abb. 6 – LEDs an Outputs 2 und 4 leuchten

Schreiben Holding Register

Zum Testen des Holding Registers wird der Debugger verwendet, da dies näher an realen Anwendungsfällen ist als eine Anzeige über LEDs. So lässt sich direkt in den lokalen Variablen prüfen, ob der vom Simply Modbus Master gesendete Wert korrekt im Holding Register 0 des ESP32 Modbus-Slaves ankommt.

Wichtig:
Der Schreibzugriff vom Master muss vor dem Setzen eines Breakpoints erfolgen, da der Slave während eines Halts im Debugger keine Daten mehr empfängt.

Zusätzlich sollten keine Breakpoints in Interrupts (ISR) gesetzt werden.
Grund: Während ein Breakpoint in einer ISR aktiv ist, werden weitere Interrupts blockiert → Zeitverhalten (UART/RS485) bricht zusammen → Kommunikation schlägt fehl oder wird verfälscht.

//Holding Register
//40001
HoldingReg = holding_reg_params.holding_data0;

Code 3 – Master schreibt Wert in das Holding Register 0 des ESP32-Slave

Dafür schreibe ich mit dem Simply Modbus Master, z.B. den Wert 0x091D in das Holding Register 0 (bzw. 40000) des ESP32-Slave. Die Daten kommen einwandrei an, wie in Abb. 7 in der lokalen Variable HoldingReg zu sehen ist.

 

schreibeninholdingreg0
Abb. 7 – Schreiben des Wertes 0x091D in das Holding Register 0 des ESP32-Slave

 

Beim Debuggen kann man sehen, dass der Wert 0x091D bzw. 2333 im Holding Register 0 angekommen ist.

lokalevariableholdingreg
Abb. 8 – Wert 0x091 = 2333 im HoldingReg

ESP32 als Modbus-Master

Schreiben eines Coils

Beim Master verwende ich keine Modbus Bibliothek von ESP-IDF. Die Modbus-Frames werden – inklusive der CRC im Skript erzeugt – und per UART an den Slave gesendet. Auch die Prüfung der Antwort vom Slave wird hier manuell erledigt.

Beim Slave verwende ich die Modbus Bibliothek von ESP-IDF, da hier der Fokus auf der Registerstruktur und der Kommunikation mit dem Master liegt. Beim Master werden die Modbus-Frames hingegen manuell erzeugt und per UART gesendet. Dadurch lässt sich der Aufbau eines Modbus-RTU-Frames, inklusive CRC und Antwortprüfung, besser nachvollziehen.

Zum Ansprechen des Slaves werden folgende Funktionen verwendet:

  • Konfiguration des UART,
    static void uart_rs485_init(void)
  • zum Schreiben des Modbus Frames an den Slave, hier explizit für das Schreiben eines Coils mit Funktionscode 5,
    modbus_write_single_coil(uint8_t slave_addr, uint16_t coil_addr, bool on)
  • Erstellung der CRC,
    static uint16_t modbus_crc16(const uint8_t *buf, uint16_t len)
  • Funktionalität, z.B. das Schalten der Relais,
    static void relay_toggle(void)
  • Auswerten der Antwort des Slaves.
    static bool modbus_read_response(uint8_t *rx, size_t expected_len)

Erstellen des Modbus Frames

Die Bytes des Modbus-Frames werden über die UART-Schnittstelle seriell im Format 8N1 übertragen. Die Länge und der Aufbau des Frames unterscheidet sich je nach Funktionscode. An dieser Stelle gehe ich nur auf den Funktionscode FC05 ein, der lediglich das Schreiben eines Coils ermöglicht. Diesen Frame benötigen wir zum Ansteuern, der Relaisplatine. Das Funktionsprinzip der Relaisplatine, wird in dem Artikel ESP32 Modbus Grundlagen – Praxis mit Relais und Sensor etwas näher erläutert.

Der Modbus-Frame wird als uint8_t Array aufgebaut, da UART die Daten byteweise überträgt und Modbus RTU genau dieses Byteformat erwartet.

Der Frame besteht bei FC05 aus 8 Bytes:

  • Slave Adresse, 1 Byte
  • Funktionscode, 1 Byte
  • Coil-Adresse, 2 Byte
  • Anzahl der Register, 2 Byte (Die gelesen und geschrieben werden. Fällt bei FC05 weg.)
  • Daten, 2 Byte (0xFF00 = ON, 0x0000 = OFF)
  • CRC, 2 Byte

Zum erstellen des Frames wird folgender Code verwendet:

static bool modbus_write_single_coil(uint8_t slave_addr, uint16_t coil_addr, bool on)
{
uint8_t tx[8];
uint8_t rx[8];

tx[0] = slave_addr;                 // Slave Adress
tx[1] = 0x05;                       // Function Code
tx[2] = (coil_addr >> 8) & 0xFF;    // Adress High Byte first
tx[3] = coil_addr & 0xFF;           // Adress Lower Byte
tx[4] = on ? 0xFF : 0x00;           // Data Higher Byte First
tx[5] = 0x00;                       // Data Lower Byte

uint16_t crc = modbus_crc16(tx, 6);
tx[6] = crc & 0xFF;
tx[7] = (crc >> 8) & 0xFF;

uart_write_bytes(MB_PORT_NUM, tx, sizeof(tx));    // Hier wird direkt an den Slave gesendet. Byteweise über UART. Das uint8_t passt genau in das UART Format 8N1, genau das möchte Modbus.

Der Slave berechnet die CRC aus den Request-Daten neu und sendet den gesamten empfangenen Frame mit der neu errechneten CRC wieder zurück an den Master. Bei erfolgreicher Übertragung, muss der Response-Frame bei FC05 exakt mit dem des Request-Frames übereinstimmen.

Den gesendeten Frame kann man schön im Debugging Modus ansehen. Die Zahlen werden Dezimal angezeigt.

frame coil1 schreiben
Abb. 9 – Modbus-Master-Frame Schreibe Coil 2, an Adresse 0x01 (Da 0-basiert)

Gesendet wurde FF 05 00 01 FF 00 C8 24.

  • FF: Slave Adresse
  • 05: Funktionscode
  • 00 01: Coil Adresse
  • FF 00: ON
  • C8 24: CRC

Auch der Response-Frame scheint korrekt zu sein. Er entspricht bei FC05 dem Request-Frame des Masters. Für die Analyse im Debugger wurden zusätzliche volatile-Variablen eingefügt, damit der Inhalt des Empfangspuffers rx nicht durch Compiler-Optimierungen entfernt wird.

frame coil1 schreiben response
Abb. 10 – Response Frame

Lesen von Input-Registern

Neben dem Schreiben von Coils kann der ESP32 Modbus-Master auch Input-Register auslesen. Damit lassen sich beispielsweise Messwerte von RS485-Sensoren, wie Temperatur- oder Feuchtigkeitssensoren, zyklisch erfassen und weiterverarbeiten.

Im folgenden Beispiel werden zwei Input-Register eines Modbus-RTU Sensors gelesen und der empfangene Response-Frame ausgewertet. Das Funktionsprinzip und die Eigenschaften des Sensors, werden in dem Artikel ESP32 Modbus Grundlagen – Praxis mit Relais und Sensor etwas näher erläutert.

Da der Temperatursensor leider keinen Indikator für die Betriebsbereitschaft hat, überprüfe ich die Funktionalität erst mit dem Simply-Modbus-Master. Die Werte zeigen, dass hier alles in Ordnung ist.

screenshot 2026 05 12 212739
Abb. 11 – Sensordaten mit Simply Modbus Master

Im Folgenden werden die Frames über den ESP32-Modbus-Master eingelesen. Zum Lesen des Modbus-Slaves, müssen wir zunächst einen Request-Frame zusammenstellen. Gelesen werden zwei, also mehrere Input-Register mit dem Funktionscode FC04. Der Sensor hat zwei Register und er erlaubt es uns auch, diese gleichzeitig zu lesen.

Der Request-Frame lautet demnach: 01 04 00 01 00 02 20 0B.

  • Slave-Adresse: 01
  • Funktionscode: 04
  • Start-Adresse: 00 01
  • Anzahl der Register: 00 02
  • CRC: 20 0B

Und der Response-Frame lautet: 01 04 04 00 F7 01 C5 8B B5

  • Slave-Adresse: 01
  • Funktionscode: 04
  • Anzahl Datenbytes: 04
  • Register 1: 00 F7   => dez 247 ( /10 = Temperature)
  • Register 2: 01 C5   => dez 453 ( /10 = Humidity)
  • CRC: 8B B5

Gesendet wird der Frame so, wie auch bei der Relaiskarte.

static bool modbus_read_input_registers(uint8_t slave_addr, uint16_t start_addr, uint16_t count, uint16_t *regs)
{
    uint8_t tx[8];
    uint8_t rx[256];

    tx[0] = slave_addr;
    tx[1] = 0x04;                 // Read Input Registers
    tx[2] = start_addr >> 8;
    tx[3] = start_addr & 0xFF;
    tx[4] = count >> 8;
    tx[5] = count & 0xFF;

    uint16_t crc = modbus_crc16(tx, 6);
    tx[6] = crc & 0xFF;
    tx[7] = crc >> 8;

    uart_flush_input(MB_PORT_NUM);

    uart_write_bytes(MB_PORT_NUM, tx, 8);

    uart_wait_tx_done(MB_PORT_NUM, pdMS_TO_TICKS(100));

Für die Antwort, ebenfalls ein Byte Array, wählen wir einfach ein Array das groß genug ist für die Empfangenen Daten.

screenshot 2026 05 12 213649
Abb. 12 – Slave Response

Demnach erhalten wir: 01 04 04 00 F6 01 C8 18 B0

  • 01      – Slave-Adresse
  • 04      – Function Code
  • 04      – Anzahl Datenbytes
  • 00 F6 – Register 1
  • 01 C8 – Register 2
  • 18 B0 – CRC

Die Inhalte der Register 1 und 2 übergeben wir zwei 16-Bit Input-Registern. Die Temperatur und Luftfeutigkeit lassen sich dann in den Registern uint16_t temp_raw = regs[0]; und uint16_t hum_raw  = regs[1]; auslesen.

screenshot 2026 05 12 214111
Abb. 13 – Sensordaten in lokalen Variablen

Weiterführende Links

Blog Artikel

Projekte