feat(spi): 增强 SPI master:性能剖析与执行耗时跟踪

- 新增 `lastWorkerExecUs()`:获取最近一次 worker 任务执行耗时(µs)。
- 引入 `SpiProfile` 结构体:事务级详细剖析,记录 SPI 事务各阶段的时间戳。
- 实现 `lastProfile()`:诊断用,获取最近一次事务剖析快照。
- SPI 时钟配置提升至 13.3 MHz,改善性能。
- 修改前端 JavaScript 轮询逻辑:新增 uptime 检查,更好地处理 ESP32 重启状态。
- SPI 测速 HTML 的计时单位由毫秒改为微秒显示。
- 新建 `spi_ring_cache.h`:共享 SPI ring push 缓存工具。
- 新增 `http_util.h`:共享 HTTP 处理器工具,含 JSON 拼接与查询参数解析。
- 引入 `json_parse.h`:轻量级 JSON 解析器,针对扁平 JSON 对象设计。
- 新增 Windows 环境下的项目构建批处理脚本。
- 重构 CAN 反馈处理:从推送式改为请求驱动架构,降低内存占用、提升效率。
This commit is contained in:
小龙虾
2026-08-02 17:37:19 +08:00
parent 04a118219f
commit f9cbcc396b
42 changed files with 1957 additions and 4537 deletions
-1
View File
@@ -35,7 +35,6 @@ idf_component_register(
REQUIRES
transport # M5: 传输层组件(SPI/CANFD/协议)
app_update # ESP32 OTA API
cjson # JSON 解析/生成
esp_driver_tsens # 温度传感器驱动
esp_driver_uart # UART 驱动(调试日志)
esp_wifi # WiFi STA/AP 模式
+3 -1
View File
@@ -132,7 +132,9 @@ extern "C" void app_main(void)
SystemBoot::mark(BOOT_OTA_VALID);
}
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s, heap=%lu, wifi_early=%s",
/* ★ banner 文案必须包含「启动」,前端 polling.js 靠
* /OTA Relay .*启动/ 匹配本行判定 ESP32 重启完成(阶段 6)。 */
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s 启动, heap=%lu, wifi_early=%s",
FW_VERSION,
(unsigned long)esp_get_free_heap_size(),
wifi_early_started_sta ? "STA" : "AP/other");
+2 -2
View File
@@ -14,8 +14,8 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 74
#define FW_VERSION_STRING "0.3.74" // can batch: 帧间隔 5→1ms;前端 BATCH_CHUNK=64 + fetch 超时加固 + finally 兜底 renderHistory
#define FW_VERSION_PATCH 76
#define FW_VERSION_STRING "0.3.76" // 重启恢复提速2:httpd就绪后再打 MAIN「启动」banner(防环缩容裁掉);前端用 uptime<120s 判重启完成,不再干等10s
// Git 信息(手动填写)
#define GIT_HASH "770ae14"
+20 -97
View File
@@ -1,14 +1,11 @@
/*
* can_feedback_task.cpp - 被动监听 GD32 主动推送的 CAN 反馈快照
* can_feedback_task.cpp - 被动监听 GD32 主动推送的事件
*
* 架构(GD32 主动推送,ESP32 被动接收):
* GD32 APP 在主循环中调用 canFeedbackPushStep(),循环遍历 3 通道 × 2 页,
* 通过 requestEsp32Read(0xC1) 下拉 DATA_READY 主动按铃,将每页反馈 payload
* 推送给 ESP32。ESP32 本任务 SpiMaster::takeEvent() 阻塞等待按铃/事件,
* 读到一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再
* publish() 到 s_latest[channel] 供 Web 读取。
*
* 替代了原先 ESP32 1Hz 主动发 0xC1 轮询的逻辑,避免占用主线 SPI 带宽。
* 架构(2026-08 改造后):
* 0xC1 CAN 反馈已改为请求驱动(前端请求时 ESP32 主动向 GD32 查询),
* 不再被动缓存。本任务仅保留 0xC4/0xC5/0xC6/0xD2 事件分发:
* 0xC4 ADC 调试反馈、0xC5 多电机状态、0xC6 多电机版本、0xD2 电机 OTA 进度
* 这些仍由 GD32 主动推送,ESP32 被动接收。
*
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务,避免与 OTA 抢占 SPI。
*/
@@ -17,7 +14,6 @@
#include "freertos/semphr.h"
#include "app_config.h"
#include "transport/canfd_bridge.h"
#include "transport/spi_master.h"
#include "monitor/debug_log.h"
#include "monitor/can_feedback_task.h"
@@ -25,7 +21,6 @@
#include "monitor/motor_state.h"
#include "monitor/motor_version.h"
#include "monitor/motor_ota_relay.h"
#include "app_config.h"
#include "esp_system.h"
#include <string.h>
@@ -34,7 +29,6 @@ namespace {
constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态推送
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
@@ -42,44 +36,22 @@ constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay
constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
static can_feedback_result_t s_latest[CAN_CHANNEL_MAX];
static bool s_valid [CAN_CHANNEL_MAX];
static SemaphoreHandle_t s_mutex = nullptr;
static volatile bool s_paused = false;
/* 累积工作缓冲:跨 page 0/1 累积每通道的完整反馈 */
static can_feedback_result_t s_work [CAN_CHANNEL_MAX];
static uint8_t s_workIdx[CAN_CHANNEL_MAX];
static uint32_t s_lastLoggedError[CAN_CHANNEL_MAX];
static volatile bool s_paused = false;
/**
* @brief 将某通道完整反馈发布到 s_latest(线程安全)。
* @param ch 通道 0..2
* @param fb 解析后的快照
*/
static void publish(uint8_t ch, const can_feedback_result_t &fb)
{
if (s_mutex == nullptr) return;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_latest[ch] = fb;
s_valid[ch] = true;
xSemaphoreGive(s_mutex);
}
}
/**
* @brief can_fb 任务主循环:takeEvent 分发 0xC1 CAN / 0xC4 ADC。
* @brief can_fb 任务主循环:takeEvent 分发 0xC4/0xC5/0xC6/0xD2 推送事件。
* 0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),不再在此处理。
*/
static void taskLoop(void *)
{
DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)");
DEBUG_INFO(kTag, "事件监听任务启动 (0xC4 ADC / 0xC5 motor / 0xC6 ver / 0xD2 ota)");
/* 冷启动延后开始监听,错开上电初始化窗口 */
{
esp_reset_reason_t rr = esp_reset_reason();
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
constexpr uint32_t kCanFbDeferMs = 1500U;
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始 CAN 反馈监听",
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始事件监听",
(unsigned long)kCanFbDeferMs);
vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs));
}
@@ -126,39 +98,14 @@ static void taskLoop(void *)
continue;
}
if (ring_type != kSpiCanFeedbackType || ring_len < 28U) {
continue;
}
const uint8_t ch = ring_payload[0];
if (ch >= CAN_CHANNEL_MAX) {
continue;
}
/* 解析本页并累积到工作缓冲,page=0 会自动重置 */
if (canFdParseFeedbackPage(ring_payload, ring_len,
&s_work[ch], &s_workIdx[ch]) == 0) {
publish(ch, s_work[ch]);
/* 累计错误发生变化时才告警一次;P0/P1 携带相同统计,不能重复打印。 */
if (s_work[ch].stats.error_count != 0U
&& s_work[ch].stats.error_count != s_lastLoggedError[ch]) {
DEBUG_WARN(kTag,
"CH%u err=%lu TEC=%u REC=%u",
static_cast<unsigned>(ch),
static_cast<unsigned long>(s_work[ch].stats.error_count),
static_cast<unsigned>(s_work[ch].stats.tec),
static_cast<unsigned>(s_work[ch].stats.rec));
s_lastLoggedError[ch] = s_work[ch].stats.error_count;
}
}
/* 0xC1 已改为请求驱动,不再在此处理;未知类型静默跳过 */
}
}
} // namespace
/**
* @brief 启动 CAN 反馈被动监听任务(幂等)。
* @brief 启动事件监听任务(幂等)。
* @return true 表示任务已存在或创建成功。
*/
bool startCanFeedbackTask()
@@ -166,19 +113,6 @@ bool startCanFeedbackTask()
static bool s_started = false;
if (s_started) return true;
if (s_mutex == nullptr) {
s_mutex = xSemaphoreCreateMutex();
if (s_mutex == nullptr) {
DEBUG_ERR(kTag, "互斥量创建失败");
return false;
}
}
memset(s_latest, 0, sizeof(s_latest));
memset(s_lastLoggedError, 0, sizeof(s_lastLoggedError));
memset(s_valid, 0, sizeof(s_valid));
memset(s_work, 0, sizeof(s_work));
memset(s_workIdx, 0, sizeof(s_workIdx));
BaseType_t xRet = xTaskCreatePinnedToCore(
taskLoop,
"can_fb",
@@ -196,7 +130,7 @@ bool startCanFeedbackTask()
}
/**
* @brief 暂停/恢复 CAN(及同任务分发的 ADC)事件监听。
* @brief 暂停/恢复事件监听。
* @param pause true=OTA 期间暂停;false=恢复
*/
void pauseCanFeedbackTask(bool pause)
@@ -204,28 +138,17 @@ void pauseCanFeedbackTask(bool pause)
if (s_paused == pause) return; // 幂等:状态未变时不重复日志
s_paused = pause;
if (pause) {
DEBUG_DBG(kTag, "CAN 监听已暂停(OTA 进行中)");
DEBUG_DBG(kTag, "事件监听已暂停(OTA 进行中)");
} else {
DEBUG_DBG(kTag, "CAN 监听已恢复");
DEBUG_DBG(kTag, "事件监听已恢复");
}
}
/**
* @brief 拷贝某通道的最新快照(线程安全)。
* @return true 表示拿到一份有效数据。
* @brief 查询是否处于暂停(OTA / send_batch 期间为 true)。
* 暂停期间 HTTP handler 应跳过 0xC1 查询,避免与 OTA 抢占 SPI。
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out)
bool canFeedbackPaused()
{
if (channel >= CAN_CHANNEL_MAX || out == nullptr || s_mutex == nullptr) {
return false;
}
bool ok = false;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
if (s_valid[channel]) {
*out = s_latest[channel];
ok = true;
}
xSemaphoreGive(s_mutex);
}
return ok;
return s_paused;
}
+10 -16
View File
@@ -1,37 +1,31 @@
/*
* can_feedback_task.h - 被动接收 GD32 主动推送的 CAN 反馈快照
* can_feedback_task.h - 事件监听任务(0xC4/0xC5/0xC6/0xD2 推送)
*
* ESP32 侧不主动 1Hz 轮询 0xC1;由 GD32 按铃推送,本任务 takeEvent 接收。
* 2026-08 架构改造:0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),
* 不再被动缓存。本任务仅分发 GD32 主动推送的 ADC/电机状态/版本/OTA 进度事件。
*/
#ifndef CAN_FEEDBACK_TASK_H
#define CAN_FEEDBACK_TASK_H
#include <stdint.h>
#include "transport/canfd_bridge.h"
/** @brief 暂停/恢复 CAN 反馈监听(OTA 期间调用 pause=true)。 */
/** @brief 暂停/恢复事件监听(OTA 期间调用 pause=true)。 */
void pauseCanFeedbackTask(bool pause);
/** @brief 查询监听是否处于暂停(OTA / send_batch 期间为 true)。
* 请求驱动的 SPI 查询(canFdQueryFeedback)在暂停期间应跳过。 */
bool canFeedbackPaused();
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief 启动 CAN 反馈被动监听任务(幂等)。
* @brief 启动事件监听任务(幂等)。
* @return true 表示任务已存在或创建成功。
*/
bool startCanFeedbackTask();
/**
* @brief 拷贝某通道的最新反馈快照(线程安全)。
* @param channel 0/1/2。
* @param out 输出缓冲。
* @return true 表示拿到一份有效数据,false 表示通道无效或尚未拿到第一份。
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out);
#ifdef __cplusplus
}
#endif
#endif // CAN_FEEDBACK_TASK_H
#endif // CAN_FEEDBACK_TASK_H
+6 -2
View File
@@ -11,8 +11,12 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
/** 单次快照最多跟踪的任务数 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 32;
/** 单次快照最多跟踪的任务数。
* 系统常驻任务 ~14(app)+ ~5(IDF: tiT/wifi/nimble/hci/ble)= ~19,
* 按需短暂任务(ble_finish/restart/cancel/gd32_wait/ota_feed)不常驻。
* 20 覆盖常驻集 + 余量 1;偶发按需任务多出时 uxTaskGetSystemState 截断,
* 仅影响该拍 CPU 统计显示,不影响功能。原 32 浪费 ~1KB BSS。 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 20;
/** 采样周期(ms)— 窗口 sample_ms 应对齐此值 */
static constexpr uint32_t CPU_STATS_SAMPLE_MS = 1000;
+10 -2
View File
@@ -509,7 +509,12 @@ esp_err_t DebugLog::uartSend(const uint8_t *data, size_t len)
return ESP_ERR_INVALID_STATE;
}
const int ret = uart_write_bytes(UART_NUM_0, data, len);
/* 非阻塞直写 FIFO:满了丢弃(日志必进 ring,web /logs 仍完整)。
* 旧实现 uart_write_bytes 在 TX buffer=0 时同步阻塞到整行发完
* (115200 下 ~5-8ms/条),OTA 期间日志密集会把 ota_relay(prio 23)
* / spi_worker(prio 6) 阻塞数 ms~数十 ms——50ms ACK 窗口被拖爆,
* 表现为改短重试后超时重发频繁(线上 12/777)。 */
const int ret = uart_tx_chars(UART_NUM_0, (const char *)data, (int)len);
if (ret < 0) {
return ESP_FAIL;
}
@@ -646,7 +651,10 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len)
if (sock >= 0) {
const size_t send_len = len > 1024 ? 1024 : len;
const int ret = ::sendto(sock, data, send_len, 0,
/* MSG_DONTWAIT:非阻塞发送。lwIP 在 ARP 未解析/路由未就绪时
* sendto 会排队等待(可达 SO_SNDTIMEO 200ms),会阻塞打日志的
* 业务任务(OTA relay/worker)——日志转发必须尽力而为。 */
const int ret = ::sendto(sock, data, send_len, MSG_DONTWAIT,
(struct sockaddr *)&m_debugAddr, sizeof(m_debugAddr));
if (ret >= 0) {
udp_ret = ESP_OK;
+6 -42
View File
@@ -2,9 +2,7 @@
* gd32_adc_feedback.cpp - GD32 ADC 调试反馈解析与缓存
*/
#include "monitor/gd32_adc_feedback.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -17,29 +15,6 @@ static SemaphoreHandle_t s_mux = nullptr;
static gd32_adc_feedback_t s_latest = {};
static bool s_have = false;
/** @brief 读小端 u16。 */
static uint16_t rdU16Le(const uint8_t *p)
{
return static_cast<uint16_t>(p[0] | (static_cast<uint16_t>(p[1]) << 8));
}
/** @brief 读小端 u32。 */
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
/** @brief 懒创建快照互斥量。 */
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
/**
@@ -80,7 +55,7 @@ bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len)
fb.uptime_sec = uptime;
fb.recv_ms = esp_timer_get_time() / 1000;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return false;
}
@@ -103,7 +78,7 @@ bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out)
if (out == nullptr) {
return false;
}
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return false;
}
@@ -124,7 +99,7 @@ bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out)
*/
uint32_t gd32AdcFeedbackAgeMs(void)
{
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return UINT32_MAX;
}
@@ -135,16 +110,5 @@ uint32_t gd32AdcFeedbackAgeMs(void)
recv = s_latest.recv_ms;
xSemaphoreGive(s_mux);
}
if (!have || recv <= 0) {
return UINT32_MAX;
}
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv) {
return 0U;
}
const int64_t age = now - recv;
if (age > static_cast<int64_t>(UINT32_MAX)) {
return UINT32_MAX;
}
return static_cast<uint32_t>(age);
}
return ringCacheAgeMs(have, recv);
}
+27 -31
View File
@@ -29,6 +29,7 @@
#include "spi_master.h"
#include "esp_event.h"
#include "esp_netif_types.h"
#include "server/http_util.h"
// ============================================================
// heap hook:捕获"破新低时的最后一次 alloc"(最后一击,未必是主因)
@@ -399,8 +400,13 @@ void HealthMonitor::taskEntry(void *pvParameters)
* 必须在 maybeRecord 之前,否则 est 长期偏低会误触发 hook 快照。 */
hookResyncEstFree(free_heap);
// 任务栈指标:本周期只采集一次,堆低水位快照和栈水位巡检共用。
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
// 堆低水位快照:仅在"最低空闲"创新低时记录一条上下文(5s 检测足够)
self.maybeRecordHeapLowWater();
// 传入已采集的 metrics,避免 maybeRecordHeapLowWater 内部二次遍历任务表。
self.maybeRecordHeapLowWater(metrics, count);
if (free_heap < HEAP_WARNING_MIN) {
/* 堆告警节流:OTA 期间阈值更低、间隔更长 */
@@ -426,10 +432,8 @@ void HealthMonitor::taskEntry(void *pvParameters)
DEBUG_ERR("MON", "健康状态危险: %d", cur);
}
// 栈水位主动巡检:复用 /system/status 的任务表,低水位时 DEBUG_ERR 主动告警。
// 栈水位主动巡检:复用已采集的任务表,低水位时 DEBUG_ERR 主动告警。
// health_mon 任务不被 OTA 暂停,告警经 UDP/UART 直发,穿透 OTA 门控。
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
for (size_t i = 0; i < count && i < STACK_TASK_MAX; ++i) {
if (metrics[i].handle == NULL || metrics[i].stack_size_bytes == 0) {
continue;
@@ -604,23 +608,6 @@ health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi)
// ============================================================
// 堆低水位快照("最低空闲"创新低时记录上下文)
// ============================================================
namespace {
/** @brief snprintf 追加到 buf 的安全封装:截断但不越界,返回新 offset。 */
int appendJsonfSafe(char *buf, size_t cap, int offset, const char *fmt, ...)
{
if (offset < 0 || (size_t)offset >= cap) return offset;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap);
va_end(ap);
if (n < 0) return offset;
if ((size_t)n >= cap - (size_t)offset) {
// 被截断:vsnprintf 返回的是"本应写入"的长度,按截断后修正 offset
return (int)cap - 1;
}
return offset + n;
}
} // namespace
/**
* @brief 检测"内部堆最低空闲"是否创新低,是则覆盖写入唯一快照槽。
@@ -630,7 +617,7 @@ int appendJsonfSafe(char *buf, size_t cap, int offset, const char *fmt, ...)
* 所有外部读取(OtaCoordinator::getSnapshot / collectTaskStackMetrics / rssi / temp)
* 都先在锁外取,避免持锁期间嵌套调用其它模块的锁;持锁后只做字段拷贝。
*/
void HealthMonitor::maybeRecordHeapLowWater()
void HealthMonitor::maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics, size_t pre_count)
{
// 与 http_handlers.cpp 同口径:内部 DRAM 数据堆
static constexpr uint32_t CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
@@ -691,8 +678,17 @@ void HealthMonitor::maybeRecordHeapLowWater()
HeapLowTaskStack stacks[8];
uint8_t stack_count = 0;
{
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
// 优先复用巡检循环已采集的指标,避免二次遍历
TaskStackMetric local_metrics[STACK_TASK_MAX];
const TaskStackMetric *metrics;
size_t count;
if (pre_metrics && pre_count > 0) {
metrics = pre_metrics;
count = pre_count;
} else {
count = collectTaskStackMetrics(local_metrics, STACK_TASK_MAX);
metrics = local_metrics;
}
// 先全部采集
struct Item { char name[16]; uint32_t rem; };
Item items[STACK_TASK_MAX];
@@ -816,7 +812,7 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
LockGuard lock(m_mutex);
if (!m_heapLowWaterValid) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
const HeapLowWaterRecord &r = m_heapLowWater;
@@ -824,7 +820,7 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
* hook_confidence 告知前端置信度:hook 命中=est_low_watermark,未命中=none。
* 前端应据此区分 estimated 与 measured(free_heap/min_free_heap 为实测)。 */
const char *hook_confidence = r.hook_fired ? "est_low_watermark" : "none";
int pos = appendJsonfSafe(buf, cap, offset,
int pos = http_json_append(buf, cap, offset,
"{\"count\":1,\"records\":[{"
"\"ts_ms\":%llu,\"free_heap\":%lu,\"min_free_heap\":%lu,"
"\"largest_free_block\":%lu,\"sys_state\":%u,\"ota_channel\":%u,"
@@ -863,24 +859,24 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
(unsigned long)r.hook_fired,
(unsigned long)r.ip_event_count);
if (pos == offset) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
for (uint8_t k = 0; k < r.task_stack_count && k < 8; ++k) {
int after = appendJsonfSafe(buf, cap, pos,
int after = http_json_append(buf, cap, pos,
"%s{\"name\":\"%s\",\"remaining\":%lu}",
k == 0 ? "" : ",",
r.task_stacks[k].name,
(unsigned long)r.task_stacks[k].remaining);
if (after == pos) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
pos = after;
}
int closed = appendJsonfSafe(buf, cap, pos, "]}]}");
int closed = http_json_append(buf, cap, pos, "]}]}");
if (closed == pos) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
return closed;
}
+6 -1
View File
@@ -12,6 +12,7 @@
#include "driver/temperature_sensor.h"
#include "esp_timer.h"
#include "app_config.h"
#include "task_stack.h" // TaskStackMetric(maybeRecordHeapLowWater 可选参数)
// T9.3: 健康状态等级
typedef enum {
@@ -133,7 +134,11 @@ public:
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。 */
void maybeRecordHeapLowWater();
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。
* @param pre_metrics 巡检循环已采集的任务栈指标(避免二次遍历);null 时内部自采 */
void maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics = nullptr, size_t pre_count = 0);
/** @brief 将唯一快照序列化为 JSON:{"count":0|1,"records":[...]}。线程安全。 */
int dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const;
+109 -111
View File
@@ -35,6 +35,59 @@ static int64_t nowMs() {
return esp_timer_get_time() / 1000;
}
/* ── 共享辅助:终态判断 + QUERY SPI 事务 ── */
/** @brief GD32 侧已进终态(IDLE/FINISHED/ERROR),喂数据应停止。 */
static inline bool isTerminalState(motor_ota_state_t s)
{
return s == MOTOR_OTA_IDLE || s == MOTOR_OTA_FINISHED || s == MOTOR_OTA_ERROR;
}
/**
* @brief 发 SPI 0xD0 QUERY 查询 GD32 真实状态。
* @param out_state 非 null 时写入 GD32 返回的 state
* @param out_err 非 null 时写入 error_code
* @param update_status true 时把结果同步写回 s_status(持锁)
* @return true=QUERY 成功且 ACK 有有效数据;false=SPI 失败或无 ACK
*
* 消除 feedTask 中 4 处重复的 qcmd[1]={2} + transact + parse qp[0..5] 块。
*/
static bool queryMotorOtaStatus(motor_ota_state_t *out_state = nullptr,
uint8_t *out_err = nullptr,
bool update_status = false)
{
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) != ESP_OK
|| qt != kRspAck || ql < 1U) {
return false;
}
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
if (out_state) *out_state = qst;
if (out_err) *out_err = (ql >= 2U) ? qp[1] : 0U;
if (update_status && s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = (ql >= 2U) ? qp[1] : 0U;
if (ql >= 6U) {
s_status.packets_acked = static_cast<uint16_t>(qp[2])
| (static_cast<uint16_t>(qp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qp[4])
| (static_cast<uint16_t>(qp[5]) << 8);
}
if (ql >= 10U) {
s_status.fw_size = static_cast<uint32_t>(qp[6])
| (static_cast<uint32_t>(qp[7]) << 8)
| (static_cast<uint32_t>(qp[8]) << 16)
| (static_cast<uint32_t>(qp[9]) << 24);
}
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
return true;
}
/* ═══════════════════ 喂数据 task ═══════════════════ */
static void feedTask(void *arg)
@@ -87,8 +140,7 @@ static void feedTask(void *arg)
* (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。 */
const motor_ota_status_t st = motorOtaGetStatus();
if (nowMs() >= grace_end_ms) {
if (st.state == MOTOR_OTA_IDLE || st.state == MOTOR_OTA_FINISHED ||
st.state == MOTOR_OTA_ERROR) {
if (isTerminalState(st.state)) {
ESP_LOGW(TAG, "GD32 终态 0x%02X,停止喂数据 offset=%u/%u",
(int)st.state, (unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
@@ -127,36 +179,17 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
vTaskDelay(pdMS_TO_TICKS(10)); /* 快速重试(原 100ms 太慢导致吐量低) */
/* suppress 期间 s_status 不更新,连续失败时主动 QUERY GD32 真实状态,
* 避免 GD32 已进 ERROR 终态但 feedTask 不知道、盲重试空转到 120s 超时。 */
if (++consec_fail >= kQueryAfterFails) {
consec_fail = 0U;
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
&& qt == kRspAck && ql >= 1U) {
motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
if (qst == MOTOR_OTA_IDLE || qst == MOTOR_OTA_FINISHED ||
qst == MOTOR_OTA_ERROR) {
ESP_LOGW(TAG, "QUERY 检测到 GD32 终态 0x%02X err=0x%02X,停止喂数据 offset=%u/%u",
(int)qst, (unsigned)qp[1],
(unsigned)s_fw_offset, (unsigned)s_fw_size);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qp[1];
if (ql >= 6U) {
s_status.packets_acked = static_cast<uint16_t>(qp[2])
| (static_cast<uint16_t>(qp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qp[4])
| (static_cast<uint16_t>(qp[5]) << 8);
}
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
break;
}
}
}
if (++consec_fail >= kQueryAfterFails) {
consec_fail = 0U;
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, true) && isTerminalState(qst)) {
ESP_LOGW(TAG, "QUERY 检测到 GD32 终态 0x%02X err=0x%02X,停止喂数据 offset=%u/%u",
(int)qst, (unsigned)qerr,
(unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
}
}
continue; /* 不推进 offset,同 offset 重发 */
}
consec_fail = 0U;
@@ -177,13 +210,8 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
/* 握手未确认时定期 QUERY:确认 GD32 已过握手阶段才标 TRANSFER */
if (!handshake_confirmed && (ack_n & 7U) == 0U) {
uint8_t qcmd[1] = {2U};
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
&& qt == kRspAck && ql >= 1U) {
motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
motor_ota_state_t qst;
if (queryMotorOtaStatus(&qst, nullptr, false)) {
if (qst == MOTOR_OTA_ERASE || qst == MOTOR_OTA_TRANSFER ||
qst == MOTOR_OTA_FINISH_OTA || qst == MOTOR_OTA_WAIT_APP_RUN) {
handshake_confirmed = true;
@@ -216,16 +244,11 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
vTaskDelay(pdMS_TO_TICKS(2));
/* 连续 WAIT 超限:可能 GD32 auto-retry 重置了 s_fw_fed=0,
* 当前 offset 被 gap 拒收(永远不会变 ACK)。QUERY 确认真实状态。 */
if (++consec_wait >= kQueryAfterWaits) {
consec_wait = 0U;
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
&& qt == kRspAck && ql >= 2U) {
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
const uint8_t qerr = qp[1];
if (++consec_wait >= kQueryAfterWaits) {
consec_wait = 0U;
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, false)) {
if (qst != MOTOR_OTA_TRANSFER) {
/* GD32 不在传输态(auto-retry 回了 kPreCancel/握手):
* 重置 offset 从头喂,配合 GD32 重新擦除+握手。 */
@@ -237,21 +260,20 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
} else {
handshake_confirmed = true; /* GD32 在 transfer,确认握手完成 */
}
/* 终态仍 break */
if (qst == MOTOR_OTA_IDLE || qst == MOTOR_OTA_FINISHED ||
qst == MOTOR_OTA_ERROR) {
ESP_LOGW(TAG, "QUERY(WAIT) 终态 0x%02X err=0x%02X,停止",
(unsigned)qst, (unsigned)qerr);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qerr;
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
break;
}
}
}
/* 终态仍 break */
if (isTerminalState(qst)) {
ESP_LOGW(TAG, "QUERY(WAIT) 终态 0x%02X err=0x%02X,停止",
(unsigned)qst, (unsigned)qerr);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qerr;
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
break;
}
}
}
} else {
/* 非 ACK/WAIT(含 ERROR/陈旧 type):不 break 整次 OTA。
* 曾因 poll 读到 EVENT/错 type 直接 break → fed 卡首 chunk。 */
@@ -273,53 +295,29 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
const uint32_t exit_deadline = nowMs() + kExitPollMs;
bool got_terminal = false;
while (nowMs() < exit_deadline) {
/* 优先吃 ring 推送(已恢复 suppress) */
{
const motor_ota_status_t st = motorOtaGetStatus();
if (st.state == MOTOR_OTA_FINISHED || st.state == MOTOR_OTA_ERROR ||
st.state == MOTOR_OTA_IDLE) {
ESP_LOGI(TAG, "feedTask 退出,ring 终态: state=%u err=%u pkt=%u/%u",
(unsigned)st.state, (unsigned)st.error_code,
(unsigned)st.packets_acked, (unsigned)st.total_packets);
got_terminal = true;
break;
}
}
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qresp_type = 0;
uint8_t qresp[16] = {0};
size_t qresp_len = 0;
esp_err_t qr = SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1,
&qresp_type, qresp, sizeof(qresp), &qresp_len, 300U);
if (qr == ESP_OK && qresp_type == kRspAck && qresp_len >= 10U) {
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qresp[0]);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qresp[1];
s_status.packets_acked = static_cast<uint16_t>(qresp[2])
| (static_cast<uint16_t>(qresp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qresp[4])
| (static_cast<uint16_t>(qresp[5]) << 8);
s_status.fw_size = static_cast<uint32_t>(qresp[6])
| (static_cast<uint32_t>(qresp[7]) << 8)
| (static_cast<uint32_t>(qresp[8]) << 16)
| (static_cast<uint32_t>(qresp[9]) << 24);
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
if (qst == MOTOR_OTA_FINISHED || qst == MOTOR_OTA_ERROR ||
qst == MOTOR_OTA_IDLE) {
ESP_LOGI(TAG, "feedTask 退出,QUERY 终态: state=%u err=%u pkt=%u/%u",
(unsigned)qst, (unsigned)qresp[1],
(unsigned)(static_cast<uint16_t>(qresp[2])
| (static_cast<uint16_t>(qresp[3]) << 8)),
(unsigned)(static_cast<uint16_t>(qresp[4])
| (static_cast<uint16_t>(qresp[5]) << 8)));
got_terminal = true;
break;
}
}
/* 优先吃 ring 推送(已恢复 suppress) */
{
const motor_ota_status_t st = motorOtaGetStatus();
if (isTerminalState(st.state)) {
ESP_LOGI(TAG, "feedTask 退出,ring 终态: state=%u err=%u pkt=%u/%u",
(unsigned)st.state, (unsigned)st.error_code,
(unsigned)st.packets_acked, (unsigned)st.total_packets);
got_terminal = true;
break;
}
}
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, true)) {
if (isTerminalState(qst)) {
const motor_ota_status_t snap = motorOtaGetStatus();
ESP_LOGI(TAG, "feedTask 退出,QUERY 终态: state=%u err=%u pkt=%u/%u",
(unsigned)qst, (unsigned)qerr,
(unsigned)snap.packets_acked, (unsigned)snap.total_packets);
got_terminal = true;
break;
}
}
vTaskDelay(pdMS_TO_TICKS(kExitGapMs));
}
if (!got_terminal) {
+6 -29
View File
@@ -2,9 +2,7 @@
* motor_state.cpp - GD32 多电机状态推送解析与缓存 (SPI TYPE 0xC5)
*/
#include "monitor/motor_state.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -13,21 +11,6 @@ namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_state_table_t s_table = {};
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
bool onMotorStateEvent(const uint8_t *payload, size_t len)
@@ -51,7 +34,7 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
return false;
}
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
@@ -102,7 +85,7 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
bool getLatestMotorState(motor_state_table_t *out)
{
if (out == nullptr) return false;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
bool ok = false;
@@ -118,7 +101,7 @@ bool getLatestMotorState(motor_state_table_t *out)
uint32_t motorStateAgeMs(void)
{
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return UINT32_MAX;
int64_t recv = 0;
@@ -128,11 +111,5 @@ uint32_t motorStateAgeMs(void)
recv = s_table.recv_ms;
xSemaphoreGive(s_mux);
}
if (!have || recv <= 0) return UINT32_MAX;
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv) return 0U;
const int64_t age = now - recv;
return (age > static_cast<int64_t>(UINT32_MAX)) ? UINT32_MAX
: static_cast<uint32_t>(age);
}
return ringCacheAgeMs(have, recv);
}
+4 -21
View File
@@ -4,9 +4,7 @@
* 镜像 motor_state.cpp 的互斥保护缓存模型。
*/
#include "monitor/motor_version.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -15,21 +13,6 @@ namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_version_table_t s_table = {};
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
bool onMotorVersionEvent(const uint8_t *payload, size_t len)
@@ -40,7 +23,7 @@ bool onMotorVersionEvent(const uint8_t *payload, size_t len)
const uint8_t count = payload[0];
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
@@ -69,7 +52,7 @@ bool onMotorVersionEvent(const uint8_t *payload, size_t len)
bool getLatestMotorVersion(motor_version_table_t *out)
{
if (out == nullptr) return false;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
bool ok = false;
@@ -81,4 +64,4 @@ bool getLatestMotorVersion(motor_version_table_t *out)
xSemaphoreGive(s_mux);
}
return ok;
}
}
+62
View File
@@ -0,0 +1,62 @@
/*
* spi_ring_cache.h - SPI ring push 缓存共享辅助
*
* gd32_adc_feedback / motor_state / motor_version 三个模块共用相同的
* 互斥懒创建 + 小端读 + ageMs 计算样板,提取到此处消除三份拷贝。
*/
#ifndef SPI_RING_CACHE_H
#define SPI_RING_CACHE_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── 小端读 ── */
static inline uint16_t rdU16Le(const uint8_t *p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
static inline uint32_t rdU32Le(const uint8_t *p)
{
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
/* ── 互斥懒创建 ── */
static inline void ringCacheEnsureMux(SemaphoreHandle_t *mux)
{
if (*mux == nullptr) {
*mux = xSemaphoreCreateMutex();
}
}
/* ── 通用 ageMs ──
* 传入缓存的 have 标志 + recv_ms 时间戳,返回距 now 的毫秒数。
* 无数据或 recv_ms<=0 返回 UINT32_MAX。
*/
static inline uint32_t ringCacheAgeMs(bool have, int64_t recv_ms)
{
if (!have || recv_ms <= 0) return UINT32_MAX;
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv_ms) return 0U;
const int64_t age = now - recv_ms;
return (age > (int64_t)UINT32_MAX) ? UINT32_MAX : (uint32_t)age;
}
#ifdef __cplusplus
}
#endif
#endif // SPI_RING_CACHE_H
+68 -33
View File
@@ -26,6 +26,7 @@
#include "esp_timer.h"
#include "spi_master.h"
#include "http_handlers.h" /* http_set_cors_headers */
#include "server/http_util.h"
#include "monitor/debug_log.h"
namespace {
@@ -37,30 +38,14 @@ constexpr size_t kPayloadDefault = 236U; /* 满帧:等效 0xD1 电机 OTA
constexpr size_t kPayloadMax = 236U; /* APP kTxBufSize=256 - V2_OVERHEAD */
constexpr uint32_t kFrameTimeoutMs = 500U;
/** @brief 从 query 里取无符号整型参数,缺失/非法时返回默认值。 */
uint32_t queryU32(httpd_req_t *req, const char *key, uint32_t defval)
{
char qbuf[64];
char vbuf[16];
if (httpd_req_get_url_query_str(req, qbuf, sizeof(qbuf)) != ESP_OK) {
return defval;
}
if (httpd_query_key_value(qbuf, key, vbuf, sizeof(vbuf)) != ESP_OK) {
return defval;
}
char *end = nullptr;
const unsigned long v = strtoul(vbuf, &end, 10);
return (end && *end == '\0') ? (uint32_t)v : defval;
}
} // namespace
esp_err_t spi_speedtest_handler(httpd_req_t *req)
{
uint32_t frames = queryU32(req, "frames", kFramesDefault);
uint32_t frames = http_query_u32(req, "frames", kFramesDefault);
if (frames < 1U) { frames = 1U; }
if (frames > kFramesMax) { frames = kFramesMax; }
size_t payload_len = (size_t)queryU32(req, "payload", (uint32_t)kPayloadDefault);
size_t payload_len = (size_t)http_query_u32(req, "payload", (uint32_t)kPayloadDefault);
if (payload_len > kPayloadMax) { payload_len = kPayloadMax; }
/* 探针 payload:递增填充(CRC32 覆盖,顺带校验数据路径完整性) */
@@ -74,9 +59,18 @@ esp_err_t spi_speedtest_handler(httpd_req_t *req)
uint64_t rtt_sum_us = 0U;
uint64_t rtt_min_us = UINT64_MAX;
uint64_t rtt_max_us = 0U;
/* RTT 分布直方图:<2 / 2-3 / 3-4 / 4-5 / >=5 ms,用于判断抖动形态
* (双峰=离散 +1ms 碰撞;均匀=tick 相位量化) */
uint32_t histo[5] = {0U, 0U, 0U, 0U, 0U};
/* RTT 分布直方图(µs 计时,<2ms 区间细化为 0.5ms 粒度):
* <0.5 / 0.5-1 / 1-1.5 / 1.5-2 / 2-3 / 3-5 / >=5 ms,
* 用于判断抖动形态(<2ms 内的尖峰/双峰 vs 离散 +1ms 碰撞) */
uint32_t histo[7] = {0U, 0U, 0U, 0U, 0U, 0U, 0U};
/* 事务耗时(worker 内执行段,SpiMaster::lastWorkerExecUs)与
* 调度/唤醒开销(RTT − 事务)拆解:事务慢=GD32 应答/SPI 时序;
* 调度慢=ESP32 任务抢占(WiFi/esp_timer 等)。 */
uint64_t w_sum_us = 0U, w_min_us = UINT64_MAX, w_max_us = 0U;
uint64_t s_sum_us = 0U, s_min_us = UINT64_MAX, s_max_us = 0U;
/* 5 段细分(SpiMaster::SpiProfile):提交/调度、发送帧、等应答、读响应、唤醒 */
uint64_t seg_sum[5] = {0U, 0U, 0U, 0U, 0U};
uint64_t seg_max[5] = {0U, 0U, 0U, 0U, 0U};
DEBUG_INFO("SPI", "[SPEEDTEST] start frames=%u payload=%u",
(unsigned)frames, (unsigned)payload_len);
@@ -100,12 +94,34 @@ esp_err_t spi_speedtest_handler(httpd_req_t *req)
rtt_sum_us += rtt;
if (rtt < rtt_min_us) { rtt_min_us = rtt; }
if (rtt > rtt_max_us) { rtt_max_us = rtt; }
const uint32_t ms_bucket = (uint32_t)(rtt / 1000U);
if (ms_bucket < 2U) { ++histo[0]; }
else if (ms_bucket < 3U) { ++histo[1]; }
else if (ms_bucket < 4U) { ++histo[2]; }
else if (ms_bucket < 5U) { ++histo[3]; }
else { ++histo[4]; }
const uint64_t wus = SpiMaster::instance().lastWorkerExecUs();
const uint64_t sus = (rtt > wus) ? (rtt - wus) : 0U;
w_sum_us += wus;
if (wus < w_min_us) { w_min_us = wus; }
if (wus > w_max_us) { w_max_us = wus; }
s_sum_us += sus;
if (sus < s_min_us) { s_min_us = sus; }
if (sus > s_max_us) { s_max_us = sus; }
/* 5 段细分(防回绕钳 0) */
const SpiMaster::SpiProfile &pf = SpiMaster::instance().lastProfile();
const uint64_t seg[5] = {
(pf.t_w0 > pf.t_submit0) ? (pf.t_w0 - pf.t_submit0) : 0U,
(pf.t_send1 > pf.t_send0) ? (pf.t_send1 - pf.t_send0) : 0U,
(pf.t_wait1 > pf.t_wait0) ? (pf.t_wait1 - pf.t_wait0) : 0U,
(pf.t_read1 > pf.t_wait1) ? (pf.t_read1 - pf.t_wait1) : 0U,
(pf.t_submit1 > pf.t_w1) ? (pf.t_submit1 - pf.t_w1) : 0U,
};
for (int k = 0; k < 5; ++k) {
seg_sum[k] += seg[k];
if (seg[k] > seg_max[k]) { seg_max[k] = seg[k]; }
}
if (rtt < 500U) { ++histo[0]; }
else if (rtt < 1000U) { ++histo[1]; }
else if (rtt < 1500U) { ++histo[2]; }
else if (rtt < 2000U) { ++histo[3]; }
else if (rtt < 3000U) { ++histo[4]; }
else if (rtt < 5000U) { ++histo[5]; }
else { ++histo[6]; }
} else {
++n_fail;
}
@@ -120,25 +136,44 @@ esp_err_t spi_speedtest_handler(httpd_req_t *req)
tput_kbps = ((double)n_ok * (double)payload_len) / ((double)elapsed_us / 1000000.0) / 1024.0;
}
if (rtt_min_us == UINT64_MAX) { rtt_min_us = 0U; }
if (w_min_us == UINT64_MAX) { w_min_us = 0U; }
if (s_min_us == UINT64_MAX) { s_min_us = 0U; }
DEBUG_INFO("SPI", "[SPEEDTEST] done ok=%u fail=%u avg=%uus tput=%.1fKB/s",
(unsigned)n_ok, (unsigned)n_fail, (unsigned)rtt_avg_us, tput_kbps);
char text[480];
char text[640];
const int len = snprintf(text, sizeof(text),
"SPI 测速结果\n"
"帧数: %u 成功: %u 失败: %u%s\n"
"载荷: %u B/帧 总耗时: %u ms\n"
"往返延迟: 平均 %.1f ms 最快 %.1f ms 最慢 %.1f ms\n"
"延迟分布: <2ms:%u 2-3ms:%u 3-4ms:%u 4-5ms:%u >=5ms:%u\n"
"往返延迟: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"SPI 事务: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"调度开销: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"单帧分段 平均: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
"单帧分段 最慢: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
"延迟分布: <0.5ms:%u 0.5-1ms:%u 1-1.5ms:%u 1.5-2ms:%u 2-3ms:%u 3-5ms:%u >=5ms:%u\n"
"等效吞吐: %.1f KB/s\n",
(unsigned)frames, (unsigned)n_ok, (unsigned)n_fail,
(n_fail > 0U) ? " <-- 存在失败帧, 链路异常!" : "",
(unsigned)payload_len, (unsigned)elapsed_ms,
(double)rtt_avg_us / 1000.0, (double)rtt_min_us / 1000.0,
(double)rtt_max_us / 1000.0,
(unsigned long long)rtt_avg_us, (unsigned long long)rtt_min_us,
(unsigned long long)rtt_max_us,
(unsigned long long)(w_sum_us / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)w_min_us, (unsigned long long)w_max_us,
(unsigned long long)(s_sum_us / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)s_min_us, (unsigned long long)s_max_us,
(unsigned long long)(seg_sum[0] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[1] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[2] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[3] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[4] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)seg_max[0], (unsigned long long)seg_max[1],
(unsigned long long)seg_max[2], (unsigned long long)seg_max[3],
(unsigned long long)seg_max[4],
(unsigned)histo[0], (unsigned)histo[1], (unsigned)histo[2],
(unsigned)histo[3], (unsigned)histo[4], tput_kbps);
(unsigned)histo[3], (unsigned)histo[4],
(unsigned)histo[5], (unsigned)histo[6], tput_kbps);
http_set_cors_headers(req);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
+11
View File
@@ -19,6 +19,7 @@
#include "esp32_ota_core.h"
#include "app_config.h"
#include "monitor/debug_log.h"
#include "spi_master.h" /* setSpiPaused:OTA 期间暂停 SPI,避免 flash 写与 SPI 读并发 */
#define TAG "OTA_CORE"
@@ -110,6 +111,7 @@ esp_err_t Esp32OtaCore::finalizeCleanupAndReturn(esp_err_t code)
}
m_ended = false;
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 结束(失败也恢复 SPI) */
resetState();
return code;
}
@@ -201,6 +203,12 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
m_ended = false;
memset(m_sha256, 0, sizeof(m_sha256));
/* ESP32 自我 OTA 期间暂停 SPI(GD32 按铃读 + 业务 transact 全部停摆):
* esp_ota_write 写 flash 时 cache 反复失效,SPI worker 的 flash 驻留代码
* 访问会被拖慢甚至触发 Cache error,且此时 GD32 数据无意义。
* finalize()/abort() 恢复。 */
SpiMaster::instance().setSpiPaused(true);
DEBUG_DBG(TAG, "ESP32 OTA begin part=%s size=%u (erase-on-write)",
m_partition->label, (unsigned)total_size);
return ESP_OK;
@@ -382,11 +390,13 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 失败也恢复 SPI */
resetState();
return err;
}
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 收尾完成,恢复 SPI 业务 */
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received);
// 保持 m_active 直到重启或下次 begin;resetState 由 begin/abort 处理
@@ -413,6 +423,7 @@ void Esp32OtaCore::abort()
esp_ota_abort(m_handle);
}
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 中止,恢复 SPI 业务 */
resetState();
DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理");
}
+55 -64
View File
@@ -100,6 +100,24 @@ void OtaCoordinator::watchdogCheckLocked()
doAbortLocked();
}
esp_err_t OtaCoordinator::beginLockedWithChecks(const char *tag, bool &out_lock)
{
out_lock = lock();
if (!out_lock) return ESP_ERR_INVALID_STATE;
// ★ 卡死自愈检查
watchdogCheckLocked();
// ★ 电机 OTA 互斥:电机喂数期间禁止新的 GD32/ESP32 OTA
if (motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 %s", tag);
unlock(true);
out_lock = false;
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
// ============================================================
// 初始化
// ============================================================
@@ -138,24 +156,15 @@ void OtaCoordinator::init()
// ============================================================
esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t channel)
{
bool h = lock();
// ★ 先做卡死自愈检查:若之前忙状态卡死,自动转 ERROR 继续后续流程
if (h) watchdogCheckLocked();
// ★ 电机 OTA 互斥:电机喂数任务与 GD32 固件共用 gd32_ota 分区,
// 电机喂数期间禁止新的 GD32 存储,避免分区被并发覆写。
if (h && motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 GD32 存储");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
bool h;
esp_err_t pre = beginLockedWithChecks("GD32 存储", h);
if (pre != ESP_OK) return pre;
// ★ 同类型重启:若上一次 GD32 存储仍在进行中(STORING)且来自同一通道,
// 自动中止旧存储并重新开始。覆盖"上传过程中刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
// RELAYING 是中继阶段,不可中断,也拒绝。
if (h && m_sysState == OTA_SYS_GD32_STORING) {
if (m_sysState == OTA_SYS_GD32_STORING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "GD32存中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel);
@@ -169,33 +178,31 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
// 系统忙检查:仅拒绝真正"在传输"的活跃状态
// STORED / COMPLETE 是"待中继/已中继"的非活跃状态 → 允许新存储覆盖
if (h && (m_sysState == OTA_SYS_ESP32_RECEIVING ||
if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
m_sysState == OTA_SYS_ESP32_REBOOTING ||
m_sysState == OTA_SYS_GD32_RELAYING)) {
m_sysState == OTA_SYS_GD32_RELAYING) {
DEBUG_WARN(TAG, "系统忙 (state=%d),拒绝 GD32 存储请求", (int)m_sysState);
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// 如果之前已有存储但未中继(STORED)或刚中继完(COMPLETE),允许新存储覆盖。
// 注意:清理 NVS meta 不在此处做,避免与紧跟的 prepareGd32Storage 内
// clearGd32Meta + saveGd32Meta 重复一次写入,仅做状态注释说明。
if (h && (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE)) {
if (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE) {
DEBUG_INFO(TAG, "覆盖旧 GD32 存储状态,开始新存储 (size=%" PRIu32 ")", total_size);
}
if (h) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_GD32_STORING;
m_gd32StorageDone = false;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
DEBUG_DBG(TAG, "GD32存开始 %" PRIu32 "B →STORING ep=%u ch=%d",
total_size, m_otaEpoch, (int)channel);
}
m_otaEpoch++; // ★ 新会话开始,递增 epoch
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_GD32_STORING;
m_gd32StorageDone = false;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
DEBUG_DBG(TAG, "GD32存开始 %" PRIu32 "B →STORING ep=%u ch=%d",
total_size, m_otaEpoch, (int)channel);
// ★ 预分配 flashWriteChunk 对齐缓冲(OTA 期消除逐块 malloc;非 OTA 期零占用)
Gd32FlashStore::acquireAlignBufForOta();
unlock(h);
return h ? ESP_OK : ESP_ERR_INVALID_STATE;
return ESP_OK;
}
esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t channel)
@@ -416,23 +423,14 @@ void OtaCoordinator::notifyBleCoexEnd()
// ============================================================
esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t channel)
{
bool h = lock();
// ★ 卡死自愈检查
if (h) watchdogCheckLocked();
// ★ 电机 OTA 互斥:ESP32 自身 OTA 会重启设备,中断正在进行的电机喂数,
// 电机 OTA 期间拒绝 ESP32 OTA。
if (h && motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 ESP32 OTA");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
bool h;
esp_err_t pre = beginLockedWithChecks("ESP32 OTA", h);
if (pre != ESP_OK) return pre;
// ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道,
// 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "ESP32收中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel);
@@ -447,7 +445,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// ★ FINALIZING 残留自愈:SHA 不匹配时 core 已 abort,但旧固件可能未把协调器
// 复位到 IDLE。core 已空闲 ⇒ 协调器状态是假占用,允许清掉后开新会话。
// core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。
if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "FINALIZING残留(core空闲),强制复位");
doAbortLocked();
@@ -459,28 +457,25 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
}
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 重启中,均拒绝。
if (h && (m_sysState == OTA_SYS_GD32_STORING ||
m_sysState == OTA_SYS_GD32_RELAYING ||
m_sysState == OTA_SYS_ESP32_REBOOTING)) {
if (m_sysState == OTA_SYS_GD32_STORING ||
m_sysState == OTA_SYS_GD32_RELAYING ||
m_sysState == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "忙 state=%d(%s) 拒ESP32 OTA",
(int)m_sysState, stateToString(m_sysState));
unlock(h);
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = ESP_ERR_INVALID_STATE;
if (h) {
// 委托 Esp32OtaCore 处理分区/互斥逻辑
err = Esp32OtaCore::instance().begin(total_size);
if (err == ESP_OK) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch(使旧 handler 的 abortCurrentIfEpoch 失效)
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_ESP32_RECEIVING;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
pauseCanFeedbackTask(true);
} else {
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
}
// 委托 Esp32OtaCore 处理分区/互斥逻辑
esp_err_t err = Esp32OtaCore::instance().begin(total_size);
if (err == ESP_OK) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch(使旧 handler 的 abortCurrentIfEpoch 失效)
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_ESP32_RECEIVING;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
pauseCanFeedbackTask(true);
} else {
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
}
unlock(h);
@@ -622,9 +617,11 @@ void OtaCoordinator::doAbortLocked()
if (m_sysState == OTA_SYS_GD32_STORING) {
Gd32FlashStore::instance().metaClear();
Gd32FlashStore::releaseAlignBufForOta(); // 释放 OTA 期对齐缓冲
} else if (m_sysState == OTA_SYS_GD32_RELAYING) {
/* 中继中止必须 CANCEL 远端,否则 GD32 停在 WAITING_* 半会话 */
OtaRelay::instance().abortRemoteAndReset(true);
Gd32FlashStore::releaseAlignBufForOta(); // 中继阶段也可能写过 flash
} else if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING) {
Esp32OtaCore::instance().abort();
@@ -761,12 +758,6 @@ const char* OtaCoordinator::stateToString(ota_system_state_t s)
}
}
/** @brief 获取 GD32 APP 存活状态(委托 OtaRelay)。 */
bool OtaCoordinator::getGd32AppAlive() const
{
return OtaRelay::getGd32AppAlive();
}
// ============================================================
// OTA 状态快照
// ============================================================
+7 -6
View File
@@ -420,12 +420,6 @@ public:
bool isIdle() const;
// ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)──
/**
* @brief 获取 GD32 APP 存活状态。
* @return true 表示 GD32 APP 存活。
*/
bool getGd32AppAlive() const;
// ── 完全复位 ──
/**
* @brief 完全复位协调器和所有子系统状态。
@@ -479,6 +473,13 @@ private:
// 已持锁中止:清理子系统并将状态复位为 IDLE
void doAbortLocked();
/** @brief 统一 "lock + watchdog 自愈 + 电机 OTA 互斥拒绝" 前缀检查。
* beginGd32Storage / beginEsp32Ota 共用此逻辑。
* @param tag 日志标签(如 "GD32 存储" / "ESP32 OTA")
* @param out_lock 成功时输出锁句柄(调用方负责 unlock)
* @return ESP_OK=前缀通过可继续;ESP_ERR_INVALID_STATE=被拒绝(已 unlock) */
esp_err_t beginLockedWithChecks(const char *tag, bool &out_lock);
// ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。
// 供 notifyEsp32*Complete / notifyEsp32Rebooting 等锁内路径复用,避免重复样板代码。
void setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg);
+75 -71
View File
@@ -349,10 +349,14 @@ ota_result_t OtaRelay::handleTransferring(relay_event_t event, const uint8_t *pa
DEBUG_INFO("OTA", "=== 阶段3: 数据包传输 (%" PRIu32 " 包) ===", m_transferTotalPackets);
/* 转发预算:自本阶段起算。正常 ~15ms/包,预算 40ms/包 + 20s 底(上限 120s)。 */
constexpr uint32_t kXferMinMs = 20U * 1000U;
/* 转发预算:自本阶段起算。正常 ~2ms/包(12MHz+ 读预算收紧后实测),
* 单包失败重发最坏 ~95ms/轮 × 3 轮 ≈ 285ms(底层 3 次尝试 + relay 50ms 窗口)。
* 预算按"正常 + 重发余量":25ms/包 ≈ 正常 2ms + 约 10% 包重发 1 次的平均,
* 底 10s 覆盖慢启动/握手余量(777 包正常仅 ~1.6s);上限 120s 兜极端慢速。
* 跨包卡死(10s 无成功帧)由 kStallTimeoutMs 单独检测,总预算不重复兜底。 */
constexpr uint32_t kXferMinMs = 10U * 1000U;
constexpr uint32_t kXferMaxMs = 120U * 1000U;
constexpr uint32_t kXferPerPktMs = 40U;
constexpr uint32_t kXferPerPktMs = 25U;
uint32_t xfer_ms = kXferMinMs + m_transferTotalPackets * kXferPerPktMs;
if (xfer_ms > kXferMaxMs) {
xfer_ms = kXferMaxMs;
@@ -448,9 +452,6 @@ ota_result_t OtaRelay::forwardFirmware()
DEBUG_INFO("OTA", "开始固件转发: %" PRIu32 " 字节", meta.total_size);
/* 中继全程禁止 worker 把 OTA ACK/WAIT 当 EVENT 偷读 */
SpiMaster::instance().setSuppressRingService(true);
/* 完整性门控(避免与 /ota/relay·BLE 触发前的 verifyGd32Firmware 二次全盘 SHA):
* - meta 必须完整 stored≥total
* - checksum 非全零:触发路径已 finalize+比对,此处信任 meta,跳过再扫 Flash
@@ -500,10 +501,8 @@ ota_result_t OtaRelay::forwardFirmware()
DEBUG_ERR("OTA", "中继失败: result=%d, state=%d", (int)res, (int)getState());
/* 必须通知 GD32 CANCEL,否则远端停在 WAITING_* 半会话 */
abortRemoteAndReset(true);
} else {
/* 成功路径:解除按铃抑制(reset 也会做,成功时状态机可能停在 DONE) */
SpiMaster::instance().setSuppressRingService(false);
}
/* 成功/失败统一由 reset() 解除 ring 抑制(relay task 侧持有 ref) */
return res;
}
@@ -1174,7 +1173,10 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
* - 等 ACK 期间可预组下一包(与从机处理重叠)。
*/
constexpr uint32_t kMaxDataSends = 3U;
constexpr uint32_t kAckWaitMs = 6000U; /* 单次发送后等 ACK 上限(覆盖 GD32 增量 drain) */
/* 单次发送后等 ACK 上限:GD32 正常 ms 级回 ACK(ISR 回 WAIT、主循环回 ACK),
* 50ms 未到即重发(DUP-SEQ 回放捞回),不再干等 6s(用户指定 50ms)。
* credit==0 的排空积压由 transact 内部 credit 背压消化,此处无需长等。 */
constexpr uint32_t kAckWaitMs = 50U;
constexpr uint32_t kPollSliceMs = 50U; /* 略紧:DATA_READY 驱动,缩短空切片 */
TickType_t pkt_start = xTaskGetTickCount();
@@ -1204,9 +1206,11 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
{
resp_len = 0;
resp_type = 0;
/* 等首响应上限 10ms:一次 SPI 事务最多 5ms(用户指定),
* 超时立即进入下方 50ms poll 窗口,不再干等 200ms。 */
esp_err_t err = SpiMaster::instance().transact(
SPI_OTA_CMD_DATA, tx_buf, 64,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1200);
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 10);
logRespPayload("DATA_PACKET", resp_type, resp_payload, resp_len);
if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) {
saw_wait = true;
@@ -1412,6 +1416,58 @@ bool OtaRelay::probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime)
return false;
}
/**
* @brief 统一 "HELLO(role=APP) → 可选 PING 探测" 循环(doWaitForAppAlive + taskEntry 共用)。
*
* 两处原循环差异仅:taskEntry 在 HELLO 已带身份(gd32IdKnown)时跳过 PING 直接成功。
* 用 allow_hello_shortcut 参数统一:true 时启用 HELLO 身份快路径。
*
* @param timeout_ms PING 超时
* @param ping_interval_ms 每轮间隔
* @param max_attempts 最大轮数
* @param allow_hello_shortcut true=HELLO 带身份即成功(taskEntry 场景)
* @return true=APP 存活已确认
*/
bool OtaRelay::helloAndProbeApp(uint32_t timeout_ms, uint32_t ping_interval_ms,
uint32_t max_attempts, bool allow_hello_shortcut)
{
bool session_ok = false;
for (uint32_t attempt = 0; attempt < max_attempts; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(ping_interval_ms));
if (!session_ok) {
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK &&
SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_ok = true;
/* HELLO 已带身份时直接缓存版本/开关,无需再等 PING 才亮版本 */
if (allow_hello_shortcut && SpiMaster::instance().gd32IdKnown()) {
StateLockGuard lock;
gd32Version = SpiMaster::instance().gd32VersionCached();
gd32AppAlive = true;
DEBUG_INFO("OTA", "HELLO就绪 APP=v%d.%d.%d.%d",
gd32Version.app_major, gd32Version.app_minor,
gd32Version.app_patch, gd32Version.app_build);
DEBUG_INFO("OTA", "HELLO就绪 BOOT=v%d.%d.%d.%d",
gd32Version.boot_major, gd32Version.boot_minor,
gd32Version.boot_patch, gd32Version.boot_build);
return true;
}
} else {
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/%u)", attempt + 1U, (unsigned)max_attempts);
continue;
}
}
/* HELLO 成功但无身份(旧固件),或 doWaitForAppAlive 路径:回退 APP PING */
if (probeAndCacheAppAlive(timeout_ms)) {
return true;
}
/* PING 失败:清 session,下轮重 HELLO(避免 sticky 假会话) */
session_ok = false;
DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/%u)", attempt + 1U, (unsigned)max_attempts);
}
return false;
}
/** @brief 协议子步骤:发送 FINISH_OTA 指令并等待 GD32 ACK(10 次重试)。 */
ota_result_t OtaRelay::doSendFinishOta()
{
@@ -1485,30 +1541,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms)
/* FINISH 后短暂安静,避免 jump/APP SPI arm 窗口被 HELLO 打乱(对齐既有 300ms 节拍)。 */
vTaskDelay(pdMS_TO_TICKS(500));
/* APP 起机约数秒;HELLO 须 role=APP 再建 session,再 appPing。 */
bool session_established = false;
for (uint32_t attempt = 0; attempt < 20; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(300));
if (!session_established) {
esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_APP);
if (hr == ESP_OK && SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_established = true;
} else {
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1);
continue;
}
}
if (probeAndCacheAppAlive(timeout_ms)) {
break;
}
/* PING 失败:清 session,下轮重 HELLO(避免 sticky 假会话) */
session_established = false;
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1);
}
if (!getGd32AppAlive()) {
/* doWaitForAppAlive 不启用 HELLO 身份快路径:OTA 完成后必须 PING 确认 APP 真正运行 */
if (!helloAndProbeApp(timeout_ms, 300U, 20U, /*allow_hello_shortcut=*/false)) {
DEBUG_ERR("OTA", "GD32 APP 启动确认超时,OTA 失败");
/* APP 未起来时 CANCEL 可能无效,仍尽力清理 BOOT 半会话 */
abortRemoteAndReset(true);
@@ -1562,42 +1596,7 @@ void OtaRelay::taskEntry(void *pvParameters)
/* 启动 GD32 探测:先 hello 建 v2 session,再 appPing。 */
{
DEBUG_INFO("OTA", "启动初始 GD32 探测 (hello+probe)...");
bool session_ok = false;
bool alive = false;
for (uint32_t attempt = 0; attempt < 20U; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(300));
if (!session_ok) {
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK &&
SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_ok = true;
/* HELLO 已带身份时直接缓存版本/开关,无需再等 PING 才亮版本 */
if (SpiMaster::instance().gd32IdKnown()) {
StateLockGuard lock;
gd32Version = SpiMaster::instance().gd32VersionCached();
gd32AppAlive = true;
alive = true;
DEBUG_INFO("OTA", "HELLO就绪 APP=v%d.%d.%d.%d",
gd32Version.app_major, gd32Version.app_minor,
gd32Version.app_patch, gd32Version.app_build);
DEBUG_INFO("OTA", "HELLO就绪 BOOT=v%d.%d.%d.%d",
gd32Version.boot_major, gd32Version.boot_minor,
gd32Version.boot_patch, gd32Version.boot_build);
break;
}
} else {
DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U);
continue;
}
}
/* 旧固件无 HELLO 身份:回退 APP PING */
if (probeAndCacheAppAlive(2000)) {
alive = true;
break;
}
session_ok = false;
DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/20)", attempt + 1U);
}
if (!alive) {
if (!helloAndProbeApp(2000U, 300U, 20U, /*allow_hello_shortcut=*/true)) {
DEBUG_WARN("OTA", "启动 GD32 探测未成功(中继前仍可再 hello)");
}
}
@@ -1610,6 +1609,11 @@ void OtaRelay::taskEntry(void *pvParameters)
}
DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg);
/* 尽早抑制 ring service:onRelayStarted 可能因协调器锁(/system/status
* 等争锁)阻塞,此窗口内 worker 会把上一阶段残留 RESPONSE 当 EVENT 偷读
* (线上 log#1:TRANSFERRING 起始瞬间 serviceRing 读到 type=0x80)。
* forwardFirmware 不再重复设置;释放统一走 reset()。 */
SpiMaster::instance().setSuppressRingService(true);
// 通过 IRelayListener 接口回调通知协调器
IRelayListener *listener = getRelayListener();
if (listener) listener->onRelayStarted();
+13 -40
View File
@@ -118,16 +118,6 @@ public:
*/
ota_result_t forwardFirmware();
// ── 事件驱动接口 ──
/**
* @brief 向状态机投递事件,驱动状态转换.
* @param event 事件类型.
* @param payload 关联数据(响应载荷等),可为nullptr.
* @param len 数据长度.
* @return 当前操作结果码(OTA_RESULT_OK 表示事件已处理且可继续).
*/
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
// ── 状态查询(线程安全)──
/**
* @brief 获取当前中继状态.
@@ -145,31 +135,6 @@ public:
* @return 中继进度快照。
*/
static RelaySnapshot getSnapshot();
/**
* @brief 设置中继状态.
* @param s 目标状态。
*/
static void setState(relay_state_t s);
/**
* @brief 设置中继当前序号.
* @param seq 序号值。
*/
static void setRelaySeq(uint32_t seq);
/**
* @brief 设置中继总包数.
* @param total 总包数。
*/
static void setRelayTotalPackets(uint32_t total);
/**
* @brief 设置GD32 APP存活标志位.
* @param alive true 表示存活。
*/
static void setGd32AppAlive(bool alive);
/**
* @brief 设置GD32固件版本.
* @param ver 版本信息结构体引用。
*/
static void setGd32Version(const gd32_version_t &ver);
/**
* @brief 获取 OTA 中继任务句柄。
* @return FreeRTOS 任务句柄。
@@ -195,11 +160,6 @@ public:
* @param listener 监听器指针,传 nullptr 取消注册。
*/
static void setRelayListener(IRelayListener *listener);
/**
* @brief 获取当前注册的监听器。
* @return 监听器指针,未注册时为 nullptr。
*/
static IRelayListener* getRelayListener();
// ── 队列访问器 ──
/**
@@ -223,6 +183,19 @@ public:
*/
static bool probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime = nullptr);
/** @brief 统一 "HELLO(role=APP) → PING 探测" 循环(doWaitForAppAlive + taskEntry 共用)。 */
static bool helloAndProbeApp(uint32_t timeout_ms, uint32_t ping_interval_ms,
uint32_t max_attempts, bool allow_hello_shortcut);
// ── 内部状态写入(仅 ota_relay.cpp 使用,无外部调用)──
static void setState(relay_state_t s);
static void setRelaySeq(uint32_t seq);
static void setRelayTotalPackets(uint32_t total);
static void setGd32AppAlive(bool alive);
static void setGd32Version(const gd32_version_t &ver);
static IRelayListener* getRelayListener();
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
// ── 线程安全锁 ──
static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用)
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间
+5 -1
View File
@@ -11,7 +11,11 @@ void drainOtaDataQueue()
{
BleOtaDataBatch stale;
while (s_otaDataQueue && xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
if (stale.data) free(stale.data);
if (stale.pool_idx >= 0) {
ble_ota_pool_release(stale.pool_idx); // 静态池槽:归还
} else if (stale.data) {
free(stale.data); // heap 回退路径:释放
}
}
}
+163 -14
View File
@@ -54,6 +54,132 @@ ota_meta_t s_ble_gd32_meta = {};
bool s_ble_gd32_meta_active = false;
bool s_conn_fallback_requested = false;
// ============================================================
// DATA_IN 缓冲池(2026-08 内存优化)
//
// 背景:原 DATA_IN 路径每包 malloc(recv_len) + worker free(item.data),
// BLE 高速下发时高频堆抖动制造碎片,挤掉 lwip pbuf 连续块导致网络断连。
//
// 策略:OTA begin 时一次性 calloc 池(连续大块,比高频 per-pack malloc
// 碎片小得多),DATA_IN 借槽拷贝 → 入队 → worker 处理后归还;
// OTA end/abort/断连时 free 整个池。
// - 非 OTA 期间:池指针为 NULL,零占用,堆基线不受影响。
// - OTA 期间:借/还 O(1) 标志位操作,无堆抖动。
// - 池满/包过大回退 malloc + 负 ACK(保留原语义,避免功能回退)。
//
// 池容量 8 槽 × 512B = 4KB(2026-08-01 修正):
// 原 8×4096B=32KB 使 OTA 全程多占 28KB 堆,刷写 1MB 固件收尾时
// (0xF8 后需创建 finish task + image verify)直接 ESP_ERR_NO_MEM。
// 单次 ATT 写入上限 = MTU 512B(BLE_MTU_SIZE),常态包 ≤512B 全走池;
// NimBLE 合并的大 batch(≤4096B,经 BLE_OTA_BUF_SIZE 截断)回退
// malloc,与无池时代行为一致。4KB 固定占用对收尾任务足够安全。
// ============================================================
struct BleDataPool {
static constexpr size_t kSlots = 8;
static constexpr size_t kSlotSize = 512; // 单次 ATT 写入上限(MTU);大 batch 回退 malloc
uint8_t *buf[kSlots]; // kSlots 个独立槽(calloc 一次分配 kSlots*kSlotSize 后切分)
volatile bool in_use[kSlots];
};
static BleDataPool *s_data_pool = nullptr;
// 前向声明(poolAlloc 与 poolFree 互相引用)
static void poolFree();
/** @brief OTA begin 时分配池。失败返回 false(调用方回退纯 malloc 路径)。
* 安全性:上次会话遗留的池仅当全部槽空闲才复用。若 worker 仍持有在途
* batch(in_use=true),强制复位复用会让新会话 DATA_IN 覆写旧 worker
* 正在解析的内存 → flash 写坏 → 收尾 SHA/verify 失败。此时本会话回退
* malloc,旧池留待 worker 归还后由下次 poolFree 释放。 */
static bool poolAlloc()
{
if (s_data_pool) {
// 上次会话遗留池:检查是否全部空闲(in_use 仅由 worker/队列消费侧复位)
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (s_data_pool->in_use[i]) {
DEBUG_WARN(TAG, "DATA_IN 池槽仍被占用,本会话回退 malloc");
return false;
}
}
return true; // 全部空闲:安全复用
}
s_data_pool = (BleDataPool *)calloc(1, sizeof(BleDataPool));
if (!s_data_pool) return false;
// 一次性分配所有槽的负载数据(连续大块,碎片友好)
uint8_t *blob = (uint8_t *)calloc(BleDataPool::kSlots, BleDataPool::kSlotSize);
if (!blob) {
free(s_data_pool);
s_data_pool = nullptr;
return false;
}
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
s_data_pool->buf[i] = blob + i * BleDataPool::kSlotSize;
s_data_pool->in_use[i] = false;
}
return true;
}
/** @brief OTA end/abort/断连时释放整个池。
* 安全性:仅当所有槽均空闲时才释放;若 worker 仍有 in-flight batch
* 占用槽(in_use=true),跳过本次释放,留待下次 poolFree 全部空闲时
* 再释放。这避免了 worker 访问已释放内存的竞态(单核抢占:
* session_reset 运行在 NimBLE host task,worker 是独立任务,二者仅
* 通过队列交接,无显式同步)。 */
static void poolFree()
{
if (!s_data_pool) return;
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (s_data_pool->in_use[i]) {
// 有槽仍被 worker 占用,跳过释放(下次 poolFree 再试)
DEBUG_DBG(TAG, "DATA_IN 池延迟释放: 槽%u in_use", (unsigned)i);
return;
}
}
// buf[0] 是连续 blob 的起始(一次 calloc 分配)
if (s_data_pool->buf[0]) {
free(s_data_pool->buf[0]);
}
free(s_data_pool);
s_data_pool = nullptr;
}
/** @brief 从池借一个槽,返回索引;无空闲或池未分配返回 -1。仅 DATA_IN(host task)调用。 */
static int poolAcquire()
{
if (!s_data_pool) return -1;
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (!s_data_pool->in_use[i]) {
s_data_pool->in_use[i] = true;
return (int)i;
}
}
return -1;
}
/** @brief 归还槽到池。仅 worker / drain(队列消费侧)调用。 */
static void poolRelease(int idx)
{
if (!s_data_pool) return;
if (idx >= 0 && idx < (int)BleDataPool::kSlots) {
s_data_pool->in_use[idx] = false;
}
}
/** @brief 借槽拷贝数据;返回槽索引或 -1(池满/未分配/包过大)。 */
static int poolCopyIn(const uint8_t *src, size_t len)
{
if (!s_data_pool || len > BleDataPool::kSlotSize) return -1;
int idx = poolAcquire();
if (idx < 0) return -1;
memcpy(s_data_pool->buf[idx], src, len);
return idx;
}
/** @brief 归还池槽(跨 TU 调用入口:drainOtaDataQueue / 会话清理)。 */
void ble_ota_pool_release(int idx)
{
poolRelease(idx);
}
// ============================================================
// DATA_IN 丢包负反馈:把 malloc 失败 / 队列满从"静默丢包"变成
// "主动发缺口 ACK 让前端按 completed_bytes 续传退避"。
@@ -394,6 +520,11 @@ esp_err_t ble_esp32_ota_begin(size_t total_size)
BleOta::instance().m_esp32LastSeq = 0xFFFFU;
// ★ 分配 reorder buffer(失败则自动回退丢弃策略)
esp32_reorder_alloc();
// ★ 分配 DATA_IN 缓冲池(失败则 DATA_IN 回退 per-pack malloc 路径)
if (!poolAlloc()) {
DEBUG_WARN(TAG, "DATA_IN 池分配失败,回退 malloc free=%u",
(unsigned)esp_get_free_heap_size());
}
// ★ BLE OTA 开始:降低 WiFi 射频占用
ble_ota_boost_wifi_ps();
/* 刷写前再要一次高速连接参数(连接时若未谈妥,此处补刀) */
@@ -439,6 +570,8 @@ void ble_ota_session_reset()
BleOta::instance().m_esp32RebootPending = false;
// 释放 ESP32 reorder buffer(无 buffer 时为空操作)
esp32_reorder_free();
// 释放 DATA_IN 缓冲池(非 OTA 期间零占用,恢复堆基线)
poolFree();
// 复位 GD32 路径乱序包计数器(节流 ACK 计数),保证下次 GD32 上传从干净状态开始
s_gd32_ooo_count = 0;
s_ble_gd32_meta = {};
@@ -697,28 +830,41 @@ int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
// 将数据拷贝到堆上并送入工作队列,避免在 NimBLE host task 中执行
// 将数据拷贝到队列缓冲并送入工作线程,避免在 NimBLE host task 中执行
// 耗时的 flash 操作(esp_ota_begin / esp_ota_write)导致 BLE 连接断开。
// ★ 优先走静态池(消除高频 malloc/free 堆抖动);池满回退 malloc + 负 ACK。
if (s_otaDataQueue) {
// 解析 cmd 字节供负 ACK 区分 ESP32/GD32 续传分支(与 worker 解析一致)
uint8_t cmd = parse_cmd_from_batch(buf, (size_t)recv_len);
uint8_t *heap_buf = (uint8_t *)malloc((size_t)recv_len);
if (heap_buf) {
memcpy(heap_buf, buf, (size_t)recv_len);
BleOtaDataBatch item = { heap_buf, (size_t)recv_len };
int pool_idx = poolCopyIn(buf, (size_t)recv_len);
if (pool_idx >= 0) {
BleOtaDataBatch item = { s_data_pool->buf[pool_idx], (size_t)recv_len, pool_idx };
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) {
// 队列满:发缺口 ACK 让前端按 completed_bytes 续传退避,
// 队列满:归还池槽 + 发缺口 ACK 让前端按 completed_bytes 续传退避,
// 而非等满 8s 窗口超时后整窗口重发 → 重传风暴叠加低堆二次失败
s_ota_drop_queue_full += 1;
poolRelease(pool_idx);
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
free(heap_buf);
sendNegativeAckIfDue(cmd);
}
} else {
// malloc 失败:同上,发缺口 ACK 形成可恢复协议反馈,避免静默丢包
s_ota_drop_malloc_fail += 1;
DEBUG_ERR(TAG, "DATA_IN 堆内存不足 (%d 字节),丢弃", recv_len);
sendNegativeAckIfDue(cmd);
// 池满或包过大:回退 malloc(保留原负 ACK 语义,避免静默丢包)
uint8_t *heap_buf = (uint8_t *)malloc((size_t)recv_len);
if (heap_buf) {
memcpy(heap_buf, buf, (size_t)recv_len);
BleOtaDataBatch item = { heap_buf, (size_t)recv_len, -1 };
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) {
s_ota_drop_queue_full += 1;
free(heap_buf);
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
sendNegativeAckIfDue(cmd);
}
} else {
// malloc 失败:同上,发缺口 ACK 形成可恢复协议反馈,避免静默丢包
s_ota_drop_malloc_fail += 1;
DEBUG_ERR(TAG, "DATA_IN 堆内存不足 (%d 字节),丢弃", recv_len);
sendNegativeAckIfDue(cmd);
}
}
}
@@ -1096,7 +1242,8 @@ void ble_ota_data_worker_task(void *pvParameters)
BleOtaDataBatch item;
if (xQueueReceive(s_otaDataQueue, &item, portMAX_DELAY) == pdTRUE) {
if (!item.data || item.len == 0) {
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
continue;
}
@@ -1104,12 +1251,14 @@ void ble_ota_data_worker_task(void *pvParameters)
if (!BleOta::instance().isConnected() ||
BleOta::instance().isSuspendedForWifiOta()) {
DEBUG_DBG(TAG, "BLE不可用 丢%zuB队列", item.len);
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
continue;
}
ble_ota_process_data_batch(item.data, item.len);
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
}
}
}
+2 -1
View File
@@ -227,7 +227,8 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
BleOtaDataBatch stale;
size_t dropped = 0;
while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
free(stale.data);
if (stale.pool_idx >= 0) ble_ota_pool_release(stale.pool_idx);
else free(stale.data);
dropped++;
}
if (dropped > 0) {
+3
View File
@@ -61,6 +61,7 @@
struct BleOtaDataBatch {
uint8_t *data;
size_t len;
int pool_idx; ///< 静态池槽索引(>=0 为池内,<0 为 heap malloc,需 free)
};
struct BleGd32RelayWaitCtx {
@@ -87,6 +88,8 @@ extern bool s_wifi_ps_boosted;
extern bool s_conn_fallback_requested;
void drainOtaDataQueue();
/** @brief 归还 DATA_IN 静态池槽(drainOtaDataQueue / 会话清理跨 TU 调用)。 */
void ble_ota_pool_release(int idx);
void ble_ota_start_advertising(void);
void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
void sendFinishEvent(uint8_t stage, uint32_t value);
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
/*
* http_util.h - HTTP handler 共享工具(JSON 追加、query 解析、hex 编解码)
*
* 消除 http_handlers.cpp / health_monitor.cpp / spi_speed_test.cpp 中的重复样板。
*/
#ifndef HTTP_UTIL_H
#define HTTP_UTIL_H
#include <stddef.h>
#include <stdint.h>
#include <stdarg.h>
#include <stdbool.h>
#include "esp_http_server.h"
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── JSON buffer 追加 ──────────────────────────────────────────
* 统一替代 feedback_append / append_jsonf / appendJsonfSafe 三份拷贝。
* 截断时不越界,返回新 offset;出错返回 -1 或原 offset(视调用方约定)。
* 返回 (int)cap-1 表示已截断填满。 */
static inline int http_json_append(char *buf, size_t cap, int offset, const char *fmt, ...)
{
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) return offset;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap);
va_end(ap);
if (n < 0) return offset;
if ((size_t)n >= cap - (size_t)offset) return (int)cap - 1; /* 截断 */
return offset + n;
}
/* ── Query 参数解析 ────────────────────────────────────────────
* 统一替代 6 处内联 ?lite=1 / ?bin=1 / ?ch=N 解析块。 */
/** 查询布尔型参数:值为 "1"/"y"/"t"/"T" 时返回 true,缺失或其它返回 false。 */
static inline bool http_query_bool(httpd_req_t *req, const char *key)
{
char query[48];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return false;
char val[8] = {};
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return false;
return val[0] == '1' || val[0] == 'y' || val[0] == 'Y' ||
val[0] == 't' || val[0] == 'T';
}
/** 查询无符号整型参数:缺失/非法时返回 defval。 */
static inline uint32_t http_query_u32(httpd_req_t *req, const char *key, uint32_t defval)
{
char query[64];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return defval;
char val[16] = {};
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return defval;
return (uint32_t)strtoul(val, NULL, 10);
}
/* ── Hex 编解码 ────────────────────────────────────────────────
* 统一替代内联 strtoul 循环和 snprintf("%02X") 循环。 */
/** hex 字符串 → 字节数组,返回写入字节数。 */
static inline size_t http_hex_to_bytes(const char *hex, uint8_t *out, size_t max_out)
{
size_t hex_len = strlen(hex);
size_t n = 0;
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
char byte_str[3] = { hex[i], hex[i + 1], 0 };
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
}
return n;
}
/** hex 字符串(带长度)→ 字节数组,返回写入字节数。
* 供 json_parse.h 的非 NUL 终止字符串值使用。 */
static inline size_t http_hex_to_bytes_n(const char *hex, size_t hex_len, uint8_t *out, size_t max_out)
{
size_t n = 0;
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
char byte_str[3] = { hex[i], hex[i + 1], 0 };
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
}
return n;
}
/** 字节数组 → 大写 hex 字符串(out 需至少 2*n+1 字节)。 */
static inline void http_bytes_to_hex_upper(const uint8_t *in, size_t n, char *out)
{
for (size_t i = 0; i < n; ++i) {
snprintf(out + i * 2, 3, "%02X", in[i]);
}
out[n * 2] = '\0';
}
/** 字节数组 → 小写 hex 字符串(out 需至少 2*n+1 字节)。 */
static inline void http_bytes_to_hex_lower(const uint8_t *in, size_t n, char *out)
{
for (size_t i = 0; i < n; ++i) {
snprintf(out + i * 2, 3, "%02x", in[i]);
}
out[n * 2] = '\0';
}
#ifdef __cplusplus
}
#endif
#endif // HTTP_UTIL_H
+243
View File
@@ -0,0 +1,243 @@
/*
* json_parse.h - 极简 JSON 扁平对象解析器(cJSON 零堆分配替代)
*
* 项目中对 JSON 的使用全部是"解析前端发来的扁平 JSON 对象":
* { "sha256": "...", "verify_only": true, "total_size": 12345, "type": "esp32" }
* 无嵌套对象、无数组(can_send_batch 已自研流式解析)。
* 本解析器直接在调用方提供的可写字符串缓冲上做扫描,
* 不分配堆内存,字符串值通过就地 NUL 终止返回指针。
*
* 用法:
* JsonView root = jsonParse(buf); // buf 须为可写 char[]
* if (!root.valid) return error;
* JsonView sha = jsonGet(&root, "sha256");
* if (sha.type == JSON_STRING) { use(sha.str); }
* JsonView sz = jsonGet(&root, "total_size");
* if (sz.type == JSON_NUMBER) { uint32_t n = (uint32_t)sz.num; }
* // 无需 free —— 指针指向 buf,buf 由调用方管理
*/
#ifndef JSON_PARSE_H
#define JSON_PARSE_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
JSON_INVALID = 0,
JSON_NULL,
JSON_BOOL,
JSON_NUMBER,
JSON_STRING,
JSON_OBJECT,
} JsonType;
typedef struct {
JsonType type;
bool valid;
char *str; /* JSON_STRING:指向 buf 内字符串(不一定 NUL 终止!) */
size_t str_len; /* JSON_STRING:字符串长度(配合 str 使用,避免就地写 NUL) */
double num; /* JSON_NUMBER / JSON_BOOL */
char *obj_start; /* JSON_OBJECT:'{' 后的内容区 */
size_t obj_len;
} JsonView;
/**
* @brief 解析 JSON 字符串为根对象视图(就地扫描,可能写入 NUL 终止符)。
* @param json 可写 NUL 终止 JSON 字符串(须为顶层 { ... } 扁平对象)
* @return type=JSON_OBJECT 且 valid=true,或 valid=false 解析失败
*/
static inline JsonView jsonParse(char *json)
{
JsonView v = {};
if (!json) return v;
char *p = json;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '{') return v;
p++;
char *content = p;
int depth = 1;
while (*p) {
if (*p == '"') {
p++;
while (*p && *p != '"') {
if (*p == '\\' && p[1]) p++;
p++;
}
if (*p) p++;
} else if (*p == '{') {
depth++; p++;
} else if (*p == '}') {
depth--;
if (depth == 0) break;
p++;
} else {
p++;
}
}
if (depth != 0) return v;
v.type = JSON_OBJECT;
v.valid = true;
v.obj_start = content;
v.obj_len = (size_t)(p - content);
return v;
}
/**
* @brief 在扁平 JSON 对象中查找指定 key 的值。
* @return valid=true 表示找到;字符串值经就地 NUL 终止后通过 .str 返回
*/
static inline JsonView jsonGet(const JsonView *obj, const char *key)
{
JsonView result = {};
if (!obj || !obj->valid || obj->type != JSON_OBJECT || !key) return result;
char *p = obj->obj_start;
char *end = obj->obj_start + obj->obj_len;
while (p < end) {
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')) p++;
if (p >= end) break;
/* "key" */
if (*p != '"') break;
p++;
char *key_start = p;
while (p < end && *p != '"') p++;
if (p >= end) break;
size_t klen = (size_t)(p - key_start);
p++;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
if (p >= end || *p != ':') break;
p++;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
if (p >= end) break;
bool match = (strlen(key) == klen && strncmp(key_start, key, klen) == 0);
if (*p == '"') {
p++;
char *val_start = p;
while (p < end && *p != '"') {
if (*p == '\\' && p + 1 < end) p++;
p++;
}
if (p >= end) break;
if (match) {
/* 不就地写 NUL(会破坏后续 jsonGet 扫描);用 str_len 返回长度 */
result.type = JSON_STRING;
result.valid = true;
result.str = val_start;
result.str_len = (size_t)(p - val_start);
return result;
}
p++;
} else if (*p == 't' || *p == 'f') {
if (match) {
result.type = JSON_BOOL;
result.valid = true;
result.num = (*p == 't') ? 1.0 : 0.0;
return result;
}
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
} else if (*p == 'n') {
if (match) {
result.type = JSON_NULL;
result.valid = true;
return result;
}
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
} else {
char *val_start = p;
if (*p == '-') p++;
while (p < end && ((*p >= '0' && *p <= '9') || *p == '.' || *p == 'e' || *p == 'E' || *p == '+' || *p == '-')) p++;
if (match) {
/* 不就地写 NUL;strtod 可在非 NUL 终止串上工作(指定长度)。
* 但 strtod 需要终止符——用临时方式:记录起止,用 sscanf 或手动解析。
* 更简方案:buf 本身是 NUL 终止的(jsonParse 要求 NUL 终止输入),
* 而 *p 此处是数字后的分隔符(',' 或 '}' 或空白),不是 NUL。
* 我们临时保存 *p、写 NUL、解析、恢复 *p。 */
char saved = *p;
*p = '\0';
result.type = JSON_NUMBER;
result.valid = true;
result.num = strtod(val_start, nullptr);
*p = saved; /* 恢复,不影响后续 jsonGet 扫描 */
return result;
}
}
}
return result;
}
/* ── 便捷谓词(兼容 cJSON 调用风格)── */
static inline bool jsonIsString(const JsonView *item) {
return item && item->valid && item->type == JSON_STRING;
}
static inline bool jsonIsNumber(const JsonView *item) {
return item && item->valid && item->type == JSON_NUMBER;
}
static inline bool jsonIsBool(const JsonView *item) {
return item && item->valid && item->type == JSON_BOOL;
}
static inline bool jsonIsTrue(const JsonView *item) {
return item && item->valid && item->type == JSON_BOOL && item->num != 0.0;
}
/**
* @brief 获取字符串值指针和长度(字符串不一定 NUL 终止,须配合 str_len 使用)。
* @param item JSON 值视图
* @param out_len 可选,输出字符串长度
* @return 字符串指针(指向 buf 内部),或 nullptr
*/
static inline const char *jsonGetStringValue(const JsonView *item, size_t *out_len = nullptr) {
if (item && item->valid && item->type == JSON_STRING) {
if (out_len) *out_len = item->str_len;
return item->str;
}
if (out_len) *out_len = 0;
return nullptr;
}
/**
* @brief 比较字符串值是否等于指定 C 字符串。
* 因 jsonGet 不写 NUL 终止符,不能用 strcmp,须用此函数。
*/
static inline bool jsonStringEquals(const JsonView *item, const char *cstr) {
if (!jsonIsString(item) || !cstr) return false;
size_t clen = strlen(cstr);
return clen == item->str_len && strncmp(item->str, cstr, clen) == 0;
}
/**
* @brief 将字符串值拷贝到 NUL 终止的输出缓冲(安全截断)。
* @return 实际拷贝长度(不含 NUL)
*/
static inline size_t jsonGetStringCopy(const JsonView *item, char *out, size_t out_max) {
if (!jsonIsString(item) || !out || out_max == 0) return 0;
size_t n = (item->str_len < out_max - 1) ? item->str_len : out_max - 1;
memcpy(out, item->str, n);
out[n] = '\0';
return n;
}
static inline double jsonGetNumberValue(const JsonView *item) {
return (item && item->valid && item->type == JSON_NUMBER) ? item->num : 0.0;
}
#ifdef __cplusplus
}
#endif
#endif // JSON_PARSE_H
+10 -1
View File
@@ -299,7 +299,10 @@ esp_err_t WifiOta::initSta()
country.policy = WIFI_COUNTRY_POLICY_MANUAL;
wifi_config_t wifi_config = {};
wifi_config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
/* FAST_SCAN:扫到目标 SSID 即停并连接(多 AP 同名时取先扫到的,可能非最强)。
* 原 ALL_CHANNEL_SCAN 要把 13 信道全扫完才连,OTA 重启后页面恢复要多等 3~8s。
* esp_restart 不清 nvs.net80211 缓存,驱动可复用上次 AP 信息,恢复更快。 */
wifi_config.sta.scan_method = WIFI_FAST_SCAN;
wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
wifi_config.sta.pmf_cfg.capable = true;
@@ -822,6 +825,12 @@ void WifiOta::taskEntry(void *pvParameters)
/* 启动日志 burst 已过:环 64→16 槽,释放大约 7.5KB 给业务堆 */
DebugLog::instance().shrinkRingAfterBoot();
/* ★ 重启完成判定信号(前端 polling.js 匹配 /OTA Relay .*启动/ + tag=MAIN):
* 必须在本函数(httpd 就绪、shrinkRingAfterBoot 之后)打印——main.cpp 的
* 早期 banner 在环缩容时会被裁掉,前端首轮 /logs 拉不到,只能干等 10s
* 安全阈值才恢复页面。此处是环内最新日志,前端一定能拉到。 */
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay v%s 启动", FW_VERSION);
// WiFi 状态监控
// 每秒一次循环:每 10 秒检查 RSSI
uint32_t tick = 0;
+57 -5
View File
@@ -14,6 +14,7 @@
#include "mbedtls/private/sha256.h"
#include "gd32_flash_store.h"
#include "app_config.h" // OTA_BLOCK_SIZE(flashWriteChunk 静态对齐缓冲容量)
#include "monitor/debug_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -147,6 +148,54 @@ esp_err_t Gd32FlashStore::ensureErasedForWrite(uint32_t offset, size_t len)
return ESP_OK;
}
// ============================================================
// OTA 期对齐缓冲池(2026-08 内存优化)
//
// flashWriteChunk 在非 4 字节对齐写入时需要临时缓冲读回 padding。
// 原实现每次 malloc/free,OTA 收流时与非对齐块叠加产生堆抖动。
// 现改为 OTA begin 时一次性 calloc,end/abort 时 free,消除抖动
// 又不增加常驻 BSS(非 OTA 期零占用)。
// ============================================================
static uint8_t *s_align_buf = nullptr;
static constexpr size_t kAlignBufCap = OTA_BLOCK_SIZE + 8;
void Gd32FlashStore::acquireAlignBufForOta()
{
if (!s_align_buf) {
s_align_buf = (uint8_t *)calloc(1, kAlignBufCap);
if (!s_align_buf) {
DEBUG_WARN("OTA", "对齐缓冲分配失败,回退 malloc free=%u",
(unsigned)esp_get_free_heap_size());
}
}
}
void Gd32FlashStore::releaseAlignBufForOta()
{
if (s_align_buf) {
free(s_align_buf);
s_align_buf = nullptr;
}
}
/** @brief 取对齐缓冲。优先用 OTA 期预分配;未分配或太小则临时 malloc。 */
static uint8_t *acquireAlignBuf(size_t needed)
{
if (s_align_buf && needed <= kAlignBufCap) {
return s_align_buf;
}
// OTA 未预分配(begin 失败)或包过大:回退临时 malloc
return (uint8_t *)malloc(needed);
}
/** @brief 归还对齐缓冲。OTA 池的不释放;临时 malloc 的 free。 */
static void releaseAlignBuf(uint8_t *buf)
{
if (buf && buf != s_align_buf) {
free(buf);
}
}
/** @brief 向 gd32_ota 分区写入数据块,自动处理非 4 字节对齐的读写。 */
esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len)
{
@@ -188,9 +237,12 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
offset, len, head_pad);
}
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
/* OTA 期按需对齐缓冲(OTA begin 时 calloc,end/abort 时 free):
* 消除每非对齐块的 malloc/free 抖动,又不增加常驻 BSS。
* 容量 = OTA_BLOCK_SIZE(4KB) + 最多 3 字节 tail pad。 */
uint8_t *aligned_buf = acquireAlignBuf(aligned_len);
if (!aligned_buf) {
DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len);
DEBUG_ERR("OTA", "flashWriteChunk 对齐缓冲不足/未分配: %zu", aligned_len);
return ESP_ERR_NO_MEM;
}
@@ -200,7 +252,7 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回head失败: offset=%" PRIu32 " head=%zu err=%s",
aligned_offset, head_pad, esp_err_to_name(err));
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
}
@@ -215,13 +267,13 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回tail失败: offset=%" PRIu32 " tail=%zu err=%s",
aligned_offset + total, tail_pad, esp_err_to_name(err));
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
}
esp_err_t err = esp_partition_write(part, aligned_offset, aligned_buf, aligned_len);
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
+6
View File
@@ -145,6 +145,12 @@ public:
*/
const esp_partition_t* getPartition();
// ── OTA 期对齐缓冲生命周期 ──
/** @brief GD32 OTA begin 时调用:预分配对齐缓冲(消除 flashWriteChunk 逐块 malloc)。 */
static void acquireAlignBufForOta();
/** @brief GD32 OTA end/abort 时调用:释放对齐缓冲(非 OTA 期零占用)。 */
static void releaseAlignBufForOta();
private:
Gd32FlashStore();
~Gd32FlashStore() = default;
+61 -8
View File
@@ -61,11 +61,13 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = ESP_OK;
/* CAN 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
/* 0xC0 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
* 喂数(0xD1→WAIT)残留在 SPI 响应管道里的陈旧帧(0xC0 命令本身永不产生 WAIT)。
* 陈旧 WAIT 会被下方误判为 ERROR → resp_len=0 → gd32_err=0xFF → 无声拒绝发送(
* 表现为“失败一次就再也发不出去”)。此处最多重试几次排空陈旧帧,
* 直到读到本命令真正的 ACK/ERROR。 */
* 表现为“失败一次就再也发不出去”)。此处最多重试 4 次排空陈旧帧,
* 直到读到本命令真正的 ACK/ERROR。
* SPI 传输失败/无响应的重发已由 SpiMaster::otaSendAndPoll 统一处理
* (同 TX_SEQ,最多 3 次尝试),此处不再各自重试;GD32 拒绝(ERROR)同样不重试。 */
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
resp_type = 0U;
resp_len = 0U;
@@ -73,8 +75,8 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
kSpiCanFrameType,
tx_buf, tx_len,
&resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms);
if (ret != ESP_OK || resp_type != SPI_OTA_RSP_WAIT) {
break; /* SPI 失败或已拿到真正响应(ACK/ERROR) */
if (resp_type != SPI_OTA_RSP_WAIT) {
break; /* 已拿到真正响应(ACK/ERROR);SPI 失败(ret!=OK)也退出交上层处理 */
}
vTaskDelay(pdMS_TO_TICKS(3)); /* 让 GD32 更新响应,再读一次排空陈旧 WAIT */
}
@@ -241,8 +243,10 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX)
? e.dlc : static_cast<uint8_t>(CANFD_ID_SNAPSHOT_DATA_MAX);
const uint16_t entrySize = static_cast<uint16_t>(8U + dataLen);
if (offset + entrySize > len) {
// 线协议条目实际大小按原始 dlc 计算(CANFD 帧可能 64B),offset 必须按
// 实际条目推进,否则后续条目错位。data 拷贝按 dataLen 截断。
const uint16_t wireEntrySize = static_cast<uint16_t>(8U + e.dlc);
if (offset + wireEntrySize > len) {
break;
}
@@ -251,7 +255,7 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
e.data[d] = 0U;
}
offset += entrySize;
offset += wireEntrySize;
(*entryIdx)++;
}
@@ -264,6 +268,55 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
return 0;
}
/* ── 0xC1 请求/响应查询(2026-08 架构改造:替代被动推送缓存)── */
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx)
{
if (out == nullptr || entryIdx == nullptr) {
return -1;
}
if (channel >= CAN_CHANNEL_MAX || page > 1U) {
return -1;
}
uint8_t req[2] = { channel, page };
uint8_t resp_type = 0;
/* SPI v2 payload 上限 236B(256 帧 - 20 开销),与 GD32 kFeedbackPayloadLen 一致 */
uint8_t resp_buf[SPI_OTA_FRAME_MAX];
size_t resp_len = 0;
esp_err_t ret = SpiMaster::instance().transact(
0xC1, req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
if (ret != ESP_OK) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u SPI失败: %s",
(unsigned)channel, (unsigned)page, esp_err_to_name(ret));
return -1;
}
if (resp_type == SPI_OTA_RSP_ERROR) {
uint8_t gd32_err = (resp_len > 0) ? resp_buf[0] : 0;
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u GD32拒绝 err=0x%02X",
(unsigned)channel, (unsigned)page, (unsigned)gd32_err);
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);
return -1;
}
/* 解析复用现有 canFdParseFeedbackPage(payload 格式与推送一致) */
int parse_rc = canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx);
if (parse_rc != 0) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 解析失败 rc=%d len=%zu",
(unsigned)channel, (unsigned)page, parse_rc, resp_len);
return -1;
}
return 0;
}
/**
* @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。
* @return 0 成功;-1 参数非法或 SPI/应答失败。
+17 -1
View File
@@ -57,7 +57,7 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
// 不重复 ID 快照表(SPI 0xC3 协议配套)
// ============================================================
#define CANFD_ID_SNAPSHOT_MAX 16 ///< 每通道最大不重复 ID 条目数
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数(CANFD 最大 64B,保持完整不截断)
/**
* @brief 单个不重复 ID 的最新帧快照条目。
@@ -131,6 +131,22 @@ typedef struct {
int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
can_feedback_result_t *out, uint8_t *entryIdx);
/**
* @brief 主动向 GD32 查询一页 CAN 反馈(SPI 0xC1 请求/响应)。
*
* 2026-08 架构改造:ESP32 不再被动缓存 GD32 推送,改为前端请求时按需查询。
* GD32 handleFeedback 接收 [channel, page] 返回 ACK + 页数据(与推送 payload 格式一致)。
* 查询成功后调 canFdParseFeedbackPage 解析,复用现有累积逻辑。
*
* @param channel 目标通道 0/1/2。
* @param page 页号 0 或 1(page 0 重置累积 + 填统计,page 1 追加 entries)。
* @param out 累积结果(跨 page 0/1 累积,调用方按通道维护)。
* @param entryIdx 当前已填充条目数(page 0 时被重置为 0)。
* @return 0 成功;-1 参数/SPI 失败;-2 GD32 返回 ERROR。
*/
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx);
/**
* @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。
* @param mode 目标模式(0=全收,1=按 ID 过滤,2=CAN0 桥接到 1/2)。
+193 -59
View File
@@ -21,6 +21,7 @@
#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 "spi_master.h"
#include "spi_link_v2.h"
#include "app_config.h"
@@ -67,8 +68,27 @@ static size_t spiRespReadBudget(void)
return SPI_APP_TX_BUF_SIZE;
}
/**
* @brief 已知"短响应"的主动请求类型:应答帧 ≤ ~70B(实测 0xA1 PING ~60-70B、
* DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短)。
* 对这些类型的主动 poll 可把读预算从 256 收紧到 96B,省 ~160B 0xFF 填充时钟
* (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)与被动 0xC1 事件读。
*/
static bool isShortRespType(uint8_t type)
{
switch (type) {
case 0xA1U: case 0x02U: case 0x01U: case 0xF7U:
case 0xB0U: case 0xC0U: case 0xC2U: case 0xD1U:
return true;
default:
return false;
}
}
/** 短响应读预算(覆盖最长 ~70B 应答 + 余量)。 */
static constexpr size_t kShortRespReadBudget = 96U;
/** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type)
bool SpiMaster::isAppOnlySpiType(uint8_t type)
{
switch (type) {
case 0xC1U: /* CAN feedback */
@@ -85,6 +105,34 @@ static bool isAppOnlySpiType(uint8_t type)
}
}
/* ── 去重辅助实现 ── */
void SpiMaster::copyFoundFrame(uint8_t *resp_type, uint8_t found_type,
uint8_t *resp_payload, size_t resp_max,
const uint8_t *found_payload, size_t found_len,
size_t *resp_len)
{
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
} else if (copy_len > 0) {
memset(resp_payload, 0, copy_len);
}
}
if (resp_len) *resp_len = found_len;
}
void SpiMaster::correctRoleFromType(uint8_t found_type, const char *src)
{
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (saw type=0x%02X, %s)",
(unsigned)m_gd32Role, found_type, src);
m_gd32Role = V2_ROLE_APP;
}
}
/** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。
* 用于 SPIready 边沿中断,避免在 cache 被禁用期间访问 Flash 代码。
*/
@@ -189,18 +237,52 @@ bool SpiMaster::popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t
}
/**
* @brief 暂停/恢复对 GD32 主动按铃的 serviceRing 处理。
* @param suppress true=OTA 等关键路径禁止偷读 PB6;false=恢复
* @brief 暂停/恢复对 GD32 主动按铃的 serviceRing 处理(引用计数)。
* @param suppress true=+1(OTA 等关键路径禁止偷读 PB6);false=-1(钳位到 0)。
*
* GD32 中继(forwardFirmware)与电机 OTA feedTask 都可能持有抑制;任一
* 持有者退出时若直接清位,会把另一方的保护误清(电机 OTA 收尾曾把进行中
* 的 GD32 中继抑制清掉 → worker 偷读 OTA ACK/WAIT → poll 空等 6s)。
*/
void SpiMaster::setSuppressRingService(bool suppress)
{
m_suppressRingService = suppress;
taskENTER_CRITICAL(&s_spiJobMux);
uint32_t refs = m_ringSuppressRefs;
if (suppress) {
++refs;
} else if (refs > 0U) {
--refs;
}
m_ringSuppressRefs = refs;
const bool now_suppressed = (refs > 0U);
taskEXIT_CRITICAL(&s_spiJobMux);
m_suppressRingService = now_suppressed;
if (now_suppressed) {
/* 丢弃已锁存的按铃标志,避免解除后立刻误读残留边沿 */
m_gd32RequestPending = false;
}
}
/**
* @brief ESP32 自我 OTA 期间暂停/恢复全部 SPI 活动。
*
* 暂停:worker 空转不读 SPI(GD32 按铃丢弃),submitJob 快速拒绝
* (业务调用立即返回 ESP_ERR_INVALID_STATE,不排队不阻塞)。
* 恢复:唤醒 worker 恢复正常。幂等,可多次调用。
*/
void SpiMaster::setSpiPaused(bool paused)
{
m_spiPaused = paused;
if (paused) {
/* 丢弃已锁存的按铃标志:暂停期间不读 GD32,解除后勿误读残留边沿 */
m_gd32RequestPending = false;
}
if (m_workerTask != nullptr) {
xTaskNotify(m_workerTask, 0x10U, eSetBits); /* 唤醒 worker 处理状态变化 */
}
}
/**
* @brief 业务侧确认本端 TX 序号已被对端接受,推进 m_txSeq。
* @note 在收到明确业务 ACK 后调用,避免重发与序号失配。
@@ -278,11 +360,7 @@ esp_err_t SpiMaster::serviceRingOnce()
{
LockGuard lock(m_mutex);
/* APP 独有事件 → 纠正粘滞的 boot role(OTA 后 HELLO 未刷新时常见) */
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (saw type=0x%02X)",
(unsigned)m_gd32Role, found_type);
m_gd32Role = V2_ROLE_APP;
}
correctRoleFromType(found_type, "ring");
(void)pushEvent(found_type, found_payload, found_len);
}
return ESP_OK;
@@ -428,6 +506,10 @@ void SpiMaster::finishPoolJob(Job *job)
*/
esp_err_t SpiMaster::submitJob(Job &job)
{
/* ESP32 自我 OTA 暂停:快速拒绝,不排队不等待(worker 空转中)。 */
if (m_spiPaused) {
return ESP_ERR_INVALID_STATE;
}
if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) {
executeJob(job);
return job.result;
@@ -529,6 +611,15 @@ void SpiMaster::workerLoop()
DEBUG_INFO("SPI", "SPI worker started, handle=%p", (void *)m_workerTask);
for (;;) {
/* ESP32 自我 OTA 暂停:空转不读 SPI(flash 写期间 cache 失效会拖慢
* flash 驻留代码,且此时 GD32 数据无意义)。恢复由 setSpiPaused(false)
* 通知唤醒。 */
if (m_spiPaused) {
m_gd32RequestPending = false;
uint32_t bits = 0U;
(void)xTaskNotifyWait(0U, 0xFFFFFFFFU, &bits, pdMS_TO_TICKS(50));
continue;
}
/* ★ 只在 ISR 置位 m_gd32RequestPending 时读 EVENT。
* 禁止见 DATA_READY 低就 serviceRing:命令 HELLO_ACK/OTA 响应也会拉低
* PB6,会被偷走当事件 → 业务 poll 全 0xFF,OTA/ENTER_BOOT 全挂。
@@ -541,7 +632,16 @@ void SpiMaster::workerLoop()
Job *job = nullptr;
if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(1)) == pdTRUE && job != nullptr) {
/* 计时 worker 实际执行段(取帧→发→读响应→完成):
* 测速页 RTT − 本值 = 提交/调度/唤醒开销,用于区分
* "ESP32 侧任务抢占" vs "GD32 应答慢" 的抖动归属。 */
const int64_t w0 = esp_timer_get_time();
m_lastProfile.t_w0 = (uint64_t)w0;
executeJob(*job);
const int64_t w1 = esp_timer_get_time();
m_lastProfile.t_w1 = (uint64_t)w1;
const int64_t wus = w1 - w0;
m_lastWorkerExecUs = (wus > 0) ? (uint32_t)wus : 0U;
finishPoolJob(job);
continue;
}
@@ -698,7 +798,9 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
t.tx_buffer = tx_ptr;
t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL;
/* SPI 事务等待使用有界超时 */
/* 中断模式(queue+result 信号量):2026-08-02 实测 polling 模式在 C3 单核上
* 整体更慢——传输段虽略快(-14~-17µs),但 worker 忙等占死 CPU,提交/调度
* 13→140µs、唤醒 18→76µs、2-5ms 尾巴 23→129 帧,RTT 658→822µs。回退。 */
const uint32_t effective_ms = (timeout_ms == 0U) ? 500U
: (timeout_ms < 50U ? 50U : timeout_ms);
const TickType_t queue_wait = pdMS_TO_TICKS(effective_ms);
@@ -755,7 +857,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
* 时钟预算见 spiRespReadBudget():BOOT=128,APP=256。
*/
esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
uint32_t timeout_ms)
uint32_t timeout_ms, size_t budget_hint)
{
if (out_frame == nullptr || out_len == nullptr || out_max < V2_HEADER_LEN) {
return ESP_ERR_INVALID_ARG;
@@ -767,6 +869,9 @@ esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_
*out_len = 0U;
size_t budget = spiRespReadBudget();
if (budget_hint > 0U && budget_hint < budget) {
budget = budget_hint; /* 主动 poll 按请求类型收紧,省 0xFF 填充时钟 */
}
if (budget > out_max) {
budget = out_max;
}
@@ -1069,7 +1174,11 @@ esp_err_t SpiMaster::init()
}
}
if (m_workerTask == nullptr) {
BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", TASK_STACK_SPI_WORKER, this, 6, &m_workerTask);
/* 优先级 8(2026-08-02 测速实验):原 6 会被 ota_feed(7) 抢占,
* SPI 完成 ISR 唤醒 worker 后还要等 feed 让出,拖长 RTT 唤醒链。
* 8 仍低于 health_mon(21)/ota_relay(23);worker 阻塞循环为主,
* 仅 job 执行时运行,不会饿死 ota_feed/httpd。 */
BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", TASK_STACK_SPI_WORKER, this, 8, &m_workerTask);
if (ok != pdPASS || m_workerTask == nullptr) {
DEBUG_ERR("SPI", "SPI worker task create failed");
m_workerTask = nullptr;
@@ -1124,6 +1233,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
if (!otaBuildFrame(type, payload, payload_len, tx_frame, &tx_len)) {
return ESP_ERR_INVALID_ARG;
}
/* 记录最近一次主动请求类型:poll 读预算按它收紧(isShortRespType)。 */
m_lastReqType = type;
DEBUG_DBG("SPI", "OTA TX: type=0x%02X len=%u", type, (unsigned)tx_len);
@@ -1194,17 +1305,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
/* 与 Git 稳定路径一致:step1 陈旧帧原样解析即可(otaSendAndPoll 传 nullptr 丢弃)。
* 勿在此处过滤 LINK/EVENT,否则改变 delayed-by-one 语义。 */
if (otaFindFrame(rx_raw, xfer_len, &found_type, &found_payload, &found_len, &frame_offset)) {
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
}
}
if (resp_len) *resp_len = found_len;
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
m_gd32Role = V2_ROLE_APP;
}
copyFoundFrame(resp_type, found_type, resp_payload, resp_max, found_payload, found_len, resp_len);
correctRoleFromType(found_type, "send");
/* expect_stale(两步法 step1)读到的是延迟一拍陈旧帧,序号由 poll 成功时推进。
* 此处若自增会导致 job 间隙/超时后 resend 使用错误 TX_SEQ。 */
if (!expect_stale) {
@@ -1286,7 +1388,12 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
? ((timeout_ms >= 80U) ? 40U : timeout_ms)
: 15U;
const uint32_t wait_ms = (ready_slice < remain_ms) ? ready_slice : remain_ms;
/* 分段剖析:等 READY 段 = GD32 收到帧→拉低 PB6 的处理时间 */
m_lastProfile.t_wait0 = (uint64_t)esp_timer_get_time();
const bool ready = (waitSpiDataReady(wait_ms) == ESP_OK);
if (ready) {
m_lastProfile.t_wait1 = (uint64_t)esp_timer_get_time();
}
now = xTaskGetTickCount();
remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U;
@@ -1303,7 +1410,12 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
uint8_t rx_raw[SPI_OTA_FRAME_MAX];
size_t rx_len = 0U;
esp_err_t ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, spi_timeout);
/* 读预算按最近请求类型收紧(短响应类型 96B,其余按角色默认) */
const size_t budget_hint = isShortRespType(m_lastReqType) ? kShortRespReadBudget : 0U;
esp_err_t ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, spi_timeout, budget_hint);
if (ret == ESP_OK) {
m_lastProfile.t_read1 = (uint64_t)esp_timer_get_time();
}
did_spi_read = true;
if (ret != ESP_OK) {
if (ret == ESP_ERR_INVALID_RESPONSE) {
@@ -1347,11 +1459,7 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
}
if ((found_flags & V2_FLAG_EVENT) != 0U) {
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (poll saw type=0x%02X)",
(unsigned)m_gd32Role, found_type);
m_gd32Role = V2_ROLE_APP;
}
correctRoleFromType(found_type, "poll");
DEBUG_DBG("SPI", "poll skip EVENT type=0x%02X", found_type);
vTaskDelay(pdMS_TO_TICKS(1));
continue;
@@ -1365,16 +1473,7 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
continue;
}
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
} else if (copy_len > 0) {
memset(resp_payload, 0, copy_len);
}
}
if (resp_len) *resp_len = found_len;
copyFoundFrame(resp_type, found_type, resp_payload, resp_max, found_payload, found_len, resp_len);
/* WAIT/ACK/ERROR:只要 ack_seq 匹配,GD32 已消费本 TX_SEQ,必须推进。
* ★ 电机 OTA:feedData 缓冲满回 WAIT 时若不清 seq,ESP32 同 seq 重发
@@ -1429,7 +1528,11 @@ esp_err_t SpiMaster::transact(uint8_t type, const uint8_t *payload, size_t paylo
job.out_payload = resp_payload;
job.out_max = resp_max;
job.out_len = resp_len;
return submitJob(job);
/* 分段剖析:记录调用方侧两个时间戳(worker 侧在 workerLoop/otaSendAndPoll 内写) */
m_lastProfile.t_submit0 = (uint64_t)esp_timer_get_time();
const esp_err_t p_ret = submitJob(job);
m_lastProfile.t_submit1 = (uint64_t)esp_timer_get_time();
return p_ret;
}
return otaSendAndPoll(type, payload, payload_len,
resp_type, resp_payload, resp_max, resp_len, timeout_ms);
@@ -1512,27 +1615,58 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
poll_timeout = 1U;
}
esp_err_t send_ret = otaTransferUnlocked(type, payload, payload_len,
nullptr, nullptr, 0U, nullptr, send_timeout,
/*expect_stale=*/true);
if (send_ret != ESP_OK && send_ret != ESP_ERR_INVALID_RESPONSE) {
return send_ret;
}
/* 统一重发机制(嵌入 worker,所有业务共用,调用方无需各自重试):
* 仅当"没读到任何有效响应"时同 TX_SEQ 重发(失败路径 seq 不推进):
* - 发送级失败(transferClocked 错误/credit 拒绝):帧大概率未送达 GD32;
* - poll 超时(无响应):帧可能未送达或响应丢失,重发靠 GD32 DUP-SEQ 去重兜底。
* 读到任何帧(WAIT/ACK/ERROR/陈旧)即成功返回,不重发:
* - 保证 GD32 明确拒绝(ERROR 0x82)天然不重试(如 CAN 负载高/BUS_OFF);
* - OTA relay 的 WAIT 语义、旧帧排空语义均不受影响。 */
constexpr uint32_t kTxRetryMax = 3U; /* 总尝试次数(1 次首发 + 2 次重发) */
for (uint32_t attempt = 0U; attempt < kTxRetryMax; ++attempt) {
m_lastProfile.t_send0 = (uint64_t)esp_timer_get_time();
esp_err_t send_ret = otaTransferUnlocked(type, payload, payload_len,
nullptr, nullptr, 0U, nullptr, send_timeout,
/*expect_stale=*/true);
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) {
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;
}
return send_ret;
}
/* CS 释放后的静默期分型处理:
* - 0xD1(电机 OTA 数据)/ 0xA1(APP PING):GD32 APP 在 onCsRelease ISR
* 内 µs 级装好响应并拉低 PB6 → 零盲等,waitSpiDataReady 由下降沿 ISR
* 精确唤醒,旧 1ms 固定延时压制喂速~47KB/s,现由事件驱动提速。
* - 其他类型(GD32 relay/BOOT AES/HELLO 等):保留 5ms——BOOT 在主循环
* 处理 CHALLENGE/flash,poll 前 2 轮盲读 CS 过早打进去会打断其
* EXTI/DMA 时序(实测去掉后 CHALLENGE 超时)。 */
if (type != 0xD1U && type != 0xA1U) {
vTaskDelay(pdMS_TO_TICKS(5));
}
/* CS 释放后的静默期分型处理:
* - 0xD1(电机 OTA 数据)/ 0xA1(APP PING):GD32 APP 在 onCsRelease ISR
* 内 µs 级装好响应并拉低 PB6 → 零盲等,waitSpiDataReady 由下降沿 ISR
* 精确唤醒,旧 1ms 固定延时压制喂速~47KB/s,现由事件驱动提速。
* - 其他类型(GD32 relay/BOOT AES/HELLO 等):保留 5ms——BOOT 在主循环
* 处理 CHALLENGE/flash,poll 前 2 轮盲读 CS 过早打进去会打断其
* EXTI/DMA 时序(实测去掉后 CHALLENGE 超时)。 */
if (type != 0xD1U && type != 0xA1U) {
vTaskDelay(pdMS_TO_TICKS(5));
}
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout,
/*expected_type=*/0xFFU);
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
esp_err_t poll_ret = otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len,
poll_timeout, /*expected_type=*/0xFFU);
if (poll_ret == ESP_OK) {
return ESP_OK; /* 已读到有效响应帧(ACK/ERROR/陈旧) */
}
if (attempt + 1U < kTxRetryMax) {
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;
}
return poll_ret;
}
return ESP_ERR_TIMEOUT; /* 不可达:循环必有返回 */
}
/**
+64 -5
View File
@@ -100,6 +100,31 @@ public:
/** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; }
/** @brief 最近一次 worker job 实际执行耗时(µs,worker 内计时)。
* 测速页用 RTT − 本值拆出"提交/调度/唤醒"开销,定位抖动归属。 */
uint32_t lastWorkerExecUs() const { return m_lastWorkerExecUs; }
/**
* @brief 单次 transact 的分段耗时剖析(测速页诊断用)。
* 5 段:提交/调度(t_submit0→t_w0)、发送帧(t_send0→t_send1)、
* 等应答(t_wait0→t_wait1)、读响应(t_wait1→t_read1)、唤醒(t_w1→t_submit1)。
* 写入顺序由 done 信号量保证 happens-before(worker 先写全再 give,调用方 take 后读)。
*/
struct SpiProfile {
uint64_t t_submit0 = 0; ///< 调用方 transact 入口
uint64_t t_w0 = 0; ///< worker executeJob 开始
uint64_t t_send0 = 0; ///< 发送帧开始
uint64_t t_send1 = 0; ///< 发送帧结束
uint64_t t_wait0 = 0; ///< 进入 poll(等 READY)
uint64_t t_wait1 = 0; ///< READY 就绪(wait 段结束)
uint64_t t_read1 = 0; ///< 响应帧读取完成
uint64_t t_w1 = 0; ///< worker executeJob 结束
uint64_t t_submit1 = 0; ///< 调用方 submitJob 返回
};
/** @brief 最近一次 transact 的剖析快照(仅测速/诊断用)。 */
SpiProfile lastProfile() const { return m_lastProfile; }
/** @brief SPI 任务队列当前待处理 job 数(固定 Job pool 槽,无堆分配)。
* 供 /system/heap_low_log 堆低水位快照采样,定位"低谷是不是 SPI 队列堆积引起"。 */
UBaseType_t getPendingJobCount() const { return m_jobQueue ? uxQueueMessagesWaiting(m_jobQueue) : 0; }
@@ -107,9 +132,18 @@ public:
/**
* @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。
* 中继开始设 true,结束/复位设 false。
* 实现为引用计数:GD32 中继与电机 OTA feed 可并发持有,任一持有者
* 退出都不会提前解除抑制(避免电机 OTA 收尾把 GD32 中继的抑制误清)。
*/
void setSuppressRingService(bool suppress);
/**
* @brief ESP32 自我 OTA 期间暂停全部 SPI 活动(GD32 按铃读 + 业务 transact)。
* 暂停后 worker 空转不读 SPI,submitJob 快速返回 ESP_ERR_INVALID_STATE
* (业务立即失败,不阻塞排队);恢复时唤醒 worker 恢复正常。幂等。
*/
void setSpiPaused(bool paused);
/**
* @brief 业务层确认当前请求已成功时推进/对齐 m_txSeq。
*/
@@ -282,12 +316,22 @@ private:
SpiLinkState m_linkState;
/** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,仅 SPI worker 消费。 */
volatile bool m_gd32RequestPending = false;
/** ring service 抑制引用计数(setSuppressRingService 内部使用,临界区保护)。 */
uint32_t m_ringSuppressRefs = 0;
/** OTA 中继时为 true:worker 不 serviceRing,避免偷读 OTA ACK/WAIT。 */
volatile bool m_suppressRingService = false;
/** ESP32 自我 OTA 期间为 true:worker 空转、submitJob 快速拒绝。 */
volatile bool m_spiPaused = false;
/** 最近一次主动请求类型(otaTransferUnlocked 发送时记录;poll 读预算按它收紧)。 */
volatile uint8_t m_lastReqType = 0xFFU;
/** SPI worker 任务句柄(ISR 唯一通知目标)。 */
TaskHandle_t m_spiTaskHandle = NULL;
TaskHandle_t m_workerTask = NULL;
QueueHandle_t m_jobQueue = NULL;
/** 最近一次 job 执行耗时(µs,worker 写入、业务只读;单核 32 位读原子)。 */
uint32_t m_lastWorkerExecUs = 0;
/** 最近一次 transact 分段剖析(worker 与调用方交叉写入,done 信号量同步)。 */
SpiProfile m_lastProfile;
/** 事件计数信号量(worker push / takeEvent pop)。 */
SemaphoreHandle_t m_eventSem = NULL;
@@ -405,6 +449,19 @@ private:
/** @brief GPIO 边沿中断回调(注册到 ESP-IDF GPIO ISR 服务)。 */
static void IRAM_ATTR onReadyEdgeISR(void *arg);
/** @brief APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type);
// ── 去重辅助 ──
/** @brief 把 otaFindFrame 找到的帧拷贝到调用方输出缓冲(安全截断)。 */
static void copyFoundFrame(uint8_t *resp_type, uint8_t found_type,
uint8_t *resp_payload, size_t resp_max,
const uint8_t *found_payload, size_t found_len,
size_t *resp_len);
/** @brief 见到 APP 独有 TYPE 时纠正粘滞的 boot role(3 处调用合并)。 */
void correctRoleFromType(uint8_t found_type, const char *src);
// 内部 SPI 传输
/**
* @brief 执行底层 SPI 时钟同步传输。
@@ -430,13 +487,15 @@ private:
/**
* @brief 读一帧响应:单 CS 固定时钟预算(BOOT=128 / APP=256),再 v2_parse 截真实长度。
* 调用者须已持锁。不用两段 KEEP_ACTIVE(Boot 中途断 CS 会清 pending → 全 0xFF)。
* @param out_frame 完整帧输出(含头+payload+CRC)
* @param out_max 输出缓冲容量
* @param out_len 实际帧长
* @param timeout_ms 单次 SPI 超时
* @param out_frame 完整帧输出(含头+payload+CRC)
* @param out_max 输出缓冲容量
* @param out_len 实际帧长
* @param timeout_ms 单次 SPI 超时
* @param budget_hint 读预算提示(0=按角色默认;>0 时取 min(hint, 角色预算),
* 主动 poll 按最近请求类型收紧,省 0xFF 填充时钟)
*/
esp_err_t readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
uint32_t timeout_ms);
uint32_t timeout_ms, size_t budget_hint = 0U);
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
/**