简介本资源是一套基于STM32实现Modbus-RTU主机通信的完整工程代码包面向嵌入式开发工程师、工业自动化初学者及高校课程设计实践者解决STM32作为主站通过RS485总线读取温湿度传感器数据的核心通信问题。压缩包共86个文件含35个头文件.h、34个源码文件.c构成协议栈与外设驱动主体8个启动与系统配置汇编文件.s以及Keil工程配置.uvprojx/.uvoptx、调试配置.dbgconf、批处理脚本.bat等总大小356KB结构清晰、模块划分明确便于移植到STM32F1系列平台。已有170人学习下载资源附带详细验证过程与移植指南涵盖USARTRS485硬件适配、Modbus CRC校验实现、主站查询帧构造、从机响应解析及OLED数据显示等关键环节代码可直接编译运行显著降低Modbus工业通信协议落地门槛。 我前阵子手里有个项目需要把现场好几台温湿度传感器和电表的数据统一采集上来同时还能远程控制几路继电器。现场设备清一色支持Modbus-RTU协议主控用的就是STM32F103C8T6。折腾完这套“基于STM32实现Modbus-RTU主机通信”的工程之后我最大的感受是Modbus协议本身不难难的是把串口收发、RS485方向切换、超时重试、帧间隔这些细节糅合到一起还得保证设备在复杂现场环境下稳定跑。这篇文章就把整个从零到联调的过程掰开揉碎讲一遍包括代码框架、状态机设计、参数计算和线上踩坑记录希望对做工业数据采集、物联网网关、设备联调的朋友能有点实际帮助。1. 项目整体设计与思路拆解1.1 为什么是Modbus-RTU而不是Modbus-TCP或者其他协议Modbus在工业现场的地位基本等同于串口界的“标准普通话”。从PLC、变频器、温控表到各种传感器、电表、IO模块几乎所有主流工业设备都会预留Modbus-RTU接口。这套协议最大的优势是简单一条报文就地址、功能码、数据、校验四个部分二进制格式在RS485总线上传输抗干扰能力也比普通TTL串口强很多。对于需要跑几十米、上百米线缆的现场场景Modbus-RTU RS485属于性价比极高的组合。Modbus-TCP当然也可以做但前提是设备得支持以太网接口而且现场要布网线或交换机。很多温湿度传感器、小型电表根本没有网口只有两线制的RS485这时候Modbus-RTU就是绕不开的选择。还有一点Modbus-RTU对单片机的要求很低不需要跑操作系统一个UART 一个定时器 一个GPIO就能实现成本极低。这也是我最终选这条路的原因。1.2 主机从机模型到底谁说了算Modbus-RTU的通信模型分主机和从机主机也叫Master从机叫Slave。一台主机可以挂最多247个从机地址1到247地址0是广播地址只能发指令从机不会回包。通信永远是主机发起请求从机收到后响应从机之间不能直接通信从机也不能主动上报数据哪怕数据发生变化也只能等主机来问。刚开始接触Modbus的人容易犯一个错误就是让所有从机都往总线上发数据结果就是总线冲突所有通信全部乱套。正确的做法一定是“轮询”主机把所有从机排个队挨个发请求、等响应、收数据然后再问下一台。整个通信节奏由主机单方面控制这样总线上的帧才不会有交集。我这套程序里也是采用轮询模式主循环里不断调用状态机一个周期查完所有从机然后再从头开始。1.3 软件架构上的三层划分在实际工程里我不会把Modbus相关代码全部堆在一个文件里而是分了三层串口驱动层负责最底层的UART收发、RS485方向引脚控制、波特率配置对外提供发送一帧数据、注册接收回调这类接口。Modbus协议层负责组帧、拆帧、CRC校验、状态机管理、超时计数对外提供读取保持寄存器、写单寄存器、写多寄存器这样的API。应用层负责把Modbus返回的裸数据解析成温度、湿度、开关状态等业务值或者把用户指令翻译成Modbus写操作。这个分层帮了大忙。项目后来换了另一款传感器从机的寄存器地址和功能码都不一样我只需要改应用层的映射表协议层和驱动层完全不用动。如果你的代码还是一坨写到底建议趁早改造成这种结构后面维护起来会省很多力气。2. Modbus-RTU协议核心报文结构、功能码与CRC校验2.1 一条报文到底长什么样Modbus-RTU的报文格式十分紧凑主机发请求和从机回响应都是同样的结构地址码1字节 功能码1字节 数据区N字节 CRC校验2字节。CRC是低字节在前这一点特别容易搞错后面我会专门讲。最常用的功能码就三个功能码含义典型用途0x03读保持寄存器读取温度、湿度、电量等参数0x06写单个寄存器给从机设置一个值比如写速度0x10写多个寄存器批量设置参数或者启动/停止等组合操作举个例子要读取地址为1的从机、起始寄存器地址0x0000、连续2个保持寄存器主机发出去的请求帧是01 03 00 00 00 02 C4 0B01从机地址03功能码读保持寄存器00 00起始寄存器地址高字节在前00 02寄存器数量C4 0BCRC16校验值如果从机正常响应会回一帧这样的数据01 03 04 00 63 00 64 3A 9F01从机地址03功能码04后面数据区字节数这里是4字节00 63第一个寄存器的值十进制9900 64第二个寄存器的值十进制1003A 9FCRC也就是说温度是99湿度100单位看从机手册是怎么定义的。如果从机收到指令但发现寄存器地址不对、功能码不支持会回异常帧功能码最高位置1比如0x83后面跟一个异常码常见的有01非法功能、02非法地址、03非法数据。主机收到这种帧要能识别并处理不能一直傻等。2.2 CRC16-Modbus的计算原理和C语言实现CRC校验是Modbus-RTU报文完整的最后一道防线。Modbus的CRC算法是基于多项式0xA001的初始值固定为0xFFFF。计算时把报文里除了CRC本身之外的所有字节依次代入每一位都要做异或和右移操作8位数据全部处理完最后得到的16位值就是CRC。需要注意的是发送时CRC低字节在前高字节在后。标准C语言实现是这样uint16_t Modbus_CRC16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }实际组帧的时候先把地址、功能码、数据填好然后对整个不含CRC的帧计算CRC再按低前高后的顺序填到帧末尾uint16_t crc Modbus_CRC16(frame, 6); frame[6] (uint8_t)(crc 0xFF); frame[7] (uint8_t)(crc 8);我之前在项目里犯过一个经典错误用别的串口工具抓到从机回的包一看CRC好像不对排查半天发现是自己代码里高低字节顺序搞反了。判断CRC是否正确最简单的办法是用现成的Modbus调试工具生成一帧数据来做对比不要手工算太容易出错。2.3 帧和帧之间的间隔怎么判断Modbus-RTU协议有一个硬性规定一帧内部字节与字节之间的间隔不能超过1.5个字符时间帧与帧之间间隔不能小于3.5个字符时间。也就是说从机收到主机请求后如果超过3.5个字符时间没有拿到下一个字节就认为这一帧已经结束了可以开始解析。在串口接收数据时如果用固定延时来判断帧结束会有两个问题。一是在高波特率下比如115200一帧数据可能1毫秒不到就收完了延时太短容易把一帧拆成两半延时太长又影响轮询效率。二是如果从机处理慢响应帧被拆成两段到达固定延时就会误判成两帧。正确的做法是每接收到一个字节就刷新一次“最后字节接收时间”然后在周期任务里检查如果距离最后一个字节已经超过3.5个字符时间就认为一帧收完了。3.5个字符时间怎么算以9600波特率、8数据位、无校验、1停止位为例一个字符实际占用1个起始位 8个数据位 1个停止位 10个位时间。一个位时间是1/9600秒约104微秒3.5个字符就是3.5乘以10再乘以104微秒约3.6毫秒。程序中我一般取整到5毫秒留一点余量。3. STM32串口底层配置与RS485方向控制3.1 CubeMX配置串口参数在STM32上做Modbus-RTU串口这块我用的是HAL库ST官方持续维护代码可读性也好。以STM32F103C8T6为例CubeMX里选了USART1配置成异步模式波特率根据从机设备来定我这个项目里的传感器最高只支持9600为了稳定我全链路统一用了96008位数据、无校验、1位停止位也就是常说的8N1。如果设备要求偶校验就选Even。需要特别留意的是Modbus-RTU的波特率、数据位、校验位必须和所有从机完全一致只要有一端不一致通信就直接失败。工程里最好在配置头文件里定义成宏#define MODBUS_BAUDRATE 9600 #define MODBUS_DATA_BITS UART_WORDLENGTH_8B #define MODBUS_PARITY UART_PARITY_NONE #define MODBUS_STOP_BITS UART_STOPBITS_1这样以后换设备改一个宏就行不用到处搜代码。3.2 RS485收发方向切换最容易翻车的环节RS485是半双工总线同一时刻只能有一个方向在传数据。STM32的UART本身是TTL电平要想跑RS485必须外接一个收发器芯片最常见的是MAX485、SP3485这类。芯片的DE发送使能和RE接收使能一般直接接在一起用一个GPIO控制。GPIO输出高电平表示进入发送模式低电平进入接收模式。这个方向切换看着简单实际执行起来坑不少。第一个坑是切换时机发送完成后不能立刻拉低方向引脚因为串口数据虽然已经送进移位寄存器但最后一位可能还没从TX引脚上完全移出。如果立刻切到接收模式最后一个字节甚至最后几个字节会被截断从机收到的就是残缺帧。我之前用的是HAL_UART_Transmit这个函数默认是等发送完成才返回的但保险起见我在拉低方向之前再加一个十几微秒的延时或者在发送后等待UART的TC标志置位再切换。发送一帧数据的完整流程RS485_DE_DIR(1); // 拉高方向引脚进入发送模式 delay_us(50); // 给收发器一点切换时间 HAL_UART_Transmit(huart1, frame, len, 100); while (!(huart1.Instance-SR USART_FLAG_TC)); // 等待真正发送完成 delay_us(50); // 再保险一下 RS485_DE_DIR(0); // 拉低方向引脚切回接收模式第二个坑是接收方向如果上电后没有把RS485芯片的RE拉低芯片会处于高阻状态总线上的数据根本进不来。所以硬件上电后第一时间就要把方向脚配置成接收模式而不是等到需要接收时才开始拉。这个习惯说来简单但市面上不少开源代码就是在初始化时忘了把方向脚置低导致主机发完请求后收不到任何数据排查半天才发现是方向引脚没初始化。3.3 接收不定长数据两种方案对比Modbus-RTU的响应帧长度不是固定的。读多个寄存器时从机返回的数据区长度随寄存器数量变化异常响应的长度又不一样。所以接收端不能像接收固定结构体那样简单处理必须支持“不定长数据接收”。第一种方案是直接用STM32串口空闲中断IDLE Interrupt。当总线上一段时间没有新数据时硬件会触发空闲事件这时DMA或中断接收到的数据就是一整帧。这个方案效率很高代码也简洁。不过有些老的型号或部分第三方库不支持空闲中断或者用户对HAL库不熟配置起来会有点麻烦。第二种方案是通用性更强的“串口接收中断 软件计时”。每收到一个字节就进一次接收中断把字节存入缓冲区同时记录当前时间戳。主循环里周期检查如果当前时间距离最后字节时间超过了帧间隔阈值比如前面算的5毫秒就认为数据收完了交给协议层去解析。这个方案不依赖特定外设代码移植性很好是我在实际项目里采用的主要方式。面对从机掉线、响应慢这些情况也更容易控制超时逻辑。4. 主机状态机设计与超时重试机制4.1 为什么必须上状态机而不是阻塞等待刚写Modbus主机时我第一版代码是这么干的发完请求帧直接在一个死循环里等接收收到数据就解析没收到就一直卡着。波特率9600时如果从机正常响应这段代码是能跑的。但只要有一台从机掉线、地址写错、或者通讯线被老鼠咬断主机就永远卡在等待循环里后面的从机全部瘫痪。整个采集系统直接锁死还得人工复位。这种情况下必须引入状态机和超时机制。主机每次发完请求后给一个等待窗口比如200毫秒。如果超过这个时间还没收到响应就标记这次通信超时继续处理下一台从机。这样即使有从机掉线整个轮询周期也只是增加几百毫秒不会影响其他设备的数据更新。4.2 主状态机拆解我的Modbus主机状态机大概分成五个状态MB_STATE_IDLE空闲状态没有待处理的请求可以接收应用层下发的新任务。MB_STATE_SEND_REQ发送请求状态把组装好的报文通过串口发出去同时启动超时定时器。MB_STATE_WAIT_RESP等待响应状态检查接收缓冲区看有没有完整帧到达。MB_STATE_PARSE解析状态收到帧后做CRC校验、地址比对、功能码检查提取数据。MB_STATE_ERROR错误处理状态统一处理超时、CRC错误、异常响应等记录错误码。主循环里每一次调用都执行一次状态推进void Modbus_Master_Task(void) { switch (mb_state) { case MB_STATE_IDLE: // 从请求队列里取一个任务组帧 if (mb_req_ready) { Modbus_BuildFrame(mb_tx_frame); mb_state MB_STATE_SEND_REQ; } break; case MB_STATE_SEND_REQ: RS485_DE(1); HAL_UART_Transmit(huart1, mb_tx_frame.buf, mb_tx_frame.len, 50); while (!(huart1.Instance-SR USART_FLAG_TC)); delay_us(50); RS485_DE(0); mb_timeout_start HAL_GetTick(); mb_rx_len 0; mb_state MB_STATE_WAIT_RESP; break; case MB_STATE_WAIT_RESP: if (mb_rx_len 0 Modbus_FrameComplete()) { mb_state MB_STATE_PARSE; } else if (HAL_GetTick() - mb_timeout_start MB_RESP_TIMEOUT_MS) { mb_error_code MB_ERR_TIMEOUT; mb_state MB_STATE_ERROR; } break; case MB_STATE_PARSE: if (Modbus_ParseFrame() MB_OK) { // 数据提取到应用缓冲区 mb_state MB_STATE_IDLE; } else { mb_error_code MB_ERR_CRC; mb_state MB_STATE_ERROR; } break; case MB_STATE_ERROR: // 记录错误统计通信质量然后回到空闲 mb_state MB_STATE_IDLE; break; } }这段代码看着简单但有几个细节我特意加了处理。一是发送请求前要把接收缓冲区长度清零不然上一帧残留数据会干扰本次解析。二是超时时间从“发送完成”那一刻开始算而不是从发送开始算否则波特率低、帧长时会误判超时。三是状态机推进要放在主循环里多次调用不要用阻塞式延时。4.3 超时时间怎么取才合理超时时间设置要平衡两个矛盾设置太短从机还在处理指令的时候主机就放弃了造成误判超时设置太长一台从机掉线后整个轮询周期被拖得很长其他设备的数据刷新率跟着下降。经验做法是超时时间至少大于从机最长响应时间。工业从机模块的响应时间一般都在100毫秒以内很多能做到20到50毫秒PLC稍慢一些可能到200毫秒。我在这套项目里取的是300毫秒既不会误伤慢速从机设备掉线后一轮最多也就多等300毫秒整个系统60个从机的轮询周期也就增加18秒左右完全可以接受。如果项目对实时性要求很高可以对不同从机配置不同的超时时间慢速设备给长一点快速设备给短一点状态机里超时时间从请求结构体里读取就行。5. 完整代码实现与联调过程5.1 协议层接口怎么设计协议层对外提供的接口我封装成了三个核心API配套一个请求结构体typedef struct { uint8_t slave_addr; // 从机地址 uint8_t func_code; // 功能码 uint16_t reg_addr; // 起始寄存器 uint16_t reg_count; // 数量 uint16_t *write_data; // 写寄存器时的数据指针 } Modbus_Request_t; void Modbus_Init(void); uint8_t Modbus_Master_Poll(Modbus_Request_t *req, uint16_t *resp_data, uint16_t *err_code);应用层调用者的写法非常直观Modbus_Request_t req; uint16_t get_temp; uint16_t err; req.slave_addr 1; req.func_code 0x03; req.reg_addr 0x0000; req.reg_count 2; if (Modbus_Master_Poll(req, get_temp, err) MB_OK) { // 解析寄存器值比如放大10倍的温度 } else { // 根据err打印错误原因 }这里把整个“组帧-发送-等待-解析”的流程全部封装在Modbus_Master_Poll里面应用层不关心底层细节。实际项目中这个API可以直接对接状态机也可以改为异步接口看整体架构怎么设计。5.2 关键实现组帧和解析完整过程组帧过程在Modbus_BuildFrame里完成核心是把请求结构体翻译成字节流static void Modbus_BuildFrame(Modbus_Request_t *req, uint8_t *frame, uint16_t *len) { uint16_t idx 0; uint16_t crc; frame[idx] req-slave_addr; frame[idx] req-func_code; if (req-func_code 0x03) { frame[idx] (uint8_t)(req-reg_addr 8); frame[idx] (uint8_t)(req-reg_addr 0xFF); frame[idx] (uint8_t)(req-reg_count 8); frame[idx] (uint8_t)(req-reg_count 0xFF); } else if (req-func_code 0x06) { frame[idx] (uint8_t)(req-reg_addr 8); frame[idx] (uint8_t)(req-reg_addr 0xFF); frame[idx] (uint8_t)((*req-write_data) 8); frame[idx] (uint8_t)((*req-write_data) 0xFF); } crc Modbus_CRC16(frame, idx); frame[idx] (uint8_t)(crc 0xFF); frame[idx] (uint8_t)(crc 8); *len idx; }解析响应时除了CRC校验还要核对响应中的从机地址是否和请求一致、功能码是否和请求一致。如果不一致哪怕CRC通过这帧数据也不能采信。这个错误在实际调试中偶尔会出现通常是总线上有其他设备干扰或者从机地址配置冲突。5.3 用PC模拟从机做联调事半功倍我特别推荐刚开始做Modbus主机调试时先用电脑上的从机模拟软件代替真实设备。这里用的是Modbus Slave这个工具PC端虚拟出一台从机然后把STM32开发板的串口通过USB转TTL接到电脑上。注意这里不需要接MAX485因为电脑串口工具出来的是TTL电平而STM32的USART也直接就是TTL电平两者可以直接对接。等调通了再把RS485收发器接上最后才接真实的现场设备。联调的第一步先在Modbus Slave里设置好从站地址、功能码、寄存器初始值然后让STM32主机发请求看PC端能不能收到正确的读请求帧。第二步反过来让STM32解析PC端返回的响应帧看看解析出来的寄存器值对不对。这个阶段建议把PC端的串口监视工具和STM32的调试串口同时打开打印每次发送和接收的完整字节流逐字节比对。我联调时的一个习惯是在解析函数里打印CRC计算结果和收到的CRC值一旦不匹配马上就能看出来是高低字节反了还是计算过程有误。这种问题在纸上推演很难发现跑起来一对比立刻就清楚了。5.4 接入真实从机时电气上的几个检查点从PC虚拟从机切换到真实RS485总线时有几个电气细节非常关键。首先RS485总线的A和B线不要接反A对应同相端B对应反相端。市面上不少接线端子标注不清或者颜色不规范接反之后通信是完全不行的。其次总线两端最好加上120欧姆终端电阻尤其是线缆较长、节点较多时不加终端电阻会出现信号反射导致偶发性的CRC错误。第三如果总线上的设备离得远或者电磁干扰强建议用屏蔽双绞线屏蔽层单端接地。这些电气问题在实验室里不一定暴露得出来因为短线、低干扰环境下信号质量好代码再烂也能跑。一到现场线长超过50米旁边再有变频器、电机启动波形畸变会非常明显。所以代码层面对CRC校验、超时重试的容错一定要做好电气上也要尽量规范两边同时努力系统才能稳。6. 常见问题与排查技巧实录6.1 从机完全不响应先排查硬件还是先排查软件从机不响应的时候很多人第一反应是怀疑代码。我的排查顺序是先用示波器或逻辑分析仪看主机TX有没有波形输出没有波形就查UART配置和GPIO有波形再看方向控制脚有没有正常拉高拉低由于RS485是半双工如果方向脚一直是接收模式数据根本送不到总线上方向也没问题再看从机到底有没有在总线上回数据。用示波器抓一下从机端的AB差分信号如果能看到回波但主机就是解析不出来那问题大概率在接收方向比如STM32没有进接收中断、或者接收缓冲区没清干净。电气上没问题再回头看软件先从最简单的入手确认从机地址和寄存器地址是不是和设备手册完全一致。我遇到过不止一次从机地址写成了0而地址0是广播地址从机收到广播指令后按协议规定是不回包的表现就是“从机完全不响应”。这种问题纯查代码很难想到一旦知道Modbus地址规则一秒就能定位。6.2 隔一段时间就出现一次CRC错误是什么原因CRC错误是Modbus调试最烦人的问题因为它是“偶发性”的多半不是代码逻辑错误而是信号质量问题或者帧间隔设置不合理。我遇到过一次排查了很久发现是RS485总线没有终端电阻线缆末端信号反射叠加到正常波形上导致高压时数据位被误判翻转。加上120欧姆电阻后CRC错误率直接就没了。还有一种特殊情况现场有两台设备波特率不一致比如一台1号从机是9600另一台2号从机是115200主机的请求帧对这些设备来说可能被切割成乱码进而产生CRC错误。我在一个项目里就踩过这个坑后来把所有从机的拨码开关统一设置成9600才解决。所以做系统联调之前一定要对每一台从机的波特率、校验位做登记不要想当然认为所有设备出厂配置都一样。6.3 最后一个字节总是丢大概率是方向切换太快这个在前面已经提到过但我还是想单独拿出来说因为这是硬件调“485方向”时最容易踩的坑。现象是主机能发出请求但从机反馈的响应帧总是缺最后几个字节或者收不到完整帧。用逻辑分析仪抓主机的TX引脚会发现最后一个字节被截成了半个字节。原因就是发送完最后一个数据后GPIO立刻拉低了DE导致数据还没完全送出。解决办法是在HAL_UART_Transmit返回后额外等待USART_FLAG_TC位置位这是串口里“数据已完全移出”的标志。然后再加几十微秒的延时给RS485收发器留出切换时间再拉低方向引脚。这个处理加上之后我再也没有遇到丢最后一个字节的问题。6.4 常见问题速查表现象可能原因排查方法一帧都不响应从机地址错误、485A/B接反、方向脚未初始化检查地址0~247范围、A/B对调、示波器看方向脚波形偶发CRC错误无终端电阻、波特率不一致、干扰加120欧姆电阻、统一波特率、检查屏蔽层接地响应帧最后几个字节丢失RS485方向切换过早发送完成等待TC标志拉低前加延时接收到乱码波特率不匹配、校验位不一致核对从机参数统一配置宏协议请求正常但解析值不对寄存器地址偏移、高低字节需要交换对照从机手册打印原始寄存器值确认多个从机通信互相干扰从机地址重复、总线未做端接检查地址拨码别重复检查终端电阻6.5 关于错误恢复机制的一点体会Modbus主机不能只是“收到就处理收不到就超时”这么简单最好把错误恢复做进机制里。比如同一台从机连续多次超时可以考虑把它标记为离线不再每次都等满超时时间而是降低轮询频率比如30秒才尝试一次。这样总线上如果有一台设备彻底死掉整个系统的通信也不会被拖垮。我在状态机的错误处理分支里维护了一个简单的错误计数器和离线标记。每次通信成功就清零连续失败次数达到5次就标记从机离线。应用层拿到这个状态后可以决定是报警还是继续轮询。这套机制在现场帮了大忙有一台从机电源开关老化经常掉线但系统一直稳定运行其他设备的数据采集完全不受影响。最后分享一个实用小技巧这套Modbus主机程序调试稳定后我还往里面加了一个“看门狗”功能。用STM32的独立看门狗IWDG主循环里每次成功轮询完一轮就从机喂狗一次。如果某次通信出现异常比如循环卡在了某个阻塞函数里看门狗到点就会自动复位单片机让系统重新初始化通信。现场设备不用人工干预过一会儿自己就能恢复正常。这种设计对无人值守的采集站来说尤其重要省去了不少跑现场复位的麻烦。如果你做的也是类似的工业数据采集项目建议把这个功能也加上。本文还有配套的精品资源点击获取