# AS5048A 编码器坏帧治理 —— 位置保持抖动修复整合文档

> 版本：**v20** ｜ 日期：2026-09-22（次日整理） ｜ 硬件：MKS ESP32 FOC V2.0 + GM5208-12（12N/14P）+ AS5048A（SPI 14bit）
> 固件：`桌面\work\配置文件\9-22\SimpleFOC_ESP32FOC\src\main.cpp`（525 行 / 27,015 B，单 UTF-8 BOM，LF）
> 验证报告（图形版）：`D:\workspace\Project1\位置保持抖动_修复验证报告.html`

---

## 0. TL;DR

| 项 | 结论 |
|---|---|
| 用户现象 | 「给的位置是 **200.123**，运动之后还是会有细微的抖动，频率不高」 |
| 根因 | **不是控制环问题**。AS5048A 的坏帧**能通过偶校验**被当成真实位置；再叠加传感器层的「伪展开」，会把连续域的**圈数**整圈拽走 |
| 反例教训 | 传感器层做「冻结式守卫」（超阈值沿用上一输出）是**严重回归**：位置反馈停住而环继续加力 ⇒ 正反馈暴走（100 s 内 7 次 ±36~54°、Vq 打到 ±2V 限幅） |
| 修法 | v20 四道防线：**真·模 2π 归约 → 帧级步长合理性（越界重读，不冻结）→ 死总线帧点名剔除 → 5 点中值** |
| 结果 | σ_rob **0.0255°（1.16 LSB = 编码器分辨极限）**；原始峰峰 **2.18° → 0.20°（11×）**、\|vel\|max **93 → 1.0 °/s（93×）**、Vqpp **0.773 → 0.042 V（18×）**、暴走 **0 次** |
| 瞬态 | 100° 阶跃超调 **+3.93° / +4.59°**、残差 ~0.00°、翻转 1（**不降反升**，v16 为 +4.52°/2 翻转） |
| 改动范围 | **只动固件**。配置文件与 DLL **未改**；位置环参数（`P_angle=20/20`、限速 1.5、`PID_vel=0.5/1.5`、`LPF_velocity.Tf=0.10`）**一个都没动** |

---

## 1. 问题与背景

调试目标是一个云台电机（GM5208-12）的单轴位置保持。移动到大角度目标（200.123 rad ≈ 31.9 圈）并停下后，
轴在**视觉上静止**，但编码器读数上仍有小幅、低频的扰动，且偶尔「抽一下」。

这不是普通的噪声，而是**坏读数进入位置环**：位置环把假位置当成真误差 ⇒ 立刻输出一记力矩脉冲 ⇒ 轴被踢一小步。
静止时看到的就是「细微抖动、频率不高」。

修复过程中还暴露出两个更隐蔽的问题（都在传感器层），见 §4。

---

## 2. 硬件与软件基线

| 项 | 值 |
|---|---|
| 控制板 | MKS ESP32 FOC V2.0（**开发板，非成品驱动器**） |
| 电机 | GM5208-12，12N/14P ⇒ **极对数 7**，相阻 ≈15.2 Ω |
| 限压策略 | 限「电压」，工作 **2.0 V**，硬件上限 4.0 V |
| 编码器 | **AS5048A**（SPI，14 bit，**无 I²C**），引脚固定 CLK18 / MISO19 / MOSI23 / CS5 |
| 帧结构 | `bit15 = 偶校验`、`bit14 = 错误标志 EF`、`bit13..0 = 14bit 角度` |
| 库 | arduino-cli + esp32 core 2.0.17 + SimpleFOC **2.2.1** |
| 主循环 | 实测 **~6.4~6.7 kHz**（随固件版本略有差异） |
| 控制结构 | 级联：`target → P_angle → 速度指令 → PID_velocity → Vq`（`MotionControlType::angle`） |
| 单位 | 位置目标 **弧度**（100° = 1.745） |
| 串口 | COM7 @ 115200 |

### 2.1 关键背景：SimpleFOC 丢掉了两个有效性位

