任务支持 #2544 » AS5048A坏帧治理_v20_整合文档.md
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 丢掉了两个有效性位
// 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π 修正」不是真展开
// ✗ 老写法:只在 |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 核心代码
// ① 真·模 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;
// ③ 死总线帧点名剔除
// 它们的偶校验恰好都能通过(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 LSB)⇒ 环的问题。
这是「到底是传感器还是控制环」最省事的硬判据。 -
度量必须 ≥100 Hz 采样,且不能只给 σ。
单点坏帧会把 σ、峰峰、到位时间全毁掉。四条一起给:
稳健 σ(MAD×1.4826)/ 稳健峰峰 / 原始峰峰 / 单点尖刺数。 -
给固件装一个「现场记录器」。
当出现可疑事件时,把原始帧 / 解码值 / 展开值 / 中值 / 上一输出存进 8 条环形缓冲,
用同一条 debug 命令打印。这比「猜坏帧长什么样」快一个数量级 ——
本次就是靠它一次定案的(rx=0x4004+last=12.5652)。 - 别在传感器层做「冻结/沿用上一输出」的守卫,要丢弃就重读。
-
串口探针的两个坑(会伪造出完全错误的结论):
-
命令回显竞争:SimpleFOC 的
B<目标>会回显目标值。发完立刻读角度可能读到回显本身
⇒ 误判「瞬间到位」,而电机其实还在以 ~85°/s 横穿 199 rad。已因此得到过
完全错误的「保持抖动 680° 峰峰 / 975 尖刺」。
⇒ 正确做法:开测前drain()一次,之后只做「写一行/读一行」靠应答前缀自同步;
到位判定要「连续 N 次在带内 + 至少 1 s」。 - 每条命令前等 150 ms 静默会把采样率降到 6 Hz,3 Hz 以上什么都看不到。
-
命令回显竞争:SimpleFOC 的
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. 后续建议
按收益排序:
-
从源头减少坏帧(电气层)。实测坏帧率与 PWM 开关耦合强相关(使能 2.0 次/秒 vs 失能 0.12 次/秒)。
可尝试:- 缩短 SPI 走线、远离功率回路;
- MISO 串 100 Ω、对地 10~47 pF;
- SPI 时钟 1 MHz → 400 kHz(AMS 上限 10 MHz,但降速对时序余量有好处);
- 编码器供电加去耦(100 nF + 10 µF)。
软件层已经兜住,但源头干净能让余量更大。
-
分辨率硬上限:14 bit ⇒ 0.0220°/LSB。当前 σ 已压到 1.16 LSB,
想再提升只能换更高位编码器(如 AS5047P 14bit 同档、或 17~21 bit 磁编/光编)。 -
临时调试手段(免烧录):
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 · 复现命令
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
# 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 + 暴走段数,一起报