☰
STM32 Modbus RTU从站工程详解:状态机、CRC校验与移植避坑指南
2026/10/14 17:43:53 网站建设 项目流程

简介:面向STM32-F0/F1/F2开发者的Modbus通信测试项目,内置主从站协议实现,并集成串口、定时器、DMA、LED、LCD等外设驱动,适合学习工业串行通信与嵌入式外设协作的开发者。压缩包共70个文件、约727KB,包含C/H源码、Keil工程文件(uvproj/uvopt)、编译中间文件(o/crf/d)以及可直接烧录的hex/axf产物;头文件声明接口,C文件实现协议与外设逻辑,中间文件可辅助分析依赖关系,map/hex/axf分别用于查看内存布局、下载固件和调试程序,便于在开发板上快速复现Modbus通信流程。

已有163人浏览学习。工程按用户、硬件等目录组织,涉及Modbus协议栈、串口收发、DMA传输、定时器超时控制、LCD状态显示等功能,可帮助理清RTU帧格式、寄存器读写、波特率校验、异常码处理等关键点;同时LED/LCD反馈降低了调试门槛,适合边读源码边验证。对想深入STM32串口通信与工业总线应用的开发者,这是一份既能巩固基础、又能直接参考实现的完整样例,无论初学协议还是调试实际设备,都有较大帮助。

1. 把 Modbus 测试工程烧进 STM32:先解决“上位机为什么收不到应答”

如果你在单片机/嵌入式 STM32-F0/F1/F2 专区翻到这份“电子-modbusTest.zip”,大概率不是来学 Modbus 协议定义的,而是手头有一块板子、一个串口助手或 Modbus Poll,正等着把从站应答调通。这份资源是一个可以直接烧进 F1 或 F0 系列芯片的 Modbus RTU 从站测试工程,里面带好了串口初始化、协议解析、CRC 校验和寄存器映射的完整代码,省掉从零搭框架的功夫。适合刚接触 Modbus 的开发者先跑通流程,也适合熟手拿来对比自己的状态机设计和超时处理。它解决的核心问题很具体:让一块 STM32 作为 Modbus 从站,被上位机正确读写,而不是卡在“发了报文没反应”的玄学闷局里。

2. 先对齐协议和工程结构:从 RTU 帧格式到 zip 里的文件布局

2.1 Modbus RTU 的帧格式与从站应答规则:为什么状态机比中断标志更可靠

Modbus RTU 是串口上最常见的工业总线协议,一帧报文的结构是:从站地址(1 字节)、功能码(1 字节)、数据段(不定长)、CRC16 校验(2 字节)。主站发请求,从站必须应答,要么返回正常应答、要么返回异常应答。帧与帧之间要求有至少 3.5 个字符时间的静默间隔,这个间隔是协议层判断一帧是否结束的关键。

很多人第一次写从站接收时,习惯用“串口接收中断里攒字节,等接收空闲中断再去解析”。这个思路在 STM32 标准外设库里没问题,但有个前提:空闲中断和字节间隔要匹配。如果用 DMA 接收的空闲中断,帧间隔刚好是 3.5T,但 USART 的空闲检测是基于 1 位空闲时间而不是 3.5 个字符,所以直接用空闲中断会把一帧拆成多帧。更稳的做法是用一个接收状态机,配合定时器测量字符间隔,或者用定时器做帧超时。这份工程里走的就是状态机路线:每个字节进中断,状态机根据当前状态决定是继续收还是判定帧结束。

状态机的价值在于,它把“什么时候该收、什么时候该处理”变成了显式的逻辑,而不是依赖中断标志的时序。比如主站发的是 8 个字节的请求,但串口分包到达,第一次中断进来 5 个字节,第二次进来 3 个字节。如果只靠空闲中断,第一次就会触发解析,结果帧不完整,CRC 必然失败。状态机配合超时定时器,只有超过 3.5T 没来新字节才认为帧结束,这样分包到达也能合并成完整帧。

2.2 解压后的工程目录:哪个文件管配置、哪个文件管协议、哪个文件是测试入口

这份 zip 解压后是一个标准 Keil 工程,没有用 HAL 库,而是标准外设库,这样在 F0/F1/F2 上都可以直接编译,依赖很少。解压后的目录大致如下:

