☰
RS485总线Modbus RTU从机实现:STM32工程实战与避坑指南
2026/10/20 12:39:01 网站建设 项目流程

简介:面向 STM32 嵌入式开发者的 RS485-Modbus 通信控制工程,基于 STM32 HAL 库实现 RS485 接口驱动与 Modbus RTU 协议,并用于中菱轮毂电机 ZLAC8015D 的控制,开发环境为 Clion。整个压缩包共 187 个文件、约 1015KB,包含 .c/.h 源码、CMake 构建脚本、.ioc 工程配置,以及 .bin/.elf/.hex 等可直接烧录的编译产物,另有 .map/.ld 链接文件与编译中间文件,便于核对工程结构和学习底层配置。项目完整展示了从串口参数设置、RS485 收发器驱动到 Modbus 请求报文构造与解析的实现过程,还涉及错误处理和重试机制,适合希望深入掌握 STM32 工业通信、Modbus 协议栈移植或 Clion 嵌入式工作流的开发者参考。目前已有 1614 人学习下载,对于工业控制、电机驱动和嵌入式协议开发场景具有不错的借鉴价值。

1. 一主多从的 RS485 采集场景:这个 Modbus 从机工程能做什么

如果你手头的设备需要和上位机对话,现场已经铺好了两根线的 RS485 总线,那最省事的协议就是 Modbus RTU。这个项目把一块 STM32F103 通过 SP3485 收发器挂到 RS485 总线上,固件完整实现了 Modbus 从机协议——读保持寄存器、写单个寄存器、写多个寄存器都支持,CRC 校验和异常响应也齐全。整套代码用 CLion 管理,CMake 编译,OpenOCD 烧录和调试,不用在多个工具之间来回切。适合刚接触 STM32 通信的新手,也适合做采集节点想快速上 Modbus 的工程师。

2. 硬件链路与工程骨架:RS485 方向切换为什么不能靠延时

2.1 RS485 半双工的硬件接法:SP3485 与 STM32 的信号映射

RS485 和串口不一样,它是半双工的差分总线,同一时刻只能有一个节点往总线上发数据。STM32 的 UART 是单端信号,所以要加一颗收发器把 TTL 电平转成差分电平。项目里用 SP3485,它工作在 3.3V,正好直接对接 STM32 的 IO,不需要额外电平转换。

接线关系不复杂,我把连接关系列成一张表,方便你对照着自己的板子检查:

SP3485 引脚功能接到 STM32备注
RO接收输出PA10(USART1_RX)总线上的差分信号转换成 TTL 给 MCU 读
DI发送数据输入PA9(USART1_TX)MCU 发送的数据从这里进收发器
RE接收使能,低有效与 DE 并接到 PA8拉低进入接收模式
DE发送使能,高有效与 RE 并接到 PA8拉高进入发送模式
A差分信号正端接 USB 转 485 模块的 A接反会导致通信失败
B差分信号负端接 USB 转 485 模块的 B接反会导致通信失败

DE 和 RE 在 SP3485 内部是互补逻辑,一个高有效一个低有效,把它们并在一起后,一个 GPIO 就能控制方向:PA8 输出高电平就是发送,输出低电平就是接收。这是个很关键的设计点,后面避坑章节里出现的“连续请求超时”,一半以上和这个引脚的切换时机有关。

另外注意总线末端最好放一颗 120 欧姆终端电阻,尤其是当现场线缆超过几十米或者波特率跑到 38400 以上的时候。没加终端电阻的表现很恶心——短距离测试一切正常,拉长线后就偶发丢帧,而且用示波器看不出明显异常,只有 A-B 差分信号边沿变圆润了。

2.2 一发一收的方向切换:发送完成回调里拉低 DE

方向切换最朴素的做法是发送前拉高 DE,发送函数执行完再拉低。刚开始我确实这么干过,代码长这样:

void rs485_send_frame_blocking(uint8_t *buf, uint16_t len) { HAL_GPIO_WritePin(RS485_DIR_GPIO_Port, RS485_DIR_Pin, GPIO_PIN_SET); HAL_UART_Transmit(&huart1, buf, len, 100); HAL_GPIO_WritePin(RS485_DIR_GPIO_Port, RS485_DIR_Pin, GPIO_PIN_RESET); }

看起来没毛病,但实际用 Modbus Poll 连续读写时,第二次请求就超时。原因是 HAL_UART_Transmit 只要把数据交给发送移位寄存器就返回了,最后一个字节可能还在移位寄存器里没真正从 TX 引脚发完,可我已经把 DE 拉低,收发器切回接收模式,帧尾被截断。

