feat(protocol): 更新SPI Link协议至v2版本并优化CAN通信

- 将SPI OTA帧格式升级为SPI Link v2,采用新的帧格式:
  | SOF0 0xA5 | SOF1 0x5A | VER 0x02 | FLAGS | SESSION 4B LE |
  TX_SEQ | ACK_SEQ | CREDIT | TYPE | LEN 2B LE | RSV 2B | PAYLOAD |
  CRC32 4B LE |
- 新增V2_OVERHEAD=20字节开销,APP最大payload为236字节,BOOT为108字节
- 修改帧类型定义:ENTER_BOOT从0xB5合并至0xB0,新增CAN业务类型0xC0~C3
- 更新CAN协议文档,支持SPI Link v2-only模式,调整最大业务payload计算
- 扩展0xC1帧功能,支持双向通信和1Hz主动推送ID快照
- 添加0xC3完整ID快照帧类型,包含seq/rx_count等完整字段
- 更新协议文档中的错误码定义和反馈payload格式

refactor(main): 重构主程序入口并添加CAN反馈任务

- 在main.cpp中添加CAN反馈任务,更新启动流程注释
- 创建createCriticalTask辅助函数,统一关键任务创建逻辑
- 调整SPI初始化顺序,确保CAN反馈任务依赖正确
- 优化错误处理机制,关键任务失败时立即重启
- 将GD32版本探测移至ota_relay任务处理
- 更新版本号至0.3.48,调整编译时间和Git哈希

config: 更新SPI和BLE配置参数

