EtherCAT 单拍耗时 345µs→124µs:改动原理与验证

调查日期 2026-09-11 · 台子:i7-14700K + Intel I210(100 Mbps 全双工)+ DCU(GD32E508 + LAN9252)+ 3 台电机 · 源报告 tools/ecat/REPORT_ecat_beat_tail_20260911.md · 本页所有数字已对照源码与实机复测核验(layout=dense、rx p50=92.1µs、wkc 3/3、v1.6.22.0)
345 → 124µs9/11 当天 0.5ms 档整拍 p50 实测(cyc_us.work)
310 → 92µs主站等回包 rx p50 —— 变化都在这一段
124.6µs9/8 同为 win 布局的实测单拍(CSV)—— 345µs 并非 win 固有成本
线速主导主站 rx ≈ 帧在线上的往返(~1KB @100Mbit ≈ 80µs 量级);从站写 ESC 是另一段时间

结论(2026-09-12 修订)

重大更正(2026-09-12 复核):220µs 的归因被动摇,两个变量被混在一起

用户质疑"5 个 STATUS 变 3 个,怎么会差 20 倍"——顺着查,发现两件原调查没做的事:

混淆的变量(刷板台账 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 档、同一静默判据下的一拍构成(µs)
主站自身(pre+post 合计 0.3µs)发送(组帧 0.2 + pcap_sendpacket ≈28.4)线路传输 81.3µs(两布局相同)从站每拍准备(PDI buildInput)
100 200 300 0 µs 1.6.15 滑窗 发送 ≈28.6µs 线路 81.3 从站每拍准备 ≈229µs rx 往返 ≈310µs = 线路 81.3 + 从站准备 ≈229 · 整拍 p50 345µs · 达成 93.9% 1.6.21 密排 整拍 p50 124µs · 达成 99.9% 线路 81.3 rx 92.1µs = 线路 81.3 + 从站准备 ≈10(差分填充命中,接近纯线时) 虚线处线路段用完(28.6+81.3≈110µs),之后整段在等从站
数字出处:整拍与 rx 取自同机同档静默窗实测(win 345 / 310;dense 124 / 92.2,1ms 档复测 92.1);发送 ≈28.6µs 为 0.5ms 档 A 臂 p50(1ms 档 dbg 拆段 26.6~31.3µs,内含组帧 0.2µs);线路 81.3µs = 1016B×8÷100Mbps;win 从站准备 ≈229 = 310−81.3,与固件自量 buildInput 357µs(更重负载下所测)同量级。三段的逐层组成见下表。

三段逐层拆解(对应图中蓝 / 粉 / 红三个色块)

段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µs1.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 的差全部消失在这里

口径备注:win 时代插桩拆段亦记录到 pcap_sendpacket 181.1µs、其后续中位 1 次 poll 即得帧(§四表)——该口径下发送调用内部吸收了部分等待,发送/等回包两段的分界随仪表口径浮动;但整拍总量(345)与"扣除线路后的从站侧总延迟"(≈229)不受此影响。另 1ms 档每拍 next_ex 轮询中位 79 次(轮询间隔 ~1.2µs),0.5ms 档为紧轮询。

原理(按 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 同款注释):

② 输入 PDO 600B 布局对比(按字节偏移)
1.6.15 滑窗 段2 STATUS 5×56B(固定 5 槽,空槽占位) 段4 覆盖窗 5×44B(每拍维护) fillRxWindow / RxWinSlot 整套 桥计数 1.6.21 密排 密排槽×3(56B/台) 按 count 扩展,最多 9 台(空节点不占位) 1.6.22 稀疏写:这段不再推入 ESC RAM count=3 时空闲 桥计数 0 100 200 300 400 500 600(字节) 头区 32..39:win= active/boot/win_base/win_count/gseq@36 · 密排= count/active/boot/magic@35/gseq@36 —— 两种布局 gseq 同在偏移 36
段3 长帧(CONFIG/版本应答):win 在 320..359,密排前移到 544..583;段5 诊断两者都在 584..599。主站 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 字节。方向是对的,但省错了地方——这里有一个关键的不对称:

不对称原理:差分填充有效、稀疏写无效

A/B/A 轮次(只改这一个开关)板上版本0.5ms 达成长拍%发送 p50/p99 µs等回包 rx p50/p99 µsload_perm
A:稀疏写=1(出厂)1.6.22.099.90%0.1028.6 / 45.992.2 / 190.9489
B:整包写=0(A/B 基线)1.6.22.199.42%0.8329.8 / 348.992.2 / 210.4441
A 回归1.6.22.099.85%0.1029.4 / 86.993.0 / 101.2—

判读:rx p50 三轮 92.2/92.2/93.0µs ⇒ 稀疏写对主站往返无可测影响;load_perm 反而在整包写侧更低(441 vs 489)⇒ 它不能当这项的验收口径(真正该看 ESC 对象 0x2000 sub10,需独占路由停循环才能读,未做)。B 臂发送 p99 348.9µs 与 A 臂不同量级,但单窗样本不足以立论。当前 config.h 已回置出厂 A 臂(CONFIG_ECAT_SPARSE_ESC_WRITE=1)。

四、配套改动:把"看不见的东西"变成仪表

排查过程暴露了四个观测缺口,对应的改动与各自原理:

改动解决什么问题原理验证
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 是真的,以及为什么今天才生效

③ 判据:槽 seq 距当前 gseq 的滞后分布(111 次连续采样 ×3 槽=333 样本)
滑窗方案预期落点 每槽每 ≥2 拍才轮到一次 滞后应 ≈4~8 帧 —— 实测 0 个样本 110 85 77 60 1 0 1 2 3 4 ≥6(帧) 样本数 · λ≈3.75 帧/拍(3750 帧/s @ 1kHz) 槽 seq 滞后于当前 global_seq(帧数)
三台电机落在槽 2/5/8:滑窗方案盖住它们需 ≥2 个基址轮转 → 每槽每 ≥2 拍才刷新、滞后应集中在 4~8 帧。实测 1/3 样本滞后为 0、全部 ≤4 帧 ⇒ 只能是"每拍带齐整轮"的密排语义。seq 与 gseq 都是 GD32 自己盖的号,滞后能对上同时证明解码偏移没错。(原报告曾把"gseq≡rx_count"当最强证据,复核发现两种布局 gseq 同在偏移 36,该条不能区分布局,已撤换为本判据。)
④ 时间线:刷写 ≠ 生效
01:44 镜像刷入 1.6.22 台账 sha=hex 展平 ✓ 白天:仍在跑老代码 主站实测 win 布局 · 232B · 整拍 345µs 中午断电重启 生效:dense · 124µs ≈10 小时:写入 ≠ 生效 三方计数器锚点(不依赖系统时钟):主站进程存活 2084s · GD32 桥计数归零 1914s(gseq÷λ)· ESP32 上电 1922s —— 后两者差 8s,GD32 与 ESP32 同次上电;板子比主站晚 ~2.8 分钟重启,且 uptime 单调增(只重启过一次)
主站在 02:3x 直接读到 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>0gap p99
win(1.6.15 滑窗)~310µs345µs93.9%(1879Hz)7.9%8.6%1144µs
dense(1.6.21/22 密排)92.2µs123µs99.9%(1998Hz)0.10%~0.6%=周期本身

0.5ms 档余量从 155µs 变为 377µs:旧方案任何调度抖动都会成片跳拍,新方案长拍基本消失。另 1ms 档现网 late/lost(k>0 口径,连 1µs 超期都算)为个位数/400 拍窗,与"长拍>1.4×≈0%"不矛盾——两套阈值灵敏度不同,并列引用时必须标注口径。

六、被实测排除的原因(别再重测)

候选解释排除依据
线路变快 / 帧变小两种布局 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——写侧省字节数不进入主站关键路径(§三不对称原理)

七、复核更正(本次排查自己错过又改掉的判断)

证据层的两处撤换

限制声明的一处更正(本次复核新增)

归因的置信度边界(2026-09-12 修订)

八、遗留与下一步

按优先级

本报告依据 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 测速)。