Giao tiếp PLC bằng Modbus TCP từ C#: tự viết client tối giản
Hiểu tận gốc gói tin Modbus TCP, đọc/ghi thanh ghi, gửi số thực 32 bit và những bẫy về địa chỉ, thứ tự byte khi làm việc với PLC.
Nội dung bài viết
Kết quả kiểm tra phải đến được PLC để dây chuyền hành động. Modbus TCP là giao thức đơn giản, mở và gần như PLC nào cũng hỗ trợ. Bài này tự viết một Modbus TCP client nhỏ bằng C# để hiểu tận gốc giao thức, không phụ thuộc thư viện ngoài.
Cấu trúc một gói Modbus TCP
| Trường | Số byte | Ý nghĩa |
|---|---|---|
| Transaction ID | 2 | Mã giao dịch, phản hồi trả về đúng mã này |
| Protocol ID | 2 | Luôn là 0 |
| Length | 2 | Số byte phía sau (Unit ID + PDU) |
| Unit ID | 1 | Địa chỉ thiết bị (thường 1) |
| Function code | 1 | 0x03 đọc holding register, 0x06 ghi 1 register, 0x10 ghi nhiều register |
| Dữ liệu | n | Địa chỉ, số lượng, giá trị (big-endian) |
Client tối giản
using System.Net.Sockets;
public sealed class ModbusTcpClient : IDisposable
{
private readonly TcpClient _tcp = new();
private NetworkStream _stream = null!;
private ushort _tid;
private readonly byte _unit;
public ModbusTcpClient(string host, int port = 502, byte unit = 1)
{
_unit = unit;
_tcp.Connect(host, port);
_stream = _tcp.GetStream();
_stream.ReadTimeout = _stream.WriteTimeout = 1000;
}
private byte[] Send(byte[] pdu)
{
_tid++;
var frame = new byte[7 + pdu.Length];
frame[0] = (byte)(_tid >> 8); frame[1] = (byte)_tid;
frame[4] = (byte)((pdu.Length + 1) >> 8); frame[5] = (byte)(pdu.Length + 1);
frame[6] = _unit;
pdu.CopyTo(frame, 7);
_stream.Write(frame);
var header = ReadExact(7);
int len = (header[4] << 8) | header[5];
var resp = ReadExact(len - 1); // PDU phản hồi
if ((resp[0] & 0x80) != 0)
throw new IOException($"Modbus exception code {resp[1]}");
return resp;
}
private byte[] ReadExact(int n)
{
var buf = new byte[n];
int read = 0;
while (read < n)
{
int r = _stream.Read(buf, read, n - read);
if (r == 0) throw new IOException("Mất kết nối");
read += r;
}
return buf;
}
public ushort[] ReadHoldingRegisters(ushort start, ushort count)
{
var resp = Send(new byte[] { 0x03, (byte)(start >> 8), (byte)start, (byte)(count >> 8), (byte)count });
var values = new ushort[count];
for (int i = 0; i < count; i++)
values[i] = (ushort)((resp[2 + 2 * i] << 8) | resp[3 + 2 * i]);
return values;
}
public void WriteSingleRegister(ushort address, ushort value) =>
Send(new byte[] { 0x06, (byte)(address >> 8), (byte)address, (byte)(value >> 8), (byte)value });
public void Dispose() => _tcp.Dispose();
}
Dùng trong trạm kiểm tra
using var plc = new ModbusTcpClient("192.168.0.10");
ushort lenh = plc.ReadHoldingRegisters(0, 1)[0]; // thanh ghi 40001: lệnh từ PLC
if (lenh == 1) // 1 = trigger
{
bool ok = KiemTra(); // xử lý ảnh
plc.WriteSingleRegister(11, (ushort)(ok ? 1 : 2)); // 40012: kết quả
plc.WriteSingleRegister(10, 0b0100); // 40011: bit DONE
}
Ở đây địa chỉ trong gói tin bắt đầu từ 0, nên "40001" trong tài liệu PLC tương ứng địa chỉ 0. Đây là chỗ nhầm lẫn phổ biến nhất, hãy thống nhất với kỹ sư PLC.
Gửi số thực 32 bit
static (ushort hi, ushort lo) FloatToRegisters(float v)
{
var b = BitConverter.GetBytes(v); // little-endian trên PC
if (BitConverter.IsLittleEndian) Array.Reverse(b);
return ((ushort)((b[0] << 8) | b[1]), (ushort)((b[2] << 8) | b[3]));
}
Một số PLC đảo thứ tự hai word ("word swap"). Kiểm tra bằng cách gửi giá trị dễ nhận biết như 1.0 và xem PLC đọc được gì.
Khi đưa vào sản xuất
- Tự kết nối lại khi mất mạng, ghi log mọi lỗi giao tiếp.
- Dùng phần mềm giả lập Modbus server để thử trước khi ra hiện trường.
- Với dự án lớn, dùng thư viện Modbus đã được kiểm chứng (ví dụ NModbus) thay vì tự viết.
Xem thêm quy trình bắt tay READY/TRIGGER/DONE trong bài giao tiếp camera vision với PLC.


