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:
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user