用户提供了一组零散输入,我将从中提取核心主题,并按真实从业者分享经验的方式组织成一篇实操型技术文章。主题锁定为:使用C#和Xamarin.Forms开发手机组态App,通过MODBUS TCP监控台达PLC,并分享源码工程。文章将按问题背景、通信协议、组态引擎设计、核心代码、联调经验、工程化落地来组织。## 1. 为什么要在手机上做组态软件:场景与选型思考
先说个我在现场遇到的场景。某个设备调试阶段,我蹲在电柜旁边,笔记本电脑连着PLC的网口,一旁还得时刻盯着工艺参数,风扇嗡嗡转,空间挤得连键盘都摊不开。那会儿我就在想,要是口袋里掏个手机就能看寄存器值、点几个按钮、抄个报表,操作员巡检也不用背着一摞打印表。这不是懒不懒的问题,而是工业现场的移动监盘需求一直存在,只是大多数传统组态厂商把精力放在Windows桌面端,移动端要么做成了残缺的“远程桌面”,要么用HTML5套壳画几个页面,交互和实时性都不靠谱。
所以我决定自己动手,用C#写一套能够跑在Android手机上的组态App,目标很明确:可以自由配置画面,绑定台达PLC的寄存器,实时刷新数据、下发指令,最关键的是源码可控,后续要加报警推送、趋势图、报表都能自己扩展。项目定名就叫MobileSCADA.Delta,整体技术栈锁定C#,UI层采用Xamarin.Forms(如果你现在起步,也可以直接选.NET MAUI,本文大部分代码思路通用)。
先回应一个大家最常问的问题:为什么不用现成的MCGS组态屏配套App,或者Home Assistant、Grafana这类工具套壳?理由很简单,工业现场的设备协议五花八门,现成App很难做到“画面自己定义、变量随心绑定、运行逻辑完全掌握”。而自研虽然前期工作量多一些,但换来的是后续迭代的自由度——这是任何商业组态软件给不了的。至于Grafana那套,它擅长做Web仪表盘,但对PLC这种实时变量的高频读写调度并不专业。
技术路线我还仔细论证过三个方案:
| 方案 | 技术栈 | 移动端能力 | 与PLC通信难度 | 团队上手成本 |
|---|---|---|---|---|
| Xamarin.Forms / .NET MAUI | C# | 原生渲染,蓝牙、网络、本地存储完整 | 直接用C#写Socket,和桌面端上位机共用逻辑 | 低,熟悉C#即可 |
| Blazor Hybrid | C# / Web | WebView承载,调用原生能力需JS互操作 | 通信逻辑可在.NET侧复用 | 中,需兼顾前端知识 |
| Flutter / React Native | Dart / JS | 原生能力强 | 需要插件或自写本地Socket服务 | 中高,技术栈不统一 |
最终选了Xamarin.Forms,原因很实际:我原本就有一堆C#上位机代码,里面包括MODBUS主站封装、日志模块、数据字典,这些在桌面端WPF项目里已经验证过。拿到移动端只要把UI重写,通信层和数据层几乎能原封不动搬过来,这部分就是巨大的时间红利。所以这篇文章里我不会只贴APP端的代码,而是把一个完整工程的架构拆开讲,覆盖:组态文件格式、通信核心、动态页面渲染、实时刷新的性能处理,以及联调中踩过的坑。
这套东西不是那种只能跑Demo的教学代码,而是我实际拿去接台达DVP14SS2和DVP28SV做了一整轮温控和电机启停控制的工程。你照着做,是可以直接上设备的。
2. 台达PLC通信层:MODBUS TCP协议的最小实现
2.1 台达DVP系列的MODBUS地址映射规则
台达PLC支持的协议不止一种,自带编程口用的是专用通信协议,但如果你的PLC带以太网口(比如DVP-EN01扩展模块,或者DVP28SV这类本身就带网口的型号),那么最省事的就是走MODBUS TCP。为什么推荐MODBUS TCP而不是串口?因为手机没有RS232/RS485直连能力,串口方案必须外接串口服务器转WiFi或蓝牙,链路多了两层不稳定因素,远不如网线进PLC、手机走WiFi局域网来的干净。
接下来是最容易出错的部分:台达DVP系列的MODBUS地址映射。很多新手在这里栽跟头,因为MODBUS协议本身只有线圈(Coil)和寄存器(Register)两类对象,而台达PLC内部的X、Y、M、D、C等多种软元件需要各占一段地址空间。
以DVP系列常用的软元件映射为例:
| PLC软元件 | 含义 | MODBUS功能码 | 数据地址(十六进制偏移) |
|---|---|---|---|
| X | 外部输入 | 02(读离散输入) | 0x0400起 |
| Y | 外部输出 | 01(读线圈)、05(写单线圈)、15(写多线圈) | 0x0500起 |
| M | 内部继电器 | 01/05/15 | 0x0800起 |
| D | 数据寄存器 | 03(读保持寄存器)、06(写单寄存器)、16(写多寄存器) | 0x4000起 |
| C | 计数器 | 03/06/16 | 0x4600起 |
也就是说,如果你要在台达PLC里读取D100这个数据寄存器,实际上MODBUS报文里的寄存器地址是0x4000 + 100转成十进制,也就是16484。同理,要读取M50,线圈地址是0x0800 + 50。这里有个容易混淆的点:有些日系或国产PLC用的是PLC原生地址直接翻译,但台达在MODBUS映射上是带了一个固定的段偏移的,你在写通信层时必须在地址转换函数里统一加上这个偏移,否则点位全部错位,读出来一堆乱码。
2.2 功能码选择与报文拼装
MODBUS TCP的报文结构比RTU简单很多,它不需要CRC校验,而是靠MBAP头来做事务管理和长度界定。一个MODBUS TCP请求由两部分组成:7字节的MBAP头 + PDU。
MBAP头包括:事务处理标识符(2字节)、协议标识符(2字节,固定为0)、长度(2字节,表示后续字节数)、单元标识符(1字节,通常为PLC站号,默认1)。
PDU方面我们最常用的四个功能码:
- 01:读线圈,用于读Y和M
- 03:读保持寄存器,用于读D和C
- 05:写单个线圈,用于置位/复位Y和M
- 06:写单个寄存器,用于写入单个D
- 16(0x10):写多个连续寄存器,用于一次写入多个D值
比如读D100到D109十个连续寄存器,拼出来的报文是:
code复制事务ID(随机) 00 01
协议ID 00 00
长度 00 06
单元ID 01
功能码 03
起始地址 40 64 (D100 → 0x4064)
寄存器数量 00 0A (10个)
响应报文则是:
code复制事务ID(同上) 00 01
协议ID 00 00
长度 00 17
单元ID 01
功能码 03
字节数 14 (20个字节,10个寄存器×2字节)
数据 00 32 00 33 ...(每个寄存器两个字节,大端字节序)
这里要注意,MODBUS规定寄存器内部是大端字节序(高字节在前)。但台达D寄存器里如果用32位指令存浮点数,那么格式是“大字在前”还是“小字在前”取决于PLC程序怎么合并。这个坑后面我会专门讲,通信层先把单寄存器读写搞扎实。
2.3 异步通信与断线重连
手机App和PLC之间是Socket长连接,但移动端网络环境比PC上位机恶劣得多:WiFi切换、锁屏休眠、网关ARP超时,任何一个环节都可能让连接悄然断开。如果通信层不做重连机制,就会出现那种最让人抓狂的现象:App看着在线,实际上PLC那边早已把Socket释放了,你发请求永远等不到响应。
我的做法是三层保活策略:
第一层,心跳报文。每隔10秒发一次读指令(例如读PLC的D0),只要收到响应就认为链路活着。连续3次无响应就判定断线。
第二层,Socket异常捕获。发送或接收过程中一旦抛出IOException或SocketException,立即关闭当前Socket,进入重连流程。
第三层,指数退避重连。重连间隔从1秒开始,每次失败翻倍,上限30秒,成功连接后重置。为什么要退避而不是固定频率?因为如果PLC掉电或网络故障,高频重连只会让自己的CPU和电量白白耗尽,指数退避可以把无谓消耗降下来。
断线重连时还有一个细节容易被忽略:重连成功后,必须重新进行“初始化读取”。因为PLC端可能有轮询状态机,你之前订阅的变量在重连后不会有推送(MODBUS TCP本来就是拉取模式),所以要在重连完成的回调里触发一次全量读取,把画面上的所有数值刷新一遍,否则界面会停留在断线瞬间的旧数据上。
2.4 通信层的线程模型
手机上的Socket如果放在UI线程里做同步收发,稍有延迟就会导致界面卡死,这是新手最容易踩的雷。我的通信层采用独立线程 + 请求队列 + 回调通知的三段式结构:
- 发送线程:维护一个
ConcurrentQueue<ModbusRequest>,逐条取出请求发送到PLC。 - 接收线程:循环等待响应,通过事务ID匹配请求,解析出数据后向UI线程发送事件。
- 回调分发:利用Xamarin.Forms的
MainThread.BeginInvokeOnMainThread切回UI线程刷新界面。
并发控制上还需要加一个信号量:同一时刻只允许一个未完成的请求。虽然MODBUS TCP支持流水线请求,但台达PLC在默认配置下对连续快速请求的处理能力有限,如果一次发太多,PLC的响应缓冲区会溢出,然后整个链路处于半死状态。实测下来,串行化请求稳定性和响应速度都更好,单周期大约10ms左右,完全够用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
3. 组态引擎设计:用JSON描述画面,APP动态渲染
3.1 为什么组态画面不写死在XAML里
这是整个项目架构上的核心决策。如果只是监控一台固定型号的PLC,你可以直接把所有控件写死在页面的XAML里,但那就不叫“组态软件”了,叫“专用监控界面”。组态软件的灵魂在于“画面可配置”——操作员在触摸屏上拖一个按钮、绑定一个地址、设置几个属性,不需要重新编译App,这也就是MCGS、组态王这类产品的基本交互模式。
在移动端要做到同样的效果,最轻量的方案就是用JSON描述画面。我设计了一个SceneDefinition模型,一个画面文件就是一个JSON对象,里面包含控件列表、变量绑定关系、事件动作。App启动后加载JSON,经过渲染器在运行时动态创建出对应控件。这样当现场的工艺画面需要调整时,只需要修改服务器上的JSON文件,App拉取新配置即可,完全不用发版。
3.2 组态文件的数据结构
来看一个实际的画面JSON示例:
json复制{
"name": "温控主画面",
"version": "1.0",
"pollInterval": 500,
"variables": [
{ "id": "V1", "device": "plc1", "address": "D100", "type": "int16", "scale": 0.1, "offset": 0 }
],
"controls": [
{
"id": "C1",
"type": "numeric",
"title": "当前温度",
"x": 20, "y": 30, "width": 160, "height": 60,
"bindings": { "value": "V1" }
},
{
"id": "C2",
"type": "button",
"title": "启动电机",
"x": 20, "y": 110, "width": 120, "height": 50,
"actions": { "click": [ { "type": "write", "address": "M50", "value": "ON" } ] }
}
]
}
这个结构看起来简单,但设计时我做了几个关键取舍:
第一,变量表独立于控件。控件通过bindings引用变量ID,好处是同一个D100可以被多个控件绑定,并且当变量语义变化时只改变量表,不用逐个控件去改。这和传统组态软件“变量字典”的概念是一致的。
第二,scale和offset字段。工业现场的数据从来不是裸的整数。温控模块读取出来的寄存器值常常是原始AD码,要乘以0.1才是实际温度;压力传感器可能是4-20mA信号经过线性映射到0-100kPa。通信层只负责把寄存器原始值读出来,量程转换交给显示层,这两个字段就是干这个用的。
第三,pollInterval是画面级属性。不同画面有不同的刷新需求:工艺参数画面可能500ms刷新就够了,而高速计数画面可能要求100ms。让每个画面自己定义轮询周期,比全局统一更灵活,也避免了不必要的网络流量。
3.3 动态渲染器:JSON到UI控件的映射
渲染器的核心是一个工厂方法,根据control.type创建对应的自定义控件。我定义了基类BaseBindableControl,它内部维护一个VariableBinding列表,每个绑定负责将VariableReadEventArgs事件里的新值写入控件的显示属性。
这里使用了两种刷新模式,对应不同控件类型:
- 推送模式适合数值显示、文本显示,变量更新事件到达后直接赋值。
- 拉取模式适合按钮、开关,这类控件通常没有持续刷新需求,只在用户交互时触发读写。
渲染器的具体实现里有一个特别容易踩坑的点:动态创建的控件必须设置AutomationId,也就是控件的唯一标识。没有唯一标识的话,后续查找控件、更新值时只能靠遍历可视树,性能极差而且容易抓错元素。我的做法是把GroupedID设计成画面ID_控件ID的组合,值更新时可以直接通过FindByName定位控件,干净利落。
3.4 变量轮询与事件分发机制
通信层跑在后台线程,UI控件跑在界面线程,两者之间必须有规范化的事件通道。我定义了一个VariableHub静态类作为全局的中枢:
- 通信层每读回一批数据,就调用
VariableHub.Publish(variableId, value)。 - 每个控件在创建时调用
VariableHub.Subscribe(variableId, handler)。 - 控件销毁时调用
VariableHub.Unsubscribe(variableId, handler)。
批量读取方面,不是每个变量单独发一个请求,而是把当前画面所有变量按地址段分组归并。比如画面里有D100、D101、D200三个变量,通信层会发出两个请求:读D100起始长度2、读D200起始长度1。这样一个画面通常只需要几个请求就能把全部数据拉回来,大幅降低PLC的响应压力。
实测数据:一个包含30个变量的画面,如果逐个读取,一轮完整刷新大约需要300ms;改成归并读取后,一轮刷新只需要50ms左右。这个性能差异在操作员快速切换画面时感受特别明显。
4. 实战手写核心代码:通信、解析与界面绑定
4.1 MODBUS TCP客户端类
这是通信层的核心,我贴一段精简过的代码,实际工程在此基础上加了日志、重试和统计。
csharp复制public class ModbusTcpClient
{
private TcpClient _tcpClient;
private NetworkStream _stream;
private SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
private ushort _transactionId = 0;
private CancellationTokenSource _cts;
public event EventHandler<byte[]> ResponseReceived;
public event EventHandler Disconnected;
public bool IsConnected => _tcpClient?.Connected ?? false;
public async Task<bool> ConnectAsync(string ip, int port, CancellationToken ct = default)
{
try
{
_tcpClient = new TcpClient();
_tcpClient.NoDelay = true;
await _tcpClient.ConnectAsync(ip, port, ct);
_stream = _tcpClient.GetStream();
_cts = new CancellationTokenSource();
StartReceiveLoop();
return true;
}
catch (Exception ex)
{
Log.Error($"连接失败: {ex.Message}");
return false;
}
}
private async void StartReceiveLoop()
{
byte[] header = new byte[7];
while (!_cts.IsCancellationRequested)
{
int read = await _stream.ReadAsync(header, 0, 7, _cts.Token);
if (read == 0) break;
int length = (header[4] << 8) | header[5];
byte[] body = new byte[length - 1]; // 减去unit id
await ReadFullAsync(_stream, body, 0, body.Length, _cts.Token);
byte[] fullFrame = new byte[7 + body.Length];
Buffer.BlockCopy(header, 0, fullFrame, 0, 7);
Buffer.BlockCopy(body, 0, fullFrame, 7, body.Length);
ResponseReceived?.Invoke(this, fullFrame);
}
Disconnected?.Invoke(this, EventArgs.Empty);
}
public async Task<byte[]> SendRequestAsync(byte unitId, byte functionCode, byte[] data, int timeoutMs = 1000)
{
await _sendLock.WaitAsync();
try
{
ushort txId = ++_transactionId;
byte[] request = BuildFrame(txId, unitId, functionCode, data);
await _stream.WriteAsync(request, 0, request.Length);
await _stream.FlushAsync();
var tcs = new TaskCompletionSource<byte[]>();
using var timer = new CancellationTokenSource(timeoutMs);
void Handler(object sender, byte[] frame)
{
ushort respTxId = (ushort)((frame[0] << 8) | frame[1]);
if (respTxId == txId)
{
tcs.TrySetResult(frame);
}
}
ResponseReceived += Handler;
try
{
var result = await tcs.Task.WaitAsync(TimeSpan.FromMilliseconds(timeoutMs));
if (result[7] != functionCode)
{
throw new ModbusException($"功能码异常: {result[7]:X2}, 错误码: {result[8]:X2}");
}
return result;
}
finally
{
ResponseReceived -= Handler;
}
}
finally
{
_sendLock.Release();
}
}
private byte[] BuildFrame(ushort txId, byte unitId, byte functionCode, byte[] data)
{
byte[] frame = new byte[9 + data.Length];
frame[0] = (byte)(txId >> 8);
frame[1] = (byte)(txId & 0xFF);
frame[2] = 0x00; // 协议ID高字节
frame[3] = 0x00; // 协议ID低字节
frame[4] = (byte)((data.Length + 2) >> 8); // 长度高字节
frame[5] = (byte)((data.Length + 2) & 0xFF); // 长度低字节
frame[6] = unitId;
frame[7] = functionCode;
Buffer.BlockCopy(data, 0, frame, 8, data.Length);
return frame;
}
}
这个类有几个设计点值得说:
_sendLock信号量保证了同一时刻只有一个未完成的请求。这在响应匹配上非常重要——MODBUS TCP用事务ID来匹配请求和响应,如果多个线程同时发请求,每个请求的事务ID虽然不同,但接收线程逐个分发时,回调匹配的复杂度会指数上升。串行化后,代码逻辑简单可靠,性能完全够用。
ResponseReceived走的是事件机制,断线检测也在接收循环里。当ReadAsync返回0时说明远端关闭了连接,必须在UI层弹出提示并启动重连。
4.2 变量地址解析与功能码映射
地址字符串(如“D100”“M50”)解析成功能码和偏移地址,是通信层和画面层之间的桥梁。这部分代码我单独抽了一个静态类。
csharp复制public static class DeltaAddressConverter
{
public static (byte FunctionCode, ushort Address) Convert(string address, OperateMode mode)
{
string type = address.Substring(0, 1).ToUpper();
int number = int.Parse(address.Substring(1));
switch (type)
{
case "X":
return (0x02, (ushort)(0x0400 + number));
case "Y":
return mode == OperateMode.Read ? (0x01, (ushort)(0x0500 + number)) : (0x05, (ushort)(0x0500 + number));
case "M":
return mode == OperateMode.Read ? (0x01, (ushort)(0x0800 + number)) : (0x05, (ushort)(0x0800 + number));
case "D":
return mode == OperateMode.Read ? (0x03, (ushort)(0x4000 + number)) : (0x06, (ushort)(0x4000 + number));
case "C":
return mode == OperateMode.Read ? (0x03, (ushort)(0x4600 + number)) : (0x06, (ushort)(0x4600 + number));
default:
throw new NotSupportedException($"不支持的软元件类型: {type}");
}
}
public static string ConvertBack(byte functionCode, ushort address)
{
// 反向转换,用于调试时定位
return (functionCode, address & 0xF000) switch
{
(0x02, 0x0400) => $"X{address - 0x0400}",
(0x01 or 0x05, 0x0500) => $"Y{address - 0x0500}",
(0x01 or 0x05, 0x0800) => $"M{address - 0x0800}",
(0x03 or 0x06, 0x4000) => $"D{address - 0x4000}",
(0x03 or 0x06, 0x4600) => $"C{address - 0x4600}",
_ => $"未知({functionCode:X2}:{address:X4})"
};
}
}
这一段代码看起来简单,却是整个项目里最容易写错的地方。注意X和Y在PLC里的实际地址区间可能因型号而异,比如DVP-14SS2的X范围是X0到X13,DVP-28SV的X范围有X0到X17,但MODBUS映射规则本身不受影响——你只能在PLC实际存在的地址范围内操作,超范围会返回异常响应。
4.3 组态页面渲染和值更新
页面渲染器负责把JSON定义转成VisualElement,并注册变量订阅。核心逻辑:
csharp复制public class SceneRenderer
{
private SceneDefinition _scene;
private Dictionary<string, BaseBindableControl> _controls = new();
public ContentView RenderScene(SceneDefinition scene)
{
_scene = scene;
var container = new Grid();
container.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
container.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
foreach (var controlDef in scene.Controls)
{
var control = ControlFactory.Create(controlDef);
control.SetValue(Grid.RowProperty, controlDef.Y);
control.SetValue(Grid.ColumnProperty, controlDef.X);
AbsoluteLayout.SetLayoutBounds(control, new Rectangle(
controlDef.X, controlDef.Y, controlDef.Width, controlDef.Height));
container.Children.Add(control);
_controls[controlDef.Id] = control;
foreach (var binding in controlDef.Bindings)
{
string varId = binding.Value;
var variable = scene.Variables.FirstOrDefault(v => v.Id == varId);
if (variable != null)
{
VariableHub.Subscribe(varId, value =>
{
MainThread.BeginInvokeOnMainThread(() =>
{
control.OnVariableUpdated(binding.Key, value, variable);
});
});
}
}
}
return new ContentView { Content = container };
}
}
布局上我用了AbsoluteLayout,因为它最贴合传统组态软件“坐标+宽高”的排列方式,也方便把老组态软件的画面直接翻译成JSON。Grid固然灵活,但对组态场景反而碍手碍脚。
4.4 供PLC主动写入的按钮动作
组态画面不只是“看”,还要“控”。按钮按下和松开的动作,我用了一个简单的绑定模型:
csharp复制public class ButtonControl : BaseBindableControl
{
private readonly Button _button = new Button();
private List<WriteAction> _clickActions;
public override void Initialize(ControlDefinition def)
{
_button.Text = def.Title;
_clickActions = def.Actions?.Where(a => a.Type == "write").ToList() ?? new List<WriteAction>();
_button.Pressed += async (s, e) =>
{
foreach (var action in _clickActions.Where(a => a.Trigger == "pressed"))
{
await ExecuteWriteAsync(action);
}
};
_button.Released += async (s, e) =>
{
foreach (var action in _clickActions.Where(a => a.Trigger == "released"))
{
await ExecuteWriteAsync(action);
}
};
Content = _button;
}
private async Task ExecuteWriteAsync(WriteAction action)
{
try
{
var (fc, addr) = DeltaAddressConverter.Convert(action.Address, OperateMode.Write);
byte[] data;
if (fc == 0x05)
{
data = new byte[] { (byte)(addr >> 8), (byte)(addr & 0xFF), action.Value == "ON" ? (byte)0xFF : (byte)0x00, 0x00 };
}
else
{
short val = short.Parse(action.Value);
data = new byte[] { (byte)(addr >> 8), (byte)(addr & 0xFF), (byte)(val >> 8), (byte)(val & 0xFF) };
}
var response = await App.PlcClient.SendRequestAsync(1, fc, data);
// 响应正常即可认为写入成功
}
catch (Exception ex)
{
Log.Error($"写入失败: {action.Address}, {ex.Message}");
await MainThread.BeginInvokeOnMainThread(async () =>
{
await Application.Current.MainPage.DisplayAlert("写操作失败", ex.Message, "OK");
});
}
}
}
按下/松开双事件的按钮设计,对应的是电机点动和电焊控制这类需要按住持续输出、松手停止的场景。这也是组态交互和普通App交互最大的差异:工业操作往往不能只是“点击一下”,而是需要持续按住,确保安全。
5. 现场联调踩坑实录:数据异常、地址混乱、界面卡死的排查链路
5.1 检测地址偏移错误:现象是部分数据能读、部分乱码
第一次接真机调试,D寄存器读出来的数据是对的,但M线圈的读取结果全乱——地址明明是M50,读回来的却是另一个从未操作过的M。当时我第一反应是PLC侧的软元件配置有问题,改了很久程序都没用,最后用MODBUS调试助手逐个地址扫描才发现:台达部分固件版本里,M区的MODBUS偏移不是0x0800,而是0x0000起连续排布,这取决于PLC型号和“附件通信模块”的设置。
排查步骤记录一下,方便你少走弯路:
- 第一步,用一个MODBUS调试工具(我用的ModbusPoll),手动读写同一软元件,看默认偏移是否和协议手册一致。
- 第二步,如果默认偏移对不上,检查PLC程序里是否启用了“MODBUS地址映射”特殊寄存器。DVP系列里,
D9900、D9901等特殊寄存器可以自定义通信地址映射,有时候上一位工程师在PLC程序里改了映射规则,但没告诉任何人。 - 第三步,检查站号。单元ID不匹配时,PLC会直接丢弃请求,表现是“连上了,但没任何响应”,而不是返回错误码。
最后发现,那台PLC的M区映射确实被改过。解决思路很干脆:不在通信层兼容这种脏配置,而是在“设备配置”里增加一个“地址偏移修正”字段,让现场部署人员可以根据实际PLC配置填入修正值。这比写死规则要稳得多。
5.2 浮点数高低字节序:读出来就是一组乱码
台达D寄存器存32位浮点数有两种常见方式:
- 方式A:低地址寄存器存高16位(大端,高位在前)
- 方式B:低地址寄存器存低16位(小端,低位在前)
两种方式取决于PLC程序里浮点数写入时用的是MOVD32还是拆字节指令序列,以及PLC固件对IEEE754的默认排列。很多人在这一步抓狂,因为同一个D100,用ModbusPoll读、用组态王读、用手机App读,可能得到三个不同的结果。
我的处理策略是在变量表里增加一个endian字段,允许每个变量单独指定字节序。
csharp复制public static float ReadFloatFromRegisters(byte[] data, int startIndex, EndianType endian)
{
byte[] floatBytes = new byte[4];
if (endian == EndianType.AB_CD) // 大端
{
floatBytes[0] = data[startIndex];
floatBytes[1] = data[startIndex + 1];
floatBytes[2] = data[startIndex + 2];
floatBytes[3] = data[startIndex + 3];
}
else // CD_AB 小端
{
floatBytes[0] = data[startIndex + 2];
floatBytes[1] = data[startIndex + 3];
floatBytes[2] = data[startIndex];
floatBytes[3] = data[startIndex + 1];
}
return BitConverter.ToSingle(floatBytes, 0);
}
如果懒得分这种细粒度,那就在“设备配置”里加一个全局字节序选项,90%的台达设备用默认值就能搞定。但注意,如果你同时接了台达和三菱两种PLC,全局选项就不够了,必须变量级覆盖。
5.3 UI线程卡顿:动态创建控件引发的主线程爆堵
组态画面第一次打开时,如果直接从JSON创建几十个控件,尤其是每个控件都带渐变、阴影、自定义字体这些视觉效果,在低端Android设备上会出现1-2秒的界面卡死,甚至直接ANR。
这个问题的本质不是数据刷新慢,而是UI首次构建的成本太高。我试过两种优化方案:
第一个方案是“分帧渲染”。把控件列表按数量拆成几批,每批创建5-8个,每批之间await Task.Delay(16)让出UI主线程,保证界面在第一批控件出来时就能响应触摸事件。体验上虽然看起来是逐批出现的,但比起白屏几秒要好得多。
第二个方案是“控件池”。画面切换时保留之前画面的控件实例,只是隐藏或移出可视树,不销毁。这样返回该画面时不需要重新构建UI,只要重新订阅变量事件即可。代价是内存占用增加——但对10个以内的画面对来说,多占几十MB内存在现在的手机上完全不是问题。
这两招组合使用后,画面切换速度从“卡顿1.5秒”降到“几乎无感切换”。
5.4 断线后不自动重连:看似在线的僵死状态
正常工作时不会出问题,但只要PLC重启或者WiFi切换,App就进入“假死”状态——界面停着,再也不刷新。这不是丢包的问题,而是接收循环在ReadAsync调用处阻塞时,PLC端重启导致连接被RST,但客户端未必立刻感知到。
我最初的实现里,重连逻辑放在Disconnected事件里,但实际测试发现,TCP的RST包到达时间并不确定,有时需要等系统TCP超时(默认好几分钟)才能触发异常。所以我把保活心跳当成了连接的“真·基石”——只要心跳超时,就主动断开,主动重连。心跳请求本身被设计成一次最小读操作(读D0),而不是单独的心跳帧。好处是:一次读操作既保活,又捎带了数据刷新,一举两得。
5.5 现场调试的辅助工具:日志回放和数据嗅探
手机App不像PC上位机那么方便打印日志查看,但排错几乎离不开日志。我的做法是内置了一个环形缓冲区日志存储到手机本地文件,同时在设置页面加了“导出Log”按钮,把日志文件通过邮件或网盘发送给开发人员。
日志里不仅要记录错误信息,还要记录MODBUS原始报文和解析结果。比如:
code复制[10:23:45.123] -> 发送: 01 03 40 64 00 0A C5 C8
[10:23:45.234] <- 接收: 01 03 14 00 32 ... (14 bytes data)
有了原始报文,很多问题不需要去现场看设备,远程看日志就能定位。这是我在这个项目里觉得最值的一项投入。
6. 工程化落地:从Demo到能上生产的几个关键细节
6.1 解决方案的项目划分和依赖关系
一个能上生产的工程,不能把代码全塞进一个项目。我的解决方案分成四个项目:
| 项目 | 职责 | 依赖 |
|---|---|---|
| MobileSCADA.Core | 通信层、变量引擎、日志、配置序列化 | 无第三方依赖 |
| MobileSCADA.SceneEngine | 组态渲染器、控件库、JSON解析 | Core |
| MobileSCADA.App | Xamarin.Forms应用外壳、页面导航、设置界面 | Core, SceneEngine |
| MobileSCADA.Test | 单元测试,覆盖MODBUS报文组装和地址转换 | Core |
这样拆的好处是Core和SceneEngine可以单独写单元测试,不用启动手机模拟器。我写了不少测试用例,其中最有价值的就是“MODBUS报文合法性验证”和“模拟PLC响应桩”。后者是一个轻量的TCP服务,模仿台达PLC返回数据,这样在没有真实PLC的情况下也能调试画面逻辑。
6.2 配置管理:密码、IP和场景文件
组态软件必然要面对“现场工程师修改配置”的需求。我的APP把配置文件分三层:
- 设备配置:存IP、端口、单元ID、超时时间。存成JSON在私有目录,内部密码保护。
- 画面配置:从服务器拉取,本地缓存。服务器不可用时使用缓存版本。
- 用户配置:语言、主题、日志级别。
设备配置里的密码保护要特别说明一下。PLC控制指令的权限管理比界面好看更重要,任何人都能通过这个App启动停止电机的话,生产安全就是笑话。我的做法是:在App层面提供“只读模式”和“操作模式”,操作模式需要输入4位PIN码,PIN码由PLC侧的特殊寄存器或密码文件校验,不在App本地明文保存。
6.3 发布到Android设备:打包签名与权限
Xamarin.Forms打包Android APK并不复杂,但有几个坑:
Android 8.0以上使用明文TCP Socket连接必须申请INTERNET权限,同时在networkSecurityConfig里允许cleartextTraffic,否则会直接报“Socket连接失败”但日志里看不出来是权限限制。
后台运行时保持TCP长连接,需要在手机上关闭电池优化白名单。不然App进入后台几分钟,系统就把网络连接断了。这属于Android电源管理统一机制,没别的办法,只能引导用户在系统设置里把App加入“不受电池优化限制”列表。
6.4 扩展思路:从单机监控到多设备集群和云端
最后聊一下这套架构后续怎么扩展。
目前App连接的是单个PLC,但工厂里往往有十几台设备。我在Core层预留了DeviceConnectionManager,支持同时维护多个PLCSession,每个Session有独立的IP、站号和变量表。画面JSON里通过variables[].device字段指定设备ID,这样一套组态画面可以自由绑定多个设备下的不同变量。
再往上走,如果要云端远程监控,有两种常见路线:
- 一是PLC → 本地网关 → MQTT → 云服务器 → 手机App。手机不直接连PLC,而是通过MQTT订阅数据。这条路线的好处是穿透性好,不需要公网端口映射。
- 二是保留目前TCP直连模式,在云端搭一个TCP中转服务,把现场PLC端口映射到公网。优点是改动小,缺点是需要公网服务而且安全风险高。
我个人的建议是:现场局域网内巡检用本文的TCP直连方案就足够;跨地域远程运维时再引入MQTT网关层。两者在变量引擎层面可以共用同一套模型——都是一条“变量ID→数据源”的路由,只是数据源类型从“TcpDevice”换成“MqttSubscriber”而已。这正是把组态引擎和通信层解耦带来的好处:画面不用变,数据通道可以按需切换。
回到最初那个蹲在电柜旁的场景。现在我在手机上打开这个App,切到“温控主画面”,几个关键温度点实时跳动着,需要调整某个回路就点击对应的按钮,PLC那边立刻响应。整个项目从零到能干活,核心工期大约三周,其中通信层占了两天、渲染引擎占了十天、UI打磨用了剩下的时间。最大的成本不是写代码,而是想清楚架构怎么组织。如果你手头也有“给PLC做移动监控”的需求,希望以上拆解能帮你少走几段弯路。源码工程在整理规范后,我会把通信层和渲染引擎的核心部分开源发布,届时会在文末更新地址。
