ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32手写精简Modbus RTU从机库:报文解析与状态机实现全攻略

2026/10/5 10:04:03 拓冰建站 浏览量
STM32手写精简Modbus RTU从机库:报文解析与状态机实现全攻略 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到2470地址是广播地址从机收到广播帧需要执行但不回复。功能码也是一个字节常用到的有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.65ms1.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功能码读多个寄存器响应帧的数据段是12*寄存器数量。如果一次最多读125个寄存器协议允许的最大值响应帧长度是1112502255字节。因此缓冲区大小建议设置为256字节确保完整容纳最大帧。4.2 帧超时检测定时器配置的临界值定时器超时间隔的设置非常关键。设置太长会拖慢响应速度设置太短又会在高波特率下误判帧结束。前面算过标准帧间隔是3.5个字符时间但这是理想值。我实际使用时会留一些余量把超时时间设定在5个字符时间左右既能可靠区分帧边界又不会显著增加响应延迟。以115200波特率为例一个字符约87us5个字符时间约435us。用STM32F103的内部定时器预分频设为72-11us计数一次自动重载值设为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字节要读取的寄存器数量CRC2字节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从机库中增加一个专门的调试寄存器主站往这个寄存器写一个特殊值从机就进入回环测试模式把收到的数据原样返回用于验证链路通信是否正常。这两个技巧在实际项目中帮了我不少忙也推荐给你试试。