fix(server): 修模块级变量粘连, op/start 入口预清理重枚举

1) g_op_active = Falseg_httpd = None 这行把 None 赋给了 g_op_active 和
   笔误变量 Falseg_httpd, 导致 g_httpd 在模块级从未定义, 第 744 行
   if g_httpd is not None 在服务启动前命中即 NameError。拆成两行。
   语法合法故初次提交未拦住。

2) _require_op_ready 入口无条件预清理(回 INIT + config_init 重枚举):
   同一连接反复停止/启动而不重枚举时从站邮箱通路逐轮退化, 第 4 轮
   config_map() 内 SOEM CoE 分段读的无边界 memcpy 吃到楔死邮箱异常帧
   触发 0xC0000005 闪退。置于入口使所有调用方全覆盖, 单从站成本 ~100ms。
   op_start 的重试分支因此简化为直接重跑序列。

3) config_map 失败时附带邮箱 SM 寄存器现场, 便于对照 GD32 串口日志。
   地址按 SM = 0x0800 + 8n 修正为 SM0=0x0800 / SM1=0x0808;
   原写的 0x0810/0x0818 实为 SM2/SM3(过程数据), 抓的现场会误导判断。
This commit is contained in:
cbb
2026-09-03 02:35:24 +08:00
parent d594b8fd94
commit a1ec486c74
+41 -15
View File
@@ -96,7 +96,8 @@ EC_TIMEOUT_STATE_US = 2_000_000
g_master = None g_master = None
g_desc = "" g_desc = ""
g_op_active = Falseg_httpd = None g_op_active = False
g_httpd = None
g_child = None # 窗口模式: HTTP 服务子进程(仅 GUI 父进程使用) g_child = None # 窗口模式: HTTP 服务子进程(仅 GUI 父进程使用)
g_port = None g_port = None
g_win = None g_win = None
@@ -319,7 +320,7 @@ def api_flash(req):
def _require_op_ready(dc_cycle_us=0, dc_shift_us=0): def _require_op_ready(dc_cycle_us=0, dc_shift_us=0):
"""进 OP: PREOP → config_map → (可选 DC) → SAFEOP → OP。返回 (log, ok)。 """进 OP: [预清理重枚举] → PREOP → config_map → (可选 DC) → SAFEOP → OP。返回 (log, ok)。
注意 pysoem 语义:state_check(X) 只是轮询等待从站到达 X,不会发送 AL Control; 注意 pysoem 语义:state_check(X) 只是轮询等待从站到达 X,不会发送 AL Control;
真正请求状态迁移必须 g_master.state = X_STATE 后 write_state()。旧版本进 SAFEOP 真正请求状态迁移必须 g_master.state = X_STATE 后 write_state()。旧版本进 SAFEOP
@@ -337,6 +338,26 @@ def _require_op_ready(dc_cycle_us=0, dc_shift_us=0):
log = [] log = []
if g_master is None: if g_master is None:
return ["未连接"], False return ["未连接"], False
# 0) 无条件预清理:完整回 INIT + config_init 重枚举(2026-09-03 01:49 复现实锤)。
# 同一连接上反复 停止/启动 而不重枚举时,从站邮箱/PDI 通路逐轮退化:第 2 轮起
# OP 态 SDO 读 0x2000 全部 WkcError(即 UI 的"SDO 读 0x2000 失败"),第 4 轮
# config_map() 内 SOEM CoE 读(ethercatcoe.c ecx_SDOread 分段传输循环 memcpy
# 无边界检查,上游缺陷)吃到楔死邮箱的异常帧 → 原生访问违例(0xC0000005)
# → 服务进程闪退、GUI 连带退出。与 01:40 用户日志逐行吻合:最后一轮"进入 OP"
# 后无任何输出 = 进程死在 config_map,响应未及回传。config_init 重写 SM0/SM1
# + 重建 slavelist,与实测恒有效的"断开重连"同机理;单从站成本 ~100ms。
# 置于入口(而非失败后)使所有调用方(op_start/自动重试/op_cycle 兜底)全覆盖,
# 且在 s = slaves[0] 取引用之前执行(config_init 会重建 slaves 列表)。
log.append("预清理:回 INIT + config_init 重枚举(等效断开重连,防楔死邮箱上 config_map 原生崩溃)…")
try:
g_master.state = pysoem.INIT_STATE | pysoem.STATE_ACK
g_master.write_state()
g_master.state_check(pysoem.INIT_STATE, EC_TIMEOUT_STATE_US)
g_master.config_init()
except Exception as e:
log.append(f"WARN: 预清理失败({type(e).__name__}: {e}),继续原序列")
s = g_master.slaves[0] s = g_master.slaves[0]
# 1) INIT -> PREOP:SSC 在此启用邮箱 SM(APPL_StartMailboxHandler 直接 ack) # 1) INIT -> PREOP:SSC 在此启用邮箱 SM(APPL_StartMailboxHandler 直接 ack)
@@ -389,7 +410,19 @@ def _require_op_ready(dc_cycle_us=0, dc_shift_us=0):
try: try:
g_master.config_map() g_master.config_map()
except Exception as e: except Exception as e:
return log + [f"config_map 失败: {type(e).__name__}: {e}"], False # 顺带抓邮箱 SM 寄存器现场(8B: start/len/ctl/status/activate/pdi)——
# 2026-09-03 02:12 实测楔死时 activate 位/StartAddr 异常(INIT 态读回全零,
# PREOP 态读回 SII 值),这是区分"邮箱 SM 被禁用/未配置"与"SSC 不处理邮箱"
# 的关键证据;失败信息里直接带上,抓 GD32 串口时可对照。
# SM 寄存器 = 0x0800 + 8n, 邮箱是 SM0=0x0800 / SM1=0x0808;
# 0x0810/0x0818 是 SM2/SM3(过程数据), 勿混用。
try:
sm0 = bytes(s._fprd(0x0800, 8, 20000)).hex().upper()
sm1 = bytes(s._fprd(0x0808, 8, 20000)).hex().upper()
sm_info = f"; 邮箱SM现场 SM0={sm0} SM1={sm1}(act位=byte[6],01=使能)"
except Exception:
sm_info = ""
return log + [f"config_map 失败: {type(e).__name__}: {e}{sm_info}"], False
# 注意:这里**不要**写 0x1C32:01 / 0x1C33:01。 # 注意:这里**不要**写 0x1C32:01 / 0x1C33:01。
# #
@@ -597,18 +630,11 @@ def api_op_start(req):
dc = _dc_args(req) dc = _dc_args(req)
log, ok = _require_op_ready(*dc) log, ok = _require_op_ready(*dc)
if not ok: if not ok:
# 2026-09-03 自动重试一次:实测"断开重连后正常",把该恢复力内建—— # 自动重试一次:_require_op_ready 入口已自带"回 INIT + config_init 重枚举"
# 带 ACK 完整回 INIT(清从站残留错误位/SM 状态) + config_init 重枚举 # 预清理(2026-09-03 01:49 复现实锤:楔死邮箱上跑 config_map 会触发 SOEM
# (重写 SM/FMMU,等效重连的核心步骤) + 完整状态序列重来。 # CoE 分段读的无边界 memcpy → 0xC0000005 原生崩溃闪退,预清理是唯一可靠
# 固件≥1.5.48 的 0x204 守护也会在这轮里自愈 AL Event Mask。 # 规避路径),重试即完整等效"断开重连"。
log.append("自动重试:完整回 INIT + 重枚举后重新执行序列…") log.append("自动重试:重新执行完整序列(入口含重枚举预清理)…")
try:
g_master.state = pysoem.INIT_STATE | pysoem.STATE_ACK
g_master.write_state()
g_master.state_check(pysoem.INIT_STATE, EC_TIMEOUT_STATE_US)
g_master.config_init()
except Exception as e:
log.append(f"WARN: 重试前清理失败({type(e).__name__}: {e})")
log2, ok = _require_op_ready(*dc) log2, ok = _require_op_ready(*dc)
log += log2 log += log2
if not ok: if not ok: