ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F407手写Modbus RTU从机协议:串口DMA+空闲中断实现详解

STM32F407手写Modbus RTU从机协议:串口DMA+空闲中断实现详解 前阵子做了一个用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 USARTDMAIDLE的具体配置步骤我用的是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 地址匹配和功能码分发收到帧之后第一件事是检查从机地址。地址不匹配直接丢弃也不用回复任何内容。地址匹配了再校验CRCCRC不过也直接丢弃省得出错响应。这两道关卡都过了才进入功能码分发。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字节、CRC2字节。从机要返回地址、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或2DMA中断优先级设成1确保即使在系统繁忙时也能第一时间响应接收事件避免丢数据。其次是看门狗和异常处理。如果帧解析出错不要让程序死循环应该清空缓冲继续等待下一帧。总线上如果有多个从机地址范围要提前规划好不要冲突。最后也是很多人容易忽略的Modbus RTU要求帧间距至少3.5个字符时间上位机轮询频率太高时从机还没处理完上一帧下一帧就到了这时需要在主循环里加一个忙标志如果还在处理中就主动丢弃新帧等处理完再恢复接收。6. 整个项目做下来的几点心得这个项目从硬件接线到协议跑通前后用了一个周末的时间。最大的感受是Modbus RTU没有想象中那么高不可攀它的核心就是“串口收发一帧协议格式CRC校验”这三件事但要把这三件事做得稳、做得可靠细节才是真正拉开差距的地方。DMAIDLE的接收方案、大端字节序的处理、RS485方向切换的时机每一个环节单独看都不难连在一起就需要整体考虑。如果你也打算在STM32上实现Modbus我建议一开始就按“地址过滤→CRC校验→功能码分发→响应组帧→RS485切换”这个流程来写别东一榔头西一棒子。后面再遇到其他功能码比如0x05写单个线圈、0x04读输入寄存器往这个框架里加分支就行扩展起来非常省事。再往后如果有余力可以把定时器超时判断和1.5字符时间间隔管理加上那就更接近工业级实现了。
返回列表