[SECS/GEM研究] (三)SECS-I 串口上消息怎么分块和重传

SECS/GEM 协议栈开发笔记 · 第 3 篇

基于代码库:Libsecs(一个 C++ 实现的 SEMI 协议栈)

说在前面:由于工作的原因,本人早些年开发了一套SEMI标准的协议栈用于公司的SECS标准实现,为了防止经验遗忘,特写下本专辑以免后续忘记。

上两篇把"为什么要有 SECS/GEM"和"SEMI 那一大堆标准怎么分层"梳理了下。

往下沉一层,来看下SECS-I这个东西是怎么玩的:SECS-I(标准编号 E4)。

本人刚接触半导体设备通信时,第一个被派去调的就是串口。当时心里还犯嘀咕:都 21 世纪了,怎么还在用 RS-232 这种老古董?后来才明白,车间环境里串口皮实、便宜、干扰小,而且很多老设备一用就是十几年,标准定下来就得认。所以这层虽然老,但绕不开,而且它里头那个"半双工 + 分块 + 重传"的设计,思路非常漂亮,值得单独唠一篇。

下面先讲标准在折腾什么,再对照看看 libsecs讲下代码的实现思路。


一、SECS-I 到底在管什么

一句话:SECS-I 管的是"两根线(RX/TX)上,字节怎么排着队飞过去,丢了怎么要回来"。

它不关心你发的是 S1F1 还是 S6F11,也不关心数据类型是 List 还是 ASCII。在它眼里,一切都只是一串要分段发出去的字节。它解决三件事:

  1. 一条长消息太大,怎么切成小块(block)发出去;
  2. 串口是半双工的(同一时刻只能一方说、一方听),怎么避免两个人同时抢着说话;
  3. 某一块在路上丢了或者坏了,怎么发现并重传。

这三条,就是 E4 标准的全部重心。你把它想成"串口上的可靠传输协议"就行,跟 TCP 在以太网上的角色有点像,只是手段土得多


二、块(block)长什么样

SECS-I 规定:消息在线路上被切成若干个块,单个块最长 254 字节。这就是它的"MTU"。

我在libsecs这个协议栈里把这个值直接写死在类里了:

cpp 复制代码
static const int MAX_MESSAGE_LEN = 254;   // Secs1Message

那 254 字节里都装了什么?拿一条真正的 SECS-II 消息来说,它本来由"10 字节消息头 + 数据体"组成。在 SECS-I 上,这 10 字节头被原封不动地带在每一个块里,剩下的空间装数据体的一个片段。

libsecs 把那个 10 字节头定义成一个紧凑结构体,如下 所示:

cpp 复制代码
struct Secs1Header {
    uint16_t session_id;   // 设备/会话 ID,最高位 R-bit
    uint8_t  stream_byte;  // Stream,最高位 W-bit(要不要对方回)
    uint8_t  func_byte;    // Function
    uint8_t  block_h;      // Block 序号高字节,最高位 E-bit(是否末块)
    uint8_t  block_l;      // Block 序号低字节
    uint32_t system;       // 事务号(System Bytes),4 字节
};

几个 bit 的含义得记一下,后面有用:

  • W-bit(stream 字节最高位):置 1 表示"我这条需要你回一条"。SECS 里很多消息是"问---答"结构,靠这个位区分。
  • R-bit(session_id 最高位):传输的方向标志。
  • E-bit(block 序号最高位):End of Block,置 1 表示"我是最后一块"。接收端看到它,就知道这一堆块拼完了。

这块头里有个很关键的设计:每块都带着完整的 10 字节消息头,包括那个 4 字节的 system(事务号)。也就是说,不管切成多少块,每一块都认得出自己属于哪条消息、是该消息的第几块。接收端即便块乱序到达(串口上一般不乱,但设计上每块自描述),也能正确归位。这是 SECS-I 分块比很多老协议聪明的地方。

下面这张图就是一条 3 块消息在线路上的样子:

