Files
DCU_APP/GD32E508_DCU_APP/comm/ecat_motor_ota.cpp
T
cbb d8b3c9767c 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 块长修复后未跑完整一轮)。
2026-10-11 02:47:19 +08:00

384 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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" */