feat(comm): 1.6.21 段2改电机整轮密排, 删段4历史覆盖窗

- 段2(32..543): count=实际电机数(0..9), 空节点不占位, 槽序按 node_id 升序
- 同号多通道取 seq 最新; magic@35=0xA5 区分密排与旧滑窗
- 差分填充: 节点集合+seq/online 未变时整段复用影子
- 删段4 fillRxWindow; 非STATUS仍走段1/3最近一帧
- 输入PDO总长仍600B, SII无需重刷; 主站需按count密排解码
- 动机: 接3电机却看到5状态帧(空槽占位); win_base滑窗对已识别电机无意义
This commit is contained in:
cbb
2026-09-11 01:08:37 +08:00
parent 959e2aef6e
commit 1742d9a759
8 changed files with 254 additions and 483 deletions
+109 -246
View File
@@ -1,9 +1,9 @@
/**
* @file ethercat_can_bridge.h
* @brief EtherCAT PDO ↔ CAN 转发桥(1.5.55)。
* @brief EtherCAT PDO ↔ CAN 转发桥(1.6.21:段 2 改 9 电机密排整轮)。
*
* 输出 PDO(0x7000, 主站→从站, 64B) = 待发送的 CAN 帧(数据区 32B)
* 输入 PDO(0x6000, 从站→主站, 384B) = 桥状态 + 电机状态窗口 + 长帧快照
* 输出 PDO(0x7000, 主站→从站, 384B) = 待发送的 CAN 帧(数据区 32B)
* 输入 PDO(0x6000, 从站→主站, 600B) = 桥状态 + 电机整轮状态 + 长帧快照 + 诊断
*
* 发送用**序号沿触发**:仅当 seq 字节变化时才真正发一帧。PDO 是周期性刷新的,
* 若按「内容非空即发」会把同一帧每个周期重发一次;seq 变化沿是 PDO 里表达
@@ -13,40 +13,23 @@
* TaskRunner::taskCanRxParse(),两个消费者会互相抢帧,OTA relay / 电机状态
* 会随机丢帧。改为由 taskCanRxParse() 的分发链回调 onCanFrame() 投递。
*
* ── 1.5.55 为电机控制台扩容(32B/32B → 64B/384B)──
* ── 1.6.21:段 2 改「一帧 = 总线电机整轮状态」,删段 4 历史帧窗口 ──
*
* 三个动机:
* 1. TX 数据区 24B→32B。电机 CMD 是 DLC 32,旧 processOutput 里
* `dlc > kEcatCanTxDataMax` 直接钳位到 24,后 8 字节被**静默丢弃**且不报错。
* 2. 输入侧加电机状态窗口。单帧快照对电机控制台不够用:9 节点各 32B STATUS,
* 一周期只带回一帧、新帧还覆盖旧帧,永远取不齐。改为直接从 MotorState
* 状态表(已按 node×channel 聚合,见 device/motor_state.h)取一个窗口,
* 主站移动 win_base 即可 4 个周期扫完全部 27 槽。
* 3. 加长帧快照区。旧 RX 数据区只有 8B,版本帧(DLC24)/CONFIG 帧(DLC24) 会被截断。
* 旧布局的问题(用户实测:接 3 个电机网页却看到 5 个状态帧):
* 1. 段 2 是固定 5 槽滑窗(win_base 要 6 拍才扫完 27 槽),空槽 node_id=0
* 也占位,win_count 报的是跨度不是电机数 —— 主站若不滤空槽就会多报;
* 2. 段 4 覆盖窗口把最近几条非 STATUS 帧反复重发,属于「历史帧 / 重复帧」。
*
* **输入 PDO 前 32 字节与 1.5.53 逐字节兼容**,偏移与语义全部不变。这样
* tools/ecat 的 CAN 调试页(ecat.html)与 ecat_server.py 的 _can_decode_input
* 不改一行也照常工作,扩容只是在后面追加新区段。
* 本版语义改为用户需求:
* · 每一拍输入 PDO 携带**总线上已识别电机的完整一轮状态**(最多 9 个);
* · **空槽不发送**:密排,count = 本拍实际打包的电机数;
* · 主站按 count 迭代,每槽自带 node_id,自动识别,无需 win_base 滑窗;
* · 不发段 4 历史帧;版本/CONFIG 仍走段 1/段 3「最近一帧」快照;
* · 上一轮已打包且 seq 未变的电机,本轮跳过 MotorState 重读与 48B 拷贝,
* 直接复用影子区(s_motShadow)。
*
* ── 1.5.56 扩到 DPRAM 硬上限并加 CANFD 批量帧区(64/384 → 384/640)──
*
* 动机:需要一个周期里合并下发/回收**多条 CANFD 帧**。1.5.55 的单帧槽
* (一个 seq 一帧)每周期最多一帧,4ms 周期就是 250 帧/s 上限,
* 而一次多轴同步下发就要 9 帧,靠排队要 36ms 才发完 —— 各轴指令不同拍,
* 失去"同步下发"的意义。
*
* 新增两个批量区,每条帧带 8B 头(ch/dlc/flags/rsvd + id)+ 数据:
* 输出 48..383(336B):批量发送,一个 seq 沿一次性发 N 帧
* 输入 360..:批量接收,一周期回收多帧而不是只带最近一帧
* (1.6.1 数据条目 64→32B;1.6.15 改定长覆盖窗口 5×44B 到 583,
* 段 5 诊断区 484..495 → 584..595,输入总长 496 → 600)
*
* 三条兼容性铁律(改动只做追加,不动既有偏移):
* 1. 输出 0..47 与 1.5.55 单帧布局完全一致(seq/ch/dlc/flags/id/win/data32);
* 2. 输入 0..359 与 1.5.55 完全一致(段1 桥状态 + 段2 电机窗口 + 段3 长帧);
* 3. 批量区**为空时**(count=0)行为与 1.5.55 完全相同,老主站照旧可用。
*
* 上限推导(为何是 384+640 而不是更大)见 comm/ethercat_pdo_size.h。
* 输入 PDO 总长仍 600B ⇒ 无需重刷 EEPROM/SII;段 2/3 偏移变了,**主站解码
* 必须同步**(tools/ecat 的 decode_input / transport-ecat.js 等)。
*/
#ifndef COMM_ETHERCAT_CAN_BRIDGE_H
#define COMM_ETHERCAT_CAN_BRIDGE_H
@@ -54,20 +37,21 @@
#include <cstdint>
#include "comm/ethercat_pdo_size.h"
#include "device/motor_state.h" /* kMotorMaxNodes / kMotorChannels */
/** 输出 PDO(主站→从站, 384B)段 1(0..47):单帧发送。**与 1.5.55 完全兼容**。 */
/** 输出 PDO(主站→从站, 384B)段 1(0..47):单帧发送。布局与 1.5.55 兼容。 */
enum : uint8_t {
ECAT_CAN_TX_OFF_SEQ = 0U, /**< 发送序号:变化沿触发一次发送(单帧+批量一起) */
ECAT_CAN_TX_OFF_CH = 1U, /**< 目标通道 0/1/2 */
ECAT_CAN_TX_OFF_DLC = 2U, /**< 数据长度 0..32;0 表示本次不发单帧(只发批量) */
ECAT_CAN_TX_OFF_FLAGS = 3U, /**< bit0=1 请求清零统计;其余保留 */
ECAT_CAN_TX_OFF_ID = 4U, /**< CAN ID,小端 uint32(29 位扩展帧) */
ECAT_CAN_TX_OFF_WIN = 8U, /**< 请求回传的电机槽窗口起始下标 0..26 */
/* 9..15 保留:让数据区落在 16 字节边界,hexdump 排查时对齐好读 */
ECAT_CAN_TX_OFF_WIN = 8U, /**< 1.6.21 起忽略(电机整轮密排,无滑窗);保留占位 */
/* 9..15 保留:让数据区落在 16 字节边界 */
ECAT_CAN_TX_OFF_DATA = 16U, /**< 数据区起点,32 字节(一帧电机 CMD) */
};
/** 输出 PDO 段 2(48..383):CANFD 批量发送区(1.5.56 新增)。 */
/** 输出 PDO 段 2(48..383):CANFD 批量发送区。 */
enum : uint16_t {
ECAT_TXB_OFF_COUNT = 48U, /**< 本次要发的帧数 0..kEcatTxBatchMax;0=不发批量 */
ECAT_TXB_OFF_FLAGS = 49U, /**< bit0=1 遇错即停(默认继续发完并各自记结果) */
@@ -85,7 +69,7 @@ enum : uint8_t {
ECAT_FRM_HDR_SIZE = 8U, /**< 头长度,数据紧跟其后 */
};
/** 输入 PDO 段 1(0..31):桥状态 + 最近一帧短快照。**与 1.5.53 完全兼容**。 */
/** 输入 PDO 段 1(0..31):桥状态 + 最近一帧短快照。 */
enum : uint8_t {
ECAT_CAN_RX_OFF_ACK = 0U, /**< 回显已执行的发送序号,主站据此确认送达 */
ECAT_CAN_RX_OFF_CH = 1U, /**< 该帧来自哪个通道 */
@@ -97,231 +81,134 @@ enum : uint8_t {
ECAT_CAN_RX_OFF_TXRES = 20U, /**< 最近一次发送结果(CanTxResult 原值,0=OK) */
ECAT_CAN_RX_OFF_BACKLOG = 21U, /**< 该通道软件 TX 队列积压 */
ECAT_CAN_RX_OFF_TXCNT = 22U, /**< 累计发送受理帧数,小端 uint16 */
ECAT_CAN_RX_OFF_DROP = 24U, /**< 段 1/段 3 快照「未取先覆」次数:上一帧尚未被任何一拍
PDO 发布就被新帧覆盖才 +1(u16,累计回绕)。 */
ECAT_CAN_RX_OFF_DROP = 24U, /**< 段 1/段 3 快照「未取先覆」次数(u16 累计回绕) */
};
/** 输入 PDO 段 2(32..319):电机状态窗口头 + 槽数组。 */
/**
* 输入 PDO 段 2(32..543):电机整轮状态(密排,1.6.21)。
*
* 主站解析:
* count = in[32] // 0..9,本拍实际打包的电机数
* for (i = 0; i < count; i++)
* slot = in[40 + i*56] // node_id/online/dlc/seq/status
*
* 空节点不占位;槽序按 node_id 升序;同号多通道取 seq 最新的一条。
*/
enum : uint16_t {
ECAT_MOTW_OFF_ACTIVE = 32U, /**< 在线电机数 */
ECAT_MOTW_OFF_BOOT = 33U, /**< 1=近 3s 收到过 0x103 BOOT 心跳 */
ECAT_MOTW_OFF_WIN_BASE = 34U, /**< 本帧携带的槽窗口起始下标(回显主站请求) */
ECAT_MOTW_OFF_WIN_COUNT = 35U, /**< 本帧携带的有效槽数(≤5) */
ECAT_MOTW_OFF_GLOBAL_SEQ = 36U,/**< 状态表全局刷新序号,小端 uint32 */
ECAT_MOTW_OFF_SLOTS = 40U, /**< 槽数组起点,5×56B(40..319,与段 3 正好相接) */
ECAT_MOTW_OFF_COUNT = 32U, /**< 本拍密排电机数 0..9(主站按此迭代) */
ECAT_MOTW_OFF_ACTIVE = 33U, /**< 在线电机数(与 count 可不同:离线但仍报最后一轮) */
ECAT_MOTW_OFF_BOOT = 34U, /**< 1=近 3s 收到过 0x103 BOOT 心跳 */
ECAT_MOTW_OFF_MAGIC = 35U, /**< 恒 kEcatMotLayoutMagic(0xA5):1.6.21 密排标识 */
ECAT_MOTW_OFF_GLOBAL_SEQ = 36U, /**< 状态表全局刷新序号,小端 uint32 */
ECAT_MOTW_OFF_SLOTS = 40U, /**< 密排槽数组起点,count×56B */
};
/** 电机状态槽内部布局(56 字节)。 */
enum : uint8_t {
ECAT_MOT_OFF_NODE = 0U, /**< 节点号 1..9,0=空槽 */
ECAT_MOT_OFF_NODE = 0U, /**< 节点号 1..9(密排下不会出现 0) */
ECAT_MOT_OFF_CH = 1U, /**< CAN 通道 0/1/2 */
ECAT_MOT_OFF_ONLINE = 2U, /**< 1=在线 */
ECAT_MOT_OFF_DLC = 3U, /**< 最后一帧 STATUS 的 DLC(正常 48) */
ECAT_MOT_OFF_SEQ = 4U, /**< STATUS 刷新序号,小端 uint32 */
ECAT_MOT_OFF_STATUS = 8U, /**< 原始 STATUS payload,48 字节(v0.74:B32-35=InitStatus) */
ECAT_MOT_OFF_STATUS = 8U, /**< 原始 STATUS payload,48 字节 */
};
/** 输入 PDO 段 3(320..359):最近一帧**完整**快照(版本/CONFIG 帧要 24B)。 */
/** 输入 PDO 段 3(544..583):最近一帧完整快照(版本/CONFIG 帧要 24B+)。 */
enum : uint16_t {
ECAT_XRX_OFF_CH = 320U, /**< 通道 */
ECAT_XRX_OFF_DLC = 321U, /**< 原始 DLC(可达 32) */
ECAT_XRX_OFF_FLAGS = 322U, /**< bit0=有效 */
ECAT_XRX_OFF_RSVD = 323U,
ECAT_XRX_OFF_ID = 324U, /**< CAN ID,小端 uint32 */
ECAT_XRX_OFF_DATA = 328U, /**< 数据区,32 字节完整 payload */
ECAT_XRX_OFF_CH = 544U, /**< 通道 */
ECAT_XRX_OFF_DLC = 545U, /**< 原始 DLC(可达 32) */
ECAT_XRX_OFF_FLAGS = 546U, /**< bit0=有效 */
ECAT_XRX_OFF_RSVD = 547U,
ECAT_XRX_OFF_ID = 548U, /**< CAN ID,小端 uint32 */
ECAT_XRX_OFF_DATA = 552U, /**< 数据区,32 字节完整 payload */
};
/** 输入 PDO 段 4(360..583):CANFD 批量接收**定长覆盖窗口**(1.6.15)。
*
* 与旧"环形队列 + 变长紧排排空"的区别:生产者(onCanFrame,主循环)按自己的
* 游标写槽、绕圈覆盖谁就是谁;消费者(fillRxWindow,PDI_Isr)每拍整块取值。
* 没有 tail、没有判满、没有 pending —— 1.6.9 修的那个"滞后的 tail 让生产者
* 写进消费者刚读过的槽"错标竞态,其载体(tail 握手)在本布局里不存在了。
*
* 定长是"整块取"的前提:槽下标 = (ECAT_RXB_OFF_ITEMS + i*ECAT_RXB_SLOT_SIZE),
* 主站按固定偏移读,不再需要"8 + min(dlc,上限)"推游标,也就不可能再出现
* 1.6.10 那种游标越进诊断区的边界错。
*
* 每槽自带 seqlock(ECAT_RXW_OFF_SEQ):PDI_Isr(preempt=0) 会抢占主循环写侧,
* 没有它主站能拿到"新 ID + 旧数据"拼出来的假帧 —— 那不是"差一个周期",是
* 现实中不存在但看起来完全合法的帧。seq 同时是主站去重与算丢帧的依据。
*
* 语义变更(相对旧批量区):**最近 N 帧窗口**,不是"保证不丢的事件流"。
* 同一帧会在连续几拍里重复出现(直到被覆盖),低频一次性帧(版本/节点应答)
* 在突发流量下可能被静默覆盖 —— 主站侧按 seq 去重后归一成事件流再上报,
* 并可用 seq 空洞算出被覆盖掉的帧数。 */
/** 输入 PDO 段 5(584..595):设备诊断区(位置未变)。 */
enum : uint16_t {
ECAT_RXB_OFF_SLOT_COUNT = 360U, /**< 窗口槽数(编译期常量 ECAT_RXB_SLOTS) */
ECAT_RXB_OFF_WR_IDX = 361U, /**< 最近一次写入的槽下标 0..N-1(诊断节拍用) */
ECAT_RXB_OFF_OVF = 362U, /**< 自上次回填以来,写入了"主站尚未取过"槽的次数
(饱和 255),回填后清零。与主站 seq 空洞是两个
独立仪表,但**度量窗口不同**:本计数是"还没拷进
PDO 镜像就被覆写"(亚拍级),主站算的是"两次解码
之间被覆写"。主站与从站同拍(1kHz)时两者应一致;
主站掉拍时主站数更大,那是真实差异不是仪表坏了。 */
ECAT_RXB_OFF_RSVD = 363U,
ECAT_RXB_OFF_ITEMS = 364U, /**< 槽数组起点,N × ECAT_RXB_SLOT_SIZE 定长 */
};
/** 覆盖窗口单槽布局(44 字节)。头 12B + 数据 32B。 */
enum : uint8_t {
ECAT_RXW_OFF_CH = 0U, /**< 通道 0/1/2 */
ECAT_RXW_OFF_DLC = 1U, /**< **原始** DLC 0..64(不是线上长度) */
ECAT_RXW_OFF_FLAGS = 2U, /**< bit0=本槽至少写过一帧; bit1=线上数据被截断到 32B */
ECAT_RXW_OFF_RSVD = 3U,
ECAT_RXW_OFF_ID = 4U, /**< CAN ID,小端 uint32 */
ECAT_RXW_OFF_SEQ = 8U, /**< 全局单调 seq,小端 u32。奇数=写入中(消费者跳过);
偶数=整槽一致。跨槽共用一个计数器,故主站可比大小
排序、可用空洞数出被覆盖的帧。
**发布值恒为 2,4,6,…(奇数被"写入中"占用),所以
相邻两帧的 seq 差 2**:主站算空洞必须用 step/2-1,
按 step-1 会把每帧都虚报成丢 1 个(1.6.15 首版踩过,
症见 can_cadence 每拍 count=2/lost=2 而 OVF=0)。 */
ECAT_RXW_OFF_DATA = 12U, /**< 数据区,32 字节(kEcatRxBatchDataMax) */
};
/** 覆盖窗口单槽字节数:12B 头 + 32B 数据。改任一字段宽度都要重算总预算。 */
constexpr uint8_t ECAT_RXB_SLOT_SIZE = 44U;
/** 覆盖窗口槽数。5 槽 = 5000 帧/s@1ms;必须 ≥ 每拍到达帧数,否则静默丢帧。 */
constexpr uint8_t ECAT_RXB_SLOTS = 5U;
/** 输入 PDO 段 5(584..595):设备诊断区(1.5.61 尾部追加,1.6.1 前移到 484,
* 1.6.15 随批量覆盖窗口扩到 5 槽再前移到 584:364+5×44=584,
* 诊断区锚在紧随其后的 12 字节,写侧是 EthercatPdo::fillDiagTail())。 */
enum : uint16_t {
ECAT_DIAG_OFF_LOAD = 584U, /**< 负载率,小端 uint16,单位 0.1%(1000=100%)。
中断 + 非延迟处理段(任务派发/OTA relay)占墙钟比,
不含常开轮询与空转;1s 滚动窗口(见 sys/cpu_load.h) */
ECAT_DIAG_OFF_TORN = 586U, /**< CAN 取帧撕裂丢帧数(低 16 位,小端,饱和不回绕)。
1.6.6 起:ISR 取帧时若 MP 的 TS/DLENC 前后不一致,
说明邮箱窗口在取数途中被硬件换帧,该帧整帧丢弃并
在此计数。这是"读到半条帧"的唯一可观测量 —— 主站
据此判断修复是否生效、总线负载是否高到频繁触发。 */
ECAT_DIAG_OFF_SEQ = 588U, /**< 诊断区序号,小端 uint16(1.6.12 起,原 u32 让出
高 2 字节),每完成一个 1s 统计窗口 +1,主站据此
判断负载值是否仍在滚动刷新。u16 足够:主站只判
"非 0 且在涨",18h 才回绕。 */
ECAT_DIAG_OFF_OVERFULL = 590U, /**< CAN RX FIFO 满溢计数 RFO0(u16 饱和,1.6.12)。
1.6.11 把溢出策略改成"满则丢新帧"后拼接并未消失
(段5@594 以 ~384/s 增长),必须直接观测溢出本身
与拼接率是否同涨,才能判定残余拼接是否仍源自溢出
(即本 IP 在 DISCARD 模式下照样毁窗口)。 */
ECAT_DIAG_OFF_RELTIMEOUT = 592U, /**< RFD0 释放握手超时计数(u16 饱和,1.6.12)。
原为 1.6.7 的 PDO 重入计数 ECAT_DIAG_OFF_REENTRY
(引入至今恒 0,不再上线,仍留内部计数)。取帧后
等 RFD0 自清 2000 次未果 = dequeue 未完成,窗口
与读指针可能已失同步 —— 拼接的直接前兆。 */
ECAT_DIAG_OFF_REJECT = 594U, /**< CAN 内容级拼接帧命中率(u16 饱和,1.6.11)。
入队前结构自洽校验(STATUS 温度量程+斜率、CONFIG
未使用区/模式)命中即计数。1.6.12 起**只计不丢**
—— 数据管道必须诚实,该计数退化为"源头是否还在
产生拼接帧"的纯测量仪表:修好 = 恒 0,没修好 =
网页看到什么,计数就是什么的倍率。 */
ECAT_DIAG_OFF_LOAD = 584U, /**< 负载率,小端 uint16,单位 0.1%(1000=100%) */
ECAT_DIAG_OFF_TORN = 586U, /**< CAN 取帧撕裂丢帧数(低 16 位,饱和) */
ECAT_DIAG_OFF_SEQ = 588U, /**< 诊断区序号,小端 uint16,每完成 1s 窗口 +1 */
ECAT_DIAG_OFF_OVERFULL = 590U, /**< CAN RX FIFO 满溢计数 RFO0(u16 饱和) */
ECAT_DIAG_OFF_RELTIMEOUT = 592U, /**< RFD0 释放握手超时计数(u16 饱和) */
ECAT_DIAG_OFF_REJECT = 594U, /**< CAN 内容级拼接帧命中计数(u16 饱和) */
};
/** 单帧数据在输出 PDO / 长帧快照区中的最大字节数(电机 CMD 是 DLC 32)。 */
constexpr uint8_t kEcatCanTxDataMax = 32U;
/** 段 1 短快照数据区字节数(保持 1.5.53 兼容,勿改)。 */
/** 段 1 短快照数据区字节数。 */
constexpr uint8_t kEcatCanRxDataMax = 8U;
/** 段 3 长快照数据区字节数。 */
constexpr uint8_t kEcatCanXRxDataMax = 32U;
/** 单个电机状态槽字节数:8B 头 + 48B STATUS(v0.74)。
* 1.6.4 前是 40B(只装得下 32B STATUS),于是 buildInput 按 48B 拷贝时会溢出
* 16B 盖到段 3,且主站永远拿不到 B32-35 的 InitStatus。 */
constexpr uint8_t kEcatMotSlotSize = 56U;
/** 一份输入 PDO 携带的电机状态槽数:段 2 可用 280B(40..319)÷ 56 = 5。
* 27 槽需 ⌈27/5⌉ = 6 个基址扫完。槽数×槽长仍是 280B,输入 PDO 总长不变,
* 所以 EEPROM/SII 无需重刷。 */
constexpr uint8_t kEcatMotSlotsPerPdo = 5U;
/** 单个电机状态槽字节数:8B 头 + 48B STATUS。 */
constexpr uint8_t kEcatMotSlotSize = 56U;
/** 一拍最多打包的电机数 = 节点号 1..9 各一槽(同号多通道取最新)。 */
constexpr uint8_t kEcatMotSlotsPerPdo = kMotorMaxNodes; /* 9 */
/** 段 2 头长度(COUNT..GLOBAL_SEQ,8B)。 */
constexpr uint8_t kEcatMotHdrSize = 8U;
/** 段 2 密排区最大字节:头 8B + 9×56B = 512B,落在 32..543。 */
constexpr uint16_t kEcatMotRegionBytes =
(uint16_t)(kEcatMotHdrSize
+ (uint16_t)kEcatMotSlotsPerPdo * kEcatMotSlotSize);
/** 布局魔数,写在 offset 35。主站据此区分 1.6.21 密排与 1.6.15 滑窗(两者
* Ibytes 同为 600,不能只靠长度分支)。0=旧布局。 */
constexpr uint8_t kEcatMotLayoutMagic = 0xA5U;
/** 过程数据长度,唯一真源见 comm/ethercat_pdo_size.h(C/C++ 共用)。 */
constexpr uint16_t kEcatPdoOutBytes = ECAT_PDO_OUT_BYTES; /* 48×64bit = 384B */
constexpr uint16_t kEcatPdoInBytes = ECAT_PDO_IN_BYTES; /* 75×64bit = 600B */
/** 过程数据长度,唯一真源见 comm/ethercat_pdo_size.h。 */
constexpr uint16_t kEcatPdoOutBytes = ECAT_PDO_OUT_BYTES; /* 384B */
constexpr uint16_t kEcatPdoInBytes = ECAT_PDO_IN_BYTES; /* 600B */
/** CANFD 单帧数据上限(DLC 64)—— 覆盖窗口/发送批量区按此上限做容量推导。 */
/** CANFD 单帧数据上限。 */
constexpr uint8_t kEcatCanFdDataMax = 64U;
/** 批量**接收**槽在 PDO 内的数据字节数上限(1.6.1 从 64 压到 32)。
* 真机采样 6086 帧 DLC 只有 24/32 两档;超长帧在装填时截断(flags bit1),
* RAM 侧 RxWinSlot 仍存全量 64B,截断只发生在拷进 PDO 这一步。
* 将来若要线上传 64B:槽长变 12+64=76,5 槽需 364+380+12=756B > 640 上限,
* 届时只能降到 3 槽(364+228+12=604 → 输入 608B),即"全量"与"帧数"二选一。 */
constexpr uint8_t kEcatRxBatchDataMax = 32U;
/** 批量发送区可用字节数与最坏情况(全 DLC64)帧数上限。 */
constexpr uint16_t kEcatTxBatchBytes = (uint16_t)(kEcatPdoOutBytes - ECAT_TXB_OFF_ITEMS);
constexpr uint8_t kEcatTxBatchMax =
(uint8_t)(kEcatTxBatchBytes / (ECAT_FRM_HDR_SIZE + kEcatCanFdDataMax));
/* 布局守卫:段 2 的槽数组必须完整落在输入 PDO 内,段 3 也不能越界。
* 改槽数/槽大小时忘了同步 PDO 长度,会在这里编译期失败。 */
static_assert(ECAT_MOTW_OFF_SLOTS + kEcatMotSlotsPerPdo * kEcatMotSlotSize
<= ECAT_XRX_OFF_CH, "电机槽窗口与长帧快照区重叠");
static_assert(ECAT_XRX_OFF_DATA + kEcatCanXRxDataMax <= ECAT_RXB_OFF_SLOT_COUNT,
"长帧快照区与批量接收区重叠");
/* ── 布局守卫 ── */
static_assert(ECAT_MOTW_OFF_SLOTS
+ (uint16_t)kEcatMotSlotsPerPdo * kEcatMotSlotSize
== ECAT_XRX_OFF_CH,
"段2密排区必须与段3首尾相接");
static_assert(ECAT_XRX_OFF_DATA + kEcatCanXRxDataMax == ECAT_DIAG_OFF_LOAD,
"段3必须与段5首尾相接");
static_assert(ECAT_CAN_TX_OFF_DATA + kEcatCanTxDataMax <= ECAT_TXB_OFF_COUNT,
"单帧发送数据区与批量发送区重叠");
static_assert(ECAT_RXB_OFF_ITEMS < kEcatPdoInBytes, "批量接收区起点越界");
static_assert(ECAT_TXB_OFF_ITEMS < kEcatPdoOutBytes, "批量发送区起点越界");
/* 批量区至少要装得下一帧满长 CANFD,否则"批量"没有意义 */
static_assert(kEcatTxBatchMax >= 1U, "批量发送区装不下一帧 DLC64");
/* 槽长必须容得下头 + 线上数据上限;改任一字段宽度都在这里拦下。 */
static_assert(ECAT_RXW_OFF_DATA + kEcatRxBatchDataMax <= ECAT_RXB_SLOT_SIZE,
"覆盖窗口槽长容不下头+数据");
static_assert(ECAT_RXW_OFF_DATA + kEcatRxBatchDataMax == ECAT_RXB_SLOT_SIZE,
"覆盖窗口槽长有空洞:定长布局不应留空隙");
/* 整个覆盖窗口 + 段 5 诊断区必须落在输入 PDO 内。这是 600B 这个数的来源:
* 少一个字节都会在 SAFEOP 报 AL 0x001A/0x0011,而根因在这里编译期就能抓到。 */
static_assert(ECAT_RXB_OFF_ITEMS + (uint16_t)ECAT_RXB_SLOTS * ECAT_RXB_SLOT_SIZE
<= ECAT_DIAG_OFF_LOAD, "覆盖窗口与段5诊断区重叠");
static_assert(ECAT_DIAG_OFF_REJECT + 2U <= kEcatPdoInBytes, "段5诊断区越界");
/* 窗口至少要能容下一拍可能到达的帧数:槽数 < 帧/拍 时超出部分会在两次 PDO
* 拷贝之间被覆盖,属静默丢帧(主站只能靠 seq 空洞事后发现)。5 槽对现场
* 4000 帧/s(=4 帧/拍) 留 25% 余量。 */
static_assert(ECAT_RXB_SLOTS >= 5U, "覆盖窗口槽数低于现场每拍到达帧数");
static_assert(ECAT_MOTW_OFF_COUNT + kEcatMotRegionBytes <= ECAT_XRX_OFF_CH,
"段2密排区越界");
/**
* @brief EtherCAT ↔ CAN 转发桥。
* @note 全静态;PDO 侧函数在 Sync0_Isr(preempt=0) 或主循环 MainLoop 上下文执行,
* onCanFrame() 在主循环 taskCanRxParse() 上下文执行。共享状态用 volatile +
* 单生产者/单消费者语义,不需要临界区(投递只写、消费只读+清标志)。
* 例外是"多字段一组"的三处,全部用 seqlock 而不是握手:
* - 段 1/段 3 的"最近一帧快照"(id/dlc/data 必须配套)→ s_xrxSeq;
* - 段 2 电机槽(48B payload 一次 memcpy)→ MotorSlot.wr_seq;
* - 段 4 覆盖窗口每槽(1.6.15)→ RxWinSlot.seq。
* 单生产者/单消费者语义。多字段一组用 seqlock:
* - 段 1/段 3 的「最近一帧快照」→ s_xrxSeq;
* - 电机槽 48B payload → MotorSlot.wr_seq。
* Sync0 是 preempt=0,critical.h 的 BASEPRI 方案挡不住它,故只能无锁协议。
* 单核下 ISR 执行期间生产者必然冻结,所以读侧判一次奇偶即可定性。
*/
class EthercatCanBridge {
public:
/**
* @brief 处理一份输出 PDO:seq 变化时向 CAN 发一帧。
* @param[in] out kEcatPdoOutBytes 字节输出过程数据。
* @note 由 APPL_Application() 每周期调用。
* @brief 输出探针:seq 变化时请求全量读。
* @note 1.6.21 起不再刷新 win_base(电机整轮密排,无滑窗);仍处理 seq 沿
* 与 FLAGS bit0 清统计。
*/
/** @brief 输出探针(1.6.0 输出按需全量读)。
*
* PDI_Isr 每周期先读输出 PDO 头 12B(覆盖 SEQ@0 与 WIN@8),由本函数判定
* 是否需要全量读 384B:
* - 始终从探针刷新 s_winBase(主站每周期移动窗口扫描,与 seq 沿无关);
* - 首帧只记 seq 基准(与 processOutput 的 priming 一致),返回 false;
* - seq 变化沿或 FLAGS bit0(清统计请求)→ 返回 true,调用方全量读。
* s_lastSeq 仍由 processOutput 在全量路径上推进,本函数不写,保证沿语义
* 只处理一次。 */
static bool peekOutput(const uint8_t *head);
static void processOutput(const uint8_t *out);
/**
* @brief 填充输入 PDO:桥状态 + 电机状态窗口 + 长帧快照。
* @param[out] in kEcatPdoInBytes 字节输入过程数据(本函数负责全部写满)。
* @brief 填充输入 PDO:桥状态 + 电机整轮密排 + 长帧快照。
* @param[out] in kEcatPdoInBytes 字节输入过程数据。
*/
static void buildInput(uint8_t *in);
/**
* @brief CAN 收帧投递入口。
* @note 由 TaskRunner::taskCanRxParse() 在分发链里调用,不消费帧(不返回
* "已处理"),只做旁路复制,因此不影响 OTA relay / 电机状态。
* @brief CAN 收帧投递入口(旁路复制,不消费帧)。
*/
static void onCanFrame(uint8_t channel, uint32_t id,
const uint8_t *data, uint8_t length);
@@ -331,33 +218,21 @@ public:
private:
/**
* @brief 处理输出 PDO 的批量发送区(seq 沿内调用一次)。
* @return 实际受理的帧数。
* @note 逐帧解析 (8B 头 + dlc),边界不足即停 —— 不信任主站给的 count,
* 越界的 count 只会让循环提前结束,不会读到 PDO 之外。
* @brief 填充段 2 电机整轮密排区(含差分跳过)。
* @return 本拍打包的电机数(写入 count 字段)。
*/
static uint8_t fillMotorRound(uint8_t *in);
static uint8_t sendBatch(const uint8_t *out);
/** @brief 把批量接收覆盖窗口整块拷进输入 PDO 段 4(1.6.15)。 */
static void fillRxWindow(uint8_t *in);
static volatile uint8_t s_lastSeq; /**< 上一次已执行的发送序号 */
static volatile bool s_seqPrimed; /**< 是否已记录过基准 seq(防上电首帧误发) */
static volatile uint8_t s_lastSeq;
static volatile bool s_seqPrimed;
static volatile uint8_t s_lastTxResult;
static volatile uint16_t s_txCount;
static volatile uint16_t s_dropCount;
/** 主站请求的电机槽窗口起始下标(输出 PDO 的 WIN 字节,每周期刷新)。 */
static volatile uint8_t s_winBase;
/* 最近一帧接收快照:taskCanRxParse 写、PDO 读。
* s_xrxSeq 为 seqlock:奇数=正在写,buildInput 本周期跳过段 1/段 3。
* 没有它,Sync0_Isr 抢占在"数据已写完、id 未更新"之间会把新帧数据
* 配旧帧 ID 发出去(STATUS 被标成 CONFIG → 前端解出离谱目标值)。 */
/* 最近一帧接收快照:taskCanRxParse 写、PDO 读。s_xrxSeq 为 seqlock。 */
static volatile bool s_rxValid;
/** 上一帧是否已被至少一拍 PDO 发布进段 1/段 3。s_rxValid 只表示"开机以来收过帧"
* (flags bit0 与 backlog 取通道都依赖它常驻),不能拿它当"未取走"判据,
* 否则 drop 会恒等于 rx 计数。写侧(onCanFrame)置 false,读侧(buildInput
* 发布分支)置 true;单核下 ISR 抢占主循环,两者不会并发写同一帧。 */
static volatile bool s_rxPublished;
static volatile uint32_t s_xrxSeq;
static volatile uint8_t s_rxCh;
@@ -367,28 +242,16 @@ private:
static volatile uint32_t s_rxCount;
static uint8_t s_rxData[kEcatCanXRxDataMax];
/* ── 批量接收定长覆盖窗口(1.6.15,取代 1.5.56 的环形队列)──
* 生产者(onCanFrame,主循环)按 s_rxWinWr 写槽并绕圈覆盖;消费者
* (fillRxWindow,PDI_Isr/Sync0_Isr)每拍整块取值,**不消费、不清除**。
* 因此没有 head/tail 握手,也就没有 1.6.9 那类"滞后的 tail 让生产者写进
* 消费者刚读过的槽"的错标窗口。
* 一致性靠每槽 seqlock:写侧 seq 置奇 → 填字段 → __DMB → seq 置偶(=++全局
* 计数)。读侧在 ISR 里,单核下生产者必然冻结,判一次奇偶即可,奇数跳过该槽。
* s_rxWinSeen 用于数 OVF:写到一个 seen==0 的槽 = 覆盖了一帧主站还没取过的
* 数据,这才是真丢帧;覆盖已取过的槽是设计意图,不计。 */
struct RxWinSlot {
volatile uint32_t seq; /**< 奇=写入中;偶=整槽一致 */
uint32_t id;
uint8_t ch;
uint8_t dlc; /**< 原始 DLC 0..64 */
uint8_t flags; /**< bit0=写过; bit1=线上截断到 32B */
uint8_t data[kEcatCanFdDataMax]; /**< RAM 侧存全量 64B,PDO 拷贝时才截断 */
};
static RxWinSlot s_rxWin[ECAT_RXB_SLOTS];
static volatile uint8_t s_rxWinWr; /**< 写游标(下一个要覆盖的槽) */
static volatile uint32_t s_rxWinSeq; /**< 全局单调 seq 源(偶数发布) */
static volatile uint8_t s_rxWinOvf; /**< 未取先覆次数(饱和,回填后清零) */
static volatile uint8_t s_rxWinSeen[ECAT_RXB_SLOTS]; /**< 1=已被某拍 PDO 取过 */
/* ── 段 2 差分填充影子(1.6.21)──
* 上一拍已发布的密排区副本 + 各节点上次发布的 seq/online + 头字段。
* 节点集合、seq、online、active/boot 全未变时整段复用,跳过 MotorState
* 重读与逐槽 48B 拷贝。掩码必须 u16:节点号 1..9,bit9 超出 uint8_t。 */
static uint8_t s_motShadow[kEcatMotRegionBytes];
static uint32_t s_motPubSeq[kMotorMaxNodes + 1U]; /* 下标=节点号 */
static uint8_t s_motPubOnline[kMotorMaxNodes + 1U]; /* checkOffline 不 bump seq,须单独比 */
static uint16_t s_motPubMask; /**< bit n=1:节点 n 在影子区中 */
static uint8_t s_motPubCount;
static bool s_motShadowValid;
};
extern "C" {