☰
STM32C092低成本Modbus RTU从站完整实现:RS-485与CRC校验实战
2026/10/19 5:28:06 网站建设 项目流程

做工业设备改造的时候,经常要把一个非标传感器、执行器或小型控制器挂到已有的PLC网络上。PLC那边大多已经跑的是Modbus RTU协议,只要你这边做一个从站,把寄存器地址规划好,剩下就是串口收发那点事。这次项目选的主控是STM32C092,一颗48MHz的Cortex-M0+,资源不大,但做Modbus从站绰绰有余。整个系统涉及RS-485硬件接口、Modbus RTU协议解析、CRC16校验、功能码应答、收发方向切换,完整从站代码量也就几百行。这篇就把我在项目里踩过的坑和最终落地的方案完整写出来,适合刚接触Modbus、准备用低成本M0+芯片做通信节点的朋友参考。

1. 项目概述与整体方案设计

1.1 为什么是STM32C092:从成本和资源两个维度看

先说选型。Modbus从站本质上是个“通信节点”,对主控的要求并不高:一个UART、一个定时器、足够放协议栈的Flash、几个IO口就够了。STM32C092这类C0系列芯片,定位就是替代传统8位机,但给了你32位ARM内核和完整的外设库支持。48MHz主频跑Modbus RTU,处理一帧报文加CRC校验,CPU占用率低到可以忽略,剩下的时间还能顺手做ADC采样、IO扫描和状态灯控制。

对比几个常见方案:老一代F1系列的芯片现在价格已经不占优势,而且很多型号缺货严重;8位机做Modbus不是不行,但遇到功能码扩展、多寄存器批量读写、异常码处理时,代码写起来比较憋屈,调试工具链也弱。STM32C092的好处是能用CubeMX生成初始化代码,HAL/LL库都有,串口接收中断、定时器这些基础外设开箱即用。成本上,C0系列已经压到了非常接近于传统8位机的价位,同时你还拿到了更高的主频、更多的Flash和SRAM,后面想加功能也有余地。

从项目角度看,这颗芯片还有个隐性优势:它内置的HSI振荡器可以直接跑48MHz,省掉一颗外部晶振。在尺寸敏感的板卡和成本敏感的批量产品上,省一个晶振不仅仅是省几毛钱的事,还少了一个故障点和一段布局空间。工业现场环境复杂,少一颗晶振就少一分受振动影响的风险。如果项目对时钟精度有更高要求,接外部晶振也很简单,CubeMX里选一下就行。

1.2 从站需求拆解:先把寄存器图定下来

做Modbus从站最容易犯的错,是上来就写UART中断、写CRC函数、写功能码解析,结果做到一半发现寄存器地址跟业务对不上,再回头重构。正确顺序是先把寄存器图定下来。寄存器图就是Modbus从站和主站之间的“合同”,主机读哪个地址代表什么,写哪个地址控制什么,全都在这个表里定义清楚了,协议栈实现才有的放矢。

这次项目的寄存器规划如下:

寄存器地址读写属性含义
0x0000只读设备固件版本号,出厂设为0x0102
0x0001读写运行模式,0表示手动,1表示自动
0x0002读写目标设定值,比如温度目标或速度目标
0x0003只读当前实际值,由ADC采样或计数器实时刷新
0x0004只读报警状态,bit0超限,bit1通信超时
0x0005只读写入命令计数,每次收到写命令自增,现场调试神器

这张表一出来,整个代码结构就清晰了:内存里放一个uint16_t holding_regs[6]数组,0x0000到0x0005对应数组下标0到5。协议层只做“把帧里的数据搬进数组”和“把数组里的数据搬回帧里”的工作,业务层只负责定时刷新0x0003和0x0004。分层清楚之后,就算后续要加寄存器,也只需要改寄存器图、数组大小和功能码边界判断,不动协议解析核心。

除了寄存器图,还有一个要提前订好的参数:从站地址。Modbus标准里地址范围是1到247,0是广播地址,只有写指令能广播,广播不需要从站回帧。项目里我用一组GPIO拨码开关读取1到7的地址,这样现场改地址不用烧固件,插拔一下就能换。如果产品量小、不需要现场配置,直接在代码里写死#define SLAVE_ADDR 1也完全可以。

2. Modbus RTU协议实现:帧格式、CRC与功能码

2.1 一帧报文到底长什么样:必须吃透的帧结构与3.5字符间隔

