Abstract
❝
This article describes how to use C# to call the NModbus4 library and the System.IO.Ports library to implement Modbus RTU communication functionality【Project address at the end of the article】.
Introduction
Modbus RTU is a serial communication protocol, where the communication mechanism is a polling mechanism where the master device sequentially queries the slave devices. This protocol is commonly used in industrial fields. This case study is a simple hands-on example of how to implement Modbus RTU communication using C#, allowing for a preliminary understanding of its basic functionalities.
This case study implements Modbus RTU communication functionality by calling NModbus4 and System.IO.Ports in a C# program. The case study only implements functional calls, so it directly uses the built-in read and write methods without concerning itself with the underlying messages. Of course, there is a reason why NModbus4 does not expose the raw messages by default; this article does not implement the display of raw messages, which will be addressed in subsequent case studies.
(1) Implemented Functions (Modbus RTU)
Function Code 01: Read Coil Status (ReadCoils)
Function Code 02: Read Input Status (ReadInputs)
Function Code 03: Read Holding Registers (ReadHoldingRegisters)
Function Code 04: Read Input Registers (ReadInputRegisters)
Function Code 05: Write Single Coil (WriteSingleCoil)
Function Code 06: Write Single Register (WriteSingleRegister)
Function Code 15: Write Multiple Coils (WriteMultipleCoils)
Function Code 16: Write Multiple Registers (WriteMultipleRegisters)
Operating Environment
Operating System: Win11
Development Software: Visual Studio 2022
.Net Version: .Net Framework 4.8.0
Dependency Version: NModbus4 2.1.0
1. Preview
(1) Running Effect
2. Code
(1) MainForm Class CodeThe MainForm class code roughly implements the following functionalities:1. Initialize the form, load the form, and initialize configuration parameters.2. Connect, read, and write.3. Timer: connection status, cyclic reading and displaying data.4. Control enable updates, parameter changes.5. Operation message updates.
public partial class MainForm : WinFormBase{ #region Objects ModbusRtuMaster modbusMaster; States connectState = States.Stop; Timer connectStateTimer = new Timer(); Timer resultShowTimer = new Timer(); #endregion
#region Initialize Form, Load Form, Initialize Configuration Parameters public MainForm() { InitializeComponent(); this.CenterToParent(); } private void MainForm_Load(object sender, EventArgs e) { Initialize(); } private void Initialize() { // Initialize Modbus modbusMaster = new ModbusRtuMaster(); // Initialize parameters cbx_SerialPort.DataSource = modbusMaster.GetSerialPortArray(); cbx_BaudRate.DataSource = modbusMaster.GetBaudRateArray(); cbx_Parity.DataSource = modbusMaster.GetParityArray(); cbx_DataBits.DataSource = modbusMaster.GetDataBitArray(); cbx_StopBits.DataSource = modbusMaster.GetStopBitArray(); cbx_FuncCode.SelectedIndex = 0; // Initialize timers connectStateTimer = new Timer(); connectStateTimer.Interval = 500; connectStateTimer.Tick += ConnectStateTimer_Tick; resultShowTimer = new Timer(); resultShowTimer.Interval = 500; connectStateTimer.Tick += ResultShowTimer_Tick; // Initialize table dataGridView.ReadOnly = true; dataGridView.Columns[0].Width = 100; dataGridView.Columns[1].Width = 100; dataGridView.Columns[0].DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter; dataGridView.Columns[1].DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter; dataGridView.RowHeadersVisible = false; // Initialize table data for (int i = 0; i < 10; i++) { dataGridView.Rows.Add(new object[] { i, 0 }); } // Initialize rtbx_Message.ForeColor = Color.Gray; btn_WriteData.Enabled = false; rsc_ConnectState.State = States.Stop; } #endregion
#region Connect, Read, Write private void btn_OpenRTU_Click(object sender, EventArgs e) { Open(); } private void btn_WriteData_Click(object sender, EventArgs e) { WriteData(); } private void Open() { if (btn_OpenRTU.Text.Equals("Connect")) { MessageUpdate("Creating Modbus RTU connection...", Color.Green); btn_OpenRTU.Enabled = false; if (modbusMaster.IsConnected) modbusMaster.DisConnect(); if (!modbusMaster.Connect(modbusMaster.protModel.PortName)) { modbusMaster.DisConnect(); Debug.WriteLine("Unable to establish connection with Modbus RTU slave"); MessageUpdate("Unable to establish connection with Modbus RTU slave", Color.Red); btn_OpenRTU.Enabled = true; return; } try { MessageUpdate("Modbus RTU connection established successfully...", Color.Green); connectState = States.Running; Task<bool[]> registers = modbusMaster.ReadCoilsAsync(modbusMaster.SlaveId, 0, 1); if (registers.Result != null) { Debug.WriteLine($"Attempting to read value: value = {registers.Result[0]}"); MessageUpdate($"Attempting to read value: value = {registers.Result[0]}", Color.Green); } ControlEnableUpdate(); connectStateTimer.Start(); resultShowTimer.Start(); btn_OpenRTU.Enabled = true; } catch (Exception ex) { Debug.WriteLine($"Exception triggered: {ex.Message}"); MessageUpdate($"Failed to read value, connection failed... Exception reason: {ex.Message}", Color.Red); modbusMaster.DisConnect(); connectState = States.Stop; connectStateTimer.Stop(); resultShowTimer.Stop(); ControlEnableUpdate(); btn_OpenRTU.Enabled = true; } } else { MessageUpdate("Disconnecting Modbus RTU communication...", Color.Red); modbusMaster.DisConnect(); connectState = States.Stop; connectStateTimer.Stop(); resultShowTimer.Stop(); rsc_ConnectState.State = connectState; ControlEnableUpdate(); btn_OpenRTU.Enabled = true; } } /// <summary> /// Write data /// </summary> private void WriteData() { ushort startAddress = (ushort)nudx_WriteStartAddress.Value; try { switch (modbusMaster.FuncCode) { case "05": modbusMaster.WriteSingleCoilAsync(modbusMaster.SlaveId, startAddress, bool.Parse(tbx_WriteData.Text)); UpdateDataShow($"Slave={modbusMaster.SlaveId}, Function Code = {modbusMaster.FuncCode}," + $" Start Address = {modbusMaster.StartAddress}, Write Value={tbx_WriteData.Text}"); break; case "06": modbusMaster.WriteSingleRegisterAsync(modbusMaster.SlaveId, startAddress, ushort.Parse(tbx_WriteData.Text)); UpdateDataShow($"Slave={modbusMaster.SlaveId}, Function Code = {modbusMaster.FuncCode}," + $" Start Address = {modbusMaster.StartAddress}, Write Value={tbx_WriteData.Text}"); break; case "15": bool[] dataBool = ParseArray<bool>(tbx_WriteData.Text); modbusMaster.WriteMultipleCoilsAsync(modbusMaster.SlaveId, startAddress, dataBool); UpdateDataShow($"Slave={modbusMaster.SlaveId}, Function Code = {modbusMaster.FuncCode}," + $" Start Address = {modbusMaster.StartAddress}, Write Value={tbx_WriteData.Text}"); break; case "16": ushort[] dataUshort = ParseArray< ushort > (tbx_WriteData.Text); modbusMaster.WriteMultipleRegistersAsync(modbusMaster.SlaveId, startAddress, dataUshort); UpdateDataShow($"Slave={modbusMaster.SlaveId}, Function Code = {modbusMaster.FuncCode}," + $" Start Address = {modbusMaster.StartAddress}, Write Value={tbx_WriteData.Text}"); break; default: MessageUpdate($"Function Code = {modbusMaster.FuncCode}, does not match...",Color.Red); break; } } catch (Exception ex) { UpdateDataShow($"Write exception, {ex.Message}"); } } /// <summary> /// Convert string to array /// </summary> private T[] ParseArray<T>(string input) { string[] items = input.Trim('[', ']').Split(','); return items.Select(item => (T)Convert.ChangeType(item.Trim(), typeof(T))).ToArray(); } #endregion
#region Timer
/// <summary> /// Connection status timer /// </summary> private void ConnectStateTimer_Tick(object sender, EventArgs e) { if (modbusMaster.IsConnected) { rsc_ConnectState.Invoke(new Action(() => { if (connectState == States.Running) { rsc_ConnectState.State = (rsc_ConnectState.State == States.None ? States.Running : States.None); Task.Delay(500); } })); } }
/// <summary> /// Address result timer /// </summary> private void ResultShowTimer_Tick(object sender, EventArgs e) { if (!modbusMaster.IsConnected || !checkBx_LoopRead.Checked) return; try { dataGridView.Invoke(new Action(() => { Task<bool[]> result; Task<ushort[]> registers; switch (modbusMaster.FuncCode) { case "01": result = modbusMaster.ReadCoilsAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<bool>(result?.Result); break; case "02": result = modbusMaster.ReadInputsAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<bool>(result?.Result); break; case "03": registers = modbusMaster.ReadHoldingRegistersAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<ushort>(registers?.Result); break; case "04": registers = modbusMaster.ReadInputRegistersAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<ushort>(registers?.Result); break; case "05": result = modbusMaster.ReadCoilsAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<bool>(result?.Result); break; case "06": registers = modbusMaster.ReadHoldingRegistersAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<ushort>(registers.Result); break; case "15": result = modbusMaster.ReadCoilsAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<bool>(result?.Result); break; case "16": registers = modbusMaster.ReadHoldingRegistersAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<ushort>(registers?.Result); break; default: result = modbusMaster.ReadCoilsAsync(modbusMaster.SlaveId, modbusMaster.StartAddress, (ushort)modbusMaster.DataLength); UpdateDataShow<bool>(result?.Result); break; } })); } catch (Exception ex) { MessageUpdate($"{ex.Message}",Color.Red); } }
/// <summary> /// Data update display /// </summary> private void UpdateDataShow<T>(T[] array,string appendText = null) { for (int i = 0; i < modbusMaster.DataLength && i < 10; i++) { dataGridView.Rows[i].Cells[0].Value = (modbusMaster.StartAddress + i); dataGridView.Rows[i].Cells[1].Value = array[i]; } if (!checkBx_LoopShow.Checked) return; MessageUpdate($"[{modbusMaster.StartAddress}][{ArrayToString<T>(array)}]", Color.Blue, "# Received ASCII>"); } private void UpdateDataShow<T>(T result, string appendText = null) { MessageUpdate($"[{modbusMaster.StartAddress}][{result}]", Color.Green, "# Sent ASCII>"); }
/// <summary> /// Array to string conversion /// </summary> private string ArrayToString<T>(T[] values, string sep = " ") { return string.Join(sep, values.Select(r => Convert.ToString(r)).ToArray()); } #endregion
#region Control Enable Update /// <summary> /// Control enable update /// </summary> private void ControlEnableUpdate() { ControlEnabled(cbx_SerialPort, !modbusMaster.IsConnected); ControlEnabled(cbx_BaudRate, !modbusMaster.IsConnected); ControlEnabled(cbx_Parity, !modbusMaster.IsConnected); ControlEnabled(cbx_DataBits, !modbusMaster.IsConnected); ControlEnabled(cbx_StopBits, !modbusMaster.IsConnected); ControlEnabled(nudx_SlaveId, !modbusMaster.IsConnected); btn_OpenRTU.Invoke(new Action(() => { btn_OpenRTU.Text = modbusMaster.IsConnected ? "Disconnect" : "Connect"; })); } /// <summary> /// Control enable activation /// </summary> public void ControlEnabled(Control control,bool flag) { control.Invoke(new Action(() => { control.Enabled = flag; })); } #endregion
#region Parameter Changes private void cbx_SerialPort_SelectedIndexChanged(object sender, EventArgs e) { if (cbx_SerialPort == null || cbx_SerialPort.SelectedItem == null) return; modbusMaster.protModel.PortName = cbx_SerialPort.SelectedItem.ToString(); } private void cbx_BaudRate_SelectedIndexChanged(object sender, EventArgs e) { if (cbx_BaudRate == null || cbx_BaudRate.SelectedItem == null) return; if (int.TryParse(cbx_BaudRate.SelectedItem.ToString(),out int result)) { modbusMaster.protModel.BaudRate = result; } else { cbx_BaudRate.SelectedItem = modbusMaster.protModel.BaudRate.ToString(); }
} private void cbx_Parity_SelectedIndexChanged(object sender, EventArgs e) { if (Enum.TryParse(cbx_Parity.SelectedItem.ToString(), out Parity result)) { modbusMaster.protModel.Parity = result; } else { cbx_Parity.SelectedItem = modbusMaster.protModel.Parity.ToString(); } } private void cbx_DataBits_SelectedIndexChanged(object sender, EventArgs e) { if (int.TryParse(cbx_DataBits.SelectedItem.ToString(), out int result)) { modbusMaster.protModel.DataBits = result; } else { cbx_DataBits.SelectedItem = modbusMaster.protModel.DataBits.ToString(); } } private void cbx_StopBits_SelectedIndexChanged(object sender, EventArgs e) { if (Enum.TryParse(cbx_StopBits.SelectedItem.ToString(), out StopBits result)) { modbusMaster.protModel.StopBits = result; } else { cbx_StopBits.SelectedItem = modbusMaster.protModel.StopBits.ToString(); } } private void cbx_FuncCode_SelectedIndexChanged(object sender, EventArgs e) { if (cbx_FuncCode==null)return; modbusMaster.FuncCode = cbx_FuncCode.SelectedItem.ToString().Split('_')[0]; byte funcCode = byte.Parse(modbusMaster.FuncCode); if (modbusMaster.IsConnected && funcCode== 5 || funcCode == 6 || funcCode == 15 || funcCode == 16) { btn_WriteData.Enabled = true; } else { btn_WriteData.Enabled = false; }
} private void nudx_StartAddress_ValueChanged(object sender, EventArgs e) { if (nudx_StartAddress.Value < ushort.MaxValue) { modbusMaster.StartAddress = (ushort)nudx_StartAddress.Value; } else { nudx_StartAddress.Value = modbusMaster.StartAddress; } } private void nudx_DataLength_ValueChanged(object sender, EventArgs e) { if (ushort.Parse(nudx_DataLength.Value.ToString()) < ushort.MaxValue) { modbusMaster.DataLength = (ushort)nudx_DataLength.Value; } else { nudx_DataLength.Value = modbusMaster.DataLength; } } private void nudx_SlaveId_ValueChanged(object sender, EventArgs e) { if (byte.Parse(nudx_SlaveId.Value.ToString()) < byte.MaxValue) { modbusMaster.SlaveId = (byte)nudx_SlaveId.Value; } else { nudx_SlaveId.Value = modbusMaster.SlaveId; } } private void nudx_WriteStartAddress_ValueChanged(object sender, EventArgs e) { if (ushort.Parse(nudx_WriteStartAddress.Value.ToString()) < ushort.MaxValue) { modbusMaster.WriteStartAddress = (ushort)nudx_WriteStartAddress.Value; } else { nudx_WriteStartAddress.Value = modbusMaster.WriteStartAddress; } } private void nudx_WriteDataLength_ValueChanged(object sender, EventArgs e) { if (ushort.Parse(nudx_WriteDataLength.Value.ToString()) < ushort.MaxValue) { modbusMaster.WriteDataLength = (ushort)nudx_WriteDataLength.Value; } else { nudx_WriteDataLength.Value = modbusMaster.WriteDataLength; } } #endregion
#region Operation Message Update /// <summary> /// Operation message update /// </summary> /// <param name="data"></param> /// <param name="color"></param> /// <param name="appendText"></param> /// <param name="maxLineNum"></param> /// <param name="isAppendTime"></param> private void MessageUpdate(string data, Color color, string appendText = null, int maxLineNum = 1000, bool isAppendTime = true) { // Empty data check if (string.IsNullOrEmpty(data)) return; // Thread-safe call if (rtbx_Message.InvokeRequired) { rtbx_Message.BeginInvoke(new Action(() =>MessageUpdate(data, color, appendText, maxLineNum, isAppendTime))); return; } lock (rtbx_Message) { rtbx_Message.SuspendLayout(); // Pause redraw to improve performance try { if (rtbx_Message.Lines.Length > maxLineNum) { rtbx_Message.Clear(); } if (isAppendTime) { rtbx_Message.AppendText($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}]:"); } if (!string.IsNullOrEmpty(appendText)) { rtbx_Message.AppendText($"{appendText}{Environment.NewLine}"); } else { rtbx_Message.AppendText($"{Environment.NewLine}"); } int startIndex = rtbx_Message.TextLength; rtbx_Message.ScrollToCaret(); rtbx_Message.SelectionStart = rtbx_Message.TextLength; rtbx_Message.AppendText($"{data}{Environment.NewLine}"); SetTextColor(rtbx_Message, startIndex, data.Length, color); } finally { rtbx_Message.ResumeLayout(); // Resume redraw } } } /// <summary> /// Set text color in the specified range of the textbox /// </summary> private void SetTextColor(RichTextBox rtb, int startIndex, int length, Color color) { rtb.Invoke(new Action(() => { // Save current selection state int originalStart = rtb.SelectionStart; int originalLength = rtb.SelectionLength; // Set new selection range rtb.Select(startIndex, length); // Change the color of the selected text rtb.SelectionColor = color; // Restore original selection state rtb.Select(originalStart, originalLength); })); }
#endregion
}
(2) ModbusRtuMaster ClassThe Modbus master class implements the following functionalities:1. Connection and disconnection of communication.2. Data reading and writing.3. Methods for obtaining communication parameters.
public class ModbusRtuMaster{ private SerialPort _serialPort; private IModbusSerialMaster _master; public SerialPortModel protModel; public byte SlaveId { get; set; } = 1;
public bool IsConnected { get; private set; } = false; public string FuncCode { get; internal set; } = "01"; public ushort StartAddress { get; set; } = 0; public ushort DataLength { get; set; } = 1; public ushort WriteStartAddress { get; set; } = 0; public ushort WriteDataLength { get; set; } = 1; public ModbusRtuMaster() { protModel = new SerialPortModel(); _serialPort = new SerialPort(); }
/// <summary> /// Baud rate, data bits, stop bits, parity bits /// </summary> /// <param name="portName"></param> /// <param name="baudRate">Baud rate</param> /// <param name="parity">Parity bit</param> /// <param name="dataBits">Data bits</param> /// <param name="stopBits">Stop bits</param> /// <returns>Connection</returns> public bool Connect(string portName, int baudRate = 9600, int dataBits = 8, Parity parity = Parity.None, StopBits stopBits = StopBits.One) { try { if (_serialPort == null) _serialPort = new SerialPort(); _serialPort.PortName = portName; _serialPort.BaudRate = baudRate; _serialPort.DataBits = dataBits; _serialPort.Parity = parity; _serialPort.StopBits = stopBits; SetTimeout(); _serialPort.Open(); _master = ModbusSerialMaster.CreateRtu(_serialPort); Debug.WriteLine($"Initialization successful!"); IsConnected = true; return true; } catch (Exception ex) { Debug.WriteLine($"Initialization failed: {ex.Message}"); IsConnected = false; return false; } }
public bool Connect(SerialPortModel portModel) { return Connect(portModel.PortName, portModel.BaudRate, portModel.DataBits, portModel.Parity, portModel.StopBits); }
public void DisConnect() { _master?.Dispose(); _serialPort?.Close(); _serialPort.Dispose(); IsConnected =false; }
/// <summary> /// Timeout settings /// </summary> public void SetTimeout(int readTimeout = 2000, int writeTimeout = 2000) { _serialPort.ReadTimeout = readTimeout; _serialPort.WriteTimeout = writeTimeout; }
#region Reading /// <summary> /// Read Coil Status (Function Code 01) /// </summary> public Task<bool[]> ReadCoilsAsync(byte slaveId, ushort startAddress, ushort numberOfPoints) { try { Task<bool[]> result = _master.ReadCoilsAsync(slaveId, startAddress, numberOfPoints); return result; } catch (Exception ex) { Debug.WriteLine($"Failed to read coils: {ex.Message}"); return null; } }
/// <summary> /// Read Input Status (Function Code 02) /// </summary> public Task<bool[]> ReadInputsAsync(byte slaveId, ushort startAddress, ushort numberOfPoints) { try { return _master.ReadInputsAsync(slaveId, startAddress, numberOfPoints); } catch (Exception ex) { Debug.WriteLine($"Failed to read input status: {ex.Message}"); return null; } }
/// <summary> /// Read Holding Registers (Function Code 03) /// </summary> public Task<ushort[]> ReadHoldingRegistersAsync(byte slaveId, ushort startAddress, ushort numberOfPoints) { try { return _master.ReadHoldingRegistersAsync(slaveId, startAddress, numberOfPoints); } catch (Exception ex) { Debug.WriteLine($"Failed to read holding registers: {ex.Message}"); throw new Exception($"Failed to read holding registers: {ex.Message}"); } }
/// <summary> /// Read Input Registers (Function Code 04) /// </summary> public Task<ushort[]> ReadInputRegistersAsync(byte slaveId, ushort startAddress, ushort numberOfPoints) { try { return _master.ReadInputRegistersAsync(slaveId, startAddress, numberOfPoints); } catch (Exception ex) { Debug.WriteLine($"Failed to read input registers: {ex.Message}"); return null; } }
#endregion
#region Writing /// <summary> /// Write Single Coil (Function Code 05) /// </summary> public bool WriteSingleCoilAsync(byte slaveId, ushort coilAddress, bool value) { try { _master.WriteSingleCoilAsync(slaveId, coilAddress, value); return true; } catch (Exception ex) { Debug.WriteLine($"Failed to write single coil: {ex.Message}"); return false; } }
/// <summary> /// Write Single Register (Function Code 06) /// </summary> public bool WriteSingleRegisterAsync(byte slaveId, ushort registerAddress, ushort value) { try { _master.WriteSingleRegisterAsync(slaveId, registerAddress, value); return true; } catch (Exception ex) { Debug.WriteLine($"Failed to write single register: {ex.Message}"); return false; } }
/// <summary> /// Write Multiple Coils (Function Code 15) /// </summary> public bool WriteMultipleCoilsAsync(byte slaveId, ushort startAddress, bool[] data) { try { _master.WriteMultipleCoilsAsync(slaveId, startAddress, data); return true; } catch (Exception ex) { Debug.WriteLine($"Failed to write multiple coils: {ex.Message}"); return false; } }
/// <summary> /// Write Multiple Registers (Function Code 16) /// </summary> public bool WriteMultipleRegistersAsync(byte slaveId, ushort startAddress, ushort[] data) { try { _master.WriteMultipleRegistersAsync(slaveId, startAddress, data); return true; } catch (Exception ex) { Debug.WriteLine($"Failed to write multiple registers: {ex.Message}"); return false; } }
#endregion
#region Get Serial Port Parameters // Baud rate array public int[] _BaudRateArray = { 9600, 14400, 19200, 38400, 57600, 115200 }; // Data bits array public int[] _DataBitArray = { 8, 7, 6, 5 }; public string[] GetSerialPortArray() { return SerialPort.GetPortNames(); } public Array GetBaudRateArray() { return _BaudRateArray; } public Array GetDataBitArray() { return _DataBitArray; } public string[] GetParityArray() { return Enum.GetNames(typeof(Parity)); } public string[] GetStopBitArray() { return Enum.GetNames(typeof(StopBits)).Skip(1).ToArray(); } #endregion
}
Conclusion
Through this case study, we learn the basic functionalities of Modbus RTU. As a beginner, I do not want to focus on the implementation principles for now, but rather on achieving functionality. By using the built-in controls in WinForms, I have created a simple interface, and through the feedback from completing the case study, I have gained interest in learning. I hope this article can be helpful to you, serving both as a share and a backup.
Project Address:gitee.com/incodenotes/csharp-modbus
For previous case study code links, click the menu 【Case Studies】
ENDIf you find this article helpful, feel free to give it a thumbs up before you go!If you have any other questions, please leave a comment for discussion!You can also join the WeChat public account [Programming Notes in] to exchange and learn together!
❖ Thank you for your attention ❖