1. 为什么要自己写一个Modbus RTU从机库
在嵌入式开发里,Modbus RTU大概是工控现场最常见的通信协议之一。PLC、触摸屏、变频器、温控表、电表,但凡带个RS485口的设备,基本都支持Modbus RTU。接到一个项目,硬件平台是STM32F103,要求通过RS485跟触摸屏通信,上报几个温度值、控制几路继电器输出,通信协议就定成了Modbus RTU从机。当时第一反应是上网找个现成的库,GitHub上搜一圈,确实有不少开源的Modbus从机协议栈,比如FreeModbus,功能也确实全面。但真去集成的时候发现一个问题:把FreeModbus移植到自己的工程里,配置回调、裁剪功能码、适配定时器,折腾一圈下来,工程量比自己写一个还大,而且协议栈为了追求通用性,代码里全是条件编译和函数指针,出问题了排查起来也不方便。
那次调了整整两天才把FreeModbus跑通,后来仔细想了想:项目需求其实很简单,就用到了03读保持寄存器、06写单个寄存器、16写多个寄存器这三个功能码,寄存器数量也就十几个,完全没必要引入一个重型的协议栈。于是干脆自己动手写一个精简版的Modbus RTU从机库,串口接收用中断加状态机,超时判断用定时器,整套代码下来不到五百行,移植到其他STM32工程里也只需要改几个底层接口。这篇就把整个实现过程从头到尾拆开讲,包括报文格式、CRC校验、状态机设计、寄存器映射,还有实际调试中踩过的坑,给同样需要在STM32上实现Modbus RTU从机通信的朋友一个可以直接抄作业的参考。
2. 动手前必须搞清楚的Modbus RTU细节
2.1 报文帧格式与字节序陷阱
Modbus RTU的报文结构本身不复杂,一条完整的请求帧由地址码、功能码、数据段和CRC校验组成。地址码占用一个字节,范围是1到247,0地址是广播地址,从机收到广播帧需要执行但不回复。功能码也是一个字节,常用到的有01读线圈、02读离散输入、03读保持寄存器、04读输入寄存器、05写单个线圈、06写单个寄存器、15写多个线圈、16写多个寄存器。数据段长度根据功能码变化,最后是两个字节的CRC16校验,低字节在前。
这里有个非常容易踩的坑:Modbus RTU的多字节数据统一采用大端模式,也就是高字节在前,低字节在后。比如要读取保持寄存器地址0x0001的值,请求报文是 01 03 00 01 00 01 CRC_H CRC_L,注意寄存器地址0x0001先发0x00再发0x01。但CRC校验码是低字节在前,算出来CRC如果是0x0C3A,那发送顺序就是 3A 0C。这个字节序问题在写代码和抓包分析时一定要时刻记着,不然就是数据对不上、响应不匹配。
另外还要注意寄存器地址的偏移问题。Modbus协议里寄存器的地址是从0开始的,但很多触摸屏和组态软件在上位机界面上显示的地址是从40001开始的(对应保持寄存器)。也就是说上位机显示40001,实际报文里的地址是0x0000。做从机库的时候,库内部统一使用报文里的实际地址,不做偏移换算,把这个工作交给上位机去配置。否则两边各偏一次,数据就对不上了。
2.2 CRC16校验的原理与实现方案
CRC校验是Modbus RTU保证数据完整性的核心机制。Modbus RTU使用的CRC16算法有固定的多项式0x8005,初始值为0xFFFF,计算过程和标准的CRC16有些差异,网上有各种说法,但其实Modbus的CRC计算有一个很明确的标准流程:每个字节先和CRC低字节异或,然后右移八次,每次检测最低位,如果是1就和0xA001异或,否则直接右移。这个0xA001其实是多项式0x8005的反映射形式,对于初学者不需要深究为什么,直接用就行。
实现方式有两种,一种是查表法,一种是逐位计算法。查表法把256个字节对应的CRC结果提前算好存在数组里,运行时每个字节查一次表,做两次异或和两次移位,速度最快。逐位计算法不需要额外的存储空间,但每个字节要处理8次位运算,在72MHz主频的STM32F103上处理一条Modbus帧(最长256字节)也就几十微秒的事,完全不影响实时性。
我的方案是采用查表法,因为Modbus RTU从机对响应时间有要求,虽然逐位计算也足够快,但查表法更规范,而且从机库将来可能移植到主频更低的MCU上。查表法的关键是要有对应的表格,这个表格不需要自己手算,常见计算器工具都能生成,或者直接从标准实现里摘过来。核心计算函数如下:
uint16_t Modbus_CRC16(uint8_t *buffer, uint16_t length) { uint16_t crc = 0xFFFF; uint16_t i, j; for (i = 0; i < length; i++) { crc ^= buffer[i]; for (j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc = crc >> 1; } } } return crc; }注意这个函数返回的crc是高字节在前,而Modbus报文要求低字节在前,所以发送时要把返回值高低字节对调。另外,收到的帧做校验时,是把整帧(包括CRC两个字节)一起带入计算,如果结果等于0,说明帧有效。这两种方式是等价的,我习惯把CRC校验放在接收完成之后单独做,便于调试时定位问题。
2.3 字符间隔与帧间隔:判断一帧数据的边界
Modbus RTU是异步串口通信,数据以字节为单位传输,没有固定的帧头帧尾。那从机怎么知道一帧数据什么时候结束呢?靠的是时间间隔。Modbus规范定义了两种时间参数:字符间超时(t1.5)和帧间超时(t3.5),单位是字符传输时间。接收端一旦检测到总线空闲超过3.5个字符时间,就认为之前收到的数据是一整帧。特性波特率下,这个时间需要经验计算。
比如9600波特率,一个字符包含1个起始位、8个数据位、1个停止位(无校验),总共10位,一帧时间就是10/9600≈1.04ms。3.5个字符时间就是3.65ms,1.5个字符时间就是1.56ms。115200波特率下,3.5个字符时间大约是304us。如果从机在接收完最后一个字节后,超过这个时间没有新的数据进来,就可以判定帧结束,开始解析。
这个时间参数的实现有几种方式:最简单的是用定时器,每当串口收到一个字节,就重新装载定时器的计数值,定时器溢出中断里置一个帧完成标志。更优雅的方案是利用STM32串口的空闲中断(IDLE),在接收到一个字节后的空闲期间触发中断,表示总线空闲了。这个后面会专门讲实现细节。
这里要特别提醒一个容易忽略的点:主站发送一帧数据时,如果各字节之间有较大的间隔(比如操作系统调度延迟),导致超过3.5个字符时间,从机就会把一帧拆成两帧来处理,产生错误响应。所以调试Modbus时,一定要用示波器或逻辑分析仪看看总线上的波形时序,确认主站的发送是否连续。
3. 从零构建从机库的工程结构
3.1 库的设计目标与文件划分
动手写之前,我先明确了这个库的设计目标:移植简单、占用资源小、代码逻辑清晰。所以整个库由两个文件组成:modbus_rtu_slave.h和modbus_rtu_slave.c。头文件对外暴露必要的接口,源文件包含具体的实现。所有标识符用前缀mb_开头,避免和其他模块命名冲突。
库和硬件相关的部分只依赖四个底层接口:串口发送一个字节、串口接收一个字节(由中断调用)、定时器启动和停止、定时器获取当前计数值。这四个接口通过函数指针的方式注入,也可以直接改为弱函数声明,让用户在外部实现。我为了代码直白方便,直接定义了四个宏:
#define MB_UART_SEND_BYTE(byte) // 外部实现: 串口发送一个字节 #define MB_UART_RECV_BYTE() // 外部实现: 串口接收一个字节 #define MB_TIMER_START() // 外部实现: 启动超时定时器 #define MB_TIMER_STOP() // 外部实现: 停止超时定时器移植到不同芯片时,只需要修改这四个宏的实现,库本身的逻辑完全不用动。这种设计最大的好处是调试时可以把串口换成虚拟串口、定时器换成软件延时,在PC上模拟整个协议栈的行为,排查逻辑问题非常方便。
3.2 寄存器模型:保持寄存器还是输入寄存器
从机库最核心的数据对象是寄存器。Modbus协议把数据分为四个区域:线圈(可读可写,位)、离散输入(只读,位)、输入寄存器(只读,16位)、保持寄存器(可读可写,16位)。对于大多数应用场景,两个区域就足够用了:保持寄存器用来存放可读写的参数(比如控制命令、设定值),输入寄存器用来存放只读的采集数据(比如温度、电压)。
以我的项目中为例:保持寄存器规划了16个(MB_REG_HOLDING_START到MB_REG_HOLDING_END),输入寄存器规划了8个(MB_REG_INPUT_START到MB_REG_INPUT_END)。在库内部定义一个数组保存寄存器值,初始化时清零。对外提供两个接口来读写这些寄存器:一个是用户程序获取寄存器值,另一个是用户程序更新寄存器值。当主站发来写寄存器命令时,库会自动更新数组内容,并通过回调通知用户程序,这样用户程序不需要关心协议细节,专注业务逻辑即可。
寄存器区的大小可以根据项目需要调整。如果寄存器数量不多但访问频繁,可以考虑用static数组直接分配;如果寄存器数量大且分散,建议用起始地址加偏移的方式来索引,从Modbus报文中的地址减去起始地址得到数组下标。注意Modbus报文里地址是相对某个数据区起点的偏移量,比如保持寄存器0x0000就是保持寄存器区的第一个寄存器。
3.3 功能码支持规划
一开始规划功能码时,我建议不要贪多,先用最常用的四个:03读保持寄存器、04读输入寄存器、06写单个保持寄存器、16写多个保持寄存器。这四个功能码覆盖了80%以上的应用场景。如果后续需要控制继电器输出,可以再把01读线圈和05写单个线圈加上。功能码的数量和协议栈的代码量成正比,每多一个功能码,就要多一个处理函数,出错的可能性也更大。对自己写库来说,按需增加是最务实的路径。
我把功能码处理做成一个switch-case分发结构,每个case对应一个功能码的处理函数。处理函数接收一个指向请求帧缓冲区的指针和帧长度,返回响应帧的长度。如果某个功能码不支持,直接返回异常码01(非法功能码)。这种设计的好处是新增功能码时只需要添加一个case,不影响其他逻辑。单独写一个异常响应的构建函数,也是这个库的基础设施之一。
4. 核心代码实现与解析
4.1 串口接收:中断驱动的状态机
串口接收是整个从机库的入口,设计得是否稳健直接决定通信质量。我采用的是串口接收中断加定时器超时的组合方案:串口每收到一个字节就进一次接收中断,把字节存入缓冲区,同时重置超时定时器;定时器溢出中断则代表帧结束,开始处理收到的完整帧。
先看串口接收中断的处理:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); mb_rx_buffer[mb_rx_index++] = data; if (mb_rx_index >= MB_RX_BUFFER_SIZE) { mb_rx_index = 0; mb_rx_state = MB_STATE_IDLE; } // 重置帧超时定时器 MB_TIMER_STOP(); MB_TIMER_START(); } }这里需要重点说明缓冲区大小的问题。Modbus RTU报文最长是256字节(地址1字节+功能码1字节+数据段252字节+CRC2字节),对于常用的03功能码读多个寄存器,响应帧的数据段是1+2*寄存器数量。如果一次最多读125个寄存器(协议允许的最大值),响应帧长度是1+1+1+250+2=255字节。因此缓冲区大小建议设置为256字节,确保完整容纳最大帧。
4.2 帧超时检测:定时器配置的临界值
定时器超时间隔的设置非常关键。设置太长会拖慢响应速度,设置太短又会在高波特率下误判帧结束。前面算过,标准帧间隔是3.5个字符时间,但这是理想值。我实际使用时会留一些余量,把超时时间设定在5个字符时间左右,既能可靠区分帧边界,又不会显著增加响应延迟。
以115200波特率为例,一个字符约87us,5个字符时间约435us。用STM32F103的内部定时器,预分频设为72-1(1us计数一次),自动重载值设为450,这样定时器每450us溢出一次。但这个方案有一个问题:如果帧本身长,前面字节到后面字节之间的间隔稍微拉大,还没到3.5个字符时间就被提前触发,导致帧被截断。最稳妥的方式是每次收到字节都重新装载定时器计数值,而不只是启动和停止。我用的是定时器的更新中断+每次接收字节时清零计数器的方式,这样无论多长的帧,只要字节间隔不超过超时阈值,就不会被误判。
4.3 帧解析:从裸字节到结构体
收到完整的一帧后,第一步做CRC校验,第二步解析地址码,第三步分发功能码。这三步顺序不能反,原因很简单:如果CRC校验不通过,说明数据在传输中出了差错,这时候再解析功能码没有意义,甚至可能因为数据错乱执行了错误操作。直接丢弃是最安全的做法。
CRC校验的代码实现如下:
static uint8_t mb_verify_crc(uint8_t *buffer, uint16_t length) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < length; i++) { crc ^= buffer[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc = crc >> 1; } } } // 帧末尾带了两字节CRC,计算时包含在内,结果应为0 return (crc == 0x0000); }这个函数把整帧(包括最后两个CRC字节)一起计算,结果如果为0就说明CRC校验通过。注意函数内部用的是逐位计算,没有用查表法,两种方式在STM32上性能差异不大,逐位计算代码更紧凑,而且不需要额外的表格存储空间。
地址匹配方面,从机地址通过一个配置接口设置,比如默认从机地址是1。当收到的帧地址不等于配置的从机地址时,直接丢弃,不产生任何响应。但如果地址是0(广播地址),需要处理但不回复。这个细节比较容易漏:广播帧往往是主站用来同步校准时间或统一复位的,从机必须执行,但不能回复,否则多个从机同时回复会导致总线冲突。
4.4 03功能码实现:读保持寄存器的响应组装
03功能码请求帧结构是:地址+功能码+起始地址(2字节)+寄存器数量(2字节)+CRC。对应格式如下表:
| 字段 | 长度 | 说明 |
|---|---|---|
| 地址码 | 1字节 | 从机地址 |
| 功能码 | 1字节 | 0x03 |
| 起始地址 | 2字节 | 要读取的起始寄存器地址 |
| 寄存器数量 | 2字节 | 要读取的寄存器数量 |
| CRC | 2字节 | CRC16校验,低字节在前 |
处理函数的逻辑:首先把起始地址和寄存器数量从大端字节序转换为本机整数,然后检查合法性:起始地址加数量不能超过寄存器数组范围,数量不能超过协议允许的最大值(0x007D,即125个)。如果合法,就开始组装响应帧:
static uint16_t mb_process_read_holding(uint8_t *req, uint16_t req_len) { uint16_t start_addr = (req[2] << 8) | req[3]; uint16_t reg_count = (req[4] << 8) | req[5]; uint8_t *rsp = mb_tx_buffer; // 参数检查 if (reg_count == 0 || reg_count > 125 || start_addr + reg_count > MB_REG_HOLDING_NUM) { return mb_build_exception_response(0x03, 0x02); // 非法数据地址 } rsp[0] = mb_slave_addr; rsp[1] = 0x03; rsp[2] = reg_count * 2; for (uint16_t i = 0; i < reg_count; i++) { uint16_t reg = mb_reg_holding[start_addr + i]; rsp[3 + i * 2] = reg >> 8; rsp[4 + i * 2] = reg & 0xFF; } uint16_t crc = Modbus_CRC16(rsp, 3 + reg_count * 2); rsp[3 + reg_count * 2] = crc & 0xFF; rsp[4 + reg_count * 2] = crc >> 8; return 3 + reg_count * 2 + 2; }注意响应帧的第二字节是字节数,这个字节从报文格式上看是在地址和功能码之后,表示数据字段的字节数。很多初学者容易把这里搞错,直接把寄存器数量填进去,导致主站解析错误。计算好响应帧长度后统一调用串口发送函数把数据发出。
4.5 06功能码实现:写单个寄存器的边界检查
06功能码请求帧格式是:地址+功能码+寄存器地址(2字节)+寄存器值(2字节)+CRC。处理时同样需要地址合法性和值范围的检查。如果写入的地址超出范围,返回异常响应,异常码是02(非法数据地址)。如果值超出了用户定义的允许范围(这个逻辑在写回调函数里做),返回异常码03(非法数据值)。
写成库的时候需要一个解耦的思路:库只负责把值写进寄存器数组,然后回调一个用户函数,让用户判断这个值是否符合业务逻辑,如果不符合可以返回错误,库会把寄存器值回滚。这个机制在工业控制场景非常有用,比如主站试图把电机转速设成超过上限的值,这时候必须拒绝写入。
4.6 16功能码实现:写多个寄存器的数据封装
16功能码的请求帧格式比06复杂一些:地址+功能码+起始地址(2字节)+寄存器数量(2字节)+字节数(1字节)+数据段(N字节)+CRC。这里的字节数指的是后面数据段的总字节数,也就是寄存器数量乘以2。处理时先检查起始地址、数量和字节数是否匹配,任何一个不匹配都视为非法请求。
我实现时还会额外处理一个边界情况:请求写入的寄存器区域跨越了数组边界。比如起始地址是15,数量是2,而寄存器数组总共有16个,这就会导致越界访问。正确处理方式是检查start_addr + reg_count > MB_REG_HOLDING_NUM,超过就直接返回异常,不做部分写入。因为Modbus协议要求要么全部写入成功,要么全部不写,不能出现写了一半的情况。
4.7 异常响应机制:正确回复错误码
当从机收到无法处理的请求时,必须回复异常响应帧,否则主站会一直等待超时。异常响应帧的格式是:地址+功能码(最高位置1)+异常码+CRC。比如03功能码出错,响应帧的功能码是0x83。常用异常码有:01非法功能码、02非法数据地址、03非法数据值、04从机设备故障。
异常响应的构造函数:
static uint16_t mb_build_exception_response(uint8_t func_code, uint8_t exception_code) { mb_tx_buffer[0] = mb_slave_addr; mb_tx_buffer[1] = func_code | 0x80; mb_tx_buffer[2] = exception_code; uint16_t crc = Modbus_CRC16(mb_tx_buffer, 3); mb_tx_buffer[3] = crc & 0xFF; mb_tx_buffer[4] = crc >> 8; return 5; }异常响应对debug意义重大。比如主站一直报超时,但用逻辑分析仪看到从机其实回复了,只是回复内容里带着异常码。这时候看异常码就能快速定位是地址越界还是功能码不支持,比盲目调线缆和波特率有效得多。
4.8 主循环与中断的协作机制
从机库的运行模式是:串口中断收字节,定时器中断判断帧结束,帧解析函数可以在主循环中执行,也可以在定时器中断中执行。两种方式各有优劣,我推荐在主循环中执行,原因是解析函数里有业务回调,回调里可能执行NRF24L01的写操作或驱动LED等耗时操作,放在中断里会阻塞其他中断,影响系统实时性。
具体做法是定时器中断里只置一个标志位mb_frame_ready = 1,主循环检测到这个标志后执行mb_poll()函数处理帧。mb_poll()完成CRC校验、功能码分发、响应发送的整个流程。处理完后清标志位。这种方案中断里只做简单操作,主循环做耗时操作,安全又简单。
5. 常见问题与排查实录
5.1 现象一:收到的数据全是0xFF或乱码
这种问题90%出在串口参数配置上。Modbus RTU常用配置是9600或115200波特率,8数据位,无校验,1停止位。先把USB转RS485模块接到电脑上用串口调试助手发数据,再用逻辑分析仪看STM32发出的数据,如果从机回的数据在电脑上显示乱码,大概率是电脑端串口参数没配对。还有一个坑是RS485方向控制:RS485是半双工通信,发送数据时要先把方向引脚拉高,发送完再拉低,如果方向切换有延时或者拉低太快,最后一个字节就可能不完整。通常的做法是发送完最后一个字节后,延时一个字节的时间再拉低方向引脚,或者利用USART的TC中断来精确控制方向。
5.2 现象二:CRC校验总是失败
CRC校验失败需要从两个方向查。一个是计算逻辑:用网上在线的Modbus CRC计算器输入同样的数据,比对计算结果,如果一致说明计算逻辑没问题,问题在收发环节。另一个是收发环节:用逻辑分析仪抓总线上的数据,确认接收到的帧和发送端一致,特别要注意CRC字节的排列顺序,Modbus是低字节在前。还有一个隐藏很深的问题:有的代码在接收缓冲区里存了额外字符(比如帧头帧尾标记),如果直接把整个缓冲区拿去算CRC,肯定失败,要确保是对原始帧数据做校验。
5.3 现象三:能收到请求但不回复
先看是不是地址不匹配:把地址配置和请求里的地址码打印出来比对。再看是否广播地址:广播地址0不回复,这是正常行为。然后看帧完成标志有没有置位:如果定时器配置不正确(比如预分频不对导致溢出时间过长),帧完成标志永远不会置位。还有一种情况是串口接收缓冲区的长度设置太短,一帧数据还没收完缓冲区就满了,提前触发了处理逻辑,这时加打印看看每次进入接收中断的索引值变化就知道原因了。
5.4 现象四:回复了但主站报超时
主站报超时有两个可能:一是从机回复得太慢,超出主站设置的超时时间。这时候检查从机处理帧的耗时,如果帧解析放在主循环里且主循环被其他任务阻塞,可能延迟很大,解决方案是把帧解析放在优先级较高的上下文执行,或者优化主循环结构。二是从机根本没把回复数据发出来,看RS485方向控制是否正常,发送完成后方向有没有及时切回接收,否则下一个请求会被自己发出去的数据干扰。
5.5 实测排错心得
我在这个项目里踩过最深的坑是波特率偏差。STM32F103用外部晶振8MHz倍频到72MHz,如果晶振精度不够或者倍频配置有误,实际波特率和标称值会有偏差。长时间连续传输数据后,误差累积会导致通信失败。排查方法是发送固定数据,用示波器测量一个字节的位宽,和理论值对比。如果偏差超过2%,就要检查时钟配置或者改用内部RC振荡器加校准。
另外强烈建议在开发阶段把发送和接收的数据都通过串口输出一份调试信息。我写了一个开关宏,开启后每个收到的字节都会通过调试串口打印出来,配合逻辑分析仪,对照报文格式逐字节分析,问题基本都能在半小时内定位。
6. 实操总结与库的扩展方向
6.1 测试环境与验证过程
在STM32F103C8T6最小系统板上搭建了测试环境,RS485收发器用SP3485,通过USB转RS485模块连到电脑。用ModbusPoll这个主站模拟软件做测试。测试流程分三步:第一步用ModbusPoll的03功能码读取保持寄存器,确认读操作正常;第二步用06功能码写入一个寄存器,再读回来验证数据一致性;第三步用16功能码同时写多个寄存器,验证多寄存器写入功能。整个测试过程中用逻辑分析仪监测RS485总线上的数据,对比每一帧的发送和响应内容。
测试下来整体效果不错,从收到请求到发出响应的总耗时大约在0.5ms以内(115200波特率),完全满足一般PLC通信的时序要求。连续跑了一整天的压力测试,在1ms循环周期下没有出现误帧或者漏响应的情况。
6.2 代码的工程化整理建议
如果要把这个库用到实际产品中,有几个地方值得再完善。一是增加看门狗保护和总线错误统计功能:统计累计接收帧数、CRC错误帧数、异常响应次数,这些数据可以通过另外一个寄存器区暴露给主站,方便远程诊断。二是考虑多从机地址支持:一个STM32接多个RS485总线时,可能需要一个库实例对应一条总线,这时候把库改成可重入的结构,所有状态都放在实例结构体里,而不是用全局变量。三是增加参数持久化功能:主站通过06功能码写入的参数,在掉电后要能恢复,可以挂一个Flash存储回调函数,写寄存器时同步存Flash,上电初始化时读Flash。
6.3 最后分享一个调试小技巧
如果手头没有逻辑分析仪,可以用STM32的空闲中断配合串口调试助手,把收到的原始数据和解析后的结果都打印出来,同样能高效定位问题。具体做法是在串口空闲中断里把缓冲区的内容通过printf输出,标注好帧序号,这样每个收到的帧都看得清清楚楚。另外一个更高级的做法是在Modbus从机库中增加一个专门的调试寄存器,主站往这个寄存器写一个特殊值,从机就进入回环测试模式,把收到的数据原样返回,用于验证链路通信是否正常。这两个技巧在实际项目中帮了我不少忙,也推荐给你试试。