ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 Modbus RTU从站完整实现:帧解析、RS485时序与CRC校验

STM32 Modbus RTU从站完整实现:帧解析、RS485时序与CRC校验 简介一套面向STM32F103微控制器的MODBUS从站程序包用于实现单片机通过RS485总线与上位机进行读写通信解决工业自动化场景下嵌入式设备对接PLC、传感器或控制器的常见需求。压缩包共包含1081个文件整体大小约26.93MB以C源文件、头文件、Keil工程文件为主还包含汇编启动代码、链接脚本、文本说明以及若干编译中间文件开发者可使用Keil MDK直接打开工程浏览整体结构并灵活修改。具体实现覆盖MODBUS常用功能码处理、寄存器地址映射、CRC校验、RS485半双工收发方向切换以及通过MAX485驱动器与串口调试助手完成的联动调试。读者可借此掌握STM32作为从机时正确应答上位机指令的方法并深入理解底层UART收发与MODBUS协议处理的配合细节。目前该包已有1482人学习适合正在学习STM32底层编程、准备移植MODBUS协议或进行RS485项目开发的嵌入式工程师参考。1. 这个压缩包能跑通但你得看懂 Modbus RTU 在 STM32 上真正做了什么下载到 STM32-MODBUS 程序压缩包解压后通常是一个裸机或带 RTOS 的 Keil 工程里面堆着 modbus 和 rs485 命名的源文件。这个标题要解决的实际问题并不复杂让 STM32 作为 Modbus RTU 从站挂在 RS485 总线上主机发一条读命令单片机把内部寄存器数据按帧格式回回去。真正挡人的是三处帧边界怎么判断、RS485 方向切换的时序怎么控制、CRC 校验和地址偏移怎么对上。如果你是从 51 或 Arduino 转过来的最容易踩的坑不是时序而是协议理解偏差比如以为串口收到的就是完整一帧。下面按移植这类程序的实际顺序从协议帧格式开始讲到串口接收、RS485 电路再到用 Modbus Poll 验证结果每一段都给出可以直接抄进工程的代码和参数。2. Modbus RTU 帧格式与 CRC 校验先于串口代码把协议定死2.1 一帧数据的固定切分地址、功能码、数据区、CRCModbus RTU 帧是“定字节序、不定长度”。地址 1 字节功能码 1 字节数据区 N 字节CRC 校验 2 字节。读保持寄存器的请求帧长度固定是 8 字节但响应帧长度取决于读了多少个寄存器所以解析时不能按固定字节数去等接收完成。看一个最典型的读请求帧主机要读 1 号从站的保持寄存器从地址 0x0000 开始读 10 个字段值说明从站地址01目标设备 ID范围 1~2470 为广播地址功能码03读保持寄存器起始地址高字节00协议内地址高字节在前起始地址低字节00对应寄存器偏移 0x0000寄存器数量高字节00要读 10 个寄存器寄存器数量低字节0A10 个数据区共 20 字节CRC 低字节C5CRC16 先发低字节CRC 高字节CD再发高字节这里有个容易忽略的细节CRC 是最后两个字节但发送顺序是低字节在前、高字节在后。很多人在解析时直接把最后两个字节拼成(rx_buf[len-1] 8) | rx_buf[len-2]拼出来的值和用算法算出来的对不上原因就在顺序上。2.2 先认功能码和数据模型再选从站要实现的寄存器Modbus 定义了四张数据表很多刚从串口裸收发转过来的开发者会在这里懵同一段内存地址功能码不同读到的含义完全不同。数据模型位/字读写属性对应功能码线圈位读写01 读、05 写单、0F 写多离散输入位只读02 读输入寄存器16 位只读04 读保持寄存器16 位读写03 读、06 写单、10 写多做从站程序时先确认上位机用哪个功能码访问 STM32多数项目只实现 03 和 06 就能跑起来。功能码响应规则也很固定正常响应把功能码原样返回异常响应把功能码最高位置 1例如 03 变 83后面跟一个异常码。异常码 01 是非法功能码02 是非法数据地址03 是非法数据值。还有一个地址偏移的坑必须提串口报文里的地址是 0x0000 基址但组态软件和人机界面上常见的“40001”格式对应的是报文里的 0x0000。如果按 40001 直接填进报文起始地址读出来的数据整体错位 1 个寄存器。2.3 CRC16 算法与发送顺序0xA001 多项式、低字节在前CRC 校验在 Modbus RTU 里固定用 CRC16多项式 0xA001初值 0xFFFF。查表法快但占 512 字节表空间STM32 的 RAM 在小型号上也就几 KB按位运算法虽然慢一点但每次只算一个帧CPU 占用完全可接受。从站程序里我用的是位运算法uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *data; for (uint8_t i 0; i 8; i) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }这个函数有两个使用要点。第一校验范围是从帧头到数据区末尾不包含 CRC 本身两个字节第二算法返回的 crc 是 16 位值发送时先resp[idx] crc 0xFF发低字节再resp[idx] crc 8发高字节。验证算法对不对可以用 01 03 00 00 00 0A 这六个字节算一遍正确结果是 0xCDC5放到帧里就是 C5 CD。3. 用串口中断 定时器超时在 STM32 上接收 Modbus 帧3.1 帧边界靠静默时间而不是长度3.5 字符间隔怎么算Modbus RTU 没有帧长度字段接收端判断一帧结束靠的是时间间隔帧内字符间隔不能超过 1.5 字符时间帧与帧之间至少间隔 3.5 字符时间。所以接收逻辑不能一次性等完所有字节而是每收到一个字节就重置定时器定时器超时了说明总线静默超过 3.5 字符时间这一帧就结束了。3.5 字符时间的估算公式是(3.5 × 10 位) / 波特率按 9600 算约 3.65ms定时器取整设 4ms 即可。115200 波特率下 3.5 字符时间约 0.3ms定时器要重新算不能拿着 9600 的参数硬套。这个方案的核心思想是用定时器模拟“总线空闲检测”是裸机移植 Modbus 从站最直接的做法。3.2 CubeMX 中串口和定时器的参数设置USART1 9600 8N1 TIM6 4ms在 CubeMX 里把 USART1 配成异步模式参数按下面表格设置。注意校验位必须和上位机一致Modbus RTU 默认可以用 8N1很多程序例程里却写的是偶校验这是串口收发一堆乱码的首要原因。串口参数值ModeAsynchronousBaud Rate9600Word Length8 BitsParityNoneStop Bits1全局中断USART1 global interrupt 打开定时器用 TIM6这是基本定时器不开引脚专门做超时判断。72MHz 主频下预分频设 71得到 1MHz 计数时钟自动重载值 ARR 设 4000溢出周期为 4ms。CubeMX 里对应关系是 Prescaler71Counter Period4000。如果板子主频不是 72MHz按实际时钟频率重算目标是让溢出时间落在 3.5~5 字符时间之间。3.3 串口接收中断与定时器超时从字节到一帧的完整代码接收端使用 HAL 库的HAL_UART_Receive_IT一字节一收每收到一个字节就存入缓冲数组同时把定时器计数器清零重新开始计时。定时器溢出时置位帧就绪标志主循环轮询这个标志。注意HAL_UART_Receive_IT是一次性的每次回调里必须重新调用一次否则只收第一个字节就停了。uint8_t rx_byte; /* 接收中断暂存字节 */ uint8_t rx_buf[256]; /* 帧缓冲 */ volatile uint16_t rx_len 0; /* 已接收字节数 */ volatile uint8_t frame_ready 0; /* 1 表示一帧接收完成 */ /* 主程序初始化时启动接收 */ HAL_UART_Receive_IT(huart1, rx_byte, 1); HAL_TIM_Base_Start_IT(htim6); /* 定时器启动后续每次收字节重装 */ /* 串口接收完成回调每个字节进入这里 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { if (rx_len sizeof(rx_buf)) rx_buf[rx_len] rx_byte; __HAL_TIM_SET_COUNTER(htim6, 0); /* 清空定时器计数器 */ HAL_TIM_Base_Start_IT(htim6); /* 重新开始计时 */ HAL_UART_Receive_IT(huart1, rx_byte, 1); /* 继续接收下一字节 */ } } /* 定时器溢出回调总线静默超时判定一帧结束 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { HAL_TIM_Base_Stop_IT(htim6); frame_ready 1; } }这段代码的关键是__HAL_TIM_SET_COUNTER与HAL_TIM_Base_Start_IT的配合。每收到一个字节就把计数器清零之前累计的计时全部作废这样只有总线真正空闲 4ms 才会触发一次溢出中断。接收缓冲 256 字节足够装下 Modbus 最大帧但代码里要防止越界写如果rx_len到 255 后还有数据直接丢弃等下一帧超时后再重置。3.4 从站响应帧构建校验地址、比对 CRC、按 0x03 应答主循环里检测到frame_ready后先做地址过滤再算 CRC最后按功能码分发处理。地址不匹配或 CRC 错误的帧直接丢弃不回任何数据这是 Modbus 从站的基本礼仪。uint8_t resp_buf[64]; uint16_t resp_len 0; if (frame_ready) { frame_ready 0; /* 地址过滤本机地址或广播地址才处理 */ if (rx_buf[0] SLAVE_ADDR || rx_buf[0] 0x00) { /* CRC 校验帧末两字节是 CRC高字节在最后一个位置 */ uint16_t crc_rx ((uint16_t)rx_buf[rx_len - 1] 8) | (uint16_t)rx_buf[rx_len - 2]; uint16_t crc_cal modbus_crc16(rx_buf, rx_len - 2); if (crc_rx crc_cal) { /* 注意广播地址的写命令不回响应帧读命令一般不广播 */ if (rx_buf[0] ! 0x00) { resp_len build_response(rx_buf, rx_len, resp_buf); if (resp_len 0) rs485_send(resp_buf, resp_len); } } } rx_len 0; /* 清空接收缓冲准备下一帧 */ }build_response是功能码分发函数以 0x03 为例响应帧结构是从站地址 功能码 字节数 寄存器数据 CRC。字节数等于寄存器数量乘 2。如果起始地址加上数量超出寄存器表范围回异常帧。uint16_t build_response(uint8_t *req, uint16_t req_len, uint8_t *resp) { uint8_t func req[1]; uint16_t start_reg ((uint16_t)req[2] 8) | req[3]; uint16_t reg_cnt ((uint16_t)req[4] 8) | req[5]; uint16_t idx 0; uint16_t i, crc; resp[idx] SLAVE_ADDR; resp[idx] func; if (func 0x03) { if (reg_cnt 125 || start_reg reg_cnt REG_TABLE_SIZE) { resp[1] | 0x80; /* 功能码最高位置 1异常响应 */ resp[idx] 0x02; /* 非法数据地址 */ } else { resp[idx] reg_cnt * 2; /* 数据区字节数 */ for (i 0; i reg_cnt; i) { resp[idx] (reg_table[start_reg i] 8) 0xFF; resp[idx] reg_table[start_reg i] 0xFF; } } } else { resp[1] | 0x80; resp[idx] 0x01; /* 非法功能码 */ } crc modbus_crc16(resp, idx); resp[idx] crc 0xFF; resp[idx] crc 8; return idx; }正常响应的功能码不回显 0x80异常响应才需要把最高位置 1。多元件开始写时很容易漏掉resp[1] | 0x80这一步导致上位机收到一个功能码为 03 但数据区含义完全错的帧报错信息会让人误以为是 CRC 问题。4. RS485 的电平转换与方向切换自动收发电路也能可靠收发4.1 TTL 转 RS485MAX3485 与 STM32 的引脚对应STM32 的 UART 引脚输出的是 TTL 电平RS485 总线需要差分信号中间要通过收发器芯片转换最常见的是 MAX3485 或兼容的 SP3485。这类芯片是 3.3V 供电可以直接用 STM32 的电源不需要额外电平转换。引脚对应关系固定如下MAX3485 引脚接 STM32/总线说明RORX (PA10)接收数据输出A-B 为高时输出 1RE#PC4 或任意 GPIO低电平使能接收DEPC4 或任意 GPIO高电平使能发送DITX (PA9)发送数据输入A总线 A 线差分正端B总线 B 线差分负端RE# 和 DE 接同一个 GPIO 是最常见的半双工接法这个引脚为高就是发送模式为低就是接收模式。驱动能力上 MAX3485 可以带 32 个节点小规模组网够用。注意有些模块叫“自动收发”内部已经用三极管做了方向切换这种模块的 DE/RE 引脚是不需要接 MCU 的接线时要区分清楚。4.2 方向切换必须等 TC 标志否则最后一个字节会被切断用 GPIO 控制方向时发送流程不是“拉高 DE调用发送函数拉低 DE”这么简单。HAL_UART_Transmit 返回时最后一个字节可能还在移位寄存器里没有完全移出此时立刻拉低 DE总线驱动器被禁用最后一个字节的后半个位会被截断对端收到后 CRC 一定错。正确做法是等待发送完成标志 TC这个标志表示移位寄存器和数据寄存器都空了串口真正把最后一位送出去了。void rs485_send(uint8_t *buf, uint16_t len) { RS485_DE_HIGH(); /* DE1进入发送模式 */ HAL_UART_Transmit(huart1, buf, len, 100); /* 必须等 TCHAL 返回不代表最后一个字节已发完 */ while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET) ; RS485_DE_LOW(); /* 拉回接收模式 */ }这段代码里的HAL_UART_Transmit是阻塞轮询方式第二个参数是超时时间100ms 足够 256 字节在 9600 波特率下传完理论最长约 267ms短帧远用不到。如果你用的是中断或 DMA 发送要等发送完成回调里再拉低 DE不能在调用后立刻操作 GPIO。从站的回帧都很短阻塞发送不会影响其他任务。4.3 组网物理层三个参数终端电阻、偏置电阻、A/B 接线RS485 组网在物理层有四个最常见的错误A/B 接反、终端电阻乱加、总线空闲无偏置、线缆走星型拓扑。先给一张排查表对应问题直接去查异常现象常见原因处理方式上位机收不到任何响应A/B 接反对调 A、B 两根线长线误码、偶发 CRC 错误没有终端电阻总线两端各并一个 120Ω 电阻上电瞬间收到 0x00 或 0xFF 干扰空闲电平无偏置A 线接上拉、B 线下拉约 560Ω多设备通信互相干扰星型分支过长改成手拉手菊花链拓扑终端电阻不是每个节点都加只在物理总线的最远端两端各加一个 120Ω。中间节点加了会导致总线上等效阻抗变小驱动电流变大反而让信号质量更差。偏置电阻的作用是总线空闲时 A-B 之间的差分电压确定在逻辑 1 状态避免所有设备都处于接收态时总线上乱跳偏置电阻一般放在主机那端或者带主站的设备上。4.4 自动收发电路能用但要注意 DE 的使能条件省掉 GPIO 方向控制的自动收发电路原理是用三极管或 MOS 管检测 TXD 电平切换方向。典型做法发送数据时 TXD 线上出现低电平通过反相驱动让 DE 变高进入发送模式TXD 空闲高电平时 DE 为低回到接收模式。这个电路在 9600 波特率下运行良好但有一个固有限制当 TXD 发送的字节全是高电平比如 0xFFDE 无法被置高驱动器不会使能。好在 Modbus 帧里 0xFF 只会出现在数据区帧头地址和功能码正常都有低电平位实际传输问题不大。如果总线速率超过 57600 或者线缆长度超过几十米还是建议改回 GPIO 控制方向避免 DE 翻转和串口时序的微小偏差在长线上被放大。5. 用 Modbus Poll 验证从站从地址、寄存器偏移到错误码5.1 Modbus Poll 的连接参数与读写定义Modbus Poll 是验证从站最顺手的工具重点不是下载和注册而是 Setup 里的参数和单片机烧录程序完全一致。打开软件后先在 Connection 菜单里选 Modbus RTU Over Serial Line串口选 STM32 对应的 COM 口波特率 9600、数据位 8、校验 None、停止位 1。这里任何一个参数和单片机不一致Modbus Poll 会一直报超时而单片机这边其实什么都没收到。然后在 Setup 菜单的 Read/Write Definition 里配置读写参数从站地址 1功能码 03起始地址 0读取数量 10。轮询间隔设 1000ms 够用设太短会把自己电脑的串口缓冲打满。配置完成后点 OK软件开始周期性发送请求帧正常状态下左侧寄存器列表会有数据变化。如果显示超时或异常按下面的表排查。5.2 四种典型故障的现象与定位思路现象定位思路一直显示 Timeout先看 DE 方向是否拉在发送模式再看 A/B 是否接反最后用示波器确认 STM32 的 TX 脚有没有波形显示 CRC Error校验位不一致最常见其次检查停止位再查接收程序是不是把帧切断了定时器超时设得太短会频繁触发返回 83 02 错误83 是 03 的异常响应02 是非法数据地址说明从站收到了帧且 CRC 通过是寄存器地址越界返回 83 01 错误01 是非法功能码从站收到了帧但没实现 03 功能码去查 build_response 的实现CRC Error 和 83 02 是两类完全不同的现象。前者说明从站解析出的帧整体错位后者说明从站正确解析了帧但业务逻辑拒绝。看到 83 开头反而是好事说明串口、RS485 方向、CRC 校验这条链路全通了。5.3 用串口助手直接发帧手工验证从站的正确姿势没有 Modbus Poll 时普通串口助手也能验证。把单片机接到 USB 转 TTL串口助手设置 9600 8N1勾选 HEX 发送手动发送一帧读请求发送01 03 00 00 00 0A C5 CD其中 01 是地址03 是读保持寄存器00 00 是起始地址00 0A 是数量 10C5 CD 是对前 6 个字节算出的 CRC。正常响应是接收01 03 14 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 xx xx0x14 是十进制 20表示后面跟 20 个数据字节。如果寄存器表里全初始化为 0数据区就是 20 个 00最后两个字节是 CRC。这里有一个手工验证的小技巧先只发地址和功能码试试错误帧比如发01 04 00 00 00 0A xx xx04 功能码没实现时应该收到01 84 01从这个响应能反向确认从站程序里异常响应的路径也是通的。6. 进阶用法用 DMA 空闲中断接收 Modbus 帧替代定时器方案6.1 关掉半传输中断避免 DMA 把一帧拆成两半定时器超时方案在 9600 和 115200 波特率下都能稳定工作但有个隐性问题每个字节都触发一次 UART 中断高波特率下 CPU 频繁进出中断如果主循环里还有 ADC 采样或显示刷新任务偶尔会碰巧把定时器超时响应延迟掉。比较稳的替代方案是 DMA 空闲中断。串口空闲中断是硬件在总线空闲超过 1 字节时间时触发的天然对应 Modbus 的帧间隔配合 DMA 收字节完全不占 CPU。HAL 库里启用方式是调用HAL_UARTEx_ReceiveToIdle_DMA接收完成或空闲中断触发时进入回调。关键一步是要关闭 DMA 的半传输中断否则缓冲区过半时会触发一次事件把一帧数据在中间拆成两半处理。/* 初始化时启动 DMA 接收 */ HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, sizeof(rx_buf)); __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT); /* 关半传输中断 */ /* 空闲中断回调Size 是实际收到字节数 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { rx_len Size; /* 一帧的字节数 */ frame_ready 1; /* 主循环处理帧 */ /* 重新启动下一轮 DMA 接收 */ HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, sizeof(rx_buf)); } }使用 DMA 方案要确认hdma_usart1_rx的实例名和 CubeMX 里生成的一致然后在回调里不要做耗时操作只置标志位。接收缓冲区大小必须大于 Modbus 最大帧 256 字节我一般给 300DMA 接收地址是缓冲数组首地址Size是硬件写出的计数值不需要像中断方案那样自己维护rx_len累加。这个方案还能顺带解决一个定时器方案很难处理的边界问题如果对方发的帧超过缓冲区大小DMA 会溢出停止此时需要检测串口错误中断并重新初始化 DMA。定时器和 DMA 两种接收方式可以共存调试时先用定时器方案把协议跑通再切到 DMA 方案优化 CPU 占用不用推倒代码重写只需要把接收入口函数换掉即可。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进