big-bang 迁移(硬约束: Npcap 网卡独占, 无法双主站并存), 四阶段真机验收的 完整契约与对拍记录见 MIGRATION.md —— Python 已删, 该文件是唯一行为基准。 - 新增 ecat_native.c: 与 Python 同构的全路由 native 守护 —— 总线生命周期 open/close/status/op.start/op.stop, 电机 motor.start/stop/spin/state/ send/rx/period/dbg, can/send|state, 配置维护 sdo/eeprom.read|write| verify/flash/sync_diag, 静态托管 src/web, shutdown/focus/--parent-pid 看门狗/独立启动清场/端口漂移; 独占 8090 即生产链路。PDO 热循环天花板 ~5kHz → ~7kHz。 - 删除 src/ecat_server.py(2826 行)、src/test_motor_backend.py(684 行)、 ecat_app.vbs(桌面窗口随 pywebview 依赖一并移除): Python 后端不留副本。 - 编码回归改金标准: native_enc_diff.py 对照 native_enc_golden.json (阶段 2 与 Python _fill_can_out 逐字节对拍通过后固化, 不碰网卡), 当场抓掉 json_tok 不跳闭合引号会静默丢 motor/send 参数的真 bug。 - build_native.bat: 编译前跑 gen_eeprom.py 生成 eeprom_image.h 供 native #include —— 同一生成器同一套断言, 断言失败构建中止; 改 SII 重跑即可。 - 修 flash 探针字历史隐患(本文件与 ecat_native.c 同步改): SCRATCH_WORD 0x0200 实际落在 TxPDO 类别区内部(类别区已长到 0x0281), 单独跑 eeprom 探针不烧录会把真数据擦成空洞; 移到 SII END 之后的 0x0400。 - 启动器 ecat_web.vbs/bat 改拉 build_native\ecat_native.exe (--substr I226 --web src\web)。 - 收录 soem-src wpcap 链接库(vendored, 构建必需)。 两轮回归修复(细节见 MIGRATION.md): 1) op/start 卡 AL 0x001B(SM 看门狗): 固件"自己写 1s 看门狗"的假设破产, do_op_start 第 5 步改为 ~5ms 节奏喂帧 ≥1s 再请求 OP, 24s 两次失败 变 1.2s 进 OP。 2) OP 态数据冻结(从站 PDI_Isr SM2 分支被旗标挡住, 固件侧根因): 主站加 楔死看门狗(输出窗口轮转 + 回显冻结 128 周期判死)与 /api/motor/state 自动恢复(10s 冷却, 快照新增 wr 字段)。 已知差异: SDO 失败文本无 pysoem abort code 粒度; 250µs 亚毫秒周期从站 吃不下, 修复前勿用。build_native/ 构建产物入 .gitignore, 由脚本重建。
191 lines
17 KiB
Markdown
191 lines
17 KiB
Markdown
# ecat_server.py → ecat_native(C/SOEM) 迁移计划
|
||
|
||
## 背景与硬约束
|
||
- **动机**:原生 C/SOEM 把 PDO 热循环天花板从 Python 的 ~5kHz 抬到 ~7kHz(见 README 原型 A)。
|
||
但控制面路由(电机命令/CAN/SDO/eeprom/前端)跑在 UI 速率,迁到 C **没有性能收益**,
|
||
迁移动机是"单一原生进程、去掉 Python 运行时依赖",不是快。
|
||
- **硬约束:网卡独占**。Npcap 上两个 EtherCAT 主站抢一条总线会互相打架。所以**不能**
|
||
"native 管 3 条路由、Python 管其余"长期并存 —— 迁移是 **big-bang 切换**:native 做到
|
||
功能齐平 → 整体接管 8090 → 才删 Python。中途 Python 始终是生产链路,native 用
|
||
`ECAT_NATIVE_URL` 转发或独立端口(8091)验证,二者不同时持网卡。
|
||
- **验证纪律**:每个阶段 native 侧真机验证通过(wkc 达标、行为与 Python 一致)才推进下一阶段;
|
||
全部阶段完成 + 回归对齐后,才删除 ecat_server.py 及其 web 资产。
|
||
|
||
## 现状(2026-09-04:迁移完成)
|
||
- native(阶段 1~4 全部完成):生命周期 `adapters/open/status/close/op.start/op.stop` +
|
||
电机控制 `motor.start/stop/spin/state/send/rx/period/dbg` + `can/send` + `can/state`
|
||
+ 配置维护 `sdo`/`eeprom.read|write|verify`/`flash`/`sync_diag`
|
||
+ 进程生命周期 `shutdown`/`focus`/`--parent-pid`/清场/端口漂移
|
||
+ 静态托管(motor.html/ecat.html/css/js) + 健康端点 + `native/enctest` 编码对照。
|
||
前端调用的 24 条路由全覆盖,native 独占 8090 即生产链路。
|
||
- Python:已彻底删除(2026-09-04 收官,含备份 zip)。`src/` 只剩 CLI 工具、
|
||
前端资产与诊断脚本;编码回归改打金标准(见阶段 4)。
|
||
|
||
## 分阶段(按依赖排序)
|
||
### 阶段 1:总线生命周期 + 状态机拆分 + 前端托管(✅ 已完成,2026-09-05 真机验证)
|
||
把 native 启动的"一把梭"拆成与 Python 同构的可控状态机,并加只读生命周期路由 + 静态托管:
|
||
- 路由:`/api/adapters`、`/api/open`(ec_init+config_init,停 PREOP)、`/api/status`、
|
||
`/api/close`、`/api/op/start`(ACK 序列进 OP)、`/api/op/stop`、`/api/motor/start`(起循环)、
|
||
`/api/motor/stop`(停循环)、`/api/motor/spin`。
|
||
- 静态:GET `/`、`/motor.html`、`/ecat.html`、`/ecat.css`、`/motor.js`(从 src/web/ 读盘)。
|
||
- 状态位:g_bus_open / g_op / g_loop_running,迁移到与 Python 一致;`--auto` 可选启动即拉起
|
||
(给 cadence/rate 压测用),默认关、纯路由驱动。控制路由改总线前先 `loop_stop()`
|
||
(对应 Python 的 `_BUS_EXCLUSIVE_PATHS` 停循环),避免与 PDO 线程并发操作同一 ecx_port。
|
||
- **验收结果**:open→op/start→motor/start 经前端实际用的同套路由驱动到 `bus_open/op/loop`
|
||
全 1、wkc 3/3;cadence_diag 端到端 3990Hz(99.8%)、work p50 139µs、路由设周期生效;
|
||
motor/stop→loop:0、op/stop→退回 INIT 均正确;GET / 返回 ecat.html(26KB)。
|
||
- **踩坑**:Npcap 设备名 `\Device\NPF_...` 含反斜杠,原样塞进 JSON 报 Invalid \escape →
|
||
加 `jesc()` 转义所有嵌入变数字符串(adapters desc/name、从站名、desc)。
|
||
|
||
### 阶段 2:PDO 循环功能核心(✅ 已完成,2026-09-05 真机验证)
|
||
把 Python `_pdo_worker` 的功能搬进 native 循环:命令队列泵入(首帧段1 + 批量区段2 共用
|
||
seq 沿)、槽窗口自适应(cover_bases + 每 64 周期发现窗 + 首圈全扫)、rx 批量回收、
|
||
完整快照。**验证分两层**:
|
||
- **编码逐字节对照**(`native_enc_diff.py`,不碰网卡):4 组用例(DLC32 单帧 / 20×DLC8
|
||
装满+第21帧越界 / dlc=0 只发批量 / 混合 DLC+win 保持)的 384B 输出与 Python
|
||
`_fill_can_out` **逐字节一致**,seq 同步。当场抓出并修掉 json_tok 不跳键闭合引号
|
||
的真 bug(该 bug 会让 motor/send 的 id/data 静默全丢 —— 没有这层对照就会带上线)。
|
||
- **真机闭环**:open→op/start→motor/start(4ms) 后,电机快照与 Python 基准逐字段一致
|
||
(slot2/node1、slot5/node2、ch2、online、seq 连续递增);探针帧发往**空载通道 ch0**
|
||
(电机在 ch2,不碰真实电机总线)→ `sent_seq:1`→队列清空→固件 **ack=1 回显**、
|
||
**tx_count=1**(帧真的发上了 CAN 总线)、tx_result=OK;motor/rx 取回 5 帧 remain 1630
|
||
(ch2 电机流量经批量区真实回流)。自适应窗口 `[2]` 与 Python 一致(修了一个移植
|
||
bug:首圈全扫结束分支读陈旧 g_bases,改为当场重算 cover_bases,对齐 Python 1673 行)。
|
||
- 与 Python 的两处有意差异(阶段 4 对齐):① motor/send/can/send 在循环未启动时报
|
||
"先 motor/start"(Python 走同步下发 fallback);② can/state 纯读快照不现场 pump。
|
||
- 本轮未动真实电机命令:任何能驱动电机的帧应由用户在 UI 知情后下发。
|
||
|
||
### 阶段 3:配置/维护面(✅ 已完成,2026-09-04 真机验证)
|
||
`/api/sdo`、`/api/eeprom/read|write|verify`、`/api/flash`、`/api/sync_diag` —— 全部走
|
||
独占总线(进 `g_bus_lock` 前先 `loop_stop()`,镜像 Python `_BUS_EXCLUSIVE_PATHS`:
|
||
邮箱/EEPROM 与周期数据并发是历史上邮箱楔死 + SOEM 分段读崩溃的诱因)。
|
||
- **默认烧录镜像**:`build_native.bat` 在编译前跑 `gen_eeprom.py > build_native/eeprom_image.h`,
|
||
ecat_native.c `#include` 它。等价于 Python 的 `from ecat_flash_eeprom import load_image`:
|
||
同一份生成器、同一套自校验断言(断言失败 → 非零退出 → 构建中止),改 SII 只需重跑脚本。
|
||
生成的数组与 `gen_eeprom.eeprom` **逐字节一致**(离线校验)。
|
||
- **真机对拍**(Python 停机 → native 独占网卡 → 逐项比对 → 恢复 Python):
|
||
- SDO 读 `0x2000:1..4`:与 Python 基准同形同值(sync0/sync1=0 free-run、escirq/uptime 递增)。
|
||
- SDO 写只读对象:Python 回 SdoError abort 文本,native 回 `SdoError wkc=0` —— 判定
|
||
(ok=false) 与 JSON 形状一致,仅异常文本粒度不同(pysoem 的 abort code 拿不到,已记差异)。
|
||
- EEPROM 读整片 2048 字:`raw`/`rows` 与 Python **逐字节一致**(4096B 全等)。
|
||
- `eeprom/verify` 同值数据 → `diffs:0` + 单行 "verify: OK, 全部一致";注入 1 字差异 →
|
||
`diffs:1` + 差异行 + 汇总行,与 Python 语义(含 `<=10` 只打前 11 行)一致。
|
||
- `eeprom/write` 同值写回 641 非-FF 字 → `written:641 failed:0`(每字写后立即读回校验)。
|
||
- `flash` 默认镜像 → 642 字全成功;回读整片与构建期镜像 **零差异**。
|
||
- **抓到并修掉的 2 个真 bug**:
|
||
① `logs_add` 不写 NUL 终止符:日志缓冲跨请求复用,`%s` 会把上一次请求的残留一起吐出
|
||
(实测 verify 返回 "OK, 全部一致" 后面粘着上一轮的 449 行差异,且是非法 JSON)。
|
||
② flash 失败分支 `"log":[...]"written":n}` 缺逗号 —— 非法 JSON。按 Python 语义改为
|
||
失败时不带 `written` 键。
|
||
- **顺带修掉一个跨实现的历史隐患(Python/native/独立脚本三处同改)**:flash 的字节序探针字
|
||
用的是 `0x0200`,注释说"在 0xFF 填充区深处、远超类别区",**但 SII 类别区早已长到
|
||
word 0x0281**(STRINGS/General/FMMU/SyncM/RxPDO 196字/TxPDO 324字,END 在 0x0282),
|
||
0x0200 落在 **TxPDO 类别内部**:探针后"恢复"成 0xFFFF 就把那一字真数据擦成空洞。
|
||
实测本机芯片 word 0x0200=0xFFFF 而镜像是 0x401B —— 正是历次探针留下的,只因烧录紧随
|
||
其后重写全片才一直没暴露(单独跑 eeprom 探针不烧录就会留下坏 SII)。改用 END 之后的
|
||
`0x0400`;复跑 flash 后芯片与镜像零差异、探针字干净回到 0xFFFF。
|
||
- **本轮未动真实电机命令**(同阶段 2 纪律)。恢复生产:Python 4ms、wkc 3/3、2 台电机
|
||
在线 seq 递增、win_bases `[2]`;`test_motor_backend.py` 16/16 全绿。
|
||
|
||
### 阶段 4:杂项 + 切换(✅ 已完成,2026-09-04 真机验证,Python 已归档)
|
||
- **can/send 重写为 Python 同款**(此前误并入 motor/send 队列路径 —— 那是真差异,
|
||
ecat.html CAN 调试页依赖同步 ack 回显):独占停循环 → 未 OP 自动 bring-up(吃
|
||
`dc_cycle_us`)→ 单帧 DLC≤32 + `clear_stats` → pump 3 拍 → `acked` 判定 + 警告文案
|
||
→ 桥字段**拍平**进响应(renderCanState 直接读顶层)。`can/state` 同款拍平,
|
||
循环活着给快照、没跑 pump 1 拍。`motor/send` 补同步 fallback:循环停时批量 pump
|
||
(3 帧 DLC1 一周期发完,`sent_sync:3 pdo_cycles:1` 实测)。
|
||
- **do_op_start 全量化**(镜像 `_require_op_ready`):入口无条件预清理(回 INIT +
|
||
config_init 重枚举,防楔死邮箱上 config_map 原生崩溃)、free-run 清残留 DC 激活
|
||
(0x0981 跨重连不消失,残留会让 SAFEOP→OP 永久等 PLL)、可选 DC(config_dc +
|
||
dcsync0)、多行 log 数组、op/start 失败自动重试一次。`motor/start` 补"现读核实 OP"
|
||
(看门狗踢出后自动重进,实测触发)。
|
||
- **生命周期契约**:`/api/shutdown`(回响应→优雅退:停循环→INIT→关适配器)、
|
||
`/api/focus`(探活应答)、`--parent-pid` 看门狗(父进程消失→同款优雅退)、
|
||
独立启动清场(8090..8095 逐个优雅 shutdown + 按进程名强杀兜底,不碰 python*)、
|
||
端口漂移 + `PORT=` 行、默认端口 8090。
|
||
- **切换**:`esp32_server._ecat_autostart()` 改拉 `build_native\ecat_native.exe`
|
||
(带 `--parent-pid`,cwd=tools/ecat);`ecat_web.vbs/bat` 改起 native。
|
||
Python 服务代码先归档 `legacy_py/`、切换验证通过后**已删除**(2026-09-04,
|
||
曾留备份 zip,同日确认后**连备份一并彻底删除**:`ecat_server.py` 行为基准、
|
||
`test_motor_backend.py` 假从站回归套件、`ecat_app.vbs` 桌面窗口模式均已不存在,
|
||
本文件的逐字段对拍记录是唯一留存的契约文档)。
|
||
编码面回归改打金标准:`native_enc_diff.py` 对照 `native_enc_golden.json`
|
||
(阶段 2 与 Python 逐字节对拍通过后固化,byte0=seq 状态字节除外 + seq 递增校验)。
|
||
`ecat_tool.py` CLI 仍用 `src/ecat_flash_eeprom.py`(活依赖,已搬回 src)。
|
||
**ECAT_NATIVE_URL 转发层随切换作废**(native 就是后端,不再需要代理)。
|
||
- **真机验收**(全走 UI 同款链路):停 Python → native 独立起(清场路径顺带验证:
|
||
新实例优雅替换旧实例)→ open/op.start/motor.start(4ms)/send/rx/sdo/eeprom/period
|
||
全通 → 8080 重启新码 → 代理透传 motor/state ✓ → autostart 拉起(`--parent-pid`
|
||
正确)✓ → 杀 8080 看 native 自我了断(端口释放、从站回 INIT)✓ → 重启 8080
|
||
自动拉起 → 生产态 4ms/wkc 3/3/2 电机/win_bases[2] 恢复 ✓。
|
||
- **切换中抓到并修掉 2 个真 bug**:① can/send 路由里 `int cs`(clear_stats)遮蔽了
|
||
`handle_conn(SOCKET cs)` 的 socket —— 响应发到句柄 0,总线侧成功、HTTP 侧空响应;
|
||
② 端口漂移循环把 `port++` 写在 for 增量段,绑定成功那轮也自增 —— 日志报出一个
|
||
没在听的端口(实听 8090、日志 8091)。
|
||
- **保留的已知差异**(都已在代码注释记录,影响面为零或已验证等价):
|
||
SDO 失败文本无 pysoem 的 abort code 粒度;~~主站侧不直写过程数据看门狗
|
||
(从站固件 ≥1.5.45 自己写 1s,Python 那次直写是"双保险")~~ **此假设已破产,
|
||
见下方"回归 2026-09-05"**;spin 路由无副作用(native 恒自旋)。
|
||
|
||
## 回归 2026-09-05:native 无法进 OP(AL 0x001B),已修复
|
||
- **症状**:`/api/op/start` 卡 SAFEOP→OP,重试一次仍失败,`AL=0x04 code=0x001b`
|
||
(Sync manager watchdog);`/api/esc/read` 手发 LRW 得 `wkc=1`(期望 3,输出写
|
||
不匹配、输入读匹配 —— SM2 已被从站禁用)。
|
||
- **排除面**(全部实测干净):SM/FMMU/DC/看门狗寄存器与 SII、固件 CoE
|
||
(0x1C12→0x1600 48×8B=384、0x1C13→0x1A00 80×8B=640)逐项一致;PREOP 下 SDO
|
||
正常(邮箱未楔死);free-run 同样失败(与 DC 无关);**pysoem CLI 同总线同从站
|
||
成功进 OP + 1237 帧 PDO 正常** —— 从站健康,问题在主站侧。
|
||
- **根因**:SSC 的 SAFEOP→OP 转换挂起等待 `bEcatFirstOutputsReceived`,而
|
||
`CheckIfEcatError` 在过程数据看门狗触发位过期时**每 1ms 清一次该标志**。从站
|
||
看门狗实测是复位默认 **100ms**(divider 0x400=2498→100µs × time 0x420=1000)——
|
||
固件 1.5.45 的"启动写 1s"(`EscLan9252::configPdWatchdog`)这次上电没生效
|
||
(需串口看 WARN 行确认是写回失败还是 ESC 事后被复位)。native `do_op_start`
|
||
第 5 步的喂帧节奏是 **~200ms/帧 > 100ms WD** → 标志永远立不住 → ~9s 超时写
|
||
0x001B 并 StopOutputHandler。Python 时代不炸是因为"直写 0x420=1s 双保险",
|
||
迁移时按"固件自己会写 1s"删掉了 —— 这就是回归点。
|
||
- **修复**(不依赖从站 WD 长度):`do_op_start` 第 5 步改为先以 ~5ms 节奏喂
|
||
≥1s(250 帧),再请求 OP 并继续 5ms 喂帧轮询(最多 ~3s,每 ~200ms 补写一次
|
||
AL Control 兜底)。op/start 从"24s 两次失败"变 **1.2s 进 OP**。
|
||
- **验证**:open→op/start(DC 4ms)→motor/start(4ms) 全链路:wkc 3/3、双电机在线
|
||
(slot2/N1、slot5/N2)、seq 递增、win_bases=[2],与 Python 生产基准一致。
|
||
## 回归 2026-09-05(下午):OP 态数据冻结("切到 ECAT 一瞬间有数据然后没了")
|
||
- **症状**:进 OP 成功、wkc 3/3、网卡 ~33Mbps 流量正常,但从站输入区停更
|
||
(slot seq / 桥 rx_count / global_seq 全冻结),ack 不回显;UI 2s 后全节点判离线。
|
||
换固件 1.5.56→1.5.58 无效;用户实测 1.5.56 + 旧 Python 主站完全正常。
|
||
- **排除面**(全部实测):`native_enc_diff.py` 金标准 4 用例逐字节通过(输出编码与
|
||
Python 一致);主站输入解析偏移与固件 `ethercat_can_bridge.h` 逐字节对齐
|
||
(MOTW 32/33/34/35/36/40、槽 40B、XRX 320..、RXB 364..);PREOP 下 SDO 正常。
|
||
⇒ 主站编码/解析/链路全干净,wkc=3 证明 FMMU 层写匹配正常。
|
||
- **根因(固件侧,其源码自述)**:`dc_app.c` `EcatDbg_GuardAleventMask` 的
|
||
1.5.48/1.5.49 注释原文——"停止回 INIT 后重进 OP **概率性**失败:主站 LRW
|
||
WKC=3/3 但 bEcatFirstOutputsReceived 永不置位……evt=0x0400 在 0x220 挂起 10s
|
||
无人处理(PDI_Isr 的 SM2 分支从未执行),escirq 2.6/s 证明 EXTI 只在 AL Control
|
||
事件时跳动"。实测冻结态 escirq 只随邮箱事件 +3~5/s(4ms LRW 应 ~250/s)、
|
||
can/send 探针 ack=0,与该记录完全吻合。**楔死点 = PDI_Isr SM2 分支被内部旗标
|
||
(bEscIntEnabled 门控)挡住 → ECAT_Application→EcatPdo_Process→buildInput 停 →
|
||
输入停更 + 输出不消费**;固件自愈"仅 SAFEOP 触发"(注释原文),OP 态无人管。
|
||
触发条件正是"停止回 INIT 后重进 OP"——native 主站每次 motor/start 异常恢复、
|
||
每次传输切换都走这条路,比 Python 时代更频繁,故表现为"上位机更新后坏"。
|
||
- **主站侧防护(本轮新增,已真机验证)**:
|
||
- `pdo_worker` 楔死看门狗:主站每周期轮转输出窗口字节 WIN(自适应窗+每 64 周期
|
||
发现窗),健康从站输入段 2 的 win_base 回显必然跟着变;回显冻结超
|
||
128 周期+2s(单基窗时发现窗可能与自适应窗同值,最久 128 周期)即楔死。
|
||
与电机流量无关,零误报(30s 稳态实测无误报)。
|
||
- `/api/motor/state` 自动恢复:检测后 0.5s,TryEnter g_bus_lock(防与互斥路由
|
||
互等死锁)→ 完整重拉 INIT→PREOP→SAFEOP→OP,10s 冷却。快照新增 `wr` 字段
|
||
(自动恢复次数);WEDGE 现场打 console(含 win_echo/ack/gseq/rx/wkc/period,
|
||
供与固件串口日志对时)。
|
||
- **遗留(固件侧,根因)**:① 串口抓一次复现,看 [ECAT][AEM] WARN 行
|
||
(固件自述"一行日志即可定位楔死路径");② 把 1.5.49 自愈扩展到 OP 态
|
||
(判据:LRW 在跑而 ack/win 回显不动——主站看门狗的判据可直接搬);③ 排查
|
||
bEscIntEnabled/bDcSyncActive 旗标卡值;④ 怀疑与 DC 模式相关
|
||
(`ethercat_can_bridge.cpp` seqlock 注释:DC 下 buildInput 跑在 Sync0_Isr——
|
||
本轮实测冻结多发生在 DC 会话)。另:250µs 亚毫秒周期从站吃不下
|
||
(槽刷新率掉 99%,见上节),修复前别用。
|
||
|
||
## 迁移结论
|
||
Python→native 的价值不在控制面(UI 速率下两者无感),在于 PDO 热循环天花板
|
||
~5kHz→~7kHz 与尾抖动干净;那 0.5% 的 I226 驱动周期维护停顿与语言无关。
|
||
`legacy_py/` 与备份 zip 均已删除,Python 后端不留副本:以后改路由语义的参照就是
|
||
本文件的对拍记录 + 真机验收;编码面回归用 `native_enc_diff.py`(金标准模式,无 Python 依赖)。
|