From d8b3c9767c509488e048a32af144ad36f1a3e946 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Sun, 11 Oct 2026 02:47:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(ota):=20=E7=94=B5=E6=9C=BA=20OTA=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20EtherCAT=20=E6=A8=A1=E5=BC=8F=EF=BC=88CoE?= =?UTF-8?q?=20=E9=82=AE=E7=AE=B1=E8=BF=9B=E6=96=99=20+=20=E9=80=9A?= =?UTF-8?q?=E9=81=93=E7=8B=AC=E5=8D=A0=E9=94=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进料口从「只有 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 块长修复后未跑完整一轮)。 --- .../GD32E508_DCU_APP.cproject.yml | 3 + GD32E508_DCU_APP/comm/ecat_motor_ota.cpp | 383 ++++++++++++++++++ GD32E508_DCU_APP/comm/ecat_motor_ota.h | 97 +++++ GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp | 10 + GD32E508_DCU_APP/comm/ssc/port/dc_app.c | 146 +++++++ GD32E508_DCU_APP/device/canfd.h | 2 + .../device/spi/app_spi_cmd_can_bridge.cpp | 12 + .../device/spi/app_spi_cmd_can_burst.cpp | 6 + GD32E508_DCU_APP/sys/config.h | 2 +- GD32E508_DCU_APP/sys/critical.h | 15 + GD32E508_DCU_APP/sys/motor_ota_relay.cpp | 222 ++++++---- GD32E508_DCU_APP/sys/motor_ota_relay.h | 44 +- GD32E508_DCU_APP/sys/ota_boot_request.cpp | 12 + GD32E508_DCU_APP/sys/task_runner.cpp | 10 +- 14 files changed, 888 insertions(+), 76 deletions(-) create mode 100644 GD32E508_DCU_APP/comm/ecat_motor_ota.cpp create mode 100644 GD32E508_DCU_APP/comm/ecat_motor_ota.h diff --git a/GD32E508_DCU_APP/GD32E508_DCU_APP.cproject.yml b/GD32E508_DCU_APP/GD32E508_DCU_APP.cproject.yml index 1acaa47..a11e138 100644 --- a/GD32E508_DCU_APP/GD32E508_DCU_APP.cproject.yml +++ b/GD32E508_DCU_APP/GD32E508_DCU_APP.cproject.yml @@ -87,6 +87,9 @@ project: - file: ./comm/ethercat_sync.cpp - file: ./comm/ethercat_can_bridge.h - file: ./comm/ethercat_can_bridge.cpp + # 电机 OTA 的 EtherCAT 邮箱进料通路(CoE 0x2510/0x2511 → MotorOtaRelay 环缓) + - file: ./comm/ecat_motor_ota.h + - file: ./comm/ecat_motor_ota.cpp - file: ./comm/ethercat_pdo.h - file: ./comm/ethercat_pdo.cpp # [DIFF-STATS] 临时仪表:差分写取证,删除时连同本两行与 comm/ethercat_diff_stats.* 一并移除 diff --git a/GD32E508_DCU_APP/comm/ecat_motor_ota.cpp b/GD32E508_DCU_APP/comm/ecat_motor_ota.cpp new file mode 100644 index 0000000..3945589 --- /dev/null +++ b/GD32E508_DCU_APP/comm/ecat_motor_ota.cpp @@ -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 +#include /* 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(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(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(s_channel), s_total, + MotorOtaRelay::FeedSource::kEcat, + static_cast(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(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(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(st.state), static_cast(st.error_code), + static_cast(st.packets_acked), + static_cast(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(st.state) + : static_cast(s_final_state); + case 8U: return st.packets_acked; + case 9U: return st.total_packets; + case 10U: return (static_cast(s_sess_err) << 8) + | (live ? static_cast(st.error_code) + : static_cast(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(s_channel))) { phase |= 0x0020U; } + if (MotorOtaRelay::isActive()) { phase |= 0x0040U; } + return phase; + } + default: return 0U; + } +} + +} /* extern "C" */ diff --git a/GD32E508_DCU_APP/comm/ecat_motor_ota.h b/GD32E508_DCU_APP/comm/ecat_motor_ota.h new file mode 100644 index 0000000..c997320 --- /dev/null +++ b/GD32E508_DCU_APP/comm/ecat_motor_ota.h @@ -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...]) + * + * 本头刻意只用 类型: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 + +/** + * ── 尺寸常量(宏: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 */ diff --git a/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp b/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp index 3d1d71f..929bb70 100644 --- a/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp +++ b/GD32E508_DCU_APP/comm/ethercat_can_bridge.cpp @@ -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]), diff --git a/GD32E508_DCU_APP/comm/ssc/port/dc_app.c b/GD32E508_DCU_APP/comm/ssc/port/dc_app.c index e82f14c..aa0009d 100644 --- a/GD32E508_DCU_APP/comm/ssc/port/dc_app.c +++ b/GD32E508_DCU_APP/comm/ssc/port/dc_app.c @@ -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 #include /* 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} }; diff --git a/GD32E508_DCU_APP/device/canfd.h b/GD32E508_DCU_APP/device/canfd.h index a4ef417..574988f 100644 --- a/GD32E508_DCU_APP/device/canfd.h +++ b/GD32E508_DCU_APP/device/canfd.h @@ -35,6 +35,7 @@ public: * @note 0xE0/0xE1 保留给 handler 入口校验(payload 过短/通道非法), * 不经 transmitFrame 返回;0xE2~0xE5 由 transmitFrame 区分四种失败原因; * 0xE6=通道处于收发器 Silent(只听不发),在入口即拒绝,不计任何错误计数。 + * 0xE7=通道被电机 OTA 独占锁定(EtherCAT/SPI 跨总线排他),同样不计错误计数。 */ enum class CanTxResult : uint8_t { kOk = 0x00U, /**< 发送成功,已进入硬件邮箱。 */ @@ -43,6 +44,7 @@ public: kErrMailbox = 0xE4U, /**< 3 个硬件邮箱全 PENDING,abort+重投仍失败。 */ kErrInvalid = 0xE5U, /**< 入口参数无效(channelIndex/dlc/data 等)。 */ kErrSilent = 0xE6U, /**< 通道收发器处于 Silent(驱动器关断),拒绝发送。 */ + kErrOtaLocked = 0xE7U, /**< 通道被电机 OTA 独占(跨总线排他),拒绝发送,不计错误计数。 */ }; /** diff --git a/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_bridge.cpp b/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_bridge.cpp index a8c03a1..ff49b11 100644 --- a/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_bridge.cpp +++ b/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_bridge.cpp @@ -3,6 +3,7 @@ #include "device/spi/app_spi_command_defs.h" #include "device/canfd.h" #include "sys/config.h" +#include "sys/motor_ota_relay.h" #include namespace { @@ -76,6 +77,17 @@ public: return; } + /* 通道正被电机 OTA 独占(WiFi 与 EtherCAT 两条链路可能同时在跑,锁是共用的): + * 这一帧挤进同一通道会 abort 掉在途的 0x101 固件包,单发模式下永不重传。 + * 回 0xE7,上位机据此知道「是被 OTA 挡了」而不是「邮箱满」。 */ + if (MotorOtaRelay::isChannelLocked(can_ch)) { + postCanSendLog(bus, "LOCKED(motor-OTA)", can_ch, can_id, can_data, can_dlc); + const uint8_t err = static_cast(CanFd::CanTxResult::kErrOtaLocked); + bus.buildResponse(ctx.tx_buf, ctx.tx_buf_size, ctx.tx_len, + SpiDefs::kRspError, &err, 1U); + return; + } + /* 0xC0 单帧发送改走 enqueueTx: * 1) allow_abort=false 杜绝硬件邮箱被后续发送主动 abort 抹杀; * 2) 电机高频上报抢占仲裁导致邮箱全占时,帧自动进入 13 深度软件队列, diff --git a/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_burst.cpp b/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_burst.cpp index f752386..d8d32db 100644 --- a/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_burst.cpp +++ b/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_burst.cpp @@ -3,6 +3,7 @@ #include "device/spi/app_spi_command_defs.h" #include "device/canfd.h" #include "sys/config.h" +#include "sys/motor_ota_relay.h" namespace { @@ -81,6 +82,11 @@ public: resp[1U + i] = 0xE1U; /* 通道非法:跳过该帧,其余照常发送 */ continue; } + if (MotorOtaRelay::isChannelLocked(ch)) { + /* 电机 OTA 独占该通道:整帧拒绝,不挤邮箱(0xE7)。 */ + resp[1U + i] = static_cast(CanFd::CanTxResult::kErrOtaLocked); + continue; + } const CanFd::CanTxResult r = CanFd::enqueueTx(ch, id, (dlc > 0U) ? &p[1] : nullptr, dlc); resp[1U + i] = static_cast(r); diff --git a/GD32E508_DCU_APP/sys/config.h b/GD32E508_DCU_APP/sys/config.h index 4134604..22d5194 100644 --- a/GD32E508_DCU_APP/sys/config.h +++ b/GD32E508_DCU_APP/sys/config.h @@ -176,7 +176,7 @@ constexpr uint32_t CONFIG_CAN_QUIET_AFTER_SEC = 10U; * ============================================================ */ constexpr uint32_t APP_FW_VERSION_MAJOR = 1U; constexpr uint32_t APP_FW_VERSION_MINOR = 7U; -constexpr uint32_t APP_FW_VERSION_PATCH = 2U; /* 1.7.2: CAN 通道收发器 Silent(只听不发)——S 脚 API + 0xC8 命令 + 0xC1 bus_state bit5;静默期发送入口回 0xE6 且不计错误 */ +constexpr uint32_t APP_FW_VERSION_PATCH = 4U; /* 1.7.4: 电机 OTA 邮箱进料块长 4096→1024——APP __HEAP_SIZE 只有 3KB,SSC 分段缓冲是唯一 malloc 用户,4096 会被从站回 abort 0x05040005 Out of memory(BOOT 堆 16KB 所以能用 4096) */ constexpr uint32_t APP_FW_VERSION_BUILD = 0U; diff --git a/GD32E508_DCU_APP/sys/critical.h b/GD32E508_DCU_APP/sys/critical.h index b930c83..d24ded3 100644 --- a/GD32E508_DCU_APP/sys/critical.h +++ b/GD32E508_DCU_APP/sys/critical.h @@ -59,4 +59,19 @@ */ #define RT_CRITICAL_RESTORE(val) __set_BASEPRI(val) +/** + * @brief Preempt-0 专用临界区:PRIMASK 全局屏蔽,用于与 preempt=0 中断共享的 + * 单字标志(读-改-写)。 + * @note 严格限定 O(1) 状态操作(置位/清位/单字赋值)。**严禁**在其中调用 + * printf/LOG_*:串口入队实测 36us,TX FIFO 满时可达 123us,会把 EtherCAT + * SYNC0 抖动顶出 DC 容差。日志一律放临界区外。 + */ +#define RT_CRITICAL_PREEMPT0_SAVE() \ + ({ \ + uint32_t __primask_save = __get_PRIMASK(); \ + __disable_irq(); \ + __primask_save; \ + }) +#define RT_CRITICAL_PREEMPT0_RESTORE(val) __set_PRIMASK(val) + #endif /* SYS_CRITICAL_H */ diff --git a/GD32E508_DCU_APP/sys/motor_ota_relay.cpp b/GD32E508_DCU_APP/sys/motor_ota_relay.cpp index 1288266..0e19b10 100644 --- a/GD32E508_DCU_APP/sys/motor_ota_relay.cpp +++ b/GD32E508_DCU_APP/sys/motor_ota_relay.cpp @@ -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((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((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(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(data[0]) - | (static_cast(data[1]) << 8) - | (static_cast(data[2]) << 16) - | (static_cast(data[3]) << 24); - const uint8_t *chunk = data + 4U; - uint16_t chunk_len = static_cast(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(s_fw_size - offset); - } + const uint32_t offset = static_cast(data[0]) + | (static_cast(data[1]) << 8) + | (static_cast(data[2]) << 16) + | (static_cast(data[3]) << 24); + return feedAt(offset, data + 4U, static_cast(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(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(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(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(ch); + } + return -1; +} + } /* namespace MotorOtaRelay */ diff --git a/GD32E508_DCU_APP/sys/motor_ota_relay.h b/GD32E508_DCU_APP/sys/motor_ota_relay.h index f0cd487..cac95b0 100644 --- a/GD32E508_DCU_APP/sys/motor_ota_relay.h +++ b/GD32E508_DCU_APP/sys/motor_ota_relay.h @@ -51,6 +51,16 @@ struct Status { uint16_t packets_acked; uint16_t total_packets; uint32_t fw_size; + /** 已被环缓接受的明文字节数(流位置)。ECAT 暂存泵用它对账: + * 电机侧自动重试会把环缓与这个位置一起清零,泵必须跟着回退。 + * 注意:只是内部 C++ 结构,SPI 0xD0 QUERY 的线格式没有改动。 */ + uint32_t fw_fed; +}; + +/** 固件明文进料来源。同一时刻只允许一个来源,环缓不接受两路交叉写入。 */ +enum class FeedSource : uint8_t { + kSpi = 0U, /* ESP32 经 SPI 0xD1 喂(WiFi/BLE 链路的既有通路) */ + kEcat = 1U, /* EtherCAT 主站经 CoE 0x2511 灌进暂存区,再由 ecat_motor_ota 泵入环缓 */ }; /* 初始化(注册 CAN RX 回调等) */ @@ -67,19 +77,51 @@ void init(); bool onCanFrame(uint8_t channel, uint32_t id, const uint8_t *data, uint8_t len); /* SPI 命令接口 */ - bool start(uint8_t can_channel, uint32_t fw_size); + /** + * @brief 启动一次电机 OTA。 + * @param can_channel 目标 CAN 通道 0..2。 + * @param fw_size 固件明文字节数。 + * @param src 进料来源(SPI / ECAT 暂存泵)。 + * @param node_id 1..9:由 GD32 自己在**已加锁**的通道上发 0x000N0000 进 BOOT 帧; + * 0:不进 BOOT(WiFi 链路沿用上位机先发帧的老顺序)。 + * @note 入口第一条语句即置通道锁,早于任何状态清零与串口日志。 + */ + bool start(uint8_t can_channel, uint32_t fw_size, + FeedSource src = FeedSource::kSpi, uint8_t node_id = 0U); /** * @brief 喂入固件明文。payload 布局:[offset:u32 LE][data...] * offset 为固件绝对字节偏移;重复/重叠区间幂等 ACK,缺口拒绝(→SPI WAIT)。 * 无 offset 的旧纯流会在 SPI 超时重传时把同一 chunk 追加进环缓(BLF 见前 236B 循环)。 */ bool feedData(const uint8_t *data, uint16_t len); + /** + * @brief 已解析过的进料写入(feedData 的核心,偏移/幂等/环缓满判据只有一份)。 + * @note 供 ECAT 暂存泵调用:那边已按 offset 寻址,无需再拼 4 字节头, + * 也避免在仅 512B 的主栈上放临时头缓冲。 + */ + bool feedAt(uint32_t offset, const uint8_t *chunk, uint16_t chunk_len); + /** + * @brief 环缓当前还能接受多少明文字节。 + * @note 给进料方按空位切片用(否则一次投过大永远排不进队首)。不改变任何判据: + * feedAt 内部仍会自己再查一次空位。 + */ + uint16_t feedFreeSpace(void); void cancel(); /* 状态查询 */ Status getStatus(); bool isActive(); +/** + * @brief 指定 CAN 通道当前是否被电机 OTA 独占。 + * @note 单条 32 位位与,可在 preempt=0(EXTI0/SYNC0、EXTI10_15)里直接调用。 + * EtherCAT 桥接与 SPI 桥接/突发在发送前必须查它,否则周期运动帧会 + * abort 掉 OTA 的 0x101 包(单发模式 ARD=1 不重传),电机会回 0x05/0x06。 + */ +bool isChannelLocked(uint8_t channel); +/** @brief 当前被锁定的通道号;-1 表示无锁。 */ +int8_t lockedChannel(); + } /* namespace MotorOtaRelay */ #endif /* SYS_MOTOR_OTA_RELAY_H */ diff --git a/GD32E508_DCU_APP/sys/ota_boot_request.cpp b/GD32E508_DCU_APP/sys/ota_boot_request.cpp index d8d20dd..59355c4 100644 --- a/GD32E508_DCU_APP/sys/ota_boot_request.cpp +++ b/GD32E508_DCU_APP/sys/ota_boot_request.cpp @@ -7,7 +7,9 @@ #include "gd32e50x.h" #include "sys/config.h" +#include "sys/motor_ota_relay.h" #include "sys/systick.h" +#include /* LOG_ERR 展开为 printf(sys/config.h),本文件原先没有 stdio */ namespace { /** Bootloader OTA 配置页地址与 word 布局:唯一真源 shared/ota_layout.h。 */ @@ -151,6 +153,16 @@ void OtaBootRequest::maybeClearCrashCount(uint32_t uptime_sec) */ bool OtaBootRequest::requestEnterBoot() { + /* 电机 OTA 正在跑(WiFi 与 EtherCAT 两条进料路都算)时绝不交回 BOOT: + * 复位会把电机的刷写腰斩在 erase 之后——旧 APP 已抹、新 APP 没收完,直接变砖。 + * 判据只放这一个执行点:SPI 0xB0、EtherCAT CoE 0x2502、CAN 0x320 三条入口 + * 最终都要过这里,不在调用方各抄一份。 */ + if (MotorOtaRelay::isActive()) { + LOG_ERR("[OTA-BOOT] enter-boot refused: motor OTA active ch=%d\r\n", + (int)MotorOtaRelay::lockedChannel()); + return false; + } + bool ok = true; ok = otaConfigSet(kAppLatestCheckAddr, kOtaAval) && ok; ok = otaConfigSet(kAppLatestAvalAddr, kOtaAval) && ok; diff --git a/GD32E508_DCU_APP/sys/task_runner.cpp b/GD32E508_DCU_APP/sys/task_runner.cpp index 6620403..3c0d789 100644 --- a/GD32E508_DCU_APP/sys/task_runner.cpp +++ b/GD32E508_DCU_APP/sys/task_runner.cpp @@ -21,6 +21,7 @@ #include "comm/ethercat_slave.h" #include "comm/ethercat_sync.h" #include "comm/ethercat_can_bridge.h" +#include "comm/ecat_motor_ota.h" #include "comm/esc_exmc_bus.h" #include "comm/esc_lan9252.h" @@ -171,6 +172,9 @@ void TaskRunner::taskCanTxSend() if (!CanFd::isChannelEnabled(ch)) { continue; } + if (MotorOtaRelay::isChannelLocked(ch)) { + continue; /* 电机 OTA 独占:诊断帧也不许挤这条通道 */ + } uint8_t data[64]; data[0] = 0xC0U; @@ -591,10 +595,14 @@ void TaskRunner::runPending() * 非活跃时立即返回,开销可忽略。 */ /* 负载率挂钩:relay 活跃时才计 busy——isActive() 是廉价枚举判断, * 空闲路径零挂钩开销;计数段覆盖等 ACK/超时/发包的真实工作。 */ - const bool motorOtaActive = MotorOtaRelay::isActive(); + const bool motorOtaActive = MotorOtaRelay::isActive() || EcatMotorOta::sessionActive(); if (motorOtaActive) { CpuLoad::beginWork(); } + /* EtherCAT 邮箱进料(comm/ecat_motor_ota.cpp):把本地 48KB 暂存区按环缓空位 + * 泵进 relay。放在 relay::process() 之前,同圈刚泵进去的字节立刻能被弹包。 + * WiFi/SPI 链路下 s_started=false,这里第一次判断就返回,开销可忽略。 */ + EcatMotorOta::process(); MotorOtaRelay::process(); /* 数据发送推进:主循环每圈调用,按 SysTick 钟表控制每包间隔 * (替代原靠 tick100us 计数 + TASK_MOTOR_OTA_TX 的方式,消除计数器竞争)。 */