Modbus RTU的帧结构其实很朴素,一帧报文从头到尾就是连续的字节流,靠“静默时间”来切分帧边界。一个完整请求帧由四段组成:从站地址(1字节)、功能码(1字节)、数据区(不定长)、CRC校验(2字节)。数据区里具体装什么,取决于功能码。

帧与帧之间必须保证至少3.5个字符时间的静默间隔。这个时间怎么算?一个字符在8N1格式下是11个位(起始位1 + 数据位8 + 停止位1),一个位的时长是1/波特率,所以3.5字符时间就是11 × 3.5 × (1/波特率)。9600波特率下大约是4ms,115200波特率下大约是334微秒。主机发送时如果字符间隙太大,从站就会判定上一帧结束、把碎片当成新帧开始,轻则CRC报错,重则功能码解析错乱。所以主机侧的发送间隔和从站侧的帧超时判定,必须遵守同一套时间标准。

接收方向,从站靠什么判断一帧结束?简单粗暴的做法是开个定时器,收到字节后刷新定时器,超过3.5字符时间没再收到新字节,就认为一帧收完了。用固定超时也可以,但要注意:如果你用固定的5ms判断帧结束,在115200波特率下主机如果紧接着发下一帧,5ms的静默已经被一帧数据隔断了,不会误判;真正要小心的是高波特率下主机连续发送时每个字节间隔本来就很短,3.5字符判定用固定5ms反而太小,所以比较好的做法是动态计算,波特率变了超时也跟着变。

2.2 CRC16-Modbus校验:按位算法与查表法怎么选

CRC16-Modbus是Modbus RTU的“看门人”。计算模型是:多项式0x8005,初始值0xFFFF,输入输出都做反射,结果低字节在前发送。这里最绕的是“反射”和“低字节在前”这两件事。反射的意思是从最低位开始运算;结果发送顺序是先发CRC低字节、再发高字节,这个顺序写错的话,从站会丢掉的报文远比你想象的多。

按位算法最直观,代码量极小,适合理解原理:

uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc = crc >> 1; } } return crc; }

上面这个0xA001就是多项式0x8005反射后的值。如果波特率不高、报文不长,按位算法完全够用,一帧最多255字节,算下来也就几百个周期。查表法更快,适合高频轮询或者计算量大、又要保持低CPU占用的场景,代价是256个条目的表占据Flash空间。在STM32C092上,按位算法实测已经能轻松应对每秒上百次的轮询,我个人建议新手先用按位算法,跑通后再考虑要不要优化成查表法。

有个特别容易踩的坑:CRC计算的范围是“从站地址到数据区结束”,不包括CRC本身那两字节。接收端算完CRC后,要把计算结果和帧尾的两个字节比较,而不是把整帧包括CRC一起再算一遍、期望结果为0。Modbus RTU不像其他一些协议那样采用“余数为0”的校验方式,至少不是直观意义上的那种,按“算完再比较”来写,代码逻辑最清晰。

2.3 功能码解析与异常码返回:03、06、10的边界检查

Modbus功能码很多,但实际项目里最常用的是三个保持寄存器功能码:03读保持寄存器、06写单个寄存器、16(0x10)写多个寄存器。如果设备有开关量输入输出,还会用到01读线圈、05写单线圈、15写多线圈,这次项目暂时没做开关量,先聚焦保持寄存器。

03读保持寄存器的请求格式是:从站地址、0x03、起始地址高字节、起始地址低字节、寄存器数量高字节、数量低字节、CRC。从站正常响应是:从站地址、0x03、字节数、数据(每寄存器占2字节,高字节在前)、CRC。这里最容易犯的错有两个:一是寄存器数量为0或者大于125,Modbus规定单次最多读125个保持寄存器,超过就要回异常;二是起始地址加数量越界,如果从站只有6个寄存器,主机却要从地址0x0005读5个寄存器,那就越界了。

06写单个寄存器的请求就是:从站地址、0x06、寄存器地址、数据值、CRC,正常响应直接原样回显请求帧。16写多个寄存器稍复杂一点,请求里多了字节数和后续数据,响应则是回显地址、功能码、起始地址、数量。不管哪一种,都必须做好边界检查。

边界检查失败时,从站要回异常帧,格式是:从站地址、功能码最高位置1(即+0x80)、异常码、CRC。三个常用异常码分别是:0x01非法功能、0x02非法数据地址、0x03非法数据值。如果收到不支持的功能码,回0x01;寄存器地址越界,回0x02;寄存器数量为0或超过上限,回0x03。异常响应不是可选项,主机端就是靠它来判断从站状态和排查问题的,做成静默丢弃反而是坑。

