本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

扫码模块 RS485 转接仲裁板:
半双工链路上的忙检测与退让

扫码模块只会往外吐字节,主板那边只剩一条半双工总线,中间这块小板要做的事说起来只有一件: 把一票数据安全地送过去。可“安全”这两个字里藏着忙检测、退让和重发三件事。 这篇记录写的是我在 4 KB RAM 上安排双串口缓冲、用空闲时间判帧、 以及给半双工总线设计一套仲裁状态机的过程,也包括一个我在整理工程时才发现的问题。

Cortex-M0+ RS485 半双工仲裁 裸机 已发布
已发布 · 个人学习项目

扫码模块 RS485 转接仲裁板(Cortex-M0+,小容量 Flash/RAM)

这块板子夹在扫码模块和主板之间:一边是模块的 UART 字节流,一边是主板那条挂着好几个节点的 RS485 半双工总线。它不做任何业务判断,只负责把数据缓存、等到总线空闲、发出去、 等一个合法应答,等不到就退让或重发。 结论是:功能已经实现,忙检测、超时退让、应答重发三件套在实测里跑得通; 但它同时给了我一次“工程卫生”的教训——我在整理时才发现, 工程目录里躺着大量根本没有参与编译的历史代码,真正在跑的只有很少几个文件。 这一条我写在第七节。

PLATFORMCortex-M0+ 小容量档(32 KB Flash / 4 KB SRAM)
STACK裸机 + 双串口 + 定时器时基
TOOLSKeil MDK + 串口助手 + 逻辑分析仪
STATUS个人学习项目 · 功能已实现

一、这块转接板要解决什么问题

现场的样子是这样的:扫码模块负责读码,读到就把一串字节从自己的串口吐出来; 主板是整台设备的控制核心,它手上只有一条 RS485 半双工总线, 而且这条总线上还挂着别的从站(键盘显示板、扩展采集板之类)。 扫码模块和主板之间需要一个东西把两头接起来,这块转接板就是那个东西。

它要解决的是三个很具体的问题:

  1. 接口对不上。扫码模块给的是 TTL 串口,主板那边是差分总线; 而且模块装的位置离主板有一段距离,直接拉一根 TTL 线过去既不稳也不合理。
  2. 总线是共享的。半双工总线上同一时刻只能有一个节点在说话。 主板可能正在和别的从站通信,这时候转接板如果直接开讲, 两边的数据会互相破坏——而且坏得没有痕迹,上层只能看到“偶发校验错”。
  3. 数据是突发的、长度不定。一票码什么时候来、有多长, 转接板都不能假设:来的可能是一个很短的码,也可能是一长串带附加信息的字节。

所以我把它的职责划得很死:只做链路层的事—— 缓存、判帧、等总线、发送、等应答、超时退让。 它不解释数据内容,不做业务判断,也不保存参数。 这个边界不是省事,而是我有意的:越靠近硬件的这一层, 越应该只有“搬运”这一种职责,因为它的每一点复杂度都会变成现场最难查的偶发故障。

为什么不让主板直接接扫码模块?因为主板那条串口已经被别的用途占掉了, 而要在主板固件里加一路 485 方向控制加一套总线仲裁,改动面和风险都比加一块小板大。 这是当时的取舍,现在回看我认为是对的:这块板出问题只需要换一块, 主板出问题要停整台设备。

扫码模块 → 转接板 → 主板,标出两条串口的方向与总线侧的方向控制
图 1 · 转接板位置与链路图

二、资源账:小容量 MCU 上怎么安排双串口缓冲与时基

这块板子的主控是 Cortex-M0+,32 KB Flash、4 KB SRAM。 在 4 KB 上做双串口,第一件事不是写代码,是算账: 四个缓冲加栈加全局状态,加起来不能越界。算完就知道, “要不要再加一个诊断缓冲”这种问题的答案必然是否定的。

