☰
STM32F407手写Modbus RTU从机协议:串口DMA+空闲中断实现详解
2026/10/4 6:56:31 网站建设 项目流程

前阵子做了一个用STM32F407和触摸屏走Modbus通信的小项目,需求其实不复杂:设备端用F407采集几个模拟量,触摸屏当上位机,通过RS485总线实时读走数据,偶尔还要往下写几个控制参数。之前这类项目都是直接套现成的协议栈,顶多改改寄存器地址就上,但这次因为要跑在裸机上、资源又紧张,我索性从零开始,用F407的USART加定时器手写了一套精简的Modbus RTU从机协议。整个过程下来发现,Modbus这个协议本身并不神秘,核心就是串口收发加一帧格式约定,难点全在细节处理上。这篇就把我实现过程中用到的思路、代码片段和踩过的坑整理出来,给想自己动手实现Modbus的朋友做个参考。

1. 项目概述:F407实现Modbus前要想清楚的三件事

1.1 为什么选Modbus RTU而不是Modbus TCP或ASCII

做设备通信,第一步就是选协议形态。Modbus常见的三种形态里,我直接排除掉了ASCII和TCP,选了RTU。原因很实际:第一,现场设备几乎全是RS485总线,触摸屏、组态软件、PLC都默认支持Modbus RTU,兼容性最好;第二,RTU帧效率高,同样的波特率下能多传将近一倍的数据,对实时性有要求的场合更合适;第三,F407作为从机节点,跑RTU模式根本不需要以太网硬件,一颗芯片加一个RS485收发器就够了,成本低得多。

当然,Modbus TCP也有它的价值,如果项目里需要跨设备、跨平台远程采集,那TCP形态更合适。但单就设备端通信来说,RTU是最稳妥也最省的方案。我这次用F407,主频168MHz,跑Modbus RTU从机绰绰有余,CPU占用率基本可以忽略不计。

1.2 搞清楚角色定位:我是从机,不是主机

动手写代码之前必须先把角色想清楚。Modbus通信里,主机(Master)负责发起请求,从机(Slave)只能被动响应。我这个项目的F407是数据采集终端,它在总线上的身份就是从机。这意味着:F407要监听总线上的请求帧,解析地址、功能码、数据,然后组织响应帧发回去。主机发什么我就应答什么,主机不发我就等。

很多新手写Modbus从机容易犯的错,就是把从机当主机用,总想着主动往上位机“推送”数据。这在Modbus RTU里是行不通的,协议本身就是一问一答的经典主从模式。从机要做的就是把数据放在寄存器里,主机什么时候来读,我什么时候给。

1.3 硬件准备:F407开发板、RS485收发器和接线

硬件上我用的是最常见的STM32F407VET6核心板,配合一个SP3485芯片做的RS485模块。接线也不复杂:F407的USART2的TX/RX分别接到SP3485的DI/RO,再拿一个普通GPIO(我习惯用PB12)控制SP3485的DE/RE使能脚,A、B两根差分线接出去就是总线。

有个细节要注意:RS485总线两端必须在A、B之间各接一个120Ω的终端电阻,否则信号反射会导致通信不稳定。之前我偷懒没接,结果近距离调试没问题,线一拉长就偶发乱码,排查了半天才找到原因。另外A、B两根线不要接反,这个接反了从机完全收不到数据,也发不出响应。

2. 协议拆解:Modbus RTU的帧格式和寄存器模型

2.1 一帧数据里到底装了什么

Modbus RTU的帧结构非常紧凑,一次完整的请求或响应帧由四部分组成:从机地址(1字节)、功能码(1字节)、数据区(N字节)、CRC16校验(2字节)。帧和帧之间至少要有3.5个字符时间的静默间隔,一帧内部的字节间隔不能超过1.5个字符时间,否则接收方会认为帧不完整。

举个读寄存器的例子:主机要读地址为0x01的从机,从保持寄存器0x0000开始读2个寄存器,发出的请求帧是01 03 00 00 00 02 C4 0B。其中01是从机地址,03是读保持寄存器功能码,00 00是起始寄存器地址(高字节在前),00 02是寄存器数量,C4 0B是整个帧的CRC16校验值。从机收到后如果一切正常,响应帧是01 03 04 00 01 00 02 的格式,其中04是数据字节数,后面4个字节是两个寄存器的值。

这里有个容易踩的坑:CRC校验值是低字节在前。直接看帧你会发现C4 0B里C4是CRC低字节,0B是CRC高字节。写代码的时候如果CRC函数返回的是16位整型值,发送时必须先发低8位再发高8位,顺序搞反了主机那边永远报CRC错误。

2.2 用一张表理清寄存器类型和功能码的对应关系

Modbus协议里寄存器分四种:线圈(Coil)、离散输入(Discrete Input)、输入寄存器(Input Register)、保持寄存器(Holding Register)。前两种按位操作,后两种按字操作。实际项目里,我最常用的是保持寄存器和输入寄存器。

寄存器类型读写属性功能码(读)功能码(写)位/字
线圈可读可写0x010x05、0x0F位
离散输入只读0x02无位
输入寄存器只读0x04无字
保持寄存器可读可写0x030x06、0x10字

我的项目里传感器采集到的温度、压力值放到保持寄存器里,让触摸屏用0x03功能码来读;控制参数也用保持寄存器,通过0x06或0x10功能码写入。一套下来刚好覆盖“读数据、写参数”两个核心需求。

2.3 CRC16校验:Modbus体系里不可跳过的一环

CRC16校验是Modbus RTU的“身份证”,帧里必须带上,否则主机不认账。Modbus采用的CRC16算法,多项式是0xA001,这是CRC-16/IBM的一种变体,也叫CRC-16/MODBUS。手写实现也不难,一张256项的查表法算下来效率很高。

uint16_t ModbusCRC16(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 j = 0; j < 8; j++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }

查表法更快,但裸机上跑Modbus RTU从机,这个位运算法已经够用了,F407跑一次CRC也就几十个机器周期。算法本身就不展开了,关键记住返回结果是16位整型,发送时要低字节先发。

3. 接收方案:串口+DMA+空闲中断,稳定接收一帧数据

3.1 为什么不用简单的串口中断一个一个字节收

最简单的做法是配置USART接收中断,来一个字节进一次中断,存到数组里。这种做法在数据量小、系统空闲时没问题,但一旦F407还在忙别的事(比如刷屏、跑算法),字节之间的间隔稍微拉长,就可能触发1.5字符时间超时判断,导致整帧丢弃。而且频繁进中断也会干扰主循环的实时性。

我自己的方案是USART空闲中断(IDLE)配合DMA接收。DMA负责把串口接收到的字节连续搬进内存缓冲区,不用CPU干预;空闲中断在总线上一帧数据说完之后触发,此时我只要把DMA当前接收到的字节数算出来,就得到了完整的一帧。这个方案对CPU占用极低,而且天然契合Modbus RTU的帧结构特点。

3.2 USART+DMA+IDLE的具体配置步骤

我用的是HAL库,配置起来也不复杂。先把USART2的接收DMA通道配置成循环模式,再开启串口的空闲中断。

void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 9600; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart2.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart2); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart2, usart2_rx_buf, RX_BUF_SIZE); }

接收缓冲区usart2_rx_buf我定义为512字节的数组,循环模式下DMA会在缓冲区里反复写,所以每帧数据可能是“分段存放”的,必须通过DMA的计数寄存器判断出当前这一帧实际落在哪个区间。空闲中断触发后,读取huart2.hdmarx->Instance->NDTR寄存器,用缓冲区大小减去NDTR,就能算出已接收的字节数,然后再与上一次记录的接收位置比较,计算出新增的帧长度。

3.3 IDLE中断里怎么把帧完整地抠出来

空闲中断触发后,我先清标志位,再关掉串口接收中断(避免解析期间又进新的空闲中断),然后根据DMA计数值计算出当前帧的长度,拷贝到独立的解析缓冲区,置一个帧就绪标志,主循环里检测到标志后再去解析。

void HAL_UART_IDLE_Callback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); HAL_UART_DMAStop(&huart2); uint16_t ndtr = __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint16_t cur_len = RX_BUF_SIZE - ndtr; if (cur_len >= last_pos) rx_frame_len = cur_len - last_pos; else rx_frame_len = RX_BUF_SIZE - last_pos + cur_len; if (rx_frame_len > 0 && rx_frame_len <= MAX_FRAME_LEN) { for (uint16_t i = 0; i < rx_frame_len; i++) rx_frame[i] = usart2_rx_buf[(last_pos + i) % RX_BUF_SIZE]; frame_ready = 1; } last_pos = cur_len; HAL_UART_Receive_DMA(&huart2, usart2_rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); } }

这个方案实测下来非常稳,9600波特率下连续跑48小时没有丢过帧。

4. 协议实现:从机状态机和三大功能码的完整代码

4.1 用主循环状态机代替RTOS,裸机也能优雅处理