3. STM32C092工程落地:串口收帧与从站应答实现

3.1 CubeMX初始化:时钟、串口引脚与中断的配置细节

STM32C092用CubeMX初始化非常快。时钟直接选HSI 48MHz,省掉外部晶振。串口我选了USART2,配置成异步模式、115200波特率、8位数据、无校验、1位停止位,也就是8N1。Modbus RTU标准允许无校验,也允许偶校验,实际工业现场绝大多数PLC默认配置是8N1,保持兼容就用8N1。

串口引脚要注意看CubeMX的Pinout视图。我用的封装,USART2的TX和RX默认映射在某个固定引脚上,实际以你选的封装和CubeMX自动分配的引脚为准。方向控制引脚DE/RE我单独留了一个普通GPIO,配置成推挽输出,初始电平为低。串口接收中断必须打开,建议中断优先级调高一点,因为接收是实时性敏感的活动。发送方向我没开中断,因为从站一次只回一帧短报文,轮询判断TXE和TC标志就够用了,中断反而增加代码复杂度。

还有一个小细节:CubeMX生成的默认工程,GPIO初始化里把没用到的引脚设成了模拟输入,这对降低功耗是好事,但在调试时有时候会困扰人。排查硬件问题时,先检查关键引脚是否被意外配置成了模拟模式。

3.2 串口收帧方案:我是怎么用“字节中断+空闲超时”攒出一帧

从站收帧方案我对比过三种:DMA加IDLE中断、状态机逐字节解析、字节中断加空闲超时。DMA加IDLE中断看起来最高级,但Modbus的帧长度不固定,处理DMA复位和超时判断反而麻烦;状态机逐字节解析省内存但代码耦合度高,不好维护。最后我选了“字节中断加空闲超时”,简单、可靠、代码直观。

实现思路是:每个接收字节进中断,往环形缓冲区里塞数据,同时记录最后一个字节到达的系统时钟tick。主循环里判断“距最后字节到达的时间”是否超过超时阈值,超过就认为一帧完了,把缓冲区里的数据交给协议解析函数。这样做的核心优势是:无论主机发送多快、帧长多少,只要有一定静默期,就能准确切出完整帧。

#define RX_BUF_SIZE 64 #define FRAME_TIMEOUT_MS 2 volatile uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_wr_idx = 0; volatile uint32_t last_rx_tick = 0; void USART2_IRQHandler(void) { if (LL_USART_IsActiveFlag_RXNE(USART2)) { uint8_t byte = (uint8_t)LL_USART_ReceiveData8(USART2); if (rx_wr_idx >= RX_BUF_SIZE) rx_wr_idx = 0; /* 溢出保护,丢弃当前帧碎片 */ rx_buf[rx_wr_idx++] = byte; last_rx_tick = get_systick(); } }

主循环里这样判断:

if (rx_wr_idx > 0 && (now - last_rx_tick) >= FRAME_TIMEOUT_MS) { modbus_process_frame((uint8_t *)rx_buf, rx_wr_idx); rx_wr_idx = 0; }

有人会问2ms够不够?按3.5字符时间算,115200波特率下是334微秒,2ms足够覆盖;9600波特率下3.5字符是4ms,2ms会把一帧拦腰截断。所以如果波特率是9600,FRAME_TIMEOUT_MS要设成5。更稳妥的做法是根据波特率常量动态计算,FRAME_TIMEOUT_MS = 4 + (3200 / baud)这种经验公式也可以,但最简单还是不同波特率直接配不同的宏定义值。

3.3 从站主循环与应答代码骨架

从站代码的核心流程是:主循环检查帧是否收完,收完了交给Modbus解析函数,解析函数校验地址和CRC,然后按功能码分发处理,最后通过串口回帧。整个过程都不建议放在中断里,因为CRC计算和回帧发送需要时间,中断里做会拖累其他中断响应。

void modbus_process_frame(uint8_t *frame, uint16_t len) { if (len < 5) return; uint16_t cal_crc = crc16_modbus(frame, len - 2); uint16_t rx_crc = frame[len - 2] | (frame[len - 1] << 8); if (cal_crc != rx_crc) return; uint8_t addr = frame[0]; uint8_t func = frame[1]; if (addr != slave_addr && addr != 0x00) return; /* 非发给自己的帧,丢弃 */ switch (func) { case 0x03: modbus_read_regs(frame, len); break; case 0x06: modbus_write_single(frame, len); break; case 0x10: modbus_write_multi(frame, len); break; default: modbus_exception(func, 0x01); break; } }

