Modbus Server

[22/12/2023]

1. Introduction

1.1 TCP Server

For Modbus Server, we are going to use "AdvancedTCPServer" application as a base.

1.2 Modbus function codes vs memory allocation

Let’s review available Modbus function code and decide which one we need for our device:

  • 0x01 Read Coils
  • 0x02 Read Discrete Inputs
  • 0x03 Read Holding Registers
  • 0x04 Read Input Registers
  • 0x05 Write Single Coil
  • 0x06 Write Single Register
  • 0x07 Read Exception Status (Serial Line only)
  • 0x08 Diagnostics (Serial Line only)
  • 0x0B Get Comm Event Counter (Serial Line only)
  • 0x0C Get Comm Event Log (Serial Line only)
  • 0x0F Write Multiple Coils
  • 0x10 Write Multiple Registers
  • 0x11 Report Server ID (Serial Line only)
  • 0x14 Read File Record
  • 0x15 Write File Record
  • 0x16 Mask Write Registers
  • 0x17 Read/Write Multiple Registers
  • 0x18 Read FIFO Queue

Most commonly used function codes (FC) are 1, 2, 3, 4, 5, 6, 15 and 16. As those function codes represent different data sets, they may be assigned to different memory maps. For example:

  • FC1 and FC5 are related to Coils and they may have their own memory mpa. In other words, there may be a piece of memory that could be accessed only with FC1 and FC5. It can’t be accessed by FC2 or any other code.
  • FC3 and FC4 as they refer to Holding Registers and Input Registers may be assigned to two different memory maps and they will have a different register values even when requesting the same address.
  • Some manufacturers allow to read memory map with both FC3 and FC4. In other words, they both refer to the same memory map.
  • Some other manufacturers (like SE for SEPAM protection relays) share memory map so it can be accessed with FC1, 2, 3 or 4.

Another thing we need to consider is the size of memory map. We may restrict memory access to the certain amount of addresses. For example:

  • With FC3, it is possible to read only addresses from 1000 to 1500. If a client tries to read memory map out of that range, server should return error with exception code indicating that (attempt of reading out of the available range).

In general, every manufacturer of the device should precisely indicate in the manual all the data, what function code they can be access with, data type, unit, range and any other information needed for to client.

If this example turns into a device available in the marked one day, the user manual should be released.

1.3 Approach

In this example, we are going to focus on the most common function codes (1,2,3,4,5,6,15,16). We are going to allow to access the memory map with any of mentioned function codes. Moreover, we are not going to restrict memory access and we will use maximum possible memory which means we well hold 65536 registers as an array[0..65535] of registers. Modbus Starting Address is always written on 2 bytes. It means that with function codes FC3, 4, 6, 16 we will be able to access all available registers [0..65535]. With FC1, 2, 5, 15, as they refer to bits, we will be able to access only a part of memory. [0..4095] of registers which means starting from 0.0 (bit 0 of register 0) and ending on 4095.15 (bit 15 of register 4095).

2. Implementation

2.1 ModbusServer Task

In the main program structure of "Advanced TCP Server" we will change functionality behind RespondToTcpClient() function. We will replace:


          60:	// exchange data: receive and send
            RespondToTcpClient(ADR(SocketSet), ADR(socketList));
            serverStep := 45;
        

with:


          60:	// exchange data: receive and send
            RespondToTcpClient(ADR(SocketSet), ADR(socketList), ADR(SERVER.database));
            serverStep := 45;
        

Additional parameter: pointer to server database is passed to RespondToTcpClient() defined as:


          VAR_GLOBAL
            database : ARRAY[0..65535] OF WORD;
          END_VAR
        

2.2 RespondToTcpClient()


          FUNCTION RespondToTcpClient : BOOL
          VAR_INPUT
            socketSet : POINTER TO SysSocket.SysSocket_Interfaces.SOCKET_FD_SET;
            list : POINTER TO TcpSocketList;
          END_VAR
          VAR
            i : DINT;
            bytesReceived : __XINT;
            dataReceived : ARRAY[1..32] OF BYTE;
            rRecv : SysSocket.SysSocket_Interfaces.RTS_IEC_RESULT;
            rSend : SysSocket.SysSocket_Interfaces.RTS_IEC_RESULT;
            rClose : SysSocket.SysSocket_Interfaces.RTS_IEC_RESULT;	
          END_VAR
          
          FOR i := 1 TO SysSocket.SysSocket_Interfaces.MAX_SOCKET_FD_SETSIZE DO
            IF list^.client[i].handle <> SysSocket.SysSocket_Interfaces.RTS_INVALID_HANDLE THEN
              IF SysSockFdIsset(list^.client[i].handle, socketSet^) THEN
                bytesReceived := SysSockRecv(list^.client[i].handle, ADR(dataReceived), SIZEOF(dataReceived), 0, ADR(rRecv));
                IF rRecv = 0 THEN
                  SysSockSend(list^.client[i].handle, ADR(dataReceived), bytesReceived, 0, ADR(rSend));
                ELSE
                  rClose := SysSockClose(list^.client[i].handle);
                  RemoveSocketFromTheList(list^.client[i].handle, list);
                END_IF
              END_IF				
            END_IF
          END_FOR
        

