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:
小龙虾
2026-09-01 02:49:34 +08:00
parent ef6d3a4be3
commit ebb3dcdf38
62 changed files with 462 additions and 19644 deletions
+2 -2
View File
@@ -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"
+38
View File
@@ -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;
+59 -8
View File
@@ -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);
}
+3
View File
@@ -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;
}
+9 -3
View File
@@ -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 全局电机状态表。 */
+10
View File
@@ -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) {
+19
View File
@@ -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,
+1
View File
@@ -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;
+85 -14
View File
@@ -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 已消费)。
+53
View File
@@ -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;
}
+26
View File
@@ -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。
+90 -18
View File
@@ -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];
+8
View File
@@ -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。 */