LK 博客
第一讲:如何实现现场采集网关(1)
项目
约 1 分钟阅读 0 赞 0 条评论 鸿蒙黑体

第一讲:如何实现现场采集网关(1)

Yukikaze
Yukikaze @Yukikaze
累计点赞 0 登录后每个账号只能点一次
内容长度 0 正文词元数
正文
目录会跟随阅读位置移动。
阅读进度

第一讲:如何实现现场采集网关(1)

项目地址(对应分支)

仓库主页:
https://github.com/AIITVisionLab/shihu

本讲对应分支:
https://github.com/AIITVisionLab/shihu/tree/feat/stm32f429-modbus-gateway

一、什么是Modbus-RTU协议?

很多人第一次接触 Modbus,会把它理解成“串口协议”。 这句话不能说错,但太粗糙了。

更准确地说,Modbus-RTU 不是单独的一根串口线,而是一套建立在串行链路上的通信规则

它至少要分成下面几层来看:

  • 物理层:线怎么接,电平怎么传,方向怎么切
  • 帧层:一帧从哪里开始,到哪里结束,CRC 怎么算
  • 协议层:地址、功能码、寄存器、异常响应是什么意思
  • 事务层:主站如何发请求、等响应、超时、重试、判定失败
  • 应用层:这一帧数据在项目里到底代表什么

如果不分层,后面一切都会搅在一起。 而这套 F429 采集网关,恰恰就是按这个思路一点一点往上搭起来的。

二、物理层:为什么 Modbus-RTU 常常跑在 RS485 上

1. RS485 不是协议,它是电气接口

首先要明确一点:

RS485 不是 Modbus

RS485 解决的是“电怎么传”的问题; Modbus-RTU 解决的是“这些字节是什么意思”的问题。

也就是说:

  • RS485 负责差分传输、抗干扰、多点挂接;
  • Modbus-RTU 负责主从问答、帧格式、地址、功能码、CRC。

在工业现场,这两者经常绑定出现,所以很容易被混成一句话。 但在设计代码时,必须把这两件事分开。

2. 为什么工业现场爱用 RS485

RS485 最典型的特点有三个:

(1)差分传输

它不是靠单根线对地传电平,而是靠两根线之间的电压差来表示逻辑状态。 这意味着它对共模噪声更不敏感,抗干扰能力比普通 TTL 串口强得多。

(2)适合总线型多点挂接

多个设备可以挂在同一条总线上,形成“一个主站 + 多个从站”的拓扑。 这正好契合 Modbus-RTU 的主从模式。

(3)适合较远距离、较复杂现场

工业环境里电机、继电器、泵、风扇、电磁阀很多,噪声重、线长长, 这时候直接用普通 UART 电平做远距离多节点通信通常不现实,RS485 就很合适。

3. 这个项目里,RS485 具体承担什么角色

在当前工程的 F429 采集网关里,RS485 不是一个“附带接口”,而是采集链路的核心入口

当前分支文档已经把这条链路讲得很明确:

  • 主控:STM32F429
  • 角色:Modbus-RTU 主站
  • 下挂:多个 STM32F103 从站,以及一个 PLC 控制从站
  • 串口:USART2
  • 引脚:PD5 / PD6 + PB8(DE/RE)
  • 帧间定时:TIM6
  • 串口格式:9600 8E1

F429 通过 USART2 收发字节,通过 PB8 控制 RS485 芯片的发送/接收方向,通过 TIM6 做 RTU 规定里的帧间隔计时

换句话说:

  • USART2 负责“字节流”
  • PB8(DE/RE) 负责“半双工方向切换”
  • TIM6 负责“RTU 帧边界判定”

这三个部件合在一起,才构成真正可用的 Modbus-RTU 物理入口。

4. 半双工是当前层级里最重要的现实约束

在二线 RS485 里,一个非常关键的现实约束是:

同一时刻,总线上只能有一个方向在说话

这就是半双工。

所以对主站来说,一次完整事务的最底层动作,通常都是这样的:

  • 1.切到发送态
  • 2.把请求帧发出去
  • 3.等发送移位完成
  • 4.切回接收态
  • 5.等从站响应
  • 6.若超时或 CRC 错误,则按失败处理