资源项我的安排理由与代价
扫码口接收缓冲 一整块,按可能收到的最长一票数据留 模块输出的长度不可预测,只能按最坏情况留;这是四个缓冲里最不能省的一个
主板口接收缓冲 一整块,按最大应答帧留 应答比上行数据短,但校验必须在整帧收完之后做,所以要能装下完整一帧
发送缓冲 一整块 发送期间不能被打断,必须有一份独立的、不会被接收改写的副本
暂存缓冲 一整块 用于“下一条待发数据”,配合“新码优先”的取舍(见第四节)
时基 一个定时器做成自动重装、带预分频,得到 1 ms 节拍 所有计时都挂在这一个节拍上:空闲判帧、忙窗口、应答超时、指示灯翻转
CRC 表 Flash 里放一份双 256 字节查表 查表法比按位算法快一个数量级,代价就是这几百字节常量
主循环 只有两个动作:处理事件、发送应答 所有耗时判断都在中断计时里完成,主循环不做等待

各缓冲的具体长度、定时器的分频比与重装值都属于工程整定值,本文不列出。 这里我想说的是那个算账的过程:先把“最坏情况一帧有多长”估出来, 再乘以缓冲个数,再给栈和全局变量留出余量,剩下的才是可以花的钱。 在 4 KB 上,这笔账算完通常就没有余量了——这也解释了为什么后来 “加一个总线忙计数”这种看起来很小的需求,我一直没有直接做进去(见第八节)。

时基的设计有一个我坚持的点:只用一个定时器。 双串口最容易犯的错误是“每个口配一个定时器”,然后在两个中断里 各自维护一套超时逻辑,最后两边的时间语义慢慢漂开。 我把所有计时都挂到同一个 1 ms 节拍上, 谁的窗口到期就用同一个计数器去比,这样“几个节拍算安静”和“几百个节拍算超时” 在代码里是同一把尺子量出来的。

三、帧格式与收发:帧头、长度校验与空闲判帧

上行帧(扫码模块这一侧收到的数据,被转接板重新组织后发到总线上)的结构很薄: 一个固定双字节帧头、一个长度字节、一段数据、末尾两字节校验。 具体取值属于工程约定,本文不列出;下面这张表说的是每个字段承担什么责任, 以及我在收到它的时候做什么检查。

字段含义接收时的处理
帧头(双字节固定值) 标记“这一帧从这里开始” 任何一个字节对不上,就丢弃已收内容、接收状态复位重来
长度 后面数据段的字节数 收到就立刻做上界检查:如果长度已经超过缓冲能容纳的范围,直接清缓冲, 不进入后续流程——避免按一个错误的长度去读后面的内存
数据段 扫码模块输出的原始字节 原样搬运,一个字节都不改(见第六节)
校验(两字节) 覆盖帧头到数据段末尾 校验不通过就整帧丢弃;只有校验通过的数据才会进入发送流程

为什么帧头要用两个字节?因为单字节帧头太容易撞上: 数据段里出现同一个值的概率并不低,一旦撞上,解析就会从错误的位置开始, 而且这种错位在短数据上不容易暴露。 两个字节同时撞上的概率低得多,再叠一层“长度不能越界”的自校验, 基本就不会误判了。这是很便宜的一层保护,值得花这两个字节。

判帧:不用固定长度,也不用帧尾字符

这是这块板子上我最想讲清楚的一个选择。扫码数据的长度由码的内容决定, 转接板不能假设;而“帧尾字符”也不可靠——数据里完全可能出现同一个字节。 所以最终用的是空闲判帧: 每收到一个字节就重置一个空闲计时器,超过窗口没有新字节,就认为这一票收完了。

这个思路和 Modbus RTU 用 3.5 个字符时间判断帧结束是同一个道理, 区别在于 Modbus 那个“帧”是协议规定的报文,而这里判的是 扫码模块自己的一段输出。空闲窗口的具体时长按模块的输出节奏与波特率定, 本文不列;下面这张表是我当时比较过的三种方案:

判帧方案适用场合在这个项目里的问题结论
固定长度 协议报文长度事先约定 扫码数据的长度由码的内容决定,根本无法预设 不可用
帧尾字符 数据是文本、可以约定分隔符 模块给的是原始字节流,任何字节都可能出现在数据里 不可靠
空闲时间 发送方是“一口气吐完一段”的设备 需要一个稳定的时基;空闲窗口要对“模块吐得慢”留余量, 否则会把一票数据切成两票 本项目采用

空闲判帧的代码形状很简单,可以直接给出来(不含本项目的任何计数值与缓冲长度):