后来改成中断发送,在发送完成回调里拉低方向引脚,逻辑就顺了:

static volatile uint8_t tx_busy = 0; void rs485_set_dir(uint8_t tx) { HAL_GPIO_WritePin(RS485_DIR_GPIO_Port, RS485_DIR_Pin, tx ? GPIO_PIN_SET : GPIO_PIN_RESET); } void rs485_send_frame(uint8_t *buf, uint16_t len) { while (tx_busy) {} // 上一帧还没发完,等 tx_busy = 1; rs485_set_dir(1); // 切到发送 HAL_UART_Transmit_IT(&huart1, buf, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { tx_busy = 0; rs485_set_dir(0); // 发完立刻回到接收 } }

这里 HAL_UART_Transmit_IT 是非阻塞发送,函数调用后立刻返回,真正发完时进入 TxCpltCallback。此时拉低 DE 才保证整帧完整地送到了总线上。tx_busy 是一个标志量,为避免重入,发送前要等它为 0;简单场景下也可以不加,但加了这个标志,后续接轮询逻辑会省心很多。

2.3 工程目录一棵树:从 main 到 modbus 模块的调用边界

这个项目虽然是 CubeMX 生成的标准结构,但 modbus 协议部分单独抽了一套文件,没有和 main.c 混在一起。目录结构大致如下:

STM32-RS485-Modbus/ ├── CMakeLists.txt ├── openocd.cfg ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── modbus.h │ │ └── crc16.h │ └── Src/ │ ├── main.c │ ├── modbus.c │ └── crc16.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ └── build/

main.c 里只做三件事:初始化时钟和 GPIO、初始化 UART 和 DMA 接收、在 while(1) 主循环里调用 modbus_poll()。真正的协议解析全部在 modbus.c 里,CRC 计算在 crc16.c 里。

这种分层的思路对开发很有用:uart 接收到的原始字节是“物理层”的事情,modbus_poll 的解析是“协议层”的事情,registermap 又是一个独立话题。改业务逻辑时你基本不用碰 DMA 和中断,改通信细节时也不用担心碰坏协议。我第一次写的时候图省事把 CRC 和功能码处理全塞进中断回调里,后来查问题查到怀疑人生,才意识到模块边界一开始就要划清楚。

3. Modbus 核心实现:从一帧数据进入到响应返回的完整链路

3.1 帧格式与 CRC16:先算对校验,后面都是水到渠成

Modbus RTU 的帧格式很紧凑:地址 1 字节、功能码 1 字节、数据 N 字节、CRC16 低字节在前。比如上位机读从机保持寄存器的请求帧是01 03 00 00 00 02,这 6 个字节最后必须拼上 CRC 才是完整的一帧。我习惯先用 CRC 计算器验证一组已知数据:01 03 00 00 00 02的 CRC 是0x0BC4,发送顺序是C4 0B,这一条能帮你确认移植的代码没写反字节序。

CRC16-Modbus 用的是多项式 0xA001,初值 0xFFFF,逐位法实现如下:

uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; // Modbus 规定初值为 0xFFFF while (len--) { crc ^= *data++; // 异或到最低位 for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) // LSB 为 1,右移后异或多项式 crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; } uint8_t check_crc16(const uint8_t *frame, uint16_t len) { uint16_t crc = crc16_modbus(frame, len - 2); // 对前面所有字节算 uint16_t recv = (uint16_t)frame[len - 1] << 8 | frame[len - 2]; // 低字节在前 return (crc == recv); }

注意这里的两个细节:校验时不包含帧尾的 CRC 本身,计算范围是 len - 2;接收帧里的 CRC 是低字节在前,所以组合 recv 时要让最后一个字节成为高 8 位。很多新手上手踩的第一个坑就是把 CRC 异或顺序搞反,结果调出“时通时不通”的玄学问题。

另一个容易被忽略的点:收到 CRC 错误的帧时,从机应当直接丢弃,不回任何数据。Modbus 协议里只有 CRC 正确的帧才需要响应,校验失败还硬要回帧,会让对端解析卡死。

3.2 帧边界判定:用串口空闲中断代替 1.5/3.5 字符定时

Modbus RTU 是帧之间的间隔来划分消息的,协议里要求帧内字符间隔不超过 1.5 个字符时间,帧与帧之间至少 3.5 个字符时间。常规做法是用一个定时器去量字符间隔,但我更推荐用串口的空闲中断(IDLE)来判断一帧结束:USART 检测到总线上一个字节之后持续空闲,就触发空闲中断,此时说明上位机已经把这一帧发完了。

项目里用的是 DMA 接收加空闲中断,代码核心就一段:

#define RX_BUFFER_SIZE 256 uint8_t rx_buf[RX_BUFFER_SIZE]; volatile uint16_t rx_len = 0; volatile uint8_t frame_ready = 0; void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_IDLE) // 总线进入空闲状态,一帧结束 { USART1->SR &= ~USART_SR_IDLE; // 清除空闲标志 rx_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); frame_ready = 1; // 置位交给主循环处理 } HAL_UART_IRQHandler(&huart1); }

逻辑说明:DMA 一直在往 rx_buf 里搬数据,当你发现串口空闲时,DMA 计数器相比初始值减少的数量就是实际收到的字节数。用这种方式,无论上位机发 6 字节还是 20 字节,从机都能在整帧到达后立刻知道帧长度。

参数上要留意 RX_BUFFER_SIZE 必须大于单条 Modbus 报文最大长度,常见从机就 256 足够;如果寄存器数量多导致单帧超过缓冲区长度,DMA 会溢出丢数据,那就得改大。另一种备选方案是逐字节中断加定时器超时,但代码复杂度更高,DMA 加空闲中断已经能覆盖绝大多数场景。

帧准备好后,要在主循环里尽快处理而不是在中断里处理,这关系到第四节会细说的并发问题。主循环里只需做一件事:

if (frame_ready) modbus_poll();

3.3 功能码分发与寄存器映射:0x03、0x06、0x10 的完整分支

modbus.c 的核心是一个分发函数。我把它写成了独立函数,帧校验、地址匹配、功能码处理分成三层,每层只关心一件事:

void modbus_poll(void) { uint8_t *req = (uint8_t *)rx_buf; uint16_t req_len = rx_len; if (req_len < 4) // 最短帧:地址 + 功能码 + 2 字节 CRC return; if (req[0] != slave_addr) // 地址不匹配,说明不是发给自己的,直接丢 return; if (!check_crc16(req, req_len)) // CRC 不过,丢弃 return; switch (req[1]) // 根据功能码分发 { case 0x03: handle_read_holding_registers(req); break; case 0x06: handle_write_single_register(req); break; case 0x10: handle_write_multiple_registers(req); break; default: build_exception(0x01); break; // 非法功能码 } }

check_crc16 的逻辑前面讲过,这里多说一句地址匹配:从机地址是 0x01,但上位机下发时第二字节是功能码,所以地址匹配不要写成req[0] == 0x01 && req[1] == 0x03这样把功能码也卷进来,独立判断更容易维护。

寄存器表我直接用一个数组承载,数组下标对应 Modbus 寄存器地址偏移:

#define REG_HOLDING_COUNT 16 uint16_t holding_regs[REG_HOLDING_COUNT]; // 保持寄存器,可读可写 void handle_read_holding_registers(uint8_t *req) { uint16_t start = (req[2] << 8) | req[3]; // 起始地址 uint16_t count = (req[4] << 8) | req[5]; // 寄存器数量 if (start + count > REG_HOLDING_COUNT) // 越界,回异常码 0x02 { build_exception(0x02); // 非法数据地址 return; } uint8_t *rsp = tx_buf; rsp[0] = slave_addr; rsp[1] = 0x03; rsp[2] = count * 2; // 字节数,每个寄存器占 2 字节 for (uint16_t i = 0; i < count; i++) { rsp[3 + i * 2] = holding_regs[start + i] >> 8; // 高字节在前 rsp[3 + i * 2 + 1] = holding_regs[start + i] & 0xFF; } uint16_t len = 3 + count * 2; uint16_t crc = crc16_modbus(rsp, len); rsp[len] = crc & 0xFF; rsp[len + 1] = crc >> 8; rs485_send_frame(rsp, len + 2); }

这里有个 Modbus 的硬性规定:多字节数据在帧里都是高字节在前,所以写响应时要先放高 8 位再放低 8 位,一个翻转都会让上位机读到完全不对的数据。

写单个寄存器的处理思路类似,只是数据字段变成 4、5 两个字节,并且要对地址和值做校验:寄存器地址越界回异常码 0x02;写入值非法(比如控制字只允许 0 或 0x0001)回异常码 0x03,也就是非法数据值。写多个寄存器 0x10 要多解析一个“字节数”字段,并且要检查字节数是否等于寄存器数量乘以 2,不等就说明这个帧结构本身有问题,直接丢弃比回异常更安全。

3.4 响应帧的拼接顺序:从地址到 CRC 为什么不能乱

响应帧的拼装顺序看起来简单,但我见过不少人在 CRC 后面又补了多余字节,或者把 CRC 的高低位放反。响应帧一定是:地址、功能码、数据区、CRC 低字节、CRC 高字节。以 0x06 写单个寄存器的成功响应为例:

void handle_write_single_register(uint8_t *req) { uint16_t reg_addr = (req[2] << 8) | req[3]; uint16_t reg_val = (req[4] << 8) | req[5]; if (reg_addr >= REG_HOLDING_COUNT) { build_exception(0x02); // 地址越界 return; } holding_regs[reg_addr] = reg_val; // 成功响应:原样回显请求帧 for (int i = 0; i < 6; i++) tx_buf[i] = req[i]; uint16_t crc = crc16_modbus(tx_buf, 6); tx_buf[6] = crc & 0xFF; tx_buf[7] = crc >> 8; rs485_send_frame(tx_buf, 8); }

0x06 的协议规定成功响应的数据区就是请求的完整回显,所以直接把接收到的 6 字节拷到发送缓冲即可。这样写有两个好处:不用再单独构造地址和数据区,避免手误;同时天然保证了响应和请求的一致性,上位机拿着请求一比对就能确认写入成功。

build_exception 的拼装逻辑也值得一提:异常响应帧是地址、功能码或 0x80、异常码、CRC。比如功能码 0x03 的非法功能码响应就是01 83 01 C0 0B这样的结构。这里功能码最高位置 1,上位机才能区分“正常响应”和“异常响应”。

4. CLion 环境配置与烧录联调:从 CMake 到 Modbus Poll 一条龙

4.1 从 STM32CubeMX 生成 CMake 工程再交给 CLion

CLion 本身不直接认识 .ioc 文件,需要先用 STM32CubeMX 生成工程。新版的 CubeMX 在 Project Manager 里能把 Toolchain 选成 CMake,直接生成带 CMakeLists.txt 的标准工程;如果版本生成的还是 Makefile 工程,CLion 也能导入,本质都是调用交叉编译工具链。

生成后的 CMakeLists.txt 有几个关键位置。打开后你会看到类似这样的片段:

add_compile_definitions(STM32F103xB USE_HAL_DRIVER) add_executable(STM32-RS485-Modbus Core/Src/main.c Core/Src/modbus.c Core/Src/crc16.c Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal.c ... ) target_link_libraries(STM32-RS485-Modbus m)

STM32F103xB 这个宏决定了芯片型号的外设寄存器定义,必须和实际芯片对应;USE_HAL_DRIVER 是 HAL 库的开关,没有它整个 HAL 头文件都编不过。如果你的项目是 F103 的其他型号,比如大容量的 ZET6,这里是 STM32F103xE,不要照抄。

CLion 打开工程后,先在 Settings 的 Toolchain 里选到 arm-none-eabi-gcc。Windows 下我直接装了 STM32CubeCLT,它把编译器和 OpenOCD 一起打包了,省去手工配环境变量的时间。工具链配好后,CLion 左上角的 build 图标亮起来,就能正常编译了。第一次编会拉一堆 HAL 源文件进来,可能有一两个告警,不影响生成 elf。

4.2 OpenOCD 烧录与 GDB 调试:一条命令跑通

烧录这件事在 CLion 里可以配置成 Run Configuration 自动执行,但为了排查问题,我经常直接在终端里手动敲 OpenOCD 命令:

openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program build/STM32-RS485-Modbus.elf verify reset exit"

参数说明:interface/stlink.cfg 告诉 OpenOCD 调试器是 ST-Link;target/stm32f1x.cfg 指定目标芯片是 STM32F1 系列;program 后面的 elf 文件路径要按实际 build 目录调整;verify 在烧录后自动校验固件是否写对;reset 让芯片复位运行;exit 执行完退出 OpenOCD。

如果这块报错先别急着改代码,check 两处:SWDIO 和 SWCLK 两根线是不是接对了,ST-Link 的 3.3V 和 GND 有没有和开发板连一起。经常有新手只接了两根信号线没接地,调试器根本枚举不到目标芯片。

想在 CLion 里打断点调试,就新建一个 Embedded GDB Server 类型的 Run Configuration,Debugger 选 arm-none-eabi-gdb,GDB Server 选 openocd 的可执行文件,其余参数和命令行一致。设好之后点调试按钮,就能直接看 modbus_poll 里每个变量的值,比加串口打印高效太多。这也是我推荐用 CLion 做嵌入式开发的核心理由——代码、编译、调试都在一个窗口里。

4.3 上位机联调:Modbus Poll 的串口参数和寄存器地址怎么填

固件烧进去以后,第一件事不是写应用,而是用 Modbus Poll 验证从机行为。Modbus Poll 是 Windows 上调试 Modbus 从机最常用的上位机,免费版够用,配置项不多:

配置项推荐值说明
Serial Port实际 USB 转 485 的 COM 口设备管理器里看,别选错
Baudrate9600与固件里的波特率一致
Data Bits8Modbus RTU 固定 8 位数据位
ParityEven很多现场使用 8E1,也可以按需改 None
Stop Bits1同上
Slave ID1对应固件里 slave_addr
Function03先读保持寄存器验证基本通路
Address0起始寄存器地址
Quantity8一次读 8 个寄存器

连接后点 Read 按钮,如果看到寄存器值稳定显示,说明物理链路、协议栈、CRC 校验整个通路是通的。先别急着测写功能,把读功能跑稳,再去点 Write 写单个寄存器验证 0x06 分支,最后测 0x10 写多个寄存器。调试的第一步能稳定读,后面的问题大多能在软件层定位,不用再怀疑硬件接错。

5. 避坑指南:调试 Modbus 从机时反复踩过的四个问题

5.1 问题一:能收到回帧但上位机超时,先查 A/B 和共地

现象:逻辑分析仪挂在 RS485 总线上能看到从机有响应波形,电平跳变也正常,但 Modbus Poll 一直显示超时。

原因:最常见的是 A/B 接反了。SP3485 的 A 端对应 USB 转 485 模块的 A,有些廉价转换线的丝印不标准,接反后从机发出的差分信号极性颠倒,上位机的收发器把逻辑 1 读成了逻辑 0。

解决:用万用表量空闲状态下 A 对 B 的电压。正常情况下 A 比 B 高 200mV 以上,如果量出来是负值,把 A/B 两根线对调。还有一次是开发板和转换器没共地,RS485 是差分传输,理论上抗共模干扰强,但收发器的输入范围有限,地电位差太大照样解不出数据。把两边 GND 接一根线,问题立刻消失。

5.2 问题二:上电第一次能通、连续操作超时,方向引脚拉低太早

现象:每次给从机上电后第一次写保持寄存器成功,第二次开始所有请求全部超时;重新上电又只成功一次。

原因:前面在 2.2 里举过具体例子——用阻塞式 HAL_UART_Transmit,函数返回时最后一个字节还在发送移位寄存器里,DE 被提前拉低,帧尾的 CRC 没有完整发出去。第一次成功是因为时序刚好处在边界,第二次数据稍有抖动就触发丢帧。

解决:把发送切成中断方式,在 HAL_UART_TxCpltCallback 里拉低 DE。如果暂时不想改代码,想快速验证这个问题,在阻塞发送后加一句等待 TC 标志:

HAL_UART_Transmit(&huart1, buf, len, 100); while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET); rs485_set_dir(0);

TC 和 TXE 的区别就在这:TXE 表示数据进入了移位寄存器,TC 表示移位寄存器发完了整个字节。很多人只知道等 TXE,忽略了 TC,这是一个非常典型的嵌入式串口翻车点。

5.3 问题三:帧校验通过却不进功能码分支,地址和帧间隔都别忽略

现象:CRC 校验已经 OK,但是程序始终不走 switch 里的 0x03 分支。用串口抓包工具看,请求帧就是最标准的01 03 00 00 00 02 C4 0B。

原因:第一层地址匹配没过。检查代码里 slave_addr 的初始化,如果从机地址还是默认的 0x02,而上位机发的是 0x01,那这条帧会在校验之前就被丢弃。另一个隐蔽原因是帧被拆成两段:如果串口接收用的是逐字节定时器而不是空闲中断,当两个字节间隔超过 1.5 字符时间,从机会误认为是两帧,导致 rx_len 只包含半截数据,CRC 校验自然过不了。

解决:先把 slave_addr 打印出来确认;再把上位机发送间隔调大,一般上位机轮询间隔不会太小,但手动发帧测试时容易连发导致帧粘连。用空闲中断加 DMA 接收的方案,能有效避免这种拆帧误判。

5.4 问题四:在串口中断里解析帧导致程序跑飞,接收和处理必须分离

现象:从机单次查询没问题,一但上位机用 100ms 周期连续轮询,偶尔出现程序跳到 HardFault,或者寄存器数据随机变错。

原因:把 modbus_poll 直接放在 USART1_IRQHandler 里调用,CRC 计算和寄存器写操作在中断上下文执行,时间长于两个字节的接收间隔,新的 DMA 数据又覆盖了正在解析的缓冲区,产生数据竞争。更严重的是,如果解析函数里调用了 HAL_UART_Transmit 阻塞等待,中断嵌套会进一步恶化。

解决:中断里只做两件事——保存 DMA 剩余计数,置位 frame_ready 标志。modbus_poll 放到主循环 while(1) 里调用。这样接收中断的占用时间只有几个时钟周期,解析逻辑再长也不会影响下次接收。从那以后我写任何串口协议栈,都强制要求“中断只置标志,处理全在主循环”,再没遇到过这种跑飞问题。

6. 二次开发:把从机改造成你的采集节点

6.1 从寄存器表改造入手:把业务数据映射到 Modbus 地址

项目里 holding_regs 是一个纯数组,实际产品里寄存器应该和业务数据一一对应。我通常会在 modbus.h 里写一个寄存器映射表,让业务代码和协议代码各取所需:

Modbus 地址变量名含义读写属性
0x0000sensor_humidity湿度值只读
0x0001sensor_temperature温度值只读
0x0002control_word控制字读写
0x0003status_word状态字只读

实现上不用搞复杂结构体,直接定义宏并让业务代码写数组对应下标即可:

#define REG_HUMIDITY 0 #define REG_TEMPERATURE 1 #define REG_CONTROL_WORD 2 #define REG_STATUS_WORD 3 // 业务层更新传感器值 holding_regs[REG_TEMPERATURE] = read_temp(); holding_regs[REG_HUMIDITY] = read_humidity();

这样协议栈不用关心里面的数据来自 ADC 还是 I2C 传感器,反正它只负责把数组里的值按 Modbus 帧格式发出去。业务代码加新变量时,只需要在数组里预留下标并更新映射宏。

6.2 新增功能码与多从机地址:顺手把可维护性也做了

要支持 0x05 写线圈或者 0x04 读输入寄存器,只需在 modbus_poll 的 switch 里加一个 case,再写对应的 handler。注意 Modbus 每个功能码的数据组织方式不一样,比如 0x05 写线圈的值规定只有 0xFF00 和 0x0000 两种,写进协议栈时按协议手册逐字段核对,别想当然。另一个容易忽略的改动是寄存器数组要加越界保护,异常响应码不要用错,地址越界是 0x02,值不合法是 0x03。

6.3 多从机与拨码地址:一组拨码开关带来的现场可维护性

如果现场要挂多台从机,地址不能写死。常见做法是把 4 位拨码开关接到 GPIO,上电时读取成从机地址:

uint8_t slave_addr = 0; for (uint8_t i = 0; i < 4; i++) slave_addr |= (HAL_GPIO_ReadPin(GPIOB, 0x01 << i) == GPIO_PIN_SET) ? (1 << i) : 0; if (slave_addr == 0) slave_addr = 1; // 全 0 时给个默认地址

地址 0x00 在 Modbus 里是广播地址,现场设备不要占用。多从机挂在同一条 RS485 总线上时,每个从机地址必须唯一,否则两个从机同时响应同一帧请求,总线冲突会把数据搅得一团糟。

6.4 改完怎么验证:手工组帧到双节点联调

每加一个功能码,我习惯先用串口调试助手手工发一帧已知数据验证,比如读寄存器就发01 03 00 00 00 02 C4 0B,看回包字节是不是和预期一致;再换 Modbus Poll 循环读取,观察长时间运行有无丢帧。多从机场景下,至少要挂两个节点反复轮询,确认地址互不干扰。这套流程看着原始,但定位问题非常快,能直接区分是协议栈的错还是业务层的错。从那以后,我每换一个项目都会先把寄存器表和地址定义写成一页注释放在 modbus.c 文件头,再开始写业务代码,二次开发基本不会再从头翻第二遍。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询