#!/usr/bin/env python3 """ecat_tool.py — DCU LAN9252 EtherCAT 一体化主站工具(连接/收发/烧录)。 子命令: adapters 列出 Npcap 适配器 status [substr] 扫描从站: 身份字/AL 状态/邮箱/PDO 尺寸 flash [--bin FILE] [substr] 烧录 EEPROM(gen_eeprom.py 镜像或 --bin)+逐字校验 eeprom read START COUNT [--out FILE] [substr] eeprom write FILE [substr] 写 bin(奇数尾补 0xFF)+回读校验 eeprom verify FILE [substr] 只读校验 sdo read IDX SUB [--size N] [substr] CoE SDO 读(需 PREOP+,邮箱来自 SII) sdo write IDX SUB HEX [substr] CoE SDO 写(HEX=字节流) send [--hex H | --u64 a,b,c,d] [--rate-ms N] [--duration S | --forever] [--dc-cycle-us N] [--dc-shift-us N] [substr] 进 OP 循环发送输出 PDO 并回显输入 PDO monitor [--rate-ms N] [--duration S] [--dc-cycle-us N] [substr] 进 OP 只读输入(输出保持 0) substr 默认 "I226"(按适配器描述模糊匹配)。send/monitor 的输出默认全 0。 --dc-cycle-us 非 0 时启用分布时钟: 从站 SYNC0 引脚按该周期输出脉冲, GD32 侧 Sync0_Isr 才会被触发(默认 free-run 模式下 SYNC0 完全不打)。 验证: 主站设 1000us, 从站 [ECAT][SYNC] 日志应显示 sync0 ≈ 1000/s。 """ import argparse import os import struct import sys import time try: import pysoem except ImportError: print("pysoem 无法加载:请先安装 Npcap(本目录 npcap-1.88.exe," "保持勾选 'Install WinPcap API-compatible Mode')") sys.exit(1) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) EEPROM_SIZE = 4096 # AT24C32 # state_check 的 timeout 单位是微秒(pysoem: "Timeout value in us"); # 2000000us = 2s,与 SOEM 的 EC_TIMEOUTSTATE 默认值一致。 # 此前各处传 2000 实际只等 2ms,能通过纯属从站 ack 快,属侥幸。 EC_TIMEOUT_STATE_US = 2_000_000 AL_STATE = {0x00: "NONE", 0x01: "INIT", 0x02: "PREOP", 0x03: "BOOT", 0x04: "SAFEOP", 0x08: "OP"} def al_name(code): return AL_STATE.get(code, f"0x{code:02X}") def find_adapter(substr): for a in pysoem.find_adapters(): desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc if substr.lower() in desc.lower(): return a return None def open_master(substr): adapter = find_adapter(substr) if adapter is None: print(f"没有找到描述包含 {substr!r} 的适配器,可用:") list_adapters() sys.exit(1) desc = adapter.desc.decode() if isinstance(adapter.desc, bytes) else adapter.desc master = pysoem.Master() master.open(adapter.name) try: n = master.config_init() except Exception as e: raise SystemExit(f"总线上没有发现从站({type(e).__name__}: {e})——" "检查 DCU 板供电、网线是否接到该网口") if not n: raise SystemExit("总线上没有发现从站——检查 DCU 板供电、网线是否接到该网口") return master, desc def list_adapters(): for i, a in enumerate(pysoem.find_adapters()): desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc print(f"[{i}] {desc}\n {a.name}") def print_slave_info(master): print(f"从站: {len(master.slaves)} 个") for i, s in enumerate(master.slaves): print(f" [{i}] name={s.name!r}") print(f" VendorID=0x{s.man:08X} ProductCode=0x{s.id:08X} Rev={s.rev}") print(f" AL state={al_name(s.state & 0x0F)} AL code=0x{s.al_status:04X}") try: print(f" 输出 {len(bytes(s.output))}B / 输入 {len(bytes(s.input))}B " f"(config_map 后有效)") except Exception: pass # ---------------------------------------------------------------- status def cmd_status(args): if args.substr in ("-l", "--list"): list_adapters() return master, desc = open_master(args.substr) print(f"适配器: {desc}") print_slave_info(master) if master.slaves and master.slaves[0].man == 0xFFFFFFFF: print("\n提示: 身份字全 F = EEPROM 无效。先执行: ecat_tool.py flash") # ---------------------------------------------------------------- eeprom def eeprom_read_word(slave, word): return bytes(slave.eeprom_read(word))[:2] def flash_image(slave, img, label): if len(img) % 2 != 0: img += b"\xFF" if len(img) > EEPROM_SIZE: raise SystemExit(f"镜像 {len(img)}B 超过 EEPROM 容量 {EEPROM_SIZE}B") # 字节序探针(写入 0x1234 找读回偏移,随后恢复擦除态) test = b"\x34\x12" slave.eeprom_write(0x0200, test) rb = bytes(slave.eeprom_read(0x0200)) off = rb.find(test) if off < 0: raise SystemExit(f"EEPROM 写探针失败: 读回 {rb.hex().upper()}") slave.eeprom_write(0x0200, b"\xFF\xFF") print(f"字节序探针 OK (读回偏移 {off})") n_written = n_fail = 0 fails = [] for word in range(len(img) // 2): val = struct.unpack_from(">> 板子断电重新上电后 ESC 才会重载配置(身份字/PDI/SYNC)") def cmd_flash(args): if args.bin: with open(args.bin, "rb") as f: img = f.read() label = os.path.basename(args.bin) if len(img) % 2: img += b"\xFF" else: from ecat_flash_eeprom import load_image # 自带 gen_eeprom.py 自校验 img = load_image() label = "gen_eeprom.py" print(f"镜像: {label}, {len(img)} 字节") master, desc = open_master(args.substr) print(f"适配器: {desc}, 从站应答正常") flash_image(master.slaves[0], img, label) def cmd_eeprom(args): master, _ = open_master(args.substr) slave = master.slaves[0] if args.op == "read": start, count = args.start, args.count rows = [] for w in range(start, start + count): rows.append(struct.unpack(" EEPROM_SIZE: raise SystemExit(f"{len(data)}B 超过 EEPROM 容量 {EEPROM_SIZE}B") n = n_fail = 0 for word in range(len(data) // 2): val = struct.unpack_from(" FAIL")) sys.exit(0 if n_fail == 0 else 3) elif args.op == "verify": with open(args.file, "rb") as f: data = f.read() if len(data) % 2: data += b"\xFF" diffs = 0 for word in range(min(len(data), EEPROM_SIZE) // 2): expect = struct.unpack_from(" FAIL")) sys.exit(0 if diffs == 0 else 3) # ---------------------------------------------------------------- SDO def cmd_sdo(args): master, desc = open_master(args.substr) s = master.slaves[0] if args.op == "read": try: data = bytes(s.sdo_read(args.index, args.sub, args.size)) except Exception as e: raise SystemExit(f"SDO 读失败: {type(e).__name__}: {e}") print(f"0x{args.index:04X}:{args.sub} = {data.hex().upper()} ({len(data)}B)") else: try: payload = bytes.fromhex(args.hex.replace(" ", "")) except ValueError: raise SystemExit("HEX 非法: 只能是十六进制字节流") try: s.sdo_write(args.index, args.sub, payload) except Exception as e: raise SystemExit(f"SDO 写失败: {type(e).__name__}: {e}") print(f"0x{args.index:04X}:{args.sub} <- {payload.hex().upper()} ({len(payload)}B) OK") # ---------------------------------------------------------------- PDO 收发 def enter_op(master, dc_cycle_us=0, dc_shift_us=0): """config_map → (可选 DC) → SAFEOP → OP。 dc_cycle_us=0 时保持 free-run(SYNC0 引脚无脉冲,从站 Sync0_Isr 不会触发)。 非 0 时先 config_dc() 测量传播延迟,再对从站下发 SYNC0 周期, 必须在 config_map() 之后、进 SAFEOP 之前完成(ETG.1020 DC 配置顺序)。 """ master.state = pysoem.PREOP_STATE | pysoem.STATE_ACK master.write_state() if master.state_check(pysoem.PREOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.PREOP_STATE: # read_state 拿真实 AL Status Code(广播 state_check 不刷新 al_status, # 陈旧缓存恒显示 0x0000)。首败常见 0x0016/0x001D 类:停止回 INIT 时 SSC # 禁用了邮箱 SM0/SM1,CheckSmSettings 拒绝 INIT→PREOP,重新 config_init 即恢复。 try: master.read_state() except Exception: pass codes = [(al_name(sl.state & 0x0F), f"0x{sl.al_status:04X}") for sl in master.slaves] raise SystemExit(f"进 PREOP 失败: AL={codes}(真实值) —— 从站未 ack AL Control=PREOP。" "0x0016/0x001D 类=邮箱 SM0/SM1 被停止时禁用(重跑即恢复);" "抓 GD32 串口看 [ECAT][AL] evt/req") # free-run 时清残留 DC 激活(2026-09-03 00:27 卡死根因之一):之前跑过 DC 的 # 0x0981 激活位跨重连不清,从站会按 DC 同步模式等 PLL,free-run 下永不进 OP。 # 必须在 config_map(其内部请求 SAFEOP)之前清除。 if not dc_cycle_us: try: master.slaves[0].dc_sync(False, 0, 0) print("已清除残留 DC 激活(free-run)") except Exception as e: print(f"WARN: 清 DC 激活失败({type(e).__name__}: {e})") master.config_map() # 不要写 0x1C32:01 / 0x1C33:01 去改 SyncType:该子索引只允许在 PREOP 写 # (ACCESS_WRITE_PREOP),而 config_map() 已把从站推到 SAFEOP,实测返回 # SdoError 0x08000022(当前状态不可写)。而且没必要——SSC 在 PREOP→SAFEOP 的 # StartInputHandler() 会自动按 DC 寄存器/PDO 长度推导出 SM_SYNCHRON # (ecatslv.c:849-880),coeappl.c 里的 FREERUN 只是上电初值。 if dc_cycle_us: has_dc = master.config_dc() if not has_dc: raise SystemExit("config_dc(): 总线上没有支持 DC 的从站 —— " "检查 SII word1 高字节(0x0151 Sync/Latch 配置)是否为 0xCC") master.slaves[0].dc_sync(True, dc_cycle_us * 1000, dc_shift_us * 1000) print(f"DC 已启用: SYNC0 周期 {dc_cycle_us}us" + (f", shift {dc_shift_us}us" if dc_shift_us else "") + f", 主站 DC 时间={master.dc_time}ns") # 显式带 ACK 写 SAFEOP(config_map 内部已推过一次,这里再立一次并顺带清掉 # 上一轮残留的错误位——SSC 只有带 ACK 位的写才能清错+迁移,普通写会被忽略) master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK master.write_state() if master.state_check(pysoem.SAFEOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.SAFEOP_STATE: try: master.read_state() # 真实 AL Status Code(广播 state_check 不刷新) except Exception: pass codes = [(al_name(sl.state & 0x0F), f"0x{sl.al_status:04X}") for sl in master.slaves] raise SystemExit(f"进 SAFEOP 失败: AL={codes}(真实值) —— 检查 SII 的 SM/FMMU 类别" "(EEPROM 是否已刷)与 GD32 SSC 是否运行") # 主站侧直写过程数据看门狗(双保险,SOEM set_watchdog → FPWR 0x0420 = 1000ms)。 # 从站固件 ≥1.5.45 也会写 1s,但板上固件未更新时 100ms 复位默认值会在 OP 等待期 # 触发 0x001B 拖回 SAFEOP;主站每次连接直接写,不依赖固件版本。 try: master.slaves[0].set_watchdog('processdata', 1000.0) except Exception as e: print(f"WARN: 主站侧写看门狗失败({type(e).__name__}: {e})") # SAFEOP -> OP:从站有输出 PDO 时,SSC 要求先收到至少一帧过程数据才 ack OP # (ecatslv.c:1993 的 `nPdOutputSize == 0 || bEcatFirstOutputsReceived`), # 否则迁移被挂起、随后回落,表现为 "进 OP 失败 ... code=0x0000"。 # 所以必须边发 processdata 边等,不能只 write_state 后干等。 # AL Control 一律带 ACK 位(0x10):SSC 对"错误位已置+无 ACK+请求≠INIT"的请求 # 直接忽略(ecatslv.c:1376-1384),残留的 0x001B 错误位不清掉,写 OP 无人应答。 master.state = pysoem.OP_STATE | pysoem.STATE_ACK master.write_state() # 状态查询用 read_state() 而非 state_check():后者 slave=0 广播模式不更新 # slavelist[0].ALstatuscode(报错里的 code 是陈旧缓存,恒 0x0000),且内部 # 1ms 轮询发帧风暴。read_state 无错误时 1 帧 BRD,有错误位时逐从站 FPRD # 拿**真实 AL Status Code**。失败时输出真实错误码+状态轨迹。 deadline = time.time() + 12.0 # 12s:覆盖从站 ESM 超时(9s,固件≥1.5.47)+恢复重试 trail = [] last_key = None state = None code = 0 wkc_samples = [] next_rewrite = 0.0 # OP|ACK 重写节流:每 200ms 最多一次(避免 AL Control 写风暴) while time.time() < deadline: master.send_processdata() wkc = master.receive_processdata(2000) if not wkc_samples or wkc_samples[-1] != (wkc, master.expected_wkc): wkc_samples.append((wkc, master.expected_wkc)) try: master.read_state() except Exception: pass state = master.slaves[0].state & 0x0F code = master.slaves[0].al_status key = (state, code) if key != last_key: trail.append((al_name(state), code)) last_key = key if state == pysoem.OP_STATE and code == 0: break # 跌回 PREOP/INIT 时:带 ACK 重写 SAFEOP 立起状态,再带 ACK 重请 OP if state < pysoem.SAFEOP_STATE: master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK master.write_state() master.state_check(pysoem.SAFEOP_STATE, 500_000) next_rewrite = 0.0 if time.time() >= next_rewrite: master.state = pysoem.OP_STATE | pysoem.STATE_ACK master.write_state() next_rewrite = time.time() + 0.2 time.sleep(0.005) # 循环节奏 ~7ms:LRW 间隔稳低于任何看门狗配置 if state != pysoem.OP_STATE or code != 0: try: master.read_state() except Exception: pass st = master.slaves[0] trail.append((al_name(st.state & 0x0F), st.al_status)) try: wd_ms = st.get_watchdog('processdata') wd_info = f", 从站 PD 看门狗={wd_ms:.0f}ms" except Exception: wd_info = "" wkc_info = " / ".join(f"wkc={w}/{e}" for w, e in wkc_samples[:6]) raise SystemExit( f"进 OP 失败: AL={al_name(st.state & 0x0F)} code=0x{st.al_status:04X}(真实值{wd_info}) —— " f"等待期轨迹: {' -> '.join(f'{n}+0x{c:04X}' for n, c in trail)}; LRW WKC: {wkc_info}。" "0x001B=SM watchdog;0x001A=DC 残留激活;0x001D/0x001E=SM 配置不匹配;" "0x0000=从站在等首帧输出。请抓 GD32 串口 [ECAT][AL] evt/req 日志对照") def leave_init(master): try: master.state = pysoem.INIT_STATE master.write_state() except Exception: pass def run_pdo(master, out_data, rate_ms, duration, label, dc_cycle_us=0, dc_shift_us=0): out_len = len(bytes(master.slaves[0].output)) if out_data is None: out_data = bytes(out_len) if len(out_data) < out_len: out_data += bytes(out_len - len(out_data)) try: master.slaves[0].output = out_data except Exception as e: raise SystemExit(f"写输出缓冲失败({type(e).__name__}: {e}) —— output 长度={out_len}") enter_op(master, dc_cycle_us, dc_shift_us) mode = f"DC {dc_cycle_us}us" if dc_cycle_us else "free-run" print(f"[{label}] 已进 OP({mode}): 输出 {out_len}B / 输入 " f"{len(bytes(master.slaves[0].input))}B, expected_wkc={master.expected_wkc}" f"{', Ctrl+C 退出' if duration is None else f', 运行 {duration}s'}") t_end = None if duration is None else time.time() + duration cycles = wkc_errs = 0 last = 0.0 try: while True: master.send_processdata() wkc = master.receive_processdata(2000) cycles += 1 if wkc < master.expected_wkc: wkc_errs += 1 now = time.time() if now - last >= 0.5: last = now inp = bytes(master.slaves[0].input) extra = "" if len(inp) >= 32: u64 = struct.unpack("<4Q", inp[:32]) extra = " u64=" + ",".join(f"0x{x:X}" for x in u64) print(f"cycles={cycles} wkc_err={wkc_errs} in={inp.hex().upper()}{extra}") if t_end is not None and now >= t_end: break time.sleep(rate_ms / 1000.0) except KeyboardInterrupt: print("\n用户中断") finally: leave_init(master) print(f"[{label}] 结束: {cycles} 周期, wkc_err={wkc_errs}, 已回 INIT") def parse_output_data(args): if args.hex: try: return bytes.fromhex(args.hex.replace(" ", "")) except ValueError: raise SystemExit("--hex 非法: 只能是十六进制字节流") if args.u64: try: vals = [int(v, 0) for v in args.u64.split(",")] except ValueError: raise SystemExit("--u64 非法: 逗号分隔的整数(支持 0x 前缀)") if len(vals) > 4: raise SystemExit("--u64 最多 4 个(4×64bit=32B)") return struct.pack(f"<{len(vals)}Q", *vals) return None def cmd_send(args): master, desc = open_master(args.substr) print(f"适配器: {desc}") run_pdo(master, parse_output_data(args), args.rate_ms, None if args.forever else args.duration, "send", args.dc_cycle_us, args.dc_shift_us) def cmd_monitor(args): master, desc = open_master(args.substr) print(f"适配器: {desc}") run_pdo(master, None, args.rate_ms, None if args.forever else args.duration, "monitor", args.dc_cycle_us, args.dc_shift_us) # ---------------------------------------------------------------- main def main(): p = argparse.ArgumentParser(description="DCU LAN9252 EtherCAT 主站工具") sub = p.add_subparsers(dest="cmd", required=False) sub.add_parser("adapters", help="列出 Npcap 适配器") sp = sub.add_parser("status", help="扫描从站身份/AL 状态") sp.add_argument("substr", nargs="?", default="I226") sp = sub.add_parser("flash", help="烧录 EEPROM + 逐字校验") sp.add_argument("--bin", help="指定 bin(默认用 gen_eeprom.py 镜像)") sp.add_argument("substr", nargs="?", default="I226") sp = sub.add_parser("eeprom", help="EEPROM read/write/verify") sp.add_argument("op", choices=["read", "write", "verify"]) sp.add_argument("file_or_start", nargs="?", default=None) sp.add_argument("count_or_file", nargs="?", default=None) sp.add_argument("--out") sp.add_argument("substr", nargs="?", default="I226") sp = sub.add_parser("sdo", help="CoE SDO read/write") sp.add_argument("op", choices=["read", "write"]) sp.add_argument("index", type=lambda v: int(v, 0)) sp.add_argument("sub", type=lambda v: int(v, 0)) sp.add_argument("hex", nargs="?", default=None) sp.add_argument("--size", type=lambda v: int(v, 0), default=8) sp.add_argument("substr", nargs="?", default="I226") sp = sub.add_parser("send", help="进 OP 循环发送输出 PDO") sp.add_argument("--hex", help="输出字节流(≤输出区长度)") sp.add_argument("--u64", help="4 个 64bit, 逗号分隔, 如 0x1,0x2,0x3,0x4") sp.add_argument("--rate-ms", type=int, default=100) sp.add_argument("--duration", type=float, default=5.0) sp.add_argument("--forever", action="store_true") sp.add_argument("--dc-cycle-us", type=int, default=0, help="启用 DC 并设 SYNC0 周期(us); 0=free-run(默认, SYNC0 不打)") sp.add_argument("--dc-shift-us", type=int, default=0, help="SYNC0 相移(us), 仅在 --dc-cycle-us 非 0 时有效") sp.add_argument("substr", nargs="?", default="I226") sp = sub.add_parser("monitor", help="进 OP 只读输入 PDO") sp.add_argument("--rate-ms", type=int, default=200) sp.add_argument("--duration", type=float, default=10.0) sp.add_argument("--forever", action="store_true") sp.add_argument("--dc-cycle-us", type=int, default=0, help="启用 DC 并设 SYNC0 周期(us); 0=free-run(默认, SYNC0 不打)") sp.add_argument("--dc-shift-us", type=int, default=0, help="SYNC0 相移(us), 仅在 --dc-cycle-us 非 0 时有效") sp.add_argument("substr", nargs="?", default="I226") args = p.parse_args() if args.cmd is None: # 双击/无参数时默认执行 status print("(未指定子命令, 默认执行 status; 完整用法: ecat_tool.py -h)\n") args.cmd = "status" args.substr = "I226" if args.cmd == "adapters": list_adapters() elif args.cmd == "status": cmd_status(args) elif args.cmd == "flash": cmd_flash(args) elif args.cmd == "eeprom": if args.op == "read": if args.file_or_start is None or args.count_or_file is None: raise SystemExit("用法: eeprom read START COUNT [--out FILE]") args.start = int(args.file_or_start, 0) args.count = int(args.count_or_file, 0) else: if args.file_or_start is None: raise SystemExit(f"用法: eeprom {args.op} FILE") args.file = args.file_or_start cmd_eeprom(args) elif args.cmd == "sdo": if args.op == "write" and args.hex is None: raise SystemExit("用法: sdo write IDX SUB HEX") cmd_sdo(args) elif args.cmd == "send": cmd_send(args) elif args.cmd == "monitor": cmd_monitor(args) if __name__ == "__main__": main()