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

17 KiB
Raw Permalink Blame History

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 依赖)。