3. 设备通信抽象层设计

在工业上位机中,设备通信往往不是单一协议:PLC 走 Modbus TCP,称重仪表走串口,扫码枪走 TCP,部分传感器走 UDP 广播。如果每个界面控件都直接调用 QSerialPort、QTcpSocket 或 Modbus 库,协议细节就会渗透到 UI 层,导致换一种设备就要改界面代码。通信抽象层的目标,是把“设备”抽象成统一对象,让上层只关心连接、读写、状态和错误,而不关心底层是串口还是网络。

统一接口:把设备当成可连接、可读写、可观察的对象

抽象层的核心是一个纯虚接口,通常命名为 IDevice 或 ICommDevice。它不继承 QObject,而是由具体实现类同时继承 QObject 和该接口,这样既能用 Qt 信号槽,又能被上层以接口指针持有。接口应覆盖四类能力:连接管理、数据读写、状态通知、错误码映射。

class IDevice : public QObject {
    Q_OBJECT
public:
    virtual ~IDevice() = default;
    virtual bool open() = 0;
    virtual void close() = 0;
    virtual bool isOpen() const = 0;
    virtual qint64 write(const QByteArray &data) = 0;
    virtual QString lastErrorText() const = 0;
    virtual int lastErrorCode() const = 0;
signals:
    void dataReceived(const QByteArray &data);
    void stateChanged(DeviceState state);
    void errorOccurred(int code, const QString &text);
};

这里的关键是:write() 只负责把字节写出去,不负责解析;dataReceived() 只负责把原始字节交给上层,不负责判断是温度还是重量。协议解析属于“设备驱动”或“协议适配器”的职责,抽象层只做通道管理。这样,Modbus 的寄存器地址、串口的波特率、TCP 的 IP 端口,都不会出现在界面代码里。

协议差异封装:串口、TCP、UDP、Modbus 各自实现

串口设备实现 SerialDevice,内部持有 QSerialPort,负责波特率、数据位、停止位、校验位配置,并用 readyRead 信号触发 dataReceived。TCP 设备实现 TcpDevice,内部持有 QTcpSocket,处理连接建立、断线检测和粘包问题。UDP 设备实现 UdpDevice,内部持有 QUdpSocket,处理广播地址和接收缓冲区。Modbus 设备则有两种做法:一种是实现 ModbusRtuDevice 和 ModbusTcpDevice,内部组合串口或 TCP 通道,再叠加 Modbus 帧封装;另一种是让 Modbus 设备直接继承 IDevice,把功能码读写封装成 readHoldingRegisters() 等语义方法。

无论哪种实现,上层看到的都是同一个 IDevice*。界面按钮点击“连接”,调用的是 open();定时器触发“读取”,调用的是 write() 或语义方法;数据回来,槽函数接收 dataReceived。协议差异被隔离在实现类内部。

设备通信抽象层:接口、实现与上层解耦 上层界面 / 业务逻辑 只依赖 IDevice*,不包含任何协议头文件 open / write / close IDevice 统一接口 open() · close() · write() · dataReceived() · stateChanged() · errorOccurred() SerialDevice QSerialPort 波特率 / 校验 / 停止位 TcpDevice QTcpSocket IP / 端口 / 粘包处理 UdpDevice QUdpSocket 广播 / 接收缓冲区 ModbusDevice RTU / TCP 帧封装 功能码 / 寄存器地址 连接管理 超时重连 · 心跳保活 状态机:断开/连接中/已连接 错误码映射 底层错误 → 统一错误码 超时 / 断线 / 校验失败 / 拒绝 状态通知 stateChanged 信号 界面指示灯 / 日志 / 告警

连接管理:状态机与超时重连

连接管理不能只靠一个 isOpen() 布尔值。工业现场网络抖动、串口拔插、设备断电都会导致连接中断,因此需要显式状态机。建议定义四种状态:Disconnected、Connecting、Connected、Reconnecting。每次状态迁移都发出 stateChanged 信号,界面据此更新指示灯颜色和按钮可用性。

超时重连的典型参数是:连接超时 3000 ms,重连间隔 2000 ms,最大重连次数 5 次。实现上可以用 QTimer 做单次触发:open() 后启动连接超时定时器,若在 3000 ms 内没有收到 connected 信号,则判定超时,关闭当前 socket,进入 Reconnecting,等待 2000 ms 后再次尝试。重连次数用计数器控制,超过上限后停止并发出错误通知,避免无限重试耗尽资源。

