ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

现场实录:一次PLC通信超时导致的停机事故,我是如何用日志逆向定位并修复的

现场实录:一次PLC通信超时导致的停机事故,我是如何用日志逆向定位并修复的

遇到通信问题,90%的工程师第一反应是查网线、换接口、量电压。但这些动作都指向“物理层”,而真正的坑,往往藏在“逻辑层”。那晚我做的第一件事,不是动螺丝刀,而是把PLC的通信日志完整导了出来。记住,日志是案发现场的照片,能拍下凶手,也能还你清白。

<p></p>

我用的工具是Qt自带的`QLoggingCategory`,加上一个自己封装的文件回滚写入器。别小看这玩意,7x24小时跑的机器,日志文件如果不做大小限制,一个星期就能吃掉几个GB的硬盘空间。

```cpp

// 日志模块头文件 logmanager.h

#pragma once

#include <QObject>

#include <QFile>

#include <QTextStream>

#include <QMutex>

#include <QDate>

class LogManager : public QObject {

Q_OBJECT

public:

static LogManager& instance() {

static LogManager mgr;

return mgr;

}

void write(const QString& level, const QString& msg) {

QMutexLocker locker(&m_mutex);

if (!m_file.isOpen()) {

initFile();

}

if (m_file.size() > 50 * 1024 * 1024) { // 50MB回滚

rollout();

}

QTextStream out(&m_file);

out << QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss.zzz")

<< " [" << level << "] " << msg << "\n";

out.flush(); // 关键:必须flush,否则断电丢失

}

private:

LogManager() {}

void initFile() {

QString path = QString("./logs/%1.log").arg(QDate::currentDate().toString("yyyyMMdd"));

m_file.setFileName(path);

m_file.open(QIODevice::Append | QIODevice::Text);

}

void rollout() {

m_file.close();

QString old = m_file.fileName() + ".1";

QFile::rename(m_file.fileName(), old);

initFile();

}

QFile m_file;

QMutex m_mutex;

};

#define LOG_INFO(msg) LogManager::instance().write("INFO", msg)

#define LOG_ERROR(msg) LogManager::instance().write("ERROR", msg)

```

**坑点提醒**:很多人写日志用`endl`,那是给文本流用的,会强制flush,但频繁flush会拖慢通信线程。我这里用`"\n"`加显示`flush()`,既保证断电不丢,又不造成IO瓶颈。还有,线程里直接调`LOG_INFO`不要加锁操作业务变量,否则日志会反过来锁死你的程序。

## 逆向定位:从“超时”到“谁发的包”

通信日志只是基础,真正让我定位到元凶的是**报文序列比对**。我把现场抓到的PLC反馈帧和本地发送帧,按时间戳对齐,发现了一个可怕的规律:每次超时前3毫秒,上位机总是会多发一个0x10 0x01的死活检测帧,而PLC在处理完正常业务帧后,还没来得及回,就又被这个检测帧给挤爆了缓冲区。

<p></p>

我当时写了个小工具,把日志里所有“Send”和“Recv”的间隔熬出来看分布。

```cpp

// 日志分析伪代码,核心逻辑展示

void analyzeIntervals(const QVector<LogEntry>& entries) {

QVector<int> intervals;

for (int i = 1; i < entries.size(); ++i) {

if (entries[i].type == "RECV" && entries[i-1].type == "SEND") {

int gap = entries[i].timestamp.msecsTo(entries[i-1].timestamp);

intervals.append(gap);

}

}

// 画出直方图,发现两个峰:5ms正常,27ms异常

// 异常峰对应发送频率>200Hz时PLC的响应延迟

}

```

</p>

真实情况是:我用`QTimer`设置了一个10ms的看门狗,结果在Windows上,定时器精度不够,实际触发周期抖动到6~15ms。当业务繁忙时,这个检测包跟正常数据包在`QTcpSocket`的`write()`缓冲里排堆了,PLC的串口处理不过来,直接丢弃。

## 修复不是改参数,而是改架构

很多人以为把PLC的通信超时从50ms调到200ms就完事了。我告诉你,那是把伤口盖住,不是治病。我做的修复是双管齐下:

**第一,把看门狗从定时器轮询,改成基于应答的滑动窗口**。只有在上一次业务请求超时后才发起检测,而不是纯周期轰炸。

**第二,给通信线程提升优先级,并且把`write()`和`waitForBytesWritten()`拆开**,避免一个慢接口卡住整个发送队列。

```cpp

// 通信线程核心逻辑

void CommThread::run() {

m_socket = new QTcpSocket();

// 必须设置低延迟选项

m_socket->setSocketOption(QAbstractSocket::LowDelayOption, 1);

while (!m_stop) {

// 处理业务请求

{

QMutexLocker locker(&m_reqMutex);

if (!m_pendingRequests.isEmpty()) {

QByteArray data = m_pendingRequests.takeFirst();

m_socket->write(data);

m_socket->flush(); // 立即发送,不攒包

}

}

// 等待应答(非阻塞方式)

if (m_socket->waitForReadyRead(5)) {

QByteArray resp = m_socket->readAll();

emit responseReady(resp);

m_lastResponseTime = QDateTime::currentDateTime();

} else {

// 超时处理,检查是否超过600ms

if (m_lastResponseTime.msecsTo(QDateTime::currentDateTime()) > 600) {

LOG_ERROR("PLC response timeout after 600ms");

emit commTimeout();

}

}

}

}

```

**坑点提醒**:还有个大坑就是`write()`只是把数据复制到系统缓冲区,不代表数据已经发出去了。你必须在`write()`之后立即调用`flush()`,否则在TCP的Nagle算法下,小包会守在缓冲区里等大包一起发,导致平均延迟增加40ms。这种问题在调试器里看不出来,因为调试器会改变时序,只有日志里才能看穿。

## 那个夜里,日志替我找出了“内鬼”

修复上线后,我特意让日志模块多跑了一个月,确认超时率从之前的每小时30多次降到了0。而当初定位的那个“内鬼”,其实是一个不起眼的`sleep(5)`——我在一个业务回调里加了一句延时模拟耗时,结果它把整个事件循环给卡住了,导致定时器无法按时触发。

<p></p>

别笑,这种问题在工业现场太常见了。你永远要防着同事在逻辑里埋地雷,日志就是排雷探测器。

最后给兄弟几个总结:

1. **日志必须带毫秒级时间戳和线程ID**,否则无法做时序分析。

2. **`write()`和`flush()`必须成对出现**,丢包往往不是你网线松了,而是数据没发出去。

3. **看门狗检测包不能霸道**,要采用“请求-应答”模式,而不是盲发。

4. **每次修改通信参数,改完跑48小时压力测试**,别改完就下班,故障往往在第三天清晨等你。

5. **千万别在通信线程里写`sleep()`**,哪怕你是为了“等一下重发”,那是自杀式编程。

返回列表