```cpp
// SimpleFOC MagneticSensorSPI::read() 的最后两句
register_value = register_value >> (1 + data_start_bit - bit_resolution);
return register_value & 0x3FFF;     // ★ 校验位和 EF 全被丢掉
```

⇒ **每一帧坏数据都被当成「真实位置」喂进位置环**。这是整个问题的入口，
所以必须在传感器层自己读帧、自己校验、自己滤。

---

## 3. 排查路径（版本演进）

| 版本 | 传感器侧做法 | 定位 |
|---|---|---|
| v13 | 无任何滤波（基线） | 复现抖动：σ_rob **4.271°** |
| v14 / v15 | 中间探索版 | — |
| **v16** | 自写 `AS5048AGuarded`：偶校验重读 + **3 点中值**（连续域） + `X` 自检命令 | σ 掉到 1.16 LSB；但仍有单点毛刺 |
| **v17** | **5 点中值** + `LPF_velocity.Tf` 0.03 → **0.10** | 吃掉 2 连坏帧；压掉速度估计的量化尖峰 |
| **v18** | 中值后加 **「物理步长守卫」**（超 0.5° 就**沿用上一输出**） | ❌ **严重回归**，见 §4.3 |
| **v19** | 守卫改为「**只记录不拦截**」+ **剔除死总线帧** `0x0000/0xFFFF` | 暴走 7 → 1 次；并靠现场记录器抓到真正根因 |
| **v20** | **真·模 2π 归约** + **帧级步长合理性（越界重读）** | ✅ **暴走 0 次**，毛刺进入噪声底 |

---

## 4. 根因分析

### 4.1 坑 A：坏帧能通过偶校验

| 坏帧形态 | 为什么拦不住 | 解码结果 |
|---|---|---|
| `0x0000`（MISO 恒低 / 器件无应答） | 1 的个数 = **0（偶数）** ⇒ 偶校验**通过** | **0°** |
| `0xFFFF`（MISO 恒高） | 1 的个数 = **16（偶数）** ⇒ 偶校验**通过** | **359.98°** |
| `0x4001` / `0x4004`（EF=1 + 角度≈0） | **与好帧结构完全一样**、偶校验也成立 | **0.02° / 0.09°** |

后一类最恶劣：**任何位级校验与模式匹配都抓不到它**，只能靠「这一帧在物理上可能出现吗」来判。

实测（本机 100% 采样）
- `bit14 (EF)` **恒为 1**（100% 的帧）⇒ EF 不能当判据；
- `bit15` 确是偶校验位，但**几乎从不失败**（1.29 M 帧只命中 2 次）；
- 健全帧的低 14 位角度完全正确（实测 2963 ⇒ 65.10°，真值 65.11°）；
- 坏帧率 ≈ **0.25%**（≈16 帧/秒 @6.7 kHz），且与 **PWM 开关耦合**相关（使能 2.0 次/秒 vs 失能 0.12 次/秒）。

### 4.2 坑 B（更隐蔽、更致命）：「一次 ±2π 修正」不是真展开

```cpp
// ✗ 老写法：只在 |a - last| < 3π 时才等价于"取最近等价角"
float d = a - last;
if (d >  _PI) d -= _2PI;
if (d < -_PI) d += _2PI;
float u = last + d;
```

关键矛盾：
- `last` 是**连续域**值，大角度目标下可达 **±200 rad**；
- 坏帧给出的 `a` 恒在 **[0, 2π)**；
- ⇒ 差值能到 **200 rad**，**一次修正远远不够**，`u` 落到**错误的圈数**上。

后果：把 3 个这样的值塞进中值窗口，中值（以及 `last`）就被**整圈拽走** ⇒
`motor.shaft_angle` 直接跳 **360° 的整数倍**。

**现场证据**（v19 固件的 8 条现场记录器吐出来的，真值 5.3452 rad）：

```
EV rx=4004 good=1 | a=0.0015 u=6.2847 m=6.2847 last=12.5652 (rad)
EV rx=4001 good=1 | a=0.0004 u=6.2836 m=6.2836 last=12.5641 (rad)
```

