变更点: 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/ 运行态
270 lines
29 KiB
HTML
270 lines
29 KiB
HTML
<!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 <= 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>包号 < 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 && 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>
|