
做嵌入式这些年Modbus RTU大概是我接到次数最多的通信需求。前阵子客户那边提了一个升级要求把原来用标准51单片机做的一台采集从机整体换成STC32G上位机还是原来的组态软件而且要求换芯之后直接通讯、不改画面、不重做工程。客户原话是最新的版本多个组态软件都得兼容。这话听着简单真做起来牵扯到的东西不少——从机源码的架构、寄存器规划、CRC校验、帧间隔处理、各家组态软件的地址习惯差异任何一环不处理好现场就是连不上偶尔超时读出来全是乱码之类的毛病。这篇文章就把这个项目从需求到源码、再到多种组态软件联调的过程完整拆开讲一遍适合正在做单片机从站、或者正准备把老51项目迁到STC32G上的开发者参考。1. 这个项目到底在解决什么问题1.1 需求拆分从老51换到STC32G的真实理由先交代一下原始设备的背景。老设备用的是8位经典51内核单片机功能很简单采集8路模拟量信号做成千里迢迢走Modbus RTU从站上位机通过一只USB转RS485模块轮询读取。设备用了好几年问题出在几个地方一是主频低处理浮点运算和滤波算法时稍微有点吃力二是单片机的Flash空间不够客户想在从机里加一段自诊断逻辑和参数掉电保存程序快塞满了三是老型号供货越来越不好拿客户项目有交付周期压力不想再为老芯片的库存发愁。所以需求就变成了用STC32G12K128这颗增强型8051核心的单片机重新实现一个Modbus RTU从机寄存器布局尽量和老设备保持一致上位机组态工程里的变量、画面、报警全部不动。也就是说从机代码可以重写但从Modbus协议的视角来看必须看起来跟老设备一模一样。这里有个容易被忽略的点客户说最新的版本指的不仅仅是单片机换新款还包括从机源码本身要整理成可维护、可扩展的版本。老项目里那种一个main函数写到底、中断里做所有事的写法这次我建议彻底重构。因为后续客户可能会加功能码、加寄存器、加第二路串口代码结构不清后面改一次崩一次。1.2 Modbus RTU为什么在组态通信里还是主力很多人觉得Modbus太老但现实是组态软件、触摸屏、PLC、仪表、采集模块Modbus RTU的覆盖率依然是最高的。原因也很朴实它简单到几乎没有门槛。一条RS485总线主站轮询从站应答请求就是一个地址加功能码加寄存器范围响应就是把数据塞回同样格式的帧里再加上两个字节的CRC校验。Modbus RTU的帧格式没有任何帧头帧尾这种东西它靠时间间隔来分帧。协议规定帧内字符间隔小于1.5个字符时间帧间间隔大于3.5个字符时间波特率不同这些毫秒数就不同比如9600波特率、8位数据位、1位停止位一个字符大约1.13ms3.5字符时间大约3.96ms。从机必须在收完一帧后等待至少这么长时间才能确定一帧已经结束然后开始解析。组态软件所以能轻轻松松连上各路设备就是因为它只需要发标准Modbus报文就行。从机只要老老实实按协议回复不管上位机是哪家组态软件都能对上话。但按协议回复这句话背后还藏着寄存器地址偏移、字节序、超时时间、功能码支持范围这些细节后面我会专门展开。2. STC32G做从机的关键资源盘点2.1 主频与波特率先把计时算清楚STC32G系列主频可以跑到30MHz以上我这套源码默认按24MHz系统时钟调校的。为什么选24MHz因为在这个频率下定时器做波特率发生器时可以做到很低的误差而且整个芯片的功耗、电磁干扰也相对可控。以串口1的波特率9600为例STC32G的定时器1作为波特率发生器时重载值计算公式是// 系统主频 FOSC 24000000 Hz // 波特率 Baud 9600 // 定时器1重载值1T模式 BRT1 65536 - FOSC / (4 * Baud); // 即 65536 - 24000000 / 38400 65536 - 625 64911 0xFDAF这个计算结果几乎零误差。实际配置时还要注意定时器1需要设置成1T模式还是12T模式两者算出来的重载值差别很大。用STC32G的时候如果照搬老51的12T计算公式波特率直接偏到姥姥家。不同主频对应不同波特率的误差我整理了一张表方便参考系统主频目标波特率定时器模式重载值实际波特率误差24MHz96001T0xFDAF96000%24MHz192001T0xFDB6192000%30MHz96001T0xFD049600.60.006%30MHz1152001T0xFF40115384.60.16%11.0592MHz96001T0xFCCD96000%串口初始化时还必须确认波特率发生器的时钟源选择。STC32G提供了T1/T2/T3/T4等多组定时器串口1常用的是定时器1或者定时器2作为波特率发生器。实测下来优先选定时器2更省心因为这样定时器1可以腾出来做别的计时任务不会互相打架。2.2 硬件CRC模块到底用不用STC32G12K128内部带了硬件CRC模块理论上可以算CRC16。但这颗芯片的硬件CRC更多是为CRC8/CRC16/CRC32多种多项式设计的并不专门针对Modbus那条CRC16多项式0x8005的反向算法0xA001。硬件模块需要配置多项式、初值、输入输出反转用起来初始化代码不少。我自己实测的结论是Modbus RTU从机完全不需要用硬件CRC。原因很简单——从机回包的数据量撑死几十字节用软件查表法算CRC16在24MHz主频下耗时只有几十微秒对整机响应时间的影响可以忽略。而硬件CRC需要额外适配不同芯片的寄存器细节移植性变差。软件CRC用查表法还是逐位法查表法按字节处理每秒能处理几十万字节挪到从机里完全杀鸡用牛刀。但查表法要占512字节的Flash放两张表对STC32G的128KB Flash来说毫无压力。逐位法则省空间但CPU开销大不推荐在项目里用。我最后打包进源码的是经典Modbus CRC16查表实现unsigned int ModbusCRC16(unsigned char *pData, unsigned int len) { unsigned int crc 0xFFFF; unsigned char i; while (len--) { crc ^ *pData; for (i 0; i 8; i) { if (crc 0x0001) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; }这里有个非常容易踩的坑这个函数返回的CRC是高字节在前、低字节在后但Modbus RTU协议规定报文里是先低字节后高字节。很多人第一次写从机直接把函数返回值往发送缓冲区里塞结果上位机永远报CRC错误。正确做法是把高低字节反过来txbuf[txlen] (unsigned char)(crc 0xFF); // CRC低字节先发 txbuf[txlen] (unsigned char)((crc 8) 0xFF); // CRC高字节后发2.3 RS485方向控制的边界时序如果只是点对点RS232收发这篇源码可以省掉一半功夫。但组态软件那套基本都走RS485总线而RS485是半双工从机必须正确控制DE/RE引脚的收发方向。这里最关键的时序是发送完最后一字节之后不能立刻把DE拉低必须让移位寄存器把最后一个停止位完整送出去。否则最后一个字符会被切断上位机收到的帧少了CRC或者末尾字节直接判超时。STC32G的串口发送完成中断TI会在发送停止位时触发但此时停止位还没完全送出。稳妥的做法是在TI中断里启动一个微延时至少延时半个字符时间后再拉低DE。或者更简单粗暴一点在TI中断里直接等一两个NOP循环实测9600波特率下一个字符约1.13ms半个字符延时约0.6ms用定时器或循环都能凑出来。我习惯在发送完成后开一个定时器0做短暂延时延时到了再拉低DE。这样接收方向切回来非常干净不会吞掉自己的尾巴。3. 从机源码的核心骨架3.1 帧接收状态机的正确打开方式Modbus RTU从机最核心的部分就是如何把串口收到的一堆字节还原成一帧。前面说过协议靠时间间隔切帧。实现上有两种常见方案方案一接收完每个字节都重置一个帧间隔定时器定时器溢出才认为帧结束。方案二直接使用串口的空闲中断。STC32G的串口没有专门的空闲中断所以方案一是最通用的。我的做法是串口1接收中断只负责把SBUF里的数据搬进接收缓冲区同时清零一次帧间隔定时器。帧间隔定时器用定时器3配置成每1ms中断一次中断里对超时计数器做累加累加值超过设定阈值就置标志位主循环查标志位后解析帧。关键代码骨架// 定时器3中断每1ms触发一次 void Timer3_Isr(void) interrupt 14 { static unsigned int cnt 0; cnt; if (cnt MB_FRAME_GAP_MS) // 一般为5~20ms { cnt 0; mb_frame_ready 1; // 表示距离上一字节接收已超过帧间隔 } } // 串口1接收中断 void UART1_Isr(void) interrupt 4 { if (RI) { RI 0; rxbuf[rxlen] SBUF; if (rxlen MB_RX_BUF_SIZE - 1) { rxlen; } mb_gap_timer_reset 1; // 告诉定时器线程重置超时计数 } }帧间隔阈值怎么选协议说3.5字符时间但实际工程建议留余量通常取5~20ms之间。9600波特率下10ms是比较稳的值。数据流速高到115200时可以把阈值缩到5ms避免响应太迟钝。这个参数我单独拎出来做成宏定义方便不同波特率下调整。这里还要注意一个坑主循环里解析一帧时必须立刻清空接收缓冲区和长度否则下一帧数据到了会拼到上一帧尾巴上。通常在帧就绪标志被置位后先缓存当前rxlen作为帧长度然后马上把rxlen清零再做解析。3.2 寄存器映射表与功能码分发Modbus从机的灵魂其实是寄存器表。上位机不管你的内部变量叫U8_AI1还是g_fTemperature它只知道地址。所以代码里要维护一张从机地址到内部数据的映射关系。我在这套源码里用了最简单也最不容易出错的方案一块连续的16位寄存器数组。保持寄存器设计成最多100个寄存器地址从0x0000到0x0063每个寄存器都是16位宽度高字节在前。#define MB_REG_HOLDING_START 0x0000 #define MB_REG_HOLDING_COUNT 100 unsigned int holding_regs[MB_REG_HOLDING_COUNT];实际数据怎么进这个数组我建议做一张映射表把寄存器地址和内部变量对应起来。比如某个温度变量是浮点型占了两个连续16位寄存器那就在采集更新时把两个半字拆开填进去。这样Modbus协议层完全不用关心数据类型只负责按地址搬运16位数据就够了。寄存器区规划建议寄存器地址含义读写属性数据类型0x0000设备地址只读16位0x0001固件版本号只读16位0x0008-0x000F设备信息字符串只读16位×80x0010-0x00178路模拟量当前值只读16位×80x0020-0x00278路上限报警值可写16位×80x0030-0x00378路下限报警值可写16位×80x0040设备运行模式可写16位功能码方面对组态软件来说最常用的就是三个03读保持寄存器、06写单个寄存器、160x10写多个寄存器。这套源码把这三个全部实现再加了一个支持广播地址0的功能。注意广播地址只允许写操作从机不回帧。分发逻辑非常直白switch (frame[1]) { case 0x03: HandleReadHoldingRegisters(frame, len); break; case 0x06: HandleWriteSingleRegister(frame, len); break; case 0x10: HandleWriteMultipleRegisters(frame, len); break; default: SendExceptionResponse(frame[0], 0x01); // 非法功能码 break; }寄存器越界检查一定要做完整。比如某次请求读起始地址0x0060、数量20个寄存器实际总数只有100个那从机应该返回异常码0x02非法数据地址而不是把越界后的数据硬填出来。很多人写的从机在这一点上很随意结果就是上位机读数偶尔会跳出一堆0或随机值。3.3 响应超时与异常码的讲究从机响应太快有时候反而不是好事。组态软件在发出请求后会等一个固定的响应超时时间通常默认是几百毫秒。从机哪怕只用1毫秒就回包也不会有问题。但反过来如果从机回包太慢超过上位机超时时间上位机就会判定通信超时然后重发请求严重时整个网络会卡成狗。STC32G从机实际响应耗时主要花在CRC计算几十微秒 组包几十微秒 串口发送。整帧回包在9600波特率下发送几十字节也就几十毫秒这个速度完全来得及。异常码返回也要规范。常见的异常码含义异常码含义常见触发条件0x01非法功能码设备不支持的功能码0x02非法数据地址寄存器地址越界0x03非法数据值写入值超出允许范围0x04从站设备故障内部变量更新异常组态软件收到异常码之后虽然也会在界面上显示通信错误但至少协议是通的排查问题比完全没反应要快得多。我建议从机代码里把异常响应做成一个公用函数任何非法请求都走同一出口。4. 多种组态软件适配实战经验4.1 先说结论协议是通的习惯是乱的我在联调阶段同时测了三款组态软件为了叙述方便下文分别叫它们A软件、B软件、C软件。A软件是老牌国产组态B软件是上位机加数据采集类软件C软件是某款HMI触摸屏自带的组态工具。三款软件底层都是标准Modbus RTU主站但工程配置界面、寄存器地址编号方式、默认超时参数差别非常大。实测得到的第一条结论是从机代码不需要为某款软件写特殊分支只需要在寄存器地址编号上统一一个规则然后针对每款软件把起始地址怎么填写成说明文档交给现场。真正要动的永远是上位机那边的配置项和地址换算从机端保持标准Modbus语义即可。下面是三款组态软件在配置同一个从机时的关键差异对照配置项A软件B软件C软件设备地址默认111寄存器起始编号0对应400011对应400010对应40001字节序默认高字节在前高字节在前高字节在前最大寄存器读取数12012564默认超时时间1000ms500ms200ms数据位/停止位/校验8/1/无8/1/无8/1/偶校验寄存器起始编号这一行就是最经典的坑A软件里你想读从机保持寄存器地址0x0000画面变量里地址填0它自动映射到Modbus地址40001B软件要求你填1它内部减1之后才得到真正的Modbus地址0x0000C软件又是另外一套映射。如果你从机端手册只写保持寄存器地址从0开始现场工程师按B软件的1去填读到的就是0x0001完全错位。所以我在最终交付的文档里做了一张换算表Modbus协议地址、A软件填法、B软件填法、C软件填法一一对应现场照着抄就错不了。4.2 浮点数的字节序不能想当然Modbus协议本身只定义寄存器是16位的怎么存浮点数协议管不着。组态软件读32位浮点数时默认从4个连续寄存器的前两个字节开始解析。问题就来了同样一个float数据按大端模式和小端模式存在寄存器里表现出来的顺序完全不同。最常见的三种32位浮点存储顺序顺序寄存器1寄存器2说明ABCD高16位低16位标准大端CDAB低16位高16位多数国产组态默认BADC也有也有部分PLC默认老设备原来用的是标准大端ABCD如果从机源码里直接照搬老代码A软件这边读出来完全正常B软件那边数字就变成了天文数字。实测B软件读一个1.5的浮点画面上显示的是1.82121e-41这种让人抓狂的值其实就是字节序反了。我的做法是从机浮点存储统一采用标准IEEE754大端ABCD然后在上位机变址配置里把每款组态软件的浮点顺序改成大端。A软件默认就是大端不用动B软件在变量类型里选浮点-高字在前C软件需要把寄存器顺序改成高字低字。如果实在不想动组态画面还有一个土办法从机内部把浮点数按组态软件的默认顺序存也就是在写入寄存器数组之前做一次字节交换。这个方案能保画面完全不改但代码可读性变差以后换组态软件又得改回来我建议除非客户百般要求否则还是统一大端更干净。4.3 轮询速度与通信超时的平衡组态软件默认轮询周期各不相同A软件默认1000ms扫一遍全部变量C软件能做到200ms甚至100ms。同一个从机面对不同轮询速度表现差异会非常明显。我在测试时故意把C软件的采集周期压到100ms它一个周期内会同时读模拟量、设备信息、报警值等多段寄存器请求频率非常高。此时从机如果响应稍慢或者帧间隔判定过于保守就会出现上位机超时然后反复重发总线上一片混乱。解决办法有两头。一头是上位机端把采集周期放宽到200ms以上把最大寄存器读取数调大让一个请求尽量读取整段连续寄存器减少请求次数。另一头是从机端确保帧接收缓冲足够大、解析逻辑足够快、响应立即组包立即发送。STC32G主频24MHz完全没有性能问题真正拖后腿的往往反而是我前面提到的DE切换延时——如果你发送之后磨磨蹭蹭过2ms才允许下一次接收高速轮询肯定扛不住。5. 实测中遇到的四个典型问题与排查链路5.1 现象A软件正常B软件偶发超时这个现象很典型A软件默认1000ms轮询B软件默认300ms轮询。从机端帧间隔阈值我当时设成20ms按说足够区分帧间间隔了。但B软件重试几次之后就报通信超时A软件从头到尾都是绿的。排查时先用串口抓包一路抓下来发现B软件发出请求之后从机确实回了包但B软件认为超时。仔细看时间戳从机响应花了大约35ms——听着不多但B软件的响应超时初始值只有200ms按理说35ms也够。真正的问题出在B软件重发机制上它收到一个非法帧比如CRC错误会立刻重发请求而旧请求的响应还在总线上回着两帧叠加导致总线碰撞。根因其实很简单从机发送完毕之后DE拉低得太慢。我前文说的发送停止位后延时再拉低DE延时时间用的是0.5个字符时间但实际它约等于0.6ms。问题出在B软件那边轮询速度快从机DE还没完全释放接收方向下一帧请求已经到了结果请求的第一个字节被自己发送电路和接收电路打架吃掉了。修正方法DE拉低的延时缩短到只保证停止位发完即可用定时器精确延时0.2个字符时间同时把从机回复前的处理时间尽可能压缩。修完之后300ms轮询下连续跑了两个小时零超时。5.2 现象读浮点数全是天文数字这个就是4.2节说的字节序问题。第一天联调时A软件读设备版本号这种16位整数完全正常一读模拟量浮点就乱。一开始我还怀疑是寄存器偏移的问题后来把从机内部寄存器数组通过串口调试打印出来发现内部数据是对的问题只出在上位机解析顺序。排查链路给各位参考先用串口助手手动发03功能码读两个连续寄存器记录原始值把原始值按IEEE754拆解确认从机发的就是标准大端到A软件变量定义里把浮点存储顺序改成ABCD立刻正常到B软件里改成高字在前也正常整个排查不超过十分钟但如果不做第2步光在软件界面里翻来覆去改设置很容易越改越乱。任何时候都要以从机发出的实际字节为准绳而不是以上位机界面的各种选项为准。5.3 现象能读不能写打开组态软件读数据都是正常的画面上的输入框里改数值按回车设置值半天不生效再看通信日志发现上位机发的是06功能码写单个寄存器。翻从机代码发现我只实现了03和16两个功能码06根本没写。这就相当于上位机用左手递东西从机只用右手接能接住才怪。06功能码是单寄存器写操作帧很短实现起来十分钟的事。但这次教训是立项时定了支持03、06、16源码里却只做了03和16功能码覆盖清单必须逐个打钩验收。另外一个容易漏的是某些组态软件写单寄存器用06写多个寄存器用16如果碰上寄存器比较多现场可能只触发16而把漏掉的06掩盖掉。5.4 现象距离一长就乱码现场调试时从机和上位机距离大约60米用的普通非屏蔽双绞线连上之后能通但偶尔冒出来一个CRC错误。近距离测试一切正常远距离就出问题。这类问题排查思路很固定先看波形示波器挂到RS485的A、B两端看数据帧边沿是否陡峭再看终端电阻总线两端是否接了120欧姆匹配电阻最后看从机的DE方向切换发送完最后一个字节到拉低DE之间有没有把停止位完整发完这个项目最后发现是终端电阻问题。总线两端都需要120欧姆电阻现场只在主站侧接了一个从机侧没接。加上线材不是屏蔽双绞线信号反射叠加个别字节被冲掉CRC就报错了。接上电阻之后乱码彻底消失。这里也提醒一句STC32G的IO驱动能力很强RS485芯片的驱动也够但长距离总线必须把硬件底线做好屏蔽双绞线、双端120欧姆电阻、合理接地。软件只能兜底不能解决物理层劣化的根本问题。6. 把这套源码复用到下一个项目的几个建议6.1 寄存器规划一开始就要留出余量这次给客户做的寄存器表是紧急拼的虽然功能没问题但地址分配不够规整。比如设备信息占了0x0000-0x000F模拟量占了0x0010-0x0017报警值占了0x0020-0x0027中间0x0018-0x001F这些地址就被浪费了。如果客户后来要加一路模拟量就得往后面顶跟报警区挤在一起整个地址表变得很难看。下次做类似项目我会在一开始就把寄存器分区写成下面这样功能块地址段数量说明设备信息区0x0000-0x000F16只读厂名、版本、序列号实时采集区0x0010-0x002F32只读模拟量、开关量状态控制参数区0x0030-0x004F32可写报警值、控制字统计信息区0x0050-0x006F32只读运行时间、故障次数预留区0x0070-0x00FF144全部返回0便于将来扩展预留区千万别直接不响应而是要规规矩矩返回数据或者异常码。很多组态软件初始化时会读一大片连续寄存器来刷新画面如果中途某个地址不响应整段读取失败画面全黑。6.2 新项目联调顺序按三步走第一步从机端自测用串口调试工具或者一张USB转485的调试板手动发03、06、16功能码确认寄存器读写都正常。这一步把从机问题全部消灭。第二步接组态软件随便选一款组态软件新建工程配好串口参数和从机地址只添加几个测试变量确认通信正常后再把全套变量导进去。第三步接真实总线把从机挂到现场的485总线上跟其他设备一起跑观察是否有地址冲突、波形劣化、轮询超时。这个顺序看似废话但实际项目里大部分人都是倒着来的——直接从第三步开始结果现场所有设备都在通信根本分不清是谁的问题。6.3 源码管理的版本注释要比客户要求更细这次客户说最新版说明之前很可能已经给过别人几个版本。所以我这次在源码文件头写清楚了三件事适用单片机型号、系统主频、支持的组态软件列表及地址映射约定。同时在main函数入口处打印一条初始化的调试信息把固件版本、寄存器表版本、CRC表版本都打出来。以后客户说我用的是你原来的旧版本拿调试信息一对就知道具体是哪个版本省掉大量扯皮时间。最后再说一点个人体会Modbus RTU从机这东西协议层并不难真正的工程难点全在细节里。寄存器地址偏移、字节序、帧间隔阈值、DE切换时序、终端电阻这些问题任何一个处理不慎都会以通信不稳定的面目出现在现场。把这套源码和排查经验当成一个可复用的框架比每次接到需求都从头写一遍要靠谱得多。