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).