From 84f496c22c1e3fc56b9403ac3fc0c0887da311ce Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Sun, 20 Sep 2026 22:58:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=200.5.0=20SPI=20=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E7=A8=B3=E5=81=A5=E6=80=A7=20+=20OTA=20=E5=86=85=E5=AD=98=20+?= =?UTF-8?q?=20=E7=8A=B6=E6=80=81=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 变更点: 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 --- .gitignore | 12 ++++ main/include/app_config.h | 3 +- main/include/version.h | 8 +-- main/monitor/can_feedback_task.cpp | 7 +- main/server/http_handlers.cpp | 105 ++++++++++++++++++++++++----- main/transport/canfd_bridge.cpp | 30 ++++++++- main/transport/spi_master.cpp | 99 +++++++++++++++++++++++---- main/transport/spi_master.h | 21 ++++++ 8 files changed, 246 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index db3c570..580e211 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/main/include/app_config.h b/main/include/app_config.h index e4566eb..eeea527 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -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 实际转发包载荷 diff --git a/main/include/version.h b/main/include/version.h index 8dc0451..86aa6ba 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -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" \ No newline at end of file +#define GIT_HASH "770ae14" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index 620e58a..b36c922 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -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) 暂停本任务(不查询不消费); diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index c2eb920..daa21b6 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #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(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(s_ota_upload_recv), to_read); + int r = httpd_req_recv(req, reinterpret_cast(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"); diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index 6691d6d..22deb33 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -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); diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index df7a238..154fe90 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -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; /* 不可达:循环必有返回 */ diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index b6d2cfe..99477ca 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -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 + 统计)。 */