电子-modbusTest/ ├── USER/ │ ├── main.c // 入口,初始化外设,启动主循环 │ ├── modbus_slave.c // Modbus 协议解析、功能码处理、异常应答 │ ├── modbus_slave.h // 寄存器映射宏、状态码定义 │ ├── stm32f1xx_it.c // 中断服务函数,串口接收、定时器超时 │ └── system_stm32f1xx.c // 时钟初始化 ├── HARDWARE/ │ ├── uart.c // 串口初始化,接收中断、发送 │ ├── tim.c // 定时器配置,用于帧间隔/超时 │ └── gpio.c // RS485 方向控制引脚配置 ├── LIB/ // 标准外设库,不用改 └── Project/ └── modbusTest.uvprojx // Keil 工程文件

打开 main.c 能看到主循环里做了三件事:查询协议状态机的解析结果、处理需要定时发送的报文、喂软看门狗。uart.c 里是串口底层,modbus_slave.c 是整个工程的核心。它对外暴露了一个接口ModbusSlave_Poll(),主循环周期调用,把接收缓冲区和寄存器数组传进去,解析并构造应答。

对于初次接触的人,我建议先别改协议代码,先把 udev 里对应的串口号改成你的实际串口,然后打开 uart.c 看USARTx_IRQHandler。这个中断函数里只有三个动作:读数据到缓冲区、启动帧超时定时器、把接收指针往后移。解析逻辑完全不在中断里,而是在主循环的 Poll 函数中。这样做的好处是把中断处理时间压到最短,避免高波特率下中断嵌套导致丢字节。

3. 移植到你的板子:串口参数、定时器帧超时与寄存器映射

3.1 串口参数与 F0/F1 外设差异:从 USART 到 GPIO 复用

Modbus RTU 最常用的是 9600 波特率、8 位数据位、无校验、1 位停止位,即 8N1。这份工程默认也是 8N1,波特率在 uart.c 顶部用宏定义:

#define MODBUS_BAUDRATE 9600

初始化过程里,先开外设时钟,再配置 GPIO 复用,然后是 USART 本身。标准外设库的写法是:

void Modbus_UART_Init(void) { // 使能 USART1 和 GPIOA 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // TX 推挽复用输出,RX 上拉输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = MODBUS_BAUDRATE; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); }

这里有个 F0 和 F1 的差异容易踩:F1 的串口 TX/RX 复用是 GPIO_Mode_AF_PP,F0 的 GPIO 初始化方式完全不同,需要先开启 GPIO 时钟,再调用GPIO_PinAFConfig指定复用功能。如果你从 F1 工程直接抄到 F030 上,这一行不改,串口完全是死的效果——初始化不报错,但引脚没有连接到 USART 外设上。

参数说明:波特率决定了 3.5T 时间。9600 波特率下,1 个字符(11 位,含起始位和停止位)约 1.146ms,3.5T 约 4.01ms。如果换成 19200,这个时间减半,定时器分频也要相应调整。工程里默认把定时器设计成可配置的,但别忽略波特率更换后要重新计算超时值。

3.2 寄存器映射表的设计:把线圈、离散量、保持寄存器对应到数组

Modbus 从站说到底是把一组内存暴露给主站读写。这份工程里定义了几个数组来映射四种对象:线圈(0x 开头)、离散输入(1x)、输入寄存器(3x)、保持寄存器(4x)。看 modbus_slave.h:

#define MODBUS_NB_COILS 64 // 线圈数量 #define MODBUS_NB_DISCRETE_IN 64 // 离散输入数量 #define MODBUS_NB_INPUT_REGS 64 // 输入寄存器数量 #define MODBUS_NB_HOLDING_REGS 128 // 保持寄存器数量 extern uint8_t coil_status[MODBUS_NB_COILS]; extern uint8_t discrete_input[MODBUS_NB_DISCRETE_IN]; extern uint16_t input_regs[MODBUS_NB_INPUT_REGS]; extern uint16_t holding_regs[MODBUS_NB_HOLDING_REGS];

长度按项目需求改即可。如果你的应用只需要 16 个保持寄存器,把MODBUS_NB_HOLDING_REGS改成 16,整个工程的缓冲区、越界检查会跟着变化。注意保持寄存器是 16 位无符号整数,上位机写入的值如果是负数需要用补码表示;工程里没有对写入值做类型转换,这一点在对接浮点数时要自己处理。

寄存器映射的逻辑在modbus_ReadHoldingRegisters里:主站发一个起始地址加数量,从站要校验地址越界。工程里统一用addr >= start && addr + num <= start + count这样的判断方式,防止主站请求超出数组边界。这个判断是 Modbus 从站最容易写错的地方——很多人只判断首地址,不判断首地址加数量,结果主站连续读 32 个寄存器时,后一半数据是缓冲区后面的垃圾值。

3.3 地址分配与功能码处理:从站地址、广播帧和异常应答的边界

从站地址在 modbus_slave.h 里用MODBUS_SLAVE_ADDR定义,默认是 1。接收帧的地址字段如果不等于本机地址,直接丢弃;如果等于 0,则是广播帧,从站需要执行但没有应答。这个广播逻辑在工作中很容易被忽略,但 Modbus 规范就是这么定义的。

处理功能码的方式是分支函数:

uint8_t ModbusSlave_ProcessFrame(uint8_t *frame, uint8_t len) { uint8_t addr = frame[0]; uint8_t func = frame[1]; if (addr != MODBUS_SLAVE_ADDR && addr != 0x00) { return 0; // 不是发给本机,也不广播,直接忽略 } switch (func) { case 0x01: return Modbus_ReadCoilStatus(frame, len); case 0x02: return Modbus_ReadDiscreteInputs(frame, len); case 0x03: return Modbus_ReadHoldingRegisters(frame, len); case 0x04: return Modbus_ReadInputRegisters(frame, len); case 0x05: return Modbus_WriteSingleCoil(frame, len); case 0x06: return Modbus_WriteSingleRegister(frame, len); case 0x0F: return Modbus_WriteMultipleCoils(frame, len); case 0x10: return Modbus_WriteMultipleRegisters(frame, len); default: return Modbus_Exception(addr, func, 0x01); } }

每种功能码的调用都会先检查长度。比如 0x01 功能码请求至少 5 个字节:地址、功能码、起始地址高、起始地址低、数量高、数量低,实际上 6 字节。如果长度不够,直接构造异常应答,异常码 0x03(非法数据值)而不是硬解析,避免收到不完整帧时访问越界。这里的每个返回函数最终都会把应答帧填到发送缓冲区,并设置发送标志。

4. 关键代码拆解:接收状态机、CRC16 校验与超时判断

4.1 接收状态机:3.5T 字符间隔怎么用定时器实现

Modbus RTU 的帧结束条件是静默超过 3.5T。工程里用一个基本定时器(TIM6 或 TIM7)来做这个间隔测量。每次收到一个字节,就把定时器计数值清零并重新启动;如果定时器溢出,说明超过 3.5T 没有新字节,就把当前缓冲区长度交给解析函数。

串口接收中断的代码大致是这样:

volatile uint8_t recv_buf[MODBUS_RX_BUF_MAX]; volatile uint16_t recv_len = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); if (recv_len < MODBUS_RX_BUF_MAX) { recv_buf[recv_len] = data; recv_len++; } // 复位帧超时定时器 TIM_SetCounter(MODBUS_TIM, 0); TIM_Cmd(MODBUS_TIM, ENABLE); } }

定时器溢出中断里做解析:

void TIM6_IRQHandler(void) { if (TIM_GetITStatus(MODBUS_TIM, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(MODBUS_TIM, TIM_IT_Update); TIM_Cmd(MODBUS_TIM, DISABLE); if (recv_len > 0) { ModbusSlave_ProcessFrame(recv_buf, recv_len); recv_len = 0; } } }

这里的关键是换算定时器溢出时间。9600 波特率下 3.5T 大约是 4ms,使用 72MHz 时钟,定时器预分频设为 7200 后计数频率为 10kHz,溢出时间就是(Period + 1) * 0.1ms。工程里把 Period 设为 39,溢出时间为 4ms。如果改波特率到 19200,这个值要跟着减半,不然主站已经发下一帧了,从站还在等上一帧超时,导致连续丢帧。

这种实现的另一个好处是:无论是一帧完整到达,还是半帧后停顿,都能被定时器兜住。如果主站发的请求帧被拆成 A 和 B 两段,超过 3.5T 后解析器会先把 A 段当作独立帧尝试处理,这时长度和 CRC 大概率不对,会被判为非法帧丢弃,B 段到达后同样无主。所以实际使用中建议上位机的帧间隔尽量短,或者干脆用单次请求、等待应答的模式。

4.2 CRC16-Modbus 查表法实现与校验陷阱

Modbus RTU 的 CRC 校验是 CRC16-IBM 多项式 0x8005,初始值 0xFFFF,结果低字节在前。工程里用查表法,速度最快。表是 256 项的常量数组,代码里已经生成好,直接用:

uint16_t Modbus_CRC16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *buf++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }

0xA001是反转后的多项式,等价于常规的 0x8005 右移算法。发送时要先发 CRC 的低字节,再发高字节。接收校验时,要么把收到的整帧(含 CRC)重新算一遍,结果为 0 则正确;要么把数据段算出来 CRC,再与收到的两个字节逐字节比较。工程里用的是后者,因为这样你能直接定位是数据段错了还是 CRC 接收错了。

常见的坑是只校验数据位,把收到的 CRC 高位、低位倒过来比。主站发来的 CRC 字节序是低位在前,比如计算出的 CRC 是 0x1234,主站发的第一个 CRC 字节是 0x34,第二个是 0x12。如果你用if (crc == ((crc_hi << 8) | crc_lo))去比,永远失败。正确做法是:

uint16_t crc_calc = Modbus_CRC16(frame, len - 2); uint8_t crc_lo = frame[len - 2]; uint8_t crc_hi = frame[len - 1]; if ((uint16_t)((crc_hi << 8) | crc_lo) == crc_calc) { // 校验通过 }

如果想看到位级别的差别,可以在 CRC 计算里插入打印,把收到的两个字节和计算值分别以十六进制打印出来,一眼就能看出字节序问题。这算是 Modbus 调试中最快定位 CRC 问题的办法。

4.3 异常应答与错误码:01/02/03/04 什么时候返回

Modbus 协议定义了几个标准异常码,从站收到格式正确但语义不对的请求时,要返回一个异常帧,结构是:从站地址、功能码 | 0x80、异常码、CRC。这份工程里的异常处理函数是:

uint8_t Modbus_Exception(uint8_t addr, uint8_t func, uint8_t exception_code) { uint8_t respond[5]; respond[0] = addr; respond[1] = func | 0x80; respond[2] = exception_code; uint16_t crc = Modbus_CRC16(respond, 3); respond[3] = crc & 0xFF; respond[4] = (crc >> 8) & 0xFF; // 发送 respond }

异常码 01(非法功能)是最常用的,用来响应不支持的 Modbus 功能码。02(非法数据地址)用于请求的寄存器地址越界。03(非法数据值)用于请求中写入的值超过量程。04(从站设备故障)是最后兜底的,内部错误时返回。

需要注意的是,异常应答本身也带 CRC,而且从站必须正确发送。有些新手在解析异常时只关注功能码,忘记校验整个异常帧的 CRC,结果上位机反复报错。这个在调试阶段最容易伪装成“从站没应答”,其实是应答帧的 CRC 算错了,被主站丢弃。

工程里每个功能码函数在返回异常时,都走同一个Modbus_Exception通道,不会出现不同函数对同一个错误码却拼出不同帧结构的情况。这个设计很朴素,但确实能减少低级错误。

5. 避坑排查:从串口乱码到连接不上,五个最常见的 Modbus 测试血泪问题

5.1 现象:上位机收不到任何应答,串口助手发出请求后一片空白

原因大概率是串口引脚配置不对,尤其是从 F1 移植到 F0 时,GPIO 复用模式没有改。F1 的 USART1 TX 是 PA9,配置成 AF_PP 就能用;F0 需要先调GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_1),否则 PA9 就是普通引脚,数据根本送不出去。解决方法是打开 GPIO 复用功能头文件,确认该芯片的 AF 映射表。另一个常见原因是 RS485 芯片的收发方向控制引脚没有初始化,如果板子上有 RS485,必须保证发送前把 DE 拉高,接收时拉低,否则总线一直被占用。

5.2 现象:偶发应答错误,上位机显示 CRC 校验失败,重置一次又好了

原因是中断优先级配置不当,串口接收中断和定时器中断互相抢占,导致定时器超时中断把接收缓冲区清空了,或者接收中断被打断丢失字节。尤其是 F1 的 NVIC 优先级分组是抢占优先级和子优先级共用 4 位,如果串口中断被其他外设中断抢占,刚好丢的是帧中间的数据,CRC 自然错。解决方法是把串口中断优先级设成最高(抢占优先级为 0),定时器中断设成比它低一级(比如抢占优先级为 1),确保接收中断只要触发就能立刻读走数据。同时检查中断服务函数里有没有做耗时操作,比如在串口中断里调printf,这种写法会直接废掉高波特率接收。

5.3 现象:用 USB 转串口调试正常,换成 RS485 就完全不通

原因是 RS485 的半双工切换时机不对。发送完最后一个字节后,不能立刻把 DE 拉低,因为串口移位寄存器可能还没发完。正确做法是在发送完成中断中延时一段时间(常见为 1 个字节时间的 2 倍,或者等 USART_FLAG_TC 置位后再拉低 DE)。这份工程里给了方向控制引脚的示例,但没说延时,实际使用建议在发送函数末尾加一个while (!USART_GetFlagStatus(USART1, USART_FLAG_TC));之后再拉低 DE。如果省略这一步,最后一个字节会被截断,上位机看到的帧长度少一个字节或 CRC 错。

5.4 现象:Modbus Poll 能读,但写入保持寄存器后值会变,且只在一段地址上生效

原因是寄存器数组的大小没跟上地址范围,或者请求的数量超过了实际数组边界。比如你定义了MODBUS_NB_HOLDING_REGS 128,但主站一次写 130 个寄存器,工程里的判断应该返回异常码 02。如果还是能写,说明判断逻辑写成了只检查起始地址,没有检查start + num。解决办法是把所有地址越界判断统一为区间判断,并在写操作函数里先计算请求数量,超出数组长度直接返回 0x02。调试时可以在写函数入口加一个断点,看调用参数里的num是不是超出预期。

5.5 现象:上电后第一次请求正常,第二次就超时,之后又恢复

原因是缓冲区没有在解析完成后清空,或者上一次应答还没发完就接收新请求。Modbus 从站必须串行处理:请求来了,解析,应答,发完,再进入空闲接收。如果上电后第一次请求恰好和工程初始化错开,之后每次处理完没有把recv_len归零,第二次请求的长度会变成之前累计的长度加新长度,CRC 必然失败。解决方法是检查定时器中断里的recv_len = 0;这一行是否在解析函数调用前后都执行。解析函数内部也要在构造应答后把发送完成标志清掉,避免USART_SendData在缓冲区未空时重复调用。

6. 进阶验证:用 Modbus Poll 做一致性测试和寄存器回环技巧

当从站能通上电、能被 Modbus Poll 读到一个保持寄存器时,别急着去联调业务逻辑,先花十几分钟做一轮协议一致性测试。我一般会先把保持寄存器映射里几个固定地址初始化为已知值,比如holding_regs[0] = 0xAAAA;,然后在上位机里读 0 号寄存器,看返回的十六进制是不是 AAAA。这一步能排除字节序问题。

接着写一个回环自检:在holding_regs[1]里写 0x1234,然后立刻读holding_regs[1],比较写入值和读取值。如果值变了,说明校验或者地址解析可能有歧义,比如地址偏移差了 1。很多 Modbus 主站在地址上是从 0 开始编号,而有的组态软件是从 1 开始,这会导致读出的数据一直错位。解决办法是在寄存器映射上做统一约定:物理地址数组下标 0 对应协议地址 40001,偏移量在代码里固定住,不跟上位机的显示编号摆动。

每完成一项验证,就用波特率为 9600、数据位 8、停止位 1 的固定参数跑一轮连续读测试,建议连续读 100 次,每次间隔 50ms 以上。一旦出现 CRC 错,立刻停下来查看中断代码里是否丢失了字节。这类连续性测试最容易暴露中断优先级和帧时序问题。如果连续读 100 次全部通过,基本可以认定底层收发可靠。

我自己的习惯是:每次拿到一个 Modbus 资源或工程,第一件事不是去看代码结构,而是先看它的帧超时定时器配置值。把波特率换算成微秒后,再对照 3.5T 的时间,如果偏差超过 20%,立刻改掉。这条经验让我少走了很多弯路。希望这份工程的拆解能帮你少花一个下午在“明明什么都是对的,就是不通”的排查上,希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询