feat(ota): 电机 OTA 支持 EtherCAT 模式(CoE 邮箱进料 + 通道独占锁)

进料口从「只有 ESP32 SPI 一条」扩成两条,中继引擎不变:主站经 CoE 0x2511 数据窗
把电机固件明文灌进 GD32 的 48KB SRAM 暂存区,再由 comm/ecat_motor_ota 泵进原有
4KB 环缓;AES 握手、CAN 单发停等、重发、0AAC 运行确认全部沿用 MotorOtaRelay。
纯网线(ESP32 WiFi 关断、/motor/ota 打不通)场合因此也能刷电机。
偏移/幂等判据仍只有一份:feedData 拆出 feedAt,两个进料口共用。

同时把审计报告 VULN-12/13/14 落地(此前只存在于文档,一行未实现):start() 第一条
语句在 PRIMASK 内原子置位 s_channel_lock_mask,EtherCAT 桥接 sendBatch/processOutput
与 SPI 0xC0/0xC7 对锁定通道回新码 0xE7=「被电机 OTA 独占」,不冒充「邮箱满」0xE4。
反向互斥一并补上:电机 OTA 活跃时 requestEnterBoot() 直接拒绝——SPI 0xB0、CoE 0x2502、
CAN 0x320 三条交接入口共用这一个判据,避免复位把刷写腰斩在 erase 之后。

完整性口径按链路分开,不互相冒充:块级 CRC32 + 整镜像 CRC32 都在从站核,且核过才
让电机进 BOOT——失败时电机仍跑旧 APP、擦除没开始,不存在「先擦再验」的变砖路径。
CRC 为 CRC-32/B(poly 0x04C11DB7,初值 0xFFFFFFFF,无反射无末尾异或),与 v2_crc32
同算法(用公开向量 123456789=0x0376E6E7 对过)。

块长取 1024 而不是 BOOT 用的 4096:SSC 的分段下载缓冲是全工程唯一的 malloc 用户,
而 APP 的 __HEAP_SIZE 只有 3KB,4096 会被从站当场回 abort 0x05040005 Out of memory
(BOOT 的堆是 16KB 才撑得住 4096——块长能抄,堆抄不了)。这是跨仓常量,须与
tools/ecat 的 MFO_CHUNK_OBJ 同步。

