1. 为什么要在STM32F103上折腾Agile Modbus
STM32F103这颗芯片,搞嵌入式的基本都摸过。Cortex-M3内核,72MHz主频,64KB到128KB的Flash,20KB的SRAM,外设该有的都有——USART、SPI、I2C、CAN、定时器、DMA,一个不少。价格便宜,资料铺天盖地,最小系统板十几块钱就能买到。拿它做工业现场的数据采集终端、小型PLC、传感器节点,是很多人的第一选择。
但问题也恰恰出在这里。F103的SRAM只有20KB,Flash最大也就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和PA10,USART2是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板的RX,A板的RX要接B板的TX,GND要共地。如果是用电脑的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或115200 | 9600更稳定,115200更快 |
| 数据位 | 8 | 固定值 |
| 停止位 | 1 | 固定值 |
| 校验位 | 无 | 靠CRC校验保证数据正确性 |
| 从站地址 | 1~247 | 0是广播地址,248~255保留 |
| 发送缓冲区 | 256字节 | 根据最大帧长度调整 |
| 接收缓冲区 | 256字节 | 根据最大帧长度调整 |
| 定时器中断频率 | 100微秒 | 根据波特率调整 |
| 帧间隔阈值 | 40(9600波特率) | 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提供了异常响应的解析API,agile_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 TCP,Agile 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可能更合适。工具没有好坏,只有合不合适。