Modbus-RTU-Master in C#

modbusmastercsharp

Inhaltsverzeichnis

C# als Modbus-Master mit NModbus

NModbus ermöglicht eine schnelle Umsetzung eines Modbus-Masters in C#. Die Bibliothek abstrahiert den kompletten Aufbau der Modbus-RTU-Kommunikation und übernimmt Aufgaben wie Frame-Erstellung, CRC-Berechnung und das Parsen der Antworten. Dadurch lassen sich bereits mit wenigen Zeilen Code Sensoren auslesen, Register schreiben oder Relais ansteuern.

NModbus übernimmt die Umsetzung des Modbus-Protokolls. Die Koordination mehrerer gleichzeitiger Zugriffe auf dieselbe serielle Schnittstelle muss jedoch von der Anwendung selbst sichergestellt werden.

Der Einstieg eignet sich besonders gut für erste Tests, Analysewerkzeuge, Visualisierungen oder eigene Steuerungssoftware. Gleichzeitig bleibt das Verständnis der eigentlichen Modbus-Kommunikation wichtig, insbesondere bei Themen wie Registeradressierung, Exception-Codes oder Timing.

Das Auslesen des Temperatur- und Feuchtigkeitssensors aus ESP32 Modbus Grundlagen – Praxis mit Relais und Sensor, lässt sich auf sehr einfache Weise – wie im folgenden Minimalbeispiel – bewerkstelligen. Das Beipiel ist nicht vollständig, denn für das saubere Beenden des Programms muss an geeigneter Stelle der Port wieder freigegeben werden mit port.Close(). Noch sauberer erfolgt die Freigabe der Ressource über IDisposable und ein using – Statement.

Das Beispiel funktioniert so bereits zuverlässig. Für die ersten Versuche reicht das völlig aus, und kaputt machen wir damit vorerst auch nichts.

using System.IO.Ports;
using NModbus;
using NModbus.Serial;

namespace ConsoleApp1
{
    internal class Program
    {
        static void Main(string[] args)
        {
            SerialPort port = new SerialPort("COM3");
            port.BaudRate = 9600;
            port.Parity = Parity.None;
            port.StopBits = StopBits.One;
            port.DataBits = 8;

            port.Open();

            var factory = new ModbusFactory();
            var master = factory.CreateRtuMaster(port);

            while (true)
            {
                // Temperatur und Luftfeuchtigkeit auslesen
                ushort[] data = master.ReadInputRegisters(1, 1, 2);

                // Ausgabe der Werte in der Konsole
                Console.Clear();
                Console.WriteLine($"Input Register 0: {(double)data[0]/10}");
                Console.WriteLine($"Input Register 1: {(double)data[1]/10}");

                // Wartezeit von 1 Sekunde, bevor die Werte erneut ausgelesen werden
                Thread.Sleep(1000);
            }
        }
    }
}

Die Werte können dann einfach in der Console angezeigt werden (Abb. 1).

temphum
Abb. 1 – Temperatur und Luftfeuchigkeitssensor auslesen

Das schalten der Relaisplatine, die ebenfalls in ESP32 Modbus Grundlagen – Praxis mit Relais und Sensor erklärt wurde, lässt sich ebenso einfach steuern, wie im folgen Minimalcode zu sehen ist.

Hier kann man auch gleich von der Funktionalität Gebrauch machen, den Status des Coils zurückzulesen. So kann man prüfen ob ein Coil softwareseitig gesetzt ist oder nicht.

Gleichzeitig kann man hier schon eine kleine Fehlerbehandlung einbauen um zu Prüfen, ob ein Slave angeschlossen ist oder dafür zu sorgen dass das Programm nicht unerwartet abstürzt, wenn kein Slave angeschlossen ist oder während der Laufzeit vom Master getrennt wird. Auch hier muss man beim Implemetieren darauf achten, den Port wieder freizugegen.

In Abb. 4 sieht man die Meldung, wenn der Slave im Betrieb vom Master getrennt wird. Das Programm stürzt nicht unerwartet ab und läuft zusätzlich weiter, wenn der Slave wieder angeschlossen wird.

using System.IO.Ports;
using System.Linq.Expressions;
using NModbus;
using NModbus.Serial;