APP 1.7.4(RAM ZI 97156)。已实测:0x2510 参数写与一块 1016B 数据写均被从站接受
(staged=1016、phase bit4);端到端电机刷写待复测(native 块长修复后未跑完整一轮)。
This commit is contained in:
cbb
2026-10-11 02:47:19 +08:00
parent f3d7fc0c30
commit d8b3c9767c
14 changed files with 888 additions and 76 deletions
+149 -73
View File
@@ -5,6 +5,7 @@
#include "sys/motor_ota_relay.h"
#include "sys/aes128.h"
#include "sys/config.h"
#include "sys/critical.h"
#include "sys/systick.h"
#include "sys/task_manager.h"
#include "main.h"
@@ -29,11 +30,23 @@ static const uint8_t K_MASTER[16] = {
* 提到 4KB≈85 包(~420ms)给 WiFi/HTTP 抖动更大缓冲。 */
static constexpr uint16_t kBufSize = 4096U;
/* ═══════════════════ 通道独占锁 ═══════════════════ */
/* 位 0..2 = CAN 通道 0..2 正被 OTA 独占。置位于 start() 第一条语句(早于任何
* 状态清零与串口日志),释位于 enterState() 进终态时。
* EtherCAT 的 PDO→CAN 在 Sync0_Isr(preempt 0) 里发,OTA 的 START 可能来自主循环
* 也可能来自 EXTI10_15(preempt 0),因此置位/判忙必须原子完成——否则两者之间
* 的窗口(含 LOG 的百微秒级耗时)足够让一拍运动帧灌进正在升级的通道。
* 同一时刻只跑一个会话,故写入用整字赋值/在 PRIMASK 里做单次 RMW,不做 RMW 于中断。 */
static volatile uint32_t s_channel_lock_mask = 0U;
/* 本次会话的进料来源:环缓只认一路,交叉写入会拼出「一半 WiFi 一半 ECAT」的镜像。 */
static FeedSource s_feed_src = FeedSource::kSpi;
/* ═══════════════════ 内部状态 ═══════════════════ */
static State s_state = State::kIdle;
static volatile State s_state = State::kIdle;
static uint8_t s_error = 0;
static uint8_t s_channel = 0;
static volatile uint8_t s_channel = 0;
static uint32_t s_fw_size = 0;
static uint32_t s_fw_fed = 0; /* 已接受的固件字节数(绝对流位置,防 0xD1 重传重复追加) */
static uint16_t s_total_packets = 0;
@@ -214,6 +227,12 @@ static void enterState(State st) {
s_last_send_ms = 0;
s_crc_resend_pending = false;
/* 通道锁释放:进终态(含 kIdle/kFinished/kError)即解锁,让 EtherCAT/SPI 恢复
* 对该通道发帧。整字清零——同一时刻只可能有一个会话,无需 RMW。 */
if (st == State::kIdle || st == State::kFinished || st == State::kError) {
s_channel_lock_mask = 0U;
}
/* auto_retrans 切换:OTA 是应用层停等协议,硬件自动重发会打穿电机单缓冲 RX
* (重发的前一帧被当成新帧处理 → receive_packet_num 错位 → CRC 错)。
* 进入 kHandshakeReq(start() 唯一入口)即关 ARD 切单发;
@@ -1067,18 +1086,33 @@ bool onCanFrame(uint8_t channel, uint32_t id, const uint8_t *data, uint8_t len)
return true; /* 已消费,不再传递给其他 handler */
}
bool start(uint8_t can_channel, uint32_t fw_size) {
if (s_state != State::kIdle && s_state != State::kFinished && s_state != State::kError) {
return false; /* 已在进行中 */
}
bool start(uint8_t can_channel, uint32_t fw_size, FeedSource src, uint8_t node_id) {
if (fw_size == 0 || fw_size > kMaxFwSize) return false;
if (can_channel >= 3) return false;
s_channel = can_channel;
s_fw_size = fw_size;
s_fw_fed = 0U;
s_total_packets = static_cast<uint16_t>((fw_size + kPacketPayload - 1U) / kPacketPayload);
s_packets_acked = 0;
/* ★ 第一条触碰共享状态的语句就完成「判忙 + 上锁」,且两者原子。
* 下面的二十余项清零和 START 日志(UART 格式化,百微秒级)都在锁之后;
* EtherCAT 的 PDO→CAN 由 preempt 0 的 Sync0_Isr 驱动,随时能插进这些空隙,
* 只要漏一拍运动帧进这条通道,单发模式(ARD=1)的 0x101 固件包就被硬件 abort,
* 电机会回 0x05/0x06 并在擦除后半路变砖。 */
{
const uint32_t save = RT_CRITICAL_PREEMPT0_SAVE();
const bool busy = (s_channel_lock_mask != 0U) ||
(s_state != State::kIdle && s_state != State::kFinished &&
s_state != State::kError);
if (!busy) {
s_channel_lock_mask = (1U << can_channel);
}
RT_CRITICAL_PREEMPT0_RESTORE(save);
if (busy) return false; /* 已有会话在跑(WiFi 或 EtherCAT,两者共用这一道门) */
}
s_channel = can_channel;
s_feed_src = src;
s_fw_size = fw_size;
s_fw_fed = 0U;
s_total_packets = static_cast<uint16_t>((fw_size + kPacketPayload - 1U) / kPacketPayload);
s_packets_acked = 0;
s_error = 0;
s_session_num = 0; /* 由 processNonceAck 根据 s_nonce 前4字节设置 */
s_buf_head = 0;
@@ -1093,81 +1127,102 @@ s_channel = can_channel;
s_ack_resend_total = 0;
s_fast_resend_total = 0;
s_last_pad_valid = 0xFFFFU;
s_last_feed_ms = SysTickTimer::getMs(); /* 从 start 起算 SPI 喂数超时 */
s_last_feed_ms = SysTickTimer::getMs(); /* 从 start 起算喂数超时 */
s_auto_retry = 0;
/* 不在 SPI 中断里同步发握手帧(电机跳 BOOT 期间 CAN 可能短暂断开)。
* 先进 kPreCancel 等待电机 0x103 心跳确认已进 BOOT,再由 process() 异步推进到握手。 */
LOG_ERR("[MOTOR-OTA] START ch=%u fw=%lu total=%u\r\n",
(unsigned)can_channel, (unsigned long)fw_size, (unsigned)s_total_packets);
LOG_ERR("[MOTOR-OTA] START ch=%u fw=%lu total=%u src=%s\r\n",
(unsigned)can_channel, (unsigned long)fw_size, (unsigned)s_total_packets,
(src == FeedSource::kEcat) ? "ECAT" : "SPI");
s_boot_heartbeat_seen = false;
enterState(State::kPreCancel);
/* 进 BOOT 帧(0x000N0000, DLC=0)由本机在**已加锁**的通道上发:EtherCAT 链路必须
* 走这里——那条链路的运动帧就是同一批 CAN 邮箱发出去的,先解锁再让上位机发帧
* 等于把窗口留给运动帧。WiFi 链路保持 node_id=0(上位机先发帧的既有顺序),
* 不动那条已验证的通路。 */
if (node_id >= 1U && node_id <= 9U) {
const uint32_t bootId = static_cast<uint32_t>(node_id) << 16;
const bool sent = canSend(bootId, nullptr, 0U);
LOG_ERR("[MOTOR-OTA] ENTER-BOOT id=0x%08lX ch=%u %s\r\n",
(unsigned long)bootId, (unsigned)can_channel, sent ? "queued" : "retry-by-state");
}
return true;
}
bool feedData(const uint8_t *data, uint16_t len) {
/* 接受任何活跃状态的数据(包括握手期间)——让 ESP32 在 kPreCancel/握手
* 期间就能预填充缓冲,进入 kTransfer 后立即有数据可发。
*
* payload: [offset:u32 LE][chunk...]。offset 为固件绝对字节偏移。
* ★ BLF 根因:无 offset 纯流时,SPI 超时重传同一 chunk,GD32 再次追加,
* CAN 上出现「前 236B 循环 185 次」→ CRC 全过但固件内容全错。 */
if (s_state == State::kIdle || s_state == State::kError ||
s_state == State::kFinished) {
return false;
}
if (data == nullptr || len < 4U) {
return false;
}
/* payload: [offset:u32 LE][chunk...]。offset 为固件绝对字节偏移。
* ★ BLF 根因:无 offset 纯流时,SPI 超时重传同一 chunk,GD32 再次追加,
* CAN 上出现「前 236B 循环 185 次」→ CRC 全过但固件内容全错。 */
if (s_feed_src != FeedSource::kSpi) {
/* 本次会话由 ECAT 暂存泵进料:SPI 侧(ESP32 还在线时)的喂数一律拒收,
* 两路交叉写环缓会拼出「半份 WiFi、半份 EtherCAT」的镜像。 */
return false;
}
if (data == nullptr || len < 4U) {
return false;
}
const uint32_t offset = static_cast<uint32_t>(data[0])
| (static_cast<uint32_t>(data[1]) << 8)
| (static_cast<uint32_t>(data[2]) << 16)
| (static_cast<uint32_t>(data[3]) << 24);
const uint8_t *chunk = data + 4U;
uint16_t chunk_len = static_cast<uint16_t>(len - 4U);
if (chunk_len == 0U) {
return true;
}
if (s_fw_size == 0U) {
return false;
}
if (offset >= s_fw_size) {
return true; /* 越界尾:幂等 ACK */
}
/* 截到固件末尾 */
if (offset + chunk_len > s_fw_size) {
chunk_len = static_cast<uint16_t>(s_fw_size - offset);
}
const uint32_t offset = static_cast<uint32_t>(data[0])
| (static_cast<uint32_t>(data[1]) << 8)
| (static_cast<uint32_t>(data[2]) << 16)
| (static_cast<uint32_t>(data[3]) << 24);
return feedAt(offset, data + 4U, static_cast<uint16_t>(len - 4U));
}
if (offset + chunk_len <= s_fw_fed) {
/* 完全已收过:幂等 ACK,不写环缓 */
return true;
}
if (offset > s_fw_fed) {
/* 缺口:拒收,ESP32 按 s_fw_offset 重发正确位置 */
static uint32_t s_last_gap_ms = 0;
const uint32_t now = SysTickTimer::getMs();
if (now - s_last_gap_ms >= 200U) {
LOG_ERR("[MOTOR-OTA] feed gap off=%lu fed=%lu len=%u\r\n",
(unsigned long)offset, (unsigned long)s_fw_fed,
(unsigned)chunk_len);
s_last_gap_ms = now;
}
return false;
}
bool feedAt(uint32_t offset, const uint8_t *chunk, uint16_t chunk_len) {
/* 接受任何活跃状态的数据(包括握手期间)——让上位机在 kPreCancel/握手
* 期间就能预填充缓冲,进入 kTransfer 后立即有数据可发。 */
if (s_state == State::kIdle || s_state == State::kError ||
s_state == State::kFinished) {
return false;
}
if (chunk == nullptr) {
return false;
}
if (chunk_len == 0U) {
return true;
}
if (s_fw_size == 0U) {
return false;
}
if (offset >= s_fw_size) {
return true; /* 越界尾:幂等 ACK */
}
/* 截到固件末尾 */
if (offset + chunk_len > s_fw_size) {
chunk_len = static_cast<uint16_t>(s_fw_size - offset);
}
/* 部分重叠:只追加 [s_fw_fed, offset+chunk_len) */
const uint32_t skip = s_fw_fed - offset;
const uint16_t add_len = static_cast<uint16_t>(chunk_len - skip);
if (bufFree() < add_len) {
return false; /* 环缓满 → SPI WAIT */
}
bufPush(chunk + skip, add_len);
s_fw_fed += add_len;
s_last_feed_ms = SysTickTimer::getMs(); /* SPI 断流检测:记录有效喂数时刻 */
return true;
}
if (offset + chunk_len <= s_fw_fed) {
/* 完全已收过:幂等 ACK,不写环缓 */
return true;
}
if (offset > s_fw_fed) {
/* 缺口:拒收,上位机按 s_fw_fed 重发正确位置 */
static uint32_t s_last_gap_ms = 0;
const uint32_t now = SysTickTimer::getMs();
if (now - s_last_gap_ms >= 200U) {
LOG_ERR("[MOTOR-OTA] feed gap off=%lu fed=%lu len=%u\r\n",
(unsigned long)offset, (unsigned long)s_fw_fed,
(unsigned)chunk_len);
s_last_gap_ms = now;
}
return false;
}
/* 部分重叠:只追加 [s_fw_fed, offset+chunk_len) */
const uint32_t skip = s_fw_fed - offset;
const uint16_t add_len = static_cast<uint16_t>(chunk_len - skip);
if (bufFree() < add_len) {
return false; /* 环缓满 → SPI WAIT / ECAT 下拍再泵 */
}
bufPush(chunk + skip, add_len);
s_fw_fed += add_len;
s_last_feed_ms = SysTickTimer::getMs(); /* 喂数断流检测:记录有效喂数时刻 */
return true;
}
void cancel() {
if (s_state == State::kIdle) return;
@@ -1198,11 +1253,32 @@ Status getStatus() {
st.packets_acked = s_packets_acked;
st.total_packets = s_total_packets;
st.fw_size = s_fw_size;
st.fw_fed = s_fw_fed;
return st;
}
uint16_t feedFreeSpace() {
return bufFree();
}
bool isActive() {
return (s_state != State::kIdle && s_state != State::kFinished && s_state != State::kError);
}
bool isChannelLocked(uint8_t channel) {
if (channel >= 3U) return false;
/* 单次 32 位读:位与本身就是原子的,不需要临界区。
* 判据只看锁位,不看状态机——状态机那几个变量是在锁之后逐步赋的,
* 拿它当门会让出几十微秒的空窗(审计报告里的 88.53% 漏包就是这么来的)。 */
return ((s_channel_lock_mask & (1U << channel)) != 0U);
}
int8_t lockedChannel() {
const uint32_t mask = s_channel_lock_mask;
for (uint8_t ch = 0U; ch < 3U; ++ch) {
if ((mask & (1U << ch)) != 0U) return static_cast<int8_t>(ch);
}
return -1;
}
} /* namespace MotorOtaRelay */