目录


一、前言

大家好,这里是 Hello_Embed

上一篇我们把 UART 封装成了统一的 UART_Device 接口——InitSendRecvByte 三个方法,底层换串口上层。

本篇进入项目核心协议:Modbus RTU。它是整个"工业互联设备管理系统"中,PC 上位机和 STM32H5 中控之间、中控和各路传感器之间的通用通信语言


二、Modbus RTU 帧格式

| 地址(1B) | 功能码(1B) | 数据(NB) | CRC16(2B) |
|----------|-----------|---------|-----------|
  • 地址:1-247(从站地址),0(广播,所有从站都执行,不回应)
  • 功能码:告诉从站"读还是写、读写哪种寄存器"
  • 数据:长度由功能码决定,可能包含寄存器地址、数量、写入值
  • CRC16:校验整帧完整性,出错则丢弃

帧与帧之间用3.5 字符时间的静默(IDLE)分隔。在 115200 波特率下,1 字符 ≈ 87μs,3.5 字符 ≈ 305μs。


三、四种寄存器模型

Modbus 从站内部维护四张"表",每种表对应不同的功能码:

类型 前缀 功能码(读/写) 大小 用途举例
线圈 Coil 0x 01 / 05,15 1 bit 继电器输出、LED 开关
离散输入 DI 1x 02 / 无写 1 bit 按键、限位开关
保持寄存器 HR 4x 03 / 06,16 16 bit 参数配置、设定值
输入寄存器 IR 3x 04 / 无写 16 bit 温度、湿度、光照读数

编号从 1 开始,但协议地址从 0 开始。例如"保持寄存器 1"→ 协议地址 0x0000


四、常用功能码报文拆解

4.1 读保持寄存器 (03)

请求:从站 1,读寄存器地址 0,读 1 个

01  03  00 00  00 01  84 0A
│   │   └───┘  └───┘  └───┘
│   │     │      │      │
│   │     │      │    CRC16
│   │     │   读取数量 = 1 个寄存器
│   │  起始地址 = 0x0000
│  功能码 03 = 读保持寄存器
从站地址 1

响应

01  03  02  00 05  38 47
│   │   │   └───┘  └───┘
│   │   │     │      │
│   │   │     │    CRC16
│   │   │  寄存器值 = 0x0005
│   │  数据字节数 = 2
│  功能码
从站地址

4.2 写单个保持寄存器 (06)

请求:写地址 1 的寄存器,值为 10

01  06  00 01  00 0A  19 CD
│   │   └───┘  └───┘  └───┘
│   │     │      │      │
│   │     │      │    CRC16
│   │     │   写入值 = 0x000A (10)
│   │  寄存器地址 = 0x0001
│  功能码 06 = 写单个寄存器
从站地址 1

响应:原样回传(确认写入成功)。

4.3 读离散输入 (02)

请求:读从站 1 的离散输入,起始地址 0,读 3 个

01 02 00 00 00 03 38 0B

响应:返回 1 字节,低 3 位对应 3 个离散输入的状态。


五、IDLE 中断与 Modbus 帧边界的天然契合

DMA+IDLE 接收模式是专门为 Modbus RTU "量身定制"的:

Modbus 帧 1 (N1字节) → 静默 305μs → Modbus 帧 2 (N2字节) → 静默 305μs → ...

HAL_UARTEx_ReceiveToIdle_DMA 的行为:
  收到帧 1 最后一个字节 → 1字符时间无数据 → IDLE 中断
    → RxEventCallback(Size=N1)  ← 完美!Size 就是帧长度
    → 入队 → 重启 DMA
  收到帧 2 ...

不需要自己拼帧——IDLE 中断天然告诉你了每帧的边界。上层只需从 Queue 取出 Size 个字节就是一帧完整报文。


六、从 OOP 视角看 Modbus 后端

libmodbus 通过 modbus_new_rtu() 接收设备名:

modbus_t *ctx = modbus_new_rtu("uart4", 115200, 'N', 8, 1);

内部流程:

modbus_new_rtu("uart4")
  → GetUARTDevice("uart4")          // 拿到 OOP 设备指针
  → ctx->backend.send    = pDev->send     // 绑定 Send
  → ctx->backend.recv    = pDev->RecvByte // 绑定 RecvByte
  → ctx->backend.connect = ...            // 调用 pDev->Init

这就是 OOP 封装的终极意义——libmodbus 完全不关心底层是 UART2、UART4 还是 USB CDC。协议栈只管 send()RecvByte(),物理层由设备表管理。

// 用板载 UART4(接到 RS-485)
modbus_t *ch2 = modbus_new_rtu("uart4", 115200, 'N', 8, 1);

// 用 USB CDC(虚拟串口,接到 PC 上位机)
modbus_t *usb = modbus_new_rtu("usb", 115200, 'N', 8, 1);

// 上层 modbus_reply / modbus_receive 代码完全一样!

七、ModbusPoll 工具验证

PC 端工具路径:Tools/Modbus/(ModbusPoll 主站 + ModbusSlave 从站模拟器)

调试流程:

  1. 用 ModbusSlave 模拟从站:设置地址、寄存器初始值
  2. 用 ModbusPoll 发命令:观察请求帧和响应帧的原始报文
  3. STM32 上先用 ModbusSlave 软件验证 PC 端主站逻辑正确
  4. 再替换为 STM32 从站(代码写好后)

验证链路:

PC ModbusPoll ←─USB CDC──→ STM32H5 ←─UART4(RS-485)──→ STM32F030传感器
     主站                      中控                       从站

八、常见坑

8.1 CRC 计算

CRC 是整帧计算(地址+功能码+数据),不包含 CRC 字段本身。

8.2 寄存器地址偏移

"保持寄存器 1"在协议里地址是 0x0000,不是 0x0001。Modbus 编号从 1 开始,协议地址从 0 开始。

8.3 帧间间隔不够

如果连续发两帧 Modbus 报文,间隔小于 3.5 字符(< 305μs @115200),从机会把两帧当成一帧,CRC 校验失败。发送时必须保证至少 305μs 的静默。

8.4 broadcast 无响应

地址 0 是广播地址,所有从站执行命令但不回应。不要等广播的响应。


九、结尾

本篇把 Modbus RTU 的核心概念全串起来了:

  • 帧格式(地址+功能码+数据+CRC)
  • 四种寄存器模型(Coil/DI/HR/IR)
  • IDLE 中断天然适合 Modbus 帧边界检测
  • UART_Device 封装如何让 libmodbus 做到"与硬件无关"

学习路径回顾:

Note 7-8:   UART 三种方式(查询/中断/DMA)
Note 9:     SPI DMA
Note 10:    DMA+IDLE
Note 11:    RTOS 信号量
Note 12:    UART_Device OOP 封装
Note 13:    Modbus 协议分析 ← 本篇

下一篇预告:libmodbus 源码走读——核心数据结构、发送/接收/回复的全景分析,以及移植到 STM32H5 的关键步骤。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