
上一篇把 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就是 250250 ÷ 10 - 40 -15.0℃。你会发现同一串字节按不同厂家约定算出来的物理量能差出一个季节这就是为什么拿代码之前必须先把手册翻到寄存器表那一页。1.3 这一篇的代码要解决到什么程度目标定得具体一点一段能长期跑在现场的读取逻辑单次读取有超时保护CRC 错误能识别并丢弃异常响应码能翻译成人话多从站轮询不互相干扰拔掉再插上能自动恢复。不追求写成通用 Modbus 库——那是另一个话题而且大多数上位机项目里一个设备型号就用两三个功能码写通用框架反而增加维护成本。我的做法是先把一个从站读通再横向复制成数组这样出问题时排查范围永远只有一台设备。2. Modbus RTU 报文拆解与 CRC 计算2.1 一帧请求里每个字节的角色读保持寄存器的请求帧结构是固定的 8 个字节一个都不能多也不能少。很多人调试时用串口助手手敲十六进制最容易漏的就是最后那个 CRC 低字节在前、高字节在后的顺序。字节位置名称典型值说明1从站地址0x011~2470 是广播设备不应答2功能码0x03读保持寄存器读输入寄存器是 0x043起始地址高字节0x00寄存器地址 0x0000~0xFFFF4起始地址低字节0x00起始地址 高字节 × 256 低字节5寄存器数量高字节0x00一次最多读 125 个6寄存器数量低字节0x02读温度加湿度就是 2 个7CRC 低字节0xC4注意低字节在前8CRC 高字节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 低多数国产仪表、部分 PLCCDAB寄存器 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.146ms9 字节响应帧在空中停留约 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.1458ms3.5 个字符就是4.01ms。这是协议规定的下限实际实现里再加点余量取 10~20ms 比较稳。这里就撞上热词里那个C# 延时效率的问题了。Windows 不是实时系统Thread.Sleep(1)的实际休眠时间通常在 10~16ms 之间取决于系统时钟精度。也就是说你想睡 10msThread.Sleep(10)大概率睡 15ms 左右你想睡 4msThread.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 拉到 20ms8 个从站轮一圈多花 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 的偏移量补上 -4032 位数据全是乱码字节序排列不符四种排列依次试验偶发丢包重发就好读超时太短、帧间隔不够超时提到 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%而这个改动的成本只有三行代码。前提是重试前记得清一次输入缓冲否则第二次读取会读到第一次的残留数据。踩过几次坑之后我现在所有串口通信代码里清缓冲都写在发送前的第一行成肌肉记忆了。