这意味着:

  • 方向控制不能乱切;
  • 发送结束的时机必须判断准;
  • 接收窗口必须在正确时间打开;
  • 帧边界必须靠定时规则确认,而不是靠“猜”。

所以你后面用 PB8(DE/RE) + TIM6 T3.5,本质上不是“为了写得复杂”,而是因为 RTU 在半双工总线上本来就必须这样严谨处理

三、帧层:Modbus-RTU 的一帧到底长什么样

1. RTU 不是“见到几个字节就收下”

Modbus-RTU 和很多“长度固定的私有串口协议”不一样。 它的帧边界,并不是靠一个固定帧头比如 0x55AA 来识别,而是靠:

  • 字节内容
  • 功能码语义
  • CRC
  • 时间间隔

共同定义。

这也是为什么 RTU 实现里,定时器不是可有可无的点缀,而是协议的一部分

2. RTU 帧的基本结构

从逻辑上讲,一帧 Modbus-RTU 可以看成这样:

+----------+-----------+------------+-----------+
| 地址(1B) | 功能码(1B) | 数据区(NB) | CRC16(2B) |
+----------+-----------+------------+-----------+

其中:

(1)从站地址

告诉总线上“这帧是发给谁的”。

(2)功能码

告诉对方“我要做什么”。

例如:

  • 0x03:读保持寄存器
  • 0x06:写单个保持寄存器
  • 0x10:写多个保持寄存器

(3)数据区

不同功能码的数据区含义不同。 比如读寄存器请求里,通常会包含“起始地址 + 数量”; 响应里则会包含“字节数 + 实际寄存器内容”。

(4)CRC16

用于检查这一帧在传输过程中有没有被破坏。 如果 CRC 对不上,这帧就应当被当作无效帧处理。

3. 一个最典型的例子:读保持寄存器、

假设主站要从某个从站读取一段保持寄存器,那么请求的大致语义就是:

从站地址 = 目标设备
功能码   = 0x03
起始地址 = 从哪里开始读
寄存器数 = 读多少个
CRC      = 对前面所有字节做校验

从站返回时,语义则会变成:

从站地址 = 自己是谁
功能码   = 0x03
字节数   = 后面数据有多少字节
数据区   = 实际寄存器值
CRC      = 对前面所有字节做校验

这就是一个完整的“问—答”闭环。

4. RTU 帧边界为什么要靠时间来判定

RTU 有一个非常经典、也非常容易被忽略的规则:

帧与帧之间需要满足最小静默间隔。

通常会提到两个量:

  • T1.5:字符间间隔判定参考
  • T3.5:帧间静默时间判定参考

它的意义是这样的:

如果你正在接收一帧,中间长时间没再收到新字节,就要怀疑这帧是否已经结束; 如果总线静默超过 T3.5,可以认为前一帧已经真正结束,新的一帧可以开始。

这正是你项目里用 TIM6T3.5 的根本原因。 不是“刚好有个定时器可用”,而是 RTU 本来就需要用时间来认帧

四、协议层:地址、寄存器、功能码,到底在说什么

1. Modbus 的核心不是“发字节”,而是“读写寄存器”

Modbus 最适合做的一类事,就是:

  • 读某个设备内部的状态值;
  • 写某个设备内部的控制值。

也就是说,它天然适合“现场设备数据采集”和“少量控制命令下发”。

这正是 F429 采集网关选择它的原因。

2. 当前项目里,寄存器已经被清楚映射出来了

当前分支文档里,寄存器映射已经明确给出:

  • 从站1:
    • 40001 -> 光照 ADC
  • 从站2:
    • 40011 -> 温度
    • 40012 -> 湿度
  • 从站3:
    • 40021 -> MQ2 ppm
  • 从站4:
    • 40031~40038 -> 状态区
    • 40101~40108 -> 控制命令写区

这说明你的 Modbus 链路并不是“先把协议做出来再说”,而是已经和具体业务对象绑定起来了:

  • 环境量走读寄存器
  • 状态量走读状态区
  • 控制命令走写寄存器区

也就是说,协议层和应用层之间已经有了明确映射

3. 功能码为什么重要

功能码的存在,决定了“同样是寄存器地址,主站此刻到底是要读还是要写”。

