遇到通信问题,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()`**,哪怕你是为了“等一下重发”,那是自杀式编程。