With "SysSockSend(list^.client[i].handle, ADR(dataReceived), bytesReceived, 0, ADR(rSend));" we are echoing back the message to the client.


          FUNCTION RespondToTcpClient : BOOL
          VAR_INPUT
            socketSet : POINTER TO SysSocket.SysSocket_Interfaces.SOCKET_FD_SET;
            list : POINTER TO TcpSocketList;
            database : POINTER TO WORD;
          END_VAR
          VAR
            i : DINT;
            bytesReceived : __XINT;
            dataReceived : ARRAY[0..259] OF BYTE;
            rRecv : SysSocket.SysSocket_Interfaces.RTS_IEC_RESULT;
            rSend : SysSocket.SysSocket_Interfaces.RTS_IEC_RESULT;
            rClose : SysSocket.SysSocket_Interfaces.RTS_IEC_RESULT;	
            
            ManageClientRequest : ModbusServerDataManager;
          END_VAR
          
          FOR i := 1 TO SysSocket.SysSocket_Interfaces.MAX_SOCKET_FD_SETSIZE DO
            IF list^.client[i].handle <> SysSocket.SysSocket_Interfaces.RTS_INVALID_HANDLE THEN
              IF SysSockFdIsset(list^.client[i].handle, socketSet^) THEN
                bytesReceived := SysSockRecv(list^.client[i].handle, ADR(dataReceived), SIZEOF(dataReceived), 0, ADR(rRecv));
                IF rRecv = 0 THEN
                  ManageClientRequest(request := ADR(dataReceived), database := database);
                  SysSockSend(list^.client[i].handle, ManageClientRequest.response, ManageClientRequest.responseSize, 0, ADR(rSend));
                ELSE
                  rClose := SysSockClose(list^.client[i].handle);
                  RemoveSocketFromTheList(list^.client[i].handle, list);
                END_IF
              END_IF
            END_IF
          END_FOR        
        

In this case, we are going to process client's request. For function codes FC1, 2, 3, 4, response will contain values of requested data. For function codes FC5, 6 ,15, 16, we are going to fill memory map with values from the client and return the response containing "confirmation" of that action.

2.3 RemoveSocketFromTheList()

Let's dive deeper into details of RemoveSocketFromTheList().


          FUNCTION_BLOCK RespondToTcpClient
          VAR_INPUT
            request : POINTER TO BYTE;
            database : POINTER TO WORD;
          END_VAR
          VAR_OUTPUT
            response : POINTER TO BYTE := ADR(responseFrame);
            responseSize : DINT;
          END_VAR
          VAR
            responseFrame : ARRAY[0..256] OF BYTE;
          END_VAR
          
          IF ExtractByte(request, MODBUS.UNIT_IDENTIFIER) = SERVER.ID THEN
            CASE ExtractByte(request, MODBUS.FUNCTION_CODE) OF
            1, 2:
              responseSize := ReadCoilsOrDiscreteInputs(request, database, response);
            3, 4:
              responseSize := ReadHoldingOrInputRegisters(request, database, response);
            5:
              responseSize := WriteSingleCoil(request, database, response);
            6:
              responseSize := WriteSingleRegister(request, database, response);
            15:
              responseSize := WriteMultipleCoils(request, database, response);
            16:
              responseSize := WriteMultipleRegisters(request, database, response);
            ELSE
              responseSize := IllegalFunctionCode(request, response);
            END_CASE
          END_IF
        

So, what's going on here?

First, we extract ID from the request. If the ID from the request doesn’t match ID that we assigned to our device, nothing happens. Client is not going to receive any response. If the ID from the request and ID that we assigned to our device matches, then we extract the function code from the request and execute matching function. If request contains function code that is not specified, Server will response with “Illegal Function Code”.

3. Modbus Server - function codes management

The common part of all below functions are exception codes.

“Illegal Data Value” is set when the client attempts to request more data than Modbus protocol allows. The limitation are:

  • FC1, 2 : 2000 bits,
  • FC3, 4 : 125 registers,
  • FC5 : 1968 bits,
  • FC6 : 123 registers,
  • FC15, 16 : not applicable as only one bit/register is written.

