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
+1 -1
View File
@@ -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;
+15
View File
@@ -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 */
+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 */
+43 -1
View File
@@ -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 */
+12
View File
@@ -7,7 +7,9 @@
#include "gd32e50x.h"
#include "sys/config.h"
#include "sys/motor_ota_relay.h"
#include "sys/systick.h"
#include <cstdio> /* 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;
+9 -1
View File
@@ -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 的方式,消除计数器竞争)。 */