Files
DCU_DesktopController/GD32_OTA_FLOW_COMPARE.html
T
cbb 383535bd1d feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本
变更点:
1. esp32_server.py
   - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在)
   - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP
2. js/common/desktop-bridge.js(新)
   - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥
3. connection.js + responsive.css + components.css
   - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画
   - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘
4. device-link.js
   - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时
   - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期
5. motor/main.js + transport-ecat.js
   - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出
   - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp
6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态
7. .gitignore
   - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
2026-09-20 22:58:00 +08:00

270 lines
29 KiB
HTML
Raw 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>GD32 刷写流程对比:ESP32 中继(老)× EtherCAT 直灌(新)</title>
<style>
:root{--fg:#111827;--mut:#6b7280;--bd:#e5e7eb;--bg:#f8fafc;--acc:#1d4ed8;--warn:#b45309;--bad:#b91c1c;--ok:#15803d}
*{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:1180px;margin:0 auto;padding:34px 28px 90px}
h1{font-size:28px;line-height:1.3;margin:0 0 6px;letter-spacing:-.2px}
h2{font-size:19px;margin:34px 0 11px;padding-bottom:7px;border-bottom:2px solid var(--bd)}
h3{font-size:15.5px;margin:24px 0 7px;color:#374151}
.meta{color:var(--mut);font-size:13.5px;margin-bottom:22px}
.card{background:var(--bg);border:1px solid var(--bd);border-left:4px solid var(--acc);border-radius:8px;padding:14px 19px;margin:16px 0}
.card.warn{border-left-color:var(--warn);background:#fffbeb}
.card.bad{border-left-color:var(--bad);background:#fef2f2}
.card.good{border-left-color:var(--ok);background:#f0fdf4}
.card h3{margin:0 0 7px;font-size:15px;color:inherit}
table{border-collapse:collapse;width:100%;font-size:13px;margin:13px 0}
th,td{border:1px solid var(--bd);padding:6px 9px;text-align:left;vertical-align:top}
th{background:#f1f5f9;font-weight:600;text-align:center}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
td.c{text-align:center}
tr.hi td{background:#ecfdf5}
tr.lo td{background:#fef2f2}
code{background:#f1f5f9;padding:1.5px 5px;border-radius:4px;font-size:12.5px;font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}
.ev{color:var(--mut);font-size:11.5px;font-family:ui-monospace,Consolas,monospace}
ul{padding-left:21px}
.kpi{display:flex;gap:13px;flex-wrap:wrap;margin:20px 0 6px}
.kpi div{flex:1 1 172px;border:1px solid var(--bd);border-radius:8px;padding:12px 14px;background:#fff}
.kpi b{display:block;font-size:22px;line-height:1.25;color:var(--acc)}
.kpi span{font-size:12px;color:var(--mut)}
.kpi div.warn b{color:var(--warn)}
.tabs{position:sticky;top:0;z-index:9;background:#fff;padding:14px 0 0;margin:22px 0 4px;border-bottom:2px solid var(--bd);display:flex;gap:8px;flex-wrap:wrap}
.tab{appearance:none;border:1px solid var(--bd);border-bottom:none;background:#f1f5f9;color:#374151;padding:9px 17px;border-radius:8px 8px 0 0;font-size:14px;cursor:pointer;font-family:inherit;position:relative;top:2px}
.tab:hover{background:#e8eef6}
.tab.on{background:#fff;color:var(--acc);font-weight:600;box-shadow:0 2px 0 #fff}
.tab .cnt{font-size:11px;color:var(--mut);font-weight:400}
.page{display:none}.page.on{display:block}
.chain{display:flex;align-items:stretch;flex-wrap:wrap;margin:16px 0 6px}
.node{flex:1 1 128px;min-width:124px;border:1px solid var(--bd);border-radius:8px;background:#fff;padding:9px 11px;font-size:12.5px;line-height:1.5}
.node b{display:block;font-size:13px;margin-bottom:2px}
.node .t{color:var(--mut);font-size:11.5px}
.node.hi{border-color:var(--acc);background:#eff6ff}
.node.drop{border-color:#fca5a5;background:#fef2f2}
.arrow{flex:0 0 24px;display:flex;align-items:center;justify-content:center;color:#9aa3af;font-size:17px}
.byte{display:flex;gap:2px;flex-wrap:wrap;margin:9px 0}
.byte div{border:1px solid var(--bd);border-radius:4px;padding:5px 7px;font-size:11.5px;font-family:ui-monospace,Consolas,monospace;background:#fff;text-align:center;line-height:1.45}
.byte div small{display:block;color:var(--mut);font-size:10.5px;font-family:inherit}
.byte div.key{background:#eff6ff;border-color:#bfdbfe}
.byte div.meta2{background:#fffbeb;border-color:#fde68a}
.legend{font-size:12.5px;color:var(--mut);margin:6px 0 0}
.foot{margin-top:44px;padding-top:15px;border-top:1px solid var(--bd);font-size:12.5px;color:var(--mut)}
</style></head><body><main>
<h1>GD32 刷写流程对比:ESP32 中继(老)× EtherCAT 直灌(新)</h1>
<div class="meta">2026-09-13 · BOOT <code>v1.1.0.7</code>(<code>ER_ROM0 64,552 / 73,728 B</code>)· 主站 <code>ecat_native.exe</code> 22:18 构建 · 每条数字带 <span class="ev">文件:行号</span> · 镜像基准 = <code>DCU_APP</code> Debug <code>.hex</code> 展平后 <b>124,992 B</b>(本机实测)</div>
<div class="kpi">
<div><b>2 604 → 527</b><span>同张镜像的链路往返次数(结构硬数字)</span></div>
<div><b>48 → 241 B</b><span>每帧净落 flash 的字节数</span></div>
<div><b>252 KB → 无</b><span>中间存储:ESP32 缓存分区整个消失</span></div>
<div class="warn"><b>0 次</b><span>新流程的上板实测次数 —— 一条都没跑过</span></div>
</div>
<div class="card"><h3>一句话结论</h3>
新流程<b>不是"更快的刷法",是"少一层先决条件的刷法"</b>。省下的 5 倍往返是副产品,真正的收益是:出厂首刷不再要求板上先有一颗刷好并配好网的 ESP32。两条路并存互为备份,而且刻意复用了同一个 flash 写入者、同一个提交顺序、同一条掉电不变式 —— 新流程没有引入第二套"刷坏了怎么回来"的语义。</div>
<nav class="tabs">
<button class="tab on" data-p="cmp">对比</button>
<button class="tab" data-p="old">老流程 <span class="cnt">WiFi → ESP32 → SPI</span></button>
<button class="tab" data-p="new">新流程 <span class="cnt">EtherCAT CoE SDO</span></button>
</nav>
<section class="page on" id="p-cmp">
<h2>逐维度对照</h2>
<table>
<tr><th style="width:126px">维度</th><th>老:WiFi → ESP32 中继 → SPI</th><th>新:PC 主站 → EtherCAT → SDO</th></tr>
<tr class="hi"><td><b>先决条件</b></td><td>板上必须已有 ① 刷好固件的 ESP32 ② 配好网的 ESP32 ③ 上位机知道设备 IP。<span class="ev">esp32_server.py:830</span></td><td>总线上有电、从站在 PREOP 即可。<b>ESP32 可以完全没刷过。</b></td></tr>
<tr><td><b>参与部件</b></td><td>浏览器 → 本机 2625(只发静态页)→ ESP32:80 → GD32。<span class="ev">esp32_server.py:1-7</span></td><td>浏览器 → 同一个 <code>ecat_native.exe</code>(页面也它发的)→ Npcap/I210 → GD32。<b>ESP32 不在链路里。</b></td></tr>
<tr><td><b>镜像中间存储</b></td><td>ESP32 flash 的 <code>gd32_ota</code> 分区 <b>252 KB</b>@0x11000,两段式:先整份存下算 SHA,<code>/ota/relay</code> 才起中继。<span class="ev">partitions.csv:7-8;http_handlers.cpp:515;ota_relay.cpp:1128</span></td><td>PC 内存 216 KB 静态缓冲 <code>BOOT_DL_IMG_MAX 0x36000</code>。不落任何中间设备。</td></tr>
<tr><td><b>浏览器→服务端请求</b></td><td>1 次整文件 POST(<code>/ota/upload</code>)+ 1 次 <code>/ota/relay</code> + 轮询。<b>无断点续传</b>:每次从偏移 0 重写。<span class="ev">upload-esp32.js:238-247;http_handlers.cpp:440</span></td><td>6 次 push(每次 24,576 B → hex 49,152 字符,卡在 native 的 65,535 B body 上限内)+ 1 次 run。<span class="ev">已冒烟:received 24576→49152→73728 精确累加</span></td></tr>
<tr class="hi"><td><b>每帧净落 flash</b></td><td>线上一帧 = v2 头 16 B + DATA 64 B + CRC 4 B;64 B 里只有 <b>48 B</b> 是固件。<span class="ev">config.h:140-146;ota_relay.cpp:1135-1151</span></td><td>4 KB 块 = <b>17 个邮箱帧</b>(1 initiate 带 240 B + 16 段×247 B)⇒ 每帧 <b>241 B</b>,是老的 5.0 倍。<span class="ev">sdoserv.c:210,744</span></td></tr>
<tr class="hi"><td><b>链路往返次数</b></td><td>124,992 / 48 = <b>2 604 包</b>,每包一次「发 → 等 ACK(50 ms 上限,最多重发 5 次)」。<span class="ev">ota_relay.cpp:1181,1185</span></td><td>124,992 / 4096 = <b>31 次 SDO 传输</b> = 527 帧,外加总长 / begin / commit 三个控制 SDO。</td></tr>
<tr><td><b>鉴权</b></td><td>AES-128 三步双向握手,会话密钥作为前缀喂进每包 CRC32(防重放/篡改)。<span class="ev">OTA.cpp:976-1246,1414-1442</span></td><td><b>出厂形态零鉴权。</b>唯一裁决点 <code>ecatArmed()</code> = 配置页令牌 <b>或</b> A 区不可启动;commit 成功即收回令牌,用完即弃。<span class="ev">OTA.cpp:792-812,905-915</span></td></tr>
<tr><td><b>主站复判身份?</b></td><td>不存在该问题 —— ESP32 是唯一持有 AES 密钥的对端。</td><td><b>不复判。</b>读 <code>0x1008</code> 只当日志读数。APP 的对象字典里没有 <code>0x2500/0x2501</code>(实测只有 0x1600/0x1A00/0x1C12/0x1C13/0x2000/0x6000/0x7000),写上去只会 SDO abort,擦不到一个字节。</td></tr>
<tr class="hi"><td><b>掉电不变式</b></td><td colspan="2"><b>完全相同,且是刻意复用的。</b>擦除前先把 A 区三面标志(CHECK/AVAL/EN)全标 <code>NOT_AVAL</code> ⇒ 任何时刻掉电都表现为「AVAL 未置 = 不可启动 = 留守 BOOT 可重灌」。<span class="ev">OTA.cpp:635-637;config.h:37-43;main.cpp:229-402</span></td></tr>
<tr><td><b>提交顺序</b></td><td>向量表校验 → <code>calculateAppCrc</code>+<code>writeAppCrc</code> → CHECK → AVAL → EN;跳转延后到 <code>process()</code> 排空 <code>pending_app_jump</code>。<span class="ev">OTA.cpp:372-410</span></td><td><b>逐字对齐同一条</b>,只在 CRC 之后多插一步收回令牌。CRC 必须先于 AVAL —— <code>verifyAppCrc()</code> 对擦除态 0xFFFFFFFF 返回 true,真门控是三面标志。<span class="ev">OTA.cpp:900-915</span></td></tr>
<tr><td><b>对在跑业务的影响</b></td><td>SPI 中继是独立链路,不占 EtherCAT 环;但会话期间 BOOT 会丢弃 CAN OTA 帧。<span class="ev">OTA.cpp:303-309;Can.cpp:174-179</span></td><td><code>run</code> 全程持 <code>g_bus_lock</code> + <code>loop_stop()</code> ⇒ PDO 循环冻结数秒。但目标本来就得停在 BOOT(进不了 SAFEOP/OP),实际没有循环可冻。</td></tr>
<tr><td><b>可观测性</b></td><td>进度靠轮询 <code>/system/status</code> 的 <code>relay_seq/relay_total_packets</code>;每 32 包一次健康检查。<span class="ev">upload-gd32.js:58,194;app_config.h:86</span></td><td>从站 <code>0x2500:03</code> 实时报已落盘字节、<code>:04</code> 报 arm/会话位;主站 <code>probe</code> 一次读回,进度条两侧各一个独立读数。</td></tr>
<tr><td><b>刷 BOOT 自己</b></td><td colspan="2"><b>两条都不行。</b>BOOT 跑在 <code>0x08000000</code>,改不了自己正在执行的 flash ⇒ 永远只能 SWD/JTAG。新流程没改变这一点。</td></tr>
<tr class="lo"><td><b>刷"正在跑 APP 的机器"</b></td><td>可以 —— 这正是老流程的主用途(APP 收 OTA 请求、复位进 BOOT 再灌)。<span class="ev">main.cpp:249-272</span></td><td><b>还不行。</b>A 区可启动 ⇒ 令牌是硬门,而置令牌的入口没接(<code>ecatArmSet</code> 目前只有 commit 里那个"收回"调用者)。要么 BOOT 加密会话加命令码(要花余下的 9,176 B),要么 APP 侧复位进 BOOT 前自己写 word8。</td></tr>
<tr><td><b>BOOT flash 代价</b></td><td>基线 36,340 B</td><td>+28,212 B ⇒ 64,552 B,<b>余 9,176 B(用 87.6%)</b>;RAM 32,392 B(堆为分段下载放大到 16 KB,栈仍 1 KB)。<span class="ev">GD32E50X_BOOT.axf.map</span></td></tr>
<tr><td><b>成熟度</b></td><td>产线在用;有 CLI 与刷写台账 —— 只有 <code>ESP32/scripts/flash_gd32.py</code> 写 <code>lab/_last_gd32_sha.txt</code>,且要拿到"板上版本变了 / uptime 归零"的证据才写。<span class="ev">scripts/flash_gd32.py:36,132-148</span></td><td><b>代码接通、零上板。</b>主站软件路径已冒烟(路由分发 / 缓冲累加 / 边界值全过),但 ESC 起板、<code>0x1008</code> 可读、SDO 字节序三条只有源码级推导。</td></tr>
</table>
<div class="card warn"><h3>关于"快了多少" —— 不能说</h3>
老流程 ESP32 侧注释有实测:<b>正常 ~2 ms/包</b>、"777 包正常仅 ~1.6 s"。<span class="ev">ota_relay.cpp:356-368</span> 按这个常数换算,124,992 B = 2 604 包 ≈ <b>5.2 s</b> 纯传输。<br>
新流程<b>没有任何一次真机计时</b>,这里填不出可比的数字 —— 5 倍往返差是代码结构决定的硬事实,端到端耗时不是。第一次上板要记的就是三件事:begin 实际阻塞多久、单块实际耗时、整轮总耗时。</div>
<div class="card good"><h3>为什么值得两条都留</h3>
<ul>
<li><b>覆盖场景不重叠</b>:出厂首刷只有新流程能做(那时还没有 ESP32 固件);刷在跑的机器目前只有老流程能做(有 AES 通道)。</li>
<li><b>故障域独立</b>:WiFi 配不上 / ESP32 没刷 / 网卡拉不起 / 从站没进 PREOP —— 任意一边坏,另一边还是条路。</li>
<li><b>共用同一个落盘点</b>:两条最终都调 <code>eraseApplicationArea / writeApplicationData / validateApplication / OTA_SET</code>;BOOT 里始终只有一个 flash writer,<code>ecatBegin</code> 在 SPI/CAN 会话活跃时直接拒绝并发。</li>
</ul></div>
<h2>新流程的未证清单</h2>
<table>
<tr><th style="width:40px">#</th><th>待证</th><th>判据(一条就够)</th></tr>
<tr><td class="c">1</td><td>BOOT 侧 ESC 起不起得来(EXMC/HBI/RST#/IRQ 这套移植层从未在 BOOT 跑过)</td><td>串口出 <code>[BOOT][ESC] PDI ok DL=… 邮箱可用</code>;否则 <code>FAIL PDI 未就绪 DL=… EEP=…</code></td></tr>
<tr><td class="c">2</td><td>主站认不认出它在 BOOT</td><td><code>probe</code> 回 <code>name="GD32E508 DCU BOOT"</code> 且 <code>armed=1</code></td></tr>
<tr><td class="c">3</td><td>SDO 分段载荷字节序(源码推得原序,未实测)</td><td>用现成 <code>/api/sdo</code> 往 <code>0x2501</code> 写 <code>00 01 02 … 0F</code>,看落 flash 后回读是否逐字节同序;不对就只在 <code>ObjFoeDlWrite</code> 一处加转换</td></tr>
<tr><td class="c">4</td><td>begin 的整区预擦到底阻塞多久、8 s 超时够不够</td><td>串口时间戳与主站 <code>ec_SDOwrite</code> 往返计时对表</td></tr>
<tr><td class="c">5</td><td>刷完 APP 真能起来</td><td>重新连接后 <code>probe</code> 的设备名变回 <code>"GD32E508 DCU"</code> —— 从另一次启动读的,不自证</td></tr>
</table>
<div class="card bad"><h3>顺带查到的一个既有缺陷(老流程同样有)</h3>
<code>writeApplicationData()</code> 的循环条件是 <code>i + 4 &lt;= size</code> ⇒ <b>不足 4 字节的尾巴被静默丢弃</b>,注释里写的"用 0xFF 补齐一个 word"代码并没有做。<span class="ev">OTA.cpp:762-782</span><br>
老流程每包固定 48 B(4 对齐)所以碰不到,只有<b>非 4 对齐的镜像总长</b>会在最后一包丢 1~3 字节;新流程在主站侧补 0xFF 到 4 对齐并按补齐长度声明总量,绕开了这个坑,但<b>没有去改已在产线跑的 SPI 路径</b>。要修该单独一次改动、单独一次回归。</div>
</section>
<section class="page" id="p-old">
<h2>老流程:浏览器 → ESP32 → SPI → GD32 BOOT</h2>
<div class="chain">
<div class="node"><b>浏览器</b><span class="t">算 SHA-256<br>整文件一次 POST</span></div><div class="arrow">→</div>
<div class="node"><b>本机 2625</b><span class="t">只发静态页<br>不碰硬件</span></div><div class="arrow">→</div>
<div class="node"><b>ESP32 :80</b><span class="t">/ota/upload<br>每 4 KB 写分区</span></div><div class="arrow">→</div>
<div class="node drop"><b>ESP32 flash</b><span class="t">gd32_ota 252 KB<br>NVS 存元数据</span></div><div class="arrow">→</div>
<div class="node"><b>/ota/relay</b><span class="t">AES 三步握手<br>逐包读回转发</span></div><div class="arrow">→</div>
<div class="node hi"><b>GD32 BOOT</b><span class="t">SPI ISR 入队<br>主循环写 flash</span></div>
</div>
<p class="legend">红框 = 新流程里整个消失的一跳。</p>
<h3>关键常量</h3>
<table>
<tr><th>项</th><th class="n">值</th><th>出处</th></tr>
<tr><td>每帧总长 / 其中固件数据</td><td class="n">64 B / <b>48 B</b></td><td class="ev">config.h:140-146</td></tr>
<tr><td>SPI v2 帧开销</td><td class="n">20 B(16 头 + 4 CRC)</td><td class="ev">spi_link_v2.h:34-36</td></tr>
<tr><td>SPI 时钟 / 注释实测每帧</td><td class="n">13.333 MHz / ≈96 µs</td><td class="ev">sdkconfig.defaults:20;spi_master.cpp:80</td></tr>
<tr><td>ISR→主循环 DATA 槽 / flash 写缓冲槽</td><td class="n">4 / <b>8</b></td><td class="ev">config.h:178,180</td></tr>
<tr><td>ESP32 上传块 / 固件上限</td><td class="n">4 096 B / 216 KB</td><td class="ev">app_config.h:74,76</td></tr>
<tr><td>单包等 ACK / 最多重发 / 跨包停刷</td><td class="n">50 ms / 5 次 / 10 s</td><td class="ev">ota_relay.cpp:1181,1185,53</td></tr>
<tr><td>转发预算</td><td class="n">25 ms/包,总 10~120 s</td><td class="ev">ota_relay.cpp:356-368</td></tr>
<tr><td>BOOT 会话超时 / APP 延后启动窗口</td><td class="n">50 000(≈1 s) / 10 000 ms</td><td class="ev">config.h:168,169</td></tr>
<tr><td>握手各步超时(ESP32 侧)</td><td class="n">800 / 5000 / 3000 / 3000 ms</td><td class="ev">ota_relay.cpp:942,1036,1062,1090</td></tr>
</table>
<h3>一帧 DATA 的字节排布</h3>
<div class="byte">
<div class="key">0–3<small>会话号 LE</small></div>
<div class="key">4–7<small>包号 LE</small></div>
<div class="meta2">8–55<small>48 B AES-128 密文</small></div>
<div class="key">56–59<small>结束标志 IFEND</small></div>
<div class="key">60–63<small>CRC32(密钥参与)</small></div>
</div>
<p class="legend">外面再套 SPI v2 头:SOF0/SOF1/VER/FLAGS、SESSION@4、TX_SEQ@8、ACK_SEQ@9、CREDIT@10、TYPE@11、LEN@12,尾部 4 B MPEG-2 CRC32。<span class="ev">spi_link_v2.h:34-51;OTA.cpp:1002-1018;ota_relay.cpp:1135-1151</span></p>
<h3>握手三步(双向认证)</h3>
<table>
<tr><th style="width:38px">步</th><th>上位机 → BOOT</th><th>BOOT → 上位机</th><th>出处</th></tr>
<tr><td class="c">1</td><td>魔术字 <code>00 AC</code></td><td>回显 ACK,转 <code>WAITING_RANDOM</code></td><td class="ev">OTA.cpp:976-988</td></tr>
<tr><td class="c">2</td><td>16 B <code>nonce_server</code></td><td>36 B = UID(12)+nonce[8..15](8)+<code>AES(K_master,K_session)</code>(16),转 <code>WAITING_CONFIRM</code></td><td class="ev">OTA.cpp:1195-1219</td></tr>
<tr><td class="c">3</td><td><code>AES(K_session, nonce XOR 0x55)</code> 16 B</td><td><code>0xBA 0xC0</code>,转 <code>CHECKING_FIRMWARE</code></td><td class="ev">OTA.cpp:1224-1246</td></tr>
</table>
<p>状态机全序 <code>IDLE → WAITING_RANDOM → WAITING_CONFIRM → CHECKING_FIRMWARE → RECEIVING_FIRMWARE → FINISHED</code><span class="ev">OTA.h:223-230</span>。每包 CRC32(IEEE) 先把 16 B 会话密钥喂进去、再喂 48 B 明文,所以改一比特或重放旧包都对不上。<span class="ev">OTA.cpp:1414-1442</span> 主密钥两端硬编码同值。<span class="ev">config.h:158-161;app_config.h:150-153</span></p>
<h3>从收到字节到落 flash</h3>
<ul>
<li>EXTI ISR 只把帧入队(4 槽)→ <code>SpiOta::poll()</code> 在主循环解密校验并回 ACK → 再 drain 8 槽环形缓冲逐字 <code>fmc_word_program</code>。<span class="ev">SpiOta.cpp:99-114;OTA.cpp:725-757</span></li>
<li>背压靠 credit:<code>g_localCredit = 8 − flash_buf_cnt − pending</code>。<b>没有固定速率上限,也没有每包往返耗时统计。</b><span class="ev">OTA.cpp:1082-1085</span></li>
<li>去重:<code>包号 &lt; next_expected_packet</code> 判重,只回 ACK 不二次写 flash;重复 SPI 帧走 DUP-SEQ 回放缓存的最终 ACK。<span class="ev">OTA.cpp:1021-1031;SpiOta.cpp:409-427</span></li>
<li>擦除只擦本轮需要的页:<code>ceil(expected_size/2048)+1</code> 页,另单独保证 CRC 页被擦。<span class="ev">OTA.cpp:635-712</span></li>
<li>ACK 帧:<code>0xBA 0xC0</code> + 4 B 期望下一包号;末包特殊 seq <code>0xF0F0F0F0</code>;错误帧多 1 B 错误码。<span class="ev">OTA.cpp:251-260,1103,1285-1295</span></li>
<li>前端防呆:选文件即读前 8 字节判包身份(MSP∈SRAM、复位向量∈A 区;落 BOOT 区即拒),HEX 自动展平成 BIN(间隙填 0xFF)。<span class="ev">file-handle.js:458-516,563-574</span></li>
</ul>
<h3>失败与恢复分流</h3>
<p><code>main.cpp run()</code> 读三面标志分流:二者皆 AVAL 且 CRC 过 → 直接跳;CHECK AVAL / EN NOT_AVAL → 开 10 s 等 OTA 窗口;A 区坏但 B 区有效 → <code>move_app_data()</code> 整体搬回;都不行 → 长期留守等重下。FWDGT 复位会把 EN 标 NOT_AVAL,阻止反复跳进卡死的固件。<span class="ev">main.cpp:204-210,229-402</span></p>
<div class="card warn"><h3>代码里没有的东西(别当成有据)</h3>
<ul>
<li>SPI 中继链路的<b>实测</b>端到端耗时 / KB 每秒 —— 只有预算常数和注释里的 ~2 ms/包。</li>
<li>WiFi/HTTP 上传的断点续传:NVS 元数据预留了 <code>stored_size/last_seq</code>,HTTP 路径<b>未实装</b>。<span class="ev">gd32_flash_store.h:23;ota_coordinator.cpp:246</span></li>
<li>BOOT 侧握手各步的独立毫秒超时:统一走 <code>OTA_TIMEOUT</code> 计数。<span class="ev">OTA.cpp:431</span></li>
</ul></div>
<div class="card"><h3>同一后端的三条入口</h3>
浏览器、<code>tools/flash_gd32.py</code>(裸 BIN、不写台账)、<code>tools/ESP32/scripts/flash_gd32.py</code>(带 <code>{"total_size":N}</code> 头、写台账)三条都打 ESP32 固件 80 端口的同一组 <code>/ota/upload + /ota/relay + /system/status</code>,<b>没有独立通道</b>;设备侧对带不带 JSON 头都兼容。<span class="ev">http_handlers.cpp:403-407;tools/flash_gd32.py:88;scripts/flash_gd32.py:98</span></div>
</section>
<section class="page" id="p-new">
<h2>新流程:PC 主站 → EtherCAT 邮箱 → BOOT 写 flash</h2>
<div class="chain">
<div class="node"><b>浏览器</b><span class="t">ecat.html「BOOT 刷写」<br>页内自带 HEX→BIN</span></div><div class="arrow">→</div>
<div class="node"><b>ecat_native :8090</b><span class="t">/api/ota/ecat<br>镜像攒在 PC 内存</span></div><div class="arrow">→</div>
<div class="node"><b>I210 / Npcap</b><span class="t">loop_stop 独占<br>CoE SDO 分段下载</span></div><div class="arrow">→</div>
<div class="node"><b>LAN9252</b><span class="t">SM0 邮箱 256 B<br>PRAM FIFO 逐 DWORD</span></div><div class="arrow">→</div>
<div class="node hi"><b>GD32 BOOT</b><span class="t">SSC 攒满 4 KB 才回调<br>OTAHandler 写 flash</span></div>
</div>
<p class="legend">只有 5 跳,且 ESP32 完全不在里面。页面本身也是 <code>ecat_native</code> 发的,所以没有跨域、没有第二个服务。</p>
<h3>操作时序(六个 op,只有后两个碰总线)</h3>
<table>
<tr><th style="width:100px">op</th><th>做什么</th><th class="n">碰总线?</th><th>要点</th></tr>
<tr><td><code>open</code></td><td>声明镜像长度,清 PC 侧缓冲</td><td class="c">否</td><td>超 221,184 B 直接拒。<b>不抢 <code>g_bus_lock</code>、不 <code>loop_stop</code></b>,所以上传过程不会把正在跑的电机循环冻掉</td></tr>
<tr><td><code>push</code></td><td>追加一段 hex 到镜像尾部</td><td class="c">否</td><td>24,576 B/次 = hex 49,152 字符,卡在 <code>handle_conn</code> 的 65,535 B body 上限内</td></tr>
<tr><td><code>status</code> / <code>abort</code></td><td>查已收字节 / 丢缓冲</td><td class="c">否</td><td><code>abort</code> 只清 PC 侧,总线一个字节都没动</td></tr>
<tr><td><code>probe</code></td><td>读 <code>0x1008</code> 设备名 + <code>0x2500:03/:04</code></td><td class="c">是</td><td><b>纯诊断读数,不是门槛</b></td></tr>
<tr><td><code>run</code></td><td>真正的灌写:总长 → begin → 31 块 → commit</td><td class="c">是</td><td>全程持 <code>g_bus_lock</code> + <code>loop_stop()</code>;只有 commit 成功才不把 PDO 循环还回来(从站正在硬复位)</td></tr>
</table>
<h3>BOOT 侧只挂两个对象</h3>
<table>
<tr><th style="width:92px">索引</th><th class="n">类型</th><th>条目</th><th>语义</th></tr>
<tr><td><code>0x2500</code></td><td class="n">REC ×4<br>UINT32</td><td>:0 条目数 / :1 命令 / :2 总长 / :3 已收 / :4 状态</td>
<td>:1 写 <code>1</code>=begin、<code>2</code>=commit、<code>3</code>=abort;:4 bit0=允许刷写、bit1=会话活跃、高 16 位=错误码。<b>:3/:4 只读</b>,读的时候现取 <code>ota.ecatReceived()</code></td></tr>
<tr><td><code>0x2501</code></td><td class="n">VAR<br>OCTETSTRING<br>4 096 B</td><td>只有 <code>:00</code></td>
<td>数据通道。建成 VAR 而不是 ARR:VAR 的 <code>maxSubindex</code> 恒为 0,所以<b>只能写 <code>:00</code>,且 complete access 一律 UNSUPPORTED_ACCESS</b> ⇒ SOEM 侧必须传 <code>CA=FALSE</code>。<span class="ev">objdef.c:1278,1295,1352</span></td></tr>
</table>
<p>BitSize 是 UINT16 ⇒ 单条目天花板 8191 B,块长取 4096 就是这个原因。SSC 的分段下载<b>攒满整块才调一次 <code>OBJ_Write</code></b>(最后一帧才分发,且分发前不做通用长度校验),所以从站侧不需要 begin/data/commit 拼包状态机。<span class="ev">sdoserv.c:292-295;objdef.c:1352;sdoserv.c:714</span></p>
<h3>一个 4 KB 块在总线上的样子</h3>
<div class="byte">
<div class="meta2">initiate<small>带 240 B</small></div>
<div class="key">段 1<small>247 B</small></div>
<div class="key">段 2<small>247 B</small></div>
<div class="key">…<small>×14</small></div>
<div class="key">段 16<small>151 B</small></div>
</div>
<p class="legend">共 <b>17 帧搬 4096 B</b>。段长上限两端各算一次取小者:SOEM <code>maxdata = mbx_l − 0x10 = 240</code>(首帧带数),SSC 段 <code>256 − 6 − 3 = 247</code>。全图 124,992 B ⇒ 31 块 = 527 帧。<span class="ev">ethercatcoe.c:346,450;sdoserv.c:210</span></p>
<h3>放行判据只有一个执行点</h3>
<div class="card good">
<p><code>ecatArmed() = 配置页 word8 == 0xACA70001 || !(CHECK==AVAL &amp;&amp; EN==AVAL)</code></p>
<p>后半句和 <code>main.cpp run()</code> 决定"跳 APP"用的是同两个 flash 字,所以四种 BOOT 留守情形(空板首刷 / 下载中断电 / A 区 CRC 失败 / FWDGT 复位)天然放行,不需要任何先决条件;反过来 APP 正常在跑时两字皆 <code>OTA_AVAL</code>,令牌就是硬门 —— 而令牌写不进(SDO 摸不到配置页),所以刷不动一台正常机器。</p>
<p><code>ecatCommit()</code> 成功路径里先 <code>ecatArmSet(false)</code> 收回令牌再置三面标志:用完即弃。此刻 CHECK/EN 还是 <code>eraseApplicationArea</code> 标的 NOT_AVAL,所以收回这一拍救砖分支照样为真,不影响本次提交。<span class="ev">OTA.cpp:792-812,905-915</span></p>
</div>
<h3>刻意复用老流程的部分(一条都没新写)</h3>
<ul>
<li>擦:<code>eraseApplicationArea()</code> —— 同样先标三面 NOT_AVAL,同样按 <code>ceil(size/2048)+1</code> 页。</li>
<li>写:<code>writeApplicationData()</code> —— 唯一区别是主站先把镜像补 0xFF 到 4 对齐(<code>ecatFeed</code> 另有游标 4 对齐前置校验)。</li>
<li>校验:<code>validateApplication()</code> —— MSP∈SRAM、复位向量∈A 区,与前端包身份判定同判据。</li>
<li>提交:CRC → CHECK → AVAL → EN 同序;跳转都靠 <code>process()</code> 排空 <code>pending_app_jump</code>,<b>不在收包/邮箱上下文里跳</b>(否则会把 SM0 半包留在总线上)。</li>
<li>并发:<code>ecatBegin</code> 在 <code>isActive()</code>(SPI/CAN 会话活跃)时直接拒绝 ⇒ BOOT 里始终只有一个 flash writer。</li>
<li>退出:<code>jumpToApplication()</code> 里补 <code>bootEcatDeinit()</code> 拆 EXTI1/NVIC、LAN9252 IRQ 引脚、EXMC、DWT,否则 APP 会吃到 BOOT 留的 pending 中断并在被占用的总线上重跑 init。</li>
</ul>
<h3>新增的移植层花了多少</h3>
<table>
<tr><th>项</th><th class="n">值</th><th>备注</th></tr>
<tr><td>无 EtherCAT 基线</td><td class="n">36,340 B</td><td class="ev">Phase 0 对照组:关掉唯一调用者后 --gc-sections 剥回此值</td></tr>
<tr><td>邮箱-only SSC(空移植层桩件)</td><td class="n">57,816 B</td><td>净增 21,472 B —— 原方案声称的"12 KB"是假的</td></tr>
<tr><td><b>真移植层 + 会话(定版)</b></td><td class="n"><b>64,552 B</b></td><td>再 +6,736 B(EXMC/HBI/CSR 双读/PRAM/RST#/IRQ + 0x2500/0x2501 + arm 门控);<b>余 9,176 B</b></td></tr>
<tr><td>RAM</td><td class="n">32,392 / 131,072 B</td><td>堆 0xC00→<b>0x4000</b>(每笔分段下载 <code>ALLOCMEM(块长)</code> 走 malloc);栈仍 1 KB ⇒ SDO 回调里不许开块级大数组</td></tr>
</table>
</section>
<div class="foot">生成:Qoder 会话 · 证据基线 <code>GD32E50X_BOOT</code> v1.1.0.7 / <code>tools/ecat</code> ecat_native 22:18 / <code>ESP32_COMM</code> 现网分支 · 本轮改动均未提交 git</div>
</main>
<script>
document.querySelectorAll(".tab").forEach(function(b){b.onclick=function(){
document.querySelectorAll(".tab").forEach(function(x){x.classList.remove("on")});
document.querySelectorAll(".page").forEach(function(x){x.classList.remove("on")});
b.classList.add("on");
document.getElementById("p-"+b.dataset.p).classList.add("on");
scrollTo(0,0);
};});
</script>
</body></html>