/* 空闲判帧的形状(按个人理解重写的最小片段)。
   空闲窗口 IDLE_TICKS 与缓冲长度 RX_BUF_LEN 都由波特率和现场节奏决定,这里只留形状。 */
void uart_rx_isr(uint8_t byte)
{
    if (rx_len < RX_BUF_LEN) {
        rx_buf[rx_len++] = byte;   /* 先存,收的时候不做任何解释 */
    } else {
        rx_len = 0;                /* 越界就重来,绝不让写指针跑出缓冲 */
    }
    idle_timer = IDLE_TICKS;       /* 每收到一个字节,就把空闲计时重新喂满 */
}

void tick_1ms(void)                /* 挂在 1 ms 时基上调用 */
{
    if (idle_timer > 0 && --idle_timer == 0) {
        frame_ready = 1;           /* 安静够久了:这一票到此结束 */
    }
}

这段代码里我想强调两个细节:接收中断里不做任何解释 (不判帧头、不算长度、不校验),因为中断要尽量短; 越界时是“清长度”而不是“丢弃最后一个字节”, 因为一旦写指针跑出缓冲,后面所有的判断都建立在错的位置上, 不如整票重来。

四、仲裁状态机:等待 / 总线忙 / 发送 / 等待回复

一次转发不是“发出去”这一个动作,而是“什么时候能发、发完等多久、等不到怎么办” 三段判断。这三段如果散在几个 if 里,代码很快就会变成一团, 所以我把它们收进一个四态状态机:

状态什么条件下进入在里面做什么什么条件下离开
等待 上电后默认状态;或者一票数据已经处理完 什么都不做,等接收侧攒够一票完整数据 收到一票校验通过的上行数据 → 准备发送
总线忙 有数据要发,但发送前先探测总线 持续探测总线是否安静;到上限仍然忙, 就放弃这一票并复位回等待 确认总线空闲 → 进入发送;探测超限 → 退回等待
发送 总线确认空闲 切方向脚到发送、把整帧移出去、等最后一个字节真正离开移位寄存器, 再切回接收 发送完成 → 进入等待回复
等待回复 发送完成 等一个长度合法、校验通过的应答 收到合法应答 → 回等待,这票结束; 超时 → 重发(有次数上限),超过上限 → 清数据回等待

半双工的方向控制:那两次延时不是保险起见

RS485 收发共用一对差分线,靠一个 GPIO 控制收发器的方向脚。 代码上只是“拉高 / 拉低”两行,但这是整块板子最容易出问题的地方: 切成发送之后,收发器本身需要一点时间才真正建立驱动能力, 如果 MCU 的串口已经比方向脚早一步开始发数据,第一个字节的起始位会被削掉; 反过来,发送完立刻切回接收,而最后一个字节还在移位寄存器里没走完, 尾字节就会被截断。

两种现象在上层看起来一模一样:主板偶尔收到一帧校验错的报文, 而且换线、换波特率都没用。收发前后各留一小段延时是必要的, 具体多长取决于收发器和波特率,本文不列——我的做法是拿逻辑分析仪 同时看方向脚和差分波形,确认“方向脚已经稳定”和“最后一个字节已经走完”这两件事, 而不是照抄一个数字。

新码优先:一次有意为之的“丢数据”

还有一种情况:转接板刚把一票数据发出去、正在等主板回复, 这时候用户又扫了一个码。如果让状态机“等旧的走完再说”, 用户会明显感觉到第二次扫码没反应。 我的选择是让新的优先:收到新的一票就复位整个状态机、 丢掉旧的那一票,并留一个短促的间隔让总线安静下来再重新开始。

这是一个明确用“可能丢一票”换“界面响应”的取舍。 我敢这么选的理由是:扫码是人在操作,人对“再扫一次”几乎没有成本, 但对“扫了没反应”非常敏感。如果这块板子用在无人场景里,这个取舍就要重新做—— 那种场合下,丢一票可能意味着一条记录丢失。

五、三件套:忙检测、超时退让、应答重发

这一节是本文的重点。半双工总线上的一次可靠转发,最终落在三件事上: 发之前确认没人在说、说不下去要会退、说完了要确认对方听见了。

忙检测:发送前的硬条件

