feat(debug_log): 新增UART0 RX泵任务支持串口日志转发
新增UART0 RX泵任务功能,将GPIO20接收到的串口数据按切行、剥离后以tag=UART推入ring buffer,通过HTTP /logs接口显示在网页日志面板。 接线方式:任意串口源TX连接至H1/H2的ESP_RX引脚,波特率3M 8N1。 feat(motor_ota): GD32 OTA增加包级防呆机制防止错误固件刷入 中继触发前校验gd32_ota镜像向量表身份(Gd32FlashStore::validateStoredImageIdentity), 对Bootloader包或非APP包在WiFi /ota/relay与BLE 0xF7均拒绝中继, 避免GD32 APP区被错误包擦除。新增0x87完成码用于固件身份验证失败场景。 feat(motor_state): 扩展CAN状态数据结构支持CONFIG负载 扩展motor_slot_t结构体,新增config_rx_count、config_seq和config_data字段 用于存储CONFIG帧的相关信息。CAN状态数据包大小从44B扩展到76B, 页面总数从6调整为9,支持目标值回报的配置数据传输。 fix(motor_ota): 修复OTA取消流程中的信用度管理和任务清理问题 ★ 先复位credit再发送CANCEL命令,防止传输卡住时背压导致CANCEL被拒绝。 延长取消等待时间至10s覆盖最坏情况,确保feedTask正常退出而非强制删除。 取消完成后复位credit防止锁存死锁,确保后续通信正常进行。 fix(http_server): 修复HTTP请求处理中的边界检查和安全验证 拒绝chunked请求(content_len<=0)防止无限循环,添加通道参数范围验证(ch 0~2), 在OTA中继触发前验证固件身份防止错误包刷入,使用http_json_append避免缓冲区越界。 refactor(gd32_flash): 实现GD32固件身份验证算法确保刷写安全 基于Cortex-M向量表内容实现包级身份校验,通过MSP和复位向量位置 区分APP包、Bootloader包和无效固件,为OTA安全提供基础保障。 BREAKING CHANGE: 电机状态API数据格式变更,CAN通道参数验证更严格
This commit is contained in:
@@ -14,8 +14,8 @@
|
||||
// 固件版本
|
||||
#define FW_VERSION_MAJOR 0
|
||||
#define FW_VERSION_MINOR 3
|
||||
#define FW_VERSION_PATCH 78
|
||||
#define FW_VERSION_STRING "0.3.78" // 版本查询checkVersionWait移到tickInFlight释放后异步触发,避免/motor/version请求拖长tick导致tickStuck误报通道闪黄
|
||||
#define FW_VERSION_PATCH 82
|
||||
#define FW_VERSION_STRING "0.3.82" // DebugLog 新增 UART0 RX 泵任务(GPIO20, 3M 8N1):收到的串口行按 \n 切行、剥 \r,以 tag=UART 推入 ring → HTTP /logs 出现在网页日志面板;接线=任意串口源 TX→H1/H2 的 ESP_RX。0.3.81 GD32 OTA 包级防呆:中继触发前校验 gd32_ota 镜像向量表身份(Gd32FlashStore::validateStoredImageIdentity),BOOT 包/非 APP 包在 WiFi /ota/relay 与 BLE 0xF7 均拒绝,GD32 APP 区不再被错误包擦除;BLE 新增 0x87 完成码
|
||||
|
||||
// Git 信息(手动填写)
|
||||
#define GIT_HASH "770ae14"
|
||||
@@ -503,6 +503,35 @@ esp_err_t DebugLog::uartInit()
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UART0 RX 泵:把 RX(GPIO20) 收到的串口行转发进 ring buffer,
|
||||
// 经 HTTP /logs 出现在网页日志面板(tag=UART)。
|
||||
// 接线:H1/H2 排针上任意串口源 TX → ESP_RX(GPIO20),3M 8N1。
|
||||
// ============================================================
|
||||
static void uartRxPumpTask(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
uint8_t chunk[64];
|
||||
char line[128];
|
||||
int pos = 0;
|
||||
|
||||
for (;;) {
|
||||
const int n = uart_read_bytes(UART_NUM_0, chunk, sizeof(chunk), portMAX_DELAY);
|
||||
for (int i = 0; i < n; i++) {
|
||||
const char c = (char)chunk[i];
|
||||
if (c == '\n' || pos >= (int)sizeof(line) - 1) {
|
||||
if (pos > 0) {
|
||||
line[pos] = '\0';
|
||||
DebugLog::instance().log(DEBUG_LOG_INFO, "UART", "%s", line);
|
||||
pos = 0;
|
||||
}
|
||||
} else if (c != '\r') {
|
||||
line[pos++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t DebugLog::uartSend(const uint8_t *data, size_t len)
|
||||
{
|
||||
if (!m_uartInitialized || !m_uartAvailable) {
|
||||
@@ -558,6 +587,15 @@ esp_err_t DebugLog::init()
|
||||
m_debugAddr.sin_port = htons(DEBUG_LOG_PORT);
|
||||
m_debugAddr.sin_addr.s_addr = inet_addr(DEBUG_LOG_IP);
|
||||
|
||||
/* UART0 RX 泵:串口行 → ring → /logs 网页面板 */
|
||||
if (m_uartAvailable) {
|
||||
if (xTaskCreate(uartRxPumpTask, "uart_rx", 3072, nullptr, 1, nullptr) == pdPASS) {
|
||||
ESP_LOGI(TAG, "UART0 RX 泵已启动 (GPIO%d → /logs)", UART_DEBUG_RX_GPIO);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "UART0 RX 泵任务创建失败(堆不足?)");
|
||||
}
|
||||
}
|
||||
|
||||
m_initialized = true;
|
||||
ESP_LOGI(TAG, "调试日志已初始化,上位机 %s:%d", DEBUG_LOG_IP, DEBUG_LOG_PORT);
|
||||
return ESP_OK;
|
||||
|
||||
@@ -288,13 +288,16 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
|
||||
|
||||
/* feedTask 字节喂完 ≠ GD32/电机已完成。suppress 期间 0xD2 被丢;
|
||||
* 末包 CAN ACK / 跳转 APP 还要数百 ms。只 QUERY 一次会把前端卡在 kTransfer。
|
||||
* 退出后轮询 QUERY,直到 GD32 进 FINISHED/ERROR/IDLE,或最多 ~8s。 */
|
||||
* 退出后轮询 QUERY,直到 GD32 进 FINISHED/ERROR/IDLE,或最多 ~8s。
|
||||
* 用户取消(s_feed_running 已被 motorOtaCancel 置 false)时跳过:
|
||||
* 设备端已进 IDLE、GD32 已 resetToIdle,无终态可等——立即收尾退出,
|
||||
* 把 cancel task 的等待窗口压到最短(也消除强删窗口)。 */
|
||||
{
|
||||
constexpr uint32_t kExitPollMs = 8000U;
|
||||
constexpr uint32_t kExitGapMs = 150U;
|
||||
const uint32_t exit_deadline = nowMs() + kExitPollMs;
|
||||
bool got_terminal = false;
|
||||
while (nowMs() < exit_deadline) {
|
||||
while (s_feed_running && nowMs() < exit_deadline) {
|
||||
/* 优先吃 ring 推送(已恢复 suppress) */
|
||||
{
|
||||
const motor_ota_status_t st = motorOtaGetStatus();
|
||||
@@ -395,6 +398,25 @@ int motorOtaStart(uint32_t fw_size, uint8_t channel)
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
300U); /* 300ms: fire-and-forget,让 worker 有足够时间完成 START。
|
||||
* 原 500ms 偏保守;GD32 收到 START 立即进 kPreCancel,<50ms 响应。 */
|
||||
if (ret == ESP_OK && resp_type != kRspAck && resp_len >= 1U &&
|
||||
resp_payload[0] == 0xE5U) {
|
||||
/* 0xE5 = GD32 端 relay 会话残留(上次 CANCEL 丢失/未达):不清会直接
|
||||
* 让本次 OTA 空转失败。补发 CANCEL(GD32 resetToIdle 归 kIdle)后
|
||||
* 重试一次 START,一次自愈即恢复,无需用户重试或刷新。 */
|
||||
DEBUG_WARN(TAG, "START 0xE5(GD32 残留会话)→ 补发 CANCEL 后重试 START");
|
||||
uint8_t ccmd[1] = {1U};
|
||||
uint8_t crt = 0;
|
||||
uint8_t crp[4] = {0};
|
||||
size_t crl = 0;
|
||||
SpiMaster::instance().resetCredit(); /* 解除背压,保证 CANCEL 发出 */
|
||||
(void)SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, ccmd, 1, &crt, crp, sizeof(crp), &crl, 500U);
|
||||
SpiMaster::instance().resetCredit(); /* GD32 已 resetToIdle,窗口对齐满 */
|
||||
ret = SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, cmd, 6,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
300U);
|
||||
}
|
||||
if (ret != ESP_OK || resp_type != kRspAck) {
|
||||
DEBUG_WARN(TAG, "START SPI 未确认 ACK (ret=%s resp=0x%02X)——fire-and-forget 继续",
|
||||
esp_err_to_name(ret), resp_type);
|
||||
@@ -441,7 +463,7 @@ void motorOtaCancel(void)
|
||||
}
|
||||
|
||||
/* 通知 GD32 取消 relay 状态机 + 等 feedTask 退出是耗时操作
|
||||
*(SPI transact 最多 1s + feedTask 退出最多 2s),不能在 httpd 线程
|
||||
*(SPI transact 最多 1s + feedTask 退出最长 ~8.2s),不能在 httpd 线程
|
||||
* 同步阻塞,否则 cancel HTTP 请求本身会超时("Failed to fetch"),
|
||||
* 且 httpd 单线程会饿死其他端点。
|
||||
* 用独立 task 异步执行,HTTP handler 立即返回。 */
|
||||
@@ -453,7 +475,12 @@ void motorOtaCancel(void)
|
||||
}
|
||||
|
||||
auto do_cancel = [](void *) {
|
||||
/* 通知 GD32 取消 relay 状态机 */
|
||||
/* ★ 先复位 credit 再发 CANCEL:取消的典型时机恰是传输卡住(GD32 缓冲满,
|
||||
* 最后一帧 RESPONSE credit=0 已锁存进 m_remoteCredit)——不先解除背压,
|
||||
* CANCEL 自己会被 CREDIT 背压 refuse,GD32 端 relay 会话收不到取消而残留,
|
||||
* 下次 START 被 GD32 0xE5 拒 → "取消后重新开始没法开始"。
|
||||
* GD32 收到 CANCEL 后 resetToIdle 恢复满窗口,与复位值对齐。 */
|
||||
SpiMaster::instance().resetCredit();
|
||||
uint8_t cmd[1] = {1U}; /* cmd=CANCEL */
|
||||
uint8_t resp_type = 0;
|
||||
uint8_t resp_payload[4];
|
||||
@@ -462,22 +489,43 @@ void motorOtaCancel(void)
|
||||
kSpiMotorOtaCmd, cmd, 1,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
1000U);
|
||||
if (ret != ESP_OK) {
|
||||
/* 重试一次:再复位 credit(防首次期间又被失配帧锁 0)后重发 */
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
SpiMaster::instance().resetCredit();
|
||||
ret = SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, cmd, 1,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
1000U);
|
||||
}
|
||||
DEBUG_WARN(TAG, "cancel: GD32 resp ret=%s type=0x%02X", esp_err_to_name(ret), resp_type);
|
||||
|
||||
/* 等待 feedTask 真正退出(最多 2s),避免 s_feed_running 已清但 task
|
||||
* 仍卡在 SpiMaster::transact 阻塞中,导致 motorOtaIsActive() 瞬时仍 true。 */
|
||||
/* 等待 feedTask 真正退出。feedTask 最坏存活:主循环收尾 ≤0.2s +
|
||||
* 退出轮询 8s ≈ 8.2s(120s 整体超时路径同样有界),等待上限取 10s
|
||||
* 覆盖之——正常路径 feedTask 因 s_status 已被置 IDLE 在 ~0.3s 内退出,
|
||||
* 这里只是兜底;尽量不强删(强删会泄漏 job 槽 + worker 悬垂栈写)。 */
|
||||
TaskHandle_t t = s_feed_task;
|
||||
if (t) {
|
||||
for (int i = 0; i < 200 && s_feed_task != nullptr; i++) {
|
||||
for (int i = 0; i < 1000 && s_feed_task != nullptr; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
if (s_feed_task != nullptr) {
|
||||
DEBUG_ERR(TAG, "cancel: feedTask 未在 2s 内退出,强制删除");
|
||||
DEBUG_ERR(TAG, "cancel: feedTask 未在 10s 内退出,强制删除");
|
||||
vTaskDelete(s_feed_task);
|
||||
s_feed_task = nullptr;
|
||||
/* 强删绕过了 feedTask 收尾,suppress 必须在此恢复,
|
||||
* 否则 ring service 永久关闭 → GD32 推送全部收不到。 */
|
||||
SpiMaster::instance().setSuppressRingService(false);
|
||||
}
|
||||
}
|
||||
s_feed_running = false; /* 双保险 */
|
||||
/* credit 复位:取消的典型时机是传输卡住(GD32 缓冲满)——最后一帧
|
||||
* RESPONSE 的 credit=0(session 合法)被锁存进 m_remoteCredit。
|
||||
* GD32 resetToIdle 后转空闲不再发帧,relay 又跑在 APP(不重启、
|
||||
* 无 session=0 帧)→ 没有任何 resync 入口能把 credit 送回来,
|
||||
* 后续所有 transact 被 CREDIT 背压拒绝 → 网页所有消息收不到。
|
||||
* GD32 端 clearOtaSessionQueues 已把接收窗口恢复满,此处对齐复位。 */
|
||||
SpiMaster::instance().resetCredit();
|
||||
cancel_task = nullptr;
|
||||
vTaskDelete(NULL);
|
||||
};
|
||||
@@ -487,6 +535,7 @@ void motorOtaCancel(void)
|
||||
DEBUG_ERR(TAG, "cancel: failed to create cancel task, doing sync cancel");
|
||||
cancel_task = nullptr;
|
||||
/* 降级:同步执行(极端 OOM 情况) */
|
||||
SpiMaster::instance().resetCredit(); /* 先解除背压,保证 CANCEL 能发出 */
|
||||
uint8_t cmd[1] = {1U};
|
||||
uint8_t resp_type = 0;
|
||||
uint8_t resp_payload[4];
|
||||
@@ -503,9 +552,11 @@ void motorOtaCancel(void)
|
||||
if (s_feed_task != nullptr) {
|
||||
vTaskDelete(s_feed_task);
|
||||
s_feed_task = nullptr;
|
||||
SpiMaster::instance().setSuppressRingService(false);
|
||||
}
|
||||
}
|
||||
s_feed_running = false;
|
||||
SpiMaster::instance().resetCredit(); /* 同 do_cancel:防 credit=0 锁存死锁 */
|
||||
}
|
||||
DEBUG_WARN(TAG, "cancel: dispatched feed_running=%d", (int)s_feed_running);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
|
||||
slot.rx_count = rdU32Le(&e[4]);
|
||||
slot.seq = rdU32Le(&e[8]);
|
||||
memcpy(slot.data, &e[12], MOTOR_STATUS_DLC);
|
||||
slot.config_rx_count = rdU32Le(&e[44]);
|
||||
slot.config_seq = rdU32Le(&e[48]);
|
||||
memcpy(slot.config_data, &e[52], MOTOR_CONFIG_DLC);
|
||||
|
||||
offset += MOTOR_ENTRY_SIZE;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
*
|
||||
* payload 格式(每页):
|
||||
* [0] page, [1] total_pages, [2] entry_count, [3] active_count
|
||||
* [4..] 每个 entry 44B:
|
||||
* [4..] 每个 entry 76B:
|
||||
* [0] node_id, [1] channel, [2] online, [3] dlc,
|
||||
* [4..7] rx_count (LE), [8..11] seq (LE),
|
||||
* [12..43] data[32] (原始 STATUS payload)
|
||||
* [44..47] config_rx_count (LE), [48..51] config_seq (LE),
|
||||
* [52..75] config_data[24] (原始 CONFIG payload, 目标值回报)
|
||||
*
|
||||
* 同一节点号可能同时挂在多个 CAN 通道(两台电机同号异线),
|
||||
* 缓存表与 GD32 端一致按 node×channel 双键存储(27 槽)。
|
||||
@@ -29,9 +31,10 @@ extern "C" {
|
||||
#define MOTOR_CHANNELS 3
|
||||
#define MOTOR_MAX_SLOTS (MOTOR_MAX_NODES * MOTOR_CHANNELS)
|
||||
#define MOTOR_STATUS_DLC 32
|
||||
#define MOTOR_ENTRY_SIZE 44
|
||||
#define MOTOR_CONFIG_DLC 24
|
||||
#define MOTOR_ENTRY_SIZE 76
|
||||
#define MOTOR_PAGE_HEADER 4
|
||||
#define MOTOR_TOTAL_PAGES 6
|
||||
#define MOTOR_TOTAL_PAGES 9
|
||||
|
||||
/** @brief 单电机状态快照。 */
|
||||
typedef struct {
|
||||
@@ -42,6 +45,9 @@ typedef struct {
|
||||
uint32_t rx_count; ///< 累计收到帧数
|
||||
uint32_t seq; ///< GD32 侧更新序号
|
||||
uint8_t data[MOTOR_STATUS_DLC]; ///< 原始 STATUS payload
|
||||
uint32_t config_rx_count; ///< 累计收到 CONFIG 帧数
|
||||
uint32_t config_seq; ///< CONFIG 更新序号
|
||||
uint8_t config_data[MOTOR_CONFIG_DLC]; ///< 原始 CONFIG payload (目标值回报)
|
||||
} motor_slot_t;
|
||||
|
||||
/** @brief 全局电机状态表。 */
|
||||
|
||||
@@ -310,6 +310,16 @@ esp_err_t OtaCoordinator::triggerGd32Relay()
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* ★ 包级防呆:SHA 匹配只证明"传对了",不证明"传的是对的包"。
|
||||
* 把 BOOT 包中继进 APP 区会擦掉好固件,触发前必须确认镜像身份为 APP。
|
||||
* 返回 ESP_ERR_INVALID_SIZE 供调用方(WiFi/BLE)映射成具体错误提示。 */
|
||||
const Gd32ImageIdentity identity = Gd32FlashStore::instance().validateStoredImageIdentity();
|
||||
if (identity != Gd32ImageIdentity::kApp) {
|
||||
DEBUG_ERR(TAG, "拒绝中继:GD32 固件身份校验失败 (identity=%d)", (int)identity);
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (OtaRelay::getState() != RELAY_IDLE &&
|
||||
OtaRelay::getState() != RELAY_DONE &&
|
||||
OtaRelay::getState() != RELAY_ERROR) {
|
||||
|
||||
@@ -76,6 +76,18 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
|
||||
DEBUG_INFO(TAG, "GD32就绪 SHA匹配 %" PRIu32 "/%" PRIu32 ",触发中继",
|
||||
meta.stored_size, meta.total_size);
|
||||
|
||||
/* ★ 包级防呆:SHA 匹配只证明传输无误。BOOT 包/非 APP 包在此拒绝,
|
||||
* 不进入 SPI 中继,GD32 APP 区不会被擦除。 */
|
||||
const Gd32ImageIdentity identity = Gd32FlashStore::instance().validateStoredImageIdentity();
|
||||
if (identity != Gd32ImageIdentity::kApp) {
|
||||
DEBUG_ERR(TAG, "GD32 固件身份校验失败 identity=%d,拒中继", (int)identity);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FW_IDENTITY,
|
||||
packBleFinishValue(session_id,
|
||||
(identity == Gd32ImageIdentity::kBootloader) ? 1 : 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (OtaRelay::getResultQueue()) {
|
||||
uint32_t stale = 0;
|
||||
while (xQueueReceive(OtaRelay::getResultQueue(), &stale, 0) == pdTRUE) {
|
||||
@@ -83,6 +95,13 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
}
|
||||
}
|
||||
esp_err_t trig_err = OtaCoordinator::instance().triggerGd32Relay();
|
||||
if (trig_err == ESP_ERR_INVALID_SIZE) {
|
||||
/* 兜底:triggerGd32Relay 内部身份校验拒绝(与上方同判据) */
|
||||
DEBUG_ERR(TAG, "GD32 固件身份校验失败(trigger 兜底),拒中继");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FW_IDENTITY,
|
||||
packBleFinishValue(session_id, 2));
|
||||
return 0;
|
||||
}
|
||||
if (trig_err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "GD32 中继触发失败: %s", esp_err_to_name(trig_err));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84
|
||||
#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85
|
||||
#define BLE_OTA_FINISH_ERR_GD32_RELAY 0x86
|
||||
#define BLE_OTA_FINISH_ERR_FW_IDENTITY 0x87 ///< 包身份不符(疑似 Bootloader/非 APP 包),拒绝中继
|
||||
|
||||
struct BleOtaDataBatch {
|
||||
uint8_t *data;
|
||||
|
||||
@@ -230,6 +230,15 @@ esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
http_set_cors_headers(req);
|
||||
|
||||
/* chunked 请求(无 Content-Length 头)时 IDF 置 content_len=-1:
|
||||
* ESP32 分支无符号比较使收流循环失去长度上界,GD32 分支则一字节
|
||||
* 不写却返回 progress:100 假成功——必须前置拒绝 */
|
||||
if (req->content_len <= 0) {
|
||||
return http_send_json_error(req, "400 Bad Request",
|
||||
"bad_content_len",
|
||||
"missing or invalid Content-Length");
|
||||
}
|
||||
|
||||
uint8_t *recv_buf = s_ota_upload_recv;
|
||||
DEBUG_WARN("WIFI", "POST /ota/upload clen=%d free=%lu big=%lu",
|
||||
req->content_len,
|
||||
@@ -580,7 +589,27 @@ esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
|
||||
"sha_compute_failed", "SHA compute failed");
|
||||
}
|
||||
|
||||
/* ★ 包级防呆:SHA 匹配只证明传输无误,不证明包内容正确。
|
||||
* 上传 BOOT 包(或其他非 APP 包)时在此拒绝,GD32 不会被擦除 APP 区。 */
|
||||
const Gd32ImageIdentity identity = Gd32FlashStore::instance().validateStoredImageIdentity();
|
||||
if (identity != Gd32ImageIdentity::kApp) {
|
||||
(void)BleOta::instance().resumeStack();
|
||||
const char *msg = (identity == Gd32ImageIdentity::kBootloader)
|
||||
? "检测到 Bootloader 固件(复位向量在 0x08000000 区),已拒绝刷入 APP 区,请选择 APP 固件"
|
||||
: (identity == Gd32ImageIdentity::kInvalid)
|
||||
? "固件向量表非法,疑似非 GD32 APP 固件,已拒绝刷写"
|
||||
: "固件存储读取失败,无法校验身份,请重新上传";
|
||||
DEBUG_ERR("OTA", "GD32 固件身份校验失败 identity=%d,拒绝中继", (int)identity);
|
||||
return http_send_json_error(req, "400 Bad Request", "firmware_identity_mismatch", msg);
|
||||
}
|
||||
|
||||
esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay();
|
||||
if (trigger_err == ESP_ERR_INVALID_SIZE) {
|
||||
/* 兜底:triggerGd32Relay 内部身份校验拒绝(与上方同判据,正常不会走到) */
|
||||
(void)BleOta::instance().resumeStack();
|
||||
return http_send_json_error(req, "400 Bad Request", "firmware_identity_mismatch",
|
||||
"固件身份校验失败,已拒绝刷写");
|
||||
}
|
||||
if (trigger_err != ESP_OK) {
|
||||
(void)BleOta::instance().resumeStack();
|
||||
return http_send_json_error(req, "400 Bad Request",
|
||||
@@ -1145,6 +1174,9 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
|
||||
return http_send_json_error(req, "400 Bad Request", "bad_args", "ch 字段格式错误");
|
||||
}
|
||||
long ch_val = readInt();
|
||||
if (ch_val < 0 || ch_val >= CAN_CHANNEL_MAX) {
|
||||
return http_send_json_error(req, "400 Bad Request", "bad_args", "ch 非法(0~2)");
|
||||
}
|
||||
if (!findKey("\"frames\"")) {
|
||||
return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 frames 字段");
|
||||
}
|
||||
@@ -2184,8 +2216,9 @@ esp_err_t motor_state_handler(httpd_req_t *req)
|
||||
* count u8 (有效 entry 数)
|
||||
* rsvd u16 = 0
|
||||
* Entry: node u8, ch u8, online u8, dlc u8,
|
||||
* rx_count u32, seq u32, data[32]
|
||||
* 27 槽(9 node × 3 ch,node×channel 双键)最大 12 + 27*44 = 1200B。
|
||||
* rx_count u32, seq u32, data[32],
|
||||
* config_rx_count u32, config_seq u32, config_data[24]
|
||||
* 27 槽(9 node × 3 ch,node×channel 双键)最大 12 + 27*76 = 2064B。
|
||||
* 无 have_data 时仍发 header(count=0),让前端区分「无数据」与「空总线」。 */
|
||||
bool want_bin = http_query_bool(req, "bin");
|
||||
if (want_bin) {
|
||||
@@ -2234,6 +2267,21 @@ esp_err_t motor_state_handler(httpd_req_t *req)
|
||||
buf[off++] = (uint8_t)((sq >> 24) & 0xFF);
|
||||
memcpy(&buf[off], s.data, MOTOR_STATUS_DLC);
|
||||
off += MOTOR_STATUS_DLC;
|
||||
/* config_rx_count LE */
|
||||
uint32_t cr = s.config_rx_count;
|
||||
buf[off++] = (uint8_t)(cr & 0xFF);
|
||||
buf[off++] = (uint8_t)((cr >> 8) & 0xFF);
|
||||
buf[off++] = (uint8_t)((cr >> 16) & 0xFF);
|
||||
buf[off++] = (uint8_t)((cr >> 24) & 0xFF);
|
||||
/* config_seq LE */
|
||||
uint32_t csq = s.config_seq;
|
||||
buf[off++] = (uint8_t)(csq & 0xFF);
|
||||
buf[off++] = (uint8_t)((csq >> 8) & 0xFF);
|
||||
buf[off++] = (uint8_t)((csq >> 16) & 0xFF);
|
||||
buf[off++] = (uint8_t)((csq >> 24) & 0xFF);
|
||||
/* config_data[24] */
|
||||
memcpy(&buf[off], s.config_data, MOTOR_CONFIG_DLC);
|
||||
off += MOTOR_CONFIG_DLC;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@@ -2257,7 +2305,9 @@ esp_err_t motor_state_handler(httpd_req_t *req)
|
||||
}
|
||||
|
||||
char *w = s_http_json_work;
|
||||
int off = snprintf(w, HTTP_JSON_WORK_CAP,
|
||||
/* http_json_append 截断时钳制在 cap-1;snprintf 截断返回期望长度,
|
||||
* off += snprintf 会越过 cap 导致 resp_send 越界读 */
|
||||
int off = http_json_append(w, HTTP_JSON_WORK_CAP, 0,
|
||||
"{\"have_data\":true,\"active_count\":%u,\"boot_active\":%s,\"age_ms\":%lu,\"motors\":[",
|
||||
(unsigned)tbl.active_count, tbl.boot_active ? "true" : "false", (unsigned long)age);
|
||||
|
||||
@@ -2269,22 +2319,28 @@ esp_err_t motor_state_handler(httpd_req_t *req)
|
||||
/* data hex */
|
||||
char hex[MOTOR_STATUS_DLC * 2 + 1];
|
||||
http_bytes_to_hex_upper(s.data, MOTOR_STATUS_DLC, hex);
|
||||
char chex[MOTOR_CONFIG_DLC * 2 + 1];
|
||||
http_bytes_to_hex_upper(s.config_data, MOTOR_CONFIG_DLC, chex);
|
||||
|
||||
off += snprintf(w + off, HTTP_JSON_WORK_CAP - off,
|
||||
off = http_json_append(w, HTTP_JSON_WORK_CAP, off,
|
||||
"%s{\"node\":%u,\"ch\":%u,\"online\":%s,\"dlc\":%u,"
|
||||
"\"rx_count\":%lu,\"seq\":%lu,\"data\":\"%s\"}",
|
||||
"\"rx_count\":%lu,\"seq\":%lu,\"data\":\"%s\","
|
||||
"\"config_rx_count\":%lu,\"config_seq\":%lu,\"config_data\":\"%s\"}",
|
||||
first ? "" : ",",
|
||||
(unsigned)s.node_id, (unsigned)s.channel,
|
||||
s.online ? "true" : "false",
|
||||
(unsigned)s.dlc,
|
||||
(unsigned long)s.rx_count,
|
||||
(unsigned long)s.seq,
|
||||
hex);
|
||||
hex,
|
||||
(unsigned long)s.config_rx_count,
|
||||
(unsigned long)s.config_seq,
|
||||
chex);
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 2) {
|
||||
off += snprintf(w + off, HTTP_JSON_WORK_CAP - off, "]}");
|
||||
if (off > 0) {
|
||||
off = http_json_append(w, HTTP_JSON_WORK_CAP, off, "]}");
|
||||
}
|
||||
|
||||
esp_err_t ret = httpd_resp_send(req, w, off > 0 ? off : 0);
|
||||
@@ -2325,7 +2381,8 @@ esp_err_t motor_version_handler(httpd_req_t *req)
|
||||
|
||||
const int64_t now_ms = esp_timer_get_time() / 1000;
|
||||
char *w = s_http_json_work;
|
||||
int off = snprintf(w, HTTP_JSON_WORK_CAP, "{\"have_data\":true,\"nodes\":[");
|
||||
int off = http_json_append(w, HTTP_JSON_WORK_CAP, 0,
|
||||
"{\"have_data\":true,\"nodes\":[");
|
||||
|
||||
bool first = true;
|
||||
for (uint8_t i = 0; i < MOTORVER_MAX_NODES && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) {
|
||||
@@ -2337,7 +2394,7 @@ esp_err_t motor_version_handler(httpd_req_t *req)
|
||||
const long age = (s.recv_ms > 0 && now_ms >= s.recv_ms)
|
||||
? static_cast<long>(now_ms - s.recv_ms) : 0L;
|
||||
|
||||
off += snprintf(w + off, HTTP_JSON_WORK_CAP - off,
|
||||
off = http_json_append(w, HTTP_JSON_WORK_CAP, off,
|
||||
"%s{\"node\":%u,\"seq\":%lu,\"age_ms\":%ld,\"version\":\"%s\"}",
|
||||
first ? "" : ",",
|
||||
(unsigned)s.node_id,
|
||||
@@ -2347,8 +2404,8 @@ esp_err_t motor_version_handler(httpd_req_t *req)
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 2) {
|
||||
off += snprintf(w + off, HTTP_JSON_WORK_CAP - off, "]}");
|
||||
if (off > 0) {
|
||||
off = http_json_append(w, HTTP_JSON_WORK_CAP, off, "]}");
|
||||
}
|
||||
|
||||
esp_err_t ret = httpd_resp_send(req, w, off > 0 ? off : 0);
|
||||
@@ -2364,7 +2421,14 @@ esp_err_t motor_ota_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
http_set_cors_headers(req);
|
||||
|
||||
uint8_t channel = static_cast<uint8_t>(http_query_u32(req, "ch", 0));
|
||||
const uint32_t ch_q = http_query_u32(req, "ch", 0);
|
||||
if (ch_q >= CAN_CHANNEL_MAX) {
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_sendstr(req, "{\"error\":\"bad channel (0~2)\"}");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
uint8_t channel = static_cast<uint8_t>(ch_q);
|
||||
|
||||
const int content_len = req->content_len;
|
||||
if (content_len <= 0 || content_len > (int)MOTOR_OTA_MAX_FW_SIZE) {
|
||||
@@ -2480,7 +2544,14 @@ esp_err_t motor_ota_relay_handler(httpd_req_t *req)
|
||||
http_set_keepalive_hdr(req);
|
||||
DEBUG_INFO("MOTOR_OTA", "=== HTTP: POST /motor/ota/relay ===");
|
||||
|
||||
uint8_t channel = static_cast<uint8_t>(http_query_u32(req, "ch", 0));
|
||||
const uint32_t ch_q = http_query_u32(req, "ch", 0);
|
||||
if (ch_q >= CAN_CHANNEL_MAX) {
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_sendstr(req, "{\"error\":\"bad channel (0~2)\"}");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
uint8_t channel = static_cast<uint8_t>(ch_q);
|
||||
|
||||
/* 读 meta 拿 fw_size(motorOtaStart 参数的唯一来源)。
|
||||
* total_size==0 = 无固件可刷(未上传过,或上次 relay 成功后 metaClear 已消费)。
|
||||
|
||||
@@ -619,3 +619,56 @@ esp_err_t Gd32FlashStore::verifyFrontendSha(const uint8_t frontend_sha32[32],
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 包级身份校验(向量表内容判定)
|
||||
// ============================================================
|
||||
|
||||
/* GD32E508 Flash 布局常量,与 GD32E50X_BOOT sys/config.h 对齐:
|
||||
* BOOT 0x08000000 ~ 0x08011FFF(72KB)
|
||||
* APP 0x08048000 ~ 0x0807DFFB(APPLATEST_START + APP_MAX_SIZE,216KB-4)
|
||||
* SRAM 0x20000000 ~ 0x2001FFFF(128KB)
|
||||
* APP 固件链接基址 0x08048000、BOOT 链接基址 0x08000000,
|
||||
* 两者向量表第二字(Reset_Handler)必然落在各自区域内,据此区分包类型。
|
||||
* 注意:BOOT 侧 validateApplication() 使用同一判据,修改需两边同步。 */
|
||||
static constexpr uint32_t kGd32BootRegionStart = 0x08000000U;
|
||||
static constexpr uint32_t kGd32BootRegionEnd = 0x08012000U;
|
||||
static constexpr uint32_t kGd32AppRegionStart = 0x08048000U;
|
||||
static constexpr uint32_t kGd32AppRegionEnd = 0x08048000U + 0x00035FFCU;
|
||||
static constexpr uint32_t kGd32SramStart = 0x20000000U;
|
||||
static constexpr uint32_t kGd32SramEnd = 0x20020000U;
|
||||
|
||||
static inline uint32_t rd_le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
Gd32ImageIdentity Gd32FlashStore::validateStoredImageIdentity()
|
||||
{
|
||||
uint8_t vec[8] = {0};
|
||||
if (flashReadChunk(0, vec, sizeof(vec)) != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "身份校验:读取向量表失败");
|
||||
return Gd32ImageIdentity::kReadFailed;
|
||||
}
|
||||
|
||||
const uint32_t msp = rd_le32(&vec[0]);
|
||||
const uint32_t reset = rd_le32(&vec[4]) & ~1U; // 清除 Thumb 位
|
||||
|
||||
const bool msp_valid = (msp >= kGd32SramStart && msp < kGd32SramEnd);
|
||||
const bool reset_is_app = (reset >= kGd32AppRegionStart && reset < kGd32AppRegionEnd);
|
||||
const bool reset_is_boot = (reset >= kGd32BootRegionStart && reset < kGd32BootRegionEnd);
|
||||
|
||||
if (msp_valid && reset_is_app) {
|
||||
return Gd32ImageIdentity::kApp;
|
||||
}
|
||||
|
||||
if (reset_is_boot) {
|
||||
DEBUG_ERR("OTA", "身份校验:检测到 Bootloader 包 (MSP=0x%08lX RH=0x%08lX),禁止刷入 APP 区",
|
||||
(unsigned long)msp, (unsigned long)reset);
|
||||
return Gd32ImageIdentity::kBootloader;
|
||||
}
|
||||
|
||||
DEBUG_ERR("OTA", "身份校验:向量表非法 (MSP=0x%08lX RH=0x%08lX),非 GD32 APP 固件",
|
||||
(unsigned long)msp, (unsigned long)reset);
|
||||
return Gd32ImageIdentity::kInvalid;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,21 @@ typedef struct {
|
||||
uint8_t checksum[32]; ///< 固件 SHA-256 校验值
|
||||
} ota_meta_t;
|
||||
|
||||
/**
|
||||
* @brief gd32_ota 分区中存储镜像的身份分类(包级防呆)。
|
||||
*
|
||||
* 判定依据是镜像自身内容(Cortex-M 向量表前两个字),不依赖文件名:
|
||||
* SHA-256 只能证明"传对了",不能证明"传的是对的包"。
|
||||
* 把 BOOT 包(或任意非 APP 包)中继进 GD32 APP 区会擦掉好固件,
|
||||
* 因此触发中继前必须确认镜像身份为 kApp。
|
||||
*/
|
||||
enum class Gd32ImageIdentity : uint8_t {
|
||||
kApp = 0, ///< 复位向量落在 APP 区 → 合法 GD32 APP 固件
|
||||
kBootloader, ///< 复位向量落在 BOOT 区(0x08000000~0x08011FFF)→ Bootloader 包,禁止刷入 APP
|
||||
kInvalid, ///< 向量表非法 → 非 GD32 APP 固件(ESP32 镜像 / 文本 / 损坏数据)
|
||||
kReadFailed, ///< 分区读取失败,无法判定
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief GD32 OTA 固件 Flash 存储抽象层(纯静态工具类)。
|
||||
*
|
||||
@@ -139,6 +154,17 @@ public:
|
||||
esp_err_t verifyFrontendSha(const uint8_t frontend_sha32[32],
|
||||
uint8_t out_device_sha[32]);
|
||||
|
||||
/**
|
||||
* @brief 读取 gd32_ota 分区镜像开头的 Cortex-M 向量表并判定包身份。
|
||||
*
|
||||
* 检查初始 MSP 是否落在 GD32E508 SRAM、复位向量是否落在 APP 区
|
||||
* (0x08048000~0x0807DFFB,与 BOOT 侧 validateApplication() 同一判据)。
|
||||
* 复位向量落在 BOOT 区(0x08000000~0x08011FFF)则判为 Bootloader 包。
|
||||
* 供中继触发前的包级防呆使用(WiFi /ota/relay 与 BLE 0xF7 共用)。
|
||||
* @return 镜像身份分类;调用方仅应在 kApp 时允许中继。
|
||||
*/
|
||||
Gd32ImageIdentity validateStoredImageIdentity();
|
||||
|
||||
/**
|
||||
* @brief 获取分区指针(懒初始化)。
|
||||
* @return 分区指针,找不到时返回 nullptr。
|
||||
|
||||
@@ -455,6 +455,7 @@ SpiMaster::Job *SpiMaster::acquireJobSlot()
|
||||
slot->done = done;
|
||||
slot->finished = false;
|
||||
slot->abandoned = false;
|
||||
slot->executing = false;
|
||||
slot->in_use = true;
|
||||
break;
|
||||
}
|
||||
@@ -493,6 +494,7 @@ void SpiMaster::releaseJobSlot(Job *job)
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
job->finished = false;
|
||||
job->abandoned = false;
|
||||
job->executing = false;
|
||||
job->in_use = false;
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
}
|
||||
@@ -567,6 +569,7 @@ esp_err_t SpiMaster::submitJob(Job &job)
|
||||
pool_job->result = ESP_FAIL;
|
||||
pool_job->finished = false;
|
||||
pool_job->abandoned = false;
|
||||
pool_job->executing = false;
|
||||
/* done / in_use 保持 acquire 时状态 */
|
||||
|
||||
const TickType_t qwait = pdMS_TO_TICKS(
|
||||
@@ -578,8 +581,11 @@ esp_err_t SpiMaster::submitJob(Job &job)
|
||||
}
|
||||
xTaskNotify(m_workerTask, 0x10U, eSetBits);
|
||||
|
||||
/* dwait 覆盖 worker 单笔执行上界:otaSendAndPoll 最多 3 次尝试、每次 ≈
|
||||
* timeout_ms,故 3×timeout+10s 保证"已开始执行的 job"必然在提交方放弃前
|
||||
* 完成(执行中 worker 在写本函数调用方的栈缓冲,栈帧必须存活到 finished)。 */
|
||||
const TickType_t dwait = pdMS_TO_TICKS(
|
||||
(job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 10000U);
|
||||
3U * (job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 10000U);
|
||||
if (xSemaphoreTake(pool_job->done, dwait) == pdTRUE) {
|
||||
const esp_err_t result = pool_job->result;
|
||||
/* worker 在 give 后才写 finished;等其退出临界区后再由本侧独占 release。 */
|
||||
@@ -596,10 +602,15 @@ esp_err_t SpiMaster::submitJob(Job &job)
|
||||
return result;
|
||||
}
|
||||
|
||||
/* 超时:标记 abandoned;若 worker 已跑完则本侧 release,否则 finishPoolJob release。 */
|
||||
/* 超时:按 worker 是否已开始执行分型(executing 由 workerLoop dequeue 时置位)。 */
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
pool_job->abandoned = true;
|
||||
const bool executing = pool_job->executing;
|
||||
const bool already_done = pool_job->finished;
|
||||
if (!executing && !already_done) {
|
||||
/* 尚未执行:标 abandoned,worker dequeue 时跳过 executeJob——
|
||||
* 本函数即将返回,out_* 将指向已消亡栈帧,再执行即 use-after-return。 */
|
||||
pool_job->abandoned = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
|
||||
if (already_done) {
|
||||
@@ -608,8 +619,38 @@ esp_err_t SpiMaster::submitJob(Job &job)
|
||||
releaseJobSlot(pool_job);
|
||||
return result;
|
||||
}
|
||||
if (executing) {
|
||||
/* 执行中:worker 此刻正写调用方栈缓冲,不可返回,须等 finished。
|
||||
* 正常时执行上界已被 dwait 覆盖,这里只兜 dequeue/dwait 边界的尾差;
|
||||
* 再等一整个 dwait(执行中意味着 dequeue 早于 dwait,S+dwait 执行上界
|
||||
* ≤ 2×dwait 必然完成)。 */
|
||||
if (xSemaphoreTake(pool_job->done, dwait) == pdTRUE) {
|
||||
for (;;) {
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
const bool fin = pool_job->finished;
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
if (fin) {
|
||||
break;
|
||||
}
|
||||
vTaskDelay(1);
|
||||
}
|
||||
const esp_err_t result = pool_job->result;
|
||||
releaseJobSlot(pool_job);
|
||||
return result;
|
||||
}
|
||||
/* 仅 worker 本身卡死才会走到这里(执行上界被打破):标 abandoned
|
||||
* 让 worker(若仍能收尾)释放槽,本侧放弃等待。 */
|
||||
DEBUG_ERR("SPI", "worker 执行中 job 仍未完成 kind=%u(标放弃)",
|
||||
(unsigned)job.kind);
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
if (!pool_job->finished) {
|
||||
pool_job->abandoned = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
DEBUG_ERR("SPI", "worker job done timeout kind=%u(池槽交 worker 释放)",
|
||||
DEBUG_ERR("SPI", "worker job done timeout kind=%u(未执行已放弃,池槽交 worker 释放)",
|
||||
(unsigned)job.kind);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
@@ -652,16 +693,29 @@ 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;
|
||||
/* ★ 执行前原子检查 abandoned:提交方超时返回后 out_* 指向已消亡的
|
||||
* 栈帧,此时执行会写悬垂指针(use-after-return → 调用方栈破坏)。
|
||||
* executing 与 abandoned 判定同临界区:提交方超时路径据此区分
|
||||
* "未执行(标 abandoned 跳过)"与"执行中(必须等写完才能返回)"。 */
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
const bool job_abandoned = job->abandoned;
|
||||
job->executing = true;
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
if (!job_abandoned) {
|
||||
/* 计时 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;
|
||||
} else {
|
||||
DEBUG_WARN("SPI", "跳过已放弃 job kind=%u(提交方已超时返回)",
|
||||
(unsigned)job->kind);
|
||||
}
|
||||
finishPoolJob(job);
|
||||
continue;
|
||||
}
|
||||
@@ -985,9 +1039,12 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
|
||||
|
||||
SpiMaster &self = SpiMaster::instance();
|
||||
|
||||
/* 更新对端 credit */
|
||||
self.m_remoteCredit = hdr.credit;
|
||||
|
||||
/* ★ credit 更新必须晚于 session 校验:被拒收的帧(session 不匹配且非 0/非 EVENT)
|
||||
* 其 credit 字段不可信(可能是 GD32 旧 session 残留帧的背压值)。
|
||||
* 若在拒收前更新 m_remoteCredit,一次 credit=0 的失配帧就会把 m_remoteCredit
|
||||
* 锁存为 0,后续所有 transact 进入 otaTransferUnlocked 的 CREDIT 背压死循环
|
||||
* (drain 读到的帧 session 仍不匹配 → 持续 return false → m_remoteCredit 永远 0)。
|
||||
* 现象:一次通信后所有 SPI 出错,只有复位 GD32(发 session=0 触发 resync)才恢复。 */
|
||||
if (self.m_v2Active) {
|
||||
/* SESSION_ID 校验 */
|
||||
if (hdr.session_id != self.m_sessionId) {
|
||||
@@ -1002,10 +1059,11 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
|
||||
if (!is_event && !is_sess0) {
|
||||
static uint32_t s_sess_mm_logs = 0U;
|
||||
if ((++s_sess_mm_logs % 32U) == 1U) {
|
||||
DEBUG_WARN("SPI", "v2 SES mismatch got=%08lX exp=%08lX t=%02X #%lu",
|
||||
DEBUG_WARN("SPI", "v2 SES mismatch got=%08lX exp=%08lX t=%02X #%lu (credit 不更新)",
|
||||
(unsigned long)hdr.session_id, (unsigned long)self.m_sessionId,
|
||||
hdr.type, (unsigned long)s_sess_mm_logs);
|
||||
}
|
||||
/* 拒收:不更新 m_remoteCredit,避免失配帧的 credit 污染背压状态机 */
|
||||
return false;
|
||||
}
|
||||
if (is_sess0 && !is_event) {
|
||||
@@ -1024,6 +1082,13 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
|
||||
(unsigned long)hdr.session_id, (unsigned long)self.m_sessionId, hdr.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* session 校验通过(m_v2Active==false / 匹配 / sess0 放行 / EVENT 放行):
|
||||
* 此帧 credit 可信,更新对端 credit。 */
|
||||
self.m_remoteCredit = hdr.credit;
|
||||
|
||||
if (self.m_v2Active) {
|
||||
|
||||
/* 响应归属:ack_seq 应等于 m_lastSentSeq。
|
||||
*
|
||||
@@ -1754,6 +1819,9 @@ esp_err_t SpiMaster::appRequestBootloader()
|
||||
m_lastSentSeq = 0;
|
||||
m_rxAckedSeq = 0;
|
||||
m_expectedRxSeq = 0;
|
||||
/* credit 复位:避免旧 session 锁存的 credit=0 跨重同步污染 */
|
||||
m_localCredit = 8;
|
||||
m_remoteCredit = 8;
|
||||
uint8_t hf[V2_MAX_FRAME];
|
||||
size_t hl = 0;
|
||||
if (v2_build_hello_frame(m_sessionId, V2_ROLE_APP, hf, &hl)) {
|
||||
@@ -2149,6 +2217,10 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint)
|
||||
m_expectedRxSeq = 0;
|
||||
m_gd32Role = 0;
|
||||
m_v2Active = false;
|
||||
/* credit 与 init() 对齐:HELLO 成功时 m_remoteCredit 会被 ACK 覆盖,
|
||||
* 失败时保持 8(而非残留旧值,避免上次通信锁存的 credit=0 跨 session 污染)。 */
|
||||
m_localCredit = 8;
|
||||
m_remoteCredit = 8;
|
||||
|
||||
/* 2. 构建 HELLO 帧 */
|
||||
uint8_t hello_frame[V2_MAX_FRAME];
|
||||
|
||||
@@ -97,6 +97,13 @@ public:
|
||||
/** @brief 读取本端剩余可接收 credit(SPEC §10.1,/system/status 展示用)。 */
|
||||
uint8_t localCredit() const { return m_localCredit; }
|
||||
|
||||
/** @brief credit 窗口复位到初始值(电机 OTA 取消等半会话终止后调用)。
|
||||
* credit 只随帧头传递:对端转空闲后不再发帧,传输期锁存的 credit=0
|
||||
* 无法自愈 → 所有 transact 被 CREDIT 背压拒绝(死锁)。
|
||||
* GD32 端 resetToIdle 已恢复接收窗口(clearOtaSessionQueues 置满),
|
||||
* 本侧同步复位与之对齐;此后每帧帧头 credit 会覆盖为真实值。 */
|
||||
void resetCredit() { m_localCredit = 8; m_remoteCredit = 8; }
|
||||
|
||||
/** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */
|
||||
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; }
|
||||
|
||||
@@ -378,6 +385,7 @@ private:
|
||||
SemaphoreHandle_t done = nullptr; ///< 池槽静态 binary,init 创建,永不 delete
|
||||
volatile bool finished = false; ///< worker 已 execute + give
|
||||
volatile bool abandoned = false; ///< 提交方已超时返回,worker 负责 release 槽
|
||||
volatile bool executing = false; ///< worker 已 dequeue(即将/正在写 out_*;超时放弃分型用)
|
||||
bool in_use = false; ///< 池槽占用(critical 保护)
|
||||
};
|
||||
/** 队列深度;池 = 队列 + 1(在途执行槽),覆盖 queued + executing。 */
|
||||
|
||||
Reference in New Issue
Block a user