STM32F407的Modbus RTU主机代码详解:CRC16校验与RS485收发切换

发布时间:2026/10/18 17:04:05
STM32F407的Modbus RTU主机代码详解:CRC16校验与RS485收发切换 简介基于STM32F407的Modbus RTU主机通信工程实现RS485总线上完整的主站收发流程适合嵌入式开发者及工业自动化技术人员在设备数据采集、从站轮询控制等场景中直接参考。工程基于STM32标准外设库编写核心代码涵盖USART串口参数配置、报文组帧、中断收发处理、CRC校验、应答解析与超时重试逻辑结构清晰便于移植到同类Cortex-M平台。压缩包共176个文件、约5.2MB其中包含49个C源文件与49个头文件另有Keil MDK工程文件uvprojx/uvoptx、编译生成的hex、axf、map以及20个反汇编文件和19个crf编译过程文件便于查看编译结果并从底层汇编角度核验时序逻辑。目前已有3310人学习下载。借助该工程可以具体理解Modbus RTU主站在STM32F407上的实现方式包括串口中断驱动、RS485方向控制、定时器超时管理等关键环节对正在搭建工业通信节点或准备学习Modbus协议的开发者有直接帮助。1. STM32F407 的 Modbus RTU 主机代码不用从零写协议栈的落地包现场设备通信失联、上位机轮询超时、从站偶尔回一帧错数据——这类问题在我手里处理过不止十次。手头没有现成主机代码时大家通常的做法是移植开源协议栈但协议栈上手成本不低对新手来说更像一个黑匣子。这套基于 STM32F407 的 Modbus RTU 主机代码解决的就是这个问题它把主站侧的帧构造、CRC16 校验、超时重试、RS485 收发切换全部封装好了直接改串口引脚和从站地址表就能用。适合正在做单片机串口通信、现场仪表数据采集又不想花两周啃协议细节的开发者和调试工程师。2. Modbus RTU 主机侧的关键设计帧结构、CRC 校验与 RS485 方向控制2.1 帧结构拆解请求帧与应答帧的字节序问题Modbus RTU 的帧格式不算复杂但字节序和寄存器地址错位问题非常容易翻车。标准请求帧由从站地址、功能码、数据区和 CRC 校验组成最小长度是 8 字节读寄存器场景地址 功能码 起始地址高字节 起始地址低字节 寄存器数量高字节 寄存器数量低字节 CRC 高字节 CRC 低字节。这套主机代码里数据区统一按大端模式填充。比如要读从站地址 0x01 的保持寄存器起始地址是 0x0000数量是 0x0002最终构造出来的请求帧应该是uint8_t req_frame[8]; req_frame[0] 0x01; // 从站地址 req_frame[1] 0x03; // 功能码读保持寄存器 req_frame[2] 0x00; // 起始地址高字节 req_frame[3] 0x00; // 起始地址低字节 req_frame[4] 0x00; // 寄存器数量高字节 req_frame[5] 0x02; // 寄存器数量低字节 uint16_t crc crc16_modbus(req_frame, 6); req_frame[6] crc 0xFF; // CRC 低字节在前 req_frame[7] (crc 8) 0xFF; // CRC 高字节在后代码里的crc16_modbus是后面要提到的校验函数它计算的是地址加功能码加数据区这 6 个字节。CRC 在线上传输时低字节在前、高字节在后这个顺序和很多人习惯的“高字节在前”相反调试时经常在这里踩坑。整个请求帧构造完后通过串口发出即可。应答帧的结构类似从站地址、功能码、字节数、数据区、CRC。读多个寄存器时数据区每两个字节为一组同样是大端序。这套代码里没有对响应帧做复杂的解释逻辑只确认功能码和 CRC 匹配再把数据区直接拷贝到用户缓冲区由上层代码按项目实际需求解析。2.2 CRC16 校验查表法与逐位法的取舍Modbus RTU 使用 CRC16 算法多项式是 0xA001反射后的值初始值为 0xFFFF。校验范围覆盖从站地址、功能码和数据区不包含 CRC 自身。有人直接套 CRC32 的库函数改参数结果对不上就是因为多项式方向和初始值不对。查表法比逐位法快很多。在 168MHz 主频的 F407 上其实两种方法都能跑但主站如果带多个从站、轮询周期要求短查表法的收益就很明显。这套代码用的是查表法表长 256生成逻辑如下static uint16_t crc_table[256]; void crc_table_init(void) { for (uint16_t i 0; i 256; i) { uint16_t crc i; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } crc_table[i] crc; } } uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { uint8_t index (crc ^ data[i]) 0xFF; crc (crc 8) ^ crc_table[index]; } return crc; }查表法的核心理念是“用空间换时间”256 个表项占 512 字节内存对 F407 的 192KB SRAM 来说九牛一毛。index的计算是取 CRC 低字节与当前数据字节的异或值这是查表法的标准写法不要改成别的形式。多项式 0xA001 是 0x8005 的反射结果如果网上找到的代码用的是 0x8005那多半是做成了非反射版本两边对不上。初始化表函数crc_table_init要在第一次发送请求前调用。放在main函数开头即可。另外有个小细节有些从站设备对 CRC 校验失败会静默丢弃请求帧主站表现是超时而不是返回错误码所以调试时先固定一个已知从站用串口助手抓包看原始字节能迅速定位是 CRC 问题还是线路问题。2.3 RS485 收发切换RE/DE 引脚控制与终端电阻RS485 是半双工总线同一时刻只能收或发。F407 的 USART 本身没有收发方向引脚需要把某个 GPIO 接到收发芯片的 RE/DE 脚上。代码里默认使用 PD4 作为方向控制引脚逻辑是发送前拉高发送完成后拉低切回接收模式。void rs485_dir_tx(void) { HAL_GPIO_WritePin(GPIOD, GPIO_PIN_4, GPIO_PIN_SET); } void rs485_dir_rx(void) { HAL_GPIO_WritePin(GPIOD, GPIO_PIN_4, GPIO_PIN_RESET); }方向切换的时机是最容易翻车的环节。发送完成的标志不能只看 TXE发送数据寄存器空必须等待 TC发送完成标志置位否则最后一个字节可能还在移位寄存器里方向已经被切回接收了帧尾部会被截断。用 HAL 库时HAL_UART_Transmit返回 HAL_OK 并不代表物理上已经发完还要额外调用一次等待 TC 标志的操作。硬件上 RS485 总线两端必须接 120 欧终端电阻否则长距离传输时反射信号会导致误码。手头没有 120 欧电阻时可以用两个 60 欧串联代替但不要省略。总线空闲时要保证 A、B 之间有一定的电压差否则接收端可能收到随机噪声误触发。如果线路很短、只有两块板子互连可以不加偏置电阻一旦总线挂的设备超过 5 个偏置必须加。3. 代码实战从串口初始化到主站状态机的完整移植路径3.1 串口与 DMA 配置接收不定长帧的标准姿势Modbus RTU 帧长度不固定最简单的接收方式是串口空闲中断配合 DMA这样不用知道帧长度也能完整收下数据。这套代码里用的是 USART3PA10 接 RX、PA11 接 TXDMA 配置为接收模式缓冲区大小设为 256 字节。对于绝大多数从站来说这个缓冲足够用如果现场帧特别长可以改成 512。初始化部分关键代码如下UART_HandleTypeDef huart3; DMA_HandleTypeDef hdma_usart3_rx; uint8_t rx_buffer[256]; volatile uint16_t rx_len; void uart3_dma_init(uint32_t baudrate) { __HAL_RCC_USART3_CLK_ENABLE(); __HAL_RCC_GPIOD_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_8 | GPIO_PIN_9; gpio.Mode GPIO_MODE_AF_PP; gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; gpio.Alternate GPIO_AF7_USART3; HAL_GPIO_Init(GPIOD, gpio); huart3.Instance USART3; huart3.Init.BaudRate baudrate; huart3.Init.WordLength UART_WORDLENGTH_8B; huart3.Init.StopBits UART_STOPBITS_1; huart3.Init.Parity UART_PARITY_NONE; huart3.Init.Mode UART_MODE_TX_RX; huart3.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart3); hdma_usart3_rx.Instance DMA1_Stream1; hdma_usart3_rx.Init.Channel DMA_CHANNEL_4; hdma_usart3_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_usart3_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart3_rx.Init.MemInc DMA_MINC_ENABLE; hdma_usart3_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart3_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart3_rx.Init.Mode DMA_NORMAL; hdma_usart3_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_usart3_rx); __HAL_LINKDMA(huart3, hdmarx, hdma_usart3_rx); HAL_UART_Receive_DMA(huart3, rx_buffer, 256); __HAL_UART_ENABLE_IT(huart3, UART_IT_IDLE); }DMA 模式要选 NORMAL 而不是 CIRCULAR因为每收完一帧后要复位 DMA 计数器循环模式会让数据指针回绕处理起来非常绕。HAL_UART_Receive_DMA启动后串口收到数据会自动写入rx_buffer不需要 CPU 参与。空闲中断在串口线上电平持续空闲一个字节时间后触发此时一帧数据已经完整进入缓冲区。波特率参数在调用uart3_dma_init(9600)时传入。现场仪表默认波特率五花八门常见的 9600、19200、38400 都测试过这套代码在 115200 以下稳定运行没有问题。波特率越高帧间隔越短对方向切换的时序要求越严格所以现场不稳定时第一步先把波特率降下来而不是怀疑代码。3.2 从站地址表与读写函数把协议封装成“填参数”接口这套代码把主机功能封装成了几个函数对使用者来说不需要关心帧怎么拼、CRC 怎么算只需要传入从站地址、寄存器地址和数据指针即可。比如读保持寄存器函数的原型uint8_t modbus_read_registers(uint8_t slave_addr, uint16_t start_addr, uint16_t reg_cnt, uint16_t *dest);函数内部做了三件事构造请求帧、调用rs485_dir_tx发送、切换回接收模式等待应答。等待应答时有个超时机制默认超时 200ms超过就返回错误码 0x01上层可以用这个返回值做重试判断。#define MODBUS_TIMEOUT_MS 200 #define MODBUS_RETRY_CNT 3 uint8_t modbus_read_registers(uint8_t slave_addr, uint16_t start_addr, uint16_t reg_cnt, uint16_t *dest) { uint8_t req[8]; req[0] slave_addr; req[1] 0x03; req[2] (start_addr 8) 0xFF; req[3] start_addr 0xFF; req[4] (reg_cnt 8) 0xFF; req[5] reg_cnt 0xFF; uint16_t crc crc16_modbus(req, 6); req[6] crc 0xFF; req[7] (crc 8) 0xFF; rs485_dir_tx(); HAL_UART_Transmit(huart3, req, 8, 100); while (__HAL_UART_GET_FLAG(huart3, UART_FLAG_TC) RESET); rs485_dir_rx(); uint32_t start_tick HAL_GetTick(); uint8_t *rx rx_buffer; while ((HAL_GetTick() - start_tick) MODBUS_TIMEOUT_MS) { if (rx_len 0) { if (rx[0] slave_addr rx[1] 0x03) { uint16_t crc_rx rx[rx_len - 1] 8 | rx[rx_len - 2]; if (crc16_modbus(rx, rx_len - 2) crc_rx) { for (uint16_t i 0; i reg_cnt; i) { dest[i] (rx[3 i * 2] 8) | rx[4 i * 2]; } rx_len 0; return 0x00; } } rx_len 0; } } return 0x01; }超时计算用的是HAL_GetTick这个函数依赖 SysTick 中断如果工程里把 SysTick 优先级调得很高或者关了超时判断会失效。要求高的话可以换 DWT 计数器来实现更精准的超时但这套代码默认 SysTick 正常运作所以直接用没问题。应答解析里有个细节CRC 验证放在功能码校验之后先确认收到的是本机请求的应答再校验数据完整性这样能过滤掉总线上其他无关报文。如果从站返回异常码功能码最高位置 1rx[1]就不再是 0x03会直接走到rx_len 0清空缓冲区然后等待下一帧。异常码的具体含义不在主机侧处理留给上层业务逻辑判断。3.3 主站状态机轮询与超时重试的工程化处理主站不像从站那样被动接收请求它需要主动发起轮询。最简单的做法是循环发送但真实工程里会遇到一个问题某个从站掉线后连发多次请求只会加重总线负担还影响其他从站的数据刷新。这套代码建议用一个轻量级状态机来管理轮询节奏。核心逻辑放在modbus_poll_tick函数里由定时器周期调用或者放在主循环中轮询typedef struct { uint8_t slave_addr; uint16_t start_addr; uint16_t reg_cnt; uint16_t *dest; uint8_t retry_left; uint8_t poll_interval; } modbus_slave_node_t; void modbus_poll_tick(modbus_slave_node_t *node, uint8_t node_cnt) { static uint8_t index 0; static uint32_t last_tick 0; uint32_t now HAL_GetTick(); if ((now - last_tick) node[index].poll_interval) { return; } uint8_t ret modbus_read_registers(node[index].slave_addr, node[index].start_addr, node[index].reg_cnt, node[index].dest); if (ret ! 0x00) { node[index].retry_left--; if (node[index].retry_left 0) { node[index].retry_left MODBUS_RETRY_CNT; index (index 1) % node_cnt; } } else { node[index].retry_left MODBUS_RETRY_CNT; index (index 1) % node_cnt; } last_tick now; }状态机里每个从站节点独立维护自己的重试计数和轮询周期。某个节点连续重试失败后而不是继续卡在当前节点发请求而是切到下一个节点保证正常节点的数据还能刷新。poll_interval的单位是毫秒按现场要求调整。同一批次从站数量多时轮询周期要大于所有节点延时的总和否则后面的节点永远等不到发送机会。这套状态机没有用 FreeRTOS 的信号量或者互斥锁因为主机侧本身就是单任务轮询不需要考虑并发问题。如果后接业务代码里也要访问dest缓冲区建议在读取前关中断或者用原子操作避免轮询函数正在写缓冲区时业务代码读了一半。4. 避坑排查RS485 与 Modbus RTU 最容易翻车的四个常见问题4.1 换一个从站设备后通信全部超时现象设备 A 用原厂触摸屏通信正常换成这套主机代码后一帧都收不到或者从站换了一个型号之前能通信现在全部超时。原因八成是串口参数不匹配。Modbus RTU 支持 8 数据位 无校验 1 停止位但部分仪表默认是 8 数据位 无校验 2 停止位或者 8 数据位 偶校验 1 停止位。串口参数不对从站根本不会解析请求帧。另一部分原因是从站地址不对有些设备出厂默认地址不是 1而是 247。解决先用串口助手抓取主机发出的原始字节确认请求帧的地址和功能码是否符合目标从站的说明书。然后用逻辑分析仪或者示波器看线路上有没有数据波形。最笨但最有效的方法是在 RS485 的 A、B 之间跨一个 USB 转 485 模块接到电脑用串口调试助手直接监听总线主机发什么、从站回什么都看得清清楚楚。4.2 发送完后立刻切方向导致帧尾丢失现象偶尔能通信但频繁出现从站没响应示波器上看发送波形最后一个字节的停止位明显不完整波形像被剪了一刀。原因发送完成后代码只等了 TXE 标志就切回接收方向实际上最后一个字节还在移位寄存器里逐位往外吐此时把驱动器切到接收模式收发芯片的发送端被禁用剩余的位直接变成高阻态帧就废了。解决发送完成条件必须等待 TCTransmission Complete标志而不是 TXE。TC 标志置位意味着所有数据已经从移位寄存器移出、发送引脚已经回到空闲状态。使用 HAL 库时可以在HAL_UART_Transmit返回后增加如下等待while (__HAL_UART_GET_FLAG(huart3, UART_FLAG_TC) RESET);如果用了HAL_UART_Transmit_IT中断发送则在发送完成回调里切换方向这样时序更稳。从那以后我每次调 RS485 工程都强制走一遍示波器看波形收尾等确认停止位完整了才敢接从站。4.3 CRC 校验结果对不上从站要求现象从站一直不回帧逻辑分析仪看到主机确实发出请求了但从站像是没收到用 PC 上的 Modbus 调试工具发同样的请求从站却能正常回复。原因CRC 的实现里有两处容易出错一是多项式算成了非反射版本二是高低字节顺序接反。很多从站内部对 CRC 的校验方式不一致有的用查表法但表生成错误有的用逐位法最终结果不匹配。解决推荐的做法是先固定一组已知正确数据做自测。比如用地址 01、功能码 03、起始地址 0000、寄存器数量 0002正确 CRC 应该是 0xC5 0x88低字节在前。把这组数据喂给crc16_modbus函数核对中间结果。查表法的表项可以独立验证crc16_modbus(0x01, 1)的结果应当与逐位法完全一致如果不一致问题出在多项式或者移位逻辑上。另外检查代码里有没有把 CRC 高低字节写反req[6] crc 0xFFreq[7] (crc 8) 0xFF如果写成req[6] (crc 8)那就反了。这属于一眼就能看出来的低级错误但调试时最容易忽略。4.4 广播地址和异常码没有单独处理现象给总线上所有从站发写命令广播时部分设备不执行或者读某个寄存器时从站返回了异常码主机把它当成正常数据解析得到乱码。原因Modbus 协议里地址 0 是广播地址从站收到广播帧后执行操作但不回复任何数据。主机如果发送广播帧后还傻等应答必然超时。另外从站收到非法请求时会返回异常码功能码最高位置 1比如 0x83数据区放的是异常码和 CRC主机如果按正常应答帧解析数据区长度和内容都对不上。解决广播写操作走单独的发送函数不进入等待应答流程。异常码检测要在应答解析的第一层判断如果rx[1] 0x80不为 0说明是异常响应直接提取rx[2]作为异常码不再走数据区解析。完整判断逻辑如下if (rx[0] slave_addr (rx[1] 0x80) 0x80) { // 异常响应 uint8_t ex_code rx[2]; // ex_code: 01 非法功能码, 02 非法地址, 03 非法数据 } else if (rx[0] slave_addr rx[1] 0x03) { // 正常数据处理 }异常码 01 表示从站不支持该功能码03 表示寄存器地址超范围处理方式是修正请求参数而不是重试否则只会让从站一直回异常码浪费总线时间。5. 验证与进阶用 PC 模拟从站闭环测试再谈轮询周期优化5.1 闭环验证没有真实从站也能跑通全部逻辑拿两块 F407 开发板做验证是最常见的方法但如果没有第二块板子可以用 PC 上的串口调试助手配合 USB 转 RS485 模块做从站模拟。操作流程是主机接 USB 转 485 模块连电脑电脑上打开串口助手设置与主机相同的波特率 9600、8N1然后手动往串口发送模拟的应答帧。验证读寄存器时主机发出01 03 00 00 00 02 C5 88后串口助手需要回一帧应答01 03 04 00 0A 00 14 CRC。0A 和 14 就是要验证的寄存器值。主机收到后验证dest[0]等于 10、dest[1]等于 20说明接收解析链路是通的。空跑一次 CRC 校验先把应答帧发出去再把最后一个字节改错重新发送观察主机是否拒绝并返回超时错误。5.2 参数调优参考参数推荐值备注波特率9600/19200超过 38400 需缩短线缆长度超时时间200ms经典值快速失败与容错折中重试次数3 次再失败切下一个节点轮询间隔50~500ms视从站响应速度而调多节点时用较大值DMA 缓冲256 字节若帧长超限调至 5125.3 轮询优化技巧主机侧代码跑通后先把轮询周期调到 500ms确认每个从站都能稳定返回数据再慢慢缩短到 100ms、50ms。缩得太快会让从站来不及处理而产生超时误判为主机问题。但通常现场工控系统对数据刷新频率要求不高几百毫秒到一秒完全够用。另一个技巧是开启串口空闲中断的 DMA 接收时不要忘了在每次读完缓冲区后重新启动 DMA否则第二次接收会直接失败。常见错误是把HAL_UART_Receive_DMA放到主循环里反复调用导致 DMA 配置被反复重置数据错乱。还是那句话先用 PC 模拟从站把收发和校验全部跑通再接真实设备能省出大量现场调线的时间。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询