news 2026/9/12 5:57:06

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 一帧数据的固定切分:地址、功能码、数据区、CRC

Modbus RTU 帧是“定字节序、不定长度”。地址 1 字节,功能码 1 字节,数据区 N 字节,CRC 校验 2 字节。读保持寄存器的请求帧长度固定是 8 字节,但响应帧长度取决于读了多少个寄存器,所以解析时不能按固定字节数去等接收完成。

看一个最典型的读请求帧,主机要读 1 号从站的保持寄存器,从地址 0x0000 开始读 10 个:

字段说明
从站地址01目标设备 ID,范围 1~247,0 为广播地址
功能码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,很多程序例程里却写的是偶校验,这是串口收发一堆乱码的首要原因。

串口参数
ModeAsynchronous
Baud Rate9600
Word Length8 Bits
ParityNone
Stop Bits1
全局中断USART1 global interrupt 打开

定时器用 TIM6,这是基本定时器,不开引脚,专门做超时判断。72MHz 主频下预分频设 71,得到 1MHz 计数时钟,自动重载值 ARR 设 4000,溢出周期为 4ms。CubeMX 里对应关系是 Prescaler=71,Counter Period=4000。如果板子主频不是 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_COUNTERHAL_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 转 RS485:MAX3485 与 STM32 的引脚对应

STM32 的 UART 引脚输出的是 TTL 电平,RS485 总线需要差分信号,中间要通过收发器芯片转换,最常见的是 MAX3485 或兼容的 SP3485。这类芯片是 3.3V 供电,可以直接用 STM32 的电源,不需要额外电平转换。引脚对应关系固定如下:

MAX3485 引脚接 STM32/总线说明
RORX (PA10)接收数据输出,A-B 为高时输出 1
RE#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(); /* DE=1,进入发送模式 */ HAL_UART_Transmit(&huart1, buf, len, 100); /* 必须等 TC:HAL 返回不代表最后一个字节已发完 */ 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 发送的字节全是高电平,比如 0xFF,DE 无法被置高,驱动器不会使能。好在 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 是数量 10,C5 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 xx

0x14 是十进制 20,表示后面跟 20 个数据字节。如果寄存器表里全初始化为 0,数据区就是 20 个 00,最后两个字节是 CRC。这里有一个手工验证的小技巧:先只发地址和功能码试试错误帧,比如发01 04 00 00 00 0A xx xx,04 功能码没实现时应该收到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 字节,我一般给 300,DMA 接收地址是缓冲数组首地址,Size是硬件写出的计数值,不需要像中断方案那样自己维护rx_len累加。

这个方案还能顺带解决一个定时器方案很难处理的边界问题:如果对方发的帧超过缓冲区大小,DMA 会溢出停止,此时需要检测串口错误中断并重新初始化 DMA。定时器和 DMA 两种接收方式可以共存,调试时先用定时器方案把协议跑通,再切到 DMA 方案优化 CPU 占用,不用推倒代码重写,只需要把接收入口函数换掉即可。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 5:56:43

基于 Firefox 的隐私浏览器定制:从 user.js 到防指纹配置实战

做浏览器这件事&#xff0c;听起来是个大工程&#xff0c;但放到今天这个开源生态里&#xff0c;门槛其实已经被压得很低了。我去年一直在维护自己的一个私用浏览分支&#xff0c;名字就叫 camofox-browser&#xff0c;camo 取自 camouflage&#xff08;伪装&#xff09;&#…

作者头像 李华
网站建设 2026/9/12 5:54:25

AgentScope Agent Service 怎么接入钉钉渠道?

AgentScope Agent Service 怎么接入钉钉渠道&#xff1f; 【免费下载链接】agentscope Build and run agents you can see, understand and trust. 项目地址: https://gitcode.com/GitHub_Trending/ag/agentscope 要把 AgentScope 2.0 的 Agent Service 接到钉钉里&…

作者头像 李华
网站建设 2026/9/12 5:52:10

wxPython进销存系统实战:轻量桌面方案落地中小商户

简介&#xff1a;这是一份面向Python初学者的wxPython GUI开发实战学习资源&#xff0c;聚焦进销存管理系统这一典型企业级应用&#xff0c;帮助开发者掌握跨平台桌面程序的设计与实现。资源共206个文件&#xff0c;包含12个核心Python源码&#xff08;如main.py入口、MainPane…

作者头像 李华
网站建设 2026/9/12 5:51:58

技术团队如何评估与拒绝不合理需求

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华