读法：`rx` = SPI 原始帧；`a` = 解码角度；`u` = 展开后连续值；`m` = 中值；`last` = 上一输出。
`0x4004` 被当成合法帧（`good=1`，偶校验成立），而 `last = 12.5652 ≈ 2π×2` —— **圈数已经错了**。

### 4.3 反例：传感器层「冻结式守卫」是严重回归

思路很自然：中值输出与上一输出相差超过物理可能的一步，就判为假读数、**沿用上一个输出**。
**实测是灾难**（同目标 200.123 rad / 100 s / 120 Hz）：

| 固件 | 基准 | 超调/毛刺 | \|vel\|max | Vqpp | Vq 打到 ±1.9V 的点 | 暴走段(>3°) |
|---|---|---|---|---|---|---|
| v16 | — | 2.18° | 93.2 °/s | 0.773 V | 0 | 0 |
| v17 | — | 2.44° | 23.5 °/s | 0.157 V | 0 | 0 |
| **v18** | **冻结式守卫(0.5°)** | **65.66°** | **515.8 °/s** | **3.980 V** | **17** | **7** |
| v19 | 冻结 → 只记录 | 37.24° | 342.2 °/s | 0.522 V | 0 | 1 |
| **v20** | **真归约 + 重读式检查** | **0.20°** | **1.0 °/s** | **0.042 V** | **0** | **0** |

**机理**：冻结输出 = 让**位置反馈停在旧值**，而位置环**仍在按那个旧值加力**
⇒ 环把轴真推走 ⇒ 中值追着真位置跑 ⇒ 与冻结值越差越远 ⇒ 继续冻结 —— **正反馈自激**。
同时被污染的展开锚点会让 `Sensor::update()` 的整圈计数一起错掉。

> ★★ **结论：传感器层永远不要「冻结输出」。要丢弃就重读。**

另注：`Sensor::update()` 里有 `if (|d_angle| > 0.8·2π) full_rotations += ...`，
所以一旦展开值被拽走，圈数错误还会被放大成整圈级的 `shaft_angle` 跳变 —— 这也解释了
为什么 v18 的毛刺量级是 36°/54° 而不是几度。

---

## 5. v20 方案

### 5.1 四道防线（顺序即优先级，越靠前越治本）

| # | 防线 | 作用 | 阈值 |
|---|---|---|---|
| ① | **真·模 2π 归约** | 坏帧误差被限制在 ≤π，**不可能改变圈数**（治本） | — |
| ② | **帧级步长合理性**（越界即**重读**，最多 3 次） | 3 连以上坏帧也进不了中值窗口 | `AS5048_STEP_EPS = 0.035 rad ≈ 2.0°` |
| ③ | **死总线帧点名剔除** | `0x0000` / `0xFFFF` 能骗过偶校验 | 精确匹配 |
| ④ | **5 点中值（连续域）** | 兜底吃掉 ≤2 连坏帧 | 窗口 5 点 |
| + | **现场记录器**（只计数不干预） | 抓坏帧现场，供串口打印 | 触发阈 2.9° |

### 5.2 核心代码

```cpp
// ① 真·模 2π 归约（v20 核心修复）
float d = a - last;
d -= _2PI * roundf(d / _2PI);      // 归约到 (-π, π]，与 |d| 多大无关
float u = last + d;

h[hi % AS5048_MED_N] = u;
hi++;
if (hi < AS5048_MED_N) { last = u; return u; }         // 先把窗口填满

// ④ 5 点中值（连续域）
float m = median5(h[0], h[1], h[2], h[3], h[4]);
if (fabsf(m - u) > AS5048_MED_EPS) n_jump++;           // 被挤掉的帧（计数）
if (fabsf(m - last) > AS5048_BURST_EPS) {              // 连串穿透：只记录，不拦截
  n_burst++;
  Ev& e = ev[ev_i & 7]; ev_i++;                        // 记现场，供 X 打印
  e.rx = rx; e.a = a; e.u = u; e.m = m; e.last = last; e.good = good ? 1u : 0u;
}
last = m;
n_ok++;
return m;
```

