简介:STM32 平台的 Modbus 通信实现资料包,面向嵌入式开发者与学习工业现场总线协议的工程师,解决在 STM32 标准外设库工程中集成 Modbus 从站通信、完成寄存器读写与 CRC 校验的实际问题。内容包含作者调试验证过的 STM32 Modbus 程序、FreeModbus 协议栈 1.6 源码,以及独立的 CRC 计算助手;协议栈可直接移植,配合配套博文教程可快速理解从站初始化、功能码处理和串口收发流程。压缩包共 107 个文件,以 C 源文件(48 个)、头文件(46 个)为主,并含 Keil 工程文件、HEX 烧录文件、PDF 说明和辅助脚本,下载包约 4.69MB。从文件构成看,工程基于 STM32 标准外设库,涉及 USART、TIM、ADC、I2C、CAN 等外设模块,便于在类似项目中核对模块配置;已有 3453 人学习下载,适合需要参考真实工程案例完成 Modbus 通信调试,或希望基于 FreeModbus 做二次开发的读者。 接到这个stm32 for modbus.zip压缩包的时候,我大概猜得到里面是什么:一份打包好的 STM32 工程,核心功能就是把 Modbus 协议跑起来。做嵌入式这几年,Modbus 几乎是绕不开的坎,不管是接 PLC、连传感器、控制伺服电机,还是跟组态软件、LabVIEW 上位机打交道,最后都会落到这个协议上。STM32 凭借便宜的售价、丰富的外设和成熟的生态,成了跑 Modbus 最主流的 MCU 选择之一。这篇文章我就拿这个项目当引子,把 STM32 上实现 Modbus 通信的完整思路、核心代码、联调方法和踩坑经验一次说透。
1. 先捋清楚这个工程到底解决什么问题
很多人拿到 Modbus 相关的 STM32 工程,第一反应是先把代码编译烧录跑起来,但我觉得首先得想明白一个问题:为什么 Modbus 在工业现场这么普及,而 STM32 为什么又成了它的最佳拍档。
1.1 为什么工业现场到处是 Modbus
Modbus 是 Modicon 公司在 1979 年发明的串行通信协议,到今天已经四十多年了。一个协议能活这么久,核心原因就两个字:简单。它没有一个复杂的 OSI 分层,没有加密认证,就是主从问答:主机发请求帧,从机回响应帧。帧格式固定、命令码明确、数据模型清晰,对于资源受限的单片机来说,几百行 C 代码就能完整实现一个从站。
工业现场的传感器、变送器、电机驱动器、智能仪表,十个里有八个支持 Modbus。这意味着你只要给 STM32 接上 RS485 收发器,写个 Modbus 从站程序,就能让单片机直接进入工业总线体系,跟 PLC 或者任何组态软件对话。这个项目标题里的 ZIP 包,本质上就是在做这样一件事:把 STM32 变成一个工业现场通信节点。
1.2 方案选型:RTU 和 TCP 怎么取舍
Modbus 有两个常见变体:Modbus RTU 走串口,Modbus TCP 走以太网。做方案选型的时候,很多新手会纠结,我到底学哪个、做哪个。
我的看法很直接:如果项目里用的是 STM32F1 这类入门芯片,现场布线距离超过几十米,或者要跑 RS485 总线,那就老老实实用 RTU。RTU 帧紧凑,8 位数据位直接搬移,波特率 9600 时一帧指令十几个字节,实时性足够。而且 RS485 两根线就能组网,成本低、抗干扰强,在工厂车间这种环境里以很可靠。Modbus TCP 的好处是传输距离可以跨越网段、速度更快,可以多主站同时读取,适合对接上位机软件、物联网网关的场景。但它依赖以太网硬件,成本高一些,实时性反而受网络环境影响。
这个工程选择的路径是标准的 STM32 + RS485 + Modbus RTU 从站实现。我建议你在看代码前先把这个背景搞清楚,因为后续所有的寄存器规划、串口配置、CRC 校验,都是围绕 RTU 帧格式展开的。
2. Modbus 协议里你必须吃透的几块硬骨头
协议本身不难,但有几个关键点一旦理解偏差,接上去就是通信乱码、无响应、数据错位。我在面试嵌入式工程师时喜欢问这几个细节,能完全答对的其实不多。
2.1 四种数据模型与地址映射
Modbus 把从站的数据抽象成四种模型,这是整个协议的地基。线圈(Coil)是位输出,对应 DO;离散输入(Discrete Input)是位输入,对应 DI;输入寄存器(Input Register)是只读的 16 位寄存器,对应 AI;保持寄存器(Holding Register)是读写的 16 位寄存器,对应 AO,也是用得最多的一类。
地址空间规划上,行业惯例是:00001-09999 是线圈,10001-19999 是离散输入,30001-39999 是输入寄存器,40001-49999 是保持寄存器。但注意,在报文里传输的地址其实是偏移量。比如读取保持寄存器 40001,报文中地址字段写 0x0000,读取 40002 就写 0x0001。这个偏移关系我见过太多人搞混,结果上位机地址和单片机数组下标永远对不上。
对应到 STM32 代码里,我的做法是简单粗暴地建几个数组:
#define REG_HOLDING_NUM 100 #define REG_INPUT_NUM 50 #define REG_DISCRETE_NUM 16 #define REG_COIL_NUM 16 uint16_t holding_regs[REG_HOLDING_NUM]; uint16_t input_regs[REG_INPUT_NUM]; uint8_t discrete_regs[REG_DISCRETE_NUM]; uint8_t coil_regs[REG_COIL_NUM];然后功能码操作的就是这些数组。做产品时再用业务逻辑去映射具体物理量,比如holding_regs[0]存温度值、holding_regs[1]存湿度值。这种写法下位机好维护,上位机也好对照。
2.2 CRC16 校验:看着简单,写错就全白干
Modbus RTU 用 CRC16 做帧校验,这玩意儿看似简单,实际坑很多。标准 Modbus CRC16 的特征是:多项式 0xA001,初值 0xFFFF,结果低字节在前发送。它和常见的 CRC16-CCITT 完全不同,别拿错表。
新手最容易出问题的地方有三个:一是初始值写成了 0x0000;二是多项式方向搞反,用了 0x8005 这种高位多项式;三是结果字节序没交换,直接按大端发出去。任何一处错,上位机都会报 CRC 错误,然后一直收不到有效数据。
这是标准的位运算法实现:
uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *data++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }位运算实现简单直观,但每个字节要循环 8 次,在低速 MCU 上会占用不少时间。项目如果对实时性要求高,建议用查表法,把 256 个 CRC 高字节的结果预先算好,运行时一次查表加两次异或就完成一个字节的校验,速度提升几十倍。压缩包里如果有crc_table相关数组,基本就是查表法。
2.3 帧格式和 3.5 字符间隔
RTU 帧格式是固定的:地址字段 1 字节,功能码 1 字节,数据字段若干字节,CRC 校验 2 字节。一帧最长 256 字节。但 Modbus 判定一帧结束不是看字节数,而是靠“静默时间”:帧内字符间隔不能超过 1.5 个字符时间,帧与帧之间必须停顿至少 3.5 个字符时间。
这个 3.5 字符时间很关键。在 9600 波特率、8N1 格式下,一个字符是 10 位(起始位 1 + 数据 8 + 停止位 1),所以 3.5 字符时间约等于 3.65ms。在 STM32 上如果只靠串口普通中断,很容易在正常接收时因为一次调度延迟就被误判为帧结束。我后面会讲怎么用定时器做精确超时判断,这里先记住结论:RTU 必须实现帧超时机制,不能在接收中断里简单拼包。
3. 工程搭建与从站核心实现
这块是实操重点。我默认你用的是标准库或者 HAL 库的工程模板,芯片以 STM32F103 为例,毕竟它全球保有量最大,网上资料也是最多的。
3.1 开发环境准备:从 Keil 到工程模板
工程搭建第一步就是环境。STM32 开发最常见的工具链是 Keil MDK,很多人问 Keil 怎么能同时兼容 C51 和 STM32,其实就是安装时分别安装 C51 和 MDK 两个版本,注意装到不同目录,然后把两个版本的 License 分别添加。打开工程时 IDE 会自动识别工程类型,不需要手动切什么模式。如果之前装过 C51 再装 MDK,可能遇到编译 STM32 工程时找不到 ARM 编译器的情况,去Project -> Manage -> Project Items里把编译路径重新指定一下就好。
固件库方面,F103 老项目用标准外设库,新项目推荐用 HAL 库加 CubeMX 初始化。老工程师可能更习惯标准库,因为代码直接操作寄存器、执行效率高;但 HAL 库生成的工程结构清晰,切换芯片也方便。这个 zip 里的工程,从文件结构上应该能看出用的是哪套库。
时钟配置要注意:如果外部晶振是 8M,SystemInit会把它倍频到 72MHz,这是 HSE 模式。如果板子上没有外部晶振,得改用 HSI 内部时钟,并把编译宏改成STM32F10X_HD配合正确的启动文件。很多人烧录后串口波特率不准,排查半天才发现是时钟源不对。
3.2 串口接收:一帧数据怎么判定结束
Modbus RTU 通信最核心的底层就是串口接收。我推荐的方案是:串口空闲中断 + 定时器超时双重保障。所谓空闲中断,就是串口在接收到一个字节后,如果总线上没有再出现新的起始位,就认为这一帧结束了。STM32 的 USART 有 IDLE 中断,DMA 接收模式下可以配合串口空闲中断做不定长接收,效率极高。
但很多老工程师更习惯传统的做法:串口逐字节中断接收,每收到一个字节就喂一次看门狗定时器(TIM),定时器溢出就认为这一帧结束,然后跳到帧处理函数。这个方法兼容性好、逻辑直观,我早期项目也这么写。核心思路是:
- 串口接收中断把字节存进缓冲区
- 同时重置定时器计数值
- 定时器溢出中断设置
frame_ready标志 - 主循环检测到标志后处理完整帧
这里的关键是超时时间该怎么设。在 9600 波特率下,一个字符约 1.04ms,3.5 字符就是 3.65ms。定时器中断周期可以根据主频和预分频算:72MHz 下,预分频 72 就得到 1MHz 计数频率,即计数 1 次 1 微秒,那么 overflow 计数值设为 4000 就对应 4ms,刚好略大于 3.5 字符时间。在 STM32 定时器配置里,初值设置为 1000-4000 微妙都行,但不能太小,否则连续收到两个字节时会出现帧中断误判。
3.3 功能码处理和状态机
帧接收拿到手后,进入处理逻辑。一个 Modbus 从站最少要支持的功能码是:01(读线圈)、02(读离散输入)、03(读保持寄存器)、04(读输入寄存器)、05(写单个线圈)、06(写单个寄存器)、0F(写多个线圈)、10(写多个寄存器)。但很多从站应用只用到 03 和 06,就足够覆盖大部分数据上报和控制需求了。
处理逻辑建议用状态机,而不是简单的主循环判断。状态机的好处是把接收、校验、处理、回复四个阶段的边界划分清楚,代码调试起来逻辑清晰:
帧空闲 -> 接收中 -> CRC校验 -> 地址匹配 -> 功能码分发 -> 构建响应 -> 发送 -> 回到帧空闲地址匹配要特别注意:广播地址 0 不需要回应;如果不是本机地址且不是广播,直接丢弃。CRC 校验通过后才执行功能码逻辑,否则直接丢弃。功能码分发用 switch 就行,结构清晰:
uint8_t modbus_process_frame(uint8_t *rx_buf, uint16_t len) { uint8_t addr = rx_buf[0]; uint8_t func = rx_buf[1]; uint16_t crc = modbus_crc16(rx_buf, len - 2); uint16_t recv_crc = (rx_buf[len - 1] << 8) | rx_buf[len - 2]; if (crc != recv_crc) return 0; // CRC错,丢弃 if (addr != SLAVE_ADDR && addr != 0x00) return 0; switch (func) { case 0x03: read_holding_reg(rx_buf, len); break; case 0x06: write_holding_reg(rx_buf, len); break; case 0x10: write_multiple_regs(rx_buf, len); break; default: send_exception(func, 0x01); // 非法功能码 break; } return 1; }生成响应帧时,需要根据功能码重新拼接报文,并计算 CRC。很多工程为了省时间直接用发送数组,但要注意:发送和接收缓冲区最好不要共用,否则容易出现覆盖问题。
4. 上位机联调、故障排查与项目扩展
代码写完只是第一步,真正的硬仗在联调阶段。下面这些内容是压缩包里看不到的,但实际项目里基本都会遇到。
4.1 Modbus Poll 和 Modbus Slave 的正确打开方式
联调通常需要上位机工具。Modbus Poll是 Modbus 主站模拟器,Modbus Slave是从站模拟器。它们的官方版本都有 30 天试用期,老版本也有免费版。我不建议你在网上找什么特殊密钥,官方试用版完全够用,而且更安全;开源免费的还有 QModMaster、ModbusPoll 的替代品。用的时候注意:串口参数要一致——波特率 9600、数据位 8、停止位 1、无校验。地址要对应——如果单片机从站地址是 1,那 Poll 里的 Slave ID 也必须是 1。
第一次联调有个很实用的土办法:先用 Modbus Slave 软件模拟一个从站,再用 Modbus Poll 去读它。如果这对主从工具之间能正常通信,说明你的上位机配置没问题;然后再把 Poll 对准你的 STM32 从站,这样问题范围就缩小了一半。硬件串口没接对、RS485 方向没切换,这套方法都能快速暴露问题。
4.2 那些年踩过的坑:常见故障与排查清单
我按实际项目中的出现频率整理了一份排查表,照着查能省很多时间。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无响应 | RS485 A/B 接反 | 交换 A/B 线再试 |
| 完全无响应 | 收发方向控制引脚没有切换 | 示波器/逻辑分析仪查看 DE 引脚电平变化 |
| 完全无响应 | 从站地址不匹配 | 确认 Slave ID 与代码一致 |
| 收到 FF FF 或乱码 | 波特率不匹配 | 核对两端串口参数 |
| 灯闪但数据不对 | CRC 字节序错误 | 检查低字节先发 |
| 偶发无响应 | 帧间隔超时设置太短 | 3.5 字符时间加大到 5ms |
| 能 ping 通但 ModScan 不通 | TCP 服务端没有启动监听 502 端口 | 检查监听状态 |
| 能通但读到的值是 0 | 寄存器地址偏移错误 | 检查 40001 对应 0x0000 偏移 |
这里单说两个高频问题。第一个是 RS485 收发方向控制。STM32 的 USART 只有 TX/RX 两根信号线,RS485 芯片还需要一个外部方向控制引脚(一般是 DE/RE)。有些新手只在发送前拉高、发送后拉低,但没考虑发送完成中断——USART_FLAG_TC置位前就把引脚拉低了,导致最后一个字节还在移位寄存器里就被切断了,接收方收到的帧缺尾巴。正确做法是等TC标志位置 1,再拉低方向引脚。
第二个是 Modbus TCP 能 ping 通但 ModScan 不通,这个高频问题我专门提一下:TCP 层通不代表应用层通,因为 Modbus TCP 服务端必须监听 502 端口,并且正确响应 0x00 事务标识符。如果这个端口没开,或者返回的单元标识符不对,ModScan 一样连不上。排查时可以用网络调试助手直接发送 Modbus TCP 帧,看返回内容。
4.3 再进一步:从 RTU 到 TCP,从采集到控制
这个 zip 工程如果只是从站,那已经完成了基础能力;但真实项目往往不止于此。我见过几个典型的扩展方向,你可以顺着走。
一个是 K210 与 STM32 通信。在 LCD 屏幕上跑视觉识别,把识别结果通过串口(某个自定义协议)发给 STM32,STM32 再把结果映射到保持寄存器里,通过 Modbus 上报给 PLC。这种架构是边缘 AI 和传统工业控制的常见结合方式。K210 跑 Keras 模型做手势或数字识别,识别结果就是一个数字,STM32 这边只需要把串口收到的值写入holding_regs[0],剩下的全部交给 Modbus 协议处理。
另一个是控制伺服电机或步进驱动器。很多工业驱动器原生支持 Modbus RTU 或 Modbus ASCII,用 STM32 做主站,轮询读取驱动器的状态寄存器和写入目标位置寄存器,就能实现运动控制。代码逻辑从从站角色切换到主站角色,核心变化只是把请求帧的发送逻辑调过来。
还有就是上位机。除了 Modbus Poll,LabVIEW 有成熟的 Modbus 库,Qt 也有现成的QModbusClient类。这些工具做数据监控界面、人机交互界面都很方便。我实际见过用 LabVIEW 写一套监控面板,从 STM32 从站读到温度、湿度、开关状态,然后实时曲线显示,整个项目就是典型的物联网测控系统雏形。
最后再分享一个从项目里总结的小技巧:联调阶段不管多急,先把固件里的从站地址、波特率、寄存器映射表打印出来,对照着上位机逐项检查。这套东西理顺了,Modbus 对你来说就不再是玄学,而是一个可靠的数据搬运工。我做过的项目里,很多后面看起来诡秘的问题,最后都归结到地址偏移、字节序、帧间隔这三个点上,你提前注意,后面真的能少熬好几个夜。
本文还有配套的精品资源,点击获取