对于串口,没有“连接”概念,open() 成功即视为已连接;但串口拔出时 QSerialPort 会发出 errorOccurred,此时应关闭并进入重连流程。对于 UDP,本身无连接,但可以定期发送心跳包并等待回应,若连续 3 个周期无回应,则判定链路异常。

错误码映射:让上层看到统一语义

底层库的错误码各不相同:QSerialPort 有 ResourceError、PermissionError;QTcpSocket 有 ConnectionRefusedError、RemoteHostClosedError;Modbus 库可能返回异常码 0x02(非法数据地址)。如果把这些直接抛给界面,界面就要写大量 if-else 判断。抽象层应定义一套统一错误码,例如:

统一错误码含义典型底层来源
0x0001连接超时TCP 连接超时、串口打开超时
0x0002连接被拒绝ConnectionRefusedError
0x0003链路断开RemoteHostClosedError、串口拔出
0x0004数据校验失败Modbus CRC 错误、帧格式错误
0x0005设备无响应Modbus 超时、心跳无回应
0x0006参数配置错误波特率不支持、IP 格式错误

每个具体设备实现类在捕获底层错误后,调用一个映射函数转换为统一错误码,再通过 errorOccurred(code, text) 发出。界面只需要根据统一错误码决定提示文案和恢复动作,例如 0x0003 触发重连,0x0004 记录日志并重发,0x0006 弹出配置对话框。

数据流与线程边界

工业上位机中,通信通常放在独立线程,避免阻塞 UI。抽象层应明确线程归属:IDevice 对象在通信线程创建,其信号通过 Qt::QueuedConnection 跨线程投递到 UI 线程。上层调用 write() 时,如果跨线程,应使用 QMetaObject::invokeMethod 或信号槽方式,而不是直接调用,否则会触发 Qt 的线程断言。

一个常见的做法是:每个设备对象配一个 QThread,设备对象 moveToThread() 到该线程。UI 线程通过信号触发 open()、write(),设备线程通过 dataReceived 把数据发回 UI。这样,串口读取、TCP 接收、Modbus 轮询都在各自线程内完成,界面保持流畅。

实践建议:抽象层不要试图封装“业务语义”。例如“读取温度”不应成为 IDevice 的方法,而应由上层组合 write() 和 dataReceived() 完成。抽象层只解决“通道”问题,协议解析和业务映射放在设备驱动或业务适配器中,这样接口才能稳定,不会因为设备型号增加而频繁修改。

一个可运行的配置示例

假设现场有一台 Modbus TCP 的温控器,IP 为 192.168.1.50,端口 502,从站地址 1,需要每 500 ms 读取保持寄存器 0x0000(温度值,单位 0.1℃)。抽象层配置可以写成:

DeviceConfig cfg;
cfg.type = DeviceType::ModbusTcp;
cfg.host = "192.168.1.50";
cfg.port = 502;
cfg.slaveId = 1;
cfg.connectTimeoutMs = 3000;
cfg.reconnectIntervalMs = 2000;
cfg.maxReconnectCount = 5;
cfg.pollIntervalMs = 500;

IDevice *dev = DeviceFactory::create(cfg);
connect(dev, &IDevice::dataReceived, this, &MainWindow::onModbusData);
connect(dev, &IDevice::stateChanged, this, &MainWindow::onDeviceState);
connect(dev, &IDevice::errorOccurred, this, &MainWindow::onDeviceError);
dev->open();

当温控器断电时,TcpDevice 检测到 RemoteHostClosedError,映射为 0x0003,发出 stateChanged(Reconnecting),界面指示灯变黄;2 秒后自动重连,若成功则恢复绿色,若 5 次失败则变红并提示“设备离线”。整个过程界面代码没有出现任何 Modbus 或 socket 细节,这就是抽象层的价值。

注意:重连间隔不宜过短。工业交换机在链路抖动时,若 200 ms 就重连一次,可能造成连接风暴。建议根据设备类型设置:串口 1000~2000 ms,TCP 2000~5000 ms,UDP 心跳 3000 ms 以上。同时,重连次数上限应可配置,避免无人值守时无限重试。

总结来说,设备通信抽象层的设计要点是:用纯虚接口统一能力,用具体实现封装协议差异,用状态机管理连接生命周期,用统一错误码屏蔽底层差异,用线程边界保证界面流畅。做到这五点,上层界面就能真正与具体协议解耦,新增一种设备只需要增加一个实现类,而不需要改动已有界面代码。