EtherCAT 单拍耗时 345µs→124µs:改动原理与验证
结论(2026-09-12 修订)
- 实测事实不变:9/11 当天 0.5ms 档整拍从 345µs 变 124µs、rx 从 ~310µs 变 92.1µs(≈纯线路 81.3µs + ~10µs),与板子重启进 1.6.21/22 密排同时发生;1.6.22 稀疏写开关 A/B/A 无可测影响(92.2 / 92.2 / 93.0µs)。
- 但"345µs 是 win 布局的固有成本、密排省出 220µs"这条归因已被动摇:9/8 的存量扫描在同样 win 布局下实测单拍 124.6µs(0.125ms 档 7780Hz 的硬约束),与 dense 相同——345µs 是 9/10 夜才出现的异常态,不是 win 的常态。详见下方"重大更正"卡。
- 仍然成立的独立事实:刷写 ≠ 生效(镜像 01:44 刷入、约 10 小时后断电重启才生效,三方计数器锚点);台账只能证明"写进去了"——
flash_gd32.py已加生效守卫。
重大更正(2026-09-12 复核):220µs 的归因被动摇,两个变量被混在一起
用户质疑"5 个 STATUS 变 3 个,怎么会差 20 倍"——顺着查,发现两件原调查没做的事:
- 机制核对(代码层):两版 buildInput 逐行对比,纯 CPU 差仅 1~3µs(旧:5×48B + 5×44B 无条件拷贝;新:27 槽选最新 + 影子比对 + 3×48B)。对 ESC 的 PRAM 写两版同为整块 600B(从站侧写 ESC 成本,与主站 rx 不是同一段时间),且 A/B/A 证明它对主站往返不可见——"删掉覆盖窗 / 差分填充省出的 CPU"解释不了 229µs。另:差分填充在本台工况下基本不命中(STATUS ~1.25 帧/拍/台,槽 seq 几乎每拍都变,重建路径照样走)。
- 反例数据(9/8 存量扫描):
lab/ecat_rate_sweep.csv——win 布局、0.5ms 档 cyc_p50=124.6µs、达成 100%;1ms=126.9µs;0.125ms=119.5µs 且实测 7780Hz(单拍必须 ≤128µs 才可能)。与 dense 的 124µs 同水平。
混淆的变量(刷板台账 git 史 + 主站 git 史):9/8 板上镜像 201832b5…(win)→ 9/10 前已刷 4e84a354…(win 期 1.6.15→1.6.20 演进);主站 9/9 03:05 提交 97fab23(三布局自适应、发现窗基址 {0,7,14,20}→{0,5,10,15,20,22}、覆盖窗按 seq 归一)。345µs 出现在这两个改动之后、9/8 之前——原归因把回归记在 1.6.21 密排头上,但密排也可能只是"绕开"了回归(dense 下主站强制 n_bases=1,不轮转窗口)。
钉死实验(一次刷板,未执行):① 刷回 959e2ae(win 固件)+ build_native/ecat_native.prev.exe(9/6 构建,早于 97fab23;它不认识 dense 布局,必须配 win 板)→ 回 ~124µs 即主站回归实锤;② 同 win 板 + 现主站 → 345µs 复现即双向确认;③ 若 prev.exe 也 345µs ⇒ 回归在从站 win 期固件演进里,对 1.6.15→1.6.20 bisect;④ win 模式读 DWT 剖分计数器 0x2000 sub5/6/9/10(PDI_Isr 全程 / 输出映射 / 输入映射 / 其中写侧)——SDO 走独占路由会停环掉 OP,需专门开窗。
一、一拍的生命周期:210µs 差在哪一环
主站每一拍做的事:组帧 → pcap_sendpacket 发出 → 等帧从线路绕回来(pcap_next_ex)→ 解码。等回包(rx)的时长 = 线路传输时间 + 从站把这一拍的输入镜像备好的时间——LAN9252 的 SM2 事件触发 PDI 中断,从站要在 ESC 把输入区搬上线路之前把 600B 输入镜像写好,主站就得等这段活干完。
三段逐层拆解(对应图中蓝 / 粉 / 红三个色块)
| 段 | 0.5ms 实测 p50 | 从外到内的组成 | 出处 / 尚未拆开的部分 |
|---|---|---|---|
| 发送(蓝) | 28.6µs | 组帧 ≈0.2µs(组 LRW + 填输出区)→ pcap_sendpacket ≈28.4µs:NPF 栈把 1016B 交给 I210 驱动并提交发送 | /api/motor/dbg 拆段(sd_us 列)。驱动/DMA 内部无仪表;B 臂曾见发送 p99 348.9µs 的偶发阻塞(单窗样本,未立论) |
| 线路(粉) | 81.3µs | 帧 1016B = 过程数据 984B(输出 384 + 输入 600)+ 以太网头 14 + EtherType 2 + 数据报头 10 + WKC 2 + FCS 4;8128bit ÷ 100Mbps;再加前导 8B + 帧间隙 12B ≈ +1.6µs;从站 ESC 硬件转发 µs 级 | 由帧构成计算,两布局逐字节同长——结构上不可能解释 220µs 差 |
| 从站准备(红)· win | ≈229µs | 1.6.15 每拍在 PDI 里:① 按 win_base 轮转准备 27 槽表 ② 段4 覆盖窗 5×44B 拷贝 ③ MotorState 重读 + 逐槽 48B 拷贝 ④ 整块 600B 写入 ESC | 实测差值 310−81.3。①②③ 纯 CPU 差仅 1~3µs;④ 是从站写 ESC(两布局同在、不影响主站往返)——229µs 载体未闭环;9/8 同布局 124.6µs 表明"345µs=win 固有"不成立 |
| 从站准备(红)· dense | ≈10µs | 差分填充命中:节点集合与各 seq 未变 → 整段复用影子,①②③全免;只剩 count 个槽的更新推送 + ESC 取数 | rx 92.1 − 81.3。220µs 的差全部消失在这里 |
原理(按 9/11 当天的观测口径):SM 长度(600B)与线速两布局完全相同,所以"线路那一截"不变;rx 的差值实测全部落在"等从站"这一段。但这段差值的载体机制存在重大疑点——两版 buildInput 的纯 CPU 差仅 1~3µs,且 9/8 同布局实测 124.6µs 表明 345µs 并非 win 固有成本——见开头"重大更正"卡。图①对比的是 9/11 上午 win 异常态 vs 午后 dense 的实测,不代表两种布局的固有差距。
二、改动原理 A:从站输入映射 1.6.15 滑窗 → 1.6.21 密排整轮
两种布局的输入区都是 600B(SM 长度、SII 都不变),只靠输入区第 35 字节的魔数 0xA5 区分(ecat_native.c:74-84,固件 ethercat_can_bridge.h:154 同款注释):
decode_input 每拍判 magic@35 自适应,两端可任意先后升级。1.6.21 的三个子改动,各砍掉什么活
① 密排整轮:固定 5 槽 + win_base 轮转扫 27 槽 → count 个密排槽
旧方案段 2 固定 5 个 STATUS 槽(空槽 node_id=0 也占位),主站按 win_base 轮转窗口才能盖到 9 节点×3 通道=27 槽的表——接 3 台电机却会看到 5 个状态帧。新方案一拍直接带齐总线上全部电机(本台 3 台=3 槽×56B),槽内按 node_id 升序,主站按 count 迭代。砍掉的是主站轮转窗口带来的多拍刷新分频与从站"为凑满 5 槽而做的填充"。
② 删段 4 覆盖窗口(fillRxWindow / RxWinSlot 整套)
旧方案每拍要把非 STATUS 帧抄进 5×44B 的历史覆盖窗反复重发——这是纯粹的每拍 CPU 拷贝活。新方案非 STATUS 帧只走段 1/3 的"最近一帧",覆盖窗整套删除(图中 win 红段在密排侧不复存在)。
③ 差分填充:节点集合与各 seq 未变时整段复用影子
注意一个容易误解的点:电机"不动"≠数据不变——STATUS 帧按电机自己的节奏持续上报(本台 3 台合计 ~3750 帧/s,每台 ~1.25 帧/拍),所以大多数拍里槽 seq 都变了、重建路径照样走(实测槽 seq 每 ~1 拍刷新一次)。差分填充真正的收益是"没变的槽不重读不重拷、重建范围从无条件 5 槽缩到实际变化的槽"——量级是 µs 级的,不能用它解释 rx 310→92(见开头"重大更正"卡)。
三、改动原理 B:1.6.22 稀疏写为何对主站往返"无效"
1.6.22 的思路:PDI 往 ESC RAM 写输入镜像时按 count 只写「段1+段2 有效区」与「段3+段5」,跳过空槽区,省 HW_EscWriteIsr 的 PRAM 字节。方向是对的,但省错了地方——这里有一个关键的不对称:
不对称原理:差分填充有效、稀疏写无效
- 主站等的关键路径 = SM 交换(ESC 引擎把 600B 输入区搬上线路)+ 交换前从站把镜像备好。
- 差分填充砍的是"备好镜像"的 CPU 活(MotorState 重读、逐槽拷贝、覆盖窗维护)→ 直接缩短关键路径 ⇒ rx 310→92µs。
- 稀疏写砍的是"推镜像进 ESC RAM"的字节数,但 ESC 引擎上线搬运量由 SM 长度(600B)决定,不看 PDI 实际写了多少 → 主站等待不变。
| A/B/A 轮次(只改这一个开关) | 板上版本 | 0.5ms 达成 | 长拍% | 发送 p50/p99 µs | 等回包 rx p50/p99 µs | load_perm |
|---|---|---|---|---|---|---|
| A:稀疏写=1(出厂) | 1.6.22.0 | 99.90% | 0.10 | 28.6 / 45.9 | 92.2 / 190.9 | 489 |
| B:整包写=0(A/B 基线) | 1.6.22.1 | 99.42% | 0.83 | 29.8 / 348.9 | 92.2 / 210.4 | 441 |
| A 回归 | 1.6.22.0 | 99.85% | 0.10 | 29.4 / 86.9 | 93.0 / 101.2 | — |
四、配套改动:把"看不见的东西"变成仪表
排查过程暴露了四个观测缺口,对应的改动与各自原理:
| 改动 | 解决什么问题 | 原理 | 验证 |
|---|---|---|---|
flash_gd32.py 生效守卫 | 台账只能证明"写进去了",不能证明"板上在跑"(本次 10 小时空窗的根因) | 刷前记 ota.gd32_app 版本 + gd32_adc.uptime_sec;刷完必须"版本变了或 uptime 归零"才写台账,否则非零退出不写 | 两轮真实刷写 1.6.22.0→.1→.0 通过 |
ecat_native.c cyc_us.late/lost 接真值 | 两字段旧为硬编码 0,超期/跳拍完全不可见 | 最近 STATS_N 拍窗口内统计:late=起跑即超期(k>0)的拍数,lost=这些拍跳过的周期数(累计仍在 skip_total) | 重载窗 late=21/lost=28,健康窗 0/0 |
/api/motor/dbg 尾部追加两列 | 发送段是黑盒:不知 sendpacket 花多久、轮询了几次 | 第 7/8 列 = 本拍 pcap_sendpacket 净耗时与 pcap_next_ex 次数;旧列序不变,按前 6 列下标读的消费方不受影响 | 0.5ms 档 181µs 调用、每拍中位 1 次 poll |
soem-src/oshw/win32/nicdrv.c pcap 调用计数 | 主/从栈调用混在一起没法归因 | 只在 stacknumber==0(主栈)累加 QPC 计数,冗余栈不计防双计 | A/B 与旧构建行为一致(中性) |
watch_beat_tail.py(新) | 跨分钟现象无法回溯(布局翻转恰好发生在两次采样之间) | 每 10s 只读一轮 motor/state+rxraw+app/load,落 27 列 CSV(layout/work/send/rx/late/lost/λ/达成) | --once 跑通 |
transport-ecat.js 常量适配 | 前端仍按旧 5 槽/6 周期口径显示 | 密排下一拍整轮:MOT_SLOTS_PER_PDO 5→9、MOTOR_SCAN_CYCLES 6→1;能否拿齐电机由后端 decode 决定,常量只影响展示口径 | 电机页原样复用,无回归 |
lab/spi_test.html 跳拍卡 | 旧版需开逐拍环才有超期数据 | 直接读 cyc_us.late/lost(最近 400 拍窗),并注明旧后端恒报 0 不可当"无超期" | 真机复跑成功,node --check 通过 |
五、证据:dense 是真的,以及为什么今天才生效
layout="win" len=232 配 345µs——刷入后 10 小时仍是老代码在跑。台账 _last_gd32_sha.txt 当时就与 Release hex 展平一致,即"写进去了"为真,"在跑了"为假。(A/B/A 之后台账现为 dda92d11…,对应 12:46 刷回 A 臂的产物,本次复核独立展平重算一致。)同机同日前后对照(观察性证据)
| 从站布局 | rx 往返 | 整拍 p50 | 达成 | 长拍>1.4× | 跳拍 k>0 | gap p99 |
|---|---|---|---|---|---|---|
win(1.6.15 滑窗) | ~310µs | 345µs | 93.9%(1879Hz) | 7.9% | 8.6% | 1144µs |
dense(1.6.21/22 密排) | 92.2µs | 123µs | 99.9%(1998Hz) | 0.10% | ~0.6% | =周期本身 |
六、被实测排除的原因(别再重测)
| 候选解释 | 排除依据 |
|---|---|
| 线路变快 / 帧变小 | 两种布局 Ibytes 同为 600B(ecat_native.c:74 与固件头文件双侧注释),帧长 ~1016B 不变,线速仍 100Mbps 全双工 |
| 收帧超时(EC_TIMEOUTRET) | 预算 2000µs(µs 单位),11,264 拍里仅 1 拍 EC_NOFRAME;win 时代 rx p50 7~9µs 起步,预算从不消耗——收紧它是自证式"变好" |
| 主站构建差异 / 双实例 | 插桩版与旧构建 A/B 一致;单进程单端口;旧 exe 留档 build_native/ecat_native.pre-instr.exe |
| 观察者负载 | 面板全量 10Hz×3、单 dbg 10Hz(≈1.5MB/s JSON)、零请求静默窗,三者结果在噪声内相同 |
| CPU 抢占 | 另开 4 / 12 个纯算线程压机,send/rx/work 一动不动 |
| "PRAM 逐 DWORD 轮数 150→45" | 被 A/B/A 否:整包写 600B 的 B 臂同样 92µs——写侧省字节数不进入主站关键路径(§三不对称原理) |
七、复核更正(本次排查自己错过又改掉的判断)
证据层的两处撤换
- "gseq≡rx_count,差恒 0"是同义反复:
MOTW_OFF_GLOBAL_SEQ与MOTW_OFF_GSEQ_DENSE都是偏移 36(ecat_native.c:69,82),两种布局读同一字节,分不开 dense 与 win。已撤换为 §五-③ 的槽滞后判据。 - 魔数 0xA5 无法从主机侧直接读:
/api/motor/rxraw返回的是批量接收区缓冲快照,不是 PDO 输入镜像;路由表中没有任何路由吐输入镜像。故"直接验魔数"这条路不存在,dense 判定只能靠上述间接判据 + 60s×30 与 111 次采样稳定性(layout 恒 dense、len 恒 176、count 恒 3、win_bases 恒 None)。
限制声明的一处更正(本次复核新增)
- "DCU_APP 不在任何 git 仓库、布局无法 A/B"不成立:
DCU_APP是独立 git 仓库(38 个提交)——959e2ae= 1.6.14~1.6.20 滑窗覆盖窗源码、1742d9a= 1.6.21 密排;命令行构建链(cbuild + AC6)现成。因此"win→dense 换来 220µs"这一环可以做受控实验钉死:checkout959e2ae重编刷板 → 预期 rx 回到 ~310µs → 再刷回 1.6.22(flash_gd32.py 生效守卫会留台账)。属刷板操作,未执行。 - 遗留:重刷同一版本无法证明生效——
gd32_adc.uptime_sec实测不随 GD32 换镜像归零,守卫的"uptime 归零"分支不可靠,目前只能靠版本号变化。根治要 BOOT 上报复位计数/复位原因,或 OTA 完成后强制 APP 复位回读。
归因的置信度边界(2026-09-12 修订)
- 已受控验证:稀疏写开关(A/B/A,单臂 2048 拍静默窗,效应上界 ≲1µs/拍);排除项(§六);"刷写≠生效"时间锚点(§五)。
- 已动摇:win→dense 与 220µs 的因果归因——9/8 同布局实测 124.6µs 的反例 + 两版 buildInput 纯 CPU 差仅 1~3µs。原"三条独立吻合"里"旧值≈357µs"一条存疑:357µs 是 1.6.11 时代(496B 输入 + 环形批量区)的测量,与滑窗 600B 版不同代。
- 待做:开头"重大更正"卡的刷板 A/B(959e2ae + prev.exe 双轮)是当前唯一能闭合因果的实验。
八、遗留与下一步
按优先级
- 布局 A/B 钉死归因(方案已更新):刷回
959e2ae(win 固件)后做两轮——配ecat_native.prev.exe(旧主站,≤78fe6cf)与现主站各测一轮 0.5ms:124µs / 345µs 的分野即判定回归在主站还是从站 win 期固件;再用 0x2000 sub5/6/9/10 定位。刷板会中断 OP 会话,需专门安排。 - 0.25ms 档第一次有理论余量(124/250=50%),值得按同一静默判据实测——但注意此前结论:从站 SSC 的 SM2 事件路径在快档有自己的墙,别只看主站达成率。
- 重刷同版本的生效证明:BOOT 上报复位计数,或 OTA 后强制 APP 复位回读版本。
0x2000 sub10(EscWrite 周期)精确验收需独占路由(停循环、从站掉 OP),要专门开一窗。- ms 级离群未清零:dense 时代仍见 work max 2153µs / rx max 1658µs(p90 仅 ~110µs),量级无害但没定位。
- 本次全部改动未提交(tools/ecat 3 文件+报告、tools/ESP32 7 文件、DCU_APP 4 文件),旧主站 exe 留档在
build_native/ecat_native.pre-instr.exe。
tools/ecat/REPORT_ecat_beat_tail_20260911.md、当日调查会话与复核证据整理;所有代码引证(偏移定义、357µs、路由表、台账 sha)已于 2026-09-11 对照源码与实机独立核验,实时复测 rx p50=92.1µs / layout=dense / wkc 3/3 / v1.6.22.0。相关页面:ECAT_RATE_TRADEOFF.html(周期×故障率×CPU 调研)· lab/spi_test.html(SPI/ECAT 测速)。