很多人一提到协议栈就觉得得上RTOS,其实Modbus RTU从机在裸机上完全可以用状态机跑。我的设计思路是:主循环负责轮询帧就绪标志,帧到了就进入解析流程;解析流程里根据功能码分派不同的处理函数;处理完组织响应帧,通过RS485发出去。

while (1) { if (frame_ready) { frame_ready = 0; Modbus_ProcessFrame(rx_frame, rx_frame_len); } // 其他任务处理 }

4.2 地址匹配和功能码分发

收到帧之后第一件事是检查从机地址。地址不匹配直接丢弃,也不用回复任何内容。地址匹配了再校验CRC,CRC不过也直接丢弃,省得出错响应。这两道关卡都过了,才进入功能码分发。

void Modbus_ProcessFrame(uint8_t *frame, uint16_t len) { if (len < 4) return; if (frame[0] != SLAVE_ADDR) return; uint16_t crc_rx = frame[len - 2] | (frame[len - 1] << 8); uint16_t crc_calc = ModbusCRC16(frame, len - 2); if (crc_rx != crc_calc) return; switch (frame[1]) { case 0x03: Modbus_ReadHoldingRegisters(frame, len); break; case 0x06: Modbus_WriteSingleRegister(frame, len); break; case 0x10: Modbus_WriteMultiRegisters(frame, len); break; default: Modbus_ExceptionResponse(frame, 0x01); break; } }

4.3 读保持寄存器(0x03)的实现细节

0x03功能码的请求帧格式是:地址、0x03、起始寄存器地址(2字节)、寄存器数量(2字节)、CRC(2字节)。从机要返回:地址、0x03、数据字节数、数据、CRC。

这里最需要注意的一个点是寄存器地址和数据值都是大端序(高字节在前)。比如起始寄存器地址是0x0000,发送时帧里的前两个字节就是0x00、0x00;寄存器值如果是0x012A,发送时先发0x01再发0x2A。千万别搞反,这是我调试时栽过跟头的地方。

void Modbus_ReadHoldingRegisters(uint8_t *frame, uint16_t len) { uint16_t start_reg = (frame[2] << 8) | frame[3]; uint16_t reg_count = (frame[4] << 8) | frame[5]; if (reg_count > 16 || start_reg + reg_count > REG_NUM) { Modbus_ExceptionResponse(frame, 0x02); return; } resp_buf[0] = SLAVE_ADDR; resp_buf[1] = 0x03; resp_buf[2] = reg_count * 2; for (uint16_t i = 0; i < reg_count; i++) { resp_buf[3 + i * 2] = holding_regs[start_reg + i] >> 8; resp_buf[4 + i * 2] = holding_regs[start_reg + i] & 0xFF; } uint16_t resp_len = 3 + reg_count * 2; uint16_t crc = ModbusCRC16(resp_buf, resp_len); resp_buf[resp_len] = crc & 0xFF; resp_buf[resp_len + 1] = crc >> 8; Modbus_SendResponse(resp_buf, resp_len + 2); }

4.4 写单个寄存器(0x06)和写多个寄存器(0x10)

写单个寄存器比较简单,请求帧里直接带着寄存器地址和要写入的值。从机成功处理后,原样回显整个请求帧,主机收到相同帧就代表写入成功。

void Modbus_WriteSingleRegister(uint8_t *frame, uint16_t len) { uint16_t reg_addr = (frame[2] << 8) | frame[3]; uint16_t reg_val = (frame[4] << 8) | frame[5]; if (reg_addr >= REG_NUM) { Modbus_ExceptionResponse(frame, 0x02); return; } holding_regs[reg_addr] = reg_val; Modbus_SendResponse(frame, len); }

写多个寄存器(0x10)麻烦一点。请求帧里除了寄存器地址和数量,还有一个字节计数字段,后面跟着实际数据。从机需要挨个字节把数据拼进寄存器数组。

void Modbus_WriteMultiRegisters(uint8_t *frame, uint16_t len) { uint16_t start_reg = (frame[2] << 8) | frame[3]; uint16_t reg_count = (frame[4] << 8) | frame[5]; uint8_t byte_count = frame[6]; if (reg_count > 16 || start_reg + reg_count > REG_NUM || byte_count != reg_count * 2) { Modbus_ExceptionResponse(frame, 0x03); return; } for (uint16_t i = 0; i < reg_count; i++) { holding_regs[start_reg + i] = (frame[7 + i * 2] << 8) | frame[8 + i * 2]; } resp_buf[0] = SLAVE_ADDR; resp_buf[1] = 0x10; resp_buf[2] = frame[2]; resp_buf[3] = frame[3]; resp_buf[4] = frame[4]; resp_buf[5] = frame[5]; uint16_t crc = ModbusCRC16(resp_buf, 6); resp_buf[6] = crc & 0xFF; resp_buf[7] = crc >> 8; Modbus_SendResponse(resp_buf, 8); }

4.5 RS485方向切换:发送时机和控制引脚的配合

RS485是半双工总线,发送前必须把收发器的DE引脚拉高,让发送器接管总线;发送完了再拉低恢复接收模式。如果方向切换不及时,主机发过来的下一帧开头就会被自己发出的字节冲掉。

我的做法是:发送函数里先把DE拉高,调用HAL_UART_Transmit发送,等发送完成(等待TC标志位或者用发送完成中断)之后再延时一小段,确保最后一个字节完全发完了再拉低DE。

void Modbus_SendResponse(uint8_t *buf, uint16_t len) { HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET); HAL_UART_Transmit(&huart2, buf, len, 100); while (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC) == RESET); HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET); }