刚才说过广播地址0需要特殊处理:只有写功能码允许广播,而且广播时从站不回复。所以addr == 0x00的帧,即使校验通过了,也只需要执行写操作,绝不能调用发送函数回帧,否则总线上就会多个从站同时抢总线。这个细节我最初忽略了,联调时两台从站一起上电,主机一广播,整个总线直接乱掉,排查了很久才定位到。

应答代码我只贴03的示例,其余类似:

static void modbus_read_regs(uint8_t *frame, uint16_t len) { uint16_t start_addr = (frame[2] << 8) | frame[3]; uint16_t quantity = (frame[4] << 8) | frame[5]; uint8_t resp[128]; if (quantity == 0 || quantity > 125) { modbus_exception(0x03, 0x03); return; } if (start_addr + quantity > REG_TOTAL) { modbus_exception(0x03, 0x02); return; } resp[0] = slave_addr; resp[1] = 0x03; resp[2] = (uint8_t)(quantity * 2); for (uint16_t i = 0; i < quantity; i++) { resp[3 + i * 2] = (uint8_t)(holding_regs[start_addr + i] >> 8); resp[4 + i * 2] = (uint8_t)(holding_regs[start_addr + i] & 0xFF); } uint16_t crc = crc16_modbus(resp, 3 + quantity * 2); resp[3 + quantity * 2] = (uint8_t)(crc & 0xFF); resp[4 + quantity * 2] = (uint8_t)(crc >> 8); uart_send_bytes(resp, 5 + quantity * 2); }

4. RS-485方向控制与硬件抗干扰处理

4.1 DE/RE切换时序:最容易被忽略的“先拉高、再发送、发完释放”

RS-485是半双工总线,同一时刻只能有一方在驱动。ST Agent状态:从站平时必须处于接收状态,也就是DE/RE引脚拉低;要回帧时,先把DE拉高进入发送状态,发完最后一个字节,等发送移位寄存器完全清空(TC标志位置位),再把DE拉低回到接收状态。方向切换的时机如果不对,就会出现两种故障:切换早了,最后一个字节的后半截还在移位寄存器里,总线上已经被释放了,对端收到的帧缺尾巴,CRC基本必错;切换晚了,从站回帧完毕还继续占着总线,主站下一轮请求就发不出来,或者被自己“吃掉”。

发送函数看起来是这样:

void uart_send_bytes(uint8_t *buf, uint16_t len) { LL_GPIO_SetOutputPin(DE_PORT, DE_PIN); /* 进入发送方向 */ for (uint16_t i = 0; i < len; i++) { while (!LL_USART_IsActiveFlag_TXE(USART2)); LL_USART_TransmitData8(USART2, buf[i]); } while (!LL_USART_IsActiveFlag_TC(USART2)); /* 等全部移出 */ LL_GPIO_ResetOutputPin(DE_PORT, DE_PIN); /* 释放总线 */ }

注意,判断“发送完成”要用TC而不是TXE。TXE表示数据从CPU搬到了数据寄存器,但还没送进移位寄存器发送完;TC才是真正的发送完成标志。很多人第一次写都会卡在这里,觉得明明发了数据,总线上却什么也没有,其实就是用了TXE做判断,DE提前释放了。

还有一点要提:方向控制引脚上电时必须默认拉低,保证从站上电瞬间处于接收状态。如果上电默认拉高了,从站会主动驱动A/B线,总线上可能跟真正的主站冲突,轻则通信不稳定,重则整条总线被拖死。即便MCU复位瞬间GPIO电平不确定,外接一个下拉电阻把DE固定到低电平,是个成本极低的保险措施。

4.2 终端电阻、A/B极性与偏置电阻的接法

RS-485硬件层有几个常年出问题的点,布线时就要注意。第一,A和B线必须用双绞线,差分管脚一进一出,尽量减少回路面积。第二,总线两端要接120欧终端电阻,用以匹配特性阻抗、减少信号反射。如果现场就两三台设备、距离不超过几十米,只在主线远端接一个120欧也常见,但规范做法还是两端都接。第三,A/B极性不能接反。接反了从站收不到任何有效数据,因为差分信号完全反相,接收器判断逻辑电平全部反过来。联调时如果死活不通,先把A/B对调试一下,我调试时这个操作排在波特率和地址检查之后、CRC检查之前。