“Illegal Data Address” is set when the client attempts to request coil/register that is not available on the server. We configured all the addresses from 0 to 65535 so it may seem that we would never get that Exception Code. Well, not exactly. The client may request 100 registers from 65500 address and in this case, the request exceeds amount of registers configured. In our cast, attempting to read addresses above 65535 will generate “Illegal Data Address” Exception Code.

In each of below functions, small trick is used to check whatever we stay within the range of legal addresses. As, we use 65536 registers of WORD type, we can't simply add Starting Address and Quantity of data requested because we may exceed the limitation of WORD. For example, we can’t add 65535 + 10 because 65545 is not within WORD. We would need to use DWORD to hold the result of this sum. But, with a different approach we can stay within WORD limit, which is implemented here. So, instead of adding Starting Address and the Quantity and comparing the result with 65535, we are going to compare whatever Starting Address value is higher or equal than 65536-Quantity. If so, request attempts to access registers higher that maximum available (65535).

When all values from the request (Starting Address, Quantity, Value) are within specific ranges, request is processed normally. Which means that data will be read from the memory map or data will be saved in memory map depending on function code used.

3.1 ReadCoilsOrDiscreteInputs()


          FUNCTION ReadCoilsOrDiscreteInputs : DINT
          VAR_INPUT
            request : POINTER TO BYTE;
            database : POINTER TO WORD;
            response : POINTER TO BYTE;
          END_VAR
          VAR
            startingRegister : UINT;
            quantity : UINT;
            responseSize : DINT;
          END_VAR
          
          CopyHeader(request, response);
          startingRegister := ExtractRegister(request, MODBUS.STARTING_ADDRESS);
          quantity := ExtractRegister(request, MODBUS.QUANTITY_OF_REGISTERS);
          IF quantity >= 16#0001 AND quantity <= 16#07D0 THEN
            IF startingRegister >= 16#0000 AND startingRegister <= (16#FFFF - quantity + 1) THEN
              Database_GetBits(response, database, startingRegister, quantity);
              responseSize := UpdateResponseLength(response, response[8] + 3);
            ELSE
              responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_ADDRESS);			
            END_IF
          ELSE
            responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_VALUE);
          END_IF
          ReadCoilsOrDiscreteInputs := responseSize;
        

3.2 ReadHoldingOrInputRegisters()


          FUNCTION ReadHoldingOrInputRegisters : DINT
          VAR_INPUT
            request : POINTER TO BYTE;
            database : POINTER TO WORD;
            response : POINTER TO BYTE;
          END_VAR
          VAR
            startingRegister : UINT;
            quantity : UINT;
            responseSize : DINT;
          END_VAR
          
          CopyHeader(request, response);
          startingRegister := ExtractRegister(request, MODBUS.STARTING_ADDRESS);
          quantity := ExtractRegister(request, MODBUS.QUANTITY_OF_REGISTERS);
          IF quantity >= 16#0001 AND quantity <= 16#007D THEN
            IF startingRegister >= 16#0000 AND startingRegister <= (16#FFFF - quantity + 1) THEN
              Database_GetRegisters(response, database, startingRegister, quantity);
              responseSize := UpdateResponseLength(response, response[8] + 3);				
            ELSE
              responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_ADDRESS);				
            END_IF
          ELSE
            responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_VALUE);
          END_IF
          ReadHoldingOrInputRegisters := responseSize;
        

3.3 WriteSingleCoil()


          FUNCTION WriteSingleCoil : DINT
          VAR_INPUT
            request : POINTER TO BYTE;
            database : POINTER TO WORD;
            response : POINTER TO BYTE;
          END_VAR
          VAR
            startingRegister : UINT;
            value : UINT;
            responseSize : DINT;
          END_VAR
          
          CopyHeader(request, response);
          startingRegister := ExtractRegister(request, MODBUS.OUTPUT_ADDRESS);
          value := ExtractRegister(request, MODBUS.OUTPUT_VALUE);
          IF value = 16#0000 OR value = 16#FF00 THEN
            IF startingRegister >= 16#0000 AND startingRegister <= 16#FFFF THEN
              Database_SetBit(database, startingRegister, value);
              CopyRegister(request, response, MODBUS.OUTPUT_ADDRESS);
              CopyRegister(request, response, MODBUS.OUTPUT_VALUE);
              responseSize := 12;
            ELSE
              responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_ADDRESS);
            END_IF
          ELSE
            responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_VALUE);	
          END_IF
          WriteSingleCoil := responseSize;
        

