STM32F103上Agile Modbus RTU主从机通信实战指南
1. 为什么要在STM32F103上折腾Agile ModbusSTM32F103这颗芯片搞嵌入式的基本都摸过。Cortex-M3内核72MHz主频64KB到128KB的Flash20KB的SRAM外设该有的都有——USART、SPI、I2C、CAN、定时器、DMA一个不少。价格便宜资料铺天盖地最小系统板十几块钱就能买到。拿它做工业现场的数据采集终端、小型PLC、传感器节点是很多人的第一选择。但问题也恰恰出在这里。F103的SRAM只有20KBFlash最大也就128KB你不可能在上面跑一个庞大的协议栈。很多刚接触Modbus的兄弟第一反应是去找现成的协议栈比如FreeMODBUS。FreeMODBUS确实经典移植资料也多但它的代码结构对新手不算友好移植过程中要改的地方不少尤其是串口中断和定时器那一块稍不留神就卡死或者丢包。而且FreeMODBUS的代码量摆在那里对于只需要实现基本主从机通信的场景来说有点杀鸡用牛刀的意思。Agile Modbus就是在这个背景下进入视野的。它是一个轻量级的Modbus协议栈纯C编写核心文件就两个——agile_modbus.c和agile_modbus.h加起来不到两千行。它不依赖任何操作系统不依赖动态内存分配所有的缓冲区都由用户自己提供。这意味着你可以精确控制RAM的占用对于F103这种资源紧张的芯片来说这一点太关键了。它同时支持RTU和TCP两种模式主站和从站都可以实现而且API设计得很简洁基本上看一遍头文件就知道怎么调。我第一次在F103上跑通Agile Modbus的时候从建工程到主从机互相收发数据前后不到两个小时。这个效率在以前用FreeMODBUS的时候是不敢想的。当然Agile Modbus也不是没有坑比如它对串口收发的时序有要求3.5个字符的帧间隔需要你自己用定时器去判断它只负责协议层的解析和组包。但恰恰是这种分工明确的设计让它在小资源平台上的表现非常稳定。这篇文章面向的是有一定STM32基础、想快速在F103上实现Modbus RTU主从机通信的开发者。不管你是做工业控制、智能家居网关还是单纯想学习Modbus协议下面的内容都可以直接参考。我会把完整的代码结构、关键配置、踩过的坑都摊开来讲你照着做就能跑起来。2. Agile Modbus的核心设计与移植思路2.1 为什么选Agile Modbus而不是FreeMODBUS先说结论如果你的项目对RAM和Flash极其敏感而且只需要Modbus RTU的基本功能Agile Modbus是更合适的选择。FreeMODBUS功能更全支持ASCII、RTU、TCP还有完整的从机回调机制但它的代码量和RAM占用也更大。在F103C8T6这种只有20KB SRAM的芯片上FreeMODBUS跑起来之后留给用户程序的空间就比较紧张了。Agile Modbus的设计哲学是“只做协议层的事”。它不关心你用什么串口、用什么定时器、数据怎么收发这些全部交给用户。它只负责把你要发送的数据按照Modbus协议打包成帧或者把收到的帧解析成对应的功能码和数据。这种设计的好处是移植极其简单你只需要提供两个函数——一个发送函数和一个接收函数剩下的就是调用它的API。从代码结构上看Agile Modbus的核心是一个agile_modbus_t结构体里面包含了从站地址、功能码、发送缓冲区指针、接收缓冲区指针、缓冲区大小等字段。初始化的时候把这些字段填好然后调用agile_modbus_rtu_init或者agile_modbus_tcp_init就可以开始用了。发送的时候调用agile_modbus_send接收的时候调用agile_modbus_receive解析的时候调用agile_modbus_unpack。整个流程非常线性没有复杂的回调嵌套。还有一个很重要的点Agile Modbus不依赖malloc。所有的缓冲区都是用户在外面定义好然后把指针传进去。这对于嵌入式开发来说太重要了因为很多工业现场的设备是不允许动态内存分配的怕的就是内存碎片导致系统跑飞。Agile Modbus从设计上就规避了这个问题。2.2 移植前需要准备什么在开始移植之前你需要准备以下几样东西STM32F103的工程模板可以用标准库也可以用HAL库。我个人建议用HAL库因为CubeMX配置起来方便生成的代码结构也清晰。如果你习惯标准库也没问题Agile Modbus本身不依赖任何库。一个可用的串口F103一般用USART1或者USART2。USART1的引脚是PA9和PA10USART2是PA2和PA3。选哪个都行看你的板子怎么引出的。一个定时器用来做3.5个字符的帧间隔判断。可以用TIM2、TIM3、TIM4随便选一个。定时器的中断频率要设置好后面会详细讲。Agile Modbus的源码从GitHub上把agile_modbus.c和agile_modbus.h下载下来加到工程里。这两个文件是核心其他的example和test文件不需要。硬件方面如果你要做主从机通信测试最好准备两块F103的板子一块做主站一块做从站。如果只有一块板子也可以用电脑上的Modbus调试助手来配合测试。电脑端可以用Modbus Poll或者QModMaster这些工具都很成熟。2.3 串口和定时器的配置要点串口的配置比较标准波特率一般用9600或者115200数据位8停止位1无校验。如果你要用校验位Agile Modbus也支持但需要在初始化的时候把校验方式传进去。我一般用无校验因为工业现场干扰大的时候校验位也挡不住错误还不如靠协议层的CRC校验。串口的收发方式有两种选择中断收发和DMA收发。对于Modbus RTU来说一帧数据最长也就256个字节用中断收发完全够用。DMA的好处是CPU占用低但配置起来稍微麻烦一点。我建议先用中断收发把功能跑通后面如果需要优化再改DMA。定时器的配置是移植Agile Modbus的关键。Modbus RTU协议规定帧与帧之间要有至少3.5个字符时间的间隔。在9600波特率下一个字符是11位1起始位8数据位1校验位1停止位3.5个字符就是38.5位大约4毫秒。在115200波特率下3.5个字符大约是0.33毫秒。定时器的中断频率要能覆盖这个时间一般设置成100微秒中断一次然后在中断里计数超过3.5个字符时间就认为一帧结束。这里有个细节定时器的中断优先级要设置好。如果串口中断的优先级比定时器高那么串口在接收数据的时候定时器中断会被打断导致计时不准。我一般把定时器中断的优先级设置得比串口高这样定时器能准时触发串口接收完一个字节后定时器重新计数不会影响帧间隔的判断。3. 主从机通信的完整实现过程3.1 从机端的实现步骤从机端的逻辑比较简单等待主站发来的请求帧解析请求执行对应的操作然后返回响应帧。用Agile Modbus实现从机核心就是三个步骤初始化、接收解析、发送响应。先看初始化。定义一个agile_modbus_t结构体然后定义发送缓冲区和接收缓冲区。缓冲区的大小根据你的需求来定一般256字节足够了。然后调用agile_modbus_rtu_init把从站地址、缓冲区指针、缓冲区大小传进去。从站地址一般设为1如果你有多个从站就分别设为1、2、3……#define AGILE_MODBUS_MAX_ADU_LENGTH 256 static uint8_t send_buf[AGILE_MODBUS_MAX_ADU_LENGTH]; static uint8_t recv_buf[AGILE_MODBUS_MAX_ADU_LENGTH]; static agile_modbus_t modbus_ctx; void modbus_slave_init(void) { agile_modbus_rtu_init(modbus_ctx, send_buf, sizeof(send_buf), recv_buf, sizeof(recv_buf)); modbus_ctx.slave 1; // 从站地址设为1 }初始化完成之后就可以在串口接收中断或者主循环里处理数据了。我一般是在串口接收中断里把数据存到一个环形缓冲区然后在主循环里判断帧间隔如果超过3.5个字符时间就认为一帧接收完成调用agile_modbus_receive和agile_modbus_unpack来解析。解析的时候Agile Modbus会根据功能码自动判断请求的类型。比如功能码0x03是读保持寄存器0x06是写单个寄存器0x10是写多个寄存器。你需要根据解析结果准备对应的响应数据。Agile Modbus提供了一组API来帮你组包比如agile_modbus_serialize_read_registers用于读寄存器的响应agile_modbus_serialize_write_register用于写寄存器的响应。void modbus_slave_poll(void) { int rc; uint8_t *req_data; int req_len; // 假设recv_buf里已经收到了完整的一帧数据 rc agile_modbus_receive(modbus_ctx, recv_buf, recv_len); if (rc 0) { return; // 接收失败直接返回 } rc agile_modbus_unpack(modbus_ctx); if (rc 0) { return; // 解析失败直接返回 } // 根据功能码处理请求 switch (modbus_ctx.func) { case AGILE_MODBUS_FC_READ_HOLDING_REGISTERS: // 读保持寄存器准备响应数据 agile_modbus_serialize_read_registers(modbus_ctx, reg_data, reg_count); break; case AGILE_MODBUS_FC_WRITE_SINGLE_REGISTER: // 写单个寄存器更新寄存器值 agile_modbus_serialize_write_register(modbus_ctx, reg_addr, reg_value); break; default: // 不支持的功能码返回异常响应 agile_modbus_serialize_exception(modbus_ctx, AGILE_MODBUS_EXCEPTION_ILLEGAL_FUNCTION); break; } // 发送响应 agile_modbus_send(modbus_ctx); }从机端有一个容易踩的坑如果你在串口中断里直接调用Agile Modbus的解析函数可能会因为中断嵌套导致数据错乱。我的做法是在串口中断里只负责把数据存到缓冲区然后设置一个标志位在主循环里根据标志位和帧间隔来判断是否处理。这样逻辑清晰也不容易出问题。3.2 主机端的实现步骤主机端比从机端稍微复杂一点因为主机需要主动发起请求然后等待从机的响应。用Agile Modbus实现主机核心步骤是初始化、组包发送、等待响应、解析响应。初始化跟从机类似只是不需要设置从站地址。主机的agile_modbus_t结构体里slave字段是用来指定要访问的从站地址的每次发送请求之前设置一下就行。void modbus_master_init(void) { agile_modbus_rtu_init(modbus_ctx, send_buf, sizeof(send_buf), recv_buf, sizeof(recv_buf)); }发送请求的时候先调用agile_modbus_serialize_read_registers或者agile_modbus_serialize_write_register来组包然后调用agile_modbus_send发送。发送完成之后等待从机的响应。等待的方式有两种一种是阻塞等待用超时机制另一种是非阻塞等待在主循环里轮询。我一般用非阻塞的方式因为主站可能还要处理其他任务不能一直卡在等待响应上。具体做法是发送请求后设置一个超时计数器然后在主循环里检查串口是否收到数据。如果收到完整的一帧就调用agile_modbus_receive和agile_modbus_unpack解析如果超时了还没收到就重发或者报错。int modbus_master_read_registers(uint8_t slave_addr, uint16_t reg_addr, uint16_t reg_count, uint16_t *reg_data) { int rc; modbus_ctx.slave slave_addr; agile_modbus_serialize_read_registers(modbus_ctx, reg_addr, reg_count); agile_modbus_send(modbus_ctx); // 等待响应超时时间设为100ms uint32_t timeout 100; while (timeout--) { if (frame_received) { // 帧接收完成的标志 rc agile_modbus_receive(modbus_ctx, recv_buf, recv_len); if (rc 0) { return -1; } rc agile_modbus_unpack(modbus_ctx); if (rc 0) { return -1; } // 解析响应数据 for (int i 0; i reg_count; i) { reg_data[i] modbus_ctx.regs[i]; } return 0; } delay_ms(1); } return -1; // 超时 }主机端有一个需要注意的地方发送请求之后要确保从机的响应帧被完整接收。如果从机的响应比较长比如读了多个寄存器响应帧可能有几十个字节串口接收中断会多次触发。这时候帧间隔的判断就很重要了必须等到3.5个字符时间没有新数据才能认为一帧接收完成。3.3 帧间隔判断的具体实现帧间隔判断是Modbus RTU通信中最容易出问题的地方。Agile Modbus本身不处理这个需要你自己用定时器实现。我的做法是这样的用一个定时器设置成100微秒中断一次。在串口接收中断里每收到一个字节就把定时器的计数器清零。在定时器中断里计数器递增。当计数器超过3.5个字符时间对应的计数值时就认为一帧接收完成设置帧接收标志。以9600波特率为例3.5个字符时间是4毫秒100微秒中断一次的话计数值超过40就认为帧结束。以115200波特率为例3.5个字符时间是0.33毫秒计数值超过3就认为帧结束。这里有个细节如果波特率很高定时器的中断频率也要相应提高否则计数值太小容易误判。我一般用100微秒中断对于115200波特率来说计数值是3虽然有点小但实测下来也能用。volatile uint32_t frame_timer 0; volatile uint8_t frame_received 0; // 串口接收中断 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); recv_buf[recv_len] data; frame_timer 0; // 每收到一个字节定时器清零 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } // 定时器中断100微秒一次 void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { frame_timer; if (frame_timer 40) { // 9600波特率下4ms对应40个计数 frame_received 1; frame_timer 0; } TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }这个逻辑看起来简单但实际调试的时候有几个坑。第一个坑是串口中断的优先级。如果串口中断的优先级比定时器高那么串口在接收数据的时候定时器中断会被延迟导致frame_timer的计数不准。我一般把定时器中断的优先级设置得比串口高这样定时器能准时触发。第二个坑是frame_received标志的清除。在主循环里处理完一帧数据后一定要把frame_received清零否则会重复处理同一帧数据。同时recv_len也要清零准备接收下一帧。第三个坑是定时器的初始化。TIM2的时钟频率是72MHz要设置成100微秒中断一次预分频系数设为72-1自动重装载值设为100-1。这样定时器的计数频率是1MHz每100个计数就是100微秒。4. 常见问题与排查技巧实录4.1 通信不上怎么办通信不上是最常见的问题可能的原因有很多。我一般按照以下顺序排查第一步检查硬件连接。A板的TX要接B板的RXA板的RX要接B板的TXGND要共地。如果是用电脑的USB转串口模块要注意模块的TX和RX是否交叉。我遇到过好几次都是因为TX和RX接反了折腾半天才发现。第二步检查波特率。主从双方的波特率必须一致数据位、停止位、校验位也要一致。如果一方用9600另一方用115200肯定通信不上。用示波器或者逻辑分析仪看一下波形能很快确认波特率是否正确。第三步检查从站地址。主站发送的请求里从站地址必须和从站设置的地址一致。如果从站地址是1主站发送的地址是2从站不会响应。这个用Modbus调试助手很容易验证。第四步检查帧间隔。如果帧间隔判断有问题可能会导致一帧数据被分成两帧或者两帧数据被合并成一帧。用逻辑分析仪抓一下串口波形看看帧与帧之间的间隔是否满足3.5个字符时间。第五步检查CRC校验。Modbus RTU的帧尾有两个字节的CRC校验。如果CRC计算错误从站会丢弃请求不返回响应。Agile Modbus内部会自动计算CRC但如果你自己组包的时候把CRC位置搞错了也会导致通信失败。4.2 数据错乱怎么排查数据错乱一般表现为读到的寄存器值不对或者写进去的值和读出来的值不一致。可能的原因有字节序问题Modbus协议规定寄存器是16位的高字节在前低字节在后。如果你在代码里把高低字节搞反了读出来的值就会不对。比如寄存器值0x1234高字节是0x12低字节是0x34发送的时候先发0x12再发0x34。如果搞反了读出来就是0x3412。缓冲区溢出如果接收缓冲区太小一帧数据没收完就溢出了后面的数据会覆盖前面的数据。Agile Modbus的缓冲区大小要设置得足够大一般256字节够了但如果你的寄存器数量很多响应帧可能会超过256字节这时候就要加大缓冲区。中断嵌套如果串口中断和定时器中断的优先级设置不当可能会导致中断嵌套数据在处理过程中被新的中断打断导致数据错乱。我一般把定时器中断的优先级设得比串口高避免嵌套。4.3 常见问题速查表问题现象可能原因排查方法解决方案完全无响应硬件连接错误检查TX/RX是否交叉GND是否共地重新接线完全无响应波特率不一致用示波器看波形确认波特率统一波特率完全无响应从站地址不匹配用调试助手发送不同地址的请求修改从站地址响应超时帧间隔判断错误用逻辑分析仪抓帧间隔调整定时器参数响应超时CRC校验失败检查CRC计算代码使用Agile Modbus内置CRC数据错乱字节序错误检查高低字节顺序调整字节序数据错乱缓冲区溢出检查缓冲区大小加大缓冲区偶尔丢包中断优先级不当检查中断优先级设置调整优先级偶尔丢包定时器精度不够提高定时器中断频率改用更高频率4.4 几个实用的调试技巧技巧一用LED指示通信状态。在串口接收中断里翻转一个LED这样你能直观地看到是否有数据进来。如果LED不闪说明硬件或者串口配置有问题如果LED闪但通信不上说明数据进来了但解析有问题。技巧二把收到的数据原样打印出来。在解析之前先把接收缓冲区的数据通过另一个串口打印出来看看收到的帧是否符合Modbus协议格式。这个技巧在调试CRC问题和帧间隔问题时特别有用。技巧三用Modbus调试助手做对比测试。如果自己的主站和从站通信不上可以先用电脑上的Modbus调试助手分别和主站、从站通信确认两边都能正常工作。这样可以快速定位问题出在哪一边。技巧四注意Agile Modbus的返回值。Agile Modbus的API函数都有返回值负值表示失败。在调试的时候把返回值打印出来能快速定位问题。比如agile_modbus_receive返回-1表示接收失败agile_modbus_unpack返回-1表示解析失败。技巧五帧间隔时间要留余量。理论上是3.5个字符时间但实际调试的时候我一般会稍微放大一点比如用4个字符时间。这样能避免因为定时器精度不够导致的误判。当然也不能太大否则会影响通信效率。5. 完整代码结构与关键配置说明5.1 工程文件组织整个工程的文件结构如下Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── modbus_slave.h │ │ └── modbus_master.h │ └── Src/ │ ├── main.c │ ├── modbus_slave.c │ └── modbus_master.c ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ ├── AgileModbus/ │ ├── agile_modbus.c │ └── agile_modbus.h └── MDK-ARM/ └── Project.uvprojxagile_modbus.c和agile_modbus.h直接加到工程里不需要做任何修改。modbus_slave.c和modbus_master.c分别实现从机和主机的逻辑。main.c里根据你的需求选择调用从机还是主机的初始化函数。5.2 关键配置参数配置项推荐值说明波特率9600或1152009600更稳定115200更快数据位8固定值停止位1固定值校验位无靠CRC校验保证数据正确性从站地址1~2470是广播地址248~255保留发送缓冲区256字节根据最大帧长度调整接收缓冲区256字节根据最大帧长度调整定时器中断频率100微秒根据波特率调整帧间隔阈值409600波特率3.5个字符时间对应的计数值响应超时时间100毫秒根据实际需求调整5.3 主从机切换的实现有时候你可能需要同一个设备既能做主站又能做从站这时候可以通过一个标志位来切换。在main.c里定义一个模式变量根据这个变量决定调用主站还是从站的初始化函数和处理函数。typedef enum { MODE_SLAVE, MODE_MASTER } modbus_mode_t; modbus_mode_t modbus_mode MODE_SLAVE; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM2_Init(); if (modbus_mode MODE_SLAVE) { modbus_slave_init(); } else { modbus_master_init(); } while (1) { if (modbus_mode MODE_SLAVE) { modbus_slave_poll(); } else { modbus_master_poll(); } } }这种设计在网关类应用中很常见比如一个设备既要作为从站响应上位机的请求又要作为主站去采集下位机的数据。Agile Modbus的主从机API是独立的可以在同一个工程里同时使用只要注意缓冲区不要冲突就行。5.4 寄存器映射的设计在实际项目中你需要把设备的实际数据映射到Modbus寄存器上。比如你有4个温度传感器可以把它们映射到保持寄存器的0~3地址有2个继电器可以把它们映射到线圈的0~1地址。映射关系最好用一张表来管理这样代码清晰也方便维护。// 保持寄存器映射 #define REG_TEMP1_ADDR 0 #define REG_TEMP2_ADDR 1 #define REG_TEMP3_ADDR 2 #define REG_TEMP4_ADDR 3 #define REG_RELAY1_ADDR 4 #define REG_RELAY2_ADDR 5 uint16_t holding_regs[10]; // 保持寄存器数组 // 更新寄存器值 void update_holding_regs(void) { holding_regs[REG_TEMP1_ADDR] read_temp1(); holding_regs[REG_TEMP2_ADDR] read_temp2(); holding_regs[REG_TEMP3_ADDR] read_temp3(); holding_regs[REG_TEMP4_ADDR] read_temp4(); holding_regs[REG_RELAY1_ADDR] get_relay1_state(); holding_regs[REG_RELAY2_ADDR] get_relay2_state(); }在从站的请求处理函数里根据请求的寄存器地址和数量从holding_regs数组里取数据返回给主站。在主站的请求函数里把读到的数据存到对应的变量里然后去控制实际的外设。6. 性能优化与进阶技巧6.1 用DMA优化串口收发中断收发的方式在低波特率下没问题但在高波特率下频繁的串口中断会占用大量CPU时间。比如115200波特率下每秒钟最多可以传输11520个字节每个字节触发一次中断CPU要处理一万多次中断负担不小。这时候可以用DMA来优化。DMA收发的配置稍微复杂一点但思路是清晰的串口接收用DMA循环模式数据直接存到缓冲区不需要CPU干预串口发送用DMA正常模式发送完成触发中断通知CPU可以发送下一帧了。帧间隔的判断还是用定时器但定时器的计数逻辑要调整一下因为DMA接收的时候你不知道什么时候收到了一个字节只能通过DMA的计数器来判断。// DMA接收配置 void uart_dma_rx_init(void) { HAL_UART_Receive_DMA(huart1, dma_rx_buf, DMA_RX_BUF_SIZE); } // 获取DMA接收到的数据长度 uint16_t uart_dma_rx_get_len(void) { return DMA_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); }用DMA之后串口中断的频率大大降低CPU可以腾出来处理其他任务。但DMA也有坑比如DMA的缓冲区要足够大否则会溢出DMA的传输完成中断和串口空闲中断要配合使用才能准确判断一帧数据的结束。6.2 多从站轮询的实现在实际项目中一个主站往往要轮询多个从站。这时候需要设计一个轮询表记录每个从站的地址、要读的寄存器地址、寄存器数量、轮询间隔等信息。主站按照轮询表依次向每个从站发送请求收到响应后更新数据然后切换到下一个从站。typedef struct { uint8_t slave_addr; uint16_t reg_addr; uint16_t reg_count; uint16_t reg_data[10]; uint32_t poll_interval; uint32_t last_poll_time; } poll_item_t; poll_item_t poll_table[] { {1, 0, 4, {0}, 1000, 0}, {2, 0, 4, {0}, 1000, 0}, {3, 0, 4, {0}, 1000, 0}, }; void modbus_master_poll(void) { static uint8_t current_slave 0; uint32_t now HAL_GetTick(); if (now - poll_table[current_slave].last_poll_time poll_table[current_slave].poll_interval) { modbus_master_read_registers( poll_table[current_slave].slave_addr, poll_table[current_slave].reg_addr, poll_table[current_slave].reg_count, poll_table[current_slave].reg_data ); poll_table[current_slave].last_poll_time now; current_slave (current_slave 1) % (sizeof(poll_table) / sizeof(poll_table[0])); } }这种轮询方式简单可靠适合从站数量不多、实时性要求不高的场景。如果从站数量很多或者实时性要求很高可以考虑用状态机的方式把轮询过程拆分成多个状态避免阻塞。6.3 异常处理与重试机制Modbus通信中异常是难免的。从站可能返回异常响应比如功能码不支持、寄存器地址越界等也可能因为干扰导致CRC校验失败主站收不到响应。这时候需要有异常处理和重试机制。Agile Modbus提供了异常响应的解析APIagile_modbus_unpack返回负值的时候可以通过modbus_ctx.exception获取异常码。主站根据异常码决定是重试还是报错。int retry_count 3; while (retry_count--) { rc modbus_master_read_registers(slave_addr, reg_addr, reg_count, reg_data); if (rc 0) { break; // 成功退出重试 } if (rc -2) { // 从站返回异常响应不需要重试 break; } delay_ms(10); // 等待一段时间再重试 } if (retry_count 0) { // 重试次数用完记录错误 log_error(Modbus read failed after retries); }重试机制要注意几点重试次数不要太多一般3次就够了重试之间要有间隔避免总线冲突如果是异常响应说明从站收到了请求但无法处理重试也没用直接报错就行。6.4 低功耗场景的考虑如果设备是电池供电的低功耗就很重要了。Modbus通信本身是间歇性的大部分时间总线是空闲的。在空闲的时候可以把串口和定时器关掉CPU进入低功耗模式等有数据的时候再唤醒。F103支持睡眠、停止、待机三种低功耗模式。睡眠模式最简单串口中断就能唤醒停止模式更省电但唤醒后需要重新配置时钟待机模式最省电但唤醒后相当于复位所有状态都丢失。对于Modbus从站来说一般用睡眠模式就够了串口收到数据的时候自动唤醒处理完再进入睡眠。void enter_low_power(void) { // 关闭定时器 HAL_TIM_Base_Stop_IT(htim2); // 进入睡眠模式等待串口中断唤醒 __WFI(); // 唤醒后重新启动定时器 HAL_TIM_Base_Start_IT(htim2); }低功耗和通信实时性是有矛盾的。睡眠模式下串口收到第一个字节的时候会唤醒CPU但唤醒需要时间如果波特率很高可能第一个字节还没处理完第二个字节就来了。所以低功耗场景下波特率一般不会太高9600是比较合适的选择。7. 实际项目中的经验总结7.1 我踩过的几个坑第一个坑是串口中断优先级。刚开始的时候我把串口中断的优先级设得比定时器高结果发现帧间隔判断总是不准有时候一帧数据被分成两帧有时候两帧数据被合并成一帧。后来把定时器中断的优先级调高问题就解决了。这个坑花了我一个下午的时间最后用逻辑分析仪抓波形才找到原因。第二个坑是缓冲区大小。有一次读32个保持寄存器响应帧的长度是69个字节我设置的接收缓冲区是64字节结果数据溢出了后面的数据覆盖了前面的数据解析出来的寄存器值全是乱的。后来把缓冲区改成256字节问题解决。所以缓冲区大小一定要根据最大帧长度来设置宁大勿小。第三个坑是CRC校验的字节序。Modbus RTU的CRC是低字节在前高字节在后。我一开始按照常规的高字节在前去计算结果从站一直不响应。后来查了协议文档才发现CRC的字节序是反的。Agile Modbus内部已经处理好了这个问题但如果你自己实现CRC计算一定要注意字节序。第四个坑是帧间隔时间。理论上是3.5个字符时间但实际调试的时候我发现用3.5个字符时间有时候会误判尤其是波特率比较高的时候。后来我改成4个字符时间就稳定了。这个余量不用太大4个字符时间足够了。7.2 几个提高稳定性的建议建议一加看门狗。工业现场干扰大程序跑飞是常有的事。加一个独立看门狗或者窗口看门狗能在程序跑飞的时候自动复位提高系统的可靠性。建议二通信超时要有处理。主站发送请求后如果从站没有响应不能一直等下去要有超时机制。超时后要么重试要么报错不能让程序卡死。建议三关键数据要备份。如果从站有重要的配置数据最好在Flash里备份一份防止掉电丢失。F103的Flash读写比较简单用HAL库的HAL_FLASH_Program函数就能实现。建议四加隔离。如果通信距离比较远或者现场干扰比较大最好加光耦隔离或者磁隔离。这样能保护MCU也能提高通信的稳定性。我一般用ADuM1201或者ADuM3201做隔离效果不错。建议五终端电阻。如果通信距离超过几十米最好在总线的两端加120欧姆的终端电阻减少信号反射。这个在RS485总线上尤其重要。7.3 关于Agile Modbus的扩展Agile Modbus虽然轻量但扩展性不错。如果你需要支持更多的功能码可以在agile_modbus.h里找到功能码的定义然后在解析函数里添加对应的处理逻辑。比如功能码0x01是读线圈0x05是写单个线圈这些Agile Modbus都支持你只需要在从站的请求处理函数里添加对应的分支就行。如果你需要支持Modbus TCPAgile Modbus也提供了TCP的初始化函数。不过F103没有内置以太网MAC需要外接ENC28J60或者W5500这样的以太网模块。W5500用SPI接口配置起来比较简单配合Agile Modbus的TCP模式可以快速实现一个Modbus TCP从站。还有一个扩展方向是多主站支持。标准的Modbus RTU是单主站协议总线上只能有一个主站。但有些场景下需要多个主站这时候可以用Modbus RTU over TCP或者自己实现一个令牌环机制让多个主站轮流访问总线。这个比较复杂一般项目用不到。7.4 调试工具推荐调试Modbus通信有几个工具是必备的逻辑分析仪抓串口波形看帧间隔、波特率、数据内容。我用的是Saleae Logic 8便宜好用配套的软件也很强大。Modbus调试助手电脑端模拟主站或者从站跟设备通信。Modbus Poll和QModMaster都不错Modbus Poll功能更全QModMaster开源免费。串口调试助手看原始数据。SSCOM或者XCOM都行我习惯用SSCOM功能简单直接。示波器看信号质量。如果通信不稳定用示波器看一下波形能发现很多问题比如信号反射、干扰、电平不匹配等。这些工具加起来也不贵逻辑分析仪一两百块钱调试助手都是免费的。但对于提高调试效率来说帮助非常大。我刚开始搞Modbus的时候没有逻辑分析仪全靠猜效率很低。后来买了逻辑分析仪很多问题一眼就能看出来。7.5 一个实际案例的分享去年我做一个智能配电箱的项目用F103做从站采集8路电流电压数据控制4路继电器。上位机用组态软件做主站通过RS485总线轮询。一开始用FreeMODBUS移植了两天通信还是不稳定偶尔丢包。后来换成Agile Modbus半天就调通了连续跑了72小时一次丢包都没有。这个项目里我用了DMA接收定时器判断帧间隔看门狗防死机。从站的寄存器映射是这样的0~7是电流值8~15是电压值16~19是继电器状态。上位机每500毫秒轮询一次读16个寄存器响应帧长度是37个字节。波特率用9600总线长度大约50米两端加了120欧姆的终端电阻。调试过程中遇到一个问题继电器的状态偶尔会跳变。后来发现是因为继电器控制寄存器的写入和读取没有做同步主站写继电器状态的时候从站正在更新寄存器值导致读到的状态不一致。解决办法是在更新寄存器值的时候加一个互斥锁或者用双缓冲的方式写入和读取分开。这个坑比较隐蔽花了不少时间才找到。这个项目做完之后我对Agile Modbus的信心大增。后来好几个项目都用了它包括一个环境监测的网关一个电机控制的从站都很稳定。Agile Modbus的代码量小移植简单运行稳定对于F103这种资源有限的平台来说确实是一个很好的选择。如果你也在用F103做Modbus通信不妨试试Agile Modbus。它的学习曲线很平缓基本上看一遍头文件就能上手。遇到问题的时候可以看看它的源码逻辑很清晰不难理解。相比FreeMODBUS它更适合快速开发和资源受限的场景。当然如果你的项目需要更完整的功能比如ASCII模式、更复杂的异常处理FreeMODBUS可能更合适。工具没有好坏只有合不合适。