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