```cpp
// ③ 死总线帧点名剔除
//    它们的偶校验恰好都能通过（1 的个数 0 / 16 都是偶数），只查校验根本拦不住。
static bool deadBus(uint16_t rx) { return rx == 0x0000 || rx == 0xFFFF; }

// ② 帧的"步长合理性"：把原始角度按 2π 归约到 (-π, π] 与上一连续值比较
bool stepPlausible(uint16_t rx) {
  if (!primed || !guard) return true;                  // 对齐期轴会大范围扫动
  float a = (float)(rx & 0x3FFF) * (_2PI / (float)AS5048_CPR);
  float d = a - last;
  d -= _2PI * roundf(d / _2PI);
  return fabsf(d) <= AS5048_STEP_EPS;
}

bool readValid(uint16_t& rx) {
  for (int k = 0; k < 3; k++) {                        // 最多读 3 次，挑一帧"干净"的
    uint16_t r = frame();
    if (deadBus(r)) { n_dead++; dbg_par = r; continue; }   // 死总线：丢弃重读
    if (!parityOk(r)) {                                    // 校验不过：丢弃重读
      n_par++; dbg_par = r;
      if ((r >> 14) & 0x1) n_ef++;
      continue;
    }
    rx = r;
    if (stepPlausible(r)) return true;
    n_glitch++;                                            // 越界：重读
  }
  return false;   // 三次都没拿到"干净"帧 ⇒ 调用方沿用 last，只冻结 1 帧
}
```

### 5.3 阈值怎么定（`AS5048_STEP_EPS = 2°`）

轴在一个控制周期内**物理上**能走多远：
- 位置模式限速 1.5 rad/s、主循环 6.6 kHz ⇒ **0.013°/周期**；
- 退到速度模式上限 8 rad/s ⇒ 0.070°/周期；
- 主循环即便**卡 20 ms** 也才 1.7°。

⇒ 取 **2°**：对 6.6 kHz 有 **150 倍余量**，能容忍 20 ms 的抖动；
而坏帧（角度≈0 而真值 306°）差 ~5.3 rad ⇒ 轻松命中。

---

## 6. 实测数据

### 6.1 版本横向对比（同目标 200.123 rad / 保持 100 s / 120 Hz）

| 固件 | σ_rob | = LSB | 稳健峰峰 | **原始峰峰** | **\|vel\|max** | **Vqpp** | Vq饱和点 | **暴走段(>3°)** | JU |
|---|---|---|---|---|---|---|---|---|---|
| v16 | 0.0255° | 1.16 | 0.774° | 2.177° | 93.2 °/s | 0.773 V | 0 | 0 | 4258 |
| v17 | 0.0255° | 1.16 | 0.155° | 2.441° | 23.5 °/s | 0.157 V | 0 | 0 | 4556 |
| v18 | 0.0255° | 1.16 | 2.808° | **65.655°** | **515.8 °/s** | **3.980 V** | **17** | **7** | 5386 |
| v19 | 0.0255° | 1.16 | 0.550° | 37.242° | 342.2 °/s | 0.522 V | 0 | 1 | 4503 |
| **v20** | **0.0255°** | **1.16** | **0.086°** | **0.200°** | **1.0 °/s** | **0.042 V** | **0** | **0** | **536** |

> **σ_rob 从 v16 到 v20 一直是 0.0255°（1.16 LSB）** —— 那是 14 bit 编码器的分辨极限
> （LSB = 360/16384 = **0.0220°**），**σ 已经没有优化空间**。
> 真正的差别全在**毛刺**上：原始峰峰 **11×**、\|vel\|max **93×**、Vqpp **18×**。
> ★ 验收必须把 σ、原始峰峰、\|vel\|max、Vqpp、暴走段数**一起报**，只报 σ 会得出「没改善」的错误结论。

### 6.2 v20 保持抖动（200.123 rad，100 s，119 Hz，11,853 点）

