ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32实现MODBUS RTU主站读取RS485设备寄存器完整指南

2026/9/12 8:30:45 拓冰建站 浏览量
STM32实现MODBUS RTU主站读取RS485设备寄存器完整指南 简介面向STM32嵌入式开发者的MODBUS通信程序包围绕STM32F103通过RS485接口实现MODBUS从站与上位机读写交互涵盖协议解析、寄存器操作、串口收发及方向切换等关键环节。压缩包约26.93MB共1081个文件其中C源码353个、头文件279个配套汇编启动文件、链接脚本、工程文件uvproj/ewp以及txt说明、hex烧录文件、调试日志等便于直接导入Keil或IAR进行编译与下载验证。已有1482人浏览学习。内容不仅包含完整的MODBUS从站示例还涉及MAX485驱动、半双工UART配置、功能码处理03/06/10等及数据打包回传思路并附带上位机联调记录和常见问题注释。对正在做工业数据采集、PLC互联或远程监控项目的工程师是兼顾底层配置与协议应用的可直接参考的实战资料。1. 从一根双绞线说起STM32 的 MODBUS RTU 读操作到底在做什么标题里的STM32-MODBUS程序.zip看似一个压缩包实际指向的任务在工业现场每天会重复几千次一块 STM32 单片机通过 RS485 总线用 MODBUS RTU 协议去读另一台设备仪表、变频器、温控器里的寄存器。这类项目在毕业设计、小型工控网关、环境监测节点里极其常见难点从来不是“用 printf 发一串字符”而是把物理层、协议层、应用层组织成一个能在现场稳定跑的闭环。很多新手会犯一个认知错误以为 MODBUS 就是串口发数据忽略 RS485 是半双工、需要方向控制或者以为03功能码只是“读”却不知道从站返回的帧可能是双字节大端序寄存器数量不同帧长度还不固定。这篇文章不会帮你解压那个 zip但会给你一套自己写出来也能跑的方案从 RS485 收发电路的设计到 RTU 帧的 CRC16 计算再到 STM32 上完整的主站读取代码最后用 Modbus Slave / MODBUS Poll 做仿真验证并把最常见的掉坑点列成一张排查表。适合正在做 STM32 串口通信开发的工程师也适合需要快速在板子上调通 MODBUS 读操作的人。2. 硬件层RS485 收发电路与 STM32 的 USART 接线2.1 为什么 MODBUS RTU 简配 RS485 半双工通信MODBUS RTU 在设计时就没有规定物理层实际工业现场超过九成使用 RS485。RS485 是差分信号A、B 两线间的电压差表示逻辑 0/1抗共模干扰能力强传输距离可达 1200 米而且支持一主多从正好符合 MODBUS 的主从模型。但代价是 RS485 是半双工同一时刻只能有一个节点在总线上发送。STM32 的 USART 是 TTL 电平TX 和 RX 独立直接接 RS485 芯片后必须由控制器控制收发方向。常见的 RS485 芯片有 MAX485、SP3485、ISL83485引脚兼容。核心引脚是 DI接 USART_TX、RO接 USART_RX、DE发送使能、RE接收使能。DE 和 RE 通常连在一起用 STM32 的一个 GPIO 控制低电平接收高电平发送。提示如果你在原理图上看到 DE 和 RE 之间有一个 10kΩ 电阻下拉说明设计者希望默认处于接收状态避免上电瞬间误发送占用总线。2.2 用 GPIO 控制方向最稳的“手动收发”电路最常见的做法是分配一个输出推挽引脚例如 PB1连接到 DE/RE。发送前拉高发送完毕后拉低。下面以 STM32F103 为例示意连接表STM32 引脚RS485 芯片引脚说明PA9 (USART1_TX)DI发送数据PA10 (USART1_RX)RO接收数据PB1 (GPIO 输出)DE RE高电平发送低电平接收GNDGND共地3.3VVCC芯片供电SP3485 支持 3.3V注意 RS485 总线两端各需接一个 120Ω 终端电阻。如果只有一个设备在调试建议在 STM32 端的 A、B 之间接上 120Ω否则信号反射会导致误码。若总线上节点多只在物理最远的两端接。// 让 RS485 进入发送模式 #define RS485_TX_EN() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET) // 让 RS485 进入接收模式 #define RS485_RX_EN() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET)发送一帧数据的顺序是先RS485_TX_EN()然后调用串口发送函数等发送完成后TXE 和 TC 标志都置位再RS485_RX_EN()。如果提前切换方向最后一个字节会被截断。用 HAL 库时HAL_UART_Transmit返回HAL_OK只代表数据进入了发送移位寄存器并不代表物理上已经发完保险做法是等待__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC)置位后再关发送。2.3 免方向控制的“自动收发电路”适合低速不可全信网上很多 STM32 项目为了省一个 GPIO会用三极管或 MOS 管搭“自动收发电路”把 TXD 信号经过反相驱动到 DE发送起始位低电平自动使能发送发送结束恢复接收。这种电路在 9600 波特率下可用但在 115200 以上或总线上有多个从站时容易因为方向切换时延产生错帧。工业级产品很少用这种方案原因很简单DE/RE 控制逻辑必须与收发时机精确配合一旦 UART 空闲电平配置不当芯片会频繁切换。如果你用自动收发电路应选择带有“无延时自动换向”功能的 RS485 收发器例如 MAX13487E。它对发送结束到接收的切换时间有内部优化但仍不建议在关键场合使用。本篇文章后续代码以 GPIO 控制为准这也是最容易排查的电路结构。3. 协议层MODBUS RTU 帧结构与 03 功能码解析3.1 RTU 帧的字节组成一个都不能错MODBUS RTU 帧没有起始符和结束符靠“帧间空闲时间”区分。一帧由四部分组成地址1 字节、功能码1 字节、数据N 字节、CRC162 字节低字节在前。主站发送完一帧后从站在 3.5 个字符时间内开始应答两个字节之间如果超过 1.5 个字符时间接收方会认为帧结束。这是协议层最容易踩的坑用串口助手手动发送十六进制串时字节间隔如果太大从站就会丢弃。以读取保持寄存器为例主站请求帧格式为字节偏移内容示例值0从站地址0x011功能码0x032起始寄存器高字节0x003起始寄存器低字节0x004寄存器数量高字节0x005寄存器数量低字节0x026CRC 低字节0xC47CRC 高字节0x0B响应帧格式为地址、功能码、字节数寄存器数 × 2、寄存器值高字节在前、CRC。例如从站返回01 03 04 00 1A 00 2B C5 F1表示读取到两个寄存器值分别为 0x001A 和 0x002B。注意寄存器值是大端序高字节在前这与 STM32 内存中的小端序相反解析时用(uint16_t)( (buf[3] 8) | buf[4] )而不要直接 memcpy 后转换否则字节序会答非所问。3.2 CRC16 的 C 语言实现直接抄进工程MODBUS RTU 的 CRC 是 CRC16-IBM多项式 0xA001初始值 0xFFFF。计算时对每个字节先与 CRC 低字节异或再右移 8 次每次检测最低位为 1 则与多项式异或。uint16_t modbus_crc16(const 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的 CRC再与收到的 CRC 值比较也可以把收到的 CRC 计进整帧再算结果为 0 则正确。3.3 功能码 03 与 04 的区别读保持寄存器 vs 读输入寄存器很多 STM32 初学者把03和04混用。区别在于寄存器属性保持寄存器Holding Register可读可写地址映射通常是 4xxxx 区输入寄存器Input Register只读映射到 3xxxx 区。如果从站只实现了输入寄存器你用03功能码请求从站会返回异常码 0x02非法数据地址。反之亦然。在任何工厂设备上要先查设备 MODBUS 寄存器表确认目标寄存器属于哪一类再选择功能码。本篇文章以03为例04的帧格式几乎一样只是功能码不同。4. 代码层STM32 上实现 MODBUS 主站读取程序4.1 初始化 USART 与 RS485 方向控制引脚假设使用 STM32F103C8T6CubeMX 配置 USART1 为异步模式波特率 9600数据位 8停止位 1无校验。PB1 配置为输出推挽、初始电平为低。注意波特率MODBUS 的 RTU 模式波特率可以是 1200、2400、4800、9600、19200 等现场最常用 9600这会影响后续超时时间计算。// main.c 中的串口初始化片段 void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 9600; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart1); }注意 STM32 的 USART 与 RS485 芯片之间不需要任何电平转换但必须确认两者的参考电平一致。如果 RS485 芯片是 5V 供电而 STM32 是 3.3V需要检查芯片 DI/RO 引脚是否兼容 TTL 电平必要时加电平转换。多数 3.3V 供电的 SP3485 可以直接匹配。4.2 发送请求帧并等待响应发送用阻塞接收用中断主站读取流程分为三步发送请求帧、等待响应、解析。发送最好用阻塞方式因为请求帧只有 8 个字节9600 波特率下耗时约 8.3msCPU 等得起。接收不能使用简单的阻塞HAL_UART_Receive因为响应帧长度未知而且从站可能不响应需要超时保护。推荐方案发送后打开接收中断在中断回调里把收到的字节存入环形缓冲区主循环中解析帧。这里先写发送部分uint8_t build_read_holding_request(uint8_t slave_addr, uint16_t start_reg, uint16_t reg_count, uint8_t *frame) { frame[0] slave_addr; frame[1] 0x03; frame[2] (start_reg 8) 0xFF; frame[3] start_reg 0xFF; frame[4] (reg_count 8) 0xFF; frame[5] reg_count 0xFF; uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; frame[7] crc 8; return 8; } void modbus_read_holding_registers(uint8_t slave_addr, uint16_t start_reg, uint16_t reg_count) { uint8_t frame[8]; uint8_t len build_read_holding_request(slave_addr, start_reg, reg_count, frame); RS485_TX_EN(); HAL_UART_Transmit(huart1, frame, len, 100); while (!__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC)) { // 等待最后一个字节完全移出移位寄存器 } RS485_RX_EN(); // 清空接收缓冲后启动中断接收 ring_buffer_reset(rx_ring); memset(rx_buf, 0, sizeof(rx_buf)); HAL_UART_Receive_IT(huart1, rx_buf, 1); // 每次只收 1 字节模拟字节流 }这里的关键是__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC)TC 标志表示发送完成。HAL 库的HAL_UART_Transmit在数据塞进 DR 寄存器后就返回如果此时把 DE 拉低最后一个字节还在移位寄存器里帧尾会被切断。4.3 接收完整帧用环形缓冲区 空闲时间判断由于不知道响应帧有多长我习惯在中断里逐字节接收并记录每个字节的时间戳。当两个字节之间的时间超过 3.5 个字符时间9600 波特率下约 4ms就认为一帧结束了。uint8_t rx_byte; uint32_t last_byte_time 0; uint8_t frame_received 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { ring_buffer_write(rx_ring, rx_byte); last_byte_time HAL_GetTick(); HAL_UART_Receive_IT(huart1, rx_byte, 1); // 继续接收下一个字节 } } // 主循环中检查帧是否结束 void check_frame_complete(void) { if (ring_buffer_len(rx_ring) 0) { uint32_t now HAL_GetTick(); if (now - last_byte_time 3) { // 3ms 空闲对于 9600 波特率满足 3.5 字符时间 process_modbus_frame(rx_ring); ring_buffer_reset(rx_ring); } } }process_modbus_frame中先判断帧长度响应帧长度 5 2 × 寄存器数量再验证地址、功能码然后算 CRC。如果 CRC 错误直接丢弃等待超时重发。提示3ms 空闲时间在 9600 波特率下略小于 3.5 个字符时间实际现场可以使用 5ms。从站应答延迟可能超过这个时间但空闲判断只针对帧内字节间隔不应影响等待响应。4.4 解析响应帧并提取寄存器值int parse_read_holding_response(uint8_t *frame, uint16_t frame_len, uint16_t *values, uint16_t reg_count) { if (frame_len 5) return -1; if (frame[1] 0x83) { // 异常响应 return -2; } uint8_t byte_count frame[2]; if (byte_count ! reg_count * 2) return -3; uint16_t crc_recv frame[frame_len - 2] | (frame[frame_len - 1] 8); uint16_t crc_calc modbus_crc16(frame, frame_len - 2); if (crc_recv ! crc_calc) return -4; for (uint16_t i 0; i reg_count; i) { values[i] (frame[3 i * 2] 8) | frame[3 i * 2 1]; } return 0; }注意异常响应的功能码是请求功能码 0x80例如0x83表示读写异常异常码在第三字节。很多初学者看到0x01地址开头的帧而不检查功能码的高位会把异常帧当正常数据处理。5. 调试与验证用 Modbus Slave 和示波器把问题逼出来5.1 用 Modbus Slave 模拟从站不需要真实仪表Modbus Slave也叫 ModbusPoll 的兄弟工具可以在 PC 上虚拟一个从站监听某个串口。设置步骤是新建连接选择串口比如 COM5波特率设成与 STM32 工程一致 9600数据格式 8N1从站地址设为 1。然后手动添加寄存器例如在地址 0 和 1 处各添加一个类型为 Holding Register 的变量初始值 0x1234 和 0xABCD。这时把你的 STM32 主站通过 USB 转 RS485 模块连接到 PC 的 RS485 转换器上。注意PC 端的 USB 转 RS485 模块同样需要 A/B 正确对接A 接 AB 接 B共地。不少调试失败的原因是 A/B 接反表现为完全收不到响应。5.2 用 MODBUS Poll 验证主站侧接收逻辑如果你要验证的是自己的 STM32 主站程序可以用 Modbus Poll 作为主站去读一个已知从站但这个用途不直接。更趁手的方法是在 STM32 里把收到的原始帧通过另一个串口打印到 PC 终端对比 Modbus Slave 发送的字节。如果不方便接第二路串口就在代码里加一个断言用调试器的 Watch 窗口查看rx_buf内容。MODBUS Poll 桌面上可以按字节查看也可以设置轮询周期。如果你想模拟“从站无响应”在 Modbus Slave 里把从站地址设成 2而 STM32 请求地址为 1就能测试超时重发逻辑。记住工具只是辅助关键是理解帧结构。5.3 常见故障与排查表现象可能原因排查动作完全无响应A/B 接反或 DE/RE 方向不切换用示波器或逻辑分析仪看 RX 引脚是否有数据响应乱码波特率不一致或从站不是 RTU 而是 ASCII 模式核对双方波特率检查帧间字节间隔偶发性 CRC 错误RS485 终端电阻缺失或干扰两端加 120Ω 终端电阻使用屏蔽双绞线最后一个字节丢失发送完成后过早切换方向确认 TC 标志后再拉低 DE收到异常码 0x02起始地址或寄存器数量超出从站范围查阅设备寄存器表调整请求参数5.4 提高可靠性的几个习惯一是在 MODBUS 主站里加超时重发机制。以 9600 波特率为例读取 2 个寄存器的响应最长约 10ms超时设为 200ms 足够。超过计时未收到帧就重新发送请求连续 3 次失败置位通信故障标志。二是合理拆分读取长度。MODBUS RTU 标准规定一次读寄存器最多 125 个功能码 03 时。如果读取范围很大不建议一次拉取而是分多次读取每次限制在几十个寄存器内降低错误帧影响面。三是处理好串口空闲中断。如果使用的 STM32 型号支持 USART 空闲中断如 F4、L4 系列接收可以在UART_IDLE中断中判断帧结束比定时器判断更精确。用 HAL 库时开启HAL_UART_Receive_DMA并配合空闲中断HAL_UARTEx_RxEventCallback会返回接收字节数这是量产代码常见的做法。若用 F103 这种没有空闲中断的型号则保持字节中断 tick 计时即可。本文还有配套的精品资源点击获取