- 添加SPI主机相关配置参数,包括CS GPIO、READY GPIO等引脚定义
- 配置SPI缓冲区大小:APP为256字节,BOOT为128字节
- 更新BLE滑动窗口参数,ACK窗口设为32,数据队列深度为72
- 调整SPI工作队列大小至5120字节,增加任务堆栈监控说明
This commit is contained in:
小龙虾
2026-07-16 23:00:15 +08:00
parent 51c89b3065
commit 5db3f66b8e
57 changed files with 4728 additions and 2098 deletions
+135 -206
View File
@@ -8,7 +8,9 @@
*/
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"
@@ -29,13 +31,16 @@
static constexpr size_t kSpiTransferMaxBytes = 256U;
/** @brief submitJob 堆 Job 与 worker 之间的 finished/abandoned 交接。 */
static portMUX_TYPE s_spiJobMux = portMUX_INITIALIZER_UNLOCKED;
/**
* @brief 发送路径 CS 时钟字节数。
* Boot TX DMA 按满缓冲预装(kTxBufSize=128),主机过短时钟会:
* - 截断延迟一拍的响应;
* - 两段变长读若中途释放 CS,Boot 会把 s_txPending 当已交付清掉 → 后续全 0xFF。
* 下限 64:覆盖 HELLO_ACK(~26)/短 ACK;上限 SPI_OTA_FRAME_MAX。
* App 大帧仍可由 poll 路径按 128/256 读。
* 下限 64:覆盖 HELLO_ACK(~26)/短 ACK;上限 SPI_OTA_FRAME_MAX(256)。
* APP 侧 TX 满缓冲 256:poll/ring 读预算见 spiRespReadBudget()。
*/
static size_t spiXferBytes(size_t frame_len)
{
@@ -49,10 +54,19 @@ static size_t spiXferBytes(size_t frame_len)
return n;
}
/** @brief 读响应时单 CS 时钟预算(Boot 满缓冲 128;App 可达 256)。 */
/**
* @brief 读响应时单 CS 时钟预算(按最近 HELLO 的 GD32 role 分支)。
* - BOOT:TX DMA 满缓冲 128 → 读 128,避免过长时钟 underrun
* - APP / unknown:TX DMA 满缓冲 256 → 读 256,避免 0xC1 等长 EVENT 被截断 CRC
* unknown 偏 APP:宁长勿短(BOOT 短帧在 256 时钟下尾部 0xFF,仍可 parse)。
*/
static size_t spiRespReadBudget(void)
{
return 128U;
const uint8_t role = SpiMaster::instance().gd32Role();
if (role == V2_ROLE_BOOT) {
return SPI_BOOT_TX_BUF_SIZE;
}
return SPI_APP_TX_BUF_SIZE;
}
/** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。
@@ -63,7 +77,7 @@ static int IRAM_ATTR readReadyLevelRaw(void)
return (REG_READ(GPIO_IN_REG) >> SPI_READY_GPIO) & 0x1;
}
/* 前向声明:waitSpiDataReady 定义在文件下方(waitSpiReady 之后),
/* 前向声明:waitSpiDataReady 定义在文件下方,
* 但 serviceRingOnce / otaPollResponseUnlocked 等更早出现的方法需要它。 */
static esp_err_t waitSpiDataReady(uint32_t timeout_ms);
@@ -244,15 +258,37 @@ void SpiMaster::executeJob(Job &job)
case JobKind::WaitBoot:
job.result = waitForBootloaderReady(job.timeout_ms);
break;
case JobKind::ServiceRing:
job.result = serviceRingOnce();
break;
default:
job.result = ESP_ERR_INVALID_ARG;
break;
}
}
void SpiMaster::finishHeapJob(Job *job)
{
if (job == nullptr) {
return;
}
/* 先 give,再标 finished;提交方 take 成功后会 spin 等 finished 再 free。 */
if (job->done) {
(void)xSemaphoreGive(job->done);
}
if (!job->heap_allocated) {
return;
}
taskENTER_CRITICAL(&s_spiJobMux);
job->finished = true;
const bool free_it = job->abandoned;
taskEXIT_CRITICAL(&s_spiJobMux);
if (free_it) {
if (job->done) {
vSemaphoreDelete(job->done);
job->done = nullptr;
}
free(job);
}
}
esp_err_t SpiMaster::submitJob(Job &job)
{
if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) {
@@ -260,31 +296,72 @@ esp_err_t SpiMaster::submitJob(Job &job)
return job.result;
}
StaticSemaphore_t done_buf;
job.done = xSemaphoreCreateBinaryStatic(&done_buf);
if (job.done == nullptr) {
executeJob(job);
return job.result;
/* 堆 Job + 动态 binary sem:避免调用方超时返回后栈上 Job/done 被 worker UAF。 */
Job *heap_job = static_cast<Job *>(calloc(1, sizeof(Job)));
if (heap_job == nullptr) {
DEBUG_ERR("SPI", "submitJob: calloc failed kind=%u", (unsigned)job.kind);
return ESP_ERR_NO_MEM;
}
*heap_job = job;
heap_job->heap_allocated = true;
heap_job->finished = false;
heap_job->abandoned = false;
heap_job->result = ESP_FAIL;
heap_job->done = xSemaphoreCreateBinary();
if (heap_job->done == nullptr) {
free(heap_job);
DEBUG_ERR("SPI", "submitJob: done sem create failed");
return ESP_ERR_NO_MEM;
}
(void)xSemaphoreTake(job.done, 0);
Job *ptr = &job;
const TickType_t qwait = pdMS_TO_TICKS(
(job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 2000U);
if (xQueueSend(m_jobQueue, &ptr, qwait) != pdTRUE) {
if (xQueueSend(m_jobQueue, &heap_job, qwait) != pdTRUE) {
DEBUG_WARN("SPI", "worker job queue full/timeout kind=%u", (unsigned)job.kind);
vSemaphoreDelete(heap_job->done);
free(heap_job);
return ESP_ERR_TIMEOUT;
}
/* 唤醒可能阻塞在 notify wait 的 worker */
xTaskNotify(m_workerTask, 0x10U, eSetBits);
const TickType_t dwait = pdMS_TO_TICKS(
(job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 10000U);
if (xSemaphoreTake(job.done, dwait) != pdTRUE) {
DEBUG_ERR("SPI", "worker job done timeout kind=%u", (unsigned)job.kind);
return ESP_ERR_TIMEOUT;
if (xSemaphoreTake(heap_job->done, dwait) == pdTRUE) {
const esp_err_t result = heap_job->result;
/* worker 在 give 后才写 finished;等其退出临界区后再由本侧独占 free。 */
for (;;) {
taskENTER_CRITICAL(&s_spiJobMux);
const bool fin = heap_job->finished;
taskEXIT_CRITICAL(&s_spiJobMux);
if (fin) {
break;
}
vTaskDelay(1);
}
vSemaphoreDelete(heap_job->done);
heap_job->done = nullptr;
free(heap_job);
return result;
}
return job.result;
/* 超时:标记 abandoned;若 worker 已跑完则本侧 free,否则 worker finishHeapJob free。 */
taskENTER_CRITICAL(&s_spiJobMux);
heap_job->abandoned = true;
const bool already_done = heap_job->finished;
taskEXIT_CRITICAL(&s_spiJobMux);
if (already_done) {
(void)xSemaphoreTake(heap_job->done, 0);
const esp_err_t result = heap_job->result;
vSemaphoreDelete(heap_job->done);
heap_job->done = nullptr;
free(heap_job);
return result;
}
DEBUG_ERR("SPI", "worker job done timeout kind=%u(堆 Job 交 worker 释放)",
(unsigned)job.kind);
return ESP_ERR_TIMEOUT;
}
void SpiMaster::workerEntry(void *arg)
@@ -312,9 +389,7 @@ void SpiMaster::workerLoop()
Job *job = nullptr;
if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(1)) == pdTRUE && job != nullptr) {
executeJob(*job);
if (job->done) {
xSemaphoreGive(job->done);
}
finishHeapJob(job);
continue;
}
@@ -333,76 +408,38 @@ void SpiMaster::workerLoop()
}
/**
* @brief GPIO 边沿中断回调(SPIready 线跳变)。
*
* - 下降沿:GD32 主动按铃(请求 ESP32 来读)。若当前空闲则置
* m_gd32RequestPending 并唤醒 SPI 任务。
* - 上升沿:GD32 释放 SPIready(WAITING_RESPONSE 态),唤醒等待任务。
* @brief GPIO 边沿中断回调(DATA_READY / PB6)。
*
* 仅处理下降沿:GD32 有待读数据(按铃)。若本端 IDLE 则置
* m_gd32RequestPending 并唤醒 SPI worker。上升沿不再使用(旧 waitSpiReady 已删)。
* ISR 内仅做标志翻转 + 任务通知,不做 SPI 传输。
*/
void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg)
{
SpiMaster *self = static_cast<SpiMaster *>(arg);
if (!self) { return; }
int level = readReadyLevelRaw();
if (level == 0) {
/* 下降沿 */
if (self->m_suppressRingService) {
/* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */
} else if (self->m_linkState.acceptRing()) {
/* GD32 按铃:仅 IDLE 态接受 */
self->m_gd32RequestPending = true;
if (self->m_spiTaskHandle) {
BaseType_t hp = pdFALSE;
xTaskNotifyFromISR(self->m_spiTaskHandle, 0x01U,
eSetBits, &hp);
portYIELD_FROM_ISR(hp);
}
}
} else {
/* 上升沿 */
if (self->m_linkState.state() == HandshakeState::WAITING_RESPONSE) {
/* GD32 释放 SPIready,可发起新 CS */
if (self->m_spiTaskHandle) {
BaseType_t hp = pdFALSE;
xTaskNotifyFromISR(self->m_spiTaskHandle, 0x02U,
eSetBits, &hp);
portYIELD_FROM_ISR(hp);
}
}
/* 只关心低电平(下降沿后采样):有数据待读 */
if (readReadyLevelRaw() != 0) {
return;
}
}
/**
* @brief 等 SPIready 适合发起下一笔主机事务(v2 DATA_READY 语义)。
*
* v2:PB6 低 = GD32 有待读数据;高 = 队列空/空闲。
* - 发新命令前:若 PB6 已高,立即返回;若仍低,可能是未读走的响应,
* 短暂等待其升高(BOOT 交付后应拉高)。超时不阻塞——CS 本身互斥。
* - 注意:poll 读响应路径不应调用本函数等 HIGH;应等 LOW 或直接读。
*/
esp_err_t SpiMaster::waitSpiReady(uint32_t timeout_ms)
{
if (timeout_ms == 0U) { return ESP_OK; }
/* 已高,立即返回 */
if (readReadyLevelRaw() == 1) { return ESP_OK; }
/* PB6 低:可能有未读响应。短等升高;BOOT 修好交付清 pending 后应很快。
* 超时返回 ESP_ERR_TIMEOUT,禁止调用方假装“已就绪”后盲发。 */
const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
while (xTaskGetTickCount() < deadline) {
if (readReadyLevelRaw() == 1) { return ESP_OK; }
vTaskDelay(pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1);
if (self->m_suppressRingService) {
/* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */
return;
}
if (!self->m_linkState.acceptRing()) {
return;
}
self->m_gd32RequestPending = true;
if (self->m_spiTaskHandle) {
BaseType_t hp = pdFALSE;
xTaskNotifyFromISR(self->m_spiTaskHandle, 0x01U, eSetBits, &hp);
portYIELD_FROM_ISR(hp);
}
m_stats.ready_timeouts++;
return ESP_ERR_TIMEOUT;
}
/**
* @brief 等 GD32 拉低 PB6(有待读响应)。
* v2 DATA_READY:低 = 有数据,与 waitSpiReady(等高)语义相反。
* v2 DATA_READY:低 = 有数据;高 = 队列空。TX 前不等 HIGH(CS 即互斥)。
* @return ESP_OK 已低;ESP_ERR_TIMEOUT 超时仍为高(调用方可再决定是否盲 poll)。
*/
static esp_err_t waitSpiDataReady(uint32_t timeout_ms)
@@ -538,7 +575,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
*
* 不用两段 KEEP_ACTIVE:Boot 在 CS 释放时无论是否读完都清 s_txPending,
* 中途断 CS 会丢 HELLO_ACK,后续 MISO 全 0xFF(线上已复现)。
* 预算 128B 对齐 Boot TX DMA 满缓冲;App 大 EVENT 仍可到 256(out_max)。
* 时钟预算见 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)
@@ -762,12 +799,12 @@ esp_err_t SpiMaster::init()
ready_cfg.mode = GPIO_MODE_INPUT; /* v2: 仅输入,不再开漏输出 */
ready_cfg.pull_up_en = GPIO_PULLUP_ENABLE;
ready_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
ready_cfg.intr_type = GPIO_INTR_ANYEDGE; /* 双边沿中断 */
ready_cfg.intr_type = GPIO_INTR_NEGEDGE; /* 仅下降沿:有数据待读 */
esp_err_t gpio_ret = gpio_config(&ready_cfg);
if (gpio_ret != ESP_OK) {
DEBUG_WARN("SPI", "SPI Ready 握手引脚 GPIO%d 配置失败: %s", SPI_READY_GPIO, esp_err_to_name(gpio_ret));
} else {
DEBUG_INFO("SPI", "SPI Ready 开漏握手已配置: GPIO%d (线与 GD32 PB6)", SPI_READY_GPIO);
DEBUG_INFO("SPI", "DATA_READY 输入已配置: GPIO%d (← GD32 PB6,低=有数据)", SPI_READY_GPIO);
}
/* 安装 GPIO ISR 服务(全局仅安装一次)并注册边沿回调 */
@@ -883,15 +920,9 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
}
}
/* 同步单 CS 传输:按帧长发送;延迟一拍下本笔 RX 为陈旧响应。 */
m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE);
/* v2 延迟一拍:PB6 低表示 GD32 有上一笔响应待读,step1 的 transferClocked
* 会自然把它当陈旧响应读走,不需要额外 drain(drain 会多消耗一个 CS 周期,
* 可能误吃掉主循环刚排好的 ACK 导致后续包永久丢 ACK)。
*
* ★ 不调 waitSpiReady(等 PB6 变高):v2 DATA_READY 下 PB6 低 = GD32 有待发
* 响应,OTA 中继期间几乎持续为低。旧代码每包忙等 200ms 超时,21 包累计
* ~4.2s 纯浪费,最终拖垮转发预算导致卡死。CS 本身即互斥,无需额外就绪等待。 */
/* 同步单 CS 传输:按帧长发送;延迟一拍下本笔 RX 为陈旧响应。
* v2:PB6 低 = 有待读;陈旧响应由本笔 transferClocked 自然读走(勿额外 drain,
* 以免误吃主循环刚排好的 ACK)。CS 即互斥,TX 前不等 PB6 HIGH。 */
m_linkState.enterBusy(HandshakeState::MASTER_SENDING);
const size_t xfer_len = spiXferBytes(tx_len);
@@ -1161,17 +1192,23 @@ bool SpiMaster::takeEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_
return false;
}
/* 循环等待:信号量到达后重新持锁取,取空则继续等(防虚假唤醒) */
const TickType_t deadline_ticks = pdMS_TO_TICKS(timeout_ms);
/* 用绝对截止时间:虚假唤醒/抢事件后按剩余时间等,避免每次重新等满 timeout。 */
const TickType_t start = xTaskGetTickCount();
const TickType_t total = pdMS_TO_TICKS(timeout_ms);
while (true) {
if (xSemaphoreTake(m_eventSem, deadline_ticks) != pdTRUE) {
return false; /* 超时 */
const TickType_t elapsed = xTaskGetTickCount() - start;
if (elapsed >= total) {
return false;
}
const TickType_t remain = total - elapsed;
if (xSemaphoreTake(m_eventSem, remain) != pdTRUE) {
return false;
}
LockGuard lock(m_mutex);
if (popEvent(type, payload, max_len, len)) {
return true;
}
/* 信号量被消费但队列空(可能被并发 takeEvent 抢走),继续等 */
/* 信号量被消费但队列空(可能被并发 takeEvent 抢走),继续等剩余时间 */
}
}
@@ -1213,112 +1250,6 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
/*expected_type=*/0xFFU);
}
/**
* @brief 带重试退避的事务(SPEC §9.2)。
*
* 同序号重试最多 3 次,退避 10/30/90ms;
* 3 次失败后 HELLO 重同步,再试 1 次。
* 调用者须已持有锁。
*/
esp_err_t SpiMaster::transactWithRetry(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
{
/* 保存原始序号用于重发时恢复 */
const uint8_t saved_tx_seq = m_txSeq;
static constexpr uint32_t kBackoffMs[] = { 10U, 30U, 90U };
static constexpr uint32_t kMaxRetries = 3U;
for (uint32_t retry = 0U; retry <= kMaxRetries; ++retry) {
if (retry > 0U) {
const uint32_t backoff = kBackoffMs[(retry - 1U) < 3U ? (retry - 1U) : 2U];
vTaskDelay(pdMS_TO_TICKS(backoff));
m_stats.retries++;
/* 重发时恢复原序号 */
m_txSeq = saved_tx_seq;
}
/* 使用两步法(持锁已由调用者完成,用 Unlocked 版本) */
const uint32_t send_timeout = (timeout_ms > 500U) ? 500U : timeout_ms;
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) {
if (retry < kMaxRetries) { continue; }
return send_ret;
}
const uint32_t poll_timeout = (timeout_ms > send_timeout)
? (timeout_ms - send_timeout) : 500U;
esp_err_t ret = otaPollResponseUnlocked(resp_type, resp_payload,
resp_max, resp_len, poll_timeout,
/*expected_type=*/0xFFU);
if (ret == ESP_OK) {
return ESP_OK;
}
if (ret == ESP_ERR_TIMEOUT) {
m_stats.ready_timeouts++;
}
/* 3 次重试后尝试 HELLO 重同步(最后一次循环) */
if (retry == kMaxRetries - 1U) {
DEBUG_WARN("SPI", "transactWithRetry: %u 次失败, HELLO 重同步...", (unsigned)retry);
m_v2Active = false;
/* HELLO 需要锁——但调用者已持锁。
* hello() 内部有 LockGuard,会递归死锁。
* 因此用 hello 的核心逻辑但不加锁:两阶段(发 + dummy 收)。 */
m_sessionId = esp_random() | 0x00000001U;
m_txSeq = 0;
m_lastSentSeq = 0;
m_rxAckedSeq = 0;
m_expectedRxSeq = 0;
uint8_t hello_frame[V2_MAX_FRAME];
size_t hello_len = 0;
if (v2_build_hello_frame(m_sessionId, 0U, hello_frame, &hello_len)) {
/* 延迟一拍连发 2 次,避免 DATA_READY/短读导致全 0xFF */
const size_t xfer_len = spiXferBytes(hello_len);
for (uint32_t hi = 0U; hi < 2U; ++hi) {
m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE);
(void)waitSpiReady(SPI_READY_TIMEOUT_MS);
m_linkState.enterBusy(HandshakeState::MASTER_SENDING);
uint8_t tx_p[SPI_OTA_FRAME_MAX];
uint8_t rx_r[SPI_OTA_FRAME_MAX];
memset(tx_p, 0xFF, xfer_len);
memcpy(tx_p, hello_frame, hello_len);
memset(rx_r, 0xFF, xfer_len);
esp_err_t hr = transferClocked(tx_p, xfer_len, rx_r, xfer_len, 2000U);
m_linkState.exitBusy();
if (hr != ESP_OK) {
continue;
}
v2_header_t hdr;
const uint8_t *pl = nullptr;
if (v2_parse_frame(rx_r, xfer_len, &hdr, &pl) &&
hdr.type == V2_TYPE_LINK_HELLO &&
(hdr.flags & V2_FLAG_RESPONSE) &&
hdr.payload_len >= 6U && pl &&
v2_get_u32le(&pl[0]) == m_sessionId) {
m_gd32Role = pl[4];
m_localCredit = pl[5];
m_remoteCredit = hdr.credit;
m_v2Active = true;
m_stats.resyncs++;
DEBUG_INFO("SPI", "transactWithRetry: HELLO 重同步成功 role=%u",
m_gd32Role);
break;
}
vTaskDelay(pdMS_TO_TICKS(5));
}
}
}
}
return ESP_ERR_TIMEOUT;
}
/**
* @brief 通知 GD32 APP 设置 OTA 标志并软复位进入 Bootloader(v2 标准帧)。
* @return ESP_OK 表示成功,否则返回错误码。
@@ -1713,8 +1644,6 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint)
while (xTaskGetTickCount() <= deadline) {
++attempt;
m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE);
(void)waitSpiReady(SPI_READY_TIMEOUT_MS);
m_linkState.enterBusy(HandshakeState::MASTER_SENDING);
uint8_t tx_padded[SPI_OTA_FRAME_MAX];