ARTICLE DETAIL

建站实战干货

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

STM32F103上Agile Modbus RTU主从机实战:从移植到稳定通信

2026/9/28 12:41:28 拓冰建站 浏览量
STM32F103上Agile Modbus RTU主从机实战:从移植到稳定通信 1. 为什么在 F103 上跑 Modbus RTU 值得单独聊一次STM32F103 这颗芯片在工控和嵌入式圈子里属于“老兵”级别的存在Cortex-M3 内核、72MHz 主频、丰富的外设资源加上极低的成本和成熟的生态让它成为大量中小型设备的首选主控。而 Modbus RTU 作为工业现场最普遍的串行通信协议之一几乎成了设备互联的“普通话”。把这两者结合起来就是无数项目里绕不开的一个基础需求让 F103 既能当 Modbus 主机去轮询传感器也能当从机被上位机或 PLC 读取数据。听起来简单但真正动手写的时候很多人会卡在几个地方串口收发怎么和协议帧的时序配合、3.5 字符间隔怎么判断、CRC 校验怎么算才不出错、主从机代码怎么复用同一套底层。如果每个项目都从头撸一遍寄存器操作效率太低也容易埋下隐患。Agile Modbus 这个轻量级协议栈的出现恰好解决了这个痛点——它把 Modbus RTU 的帧解析、CRC 校验、功能码处理都封装好了我们只需要对接串口的收发接口和几个回调函数就能快速跑通主从机通信。这篇文章面向的是已经有一定 STM32 基础、想快速把 Modbus RTU 落地到实际项目里的开发者。不管你是用 HAL 库还是标准库不管你是做主机轮询还是从机响应下面的内容都会给出可直接复用的代码结构和实操细节。我会把重点放在“为什么这样设计”和“实际跑的时候会遇到什么”上而不是单纯贴一段代码了事。毕竟能跑通的代码和能稳定跑在工业现场的代码中间差的就是这些经验。2. Agile Modbus 的定位与 F103 上的适配思路2.1 这个协议栈到底帮你做了什么Agile Modbus 是一个用纯 C 写的 Modbus 协议栈代码量很小核心文件就几个移植起来不需要依赖操作系统裸机就能跑。它支持 Modbus RTU 和 Modbus TCP我们这里只关注 RTU 模式。它主要帮你处理了这几件事帧的组装与解析你给它原始字节流它帮你判断帧头、帧尾、地址、功能码、数据长度并提取出有效数据。CRC16 校验Modbus RTU 用的是 CRC16/MODBUS 算法自己写容易出错它内置了查表法和计算法两种实现。功能码分发主机发送的请求和从机返回的响应它根据功能码自动调用对应的处理逻辑。主从机统一接口同一套 API 既可以做主机发起请求也可以做从机等待请求并响应。换句话说你只需要负责串口的字节收发和几个回调函数的实现剩下的协议细节它都包了。这对于 F103 这种资源有限的芯片来说非常友好Flash 占用大概几 KBRAM 占用也就几百字节完全不会给系统带来负担。2.2 为什么选它而不是自己撸或者用 FreeMODBUS自己撸 Modbus RTU 不是不行但你要处理 3.5 字符间隔的定时器、CRC 校验、异常码返回、功能码 0x03/0x06/0x10 的解析代码量不小而且容易在边界条件上翻车。FreeMODBUS 功能更全但代码结构相对复杂移植起来需要适配 port 层对于只想快速跑通的小项目来说有点重。Agile Modbus 的定位刚好卡在中间比自己写省事比 FreeMODBUS 轻量。它的 API 设计也比较直观比如agile_modbus_rtu_init初始化、agile_modbus_send发送、agile_modbus_receive接收看名字就知道干什么。在 F103 上你甚至不需要动态内存分配所有 buffer 都可以用静态数组这对裸机环境来说很重要。2.3 F103 上的串口资源分配与中断策略F103 一般有 3 到 5 个 USART做 Modbus RTU 通常选 USART1 或 USART2。我的建议是如果只是做从机用一个串口就够了如果要做主机轮询多个从机也只需要一个串口因为 Modbus RTU 是半双工的主从轮询机制同一时刻只有一个主机在发送。中断策略上我推荐用“接收中断 空闲中断”的组合。接收中断负责把每个字节存进 buffer空闲中断负责判断一帧数据接收完毕。为什么不用 DMADMA 当然可以但对于 Modbus RTU 这种帧长度不固定、帧间隔敏感的协议空闲中断的响应更直接代码也更简单。DMA 的优势在于大批量数据传输而 Modbus 单帧最多 256 字节中断完全扛得住。具体配置上串口波特率常用 9600 或 19200数据位 8停止位 1无校验。注意Modbus RTU 规范里其实要求有校验位奇偶校验但实际项目中很多人用无校验靠 CRC 来保证数据完整性。如果你要和严格的 PLC 通信最好确认对方的校验设置。3. 从零搭建工程配置与串口底层对接3.1 用 CubeMX 生成基础工程时容易忽略的细节如果你用 CubeMX 生成工程有几个地方需要特别注意。首先是串口的中断优先级建议把 USART 的抢占优先级设高一点避免被其他中断打断导致接收丢字节。其次是 NVIC 里要勾选 USART 的全局中断否则你写了中断服务函数也不会被调用。时钟配置上F103 的 USART1 挂在 APB2 上USART2/3 挂在 APB1 上波特率的计算和时钟频率有关。CubeMX 会自动算好但你要知道这个细节万一通信不上先检查时钟树对不对。还有一个容易踩的坑CubeMX 生成的HAL_UART_MspInit里会配置 GPIO 和 NVIC但如果你手动改了引脚或者中断优先级记得在MspInit里同步修改否则会出现“代码里改了但实际没生效”的情况。3.2 串口收发接口的封装给 Agile Modbus 喂数据Agile Modbus 不直接操作串口它需要你提供两个回调一个用于发送数据一个用于接收数据。发送很简单调用HAL_UART_Transmit或者标准库的USART_SendData循环发送即可。接收稍微复杂一点因为要处理帧的边界。我的做法是定义一个接收 buffer 和一个接收索引在串口接收中断里把字节存进去同时重置一个定时器。当空闲中断触发时说明一帧数据接收完毕把 buffer 和长度交给 Agile Modbus 去解析。这里的关键是空闲中断的判断要准不能把一帧拆成两帧也不能把两帧合成一帧。具体代码结构大概是这样#define MODBUS_BUF_SIZE 256 uint8_t modbus_rx_buf[MODBUS_BUF_SIZE]; uint16_t modbus_rx_len 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { if (modbus_rx_len MODBUS_BUF_SIZE) { modbus_rx_buf[modbus_rx_len] USART_ReceiveData(USART1); } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { USART_ReceiveData(USART1); // 清空闲标志 modbus_frame_ready 1; // 通知主循环处理 USART_ClearITPendingBit(USART1, USART_IT_IDLE); } }注意标准库清空闲中断标志需要先读 SR 再读 DRHAL 库则用__HAL_UART_CLEAR_IDLEFLAG。不同库的写法不一样别搞混了。3.3 3.5 字符间隔的定时器处理方案Modbus RTU 规范里帧与帧之间需要至少 3.5 个字符时间的静默间隔。在 9600 波特率下一个字符是 11 位1 起始 8 数据 1 校验 1 停止大约 1.146ms3.5 个字符就是 4ms 左右。在 19200 下就是 2ms 左右。实际实现时有两种做法一种是用空闲中断硬件自动检测总线空闲另一种是用一个定时器在接收每个字节时重置计数超时后认为一帧结束。空闲中断更省事但有些老型号的 F103 串口空闲中断触发时机可能不太准这时候就需要定时器兜底。我的经验是如果通信速率不高9600、19200空闲中断完全够用如果速率到了 115200帧间隔只有几百微秒空闲中断可能会把一帧拆开这时候建议用定时器方案或者降低速率。工业现场大多数 Modbus 设备跑在 9600 或 19200所以空闲中断是首选。4. 从机模式让 F103 被上位机稳定读取4.1 从机初始化的完整流程从机模式的核心是等待主机发来的请求帧解析功能码执行对应操作然后返回响应帧。用 Agile Modbus 做从机初始化流程大概是这样的agile_modbus_t ctx; uint8_t send_buf[AGILE_MODBUS_MAX_ADU_LENGTH]; uint8_t read_buf[AGILE_MODBUS_MAX_ADU_LENGTH]; agile_modbus_rtu_init(ctx, send_buf, sizeof(send_buf), read_buf, sizeof(read_buf)); agile_modbus_set_slave(ctx, 0x01); // 从机地址设为 1然后你需要实现几个回调函数比如读保持寄存器、写保持寄存器、读输入寄存器等。Agile Modbus 提供了agile_modbus_set_compute_meta_length_after_function_cb和agile_modbus_set_compute_data_length_after_meta_cb这两个回调用来告诉协议栈不同功能码的数据长度怎么算。如果你只用标准功能码直接用默认的就行。4.2 功能码 0x03 和 0x06 的处理细节0x03 是读保持寄存器主机发过来的请求格式是从机地址 0x03 起始地址高 起始地址低 寄存器数量高 寄存器数量低 CRC。从机需要返回从机地址 0x03 字节数 寄存器数据 CRC。0x06 是写单个保持寄存器请求格式是从机地址 0x06 寄存器地址高 低 数据高 低 CRC。从机返回的是原样回显。在 Agile Modbus 里你不需要手动拼这些字节只需要在回调里根据寄存器地址去操作你的数据数组。比如int read_holding_registers_cb(agile_modbus_t *ctx, int slave, int function, int start_addr, int quantity, uint16_t *data) { for (int i 0; i quantity; i) { data[i] holding_regs[start_addr i]; } return 0; }这里有个细节holding_regs数组的大小要覆盖你所有可能被访问的寄存器地址否则会越界。我一般会定义一个 256 或 512 大小的数组然后在初始化时清零。4.3 从机响应超时与异常码返回从机在收到请求后需要在规定时间内返回响应。Modbus 规范里从机的响应超时一般是 300ms 到 1s具体看主机设置。如果你在从机回调里做了耗时操作比如读 Flash、等 ADC 转换可能会超时导致主机报错。我的建议是从机回调里只做数据搬运不要做耗时操作。如果确实需要采集数据提前在定时器中断里采好回调里直接读缓存。另外如果主机请求了不存在的寄存器地址从机应该返回异常码 0x02非法数据地址而不是直接不响应。Agile Modbus 会自动处理一部分异常但你需要确保回调返回正确的错误码。5. 主机模式轮询多个从机的实战要点5.1 主机请求的发起与超时重试机制主机模式比从机复杂一点因为你要主动发起请求还要处理超时和重试。Agile Modbus 的主机 API 大概是这样的agile_modbus_set_slave(ctx, slave_addr); int ret agile_modbus_send(ctx, function, start_addr, quantity); if (ret 0) { // 等待响应 int rc agile_modbus_receive(ctx, timeout_ms); if (rc 0) { // 解析响应 } else { // 超时或错误重试 } }超时时间怎么定一般建议是波特率 9600 时超时设 500ms19200 时设 300ms115200 时设 100ms。重试次数一般 2 到 3 次太多会影响轮询周期。这里有个坑如果总线上有多个从机某个从机掉线了主机会一直等它响应导致后面的从机轮询被拖慢。解决办法是给每个从机单独设超时并且记录每个从机的通信状态掉线的从机降低轮询频率或者跳过。5.2 多从机轮询的状态机设计轮询多个从机时不要用delay死等而是用一个状态机来管理。状态机的大致状态包括空闲、发送请求、等待响应、处理响应、错误重试。每个状态切换时检查时间戳避免阻塞。我通常会用这样的结构typedef enum { POLL_IDLE, POLL_SEND, POLL_WAIT, POLL_DONE, POLL_ERROR } poll_state_t; poll_state_t state POLL_IDLE; uint32_t last_tick 0; uint8_t current_slave 1;在主循环里根据状态切换每次只处理一个从机处理完切换到下一个。这样即使某个从机响应慢也不会影响整个系统的实时性。5.3 主机读取数据的缓存与解析主机收到响应后需要把数据解析出来存到自己的缓存里。Agile Modbus 的agile_modbus_receive返回后你可以通过ctx-read_buf拿到原始数据但更推荐用它提供的解析函数比如agile_modbus_get_holding_registers。解析时要注意字节序。Modbus 是大端序高字节在前。F103 是小端序所以如果你直接把收到的两个字节拼成一个uint16_t需要手动交换高低字节。Agile Modbus 内部已经处理了这个问题你拿到的就是正确的uint16_t值。6. 实测中那些文档不会告诉你的坑6.1 CRC 校验失败的三种常见原因CRC 校验失败是 Modbus 调试中最常见的问题我遇到过的情况大概分三类第一类是字节序搞反了。CRC 在 Modbus RTU 帧里是低字节在前、高字节在后但有些实现会搞反。如果你自己算 CRC一定要确认输出顺序。第二类是接收 buffer 里混入了噪声。比如串口初始化时 GPIO 配置不对或者波特率有偏差导致收到的字节不对。用示波器或者逻辑分析仪抓一下波形看看波特率是否准确。第三类是多字节接收时丢了字节。如果中断优先级设置不当或者接收中断里做了耗时操作可能会丢字节。检查一下中断服务函数里有没有printf之类的耗时调用。6.2 串口中断优先级与数据丢失的关系F103 的中断优先级分组默认是 4 位抢占优先级、0 位子优先级或者 2 位抢占、2 位子优先级。如果你把串口中断的抢占优先级设得比 SysTick 低那么 SysTick 中断可能会打断串口接收导致丢字节。我的做法是串口接收中断的抢占优先级设为 1 或 2SysTick 设为 15最低其他不关键的中断也设低一点。这样串口接收永远能及时响应。另外接收中断里只做“存字节”这一件事不要做解析、不要做判断、不要调用任何可能阻塞的函数。解析放到主循环里做这样中断响应时间最短。6.3 多机通信时的地址冲突与总线竞争Modbus RTU 是主从架构理论上不会出现总线竞争因为只有主机能主动发送。但实际项目中如果两个主机同时挂在一条总线上或者从机地址重复就会出现冲突。我遇到过一次现场有两个设备都设成了地址 1结果主机轮询时两个从机同时响应数据全乱了。解决办法是上电时让每个从机通过拨码开关或者 Flash 配置读取自己的地址确保唯一。主机轮询时如果发现响应异常先检查地址是否冲突。还有一个细节如果总线上某个从机一直拉低总线比如故障导致 TX 一直输出主机会一直收到 0x00这时候需要硬件上做总线保护或者软件上检测连续错误后报警。6.4 低波特率下的帧间隔与响应时间权衡在 9600 波特率下一帧 8 字节的数据传输时间大约是 8.3ms加上 3.5 字符间隔 4ms一帧的完整周期大概 12ms。如果你轮询 10 个从机每个从机读 10 个寄存器一轮下来大概 200ms 到 300ms。这个周期对于大多数工业场景是够用的但如果你需要更快的刷新率就得提高波特率或者减少轮询数据量。提高波特率到 115200 后一帧时间缩短到 0.7ms但帧间隔也缩短到 0.3ms这时候空闲中断可能不够准需要改用定时器方案。而且高波特率下线路上的噪声影响更大通信距离也会缩短。所以波特率的选择要在速度和稳定性之间权衡。7. 代码组织与跨项目复用的建议7.1 把 Modbus 相关代码抽成独立模块我习惯把 Modbus 相关的代码放在一个独立的文件夹里比如modbus_port.c和modbus_port.h里面包含串口初始化、中断处理、Agile Modbus 的初始化和回调。这样换一个项目时只需要改串口引脚和波特率其他代码直接复制。具体来说modbus_port.h里暴露这几个函数void modbus_port_init(uint32_t baudrate, uint8_t slave_addr); void modbus_port_poll(void); int modbus_port_read_holding(uint16_t addr, uint16_t *data, uint16_t len); int modbus_port_write_holding(uint16_t addr, uint16_t *data, uint16_t len);这样上层应用不需要知道 Agile Modbus 的存在只需要调用这些封装好的接口。7.2 主从机代码的切换与条件编译有时候一个项目里既要做主机又要做从机或者不同项目之间需要切换。我的做法是用宏定义来切换#define MODBUS_MODE_SLAVE 0 #define MODBUS_MODE_MASTER 1 #define MODBUS_MODE MODBUS_MODE_SLAVE然后在modbus_port_poll里根据模式调用不同的处理逻辑。这样同一套代码可以复用在主从机场景减少维护成本。7.3 调试阶段的数据抓包与日志输出调试 Modbus 时最有效的手段是抓包。如果没有专用工具可以用一个 USB 转串口模块并联在总线上用串口助手看原始数据。注意并联时要把 USB 转串口的 RX 接到总线的 A 或 B 上具体看你的收发器是 RS485 还是 TTL。另外在代码里加日志输出也很有用但不要用printf直接输出到 Modbus 串口会干扰通信。可以另开一个串口专门做日志或者用 SWO 输出。我一般会在关键节点打标记比如“收到请求”“CRC 通过”“返回响应”这样出问题时能快速定位。8. 一些个人体会Agile Modbus 在 F103 上的表现比我预期的要稳。我最早用自己写的 Modbus 协议栈功能码处理、CRC、帧间隔都要自己管代码写了上千行调试花了两周。换成 Agile Modbus 后核心代码不到 200 行半天就跑通了主从机通信。当然前提是串口底层要调好中断优先级要配对这些是协议栈帮不了你的。另一个体会是Modbus RTU 的稳定性很大程度上取决于硬件。RS485 收发器的质量、终端电阻的匹配、线缆的屏蔽这些比软件优化更重要。我遇到过软件层面怎么调都偶发 CRC 错误最后发现是收发器的使能引脚时序不对换了一个带自动方向控制的收发器就解决了。最后分享一个小技巧如果你在调试时不确定是主机问题还是从机问题可以用一个 Modbus 调试软件模拟从机先验证主机代码或者用软件模拟主机验证从机代码。这样能把问题范围缩小到一半比盲目抓包效率高得多。