namespace ConsoleApp1
{
    internal class Program
    {
        static void Main(string[] args)
        {
            SerialPort port = new SerialPort("COM13");
            port.BaudRate = 9600;
            port.Parity = Parity.None;
            port.StopBits = StopBits.One;
            port.DataBits = 8;
            byte slaveAdress = 255;

            port.ReadTimeout = 1000;
            port.WriteTimeout = 1000;
            port.Open();

            var factory = new ModbusFactory();

            bool[] RelaisStatus = new bool[2];

            var master = factory.CreateRtuMaster(port);

            while (true)
            {
                try
                {
                    // Coil 1 schreiben, Relais schalten
                    master.WriteSingleCoil(255, 0, true);
                    Console.Clear();
                    Console.WriteLine("Relais AN");
                    // Coil 1 lesen, Relaisstatus abfragen
                    RelaisStatus = master.ReadCoils(slaveAdress, 3, 1);

                    Thread.Sleep(1000);

                    // Coil 1 schreiben, Relais ausschalten
                    master.WriteSingleCoil(255, 0, false);
                    Console.Clear();
                    Console.WriteLine("Relais AUS");
                    // Coil 1 lesen, Relaisstatus abfragen
                    RelaisStatus = master.ReadCoils(slaveAdress, 5, 1);

                    Thread.Sleep(1000);                    
                }
                catch (NModbus.SlaveException ex)
                {
                    Console.WriteLine($"Slave: {ex.SlaveAddress}");
                    Console.WriteLine($"Function Code: {ex.FunctionCode}");
                    Console.WriteLine($"Exception Code: {ex.SlaveExceptionCode}");
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Fehler mit Slave {slaveAdress}: {ex.Message}");
                    Thread.Sleep(1000);
                }

            }
        }
    }
}

Im Debugger kann man den Status der Relais prüfen (Abb. 2 und Abb. 3).

coilschreibendebuggerstatustrue
Abb. 2 – Relais EINgeschaltet, Status des Coils TRUE
coilschreibendebuggerstatusfalse
Abb. 3 – Relais AUSgeschaltet, Status des Coils FALSE
timeout
Abb. 4 – Timeout beim Trennen des Slave vom Master

Es ist außerdem möglich, Modbus-Exception-Codes auszuwerten. Diese werden vom Slave zurückgegeben, wenn eine Anfrage zwar empfangen wurde, aber nicht ausgeführt werden kann – zum Beispiel bei einer ungültigen Registeradresse oder einem nicht unterstützten Funktionscode.

Bei der Relaisplatine scheint bei einem Fehler keine Antwort zu kommen, da erscheint immer nur das Timeout bzw. die allgemeine Exception, wenn z.B. eine fehlerhafte Coil-Adresse eingegeben wird. Da hätte ich einen Fehlercode erwartet.

WPF-Testoberfläche

Die bisherige Konsolenanwendung wird im nächsten Schritt in eine einfache WPF-Testoberfläche überführt. Ziel ist noch kein fertiges Modbus-Tool, sondern eine erste grafische Oberfläche zum Testen der Grundfunktionen.

Über die Oberfläche sollen zunächst zwei Relais geschaltet und die aktuellen Zustände zurückgelesen werden. Zusätzlich werden die Werte des Temperatur- und Feuchtigkeitssensors zyklisch ausgelesen und angezeigt. Damit lässt sich schnell prüfen, ob die Modbus-Kommunikation grundsätzlich funktioniert und ob der angeschlossene Slave korrekt antwortet.

Ziel ist eine Minimal WPF zum Testen des Relais, wie in Abb. 5. Ich beginne erst nur mit dem Relais.

minimalwpf
Abb. 5 – Minimal WPF

Dafür erstelle ich drei Projekte, ModbusConsole, ModbusCore und ModbusWpf. Der Code aus obigem Beispiel, wird in ModbusConsole und ModbusCore aufgesplittet, wobei ModbusCore die gesamte Funktionalität, also die Modbus-Logik beinhaltet und ModbusConsole die main(). Dann kann das Programm auch ohne die WPF laufen. Die ModbusWpf kümmert sich ausschließlich um die Oberfläche.

Die gesamte Initialisierung übernimmt jetzt ein Konstruktor in ModbusCore.

// Konstruktor, initialisiert den seriellen Port und den Modbus Master.
public RelayBoard(string comPort, byte slaveAddress)
{
    _slaveAddress = slaveAddress;

    _port = new SerialPort(comPort);
    var port = new SerialPort(comPort)
    {
        BaudRate = 9600,
        Parity = Parity.None,
        StopBits = StopBits.One,
        DataBits = 8,
        ReadTimeout = 1000,
        WriteTimeout = 1000
    };

    port.Open();

    ModbusFactory factory = new ModbusFactory();
    _master = factory.CreateRtuMaster(port);
}

So lässt sich die WPF sauber von der Modbus-Logik trennen. Dadurch bleibt das Projekt wartbarer und besser erweiterbar, als wenn die komplette Funktionalität direkt im WPF-Code-Behind implementiert werden würde.