text 复制代码
            10字节头            数据片段
块1:  [ session | S/F | blk#1      | system ] [ ....数据.... ]   (E-bit=0)
块2:  [ session | S/F | blk#2      | system ] [ ....数据.... ]   (E-bit=0)
块3:  [ session | S/F | blk#3+EOB  | system ] [ ..剩余数据.. ]   (E-bit=1, 末块)

每块头里的 block 序号从 1 往上走,末块把 E-bit 拉高。接收端从块 1 收起,攒到看见 E-bit 为止,拼成原消息交给上层。


三、半双工:为什么不能直接狂发

以太网是全双工的,你发你的我发我的,互不耽误。串口(RS-232)虽然也是全双工的,但是如果要描述事务的话,通常也是做半双工使用------同一根线上,要么你在说,要么我在说,不能同时,这其实是一种降低速率但是提高通讯稳定性的方法。

这就带来一个麻烦:设备想发消息前,得先确认线路空闲、对方愿意听,否则两边同时开口就成了一团噪音。

SECS-I 用一套很轻量的握手解决这个问题,核心就两个字符:

  • ENQ(ASCII 0x05):"我想用线路,申请一下。"
  • ACK(ASCII 0x06):"准了,你说吧。"

流程大概是:发送方先发 ENQ,等对方回 ACK,然后才开始把块一个一个发过去;每发一块,接收方校验没问题就回 ACK,发送方再发下一块。全部发完,末块的 ACK 也就意味着这次传输结束。

libsecs 里这套握手机制我其实是做成状态机的,我把握手的时序分成了不通的状态:

text 复制代码
S_SEND_ENQ  ->  S_WAIT_ACK  ->  ...(发块、等 ACK)...

也就是"先发 ENQ、再等 ACK、然后进发块循环"。至于收方,则是"收到 ENQ 回 ACK、收到块校验 OK 再回 ACK"。哪一方是主动申请线路的那一方,由角色决定:libsecs 里 EQ(设备)在 SECS-I 下默认扮演 MASTER,也就是由设备主动发起 ENQ 去抢线路(注意:HSMS 里是 host 主动建 TCP 连接,方向正好反过来,这点后面会点出来)。

这套握手看着啰嗦,但好处是稳:任何一步没收到应答,发送方就知道"线路出问题了或者对方忙",可以重来,不会 blindly 把数据往死里灌。


四、超时与重传:丢了怎么办

串口环境脏,块在路上丢了、被干扰坏掉、或者 ACK 没回来,都是常事。SECS-I 靠"超时 + 重传"兜底。

标准里定义了几个超时参数,libsecs 在 Secs1Param 里把它们都收在一个结构体里:

cpp 复制代码
typedef struct {
    uint16_t T1, T2, T3, T4;
    uint16_t rty;         // retry 次数上限
    enum Role { EQ, HOST } role;
} Secs1Param;

这里有几个超时时间的参数:

  • T1(块间超时):发完一块、到收到下一块允许的最大间隔。默认 500ms。意思就是"一块接一块,中间卡太久就当你丢了"。
  • T2(等应答超时) :发完一块、等多久没收到 ACK 就认为失败。libsecs 协议层默认 10 秒,而设备类 Secs1Equipment 在初始化时把它压成了 2000ms(更快失败、更快重传,适合车间节奏)。

还有 rty(重传次数),libsecs 默认设成 3。重传逻辑很朴素:

发一块 → 等 ACK → 超时没收到 / 收到 NAK(校验不过)→ 带着 R-bit 把这块重发一次 → 还不行就再发 → 直到 rty 次用尽 → 判定这次通信失败,上报异常。

T3、T4 这俩在 SECS-I 语境里含义和 HSMS 略有区别,篇幅所限先不展开,你只要记住:T1/T2 管"块级"快慢,rty 管"最多试几次",这一套组合起来就是 SECS-I 的可靠性来源。


五、libsecs 里大概是怎么搭的

讲一下我在Libsecs里面的实现, SECS-I 这一层在 libsecs 里主要由三个角色配合完成):

  • Secs1Equipment :设备对象,继承自通用的 SecsEquipment。它干两件事:把上层的一条 SecsMessage 序列化成字节流(serializeMessage),以及把收到的字节流喂给解析器。它内部持有 Secs1ProtocolSecsParser
  • Secs1Protocol :真正的传输引擎,管串口、管 ENQ/ACK 握手状态机、管 T1/T2 超时和重传。它对外暴露的接口很干净:addMessage() 把要发的块丢进发送队列,setMessageHandler() 注册一个回调,收到完整消息时自动调你。
  • Secs1Parser :解析器,把串口上零碎到达的字节一点点攒起来,按块头里的 E-bit 判断一条消息收齐没有,收齐了就组装回 Message 交上去。

分块这件事具体怎么做,看 serializeMessage 的思路就很清楚:

text 复制代码
id = 1
循环:
    取本块能装的数据片段(最多 MAX_BLOCK_ITEM_SIZE 字节)
    如果是最后一块(剩余 <= 上限):
        block_id 最高位 |= 0x8000   // 打上 EOB 标志
    拼 10 字节头 + 这段数据
    推进偏移量
    直到整条消息发完

接收端反着来:parser 每收一块,检查 block_h & 0x80 是不是 1(EOB),不是就暂存,是就拼齐交付。就是这么直接。

这里有个实现上的小聪明值得说:发送端是按"每块固定大小"机械切的,但每块都带完整 10 字节头(含 system 事务号和 block 序号),所以接收端根本不需要知道对方切了多少块、怎么切的,纯靠每块自带的元数据就能还原。这种"每块自描述"的设计,让收发两端解耦得很干净。


六、小结

SECS-I(E4)这一层差不多就是这样。记住三句话够了:

  1. 它管串口上的可靠传输:分块、半双工握手、超时重传;
  2. 块是 254 字节上限,每块带 10 字节头,靠 E-bit 标末块、W-bit 标要不要回、R-bit 标代表方向;
  3. 可靠性来自 T1/T2 超时 + rty 次重传,而不是"发出去就不管了"。

下一篇聊 HSMS(E37)。你会发现,现代设备基本不用串口了,改跑 TCP/IP,而 HSMS 就是"把 SECS-II 这套消息原样搬到网线上"的标准。它不用 ENQ/ACK 那套握手了(TCP 自己保证可靠),而是用 Select/Deselect 建链、用 Linktest 当心跳。为什么现在都爱用 HSMS 而不用 SECS-I?个人认为和历史原因有关,这个后面再说。

本人不是 SEMI 专家,上面这些也是当年啃 E4 文档一点点尝试出来的,哪里理解有偏差,评论区直接拍我。

相关推荐
光头闪亮亮1 小时前
Fyne ( go跨平台GUI )项目实战-项目开发必备基础知识(中)
android·c++·go
光头闪亮亮1 小时前
Fyne ( go跨平台GUI )项目实战-项目开发必备基础知识(下)
android·c++·go
_wyt0012 小时前
洛谷 P7912 [CSP-J 2021] 小熊的果篮 题解
c++·队列
choumin3 小时前
创建型模式——原型模式
c++·设计模式·原型模式·创建型模式
code_pgf3 小时前
C/C++ 常用容器功能汇总
c语言·开发语言·c++
王维同学4 小时前
Credential Provider、Filter 与 PLAP 的 CLSID 枚举
c++·windows·安全·注册表
Carlgood-Minecraft5 小时前
随机分位系统破解版 (BPS)Version-B4.2
c++
choumin5 小时前
结构型模式——装饰模式
c++·设计模式·装饰模式·结构型模式
paeamecium7 小时前
【PAT甲级真题】- Kuchiguse (20)
数据结构·c++·python·算法·pat考试·pat