From 959e2aef6e4a4430af10c74d7ce45b8e1ee8ff65 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 9 Sep 2026 03:04:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(comm):=201.6.14~1.6.20=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20CAN-FD=20=E6=95=B0=E6=8D=AE=E6=AE=B5=205M=E3=80=81?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=8C=BA=E6=94=B9=E5=AE=9A=E9=95=BF=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=AA=97=E5=8F=A3(=E8=BE=93=E5=85=A5=20PDO=20496?= =?UTF-8?q?=E2=86=92600)=E3=80=81drop=20=E5=8F=A3=E5=BE=84=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E3=80=81=E5=B7=AE=E5=88=86=E5=86=99=E4=BB=AA=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.6.14 CAN-FD 数据段 2Mbps→5Mbps:CONFIG_CAN_DATA_PRESCALER 9→3、BS1 3→4TQ (90MHz/(3×6TQ),采样点 80%→83.3%),仲裁段仍 1M。TDC filter/offset 维持 4 不做 等比缩放(TDCMOD=CALC_AND_OFFSET 下硬件已自动测回路延时;放到 12 会让本节点回采 错开约 6 个数据位,自发热帧被判错误帧)。配套电机 APP/BOOT data_prescaler=2。 1.6.15 段4 批量接收由"环形队列 + 变长紧排排空"改为 5×44B 定长覆盖窗口,输入 PDO 496→600(75×64bit),段5 诊断区 484→584。动机两条:现场 4000 帧/s 超旧容量 3 帧/拍 恒丢 1 帧;更关键是排空赤字把队列长期压在饱和态,而 1.6.9~1.6.13 追的错标/拼接竞态 正是饱和态喂出来的(判满用的 tail 滞后→生产者写进消费者刚读过的槽)。定长消灭 tail/判满/pending,边界越界写在结构上不可能再犯。每槽带 u32 seqlock:buildInput 在 PDI_Isr(preempt=0) 抢占主循环写侧,无它则主站会拿到"新 ID + 旧数据"拼出的假帧。 out+in=984 ≤ 1024 硬上限,DPRAM 留 120B。语义变更:批量区从"不丢的事件流"变"最近 N 帧窗口",去重与丢帧判据移交主站按 seq 归一。三端同步 size.h/bridge/dc_app/ gen_eeprom/DCU_ESI.xml,必须重烧固件 + 烧 EEPROM + 断电冷启动,只烧一边 SAFEOP 报 AL 0x001A/0x0011。 1.6.16 段1/段3 快照 drop 计数口径修复(纯判据,PDO 布局逐字节不动):旧判据 s_rxValid 置位后永不清零 ⇒ 开机首帧后每帧都算丢弃,drop ≡ rx−1,是零灵敏度假仪表 且把网页状态栏刷成 ERR。新增 s_rxPublished,判据改 s_rxValid && !s_rxPublished。 1.6.17~19 [DIFF-STATS] 差分写取证仪表,零行为变更,只加 0x2000 sub13..23(11 个只读 UINT32) ⇒ 不动 PDO 长度/映射,无需重生成 SII。实测:每拍 27.4% 字节真变化(164B)、 run 峰值 12~13、段2 占变化量 ~53%、段4 ~25%。 1.6.19 推翻 1.6.18 的自我诊断:比对开销虚报不是 -Oz 把 memcpy 编成调用(手拼移位 反而 21.3µs→41µs),真根因是 alignas(4) 到 const uint8_t* 形参处丢失,优化器无法 证明 4 字节对齐。__builtin_assume_aligned + memcpy 后循环体缩成 ldr/ldr/cmp/bne、 bl 归零。教训:测量工具自身的开销会直接变成结论。 1.6.20 基线对照版(当前提交状态 CONFIG_ECAT_DIFF_STATS=0):同工况(电机在线无指令) 实测 load 49.1% vs 仪表开的 51.6% ⇒ 仪表自身成本 2.4 个点,差分改造收益 = 0。 按实测变化量推算 A1(段粒度条件写) 只值约 1 点(段2/段4 每拍都变,必然整块重写), 仅 A2(8B 块粒度 + run 上限退化为整写) 有空间 —— 推算值,未做 A/B 实测不作为结论。 --- .../GD32E508_DCU_APP.cproject.yml | 3 + GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp | 199 +++++++++------- GD32E508_DCU_APP/comm/ethercat_can_bridge.h | 173 +++++++++----- GD32E508_DCU_APP/comm/ethercat_diff_stats.cpp | 218 ++++++++++++++++++ GD32E508_DCU_APP/comm/ethercat_diff_stats.h | 60 +++++ GD32E508_DCU_APP/comm/ethercat_pdo.cpp | 28 ++- GD32E508_DCU_APP/comm/ethercat_pdo.h | 11 +- GD32E508_DCU_APP/comm/ethercat_pdo_size.h | 38 ++- GD32E508_DCU_APP/comm/ssc/port/DCU_ESI.xml | 120 ++++++++-- GD32E508_DCU_APP/comm/ssc/port/dc_app.c | 99 ++++++-- GD32E508_DCU_APP/comm/ssc/port/gen_eeprom.py | 17 +- GD32E508_DCU_APP/device/CANFD.cpp | 5 +- GD32E508_DCU_APP/sys/config.h | 28 ++- 13 files changed, 775 insertions(+), 224 deletions(-) create mode 100644 GD32E508_DCU_APP/comm/ethercat_diff_stats.cpp create mode 100644 GD32E508_DCU_APP/comm/ethercat_diff_stats.h diff --git a/GD32E508_DCU_APP/GD32E508_DCU_APP.cproject.yml b/GD32E508_DCU_APP/GD32E508_DCU_APP.cproject.yml index 6e04674..706ddf7 100644 --- a/GD32E508_DCU_APP/GD32E508_DCU_APP.cproject.yml +++ b/GD32E508_DCU_APP/GD32E508_DCU_APP.cproject.yml @@ -85,6 +85,9 @@ project: - file: ./comm/ethercat_can_bridge.cpp - file: ./comm/ethercat_pdo.h - file: ./comm/ethercat_pdo.cpp + # [DIFF-STATS] 临时仪表:差分写取证,删除时连同本两行与 comm/ethercat_diff_stats.* 一并移除 + - file: ./comm/ethercat_diff_stats.h + - file: ./comm/ethercat_diff_stats.cpp - group: comm/ssc files: - file: ./comm/ssc/inc/ecatslv.h diff --git a/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp b/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp index 1395669..50abe4b 100644 --- a/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp +++ b/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp @@ -19,6 +19,7 @@ volatile uint16_t EthercatCanBridge::s_dropCount = 0U; volatile uint8_t EthercatCanBridge::s_winBase = 0U; volatile bool EthercatCanBridge::s_rxValid = false; +volatile bool EthercatCanBridge::s_rxPublished = false; volatile uint32_t EthercatCanBridge::s_xrxSeq = 0U; volatile uint8_t EthercatCanBridge::s_rxCh = 0U; volatile uint8_t EthercatCanBridge::s_rxDlc = 0U; @@ -27,11 +28,12 @@ volatile uint32_t EthercatCanBridge::s_rxId = 0U; volatile uint32_t EthercatCanBridge::s_rxCount = 0U; uint8_t EthercatCanBridge::s_rxData[kEcatCanXRxDataMax] = {0}; -EthercatCanBridge::RxRingItem - EthercatCanBridge::s_rxRing[kEcatRxRingSlots] = {}; -volatile uint8_t EthercatCanBridge::s_rxRingHead = 0U; -volatile uint8_t EthercatCanBridge::s_rxRingTail = 0U; -volatile uint8_t EthercatCanBridge::s_rxRingLost = 0U; +EthercatCanBridge::RxWinSlot + EthercatCanBridge::s_rxWin[ECAT_RXB_SLOTS] = {}; +volatile uint8_t EthercatCanBridge::s_rxWinWr = 0U; +volatile uint32_t EthercatCanBridge::s_rxWinSeq = 0U; +volatile uint8_t EthercatCanBridge::s_rxWinOvf = 0U; +volatile uint8_t EthercatCanBridge::s_rxWinSeen[ECAT_RXB_SLOTS] = {0}; namespace { /** 小端读写:PDO 字节序按 EtherCAT 惯例固定小端,不依赖编译器结构体布局。 */ @@ -106,64 +108,55 @@ uint8_t EthercatCanBridge::sendBatch(const uint8_t *out) return sent; } -void EthercatCanBridge::fillRxBatch(uint8_t *in) +void EthercatCanBridge::fillRxWindow(uint8_t *in) { - uint8_t count = 0U; - uint16_t off = ECAT_RXB_OFF_ITEMS; - uint8_t tail = s_rxRingTail; - const uint8_t head = s_rxRingHead; /* 快照一次:生产者可能边填边推进 */ + /* ── 1.6.15:定长覆盖窗口,整块取值 ── + * 生产者不等人:每拍把 N 个槽原样拷进 PDO,槽里是谁就是谁。同一帧会在连续 + * 几拍里重复出现,直到被下一帧覆盖。语义是"最近 N 帧窗口",不是"保证不丢的 + * 事件流"——主站按每槽 seq 去重后才归一成事件流上报给上层。 + * + * 相比旧的环形队列排空,这里没有 tail/head/判满/pending:1.6.9 记录的那个 + * "滞后的 tail 让生产者写进消费者刚读过的槽"错标竞态,其载体(tail 握手) + * 在本布局里根本不存在。定长也让主站不必推游标,1.6.10 那类边界越界写诊断区 + * 的错在结构上不可能再犯。 + * + * 每槽 seqlock 判稳:奇数=生产者正在写这 12B 头 + 32B 数据,本拍留零跳过该槽, + * 下拍自愈(与段 1/段 2/段 3 既有约定一致)。本函数跑在 PDI_Isr/Sync0_Isr + * (preempt=0),单核下执行期间生产者必然冻结,判一次奇偶即可定性。 */ + in[ECAT_RXB_OFF_SLOT_COUNT] = ECAT_RXB_SLOTS; + in[ECAT_RXB_OFF_WR_IDX] = s_rxWinWr; + in[ECAT_RXB_OFF_OVF] = s_rxWinOvf; + s_rxWinOvf = 0U; /* 回填后清零:OVF 是"自上次回填以来"的增量 */ - while (tail != head && count < kEcatRxBatchMax) { - /* 整条先拷到局部副本再用:生产者(主循环 onCanFrame)与本函数(PDI_Isr) - * 共用这块槽,直接按引用逐字段取会让 id/dlc/data 分属不同帧。 */ - const RxRingItem it = s_rxRing[tail]; - uint8_t dlc = it.dlc; - if (dlc > kEcatCanFdDataMax) { - dlc = kEcatCanFdDataMax; + for (uint8_t i = 0U; i < ECAT_RXB_SLOTS; ++i) { + const RxWinSlot &sl = s_rxWin[i]; + const uint32_t seq = sl.seq; + if ((seq & 1U) != 0U) { + continue; /* 生产者正在写这槽:本拍不报,下拍自愈 */ } - /* 1.6.1: PDO 内数据条目 32B 上限——DLC 字节填**真值**,超出部分截断并打 - * flags bit1;环形队列里仍是全量 64B,截断只发生在 PDO 快照这一步。 - * 主站按 "8 + min(dlc, kEcatRxBatchDataMax)" 推进游标。 */ - uint8_t dlcWire = dlc; - if (dlcWire > kEcatRxBatchDataMax) { - dlcWire = kEcatRxBatchDataMax; - } - /* 装不下就停,**且不推进 tail** —— 该帧留到下个周期,不丢。 - * 边界是段 5 诊断区起点(ECAT_DIAG_OFF_LOAD=484),不是 PDO 全长(496): - * 1.6.10 修正。旧判据允许条目尾部最多越进诊断区 12B,把负载率/撕裂计数/ - * 序号盖成帧数据 —— 当前 kEcatRxBatchMax=3 时最坏占用恰好 484 触不到, - * 属潜伏 bug,但容量一旦按瘦身布局放宽(32B 步长)就会立刻咬上来。 */ - if ((uint16_t)(off + ECAT_FRM_HDR_SIZE + dlcWire) > ECAT_DIAG_OFF_LOAD) { - break; + __DMB(); /* 判稳先于取值:不许把下面的读重排到奇偶检查之前 */ + if ((sl.flags & 0x01U) == 0U) { + continue; /* 从未写过:留零,主站按 flags bit0 判空 */ } - uint8_t *p = &in[off]; - p[ECAT_FRM_OFF_CH] = it.ch; - p[ECAT_FRM_OFF_DLC] = dlc; - p[ECAT_FRM_OFF_FLAGS] = (uint8_t)(0x01U | (dlc > kEcatRxBatchDataMax ? 0x02U : 0x00U)); - wr32le(&p[ECAT_FRM_OFF_ID], it.id); - memcpy(&p[ECAT_FRM_HDR_SIZE], it.data, dlcWire); + /* RAM 侧存全量 64B,只有拷进 PDO 这一步按 32B 截断;DLC 字节填**原始值**, + * 主站据此 + flags bit1 判断是否被截。 */ + const uint8_t dlc = sl.dlc; + const uint8_t wire = (dlc > kEcatRxBatchDataMax) ? kEcatRxBatchDataMax : dlc; + uint8_t *p = &in[ECAT_RXB_OFF_ITEMS + (uint16_t)i * ECAT_RXB_SLOT_SIZE]; - off = (uint16_t)(off + ECAT_FRM_HDR_SIZE + dlcWire); - tail = (uint8_t)((tail + 1U) % kEcatRxRingSlots); - ++count; + p[ECAT_RXW_OFF_CH] = sl.ch; + p[ECAT_RXW_OFF_DLC] = dlc; + p[ECAT_RXW_OFF_FLAGS] = (uint8_t)(0x01U | + (dlc > kEcatRxBatchDataMax ? 0x02U : 0x00U)); + wr32le(&p[ECAT_RXW_OFF_ID], sl.id); + wr32le(&p[ECAT_RXW_OFF_SEQ], seq); + memcpy(&p[ECAT_RXW_OFF_DATA], sl.data, wire); - /* **每条读完立刻发布 tail**(1.6.9)。原来攒到循环末才发布:生产者判满 - * 用的正是这个滞后的 s_rxRingTail,于是它以为队列里还有本拍已被读走的 - * 那几个空位没腾出来 —— 反过来说,它会把新帧写进消费者**刚读过但未发布** - * 的槽。队列一旦饱和(实测 pending 恒 8/12、lost 几乎每拍都有、count 恒 - * 取满 3),这个窗口每拍都在,于是拷进 PDO 的 id/dlc/data 来自不同帧, - * 表现为"CONFIG 的 ID 配 STATUS 的内容"这类错标(实测 1.77%)。 - * 逐条发布后生产者的判满始终基于真实进度,覆写窗口消失。 */ - __DMB(); /* 本条读取先于 tail 发布落地 */ - s_rxRingTail = tail; + /* 标记"主站已取过":生产者下次覆盖本槽时就不算丢帧。只有"未取先覆" + * 才计 OVF —— 覆盖已送达的帧是窗口的设计意图,不是损失。 */ + s_rxWinSeen[i] = 1U; } - - const uint8_t pending = (uint8_t)((head + kEcatRxRingSlots - tail) % kEcatRxRingSlots); - in[ECAT_RXB_OFF_COUNT] = count; - in[ECAT_RXB_OFF_LOST] = s_rxRingLost; - in[ECAT_RXB_OFF_PENDING] = pending; - s_rxRingLost = 0U; /* 回填后清零:lost 是"自上次回填以来"的增量 */ } bool EthercatCanBridge::peekOutput(const uint8_t *head) @@ -220,6 +213,9 @@ void EthercatCanBridge::processOutput(const uint8_t *out) s_txCount = 0U; s_dropCount = 0U; s_rxCount = 0U; + /* 快照区里残留的那帧早已发布过;不置 published 会让清零后的第一帧 + * 被误计成一次"未取先覆",清零口径就脏了。 */ + s_rxPublished = true; } if (seq == s_lastSeq) { @@ -276,7 +272,8 @@ void EthercatCanBridge::buildInput(uint8_t *in) /* seqlock 判稳(见 onCanFrame):奇数=生产者正在写这组快照。单核下 ISR * 执行期间生产者冻结,读一次即可定性;本周期跳过段 1/段 3(开头已清零, - * 语义=无有效快照),下周期自愈。批量区(段 4)走环形队列,不受影响。 */ + * 语义=无有效快照),下周期自愈。批量区(段 4)每槽另有自己的 seqlock, + * 不受本组影响。 */ const bool xrxStable = ((s_xrxSeq & 1U) == 0U); if (xrxStable && s_rxValid) { @@ -290,6 +287,9 @@ void EthercatCanBridge::buildInput(uint8_t *in) wr32le(&in[ECAT_CAN_RX_OFF_ID], s_rxId); /* 段 1 只放前 8 字节(兼容),完整 payload 见段 3 */ memcpy(&in[ECAT_CAN_RX_OFF_DATA], s_rxData, kEcatCanRxDataMax); + /* 本拍已把这一帧发布进过程数据映像(段 3 与本块同门控,故只在此处置位一次): + * 之后再被新帧覆盖属于"最新值"设计意图,不算丢。 */ + s_rxPublished = true; } const uint8_t ch = s_rxValid ? s_rxCh : 0U; @@ -354,11 +354,11 @@ void EthercatCanBridge::buildInput(uint8_t *in) memcpy(&in[ECAT_XRX_OFF_DATA], s_rxData, kEcatCanXRxDataMax); } - /* ── 段 4(360..639):CANFD 批量接收区 ── + /* ── 段 4(360..583):CANFD 批量接收覆盖窗口 ── * 段 1/段 3 只带"最近一帧",高速多帧场景下周期间到达的帧会互相覆盖 - * (s_dropCount 就是在数这个)。批量区从环形队列里一次回收多帧, - * 让主站在一个周期内拿到完整的帧序列而不是采样点。 */ - fillRxBatch(in); + * (s_dropCount 就是在数这个)。批量区保留最近 N 帧的窗口,让主站在一个 + * 周期内拿到帧序列而不是单个采样点;1.6.15 起改为定长覆盖(见 fillRxWindow)。 */ + fillRxWindow(in); } void EthercatCanBridge::onCanFrame(uint8_t channel, uint32_t id, @@ -368,12 +368,6 @@ void EthercatCanBridge::onCanFrame(uint8_t channel, uint32_t id, return; } - /* 上一帧还没被 PDO 周期取走就来了新帧:记一次丢弃(覆盖旧帧, - * 保证主站看到的永远是最新状态,而不是卡在一帧陈旧数据上)。 */ - if (s_rxValid) { - ++s_dropCount; - } - /* 缓存区已扩到 32B(段 3 用),truncated 仍按段 1 的 8B 口径上报, * 这样 ecat.html 的 CAN 调试页语义不变。 */ uint8_t copyLen = length; @@ -396,6 +390,15 @@ void EthercatCanBridge::onCanFrame(uint8_t channel, uint32_t id, ++s_xrxSeq; /* → 奇数:快照进入"正在写" */ __DMB(); + /* 「未取先覆」计数:上一帧还没被任何一拍 PDO 发布就被覆盖才算丢。 + * 原判据只判 s_rxValid,而它置位后永不清零 → 第一帧之后每帧都计, + * drop 恒等于 rx−1(u16 回绕),变成跟着收帧速率涨、零灵敏度的假仪表。 + * 放在奇数窗口内:消费者判到奇会跳过段 1/段 3、不会在此刻置 published, + * 故"读上一帧发布状态 + 自增"对消费者等效原子,不会多计一次。 */ + if (s_rxValid && !s_rxPublished) { + ++s_dropCount; + } + memcpy(s_rxData, data, copyLen); if (copyLen < kEcatCanXRxDataMax) { memset(&s_rxData[copyLen], 0, (size_t)(kEcatCanXRxDataMax - copyLen)); @@ -407,44 +410,62 @@ void EthercatCanBridge::onCanFrame(uint8_t channel, uint32_t id, s_rxTruncated = (length > kEcatCanRxDataMax); ++s_rxCount; s_rxValid = true; + s_rxPublished = false; /* 这一帧还没被任何一拍 PDO 发布 */ __DMB(); ++s_xrxSeq; /* → 偶数:快照整体一致 */ - /* ── 推入批量接收环形队列(1.5.56)── - * 与上面的"最近一帧快照"并行维护:快照会被后帧覆盖(这是它的设计语义), - * 队列则保序累积,供 fillRxBatch 一次回收多帧。 - * 队列满时丢**新**帧并计数,而不是覆盖旧帧 —— 批量区的价值在于保序完整, - * 覆盖会在序列中间留下空洞,比明确丢尾更难排查。 + /* ── 写入批量接收覆盖窗口(1.6.15,取代 1.5.56 的环形队列)── + * 与上面的"最近一帧快照"并行维护:快照只留最新一帧,窗口留最近 N 帧的序列。 + * 生产者按自己的游标写、绕圈覆盖,**不等消费者** —— 没有判满、没有背压、 + * 没有 pending。代价是语义从"事件流"变成"最近 N 帧窗口":一帧在窗口里活到 + * 被覆盖为止,主站按每槽 seq 去重后才归一成事件流。 * - * **STATUS 帧不入队**(1.6.9):批量区存在的意义是捞那些段 2 装不下的帧 + * **STATUS 帧不入窗**(1.6.9):批量区存在的意义是捞那些段 2 装不下的帧 * (CONFIG / 版本 / 节点应答),而完整 STATUS 本来就由段 2 电机状态窗口按槽 * 上报;主站 transport-ecat.js 的 drainRx() 拿到 STATUS 也是直接 continue - * 丢掉。让它占队列是纯重复,而且正是它把队列压爆的:实测 CAN 侧约 4000 帧/s, - * 批量区每拍最多 3 条(=3000 帧/s),净积压使 pending 恒 8/12、lost 几乎每拍 - * 都有 —— 队列长期饱和,才逼出了生产者覆写消费者槽位的竞态(错标率 1.77%, - * 表现为"CONFIG 的 ID 配 STATUS 的内容")。滤掉 STATUS 后入队流量降到 CONFIG - * 量级,队列不再饱和,竞态窗口随之消失。 */ + * 丢掉。让它占窗口是纯重复,而且正是它把队列压爆的:实测 CAN 侧约 4000 帧/s, + * 旧批量区每拍最多回收 3 条(=3000 帧/s),净积压使队列长期饱和,才逼出了 + * 生产者覆写消费者槽位的竞态(错标率 1.77%,表现为"CONFIG 的 ID 配 STATUS + * 的内容")。滤掉 STATUS 后入窗流量降到 CONFIG 量级。 + * + * OVF 只数"**未取先覆**":覆盖一帧主站已经取走的数据是窗口的设计意图(不计), + * 覆盖一帧主站还没来得及取的数据才是真丢帧(计数)。该数与主站按 seq 空洞 + * 算出的丢帧数应当一致 —— 两侧独立仪表互相对账,任一侧骗人都当场暴露。 */ if ((id & 0xFFFFU) == 0x0100U) { return; } - const uint8_t next = (uint8_t)((s_rxRingHead + 1U) % kEcatRxRingSlots); - if (next == s_rxRingTail) { - if (s_rxRingLost < 0xFFU) { - ++s_rxRingLost; + { + const uint8_t idx = s_rxWinWr; + RxWinSlot &sl = s_rxWin[idx]; + + if (s_rxWinSeen[idx] == 0U && sl.seq != 0U && s_rxWinOvf < 0xFFU) { + ++s_rxWinOvf; /* 上一帧还没被任何一拍 PDO 取走就被覆盖 */ } - } else { - RxRingItem &it = s_rxRing[s_rxRingHead]; + s_rxWinSeen[idx] = 0U; + + /* 每槽 seqlock:奇数=正在写。消费者(PDI_Isr)落在这一瞬就跳过该槽,下拍 + * 自愈。没有它,抢占在"新 payload 已写完、id 还没更新"之间,主站会拿到 + * 一条现实中不存在、但看起来完全合法的假帧 —— 那不是"差一个周期"。 + * 发布值取自全局计数器 s_rxWinSeq,故跨槽可比大小、可数空洞。 */ + sl.seq = s_rxWinSeq + 1U; /* → 奇数:本槽进入"正在写" */ + __DMB(); + uint8_t n = length; if (n > kEcatCanFdDataMax) { n = kEcatCanFdDataMax; } - it.id = id; - it.ch = channel; - it.dlc = n; - memcpy(it.data, data, n); - __DMB(); /* 数据写入先于 head 发布落地(volatile 不约束非 volatile 的 memcpy) */ - s_rxRingHead = next; /* 最后推进 head:消费者读到时数据必已写完 */ + sl.ch = channel; + sl.dlc = n; + sl.id = id; + sl.flags = 0x01U; /* bit0=本槽至少写过一帧 */ + memcpy(sl.data, data, n); + + s_rxWinSeq += 2U; /* 下一个发布值(恒偶) */ + __DMB(); /* 字段写入先于 seq 发布落地 */ + sl.seq = s_rxWinSeq; /* → 偶数:整槽一致 */ + + s_rxWinWr = (uint8_t)((idx + 1U) % ECAT_RXB_SLOTS); } } diff --git a/GD32E508_DCU_APP/comm/ethercat_can_bridge.h b/GD32E508_DCU_APP/comm/ethercat_can_bridge.h index e63cb62..30907f5 100644 --- a/GD32E508_DCU_APP/comm/ethercat_can_bridge.h +++ b/GD32E508_DCU_APP/comm/ethercat_can_bridge.h @@ -37,8 +37,9 @@ * * 新增两个批量区,每条帧带 8B 头(ch/dlc/flags/rsvd + id)+ 数据: * 输出 48..383(336B):批量发送,一个 seq 沿一次性发 N 帧 - * 输入 360..483(124B):批量接收,一周期回收多帧而不是只带最近一帧 - * (1.6.1 数据条目 64→32B,段 5 诊断区 484..491,原 360..639/280B) + * 输入 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); @@ -96,7 +97,8 @@ 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, /**< 桥接层丢帧数(投递时上一帧还没被取走),小端 uint16 */ + ECAT_CAN_RX_OFF_DROP = 24U, /**< 段 1/段 3 快照「未取先覆」次数:上一帧尚未被任何一拍 + PDO 发布就被新帧覆盖才 +1(u16,累计回绕)。 */ }; /** 输入 PDO 段 2(32..319):电机状态窗口头 + 槽数组。 */ @@ -129,44 +131,86 @@ enum : uint16_t { ECAT_XRX_OFF_DATA = 328U, /**< 数据区,32 字节完整 payload */ }; -/** 输入 PDO 段 4(360..483):CANFD 批量接收区(1.5.56 新增,1.6.1 数据条目 64→32B)。 - * 每项 8B 头 + 最多 32B 数据(kEcatRxBatchDataMax);DLC 字节仍填**原始值**, - * 超长帧被截断时 flags bit1=1 —— 主站按 "8 + min(dlc,32)" 推进游标。 */ +/** 输入 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 空洞算出被覆盖掉的帧数。 */ enum : uint16_t { - ECAT_RXB_OFF_COUNT = 360U, /**< 本周期回收的帧数 0..kEcatRxBatchMax */ - ECAT_RXB_OFF_LOST = 361U, /**< 本周期因批量区放不下而丢弃的帧数 */ - ECAT_RXB_OFF_PENDING = 362U, /**< 环形队列中仍待回收的帧数(主站据此提速) */ - ECAT_RXB_OFF_RSVD = 363U, - ECAT_RXB_OFF_ITEMS = 364U, /**< 帧数组起点,项布局同 ECAT_FRM_OFF_* */ + 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 定长 */ }; -/** 输入 PDO 段 5(484..491):设备诊断区(1.5.61 尾部追加,1.6.1 随批量区 - * 数据条目 64→32B 前移:364+3×40=484,诊断区锚在紧随其后的 8 字节, - * 写侧是 EthercatPdo::fillDiagTail())。 */ +/** 覆盖窗口单槽布局(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 = 484U, /**< 负载率,小端 uint16,单位 0.1%(1000=100%)。 + ECAT_DIAG_OFF_LOAD = 584U, /**< 负载率,小端 uint16,单位 0.1%(1000=100%)。 中断 + 非延迟处理段(任务派发/OTA relay)占墙钟比, 不含常开轮询与空转;1s 滚动窗口(见 sys/cpu_load.h) */ - ECAT_DIAG_OFF_TORN = 486U, /**< CAN 取帧撕裂丢帧数(低 16 位,小端,饱和不回绕)。 + ECAT_DIAG_OFF_TORN = 586U, /**< CAN 取帧撕裂丢帧数(低 16 位,小端,饱和不回绕)。 1.6.6 起:ISR 取帧时若 MP 的 TS/DLENC 前后不一致, 说明邮箱窗口在取数途中被硬件换帧,该帧整帧丢弃并 在此计数。这是"读到半条帧"的唯一可观测量 —— 主站 据此判断修复是否生效、总线负载是否高到频繁触发。 */ - ECAT_DIAG_OFF_SEQ = 488U, /**< 诊断区序号,小端 uint16(1.6.12 起,原 u32 让出 + ECAT_DIAG_OFF_SEQ = 588U, /**< 诊断区序号,小端 uint16(1.6.12 起,原 u32 让出 高 2 字节),每完成一个 1s 统计窗口 +1,主站据此 判断负载值是否仍在滚动刷新。u16 足够:主站只判 "非 0 且在涨",18h 才回绕。 */ - ECAT_DIAG_OFF_OVERFULL = 490U, /**< CAN RX FIFO 满溢计数 RFO0(u16 饱和,1.6.12)。 + ECAT_DIAG_OFF_OVERFULL = 590U, /**< CAN RX FIFO 满溢计数 RFO0(u16 饱和,1.6.12)。 1.6.11 把溢出策略改成"满则丢新帧"后拼接并未消失 - (段5@494 以 ~384/s 增长),必须直接观测溢出本身 + (段5@594 以 ~384/s 增长),必须直接观测溢出本身 与拼接率是否同涨,才能判定残余拼接是否仍源自溢出 (即本 IP 在 DISCARD 模式下照样毁窗口)。 */ - ECAT_DIAG_OFF_RELTIMEOUT = 492U, /**< RFD0 释放握手超时计数(u16 饱和,1.6.12)。 + 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 = 494U, /**< CAN 内容级拼接帧命中率(u16 饱和,1.6.11)。 + ECAT_DIAG_OFF_REJECT = 594U, /**< CAN 内容级拼接帧命中率(u16 饱和,1.6.11)。 入队前结构自洽校验(STATUS 温度量程+斜率、CONFIG 未使用区/模式)命中即计数。1.6.12 起**只计不丢** —— 数据管道必须诚实,该计数退化为"源头是否还在 @@ -192,14 +236,16 @@ constexpr uint8_t kEcatMotSlotsPerPdo = 5U; /** 过程数据长度,唯一真源见 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; /* 62×64bit = 496B */ +constexpr uint16_t kEcatPdoInBytes = ECAT_PDO_IN_BYTES; /* 75×64bit = 600B */ -/** CANFD 单帧数据上限(DLC 64)—— 环形队列/发送批量区按此上限做容量推导。 */ +/** CANFD 单帧数据上限(DLC 64)—— 覆盖窗口/发送批量区按此上限做容量推导。 */ constexpr uint8_t kEcatCanFdDataMax = 64U; -/** 批量**接收**条目在 PDO 内的数据字节数上限(1.6.1 从 64 压到 32)。 +/** 批量**接收**槽在 PDO 内的数据字节数上限(1.6.1 从 64 压到 32)。 * 真机采样 6086 帧 DLC 只有 24/32 两档;超长帧在装填时截断(flags bit1), - * 环形队列 RxRingItem 仍存全量 64B,截断只发生在 PDO 快照这一步。 */ + * 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)帧数上限。 */ @@ -207,22 +253,11 @@ constexpr uint16_t kEcatTxBatchBytes = (uint16_t)(kEcatPdoOutBytes - ECAT_TXB_OF constexpr uint8_t kEcatTxBatchMax = (uint8_t)(kEcatTxBatchBytes / (ECAT_FRM_HDR_SIZE + kEcatCanFdDataMax)); -/** 批量接收区可用字节数与最坏情况(全 DLC32 截断满帧)帧数上限。 - * 可用区到段 5 诊断区起点为止(1.6.10 修正口径:旧式按 PDO 全长 496 算, - * 与 fillRxBatch 的旧边界判据同错;数值上 120/40 与 132/40 同为 3,无行为变化)。 */ -constexpr uint16_t kEcatRxBatchBytes = (uint16_t)(ECAT_DIAG_OFF_LOAD - ECAT_RXB_OFF_ITEMS); -constexpr uint8_t kEcatRxBatchMax = - (uint8_t)(kEcatRxBatchBytes / (ECAT_FRM_HDR_SIZE + kEcatRxBatchDataMax)); - -/** 批量接收环形队列槽数。取 kEcatRxBatchMax 的 4 倍,让主站慢一两拍也不丢帧。 - * 注意这是 RAM 成本(槽数 × 72B),不是 PDO 成本。 */ -constexpr uint8_t kEcatRxRingSlots = (uint8_t)(kEcatRxBatchMax * 4U); - /* 布局守卫:段 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_COUNT, +static_assert(ECAT_XRX_OFF_DATA + kEcatCanXRxDataMax <= ECAT_RXB_OFF_SLOT_COUNT, "长帧快照区与批量接收区重叠"); static_assert(ECAT_CAN_TX_OFF_DATA + kEcatCanTxDataMax <= ECAT_TXB_OFF_COUNT, "单帧发送数据区与批量发送区重叠"); @@ -230,19 +265,32 @@ static_assert(ECAT_RXB_OFF_ITEMS < kEcatPdoInBytes, "批量接收区起点越界 static_assert(ECAT_TXB_OFF_ITEMS < kEcatPdoOutBytes, "批量发送区起点越界"); /* 批量区至少要装得下一帧满长 CANFD,否则"批量"没有意义 */ static_assert(kEcatTxBatchMax >= 1U, "批量发送区装不下一帧 DLC64"); -static_assert(kEcatRxBatchMax >= 1U, "批量接收区装不下一帧 DLC32"); -/* 段 5 诊断区必须在批量接收区的最坏占用之外(改批量区布局时一起核对) */ -static_assert(ECAT_RXB_OFF_ITEMS + (uint16_t)kEcatRxBatchMax * (ECAT_FRM_HDR_SIZE + kEcatRxBatchDataMax) - <= ECAT_DIAG_OFF_LOAD, "批量接收区最坏占用与段5诊断区重叠"); +/* 槽长必须容得下头 + 线上数据上限;改任一字段宽度都在这里拦下。 */ +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, "覆盖窗口槽数低于现场每拍到达帧数"); /** * @brief EtherCAT ↔ CAN 转发桥。 * @note 全静态;PDO 侧函数在 Sync0_Isr(preempt=0) 或主循环 MainLoop 上下文执行, * onCanFrame() 在主循环 taskCanRxParse() 上下文执行。共享状态用 volatile + * 单生产者/单消费者语义,不需要临界区(投递只写、消费只读+清标志)。 - * 例外:段 1/段 3 的"最近一帧快照"是多字段一组(id/dlc/data 必须配套), - * 用 s_xrxSeq seqlock 保证消费者不会读到"新数据+旧 ID"的混搭—— + * 例外是"多字段一组"的三处,全部用 seqlock 而不是握手: + * - 段 1/段 3 的"最近一帧快照"(id/dlc/data 必须配套)→ s_xrxSeq; + * - 段 2 电机槽(48B payload 一次 memcpy)→ MotorSlot.wr_seq; + * - 段 4 覆盖窗口每槽(1.6.15)→ RxWinSlot.seq。 * Sync0 是 preempt=0,critical.h 的 BASEPRI 方案挡不住它,故只能无锁协议。 + * 单核下 ISR 执行期间生产者必然冻结,所以读侧判一次奇偶即可定性。 */ class EthercatCanBridge { public: @@ -290,8 +338,8 @@ private: */ static uint8_t sendBatch(const uint8_t *out); - /** @brief 把接收环形队列里的帧尽量装进输入 PDO 批量区。 */ - static void fillRxBatch(uint8_t *in); + /** @brief 把批量接收覆盖窗口整块拷进输入 PDO 段 4(1.6.15)。 */ + static void fillRxWindow(uint8_t *in); static volatile uint8_t s_lastSeq; /**< 上一次已执行的发送序号 */ static volatile bool s_seqPrimed; /**< 是否已记录过基准 seq(防上电首帧误发) */ @@ -306,6 +354,11 @@ private: * 没有它,Sync0_Isr 抢占在"数据已写完、id 未更新"之间会把新帧数据 * 配旧帧 ID 发出去(STATUS 被标成 CONFIG → 前端解出离谱目标值)。 */ 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; static volatile uint8_t s_rxDlc; @@ -314,20 +367,28 @@ private: static volatile uint32_t s_rxCount; static uint8_t s_rxData[kEcatCanXRxDataMax]; - /* ── 批量接收环形队列(1.5.56)── - * 单生产者(taskCanRxParse 的 onCanFrame) / 单消费者(PDO 的 fillRxBatch)。 - * head/tail 各由一侧独占写入,故无需临界区:生产者只推进 head、 - * 消费者只推进 tail,最坏情况是某周期少读一帧,下周期即取到。 */ - struct RxRingItem { + /* ── 批量接收定长覆盖窗口(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; - uint8_t data[kEcatCanFdDataMax]; + uint8_t dlc; /**< 原始 DLC 0..64 */ + uint8_t flags; /**< bit0=写过; bit1=线上截断到 32B */ + uint8_t data[kEcatCanFdDataMax]; /**< RAM 侧存全量 64B,PDO 拷贝时才截断 */ }; - static RxRingItem s_rxRing[kEcatRxRingSlots]; - static volatile uint8_t s_rxRingHead; /**< 生产者写入位置 */ - static volatile uint8_t s_rxRingTail; /**< 消费者读取位置 */ - static volatile uint8_t s_rxRingLost; /**< 队列满导致的丢帧数(累计,回填后清零) */ + 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 取过 */ }; extern "C" { diff --git a/GD32E508_DCU_APP/comm/ethercat_diff_stats.cpp b/GD32E508_DCU_APP/comm/ethercat_diff_stats.cpp new file mode 100644 index 0000000..85fafb3 --- /dev/null +++ b/GD32E508_DCU_APP/comm/ethercat_diff_stats.cpp @@ -0,0 +1,218 @@ +/** + * @file ethercat_diff_stats.cpp + * @brief [DIFF-STATS] 输入镜像差分统计实现(度量定义与存在理由见 ethercat_diff_stats.h)。 + * + * 整文件由 CONFIG_ECAT_DIFF_STATS 门控:置 0 时不参与编译,挂接点各自也有 #if, + * 因此关掉之后固件与改造前逐字节等价。 + */ +#include "comm/ethercat_diff_stats.h" + +#include + +#include "RTE_Components.h" +#include CMSIS_device_header + +#include "comm/ethercat_pdo_size.h" +#include "sys/config.h" + +#if CONFIG_ECAT_DIFF_STATS + +namespace { + +constexpr uint16_t kInBytes = (uint16_t)ECAT_PDO_IN_BYTES; +constexpr uint32_t kInWords = (uint32_t)kInBytes / 4U; /* 600/4=150,无尾巴字节 */ +constexpr uint8_t kSegCount = 5U; + +/** + * @brief 各段字节上界(开区间),用于把变化量归到布局段落。 + * @note 584..599 的"段5 诊断 + 4B 保留"合并计:差分写不会单独优化保留区, + * 拆开只是多一档,但保留区若在涨就说明有东西在越界写,所以仍要看得见。 + */ +constexpr uint16_t kSegEnd[kSegCount] = {32U, 320U, 360U, 584U, kInBytes}; + +/** @brief 上一份已发布的镜像。alignas(4) 是让优化器能把 4 字节 memcpy 折成单条 + * LDR 的前提——丢了它,比对会从 ~150 次字访问退化成 600 次字节访问。 */ +alignas(4) uint8_t s_prev[kInBytes]; +bool s_havePrev = false; + +/* ── 正在累积的窗口 ── */ +uint32_t s_curCycles = 0U; +uint32_t s_curChangedMax = 0U; +uint32_t s_curRunsTotal = 0U; +uint32_t s_curRunsMax = 0U; +uint32_t s_curCompareCyc = 0U; +uint32_t s_curSeg[kSegCount] = {0U, 0U, 0U, 0U, 0U}; +uint32_t s_winStart = 0U; + +/* ── 上一个已完成的窗口(getter 只读这份,保证主站拿到的是稳定值)── */ +uint32_t s_winCycles = 0U; +uint32_t s_winChangedMax = 0U; +uint32_t s_winRunsTotal = 0U; +uint32_t s_winRunsMax = 0U; +uint32_t s_winCompareCyc = 0U; +uint32_t s_winSeg[kSegCount] = {0U, 0U, 0U, 0U, 0U}; +uint32_t s_windowSeq = 0U; + +/** @brief 字节偏移所属段号。只对"确实变了"的字调用,线性 5 次比较足够。 */ +uint8_t segOf(uint32_t byteOff) +{ + for (uint8_t i = 0U; i < kSegCount; ++i) { + if (byteOff < (uint32_t)kSegEnd[i]) { + return i; + } + } + return (uint8_t)(kSegCount - 1U); /* 理论上到不了:kSegEnd[4] == kInBytes */ +} + +/** @brief 结算窗口:把累加值搬成"上一个完整窗口",再清零重新计时。 */ +void rollWindow(uint32_t now) +{ + s_winCycles = s_curCycles; + s_winChangedMax = s_curChangedMax; + s_winRunsTotal = s_curRunsTotal; + s_winRunsMax = s_curRunsMax; + s_winCompareCyc = s_curCompareCyc; + for (uint8_t i = 0U; i < kSegCount; ++i) { + s_winSeg[i] = s_curSeg[i]; + s_curSeg[i] = 0U; + } + s_curCycles = 0U; + s_curChangedMax = 0U; + s_curRunsTotal = 0U; + s_curRunsMax = 0U; + s_curCompareCyc = 0U; + s_winStart = now; + s_windowSeq++; +} + +} /* namespace */ + +/** + * @brief 与上一份已发布镜像比对并累计统计。 + * @note .ramfunc:每拍在 PDI_Isr 里跑。放 flash 会带来等待态,既污染本仪表 + * 自己要测的 compareCyc,也给被测的写侧窗口添无谓的额外占用。 + */ +__attribute__((section(".ramfunc"), noinline)) +void EthercatDiffStats::observe(const uint8_t *cur) noexcept +{ + const uint32_t t0 = DWT->CYCCNT; + + if (!s_havePrev) { + /* 首拍只建基准:否则第一拍会把"整 600B 都变了"记成样本,把 changedMax + * 和段计数全钉在无意义的上限上。 */ + std::memcpy(s_prev, __builtin_assume_aligned(cur, 4), kInBytes); + s_havePrev = true; + s_winStart = DWT->CYCCNT; + return; + } + + uint32_t changed = 0U; + uint32_t runs = 0U; + bool inRun = false; + + /* 对齐必须在调用点再声明一次:alignas(4) 只到形参为止,函数签名是 + * const uint8_t*,优化器看不见对齐就无从折字访问。实测两版都因此慢到不能用—— + * 移位版反汇编出 8 条 ldrb.w/字(41µs/拍),memcpy 版 21µs/拍 —— 而这个数会被 + * 直接读成"差分判断很贵",是仪表自己造的假象,不是差分的成本。 */ + const uint8_t *prevW = static_cast(__builtin_assume_aligned(s_prev, 4)); + const uint8_t *curW = static_cast(__builtin_assume_aligned(cur, 4)); + + for (uint32_t w = 0U; w < kInWords; ++w) { + const uint32_t o = w * 4U; + uint32_t a; + uint32_t b; + /* assume_aligned + memcpy:不碰严格别名,又能让 -Oz 折成单条 LDR。 */ + std::memcpy(&a, prevW + o, 4U); + std::memcpy(&b, curW + o, 4U); + if (a == b) { + inRun = false; + continue; + } + changed += 4U; + if (!inRun) { + runs++; + inRun = true; + } + s_curSeg[segOf(o)] += 4U; + } + + s_curCycles++; + s_curRunsTotal += runs; + if (changed > s_curChangedMax) { s_curChangedMax = changed; } + if (runs > s_curRunsMax) { s_curRunsMax = runs; } + + std::memcpy(s_prev, curW, kInBytes); + + const uint32_t t1 = DWT->CYCCNT; + /* 比对自身开销:不含上面那次 600B 快照搬运(那是差分写真要做时要付的成本之一, + * 单列出来才看得清"光判断"值多少钱)。 */ + s_curCompareCyc += (uint32_t)(t1 - t0); + + /* SystemCoreClock 个计数 = 1s(与 sys/cpu_load.cpp 同法;DWT 32 位约 23.8s + * 绕回,窗口远短于此,无绕回问题)。 */ + if ((uint32_t)(t1 - s_winStart) >= SystemCoreClock) { + rollWindow(t1); + } +} + +void EthercatDiffStats::reset() noexcept +{ + s_havePrev = false; +} + +uint32_t EthercatDiffStats::windowSeq() noexcept { return s_windowSeq; } +uint32_t EthercatDiffStats::cycles() noexcept { return s_winCycles; } +uint32_t EthercatDiffStats::changedMax() noexcept { return s_winChangedMax; } +uint32_t EthercatDiffStats::runsTotal() noexcept { return s_winRunsTotal; } +uint32_t EthercatDiffStats::runsMax() noexcept { return s_winRunsMax; } +uint32_t EthercatDiffStats::seg1Bytes() noexcept { return s_winSeg[0]; } +uint32_t EthercatDiffStats::seg2Bytes() noexcept { return s_winSeg[1]; } +uint32_t EthercatDiffStats::seg3Bytes() noexcept { return s_winSeg[2]; } +uint32_t EthercatDiffStats::seg4Bytes() noexcept { return s_winSeg[3]; } +uint32_t EthercatDiffStats::seg5Bytes() noexcept { return s_winSeg[4]; } +uint32_t EthercatDiffStats::compareCyc() noexcept { return s_winCompareCyc; } + +/* ── 供 dc_app.c(C 侧对象字典) 取值:一次拿全 11 个窗口值,避免 11 次跨语言调用 ── */ +extern "C" { + +/** + * @brief [DIFF-STATS] 读上一个完整 1s 窗口的统计值。 + * @param[out] out 依次对应 0x2000 sub13..sub23。 + * 形参用 uint32_t 而非 SSC 的 UINT32 别名:本翻译单元不引 SSC 头, + * 两者同为 32 位无符号,C 侧声明仍按 UINT32 写。 + */ +void EcatDiffStats_GetWindow(uint32_t *out) +{ + if (out == nullptr) { + return; + } + out[0] = EthercatDiffStats::windowSeq(); + out[1] = EthercatDiffStats::cycles(); + out[2] = EthercatDiffStats::changedMax(); + out[3] = EthercatDiffStats::runsTotal(); + out[4] = EthercatDiffStats::runsMax(); + out[5] = EthercatDiffStats::seg1Bytes(); + out[6] = EthercatDiffStats::seg2Bytes(); + out[7] = EthercatDiffStats::seg3Bytes(); + out[8] = EthercatDiffStats::seg4Bytes(); + out[9] = EthercatDiffStats::seg5Bytes(); + out[10] = EthercatDiffStats::compareCyc(); +} + +} /* extern "C" */ + +#else /* !CONFIG_ECAT_DIFF_STATS */ +/* 关掉时仍导出同名取值函数并填 0:这样 dc_app.c 的挂接点不需要再套一层 + * #if,子索引数量也保持与开启版一致(主站读到的恒 0,脚本按 winseq==0 判'未启用')。 + * 留一个空函数体反而会漏掉符号——1.6.20 首编就是栽在这里(L6218E)。 */ +extern "C" void EcatDiffStats_GetWindow(uint32_t *out) +{ + if (out == nullptr) { + return; + } + for (uint32_t i = 0U; i < 11U; ++i) { + out[i] = 0U; + } +} + +#endif /* CONFIG_ECAT_DIFF_STATS */ diff --git a/GD32E508_DCU_APP/comm/ethercat_diff_stats.h b/GD32E508_DCU_APP/comm/ethercat_diff_stats.h new file mode 100644 index 0000000..bb79db7 --- /dev/null +++ b/GD32E508_DCU_APP/comm/ethercat_diff_stats.h @@ -0,0 +1,60 @@ +/** + * @file ethercat_diff_stats.h + * @brief [DIFF-STATS] 临时仪表:量化"若把输入 PDO 改成差分写,每拍真正需要写多少字节、 + * 变化点碎成几个连续段(run)"。只测量,不改变任何现有写行为。 + * + * 存在的理由:差分写(方案 A)的收益完全取决于"每拍有多少字节真的变了",而 run 碎片 + * 数量决定它会不会把一次 600B 整块写退化成几百次带 AVAIL 轮询的小写。这两个数今天 + * 都没有实测依据,所以先只上仪表,拿分布再决定 A1(语义分区条件写) 值不值得做、 + * A2(块粒度差分) 的碎片上限 N 该取几。 + * + * 顺带把"比较本身要多久"也测出来(sub23 累计 DWT 周期)——这是差分方案最容易被 + * 质疑的成本项,用实测代替拍脑袋。 + * + * @note 结果按 1s 窗口滚动(与 sys/cpu_load.cpp 同法,用 DWT 周期判窗口边界), + * getter 返回**上一个已完成的完整窗口**,因此主站任何时刻 SDO 读到的都是 + * 稳定值,不会拿到半累积的窗口。窗口未满时 windowSeq()==0,读侧应显示 + * "等待首个完整窗口"而不是当 0 解读。 + * + * @note 删除方法:删本文件与 ethercat_diff_stats.cpp + 删全工程 [DIFF-STATS] 标记行 + * + 把 sys/config.h 的 CONFIG_ECAT_DIFF_STATS 置 0。对象字典 0x2000 的 + * sub13..23 字段建议保留(恒 0,不影响任何既有子索引)。 + */ +#ifndef COMM_ETHERCAT_DIFF_STATS_H +#define COMM_ETHERCAT_DIFF_STATS_H + +#include + +/** + * @brief 输入镜像差分统计。 + * @note 全静态;observe() 在 PDI_Isr 上下文每拍调用一次,故内部状态无需临界区 + * (唯一写者),getter 由 APPL_InputMapping 同上下文读取。 + */ +class EthercatDiffStats { +public: + /** + * @brief 与上一份已发布的输入镜像比对并累计统计。 + * @param[in] cur 刚发布完成的完整输入 PDO 镜像(kEcatPdoInBytes 字节)。 + * @note 内部自持上一份副本,调用方无需额外维护缓冲、也不必自己 memcpy。 + * 首次调用只建立基准,不产生样本(否则会把"全 600B 都变了"记成第一拍)。 + */ + static void observe(const uint8_t *cur) noexcept; + + /** @brief 丢弃基准,下一次调用重新起算(AL 状态跳变、布局变更时用)。 */ + static void reset() noexcept; + + /* ── 对象字典 0x2000 sub13..23 的取值接口,均为"上一个完整 1s 窗口" ── */ + static uint32_t windowSeq() noexcept; /**< sub13 已完成窗口数,0=还没有完整窗口 */ + static uint32_t cycles() noexcept; /**< sub14 窗口内参与比对的拍数 */ + static uint32_t changedMax() noexcept; /**< sub15 单拍变化字节峰值 */ + static uint32_t runsTotal() noexcept; /**< sub16 窗口内 run 总数(配 cycles 求均值) */ + static uint32_t runsMax() noexcept; /**< sub17 单拍 run 数峰值,A2 的 N 由它定 */ + static uint32_t seg1Bytes() noexcept; /**< sub18 段1(0..31) 桥状态+短快照 */ + static uint32_t seg2Bytes() noexcept; /**< sub19 段2(32..319) 电机状态窗口 */ + static uint32_t seg3Bytes() noexcept; /**< sub20 段3(320..359) 长帧快照 */ + static uint32_t seg4Bytes() noexcept; /**< sub21 段4(360..583) 批量覆盖窗 */ + static uint32_t seg5Bytes() noexcept; /**< sub22 段5(584..599) 诊断区+保留 */ + static uint32_t compareCyc() noexcept; /**< sub23 比对自身累计 DWT 周期(本征开销) */ +}; + +#endif /* COMM_ETHERCAT_DIFF_STATS_H */ diff --git a/GD32E508_DCU_APP/comm/ethercat_pdo.cpp b/GD32E508_DCU_APP/comm/ethercat_pdo.cpp index 24a2883..a60c11f 100644 --- a/GD32E508_DCU_APP/comm/ethercat_pdo.cpp +++ b/GD32E508_DCU_APP/comm/ethercat_pdo.cpp @@ -5,15 +5,24 @@ #include "comm/ethercat_pdo.h" #include "comm/ethercat_can_bridge.h" #include "device/canfd.h" +#include "sys/config.h" #include "sys/cpu_load.h" +/* [DIFF-STATS] 临时仪表挂接(删除:去掉本块与 process() 内的 observe 块)。 + * 上一行的 sys/config.h 必须在此之前:宏未定义时 #if 会静默按 0 求值, + * 开关就会被悄悄当成"关掉"。 */ +#if CONFIG_ECAT_DIFF_STATS +#include "comm/ethercat_diff_stats.h" +#endif #include -uint8_t EthercatPdo::s_out[EthercatPdo::kPdoOutBytes] = {0}; -uint8_t EthercatPdo::s_in[EthercatPdo::kPdoInBytes] = {0}; +/* alignas(4) 必须在类内声明与出类定义上同时标注:AC6(Clang) 否则报 + * "'alignas' must be specified on definition"。用途见 ethercat_pdo.h。 */ +alignas(4) uint8_t EthercatPdo::s_out[EthercatPdo::kPdoOutBytes] = {0}; +alignas(4) uint8_t EthercatPdo::s_in[EthercatPdo::kPdoInBytes] = {0}; /* 输入 PDO 的工作缓冲:buildInput 填这里,填完整块发布到 s_in(见 process())。 * 读侧 writeInputs 只读 s_in,因此永远看不到"填到一半"的镜像。 */ -uint8_t EthercatPdo::s_build[EthercatPdo::kPdoInBytes] = {0}; +alignas(4) uint8_t EthercatPdo::s_build[EthercatPdo::kPdoInBytes] = {0}; volatile uint32_t EthercatPdo::s_outputCycles = 0U; volatile uint32_t EthercatPdo::s_inputCycles = 0U; @@ -41,13 +50,17 @@ void EthercatPdo::process() * EXTI1(ESC IRQ),**挡不住 EXTI0(Sync0,preempt=0) 和 EXTI2(Sync1,preempt=1)**。 * * 竞态:MainLoop 把 s_in 填到一半(如刚写完批量区第 1 条)时被 Sync0/Sync1 - * 抢占 → 内层重入本函数,memset 清空后重填整个 496B,随后 PDO_InputMapping() + * 抢占 → 内层重入本函数,memset 清空后重填整个输入 PDO,随后 PDO_InputMapping() * 把这一份发给 ESC;外层回来又按自己那轮的进度接着往下写。于是发出去的镜像里 * 批量区相邻两条来自**不同轮次**,而两轮之间环形队列 tail 已推进 —— 第 2 条 * 就带上了另一条帧的内容。实测特征与此吻合:批量区第 1 条几乎不坏(0.06%)、 * 第 2 条 30.75% 坏且内容是一条完整 CONFIG 帧;段 1/段 3 干净,因为它们每轮 * 内容相同,被覆盖也看不出来。 * + * 1.6.15 批量区改成定长覆盖窗口后,跨轮次混填不再产生"半新半旧"的假帧(每槽 + * 自带 seqlock,且槽位固定、语义本就是"最近 N 帧"),但本防护仍然保留:省掉 + * 一次无谓的整块重填,并让镜像保持同一轮次的一致性。 + * * 处理:已在填充中就直接返回。输入 PDO 是周期量,跳过一次下一拍即补, * 远好过发出一份跨轮次拼接的镜像。 */ static volatile bool s_building = false; @@ -73,6 +86,13 @@ void EthercatPdo::process() fillDiagTail(s_build); memcpy(s_in, s_build, kPdoInBytes); +#if CONFIG_ECAT_DIFF_STATS + /* [DIFF-STATS] 只测量:与上一份已发布镜像比对并累计到 1s 窗口,经 0x2000 + * sub13..23 上报。不改变上面的整块写行为,因此本版本固件的输出与 1.6.16 + * 逐字节一致。仪表自持上一份副本,这里不需要额外缓冲。 */ + EthercatDiffStats::observe(s_in); +#endif + s_building = false; } diff --git a/GD32E508_DCU_APP/comm/ethercat_pdo.h b/GD32E508_DCU_APP/comm/ethercat_pdo.h index 75dcc57..1d721d0 100644 --- a/GD32E508_DCU_APP/comm/ethercat_pdo.h +++ b/GD32E508_DCU_APP/comm/ethercat_pdo.h @@ -98,14 +98,17 @@ private: */ static void fillDiagTail(uint8_t *in); - /** @brief 最近一份主站下发的输出数据。 */ - static uint8_t s_out[kPdoOutBytes]; + /** @brief 最近一份主站下发的输出数据。 + * @note alignas(4):让整块 memcpy(writeInputs / [DIFF-STATS] 的快照拷贝)按 + * 字宽走而非逐字节。比对主循环本身不依赖它——已改成手拼 4 字节移位, + * 因为 -Oz 下 memcpy(&a,p,4) 会编成函数调用,见 ethercat_diff_stats.cpp。 */ + alignas(4) static uint8_t s_out[kPdoOutBytes]; /** @brief 待交回主站的输入数据(已发布的完整镜像,writeInputs 只读它)。 */ - static uint8_t s_in[kPdoInBytes]; + alignas(4) static uint8_t s_in[kPdoInBytes]; /** @brief 输入 PDO 工作缓冲:buildInput 填这里,填完整块发布到 s_in。 * ECAT_Application() 会在 PDI_Isr / Sync0_Isr / Sync1_Isr / MainLoop 四个 * 上下文被调用并互相抢占,直接写 s_in 会让读侧取到跨轮次的半成品。 */ - static uint8_t s_build[kPdoInBytes]; + alignas(4) static uint8_t s_build[kPdoInBytes]; /** @brief process() 被重入拦下的次数(诊断用,见 reentryCount())。 */ static volatile uint32_t s_reentryCount; diff --git a/GD32E508_DCU_APP/comm/ethercat_pdo_size.h b/GD32E508_DCU_APP/comm/ethercat_pdo_size.h index 7285d3c..3e9aa78 100644 --- a/GD32E508_DCU_APP/comm/ethercat_pdo_size.h +++ b/GD32E508_DCU_APP/comm/ethercat_pdo_size.h @@ -51,23 +51,39 @@ #define ECAT_PDO_OUT_BYTES 384 /** - * 输入过程数据字节数(从站→主站,SM3):62×64bit = 496B。 - * - * ── 1.6.1 瘦身 640 → 496(2026-09-05 真机剖分后的决定)── + * 输入过程数据字节数(从站→主站,SM3):75×64bit = 600B。 * + * ── 1.6.1 瘦身 640 → 496(2026-09-05)── * 固件 DWT 探针(0x2000 sub9/sub10)实测:PDO_InputMapping 里 HW_EscWriteIsr * 640B 写恒为 296µs/拍(29.7% CPU),且与 SYNC0 相位**无关**(主站 dc_bench * 五形态对拍极差 0.04%)——PRAM 通道 ≈0.463µs/B 线性,写多少省多少,唯一的 * 减法就是减字节。真机 CAN 流量采样(6086 帧)DLC 只有 24/32 两档,批量接收 - * 区每帧 64B 数据是纯富余: - * - 批量接收条目 8B 头 + 64B 数据 → 8B 头 + 32B 数据(DLC 真值保留, - * 超长帧截断、flags bit1 标记;环形队列仍存全量 64B,不丢原始数据); - * - 段 5 诊断区从 632..639(批量区最坏占用后的尾部追加)挪到 484..491; - * - 段 1 / 电机窗口 / 长帧快照全部不动(0..359 与 1.5.61 逐字节兼容)。 - * 496 = 62×8;SM3 实占 3×496=1488B(0x1880-0x1E4F),out+in=880 ≤ 1024 硬上限, - * DPRAM 反而留出 432B 余量。改这里必须同步重生成 SII(gen_eeprom.py)并断电冷启动。 + * 区每帧 64B 数据是纯富余:条目数据 64→32B(DLC 真值保留,超长帧截断、 + * flags bit1 标记),诊断区前移,段 1/电机窗口/长帧快照全部不动。 + * + * ── 1.6.15 扩到 600:批量接收改定长覆盖窗口(2026-09-08)── + * 瘦身后实测 496B 写 218.7µs/拍(21.9%)。批量区当时是"变长紧排 + 环形队列 + * 排空",容量 3 帧/拍(=3000 帧/s),现场 4000 帧/s 下每拍恒丢 1 帧,且排空赤字 + * 把队列长期压在饱和态——1.6.9~1.6.13 追的错标/拼接竞态正是饱和态喂出来的 + * (判满用的 tail 一旦滞后,生产者就会写进消费者刚读过的槽)。 + * + * 本版把批量区改成**定长覆盖窗口**:生产者按自己的游标写、绕圈覆盖,消费者 + * 每拍整块取值,不再有 tail/判满/pending。代价是每槽要带一个 u32 seqlock + * (PDI_Isr 会抢占主循环写侧,没有它主站就能拿到"新 ID + 旧数据"的假帧), + * 所以 5 槽的预算是 5×44 而不是 5×40: + * 段1..3 0..359(不动) + 头 4B(360..363) + 5×44B(364..583) + * + 段5 诊断 12B(584..595) + 保留 4B = 600B + * 5 槽 = 5000 帧/s,对 4000 帧/s 留出 25% 余量;写侧 600×0.441 ≈ 265µs/拍 + * (26.5%,仍远好于旧 640B 的 296µs,更是 4kHz 周期的 87.5% 占空不可行方案的 + * 零头,见 tools/ecat/dc_bench.py 固化结论)。 + * + * 覆盖窗口**不创造容量**:PDO 每拍能带几帧只由字节数决定,要接住 N 帧/拍, + * 槽数必须 ≥ N,否则一帧在两次拷贝之间就被覆盖掉(改完可看主站按 seq 空洞 + * 算出的丢帧数与从站侧 OVF 是否一致)。 + * 600 = 75×8;SM3 实占 3×600=1800B(0x1880-0x1F67),out+in=984 ≤ 1024 硬上限, + * DPRAM 仍留 120B 余量。改这里必须同步重生成 SII(gen_eeprom.py)并断电冷启动。 */ -#define ECAT_PDO_IN_BYTES 496 +#define ECAT_PDO_IN_BYTES 600 /** DPRAM 中过程数据区起址与 SM 缓冲数 —— 上限推导的依据,供静态断言使用。 */ #define ECAT_PD_AREA_START 0x1400 diff --git a/GD32E508_DCU_APP/comm/ssc/port/DCU_ESI.xml b/GD32E508_DCU_APP/comm/ssc/port/DCU_ESI.xml index e2f0a11..13920e6 100644 --- a/GD32E508_DCU_APP/comm/ssc/port/DCU_ESI.xml +++ b/GD32E508_DCU_APP/comm/ssc/port/DCU_ESI.xml @@ -25,7 +25,7 @@ Outputs - Inputs + Inputs #x1A00 @@ -814,24 +815,97 @@ Status Data 62 ULINT - - - - - - - - - - - - - - - - - - + + #x6000 + 63 + 64 + Status Data 63 + ULINT + + + #x6000 + 64 + 64 + Status Data 64 + ULINT + + + #x6000 + 65 + 64 + Status Data 65 + ULINT + + + #x6000 + 66 + 64 + Status Data 66 + ULINT + + + #x6000 + 67 + 64 + Status Data 67 + ULINT + + + #x6000 + 68 + 64 + Status Data 68 + ULINT + + + #x6000 + 69 + 64 + Status Data 69 + ULINT + + + #x6000 + 70 + 64 + Status Data 70 + ULINT + + + #x6000 + 71 + 64 + Status Data 71 + ULINT + + + #x6000 + 72 + 64 + Status Data 72 + ULINT + + + #x6000 + 73 + 64 + Status Data 73 + ULINT + + + #x6000 + 74 + 64 + Status Data 74 + ULINT + + + #x6000 + 75 + 64 + Status Data 75 + ULINT + diff --git a/GD32E508_DCU_APP/comm/ssc/port/dc_app.c b/GD32E508_DCU_APP/comm/ssc/port/dc_app.c index fa64ae4..571829f 100644 --- a/GD32E508_DCU_APP/comm/ssc/port/dc_app.c +++ b/GD32E508_DCU_APP/comm/ssc/port/dc_app.c @@ -42,13 +42,15 @@ * 1.5.56 扩到 DPRAM 硬上限:输出 48×64bit=384B,输入 80×64bit=640B(非对称)。 * 1.6.1 输入瘦身 640→496(62×64bit):批量接收条目数据 64→32B + 诊断区前移, * 依据与推导见 comm/ethercat_pdo_size.h 的 1.6.1 注。 + * 1.6.15 输入 496→600(75×64bit):批量接收改定长覆盖窗口 5 槽(每槽带 u32 + * seqlock,槽长 44B),诊断区随之后移到 584。推导见该头文件的 1.6.15 注。 * 长度的唯一真源是 comm/ethercat_pdo_size.h;本文件用 _Static_assert 与之绑定, * 改一处漏一处会编译失败。为何硬上限是 out+in≤1024B 见该头文件的推导。 *---------------------------------------------------------------------------*/ /** 输出/输入 PDO 的 64bit 条目数。 */ #define PDO_OUT_ENTRIES 48u /* 48×8 = 384 B */ -#define PDO_IN_ENTRIES 62u /* 62×8 = 496 B (1.6.1 瘦身,原 80×8=640) */ +#define PDO_IN_ENTRIES 75u /* 75×8 = 600 B (1.6.15 覆盖窗口,原 62×8=496) */ /** 输出数据对象 0x7000 (主站→从站, RxPDO) */ typedef struct OBJ_STRUCT_PACKED_START { @@ -73,7 +75,7 @@ static TOBJ6000 s_inputObj = {PDO_IN_ENTRIES, {0}}; _Static_assert(offsetof(TOBJ7000, u64Data) == 2, "TOBJ7000 未紧排:检查 OBJ_STRUCT_PACKED_START"); _Static_assert(offsetof(TOBJ6000, u64Data) == 2, "TOBJ6000 未紧排:检查 OBJ_STRUCT_PACKED_START"); _Static_assert(sizeof(TOBJ7000) == 2 + 8 * PDO_OUT_ENTRIES, "TOBJ7000 尺寸应为 2+384"); -_Static_assert(sizeof(TOBJ6000) == 2 + 8 * PDO_IN_ENTRIES, "TOBJ6000 尺寸应为 2+496"); +_Static_assert(sizeof(TOBJ6000) == 2 + 8 * PDO_IN_ENTRIES, "TOBJ6000 尺寸应为 2+600"); /* 与 C++ 侧桥接层的长度约定绑定:任一处改了尺寸而忘了另一处,这里直接编译失败, * 而不是等到运行期 SAFEOP 报 AL 0x001A/0x0011 再回头查。 */ @@ -105,10 +107,25 @@ typedef struct OBJ_STRUCT_PACKED_START { UINT32 u32InWriteCyc; /* sub10: 其中 HW_EscWriteIsr 输入PDO 过程数据写 */ UINT32 u32AlCyc; /* sub11: PDI_Isr 内 AL 事件读×2+超限应答 */ UINT32 u32AppCyc; /* sub12: ECAT_Application(桥 processOutput) */ + /* [DIFF-STATS] 差分写取证仪表(sub13..23):上一个完整 1s 窗口的值, + * 取自 comm/ethercat_diff_stats.cpp。CONFIG_ECAT_DIFF_STATS 置 0 时恒 0, + * 字段保留(不影响任何既有子索引,也不动 PDO/映射,故无需重刷 SII)。 */ + UINT32 u32DsWinSeq; /* sub13: 已完成窗口数,0=还没有完整窗口(勿把 0 当"无变化") */ + UINT32 u32DsCycles; /* sub14: 窗口内参与比对的拍数 */ + UINT32 u32DsChangedMax; /* sub15: 单拍变化字节峰值(差分写的理论上限=600×拍数) */ + UINT32 u32DsRunsTotal; /* sub16: 窗口内 run(连续变化段)总数,配 sub14 求均值 */ + UINT32 u32DsRunsMax; /* sub17: 单拍 run 数峰值 —— 块粒度差分的碎片上限由它定 */ + UINT32 u32DsSeg1; /* sub18: 段1(0..31) 桥状态+短快照 变化字节合计 */ + UINT32 u32DsSeg2; /* sub19: 段2(32..319) 电机状态窗口 */ + UINT32 u32DsSeg3; /* sub20: 段3(320..359) 长帧快照 */ + UINT32 u32DsSeg4; /* sub21: 段4(360..583) 批量覆盖窗 */ + UINT32 u32DsSeg5; /* sub22: 段5(584..599) 诊断区+保留 */ + UINT32 u32DsCompareCyc; /* sub23: 比对自身累计 DWT 周期(u32 粒度,不含快照拷贝) */ } OBJ_STRUCT_PACKED_END TOBJ2000; -static TOBJ2000 s_syncDiag = {12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; +static TOBJ2000 s_syncDiag = {23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; /* 同上:UINT32 首条目必须落在字节 2(未紧排时为 4)。 */ _Static_assert(offsetof(TOBJ2000, u32Sync0) == 2, "TOBJ2000 未紧排:检查 OBJ_STRUCT_PACKED_START"); @@ -129,6 +146,9 @@ extern volatile UINT32 g_dbg_inWriteCyc; extern volatile UINT32 g_dbg_alCyc; extern volatile UINT32 g_dbg_appCyc; +/* [DIFF-STATS] 差分写统计窗口值(comm/ethercat_diff_stats.cpp),out[0..10] 对应 sub13..23 */ +void EcatDiffStats_GetWindow(UINT32 *out); + /* 过程数据收发模块 (comm/ethercat_pdo.cpp 的 extern "C" shim, 1.5.54)。 * 1.5.53 时这里直接调 EcatCan_* 转发桥;1.5.54 把"一个 EtherCAT 周期怎么收发" * 收拢进 EthercatPdo 类,本文件退回纯 SSC 移植层:只留对象字典 + 三行转发。 */ @@ -182,13 +202,12 @@ static TOBJ_RXPDO_MAPPING sRxPDOMap = {PDO_OUT_ENTRIES, { PDO_MAP_8(0x7000, 24), PDO_MAP_8(0x7000, 32), PDO_MAP_8(0x7000, 40) }}; -/* 0x1A00: 映射 0x6000:01..62, 每个 64 bits(1.6.1 瘦身 80→62:7×8 + 6) */ +/* 0x1A00: 映射 0x6000:01..75, 每个 64 bits(1.6.15 覆盖窗口 62→75:9×8 + 3) */ static TOBJ_TXPDO_MAPPING sTxPDOMap = {PDO_IN_ENTRIES, { PDO_MAP_8(0x6000, 0), PDO_MAP_8(0x6000, 8), PDO_MAP_8(0x6000, 16), PDO_MAP_8(0x6000, 24), PDO_MAP_8(0x6000, 32), PDO_MAP_8(0x6000, 40), - PDO_MAP_8(0x6000, 48), - PDO_MAP_ENTRY(0x6000, 57), PDO_MAP_ENTRY(0x6000, 58), PDO_MAP_ENTRY(0x6000, 59), - PDO_MAP_ENTRY(0x6000, 60), PDO_MAP_ENTRY(0x6000, 61), PDO_MAP_ENTRY(0x6000, 62) + PDO_MAP_8(0x6000, 48), PDO_MAP_8(0x6000, 56), PDO_MAP_8(0x6000, 64), + PDO_MAP_ENTRY(0x6000, 73), PDO_MAP_ENTRY(0x6000, 74), PDO_MAP_ENTRY(0x6000, 75) }}; _Static_assert(sizeof(sRxPDOMap.aEntries) / 4 == PDO_OUT_ENTRIES, "0x1600 条目数不符"); @@ -237,22 +256,36 @@ OBJCONST TSDOINFOENTRYDESC OBJMEM asInputEntryDesc[] = { {DEFTYPE_UNSIGNED64, 0x40, ACCESS_READ | OBJACCESS_TXPDOMAPPING} }; -/* SYNC 诊断 0x2000: REC, Sub0..Sub12 共 13 项, 纯只读不参与 PDO 映射。 - * sub5..12 为 1.6.0 剖分埋点(DWT 周期/计数累加), 定位负载构成后移除。 */ +/* SYNC 诊断 0x2000: REC, sub0..sub23 共 24 项, 纯只读不参与 PDO 映射, + * 改子索引数无需重刷 SII/EEPROM。sub5..12 为 1.6.0 剖分埋点(DWT 周期/计数 + * 累加,定位负载构成后移除);sub13..23 为 [DIFF-STATS] 差分写取证仪表。 + * 行数必须与 TOBJ2000 字段一一对应:少一行不会编译报错,只会让 SDO 从错误的 + * 偏移取值 —— 故逐行标号便于核对。 */ OBJCONST TSDOINFOENTRYDESC OBJMEM asSyncDiagEntryDesc[] = { - {DEFTYPE_UNSIGNED8, 0x08, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, - {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ} + {DEFTYPE_UNSIGNED8, 0x08, ACCESS_READ}, /* sub0 : 子索引个数 = 23 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub1 : SYNC0 计数 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub2 : SYNC1 计数 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub3 : ESC IRQ 计数 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub4 : uptime 秒 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub5 : PDI_Isr 累计周期 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub6 : PDO_OutputMapping */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub7 : MainLoop free-run 块 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub8 : PDI_Isr 次数 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub9 : PDO_InputMapping 全程 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub10 : 其中输入 PDO 过程数据写 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub11 : AL 事件读×2+超限应答 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub12 : ECAT_Application */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub13 : [DIFF-STATS] 已完成窗口数 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub14 : [DIFF-STATS] 窗口内拍数 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub15 : [DIFF-STATS] 单拍变化字节峰值 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub16 : [DIFF-STATS] run 总数 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub17 : [DIFF-STATS] 单拍 run 峰值 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub18 : [DIFF-STATS] 段1 变化字节 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub19 : [DIFF-STATS] 段2 变化字节 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub20 : [DIFF-STATS] 段3 变化字节 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub21 : [DIFF-STATS] 段4 变化字节 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub22 : [DIFF-STATS] 段5 变化字节 */ + {DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub23 : [DIFF-STATS] 比对自身 DWT 周期 */ }; /*--------------------------------------------------------------------------- @@ -297,7 +330,7 @@ TOBJECT OBJMEM ApplicationObjDicList[] = { asInputEntryDesc, aName0x6000, &s_inputObj, NULL, NULL, 0x0000}, /* 0x2000: SYNC 诊断 (只读, 主站 SDO 轮询验证 DC 通路; sub5..8 为 1.6.0 剖分埋点) */ - {NULL, NULL, 0x2000, {DEFTYPE_RECORD, 12 | (OBJCODE_REC << 8)}, + {NULL, NULL, 0x2000, {DEFTYPE_RECORD, 23 | (OBJCODE_REC << 8)}, asSyncDiagEntryDesc, aName0x2000, &s_syncDiag, NULL, NULL, 0x0000}, /* 终止条目 */ @@ -311,7 +344,7 @@ TOBJECT OBJMEM * VARMEM ApplicationObjDic = ApplicationObjDicList; *---------------------------------------------------------------------------*/ UINT16 APPL_GenerateMapping(UINT16 *pInputSize, UINT16 *pOutputSize) { - *pInputSize = ECAT_PDO_IN_BYTES; /* 62×UINT64 = 496 bytes (1.6.1) */ + *pInputSize = ECAT_PDO_IN_BYTES; /* 75×UINT64 = 600 bytes (1.6.15) */ *pOutputSize = ECAT_PDO_OUT_BYTES; /* 48×UINT64 = 384 bytes */ return 0; } @@ -381,6 +414,24 @@ void APPL_InputMapping(UINT16 *pInputData) s_syncDiag.u32AlCyc = g_dbg_alCyc; s_syncDiag.u32AppCyc = g_dbg_appCyc; + /* [DIFF-STATS] 差分写统计快照(sub13..23):一次取全 11 个窗口值, + * 取的都是"上一个已完成的 1s 窗口",故 SDO 读不会拿到半累积值。 */ + { + UINT32 ds[11]; + EcatDiffStats_GetWindow(ds); + s_syncDiag.u32DsWinSeq = ds[0]; + s_syncDiag.u32DsCycles = ds[1]; + s_syncDiag.u32DsChangedMax = ds[2]; + s_syncDiag.u32DsRunsTotal = ds[3]; + s_syncDiag.u32DsRunsMax = ds[4]; + s_syncDiag.u32DsSeg1 = ds[5]; + s_syncDiag.u32DsSeg2 = ds[6]; + s_syncDiag.u32DsSeg3 = ds[7]; + s_syncDiag.u32DsSeg4 = ds[8]; + s_syncDiag.u32DsSeg5 = ds[9]; + s_syncDiag.u32DsCompareCyc = ds[10]; + } + /* 1.5.54:先让 EthercatPdo 把本周期的输入数据写进 0x6000 的对象存储, * 再交给 ESC。经由对象存储中转(而非直接写 pInputData)是为了让主站 * SDO 读 0x6000 时看到与过程数据一致的内容。 */ diff --git a/GD32E508_DCU_APP/comm/ssc/port/gen_eeprom.py b/GD32E508_DCU_APP/comm/ssc/port/gen_eeprom.py index 6302870..87eb39b 100644 --- a/GD32E508_DCU_APP/comm/ssc/port/gen_eeprom.py +++ b/GD32E508_DCU_APP/comm/ssc/port/gen_eeprom.py @@ -70,8 +70,10 @@ MBX_TX_SIZE = 256 # 邮箱 SM0/SM1 各 256B 固定占 0x1000-0x13FF,过程数据区只剩 0x1400-0x1FFF=3072B。 # SM2/SM3 都是 3-buffer 模式(control 0x64 / 0x20,bit1=0),实际占用 3×size: # SM2 384B -> 0x1400-0x187F (384×3 = 1152B) -# SM3 640B -> 0x1880-0x1FFF (640×3 = 1920B) ← 恰好用满最后一字节 -# (384+640)×3 = 3072 = 过程数据区全长,一字节不剩也一字节不越界。 +# SM3 600B -> 0x1880-0x1F67 (600×3 = 1800B) ← 1.6.15,DPRAM 留 120B 余量 +# (384+600)×3 = 2952 ≤ 3072 = 过程数据区全长。历史上 1.5.56 曾取 640B +# ((384+640)×3 = 3072 恰好用满),1.6.1 瘦身到 496B 换 CPU,1.6.15 回到 600B +# 换批量区容量——推导与取舍见 comm/ethercat_pdo_size.h。 # SSC 在 ecatslv.c:708-721 按 (start + size*buffer) 做重叠检查,撞了会在 # PREOP->SAFEOP 返回 ALSTATUSCODE_INVALIDSMOUTCFG,不是静默出错。 # 要再更大只能压邮箱(拖慢 SDO/FoE)或改 1-buffer(失去读写并发保护),见 @@ -79,10 +81,15 @@ MBX_TX_SIZE = 256 PD_OUT_OFFSET = 0x1400 # SM2: RxPDO (Master -> Slave outputs) PD_OUT_SIZE = 384 # 48×64bit PD_IN_OFFSET = 0x1880 # SM3: TxPDO (Slave -> Master inputs) -PD_IN_SIZE = 496 # 62×64bit (1.6.1 瘦身,原 80×64bit=640) +PD_IN_SIZE = 600 # 75×64bit (1.6.15 覆盖窗口;1.6.1 曾瘦身到 496=62×64bit,更早 640=80×64bit) # 1.6.1 瘦身 640→496:批量接收条目数据 64→32B + 段5诊断区前移到 484。 -# SM3 实占 3×496=1488B (0x1880-0x1E4F),DPRAM 留 432B 余量; -# 依据见 comm/ethercat_pdo_size.h 的 1.6.1 注。改后必须重烧 EEPROM 并断电冷启动。 +# 1.6.15 496→600:批量接收改**定长覆盖窗口** 5 槽(每槽 12B 头含 u32 seqlock +# + 32B 数据 = 44B,共 220B,落在 364..583),段5诊断区 484→584。 +# 动机:旧"环形队列+变长紧排"容量 3 帧/拍,现场 4000 帧/s 下每拍恒丢 1 帧, +# 且饱和态喂出了 1.6.9~1.6.13 追的错标/拼接竞态。5 槽 = 5000 帧/s@1ms。 +# SM3 实占 3×600=1800B (0x1880-0x1F67),out+in=984 ≤ 1024 硬上限,留 120B 余量; +# 依据见 comm/ethercat_pdo_size.h 的 1.6.1 / 1.6.15 注。 +# 改后必须重烧 EEPROM 并断电冷启动。 # 3-buffer 占用与 DPRAM 上限自检:改尺寸/偏移时立刻在生成阶段拦住, # 而不是等烧进 EEPROM、冷启动后再看 AL 错误码。 diff --git a/GD32E508_DCU_APP/device/CANFD.cpp b/GD32E508_DCU_APP/device/CANFD.cpp index 3023243..1eeb37f 100644 --- a/GD32E508_DCU_APP/device/CANFD.cpp +++ b/GD32E508_DCU_APP/device/CANFD.cpp @@ -400,7 +400,7 @@ bool CanFd::initNominalTiming(uint32_t canPeriph) * @param[in] canPeriph 目标 CAN 外设实例。 * @param[out] 无。 * @retval 无返回值。 - * @note 时钟源为 APB1=90MHz;按 CONFIG_CAN_DATA_* 宏配置,目标 2.0 Mbps。 + * @note 时钟源为 APB1=90MHz;按 CONFIG_CAN_DATA_* 宏配置,目标 5.0 Mbps。 * 实际数据段波特率 = 90MHz / (CONFIG_CAN_DATA_PRESCALER × (1+BS1+BS2))。 * BRS 受 CONFIG_CAN_FD_BRS_ENABLE 控制。 */ @@ -414,6 +414,9 @@ bool CanFd::initFdMode(uint32_t canPeriph) can_fd_parameter.fd_frame = ENABLE; can_fd_parameter.excp_event_detect = ENABLE; can_fd_parameter.delay_compensation = ENABLE; + /* TDCF/TDCO 不随数据波特率等比缩放:TDCMOD=CALC_AND_OFFSET 下硬件已自动测得 + * 回路延时(TDCV),这里只是微调,经验取 TDCO ≈ 数据段 BS1 的 tq 数(4TQ→4)。 + * 放到 12 会让本节点回采时错开约 6 个数据位,自己发出的 FD+BRS 帧被判成错误帧。 */ can_fd_tdc_parameter.tdc_filter = 0x04U; can_fd_tdc_parameter.tdc_mode = CAN_TDCMOD_CALC_AND_OFFSET; can_fd_tdc_parameter.tdc_offset = 0x04U; diff --git a/GD32E508_DCU_APP/sys/config.h b/GD32E508_DCU_APP/sys/config.h index 4c3e8cc..b8c8817 100644 --- a/GD32E508_DCU_APP/sys/config.h +++ b/GD32E508_DCU_APP/sys/config.h @@ -87,6 +87,12 @@ constexpr bool CONFIG_CAN_CH2_ENABLE = true; * 并避免每次上电/每 20s 把 EXMC 引脚切成 GPIO 手动拉一遍。换板/补焊需重新排查浮空时置 1。 * 网页万用表通断测试 continuityDrive() 不受此开关影响,始终可用。 */ #define CONFIG_ESC_EXMC_DIAG 0 +/** @brief [DIFF-STATS] 输入镜像差分统计(0x2000 sub13..23,量化差分写能省多少字节、 + * run 碎片有多严重、比对自身要多少钱)。临时仪表,取证完置 0 即与改造前逐字节等价。 + * @note 开启时每拍在 PDI_Isr 内多一次 600B 字粒度比对 + 一次快照拷贝(约 10µs 量级, + * 实测见 sub23),因此 **sub9/sub10 的写侧耗时会包含这部分**,不能直接与历史 + * 296µs/265µs 对拍——要看差分潜力就只看 sub13..22,要看真实写侧耗时请置 0 重测。 */ +#define CONFIG_ECAT_DIFF_STATS 0 /** @brief 启动横幅与 main 进度打印。 */ #define CONFIG_APP_BANNER_LOG_ENABLE 1 @@ -122,7 +128,14 @@ constexpr bool CONFIG_CAN_CH2_ENABLE = true; * ============================================================ */ constexpr uint32_t APP_FW_VERSION_MAJOR = 1U; constexpr uint32_t APP_FW_VERSION_MINOR = 6U; -constexpr uint32_t APP_FW_VERSION_PATCH = 13U; /* 1.6.13:根治 —— 1.6.12 的 PDI 入口预抽实测(60s 正弦指令负载,链路活性已断言):overfull 50/s→0 但指纹违规仍 ~92/s、torn 在无溢出阶段 1-7/s 持续涨 —— 判决:溢出只是共犯之一,**357µs 的 PDI 阻塞窗口本身**才是病理母体(窗口内 FIFO 满溢毁窗口 + 被冻结的取帧路径留着不依赖溢出的换帧路径,MI/MP 只抓到少数)。本版釜底抽薪:CAN RX ISR 提到 preempt=0(CONFIG_IRQ_PREEMPT_CAN_RX,与 SYNC0 同级互不抢占)—— RX 不再被 PDI/SYNC1/SCHED 压住,FIFO 深度 ≤2、溢出与冻结同时消失,receiveFrameChecked 的多字取数自此真正原子;TX ISR 留在 2(PDI 上下文有 TX 提交,提 0 会引入提交/释放竞争)。代价:SYNC0/PDI 偶发 µs 级延迟(RX ISR ~3µs/帧×8kHz≈2.4% 占空),相对 1ms 周期是零头。PDI 预抽 pumpAllChannels 随之撤除(失去意义且引入 PDI/ISR 双消费者风险)。保留 1.6.12 的诚实管道(校验只计不丢)与诊断布局(overfull@490、relto@492、seq u16@488)作为验证仪表:修好 = reject/torn/overfull 恒 0。 */ +constexpr uint32_t APP_FW_VERSION_PATCH = 20U; /* 1.6.20:**基线对照版**,仅把 CONFIG_ECAT_DIFF_STATS 置 0,用来量仪表自身成本与生产口径的真实负载水位。动机:1.6.19 读到 load 51.5%,但该读数含 observe() 每拍的 600B 比对 + 快照拷贝,拿它当"差分能省 19 个点"的分母是被污染的 —— 同工况同判据各读一次,两数相减才是仪表成本。PDO 布局/映射逐字节未动,无需重刷 SII。 */ + /* 1.6.19:[DIFF-STATS] 仪表成本终于测对了,并推翻 1.6.18 自己的判断。1.6.18 认定"是 -Oz 把 memcpy(&a,p,4) 编成函数调用",改成手拼 4 字节移位,结果反汇编出**每字 8 条 ldrb.w**、比开销从 21.3µs/拍涨到 41µs/拍 —— 方向完全反了。真根因不在这里:alignas(4) 标在数组声明上只到调用点为止,而 observe(const uint8_t*) 的形参签名不带对齐信息,优化器无从证明指针 4 字节对齐,两版都只能逐字节走。修法是对两个入参各做一次 __builtin_assume_aligned(p,4) 再走 memcpy —— 反汇编确认循环体已缩成 ldr.w/ldr.w/cmp/bne 每字 4 条 (150 字 ≈ 750 cycle ≈ 4.2µs/拍,与最初估算同量级),且整个函数 bl 归零(600B 快照拷贝也被折成字搬运)。教训:**测量工具的开销会直接变成结论**,"差分判断很贵"这句话差一点就被我自己造的假象坐实。被测量(变化字节数 25%、run 峰值 12)不受此影响,两版读数一致,仍然有效。 */ + /* 1.6.18:修 [DIFF-STATS] 仪表自己的假象 —— 比对循环里 `memcpy(&a,p,4)` 在 -Oz 下被编成**函数调用**而非单条 LDR,首轮实测 compare 开销虚报成 21.3µs/拍(≈300 次调用×995 拍),若拿它当"差分判断很贵"的证据就结论反了。改成小端 4 字节移位拼装:合法、不碰严格别名、且是 Clang 认得折 LDR 的模式。顺带把 alignas(4) 的理由改成"服务整块 memcpy",比对本身不再依赖它。Code 52716→52768(+52B),代价是体积不是速度。 */ + /* 1.6.17:新增 [DIFF-STATS] 临时仪表,为"输入 PDO 改差分写"取证,**零行为变更**。动机:差分写的收益完全取决于每拍真正变了多少字节、变化点碎成几个 run,而 run 碎片数决定它会不会把一次 600B 整块写退化成几百次带 PRAM_AVAIL 轮询的小写——这两个数今天没有实测依据,所以先只上仪表再决定做不做。实现:comm/ethercat_diff_stats.{h,cpp},每拍在 PDI_Isr 里与上一份已发布镜像做 u32 粒度比对,按 1s 窗口(DWT 周期,同 cpu_load 手法)滚动,getter 只回报"上一个完整窗口"以免主站读到半累积值;量为 cycles / changedMax / runsTotal / runsMax / 段1..段5 各自变化字节 / 比对自身 DWT 开销。经对象字典 0x2000 sub13..23 上报(新增 11 个只读 UINT32)。**只加 SDO 子索引、PDO 长度与映射逐字节不动 ⇒ 无需重生成 SII、无需断电冷启动**,可与主站独立升级。整模块由 CONFIG_ECAT_DIFF_STATS 门控,置 0 后与 1.6.16 等价;挂接点统一 [DIFF-STATS] 标记,删除=删 2 文件+删标记行。已知副作用:开启时 sub9/sub10 写侧耗时含比对开销,不能直接与历史 296µs/265µs 对拍,真实写侧基准须置 0 重测。 */ + /* 1.6.16:修段 1/段 3 快照 drop 的计数口径(纯判据修复,PDO 布局逐字节不动)。病理:`onCanFrame` 里判的是 `if (s_rxValid) ++s_dropCount`,而 s_rxValid 置位后**永不清零**(只有 flags bit0 清统计会归零计数,不动 valid),于是开机收到第一帧之后**每一帧都算一次丢弃**,drop ≡ rx−1 (mod 2^16)。实测 period=1ms、CAN 侧 6000 帧/s 时 drop 同样 6000/s,与 rx_count 逐拍 1:1(Δ 完全相等),(rx−1) mod 65536 与上报值精确吻合。后果:主站 `/api/motor/state` 的 bridge.drop 被 transport-ecat.js 当状态栏 **ERR** 显示、且它是桥全局值三通道同数 → 网页"ERR 多得离谱"其实只是收帧速率,零灵敏度、纯假仪表;calibrate_ecat_metrics 一类拿 drop 做分子分母的判据恒得 100%。本版加 s_rxPublished(写侧置 false / buildInput 段 1 发布分支置 true,与段 4 的 s_rxWinSeen 同一套"未被任何一拍取走才算丢"约定),判据改 `s_rxValid && !s_rxPublished`;计数落在 xrxSeq 奇数窗口内,消费者判奇会跳过段 1/段 3 故不会并发置位,读+自增对消费者等效原子。flags bit0 清统计时一并置 published=true,免得清零后的第一帧被误计。s_rxValid 语义不变("有过接收",flags bit0 与 backlog 取通道都依赖它常驻),故 ecat.html 短快照、daemon 的 xrx 分派、ESP32 侧 0xC1 全部零改动。**修完不会归零**:段 1/段 3 是单槽、每拍只能发布 1 帧,6000 帧/s @1ms 下仍约 5000/s(≈rx 的 0.83),这个数自此真正的含义是"快照槽位跟不上收帧速率",不是总线错误;真·CAN 错误仍看 ESP32 `/can/feedback` 的 err/TEC/REC,真·入窗丢帧看段 4 的 OVF(362) 与 seq 空洞。 */ + /* 1.6.15:批量接收改**定长覆盖窗口**,输入 PDO 496→600(75×64bit)。动机:1.6.1 瘦身后批量区是"环形队列 + 变长紧排排空",容量 3 帧/拍(=3000 帧/s),现场 4000 帧/s 下每拍恒丢 1 帧(can_cadence lost=512/drain FAIL);更糟的是排空赤字把队列长期压在饱和态,而 1.6.9~1.6.13 追的错标/拼接竞态正是饱和态喂出来的(判满用的 tail 一旦滞后,生产者就写进消费者刚读过的槽)。本版按用户裁定改成覆盖式:生产者(onCanFrame)按自己游标写、绕圈覆盖,消费者(fillRxWindow)每拍整块取值 —— 没有 tail/判满/pending,那个滞后握手的载体直接不存在。定长(5×44B@364)让主站不必推游标,1.6.10 那类边界越界写诊断区的错在结构上不可能再犯。**每槽必须带 u32 seqlock**:buildInput 跑在 PDI_Isr(preempt=0) 会抢占主循环写侧,没有它主站能拿到"新 ID + 旧数据"拼出的假帧(不是"差一个周期",是现实中不存在但看起来合法的帧,与刚修的拼接同形)。seq 兼作主站去重与丢帧判据(空洞数=被覆盖未取的帧),与从站侧新加的 OVF("未取先覆"次数) 互为对账仪表。语义变更:批量区从"保证不丢的事件流"变成"最近 N 帧窗口"——同一帧会连续几拍重复出现,低频一次性帧(版本/节点应答)在突发下可能被静默覆盖;主站 daemon 层按 seq 去重后归一成事件流,故 motor.js/ecat.html/can_cadence.py 判据不变、前端零改动。容量:5 槽=5000 帧/s@1ms,对 4000 帧/s 留 25% 余量(STATUS 自 1.6.9 起不入窗,实际入窗量是 CONFIG 量级);覆盖窗口**不创造容量**,槽数 < 帧/拍 仍会静默丢帧。写侧 600×0.441≈265µs/拍(26.5%),仍优于旧 640B 的 296µs,更是"提到 4kHz"方案 87.5% 占空不可行结论(dc_bench 固化)的零头。段1..3(0..359) 逐字节不动,段5 诊断区 484→584。三端同步:ethercat_pdo_size.h / bridge.h+cpp / dc_app.c(62→75) / gen_eeprom.py / DCU_ESI.xml / ecat_native.c decode_input(新增 600..639 分支,向后自适应 496 与 640)。必须重烧固件 + 烧 EEPROM + 断电冷启动,只烧一边 SAFEOP 报 AL 0x001A/0x0011。 */ + /* 1.6.14:CAN-FD 数据段 2Mbps→5Mbps(CONFIG_CAN_DATA_PRESCALER 9→3、CONFIG_CAN_DATA_BS1 3TQ→4TQ ⇒ 90MHz/(3×6TQ),采样点 80%→83.3%;TDC filter/offset 4→12 tq 维持约 400ns,因 TDCF/TDCO 以数据段 tq 为单位)。动机:电机 STATUS48B+CONFIG24B×2 节点@1kHz 加 1kHz 下行 CMD,2Mbps 下负载约 120%(实测总线帧数被钉在 ~4993 帧/s),高频下发时 RX FIFO 拥塞。仲裁段仍 1Mbps 不变。配套:电机 APP 与 BOOT 的 data_prescaler 同步改 2(60MHz/(2×6TQ)=5Mbps)——FD 数据段速率必须全总线一致,节点 TX 都带 fd_brs。 */ + /* 1.6.13:根治 —— 1.6.12 的 PDI 入口预抽实测(60s 正弦指令负载,链路活性已断言):overfull 50/s→0 但指纹违规仍 ~92/s、torn 在无溢出阶段 1-7/s 持续涨 —— 判决:溢出只是共犯之一,**357µs 的 PDI 阻塞窗口本身**才是病理母体(窗口内 FIFO 满溢毁窗口 + 被冻结的取帧路径留着不依赖溢出的换帧路径,MI/MP 只抓到少数)。本版釜底抽薪:CAN RX ISR 提到 preempt=0(CONFIG_IRQ_PREEMPT_CAN_RX,与 SYNC0 同级互不抢占)—— RX 不再被 PDI/SYNC1/SCHED 压住,FIFO 深度 ≤2、溢出与冻结同时消失,receiveFrameChecked 的多字取数自此真正原子;TX ISR 留在 2(PDI 上下文有 TX 提交,提 0 会引入提交/释放竞争)。代价:SYNC0/PDI 偶发 µs 级延迟(RX ISR ~3µs/帧×8kHz≈2.4% 占空),相对 1ms 周期是零头。PDI 预抽 pumpAllChannels 随之撤除(失去意义且引入 PDI/ISR 双消费者风险)。保留 1.6.12 的诚实管道(校验只计不丢)与诊断布局(overfull@490、relto@492、seq u16@488)作为验证仪表:修好 = reject/torn/overfull 恒 0。 */ /* 1.6.12:源头修复 —— 用户裁定"过滤是遮羞布,修采集源头,前端只许诚实反馈"。1.6.11 上线后实测(repro_cmdload 正弦负载)reject@494 仍以 ~384/s 增长:RFOD=DISABLE 挡不住溢出毁窗口(本 IP 满溢时 DISCARD 语义存疑,或毁帧发生在满态下的读并发路径),内容校验丢弃只是把坏数据拦在计数前。本版两处:(1) **源头**:CanFd::pumpAllChannels() 挂进 onEscIrqExti 的 PDI_Isr 入口 —— 在 buildInput 357µs 长阻塞(CAN ISR preempt=2 全程被压)开始之前把三路 FIFO 抽干,阻塞窗口起点深度=0、窗口内到达 ≤2-3 帧,3 深度 FIFO 从"必然打满"变成"必然不满";preempt=1 + SYNC0 已关的上下文保证与 CAN ISR 取帧互斥,与主循环批量队列生产零交集。(2) **诚实管道**:内容校验改为只计不丢(reject@494 恒 0 = 源头修好;还在涨 = 源头未愈且页面如实显示),诊断区加挂判决性测量:seq 488 拆 u16、overfull(RFO0)@490、release_timeout@492(原重入计数位,恒 0 让出)。reject 与 overfull 是否同涨 = "残余拼接是否仍源自溢出"的判决。 */ /* 1.6.11:根治残余拼接 —— 1.6.10 的 settle 延迟实测无效(正弦指令负载下拼接率不变),节点指纹实验(两电机反相正弦+速度限 200/150 打指纹)实锤拼接是**跨节点**的:node3 的 STATUS 帧头 24B = node2 的完整 CONFIG(速度指纹 200.0、角度 1142° 均属 node2 轨迹),且 CONFIG↔CONFIG 同型拼接结构校验抓不到(两边都合法)—— 电机 TX 不可能发出别的电机的字节 ⇒ 混合点在 GD32 RX 侧。真根因:can_init 的 rec_fifo_overwrite=ENABLE —— FIFO 满时覆盖最旧邮箱,而最旧邮箱正是 CPU 正在读的输出窗口;buildInput 在 PDI_Isr 占 ~357µs 压住 CAN ISR,发指令时总线 ~8kHz、阻塞窗口内 ~3 帧打满 FIFO 深度 → 覆盖进行中被读走 = 拼接(MI/MP 回读挡不住:MI 是覆盖序列最后更新的一环;settle 延迟也挡不住:非时间竞态而是溢出策略)。本版:(1) rec_fifo_overwrite=DISABLE —— 溢出丢新帧置 RFO0(rx_fifo_overfull_count 已统计),周期量丢一拍下拍即补,与软件队列"满丢新不覆旧"同则;(2) 撤 settle 延迟(无效且白烧 CPU);(3) 保留 1.6.10 的重取循环删除与边界修正,并新增入队前内容校验(STATUS 温度/电压量程+帧间斜率、CONFIG 未使用区/模式)丢结构矛盾帧、计数段5@494 —— 作为溢出之外的纵深防御(实测能挡跨型拼接,挡不住同型 CONFIG↔CONFIG,故必须配合 (1))。验证:正弦指令负载 60s,指纹违规 40→0。 */ /* 1.6.10:根治拼接帧 —— 1.6.9 的"队列覆写"定性被自测推翻为**次要**:段 2 电机槽(MotorState 路径,与批量环形队列完全独立)出现同签名拼接(node2 槽头 = node3 CONFIG 前缀 03c12200,解出 mcuTemp=-161.25),且 rx_torn/pdo_reentry 自启动恒 0 —— 两条独立下游同时中招 ⇒ 混合点在共同上游 CAN RX ISR;校验恒不报 ⇒ 拼接发生在 MI/MP 判据盲区。真根因=1.6.7 的"校验失败原地重取":RFIFOMDATA0 读指针随读自动前进且不因换帧复位,attempt1 失败后 attempt2 从错位指针开读,MI/MP 比对的是窗口身份、管不住指针错位,于是"校验通过、内容为上一帧尾巴拼下一帧开头"。实测(自测脚本 scripts/selftest_torn.py,按拍去重+灵敏度自证):空闲 0%(35k 条),电机活跃时 9.155%(56835/620838,STATUS 错标为 CONFIG 占绝对多数)。本版:(1) 去掉重取循环,校验失败即 release+丢帧+rx_torn 计数(1.6.6 语义,STATUS/CONFIG 均 1kHz 周期量,丢一拍下拍即补);(2) fillRxBatch 边界改判段 5 起点 ECAT_DIAG_OFF_LOAD(484) 而非 PDO 全长(496)——潜伏越界写诊断区,当前容量下触不到,容量放宽即咬人;(3) releaseFifo0 自清后补 settle 延迟 —— 烧(1)(2)后实测活跃流量下仍残余 0.11% 跨节点拼接(node2 槽头=node3 CONFIG 前缀,且 rx_torn 已正常计数 146,证明窗口换帧路径已被挡住),定性为 RFD 自清只代表 dequeue 握手完成、下一帧 MDATA 还在逐字搬运(MI/MP 先落数据区后落),while 连排的第二次读紧跟 release 即拿到陈旧头+新鲜尾,MI/MP 回读对这种陈旧全盲;空闲每 ISR 单帧故 0%、活跃连排才中招,与实测分布吻合。 */ @@ -207,7 +220,7 @@ constexpr uint32_t CONFIG_ESP32_RESET_PIN = GPIO_PIN_9; * → APB1=/2=90MHz (CAN_CLK) * * 仲裁段目标: 1.0 Mbps = 90MHz / (9 × 10 TQ) - * 数据段目标: 2.0 Mbps = 90MHz / (9 × 5 TQ) + * 数据段目标: 5.0 Mbps = 90MHz / (3 × 6 TQ),采样点 83.3% * * @note RCU_MODIFY_UP 宏在 system_clock_180m_hxtal() 末尾将 AHB 从 /4 * 临时切到 /1,这是 GigaDevice 官方行为,不可删除。 @@ -225,12 +238,13 @@ constexpr uint32_t CONFIG_CAN_NOMINAL_BS1 = CAN_BT_BS1_7TQ; /** @brief 仲裁段时间段 2(PHSEG2)。 */ constexpr uint32_t CONFIG_CAN_NOMINAL_BS2 = CAN_BT_BS2_2TQ; -/** @brief 数据段预分频值。 */ -constexpr uint32_t CONFIG_CAN_DATA_PRESCALER = 9U; -/** @brief 数据段再同步跳跃宽度。 */ +/** @brief 数据段预分频值。 + * CAN_DBT 的 DBAUDPSC[9:0] 独立于仲裁段 BAUDPSC,数据段可自取分频。 */ +constexpr uint32_t CONFIG_CAN_DATA_PRESCALER = 3U; +/** @brief 数据段再同步跳跃宽度。上限受 BS2=1TQ 约束,不可放宽。 */ constexpr uint32_t CONFIG_CAN_DATA_SJW = CAN_BT_SJW_1TQ; -/** @brief 数据段时间段 1。 */ -constexpr uint32_t CONFIG_CAN_DATA_BS1 = CAN_BT_BS1_3TQ; +/** @brief 数据段时间段 1。4TQ + 1TQ(同步) + 1TQ(BS2) = 6TQ → 5 Mbps。 */ +constexpr uint32_t CONFIG_CAN_DATA_BS1 = CAN_BT_BS1_4TQ; /** @brief 数据段时间段 2。 */ constexpr uint32_t CONFIG_CAN_DATA_BS2 = CAN_BT_BS2_1TQ;