我习惯在主循环里加一个极小的延时,比如20微秒,等总线电位稳定。实际测试中这个办法简单有效,几乎没有出现过方向切换导致的通信异常。

5. 实测调试与问题排查:Modbus Poll验证全流程

5.1 用Modbus Poll验证从机功能

代码写完之后,验证手段我用的是Modbus Poll这个工具(Modbus Slave则是模拟从机用的,两个要分清)。打开Modbus Poll,选择串口模式,配置好COM口号、波特率9600、数据位8、停止位1、无校验,从站地址设为1,功能码选03保持寄存器,起始地址0,数量4,然后点连接。

如果一切正常,F407收到请求后会返回寄存器数据,Modbus Poll的界面上就能直接看到数值变化,这在调试传感器采集时特别直观。写寄存器测试就切换功能码到06或10,往指定地址写值,再读回来确认。

这里我踩过一个坑:Modbus Poll里默认的寄存器地址显示是0开始的,但有些上位机软件里地址显示是从40001开始的,也就是把保持寄存器的偏移地址直接显示出来。看着是误差,实际是不同软件对地址表示法的差异,别被搞晕。

5.2 常见问题速查表

现象可能原因排查办法
从机完全无响应A/B线接反、从机地址不匹配、DE/GND接线错误用示波器看A/B电平,检查地址寄存器,逐段量通断
响应时好时坏缺少终端电阻、地线不共地、DE切换时序太慢总线两端并联120Ω电阻,检查地线,发送后增加延时
主机报CRC错误CRC发送字节序错误、帧内字节间隔超时检查CRC发送低字节在前,缩短轮询周期避免打断
收不到完整帧串口中断优先级配置不当、DMA缓冲区长度不足调高串口中断优先级,加大RX缓冲区
偶发收到错误数据电源纹波、RS485收发器质量问题电源加滤波电容,换带ESD保护的收发器

5.3 几个提升可靠性的细节

现场总线环境比实验室复杂得多,我实际用下来有这几个经验值得分享。首先是串口中断优先级,我建议把USART的抢占优先级设成1或2,DMA中断优先级设成1,确保即使在系统繁忙时也能第一时间响应接收事件,避免丢数据。

其次是看门狗和异常处理。如果帧解析出错,不要让程序死循环,应该清空缓冲继续等待下一帧。总线上如果有多个从机,地址范围要提前规划好,不要冲突。

最后,也是很多人容易忽略的:Modbus RTU要求帧间距至少3.5个字符时间,上位机轮询频率太高时,从机还没处理完上一帧,下一帧就到了,这时需要在主循环里加一个忙标志,如果还在处理中就主动丢弃新帧,等处理完再恢复接收。

6. 整个项目做下来的几点心得

这个项目从硬件接线到协议跑通,前后用了一个周末的时间。最大的感受是:Modbus RTU没有想象中那么高不可攀,它的核心就是“串口收发+一帧协议格式+CRC校验”这三件事,但要把这三件事做得稳、做得可靠,细节才是真正拉开差距的地方。DMA+IDLE的接收方案、大端字节序的处理、RS485方向切换的时机,每一个环节单独看都不难,连在一起就需要整体考虑。

如果你也打算在STM32上实现Modbus,我建议一开始就按“地址过滤→CRC校验→功能码分发→响应组帧→RS485切换”这个流程来写,别东一榔头西一棒子。后面再遇到其他功能码(比如0x05写单个线圈、0x04读输入寄存器),往这个框架里加分支就行,扩展起来非常省事。再往后如果有余力,可以把定时器超时判断和1.5字符时间间隔管理加上,那就更接近工业级实现了。

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

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

立即咨询