
第二讲:USART2 + PB8(DE/RE) + TIM6是怎样组成 RS485 驱动层的
第二讲:USART2 + PB8(DE/RE) + TIM6是怎样组成 RS485 驱动层的
项目地址(对应分支)
仓库主页:
https://github.com/AIITVisionLab/shihu
本讲对应分支:
https://github.com/AIITVisionLab/shihu/tree/feat/stm32f429-modbus-gateway
本讲聚焦的内容
mcu/bsp/rs485
mcu/middleware/Modbus-RTU
docs/architecture.md
docs/modbus-register-map.md
一、为什么从“底层入口”讲起
如果说之前解决的是“Modbus-RTU 是什么”“为什么实现必须分层”, 那么从这一讲开始,就要真正进入 F429 这条链路最底下的那一层:RS485 驱动入口。
原因非常简单。
对于一个 Modbus-RTU 主站来说,真正的起点不是功能码,不是寄存器地址,甚至也不是 CRC, 而是下面这几个最现实、最硬的底层问题:
- 字节到底通过哪个串口发出去、收进来;
- 总线当前是发送态还是接收态;
- 一帧什么时候才算真正结束;
- 底层中断到底该负责什么,不该负责什么;
- 上层中间件应该从底层拿到哪些“事件”,而不是去猜底层状态。
如果这些问题没有先站稳,那么后面的主站事务、中间件状态机、寄存器轮询,都会建立在一层很脆弱的地基上。
而在当前这个分支里,这层地基已经很明确了:
- 串口:
USART2 - 方向控制:
PB8(DE/RE) - 帧间计时:
TIM6 - 运行模式:
9600 8E1 - 总线形式:
RS485半双工 - 驱动原则:ISR 只做收发字节与
T3.5到期通知,不在中断里跑协议状态机
这几项拼在一起,才构成了 F429 采集网关真正的 Modbus-RTU 底层入口。
二、现实约束
很多人看代码时会下意识觉得:
“无非就是一个串口,再加一个方向控制脚,再加一个定时器。”
但如果真这么理解,就会低估这层的重要性。 因为它并不是三个孤立的外设,而是围绕 半双工 RTU 总线 这个现实约束,组成的一整套协同行为。
三、USART2:字节流入口,不是协议入口
1. 串口负责的是“字节”,不是“Modbus”
基本原则:
UART 只负责字节流,不负责协议语义。
也就是说,在 USART2 看来:
- 它不知道你发的是读光照还是写 PLC;
- 它不知道这是不是一个异常响应;
- 它也不知道当前轮询到了第几个从站。
对它来说,事情只有两类:
- 一个字节被发出去了;
- 一个字节被收进来了。
这听起来很基础,但正因为如此,驱动层必须克制。 否则一旦 UART 层开始承担“协议含义”,分层马上就会塌。
2. 为什么当前选 USART2
在当前这套板级方案里,USART2 不是一个抽象概念,而是已经和具体口线绑定:
- PD5 / PD6
- PB8(DE/RE)
这说明当前实现是贴着板级资源来的。 这没有问题,实际工程本来就必须先落到具体硬件上。
但这也意味着后人接手时要明白一件事:
当前 RS485 驱动层的第一层“固定条件”,就是它默认跑在 USART2 上。
后面如果迁移板子、换外设资源、或者总线数量增加,这一层会是最先需要抽象的地方之一。
3. 为什么串口格式是 9600 8E1
当前主站参数里已经明确写了:
- 9600
- 8E1
也就是:
- 波特率 9600
- 8 位数据位
- Even parity 偶校验
- 1 位停止位
这不是随手填的,而是 Modbus-RTU 在当前现场设备之间的共同约定。 只要总线上任意一方串口格式不一致,后果通常不是“值有点不对”,而是整帧都无法稳定解码。
所以在驱动层看来,USART2 初始化时最重要的不是“把串口打开”,而是:
确保总线上的所有设备都在同一个字节格式假设里说话。
换句话说,9600 8E1 在这里不只是配置项,它是整条 RTU 总线的语言环境。
四、PB8(DE/RE):为什么方向控制是 RS485 的核心,而不是附属细节
1. RS485 的最大现实约束:半双工
在二线 RS485 总线上,一个最基本的事实就是:
同一时刻,总线上只能有一个方向在说话。
这就是半双工。
所以,和普通 UART 最大的不同在于:
- 不是“想发就发”;
- 也不是“发完就自然继续收”;
- 而是必须明确控制总线收发方向。
这就是 DE/RE 存在的意义。
2. DE/RE 解决的不是“能不能发”,而是“谁在占总线”
很多初学者看到 DE/RE,会把它理解成“串口使能脚”。 这个理解太浅了。
更准确地说:
- DE 决定驱动器是否把 MCU 发送的数据真正推到总线上;
- RE 决定接收器是否处于接收状态;
- 它们合起来,决定当前这块板子是在“说”还是在“听”。
所以,PB8(DE/RE) 在当前系统里的本质作用不是 GPIO 控制,而是:
主站对总线占用权的控制开关。
3. 一个完整请求为什么必须围绕 DE/RE 切换组织
在当前这套主站里,一次最完整的请求流程,从最底层看,通常都是这样的:
- 确认总线空闲;
- 切换到发送态;
- 逐字节发出请求帧;
- 等最后一个字节真正移位完成;
- 切回接收态;
- 开始等待从站响应;
- 进入帧接收与 T3.5 判定流程。
注意这里最容易被写错的一点:
“发送完成”不是指软件把最后一个字节写进发送寄存器,而是指最后一个字节真的已经离开 UART,离开收发器,送上总线。
如果方向切换太早,最后几个字节可能会被截断; 如果方向切换太晚,从站的响应起始部分可能会被主站自己挡掉。
所以,DE/RE 控制不是一个附带动作,而是 RS485 驱动层最关键的状态切换点之一。
4. 为什么这一层不能交给协议层“顺手处理”
如果让协议层自己到处拉高/拉低方向控制脚,看起来好像省掉了一层封装, 实际上会带来几个严重问题:
- 协议层被迫知道硬件方向时序;
- 后面更换收发器或更换控制策略时,协议层会被牵连;
- 串口发送完成与移位完成的边界容易被写错;
- 协议状态机和物理层时序会被强耦合。
所以更合理的方式应该是:
方向控制属于 RS485 驱动层的职责,不应该散落在上层事务代码里。
五、TIM6:为什么 Modbus-RTU 一定要有“帧间静默时间”建模
1. RTU 不是靠固定帧头判帧
很多私有串口协议会用固定帧头,比如 0x55AA、0xA5 0x5A 来判定一帧开始。 Modbus-RTU 不是这样。
它的一帧边界,依赖的是:
- 字节流;
- CRC;
- 以及最关键的——静默时间。
这意味着你不能只靠“收到了多少字节”来判断一帧是否结束。 你必须再问一个问题:
这些字节之间的时间关系,是否已经满足 RTU 规定的帧结束条件?
这就是 T3.5 存在的意义。
2. 为什么一定要把 T3.5 单独建模出来
在当前工程里,TIM6 被专门拿来做这件事。 这说明设计不是“收完了就算一帧”,而是明确承认:
RTU 的帧边界,本身就是协议的一部分。
如果没有独立的 T3.5 建模,常见问题会很多:
- 收到半帧就提前解析;
- 前一帧和后一帧黏在一起;
- 噪声字节导致帧缓存污染;
- 上层只能靠长度猜测帧是否完整;
- 总线上多个节点响应时,边界混乱。
所以,TIM6 在这里不是“正好有个空闲定时器”,而是:
RTU 帧边界的时序裁判。
3. TIM6 在这套系统里的职责
它最核心的职责其实只有一个:
当接收过程中出现足够长的静默时间时,通知上层:这一帧可以尝试收口了。
注意,是“通知可以尝试收口”,而不是“在定时器中断里直接完成协议解析”。
这点非常重要。
因为 TIM6 中断本质上只是一个底层事件:
- 静默时间到了;
- 接收窗口大概率已经结束;
- 可以把当前缓存交给链路层进一步判断。
它不应该承担:
- CRC 计算;
- 功能码解析;
- 数据区拆解;
- 主站事务流转;
- 业务快照更新。
这些都已经越层了。
六、为什么我坚持:ISR 只做最短动作,不在中断里跑状态机
当前工程文档里,已经把这条原则写得很明确:
- ISR 只做收字节、发字节、T3.5 到期通知;
- 协议状态机不放到中断里执行。
这是这套系统的核心边界之一。
1. 中断里应该留下哪些动作
对当前 RS485 驱动层来说,中断里真正合理的动作只有这些:
(1)串口接收中断
- 收到一个字节;
- 把这个字节放进接收缓存;
- 必要时刷新帧间定时逻辑;
- 然后立刻返回。
(2)串口发送相关中断
- 当前字节已经进入发送流程;
- 如果发送缓冲区还有下一个字节,就继续装载;
- 如果最后一个字节真正完成,则上报“发送完成”事件;
- 然后立刻返回。
(3)TIM6 中断
- 说明当前已经满足 T3.5 条件;
- 上报“接收帧结束候选”事件;
- 然后立刻返回。
(4)硬件错误中断
- 记录底层错误事实;
- 上报给上层;
- 然后立刻返回。
这些动作有一个共同特点:
它们只报告事实,不解释语义。
2. 为什么不在中断里直接跑协议状态机
因为一旦这么做,问题会立刻变复杂。
(1)执行时间不可控
中断本来应该尽快退出。 如果里面开始 CRC、开始解功能码、开始判断异常响应,响应时间会迅速拉长。
(2)分层会塌
ISR 本来只该知道“我收到了字节”“定时器到了”。 如果它开始知道“这是温度寄存器响应”,那说明链路层、协议层、应用层已经缠在一起了。
(3)调试会非常困难
当 ISR、协议解析、主站事务、业务轮询混在一层时, 出了问题以后你很难判断到底是:
- 硬件收发错了;
- 帧边界判错了;
- CRC 算错了;
- 协议解析错了;
- 还是业务调度本身有问题。
所以,这里真正应该坚持的不是“中断越少越好”,而是:
中断只负责产出底层事件,不负责解释这些事件的高层意义。
七、底层驱动到底应该向上层提供什么“事件”
这是后人接手时非常关键的一个问题。 因为驱动层不是为了“把外设初始化好”而存在的,而是为了向上提供一个稳定、可被中间件消费的事件接口。
对于当前这套系统,我认为最合理的底层事件至少应该包括:
1. TX_BEGIN
表示驱动已经正式切到发送态,准备占用总线。
它的意义在于让上层知道: 当前总线方向已经切换完成,请求发送流程已经开始。
2. TX_DONE
表示最后一个字节已经真正发送完成,可以安全切回接收态。
这个事件特别关键。 因为它不是“软件已经写完发送缓冲区”,而是“硬件层面真正发完了”。
3. RX_BYTE
表示驱动层收到了一个新字节,并已经进入接收缓存。
这个事件本身通常不一定需要完整上抛给任务层, 但在驱动内部,它一定是帧接收逻辑的基本输入。
4. RX_FRAME_TIMEOUT 或等效事件
表示 T3.5 已到,可以认为当前接收帧已经结束,交给链路层收口。
这个事件是 RTU 帧边界的关键。
5. PORT_ERROR
表示发生底层硬件错误。
例如:
- 串口硬件异常;
- 接收异常;
- 缓冲区越界;
- 明显不合法的底层状态切换。
6. BUS_IDLE
表示当前总线已经处于可启动新事务的空闲态。
这个事件不是每套实现都必须显式化, 但在主站事务层设计清晰的时候,它会非常有帮助。
八、从底层事件到中间件通知,边界应该怎么划分
驱动层最大的职责,不是让上层“知道得更多”,而是让上层“猜得更少”。
也就是说,底层驱动不应该要求中间件去猜:
- 现在是不是已经发完了;
- 这一帧是不是可能已经结束了;
- 当前总线是不是已经切回接收态了。
更好的做法应该是:
驱动层把有限、清晰、可验证的底层事件明确抛出来;
中间件只基于这些事件推进链路状态机。
于是边界就会非常清楚:
RS485 驱动层负责:
- 管 UART;
- 管方向控制;
- 管 T3.5 计时;
- 管底层硬件错误;
- 产出底层事件。
RTU 链路层负责:
- 管帧缓存;
- 管帧结束收口;
- 管 CRC 检查;
- 决定是否把当前帧交给协议层。
协议层负责:
- 判断地址、功能码、异常响应、数据区合法性。
主站事务层负责:
- 发请求;
- 等结果;
- 超时与重试;
- 失败分类。
应用层负责:
- 决定轮询谁、何时写命令、如何生成快照并上报。
这样一来,每层都不用替别层做事。
九、总结
很多人会把 RS485 驱动层看成一个相对边缘的“底层细节”, 但在我看来,它其实决定的是整条总线最初的秩序。
因为从这里开始,系统已经明确了:
- 字节怎样进出总线;
- 方向怎样切换;
- 一帧怎样结束;
- 中断应该做到哪一步为止;
- 上层应该接收哪些清晰事件,而不是去猜底层状态。
这意味着,这一层并不只是“把串口点亮”,而是在为上面的 RTU 链路层、协议层、事务层、轮询层打地基。
所以这一讲真正想留下来的结论只有一句:
不要把 RS485 驱动层当成附属实现; 它是整条 Modbus-RTU 主站链路的入口秩序。
只要这层入口秩序清楚,后面的中间件和主站事务就能稳定长出来。 如果这层入口秩序混乱,后面无论协议写得多漂亮,都会建立在不稳的基础上。