Files
cbb a56b702d83 feat!: Python 后端整体切换为原生 C/SOEM 守护进程(ecat_native)
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, 由脚本重建。
2026-09-05 16:01:15 +08:00

191 lines
17 KiB
Markdown
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.
# 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 依赖)。