还有一个容易被忽略的就是偏置电阻。总线处于空闲状态时,如果A/B之间没有稳定的偏置电压,接收器会输出不确定电平,从站可能误收一堆乱码。规范的电路一般会在总线上加偏置电阻:A端上拉到VCC,B端下拉到GND,典型值560欧到680欧,配合终端电阻能保证空闲时A-B差分电压在200毫伏以上。很多廉价的USB转485模块内部已经做了偏置,但如果你自己画板子,还是建议加上。

接地问题也要说一句。RS-485本质上是差分传输,理论上对地电位不敏感,但收发器芯片还是需要共同的地参考。距离短、设备都接同一个电源时问题不大;距离稍长或者现场电源各自独立,就容易出现共模电压过高烧芯片的情况。要么保证底线可靠共地,要么直接上隔离型RS-485收发器。项目里如果整机成本允许,隔离方案省心得多。

5. 联调实测与常见问题排查

5.1 用PC主机工具做一次完整读写验证

从站固件烧进去之后,联调第一步不是接PLC,而是用PC加一个USB转485模块,配合串口调试工具或专门的Modbus调试工具验证。把USB转485的A接从站A、B接从站B,注意共地,PC工具里配置成RTU模式、115200、8N1、从站地址1。

先做读验证。读设备版本,手动发送报文:01 03 00 00 00 01 CRC,右侧填好CRC。正常情况下从站会回一帧01 03 02 01 02 CRC,前两个数据字节是0x01和0x02,对应寄存器里的0x0102版本号。读到这一步,说明串口、CRC、从站地址、03功能码整条链路已经通了。

接着验证06写单个寄存器。写目标设定值寄存器0x0002,用主机工具发送01 06 00 02 00 64 CRC,从站正常应回显完全相同的帧。然后再读一次0x0002,确认值变成了100(0x0064)。最后验证10写多个寄存器,一次写0x0001和0x0002两个寄存器,观察响应是01 10 00 01 00 02 CRC格式。这三个功能码全部通过,Modbus从站就算基本可用了。

如果这个过程中CRC总报错,我建议先在PC工具里手动把报文和工具自动计算的CRC比较一遍,确认工具的计算方式是不是Modbus标准。很多国产调试工具默认计算其他变种CRC,坑过不少人。

5.2 高频问题速查表

把我在项目里遇到的和身边同事遇到的高频问题整理成一张表,排查顺序基本就是表里的顺序:

现象可能原因排查和解决办法
从站完全无响应A/B接反对调A/B线再看
从站完全无响应从站地址不匹配核对帧里的地址和从站配置
从站完全无响应DE/RE方向控制错误用示波器看DE引脚电平变化
从站完全无响应波特率不一致逐个尝试9600/19200/115200
收到乱码或CRC错收发方向切换过早确认发送完成用TC标志
收到乱码或CRC错帧超时时间设定不匹配波特率9600波特率下用5ms超时
偶发丢帧总线缺少偏置电阻空闲时量A-B电压应在200mV以上
偶发丢帧无共地或地环路用隔离模块或可靠共地
主机一广播就乱多个从站地址冲突或广播回复检查每个从站地址唯一性
高波特率下距离缩短终端电阻缺失或过多单段总线两端各接一个120欧

5.3 现场联调时几个值得记住的细节

联调快结束的时候,我养成了一个习惯:把寄存器0x0005的写入计数寄存器认真用起来。每次主站下发写命令,从站就把这个寄存器加一,写成功后主站顺手读回来。这个寄存器在排查“主站明明发了写指令,设备却没动作”这种问题时有奇效,因为它能区分出“报文没到从站”“报文到了但被CRC拒了”“报文处理了但业务没执行”三种情况。这个思路后来也用在别的项目上,成了我做通信类设备的标配。

另外一个经验是关于响应时间的。Modbus主机一般有响应超时设置,默认可能几百毫秒。从站如果处理快,一次请求的响应时间在1毫秒到几毫秒量级。如果你发现从站响应时间飘到几十毫秒甚至上百毫秒,多半是主循环里有什么阻塞操作,比如EEPROM写入、长延时、死等某个外设标志位。Modbus从站追求的是“稳定且快”,主循环里所有耗时操作都要拆成状态机或放到定时任务里去。

我个人这次做下来最大的体会是:Modbus从站本身不复杂,复杂的是把“协议正确”和“现场可靠”这两件事同时做好。CRC和功能码解析只是逻辑正确性的问题,花一个下午就能写完;真正花时间的是控制好DE/RE方向切换、处理好总线偏置、把主循环的实时性维护好。这些层面的可靠性,才是从站能挂在工业总线上一跑几年不出问题的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询