问题背景

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

日志中的时间使用单调计时器计算耗时,墙上时钟只用于跨系统关联。原始帧要限制长度并避免记录敏感业务参数。

二、验证是否存在并发调用

在串口入口记录等待锁、获得锁和释放锁时间。如果同一时间出现两个“已获得”,说明锁的位置不在物理资源边界;如果等待时间很长,则总线负载或上层调用频率需要调整。

三、逐层替换变量

  1. 降低轮询频率,观察失败率是否变化;
  2. 只保留一个从站,排除地址冲突;
  3. 更换短线、终端电阻和 USB 转串口模块;
  4. 用串口分析仪确认请求、响应和静默间隔;
  5. 对照设备手册确认功能码、寄存器数量和响应时间。

一次只改变一个变量,否则即使问题消失,也无法确定根因。

根因

假设最终证据显示:配方服务和状态轮询服务各自持有一个 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 偶发超时,应先建立请求到响应的完整证据链,再检查物理总线所有权、帧间隔、长度解析和现场线路。增加超时时间只能改变症状,无法修复共享串口并发或错误帧边界。