Files
cbb beeae8a382 chore(hygiene): P3 报告归档 docs/reports + scripts 收敛 + lab 清理 + CSS/README 约定
正式 scripts 仅保留 README 自测清单;根目录报告迁入 docs/reports;
lab 采样 dump 出库;CSS 注明 btn/toast 所有权并 bump ?v=。
2026-09-21 02:26:42 +08:00

290 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>EtherCAT 单拍耗时 345µs→124µs:改动原理与验证</title>
<style>
:root{--fg:#111827;--mut:#6b7280;--bd:#e5e7eb;--bg:#f8fafc;--acc:#1d4ed8;--warn:#b45309;--bad:#b91c1c;--ok:#047857}
*{box-sizing:border-box}
body{margin:0;background:#fff;color:var(--fg);
font:15.5px/1.72 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}
main{max-width:1120px;margin:0 auto;padding:40px 28px 90px}
h1{font-size:29px;line-height:1.3;margin:0 0 6px;letter-spacing:-.2px}
h2{font-size:20px;margin:44px 0 12px;padding-bottom:7px;border-bottom:2px solid var(--bd)}
h3{font-size:16px;margin:26px 0 8px;color:#374151}
p,li{margin:8px 0}
.meta{color:var(--mut);font-size:13.5px;margin-bottom:26px}
.card{background:var(--bg);border:1px solid var(--bd);border-left:4px solid var(--acc);
border-radius:8px;padding:16px 20px;margin:18px 0}
.card.warn{border-left-color:var(--warn);background:#fffbeb}
.card.bad{border-left-color:var(--bad);background:#fef2f2}
.card.ok{border-left-color:var(--ok);background:#ecfdf5}
.card h3{margin:0 0 8px;font-size:15px;color:inherit}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:18px;margin:18px 0}
.fig{border:1px solid var(--bd);border-radius:8px;padding:12px 10px 8px;background:#fff}
.fig > .cap{font-size:12.5px;font-weight:600;color:#374151;margin:0 0 4px 8px}
.fig > .sub{font-size:11.5px;color:var(--mut);margin:0 0 4px 8px}
table{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}
th,td{border:1px solid var(--bd);padding:6px 9px;text-align:right}
th{background:#f1f5f9;font-weight:600;text-align:center}
td:first-child,th:first-child{text-align:left}
tr.best td{background:#ecfdf5;font-weight:600}
code{background:#f1f5f9;padding:1.5px 5px;border-radius:4px;font-size:12.5px;
font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}
.kpi{display:flex;gap:14px;flex-wrap:wrap;margin:22px 0 8px}
.kpi div{flex:1 1 168px;border:1px solid var(--bd);border-radius:8px;padding:13px 15px;background:#fff}
.kpi b{display:block;font-size:23px;line-height:1.25;color:var(--acc)}
.kpi span{font-size:12px;color:var(--mut)}
ul{padding-left:22px}
.foot{margin-top:44px;padding-top:16px;border-top:1px solid var(--bd);
font-size:12.5px;color:var(--mut)}
.legend{font-size:12px;color:#374151;margin:2px 0 4px 8px}
.legend i{display:inline-block;width:11px;height:11px;border-radius:2px;margin:0 4px -1px 10px}
</style></head><body><main>
<h1>EtherCAT 单拍耗时 345µs→124µs:改动原理与验证</h1>
<div class="meta">调查日期 2026-09-11 · 台子:i7-14700K + Intel I210(100 Mbps 全双工)+ DCU(GD32E508 + LAN9252)+ 3 台电机 · 源报告 <code>tools/ecat/REPORT_ecat_beat_tail_20260911.md</code> · 本页所有数字已对照源码与实机复测核验(<code>layout=dense</code>、rx p50=92.1µs、wkc 3/3、v1.6.22.0)</div>
<div class="kpi"><div><b>345 → 124µs</b><span>9/11 当天 0.5ms 档整拍 p50 实测(cyc_us.work)</span></div><div><b>310 → 92µs</b><span>主站等回包 rx p50 —— 变化都在这一段</span></div><div><b>124.6µs</b><span>9/8 同为 win 布局的实测单拍(CSV)—— 345µs 并非 win 固有成本</span></div><div><b>线速主导</b><span>主站 rx ≈ 帧在线上的往返(~1KB @100Mbit ≈ 80µs 量级);从站写 ESC 是另一段时间</span></div></div>
<div class="card"><h3>结论(2026-09-12 修订)</h3>
<ul>
<li><b>实测事实不变</b>: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)。</li>
<li><b>但"345µs 是 win 布局的固有成本、密排省出 220µs"这条归因已被动摇</b>:9/8 的存量扫描在同样 win 布局下实测单拍 <b>124.6µs</b>(0.125ms 档 7780Hz 的硬约束),与 dense 相同——345µs 是 9/10 夜才出现的异常态,不是 win 的常态。详见下方"重大更正"卡。</li>
<li><b>仍然成立的独立事实</b>:刷写 ≠ 生效(镜像 01:44 刷入、约 10 小时后断电重启才生效,三方计数器锚点);台账只能证明"写进去了"——<code>flash_gd32.py</code> 已加生效守卫。</li>
</ul></div>
<div class="card bad"><h3>重大更正(2026-09-12 复核):220µs 的归因被动摇,两个变量被混在一起</h3>
<p>用户质疑"5 个 STATUS 变 3 个,怎么会差 20 倍"——顺着查,发现两件原调查没做的事:</p>
<ul>
<li><b>机制核对(代码层)</b>:两版 buildInput 逐行对比,纯 CPU 差仅 <b>1~3µs</b>(旧:5×48B + 5×44B 无条件拷贝;新:27 槽选最新 + 影子比对 + 3×48B)。对 ESC 的 PRAM 写两版同为整块 600B(从站侧写 ESC 成本,与主站 rx 不是同一段时间),且 A/B/A 证明它对主站往返不可见——<b>"删掉覆盖窗 / 差分填充省出的 CPU"解释不了 229µs</b>。另:差分填充在本台工况下基本不命中(STATUS ~1.25 帧/拍/台,槽 seq 几乎每拍都变,重建路径照样走)。</li>
<li><b>反例数据(9/8 存量扫描)</b>:<code>lab/ecat_rate_sweep.csv</code>——win 布局、0.5ms 档 cyc_p50=<b>124.6µs</b>、达成 100%;1ms=126.9µs;0.125ms=119.5µs 且实测 7780Hz(单拍必须 ≤128µs 才可能)。与 dense 的 124µs 同水平。</li>
</ul>
<p><b>混淆的变量</b>(刷板台账 git 史 + 主站 git 史):9/8 板上镜像 <code>201832b5…</code>(win)→ 9/10 前已刷 <code>4e84a354…</code>(win 期 1.6.15→1.6.20 演进);主站 9/9 03:05 提交 <code>97fab23</code>(三布局自适应、发现窗基址 {0,7,14,20}→{0,5,10,15,20,22}、覆盖窗按 seq 归一)。<b>345µs 出现在这两个改动之后、9/8 之前</b>——原归因把回归记在 1.6.21 密排头上,但密排也可能只是"绕开"了回归(dense 下主站强制 n_bases=1,不轮转窗口)。</p>
<p><b>钉死实验(一次刷板,未执行)</b>:① 刷回 <code>959e2ae</code>(win 固件)+ <code>build_native/ecat_native.prev.exe</code>(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,需专门开窗。</p></div>
<h2>一、一拍的生命周期:210µs 差在哪一环</h2>
<p>主站每一拍做的事:<b>组帧 → <code>pcap_sendpacket</code> 发出 → 等帧从线路绕回来(<code>pcap_next_ex</code>)→ 解码</b>。等回包(rx)的时长 = 线路传输时间 + <b>从站把这一拍的输入镜像备好的时间</b>——LAN9252 的 SM2 事件触发 PDI 中断,从站要在 ESC 把输入区搬上线路之前把 600B 输入镜像写好,主站就得等这段活干完。</p>
<div class="grid">
<div class="fig"><div class="cap">① 同一 0.5ms 档、同一静默判据下的一拍构成(µs)</div>
<div class="legend"><i style="background:#94a3b8"></i>主站自身(pre+post 合计 0.3µs)<i style="background:#2563eb"></i>发送(组帧 0.2 + pcap_sendpacket ≈28.4)<i style="background:#fca5a5"></i>线路传输 81.3µs(两布局相同)<i style="background:#dc2626"></i>从站每拍准备(PDI buildInput)</div>
<svg viewBox="0 0 620 212" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="一拍构成对比">
<line x1="150" y1="180" x2="600" y2="180" stroke="#9aa3af" stroke-width="1.2"/>
<line x1="150" y1="28" x2="150" y2="180" stroke="#9aa3af" stroke-width="1.2"/>
<line x1="278" y1="176" x2="278" y2="184" stroke="#9aa3af"/><text x="278" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">100</text>
<line x1="406" y1="176" x2="406" y2="184" stroke="#9aa3af"/><text x="406" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">200</text>
<line x1="534" y1="176" x2="534" y2="184" stroke="#9aa3af"/><text x="534" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">300</text>
<text x="150" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">0</text>
<text x="596" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">µs</text>
<text x="142" y="64" text-anchor="end" font-size="12" fill="#374151">1.6.15 滑窗</text>
<text x="170" y="40" text-anchor="middle" font-size="10.5" fill="#1e40af">发送 ≈28.6µs</text>
<rect x="150" y="48" width="2" height="24" fill="#94a3b8"/>
<rect x="152" y="48" width="36.6" height="24" fill="#2563eb"/>
<rect x="188.6" y="48" width="104.1" height="24" fill="#fca5a5"/>
<rect x="292.7" y="48" width="293.1" height="24" fill="#dc2626"/>
<rect x="585.8" y="48" width="2" height="24" fill="#94a3b8"/>
<text x="240" y="64" text-anchor="middle" font-size="10.5" fill="#7f1d1d" font-weight="600">线路 81.3</text>
<text x="439" y="64" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">从站每拍准备 ≈229µs</text>
<text x="152" y="86" text-anchor="start" font-size="11" font-weight="600" fill="#b91c1c">rx 往返 ≈310µs = 线路 81.3 + 从站准备 ≈229 · 整拍 p50 345µs · 达成 93.9%</text>
<text x="142" y="134" text-anchor="end" font-size="12" fill="#374151">1.6.21 密排</text>
<text x="308" y="110" text-anchor="start" font-size="11.5" font-weight="600" fill="#047857">整拍 p50 124µs · 达成 99.9%</text>
<rect x="150" y="118" width="2" height="24" fill="#94a3b8"/>
<rect x="152" y="118" width="36.6" height="24" fill="#2563eb"/>
<rect x="188.6" y="118" width="104.1" height="24" fill="#fca5a5"/>
<rect x="292.7" y="118" width="12.8" height="24" fill="#dc2626"/>
<rect x="305.5" y="118" width="2" height="24" fill="#94a3b8"/>
<text x="240" y="134" text-anchor="middle" font-size="10.5" fill="#7f1d1d" font-weight="600">线路 81.3</text>
<text x="152" y="156" text-anchor="start" font-size="11" font-weight="600" fill="#047857">rx 92.1µs = 线路 81.3 + 从站准备 ≈10(差分填充命中,接近纯线时)</text>
<line x1="292.7" y1="30" x2="292.7" y2="172" stroke="#dc2626" stroke-width="1" stroke-dasharray="4,3" opacity="0.55"/>
<text x="298" y="36" font-size="10.5" fill="#b91c1c">虚线处线路段用完(28.6+81.3≈110µs),之后整段在等从站</text>
</svg>
<div class="sub">数字出处:整拍与 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(更重负载下所测)同量级。三段的逐层组成见下表。</div>
</div>
</div>
<h3>三段逐层拆解(对应图中蓝 / 粉 / 红三个色块)</h3>
<table>
<tr><th>段</th><th>0.5ms 实测 p50</th><th>从外到内的组成</th><th>出处 / 尚未拆开的部分</th></tr>
<tr><td>发送(蓝)</td><td>28.6µs</td><td>组帧 ≈0.2µs(组 LRW + 填输出区)→ <code>pcap_sendpacket</code> ≈28.4µs:NPF 栈把 1016B 交给 I210 驱动并提交发送</td><td><code>/api/motor/dbg</code> 拆段(sd_us 列)。驱动/DMA 内部无仪表;B 臂曾见发送 p99 348.9µs 的偶发阻塞(单窗样本,未立论)</td></tr>
<tr><td>线路(粉)</td><td>81.3µs</td><td>帧 1016B = 过程数据 984B(输出 384 + 输入 600)+ 以太网头 14 + EtherType 2 + 数据报头 10 + WKC 2 + FCS 4;8128bit ÷ 100Mbps;再加前导 8B + 帧间隙 12B ≈ +1.6µs;从站 ESC 硬件转发 µs 级</td><td>由帧构成计算,两布局逐字节同长——结构上不可能解释 220µs 差</td></tr>
<tr><td>从站准备(红)· win</td><td>≈229µs</td><td>1.6.15 每拍在 PDI 里:① 按 win_base 轮转准备 27 槽表 ② 段4 覆盖窗 5×44B 拷贝 ③ MotorState 重读 + 逐槽 48B 拷贝 ④ 整块 600B 写入 ESC</td><td>实测差值 310−81.3。①②③ 纯 CPU 差仅 1~3µs;④ 是从站写 ESC(两布局同在、不影响主站往返)——229µs 载体未闭环;9/8 同布局 124.6µs 表明"345µs=win 固有"不成立</td></tr>
<tr><td>从站准备(红)· dense</td><td>≈10µs</td><td>差分填充命中:节点集合与各 seq 未变 → 整段复用影子,①②③全免;只剩 count 个槽的更新推送 + ESC 取数</td><td>rx 92.1 − 81.3。220µs 的差全部消失在这里</td></tr>
</table>
<p class="meta">口径备注:win 时代插桩拆段亦记录到 <code>pcap_sendpacket</code> 181.1µs、其后续中位 1 次 poll 即得帧(§四表)——该口径下发送调用内部吸收了部分等待,<b>发送/等回包两段的分界随仪表口径浮动</b>;但整拍总量(345)与"扣除线路后的从站侧总延迟"(≈229)不受此影响。另 1ms 档每拍 next_ex 轮询中位 79 次(轮询间隔 ~1.2µs),0.5ms 档为紧轮询。</p>
<p><b>原理(按 9/11 当天的观测口径):</b>SM 长度(600B)与线速两布局完全相同,所以"线路那一截"不变;rx 的差值实测全部落在"等从站"这一段。但这段差值的载体机制存在重大疑点——两版 buildInput 的纯 CPU 差仅 1~3µs,且 9/8 同布局实测 124.6µs 表明 345µs 并非 win 固有成本——见开头"重大更正"卡。图①对比的是 9/11 上午 win 异常态 vs 午后 dense 的实测,不代表两种布局的固有差距。</p>
<h2>二、改动原理 A:从站输入映射 1.6.15 滑窗 → 1.6.21 密排整轮</h2>
<p>两种布局的输入区都是 600B(SM 长度、SII 都不变),只靠输入区第 35 字节的魔数 <code>0xA5</code> 区分(<code>ecat_native.c:74-84</code>,固件 <code>ethercat_can_bridge.h:154</code> 同款注释):</p>
<div class="grid">
<div class="fig"><div class="cap">② 输入 PDO 600B 布局对比(按字节偏移)</div>
<svg viewBox="0 0 620 252" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="输入区布局对比">
<text x="142" y="68" text-anchor="end" font-size="12" fill="#374151">1.6.15 滑窗</text>
<rect x="150" y="52" width="22.4" height="26" fill="#cbd5e1"/>
<rect x="172.4" y="52" width="5.6" height="26" fill="#64748b"/>
<rect x="178" y="52" width="196" height="26" fill="#2563eb"/>
<rect x="374" y="52" width="28" height="26" fill="#7c3aed"/>
<rect x="402" y="52" width="156.8" height="26" fill="#dc2626"/>
<rect x="558.8" y="52" width="11.2" height="26" fill="#16a34a"/>
<text x="276" y="68" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">段2 STATUS 5×56B(固定 5 槽,空槽占位)</text>
<text x="480" y="47" text-anchor="middle" font-size="10.5" fill="#b91c1c" font-weight="600">段4 覆盖窗 5×44B(每拍维护)</text>
<text x="480" y="90" text-anchor="middle" font-size="10" fill="#7f1d1d">fillRxWindow / RxWinSlot 整套</text>
<text x="161" y="47" text-anchor="middle" font-size="10" fill="#475569">桥计数</text>
<text x="142" y="178" text-anchor="end" font-size="12" fill="#374151">1.6.21 密排</text>
<rect x="150" y="162" width="22.4" height="26" fill="#cbd5e1"/>
<rect x="172.4" y="162" width="5.6" height="26" fill="#64748b"/>
<rect x="178" y="162" width="117.6" height="26" fill="#2563eb"/>
<rect x="295.6" y="162" width="235.2" height="26" fill="#eff6ff" stroke="#93c5fd" stroke-dasharray="4,3"/>
<rect x="530.8" y="162" width="28" height="26" fill="#7c3aed"/>
<rect x="558.8" y="162" width="11.2" height="26" fill="#16a34a"/>
<text x="236" y="178" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">密排槽×3(56B/台)</text>
<text x="413" y="157" text-anchor="middle" font-size="10" fill="#1d4ed8">按 count 扩展,最多 9 台(空节点不占位)</text>
<text x="413" y="200" text-anchor="middle" font-size="10" fill="#1d4ed8">1.6.22 稀疏写:这段不再推入 ESC RAM</text>
<text x="413" y="178" text-anchor="middle" font-size="10" fill="#6b7280">count=3 时空闲</text>
<text x="161" y="157" text-anchor="middle" font-size="10" fill="#475569">桥计数</text>
<line x1="150" y1="216" x2="570" y2="216" stroke="#9aa3af" stroke-width="1.2"/>
<text x="150" y="233" text-anchor="middle" font-size="10" fill="#6b7280">0</text>
<text x="220" y="233" text-anchor="middle" font-size="10" fill="#6b7280">100</text>
<text x="290" y="233" text-anchor="middle" font-size="10" fill="#6b7280">200</text>
<text x="360" y="233" text-anchor="middle" font-size="10" fill="#6b7280">300</text>
<text x="430" y="233" text-anchor="middle" font-size="10" fill="#6b7280">400</text>
<text x="500" y="233" text-anchor="middle" font-size="10" fill="#6b7280">500</text>
<text x="570" y="233" text-anchor="middle" font-size="10" fill="#6b7280">600(字节)</text>
<text x="150" y="248" font-size="10" fill="#6b7280">头区 32..39:win= active/boot/win_base/win_count/gseq@36 · 密排= count/active/boot/magic@35/gseq@36 —— 两种布局 gseq 同在偏移 36</text>
</svg>
<div class="sub">段3 长帧(CONFIG/版本应答):win 在 320..359,密排前移到 544..583;段5 诊断两者都在 584..599。主站 <code>decode_input</code> 每拍判 magic@35 自适应,两端可任意先后升级。</div>
</div>
</div>
<h3>1.6.21 的三个子改动,各砍掉什么活</h3>
<div class="card"><h3>① 密排整轮:固定 5 槽 + win_base 轮转扫 27 槽 → count 个密排槽</h3>
<p>旧方案段 2 固定 5 个 STATUS 槽(空槽 node_id=0 也占位),主站按 <code>win_base</code> 轮转窗口才能盖到 9 节点×3 通道=27 槽的表——接 3 台电机却会看到 5 个状态帧。新方案一拍直接带齐总线上全部电机(本台 3 台=3 槽×56B),槽内按 node_id 升序,主站按 <code>count</code> 迭代。砍掉的是<b>主站轮转窗口带来的多拍刷新分频</b>与从站"为凑满 5 槽而做的填充"。</p></div>
<div class="card"><h3>② 删段 4 覆盖窗口(fillRxWindow / RxWinSlot 整套)</h3>
<p>旧方案每拍要把非 STATUS 帧抄进 5×44B 的历史覆盖窗反复重发——这是纯粹的每拍 CPU 拷贝活。新方案非 STATUS 帧只走段 1/3 的"最近一帧",覆盖窗整套删除(图中 win 红段在密排侧不复存在)。</p></div>
<div class="card"><h3>③ 差分填充:节点集合与各 seq 未变时整段复用影子</h3>
<p>注意一个容易误解的点:电机"不动"≠数据不变——STATUS 帧按电机自己的节奏持续上报(本台 3 台合计 ~3750 帧/s,每台 ~1.25 帧/拍),所以大多数拍里槽 seq 都变了、重建路径照样走(实测槽 seq 每 ~1 拍刷新一次)。差分填充真正的收益是"没变的槽不重读不重拷、重建范围从无条件 5 槽缩到实际变化的槽"——量级是 µs 级的,<b>不能用它解释 rx 310→92</b>(见开头"重大更正"卡)。</p></div>
<h2>三、改动原理 B:1.6.22 稀疏写为何对主站往返"无效"</h2>
<p>1.6.22 的思路:PDI 往 ESC RAM 写输入镜像时按 count 只写「段1+段2 有效区」与「段3+段5」,跳过空槽区,省 <code>HW_EscWriteIsr</code> 的 PRAM 字节。<b>方向是对的,但省错了地方</b>——这里有一个关键的不对称:</p>
<div class="card warn"><h3>不对称原理:差分填充有效、稀疏写无效</h3>
<ul>
<li>主站等的关键路径 = <b>SM 交换</b>(ESC 引擎把 600B 输入区搬上线路)+ 交换前从站把镜像<b>备好</b>。</li>
<li><b>差分填充砍的是"备好镜像"的 CPU 活</b>(MotorState 重读、逐槽拷贝、覆盖窗维护)→ 直接缩短关键路径 ⇒ rx 310→92µs。</li>
<li><b>稀疏写砍的是"推镜像进 ESC RAM"的字节数</b>,但 ESC 引擎上线搬运量由 <b>SM 长度(600B)</b>决定,不看 PDI 实际写了多少 → 主站等待不变。</li>
</ul></div>
<table>
<tr><th>A/B/A 轮次(只改这一个开关)</th><th>板上版本</th><th>0.5ms 达成</th><th>长拍%</th><th>发送 p50/p99 µs</th><th>等回包 rx p50/p99 µs</th><th>load_perm</th></tr>
<tr class="best"><td>A:稀疏写=1(出厂)</td><td>1.6.22.0</td><td>99.90%</td><td>0.10</td><td>28.6 / 45.9</td><td>92.2 / 190.9</td><td>489</td></tr>
<tr><td>B:整包写=0(A/B 基线)</td><td>1.6.22.1</td><td>99.42%</td><td>0.83</td><td>29.8 / 348.9</td><td>92.2 / 210.4</td><td>441</td></tr>
<tr class="best"><td>A 回归</td><td>1.6.22.0</td><td>99.85%</td><td>0.10</td><td>29.4 / 86.9</td><td>93.0 / 101.2</td><td>—</td></tr>
</table>
<p class="meta">判读:rx p50 三轮 92.2/92.2/93.0µs ⇒ 稀疏写对主站往返无可测影响;<code>load_perm</code> 反而在整包写侧更低(441 vs 489)⇒ 它不能当这项的验收口径(真正该看 ESC 对象 0x2000 sub10,需独占路由停循环才能读,未做)。B 臂发送 p99 348.9µs 与 A 臂不同量级,但单窗样本不足以立论。当前 <code>config.h</code> 已回置出厂 A 臂(<code>CONFIG_ECAT_SPARSE_ESC_WRITE=1</code>)。</p>
<h2>四、配套改动:把"看不见的东西"变成仪表</h2>
<p>排查过程暴露了四个观测缺口,对应的改动与各自原理:</p>
<table>
<tr><th>改动</th><th>解决什么问题</th><th>原理</th><th>验证</th></tr>
<tr><td><code>flash_gd32.py</code> 生效守卫</td><td>台账只能证明"写进去了",不能证明"板上在跑"(本次 10 小时空窗的根因)</td><td>刷前记 <code>ota.gd32_app</code> 版本 + <code>gd32_adc.uptime_sec</code>;刷完必须"版本变了<b>或</b> uptime 归零"才写台账,否则非零退出不写</td><td>两轮真实刷写 1.6.22.0→.1→.0 通过</td></tr>
<tr><td><code>ecat_native.c</code> <code>cyc_us.late/lost</code> 接真值</td><td>两字段旧为硬编码 0,超期/跳拍完全不可见</td><td>最近 STATS_N 拍窗口内统计:<code>late</code>=起跑即超期(k&gt;0)的拍数,<code>lost</code>=这些拍跳过的周期数(累计仍在 skip_total)</td><td>重载窗 late=21/lost=28,健康窗 0/0</td></tr>
<tr><td><code>/api/motor/dbg</code> 尾部追加两列</td><td>发送段是黑盒:不知 sendpacket 花多久、轮询了几次</td><td>第 7/8 列 = 本拍 <code>pcap_sendpacket</code> 净耗时与 <code>pcap_next_ex</code> 次数;旧列序不变,按前 6 列下标读的消费方不受影响</td><td>0.5ms 档 181µs 调用、每拍中位 1 次 poll</td></tr>
<tr><td><code>soem-src/oshw/win32/nicdrv.c</code> pcap 调用计数</td><td>主/从栈调用混在一起没法归因</td><td>只在 <code>stacknumber==0</code>(主栈)累加 QPC 计数,冗余栈不计防双计</td><td>A/B 与旧构建行为一致(中性)</td></tr>
<tr><td><code>watch_beat_tail.py</code>(新)</td><td>跨分钟现象无法回溯(布局翻转恰好发生在两次采样之间)</td><td>每 10s 只读一轮 <code>motor/state</code>+<code>rxraw</code>+<code>app/load</code>,落 27 列 CSV(layout/work/send/rx/late/lost/λ/达成)</td><td><code>--once</code> 跑通</td></tr>
<tr><td><code>transport-ecat.js</code> 常量适配</td><td>前端仍按旧 5 槽/6 周期口径显示</td><td>密排下一拍整轮:<code>MOT_SLOTS_PER_PDO</code> 5→9、<code>MOTOR_SCAN_CYCLES</code> 6→1;能否拿齐电机由后端 decode 决定,常量只影响展示口径</td><td>电机页原样复用,无回归</td></tr>
<tr><td><code>lab/spi_test.html</code> 跳拍卡</td><td>旧版需开逐拍环才有超期数据</td><td>直接读 <code>cyc_us.late/lost</code>(最近 400 拍窗),并注明旧后端恒报 0 不可当"无超期"</td><td>真机复跑成功,<code>node --check</code> 通过</td></tr>
</table>
<h2>五、证据:dense 是真的,以及为什么今天才生效</h2>
<div class="grid">
<div class="fig"><div class="cap">③ 判据:槽 seq 距当前 gseq 的滞后分布(111 次连续采样 ×3 槽=333 样本)</div>
<svg viewBox="0 0 560 232" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="槽滞后分布">
<rect x="376" y="20" width="160" height="180" fill="#dc2626" opacity="0.06"/>
<rect x="376" y="20" width="160" height="180" fill="none" stroke="#dc2626" stroke-dasharray="4,3" opacity="0.5"/>
<text x="456" y="52" text-anchor="middle" font-size="10.5" fill="#b91c1c" font-weight="600">滑窗方案预期落点</text>
<text x="456" y="68" text-anchor="middle" font-size="10" fill="#7f1d1d">每槽每 ≥2 拍才轮到一次</text>
<text x="456" y="82" text-anchor="middle" font-size="10" fill="#7f1d1d">滞后应 ≈4~8 帧 —— 实测 0 个样本</text>
<rect x="73" y="24.5" width="34" height="170.5" fill="#2563eb"/>
<rect x="125" y="63.3" width="34" height="131.7" fill="#2563eb"/>
<rect x="177" y="75.7" width="34" height="119.3" fill="#2563eb"/>
<rect x="229" y="102" width="34" height="93" fill="#2563eb"/>
<rect x="281" y="193.5" width="34" height="1.5" fill="#2563eb"/>
<text x="90" y="20" text-anchor="middle" font-size="10" fill="#1e40af">110</text>
<text x="142" y="58" text-anchor="middle" font-size="10" fill="#1e40af">85</text>
<text x="194" y="71" text-anchor="middle" font-size="10" fill="#1e40af">77</text>
<text x="246" y="97" text-anchor="middle" font-size="10" fill="#1e40af">60</text>
<text x="298" y="189" text-anchor="middle" font-size="10" fill="#1e40af">1</text>
<line x1="60" y1="195" x2="536" y2="195" stroke="#9aa3af" stroke-width="1.2"/>
<text x="90" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">0</text>
<text x="142" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">1</text>
<text x="194" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">2</text>
<text x="246" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">3</text>
<text x="298" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">4</text>
<text x="456" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">≥6(帧)</text>
<text x="60" y="16" font-size="10.5" fill="#6b7280">样本数 · λ≈3.75 帧/拍(3750 帧/s @ 1kHz)</text>
<text x="280" y="228" text-anchor="middle" font-size="11" fill="#374151">槽 seq 滞后于当前 global_seq(帧数)</text>
</svg>
<div class="sub">三台电机落在槽 2/5/8:滑窗方案盖住它们需 ≥2 个基址轮转 → 每槽每 ≥2 拍才刷新、滞后应集中在 4~8 帧。实测 1/3 样本滞后为 0、全部 ≤4 帧 ⇒ 只能是"每拍带齐整轮"的密排语义。seq 与 gseq 都是 GD32 自己盖的号,滞后能对上同时证明解码偏移没错。(原报告曾把"gseq≡rx_count"当最强证据,复核发现两种布局 gseq 同在偏移 36,该条不能区分布局,已撤换为本判据。)</div>
</div>
<div class="fig"><div class="cap">④ 时间线:刷写 ≠ 生效</div>
<svg viewBox="0 0 620 150" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="时间线">
<line x1="70" y1="70" x2="560" y2="70" stroke="#9aa3af" stroke-width="1.6"/>
<circle cx="100" cy="70" r="5" fill="#2563eb"/>
<circle cx="290" cy="70" r="5" fill="#dc2626"/>
<circle cx="430" cy="70" r="5" fill="#b45309"/>
<circle cx="530" cy="70" r="5" fill="#047857"/>
<text x="100" y="30" text-anchor="middle" font-size="11" fill="#374151">01:44 镜像刷入 1.6.22</text>
<text x="100" y="45" text-anchor="middle" font-size="10" fill="#6b7280">台账 sha=hex 展平 ✓</text>
<text x="290" y="30" text-anchor="middle" font-size="11" fill="#b91c1c">白天:仍在跑老代码</text>
<text x="290" y="45" text-anchor="middle" font-size="10" fill="#6b7280">主站实测 win 布局 · 232B · 整拍 345µs</text>
<text x="430" y="100" text-anchor="middle" font-size="11" fill="#b45309">中午断电重启</text>
<text x="530" y="100" text-anchor="middle" font-size="11" fill="#047857">生效:dense · 124µs</text>
<path d="M100,84 L100,96 L430,96 L430,84" fill="none" stroke="#b45309" stroke-width="1.4"/>
<text x="265" y="115" text-anchor="middle" font-size="11.5" font-weight="600" fill="#b45309">≈10 小时:写入 ≠ 生效</text>
<text x="70" y="140" font-size="10" fill="#6b7280">三方计数器锚点(不依赖系统时钟):主站进程存活 2084s · GD32 桥计数归零 1914s(gseq÷λ)· ESP32 上电 1922s —— 后两者差 8s,GD32 与 ESP32 同次上电;板子比主站晚 ~2.8 分钟重启,且 uptime 单调增(只重启过一次)</text>
</svg>
<div class="sub">主站在 02:3x 直接读到 <code>layout="win" len=232</code> 配 345µs——刷入后 10 小时仍是老代码在跑。台账 <code>_last_gd32_sha.txt</code> 当时就与 Release hex 展平一致,即"写进去了"为真,"在跑了"为假。(A/B/A 之后台账现为 dda92d11…,对应 12:46 刷回 A 臂的产物,本次复核独立展平重算一致。)</div>
</div>
</div>
<h3>同机同日前后对照(观察性证据)</h3>
<table>
<tr><th>从站布局</th><th>rx 往返</th><th>整拍 p50</th><th>达成</th><th>长拍&gt;1.4×</th><th>跳拍 k&gt;0</th><th>gap p99</th></tr>
<tr><td><code>win</code>(1.6.15 滑窗)</td><td>~310µs</td><td>345µs</td><td>93.9%(1879Hz)</td><td>7.9%</td><td>8.6%</td><td>1144µs</td></tr>
<tr class="best"><td><code>dense</code>(1.6.21/22 密排)</td><td>92.2µs</td><td>123µs</td><td>99.9%(1998Hz)</td><td>0.10%</td><td>~0.6%</td><td>=周期本身</td></tr>
</table>
<p class="meta">0.5ms 档余量从 155µs 变为 377µs:旧方案任何调度抖动都会成片跳拍,新方案长拍基本消失。另 1ms 档现网 <code>late/lost</code>(k&gt;0 口径,连 1µs 超期都算)为个位数/400 拍窗,与"长拍&gt;1.4×≈0%"不矛盾——两套阈值灵敏度不同,并列引用时必须标注口径。</p>
<h2>六、被实测排除的原因(别再重测)</h2>
<table>
<tr><th>候选解释</th><th>排除依据</th></tr>
<tr><td>线路变快 / 帧变小</td><td>两种布局 Ibytes 同为 600B(<code>ecat_native.c:74</code> 与固件头文件双侧注释),帧长 ~1016B 不变,线速仍 100Mbps 全双工</td></tr>
<tr><td>收帧超时(EC_TIMEOUTRET)</td><td>预算 2000µs(µs 单位),11,264 拍里仅 1 拍 EC_NOFRAME;win 时代 rx p50 7~9µs 起步,预算从不消耗——收紧它是自证式"变好"</td></tr>
<tr><td>主站构建差异 / 双实例</td><td>插桩版与旧构建 A/B 一致;单进程单端口;旧 exe 留档 <code>build_native/ecat_native.pre-instr.exe</code></td></tr>
<tr><td>观察者负载</td><td>面板全量 10Hz×3、单 dbg 10Hz(≈1.5MB/s JSON)、零请求静默窗,三者结果在噪声内相同</td></tr>
<tr><td>CPU 抢占</td><td>另开 4 / 12 个纯算线程压机,send/rx/work 一动不动</td></tr>
<tr><td>"PRAM 逐 DWORD 轮数 150→45"</td><td>被 A/B/A 否:整包写 600B 的 B 臂同样 92µs——写侧省字节数不进入主站关键路径(§三不对称原理)</td></tr>
</table>
<h2>七、复核更正(本次排查自己错过又改掉的判断)</h2>
<div class="card bad"><h3>证据层的两处撤换</h3>
<ul>
<li><b>"gseq≡rx_count,差恒 0"是同义反复</b>:<code>MOTW_OFF_GLOBAL_SEQ</code> 与 <code>MOTW_OFF_GSEQ_DENSE</code> 都是偏移 36(<code>ecat_native.c:69,82</code>),两种布局读同一字节,分不开 dense 与 win。已撤换为 §五-③ 的槽滞后判据。</li>
<li><b>魔数 0xA5 无法从主机侧直接读</b>:<code>/api/motor/rxraw</code> 返回的是批量接收区缓冲快照,不是 PDO 输入镜像;路由表中没有任何路由吐输入镜像。故"直接验魔数"这条路不存在,dense 判定只能靠上述间接判据 + 60s×30 与 111 次采样稳定性(layout 恒 dense、len 恒 176、count 恒 3、win_bases 恒 None)。</li>
</ul></div>
<div class="card bad"><h3>限制声明的一处更正(本次复核新增)</h3>
<ul>
<li><b>"DCU_APP 不在任何 git 仓库、布局无法 A/B"不成立</b>:<code>DCU_APP</code> 是独立 git 仓库(38 个提交)——<code>959e2ae</code> = 1.6.14~1.6.20 滑窗覆盖窗源码、<code>1742d9a</code> = 1.6.21 密排;命令行构建链(cbuild + AC6)现成。因此"win→dense 换来 220µs"这一环<b>可以做受控实验钉死</b>:checkout <code>959e2ae</code> 重编刷板 → 预期 rx 回到 ~310µs → 再刷回 1.6.22(flash_gd32.py 生效守卫会留台账)。属刷板操作,未执行。</li>
<li><b>遗留:重刷同一版本无法证明生效</b>——<code>gd32_adc.uptime_sec</code> 实测不随 GD32 换镜像归零,守卫的"uptime 归零"分支不可靠,目前只能靠版本号变化。根治要 BOOT 上报复位计数/复位原因,或 OTA 完成后强制 APP 复位回读。</li>
</ul></div>
<div class="card warn"><h3>归因的置信度边界(2026-09-12 修订)</h3>
<ul>
<li>已受控验证:稀疏写开关(A/B/A,单臂 2048 拍静默窗,效应上界 ≲1µs/拍);排除项(§六);"刷写≠生效"时间锚点(§五)。</li>
<li><b>已动摇</b>:win→dense 与 220µs 的因果归因——9/8 同布局实测 124.6µs 的反例 + 两版 buildInput 纯 CPU 差仅 1~3µs。原"三条独立吻合"里"旧值≈357µs"一条存疑:357µs 是 1.6.11 时代(496B 输入 + 环形批量区)的测量,与滑窗 600B 版不同代。</li>
<li>待做:开头"重大更正"卡的刷板 A/B(959e2ae + prev.exe 双轮)是当前唯一能闭合因果的实验。</li>
</ul></div>
<h2>八、遗留与下一步</h2>
<div class="card"><h3>按优先级</h3>
<ul>
<li><b>布局 A/B 钉死归因(方案已更新)</b>:刷回 <code>959e2ae</code>(win 固件)后做两轮——配 <code>ecat_native.prev.exe</code>(旧主站,≤78fe6cf)与现主站各测一轮 0.5ms:124µs / 345µs 的分野即判定回归在主站还是从站 win 期固件;再用 0x2000 sub5/6/9/10 定位。刷板会中断 OP 会话,需专门安排。</li>
<li><b>0.25ms 档第一次有理论余量</b>(124/250=50%),值得按同一静默判据实测——但注意此前结论:从站 SSC 的 SM2 事件路径在快档有自己的墙,别只看主站达成率。</li>
<li><b>重刷同版本的生效证明</b>:BOOT 上报复位计数,或 OTA 后强制 APP 复位回读版本。</li>
<li><code>0x2000 sub10</code>(EscWrite 周期)精确验收需独占路由(停循环、从站掉 OP),要专门开一窗。</li>
<li>ms 级离群未清零:dense 时代仍见 work max 2153µs / rx max 1658µs(p90 仅 ~110µs),量级无害但没定位。</li>
<li>本次全部改动<b>未提交</b>(tools/ecat 3 文件+报告、tools/ESP32 7 文件、DCU_APP 4 文件),旧主站 exe 留档在 <code>build_native/ecat_native.pre-instr.exe</code>。</li>
</ul></div>
<div class="foot">本报告依据 <code>tools/ecat/REPORT_ecat_beat_tail_20260911.md</code>、当日调查会话与复核证据整理;所有代码引证(偏移定义、357µs、路由表、台账 sha)已于 2026-09-11 对照源码与实机独立核验,实时复测 rx p50=92.1µs / layout=dense / wkc 3/3 / v1.6.22.0。相关页面:<a href="ECAT_RATE_TRADEOFF.html">ECAT_RATE_TRADEOFF.html</a>(周期×故障率×CPU 调研)· <code>lab/spi_test.html</code>(SPI/ECAT 测速)。</div>
</main></body></html>