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
+12
View File
@@ -72,6 +72,18 @@ Thumbs.db
ehthumbs.db
Desktop.ini
# 本机调试 dump / 状态快照
logs*.json
st*.json
st_new.json
# IDE / 过程产物
.trae/
.agents/
.worktrees/
.qoder/
.zcode/
# User-specific configuration files
*.user
*.workspace # General workspace files, can be from various tools
+2 -1
View File
@@ -71,8 +71,9 @@ inline constexpr uint32_t TASK_STACK_BLE_RESTART = 2048; ///< 按需:延时
// ============================================================
// OTA 配置
// ============================================================
inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< 上传接收块:4KB(吞吐优先;s_ota_upload_recv 静态 +2KB vs 2KB 块)
inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< 上传接收块:4KB(吞吐优先);会话内 malloc,稳态不占 .bss
// OTA_BLOCK_SIZE 演进:8KB→2KB(压堆)→4KB(试吞吐)→2KB(2026-07 回退)→4KB(2026-07-20 再提吞吐)。
// 2026-09:s_ota_upload_recv 从常驻静态改为 OTA 会话内 RAII malloc,稳态省 4KB。
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB = 221184B = 0x35F90,固件大小上限
inline constexpr size_t GD32_OTA_PACKET_SIZE = 48; // GD32 OTA 实际转发包载荷
+4 -4
View File
@@ -13,9 +13,9 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 4
#define FW_VERSION_PATCH 1
#define FW_VERSION_STRING "0.4.1" // GD32 OTA 中继全程 ring 抑制修复:handleIdle 起点调 reset() 曾把 taskEntry 刚置位的 setSuppressRingService(true) 立即解除,导致中继全程 worker 偷读 OTA ACK/WAIT("serviceRing 读到 RESPONSE"告警,log#449),poll 空等→50ms 重发→包失败;reset() 增加 release_ring_suppress 参数,状态机起点 reset(false) 只清状态不清抑制,结束/中止路径仍统一释放。0.3.85 INIT_STATUS 日志补打 fault_codes hex 一行(13B 模块故障码,编码见 GD32 sys/initial.h)——排查 GD32 init 失败无需解码位图;配套 GD32 1.5.38 看门狗 LSI 修复验证。0.3.84 GD32 模块级故障码(配套 GD32 1.5.36):0xA3 INIT_STATUS 响应 25B 时解析 [12..24] fault_codes(每模块 1B,canfd 高 4 位为通道号),gd32_init_status_t 增 fault_codes/has_fault_codes;/system/status gd32_init 新增 fault_codes 数组,前端「GD32 自检」悬停/值直接显示中文失败原因。0.3.83 GD32 APP 初始化状态可视(配套 GD32 1.5.34):身份块[15]解析 init_state(0=OK/1=degraded/2=critical_fail,HELLO/PING 自动刷新,异常打 WARN);新增 SpiMaster::appInitStatus() 经 SPI 0xA3 查询模块位图/critical 失败位图/复位源(仅 APP 角色,成功即缓存,session 重建自清);/system/status 新增 gd32_init 字段(state/state_known/detail_known/module_mask/critical_fail_mask/reset_cause,惰性查询+10s 失败退避);0xA3 加入短响应/ISR 快速响应类型表。0.3.82 DebugLog 新增 UART0 RX 泵任务(GPIO20, 3M 8N1):收到的串口行按 \n 切行、剥 \r,以 tag=UART 推入 ring → HTTP /logs 出现在网页日志面板;接线=任意串口源 TX→H1/H2 的 ESP_RX。0.3.81 GD32 OTA 包级防呆:中继触发前校验 gd32_ota 镜像向量表身份(Gd32FlashStore::validateStoredImageIdentity),BOOT 包/非 APP 包在 WiFi /ota/relay 与 BLE 0xF7 均拒绝,GD32 APP 区不再被错误包擦除;BLE 新增 0x87 完成码
#define FW_VERSION_MINOR 5
#define FW_VERSION_PATCH 0
#define FW_VERSION_STRING "0.5.0" // 内存:s_ota_upload_recv 从常驻静态 4KB 改为 OTA 会话内 RAII malloc,稳态省 4KB 内部 DRAM(httpd 单线程+协调器互斥,结束/失败自动 free)。0.4.9 tx_total/tx_retry/tx_fail/tx_fail_last_type 计数进 /system/status:透明重试(kTxRetryMax=3)掩盖真实失败率,DBG 日志被 CONFIG_LOG_MAXIMUM_LEVEL=3 编译期裁掉→"默默失败无打印"。0.4.8 ring_steal_diag=[rxLen,rb0,rb1] 撕裂形态签名 // 0.4.5 CS 事务间最小间隔 1ms(transferClockedFlags 单点):GD32 CS EXTI re-arm 被 ECAT/CAN 中断延迟时,ESP32 背靠背 CS 撞进 re-arm 窗口使 RX DMA mid-pulse 重装→查询帧撕裂→0x82 0xE8→迟到偷读风暴。0.4.4 快速响应类型禁用盲读。0.4.3 0x82 量化归因插桩。0.4.2 serviceRing 偷读迟到 RESPONSE 的 seq 失配修复:poll 超时放弃后按铃读把响应当 EVENT 偷走且不推进 m_txSeq→下一条命令复用旧序号被 GD32 判重吞掉回放缓存旧 0x82(命令不再执行根因)。0.3.85 INIT_STATUS fault_codes hex;0.3.84 模块级故障码;0.3.83 GD32 APP 初始化状态可视;0.3.82 UART0 RX 泵→/logs;0.3.81 GD32 OTA 包级防呆
// Git 信息(手动填写)
#define GIT_HASH "770ae14"
#define GIT_HASH "770ae14"
+4 -3
View File
@@ -4,9 +4,10 @@
* 架构(2026-08 改造后):
* 0xC1 CAN 反馈:请求驱动(HTTP 侧 canFdQueryFeedback),本任务不处理。
* 0xC4 ADC / 0xC5 电机状态:由 GD32 主动按铃推送改为本任务周期查询——
* 0xC5 每 250ms 查 6 页、0xC4 每 1s 查 1 帧,查询响应 payload 格式与推送
* 一致,复用 onMotorStateEvent()/onGd32AdcFeedbackEvent() 解析入库。
* 消除 OTA 期间 GD32 悬空按铃(ESP32 抑制 ring 不读)导致“按铃超时取消”刷屏。
* 0xC5 每 250ms 查 MOTOR_TOTAL_PAGES 页,0xC4 每 1s 查 1 帧,查询响应
* payload 格式与推送一致,复用 onMotorStateEvent()/onGd32AdcFeedbackEvent()
* 解析入库。消除 OTA 期间 GD32 悬空按铃(ESP32 抑制 ring 不读)导致
* “按铃超时取消”刷屏。
* 0xC6 电机版本 / 0xD2 电机 OTA 进度:仍由 GD32 事件驱动推送,本任务 takeEvent 消费。
*
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务(不查询不消费);
+89 -16
View File
@@ -4,6 +4,7 @@
#include <string.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdlib.h>
#include <inttypes.h>
#include <algorithm>
#include "freertos/FreeRTOS.h"
@@ -221,8 +222,36 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size)
return total;
}
/* OTA 上传静态收块:避免 OTA 中途再 malloc(2KB) 与 TCP 窗口抢堆。 */
static uint8_t s_ota_upload_recv[OTA_BLOCK_SIZE];
/* OTA 上传收块:稳态不占堆;会话开始时一次性 malloc,结束/失败由 RAII 释放。
* 旧方案常驻静态 4KB(.bss),空闲时白占;本方案把 malloc 挪到 handler 入口
* (TCP 窗口尚未灌满、协调器已拦下并发 OTA),峰值与静态相当,稳态省 4KB。
* httpd 单线程 + OTA 协调器互斥,不会两路同时持有。 */
class OtaUploadRecv {
public:
uint8_t *buf = nullptr;
OtaUploadRecv()
{
buf = static_cast<uint8_t *>(malloc(OTA_BLOCK_SIZE));
if (buf == nullptr) {
DEBUG_ERR("WIFI", "OTA收块分配失败 free=%lu big=%lu",
(unsigned long)esp_get_free_heap_size(),
(unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT));
}
}
~OtaUploadRecv()
{
if (buf != nullptr) {
free(buf);
buf = nullptr;
}
}
OtaUploadRecv(const OtaUploadRecv &) = delete;
OtaUploadRecv &operator=(const OtaUploadRecv &) = delete;
bool ok() const { return buf != nullptr; }
};
static uint8_t s_ota_json_scratch[256];
static uint8_t s_ota_preread[256];
@@ -239,7 +268,12 @@ esp_err_t ota_upload_handler(httpd_req_t *req)
"missing or invalid Content-Length");
}
uint8_t *recv_buf = s_ota_upload_recv;
OtaUploadRecv upload_recv;
if (!upload_recv.ok()) {
return http_send_json_error(req, "500 Internal Server Error",
"oom_upload_buf", "OTA receive buffer alloc failed");
}
uint8_t *recv_buf = upload_recv.buf;
DEBUG_WARN("WIFI", "POST /ota/upload clen=%d free=%lu big=%lu",
req->content_len,
(unsigned long)esp_get_free_heap_size(),
@@ -1630,8 +1664,9 @@ esp_err_t spi_send_handler(httpd_req_t *req)
payload_len = http_hex_to_bytes_n(hex, hex_len, payload_data, SPI_OTA_FRAME_MAX);
}
DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节",
frame_type, payload_len);
/* 诊断透传高频(网页 2s 轮询等),成功路径降为 DBG:默认 INFO 级不再刷串口 */
DEBUG_DBG("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节",
frame_type, payload_len);
char resp[1024];
uint8_t resp_type = 0;
@@ -1648,8 +1683,8 @@ esp_err_t spi_send_handler(httpd_req_t *req)
"{\"status\": \"ok\", \"type\": \"%02X\", \"payload_len\": %zu, \"resp_type\": \"%02X\", \"resp_len\": %zu, \"recv\": \"%s\"}",
frame_type, payload_len, resp_type, resp_len, resp_hex);
/* 日志只记 type/len,完整 payload 仅在 HTTP JSON 返回给前端 */
DEBUG_INFO("SPI", "WEB SPI ok: type=0x%02X len=%zu",
resp_type, resp_len);
DEBUG_DBG("SPI", "WEB SPI ok: type=0x%02X len=%zu",
resp_type, resp_len);
} else {
snprintf(resp, sizeof(resp),
"{\"status\": \"error\", \"code\": %d, \"message\": \"%s\"}",
@@ -1905,6 +1940,10 @@ esp_err_t system_status_handler(httpd_req_t *req)
httpd_resp_set_type(req, "application/json");
/* ── 分块 1:核心状态 + OTA/WiFi ── */
/* GD32 版本只认 SPI 实时身份(HELLO/PING)。OtaRelay 那份缓存只在
* 中继/手动探测时写入,ECAT 直灌等旁路刷写后会永久过期——宁可报
* 0.0.0.0(前端显示未知/离线),也不回旧版本。 */
const gd32_version_t gver = SpiMaster::instance().gd32VersionCached();
int len = snprintf(resp, HTTP_JSON_WORK_CAP,
"{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, "
"\"free_heap\": %lu, \"min_free_heap\": %lu, \"largest_free_block\": %lu, "
@@ -1938,10 +1977,10 @@ esp_err_t system_status_handler(httpd_req_t *req)
(unsigned long)snap.relay.seq,
(unsigned long)snap.relay.total_packets,
snap.relay.gd32_app_alive ? "true" : "false",
snap.relay.version.app_major, snap.relay.version.app_minor,
snap.relay.version.app_patch, snap.relay.version.app_build,
snap.relay.version.boot_major, snap.relay.version.boot_minor,
snap.relay.version.boot_patch, snap.relay.version.boot_build);
gver.app_major, gver.app_minor,
gver.app_patch, gver.app_build,
gver.boot_major, gver.boot_minor,
gver.boot_patch, gver.boot_build);
if (len < 0 || (size_t)len >= HTTP_JSON_WORK_CAP) {
if (mux) {
xSemaphoreGive(mux);
@@ -1979,7 +2018,13 @@ esp_err_t system_status_handler(httpd_req_t *req)
"\"v2_active\":%s,\"local_credit\":%u,\"remote_credit\":%u,"
"\"crc_errors\":%lu,\"duplicate_requests\":%lu,\"seq_errors\":%lu,"
"\"retries\":%lu,\"ready_timeouts\":%lu,\"rx_overruns\":%lu,"
"\"resyncs\":%lu,\"max_resp_latency_us\":%lu}",
"\"resyncs\":%lu,\"max_resp_latency_us\":%lu,"
"\"rsp_err_0x82\":%lu,\"rsp_err_last_req\":\"0x%02X\",\"rsp_err_last_p0\":\"0x%02X\","
"\"rsp_err_last_p1\":%u,\"rsp_err_last_p2\":\"0x%02X\",\"rsp_err_last_p3\":\"0x%02X\","
"\"ring_steals\":%lu,\"ring_steal_last_type\":\"0x%02X\",\"ring_steal_last_ack\":%u,"
"\"ring_steal_last_p0\":\"0x%02X\",\"ring_steal_last_req\":\"0x%02X\","
"\"ring_steal_diag\":[%u,\"0x%02X\",\"0x%02X\"],"
"\"tx_total\":%lu,\"tx_retry\":%lu,\"tx_fail\":%lu,\"tx_fail_last_type\":\"0x%02X\"}",
(unsigned)ls.version,
role_str,
(unsigned long)ls.session_id,
@@ -1993,7 +2038,25 @@ esp_err_t system_status_handler(httpd_req_t *req)
(unsigned long)ls.stats.ready_timeouts,
(unsigned long)ls.stats.rx_overruns,
(unsigned long)ls.stats.resyncs,
(unsigned long)ls.stats.max_resp_latency_us);
(unsigned long)ls.stats.max_resp_latency_us,
(unsigned long)ls.stats.rsp_err_0x82,
(unsigned)ls.stats.rsp_err_last_req,
(unsigned)ls.stats.rsp_err_last_p0,
(unsigned)ls.stats.rsp_err_last_p1,
(unsigned)ls.stats.rsp_err_last_p2,
(unsigned)ls.stats.rsp_err_last_p3,
(unsigned long)ls.stats.ring_steals,
(unsigned)ls.stats.ring_steal_last_type,
(unsigned)ls.stats.ring_steal_last_ack,
(unsigned)ls.stats.ring_steal_last_p0,
(unsigned)ls.stats.ring_steal_last_req,
(unsigned)ls.stats.ring_steal_last_p1,
(unsigned)ls.stats.ring_steal_last_p2,
(unsigned)ls.stats.ring_steal_last_p3,
(unsigned long)ls.stats.tx_total,
(unsigned long)ls.stats.tx_retry,
(unsigned long)ls.stats.tx_fail,
(unsigned)ls.stats.tx_fail_last_type);
}
/* ── GD32 APP 初始化状态(GD32 1.5.34+)──
@@ -2510,11 +2573,21 @@ esp_err_t motor_ota_upload_handler(httpd_req_t *req)
coord.abortCurrent();
(void)coord.clearGd32Meta();
/* ── 流式收固件写 flash(无整固件堆缓冲;复用 OTA 静态收块)──
/* ── 流式收固件写 flash(无整固件堆缓冲;会话内临时收块)──
* ★ full_erase=true:强制擦除整个 gd32_ota 分区(252KB ~200ms),
* 杜绝上次固件残留 + 擦除位错误导致的单 bit 翻转(0xAC20 处 0x19→0x18 根因)。 */
Gd32FlashStore::instance().beginWriteSession(true);
OtaUploadRecv upload_recv;
if (!upload_recv.ok()) {
DEBUG_ERR("MOTOR_OTA", "收块分配失败 free=%lu",
(unsigned long)esp_get_free_heap_size());
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"error\":\"ota receive buffer alloc failed\"}");
return ESP_FAIL;
}
int received = 0;
while (received < content_len) {
size_t to_read = (size_t)content_len - received;
@@ -2522,7 +2595,7 @@ esp_err_t motor_ota_upload_handler(httpd_req_t *req)
otaUploadPaceIfHeapTight();
int r = httpd_req_recv(req, reinterpret_cast<char *>(s_ota_upload_recv), to_read);
int r = httpd_req_recv(req, reinterpret_cast<char *>(upload_recv.buf), to_read);
if (r <= 0) {
DEBUG_ERR("MOTOR_OTA", "upload interrupted: received=%d expected=%d", received, content_len);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "recv failed");
@@ -2530,7 +2603,7 @@ esp_err_t motor_ota_upload_handler(httpd_req_t *req)
}
esp_err_t werr = Gd32FlashStore::instance().flashWriteChunk(
(uint32_t)received, s_ota_upload_recv, (size_t)r);
(uint32_t)received, upload_recv.buf, (size_t)r);
if (werr != ESP_OK) {
DEBUG_ERR("MOTOR_OTA", "flash write fail @%d: %s", received, esp_err_to_name(werr));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "flash write failed");
+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 + 统计)。 */