ModbusConsole enthält vorerst nur einen kleinen Code zum allgemeinen Testen der Funktionalität in ModbusCore. Hier wird das Relais einfach fünf mal ein- und ausgeschaltet.

using ModbusCore;

namespace ModbusConsole
{
    internal class Program
    {
        static void Main(string[] args)
        {
            var relay = new RelayBoard("COM13", 255);      

            for (int i = 0; i < 5; i++)
            {
                relay.SetRelay(0, true);
                Console.WriteLine($"{i+1}: Relais AN");
                Thread.Sleep(500);

                relay.SetRelay(0, false);
                Console.WriteLine($"{i+1}: Relais AUS");
                Thread.Sleep(500);
            }

            relay.Close();
        }
    }
}

Das Schalten funktioniert einwandfrei und die WPF-Minimal Oberfläche kann man auch schon verwenden, wenn man die .exe startet.

Anfangs ist das alles noch vollkommen unspektakulär, zum Testen reicht es aber völlig aus. Es fehlt allerdings noch eine saubere Fehlerbehandlung: Ist beispielsweise kein Slave angeschlossen oder wird während der Laufzeit vom Master getrennt, wird eine Exception geworfen und das Programm verabschiedet sich kommentarlos. Genau solche stillen Abstürze mögen Anwender natürlich überhaupt nicht.

Mehrere Slaves an einem Bus

Damit beide Slaves, die Relais Platine und der Temperatur- und Feuchtigkeitssensor, an einen Bus angeschlossen werden können und somit an einem Master hängen, müssen die Parameter für die serielle Verbindung für alle Slaves gelten. Dafür erstelle ich für die serielle Verbindung eine eigene Klasse ModbusSerialConnectionComPort.

Über die Eigenschaft Master können jetzt alles Teilnehmer diesen RTU-Master implementieren.

using NModbus;
using NModbus.Serial;
using System;
using System.Collections.Generic;
using System.IO.Ports;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ModbusCore
{
    public class ModbusSerialConnectionComPort
    {
        public IModbusSerialMaster Master { get; }

        private readonly SerialPort _port;

        public ModbusSerialConnectionComPort(string comPort)
        {
            _port = new SerialPort(comPort)
            {
                BaudRate = 9600,
                Parity = Parity.None,
                StopBits = StopBits.One,
                DataBits = 8,
                ReadTimeout = 1000,
                WriteTimeout = 1000
            };

            _port.Open();

            var factory = new ModbusFactory();

            Master = factory.CreateRtuMaster(_port);
        }

        public void Close()
        {
            _port.Close();
        }
    }
}

In dem folgenden Video kommuniziert ein Master mit zwei Slaves auf dem selben Bus. Die Oberfläche dient aktuell ausschließlich Testzwecken und wird später überarbeitet. Der Sensor wird aktuell nur per Button-Klick gelesen, was an dieser Stelle aber noch erforderlich ist, wie wir später sehen werden. Die zyklische Abfrage erfordert noch einen Zwischenschritt.

Der Vorteil dieser primitiven Variante besteht darin, dass konkurrierende Modbus-Zugriffe praktisch ausgeschlossen sind. Da die Relais ausschließlich über Button-Klicks angesteuert werden, erfolgt jeder Kommunikationsvorgang nacheinander. Nur bei einer Multitouch-HMI könnten mehrere Schaltbefehle gleichzeitig ausgelöst werden. Dieser Sonderfall wird hier jedoch vernachlässigt.

Die Herausforderung beim zyklischen Einlesen der Temperatur- und Luftfeuchtigkeitsdaten besteht darin, dass auf einem Modbus-RTU-Bus immer nur eine Anfrage zur Zeit verarbeitet werden kann. Wird beispielsweise jede Sekunde per DispatcherTimer der Sensor abgefragt, kann es zu Konflikten mit dem Relaismodul kommen. Klickt der Anwender genau in dem Moment auf einen Relais-Button, während die WPF-Anwendung gerade eine Sensorabfrage ausführt, greifen beide Funktionen auf denselben Modbus-Master und dieselbe serielle Schnittstelle zu. Dadurch kann der Schaltbefehl verzögert werden oder im Fehlerfall in einen Timeout laufen.

Eine einfache Lösung wäre, den Sensor nur einmal pro Minute oder noch seltener abzufragen, da die Wahrscheinlichkeit einer Kollision dadurch sinkt. Vollständig vermeiden lässt sich das Problem jedoch auch damit nicht. Selbst bei langen Abfrageintervallen kann es vorkommen, dass ein Relais genau während einer laufenden Sensorabfrage betätigt werden soll.

