简介:NModbus Tools 是一套基于 NModbus4 的 Modbus 通讯辅助工具集,面向工业自动化开发者、PLC 调试人员及正在学习 Modbus 协议的读者,可用于串口与网络通信测试、请求响应报文解析、虚拟从站模拟和常见故障诊断,帮助理解设备间的协议交互过程。压缩包为 zip,共 104 个文件、整体约 90KB,以 72 个 C# 源码文件承载协议封装与业务逻辑,辅以 5 个 XAML 界面、6 个 config 配置、5 个 csproj 工程及图片、设置和资源文件,构成完整可编译的解决方案,目录组织便于按模块检索。已有 115 人学习下载。读者可参考客户端与服务端连接封装、串行和 TCP 通道建立、报文数值处理、从站浏览与连接选择等模块的职责划分,并借助配置与界面代码理解工具集如何落地,为二次开发、设备联调和通讯排错提供可复用的实现思路。
1. NModbus Tools 拆包:一套基于 NModbus4 的主从站调试工具能省掉多少手工拼帧
现场最烦的场景不是协议难,是手头只有一台老设备、一个 RS485 口和一台没装任何调试软件的笔记本。这次拆的 NModbus Tools,就是冲着这个场景做的一套桌面工具:底层基于 NModbus4 这个 .NET 平台上的 Modbus 协议库,把主站读写、从站模拟、寄存器监视这三件事收进同一个界面,功能码从 01 到 16 覆盖得比较全,RTU 和 TCP 两条链路都能走。
它解决的其实是「验证成本」问题。以前测一个仪表,得先手写 CRC16、拼01 03 00 00 00 0A、算超时、抓串口日志,一通操作下来半小时过去了,还没确认到底是设备问题还是自己帧拼错了。有了现成的主站壳子,你填地址、填数量、点读,返回值立刻出来,剩下的时间全花在真正有意义的排查上。
适合三类人:做 PLC/仪表集成的现场工程师、写上位机采集中间件的后端开发、以及要给学生演示 Modbus 报文结构的教学场景。不适合指望它替代正式 SCADA 的人,它是调试工具,不是生产采集平台。
2. 选型与协议栈:为什么值得用 NModbus4 封装而不是自己拼报文
很多人第一次接触 Modbus,本能反应是「不就 8 个字节吗,我自己拼」。这话在只读一个保持寄存器的时候成立,一旦要做多从站轮询、异常码分类、超时重试、TCP 粘包处理,手写代码的复杂度会以肉眼可见的速度膨胀。把这件事想清楚,比直接抄代码重要得多。
2.1 Modbus RTU/TCP 帧结构与库的抽象边界
先看 RTU 帧长什么样:从站地址 1 字节 + 功能码 1 字节 + 数据 N 字节 + CRC16 校验 2 字节。TCP 帧则是 MBAP 头 7 字节(事务标识 2 + 协议标识 2 + 长度 2 + 单元标识 1)+ PDU。看起来简单,但 RTU 有 3.5 字符的帧间隔判定,TCP 有流式数据的粘包和拆包,这两件事都不是「拼字节」能解决的,得靠状态机或者底层框架兜住。
NModbus4 的价值就在这一层:IModbusMaster接口把「读保持寄存器」「写单个线圈」这些操作抽象成方法调用,IModbusTransport负责帧组装、CRC 计算、超时控制和重试策略。你调用ReadHoldingRegisters(1, 0, 10),它内部完成的事情包括:按功能码 03 组 PDU、把ushort[]序列化成大端字节、追加 CRC、写入串口、等待响应、校验 CRC、判断异常码、把响应反序列化回ushort[]。少写至少三百行。
从站侧对应的是IModbusSlave加DataStore,后者是一个内存数据模型,保持寄存器、输入寄存器、线圈、离散输入各有一块区域,主站读你从站的时候,读的就是这块内存。调试阶段这个设计非常省事:想模拟一个温度值不断变化的仪表,直接改DataStore.HoldingRegisters[0]就行,不用碰协议层。
这里有个边界要认清:NModbus4 只做到「协议层」,不做「设备语义」。寄存器 40001 里放的是温度还是压力、单位是 0.1℃ 还是 1℃,库完全不管,这部分必须靠设备手册和你在上层写的解析逻辑。把它当协议栈用,不要当设备驱动用。
提示:NModbus4 属于较早的一支实现,在 .NET Framework 项目里比较稳;如果目标框架是较新的 .NET,常见做法是换成 API 同源但维护更活跃的分支包,方法签名大体一致,
ModbusSerialMaster.CreateRtu这类工厂方法基本不用改。
2.2 手写 Socket 组帧和 NModbus4 封装的实际成本对比
拿一个真实需求来比:轮询 8 个从站,每个站读 20 个保持寄存器,失败重试 3 次,超时 500ms。
手写方案的代码骨架大致是这样:打开串口、循环每个站号、构造请求数组、算 CRC、写入、阻塞读、判断读到的字节数、校验 CRC、判断功能码最高位是否为 1(异常响应)、拆数据、把两个字节拼成一个 ushort、记录日志、失败重试。这里面每一个环节都能翻车,尤其是「阻塞读到底读多少字节」这一步——很多新手用Read(buf, 0, 8)死等,结果响应长度大于 8 就截断了,或者设备没响应时直接卡死到天荒地老。
用 NModbus4 的话,核心循环压到十来行,超时、重试、CRC 全在Transport上配置:
| 维度 | 手写组帧 | NModbus4 封装 |
|---|---|---|
| 帧组装与 CRC16 | 自己实现,容易查表写错 | 内置,无需关心 |
| 超时控制 | 靠 SerialPort.ReadTimeout,易卡死 | Transport.ReadTimeout统一管理 |
| 重试机制 | 自己写循环,易漏状态复位 | Transport.Retries配置项 |
| 异常码识别 | 判断功能码 bit7,再映射码表 | 抛SlaveException,取SlaveExceptionCode |
| TCP 粘包 | 自己做长度字段解析 | 内置 MBAP 解析 |
| 从站模拟 | 基本要另写一套 | DataStore加Listen()直接起 |
代价是灵活性降低:非常规的私有功能码(比如某些厂商自造的 0x41)库不一定支持,得走Transport.UnicastMessage这类底层口子自己组。但 95% 的场景用标准功能码就够了。
2.3 工程搭起来:引用、命名空间与第一个连接
新建一个控制台或 WinForms 工程,通过包管理器引入 NModbus4,然后落地第一段可运行的代码。串口对象的参数必须和设备手册完全一致,一个都不许猜。
using System; using System.IO.Ports; using Modbus.Device; // 主站/从站工厂 using Modbus.Exception; // SlaveException 等异常类型 class Program { static void Main() { // 1. 串口参数严格对齐设备手册:8 数据位、无校验、1 停止位是 RTU 最常见组合 var port = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One) { ReadTimeout = 1000, WriteTimeout = 1000 }; port.Open(); // 2. CreateRtu 内部会接管该 SerialPort,后续不要再直接操作 port var master = ModbusSerialMaster.CreateRtu(port); // 3. 传输层超时与重试,比 SerialPort.ReadTimeout 更贴近协议语义 master.Transport.ReadTimeout = 500; master.Transport.WriteTimeout = 500; master.Transport.Retries = 3; master.Transport.WaitToRetryMilliseconds = 100; try { // 4. 从站地址 1,起始地址 0,读 10 个保持寄存器(功能码 03) ushort[] regs = master.ReadHoldingRegisters(1, 0, 10); for (int i = 0; i < regs.Length; i++) Console.WriteLine($"addr {i}: {regs[i]}"); } catch (SlaveException ex) { // 设备返回的异常响应,码值决定排查方向 Console.WriteLine($"从站异常码: {ex.SlaveExceptionCode}"); } catch (TimeoutException) { Console.WriteLine("响应超时,先查接线和波特率"); } finally { port.Close(); } } }这段代码的逻辑顺序值得说清楚。第一步建立SerialPort时必须显式指定波特率、数据位、校验、停止位,因为 RTU 没有握手协商,参数错一个字节,收到的全是乱码,现象和「设备没上电」几乎一样。第二步CreateRtu把串口包装成 Modbus 传输层,从此这个SerialPort对象归库管,你再去port.Read()会破坏帧同步。
第三步是很多人忽略的地方:SerialPort自己的ReadTimeout和Transport.ReadTimeout不是一回事。前者管底层字节读,后者管「从发出请求到收齐完整响应」的整体等待,还包括重试之间的间隔。实际调参时应该以Transport上的三个值为准,SerialPort上的超时设得比它大一点做兜底。
第四步的地址参数是ushort,从 0 开始。设备手册上写的 40001 是「Modbus 数据模型地址」,转成 PDU 地址要减 1,也就是 0。这一步的换算错误是新手最高频的翻车点,后面单独拆。
3. 两条链路跑通:RTU 串口与 TCP 的落地参数配置
协议层通了之后,真正决定工具好不好用的是链路配置。RTU 和 TCP 在 NModbus4 里走的是两套完全不同的工厂方法,参数含义也不一样,混着理解必出问题。
3.1 串口对象的参数陷阱与 Transport 超时
RTU 侧的坑集中在两个地方:串口参数和 RS485 方向控制。
串口参数这块,除了波特率/数据位/校验/停止位,还有两个容易漏的:一是Handshake,默认None是对的,如果有人手贱设成RequestToSend,帧就发不完整;二是DtrEnable/RtsEnable,某些 USB 转 485 的转换器靠 RTS 控制收发方向,库不管这个,得看转换器说明书。
更隐蔽的是延迟。RS485 是半双工,主机发完请求后要切到接收态,转换器切换需要几毫秒。如果设备响应特别快,主机可能还在发送态就收到了回包,直接丢掉。这种问题的现象是「偶发超时,重试一次就好」,非常像玄学。稳妥做法是给Transport.WaitToRetryMilliseconds留足余量,并在转换器侧确认有没有硬件自动方向控制。
// 半双工链路的保守配置:超时给足,重试间隔不要压到 0 master.Transport.ReadTimeout = 800; master.Transport.WriteTimeout = 800; master.Transport.Retries = 3; master.Transport.WaitToRetryMilliseconds = 200; // 给 485 转换器留切换时间 // 多从站轮询时,站与站之间也要留间隔,否则总线冲突 const int interFrameDelayMs = 30;Retries的含义是「失败后额外重试的次数」,设 3 意味着最多尝试 4 次。有人为了「更稳」直接设 10,结果一个坏站拖慢整轮轮询,8 个站的总周期从 2 秒涨到 20 秒,上位机界面直接卡成幻灯片。经验值是 2 到 3,配合上层把连续失败的站标记为离线,跳过它。
WaitToRetryMilliseconds也别设成 0。设 0 会导致失败后立刻重发,而总线上可能还有上一帧的尾巴没消散,重发的帧和残留数据撞在一起,CRC 校验必然失败,越重试越乱。
3.2 TCP 的 UnitId、端口复用与连接保持
TCP 侧看起来简单——new TcpClient(ip, 502)然后ModbusIpMaster.CreateIp(client),但真正的坑在后面。
第一个是 UnitId。Modbus TCP 的 MBAP 头里有一个「单元标识」,直连设备时它通常没意义,网关后面才用它区分串口上的从站。NModbus4 的ReadHoldingRegisters(slaveAddress, ...)第一个参数在 TCP 模式下会写进这个字段。如果设备是直连的以太网模块,随便填 1 或 255 通常都能通;如果走串口服务器,必须填后端真实从站地址,否则服务器不转发。
第二个是连接复用。一个TcpClient上开多个线程同时调ReadHoldingRegisters,必然出问题——Modbus 是「一问一答」的严格串行协议,事务标识对不上就乱套。常见做法是给 master 加一把锁,或者干脆每个采集任务用独立连接。
第三个是断线检测。TCP 连接被对端静默关闭时,TcpClient.Connected属性依然返回 true,这是 .NET 里的老问题。真正可靠的判断方式是看读写有没有抛IOException,抛了就重建连接。
using System.Net.Sockets; using Modbus.Device; TcpClient client = null; IModbusMaster master = null; void EnsureConnected(string ip, int port = 502) { if (master != null && client != null && client.Connected) return; client?.Close(); client = new TcpClient(); client.ReceiveTimeout = 1000; client.SendTimeout = 1000; client.NoDelay = true; // 关掉 Nagle,小报文别攒 client.Connect(ip, port); master = ModbusIpMaster.CreateIp(client); master.Transport.ReadTimeout = 1000; master.Transport.Retries = 2; }NoDelay = true这一行在很多教程里没有,但 Modbus 的请求包只有十几个字节,Nagle 算法会为了凑够 MSS 而延迟发送,表现出来就是「明明网络很好,每次读都要卡 40 毫秒」。关掉之后响应时间立刻降下来。
client.Connected那个判断只能当「快速失败」用,真要健壮就得在读写外面包一层 try-catch,捕获IOException和SocketException后重建连接。这是血泪经验,别偷懒。
3.3 读写调用的统一封装
工具要能同时支持 RTU 和 TCP,上层业务代码不该关心底层用的是哪个。用一个简单的工厂加接口抽象就能做到:
using Modbus.Device; using System.IO.Ports; public interface IModbusChannel : IDisposable { ushort[] ReadHolding(byte slave, ushort start, ushort count); void WriteSingle(byte slave, ushort addr, ushort value); } // RTU 实现:内部持有 SerialPort,Dispose 时释放 public class RtuChannel : IModbusChannel { private readonly SerialPort _port; private readonly IModbusSerialMaster _master; public RtuChannel(string portName, int baud) { _port = new SerialPort(portName, baud, Parity.None, 8, StopBits.One); _port.Open(); _master = ModbusSerialMaster.CreateRtu(_port); _master.Transport.ReadTimeout = 800; _master.Transport.Retries = 3; _master.Transport.WaitToRetryMilliseconds = 200; } public ushort[] ReadHolding(byte slave, ushort start, ushort count) => _master.ReadHoldingRegisters(slave, start, count); public void WriteSingle(byte slave, ushort addr, ushort value) => _master.WriteSingleRegister(slave, addr, value); public void Dispose() { _master?.Dispose(); _port?.Close(); _port?.Dispose(); } }接口里只放最常用的两个方法,是因为工具的核心诉求是「快速验证」,接口太胖反而增加实现成本。ReadHolding的count参数是ushort,理论上限受 PDU 长度约束(保持寄存器一次最多 125 个),超过就得分块,这一点在批量采集时必须处理。
把Dispose写成先释放 master 再关串口,顺序不能反。反过来会出现库还在等响应、串口已经被关掉的情况,抛出的异常信息非常误导人。
4. 寄存器到工程量:字节序、字序与数据类型还原
读到ushort[]只是第一步,业务要的是 23.5℃、1234.56 kWh 这样的工程量。这一层转换里的坑,比协议层加起来还多。
4.1 16 位寄存器与大端约定
Modbus 规定寄存器内部是大端:一个 16 位值的高字节先传。所以收到[0x00, 0x64]这两个字节时,值应该是 0x0064 = 100,而不是 0x6400。好在 NModbus4 的ushort[]已经帮你做完这层转换,拿到的就是数值本身。
真正要小心的是地址基准。设备手册里常写「40001 起为温度」,这里有两套体系:
- 数据模型地址:40001 表示「保持寄存器区第 1 个」,区号 4 只是分类标识
- PDU 地址:实际报文里的偏移量,从 0 开始
转换关系是PDU 地址 = 数据模型地址 - 40001。所以 40001 对应startAddress = 0,40011 对应 10。如果手册写的是「偏移 1」,那就是startAddress = 1,别自作主张减一。
有些设备更过分,直接在手册上标「寄存器地址 0x0000」,那本来就是 PDU 地址,照抄即可。判断方法很简单:看数值范围,四万多的一定是数据模型地址,个位数、几十的一定是偏移。
4.2 32 位整型与浮点的跨寄存器拼接
32 位数据占两个连续寄存器,于是「字序」问题来了。同一个 IEEE754 浮点数,不同厂商的排法能凑出四种:
| 排列 | 寄存器 N(高 16 位) | 寄存器 N+1 | 常见叫法 |
|---|---|---|---|
| ABCD | 高 16 位 | 低 16 位 | 大端 / 正序 |
| CDAB | 低 16 位 | 高 16 位 | 字交换 |
| BADC | 高 16 位内部字节交换 | 低 16 位内部字节交换 | 字节交换 |
| DCBA | 全反 | 全反 | 小端 |
手册里通常只写「高位在前」或「低位在前」,实际得试。做法是拿一个已知的量(比如设备显示 25.0℃)去对,四种排列逐个试,哪个能算出 25.0 就是哪个。下面这段封装把这四种情况都覆盖了:
using System; public static class RegisterCodec { // wordOrder: 0=ABCD, 1=CDAB, 2=BADC, 3=DCBA public static float ToFloat(ushort high, ushort low, int wordOrder = 0) { byte[] b = new byte[4]; switch (wordOrder) { case 0: // ABCD b[0] = (byte)(high >> 8); b[1] = (byte)(high & 0xFF); b[2] = (byte)(low >> 8); b[3] = (byte)(low & 0xFF); break; case 1: // CDAB:两个寄存器整体交换 b[0] = (byte)(low >> 8); b[1] = (byte)(low & 0xFF); b[2] = (byte)(high >> 8); b[3] = (byte)(high & 0xFF); break; case 2: // BADC:寄存器顺序不变,内部字节互换 b[0] = (byte)(high & 0xFF); b[1] = (byte)(high >> 8); b[2] = (byte)(low & 0xFF); b[3] = (byte)(low >> 8); break; default: // DCBA:全反 b[0] = (byte)(low & 0xFF); b[1] = (byte)(low >> 8); b[2] = (byte)(high & 0xFF); b[3] = (byte)(high >> 8); break; } // 组装成 32 位大端后再转,避免 BitConverter 受平台字节序影响 if (BitConverter.IsLittleEndian) Array.Reverse(b); return BitConverter.ToSingle(b, 0); } public static uint ToUInt32(ushort high, ushort low, bool highFirst = true) => highFirst ? ((uint)high << 16) | low // ABCD : ((uint)low << 16) | high; // CDAB }ToFloat里的BitConverter.IsLittleEndian判断不能省。BitConverter.ToSingle读的是本机内存字节序,x86 是小端,而你刚拼出来的是大端,不翻转就会得到一个天文数字,典型现象是温度读出来是 -2.3e+38 或者 1.6e-42 这种明显不合理的值。看到这类数量级,先怀疑字节序,别怀疑设备坏了。
ToUInt32的highFirst参数是给字序用的,跟字节序无关。很多人把这两个概念混为一谈,调半天调不对。记住:寄存器内部的高低位是协议规定的,你没法改;寄存器之间的先后顺序是厂商定的,必须可配。
4.3 位量:线圈、离散输入与掩码处理
开关量走的是另一套。ReadCoils(slave, start, count)返回bool[],看起来最省心,但有两个细节。
第一个是一次读的数量。协议规定读线圈一次最多 2000 个,实际业务里读几十个就够了,读太多纯属浪费带宽。第二个是「位在寄存器里打包」的情况——有些设备为了省空间,把 16 个报警位塞进一个保持寄存器,这时候你得自己拆位:
// 一个 16 位寄存器承载 16 路报警位,bit0 对应第 1 路 ushort alarmWord = regs[0]; for (int i = 0; i < 16; i++) { bool active = ((alarmWord >> i) & 0x01) == 1; if (active) Console.WriteLine($"第 {i + 1} 路报警"); }位移方向和起始编号是最容易搞反的地方。有的设备手册写「bit15 为第 1 路」,意思是从高位往低位排,这时候循环里要改成(alarmWord >> (15 - i)) & 0x01。判断依据还是那句老话:拿一个已知状态去对,对不上就换方向试,别硬猜。
5. 避坑与排查:五个把现场调试拖成通宵的问题
下面这五条,基本覆盖了用这类工具调试 Modbus 时 80% 的「设备好像坏了」的误判。每一条都按现象、原因、解决的顺序写,方便对照排查。
5.1 读回来全是 0,或者稳定返回异常码 02
现象:主站能连上,不超时,不报 CRC 错,但读到的寄存器全是 0;或者每次返回异常码 0x02(非法数据地址)。
原因:地址基准错了。手册上的 40001 被直接填进了startAddress,库发出去的 PDU 地址是 40001,远超设备实际映射范围,设备只能回 0x02。全 0 的情况则是恰好读到了未使用的空洞区。
解决:把数据模型地址减 40001 得到 PDU 偏移。40001 → 0,40101 → 100。改完之后用单个寄存器读一次验证,通了再批量读。
5.2 偶发超时,重试一次就成功
现象:十次里成功七八次,失败的那几次报TimeoutException,重试立刻就好,而且白天好好的,晚上或者设备多的时候更频繁。
原因:RS485 半双工方向切换来不及,或者波特率和设备实际值有偏差(比如设 9600 而设备是 19200,低速下偶发误码)。还有一种可能是Transport.ReadTimeout设得太小,设备本身响应就要 200ms,你只给 100ms。
解决:先按 3.1 的配置把ReadTimeout提到 800ms、WaitToRetryMilliseconds提到 200ms,观察是否改善。仍偶发的话,用示波器或者带时间戳的串口抓包工具看请求帧和响应帧之间的间隔,确认是不是转换器切换问题。波特率一定和手册逐位核对,别信「应该是 9600」。
5.3 TCP 连接读几十次后彻底卡死
现象:一开始正常,跑几分钟后所有请求都超时,但TcpClient.Connected还是 true,重连代码不触发。
原因:多线程共用一个 master 实例,请求和响应的事务标识错位;或者对端设备有最大连接数限制,你反复新建连接没关旧的,把连接池占满了。
解决:给 master 的所有调用套一把锁,保证「一问一答」严格串行;或者每个采集线程独立建连接。同时在EnsureConnected里显式Close()旧 client,别指望 GC。判断真实断线用 try-catch 捕IOException,别信Connected属性。
5.4 浮点数读出来量级离谱
现象:设备面板显示 25.0,工具读出来是 -2.3e+38、1.6e-42 或者上亿的整数。
原因:字序或字节序不匹配。你按 ABCD 拼,设备是 CDAB,拼出来的位模式完全是另一个数。
解决:拿设备面板上的已知值做基准,用 4.2 里的四种排法逐个试,哪个能还原出面板值就用哪个。同时确认是不是根本没做BitConverter.IsLittleEndian判断,直接把大端字节喂给了ToSingle。
5.5 批量读整片区域总是超时
现象:读 5 个寄存器没问题,读 100 个必超时。
原因:单次请求的寄存器数量超过设备的处理能力,或者超过协议 PDU 上限(保持寄存器 125 个)。有些老设备内部缓冲区只有几十字节,读多了直接不响应,连异常码都不给。
解决:分块读,每块 20 到 50 个寄存器,块之间留 10 到 30ms 间隔。分块大小做成可配置项,不同型号的设备给不同值。别指望一个固定值通吃所有设备。
注意:排查顺序建议是「先单点、再多点、最后批量」。单寄存器读通了说明链路和地址都对,多点读通了说明数量在设备承受范围内,批量再出问题就只剩并发和节奏的事了。
6. 从能读到能跑:轮询调度、异常码分类与压测验证
工具在实验室跑通只是及格线,放现场能不能扛住一整天的轮询才是真考验。这一章说三个我认为最值钱的点。
第一个是异常码的分类处理。SlaveException抛出来的码值不能一视同仁:0x01(非法功能码)和 0x02(非法数据地址)属于配置错误,重试一百次也没用,应该立刻标记该点位为「配置异常」并告警;0x03(非法数据值)通常是写入时参数越界,属于业务问题;0x04(从站设备故障)和超时才值得重试。把「可重试」和「不可重试」分开,能避免大量无意义的等待。
第二个是轮询节奏。不要用一个while(true)死循环加Thread.Sleep(100),那样会让高优先级的点位被低优先级的拖累。常见做法是给点位分组,按组分配时间片,每组内部串行、组与组之间按周期调度。RTU 总线上所有从站共享物理链路,必须全局串行;TCP 则可以按连接拆并发。
第三个是压测验证。上线前至少跑一轮 24 小时的连续轮询,记录四个指标:
| 指标 | 观测方式 | 合格线参考 |
|---|---|---|
| 单次请求 P95 耗时 | 请求前后打时间戳 | RTU 小于超时值的 60% |
| 超时率 | 异常计数 / 总请求数 | 低于千分之一 |
| 内存占用曲线 | 进程工作集每小时采样 | 24 小时内无单调上涨 |
| 重连次数 | 断线重建计数 | 稳定网络下为 0 |
内存那条特别重要。SerialPort和TcpClient如果不正确释放,句柄会缓慢泄漏,跑一天没事,跑一周就崩。压测时把Dispose路径全走一遍,比事后查内存 dump 省事得多。
最后一个技巧是给每条请求留可回溯的日志:时间戳、从站号、功能码、起始地址、数量、耗时、结果码。现场出问题时,这份日志是唯一的黑匣子。日志别打太细,每个寄存器一行会让文件几小时就上 G,打成「一次请求一行」就够。
写到这里说个自己的教训。早年做现场调试,我图省事直接在主线程里同步读,界面卡住就以为设备挂了,重启了三次工具才发现是自己在 UI 线程里Join了一个阻塞调用。从那以后我每次接新设备,都强制走一遍「单点读 → 多点读 → 批量读 → 24 小时压测」这四步,一步不跳,再急的现场也不例外。希望帮到你。
本文还有配套的精品资源,点击获取