fix(comm): 回退LAN9252批量读写优化并修复多个CAN-EtherCAT桥竞态问题

回退1.6.0引入的LAN9252 PRAM批量读写优化,因其AVAIL_CNT计数器对短读存在语义问题,
导致邮箱短读和探针传输时出现FIFO陈旧数据损坏问题。

同时修复EtherCAT-CAN桥中的多个竞态条件:
- 修复消费者在fillRxBatch中直接访问共享ring buffer导致的数据混淆问题
- 修复生产者-消费者间tail指针发布时机不当造成的槽位覆盖竞态
- 修复WIN窗口基址更新位置错误导致电机状态窗口被锁定的问题
- 修复buildInput中断上下文与主循环间的数据竞争和内存撕裂问题
- 添加STATUS帧过滤机制减少批量区压力并解决队列饱和问题

此外还增加了多个诊断计数器用于监控撕裂、溢出和拼接错误,并调整了
输入PDO段布局和大小参数以支持新的48字节STATUS格式。

这些修改解决了上位机观察到的帧标签错误、电机状态丢失和数据拼接问题。
This commit is contained in:
cbb
2026-09-08 00:52:35 +08:00
parent ace5a1671b
commit 0cd3d9bed2
13 changed files with 518 additions and 111 deletions
+26 -53
View File
@@ -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) {}
+54 -15
View File
@@ -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<uint8_t>(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) {
+42 -14
View File
@@ -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));
+72 -7
View File
@@ -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 <cstring>
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)
+11 -1
View File
@@ -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;
+3
View File
@@ -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);