发送前必须先确认总线上没有别人在说话,这一步没有任何捷径。 我用的是两条腿一起走:一是“听”——接收线上有没有在持续收到字节; 二是看自己的接收口在这段时间里有没有被触发过。 只有确认已经安静了一整段完整的时间,才算空闲。 这里的“一整段”很重要:一两个字节的间隙不算空闲, 那可能只是对方在换方向或者 MCU 在取下一个字节。

为什么必须退让(这一条我踩过)

我第一版的忙检测只有“等”:总线忙就继续等,等到空闲为止。 逻辑上没错,但在现场遇到一个一直不回话的节点时,这块板子就永久卡在等待里了。 后果比想象的严重:

  1. 半双工总线上没有冲突检测。以太网有 CSMA/CD, 冲突了双方都知道要退;RS485 没有。两个节点同时驱动差分线, 结果就是两边的数据互相破坏,而且这种破坏在上层看起来像“偶发的校验错”, 是所有故障里最难查的一种。所以“确认空闲”这件事只能靠软件自己守。
  2. “一直等”等于把功能永久停掉。如果总线上那个节点因为故障一直占着 (或者我们的判断逻辑把它误判成一直忙),那么“等下去”的结果不是“晚一点发”, 而是这块板子再也不发了。更糟的是扫码口的数据还在源源不断地进来, 缓冲迟早写满,用户看到的是“扫码没反应”,而板子本身还活着、 指示灯也还在闪——这种“看起来正常其实死了”的状态最难排查。
  3. 所以忙检测必须配一个放弃的上限:连续探测到上限还是忙, 就判定为总线超时,放弃这一票、清状态回等待。 放弃的代价是这一票丢了;换来的是这块板子不会卡死,下一票还能正常走。

这个取舍的根据是“谁在等”:扫码是人在等,一票丢掉可以重扫; 板子卡死要断电才能恢复,而现场可能没有人能断电。 两害相权,退让更划算。 这也是我在这个项目里最重要的一条经验: 只要涉及“等别人”,就必须有一个上限,这个上限不是优化,是保命。

应答重发:确认对方真的收到了

发出去不等于送到。主板可能没收到(总线被抢、干扰), 也可能收到了但回复丢了。所以发送完成后要进入等待回复的状态, 在一个窗口内等应答;“合法应答”的判定是两条: 长度至少能装下一个最小应答帧,并且校验通过。 只看长度不看校验、或者只看校验不看长度,都会被噪声骗过去。

窗口内没等到合法应答,就重发同一票。重发同样要有次数上限—— 没有上限的重发比不重发更糟,因为它会把状态机一直占住, 新扫的码永远排在后面。超过上限就放弃这一票、清数据回等待。

三件套解决什么问题不做会怎样
忙检测 确认发送时总线上没有别人 两个节点同时驱动总线,数据互相破坏,表现为偶发校验错,极难定位
超时退让 把“一直等”变成“等到上限就放弃” 遇到故障节点时永久卡在等待里,扫码口缓冲写满,功能彻底停掉
应答重发 兜住“发出去了但对方没收到 / 回复丢了” 数据静默丢失,没有错误提示,主板那边只是“少了一票”

下面这段是这三件事的代码形状,所有上限都由工程整定值给出,不含本项目的任何计数值:

/* 退让与重试的形状(按个人理解重写的最小片段)。
   busy_limit / wait_limit / retry_max 都是工程整定值,这里只保留判断结构。 */
if (bus_is_idle()) {
    send_frame();
    state = WAIT_REPLY;
} else if (++busy_ticks >= busy_limit) {   /* 一直忙:不再等下去 */
    clear_frame();
    state = WAIT;                          /* 退让:把总线让出去,等下一票 */
}

/* 等待应答:超时就重发,但重发次数有上限 */
if (reply_ok()) {
    clear_frame();
    state = WAIT;
} else if (++wait_ticks >= wait_limit) {
    if (++retry < retry_max) {
        send_frame();                      /* 再试一次 */
    } else {
        clear_frame();
        state = WAIT;                      /* 放弃这一票,别把状态机占住 */
    }
}

六、一个必须承认的事实:这块板不做码制解析

