STM32实现Modbus RTU从站:全功能码解析与工程实践 简介一套基于STM32的MODBUS RTU通信实例工程完整覆盖读寄存器03/04、写单个寄存器06、写多个寄存器10以及线圈/离散输入等常用功能码适合需要快速落地串口通信和RS485总线应用的嵌入式开发者。资源共787个文件压缩包11.14MB包含完整Keil工程uvprojx、C/H源码、启动汇编文件以及编译产物hex/axf固件可下载后直接烧录验证另有大量html、png、pdf文档和lst、map、crf等编译过程文件便于深入梳理协议实现和构建细节。代码工程结构清晰从串口初始化、RS485方向控制到MODBUS帧解析、CRC校验和异常响应均有对应模块并配有一键拷贝/删除目标文件等辅助bat脚本以及SD卡、NAND Flash、LCD等外设驱动示例方便在真实项目中二次移植。目前已有5977人学习下载无论初学者需要理解MODBUS帧格式还是有经验工程师寻找现成参考这套实例都能有效支撑开发尤其适合作为STM32工业通信项目的起点可快速验证从站功能并移植到真实产品。 搞嵌入式这些年MODBUS RTU是逃不掉的一个协议。项目标题写得直白单片机STM32 MODBUS RTU通讯实例功能码很全。我自己的理解是这份代码要用STM32实现一个完整的Modbus RTU从站至少把常用的01、02、03、04、05、06、15、16这几个功能码全部走通既能读线圈、读离散输入、读保持寄存器、读输入寄存器也能写单线圈、写单寄存器、写多线圈、写多寄存器。说实话一套能跑通全功能码的实例比东拼西凑的demo值钱得多。这篇文章就是把我实践中的方案、代码逻辑、调试经验和踩坑记录完整放出来适合正在做单片机通讯、工控采集、设备联网的开发者参考。1. 整体思路与方案设计1.1 为什么选择STM32作为Modbus RTU载体STM32做Modbus RTU从站最大的优势不是算力而是串口、定时器、GPIO这些外设配合起来非常顺手。实际项目中我更习惯用标准库因为控制细粒度更高尤其收发切换这种毫秒级动作HAL库的抽象层反而容易让人忽略底层时序。一个完整的Modbus RTU从站本质就是“状态机 数据映射表”。状态机负责判断一帧起止、解析功能码数据映射表负责把协议地址翻译成真实的线圈、寄存器数组。把这层想清楚后面写代码就是填case分支。很多人一开始会纠结要不要移植FreeModbus。我的建议是如果你想把协议吃透或者功能码需要裁剪完全可以用裸机自己写。FreeModbus成熟但想加一个自定义功能码得先花时间读它的代码结构自研协议栈的好处是每一行都在自己掌控里现场出问题好定位。尤其标题里点名“功能码很全”意味着01到16这8个常用功能码都得支持这种情况下自己组织一张功能码分发表反而更直观。1.2 物理层RS485半双工通信的关键设计Modbus RTU跑在现场物理层九成是RS485。两根差分线A/B抗共模干扰能力强传输距离几十米到上千米都见过。RS485是半双工同一时刻只能收或者只能发所以必须有一根GPIO控制485收发器的DE/RE引脚。STM32串口的TX/RX直接接MAX485或SP3485DE和RE一般短接由单片机一个引脚控制发送时拉高接收时拉低。这个切换看似简单却是整个方案里翻车率最高的地方。发送函数把数据写到串口数据寄存器后如果立刻把DE拉低发送移位寄存器可能还没把最后一个字节送完帧尾直接丢了主站就会一直报超时。正确做法是等串口发送完成标志USART_TC置位确认移位寄存器空了再拉低DE。我在调试室里见过太多“时好时坏”的485通信最后都是这个原因。物理层参数一般设9600 8N1老式PLC、仪表基本都兼容这个配置。距离短也可以上115200但帧间隔定时器就要重新调。后文会专门讲怎么算3.5字符时间。1.3 功能码清单与数据模型映射既然说“功能码很全”先列一个实测过的功能码清单功能码名称操作对象对应数据区01读线圈输出位线圈数组02读离散输入输入位离散输入数组03读保持寄存器可读写寄存器保持寄存器数组04读输入寄存器只读寄存器输入寄存器数组05写单个线圈输出位线圈数组06写单个寄存器保持寄存器保持寄存器数组15写多个线圈输出位线圈数组16写多个寄存器保持寄存器保持寄存器数组数据结构上我建议把四个数据区独立定义。线圈和离散输入是位操作用数组按位存储省内存保持寄存器和输入寄存器是16位数据直接用uint16_t数组。#define COIL_NUM 64 #define DISCRETE_NUM 64 #define HOLDING_REG_NUM 100 #define INPUT_REG_NUM 100 uint8_t coilStatus[COIL_NUM / 8]; // 线圈位映射 uint8_t discreteInput[DISCRETE_NUM / 8]; // 离散输入位映射 uint16_t holdingRegs[HOLDING_REG_NUM]; // 保持寄存器 uint16_t inputRegs[INPUT_REG_NUM]; // 输入寄存器地址映射规则也一并定死Modbus请求中的地址0对应数组下标0地址N对应数组下标N。线圈地址addr对应coilStatus[addr / 8]的第addr % 8位。寄存器直接读holdingRegs[addr]。很多初学者会在“寄存器地址是从0开始还是从1开始”上纠结我的经验是协议报文里按0处理设备手册里按1编号解析时把请求地址减1再映射到数组。这个规则一定要在文档里写清楚不然换个人维护代码迟早搞混。2. 核心细节解析与实操要点2.1 串口接收状态机一帧数据怎么算完整Modbus RTU是帧协议不是字节协议。主机发完一帧后从机要知道“什么时候算一帧结束”。最标准的方法是看帧间隔时间当上一个字节接收后超过3.5个字符时间没有新字节就认为一帧已经结束。3.5字符时间的计算很简单以9600bps、8N1为例一个字符含1起始位8数据位1停止位共10位所以3.5字符时间 3.5 × 10 / 9600 ≈ 3.64ms。工程上定时器一般取5ms稳定可靠。115200bps时约0.3ms定时器至少要调到0.5ms~1ms。我实际用的方案是串口RXNE中断 基本定时器超时。串口每收一个字节就写进缓冲数组同时清掉定时器计数值重新计时定时器溢出中断里置一个帧完成标志主循环检测到标志后处理整帧数据。关键点在于中断优先级串口接收中断必须高于定时器超时中断否则定时器频繁抢断可能导致串口丢字节。2.2 CRC16校验查表法和位运算法Modbus RTU的CRC16是必做项。协议规定CRC初始值0xFFFF多项式0xA001结果低字节在前发送。现场调试时如果CRC总是不过先检查是不是高低字节发反了。uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; uint8_t i; while (len--) { crc ^ *data; for (i 0; i 8; i) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }计算范围是从站地址功能码数据区不包含CRC本身。发送时把算出来的16位结果先发低字节再发高字节。我建议调试时用已知正确帧做对照例如 01 03 00 00 00 01 84 0A其中84 0A就是CRC。用工具核对算法没问题后再看字节发送顺序。查表法比逐位运算快很多如果MCU主频低或者报文频繁建议生成一张256项的CRC表。2.3 功能码读写响应与打包规则功能码01和02的响应都要把位压缩成字节。从起始地址开始连续取count个位第0位放字节的bit0第1位放bit1依此类推。需要返回的字节数 (count 7) / 8。功能码03和04则简单很多每个寄存器2字节高字节先发。这里最容易忽略的是地址越界检查如果“起始地址 数量”超过了数组长度必须返回异常码02否则数组越界会踩出各种诡异问题。功能码05写单个线圈时数据值0xFF00代表置10x0000代表置0不是简单的1和0。我见过有人在代码里直接判断data 1结果主站发0xFF00时写不进去。收到其他值应该返回异常码03。功能码15写多个线圈和16写多个寄存器属于批量写必须先把整帧接收完解析出字节数和数量再统一校验。校验通过后一次性写入数组避免写了前面几个寄存器后面数据又出错造成现场数据不一致。2.4 异常响应与错误码设计Modbus从站不能只会“闷头干活”遇到错误要回异常帧。异常帧格式是从站地址 (功能码 | 0x80) 异常码 CRC16。我常用的异常码有三个异常码含义典型场景01非法功能码收到协议里不存在的功能码02非法数据地址起始地址加数量越界03非法数据值05功能码收到非0xFF00/0x0000异常响应不能省。现场主站会持续轮询如果从站收到错误请求后不回任何数据主站会一直等一直超时整个总线都受影响。我在调试16功能码时故意发一个超出寄存器数量的请求如果从站立刻返回0x90 0x02这样的异常帧就说明异常处理到位了。3. 实操过程与核心环节实现3.1 硬件平台与工程配置流程实测平台是STM32F103C8T6最小系统板 SP3485模块 USB转485。接线USART2_TX(PB10)接485模块的DIUSART2_RX(PB11)接ROPD0接DE和RE短接点A/B接USB转485。手边有别的485芯片比如MAX485也可以引脚兼容。工程上用CubeMX初始化也行但标准库更直白。需要配四样东西USART2波特率9600、8N1、使能RXNE中断TIM3配置成1ms溢出使能更新中断PD0配置为推挽输出还有中断优先级串口RXNE中断高于TIM3中断。初始化完成后先用串口助手直接发一帧 01 03 00 00 00 0A C5 CD观察从机是否返回20字节数据。如果返回正确再接入Modbus Poll联调。3.2 Modbus Poll联调与功能码全测Modbus Poll是调试Modbus从站最顺手的工具。新建连接选Modbus RTU Over Serial Line设置COM口和波特率9600从站地址1功能码选03起始地址0数量20读取间隔500ms。能正确读到数据后再挨个测试01、02、04、05、06、15、16。我的习惯是把测试结果记成一张表功能码、起始地址、数量、预期响应、实测结果。这样不仅自己心里有底后面写测试报告也方便。实际操作中PC端USB转485模块和STM32的485模块A/B接反是很常见的问题。如果完全没反应先量485芯片RO端有没有波形再检查A/B不要一上来就怀疑代码。另外两个485模块之间最好共地否则通信距离一长就容易丢包。3.3 帧间隔定时器参数的实测调整9600bps下定时器溢出设5ms我用了很长时间都没问题。后来把波特率提到115200就必须把超时时间降到0.5ms~1ms。还有一个坑主循环处理如果太慢比如在里面做延时或printf下一帧可能已经覆盖接收缓冲。建议接收缓冲数组开到256字节主循环尽量只做协议处理不做无关逻辑。更稳妥的做法是用环形缓冲区接收主循环再从环形缓冲区取完整帧。不过我裸机项目里静态缓冲也够用只要处理速度跟得上就行。4. 常见问题与排查技巧实录4.1 RS485收发切换方向导致丢尾字节现象是Modbus Poll偶发超时抓报文发现帧尾最后一个字节丢了。原因前面说过发送完直接拉低DE移位寄存器还没清空。解决办法很简单每次发完最后一个字节等USART_GetFlagStatus(USARTx, USART_FLAG_TC) SET再拉低DE。这个标志表示“发送移位寄存器已空”是最保险的切换时机。半双工通信这条不能省。4.2 CRC校验总是失败最常遇到的是高低字节顺序错误。Modbus RTU发送CRC时低字节在前高字节在后这一点和很多人的直觉相反。其次是计算范围搞错把已经写入发送缓冲区的CRC也纳入计算导致算出来的结果永远对不上。调试时用已知帧排查比如 01 03 00 00 00 01 84 0A把接收到的原始报文严格按字节对比立刻能定位是算法问题还是顺序问题。4.3 多寄存器写入出现部分成功部分失败功能码16写多个寄存器时请求帧很长如果边解析边写入后半帧出错时前面几个寄存器已经改了会造成数据不一致。更严重的是一旦地址加数量越界写入过程会把其他数组甚至栈空间踩坏。解决办法先解析完整帧校验“起始地址 数量 寄存器数组长度”、字节数与数量匹配全部通过后再一次性拷贝。批量写入期间如果有其他任务读同一块寄存器最好临时关中断保证数据一致性。4.4 地址越界导致程序跑飞我见过一次现场事故上位机一次读200个保持寄存器而设备只有100个从机代码里没检查范围直接越界访问把后面的变量全改了设备逻辑彻底混乱。从那以后我所有功能码处理都先做边界判断起始地址加数量超过数组上限立刻回异常码02。这不仅是协议要求更是嵌入式程序自我保护的关键。4.5 定时器优先级冲突导致帧判断异常如果串口接收中断优先级低于定时器中断可能出现这种情况定时器刚要溢出判帧结束这时候又来了一个字节但串口中断没及时处理定时器抢先置了帧完成标志主循环就把不完整的帧拿去解析结果CRC不过协议栈直接丢弃。我的方案是串口RXNE中断优先级高于定时器并且在定时器超时处理里再判断一次“如果串口接收标志又置位就放弃本次超时标志”。这样能从根上避免拆帧错帧。5. 功能码全协议栈的扩展思路5.1 切换为主机模式需要注意的问题如果现场需要STM32主动去读其他仪表或PLC同样的协议栈可以改成主机模式。发送请求帧时设置一个接收超时定时器不能死等从站响应超时或收到异常码都要释放总线继续下一轮。主机模式的功能码和从站正好镜像关键是设计好轮询表。比如要采集多个温湿度传感器每秒钟轮询一遍记录每个从站地址的通讯状态超时三次就标记掉线。这样才能做出一套可维护的采集链路。5.2 向FreeModbus移植或自己写协议栈的取舍FreeModbus开源、稳定、功能码也全但配置起来要花时间读文档。自研协议栈的好处是裁剪灵活加一个自定义功能码就是加一个case分支出了问题能直接定位到自己的代码。我的建议是如果是学习一定自己写一遍如果是急着出产品可以用FreeModbus。但不管用哪种寄存器映射表一定要独立一层方便后续移植。5.3 Modbus RTU转TCP和物联网方向一个常见需求是把现场RS485设备接入局域网或云端。STM32通过串口跑Modbus RTU再挂一个ESP8266或以太网模块跑MODBUS TCP转发时把RTU帧里的从站地址去掉其余功能码和数据区完全兼容。只要底层RTU解析层写得干净转TCP不过是在上面换一层承载协议。我做网关项目时就是共用一套寄存器数组下面串口驱动、上面网络驱动逻辑非常清晰。我个人实际做下来最大的体会是Modbus RTU不难难的是把所有边界条件和异常分支都处理干净。寄存器映射表设计好比什么都重要后续每加一个功能码都只是增加一个处理分支而已。功能码“全”不只是列表好看而是要在各种异常请求下都能稳如老狗。希望这份记录能帮你省下几个晚上的调试时间。本文还有配套的精品资源点击获取