| 指标 | 值 |
|---|---|
| σ_rob | **0.0255°**（1.16 LSB） |
| 稳健峰峰（0.5~99.5%） | 0.086° |
| 原始峰峰 | **0.200°** |
| 1 秒窗口最大峰峰 | 0.132° |
| \|vel\| 峰值 | **1.0 °/s** |
| Vq 峰峰 | **0.042 V**（离 ±2.0 V 限幅很远） |
| 暴走事件（>3°） | **0 次** |

### 6.3 瞬态回归（`_step100.py`）

| 项目 | 结果 |
|---|---|
| 100° 阶跃 #1 | 超调 **+3.93°**、到位 2.9 s、末态残差 **+0.003°**、翻转 **1** |
| 100° 阶跃 #2 | 超调 **+4.59°**、到位 2.7 s、末态残差 **−0.002°**、翻转 **1** |
| 5° 小阶跃 #1 | 峰值 +0.191°、末态 −0.031°、\|vel\|max 3.56 °/s |
| 5° 小阶跃 #2 | 峰值 +0.317°、末态 −0.003°、\|vel\|max 4.90 °/s |
| 参考（v16 同口径） | 超调 +4.52°、翻转 2 |

⇒ **瞬态不降反升**，证明所有改动都只作用在「传感器读数」这一层，**没有碰控制环**。

### 6.4 计数（v20 一次阶跃会话）

```
SA=160617  JU=77  BS=0  PA=516  EF=463  DB=0  GL=421  loop=5942Hz
```

- `BS = 0` ⇒ 连串穿透已被前面三道防线**全部拦下**；
- `GL = 421` ⇒ 步长检查**确实在干活**，且每次重读都拿到了干净帧 ⇒ **输出零毛刺**；
- 对比 v18 的 `BS=7112`：那是「检测到但冻结输出」的次数，正是暴走的来源。

---

## 7. 可复用的诊断方法论

1. **先做判别器：把电机失能再采一次。**
   失能后角度仍自己漂/跳 ⇒ **传感器问题**；纹丝不动（σ≈1 LSB）⇒ 环的问题。
   这是「到底是传感器还是控制环」最省事的硬判据。
2. **度量必须 ≥100 Hz 采样**，且**不能只给 σ**。
   单点坏帧会把 σ、峰峰、到位时间全毁掉。四条一起给：
   **稳健 σ（MAD×1.4826）/ 稳健峰峰 / 原始峰峰 / 单点尖刺数**。
3. **给固件装一个「现场记录器」。**
   当出现可疑事件时，把**原始帧 / 解码值 / 展开值 / 中值 / 上一输出**存进 8 条环形缓冲，
   用**同一条 debug 命令**打印。这比「猜坏帧长什么样」快一个数量级 ——
   本次就是靠它一次定案的（`rx=0x4004` + `last=12.5652`）。
4. **别在传感器层做「冻结/沿用上一输出」的守卫**，要丢弃就**重读**。
5. **串口探针的两个坑**（会伪造出完全错误的结论）：
   - **命令回显竞争**：SimpleFOC 的 `B<目标>` 会**回显**目标值。发完立刻读角度可能读到回显本身
     ⇒ 误判「瞬间到位」，而电机其实还在以 ~85°/s 横穿 199 rad。已因此得到过
     完全错误的「保持抖动 680° 峰峰 / 975 尖刺」。
     ⇒ 正确做法：开测前 `drain()` 一次，之后只做「写一行/读一行」靠应答前缀自同步；
       到位判定要「连续 N 次在带内 + 至少 1 s」。
   - **每条命令前等 150 ms 静默**会把采样率降到 **6 Hz**，3 Hz 以上什么都看不到。

---

## 8. 文件清单 / 工具 / 回退

### 8.1 固件与备份

