上一篇把 SerialPort 的打开、关闭、参数配置和基本收发捋了一遍,但真把一支 RS485 温湿度变送器接到电脑上,很多人会卡在同一个地方:串口明明打开了,命令也发出去了,返回的要么是一串看不懂的01 03 04 00 FA 01 2C XX XX,要么干脆什么都不回。这不是串口的问题,是上面那层 Modbus RTU 没实现对。C# 本身不带任何 Modbus 封装,System.IO.Ports.SerialPort只负责把字节丢出去、再收回来,剩下的活儿——怎么组帧、CRC 怎么算、超时怎么等、收到半截数据怎么办、两个字节拼出来的数字为什么是 400 而不是 25——全都得自己动手。这篇接着上一部分,把这条链路从头打通,读的对象是常见的 RS485 温湿度变送器,温度和湿度各占一个 16 位保持寄存器。涉及功能码 0x03、CRC16、大小端、多从站轮询、断线重连这些绕不开的东西,代码可以直接拿去改,参数对照自己手头设备的手册核一遍就行。
1. 先把链路想清楚:为什么不能 Write 一串字节就完事
1.1 上一篇留下的三个半成品
上一篇结束的时候,大部分人手里是这样的代码:SerialPort打开了,Write()发了一串字节,然后DataReceived事件里把收到的内容Console.WriteLine出来。表面上看流程完整,实际有三个洞。第一是没有帧边界概念,串口是字节流,不是消息流,收到 8 个字节还是 4 个字节是完全随机的,传感器回一帧 9 个字节,你的程序可能在第一波事件里只拿到前 3 个。第二是没有校验,收到的数据里但凡有一个位被干扰翻转了,你的代码照样把它当正确值算进温度里,读出来 25.3℃ 变成 89.6℃,而且你看不出哪里错了。第三是没有异常分支,从站地址写错、寄存器地址不存在、设备被拔掉,这些情况在 Modbus 里都有明确的响应码,但很多人的代码里没有任何一条分支去接。
这三个洞在"读一个字节看看灯亮不亮"的玩具场景里无所谓,但只要上了现场,一天跑几万次轮询,任何一个都会变成半夜告警电话。所以这一篇的第一件事不是写代码,而是把"一次完整事务"的定义立起来:发一帧请求、等一帧响应、校验收到的每一个字节、解析出物理量、把失败情况分类上报。这五步少一步都不算完整。
1.2 温湿度变送器的寄存器模型其实高度统一
好消息是,这类传感器的寄存器布局大同小异。我接触过的绝大多数 RS485 温湿度变送器都是这个套路:从站地址出厂默认 1,可通过拨码或命令改到 1~247;串口参数基本固定 9600、8 数据位、无校验、1 停止位(也就是常说的 9600 8N1);温度放在一个 16 位保持寄存器里,湿度放在紧随其后的另一个;功能码用 0x03 读保持寄存器。不同厂家真正的差异集中在三处:起始寄存器地址(有的从 0x0000 开始,有的从 0x0001 开始,手册上写的是"1 号寄存器",实际发帧要减 1)、量纲(有的原始值除以 10,有的除以 100)、符号位(温度支持负值时,那个 16 位数据是有符号还是无符号)。
比如说一次典型的交互,请求帧是01 03 00 00 00 02 C4 0B,从站回01 03 04 00 FA 01 2C 3A 9E。拆开看:第一个01是从站地址,03是功能码,04表示后面跟 4 个数据字节,00 FA是第一个寄存器的值,01 2C是第二个寄存器的值,最后两个字节是 CRC。如果设备约定温度 = 原始值 ÷ 10 - 40,那0x00FA就是 250,250 ÷ 10 - 40 = -15.0℃。你会发现,同一串字节,按不同厂家约定算出来的物理量能差出一个季节,这就是为什么拿代码之前必须先把手册翻到寄存器表那一页。
1.3 这一篇的代码要解决到什么程度
目标定得具体一点:一段能长期跑在现场的读取逻辑,单次读取有超时保护,CRC 错误能识别并丢弃,异常响应码能翻译成人话,多从站轮询不互相干扰,拔掉再插上能自动恢复。不追求写成通用 Modbus 库——那是另一个话题,而且大多数上位机项目里一个设备型号就用两三个功能码,写通用框架反而增加维护成本。我的做法是先把一个从站读通,再横向复制成数组,这样出问题时排查范围永远只有一台设备。
2. Modbus RTU 报文拆解与 CRC 计算
2.1 一帧请求里每个字节的角色
读保持寄存器的请求帧结构是固定的 8 个字节,一个都不能多也不能少。很多人调试时用串口助手手敲十六进制,最容易漏的就是最后那个 CRC 低字节在前、高字节在后的顺序。
| 字节位置 | 名称 | 典型值 | 说明 |
|---|---|---|---|
| 1 | 从站地址 | 0x01 | 1~247,0 是广播,设备不应答 |
| 2 | 功能码 | 0x03 | 读保持寄存器,读输入寄存器是 0x04 |
| 3 | 起始地址高字节 | 0x00 | 寄存器地址 0x0000~0xFFFF |
| 4 | 起始地址低字节 | 0x00 | 起始地址 = 高字节 × 256 + 低字节 |
| 5 | 寄存器数量高字节 | 0x00 | 一次最多读 125 个 |
| 6 | 寄存器数量低字节 | 0x02 | 读温度加湿度就是 2 个 |
| 7 | CRC 低字节 | 0xC4 | 注意低字节在前 |
| 8 | CRC 高字节 | 0x0B | 高字节在后 |
响应帧的长度是变的,公式是5 + 寄存器数量 × 2。地址 1 字节、功能码 1 字节、字节计数 1 字节、数据数量 × 2字节、CRC 2 字节。读 2 个寄存器就是 9 个字节。这个公式很重要,它是第 3 节里判断"我到底收够了没有"的依据。异常响应的长度则固定为 5 字节:地址、功能码(原功能码加 0x80,也就是 0x83)、异常码、CRC 两字节。看到 0x83 就别去解析后面的数据了,那是错误码,常见的有 0x01 非法功能码、0x02 非法数据地址、0x03 非法数据值。
2.2 CRC16 的两种写法与选择理由
Modbus 用的是 CRC-16/MODBUS,多项式 0xA001(反向表示),初始值 0xFFFF,输入输出都不反转异或。逐位实现最直观,也最容易验证:
public static ushort Crc16(byte[] data, int offset, int length) { ushort crc = 0xFFFF; for (int i = offset; i < offset + length; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if ((crc & 0x0001) != 0) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return crc; }逐位法的计算量是字节数 × 8次循环,8 字节请求帧就是 64 次,单次调用几微秒,完全够用。但如果你的场景是 8 个从站每秒轮询 10 轮,加上响应帧校验,每秒几千次调用,那就有优化的必要了。查表法预先算好 256 项,每次循环只做一次异或和一次查表:
private static readonly ushort[] Table = BuildTable(); private static ushort[] BuildTable() { var t = new ushort[256]; for (int i = 0; i < 256; i++) { ushort c = (ushort)i; for (int j = 0; j < 8; j++) c = (c & 1) != 0 ? (ushort)((c >> 1) ^ 0xA001) : (ushort)(c >> 1); t[i] = c; } return t; } public static ushort Crc16Fast(byte[] data, int offset, int length) { ushort crc = 0xFFFF; for (int i = offset; i < offset + length; i++) crc = (ushort)((crc >> 8) ^ Table[(crc ^ data[i]) & 0xFF]); return crc; }我的建议是能上查表就上查表,代码多不了十行,好处是轮询频率再高也不会成为瓶颈。写完一定要拿两个已知向量测一下:01 03 00 00 00 02的 CRC 应该是 0x0BC4(发出去低字节 0xC4 在前),01 03 04 00 FA 01 2C的 CRC 是 0x9E3A。手算验证一次,比调半天程序强。
2.3 高低位和字节序:80% 的对接故障都在这
热词里有个"汇川 PLC 用 Modbus RTU 高低位转换",这个问题在跨设备对接时极其常见。根源在于:Modbus 协议本身规定单个寄存器内部是大端,也就是高字节在前,这一点没有歧义。但当一个物理量需要 32 位(比如用浮点表示温度)时,它要占用两个连续的寄存器,这时候"先发哪个寄存器"就由厂家自己定了,于是出现了四种排列:
| 排列方式 | 字节顺序 | 常见于 |
|---|---|---|
| ABCD | 寄存器 1 高、寄存器 1 低、寄存器 2 高、寄存器 2 低 | 多数国产仪表、部分 PLC |
| CDAB | 寄存器 2 高、寄存器 2 低、寄存器 1 高、寄存器 1 低 | 部分 PLC 的默认字交换 |
| BADC | 寄存器 1 低、寄存器 1 高、寄存器 2 低、寄存器 2 高 | 少数仪表 |
| DCBA | 寄存器 2 低、寄存器 2 高、寄存器 1 低、寄存器 1 高 | 少见 |
处理办法很笨但很有效:把手册给定的一组已知值(比如 25.5℃)对应的原始字节抄下来,四种排列都转一遍,哪个算出来对得上就用哪个。别猜,试。我遇到过同一批采购的变送器,因为固件版本不同,字序约定居然不一样,最后只能每台设备存一个"字节序类型"配置项。
另外提醒一个 C# 特有的坑:BitConverter.ToSingle在 x86 上是小端解析,而 Modbus 传过来的是大端。你直接把四个字节丢进去,得到的是一堆无意义的浮点数,而且不报错,最容易被骗。
// 假设手册约定是 ABCD,收到的 4 个字节是 a b c d byte[] raw = { d, c, b, a }; // 反转成小端再交给 BitConverter float value = BitConverter.ToSingle(raw, 0);.NET Core 3.0 以后更推荐用BinaryPrimitives,语义清楚不容易错:
ushort reg = BinaryPrimitives.ReadUInt16BigEndian(span); // 单寄存器,大端 uint value = BinaryPrimitives.ReadUInt32BigEndian(span); // 双寄存器,大端System.Buffers.Binary这个命名空间值得记住,凡是涉及通信协议的字节解析,用它比自己移位拼装更不容易出岔子。
3. C# 完整实现:从组帧到解析
3.1 串口初始化里那些容易被忽略的参数
先把口开对。除了波特率、数据位、校验、停止位这几个常规项,有几个参数建议显式设置:
private const int ExpectedTx = 8; private const int ExpectedRx = 9; // 读 2 个寄存器:5 + 2*2 private SerialPort OpenPort(string portName) { var port = new SerialPort(portName) { BaudRate = 9600, DataBits = 8, Parity = Parity.None, StopBits = StopBits.One, Handshake = Handshake.None, ReadTimeout = 300, WriteTimeout = 300, ReadBufferSize = 4096, WriteBufferSize = 1024 }; port.Open(); port.DiscardInBuffer(); port.DiscardOutBuffer(); return port; }ReadTimeout设 300ms 是有讲究的。9600 波特率下,一个字符是 11 位(1 起始 + 8 数据 + 1 校验 + 1 停止),单字符耗时 11 ÷ 9600 ≈ 1.146ms,9 字节响应帧在空中停留约 10.3ms。再算上设备内部处理时间(多数变送器 5~20ms)、USB 转串口芯片和驱动的缓冲延迟,实测慢的设备 100ms 左右才回全。给 300ms 是留了余量又不至于让一次故障卡住整个轮询周期。别设 50ms,那点余量在实际现场百分之百不够。
DiscardInBuffer()在打开后立刻调用,清掉驱动缓冲区里可能存在的上电噪声。如果你的设备是热插拔的,重连后也一定要清一次,否则旧数据会污染第一帧。
3.2 请求帧构造:把地址和数量拼进去
private static byte[] BuildReadHolding(byte slaveId, ushort startAddr, ushort count) { var frame = new byte[8]; frame[0] = slaveId; frame[1] = 0x03; frame[2] = (byte)(startAddr >> 8); frame[3] = (byte)(startAddr & 0xFF); frame[4] = (byte)(count >> 8); frame[5] = (byte)(count & 0xFF); ushort crc = Crc16Fast(frame, 0, 6); frame[6] = (byte)(crc & 0xFF); // 低字节在前 frame[7] = (byte)(crc >> 8); return frame; }起始地址那个参数最容易出问题。手册上写"温度寄存器地址 0x0000,湿度 0x0001",直接填 0 就对了。但有些手册写"温度寄存器 1 号",这时候协议层要填 0,因为 Modbus 的地址是从 0 计数的。这个差 1 的坑,我至少见过五次不同的人栽进去,症状都是"设备有响应,但数据永远是 0 或者明显不对"。
3.3 接收:不要迷信 DataReceived 事件
DataReceived事件有两个特点:一是它在线程池线程上触发,二是它只保证"有数据了",不保证"数据齐了"。9 字节的响应帧,你完全可能在第一次事件里收到 4 个字节,第二次收到 5 个,也可能两次事件之间隔了几十毫秒。用事件拼帧不是不行,但需要维护一个残留缓冲区加上时间窗口判断,复杂度上去了。
既然 Modbus RTU 响应帧长度是可预测的,那就用最朴素的阻塞读法:知道该收几个字节,就死等到收满或者超时。
private byte[] Transaction(byte[] request, int expectLen) { lock (_sync) { _port.DiscardInBuffer(); _port.Write(request, 0, request.Length); var buffer = new byte[expectLen]; int got = 0; var sw = Stopwatch.StartNew(); while (got < expectLen && sw.ElapsedMilliseconds < _port.ReadTimeout) { try { got += _port.Read(buffer, got, expectLen - got); } catch (TimeoutException) { break; // 读到一半超时,跳出后按长度不足处理 } } if (got < 3) throw new IOException($"响应过短,仅收到 {got} 字节"); // 先判异常响应,它只有 5 字节,长度对不上 expectLen if ((buffer[1] & 0x80) != 0) throw new IOException($"设备返回异常码 0x{buffer[2]:X2}"); if (got != expectLen) throw new IOException($"帧长度不足,期望 {expectLen} 实收 {got}"); ushort crcCalc = Crc16Fast(buffer, 0, got - 2); ushort crcRecv = (ushort)(buffer[got - 2] | (buffer[got - 1] << 8)); if (crcCalc != crcRecv) throw new IOException($"CRC 校验失败,计算值 0x{crcCalc:X4} 收到值 0x{crcRecv:X4}"); return buffer; } }这里有几个细节值得说。lock是必须的,因为后面要做多从站轮询,多个线程同时往一个串口写会让两个从站都收到乱码。DiscardInBuffer()在发送前调用,保证的是"发请求之前把可能残留的上一帧清掉",而不是发完之后。异常响应要在长度判断之前处理,因为异常帧只有 5 字节,你按 9 字节去等会一直等到超时,白白浪费 300ms。
还有个小优化:循环里每一轮都检查sw.ElapsedMilliseconds,而不是依赖ReadTimeout自己抛异常。原因是ReadTimeout是每次Read调用的超时,如果设备回得很碎(一次 1 个字节),9 次 Read 每次都不超时,总耗时就可能远超你的预期。用Stopwatch卡总时长才是对的。
3.4 数据解析:从两个字节到摄氏度
拿到 9 字节响应后,解析这部分反而简单,关键是别搞错量纲。
public (double temp, double humidity) ReadTh(byte slaveId) { var req = BuildReadHolding(slaveId, 0x0000, 2); var resp = Transaction(req, 9); if (resp[2] != 4) throw new IOException($"字节计数异常:{resp[2]}"); ushort rawTemp = BinaryPrimitives.ReadUInt16BigEndian(resp.AsSpan(3, 2)); ushort rawHumi = BinaryPrimitives.ReadUInt16BigEndian(resp.AsSpan(5, 2)); // 以下换算规则必须以手册为准,这里给出两种最常见的约定 // 约定 A:温度 = 原始值/10 - 40,湿度 = 原始值/10 double temp = rawTemp / 10.0 - 40.0; double humi = rawHumi / 10.0; return (temp, humi); }上面注释里的两种约定不是随手编的。约定 A 在壁挂式变送器里很常见,好处是温度原始值 0 对应 -40℃,正好覆盖了变送器的整个量程起点;约定 B 是直接raw / 10.0,量程从 0℃ 开始,多见于机房用的型号。如果你的设备测到了低于 0 度的环境,而换算公式忘了那个 -40,读数会显示成 40 多度,你会以为传感器坏了。这就是为什么我一直强调先把手册的换算公式抄在代码注释里,别指望三个月后还记得。
温度支持负值的时候,还有一种情况:设备用有符号16 位表示。这时候要这么转:
short signedRaw = (short)rawTemp; double temp = signedRaw / 10.0;判断标准很简单:把设备放进冰箱(或者用手捂冷),看原始值是不是从 0x0000 往下掉到 0xFFF6 这种。如果往那边掉,说明是有符号数,直接(short)强转;如果不变或者跳到 65535 附近不动,那可能是不支持负温。
4. 多从站轮询与线程模型
4.1 一个串口挂 8 个传感器怎么轮
RS485 总线理论上可以挂 32 个甚至 128 个节点,实际项目里常见的是 4 到 16 个。帧不需要你自己去抢总线,Modbus 的机制就是同一时刻只有一个主站、一个从站说话,所以程序里必须串行化。最直白的做法是一个for循环遍历地址:
private readonly byte[] _slaveIds = { 1, 2, 3, 4, 5, 6, 7, 8 }; private void PollAll(CancellationToken token) { while (!token.IsCancellationRequested) { foreach (var id in _slaveIds) { try { var (t, h) = ReadTh(id); _cache[id] = (t, h, DateTime.Now, null); } catch (Exception ex) { _cache[id] = (double.NaN, double.NaN, DateTime.Now, ex.Message); } Thread.Sleep(20); // 帧间静默,见下一节 } Thread.Sleep(200); // 一轮结束歇一下,避免疯狂占用 CPU } }用Dictionary或者定长数组缓存每个从站的最新值,UI 层(WinForm 或 WPF)只读缓存,不直接碰串口。这个分层看着多余,但实际非常关键:UI 线程绝对不能阻塞在读串口上,否则设备一超时,整个界面就假死了。上位机被人吐槽"点了没反应",十有八九是这个原因。UI 那边用一个System.Windows.Forms.Timer每 500ms 刷新一次界面就够了,人的眼睛也不需要每秒看 100 次温度。
4.2 延时效率与 3.5 字符间隔这笔账
Modbus RTU 规定帧与帧之间至少要有 3.5 个字符时间的静默,用来让从站识别帧边界。9600 波特率下算一下:单字符 11 位 ÷ 9600 = 1.1458ms,3.5 个字符就是4.01ms。这是协议规定的下限,实际实现里再加点余量,取 10~20ms 比较稳。
这里就撞上热词里那个"C# 延时效率"的问题了。Windows 不是实时系统,Thread.Sleep(1)的实际休眠时间通常在 10~16ms 之间,取决于系统时钟精度。也就是说,你想睡 10ms,Thread.Sleep(10)大概率睡 15ms 左右;你想睡 4ms,Thread.Sleep(4)也可能睡 15ms。对于轮询逻辑来说这不致命(多等几毫秒而已),但如果你想精确控制到毫秒级,就得换思路:
private static void PreciseDelay(double milliseconds) { var sw = Stopwatch.StartNew(); double target = milliseconds; if (target > 20) { Thread.Sleep((int)(target - 15)); // 大头交给系统调度 } while (sw.Elapsed.TotalMilliseconds < target) { Thread.SpinWait(50); // 剩下的小头自旋等 } }思路是"粗睡 + 细自旋":超过 20ms 的部分交给Thread.Sleep,最后 15ms 用Stopwatch自旋等。自旋会吃 CPU,所以不能纯自旋等 200ms。回到 Modbus 场景,我的建议是别折腾精度,直接Thread.Sleep(20)。理由很实在:帧间隔从 4ms 拉到 20ms,8 个从站轮一圈多花 128ms,对温度这种秒级变化的物理量毫无影响,但换来的稳定性提升是实实在在的。追求极致毫秒精度在这里属于自我感动。
4.3 断线重连:现场最容易挨骂的功能
实验室里跑通的代码,到了现场第一个被打脸的功能就是热插拔恢复。USB 转串口线被人碰掉是常态,设备断电重启也是常态。如果代码里只有"打开一次,一直用",掉线之后程序要么抛一堆异常,要么永远读不到数据,必须重启软件——这在客户眼里就是"你们这软件不行"。
处理方式是在轮询循环外层加一层健康检查:
private bool EnsurePortAlive() { try { if (_port != null && _port.IsOpen) return true; _port?.Dispose(); _port = OpenPort(_portName); _log.Info($"串口 {_portName} 重新打开成功"); return true; } catch (UnauthorizedAccessException ex) { _log.Warn($"串口被占用:{ex.Message}"); return false; } catch (IOException ex) { _log.Warn($"串口不存在或拔除:{ex.Message}"); return false; } }关键点在于连续失败计数再重建,而不是一次失败就关掉重开。一次超时可能只是设备忙,立刻关掉重开会打断正常的轮询节奏。我的阈值是连续 3 次失败才触发重建,重建前先Dispose,等 500ms 再Open,因为 USB 转串口芯片从拔出到系统重新枚举出串口号需要时间,立即 Open 大概率失败。
5. 排查速查表与真实踩坑记录
5.1 现象、原因、处置三列对照
这张表是我这些年攒下来的,出现问题时从上往下挨个排除,基本都能定位到。
| 现象 | 可能原因 | 处置方式 |
|---|---|---|
| 完全无响应,超时 | 从站地址错、A/B 线接反、波特率不符 | 用串口调试工具手发原始帧,确认接线 |
| 有响应但 CRC 一直失败 | 波特率偏差大、线缆过长有干扰、帧粘连 | 缩短线缆、加 120Ω 终端电阻、检查接地 |
| 数据固定为 0 或 65535 | 寄存器地址差 1、读到了未使用的寄存器 | 核对手册地址基准,试 0x0000 和 0x0001 |
| 温度值大得离谱 | 量纲没换算(原始值没除 10) | 检查换算公式 |
| 低温环境读数变成 40 多度 | 忘了减 40 的偏移量 | 补上 -40 |
| 32 位数据全是乱码 | 字节序排列不符 | 四种排列依次试验 |
| 偶发丢包,重发就好 | 读超时太短、帧间隔不够 | 超时提到 300ms,间隔提到 20ms |
| 跑几小时后卡死 | UI 线程阻塞在串口读、缓冲区未清 | 读写放独立线程,发送前清缓冲 |
| 插拔后无法恢复 | 串口号变更、端口未释放 | 连续 3 次失败后重建,重扫可用串口 |
5.2 三个印象最深的坑
第一个是波特率标称不一致。有批变送器手册写 9600,实际出厂固件是 19200。症状是偶尔能收到数据但 CRC 全错,时好时坏。后来用示波器测了一个位宽才确认。这件事教给我的是:怀疑软件之前先怀疑物理层,示波器或者逻辑分析仪花几百块钱能省掉无数个通宵。
第二个是响应帧被"吃掉"前几个字节。原因是打开串口后没有DiscardInBuffer(),驱动缓冲区里存着设备上电时自发的一串噪声,和第一帧响应的头部搅在一起。现象是第一轮读取必定失败,之后都正常。加一行清缓冲解决。所以现在我的OpenPort里清缓冲是标配。
第三个是查表法的表初始化顺序问题。我把CrcTable写成了静态字段,但初始化放在了一个静态构造函数里,而这个构造函数又访问了另一个静态字段。C# 静态字段初始化顺序是按文本顺序的,结果表是空的,CRC 全算错。排查了半天才想起来这茬。教训是静态初始化器里不要有依赖关系,表就老老实实用静态只读字段加静态方法生成,别搞花活。
5.3 调试阶段的两个提效动作
一是把组帧逻辑做成可粘贴的输出。调试时我最常做的一件事是把程序里生成的请求帧以01 03 00 00 00 02 C4 0B这种格式打日志,然后直接复制到串口调试助手里手工发一遍。如果能收到正确响应,说明协议层没问题,故障在接收逻辑;如果收不到,问题在设备侧或者接线。这个动作把"软件问题还是硬件问题"的二分变得非常快。
二是准备两个已知的 CRC 向量做单元测试。写测试不是为了好看,是因为 CRC 算错这种情况太隐蔽了——长度对、格式对、就是值不对,肉眼根本看不出来。我在项目里固定放两个测试用例,一个空数据的初始值,一个标准请求帧的结果,每次改动通信层就跑一遍,五秒钟的事。
6. 把这些组合成一个能长期跑的结构
6.1 三层结构就够了
写到这里你会发现,代码其实不需要什么复杂设计模式,分成三层就很清楚。传输层负责串口的开关、写字节、读字节、超时和重连,对外只暴露一个Transaction(byte[] request, int expectLen)。协议层负责组帧、CRC、异常码翻译、字节序转换,它只依赖传输层的方法,不直接碰SerialPort。应用层负责轮询调度、量纲换算、数据缓存,它只调用"读 1 号从站的温湿度"这种语义化方法。
这么分的好处是测试和替换都方便。想换设备改协议层的组帧和解析就行,传输层不动;想把串口换成 TCP 网关(很多现场用串口服务器),只要新写一个同样签名的传输层实现,上面两层一行不改。这是我做了几个类似项目之后总结出来的最省事的划分,比上来就写通用 Modbus 框架实在得多。
6.2 日志要打什么
长期运行的软件最怕"出问题了但没线索"。日志里至少要落这几样:每次事务的请求帧十六进制、响应帧十六进制、耗时毫秒数、失败时的异常类型和消息。成功的事务可以按采样率降频打印(比如每分钟只记一次),失败的事务必须全记。另外,每个从站单独统计成功率,某个从站成功率明显低于其他,那基本就是那台设备的接线或者地址有问题,一眼就能看出来。
6.3 关于配置化的一点建议
从站地址、寄存器地址、量纲换算系数、字节序类型这四个东西,别写死在代码里。放到配置文件或者数据库表里,字段就四个:SlaveId、StartAddress、Scale、ByteOrder。现场换设备的时候改配置不用重新编译发版,这个便利性在后期维护阶段价值极高。我见过太多项目因为地址写死,换一批传感器就要出个新版本,非常折腾。
最后再分享一个我在实际项目里常用的小技巧:把ReadTh方法包一层重试,失败后立刻重发一次再判定为失败。Modbus 在电气噪声大的环境里偶发单次失败很正常,加一次重试能把有效成功率从 99% 提到 99.9%,而这个改动的成本只有三行代码。前提是重试前记得清一次输入缓冲,否则第二次读取会读到第一次的残留数据。踩过几次坑之后,我现在所有串口通信代码里,清缓冲都写在发送前的第一行,成肌肉记忆了。