3.4 WriteSingleRegister()


          FUNCTION WriteSingleRegister : DINT
          VAR_INPUT
            request : POINTER TO BYTE;
            database : POINTER TO WORD;
            response : POINTER TO BYTE;
          END_VAR
          VAR
            startingRegister : UINT;
            value : UINT;
            responseSize : DINT;
          END_VAR
          
          CopyHeader(request, response);
          startingRegister := ExtractRegister(request, MODBUS.REGISTER_ADDRESS);
          value := ExtractRegister(request, MODBUS.REGISTER_VALUE);
          IF value >= 16#0000 AND value <= 16#FFFF THEN
            IF startingRegister >= 16#0000 AND startingRegister <= 16#FFFF THEN
              Database_SetRegister(database, startingRegister, value);		
              CopyRegister(request, response, MODBUS.REGISTER_ADDRESS);
              CopyRegister(request, response, MODBUS.REGISTER_VALUE);
              responseSize := 12;
            ELSE
              responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_ADDRESS);
            END_IF
          ELSE
            responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_VALUE);
          END_IF
          WriteSingleRegister := responseSize;
        

3.5 WriteMultipleCoils


          FUNCTION WriteMultipleCoils : DINT
          VAR_INPUT
            request : POINTER TO BYTE;
            database : POINTER TO WORD;
            response : POINTER TO BYTE;
          END_VAR
          VAR
            startingRegister : UINT;
            quantity : UINT;
            responseSize : DINT;
          END_VAR
          
          CopyHeader(request, response);
          startingRegister := ExtractRegister(request, MODBUS.STARTING_ADDRESS);
          quantity := ExtractRegister(request, MODBUS.QUANTITY_OF_REGISTERS);
          IF quantity >= 16#0001 AND quantity <= 16#07B0 THEN
            IF startingRegister >= 16#0000 AND startingRegister <= (16#FFFF - quantity + 1) THEN
              Database_SetBits(request, database, startingRegister, quantity);
              CopyRegister(request, response, MODBUS.STARTING_ADDRESS);
              CopyRegister(request, response, MODBUS.QUANTITY_OF_REGISTERS);
              responseSize := UpdateResponseLength(response, 6);
            ELSE
              responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_ADDRESS);
            END_IF
          ELSE
            responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_VALUE);
          END_IF
          WriteMultipleCoils := responseSize;
        

3.6 WriteMultipleRegisters()


          FUNCTION WriteMultipleRegisters : DINT
          VAR_INPUT
            request : POINTER TO BYTE;
            database : POINTER TO WORD;
            response : POINTER TO BYTE;
          END_VAR
          VAR
            startingRegister : UINT;
            quantity : UINT;
            responseSize : DINT;
          END_VAR
          
          CopyHeader(request, response);
          startingRegister := ExtractRegister(request, MODBUS.STARTING_ADDRESS);
          quantity := ExtractRegister(request, MODBUS.QUANTITY_OF_REGISTERS);
          IF quantity >= 16#0001 AND quantity <= 16#007B THEN
            IF startingRegister >= 16#0000 AND startingRegister <= (16#FFFF - quantity + 1) THEN
              Database_SetRegisters(request, database, startingRegister, quantity);
              CopyRegister(request, response, MODBUS.STARTING_ADDRESS);
              CopyRegister(request, response, MODBUS.QUANTITY_OF_REGISTERS);
              responseSize := UpdateResponseLength(response, 6);
            ELSE
              responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_ADDRESS);
            END_IF
          ELSE
            responseSize := ExceptionCode(response, MODBUS.ILLEGAL_DATA_VALUE);
          END_IF
          WriteMultipleRegisters := responseSize;
        

4. Exception code & Utility functions

All above functions manipulate on “request” data array, and store the result on “response” data array. Keep in mind that:

  • part of data in both “request” and “response” array are the same. For example Transaction Identifier or Unit Identifier never change,
  • “request” and “response” length may not be the same,
  • function code remains the same unless client tries to exceed memory map or protocol limitations.

And for that, there are several functions that helps to keep the code easier to read:

  • Copy*() functions : copy whatever is in “request” to the “response” array,
  • Extract*() functions : extract data from the “request” array so it can be used for comparison,
  • ExceptionCode() function : "overrides" function code and indicates exception code error,
  • UpdateResponseLength() function : to sets "response" length,
  • Database_*() functions : operate directly on memory map.

5. Summary

It took some effort to implement function code management based on proposed memory map. As the program was written in CodeSys, it could be run on an industrial computer under Linux or even on Raspberry PI. It only requires proper CodeSys license and we can easily have our own, fully functioning Modbus Server. Remaining to implement algorithms to fill database cause right now it is empty.

Source code here (IP: 192.168.0.204, PORT: 502, ID: 248).