| 文件 | 说明 |
|---|---|
| `...\9-22\SimpleFOC_ESP32FOC\src\main.cpp` | **当前 v20**（525 行 / 27,015 B） |
| `main.cpp.bak_v19 … .bak_v13` | 逐版备份，逐个可回退 |
| `main.cpp.bak_before_as5048a` | 未接管传感器层之前的原始版 |
| `_build_single\out\SimpleFOC_Single.ino.bin` | 已烧录的 v20 二进制（307,856 B，md5 `eec40a7095138eb13023784f86cf022b`） |
| `_build_single\out_prev_v16\`、`out_prev_v13\` | 二进制级备份 |
| `_build_single\compile_v19.log` / `compile_v20.log` | 编译日志（0 错误） |
| `_build_single\upload_v19.log` / `upload_v20.log` | 烧录日志（各 4 段 `Hash of data verified`） |

### 8.2 脚本（均在 `D:\workspace\Project1\`，用 Python 3.10 跑，含 pyserial）

| 脚本 | 用途 |
|---|---|
| `_hold100.py <ver>` | 聚焦：移动 200.123 + 保持 100 s，存 `_hold100_<ver>.json` |
| `_step100.py` | 聚焦：100° / 5° 阶跃回归 + 计数 |
| `_cmp_versions.py` | v16→v20 横向对比表 |
| `_verify_v18.py` / `_verify_v18b.py` | 全量验证（100° 阶跃 + 小目标 + 100 s 保持 + 计数） |
| `_patch_v19.py` / `_patch_v20.py` | 固件补丁（字节安全：锚点 `count==1` 断言、单 BOM 校验、LF 校验） |
| `_gen_report5.py` | 生成图形版 HTML 报告 |
| `_save_trace.py` / `_save_trace2.py` | 保存原始轨迹 |
| `_sweep_bvf.py` | `LPF_velocity.Tf` 在线扫参（免烧录） |
| `_dis_vs_en.py` | 失能/使能判别器 |

### 8.3 数据文件

| 文件 | 内容 |
|---|---|
| `_hold_trace2.json` | v16 的 200.123 / 100 s 轨迹 |
| `_hold_trace3.json` | v17 同口径轨迹 |
| `_hold_trace4.json` | v18 同口径轨迹（暴走对照） |
| `_hold100_v19.json` / `_hold100_v20.json` | v19 / v20 同口径轨迹 |
| `_hold100_v19.out` | v19 的 `X` 输出（**含 EV 现场证据**） |
| `_step100_v20.out` | v20 的阶跃回归 + 计数 |
| `_cmp_versions.out` | 版本对比表 |
| `位置保持抖动_修复验证报告.html` | 图形版报告（轨迹图、柱状对比、现场证据） |

### 8.4 回退方法

```
# 源码回退（任选一版）
copy  ...\9-22\SimpleFOC_ESP32FOC\src\main.cpp.bak_v19  main.cpp      # 回到 v19
copy  ...\9-22\SimpleFOC_ESP32FOC\src\main.cpp.bak_v17  main.cpp      # 回到 v17（无守卫版）

# 重新编译 + 烧录
cd D:\workspace\Project1\_build_single
copy <main.cpp> SimpleFOC_Single\SimpleFOC_Single.ino
arduino-cli.exe --config-file D:\workspace\tools\arduino-cli\arduino-cli.yaml ^
    compile --fqbn esp32:esp32:esp32 --output-dir out SimpleFOC_Single
arduino-cli.exe --config-file D:\workspace\tools\arduino-cli\arduino-cli.yaml ^
    upload -p COM7 --fqbn esp32:esp32:esp32 --input-dir out
```

> ★ Git Bash 里调 arduino-cli **必须** `MSYS_NO_PATHCONV=1` + `D:/...` 正斜杠，
> 否则路径被转成 `d:\d\workspace\...` 报 `Can't open sketch`。

---

## 9. 后续建议

按收益排序：

1. **从源头减少坏帧（电气层）**。实测坏帧率与 PWM 开关耦合强相关（使能 2.0 次/秒 vs 失能 0.12 次/秒）。
   可尝试：
   - 缩短 SPI 走线、远离功率回路；
   - MISO 串 **100 Ω**、对地 **10~47 pF**；
   - SPI 时钟 **1 MHz → 400 kHz**（AMS 上限 10 MHz，但降速对时序余量有好处）；
   - 编码器供电加去耦（100 nF + 10 µF）。
   软件层已经兜住，但源头干净能让余量更大。