我把这个工程从头读了一遍,结论必须如实写在这里: 代码里没有任何码制解析——没有二维码、条形码的类型识别, 没有对码内容的拆分,也没有触发控制(补光、蜂鸣、识读成功指示之类)。 扫码模块输出的原始字节流是被原样透传的: 模块给什么,转接板就搬什么,一个字节都不改。

也就是说,读出来的是 QR 码还是别的码制、是自动感应触发还是命令触发, 这些都由扫码模块自身的固件决定(它本身就是可配置的器件), 而码内容的解析在主板那一侧完成。 这块板子在整条链路里只承担“搬运 + 仲裁”。

我把这一条写出来,是因为它容易被误读成缺陷。我的看法是: 这是如实记录,不是缺陷。把解析放在主板侧的三个理由:

  • 规则要跟着业务变。什么码有效、要不要做前缀校验、要不要去重, 这些是业务规则,会随场景改;放在主板侧只需要改一处。
  • 转接板越“笨”越可靠。它现在的全部状态就是几个缓冲加一个四态状态机, 任何异常都能用“清缓冲回等待”兜住;一旦它开始理解内容, 它就有了业务状态,也就有了新的故障模式。
  • 模块本来就是可配的。码制、触发方式在模块那一侧配置更自然, 转接板重复做一遍只会带来“两边配置不一致”的新问题。

代价也要说清楚:这一层做不了任何内容相关的处理—— 不能按内容过滤、不能对重复的码做去重、不能做白名单。 它只能按长度和空闲时间判帧。如果将来现场真的需要“同一个码短时间内不重复上报”, 那需要先和主板约定一个明确的规则,而不是在这一层猜码制。

另外还有一件事同样要说明:这块板没有参数存储。 Flash 驱动虽然在编译列表里,但主流程里没有找到参数读写的调用, 所以它的行为(本机地址、各种时间窗口、重发上限)实际上都是编译期决定的常量, 改一次就要重新烧写。这是我在第八节列出的第一件要改的事。

七、问题与解决

下面这些是这块板子上真实的“现象 → 原因 → 办法”:

现象原因办法
遇到一直不回话的节点,整块板子卡住, 扫码口一直进数据、缓冲写满开始丢,用户看到“扫码没反应” 忙等与等待回复都没有上限: “等对方”被写成了无限循环,等于把功能永久停掉 两个窗口各加一个上限:连续探测仍忙就放弃这一票、 重发超过上限就清数据回等待;让状态机永远有路可退
主板偶尔说收不到,或者收到的帧校验不过, 换线、换波特率都没有改善 两种原因:一是总线上有别的节点在说话而我们同时发了(仲裁失败); 二是方向切换时序不对——发送前收发器还没建立, 或发送尾字节还没移完就切回接收 把“发送前先确认安静一整段完整时间”做成硬条件; 收发前后各留出建立与移出所需的延时, 并用逻辑分析仪同时看方向脚与差分波形来确认,而不是照抄数字
按固定长度收数据,永远收不对 一票数据的长度由码的内容决定,转接板不能假设长度 改成空闲判帧:收到字节就喂一次计时,安静够久即认为一帧结束; 同时对明显过短的数据直接丢弃(阈值按模块的输出习惯定)
数据段里的某个字节被当成帧头, 解析从错误的位置开始,后面全乱 单字节帧头容易被数据撞上,而且当时没有长度自校验 改成双字节帧头,并对长度字段做上界检查; 任何一步不匹配就清缓冲重来,不在错误状态上继续解析
新扫的码要等很久才发得出去, 用户以为没扫上,又扫了一遍 状态机把“正在等应答”当成了不可打断的过程 改成新码优先:收到新的一票就复位状态机、丢掉旧的那票, 先留一个短促间隔让总线安静,再重新开始
早期把“总线忙”和“没收到应答”混成同一种超时, 处理起来总是顾此失彼 两者的正确反应完全不同:忙应该退让放弃, 没应答应该重发 拆成两个窗口、两个上限、两套子状态; 退让和重发各自独立计数,互不干扰

整理工程时才发现的问题:目录里躺着大量没有参与编译的代码

这一条不是运行故障,但我觉得比前面任何一条都值得写下来。 在整理这个工程的时候我才发现:目录里躺着大量历史代码—— 另一类产品(读卡器)的驱动、协议、卡片操作那一整套, 文件名看上去和这个工程毫不相干,但就摆在同一个 source/ 目录里。 而真正进入编译列表的,只有很少几个文件。

原因是这个工程从别的产品复用过来:文件没有删,只是没有加进 Keil 的编译列表。 后果是我一开始按目录名去理解这块板子在做什么, 于是把一大堆根本不在运行的逻辑当成了它的功能—— 甚至在读代码时困惑“一个扫码转接板为什么要做卡片认证”。

正确的做法是:不要凭目录名判断功能,要用工程文件里的编译清单划出真正在跑的代码。 我是先在工程文件里把参与编译的文件名列出来,再逐个看调用链, 才把“这个工程到底在做什么”确定下来的。 这套顺序(先确认真实编译清单,再读资源、时钟、引脚、版本)我整理在 《拿到一份陌生固件工程的前 30 分钟》 里。

顺便说一句:参与编译的文件里也有和当前功能无关的实现(例如加密相关的文件), 我暂时没有动它们——删代码之前要先确认没有隐藏的调用点, 而“没有调用点”这件事本身也需要先建立可信的编译清单才能确认。

四个状态:等待、总线忙、发送、等待回复,标出退让分支(连续探测失败后放弃)与重发分支
图 2 · 仲裁状态机图

八、局限与还没做完的部分

功能是实现了,但下面这些是我心里清楚的欠账:

问题现状我打算怎么改
没有参数存储 本机地址、各种时间窗口、重发上限全是编译期常量, 改一次要重新烧写;现场想微调只能重新出固件 把需要现场调的项目挪进 Flash 参数区,加上版本号与默认值回退; 参数区的位置与长度属于工程规划,不在这里写死
看门狗 看门狗的驱动虽然在编译列表里,但我在主流程里没有找到喂狗调用。 这一点我尚未确认,不敢写成“已经启用” 先把所有可能长时间不返回的路径找出来,确认它们都能喂狗, 再决定是否打开——没理清就打开等于制造随机复位
码制与内容处理 原样透传,不做任何解析或过滤。这在职责划分上是我有意选择的, 但确实是能力上的空白 如果现场需要“同一个码短时间内不重复上报”, 先和主板约定判定规则与去重窗口,而不是在这块板子上猜码制
历史代码未清理 目录里还有大量没有参与编译的读卡器驱动, 容易让下一个人(包括我自己)误读这块板子的功能 归档到单独目录并写一份说明:哪些文件在编译、哪些不在、为什么留着; 同时把编译清单写进 README
没有量化测试 我只做了“改一处、看一处”的对照观察, 没有做长时间、满负载、多节点同时在线的压力测试 补一份测试清单:连续扫码、总线被持续占用、 拔掉终端电阻、电源波动、总线短路,逐项记录现象与恢复方式
没有现场日志 出问题只能拿逻辑分析仪抓现场, 而“总线忙被迫放弃了几次”“重发过几次”这类统计完全拿不到 加两个计数器(退让次数、重发次数),让主板能读出去; 这比在本地存日志便宜得多,也不需要额外介质

这块板子给我的最大收获反而不是仲裁本身,而是两句话: 第一,凡是“等别人”的地方都必须有上限; 第二,读一个陌生工程,先看编译清单,不要看目录名。 这两条都不是从书上学的,都是被现场教出来的。

参考资料与说明

  • TIA/EIA-485 串行通信标准相关公开资料,用于确认半双工总线、方向控制与总线负载的要求。
  • Modbus 应用协议规范中关于 RTU 帧间隔(3.5 个字符时间)的说明,作为空闲判帧的参考依据。
  • CRC-16/MODBUS 的公开算法说明(多项式与初值),用于确认校验实现;查表法属于公开的通用做法。
  • 所用 MCU 的公开用户手册(UART、定时器、GPIO 与中断章节),用于确认双串口配置与时基来源。
  • 扫码模块的公开通信手册(输出格式与触发方式部分),用于确认“原始字节流透传”这一职责边界。
  • 文中出现的芯片内核与器件类别仅用于说明技术方案,与相关厂商无隶属或授权关系。
  • 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 其中设备地址、帧头取值、超时计数、缓冲长度、波特率、定时器重装值与重试次数均不在本文范围内。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。