简介:面向嵌入式开发者和工业自动化工程师,提供以STM32F103为从机、通过RS485接口运行Modbus RTU协议的完整工程源码,用于解决主站与设备之间远程数据采集、寄存器读写和控制命令下发等问题。压缩包内有145个文件,大小仅2.58MB,以30余个C源文件和头文件为核心,同时包含Keil MDK工程配置、编译链接生成的HEX/AXF文件、启动文件及工程备份等,基本覆盖标准外设库开发所需模块。代码工程实现了UART中断接收、Modbus RTU帧解析、多种功能码响应、CRC16校验以及RS485收发方向切换等关键环节,并保留明确的移植接口与错误处理思路。通过对源码和工程组织结构的学习,可清楚理解从机通信的完整链路,并直接复制到实际项目中修改引脚或寄存器映射。目前已有2971人学习,适合正在设计远程监控、联网仪表或需要为STM32增加Modbus从机能力的开发者作为参考起点。 做嵌入式这几年,我接过不少“把数据从设备里弄出来”的活儿。最常见的场景就是:车间里有好几个传感器或者控制器,要往总线上传数据,上位机是PLC或者工控机,它们认的协议基本都是Modbus。而STM32作为从机,通过RS485物理层跑Modbus RTU,几乎是这类项目里最稳、最省成本、也最不容易出错的一套组合。这篇文章就把我从零开始做“STM32从机+RS485+Modbus RTU”的完整思路、电路设计、代码框架、排错过程都写清楚,给正准备在这个方向上动手的朋友一份可以直接照做的参考。
1. 从机角色定位:RS485总线上,你只在别人问话时开口
先说一个很多人刚接触Modbus时容易搞反的点:咱们做的是从机(Slave),不是主机(Master)。
主机是总线上的“话事人”,它轮流问每个从机“你数据是多少”。从机的任务只有一个:收到问话,核对是不是问我,然后老老实实回答。不能主动往总线上丢数据,也不能和别的从机抢着说话。这个思路直接决定了你写代码的结构——不是“我定时发一包数据出去”,而是“我收到一包合法请求,然后回一包响应”。
STM32做从机,角色再具体一点:主站发过来一帧Modbus RTU报文,里面带着地址码、功能码、寄存器地址、数据、CRC校验。STM32先看地址码是不是自己的,再看CRC对不对,解析功能码,操作对应的寄存器,最后拼一帧响应报文发回去。整个过程就是“收—查—改—回”四个动作。
从机地址是唯一标识,一般设1到247之间,有些项目会用到0作为广播地址(群发指令,从机不需要回复)。我在项目里习惯把地址放在Flash或者一个宏定义里改,调试时用Modbus Poll或者串口助手直接换地址测试。总线上的设备一开始规划好,别等焊完板子再发现地址冲突。
一句话总结从机逻辑:状态机驱动。主站不发请求时,从机就挂在接收状态,什么都不做;一旦把整帧收齐、校验通过,再进入处理状态,拼响应、切换收发方向、发出去,再回到接收。这个“先收完整帧再处理”的模型是后面所有代码的基础。
2. RS485接口电路:从MAX485到每个电阻都别偷懒
RS485是差分信号,靠A、B两根线的电压差传数据,抗共模干扰能力强,传输距离能到千米级。但物理层不是STM32的USART引脚直接接上去就能用的,中间必须加RS485收发器,最常见的就是MAX485或者SP3485。
### 2.1 最小电路:一个收发器加四个外围件
以MAX485为例,它的引脚不多,关键连接如下:
- RO(接收输出)接STM32的RX引脚
- DI(发送输入)接STM32的TX引脚
- DE(发送使能)和RE(接收使能)接同一个GPIO,控制方向
- A、B两根差分线接总线
- VCC接3.3V或5V,GND共地
很多项目里DE和RE直接短接,用一个GPIO控制:拉高就是发送模式,拉低就是接收模式。这个GPIO我用的是普通推挽输出,实测够用。有些设计会用串口的RTS信号自动控制方向,但我个人更喜欢显式GPIO控制,代码里收发切换看得清楚,排查问题也方便。
### 2.2 偏置电阻和终端电阻:这两个别省
总线空闲时,A、B之间如果没有电压差,收发器输出状态不确定,STM32会收到一堆乱码。解决办法是加偏置电阻:A线通过一个电阻上拉到VCC,B线通过一个电阻下拉到GND,给总线一个确定性的空闲电平。阻值常用1k到10k,我一般取4.7k或10k,配合终端电阻算出偏置电压,确保空闲时A比B高至少200mV。
终端电阻是并接在总线最远两端节点A、B之间的120Ω电阻,作用是匹配特性阻抗、减小信号反射。注意:只在总线两端各加一个,不是每个节点都加。我之前在实验室把三个板子全加了120Ω,示波器一看波形振铃严重,去掉中间那个立刻正常。节点少、线长很短的情况下不加也可能工作,但正规项目建议加上。
### 2.3 防护和隔离:按现场环境决定
如果STM32和RS485收发器直接共地、距离近、干扰小,可以用非隔离方案。但如果走线距离长、两端电位差大,或者现场有变频器、电机这类强干扰源,就得上隔离:收发器前端加数字隔离器(比如ADUM1201),电源用隔离模块(B0505S之类),把总线侧和下位机侧的地彻底分开。A、B线上还可以并联TVS管,防止雷击浪涌或静电打坏芯片。这个看成本预算,但至少预留焊盘位置,不要等到烧了芯片再补。
3. 抓帧是第一步:Modbus RTU帧格式和T3.5间隔
Modbus RTU的帧格式不复杂,但每个字节都有讲究。一帧完整报文长这样:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从机地址 | 1字节 | 0x01~0xF7,0为广播 |
| 功能码 | 1字节 | 0x03/0x06/0x10等 |
| 数据区 | N字节 | 寄存器地址、数量、数据值 |
| CRC16 | 2字节 | 低字节在前,高字节在后 |
### 3.1 CRC16的计算和落位
CRC16是Modbus RTU最容易写错的地方。算法是CRC-Modbus,多项式0x8005,初值0xFFFF,逐字节异或然后右移8次,最后生成的两个字节要低字节在前发送。下面这段是我一直用的查表/逐位计算版本:
uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; uint16_t i; uint8_t j; for (i = 0; i < len; i++) { crc ^= data[i]; for (j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }写响应帧时,把CRC的低字节放前面、高字节放后面。调试时最容易发现的错误是:用Modbus Poll收不到数据,或者上位机报CRC错误,十有八九就是把字节序填反了。
### 3.2 T3.5字符间隔:FIFO和拆帧的分界线
Modbus RTU规定,帧内两个字节之间的间隔不能超过3.5个字符时间,帧与帧之间的间隔至少要3.5个字符时间。这个间隔的意义是让接收方判断“一帧结束了”。
3.5个字符时间怎么算?RTU模式一字节是11位(1起始位+8数据位+1校验位+1停止位,或者无校验时也用11位时序),所以:
9600波特率下,T3.5 ≈ 3.5 × 11 ÷ 9600 ≈ 4.0ms
主站在发出请求帧后,要等这个间隔结束才能发下一帧,从机也要基于这个间隔来判定一帧接收完毕。实现方式有两种:一是用定时器,收到一个字节就重置定时器,超时认为帧结束;二是用STM32的空闲中断(IDLE),硬件检测到接收线上空闲超时自动触发。我强烈推荐后者,一个中断就能解决拆帧问题,省一颗定时器。
4. STM32侧核心代码:空闲中断+DMA,让解析不卡主循环
我用的方案是USART空闲中断(IDLE)+ DMA接收。DMA把串口收到的字节直接搬进缓冲区,硬件空闲时触发IDLE中断,在中断里把缓冲区里的整帧交给解析函数。CPU几乎不参与逐字节拷贝,对主循环的影响极小,波特率即使到115200也跑得动。
### 4.1 初始化要点
串口初始化时使能空闲中断,并把DMA配置成循环模式,绑定到USART的RX请求上:
USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); DMA_InitTypeDef DMA_InitStructure; // 配置外设地址为USART1->DR,内存地址为rx_buffer // 传输方向:外设到内存,缓冲区大小BUFFER_SIZE // 模式:循环模式(Circular Mode) DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_Init(DMA1_Channel5, &DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE);当IDLE中断到来,当前DMA计数器表示“还剩多少字节没填”,用缓冲区总长度减去这个值,就是本次实际接收的字节数。注意要先读一下USART的SR和DR寄存器清除IDLE标志,再处理DMA。
### 4.2 帧解析的完整流程
中断里拿到接收长度后,按下面顺序处理:
- 长度检查:最少8字节(地址1+功能码1+数据至少2+CRC2,请求帧还要有寄存器地址和数量),一帧最长256字节,超长直接丢弃。
- 地址匹配:不等于本机地址且不是广播地址,直接忽略。
- CRC校验:计算帧前N-2字节的CRC,和接收到的CRC两字节比对,不对就丢弃。
- 功能码分发:目前常用的是0x03读保持寄存器、0x04读输入寄存器、0x06写单个寄存器、0x10写多个寄存器。
- 执行操作并拼响应帧:处理完寄存器后,把地址、功能码、数据、CRC拼成一包,切到发送模式发出去。
### 4.3 功能码实现示例
0x03读保持寄存器是最常用的。请求格式里,数据区前两字节是起始寄存器地址,后两字节是寄存器数量。响应数据区是:功能码 + 字节数 + 寄存器值(每寄存器2字节,高字节在前)。我一般用一个数组当寄存器池:
uint16_t holding_regs[16]; // 保持寄存器池 void process_read_holding(uint8_t *frame, uint8_t len) { uint16_t start_reg = (frame[2] << 8) | frame[3]; uint16_t reg_cnt = (frame[4] << 8) | frame[5]; uint8_t resp[64]; uint8_t resp_len = 0; if (start_reg + reg_cnt > REG_NUM) { send_exception(0x03, 0x02); // 非法数据地址 return; } resp[resp_len++] = slave_addr; resp[resp_len++] = 0x03; resp[resp_len++] = reg_cnt * 2; for (uint16_t i = 0; i < reg_cnt; i++) { resp[resp_len++] = (holding_regs[start_reg + i] >> 8) & 0xFF; resp[resp_len++] = holding_regs[start_reg + i] & 0xFF; } append_crc(resp, &resp_len); send_response(resp, resp_len); }写单个寄存器0x06和写多个寄存器0x10也类似,先解析寄存器地址和数据,校验越界,再更新寄存器池,最后回显请求或返回成功信息。异常时回应异常帧,功能码最高位置1,再加一个异常码:0x01非法功能、0x02非法数据地址、0x03非法数据值。上位机看到异常码能快速定位问题。
### 4.4 收发方向切换的时序关键点
发送前先把DE/RE拉高,然后等一小段时间(至少1字节时间)再开始发送,确保总线切换稳定。发送完最后一个字节后,不能立刻拉低,要等发送移位寄存器完全结束——用USART的TC(发送完成)中断,在TC中断里拉低DE/RE回到接收模式。很多人在这里翻车:写完DR寄存器马上把方向切回接收,最后一个字节还没发完,导致响应帧尾部被截断,上位机收到CRC错误。
5. 实测踩坑实录:总线乱码、收发打架、T3.5拆帧
这部分都是我自己做项目时实际踩过的坑,列出来省得大家重复“交学费”。
### 5.1 收发方向接死,回环数据把自己搞崩
第一次画板子图省事,把DE/RE引脚直接通过跳线接高电平,想“一直处于发送状态”。结果就是:STM32发出去的数据,通过收发器输出到A/B总线,又从自己的RO脚回流到串口接收。主循环还没处理完响应逻辑,接收缓冲区又被自己怼满了。这类问题的特征是:串口助手收到一帧“回显”,和发送内容一模一样,而且CRC怎么也对不上。
解决办法就是按我前面说的,DE/RE必须由一个GPIO或RTS硬件控制,收发切换走TC中断,保证同一时刻只处于一个方向。
### 5.2 总线空闲时收到0xFF乱码
这个出现在我第二版测试板上——没加A/B的上下拉偏置电阻。主站没发数据时,总线上A、B悬空,MAX485内部比较器输出不定态,RO脚乱跳,STM32不断收到0xFF或者0x00。上位机不通信时看起来没事,一上电就疯狂触发接收中断。
加了4.7k上拉和下拉电阻,再测波形:总线空闲时A比B高200mV以上,乱码立刻消失。记住一个判断标准:空闲时A-B的电压差必须大于200mV。
### 5.3 终端电阻加多了,波形振铃像弹簧
这是做三节点测试时发现的。三个板子本来距离都很近,我图省事每块都焊了120Ω终端电阻,导致总线阻抗严重失配,A/B波形上升沿之后跟着一串振铃,偶尔出现误码。用示波器看A-B差分波形特别明显。最后只保留最远两端各一个120Ω,振铃消失,通信稳定。
### 5.4 T3.5间隔设置错误导致完美拆帧
我最早用定时器做字节超时判帧,判断间隔写成了“1ms”。9600波特率下T3.5大约是4ms,结果主站发来的一整帧里,相邻字节间隔稍微大一点,就被我这个1ms超时给“拦腰截断”成好几段,每段都通过不了地址和CRC,从机一直没反应。后来改用IDLE中断后,系统自动判断总线空闲,不再依赖我手算超时时间,这类问题直接从根上解决。如果坚持用定时器方案,务必按波特率算足T3.5,留20%以上余量。
6. 调试与验证:用Modbus Poll先把从机“遛”起来
代码写完,接线接好,接下来就是联调。我习惯用一套固定流程验证从机功能,效率很高。
### 6.1 先本机回环,再上总线
第一件事不是接主站,而是先把STM32的TXD和RXD短接(或者用USB转TTL),用串口助手发测试帧,看从机的回包。这一步能验证:串口配置对不对、CRC计算对不对、功能码处理逻辑有没有问题。比如手动发一帧:
01 03 00 00 00 02 C4 0B期望收到:
01 03 04 00 0A 00 14 C4 5C如果回包正确,说明核心逻辑通了。这一步建议用“发送HEX”模式,不要用文本模式,避免编解码干扰判断。
### 6.2 Modbus Poll连从站
Modbus Poll是常用的主机仿真软件,新建连接时选Modbus RTU Over Serial,填串口号、波特率、数据格式(我常用8N1)、从站地址,功能码选03保持寄存器,起始地址填0,数量填寄存器池大小。点连接后,如果一切正常,能看到寄存器数值在定时刷新。如果显示“Illegal Data Address”或者“CRC Error”,对照异常码和日志定位:不合法地址就检查寄存器越界判断,CRC错误就往CRC字节序查。
### 6.3 用逻辑分析仪核对帧间隔和收发时序
逻辑分析仪是排查这类通信问题的高效工具。把探头夹在MAX485的RO和DI上,同时抓两路,能清楚看到:
- 主站请求帧发送结束后,是否有足够的T3.5间隔
- 从机收到请求后,响应帧延迟多久发出
- 响应帧期间DE/RE是否始终保持发送状态
- 帧与帧之间有没有多余的杂波
很多“偶尔帧错误”的问题,用肉眼看串口日志很难发现,逻辑分析仪一抓波形就原形毕露。我手里这台几十块钱的逻辑分析仪已经帮我修过不少“玄学”通信问题。
### 6.4 故障快速定位表
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 完全收不到响应 | 地址不匹配/接线错误/方向引脚接死 | 串口助手测回环,逻辑分析仪看波形 |
| 收到响应但CRC错误 | CRC字节序反/发送截断 | 用CRC计算工具核对,检查TC中断切方向 |
| 偶发莫名丢帧 | 终端电阻过多/偏置不足/干扰 | 示波器看A-B波形,调整上下拉阻值 |
| 总线空闲乱码 | 缺少A/B偏置电阻 | 测A-B空闲差分电压,加4.7k~10k上下拉 |
整个项目做下来,我对“STM32从机通过RS485跑Modbus RTU”最大的体会是:真正的难点其实不在STM32代码本身,而在于物理层和时序层的细节。把RS485收发器电路设计扎实、把T3.5帧间隔和收发方向切换处理好,Modbus这套协议就像是给串口通信穿上了一套规整的制服,稳定性和可维护性都会好很多。后面的扩展方向也很明确:把寄存器池和业务逻辑解耦、加入多个功能码支持、甚至移植FreeModbus协议栈统一管理,都是一个套路往深走的路子。
本文还有配套的精品资源,点击获取