feat(core): 0.5.0 SPI 链路稳健性 + OTA 内存 + 状态诊断

变更点:
1. SPI master
   - CS 事务间强制 ≥1ms,根治 GD32 CS EXTI re-arm 窗口 RX DMA 撕裂
   - APP ISR 快速响应类型禁用盲读
   - serviceRing 偷读迟到 RESPONSE 时推进 m_txSeq,避免命令被旧序号吞掉
   - tx_total/retry/fail + ring_steal_* + rsp_err_0x82 进 /system/status
2. OTA
   - 收块缓冲从常驻 4KB 静态改为会话内 RAII malloc
3. CAN / 日志
   - 0xC1 LINK_ERROR(0x7F) 风暴限流;网页 SPI 成功日志降 DBG
4. 版本
   - /system/status 的 GD32 版本只认 SPI 实时身份,不用 OtaRelay 过期缓存
5. 固件版本 0.5.0
This commit is contained in:
cbb
2026-09-20 22:58:17 +08:00
parent 5315f91475
commit 84f496c22c
8 changed files with 246 additions and 39 deletions
+28 -2
View File
@@ -25,6 +25,11 @@ constexpr uint8_t kSpiCanSetModeType = 0xC2U;
constexpr uint8_t kFeedbackFixedHeader = 28U;
constexpr uint8_t kFeedbackMaxPage = 1U; /* GD32 仅 page 0/1 */
/* 0xC1 收到链路层 LINK_ERROR(0x7F) 的限流标记:见 canFdQueryFeedback。
* 一轮"拒绝风暴"(同一成因:GD32 刚复位、序号/session 对不上)只报第一条 WARN,
* 其后降级 DBG;任一次查询成功即复位,下一轮风暴仍能报出来。 */
static bool s_linkErrLogged = false;
/* 突发 payload 上限 = V2_MAX_PAYLOAD(236);头 4B = count+dlc+suffix(2)。 */
constexpr uint8_t kBurstHeaderLen = 4U;
constexpr uint16_t kBurstPayloadCap = 236U;
@@ -409,10 +414,31 @@ int canFdQueryFeedback(uint8_t channel, uint8_t page,
return -2;
}
if (resp_type != SPI_OTA_RSP_ACK || resp_len < 1U) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 非ACK type=0x%02X len=%zu",
(unsigned)channel, (unsigned)page, (unsigned)resp_type, resp_len);
/* 0x7F = 链路层 LINK_ERROR(payload = [错误码, 期望序号])。成因几乎只有一种:
* GD32 刚被复位过(APP→BOOT 交接、BOOT→新 APP 跳转、上电),它的接收序号/session
* 从 0 重来,而 ESP32 仍按旧序号发 —— 于是一帧一条。此刻对面根本没有 CAN 桥
* (BOOT 不提供 0xC1),这些查询注定无果,属纯噪音。
* 一轮风暴只报第一条 WARN(含错误码与期望序号,够定位),其余降级 DBG;
* 成功一次即复位标记,下一轮风暴照样能报。避免 1Hz × 3 通道刷屏。 */
if (resp_type == V2_TYPE_LINK_ERROR) {
const uint8_t errc = (resp_len > 0U) ? resp_buf[0] : 0xFFU;
const uint8_t exp = (resp_len > 1U) ? resp_buf[1] : 0xFFU;
if (!s_linkErrLogged) {
s_linkErrLogged = true;
DEBUG_WARN("CAN", "queryFeedback 链路层拒绝(0x7F code=0x%02X exp=%u):"
"GD32 正在复位/重同步(刷写交接或 BOOT↔APP),同类后续静默",
errc, exp);
} else {
DEBUG_DBG("CAN", "queryFeedback 链路层拒绝(0x7F code=0x%02X exp=%u) 已静默",
errc, exp);
}
} else {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 非ACK type=0x%02X len=%zu",
(unsigned)channel, (unsigned)page, (unsigned)resp_type, resp_len);
}
return -1;
}
s_linkErrLogged = false; /* 通了一次:链路已重同步,标记复位 */
/* 解析复用现有 canFdParseFeedbackPage(payload 格式与推送一致) */
int parse_rc = canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx);
+86 -13
View File
@@ -21,7 +21,8 @@
#include "esp_log.h"
#include "esp_random.h" // esp_random() for HELLO session_id
#include "esp_intr_alloc.h" // ESP_INTR_FLAG_IRAM
#include "esp_timer.h" // esp_timer_get_time():worker job 执行耗时采样
#include "esp_timer.h"
#include "esp_rom_sys.h" // esp_timer_get_time():worker job 执行耗时采样
#include "spi_master.h"
#include "spi_link_v2.h"
#include "app_config.h"
@@ -30,6 +31,10 @@
static constexpr size_t kSpiTransferMaxBytes = 256U;
/** @brief 上一笔 CS 事务结束时刻(µs,worker 单线程读写)。
* transferClockedFlags 用它强制 CS 事务间最小间隔(0.4.4 RX 撕裂根因修复)。 */
static int64_t s_lastCsEndUs = 0;
/** @brief submitJob 池 Job 与 worker 之间的 finished/abandoned/in_use 交接。 */
static portMUX_TYPE s_spiJobMux = portMUX_INITIALIZER_UNLOCKED;
@@ -357,23 +362,44 @@ esp_err_t SpiMaster::serviceRingOnce()
const uint8_t *found_payload = nullptr;
size_t found_len = 0;
size_t frame_offset = 0;
uint8_t found_flags = 0U;
if (!otaFindFrame(rx_raw, rx_len, &found_type, &found_payload,
&found_len, &frame_offset)) {
&found_len, &frame_offset, &found_flags)) {
return ESP_ERR_INVALID_RESPONSE;
}
if (found_type == V2_TYPE_CREDIT_UPDATE) {
DEBUG_DBG("SPI", "worker CREDIT_UPDATE credit=%u", (unsigned)m_remoteCredit);
return ESP_ERR_NOT_FOUND;
}
/* RESPONSE(OTA ACK/WAIT/ERROR)绝不能当 EVENT 入队;否则 OTA poll 丢响应。
* 此处帧已被时钟读走,只能记统计;真正防护靠 m_suppressRingService。 */
if (found_type == SPI_OTA_RSP_ACK || found_type == SPI_OTA_RSP_WAIT ||
found_type == SPI_OTA_RSP_ERROR || found_type == V2_TYPE_RSP_ACK ||
found_type == V2_TYPE_RSP_WAIT || found_type == V2_TYPE_RSP_ERROR) {
/* RESPONSE(ACK/WAIT/ERROR/LINK_ERROR,含 0xC4 等回自身类型的响应)绝不能当
* EVENT 入队;此处帧已被时钟读走,真正防护靠 m_suppressRingService。本分支
* 只在 poll 已超时放弃、GD32 忙导致响应迟到时可达。
* ★ 必须与 poll 成功路径一样推进 m_txSeq:ack_seq 匹配说明 GD32 已消费本端
* TX_SEQ(缓存响应待重放)。若不推进,下一条命令(含重试)复用旧序号 →
* GD32 判为重复请求直接回放缓存旧帧、新命令被吞 → 业务反复收到同一个
* 旧 0x82/旧响应,命令永远不再执行("一直读到 0x82"的根因)。 */
if ((found_flags & V2_FLAG_RESPONSE) != 0U) {
const bool ack_matched = m_lastRxAckMatched;
if (ack_matched) {
m_txSeq = (uint8_t)(m_lastSentSeq + 1U);
}
m_stats.ring_steals++; /* 0.4.3:量化归因(/system/status 直读,不依赖 /logs) */
m_stats.ring_steal_last_type = found_type; /* 0.4.5:偷的什么帧 */
m_stats.ring_steal_last_ack = ack_matched ? 1U : 0U;
m_stats.ring_steal_last_p0 = (found_len > 0U) ? found_payload[0] : 0U; /* 0.4.6 */
m_stats.ring_steal_last_req = m_lastReqType;
m_stats.ring_steal_last_p1 = (found_len > 1U) ? found_payload[1] : 0U; /* 0.4.8 */
m_stats.ring_steal_last_p2 = (found_len > 2U) ? found_payload[2] : 0U;
m_stats.ring_steal_last_p3 = (found_len > 3U) ? found_payload[3] : 0U;
static uint32_t s_steal_logs = 0U;
if ((++s_steal_logs % 16U) == 1U) {
DEBUG_WARN("SPI", "serviceRing 读到 RESPONSE type=0x%02X(应禁止,log#%lu)",
found_type, (unsigned long)s_steal_logs);
DEBUG_WARN("SPI", "serviceRing 偷读迟到 RESPONSE t=0x%02X p0=0x%02X "
"ack_match=%u lastReq=0x%02X(poll 已超时,帧丢弃%s,log#%lu)",
found_type,
(found_len > 0U) ? found_payload[0] : 0U,
(unsigned)ack_matched, (unsigned)m_lastReqType,
ack_matched ? "、seq 已推进" : "",
(unsigned long)s_steal_logs);
}
return ESP_ERR_INVALID_RESPONSE;
}
@@ -866,6 +892,23 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
return ESP_ERR_INVALID_SIZE;
}
/* 0.4.4:CS 事务间最小间隔(根因修复)。
* GD32 侧每个 CS 事务要跑一次 EXTI ISR(stop DMA → processCommand → re-arm,
* ~几十 µs);该 ISR 抢占级 1,会被 ECAT SYNC0/CAN-RX(级 0)及同级 PDI ISR
* 周期性延迟。ESP32 背靠背 CS 脉冲(send→read µs 级间隙)一旦撞进其
* "pin 已回高但 re-arm 未完成"的窗口,GD32 的 RX DMA 就 mid-pulse 重装——
* 下一笔查询帧头部丢失、尾部被 256B 缓冲截断(GD32 诊断实测:rxLen=255、
* 缓冲全 FF + 帧残片)→ v2 解析失败回 0x82 0xE8 → 迟到被 serviceRing 偷读
* ("偷读迟到 RESPONSE"持续风暴的根因)。1ms 间隔给 GD32 留出确定性窗口,
* 换取查询循环 ~5% 吞吐,彻底消除 RX 撕裂。 */
{
const int64_t now_us = esp_timer_get_time();
const int64_t since_us = now_us - s_lastCsEndUs;
if (since_us >= 0 && since_us < 1000) {
esp_rom_delay_us((uint32_t)(1000 - since_us));
}
}
uint8_t tx_staging[kSpiTransferMaxBytes];
uint8_t rx_staging[kSpiTransferMaxBytes];
const uint8_t *tx_ptr = tx_buf;
@@ -938,6 +981,8 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
if (rx_len > 0U && rx_ptr != rx_buf) {
memcpy(rx_buf, rx_ptr, rx_len);
}
/* CS 上升沿(事务结束)时间戳:供下一笔事务前的最小间隔判断 */
s_lastCsEndUs = esp_timer_get_time();
return ESP_OK;
}
@@ -1504,8 +1549,17 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
now = xTaskGetTickCount();
remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U;
const bool last_chance = (remain_ms == 0U) || (attempt >= 6U && !did_spi_read);
/* 0.4.4:APP ISR 快速响应类型禁用盲读。
* 盲读在 PB6 未低时强行打 256 时钟 0xFF——若 GD32 的 CS EXTI 还压着
* ECAT/CAN 中断没处理完上一笔(re-arm 未跑),盲读会撕碎其 RX 缓冲
* (实测 GD32 侧 rxLen=255 全 FF + 查询帧残片)→ 解析失败回 0x82 0xE8
* → 迟到被 serviceRing 偷读("偷读迟到 RESPONSE 0xEE/E8"风暴的根因)。
* 快速类型的 PB6 是事件驱动(GD32 ISR µs 级拉低),纯等电平即可;
* 响应真丢了就走 timeout→重发(GD32 DUP 缓存回放兜底),无 corrupt。 */
const bool no_blind = isAppIsrFastRespType(m_lastReqType);
/* 无 ready:前 2 轮盲读;之后每 8 轮盲读;deadline 末强制盲读一次 */
if (!ready && attempt > 2U && (attempt % 8U) != 0U && !last_chance) {
if (!ready && (no_blind ||
(attempt > 2U && (attempt % 8U) != 0U && !last_chance))) {
vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_POLL_BACKOFF_MS));
continue;
}
@@ -1587,10 +1641,19 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
* → fed 卡在首 chunk(日志 fed=232)、CAN 抽空后 0x01 超时。
* BOOT OTA 见 WAIT 后只 poll 不重发 DATA,推进 seq 同样正确。 */
if (m_lastRxAckMatched) {
m_txSeq = (uint8_t)(m_lastSentSeq + 1U);
m_txSeq = (uint8_t)(m_lastSentSeq + 1U);
}
/* 0.4.3 诊断:GD32 明确回 0x82 的量化归因(/system/status 直读) */
if (found_type == V2_TYPE_RSP_ERROR) {
m_stats.rsp_err_0x82++;
m_stats.rsp_err_last_req = m_lastReqType;
m_stats.rsp_err_last_p0 = (*resp_len > 0U) ? resp_payload[0] : 0U;
m_stats.rsp_err_last_p1 = (*resp_len > 1U) ? resp_payload[1] : 0U;
m_stats.rsp_err_last_p2 = (*resp_len > 2U) ? resp_payload[2] : 0U;
m_stats.rsp_err_last_p3 = (*resp_len > 3U) ? resp_payload[3] : 0U;
}
m_linkState.exitBusy();
return ESP_OK;
m_linkState.exitBusy();
return ESP_OK;
}
vTaskDelay(pdMS_TO_TICKS(1));
@@ -1693,6 +1756,10 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
resp_type, resp_payload, resp_max, resp_len, timeout_ms);
}
/* 0.4.9:量化"透明重试"下的真实失败率(DBG 级日志被编译期裁掉,
* 旧实现里 send/poll 超时重试完全静默)。/system/status 直读:
* tx_total=进入事务次数 tx_retry=走到重发的次数 tx_fail=耗尽重试仍失败 */
m_stats.tx_total++;
LockGuard lock(m_mutex);
/* 步骤 1: 发送命令(忽略延迟一拍的过期响应)。
@@ -1737,12 +1804,15 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
m_lastProfile.t_send1 = (uint64_t)esp_timer_get_time();
if (send_ret != ESP_OK && send_ret != ESP_ERR_INVALID_RESPONSE) {
if (attempt + 1U < kTxRetryMax) {
m_stats.tx_retry++; /* 0.4.9:发送级失败→重发(原静默) */
DEBUG_DBG("SPI", "TX retry(%u/%u) send fail type=0x%02X %s",
(unsigned)(attempt + 1U), (unsigned)kTxRetryMax,
type, esp_err_to_name(send_ret));
vTaskDelay(pdMS_TO_TICKS(3));
continue;
}
m_stats.tx_fail++; /* 0.4.9:耗尽重试仍失败 */
m_stats.tx_fail_last_type = type;
return send_ret;
}
@@ -1764,12 +1834,15 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
return ESP_OK; /* 已读到有效响应帧(ACK/ERROR/陈旧) */
}
if (attempt + 1U < kTxRetryMax) {
m_stats.tx_retry++; /* 0.4.9:poll 无响应→重发(原静默) */
DEBUG_DBG("SPI", "TX retry(%u/%u) no resp type=0x%02X %s",
(unsigned)(attempt + 1U), (unsigned)kTxRetryMax,
type, esp_err_to_name(poll_ret));
vTaskDelay(pdMS_TO_TICKS(3));
continue;
}
m_stats.tx_fail++; /* 0.4.9:耗尽重试仍失败 */
m_stats.tx_fail_last_type = type;
return poll_ret;
}
return ESP_ERR_TIMEOUT; /* 不可达:循环必有返回 */
+21
View File
@@ -205,6 +205,27 @@ public:
uint32_t rx_overruns; ///< RX overrun 计数
uint32_t resyncs; ///< HELLO 重同步次数
uint32_t max_resp_latency_us; ///< 最大响应延迟(微秒)
/* 0.4.3 诊断:GD32 回 0x82 的量化归因(/system/status 直读,不依赖 /logs) */
uint32_t rsp_err_0x82; ///< 收到 GD32 0x82 ERROR 响应总数
uint8_t rsp_err_last_req; ///< 最近一笔 0x82 对应的主动请求 TYPE
uint8_t rsp_err_last_p0; ///< 最近一笔 0x82 的 payload[0](GD32 错误码)
uint8_t rsp_err_last_p1; ///< 0xE8 时=GD32 收到的 rxLen
uint8_t rsp_err_last_p2; ///< 0xE8 时=GD32 rxBuf[0](期望 SOF0=0xE2?)
uint8_t rsp_err_last_p3; ///< 0xE8 时=GD32 rxBuf[1](期望 SOF1)
uint32_t ring_steals; ///< serviceRing 偷读迟到 RESPONSE 总数
uint8_t ring_steal_last_type; ///< 最近一笔偷读帧的 TYPE(0.4.5 归因)
uint8_t ring_steal_last_ack; ///< 最近一笔偷读帧 ack_seq 是否匹配 lastSent
uint8_t ring_steal_last_p0; ///< 最近一笔偷读帧 payload[0](0.4.6)
uint8_t ring_steal_last_req; ///< 偷读时刻的 m_lastReqType(0.4.6)
uint8_t ring_steal_last_p1; ///< 0xE8 时=GD32 诊断 rxLen(0.4.8)
uint8_t ring_steal_last_p2; ///< 0xE8 时=GD32 诊断 rxBuf[0](0.4.8)
uint8_t ring_steal_last_p3; ///< 0xE8 时=GD32 诊断 rxBuf[1](0.4.8)
/* 0.4.9 诊断:透明重试会掩盖真实失败率(DBG 被编译裁掉、无计数)。
* tx_total 进入事务;tx_retry 走到重发;tx_fail 耗尽重试仍失败返回。 */
uint32_t tx_total; ///< otaSendAndPoll 事务总数
uint32_t tx_retry; ///< 因 send/poll 超时触发的重发总次数
uint32_t tx_fail; ///< 重试后仍失败(返回非 OK)的事务数
uint8_t tx_fail_last_type; ///< 最近一笔最终失败的命令 TYPE
};
/** @brief v2 完整链路状态(SPEC §10.1,会话/credit + 统计)。 */