
简介这是一套面向STM32嵌入式开发者的FreeModbus主从站通信实现代码包基于Real-Time-ThreadRTT实时操作系统适合需要在资源受限设备上快速集成Modbus RTU/TCP通信的工程师与学习者。压缩包共包含687个文件以C源码271个和头文件208个为主另有工程配置文件、汇编启动文件、示例与文档覆盖完整编译与调试环境整体大小约4.91MB目录结构清晰便于按功能模块检索。资源完整展示了Modbus主站请求构造与从站寄存器映射机制可直接参考API调用方式、任务集成思路及串口参数配置方法帮助用户理解工业设备间数据交换流程并快速移植到自有STM32项目中。目前已有688人学习下载适合具备基础单片机知识、希望掌握Modbus协议工程化应用的开发者。1. 这个压缩包名字里的门道FreeModbus 主从站、RTT 与 STM32 的关系拿到FreeModbus_Slave-Master-RTT-STM32.zip这个名字先别急着解压。前半段Slave-Master说明这个包不是官方原版——FreeModbus 官方仓库长期只维护从站协议栈主站代码处于“能编译但没人管”的状态凡是在文件名里同时写Master和RTT的基本都是别人在 STM32 上基于 RT-Thread 二次整合过的工程。后半段RTT指的是 RT-Thread 实时操作系统STM32是目标芯片平台_fr大概率是作者自己的版本标记。这个标题实际指代的东西一套把 FreeModbus 从站协议栈移植到 RT-Thread 上、同时补齐主站帧收发能力的 STM32 工程。它能解决的是工业现场最常见的需求——让 STM32 既当 Modbus 从站被触摸屏或上位机轮询又能主动去读传感器、变频器和电表。适合正在做设备联网、网关或 PLC 替代方案的嵌入式工程师也适合毕业设计想用 STM32 做 Modbus 通信的同学。这篇文章就把这条路上从移植到联调的方案完整讲一遍。2. FreeModbus 在 STM32 RT-Thread 上的移植从官方代码到 RTT 线程化收包2.1 先看清 FreeModbus 的目录结构别把 demo 文件当协议栈FreeModbus 之所以被广泛用在 STM32 上是因为它把协议栈和底层硬件完全剥离开了。官方源码里modbus/目录下是协议栈本体port/目录下才是需要你根据平台改写的移植层。第一次接触这个包的人最容易犯的错是把demo目录里的portserial.c、porttimer.c当标准答案直接复制结果在 RT-Thread 上跑起来一会儿收不到帧一会儿又进 HardFault。FreeModbus/ ├── modbus/ │ ├── include/ # 协议栈头文件mb.h 是总入口 │ ├── functions.c # 功能码处理03/04/06/16 等 │ ├── mb.c # 状态机核心eMBInit/eMBEnable │ └── mb_m.c # 主站相关源文件老版本就有但极少维护 ├── port/ │ ├── port.h # 平台相关的类型定义 │ ├── portevent.c # 事件机制FreeRTOS 版用队列实现 │ ├── portserial.c # 串口收发底层 │ └── porttimer.c # 3.5T 定时器用来切分 Modbus 帧 └── demo/ └── STM32/ # 裸机 demo不要直接搬我在实际项目里的习惯是只把modbus/整个目录拿进来port/和demo/全部丢掉自己在 RT-Thread 的 BSP 上重写portserial.c和porttimer.c。mb_m.c这个文件要看清楚它里面的主站代码并没有被官方正式维护接口也不是很稳定所以第 4 章我会讲另一种在主站上用状态机自己拼帧的常见做法而不是直接依赖mb_m.c。提示移植前先确认你的 Modbus 功能码范围。只做风机、水泵这类设备监控时把MB_FUNC_READ_INPUT_REGISTER和MB_FUNC_WRITE_MULTIPLE_REGISTERS打开就够了其余功能码对应的宏注释掉能省不少 RAM。2.2 把串口接收从裸机中断改成 RT-Thread 信号量驱动的收包线程FreeModbus 官方 demo 的串口移植是中断里逐字节调用xMBPortSerialRxISR()每收一个字节都要进一次中断。这在裸机或 FreeRTOS 下没问题但放到 RT-Thread 里会浪费线程切换的开销而且如果你用的是 RT-Thread 的串口设备框架中断回调里再嵌套协议栈调用容易把优先级关系搞乱。我一般会这么做串口收到数据时只释放一个信号量真正的字节读取和xMBPortSerialRxISR()调用全部放到一个专用线程里。下面是一份基于 RT-Thread 串口设备框架的portserial.c核心逻辑#include rtthread.h #include rtdevice.h static struct rt_semaphore rx_sem; static rt_device_t serial_dev; /* 串口接收回调由 RTT 的串口驱动在中断上下文调用只做一件事 */ static rt_err_t rx_indicate(rt_device_t dev, rt_size_t size) { rt_sem_release(rx_sem); /* 唤醒接收线程 */ return RT_EOK; } /* Modbus 接收线程代替裸机中断逐字节喂给协议栈 */ static void mb_rx_thread_entry(void *param) { uint8_t ch; while (1) { if (rt_sem_take(rx_sem, RT_WAITING_FOREVER) RT_EOK) { /* 注意这里要循环读因为一次中断可能收到多个字节 */ while (rt_device_read(serial_dev, 0, ch, 1) 1) { xMBPortSerialRxISR(ch, 1); /* 关键交给 FreeModbus 收包状态机 */ } } } }逻辑拆解rx_indicate()是设备驱动层的回调RTT 默认在中断上下文调用它所以里面不能做耗时操作释放信号量是最安全的选择。mb_rx_thread_entry()里rt_device_read()返回 1 表示读到一字节循环读完接收缓冲区的所有数据。xMBPortSerialRxISR()是 FreeModbus 的收包入口它内部会自己判断是否收满一帧、是否需要启动定时器算 3.5T 间隔。这里的参数要注意两点rx_sem的初值设成 0用rt_sem_create(mb_rx, 0, RT_IPC_FLAG_FIFO)创建信号量计数值代表当前缓冲区里至少有 1 个字节线程栈大小给 512 字节就够xMBPortSerialRxISR()里不会做递归调用但要注意portserial.c里如果开了MB_ASCII_ENABLED栈占用会明显变大建议给到 1024。2.3 移植时最容易出错的 3 个参数时钟源、波特率容差、3.5T 定时器配置FreeModbus 官方 demo 是给裸机写的porttimer.c用的是 SysTick 或者通用定时器的中断。到了 RT-Thread 上最稳妥的做法是复用 RT-Thread 的rt_tick_get()来模拟定时器但 FreeModbus 的vMBPortTimersEnable()和vMBPortTimersDisable()这两个接口语义是“启动/停止一帧的接收窗口”直接用 tick 计数会有精度问题尤其是波特率高于 115200 时1 个 tick 可能就有好几个字节的时间了。我的经验是保留硬件定时器但把中断服务函数改成只做一件事调用xMBPortTimersTicksISR()。下面是一个在 STM32F103 上基于 TIM2 的配置片段void vMBPortTimersInit(void) { TIM_TimeBaseInitTypeDef tim; /* 假设时钟频率 72MHz预分频 72得到 1MHz 计数频率 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructInit(tim); tim.TIM_Period 15000; /* 初始值不重要后面会改 */ tim.TIM_Prescaler 72 - 1; /* 1us 计数一次 */ tim.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, tim); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); NVIC_EnableIRQ(TIM2_IRQn); } void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); xMBPortTimersTicksISR(); /* 协议栈内部会调用 prvvTimerExpired() */ } }这里最关键的是TIM_Prescaler和TIM_Period的配合。FreeModbus 的xMBPortTimersTicksISR()每进去一次代表一个“tick”这个 tick 的时间间隔要远小于字符时间。波特率 9600 时一个字符约 1.167ms3.5T 大约 4ms这时候 tick 间隔写成 0.1ms 比较合适波特率 115200 时 3.5T 只有 0.3ms 左右tick 间隔最好压在 0.05ms 以内。上面代码里预分频 72、计数频率 1MHzTIM_Period填 50 就是 50us 一个 tick对 115200 足够。配置项推荐值出错表现TIM_Prescaler72 - 11MHz 计数定时器进入中断过于频繁CPU 占用高TIM_Period波特率 9600 时填 100115200 时填 50收帧超时判断错误频繁出现 CRC 校验失败串口中断优先级低于系统 tick高于普通外设收帧丢字节RT-Thread 调度异常USART_WordLength8 位数据无校验时配 9 位含无校验位偶发收错字节尤其是中文注释里的转义字符提示RT-Thread 的rt_device_read()在中断屏蔽期间可能会被延迟如果测试时发现波特率 115200 丢字节优先检查你的串口 DMA 是否开启而不是怀疑 FreeModbus 协议栈本身。3. Slave 从站角色初始化顺序、寄存器回调与协议栈事件循环的配合3.1 从站初始化调用顺序eMBInit、eMBEnable 与 RTT 线程的配合从站模式下FreeModbus 的工作方式是被动的——它不主动发数据只在收到主站请求后回复响应。这意味着协议栈内部的事件循环要一直被调用你不能像裸机 demo 那样在while(1)里不断调用eMBPoll()到了 RT-Thread 上就创建一个专用线程线程主体就是无限循环调eMBPoll()。初始化顺序是这类工程里最常见的坑。正确顺序是先eMBInit()再注册好你自己的回调最后eMBEnable()。不少人把eMBEnable()放在串口初始化之前导致vMBPortTimersEnable()打开定时器时定时器根本没初始化跑起来后第一帧数据就丢。#include mb.h #include mbport.h /* 从站地址 1波特率 9600偶校验从站模式 */ #define MB_SLAVE_ADDR 1 #define MB_BAUD_RATE 9600 static void mb_slave_thread_entry(void *param) { eMBErrorCode err; /* 1. 初始化协议栈模式、地址、波特率、校验方式 */ err eMBInit(MB_RTU, MB_SLAVE_ADDR, 0, MB_BAUD_RATE, MB_PAR_EVEN); if (err ! MB_ENOERR) { rt_kprintf(eMBInit failed: %d\n, err); return; } /* 2. 使能协议栈之后 eMBPoll() 才会真正处理帧 */ err eMBEnable(); if (err ! MB_ENOERR) { rt_kprintf(eMBEnable failed: %d\n, err); return; } /* 3. 事件循环 */ while (1) { eMBPoll(); /* 这里可以放你自己的业务逻辑但不能阻塞超过 10ms */ rt_thread_mdelay(1); } }这段代码里参数逐个说明eMBInit()的第二个参数是设备地址范围 1~2470 是广播地址不能用作从站地址第三个参数是串口编号在官方移植里只有 0 和 1 两个取值对应portserial.c里初始化好的串口句柄第四个参数是波特率注意这里的MB_BAUD_RATE是一个整数而不是波特率寄存器的值最后一个参数是校验方式MB_PAR_EVEN是偶校验MB_PAR_NONE是无校验如果选无校验串口初始化必须配置成 8 数据位 1 停止位而不是 9 位。eMBPoll()是协议栈的事件调度函数它内部会检查是否有新帧到达、是否有超时、是否有需要发送的响应。官方建议是在主循环里频繁调用裸机项目里很多是 1ms 调一次。放到 RT-Thread 的线程里时rt_thread_mdelay(1)这个延时必须保留否则这个线程会占满 CPU导致其他更低优先级的线程饿死。3.2 寄存器映射回调 eMBRegHoldingCB 的正确写法和返回值约束从站最核心的交互是读写保持寄存器Holding Register也就是功能码 03读和 16写多个。这些操作最终都会进到eMBRegHoldingCB()这个回调里。这个回调的签名是固定的eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode)参数含义分别是pucRegBuffer是协议栈内部的收发缓冲区你要从这里读数据或者往这里写数据usAddress是寄存器地址注意是 1 起始的 Modbus 地址不是数组下标usNRegs是要读写的寄存器数量eMode是MB_REG_READ或MB_REG_WRITE。下面是一份实际可用的实现static uint16_t usRegHoldingBuf[64]; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT usRegIndex; eMBErrorCode eStatus MB_ENOERR; /* Modbus 地址从 1 开始数组下标从 0 开始必须减 1 */ usRegIndex usAddress - 1; /* 越界检查协议栈本身会查 usAddress但数量过大时还是要拦 */ if ((usRegIndex usNRegs) 64) { return MB_ENOREG; } switch (eMode) { case MB_REG_READ: while (usNRegs 0) { *pucRegBuffer (UCHAR)(usRegHoldingBuf[usRegIndex] 8); *pucRegBuffer (UCHAR)(usRegHoldingBuf[usRegIndex] 0xFF); usRegIndex; usNRegs--; } break; case MB_REG_WRITE: while (usNRegs 0) { /* Modbus 协议大端传输先高字节后低字节 */ usRegHoldingBuf[usRegIndex] (*pucRegBuffer 8) | *(pucRegBuffer 1); pucRegBuffer 2; usRegIndex; usNRegs--; } break; default: eStatus MB_ENOREG; break; } return eStatus; }这里有两个细节直接决定联调成败。第一是字节序Modbus RTU 的寄存器数据恒为大端传输所以读时先取高字节再取低字节写时要把pucRegBuffer[0]左移 8 位后和pucRegBuffer[1]按位或。第二是回调的执行上下文eMBRegHoldingCB()是在eMBPoll()的线程里被调用的不是中断上下文所以你可以放心地在里面操作临界资源但如果保持寄存器和业务线程共享仍然建议用关中断或者互斥量保护数组。MB_ENOREG这个返回值会被协议栈转换成 Modbus 异常码 02Illegal Data Address主站侧会收到异常响应联调时可以拿这个判断是地址越界还是协议栈本身的问题。实测中遇到过很多人直接用return MB_ENOREG而不做索引检查结果主站写的数据溢出到数组外把其他寄存器覆盖了。3.3 用 Modbus Slave 工具和 Free Master 示波器验证从站是否移植成功从站移植完成后验证不能只靠串口助手看十六进制。我更推荐的做法是用两种工具交叉验证先用 Modbus Poll 模拟主站去读写再用 Free Master 的示波器功能观察寄存器值的变化曲线。Modbus Poll 和 Modbus Slave 怎么连接这个问题本质上是确认两边的串口参数完全一致——设备地址、波特率、校验位、数据位、停止位任何一个对不上都会表现为超时。FreeMaster 这个工具对从站开发者尤其友好。它可以把保持寄存器的值映射成示波器波形你在eMBRegHoldingCB()里给一个测试寄存器写入正弦序列然后看 FreeMaster 的滚动曲线是否平滑就能直观地确认协议栈读写是连续正确的而不是偶发出错。结合 FreeModbus 移植实际测试的安排我建议验证从站时始终带着一个带 CRC 校验的串口工具把主站发出的二进制帧原样记录下来比对寄存器地址和值的字节序是否和协议一致。4. Master 主站角色官方协议栈没做完整的事情这个包补了什么4.1 FreeModbus 官方为什么默认只维护从站主站要靠自己拼帧FreeModbus 这个开源库的历史定位是“为嵌入式设备提供从站功能”所以协议栈核心mb.c里的主状态机把从站的收帧、判帧、响应逻辑写得很完整而主站部分只在mb_m.c里提供了一个半成品框架它知道怎么构造请求帧但发送后的超时处理、重试机制、多从站调度都留给使用者自己填。这就是为什么很多工程师拿到这个Slave-Master压缩包后打开工程发现主站代码没法直接跑通——不是包坏了是 FreeModbus 官方的主站设计本身就只覆盖了一小部分。在实际项目中做 STM32 上的 Modbus 主站常见做法是绕开mb_m.c直接用协议栈里现成的串口发送函数配合自己的超时状态机来拼帧。这样做的好处是主站侧完全可控超时时间、重试次数、轮询顺序都由业务决定。这一节就按这个思路给你一套可以在 RT-Thread 上跑通的最小实现。4.2 主站请求帧构造与超时重试状态机的实现思路Modbus RTU 主站的请求帧格式非常固定从站地址 功能码 数据 CRC16。难点不在拼帧而在发送之后的状态迁移。发送完一帧你要等从站响应等到了要校验 CRC 和从站地址没等到要判断是超时还是线路噪声还要决定重试几次、间隔多久。下面是一段用 RT-Thread 信号量实现的主站发送和等待响应逻辑static struct rt_semaphore mb_master_rx_sem; static uint16_t usMasterTimeoutMs 100; /* 默认超时 100ms */ static uint8_t ucMasterRetryCnt 0; static uint8_t ucMasterMaxRetry 3; /* 构造读保持寄存器请求帧读从站 1 的地址 0长度 10 */ uint16_t ucMasterReqBuf[256]; uint8_t ucMasterReqLen 0; static void master_build_read_req(uint8_t ucSlaveAddr, uint16_t usStartAddr, uint16_t usRegCnt) { uint16_t crc; ucMasterReqBuf[0] ucSlaveAddr; ucMasterReqBuf[1] 0x03; /* 功能码 03 */ ucMasterReqBuf[2] (uint8_t)(usStartAddr 8); /* 高字节 */ ucMasterReqBuf[3] (uint8_t)(usStartAddr 0xFF); /* 低字节 */ ucMasterReqBuf[4] (uint8_t)(usRegCnt 8); ucMasterReqBuf[5] (uint8_t)(usRegCnt 0xFF); /* 从 0 开始做 CRC 校验注意协议栈里自带 xMBPortSerialPutByte也可直接用 crc16 函数 */ crc usMBCRC16((uint8_t *)ucMasterReqBuf, 6); ucMasterReqBuf[6] (uint8_t)(crc 0xFF); ucMasterReqBuf[7] (uint8_t)(crc 8); ucMasterReqLen 8; } /* 发送请求并在信号量上等待响应超时重试 */ int master_read_holding_reg(uint8_t ucSlaveAddr, uint16_t usStartAddr, uint16_t usRegCnt, uint16_t *pRegs) { uint8_t ucRetry; master_build_read_req(ucSlaveAddr, usStartAddr, usRegCnt); for (ucRetry 0; ucRetry ucMasterMaxRetry; ucRetry) { /* 发送前清掉上次可能的残留信号量 */ rt_sem_control(mb_master_rx_sem, RT_IPC_CMD_RESET, 0); /* 逐字节发出去实际项目中建议用 DMA 发送完整帧 */ for (uint8_t i 0; i ucMasterReqLen; i) { xMBPortSerialPutByte((CHAR)ucMasterReqBuf[i]); } /* 等待响应帧超时返回 RT_ETIMEOUT */ if (rt_sem_take(mb_master_rx_sem, rt_tick_from_millisecond(usMasterTimeoutMs)) RT_EOK) { /* 解析响应帧校验地址、功能码、长度和 CRC */ if (master_parse_response((uint8_t *)ucMasterReqBuf, ucMasterReqLen, pRegs) 0) { return 0; } } } return -1; /* 重试多次仍未成功返回错误 */ }帧构造的逻辑说明前 6 个字节是标准的读保持寄存器请求地址和寄存器数量都按大端序拆成高低字节这块没有技术含量照着 Modbus 协议规范写就行。usMBCRC16()是 FreeModbus 协议栈自带的 CRC 校验函数如果你在自己写的驱动里不想依赖协议栈也可以把协议栈里crc16.c的实现单独拿出来复用注意它的输出是低字节在前。超时重试的状态机是这段代码的骨架。rt_sem_take()的超时时间建议设成 100ms如果从站响应慢或者线路干扰导致丢帧一次请求最多会消耗 300ms3 次重试。master_parse_response()这里没展开细节但它内部至少要检查响应帧里的从站地址是否与请求一致、功能码最高位是否为 1表示异常帧以及帧长是否匹配2 2 * usRegCnt。ucMasterMaxRetry 3这个值在轮询多个从站时要仔细评估。如果你要轮询 10 个从站每个从站正常响应 20ms那单轮周期约 200ms如果某个从站离线重试 3 次加超时就是 300ms 的额外阻塞整个轮询周期会翻倍这是很多 STM32 做 Modbus 主站后系统实时性变差的根因。经验值离线从站的重试次数设成 1 就够线上暂时离线的设备会在下一轮轮询中恢复通信。4.3 主站轮询多个从站的调度表和变频器场景的注意事项主站与从站一对一通信的情况在真实项目里很少见更多是像 STM32 和变频器通讯这样一个主站挂多台设备。这时要有一个明确的调度机制防止对某一台从站的超时等待拖累整个轮询周期。常见做法是用一张静态表按顺序记录每个从站的地址、功能码和寄存器映射关系。调度顺序从站地址功能码寄存器地址寄存器数量数据用途11030x00002变频器频率与电流22030x00041电表电压33040x00004温湿度传感器41060x00011变频器启停控制字调度实现上主线程里执行完一次完整的表遍历后把数据存入全局结构体业务线程直接读取不需要在协议栈回调里做复杂的数据搬运。特别提醒一点如果从站是变频器它的响应时间通常比 PLC 和无源传感器慢读取变频器数据时把usMasterTimeoutMs提到 300ms否则会频繁触发重试看起来就像变频器不响应实际上是主站把自己限死了。5. 主从联调日志把帧头帧尾和时间戳一起打出来的排错技巧主站和从站分别移植好之后联调阶段的报错几乎都是同一个共性对着串口助手看数据能收到一堆意义不明的字节但不知道是哪一头发出的、是不是完整的帧、CRC 到底过没过。我的做法是在两个端点的串口发送和接收函数里各加一段调试日志用统一的格式记录方向、时间戳、原始字节、解析结果。下面这段日志逻辑可以直接放在portserial.c的xMBPortSerialPutByte()和接收解析入口之后static void mb_debug_log_frame(uint8_t *pFrame, uint8_t len, uint8_t dir) { static uint32_t log_cnt 0; /* dir: 0 表示从站收1 表示从站发 */ rt_kprintf([%06d] %s %02d: , rt_tick_get(), dir ? TX : RX, log_cnt); for (uint8_t i 0; i len; i) { rt_kprintf(%02X , pFrame[i]); } rt_kprintf(\n); }日志要重点观察两个维度。第一个是帧间隔Modbus RTU 要求帧与帧之间至少 3.5T 的静默时间如果你在日志里看到两个连续的 RX 帧时间戳之差小于 1ms说明接收线程被频繁唤醒协议栈可能会把两帧合并成一帧解析导致 CRC 校验不通过。第二个维度是字节序主站发出的请求帧如果地址和寄存器数量高低字节搞反从站通常会回异常码 02这个在日志里非常容易定位。还有一个实用技巧联调时不要直接在主站侧看从站的回包而是用逻辑分析仪或者另一个串口监听工具挂在 RS-485 总线上抓取总线上真实传输的波形。这样能看到谁在发、谁在回、冲突发生在哪个字节。很多 Modbus 通信问题本质上是 RS-485 收发切换的时机问题从站发送完最后字节后立刻切回接收主站侧如果上拉电阻配置不当会在总线上产生一小段毛刺正好被主站当成新帧头。日志里表现为主站收到了长度只有 2~3 字节的乱帧这时检查一下主站和从站的 A/B 线极性以及终端电阻是否接反通常比改协议栈代码更管用。本文还有配套的精品资源点击获取