在当前场景里,最重要的不是功能码种类有多全,而是:

  • 读功能要稳定;
  • 写功能要可控;
  • 异常响应要能被识别;
  • 事务失败要能归因。

工业系统不怕协议简单,怕的是行为不清楚。

4. 异常响应也属于协议的一部分

很多初学者会只盯着“正常响应”,但真正能把系统做稳的,往往是你怎么处理异常。

Modbus 异常响应有一个很重要的规则:

如果从站无法正常处理请求,它会返回异常功能码

常见形式是:

异常功能码 = 原功能码 | 0x80

后面再跟一个异常码。

这意味着主站不能只判断“有没有收到数据”,还要判断:

  • 地址对不对;
  • 功能码是否是正常响应;
  • 是否是异常响应;
  • CRC 是否正确;
  • 数据长度是否匹配预期。

这就是为什么文档里专门区分了:

  • CRC
  • PROTOCOL
  • EXCEPTION
  • PORT
  • TIMEOUT

因为这几类失败,根本不是一回事。

五、事务层:为什么主站不是“轮着发包”这么简单

1. 主站真正做的是事务管理

站在代码角度,主站并不是“把一串字节发出去”就完事了。 它真正做的是一整套事务控制:

  • 组请求帧;
  • 切发送方向;
  • 发出请求;
  • 等待发送完成;
  • 切回接收;
  • 等待响应;
  • 判定帧结束;
  • 做 CRC 校验;
  • 解析功能码和数据区;
  • 成功则交给上层,失败则超时/重试/记错。

所以,一个成熟的主站实现,天然就应该分层。 否则所有逻辑都塞在一个函数里,后面几乎必然不可维护。

2. 当前的设计,其实已经很成熟了

分支文档里,可以看到当前实现的几个关键原则:

(1)总线上固定只保留一个未完成请求

这是非常重要的设计。 因为现在做的是 RTU 主站轮询,不是高并发消息总线。 一次只允许一个未完成事务,逻辑最清晰,也最稳。

(2)协议状态机不放到中断里执行

中断里适合做的是“收了一个字节”“发完一个字节”“T3.5 到了”这种短动作, 不适合做完整状态流转、协议解析和复杂错误处理。

(3)单从站异常只影响该从站,不阻塞其他从站

这也是工业轮询系统非常重要的性质。 一台从站挂了,不应该让整条总线逻辑一起死掉。

这三条原则,已经足以说明: 这套实现不是“能跑就行”,而是已经是一个靠谱的主站。

六、把这些抽象概念对回 F429 上的设计

到这里,就可以把前面的协议知识重新映射回代码的工程分层了。

1. 物理层 / BSP

这一层对应:

mcu/bsp/rs485

它负责:

  • USART2
  • PB8(DE/RE)
  • TIM6
  • 收发中断
  • T3.5 到期通知

这一层只回答一句话:

字节怎么在总线上被正确地发出去、收进来。

2. 帧层 / 链路层

这一层对应:

mcu/middleware/Modbus-RTU

其中至少包括这些职责:

  • CRC16
  • 帧缓存
  • 帧结束判定
  • 响应合法性初筛

这一层回答的问题是:

这一串字节到底能不能算一帧合法的 Modbus-RTU 报文

3. 协议层

还是在:

mcu/middleware/Modbus-RTU

但这里关注的是:

  • 功能码是什么
  • 响应是不是异常响应
  • 数据区长度对不对
  • 读到的寄存器值怎么解释

这一层回答的问题是:

这帧是“什么意思”,而不是“有没有收到”

4. 事务层

这一层同样在主站中间件里,但已经开始偏“主站控制逻辑”:

  • 发请求
  • 等响应
  • 超时
  • 重试
  • 失败分类
  • 返回上层结果

这一层回答的问题是:

这次主站访问到底成功了没有,失败又是为什么

  1. 应用层

这一层对应:

mcu/app/task_modbus_master
mcu/app/app_data
mcu/app/app_control

这一层不再关心 CRC、字节计数、DE/RE,而是关心:

  • 轮询谁
  • 先轮询谁
  • 读到的值放到哪里
  • 写命令何时下发
  • 哪些数据要打包成快照
  • 哪些异常要标记为从站离线