Darüber hinaus ist zu beachten, dass die verwendete Relaisplatine selbst keine gleichzeitige Ansteuerung mehrerer Relais zulässt. Auch diese Zugriffe müssen daher nacheinander erfolgen. Bereits bei diesem einfachen Beispiel zeigt sich, dass sämtliche Modbus-Kommunikation koordiniert werden muss, um Konflikte und unerwartete Zustände zu vermeiden.

Grundregel bei Modbus RTU:

Ein Master, eine serielle Leitung, immer nur ein Request zur Zeit.

Hinzu kommt, dass wir später nicht nur ein Relaismodul mit zwei Relais und einen Temperatur- und Luftfeuchtigkeitssensor an den Master anschließen möchten. In realen Anwendungen kommunizieren häufig zahlreiche Relaismodule, Sensoren und andere Aktoren über denselben Bus. Damit die Kommunikation auch bei einer größeren Anzahl von Teilnehmern zuverlässig funktioniert, ist eine saubere Verwaltung aller Modbus-Zugriffe erforderlich.

Die Lösung besteht darin, alle Modbus-Zugriffe zentral zu koordinieren. Sensorabfragen und Relaisschaltbefehle dürfen nicht parallel ausgeführt werden, sondern müssen nacheinander über eine gemeinsame Kommunikationslogik laufen.

Aus diesem Grund wird im weiteren Verlauf eine zentrale Kommunikationsschicht eingeführt, die alle Modbus-Anfragen in eine Warteschlange einreiht und nacheinander abarbeitet.

Zentrale Verwaltung von Modbus-Anfragen

Für die zentrale Verwaltung von Modbus-Anfragen wird eine zusätzliche Klasse ModbusCommunicationManager im Projekt ModbusCore eingeführt. Der bisher direkt verwendete Modbus-Master wird in diese Klasse ausgelagert, sodass sämtliche Kommunikationsvorgänge über eine gemeinsame Instanz abgewickelt werden können.

Darüber hinaus kommt die Klasse SemaphoreSlim zum Einsatz. Sie stellt sicher, dass immer nur eine Modbus-Anfrage gleichzeitig auf den Bus zugreifen kann. Dadurch werden konkurrierende Zugriffe durch Relais-Schaltvorgänge und zyklische Sensorabfragen verhindert.

Im folgenden Video ist die Funktionalität mit asynchronen Methodenaufrufen und einem DispatcherTimer zu sehen. Die Umgebungsdaten des Sensors – Temperatur und Luftfeuchtigkeit – werden alle 10 Sekunden aktualisiert. Durch die asynchrone Verarbeitung bleibt die WPF-Oberfläche jederzeit bedienbar. Gleichzeitig sorgt die zentrale Kommunikationsverwaltung in Verbindung mit SemaphoreSlim dafür, dass die Modbus-Kommunikation sequenziell und ohne Buskollisionen erfolgt.

In der Praxis kann das Abfrageintervall des Sensors problemlos auf 60 Sekunden oder länger erhöht werden. Temperatur- und Luftfeuchtigkeitswerte ändern sich in der Regel nur langsam, sodass eine häufigere Aktualisierung meist keinen zusätzlichen Nutzen bringt. Dennoch müssen auch bei langen Abfrageintervallen konkurrierende Zugriffe auf den Modbus-Bus berücksichtigt werden. Ein Relais-Schaltvorgang kann jederzeit genau dann erfolgen, wenn gerade eine Sensorabfrage aktiv ist. Die zentrale Kommunikationsverwaltung stellt sicher, dass solche Anfragen nacheinander abgearbeitet werden und es nicht zu Konflikten auf dem Bus kommt.

Die Methoden zum Schreiben und Lesen der Daten definieren wir in der Klasse ModbusCommunicationManager jetzt als asynchrone Methoden.

public async Task WriteSingleCoilAsync(byte slaveId, ushort coilAddress, bool value)
public async Task<ushort[]> ReadInputRegistersAsync(byte slaveId, ushort startAddress, ushort numberOfPoints)

Die Methoden die auf die Member dieser Klasse zugreifen müssen ebenfalls asynchron definiert werden, wie im Beispiel des Temperatur- und Feuchtigkeitssensors.

public async Task<ushort[]> getTemp(ushort coilAddress)
{            
    return await _communicationManager.ReadInputRegistersAsync(_slaveAddress, coilAddress, 1);
}

Weiter zu: C# Master und ESP32 Slave →

Weiterführende Links

Projekte