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
+383
View File
@@ -0,0 +1,383 @@
/**
* @file ecat_motor_ota.cpp
* @brief EtherCAT 邮箱进料通路实现(职责与时序前提见 ecat_motor_ota.h)。
*
* 上下文:三个 SDO 钩子只在 MainLoop(ECAT_Application 调用链)里被调,泵 process()
* 也在主循环,因此 48KB 暂存区没有并发写者。真正的硬保证仍是通道锁:即便主站循环
* 被别的路径(页面自愈 / 守护重连)拉回来,运动帧也进不了正在升级的通道。
*/
#include "comm/ecat_motor_ota.h"
#include "sys/config.h"
#include "sys/motor_ota_relay.h"
#include "sys/ota_boot_request.h"
#include "sys/systick.h"
#include "spi_link_v2.h" /* v2_crc32:CRC32 MPEG-2(poly 0x04C11DB7,初值 0xFFFFFFFF,无反射、无末尾异或) */
#include <cstring>
#include <cstdio> /* LOG_ERR 展开为 printf(sys/config.h) */
namespace {
/*============================================================================
* 尺寸常量与守卫
*==========================================================================*/
constexpr uint32_t kChunkObjBytes = ECAT_MOTA_CHUNK_OBJ_BYTES;
constexpr uint32_t kChunkHdrBytes = ECAT_MOTA_CHUNK_HDR_BYTES;
constexpr uint32_t kImgMaxBytes = ECAT_MOTA_IMG_MAX_BYTES;
constexpr uint8_t kCtlSubs = ECAT_MOTA_CTL_SUBS;
static_assert(kImgMaxBytes == MotorOtaRelay::kMaxFwSize,
"暂存区上限必须与 MotorOtaRelay::kMaxFwSize 一致");
static_assert(kChunkObjBytes * 8U <= 0xFFFFU,
"0x2511 块长超出 SDO 单条目 BitSize(UINT16)上限");
static_assert(kChunkObjBytes > kChunkHdrBytes, "数据窗要留出净载荷");
/** APP 的堆容量(RTE/Device/GD32E508VE/regions_GD32E508VE.h 的 __HEAP_SIZE)。
* SSC 每笔分段下载都要 malloc(块长)(sdoserv.c:751),堆不够就从站直接回
* abort 0x05040005「Out of memory」——2026-10-11 就是拿 4096 撞出来的。
* 这里留 3 倍余量:谁要把块长往上抬,先抬 __HEAP_SIZE 再来改这一行。 */
constexpr uint32_t kHeapBytes = 3072U;
static_assert(kChunkObjBytes * 3U <= kHeapBytes,
"块长受 APP 堆限制:malloc(块长) 必须留足余量,否则从站 OOM 拒收");
/** 单次 process() 最多搬运的字节数:把一圈的占用钳住,别在 MainLoop 里长时间拷 48KB。 */
constexpr uint16_t kPumpSlice = 512U;
/*============================================================================
* 会话状态
*==========================================================================*/
uint32_t s_cmd = 0U; /**< sub1: 最近一次执行的命令 */
uint32_t s_total = 0U; /**< sub2: 声明的镜像总字节数 */
uint32_t s_channel = 0U; /**< sub3: 目标 CAN 通道 */
uint32_t s_node = 0U; /**< sub4: 目标节点 1..9 */
uint32_t s_img_crc = 0U; /**< sub5: 整镜像期望 CRC32 */
uint32_t s_staged = 0U; /**< 已连续暂存字节数(水位,写块时前移) */
uint8_t s_sess_err = ECAT_MOTA_OK;
bool s_armed = false; /**< 已声明总长,可收块 */
bool s_started = false; /**< 已交给 relay(暂存区冻结,只剩泵) */
bool s_crc_ok = false; /**< 整镜像 CRC 已核对通过 */
bool s_last_chunk_ok = false;
bool s_end_logged = false;/**< 终态日志只打一次 */
/* 终态锁存:relay 会话结束后可能很快回到 kIdle(主站下一拍才来读),
* 不做锁存就会把「刷成功了」读成 kIdle,主站据此报的结论就成了猜的。 */
bool s_final_valid = false;
uint8_t s_final_state = 0U;
uint8_t s_final_err = 0U;
uint32_t s_pump_pos = 0U; /**< 泵在暂存区里的读游标 */
/** 48KB 暂存区(.bss:启动零耗时;APP 当前 RAM 用量 35KB/128KB,放得下)。 */
uint8_t s_img[kImgMaxBytes];
inline uint32_t loadU32(const uint8_t *p) {
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
void resetSession()
{
s_armed = false;
s_started = false;
s_crc_ok = false;
s_staged = 0U;
s_pump_pos = 0U;
s_total = 0U;
s_last_chunk_ok = false;
s_end_logged = false;
s_final_valid = false;
s_final_state = 0U;
s_final_err = 0U;
}
/** 只认「已连续暂存水位之内」的偏移:留洞会让泵卡住,而且说明主站发料顺序错了。 */
uint8_t acceptChunk(uint32_t offset, uint32_t crc, const uint8_t *data, uint32_t len)
{
if (!s_armed || s_total == 0U) {
s_sess_err = ECAT_MOTA_E_NOTARMED;
return s_sess_err;
}
if (s_started) {
s_sess_err = ECAT_MOTA_E_STARTED;
return s_sess_err;
}
if (offset > s_staged || offset + len > s_total) {
s_sess_err = ECAT_MOTA_E_GAP;
return s_sess_err;
}
if (len > 0U) {
/* 块级校验:EtherCAT 数据报**头**有 CRC,载荷没有;邮箱分段拼装还是软件路径。
* 这里挡一次,坏块就地重发,不会等到电机回 0x06 才发现(那时 flash 已擦)。 */
if (v2_crc32(data, len) != crc) {
s_sess_err = ECAT_MOTA_E_CHUNKCRC;
s_last_chunk_ok = false;
return s_sess_err;
}
std::memcpy(&s_img[offset], data, len);
if (offset + len > s_staged) {
s_staged = offset + len;
}
}
s_last_chunk_ok = true;
s_sess_err = ECAT_MOTA_OK;
return ECAT_MOTA_OK;
}
/** 整镜像核对 + 交给 relay。所有失败路径都必须在电机进 BOOT **之前**。 */
bool beginTransfer()
{
if (s_total == 0U || s_total > kImgMaxBytes) {
s_sess_err = ECAT_MOTA_E_BADLEN;
return false;
}
if (s_staged != s_total) {
s_sess_err = ECAT_MOTA_E_GAP;
return false;
}
if (s_channel >= 3U || s_node < 1U || s_node > 9U) {
s_sess_err = ECAT_MOTA_E_PARAM;
return false;
}
/* DCU 自己正在回 BOOT(配置页写 + 复位在路上)时绝不能起电机会话:
* 复位会把半程的刷写腰斩在 erase 之后。 */
if (OtaBootRequest::isRebootPending()) {
s_sess_err = ECAT_MOTA_E_BOOTPEND;
return false;
}
/* 整镜像 CRC 先行:这一刻电机还在 APP 正常跑、擦除没开始,校验不过就是零代价失败。
* 反过来「先进 BOOT 再验」才是变砖路径。 */
if (v2_crc32(s_img, s_total) != s_img_crc) {
s_sess_err = ECAT_MOTA_E_IMGCRC;
return false;
}
s_crc_ok = true;
if (!MotorOtaRelay::start(static_cast<uint8_t>(s_channel), s_total,
MotorOtaRelay::FeedSource::kEcat,
static_cast<uint8_t>(s_node))) {
s_sess_err = ECAT_MOTA_E_BUSY;
s_crc_ok = false;
return false;
}
s_started = true;
s_end_logged = false;
s_pump_pos = 0U;
s_final_valid = false;
s_sess_err = ECAT_MOTA_OK;
LOG_ERR("[ECAT-MOTA] start ch=%lu node=%lu size=%lu crc=%08lX\r\n",
(unsigned long)s_channel, (unsigned long)s_node,
(unsigned long)s_total, (unsigned long)s_img_crc);
return true;
}
} /* namespace */
/*============================================================================
* EcatMotorOta(C++ 侧:主循环钩子与查询)
*==========================================================================*/
namespace EcatMotorOta {
void process()
{
if (!s_started) {
return;
}
const MotorOtaRelay::Status st = MotorOtaRelay::getStatus();
/* 电机侧自动重试会清环缓并把 fw_fed 归零:泵必须跟着回退,否则会把
* 「后半段」当成已喂过,喂出一个前段缺失的镜像。 */
if (st.fw_fed < s_pump_pos) {
s_pump_pos = st.fw_fed;
}
/* 泵:按环缓空位切片,搬不动就下一圈再来(不阻塞、不忙等、不碰 CAN)。 */
while (s_pump_pos < s_total) {
const uint16_t freeSpace = MotorOtaRelay::feedFreeSpace();
if (freeSpace == 0U) {
break;
}
const uint32_t remain = s_total - s_pump_pos;
uint16_t n = static_cast<uint16_t>(remain < kPumpSlice ? remain : kPumpSlice);
if (n > freeSpace) {
n = freeSpace;
}
if (!MotorOtaRelay::feedAt(s_pump_pos, &s_img[s_pump_pos], n)) {
break;
}
s_pump_pos += n;
}
if (!MotorOtaRelay::isActive()) {
if (!s_end_logged) {
s_end_logged = true;
s_final_valid = true;
s_final_state = static_cast<uint8_t>(st.state);
s_final_err = st.error_code;
LOG_ERR("[ECAT-MOTA] end state=%u err=0x%02X acked=%u/%u fed=%lu/%lu\r\n",
static_cast<unsigned>(st.state), static_cast<unsigned>(st.error_code),
static_cast<unsigned>(st.packets_acked),
static_cast<unsigned>(st.total_packets),
(unsigned long)st.fw_fed, (unsigned long)st.fw_size);
}
s_started = false;
s_crc_ok = false;
/* 会话已结束:不再收块。主站要开下一轮必须重新写 0x2510:02 声明总长,
* 那一步复位全部会话状态(包括上面的终态锁存)。 */
s_armed = false;
s_last_chunk_ok = false;
}
}
bool sessionActive()
{
return s_started;
}
} /* namespace EcatMotorOta */
/*============================================================================
* SDO 钩子(extern "C":dc_app.c 的对象表直接引用)
*==========================================================================*/
extern "C" {
uint8_t EcatMotorOtaChunkWrite(uint32_t dataSize, const uint8_t *bytes)
{
if (bytes == nullptr) {
return ECAT_MOTA_E_BADLEN;
}
if (dataSize < kChunkHdrBytes) {
s_sess_err = ECAT_MOTA_E_BADLEN;
return s_sess_err;
}
if (dataSize > kChunkObjBytes) {
s_sess_err = ECAT_MOTA_E_BADLEN;
return s_sess_err;
}
const uint32_t offset = loadU32(&bytes[0]);
const uint32_t crc = loadU32(&bytes[4]);
const uint32_t len = dataSize - kChunkHdrBytes;
const uint8_t rc = acceptChunk(offset, crc, &bytes[kChunkHdrBytes], len);
if (rc != ECAT_MOTA_OK) {
/* 一次失败只打一行:主站会重发同一块,串口刷屏会拖慢 MainLoop。 */
LOG_ERR("[ECAT-MOTA] chunk reject off=%lu len=%lu err=0x%02X\r\n",
(unsigned long)offset, (unsigned long)len, (unsigned)rc);
}
return rc;
}
uint8_t EcatMotorOtaCtlWrite(uint8_t subindex, uint32_t dataSize, const uint8_t *bytes)
{
if (bytes == nullptr || dataSize < 4U) {
return ECAT_MOTA_E_BADLEN;
}
const uint32_t v = loadU32(bytes);
switch (subindex) {
case 1U: {
/* sub1 cmd:1 = 校验并启动,3 = 中止会话 */
s_cmd = v;
if (v == 1U) {
if (!beginTransfer()) {
return s_sess_err;
}
} else if (v == 3U) {
if (s_started) {
MotorOtaRelay::cancel();
LOG_ERR("[ECAT-MOTA] cancel by master\r\n");
}
resetSession();
s_sess_err = ECAT_MOTA_OK;
} else {
return ECAT_MOTA_E_PARAM;
}
return ECAT_MOTA_OK;
}
case 2U: {
/* sub2 总长:声明即复位会话(主站重开一轮不需要先 abort) */
if (v == 0U || v > kImgMaxBytes) {
s_sess_err = ECAT_MOTA_E_BADLEN;
return s_sess_err;
}
if (s_started) {
s_sess_err = ECAT_MOTA_E_STARTED;
return s_sess_err;
}
resetSession();
s_total = v;
s_armed = true;
s_sess_err = ECAT_MOTA_OK;
return ECAT_MOTA_OK;
}
case 3U:
if (v >= 3U) {
s_sess_err = ECAT_MOTA_E_PARAM;
return s_sess_err;
}
s_channel = v;
return ECAT_MOTA_OK;
case 4U:
if (v < 1U || v > 9U) {
s_sess_err = ECAT_MOTA_E_PARAM;
return s_sess_err;
}
s_node = v;
return ECAT_MOTA_OK;
case 5U:
s_img_crc = v;
return ECAT_MOTA_OK;
default:
/* sub0 是条目数;sub6..sub11 只读 */
return ECAT_MOTA_E_PARAM;
}
}
uint32_t EcatMotorOtaCtlRead(uint8_t subindex)
{
const MotorOtaRelay::Status st = MotorOtaRelay::getStatus();
/* live = 会话还在跑,或从没跑过(那就照实反映 relay 当前状态);
* 会话刚结束时返回锁存值,避免主站把「成功」读成 kIdle。 */
const bool live = (s_started || !s_final_valid);
switch (subindex) {
case 0U: return kCtlSubs;
case 1U: return s_cmd;
case 2U: return s_total;
case 3U: return s_channel;
case 4U: return s_node;
case 5U: return s_img_crc;
case 6U: return s_staged;
case 7U: return live ? static_cast<uint32_t>(st.state)
: static_cast<uint32_t>(s_final_state);
case 8U: return st.packets_acked;
case 9U: return st.total_packets;
case 10U: return (static_cast<uint32_t>(s_sess_err) << 8)
| (live ? static_cast<uint32_t>(st.error_code)
: static_cast<uint32_t>(s_final_err));
case 11U: {
/* phase 位图:只报能被板子真实触发的事实,不做任何推测性状态。
* bit0 已声明总长 · bit1 暂存齐 · bit2 整镜像 CRC 过 · bit3 会话在跑
* bit4 上一块被接受 · bit5 本会话目标通道当前持锁 · bit6 relay 活跃 */
uint32_t phase = 0U;
if (s_armed) { phase |= 0x0001U; }
if (s_total != 0U && s_staged == s_total) { phase |= 0x0002U; }
if (s_crc_ok) { phase |= 0x0004U; }
if (s_started) { phase |= 0x0008U; }
if (s_last_chunk_ok) { phase |= 0x0010U; }
if (MotorOtaRelay::isChannelLocked(static_cast<uint8_t>(s_channel))) { phase |= 0x0020U; }
if (MotorOtaRelay::isActive()) { phase |= 0x0040U; }
return phase;
}
default: return 0U;
}
}
} /* extern "C" */
+97
View File
@@ -0,0 +1,97 @@
/**
* @file ecat_motor_ota.h
* @brief EtherCAT(CoE 邮箱)电机 OTA 进料通路 —— 主站把电机固件灌进 DCU 本地暂存区,
* 再由本模块泵进 MotorOtaRelay 现有的 4KB 环缓。
*
* 为什么要有它(不是重复造轮子):电机 OTA 的引擎一直是 MotorOtaRelay
* (AES 握手 + CAN 停等 + 单发模式),只有一条进料路:ESP32 经 SPI 0xD1 喂。
* 纯 EtherCAT 场合(只插网线、ESP32 WiFi 关断)那条 HTTP 打不通,
* 于是前端把能力标成 supportsOta=false。本模块只换**进料口**,不换引擎:
* - 分块写入仍走 MotorOtaRelay::feedAt(),offset/幂等/环缓满的判据只有一份;
* - 加密、停等、重发、终态判定全部沿用原状态机;
* - 通道独占锁同样沿用(EtherCAT 自己的 PDO 运动帧也被它挡在 OTA 之外)。
*
* 与 BOOT 的 0x2500/0x2501(刷 DCU 自身)的区别:那边落 flash,这边只落 SRAM 暂存区,
* 全程不碰 fmc_*,因此不需要关中断擦写那套优雅关停。
*
* 对象(APP 字典,表项与钩子包装在 comm/ssc/port/dc_app.c):
* 0x2510 会话控制(REC of UINT32,读写钩子返回实时值)
* 0x2511 数据窗(VAR OCTETSTRING,4096B,载荷 [offset:u32 LE][crc32:u32 LE][data...])
*
* 本头刻意只用 <stdint.h> 类型:dc_app.c 是 C 且靠相对路径取 ecat_def.h,
* 而 comm/ 下的 C++ 拿不到那套 SSC 类型。SDO abort 码的映射留在 dc_app.c 那一层。
*/
#ifndef COMM_ECAT_MOTOR_OTA_H
#define COMM_ECAT_MOTOR_OTA_H
#include <stdint.h>
/**
* ── 尺寸常量(宏:C 与 C++ 两侧共用同一真源) ──
* 0x2511 对象字节数上限,含 8 字节自描述头。
* 单条目 BitSize 是 UINT16 ⇒ 8191 字节是天花板,但**真正的上限是 APP 的堆**:
* SSC 的分段下载缓冲是 `ALLOCMEM(ROUNDUPBYTE2WORD(size))` 即 malloc(size)
* (comm/ssc/src/sdoserv.c:751,池不够就回 abort 0x05040005 Out of memory),
* 而 APP 的 `__HEAP_SIZE` 只有 0xC00=3072B(BOOT 是 0x4000,所以 BOOT 的
* 0x2501 能用 4096 —— 照抄块长不照抄堆就会当场失败,2026-10-11 实测)。
* 取 1024:48KB 镜像 = 47 块,每块 1 initiate + 4 段 ≈ 5 个邮箱帧,
* 总邮箱流量仍在亚秒级,且给 malloc 留出 3 倍余量。
* 改这里必须同步 tools/ecat/ecat_native.c 的 MFO_CHUNK_OBJ(两边各自断言不了对方)。
*/
#define ECAT_MOTA_CHUNK_OBJ_BYTES 1024U
#define ECAT_MOTA_CHUNK_HDR_BYTES 8U
/** 电机固件上限:必须与 MotorOtaRelay::kMaxFwSize(48KB)一致,C++ 侧有 static_assert 守着。 */
#define ECAT_MOTA_IMG_MAX_BYTES 0xC000U
/** 0x2510 的子索引个数(sub1..sub11)。 */
#define ECAT_MOTA_CTL_SUBS 11U
/**
* ── 会话结果码 ──
* 与 MotorOtaRelay 的协议错误码(0x01..0x3x,来自电机 BOOT)刻意分段:
* 0x8x 永远是「DCU 本端把料挡了」,不是电机报错。看到 0x8x 查链路与参数,
* 看到 0x0x/0x1x/0x3x 查电机与 CAN。同一份码既写进 0x2510:10 高字节(主站可读),
* 也作为钩子的返回值(dc_app.c 再换成 SDO abort 码)。
*/
#define ECAT_MOTA_OK 0x00U
#define ECAT_MOTA_E_BADLEN 0x81U /* 声明总长或块长非法 */
#define ECAT_MOTA_E_CHUNKCRC 0x82U /* 块 CRC32 不符:邮箱搬坏了,主站重发该块 */
#define ECAT_MOTA_E_GAP 0x83U /* 块偏移越界/留洞:主站发料顺序错 */
#define ECAT_MOTA_E_IMGCRC 0x84U /* 整镜像 CRC32 不符:拒绝进 BOOT,一个字节都没发 */
#define ECAT_MOTA_E_BUSY 0x85U /* relay 已在跑(另一条链路或上一会话未结束) */
#define ECAT_MOTA_E_NOTARMED 0x86U /* 没声明总长就写数据窗 */
#define ECAT_MOTA_E_STARTED 0x87U /* 已 start,暂存区冻结,不再收块 */
#define ECAT_MOTA_E_PARAM 0x88U /* 通道/节点参数非法 */
#define ECAT_MOTA_E_BOOTPEND 0x89U /* DCU 自己正在交接回 BOOT,此时不许刷电机 */
#ifdef __cplusplus
namespace EcatMotorOta {
/** 主循环每圈调用:把暂存区泵进环缓,并在会话结束时收标志。 */
void process(void);
/** 本模块的会话是否活跃(已 start 且 relay 未进终态)。 */
bool sessionActive(void);
} /* namespace EcatMotorOta */
#define ECAT_MOTA_LINKAGE extern "C"
#else
#define ECAT_MOTA_LINKAGE extern
#endif /* __cplusplus */
/*---------------------------------------------------------------------------
* SDO 钩子(dc_app.c 的对象表引用这三个,实现在 ecat_motor_ota.cpp)
*
* 返回 ECAT_MOTA_OK 或上面的 ECAT_MOTA_E_*;dc_app.c 把非 0 换成 SDO abort 码,
* 主站既拿得到 abort,也能从 0x2510:10 读到具体是哪一条,不用猜。
*---------------------------------------------------------------------------*/
/** @brief 0x2510 写入:sub1=cmd(1=start / 3=abort)、sub2=总长、sub3=通道、sub4=node、sub5=整镜像 CRC32。 */
ECAT_MOTA_LINKAGE uint8_t EcatMotorOtaCtlWrite(uint8_t subindex, uint32_t dataSize,
const uint8_t *bytes);
/** @brief 0x2510 读取:返回该 sub 的实时值(进度不落地缓存,避免读到旧数)。 */
ECAT_MOTA_LINKAGE uint32_t EcatMotorOtaCtlRead(uint8_t subindex);
/** @brief 0x2511 写入:一块镜像(自描述 offset + 块 CRC32),失败即 SDO abort。 */
ECAT_MOTA_LINKAGE uint8_t EcatMotorOtaChunkWrite(uint32_t dataSize, const uint8_t *bytes);
#endif /* COMM_ECAT_MOTOR_OTA_H */
@@ -5,6 +5,7 @@
#include "comm/ethercat_can_bridge.h"
#include "device/canfd.h"
#include "device/motor_state.h"
#include "sys/motor_ota_relay.h"
#include "sys/systick.h"
#include "RTE_Components.h"
@@ -108,6 +109,12 @@ uint8_t EthercatCanBridge::sendBatch(const uint8_t *out)
CanFd::CanTxResult r;
if (!CanFd::isChannelEnabled(ch)) {
r = CanFd::CanTxResult::kErrInvalid;
} else if (MotorOtaRelay::isChannelLocked(ch)) {
/* 该通道正被电机 OTA 独占:PDO 运动帧一律不投递。
* OTA 用单发模式(ARD=1),一旦挤进同一个邮箱就会 abort 掉在途的
* 0x101 固件包,或让电机 BOOT 收到非预期长度直接回 0x05/0x06。
* 计数与 lastTxResult 走 0xE7,主站侧能看到「被 OTA 挡了」而不是「邮箱满」。 */
r = CanFd::CanTxResult::kErrOtaLocked;
} else {
r = CanFd::enqueueTx(ch, rd32le(&f[ECAT_FRM_OFF_ID]),
&f[ECAT_FRM_HDR_SIZE], dlc);
@@ -322,6 +329,9 @@ void EthercatCanBridge::processOutput(const uint8_t *out)
if (dlc > 0U) {
if (!CanFd::isChannelEnabled(ch)) {
s_lastTxResult = (uint8_t)CanFd::CanTxResult::kErrInvalid;
} else if (MotorOtaRelay::isChannelLocked(ch)) {
/* 同 sendBatch:OTA 独占通道,周期运动帧不投邮箱(0xE7 上报主站)。 */
s_lastTxResult = (uint8_t)CanFd::CanTxResult::kErrOtaLocked;
} else {
const CanFd::CanTxResult r =
CanFd::enqueueTx(ch, rd32le(&out[ECAT_CAN_TX_OFF_ID]),
+146
View File
@@ -21,6 +21,7 @@
#include "objdef.h"
#include "dc_app.h"
#include "../../ethercat_pdo_size.h" /* ECAT_PDO_OUT_BYTES / ECAT_PDO_IN_BYTES */
#include "../../ecat_motor_ota.h" /* 0x2510/0x2511:电机 OTA 邮箱进料的尺寸、会话码与钩子 */
#include <string.h>
#include <stddef.h> /* offsetof:下方布局守卫断言使用 */
@@ -391,6 +392,141 @@ static UINT8 ObjEsp32EnWrite(UINT16 index, UINT8 subindex, UINT32 dataSize,
return 0U;
}
/*---------------------------------------------------------------------------
* 0x2510 / 0x2511 电机 OTA 邮箱进料通路(主站 → APP)
*
* 为什么要有它:电机 OTA 的引擎在 GD32(MotorOtaRelay:AES 握手 + CAN 停等),
* 但固件明文只有一条进料路 —— ESP32 经 SPI 0xD1 喂。只插网线的场合那条 HTTP
* 打不通,于是前端把电机 OTA 标成「本链路不支持」。这两个对象把进料口换成
* EtherCAT 邮箱,引擎一行没动:数据窗的写回调最终调的还是 MotorOtaRelay::feedAt()。
*
* 形状完全照 BOOT 已验证的 0x2500/0x2501(comm/ecat/boot_ecat_app.cpp):
* - REC 的条目统一 UINT32,偏移才和 SSC「si0 占 2 字节 + 按 BitLength 紧排」一致;
* - 数据窗建成 VAR(不是 ARR):VAR 没有 sub0 计数字段,主站按 `:00` 整块写;
* VAR 的 maxSubindex 恒为 0 ⇒ 写 `:1` 会直接 ABORTIDX_SUBINDEX_NOT_EXISTING;
* complete access 对 VAR 一律 UNSUPPORTED ⇒ 主站必须传 CA=FALSE;
* - 条目描述必须带写权限位,objdef.c:1325 的通用写权限门早于 1352 的回调分发,
* 少了它回调一次都不会进(BOOT 的 0x2500 当初就这么静默躺平过)。
* 业务逻辑(暂存、CRC、泵、终态锁存)在 comm/ecat_motor_ota.cpp,本层只做
* SDO 字节 ↔ 纯整型参数的翻译。
*---------------------------------------------------------------------------*/
typedef struct OBJ_STRUCT_PACKED_START {
UINT16 bSubIndex; /**< sub0:条目数,SSC 按 UINT16 读 */
UINT32 u32Cmd; /**< sub1:W 1=start 3=abort;R 最近一次命令 */
UINT32 u32Total; /**< sub2:镜像总字节数 */
UINT32 u32Channel; /**< sub3:目标 CAN 通道 0..2 */
UINT32 u32Node; /**< sub4:目标节点 1..9 */
UINT32 u32ImgCrc; /**< sub5:整镜像 CRC32(MPEG-2,与 SPI 链路同款) */
UINT32 u32Staged; /**< sub6:已连续暂存字节数(只读) */
UINT32 u32RelayState; /**< sub7:MotorOtaRelay 状态(只读) */
UINT32 u32PkAcked; /**< sub8:已确认包数(只读) */
UINT32 u32PkTotal; /**< sub9:总包数(只读) */
UINT32 u32Err; /**< sub10:高字节=本模块会话码 0x8x,低字节=电机协议错误码 */
UINT32 u32Phase; /**< sub11:位图,见 ecat_motor_ota.cpp */
}
OBJ_STRUCT_PACKED_END
TOBJ2510;
_Static_assert(offsetof(TOBJ2510, u32Cmd) == 2, "TOBJ2510 未紧排:检查 OBJ_STRUCT_PACKED_START");
_Static_assert(sizeof(TOBJ2510) == 2 + 4 * 11, "TOBJ2510 尺寸应为 2+44");
static TOBJ2510 s_motorOtaCtl = {ECAT_MOTA_CTL_SUBS, 0U, 0U, 0U, 0U, 0U,
0U, 0U, 0U, 0U, 0U, 0U};
OBJCONST TSDOINFOENTRYDESC OBJMEM asMotorOtaCtlEntryDesc[] = {
{DEFTYPE_UNSIGNED8, 0x08, ACCESS_READ}, /* sub0 : 子索引个数 = 11 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READWRITE}, /* sub1 : cmd(必须带写位,否则回调进不来) */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READWRITE}, /* sub2 : 镜像总长 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READWRITE}, /* sub3 : CAN 通道 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READWRITE}, /* sub4 : 节点号 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READWRITE}, /* sub5 : 整镜像 CRC32 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub6 : 已暂存字节 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub7 : relay 状态 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub8 : 已 ACK 包数 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub9 : 总包数 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub10 : 会话码<<8 | 协议错误码 */
{DEFTYPE_UNSIGNED32, 0x20, ACCESS_READ}, /* sub11 : phase 位图 */
};
static OBJCONST UCHAR aName0x2510[] = "Motor OTA control";
OBJCONST TSDOINFOENTRYDESC OBJMEM asMotorOtaChunkEntryDesc[] = {
/* 唯一条目 = 一整块 [offset:u32][crc32:u32][data...]。
* 写回调在 objdef.c:1352 早分发,SSC 的默认拷贝路径不会执行。 */
{DEFTYPE_OCTETSTRING, (UINT16)(ECAT_MOTA_CHUNK_OBJ_BYTES * 8U), ACCESS_READWRITE},
};
static OBJCONST UCHAR aName0x2511[] = "Motor firmware chunk";
/** 数据窗的落地缓冲:写路径不依赖它(回调自己取字节),保留是给默认读路径的落点。 */
static UINT8 s_motorOtaChunk[ECAT_MOTA_CHUNK_OBJ_BYTES];
static UINT8 ObjMotorOtaCtlWrite(UINT16 index, UINT8 subindex, UINT32 dataSize,
UINT16 MBXMEM *pData, UINT8 bCompleteAccess)
{
const UINT8 rc = EcatMotorOtaCtlWrite(subindex, dataSize, (const UINT8 *)pData);
(void)index;
(void)bCompleteAccess;
if (subindex == 0U) {
return ABORTIDX_SUBINDEX_NOT_EXISTING; /* sub0 是条目数,不可写 */
}
if (subindex >= 2U + ECAT_MOTA_CTL_SUBS) {
return ABORTIDX_SUBINDEX_NOT_EXISTING;
}
if (rc != ECAT_MOTA_OK) {
/* 具体原因不靠 abort 码分类:主站读 0x2510:10 的高字节拿真码。 */
return ABORTIDX_DATA_CANNOT_BE_READ_OR_STORED;
}
return 0U;
}
/** 0x2510 读取:逐 subindex 从 ecat_motor_ota 取**实时**值,不读 s_motorOtaCtl 的镜像。 */
static UINT8 ObjMotorOtaCtlRead(UINT16 index, UINT8 subindex, UINT32 objSize,
UINT16 MBXMEM *pData, UINT8 bCompleteAccess)
{
const UINT32 v = EcatMotorOtaCtlRead(subindex);
UINT8 *p8 = (UINT8 *)pData;
(void)index;
(void)objSize;
(void)bCompleteAccess;
if (pData == NULL) {
return ABORTIDX_PARAM_LENGTH_TOO_SHORT;
}
if (subindex == 0U) {
/* sub0 是 UINT8(条目数),只占载荷一个字节 */
p8[0] = (UINT8)v;
} else {
p8[0] = (UINT8)(v & 0xFFU);
p8[1] = (UINT8)((v >> 8) & 0xFFU);
p8[2] = (UINT8)((v >> 16) & 0xFFU);
p8[3] = (UINT8)((v >> 24) & 0xFFU);
}
return 0U;
}
static UINT8 ObjMotorOtaChunkWrite(UINT16 index, UINT8 subindex, UINT32 dataSize,
UINT16 MBXMEM *pData, UINT8 bCompleteAccess)
{
const UINT8 rc = EcatMotorOtaChunkWrite(dataSize, (const UINT8 *)pData);
(void)index;
(void)bCompleteAccess;
if (subindex != 0U) {
return ABORTIDX_SUBINDEX_NOT_EXISTING; /* VAR 只有 :00 */
}
if (rc == ECAT_MOTA_E_GAP || rc == ECAT_MOTA_E_PARAM || rc == ECAT_MOTA_E_BADLEN) {
return ABORTIDX_VALUE_EXCEEDED;
}
if (rc != ECAT_MOTA_OK) {
return ABORTIDX_DATA_CANNOT_BE_READ_OR_STORED;
}
return 0U;
}
/*---------------------------------------------------------------------------
* Object Names
*---------------------------------------------------------------------------*/
@@ -444,6 +580,16 @@ TOBJECT OBJMEM ApplicationObjDicList[] = {
{NULL, NULL, 0x2503, {DEFTYPE_UNSIGNED32, 0 | (OBJCODE_VAR << 8)},
sEntryDesc0x2503, aName0x2503, &s_esp32EnVal, NULL, ObjEsp32EnWrite, 0x0000},
/* 0x2510: 电机 OTA 会话控制(写 sub2 声明总长 → 反复写 0x2511 灌块 → 写 sub1=1 启动) */
{NULL, NULL, 0x2510, {DEFTYPE_RECORD, ECAT_MOTA_CTL_SUBS | (OBJCODE_REC << 8)},
asMotorOtaCtlEntryDesc, aName0x2510, &s_motorOtaCtl,
ObjMotorOtaCtlRead, ObjMotorOtaCtlWrite, 0x0000},
/* 0x2511: 电机固件数据窗(VAR OCTETSTRING,主站分段下载攒完才回调一次) */
{NULL, NULL, 0x2511, {DEFTYPE_OCTETSTRING, 0 | (OBJCODE_VAR << 8)},
asMotorOtaChunkEntryDesc, aName0x2511, &s_motorOtaChunk,
NULL, ObjMotorOtaChunkWrite, 0x0000},
/* 终止条目 */
{NULL, NULL, 0xFFFF, {0, 0}, NULL, NULL, NULL, NULL, NULL, 0x000}
};