这一层回答的问题是:

这些 Modbus 数据在这个项目里到底有什么业务意义

七、我的当前轮询拓扑是什么

后来者接手时,最需要知道的是:

现在这套系统不是“任意轮询”,而是已经有固定拓扑和顺序的

当前分支文档给出的每轮顺序是:

  • 1.如果 uplink ACK 中有 pendingCommand,先写从站4的 40101~40108
  • 2.读取从站4 40031~40038
  • 3.读取从站1 40001
  • 4.读取从站2 40011、40012
  • 5.读取从站3 40021
  • 6.生成采集快照并入队上报

这说明你现在的系统拓扑不是“纯采集”,而是已经具备了:

  • 状态读
  • 环境采集
  • 控制写入
  • 结果上报

四种角色。

换句话说,它已经是一个小型工业主站,而不只是一个读几个传感器数值的串口 demo。

八、这一版实现里,哪些地方明显是阶段性写死的

这一节我必须提前写给后来者。 因为“能跑”不等于“适合长期继承”。

从当前文档可以直接看出,至少有这些地方是明显写死或半写死的:

1. 从站拓扑是固定写死的

当前就是 4 个从站,地址固定:

  • 1
  • 2
  • 3
  • 4

如果以后从站数量变化、地址变化,或者设备类型变化, 现在这套轮询逻辑很可能要改代码,而不是改配置。

后续建议

把从站表抽成配置化结构,例如:

  • 从站地址
  • 读写功能
  • 起始寄存器
  • 数量
  • 超时
  • 重试次数
  • 采样周期
  • 数据落点

都放到统一描述表里。

2. 寄存器映射是按当前现场设备直接写死的

现在 4000140011400124002140031~4003840101~40108 都是按当前系统约定写的。 这对当前项目当然是合理的,但对长期继承来说,会带来两个问题:

  • 新设备一接入就得改源代码;
  • 老设备一换协议,整个上层解析都要跟着动。

后续建议

把“寄存器地址 -> 业务字段”的映射单独收口, 不要让轮询逻辑和业务解释逻辑强耦合在一起。

3. 串口与引脚绑定是固定的

当前文档里明确写了:

  • USART2
  • PD5/PD6
  • PB8(DE/RE)

这意味着当前实现目前是贴着这块板子的。

后续建议

至少要把以下内容集中到统一端口配置层:

  • UART 实例
  • TX/RX 引脚
  • DE/RE 引脚
  • 波特率
  • 校验位
  • 停止位

这样后面如果迁移到别的 F4 或别的主控,代价会小很多。

4. 时序参数是固定值

当前参数也已经明确给出:

  • 9600 8E1
  • 单事务超时 1000ms
  • 失败重试 2
  • 请求间隔 10ms
  • 轮询周期 1000ms

这些值在当前现场是合理的,但未必适合未来所有节点。

后续建议

后面最好把它们从“写死常量”提升到“可调参数”。

因为不同从站的响应速度、总线长度、噪声水平都可能不同, 统一写死并不利于长期演进。

5. 轮询顺序是固定的

当前顺序是先控制写入,再读状态区,再读三个采集从站。 这很符合当前系统逻辑,但这其实已经隐含了“控制优先于采集”的策略。

后续建议

这类策略最好显式写成“调度策略”,而不是隐式藏在顺序代码里。

九、这一讲的结尾:先把 Modbus 看成一套分层系统

到这里,这一讲真正想留下的只有一句话:

Modbus-RTU 不是“串口发几个字节”,而是一套从 RS485 物理层一直往上叠到主站事务层的完整通信系统

只有先把这件事看清楚,后面再去看:

  • CRC 怎么算;
  • T3.5 怎么定;
  • 为什么 ISR 只做收发通知;
  • 为什么主站必须一次只保留一个未完成事务;
  • 为什么异常要分成 CRC / PROTOCOL / EXCEPTION / PORT / TIMEOUT;

这些实现选择,才都会变得顺理成章。

作者名片

Yukikaze
Yukikaze
@Yukikaze

私にできることなら何でもするから

评论区
文章作者和管理员都可以管理这里的评论。
0 条评论
登录后即可参与评论。 去登录
还没有评论,欢迎留下第一条交流内容。