From 0cd3d9bed2ce73fcac920b332c447337ec8d3cbd Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Tue, 8 Sep 2026 00:52:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(comm):=20=E5=9B=9E=E9=80=80LAN9252=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=AF=BB=E5=86=99=E4=BC=98=E5=8C=96=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=9A=E4=B8=AACAN-EtherCAT=E6=A1=A5=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回退1.6.0引入的LAN9252 PRAM批量读写优化,因其AVAIL_CNT计数器对短读存在语义问题, 导致邮箱短读和探针传输时出现FIFO陈旧数据损坏问题。 同时修复EtherCAT-CAN桥中的多个竞态条件: - 修复消费者在fillRxBatch中直接访问共享ring buffer导致的数据混淆问题 - 修复生产者-消费者间tail指针发布时机不当造成的槽位覆盖竞态 - 修复WIN窗口基址更新位置错误导致电机状态窗口被锁定的问题 - 修复buildInput中断上下文与主循环间的数据竞争和内存撕裂问题 - 添加STATUS帧过滤机制减少批量区压力并解决队列饱和问题 此外还增加了多个诊断计数器用于监控撕裂、溢出和拼接错误,并调整了 输入PDO段布局和大小参数以支持新的48字节STATUS格式。 这些修改解决了上位机观察到的帧标签错误、电机状态丢失和数据拼接问题。 --- GD32E508_DCU_APP/comm/esc_lan9252.cpp | 79 ++---- GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp | 69 +++-- GD32E508_DCU_APP/comm/ethercat_can_bridge.h | 56 +++- GD32E508_DCU_APP/comm/ethercat_pdo.cpp | 79 +++++- GD32E508_DCU_APP/comm/ethercat_pdo.h | 12 +- GD32E508_DCU_APP/comm/ethercat_sync.cpp | 3 + .../__pycache__/gen_eeprom.cpython-312.pyc | Bin 14409 -> 14409 bytes GD32E508_DCU_APP/device/CANFD.cpp | 248 +++++++++++++++++- GD32E508_DCU_APP/device/canfd.h | 15 ++ GD32E508_DCU_APP/device/motor_state.cpp | 26 +- GD32E508_DCU_APP/device/motor_state.h | 15 +- GD32E508_DCU_APP/sys/config.h | 23 +- GD32E508_DCU_APP/sys/motor_ota_relay.cpp | 4 + 13 files changed, 518 insertions(+), 111 deletions(-) diff --git a/GD32E508_DCU_APP/comm/esc_lan9252.cpp b/GD32E508_DCU_APP/comm/esc_lan9252.cpp index 24c3b8b..1125b42 100644 --- a/GD32E508_DCU_APP/comm/esc_lan9252.cpp +++ b/GD32E508_DCU_APP/comm/esc_lan9252.cpp @@ -369,14 +369,13 @@ void EscLan9252::pramRead(uint16_t addr, uint8_t *buf, uint16_t len) * 首 DWORD 有效字节从 (addr&3) 起(表 12-7),末 DWORD 尾部无效(表 12-8)。 * 别名口 0x00-0x1C 功能相同,轮换寻址即官方突发用法;每读满一个 DWORD 弹一项。 * - * 1.6.0 批量搬运(负载优化):旧实现每搬一个 DWORD 都重新轮询一次 CMD 的 - * AVAIL 位——每次轮询是 setHbiIndex+读低/高半字共 3 次 EXMC 事务,数据本身 - * 又 3 次,单周期 1024B 过程数据因此跑 ~1600 次 EXMC 访问,是 GD32 侧 CPU - * 占用的大头。DS 12.13.6:ECAT_PRAM_RD_CMD bits[12:8] = PRAM_READ_AVAIL_CNT, - * 指示"无需再查状态即可继续读 FIFO 的次数"(≤16,每次整 DWORD 读自减)。 - * 轮询命中时顺手取该计数,一口气读 min(CNT, 8[别名窗口一程], 剩余 DWORD)个, - * 轮询开销从"每 DWORD 一次"降到"每批一次"。字节装配/lead 逻辑逐字节不变, - * 任意 (addr,len) 组合的搬运结果与旧实现完全一致。 */ + * 1.6.2 回退逐 DWORD 轮询,弹前必查 AVAIL 位。1.6.0 曾信 CMD 的 + * AVAIL_CNT(bits[12:8])一次弹一批:实测 12B 短读(1.6.0 输出探针,主站窗口 + * 请求 WIN@8 靠它送达)首 DWORD 新鲜、后续 DWORD 取到 FIFO 陈旧项,窗口 + * 基址冻结,槽位 7..26 的电机状态因此永不上报(仅 seq 沿触发的 384B 全量读 + * 路径能拿到新 WIN)。批量模式 1.6.0 上机对拍本来就是零收益,384/496B 长读 + * 未见损坏,但邮箱短读/探针这类小传输只验证过"逐 DWORD 查 AVAIL"的旧模式 + * ——在弄清该计数器对短读的精确语义之前,不要恢复批量弹 DWORD。 */ if (len == 0U) { return; } @@ -386,31 +385,18 @@ void EscLan9252::pramRead(uint16_t addr, uint8_t *buf, uint16_t len) uint16_t alias = 0U; while (done < len) { uint32_t n = POLL_MAX; - uint32_t cmd; - while (((cmd = readReg32(ECAT_PRAM_RD_CMD)) & PRAM_AVAIL) == 0U && --n != 0U) {} + while ((readReg32(ECAT_PRAM_RD_CMD) & PRAM_AVAIL) == 0U && --n != 0U) {} if (n == 0U) { break; /* 芯片异常:避免死等,交出残缺数据 */ } - uint32_t batch = (cmd >> 8) & 0x1FU; - if (batch == 0U || batch > 8U) { - batch = 1U; /* 计数字段异常时退回单拍,等价旧实现 */ - } - /* 本批以"吃完全部剩余字节"为上限(lead 只在首 DWORD 出现) */ - const uint32_t lead0 = (done == 0U) ? (uint32_t)(addr & 3U) : 0U; - const uint32_t needDW = ((uint32_t)(len - done) + lead0 + 3U) / 4U; - if (batch > needDW) { - batch = needDW; - } - while (batch-- != 0U) { - const uint32_t dw = readReg32((uint16_t)(ECAT_PRAM_RD_DATA + alias)); - alias = (uint16_t)((alias + 4U) & 0x1FU); - const uint8_t lead = (done == 0U) ? (uint8_t)(addr & 3U) : 0U; - const uint8_t take = (uint8_t)((len - done) < (4U - lead) ? (len - done) : (4U - lead)); - for (uint8_t i = 0U; i < take; ++i) { - buf[done + i] = (uint8_t)(dw >> (8U * (lead + i))); - } - done = (uint16_t)(done + take); + const uint32_t dw = readReg32((uint16_t)(ECAT_PRAM_RD_DATA + alias)); + alias = (uint16_t)((alias + 4U) & 0x1FU); + const uint8_t lead = (done == 0U) ? (uint8_t)(addr & 3U) : 0U; + const uint8_t take = (uint8_t)((len - done) < (4U - lead) ? (len - done) : (4U - lead)); + for (uint8_t i = 0U; i < take; ++i) { + buf[done + i] = (uint8_t)(dw >> (8U * (lead + i))); } + done = (uint16_t)(done + take); } } @@ -418,9 +404,8 @@ void EscLan9252::pramWrite(uint16_t addr, const uint8_t *buf, uint16_t len) { /* DS 12.11.4:写 ADDR_LEN 再置 BUSY 启动;首 DWORD 有效字节从 (addr&3) 起, * 引擎只取有效字节;别名口 0x20-0x3C 轮换;写满一个 DWORD 推一项。 - * 1.6.0 批量搬运:与 pramRead 同理,DS 12.13.7 bits[12:8] = - * PRAM_WRITE_AVAIL_CNT 指示"无需再查状态即可继续写 FIFO 的次数", - * 轮询从"每 DWORD 一次"降到"每批一次",单周期 EXMC 事务约减半。 */ + * 1.6.2 与 pramRead 同步回退逐 DWORD 轮询(推前必查 AVAIL 位):1.6.0 批量 + * 模式实测零收益,且与短读损坏同源(见 pramRead 注),邮箱小 PDU 写同类风险。 */ if (len == 0U) { return; } @@ -430,31 +415,19 @@ void EscLan9252::pramWrite(uint16_t addr, const uint8_t *buf, uint16_t len) uint16_t alias = 0U; while (done < len) { uint32_t n = POLL_MAX; - uint32_t cmd; - while (((cmd = readReg32(ECAT_PRAM_WR_CMD)) & PRAM_AVAIL) == 0U && --n != 0U) {} + while ((readReg32(ECAT_PRAM_WR_CMD) & PRAM_AVAIL) == 0U && --n != 0U) {} if (n == 0U) { break; /* 芯片异常:放弃剩余字节 */ } - uint32_t batch = (cmd >> 8) & 0x1FU; - if (batch == 0U || batch > 8U) { - batch = 1U; /* 计数字段异常时退回单拍,等价旧实现 */ - } - const uint32_t lead0 = (uint32_t)((uint16_t)(addr + done) & 3U); - const uint32_t needDW = ((uint32_t)(len - done) + lead0 + 3U) / 4U; - if (batch > needDW) { - batch = needDW; - } - while (batch-- != 0U) { - const uint8_t lead = (done == 0U) ? (uint8_t)((uint16_t)(addr + done) & 3U) : 0U; - const uint8_t take = (uint8_t)((len - done) < (4U - lead) ? (len - done) : (4U - lead)); - uint32_t dw = 0U; - for (uint8_t i = 0U; i < take; ++i) { - dw |= (uint32_t)buf[done + i] << (8U * (lead + i)); - } - writeReg32((uint16_t)(ECAT_PRAM_WR_DATA + alias), dw); - alias = (uint16_t)((alias + 4U) & 0x1FU); - done = (uint16_t)(done + take); + const uint8_t lead = (done == 0U) ? (uint8_t)((uint16_t)(addr + done) & 3U) : 0U; + const uint8_t take = (uint8_t)((len - done) < (4U - lead) ? (len - done) : (4U - lead)); + uint32_t dw = 0U; + for (uint8_t i = 0U; i < take; ++i) { + dw |= (uint32_t)buf[done + i] << (8U * (lead + i)); } + writeReg32((uint16_t)(ECAT_PRAM_WR_DATA + alias), dw); + alias = (uint16_t)((alias + 4U) & 0x1FU); + done = (uint16_t)(done + take); } uint32_t n = POLL_MAX; while ((readReg32(ECAT_PRAM_WR_CMD) & PRAM_BUSY) != 0U && --n != 0U) {} diff --git a/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp b/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp index da2b8ba..1395669 100644 --- a/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp +++ b/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp @@ -114,7 +114,9 @@ void EthercatCanBridge::fillRxBatch(uint8_t *in) const uint8_t head = s_rxRingHead; /* 快照一次:生产者可能边填边推进 */ while (tail != head && count < kEcatRxBatchMax) { - const RxRingItem &it = s_rxRing[tail]; + /* 整条先拷到局部副本再用:生产者(主循环 onCanFrame)与本函数(PDI_Isr) + * 共用这块槽,直接按引用逐字段取会让 id/dlc/data 分属不同帧。 */ + const RxRingItem it = s_rxRing[tail]; uint8_t dlc = it.dlc; if (dlc > kEcatCanFdDataMax) { dlc = kEcatCanFdDataMax; @@ -126,8 +128,12 @@ void EthercatCanBridge::fillRxBatch(uint8_t *in) if (dlcWire > kEcatRxBatchDataMax) { dlcWire = kEcatRxBatchDataMax; } - /* 装不下就停,**且不推进 tail** —— 该帧留到下个周期,不丢。 */ - if ((uint16_t)(off + ECAT_FRM_HDR_SIZE + dlcWire) > kEcatPdoInBytes) { + /* 装不下就停,**且不推进 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; } @@ -141,9 +147,17 @@ void EthercatCanBridge::fillRxBatch(uint8_t *in) off = (uint16_t)(off + ECAT_FRM_HDR_SIZE + dlcWire); tail = (uint8_t)((tail + 1U) % kEcatRxRingSlots); ++count; + + /* **每条读完立刻发布 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; } - __DMB(); /* 槽位读取先于 tail 发布落地:否则生产者可能提前覆写还没读完的槽 */ - s_rxRingTail = tail; /* 只在这里推进:消费者独占写 tail */ const uint8_t pending = (uint8_t)((head + kEcatRxRingSlots - tail) % kEcatRxRingSlots); in[ECAT_RXB_OFF_COUNT] = count; @@ -187,10 +201,11 @@ void EthercatCanBridge::processOutput(const uint8_t *out) const uint8_t seq = out[ECAT_CAN_TX_OFF_SEQ]; - /* 电机状态窗口下标每周期都取(与 seq 沿无关):主站靠移动这个值滚动扫表, - * 不该被「有没有新 CAN 帧要发」耦合。越界值钳到 0,避免读到槽数组外。 */ - const uint8_t win = out[ECAT_CAN_TX_OFF_WIN]; - s_winBase = (win < kMotorSlotCount) ? win : 0U; + /* WIN 不在这里取:1.6.0 起 out 只在 seq 沿那拍才是新鲜的,无沿的周期里它是 + * 上一次全量读的残值。在这里回写 s_winBase,等于用开机首拍的 0 每拍盖掉 + * peekOutput() 刚取到的新值 —— 窗口被钉死在基址 0,槽位 7..26(node3 及 + * 以上)永不上报,直到主站真发一帧触发一次全量读才复位。 + * 每拍刷新由 peekOutput()(SSC PDO_OutputMapping 的 12B 探针)独占负责。 */ /* 上电/重连后第一次看到输出数据时只记录基准,不发送:否则主站残留的 * 非零 seq 会被当成一次新请求,在进 OP 的瞬间凭空发出一帧。 */ @@ -283,11 +298,16 @@ void EthercatCanBridge::buildInput(uint8_t *in) /* ── 段 2(32..319):电机状态窗口 ── * 直接读 MotorState 聚合表,不再依赖"最近一帧 CAN"。窗口起点由主站通过 - * 输出 PDO 的 WIN 字节指定,主站每周期 +7 即可 4 周期扫完 27 个槽。 + * 输出 PDO 的 WIN 字节指定,主站每周期 +5 即可 6 周期扫完 27 个槽。 * - * 上下文安全:buildInput 在主循环(free-run)或 Sync0_Isr 里跑,而状态表由 - * 主循环的 taskCanRxParse 写。单字段撕裂最坏是某槽的 seq 与 data 不同步一拍, - * 主站按 seq 变化判新旧、下一周期即自愈,故不加临界区(与本文件既有约定一致)。 */ + * 上下文安全(1.6.5 修正):状态表由主循环的 taskCanRxParse 写,而本函数跑在 + * PDI_Isr(SM 同步模式)/Sync0_Isr(DC 模式),会抢占写侧那次 + * memcpy(slot.data, 48B)。旧注释称"最坏是 seq 与 data 不同步一拍,主站按 seq + * 判新旧即可自愈"是错的:写侧先 seq 后 data,抢占留下的恰好是"新 seq + + * 半新半旧 data",而主站只拿 seq 判新鲜度,分辨不出同一 seq 下 payload 换过 + * 内容,混合值照原样进界面(边界落在 B16/B20/B24 这类 float 符号/指数字节上 + * 就是离谱值)。改用与段 1/段 3 同一套 seqlock(MotorSlot.wr_seq):奇数即 + * 跳过本槽,与本片既有的"本拍不报、下拍自愈"约定一致。 */ const MotorStateTable &mt = MotorState::table(); const uint8_t base = (s_winBase < kMotorSlotCount) ? s_winBase : 0U; @@ -305,13 +325,20 @@ void EthercatCanBridge::buildInput(uint8_t *in) const MotorSlot &s = mt.slots[idx]; uint8_t *p = &in[ECAT_MOTW_OFF_SLOTS + (uint16_t)i * kEcatMotSlotSize]; + /* 槽按 i 定位,跳过的那格照样占位,所以 win_count 报的是跨度而不是实际 + * 填充数:主站 decode_input 对 node_id==0 的块直接 continue。 */ + count = static_cast(i + 1U); + if ((s.wr_seq & 1U) != 0U) { + continue; /* 生产者正在写这 48B:本拍留零,下拍自愈 */ + } + __DMB(); /* 判稳先于取值:不许把下面的读重排到奇偶检查之前 */ + p[ECAT_MOT_OFF_NODE] = s.node_id; p[ECAT_MOT_OFF_CH] = s.channel; p[ECAT_MOT_OFF_ONLINE] = s.online; p[ECAT_MOT_OFF_DLC] = s.dlc; wr32le(&p[ECAT_MOT_OFF_SEQ], s.seq); memcpy(&p[ECAT_MOT_OFF_STATUS], s.data, kMotorStatusDlc); - ++count; } in[ECAT_MOTW_OFF_WIN_COUNT] = count; @@ -388,7 +415,19 @@ void EthercatCanBridge::onCanFrame(uint8_t channel, uint32_t id, * 与上面的"最近一帧快照"并行维护:快照会被后帧覆盖(这是它的设计语义), * 队列则保序累积,供 fillRxBatch 一次回收多帧。 * 队列满时丢**新**帧并计数,而不是覆盖旧帧 —— 批量区的价值在于保序完整, - * 覆盖会在序列中间留下空洞,比明确丢尾更难排查。 */ + * 覆盖会在序列中间留下空洞,比明确丢尾更难排查。 + * + * **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 + * 量级,队列不再饱和,竞态窗口随之消失。 */ + if ((id & 0xFFFFU) == 0x0100U) { + return; + } const uint8_t next = (uint8_t)((s_rxRingHead + 1U) % kEcatRxRingSlots); if (next == s_rxRingTail) { if (s_rxRingLost < 0xFFU) { diff --git a/GD32E508_DCU_APP/comm/ethercat_can_bridge.h b/GD32E508_DCU_APP/comm/ethercat_can_bridge.h index dabbabe..e63cb62 100644 --- a/GD32E508_DCU_APP/comm/ethercat_can_bridge.h +++ b/GD32E508_DCU_APP/comm/ethercat_can_bridge.h @@ -104,19 +104,19 @@ 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, /**< 本帧携带的有效槽数(≤7) */ + ECAT_MOTW_OFF_WIN_COUNT = 35U, /**< 本帧携带的有效槽数(≤5) */ ECAT_MOTW_OFF_GLOBAL_SEQ = 36U,/**< 状态表全局刷新序号,小端 uint32 */ - ECAT_MOTW_OFF_SLOTS = 40U, /**< 槽数组起点,7×40B */ + ECAT_MOTW_OFF_SLOTS = 40U, /**< 槽数组起点,5×56B(40..319,与段 3 正好相接) */ }; -/** 电机状态槽内部布局(40 字节)。 */ +/** 电机状态槽内部布局(56 字节)。 */ enum : uint8_t { 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(正常 32) */ + ECAT_MOT_OFF_DLC = 3U, /**< 最后一帧 STATUS 的 DLC(正常 48) */ ECAT_MOT_OFF_SEQ = 4U, /**< STATUS 刷新序号,小端 uint32 */ - ECAT_MOT_OFF_STATUS = 8U, /**< 原始 STATUS payload,32 字节 */ + ECAT_MOT_OFF_STATUS = 8U, /**< 原始 STATUS payload,48 字节(v0.74:B32-35=InitStatus) */ }; /** 输入 PDO 段 3(320..359):最近一帧**完整**快照(版本/CONFIG 帧要 24B)。 */ @@ -147,9 +147,31 @@ enum : uint16_t { ECAT_DIAG_OFF_LOAD = 484U, /**< 负载率,小端 uint16,单位 0.1%(1000=100%)。 中断 + 非延迟处理段(任务派发/OTA relay)占墙钟比, 不含常开轮询与空转;1s 滚动窗口(见 sys/cpu_load.h) */ - ECAT_DIAG_OFF_RSVD = 486U, /**< 保留 2B */ - ECAT_DIAG_OFF_SEQ = 488U, /**< 诊断区序号,小端 uint32,每完成一个统计窗口 +1, - 主站据此判断负载值是否仍在滚动刷新 */ + ECAT_DIAG_OFF_TORN = 486U, /**< CAN 取帧撕裂丢帧数(低 16 位,小端,饱和不回绕)。 + 1.6.6 起:ISR 取帧时若 MP 的 TS/DLENC 前后不一致, + 说明邮箱窗口在取数途中被硬件换帧,该帧整帧丢弃并 + 在此计数。这是"读到半条帧"的唯一可观测量 —— 主站 + 据此判断修复是否生效、总线负载是否高到频繁触发。 */ + ECAT_DIAG_OFF_SEQ = 488U, /**< 诊断区序号,小端 uint16(1.6.12 起,原 u32 让出 + 高 2 字节),每完成一个 1s 统计窗口 +1,主站据此 + 判断负载值是否仍在滚动刷新。u16 足够:主站只判 + "非 0 且在涨",18h 才回绕。 */ + ECAT_DIAG_OFF_OVERFULL = 490U, /**< CAN RX FIFO 满溢计数 RFO0(u16 饱和,1.6.12)。 + 1.6.11 把溢出策略改成"满则丢新帧"后拼接并未消失 + (段5@494 以 ~384/s 增长),必须直接观测溢出本身 + 与拼接率是否同涨,才能判定残余拼接是否仍源自溢出 + (即本 IP 在 DISCARD 模式下照样毁窗口)。 */ + ECAT_DIAG_OFF_RELTIMEOUT = 492U, /**< 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)。 + 入队前结构自洽校验(STATUS 温度量程+斜率、CONFIG + 未使用区/模式)命中即计数。1.6.12 起**只计不丢** + —— 数据管道必须诚实,该计数退化为"源头是否还在 + 产生拼接帧"的纯测量仪表:修好 = 恒 0,没修好 = + 网页看到什么,计数就是什么的倍率。 */ }; /** 单帧数据在输出 PDO / 长帧快照区中的最大字节数(电机 CMD 是 DLC 32)。 */ @@ -159,10 +181,14 @@ constexpr uint8_t kEcatCanRxDataMax = 8U; /** 段 3 长快照数据区字节数。 */ constexpr uint8_t kEcatCanXRxDataMax = 32U; -/** 单个电机状态槽字节数。 */ -constexpr uint8_t kEcatMotSlotSize = 40U; -/** 一份输入 PDO 携带的电机状态槽数。 */ -constexpr uint8_t kEcatMotSlotsPerPdo = 7U; +/** 单个电机状态槽字节数: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; /** 过程数据长度,唯一真源见 comm/ethercat_pdo_size.h(C/C++ 共用)。 */ constexpr uint16_t kEcatPdoOutBytes = ECAT_PDO_OUT_BYTES; /* 48×64bit = 384B */ @@ -181,8 +207,10 @@ constexpr uint16_t kEcatTxBatchBytes = (uint16_t)(kEcatPdoOutBytes - ECAT_TXB_OF constexpr uint8_t kEcatTxBatchMax = (uint8_t)(kEcatTxBatchBytes / (ECAT_FRM_HDR_SIZE + kEcatCanFdDataMax)); -/** 批量接收区可用字节数与最坏情况(全 DLC32 截断满帧)帧数上限。 */ -constexpr uint16_t kEcatRxBatchBytes = (uint16_t)(kEcatPdoInBytes - ECAT_RXB_OFF_ITEMS); +/** 批量接收区可用字节数与最坏情况(全 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)); diff --git a/GD32E508_DCU_APP/comm/ethercat_pdo.cpp b/GD32E508_DCU_APP/comm/ethercat_pdo.cpp index ba09cf8..24a2883 100644 --- a/GD32E508_DCU_APP/comm/ethercat_pdo.cpp +++ b/GD32E508_DCU_APP/comm/ethercat_pdo.cpp @@ -4,15 +4,20 @@ */ #include "comm/ethercat_pdo.h" #include "comm/ethercat_can_bridge.h" +#include "device/canfd.h" #include "sys/cpu_load.h" #include uint8_t EthercatPdo::s_out[EthercatPdo::kPdoOutBytes] = {0}; uint8_t EthercatPdo::s_in[EthercatPdo::kPdoInBytes] = {0}; +/* 输入 PDO 的工作缓冲:buildInput 填这里,填完整块发布到 s_in(见 process())。 + * 读侧 writeInputs 只读 s_in,因此永远看不到"填到一半"的镜像。 */ +uint8_t EthercatPdo::s_build[EthercatPdo::kPdoInBytes] = {0}; volatile uint32_t EthercatPdo::s_outputCycles = 0U; volatile uint32_t EthercatPdo::s_inputCycles = 0U; +volatile uint32_t EthercatPdo::s_reentryCount = 0U; void EthercatPdo::readOutputs(const uint8_t *esc) { @@ -30,15 +35,45 @@ bool EthercatPdo::outputProbe(const uint8_t *head) void EthercatPdo::process() { + /* 重入防护(1.6.7):SSC 的 ECAT_Application()→本函数 在**四个上下文**都会被 + * 调用 —— PDI_Isr / Sync0_Isr / Sync1_Isr / MainLoop(见 ssc/src/ecatappl.c + * 388、499、527、641 行)。而 MainLoop 那条路只用 DISABLE_ESC_INT() 关了 + * EXTI1(ESC IRQ),**挡不住 EXTI0(Sync0,preempt=0) 和 EXTI2(Sync1,preempt=1)**。 + * + * 竞态:MainLoop 把 s_in 填到一半(如刚写完批量区第 1 条)时被 Sync0/Sync1 + * 抢占 → 内层重入本函数,memset 清空后重填整个 496B,随后 PDO_InputMapping() + * 把这一份发给 ESC;外层回来又按自己那轮的进度接着往下写。于是发出去的镜像里 + * 批量区相邻两条来自**不同轮次**,而两轮之间环形队列 tail 已推进 —— 第 2 条 + * 就带上了另一条帧的内容。实测特征与此吻合:批量区第 1 条几乎不坏(0.06%)、 + * 第 2 条 30.75% 坏且内容是一条完整 CONFIG 帧;段 1/段 3 干净,因为它们每轮 + * 内容相同,被覆盖也看不出来。 + * + * 处理:已在填充中就直接返回。输入 PDO 是周期量,跳过一次下一拍即补, + * 远好过发出一份跨轮次拼接的镜像。 */ + static volatile bool s_building = false; + if (s_building) { + ++s_reentryCount; + return; + } + s_building = true; + /* PDO ↔ CAN 转发:输出 PDO 的 seq 字节变化沿触发一次 CAN 发送, * 输入 PDO 回填桥状态 + 电机状态窗口(布局见 ethercat_can_bridge.h)。 * * 顺序必须是先 processOutput 后 buildInput:buildInput 会把本周期刚执行的 * seq 回显成 ack,主站因此能在下一周期立刻确认送达;反过来则 ack 恒滞后一拍。 - * processOutput 还负责取走主站请求的状态窗口下标,也必须在 buildInput 之前。 */ + * processOutput 还负责取走主站请求的状态窗口下标,也必须在 buildInput 之前。 + * + * 填的是**工作缓冲** s_build,填完整体发布到 s_in:只有重入门还不够 —— + * 内层被门挡住后,它随后的 PDO_InputMapping() 仍会去读 s_in,若 buildInput + * 直接写 s_in,读到的就是外层写到一半的半成品。填在别处、整块发布之后, + * 读侧要么拿到上一份完整镜像、要么拿到这一份完整镜像,不存在中间态。 */ EthercatCanBridge::processOutput(s_out); - EthercatCanBridge::buildInput(s_in); - fillDiagTail(s_in); + EthercatCanBridge::buildInput(s_build); + fillDiagTail(s_build); + memcpy(s_in, s_build, kPdoInBytes); + + s_building = false; } /** @@ -53,11 +88,41 @@ void EthercatPdo::fillDiagTail(uint8_t *in) in[ECAT_DIAG_OFF_LOAD] = (uint8_t)(load & 0xFFU); in[ECAT_DIAG_OFF_LOAD + 1] = (uint8_t)(load >> 8); + /* CAN 取帧撕裂丢帧数(1.6.6)。只有 16 位,饱和不回绕 —— 主站看的是 + * "有没有涨、涨多快",回绕会被误读成计数清零。 */ + const CanFd::Diag *cd = CanFd::diag(); + const uint32_t torn = (cd != nullptr) ? cd->rx_torn_count : 0U; + const uint16_t torn16 = (torn > 0xFFFFU) ? 0xFFFFU : (uint16_t)torn; + in[ECAT_DIAG_OFF_TORN] = (uint8_t)(torn16 & 0xFFU); + in[ECAT_DIAG_OFF_TORN + 1] = (uint8_t)(torn16 >> 8); + const uint32_t seq = CpuLoad::windowSeq(); - in[ECAT_DIAG_OFF_SEQ] = (uint8_t)(seq & 0xFFU); - in[ECAT_DIAG_OFF_SEQ + 1] = (uint8_t)((seq >> 8) & 0xFFU); - in[ECAT_DIAG_OFF_SEQ + 2] = (uint8_t)((seq >> 16) & 0xFFU); - in[ECAT_DIAG_OFF_SEQ + 3] = (uint8_t)((seq >> 24) & 0xFFU); + const uint16_t seq16 = (uint16_t)seq; /* 1.6.12:u32 拆成 u16,高 2 字节让给 + overfull 计数。主站只判"非 0 且在涨", + 按 u32 读到 seq|overfull<<16 同样成立。 */ + in[ECAT_DIAG_OFF_SEQ] = (uint8_t)(seq16 & 0xFFU); + in[ECAT_DIAG_OFF_SEQ + 1] = (uint8_t)(seq16 >> 8); + + /* CAN RX FIFO 满溢计数 RFO0(1.6.12 测量):1.6.11 改"满则丢新"后拼接未消失, + * 本计数与 reject@494 是否同涨,是"残余拼接是否仍源自溢出"的判决性观测。 */ + const uint32_t of = (cd != nullptr) ? cd->rx_fifo_overfull_count : 0U; + const uint16_t of16 = (of > 0xFFFFU) ? 0xFFFFU : (uint16_t)of; + in[ECAT_DIAG_OFF_OVERFULL] = (uint8_t)(of16 & 0xFFU); + in[ECAT_DIAG_OFF_OVERFULL + 1] = (uint8_t)(of16 >> 8); + + /* RFD0 释放握手超时计数(1.6.12,占用原 1.6.7 重入计数位 492——该计数引入 + * 至今恒 0,转为纯内部)。dequeue 握手没完成 = 窗口/读指针失同步前兆。 */ + const uint32_t rt = (cd != nullptr) ? cd->rx_release_timeout_count : 0U; + const uint16_t rt16 = (rt > 0xFFFFU) ? 0xFFFFU : (uint16_t)rt; + in[ECAT_DIAG_OFF_RELTIMEOUT] = (uint8_t)(rt16 & 0xFFU); + in[ECAT_DIAG_OFF_RELTIMEOUT + 1] = (uint8_t)(rt16 >> 8); + + /* 内容级拼接帧拦截次数(1.6.11):MI/MP 校验挡不住的指针错位拼接由入队前 + * 结构自洽校验兜底,语义同上 —— 主站看"涨不涨"判断拼接是否仍在发生。 */ + const uint32_t rj = (cd != nullptr) ? cd->rx_content_reject_count : 0U; + const uint16_t rj16 = (rj > 0xFFFFU) ? 0xFFFFU : (uint16_t)rj; + in[ECAT_DIAG_OFF_REJECT] = (uint8_t)(rj16 & 0xFFU); + in[ECAT_DIAG_OFF_REJECT + 1] = (uint8_t)(rj16 >> 8); } void EthercatPdo::writeInputs(uint8_t *esc) diff --git a/GD32E508_DCU_APP/comm/ethercat_pdo.h b/GD32E508_DCU_APP/comm/ethercat_pdo.h index 64bf8bf..75dcc57 100644 --- a/GD32E508_DCU_APP/comm/ethercat_pdo.h +++ b/GD32E508_DCU_APP/comm/ethercat_pdo.h @@ -75,6 +75,10 @@ public: /** @brief 累计交回输入的周期数。 */ static uint32_t inputCycles() { return s_inputCycles; } + /** @brief process() 被重入拦下的次数。持续增长 = 跨上下文抢占确实在发生 + * (PDI_Isr / Sync0_Isr / Sync1_Isr / MainLoop 互相打断),也就证明了 + * 批量区跨轮次拼接的成因;恒为 0 则说明该机制推断有误。 */ + static uint32_t reentryCount() { return s_reentryCount; } /** @brief 是否已开始过程数据交换(收到过至少一个输出周期)。 */ static bool isExchanging() { return s_outputCycles != 0U; } @@ -96,8 +100,14 @@ private: /** @brief 最近一份主站下发的输出数据。 */ static uint8_t s_out[kPdoOutBytes]; - /** @brief 待交回主站的输入数据。 */ + /** @brief 待交回主站的输入数据(已发布的完整镜像,writeInputs 只读它)。 */ 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]; + /** @brief process() 被重入拦下的次数(诊断用,见 reentryCount())。 */ + static volatile uint32_t s_reentryCount; static volatile uint32_t s_outputCycles; static volatile uint32_t s_inputCycles; diff --git a/GD32E508_DCU_APP/comm/ethercat_sync.cpp b/GD32E508_DCU_APP/comm/ethercat_sync.cpp index 353640a..63591e8 100644 --- a/GD32E508_DCU_APP/comm/ethercat_sync.cpp +++ b/GD32E508_DCU_APP/comm/ethercat_sync.cpp @@ -130,6 +130,9 @@ void EthercatSync::onEscIrqExti() * 代价是 PDI_Isr 剩余时长(µs 级)计入 SYNC0 抖动。 */ const uint32_t prevSync0 = NVIC_GetEnableIRQ(CONFIG_ESC_SYNC0_IRQn); NVIC_DisableIRQ(CONFIG_ESC_SYNC0_IRQn); + /* 1.6.12 曾在此预抽 CAN FIFO(pumpAllChannels),实测溢出大降但拼接照旧 + * —— 病理在 357µs 阻塞窗口本身,1.6.13 直接把 CAN RX 提到 preempt=0 + * 根治(见 sys/config.h),预抽失去意义且引入 PDI/ISR 双消费者风险,撤除。 */ PDI_Isr(); if (prevSync0 != 0U) { NVIC_EnableIRQ(CONFIG_ESC_SYNC0_IRQn); diff --git a/GD32E508_DCU_APP/comm/ssc/port/__pycache__/gen_eeprom.cpython-312.pyc b/GD32E508_DCU_APP/comm/ssc/port/__pycache__/gen_eeprom.cpython-312.pyc index 4d0275fa32c5a80bff77ad33d2ea361025f89466..496d8576fa38cff717f75f80c2b16cb6023a599b 100644 GIT binary patch delta 98 zcmV-o0GvtTEh1OZr+`zw+GS+kBTNdo~0v)C_j E2y)sXzW@LL diff --git a/GD32E508_DCU_APP/device/CANFD.cpp b/GD32E508_DCU_APP/device/CANFD.cpp index 81f4736..3023243 100644 --- a/GD32E508_DCU_APP/device/CANFD.cpp +++ b/GD32E508_DCU_APP/device/CANFD.cpp @@ -19,6 +19,107 @@ static inline void spinDelay(uint32_t loops) { for (volatile uint32_t d = 0; d < loops; ++d) { } } +/* DLENC>8 的长度映射,与厂商库 g_can_fdlength_table 一致 */ +constexpr uint8_t kFdLenTable[7] = { 12U, 16U, 20U, 24U, 32U, 48U, 64U }; + +/** 释放 FIFO0 邮箱并等待 RFD0 由硬件自动清 0(手册 26.3.4 第 3 步)。 + * + * 两处与厂商库 can_message_receive() 的差别,都是它漏掉的: + * 1) 用**直接赋值**而不是 `|=`。CAN_RFIFO0 里 RFF0(bit3)/RFO0(bit4) 是 rc_w1 + * (写 1 清除),读-改-写会把这期间硬件刚置起来的"FIFO 满/溢出"标志顺手 + * 清掉 —— 溢出证据就是这么丢的。写 0 对 rc_w1 位无副作用。 + * 2) 置位后**等它自清**。手册原文:"将 RFD 置 1 释放邮箱,并且等待其由硬件 + * 自动清 0"。不等就接着读下一条,会撞上 dequeue 尚未完成的邮箱窗口。 + * @return true=已确认释放完成;false=超时(异常,计数后照常继续,不死等)。 */ +static inline bool releaseFifo0(uint32_t canPeriph) +{ + CAN_RFIFO0(canPeriph) = CAN_RFIFO0_RFD0; + for (uint32_t n = 0U; n < 2000U; ++n) { + if ((CAN_RFIFO0(canPeriph) & CAN_RFIFO0_RFD0) == 0U) { + /* 1.6.11 注:这里曾加过 128/2048 拍的 settle 延迟,假设"自清后 MDATA + * 还在搬运"是装载时间竞态 —— 实测无效(正弦指令负载下拼接率不变)。 + * 节点指纹实验证明拼接是 FIFO 缓冲指针与帧边界失同步(node3 的 + * STATUS 帧头 24B = node2 的完整 CONFIG,错位恰为 CONFIG 帧长 6 字), + * 不是延迟能挡的;结构矛盾的帧由 handleRxInterrupt 的内容校验计数 + * (1.6.12 起只计不丢 —— 管道必须诚实;源头修复见 1.6.13 的 + * CONFIG_IRQ_PREEMPT_CAN_RX=0)。 */ + return true; + } + } + return false; +} + +/** 取一帧 FIFO0 报文,并校验取数期间硬件没有换帧。 + * + * 为什么自己实现(不用厂商库 can_message_receive):一条 CANFD 报文要对同一个 + * 地址 CAN_RFIFOMDATA0 连读 dlen/4 次才取完(48B = 12 次),加上头部两次,一条 + * 帧就是十几次有先后依赖的总线访问,整个过程**不是原子的**。若这期间硬件把下 + * 一帧搬进邮箱窗口,就会读出"前几个字属于上一帧、后几个字属于这一帧"的拼接 + * 体,而 id/dlc 取自最开始那次读 —— 实测表现为 STATUS 帧的 ID 配上 CONFIG 帧 + * 的前 12~24 字节,且混合边界 100% 落在 4 字节边界上。 + * + * 判据用 MP 寄存器:TS[31:16] 是硬件接收时间戳、DLENC[3:0] 是长度,同一帧内两 + * 者恒定。取数前后各读一次,只要有一个变了就说明窗口内容已不是原来那帧,整帧 + * 作废(计入 rx_torn_count)—— 宁可丢一帧,也不把拼接数据递上去。 + * + * @return true=取到一帧完整报文;false=本帧判为撕裂、应丢弃(邮箱仍需释放)。 */ +static bool receiveFrameChecked(uint32_t canPeriph, can_receive_message_struct *msg) +{ + /* 整段取数放进 BASEPRI 临界区:屏蔽 preempt>=1,把 EtherCAT 的 ESC(1)/SYNC1(1) + * 挡在外面。取数是十几次有先后依赖的寄存器读,被抢占就会读出跨帧拼接体。 + * 1.6.13 起本 ISR 自身就是 preempt=0(CONFIG_IRQ_PREEMPT_CAN_RX),与 SYNC0 + * 同级互不抢占,取数自此真正原子;MI/MP 回读校验保留,作为对硬件自发换帧 + * (dequeue 异步完成等)的兜底观测 —— rx_torn 计数即"该病理是否还在发生"。 */ + const uint32_t basepri = RT_CRITICAL_SAVE(); + + const uint32_t mi0 = CAN_RFIFOMI(canPeriph, CAN_FIFO0); + const uint32_t mp0 = CAN_RFIFOMP(canPeriph, CAN_FIFO0); + + msg->rx_ff = static_cast(CAN_RFIFOMI_FF & mi0); + if (CAN_FF_STANDARD == msg->rx_ff) { + msg->rx_sfid = static_cast(GET_RFIFOMI_SFID(mi0)); + } else { + msg->rx_efid = static_cast(GET_RFIFOMI_EFID(mi0)); + } + msg->rx_ft = static_cast(CAN_RFIFOMI_FT & mi0); + msg->rx_fi = static_cast(GET_RFIFOMP_FI(mp0)); + msg->fd_flag = static_cast((CAN_RFIFOMP_FDF & mp0) >> 7); + msg->fd_brs = static_cast((mp0 & CAN_RFIFOMP_BRS) >> 5); + msg->fd_esi = static_cast((mp0 & CAN_RFIFOMP_ESI) >> 4); + + const uint8_t dlenc = static_cast(GET_RFIFOMP_DLENC(mp0)); + const uint8_t dlen = (dlenc <= 8U) ? dlenc + : ((dlenc <= 15U) ? kFdLenTable[dlenc - 9U] : 64U); + msg->rx_dlen = dlen; + + /* 按字取数:与厂商库同样是对同一地址重复读(硬件读指针自动前进)。 + * 5~7 字节要多读一个字才取全,与厂商库口径一致。 */ + uint32_t words = static_cast(dlen) / 4U; + if ((1U == words) && (4U != dlen)) { + words++; + } + if (0U == words) { + words = 1U; /* dlen<4 也要读走一个字 */ + } + for (uint32_t i = 0U; i < words; ++i) { + const uint32_t w = CAN_RFIFOMDATA0(canPeriph, CAN_FIFO0); + std::memcpy(&msg->rx_data[i * 4U], &w, sizeof(w)); + } + + /* 取数后回读 MI+MP:任一变化 ⇒ 邮箱窗口在取数期间被换帧,这一帧是拼接体。 + * 必须连 MI(完整 ID)一起比 —— 只比 MP 的 TS/DLENC 会漏:TS 是 16 位计数器, + * 相邻两帧可能同值,DLENC 也常常相同(都是 48),于是"ID 报 STATUS、数据却是 + * CONFIG"这种错配能从校验里溜过去(实测占坏帧的绝大多数)。ID 变了就一定是 + * 换了帧,这条判据没有巧合空间。 */ + const uint32_t keep = CAN_RFIFOMP_TS | CAN_RFIFOMP_DLENC; + const uint32_t mp1 = CAN_RFIFOMP(canPeriph, CAN_FIFO0); + const uint32_t mi1 = CAN_RFIFOMI(canPeriph, CAN_FIFO0); + const bool intact = ((mp1 & keep) == (mp0 & keep)) && (mi1 == mi0); + + RT_CRITICAL_RESTORE(basepri); + return intact; +} + struct CanChannelConfig { uint32_t canPeriph; rcu_periph_enum canRcu; @@ -166,6 +267,7 @@ CanFd::ChannelFeedback CanFd::s_feedback[3]{}; uint32_t CanFd::s_feedbackSeq = 0U; CanFd::IdSnapshotTable CanFd::s_idSnapshot[3]{}; bool CanFd::s_channelHwOk[3]{}; +bool CanFd::s_brsSuppress[3]{}; /* TX 错误事件标志(ARD=1 单发丢帧通知):TX 完成 ISR 置位,主循环 * consumeTxError() 读清;单写单读,无锁。 */ @@ -274,7 +376,16 @@ bool CanFd::initNominalTiming(uint32_t canPeriph) can_parameter.auto_bus_off_recovery = ENABLE; can_parameter.auto_wake_up = DISABLE; can_parameter.auto_retrans = ENABLE; /* 默认自动重发兜底偶发 ACK 丢失;OTA 停等协议期间由 setChannelAutoRetrans(ch,false) 临时关闭,避免重发打穿电机单缓冲 RX */ - can_parameter.rec_fifo_overwrite = ENABLE; + /* 1.6.11 真根因:这里曾是 ENABLE —— FIFO 满时**覆盖最旧邮箱**,而最旧邮箱 + * 正是 CPU 正在读取的输出窗口。buildInput 在 PDI_Isr(preempt1) 占 ~357µs + * 会压住 CAN ISR(preempt2),发指令时电机 CONFIG 翻倍到 2kHz/节点、总线 + * ~8kHz,阻塞窗口内到达 ~3 帧恰好打满 FIFO 深度 → 覆盖进行到一半被读走 + * = 跨节点/跨类型拼接帧(指纹实验实锤:node2 的 CONFIG 条目带 node3 的 + * 速度限 150 与反相角度;settle 延迟/重取/MI-MP 校验都挡不住,因为 + * MI/MP 是覆盖序列里最后更新的一环)。DISABLE = 溢出丢新帧并置 RFO0 + * (rx_fifo_overfull_count 已在统计):STATUS/CONFIG 均周期量,丢一拍下拍 + * 即补;覆盖递上来的才是网页乱码。与本文件软件队列"满丢新不覆旧"同则。 */ + can_parameter.rec_fifo_overwrite = DISABLE; can_parameter.trans_fifo_order = ENABLE; can_parameter.working_mode = CAN_NORMAL_MODE; can_parameter.resync_jump_width = CONFIG_CAN_NOMINAL_SJW; @@ -422,7 +533,8 @@ void CanFd::initChannelHardware(uint8_t i) CAN_TSTAT(ch.canPeriph) = CAN_TSTAT_MTF0 | CAN_TSTAT_MTF1 | CAN_TSTAT_MTF2; can_interrupt_enable(ch.canPeriph, CAN_INT_TME | CAN_INT_RFNE0 | CAN_INT_RFF0 | CAN_INT_RFO0); nvic_irq_enable(ch.txIrq, CONFIG_IRQ_PREEMPT_CAN, static_cast(i)); - nvic_irq_enable(ch.rx0Irq, CONFIG_IRQ_PREEMPT_CAN, static_cast(i)); + /* 1.6.13:RX 提到 preempt=0 —— 见 sys/config.h CONFIG_IRQ_PREEMPT_CAN_RX 注释。 */ + nvic_irq_enable(ch.rx0Irq, CONFIG_IRQ_PREEMPT_CAN_RX, static_cast(i)); s_rxQueues[i].head = 0U; s_rxQueues[i].tail = 0U; @@ -987,7 +1099,9 @@ CanFd::CanTxResult CanFd::transmitFrame(uint8_t channelIndex, uint32_t ext_id, #ifdef GD32E508 tx_msg.fd_flag = (dlc > 8U) ? ENABLE : DISABLE; #if (CONFIG_CAN_FD_BRS_ENABLE != 0) - tx_msg.fd_brs = (dlc > 8U) ? ENABLE : DISABLE; + /* 电机 BOOT 收不了 BRS 数据段,OTA 期间 relay 对该通道压制(setChannelBrsSuppress), + * DLC>8 帧回仲裁段速率(FD-no-BRS,OTA 长期验证过的组合)。 */ + tx_msg.fd_brs = (dlc > 8U && !s_brsSuppress[channelIndex]) ? ENABLE : DISABLE; #else tx_msg.fd_brs = DISABLE; #endif @@ -1182,6 +1296,17 @@ void CanFd::setChannelAutoRetrans(uint8_t channelIndex, bool enable) (CAN_CTL(canPeriph) & CAN_CTL_ARD) ? 1U : 0U); } +void CanFd::setChannelBrsSuppress(uint8_t channelIndex, bool suppress) +{ + if (channelIndex >= kChannelCount || !kChannelEnabled[channelIndex]) { + return; + } + /* 仅置运行时标志,打点在 transmitFrame:BRS 是逐帧属性(fd_brs 位),无需动寄存器。 + * 写方(relay enterState 钩子)与读方(主循环/CS 中断的 transmitFrame)间 bool 单写单读,原子。 */ + s_brsSuppress[channelIndex] = suppress; + CAN_LOGI("[CAN] CH%u set brs_suppress=%u\r\n", (unsigned)channelIndex, (unsigned)suppress); +} + uint32_t CanFd::readChannelErrorReg(uint8_t channelIndex) { if (channelIndex >= kChannelCount || !kChannelEnabled[channelIndex]) { @@ -1292,7 +1417,113 @@ void CanFd::handleRxInterrupt(uint8_t channelIndex) can_receive_message_struct rx_message{}; can_struct_para_init(CAN_RX_MESSAGE_STRUCT, &rx_message); - can_message_receive(ch.canPeriph, CAN_FIFO0, &rx_message); + /* 自己取帧并校验(见 receiveFrameChecked):厂商库不校验也不等 RFD 自清, + * 会把"半条上一帧 + 半条本帧"的拼接体当正常帧交上来。 + * + * 1.6.10:**校验失败只丢不重取**。1.6.7 起这里是"原地重取最多 3 次", + * 实测被证伪为撕裂的直接制造者:RFIFOMDATA0 是"重复读同一地址、硬件读 + * 指针自动前进"(厂商驱动证实),attempt1 失败时读指针已随取数前进若干 + * 字,重取的 attempt2 从错位指针开读 —— MI/MP 回读比对的是"窗口身份", + * 管不住"指针相对窗口起点的错位",于是校验通过、内容却是"上一帧的尾巴 + * 拼下一帧的开头"。自测实锤:重取版 1.6.9 在电机活跃时错标率 9.155% + * (56835/620838),而 rx_torn 全程恒 0 —— 拼接恰好都从校验里溜过去。 + * 丢帧无害:STATUS/CONFIG 都是 1kHz 周期量,下拍即补;递上去才会变乱码。 + * CAN ISR 是 preempt=2,EtherCAT 的 SYNC(0)/ESC(1)/SYNC1(1) 都能抢占它, + * 这正是取数被打断的来源,也解释了为什么只有 EtherCAT 链路上看得到拼接、 + * WiFi 链路干净。 */ + const bool intact = receiveFrameChecked(ch.canPeriph, &rx_message); + if (!releaseFifo0(ch.canPeriph)) { + s_diag.rx_release_timeout_count++; + } + if (!intact) { + /* 校验判为拼接:整帧作废,不计 rx_count、不入队、不转发。 + * 本版起 rx_torn_count 恢复真实语义 —— 它恒 0 说明校验没在挡东西。 */ + s_diag.rx_torn_count++; + continue; + } + + /* 本帧的 ID 用局部量,不要读 s_diag.last_rx_id ——三路通道共用那个全局, + * 嵌套/交错时会串出"A 通道的 id 配 B 通道的数据"。 */ + const uint32_t rxId = (rx_message.rx_ff == CAN_FF_STANDARD) + ? rx_message.rx_sfid : rx_message.rx_efid; + + /* ── 1.6.11:内容级拼接帧拦截 ── + * MI/MP 回读校验管不住 FIFO 缓冲指针与帧边界的失同步(1.6.10 的 + * settle 延迟实测无效,指纹实验证明 node3 的 STATUS 帧头 24B 可以是 + * node2 的完整 CONFIG —— 错位恰为 CONFIG 帧长 6 字,与混合帧长交错 + * 相关,是 CAN IP 的指针病理,软件无法从取数时序上根除)。 + * 改为在入队前做结构自洽校验:拼接帧的 (id, data) 必矛盾,而真帧必自洽。 + * STATUS(0x000N0100, dlc48):CONFIG 头灌入必然把 MCU/Motor 温度推出 + * [-40,125]℃ 物理量程(CONFIG B0-1 = 03+限压低字节 → 123~250℃ 或 + * 负值),母线电压同步失序; + * CONFIG(0x000N0101, dlc24):DBC v0.74 规定 B15-23 未使用恒 0, + * STATUS 体灌入几乎必非 0;模式字节限 0..7(电机模式表最大 7)。 + * 命中即计数(1.6.12 起不再丢帧——管道必须诚实,计数仅做"源头是否 + * 还在产拼接帧"的测量):源头修复生效后本计数应恒 0,非 0 即失效告警。 + * 计数经段5@494 上报。 + * 残余风险:限压字节恰使温度落回量程内的理论组合(24V 台架实测限压 + * ≥15V → 对应"温度"≥153℃,仍在量程外),可接受。 */ + { + const uint16_t idLo = (uint16_t)(rxId & 0xFFFFU); + bool reject = false; + /* 上一帧已接受的温度/电压(按节点):拼接帧的 CONFIG 字节可能恰好落进 + * 静态量程(实测 vel=150 指纹时 0301→2.59℃、5a00→0.9℃、cace→52.9V + * 全部"合法"漏过),但绝不可能在 1ms 帧间隔里物理地跳过去 —— 斜率判据 + * 是静态量程补不上的洞。阈值留 3 个数量级余量:温度 20℃/帧(实际 + * ≤0.005℃),电压 5V/帧(实际 ≤0.05V)。 */ + static int16_t s_lastMcu[16]; + static int16_t s_lastMot[16]; + static uint16_t s_lastPwr[16]; + static uint8_t s_lastOk[16]; + if (idLo == 0x0100U && rx_message.rx_dlen == 48U) { + const int16_t mcu = (int16_t)((uint16_t)rx_message.rx_data[0] | + ((uint16_t)rx_message.rx_data[1] << 8)); + const int16_t mot = (int16_t)((uint16_t)rx_message.rx_data[2] | + ((uint16_t)rx_message.rx_data[3] << 8)); + const uint16_t pwr = (uint16_t)rx_message.rx_data[11] | + ((uint16_t)rx_message.rx_data[12] << 8); + if (mcu < -4000 || mcu > 12500 || mot < -4000 || mot > 12500 || + pwr < 8000 || pwr > 60000) { + reject = true; + } + const uint8_t nd = (uint8_t)((rxId >> 16) & 0x0FU); + if (!reject && nd >= 1U && nd < 16U && (s_lastOk[nd] & 1U)) { + int16_t dmcu = (int16_t)(mcu - s_lastMcu[nd]); + int16_t dmot = (int16_t)(mot - s_lastMot[nd]); + if (dmcu < 0) { dmcu = (int16_t)-dmcu; } + if (dmot < 0) { dmot = (int16_t)-dmot; } + /* 只比温度斜率:拼接帧的 CONFIG 头必然把 mcu/mot 推出 20℃/帧 + * (实测最小跳变 35℃,而真实温度 ≤0.005℃/帧,4 个数量级余量)。 + * 电压不比斜率 —— 正弦换向/堵转时母线真实跌落可超 5V/帧, + * 误杀会让显示冻结;拼接的电压特征(≈48V)已由量程+温度斜率兜住。 */ + if (dmcu > 2000 || dmot > 2000) { + reject = true; + } + } + if (!reject && nd >= 1U && nd < 16U) { + s_lastMcu[nd] = mcu; + s_lastMot[nd] = mot; + s_lastPwr[nd] = pwr; + s_lastOk[nd] = 1U; + } + } else if (idLo == 0x0101U && rx_message.rx_dlen == 24U) { + if (rx_message.rx_data[0] > 7U) { + reject = true; + } + for (uint8_t i = 15U; !reject && i < 24U; ++i) { + if (rx_message.rx_data[i] != 0U) { + reject = true; + } + } + } + if (reject) { + /* 1.6.12:只计不丢 —— 数据管道必须诚实(用户裁定:过滤是遮羞布, + * 修的是采集源头)。网页/ESP32/主站看到的就是源头产出的原始字节, + * 本计数退化为纯测量仪表:源头修好 = 恒 0;还在涨 = 源头问题仍在, + * 且网页如实显示着,不存在"看着干净其实坏了"。 */ + s_diag.rx_content_reject_count++; + } + } s_diag.rx_count++; s_diag.last_rx_channel = channelIndex; @@ -1300,14 +1531,14 @@ void CanFd::handleRxInterrupt(uint8_t channelIndex) s_diag.last_rx_fd_flag = rx_message.fd_flag; s_diag.last_rx_brs = rx_message.fd_brs; s_diag.last_rx_esi = rx_message.fd_esi; - s_diag.last_rx_id = (rx_message.rx_ff == CAN_FF_STANDARD) ? rx_message.rx_sfid : rx_message.rx_efid; + s_diag.last_rx_id = rxId; /* CAN0 桥接模式:将刚收到的原帧透传到 CAN1 与 CAN2,保留 11/29 位、FD、BRS、ESI。 */ if (channelIndex == 0U && s_mode == Mode::kModeBridge0To12) { for (uint8_t bridgeCh = 1U; bridgeCh <= 2U; ++bridgeCh) { const uint32_t bridgePeriph = kChannels[bridgeCh].canPeriph; const bool ok = bridgeTransmitRaw(bridgePeriph, rx_message); if (ok) { - s_diag.last_tx_id = s_diag.last_rx_id; + s_diag.last_tx_id = rxId; s_diag.last_tx_channel = bridgeCh; s_diag.last_payload_length = rx_message.rx_dlen; } else { @@ -1320,7 +1551,7 @@ void CanFd::handleRxInterrupt(uint8_t channelIndex) /* 维护每通道独立计数与最近一帧快照,用于 1Hz 反馈给 ESP32。 */ auto &stats = s_channelStats[channelIndex]; stats.rx_count++; - stats.last_rx_id = s_diag.last_rx_id; + stats.last_rx_id = rxId; stats.last_rx_dlc = rx_message.rx_dlen; stats.last_rx_fd_flag = rx_message.fd_flag; stats.last_rx_brs = rx_message.fd_brs; @@ -1336,7 +1567,6 @@ void CanFd::handleRxInterrupt(uint8_t channelIndex) /* 维护每通道不重复 ID 快照表,用于 1Hz 分页拉取给 ESP32。 */ { auto &table = s_idSnapshot[channelIndex]; - const uint32_t rxId = s_diag.last_rx_id; /* 查找已有条目 */ uint8_t found = CANFD_ID_SNAPSHOT_MAX; for (uint8_t si = 0U; si < table.count; ++si) { @@ -1387,7 +1617,7 @@ void CanFd::handleRxInterrupt(uint8_t channelIndex) auto &frame = queue.frames[queue.tail]; frame.channel = channelIndex; - frame.id = s_diag.last_rx_id; + frame.id = rxId; frame.length = rx_message.rx_dlen; for(uint8_t i = 0; i < rx_message.rx_dlen; ++i) { frame.data[i] = rx_message.rx_data[i]; diff --git a/GD32E508_DCU_APP/device/canfd.h b/GD32E508_DCU_APP/device/canfd.h index 805898a..e15c204 100644 --- a/GD32E508_DCU_APP/device/canfd.h +++ b/GD32E508_DCU_APP/device/canfd.h @@ -53,6 +53,15 @@ public: uint32_t rx_drop_count; uint32_t rx_fifo_full_count; uint32_t rx_fifo_overfull_count; + /** 取帧期间被硬件换帧、整帧丢弃的次数(1.6.6,TS/DLC 前后不一致即判定)。 + * 这是"读到半条帧"的唯一可观测量,稳态下应当只在高负载时缓慢增长。 */ + uint32_t rx_torn_count; + /** 释放邮箱后等待 RFD 硬件自清超时的次数(手册 26.3.4 第 3 步要求等待)。 */ + uint32_t rx_release_timeout_count; + /** 内容级拼接帧拦截次数(1.6.11):(id, data) 结构自相矛盾(STATUS 的 + * 温度/电压出量程、CONFIG 的未使用区非 0)⇒ 整帧作废。与 rx_torn 互补: + * torn 挡"取数期间窗口被换",这个挡"窗口没换但 FIFO 缓冲指针错位"。 */ + uint32_t rx_content_reject_count; uint32_t tx_abort_count; uint32_t last_rx_id; uint32_t last_tx_id; @@ -173,6 +182,11 @@ public: /** @brief 运行时切换指定通道 CAN 自动重发(ARD 位,需 init-mode);停等式 OTA 期间关闭、结束后恢复。 */ static void setChannelAutoRetrans(uint8_t channelIndex, bool enable); + /** @brief 运行时压制指定通道 TX 帧的 BRS:压制中 DLC>8 的 FD 帧数据段回退仲裁段速率。 + * 电机 BOOT 无法正确接收 BRS 高速段(见 sys/config.h CONFIG_CAN_FD_BRS_ENABLE 注释), + * 电机 OTA relay 经 enterState 钩子与 setChannelAutoRetrans 同点切换,OTA 结束必恢复。 */ + static void setChannelBrsSuppress(uint8_t channelIndex, bool suppress); + /** @brief 读取指定通道 CAN_ERR 寄存器原始值(事件时刻只读快照,不依赖 1Hz 采样)。 * 解码:TEC=bit16-23,REC=bit24-31,ERRN=bit4-6;无效通道返回 0。仅供诊断日志,禁用于发送门控。 */ static uint32_t readChannelErrorReg(uint8_t channelIndex); @@ -294,6 +308,7 @@ private: static uint8_t s_nextConsumeChannel; static volatile Mode s_mode; static ChannelStats s_channelStats[3]; + static bool s_brsSuppress[3]; /**< TX BRS 压制(电机 OTA 期间该通道 DLC>8 帧禁 BRS) */ static ChannelFeedback s_feedback[3]; /** 各通道硬件 init(时序+退出 init mode)是否成功 */ static bool s_channelHwOk[3]; diff --git a/GD32E508_DCU_APP/device/motor_state.cpp b/GD32E508_DCU_APP/device/motor_state.cpp index 3c35019..f39df4e 100644 --- a/GD32E508_DCU_APP/device/motor_state.cpp +++ b/GD32E508_DCU_APP/device/motor_state.cpp @@ -2,8 +2,10 @@ * @file motor_state.cpp * @brief 多电机状态表实现 — CAN STATUS 帧识别、内存缓存、SPI 推送序列化。 * - * 所有函数仅在主循环上下文调用(taskCanRxParse / taskHeartbeat / pushStep), - * 无 ISR 竞态,不需要临界区保护。 + * 本模块的函数只在主循环上下文调用(taskCanRxParse / taskHeartbeat / pushStep), + * 但消费者不止主循环:EtherCAT 的 buildInput 跑在 PDI_Isr,会抢占 STATUS 写侧, + * 故 STATUS 的 seq/data 由 MotorSlot.wr_seq(seqlock)保护,见 onCanFrame。 + * SPI 侧的 buildPayload 与写侧同上下文,天然无此问题。 */ #include "device/motor_state.h" @@ -36,7 +38,7 @@ void onCanFrame(uint8_t channel, uint32_t id, const uint8_t *data, uint8_t len) const uint32_t masked = id & kMotorStatusIdMask; - /* STATUS 帧 0x000N0100 (DLC 32, 实时值) */ + /* STATUS 帧 0x000N0100 (DLC 48, 实时值,字节 32~35 = InitStatus) */ if (masked == kMotorStatusIdMatch) { const uint8_t node = static_cast((id >> 16) & 0x0FU); if (node < 1U || node > kMotorMaxNodes || channel >= kMotorChannels) { @@ -45,6 +47,14 @@ void onCanFrame(uint8_t channel, uint32_t id, const uint8_t *data, uint8_t len) MotorSlot &slot = s_motorTable.slots[(node - 1U) * kMotorChannels + channel]; + /* seqlock 写侧(1.6.5):奇数=本槽 seq/data 正在改。EtherCAT 的 buildInput + * 跑在 PDI_Isr,会抢占这里;抢占落在下面那次 memcpy 中途就会发布 + * "新 seq + 半新半旧 48B"——两帧字节拼成一个 STATUS,边界若落在 float 的 + * 符号/指数字节上就是网页上的离谱值。缺的不是顺序而是让消费者能认出 + * "这一段没写完"的标记。 */ + ++slot.wr_seq; /* → 奇数 */ + __DMB(); /* 普通写不得重排到奇/偶两次自增之外 */ + slot.node_id = node; slot.channel = channel; slot.online = 1U; @@ -57,6 +67,9 @@ void onCanFrame(uint8_t channel, uint32_t id, const uint8_t *data, uint8_t len) if (slot.dlc < kMotorStatusDlc) { std::memset(&slot.data[slot.dlc], 0, kMotorStatusDlc - slot.dlc); } + + __DMB(); + ++slot.wr_seq; /* → 偶数:本槽整体一致 */ return; } @@ -159,9 +172,10 @@ uint16_t buildPayload(uint8_t page, uint8_t *buf, uint16_t maxLen) writeU32Le(&buf[offset + 4U], slot.rx_count); writeU32Le(&buf[offset + 8U], slot.seq); std::memcpy(&buf[offset + 12U], slot.data, kMotorStatusDlc); - writeU32Le(&buf[offset + 44U], slot.config_rx_count); - writeU32Le(&buf[offset + 48U], slot.config_seq); - std::memcpy(&buf[offset + 52U], slot.config_data, kMotorConfigDlc); + /* STATUS DLC 48 后,CONFIG 字段整体后移 16B */ + writeU32Le(&buf[offset + 60U], slot.config_rx_count); + writeU32Le(&buf[offset + 64U], slot.config_seq); + std::memcpy(&buf[offset + 68U], slot.config_data, kMotorConfigDlc); offset += kMotorEntrySize; count++; diff --git a/GD32E508_DCU_APP/device/motor_state.h b/GD32E508_DCU_APP/device/motor_state.h index 3c2663a..3175ea6 100644 --- a/GD32E508_DCU_APP/device/motor_state.h +++ b/GD32E508_DCU_APP/device/motor_state.h @@ -32,7 +32,7 @@ static constexpr uint8_t kMotorMaxNodes = CONFIG_MOTOR_MAX_NODES; * 状态表按 node×channel 双键存储,各通道独立槽位互不覆盖。 */ static constexpr uint8_t kMotorChannels = 3U; static constexpr uint8_t kMotorSlotCount = kMotorMaxNodes * kMotorChannels; /* 9×3 = 27 */ -static constexpr uint8_t kMotorStatusDlc = 32U; +static constexpr uint8_t kMotorStatusDlc = 48U; /* v0.74:STATUS 帧 DLC 48(字节 32~35 = InitStatus) */ static constexpr uint8_t kMotorConfigDlc = 24U; /* CONFIG 帧 DLC */ static constexpr uint16_t kMotorOfflineSec = CONFIG_MOTOR_OFFLINE_MS / 1000U; @@ -50,18 +50,18 @@ static constexpr uint32_t kBootHeartbeatTimeoutSec = 3U; /* >3s 无 0x103 → static constexpr uint8_t kMotorActiveBootBit = 0x80U; /* 0xC5 page0 header active 字节 bit7 = boot_active */ /* ── 序列化常量 (0xC5 payload) ── */ -static constexpr uint8_t kMotorEntrySize = 76U; /* 1+1+1+1+4+4+32+4+4+24 (STATUS+CONFIG) */ +static constexpr uint8_t kMotorEntrySize = 92U; /* 1+1+1+1+4+4+48+4+4+24 (STATUS+CONFIG) */ static constexpr uint8_t kMotorPageHeaderLen = 4U; /* page+total+count+active */ -static constexpr uint8_t kMotorEntriesPerPage = 3U; /* (236-4)/76 = 3.05 → 3 */ +static constexpr uint8_t kMotorEntriesPerPage = 2U; /* (236-4)/92 = 2.52 → 2 */ static constexpr uint8_t kMotorTotalPages = - (kMotorSlotCount + kMotorEntriesPerPage - 1U) / kMotorEntriesPerPage; /* ceil(27/3) = 9 */ + (kMotorSlotCount + kMotorEntriesPerPage - 1U) / kMotorEntriesPerPage; /* ceil(27/2) = 14 */ /// 单电机状态槽 struct MotorSlot { uint8_t node_id; ///< 1-9, 0=空槽 uint8_t channel; ///< 最后收到帧的 CAN 通道 (0/1/2) uint8_t online; ///< 1=在线, 0=离线 - uint8_t dlc; ///< 最后帧 DLC (正常=32) + uint8_t dlc; ///< 最后帧 DLC (正常=48) uint32_t rx_count; ///< 累计收到 STATUS 帧数 uint32_t last_rx_sec; ///< 最后收到帧时的 g_app_uptime_sec uint32_t seq; ///< 更新序号 (每次刷新 +1) @@ -69,6 +69,11 @@ struct MotorSlot { uint32_t config_rx_count; ///< 累计收到 CONFIG 帧数 uint32_t config_seq; ///< CONFIG 更新序号 uint8_t config_data[kMotorConfigDlc]; ///< 原始 CONFIG payload (目标值回报) + /* STATUS 写侧 seqlock(1.6.5):奇数=正在写本槽的 seq/data。 + * 只有主循环(onCanFrame 的 STATUS 分支)自增,ISR 只读 —— 单核下消费者是 + * ISR,它执行期间生产者被冻结,故"读到偶数 ⇒ 生产者不在这段里"成立。 + * 放结构体末尾,已有字段偏移全不变(SPI 序列化/PDO 布局照旧)。 */ + volatile uint8_t wr_seq; }; /// 全局电机状态表 diff --git a/GD32E508_DCU_APP/sys/config.h b/GD32E508_DCU_APP/sys/config.h index 638a880..4c3e8cc 100644 --- a/GD32E508_DCU_APP/sys/config.h +++ b/GD32E508_DCU_APP/sys/config.h @@ -122,7 +122,17 @@ 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 = 1U; /* 1.6.1:输入 PDO 瘦身 640→496——批量接收条目数据 64→32B(真机 6086 帧 DLC 仅 24/32,超长帧截断打 flags bit1,环形队列仍存全量)、段5 诊断区 632→484;DWT 实测 640B PRAM 写 296µs/拍且与 SYNC0 相位无关(≈0.463µs/B 线性),预期写侧 −67µs/拍。五处一致(pdo_size/bridge/dc_app/gen_eeprom/ESI)已同步,需重烧固件+EEPROM 并断电冷启动,主站 decode_input 按 Ibytes 双布局自适应可任意先后升级。 */ +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。 */ + /* 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%、活跃连排才中招,与实测分布吻合。 */ + /* 1.6.9:拿到实证并定位真根因 —— 给主站加了同拍 raw+parsed 的只读诊断路由(/api/motor/rxraw)后抓到:**坏帧在原始 PDO 字节里就已经是错的**(如 off=72 处条目声称 id=0x20101/dlc=24,数据却是合法 STATUS:mcuTemp=35.92 motTemp=33.55 pwrV=23.972),而主站 parsed 与 raw 逐字节一致 —— 坏在从站写入侧,与主站解析无关。根因:批量接收环形队列长期饱和下,生产者覆写了消费者正在读的槽。实测证据:CAN 侧约 4000 帧/s 而批量区每拍最多 3 条(=3000 帧/s),净积压使 pending 恒 8/12、lost 几乎每拍都有、count 恒取满 3;消费者 fillRxBatch 又攒到循环末才发布 s_rxRingTail,生产者判满用的正是这个滞后值,于是把新帧写进消费者刚读过但未发布的槽,拷进 PDO 的 id/dlc/data 遂来自不同帧(错标率 1.77%)。两处修复:(1) onCanFrame 入队**过滤 STATUS 帧** —— 完整 STATUS 本就由段 2 按槽上报,主站 drainRx() 拿到 STATUS 也直接 continue 丢掉,让它占队列纯属重复,正是它把队列压爆;(2) fillRxBatch 改为整条拷贝到局部副本再用,并逐条发布 tail,使生产者判满始终基于真实进度。 + 1.6.8:曾判定为"输入 PDO 填充跨上下文重入"并加了双缓冲发布 + 重入计数(段5@492)。**该假说事后被证伪**:SM-Synchron 稳态下 MainLoop 分支的门控 (!bEscIntEnabled||!bEcatFirstOutputsReceived)&&!bDcSyncActive 恒为假不执行,Sync0 因 free-run 无 DC 脉冲不触发,Sync1 与 PDI_Isr 同为 preempt=1 不能抢占 —— buildInput 实际只在 PDI_Isr 单上下文跑。双缓冲发布本身是正确加固,予以保留。 + 1.6.7:接着 1.6.6 治 CAN 取帧拼接。1.6.6 的校验只读 MP 的 TS/DLENC,TS 是 16 位计数器、相邻帧可能同值,DLENC 更常同为 48,于是"ID 报 STATUS、数据却是 CONFIG 帧"这种错配从校验漏了过去。本版三处改动:(1) 回读校验再加上 MI(完整 ID/帧格式),ID 变了就一定是换了帧,没有巧合空间;(2) 校验失败改为原地重取最多 3 次而不是直接丢弃 —— 失败时窗口里躺着的正是完整的新帧,重读一次通常就能拿全;(3) 整段取数放进 BASEPRI 临界区,挡住 preempt>=1 的 EtherCAT 中断。注:事后(1.6.8)证明本版方向不是主因,但这三条本身仍是正确的加固,予以保留。 + 1.6.6:根治 ECAT/CAN 偶发乱码 —— 取帧不再用厂商库 can_message_receive。一条 48B CANFD 报文要对同一地址 CAN_RFIFOMDATA0 连读 12 次才取完,整个过程不原子;而厂商库置 RFD0 后既不等硬件自清也不校验,取数过程会横跨 dequeue、读出两帧拼接。现改为本地 receiveFrameChecked():取数前后各读一次 MP,比对 TS[31:16] 与 DLENC,不一致即判定邮箱窗口被换帧,整帧丢弃并计入 Diag::rx_torn_count(经输入 PDO 段 5 偏移 486 原保留位上报,PDO 长度不变、EEPROM 无需重刷);releaseFifo0() 置位后轮询等自清,且改用直接赋值 —— 原来的 |= 是读改写,会把期间硬件置起的 RFF0/RFO0(rc_w1) 顺手清掉,FIFO 溢出证据就是这么丢的。另修 ISR 里 frame.id/stats.last_rx_id 取自三路共享的 s_diag.last_rx_id(而 data 取自局部 rx_message,会串出"A 通道 id 配 B 通道数据"),一律改用本帧局部 rxId。 + 1.6.5:段 2 电机槽补 seqlock(MotorSlot.wr_seq)。buildInput 跑在 PDI_Isr,会抢占主循环的 MotorState::onCanFrame,而那里是"先 seq=++global_seq、后 memcpy(slot.data,48)"——抢占落在 memcpy 中途就会发布"新 seq + 半新半旧 48B",主站只按 seq 判新鲜度分辨不出,混合边界若落在 float 的符号/指数字节(B16/B20/B24)就是网页上的偶发离谱值。现消费者见 wr_seq 为奇数即跳过本槽(该 56B 留零,主站按 node_id==0 跳过、下拍自愈),win_count 改报窗口跨度而非填充数以保持按 i 定位。CONFIG 路径不套锁:它只写 config_* 与恒等的 node_id/channel,buildInput 不同读这些。槽布局不变 ⇒ 输入 PDO 仍 496B,EEPROM/SII 无需重刷。 + 1.6.4:电机槽 40B→56B(8B 头 + v0.74 的 48B STATUS),每拍槽数 7→5(段 2 的 280B 正好 5×56)。修掉两件事:buildInput 按 48B 拷贝时溢出 16B 盖住段 3 长帧快照;主站侧 STATUS 被截到 32B 使 InitStatus(B32-35) 永远拿不到、网页把"无数据"显示成 NORMAL。槽数×槽长不变 ⇒ 输入 PDO 仍 496B,EEPROM/SII 无需重刷;主站需同步 MOT_SLOT_SIZE / MOT_SLOTS_PER_PDO / MOT_STATUS_DLC。 + 1.6.3:processOutput 不再从 out 回读 WIN@8——1.6.0 起 out 只在 seq 沿那拍新鲜,无沿周期里它是上一次全量读的残值,于是每拍用开机首拍的 0 盖掉 peekOutput 刚取到的新值,电机状态扫描窗口被钉死在基址 0,槽位 7..26(node3 及以上)永不上报,只有主站真发一帧才复位。WIN 的每拍刷新改由 peekOutput 独占。判据:不发任何帧,从站回显 win_base 也应等于主站请求值。 */ constexpr uint32_t APP_FW_VERSION_BUILD = 0U; @@ -298,6 +308,17 @@ constexpr uint32_t CONFIG_NVIC_PRIORITY_GROUP = NVIC_PRIGROUP_PRE4_SUB0 constexpr uint32_t CONFIG_IRQ_PREEMPT_ETHERCAT_SYNC = 0U; constexpr uint32_t CONFIG_IRQ_PREEMPT_SCHED = 1U; constexpr uint32_t CONFIG_IRQ_PREEMPT_CAN = 2U; +/* 1.6.13:CAN RX 提到 preempt=0(与 EtherCAT SYNC0 同级)。 + * 1.6.12 的 PDI 入口预抽实测:溢出确实大降(overfull 50/s→0)但拼接照旧 + * (指纹违规 ~92/s,且 torn 在无溢出阶段仍 1-7/s 在涨)—— 357µs 的 PDI + * 阻塞窗口本身就是病理:窗口内 FIFO 满溢毁窗口的同时,被冻结的取帧路径 + * 还留着不依赖溢出的换帧路径(MI/MP 只抓到少数)。preempt=0 一并掐断: + * RX ISR 不再被 PDI/SYNC1/SCHED 压住(FIFO 深度 ≤2,溢出与冻结同时消失), + * 且与 SYNC0 同级互不抢占 —— receiveFrameChecked 的多字取数自此真正原子。 + * TX ISR 留在 2:PDI 上下文有 TX 提交,提到 0 会引入提交/释放竞争。 + * 代价:SYNC0/PDI 偶发 µs 级延迟(RX ISR ~3µs/帧 × ~8kHz ≈ 2.4% 占空), + * 相对 1ms 周期与 165µs 总线时间是零头。 */ +constexpr uint32_t CONFIG_IRQ_PREEMPT_CAN_RX = 0U; constexpr uint32_t CONFIG_IRQ_PREEMPT_ETHERCAT_ESC = 1U; constexpr uint32_t CONFIG_IRQ_PREEMPT_ETHERCAT_SYNC1 = 1U; constexpr uint32_t CONFIG_IRQ_PREEMPT_SPI_USART = 3U; diff --git a/GD32E508_DCU_APP/sys/motor_ota_relay.cpp b/GD32E508_DCU_APP/sys/motor_ota_relay.cpp index dbc46a5..1288266 100644 --- a/GD32E508_DCU_APP/sys/motor_ota_relay.cpp +++ b/GD32E508_DCU_APP/sys/motor_ota_relay.cpp @@ -229,8 +229,12 @@ static void enterState(State st) { || prev == State::kFinishOta); if (otaActive && !wasActive) { CanFd::setChannelAutoRetrans(s_channel, false); /* OTA 开始:单发 */ + CanFd::setChannelBrsSuppress(s_channel, true); /* OTA 开始:压 BRS——电机 BOOT 收不了 + BRS 数据段,nonce/confirm/64B 数据包 + (DLC>8) 会全部静默丢失 */ } else if (!otaActive && wasActive) { CanFd::setChannelAutoRetrans(s_channel, true); /* OTA 结束:恢复重发 */ + CanFd::setChannelBrsSuppress(s_channel, false); /* OTA 结束:恢复 BRS(EtherCAT 桥接吞吐) */ } }