2. **分辨率硬上限**：14 bit ⇒ **0.0220°/LSB**。当前 σ 已压到 1.16 LSB，
   想再提升只能换更高位编码器（如 AS5047P 14bit 同档、或 17~21 bit 磁编/光编）。

3. **临时调试手段**（免烧录）：`BVF0.03` 可关掉速度滤波看原始速度噪声；
   `BLV/BAI/BAP/BVP/BVI/BLU` 在线调位置环与限幅；
   `BMD1` 开 monitor、`BMG7` 取全状态。

---

## 附录 A · 串口命令与计数含义

### A.1 控制命令

| 命令 | 作用 |
|---|---|
| `A\|B<rad>` | 设位置目标（单位**弧度**，100° = 1.745）。`A` 是 `B` 的别名 |
| `BE1` / `BE0` | 使能 / 失能。★ 上电目标 = 当前位置，`BE1` **不会**跳回零点 |
| `BMD1` / `BMD0` | 开 / 关监视输出 |
| `BMG7` | 一次性打印全状态：`target;Vq;Vd;Cq;Cd;vel;angle` |
| `BMG6` | 一次性打印角度 |
| `BLV/BAI/BAP/BVP/BVI/BLU/BVF` | 在线调 限速/位置环I/位置环P/速度环P/速度环I/限压/速度滤波 Tf |
| `#4` | 清速度环积分（换向/停车后不"憋劲"） |
| `X` | **传感器自检**：计数 + 原始帧环形缓冲 + 连串穿透现场 |

### A.2 `X` 计数含义

| 缩写 | 含义 | v20 期望值 |
|---|---|---|
| `SA` | 有效帧数（accepted） | — |
| `JU` | 被中值挤掉的帧 | 小 |
| `BS` | 连串穿透（中值输出与上一输出差 >2.9°） | **恒 0** |
| `PA` | 偶校验失败的读次数 | 小（每帧最多计 3 次） |
| `EF` | EF 位置位的次数 | 仅统计 |
| `DB` | 死总线帧（`0x0000`/`0xFFFF`） | 少量 |
| `GL` | 步长越界重读次数 | 数百/会话，**正常** |
| `loop` | 主循环频率 | ~6.5 kHz |

### A.3 `EV` 现场行格式

```
EV rx=<原始帧HEX> good=<0/1> | a=<解码值> u=<展开值> m=<中值> last=<上一输出> (rad)
```

---

## 附录 B · 复现命令

```bash
cd D:\workspace\Project1

# 1) 聚焦：100 s 保持（约 4.5 分钟，含 143 s 行程）
python _hold100.py v20

# 2) 聚焦：阶跃回归 + 计数（约 1 分钟）
python _step100.py

# 3) 版本横向对比
python _cmp_versions.py

# 4) 重新生成图形版报告
python _gen_report5.py
```

```bash
# 5) 手工看传感器现场（串口 115200）
#    开串口会触发 ESP32 自动复位，setup()（含 initFOC）约 8.7 s 才进 loop()
#    发 X 后读一行 SA... + 4 行 RG... + EV 行
```

---

## 附录 C · 结论速查卡

```
现象：停在目标后"细微抖动、频率不高"
  ↓
第一步：电机失能再采一次
  ├─ 失能后纹丝不动  ⇒ 控制环问题（查齿槽井 / P_angle.I / 限幅）
  └─ 失能后仍跳/漂   ⇒ 传感器坏帧（本文档的情形）
        ↓
     不要调 P/I（只会掩盖症状）
        ↓
     传感器层做三件事：
       ① 真·模 2π 归约   d -= 2π*roundf(d/2π)
       ② 帧级步长合理性   越界即重读（不冻结！）
       ③ 死总线帧剔除     0x0000 / 0xFFFF（它们能骗过偶校验）
       + 5 点中值兜底 + 现场记录器
        ↓
     验收：σ(1.16 LSB) + 原始峰峰 + |vel|max + Vqpp + 暴走段数，一起报
```
