
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.


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

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

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

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

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.

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

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.

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 -Variablen eingefügt, damit der Inhalt des Empfangspuffers volatile nicht durch Compiler-Optimierungen entfernt wird.rx

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.

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.

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.

Weiterführende Links
- Installation ESP-IDF unter Windows 10/11
- Erstes Programmbeispiel, die blinkende LED
- Visual Studio Code und ESP-IDF Extension
- ESP32 – Das Entwicklungsboard
- ESP32 JTAG-Debugger
- FreeRTOS am ESP32 – Grundlagen, Tasks und Scheduling
- ESP-IDF Menuconfig – Überblick und wichtigste Optionen
Blog Artikel
- FreeRTOS: Normale Tasks vs. Echtzeit-Tasks
- FreeRTOS: Superloop vs. Tasks
- ESP32 Modbus Grundlagen – Praxis mit Relais und Sensor