问题背景
Modbus RTU 超时最棘手的情况不是完全无法通信,而是设备连续运行数小时后偶发一次失败。立即重试通常又能成功,于是问题被归因于“现场干扰”,却没有留下足够证据。
真正有效的排查要先区分:请求是否完整发出、从站是否响应、响应是否完整到达、主站解析是否正确。只有明确失败发生在哪一层,调整超时或重试才有意义。
现象
一个典型现场现象可能包括:
- 单独调试某个从站时稳定,整机运行时偶发超时;
- 轮询频率越高,异常越容易出现;
- 重启上位机或重新打开串口后暂时恢复;
- 日志只有“读取失败”,没有原始请求、已接收字节数和耗时;
- 两个业务模块可能同时访问同一个串口。
这些信息已经暗示问题可能来自并发访问、帧边界或缓冲区状态,而不一定是物理线路。
最小复现
下面的写法看似异步,实际上允许多个调用同时操作同一个串口:
public async Task<byte[]> ReadRegistersAsync(byte slave, ushort address)
{
byte[] request = BuildReadRequest(slave, address, count: 2);
await _port.BaseStream.WriteAsync(request);
var response = new byte[9];
int count = await _port.BaseStream.ReadAsync(response);
return response[..count];
}
问题至少有三个:没有串行化访问、假设一次读取返回完整响应、没有明确超时与取消。两个调用的请求和响应可能在同一缓冲区交错。
原理
RTU 依靠静默间隔分帧
Modbus RTU 帧本身没有长度字段覆盖所有场景,链路通过至少 3.5 个字符时间的静默间隔区分帧。字符时间取决于波特率、数据位、校验位和停止位。
如果主站连续发送请求,没有给从站和总线留出要求的间隔,从站可能把字节识别为异常帧。USB 转串口缓冲和操作系统调度也会影响实际发送节奏,因此不能只依据业务代码中两次调用的时间差判断。
一条总线只能有一个请求在途
传统 Modbus RTU 是主从轮询模型。同一串口上通常只能有一个请求等待响应。即使调用来自不同设备服务,也必须在物理总线层排队,而不是每个服务各自加锁。
响应长度由功能码决定
正常响应和异常响应长度不同。例如读取保持寄存器的正常响应包含字节计数字段,异常响应则只有地址、异常功能码、异常码和 CRC。读取逻辑必须先获得足够的头部,再决定剩余长度。
排查过程
一、补齐证据日志
每次请求记录以下字段:
requestId, slave, function, requestHex
writeStartedAt, writeCompletedAt
firstByteAt, completedAt, receivedBytes
responseHex, crcValid, exceptionCode
timeout, retryIndex
日志中的时间使用单调计时器计算耗时,墙上时钟只用于跨系统关联。原始帧要限制长度并避免记录敏感业务参数。
二、验证是否存在并发调用
在串口入口记录等待锁、获得锁和释放锁时间。如果同一时间出现两个“已获得”,说明锁的位置不在物理资源边界;如果等待时间很长,则总线负载或上层调用频率需要调整。
三、逐层替换变量
- 降低轮询频率,观察失败率是否变化;
- 只保留一个从站,排除地址冲突;
- 更换短线、终端电阻和 USB 转串口模块;
- 用串口分析仪确认请求、响应和静默间隔;
- 对照设备手册确认功能码、寄存器数量和响应时间。
一次只改变一个变量,否则即使问题消失,也无法确定根因。
根因
假设最终证据显示:配方服务和状态轮询服务各自持有一个 SemaphoreSlim,但它们共享同一个串口。两个局部锁无法保护同一物理资源,请求仍会交错。某次响应被错误的调用读取后,另一个调用等待到超时。
这类问题之所以“偶发”,是因为它依赖线程调度、从站响应时间和轮询相位。
解决方案
把串口所有权集中到一个总线调度器,由它串行执行事务:
public sealed class ModbusRtuBus
{
private readonly SemaphoreSlim _mutex = new(1, 1);
private readonly SerialPort _port;
public ModbusRtuBus(SerialPort port) => _port = port;
public async Task<byte[]> ExecuteAsync(
byte[] request,
TimeSpan timeout,
CancellationToken cancellationToken)
{
await _mutex.WaitAsync(cancellationToken);
try
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
timeoutSource.CancelAfter(timeout);
_port.DiscardInBuffer();
await _port.BaseStream.WriteAsync(request, timeoutSource.Token);
await _port.BaseStream.FlushAsync(timeoutSource.Token);
return await ReadValidatedFrameAsync(_port, timeoutSource.Token);
}
finally
{
_mutex.Release();
}
}
}
实际实现还应注意:
DiscardInBuffer只应在确认没有合法响应待处理时使用;- 读取函数根据地址、功能码和字节计数确定完整长度;
- CRC 错误、从站异常响应和超时是不同结果;
- 写命令重试前先判断是否幂等,必要时回读设备状态;
- 连续事务之间满足设备手册要求的最小间隔。
为什么这个方案有效
锁被放在真正的共享资源——物理串口——上,因此无论上层有多少设备服务,同一时刻都只有一个事务。完整帧解析又避免了把部分响应当成完整结果。
更重要的是,超时被保留为一种明确结果,而不是统一转换成“设备离线”。系统可以根据功能码决定重试、回读确认或转入人工恢复。
最终结果
修复后的压力测试应同时覆盖高频轮询、并发业务请求、从站延迟、错误 CRC、半帧和取消。验收不只是“运行一晚没报错”,还要确认人为注入的每类失败都进入预期分支,并留下足够日志。
总结
排查 Modbus RTU 偶发超时,应先建立请求到响应的完整证据链,再检查物理总线所有权、帧间隔、长度解析和现场线路。增加超时时间只能改变症状,无法修复共享串口并发或错误帧边界。