不依赖库C# 手搓 Modbus RTU 主站读取变送器数据串口 WinForms 实战含帧构造、CRC16、数据解析很多人做 Modbus 通信直接用 NModbus 之类的库帧怎么拼、CRC 怎么算一概不知一旦通信出问题就无从下手。本文用一个真实的变送器数据读取WinForms 程序带你不依赖任何第三方库纯手工实现 Modbus RTU 主站从串口参数配置、问询帧逐字节拼装、CRC16 校验计算到接收应答、高低位合并、定时轮询全部讲透。适合有一点 C# 基础、正在学习串口/Modbus 通信的初学者。一、项目背景与基础知识1.1 这个程序做什么程序通过串口RS485 转串口连接一台变送器周期性读取它的 4 个测量值并显示在界面上。界面上的 4 个参数依次为含水量、温度值、盐度、PH。通信方式是工业现场最常用的Modbus RTU本机作为**主站Master主动问询变送器作为从站Slave**被动应答。1.2 Modbus RTU 基础初学者必读Modbus 是一种主从式、一问一答的协议总线同一时刻只能有主站说话从站不会主动上报主站发送问询帧对应地址的从站返回应答帧每条 RTU 报文末尾都带CRC16 校验码用来判断数据在传输中是否出错。本程序使用功能码 0x03读保持寄存器 Read Holding Registers。一个寄存器是 16 位2 字节变送器的测量值就放在这些寄存器里。主站问询帧结构功能码 03共 8 字节字节位置内容本例说明0从站地址0x01要访问的设备地址1功能码0x03读保持寄存器2起始地址高字节0x00寄存器起始地址3起始地址低字节0x00从 0 号寄存器开始4读取数量高字节0x00读几个寄存器5读取数量低字节0x04本例读 4 个6CRC 低字节计算得到CRC 校验低字节在前7CRC 高字节计算得到CRC 校验高字节在后从站应答帧结构读 4 个寄存器共 13 字节字节位置内容说明0从站地址原样返回1功能码原样返回异常时最高位会置 12有效字节数4 个寄存器 × 2 0x083~104 个寄存器数据每个寄存器高字节在前、低字节在后11~12CRC 校验低字节在前、高字节在后调试时如果手头没有真实硬件可以用虚拟串口对软件如 VSPD Modbus 从站模拟软件如 Modbus Slave在一台电脑上自己和自己通信。串口通信参数波特率、数据位、校验位、停止位必须和设备一致本例为9600 / 8 / None / 1。二、程序整体结构与界面窗体核心成员只有三个一个串口对象、一个定时器、一个取消令牌publicpartialclassForm1:Form{SerialPort_serialPortnewSerialPort();// 串口对象CancellationTokenSource_ctsnewCancellationTokenSource();System.Timers.Timer_timernewSystem.Timers.Timer();// 定时轮询用publicForm1(){InitializeComponent();_timer.Interval1000;// 每 1 秒触发一次_timer.Elapsed_timer_Elapsed;_serialPort.DataReceived_serialPort_DataReceived;// 注册接收事件}}界面包含4 个文本框显示含水量、温度值、盐度、PH、一个读取按钮手动读一次、一个实时检测复选框勾选后每秒自动轮询。【插图 1程序运行效果图】三、串口初始化参数从配置文件读取好的实践是把串口参数写在App.config里而不是硬编码换设备、换波特率时改配置即可!-- App.config --appSettingsaddkeyPortNamevalueCOM4/addkeyBaudRatevalue9600/addkeyDataBitsvalue8/addkeyParityvalueNone/addkeyStopBitsvalueOne//appSettings窗体加载时读取配置并打开串口privatevoidForm1_Load(objectsender,EventArgse){_serialPort.PortNameCOM4;// 波特率、数据位是数字用 int.Parse 转换_serialPort.BaudRateint.Parse(ConfigurationManager.AppSettings[BaudRate]);_serialPort.DataBitsint.Parse(ConfigurationManager.AppSettings[DataBits]);// 校验位、停止位是枚举用 Enum.Parse 把字符串转成对应枚举值_serialPort.Parity(Parity)Enum.Parse(typeof(Parity),ConfigurationManager.AppSettings[Parity]);_serialPort.StopBits(StopBits)Enum.Parse(typeof(StopBits),ConfigurationManager.AppSettings[StopBits]);try{_serialPort.Open();// 打开串口}catch(Exceptionex){// 串口被占用、不存在等情况会抛异常弹窗提示MessageBox.Show(ex.Message);}}注意使用ConfigurationManager需要先通过 NuGet 安装System.Configuration.ConfigurationManager并在代码里using System.Configuration;。四、核心手工构造 Modbus RTU 问询帧【插图 2设备的官方文档】.4.1 发起读取点击读取按钮或定时器触发时调用Read方法从 0 号寄存器开始读 4 个// 数据读取privatevoidbutton1_Click(objectsender,EventArgse){if(!_serialPort.IsOpen)return;// 串口没打开直接返回Read(0x00,4);// 起始地址 0读取数量 4}/// summary/// 读取保持寄存器/// /summary/// param nameregisterAddress寄存器起始地址/param/// param namenum读取数量/paramprivatevoidRead(ushortregisterAddress,ushortnum){// 从站地址为 1功能码为 3拼出完整问询帧byte[]_dataReadingData(1,3,registerAddress,num);_serialPort.Write(_data,0,_data.Length);// 通过串口发出}4.2 拼装帧的前 6 个字节起始地址和数量都是 16 位2 字节需要拆成高字节 低字节这里用到了位运算/// summary/// 主机问询帧构造/// /summaryprivatebyte[]ReadingData(byteslaveAddress,bytefunction,ushortregisterAddress,ushortnumber){// 先放前 6 个字节CRC 校验正是基于这 6 个字节计算byte[]data1newbyte[6];data1[0]slaveAddress;// 从站地址data1[1]function;// 功能码// 起始地址是 2 字节要拆成高位、低位高位在前// 是按位右移把 16 位整体右移 8 位原来的高 8 位就落到低 8 位// 255255 二进制是 8 个 1用于只保留低 8 位得到一个 0~255 的字节data1[2]Convert.ToByte(registerAddress8255);// 起始地址高字节data1[3]Convert.ToByte(registerAddress255);// 起始地址低字节// 读取数量同样拆成高、低字节data1[4]Convert.ToByte(number8255);// 数量高字节data1[5]Convert.ToByte(number255);// 数量低字节// 对前 6 字节计算 CRC得到 2 字节校验码byte[]crcCrc16(data1);// 拼成完整 8 字节前 6 字节 CRC 2 字节byte[]datanewbyte[8];Array.Copy(data1,0,data,0,6);Array.Copy(crc,0,data,6,2);returndata;}关于 8 255举个例子地址0x0123十进制 291右移 8 位后高字节0x01落到低位再 255得到1直接 255得到低字节0x23 35。这样就把一个 16 位数拆成了两个字节。4.3 CRC16 校验计算CRC循环冗余校验的根本目的是检验数据在传输过程中是否丢包或被干扰。Modbus RTU 统一使用 CRC-16/MODBUS 算法初始值0xFFFF特征多项式0xA001它是标准多项式0x8005的位反转形式。这套代码是通用的任何 Modbus RTU 场景都能直接用privatebyte[]Crc16(byte[]data){ushortcrc160xFFFF;// 初始化 CRC 寄存器为全 1for(inti0;idata.Length;i){crc16^(ushort)data[i];// 第一步当前字节与 CRC 低 8 位异或for(intj0;j8;j)// 一个字节 8 位逐位处理{if((crc160x0001)!0)// 检查最低位是不是 1{crc161;// 最低位为 1右移一位crc16^0xA001;// 再与特征常数 0xA001 异或}else{crc161;// 最低位为 0只右移不异或}}}// Modbus 规定 CRC 低字节在前、高字节在后所以这里做高低字节交换returnnewbyte[]{(byte)(crc160xFF),// 低字节(byte)(crc168)// 高字节};}五、定时轮询手动 自动两种方式点读取按钮手动读一次勾选实时检测启动定时器每 1 秒自动读一次实现数据实时刷新。// 定时器到点就调用一次按钮的读取逻辑privatevoid_timer_Elapsed(objectsender,System.Timers.ElapsedEventArgse){button1_Click(sender,e);}// 实时检测复选框勾选启动定时器取消勾选停止privatevoidcheckBox1_CheckedChanged(objectsender,EventArgse){if(checkBox1.Checked)_timer.Start();else_timer.Stop();}六、核心接收应答帧并解析数据6.1 DataReceived 事件串口收到数据会在后台线程触发DataReceived事件。注意它不在 UI 线程所以更新界面控件时必须用Invoke切回 UI 线程。privatevoid_serialPort_DataReceived(objectsender,SerialDataReceivedEventArgse){// 一次性读取串口接收缓冲区中的所有字节byte[]buffernewbyte[_serialPort.BytesToRead];intnum_serialPort.Read(buffer,0,buffer.Length);if(num0)return;// 把原始字节打印到输出窗口方便观察真实报文foreach(variteminbuffer){Console.Write(item );}// 应答帧[0]地址码 [1]功能码 [2]有效字节数 [3..]数据区 [末尾]校验码bytefuncbuffer[1];// 功能码bytecountbuffer[2];// 有效字节数if(func3)// 功能码 3寄存器应答结果有效{// DataReceived 在后台线程更新控件必须 Invoke 到 UI 线程Invoke(newAction((){// 每个寄存器占 2 字节高字节 buffer[n]、低字节 buffer[n1]// 合并方法高字节 × 256 低字节即高字节左移 8 位// 多数变送器测量值带 1 位小数寄存器里放大了 10 倍所以再 /10stringdataStr((buffer[3]*256buffer[4])/10).ToString();// 含水量stringdataStr2((buffer[5]*256buffer[6])/10).ToString();// 温度值stringdataStr3((buffer[7]*256buffer[8])).ToString();// 盐度整数stringdataStr4((buffer[9]*256buffer[10])/10).ToString();// PHtxtwendu.TextdataStr;// 含水量textBox2.TextdataStr2;// 温度值textBox3.TextdataStr3;// 盐度textBox4.TextdataStr4;// PH}));}}【插图 3控制台原始字节输出图】6.2 重点理解高低位合并一个寄存器是 16 位分高、低两个字节传输解析时要把它们重新拼成一个数。以数值 300二进制0000 0001 0010 1100为例高字节0000 0001 1放在buffer[3]低字节0010 1100 44放在buffer[4]如果直接相加1 44 45错误正确做法是把高字节左移 8 位1 × 256 44 300。这就是高低位合并和发送时的拆字节正好是逆过程。6.3 关于精度除以 10变送器通常把带小数的测量值放大 10 倍、100 倍后以整数形式存进寄存器比如实际 25.6℃寄存器里是 256。所以解析后要按手册除以对应的倍数。本例大部分数据/10盐度按整数显示没有除——具体除以多少、要不要除一定要查该设备的通信协议手册不能照抄。七、初学者常见坑完全收不到数据先检查串口参数波特率/数据位/校验/停止位是否与设备一致、RS485 的 A/B 线有没有接反、从站地址对不对。程序一运行就弹窗报错多半是串口号不对、被其他软件占用或设备没插好打开串口要用try/catch包住。更新文本框报线程间操作无效DataReceived在后台线程更新 UI 必须用Invoke。数据解析出来明显不对值特别小忘了高低位合并正确公式是高字节 × 256 低字节。数值是实际值的 10 倍寄存器放大了倍数按手册/10或/100。偶尔能读到、偶尔 CRC 错误/丢数据可能是一次Read没读完整帧。DataReceived可能在一帧数据没到齐时就触发严谨做法是把收到的字节先缓存凑够完整帧按字节数字段判断长度再解析。CRC 算出来和设备对不上检查多项式是否为0xA001、初值0xFFFF、结果是否做了高低字节交换、参与计算的是否只有前面的数据字节不含 CRC 本身。定时器轮询过快导致数据错乱轮询间隔要给设备留出应答时间本例 1 秒更严谨的方式是发一帧、等应答回来再发下一帧。配置文件读不到确认 NuGet 装了System.Configuration.ConfigurationManager且 key 名称拼写一致。八、小结本文用一个真实的变送器读取程序完整演示了不依赖第三方库、纯手工实现 Modbus RTU 主站的全过程串口参数集中放在App.config窗体加载时读取并打开串口按地址 功能码 起始地址 数量拼出前 6 字节地址和数量用 8、 255拆成高/低字节用标准 CRC-16/MODBUS初值0xFFFF、多项式0xA001计算校验码低字节在前拼成完整 8 字节问询帧在DataReceived事件里读取应答判断功能码后用高字节 × 256 低字节合并数据、按精度除以倍数并通过Invoke更新界面用定时器实现 1 秒周期的自动轮询。亲手拼一次帧、算一次 CRC比直接调用第三方库理解得深刻得多以后遇到通信问题也能快速定位。建议对照本文代码配合虚拟串口和 Modbus 从站模拟软件在本机跑一遍。
