Files
ESP32_COMM/main/transport/spi_master.h
T
小龙虾 51c89b3065 feat(ota): 实现GD32 OTA包间背压机制,优化传输节奏控制
移除固定的包间延迟配置 `OTA_PACKET_DELAY_MS`,采用基于SPI Link v2
CREDIT背压的动态节奏控制,在SpiMaster中通过remoteCredit==0时wait/拒发
实现流量控制,提高OTA传输稳定性。

refactor(stack): 统一任务栈大小配置并添加CPU统计功能

- 在app_config.h中集中定义所有任务栈大小常量,基于实测水位优化分配
- 新增monitor/cpu_stats.cpp组件,提供1Hz FreeRTOS CPU占用采样
- 更新main.cpp中的任务创建使用新的栈大小常量
- 调整health_monitor和ota_relay等任务的栈分配

perf(ota): 改进ESP32和GD32 OTA分区预擦策略

- ESP32 OTA从边写边擦改为整分区预擦模式,避免写入时的扇区擦除阻塞
- GD32 OTA在prepare阶段进行整分区预擦,提升后续写入吞吐量
- 优化OTA协调器状态管理,修复FINALIZING状态残留问题

fix(log): 改进UDP日志发送的堆内存管理

- 当堆内存不足时记录UDP发送跳过的告警信息到ring buffer
- 降低UDP发送跳过的内存阈值从10KB到6KB
- 降低日志排水最小内存阈值从8KB到4KB

docs(readme): 更新OTA配置参数文档说明

移除已废弃的OTA_PACKET_DELAY_MS参数,更新包间节奏控制说明为
基于CREDIT背压机制的描述。
2026-07-16 01:24:12 +08:00

436 lines
19 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* spi_master.h - SPI Master 驱动头文件
* CANFD 3通道发送接口 + 底层 SPI 总线
*
* SpiMaster 类封装 SPI 总线初始化、CANFD 通信和 SPI OTA 协议。
*
* 握手架构(线与 SPIready,GPIO10<->GD32 PB6 开漏单线):
* - 空闲态线 HIGH(双方释放,靠上拉)。
* - ESP32 主动发起:下拉 SPIready -> 立刻 CS 发请求 -> CS 释放后保持下拉,
* GD32 在 onCsRelease() ISR 内完成帧处理+响应装载后释放 SPIready(高),
* ESP32 GPIO 上升沿中断唤醒后发起 CS#2 读响应。
* - GD32 主动按铃:下拉 SPIready -> ESP32 下降沿中断唤醒后发起读事务。
*/
#ifndef SPI_MASTER_H
#define SPI_MASTER_H
#include <stdint.h>
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "spi_link_v2.h"
// SPI 配置常量
#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚
#define SPI_CLOCK_HZ CONFIG_DCU_SPI_CLOCK_HZ // 默认 4MHz,可在 menuconfig 手动提速
#define SPI_MODE (0) // CPOL=0, CPHA=0
#define SPI_QUEUE_SIZE (4) ///< SPI 事务队列深度
#define SPI_READY_GPIO (10) // 线与 SPIready 握手引脚(开漏,GPIO10<->GD32 PB6)
#define SPI_READY_TIMEOUT_MS (200) // 等待 SPIready 上升沿超时(毫秒)
#define SPI_OTA_FRAME_MAX (256) ///< SPI 同步帧最大字节数
// GD32 SPI 同步帧类型
#define SPI_OTA_CMD_REQ (0x01)
#define SPI_OTA_CMD_DATA (0x02)
#define SPI_APP_ENTER_BOOT_CMD (0xB0)
#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5) // GD32 APP 固件须同步修改 ACK 字节为 0xB5
#define SPI_OTA_RSP_ACK (0x80)
#define SPI_OTA_RSP_WAIT (0x81)
#define SPI_OTA_RSP_ERROR (0x82)
// GD32 版本信息(从 APP PING 响应解析)
typedef struct {
uint16_t app_major;
uint16_t app_minor;
uint16_t app_patch;
uint16_t app_build;
uint16_t boot_major;
uint16_t boot_minor;
uint16_t boot_patch;
uint16_t boot_build;
} gd32_version_t;
/**
* @brief SPI Master 驱动类(单例)
*
* 封装 SPI 总线初始化、同步传输、CANFD 通信和 SPI OTA 协议。
*/
class SpiMaster {
public:
/**
* @brief 获取 SpiMaster 单例实例。
* @return SpiMaster 单例引用。
*/
static SpiMaster& instance();
/** @brief 初始化 SPI2 总线与目标从设备。 */
esp_err_t init();
// ============================================================
// SPI OTA 协议
// ============================================================
/** @brief 仅执行 SPI OTA 响应读取事务,不重复发送请求帧。 */
esp_err_t otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
/** @brief ESP32→GD32 发送命令 + 轮询读取真实响应(两步法,原子持锁)。
*
* 解决 GD32 "延迟一拍"协议:transact 首笔 CS 读到过期响应,
* otaPollResponse 读取本笔命令的真实响应。命令只发送一次,不重发。
* 适用于所有 ESP32→GD32 方向的命令(CAN 发送/模式切换/ID 快照等)。
*
* @param type 请求帧类型(如 0xC0/0xC2/0xC3)。
* @param payload 请求负载数据。
* @param payload_len 请求负载长度。
* @param resp_type 输出响应类型(ACK/WAIT/ERROR)。
* @param resp_payload 输出响应负载缓冲区。
* @param resp_max 响应负载缓冲区最大长度。
* @param resp_len 输出实际响应负载长度。
* @param timeout_ms 总超时(发送 + 轮询),内部分配。
* @return ESP_OK 成功读到响应;其他为 SPI 错误或超时。
*/
esp_err_t otaSendAndPoll(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);
/** @brief 读取对端最新上报的 CREDIT(0 表示对端忙,应 POLL 等待而非重发)。 */
uint8_t remoteCredit() const { return m_remoteCredit; }
/** @brief 读取本端剩余可接收 credit(SPEC §10.1,/system/status 展示用)。 */
uint8_t localCredit() const { return m_localCredit; }
/**
* @brief OTA 中继期间禁止 worker 按铃读。
*
* PB6 在 OTA 下也会因 WAIT/ACK 拉低。若 worker 在 job 间隙把 RESPONSE
* 当 EVENT 偷读,业务 poll 只能空等 → 约 10s 后 DATA 重发 → GD32 DUP-SEQ,
* 表现为刷写断断续续。中继开始设 true,结束/复位设 false。
*/
void setSuppressRingService(bool suppress);
/**
* @brief OTA 业务层确认当前请求已成功(结构化 ACK 校验通过)。
* 在 SPI 头 ack_seq 偶发失配但仍接受业务 ACK 时补齐 m_txSeq,避免后续包卡死。
*/
void confirmTxSeqAccepted();
/**
* @brief 统一请求入口:经 SPI worker 串行执行(send+poll)。
*/
esp_err_t transact(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);
/**
* @brief 取出 GD32 EVENT 帧(worker 已读入事件队列)。
* @param timeout_ms 等待超时(ms)。0 = 非阻塞(原语义)。
* @return true 取到事件;false 队列空且超时未到达。
*/
bool takeEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len,
uint32_t timeout_ms = 0U);
// ============================================================
// SPI Link v2 链路层(阶段1:帧格式 + 状态成员就位)
// ============================================================
/** @brief v2 链路状态统计(SPEC/spi_link_v2.md §12)。 */
struct SpiLinkStats {
uint32_t crc_errors; ///< CRC 校验失败计数
uint32_t duplicate_requests; ///< 重复请求计数(序号回退)
uint32_t seq_errors; ///< 序号跳变计数
uint32_t retries; ///< 重试总数
uint32_t ready_timeouts; ///< DATA_READY 超时计数
uint32_t rx_overruns; ///< RX overrun 计数
uint32_t resyncs; ///< HELLO 重同步次数
uint32_t max_resp_latency_us; ///< 最大响应延迟(微秒)
};
/** @brief v2 完整链路状态(SPEC §10.1,会话/credit + 统计)。 */
struct SpiLinkStatus {
uint8_t version; ///< 协议版本(固定 2)
uint8_t gd32_role; ///< 0=unknown, 1=app, 2=boot
uint32_t session_id; ///< 当前会话 ID
uint8_t local_credit; ///< 本端剩余 credit
uint8_t remote_credit; ///< 对端剩余 credit
SpiLinkStats stats; ///< 8 个统计计数器
};
/** @brief 当前会话 ID(0 = 未建立 session)。 */
uint32_t sessionId() const { return m_sessionId; }
/** @brief v2 session 是否已激活。 */
bool v2Active() const { return m_v2Active; }
/** @brief 最近一次 HELLO 得到的 GD32 角色(0=unknown, 1=app, 2=boot)。 */
uint8_t gd32Role() const { return m_gd32Role; }
/** @brief 获取链路统计快照(线程安全,持锁读取)。 */
SpiLinkStats linkStats();
/** @brief 获取完整链路状态(SPEC §10.1,会话/credit + 统计)。 */
SpiLinkStatus linkStatus();
/** @brief 发起 HELLO 握手建立 v2 session(SPEC §5.1)。
* 生成随机非零 SESSION_ID,发送 LINK_HELLO,等待 HELLO_ACK。
* 成功后 m_v2Active=true,序号归零。
* @param timeout_ms 总超时(毫秒),默认 3000。
* @param role_hint 期望的对端角色(0=any, 1=app, 2=boot)。
* @return ESP_OK 成功;ESP_ERR_TIMEOUT 超时;ESP_ERR_INVALID_RESPONSE 响应异常。 */
esp_err_t hello(uint32_t timeout_ms = 3000U, uint8_t role_hint = 0U);
/** @brief 通知 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。 */
esp_err_t appRequestBootloader();
/** @brief 通过 HELLO 轮询等待 Bootloader 就绪(v2 方式,不再依赖 PB6 序列)。
* App→Boot 切换后,ESP32 每 20ms 发送 HELLO(role_hint=boot),
* 等待 HELLO_ACK 且 role=boot,最长等待 timeout_ms。
* 超时返回 ESP_ERR_TIMEOUT。 */
esp_err_t waitForBootloaderReady(uint32_t timeout_ms);
/** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */
esp_err_t appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version = nullptr);
private:
/**
* @brief 私有默认构造函数。
*/
SpiMaster() = default;
/**
* @brief 私有析构函数。
*/
~SpiMaster() = default;
/**
* @brief 禁止复制构造。
*/
SpiMaster(const SpiMaster&) = delete;
/**
* @brief 禁止复制赋值。
*/
SpiMaster& operator=(const SpiMaster&) = delete;
spi_device_handle_t m_spiDevice = NULL;
SemaphoreHandle_t m_mutex = NULL;
// ── 线与 SPIready 握手状态机 ──
/** 握手状态机枚举。 */
enum class HandshakeState : uint8_t {
IDLE = 0, ///< 空闲,线 HIGH
MASTER_SENDING, ///< ESP32 已下拉 SPIready,正在 CS#1 发送请求
WAITING_RESPONSE, ///< CS#1 完成,等待 GD32 释放 SPIready(上升沿唤醒)
SERVING_GD32_RING, ///< 响应 GD32 按铃,正在读 GD32 请求帧
};
/**
* @brief SPI 链路状态机:封装握手状态 + 完成信号逻辑。
*
* 线状态语义(对齐 OTA):
* - 线 HIGH = 空闲(双方释放),可发起新事务
* - 线 LOW = 忙碌(某方下拉占用),禁止发起新事务
*
* ESP32 作为主动方(transact/appPing):enterBusy() 占用线 → transferClocked → exitBusy() 释放线
* ESP32 作为按铃响应方(takeEvent):同上
*/
class SpiLinkState {
public:
/** 进入忙碌态。 */
void enterBusy(HandshakeState hs) { m_hsState = hs; }
/** 退出忙碌态,回到 IDLE。 */
void exitBusy() { m_hsState = HandshakeState::IDLE; }
/** 当前是否空闲(可接受新按铃下降沿)。 */
bool isIdle() const { return m_hsState == HandshakeState::IDLE; }
/** 当前握手状态。 */
HandshakeState state() const { return m_hsState; }
/** ISR 检测下降沿时调用:仅 IDLE 态接受按铃。 */
bool acceptRing() const { return isIdle(); }
private:
volatile HandshakeState m_hsState = HandshakeState::IDLE;
};
SpiLinkState m_linkState;
/** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,仅 SPI worker 消费。 */
volatile bool m_gd32RequestPending = false;
/** OTA 中继时为 true:worker 不 serviceRing,避免偷读 OTA ACK/WAIT。 */
volatile bool m_suppressRingService = false;
/** SPI worker 任务句柄(ISR 唯一通知目标)。 */
TaskHandle_t m_spiTaskHandle = NULL;
TaskHandle_t m_workerTask = NULL;
QueueHandle_t m_jobQueue = NULL;
/** 事件计数信号量(worker push / takeEvent pop)。 */
SemaphoreHandle_t m_eventSem = NULL;
// ── 多槽事件队列(worker 写入,业务 takeEvent 读出)──
static constexpr uint8_t kEventSlots = 4U;
struct EventSlot {
uint8_t type;
uint16_t len;
uint8_t data[SPI_OTA_FRAME_MAX];
};
EventSlot m_eventQ[kEventSlots] = {};
uint8_t m_eventHead = 0;
uint8_t m_eventTail = 0;
uint8_t m_eventCount = 0;
// ── 作业提交(调用方栈上 Job + StaticSemaphore,worker 执行)──
enum class JobKind : uint8_t {
Transact = 0,
Poll,
Hello,
EnterBoot,
Ping,
WaitBoot,
ServiceRing,
};
struct Job {
JobKind kind = JobKind::Transact;
uint32_t timeout_ms = 0;
uint8_t type = 0;
uint8_t expected_type = 0xFFU;
uint8_t role_hint = 0;
size_t payload_len = 0;
uint8_t payload[SPI_OTA_FRAME_MAX] = {};
uint8_t *out_type = nullptr;
uint8_t *out_payload = nullptr;
size_t out_max = 0;
size_t *out_len = nullptr;
uint32_t *out_uptime = nullptr;
gd32_version_t *out_version = nullptr;
esp_err_t result = ESP_FAIL;
SemaphoreHandle_t done = nullptr;
};
static constexpr UBaseType_t kJobQueueLen = 6;
bool onWorker() const;
esp_err_t submitJob(Job &job);
void executeJob(Job &job);
static void workerEntry(void *arg);
void workerLoop();
esp_err_t serviceRingOnce();
bool pushEvent(uint8_t type, const uint8_t *payload, size_t len);
bool popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len);
// ── 帧内同步状态机 ──
volatile uint8_t m_localCredit = 8; ///< 本端剩余可接收帧数(信用窗口)
volatile uint8_t m_remoteCredit = 8; ///< 对端上报的剩余信用
// ── SPI Link v2 会话/序号状态(阶段1 就位,阶段2 启用校验)──
volatile uint32_t m_sessionId = 0; ///< 会话 ID(0 = 未建立,HELLO 时由 ESP32 生成)
volatile uint8_t m_txSeq = 0; ///< 本端发送序号(每发一帧递增,重发不变)
volatile uint8_t m_lastSentSeq = 0; ///< 上次发送的序号(用于响应归属匹配)
volatile uint8_t m_rxAckedSeq = 0; ///< 已确认的对端最新序号
volatile uint8_t m_expectedRxSeq = 0; ///< 期望收到的对端序号
volatile uint8_t m_gd32Role = 0; ///< GD32 角色(0=unknown, 1=app, 2=boot)
volatile bool m_v2Active = false; ///< v2 session 是否激活
/** 最近一次 otaFindFrame 解析到的 RESPONSE 是否与 m_lastSentSeq 归属匹配 */
volatile bool m_lastRxAckMatched = false;
SpiLinkStats m_stats = {}; ///< 链路统计
// RAII 锁守卫
class LockGuard {
public:
explicit LockGuard(SemaphoreHandle_t lock) : m_lock(lock) {
if (m_lock) { xSemaphoreTake(m_lock, portMAX_DELAY); }
}
~LockGuard() {
if (m_lock) { xSemaphoreGive(m_lock); }
}
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
SemaphoreHandle_t m_lock;
};
/**
* @brief 等 GD32 释放 SPIready(上升沿),中断驱动非忙等。
*
* 调用前须将 m_linkState 置为 WAITING_RESPONSE(enterBusy),ISR 上升沿检测到该状态
* 后发 0x02 通知唤醒本任务。GD32 写 Flash 期间保持 PB6 低,本函数阻塞,
* 写完拉高 → 唤醒 → 继续,自然避免 overrun。
*
* @param timeout_ms 超时(毫秒),0 表示不等待。
* @return ESP_OK 线已高;ESP_ERR_TIMEOUT 超时。
*/
esp_err_t waitSpiReady(uint32_t timeout_ms);
/** @brief GPIO 边沿中断回调(注册到 ESP-IDF GPIO ISR 服务)。 */
static void IRAM_ATTR onReadyEdgeISR(void *arg);
// 内部 SPI 传输
/**
* @brief 执行底层 SPI 时钟同步传输。
* @param tx_buf 发送数据缓冲区。
* @param tx_len 发送数据长度。
* @param rx_buf 接收数据缓冲区。
* @param rx_len 接收数据长度。
* @param timeout_ms 超时时间(毫秒),0 表示无限等待。
* @return ESP_OK 表示成功,否则返回错误码。
*/
esp_err_t transferClocked(const uint8_t *tx_buf, size_t tx_len,
uint8_t *rx_buf, size_t rx_len,
uint32_t timeout_ms = 0U);
/**
* @brief 底层传输,支持 SPI 事务 flags(如 SPI_TRANS_CS_KEEP_ACTIVE)。
* 多段同 CS 变长读时:先 spi_device_acquire_bus,段间 KEEP_ACTIVE,末段清 flag 后 release_bus。
*/
esp_err_t transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
uint8_t *rx_buf, size_t rx_len,
uint32_t timeout_ms, uint32_t trans_flags);
/**
* @brief 变长读:同 CS 先读 16B Header,再按 LEN 读 payload+CRC。
* 调用者须已持锁;仅在 DATA_READY 已低(有数据)时调用,禁止盲读。
* @param out_frame 完整帧输出(含头+payload+CRC)
* @param out_max 输出缓冲容量
* @param out_len 实际帧长
* @param timeout_ms 单段 SPI 超时
*/
esp_err_t readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
uint32_t timeout_ms);
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
/**
* @brief 无锁版本的 OTA 响应轮询(调用者须已持有锁)。
* @param resp_type 输出响应类型。
* @param resp_payload 输出响应负载缓冲区。
* @param resp_max 响应负载缓冲区最大长度。
* @param resp_len 输出实际响应长度。
* @param timeout_ms 超时时间(毫秒)。
* @return ESP_OK 表示成功,否则返回错误码。
*/
esp_err_t otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms,
uint8_t expected_type = 0xFFU);
/** @brief 无锁版本的 otaTransfer(调用者须已持有锁)。
* @param expect_stale true 表示调用方预期读到过期/空响应(如 otaSendAndPoll 步骤1),
* 找不到帧时降级为 DBG 日志,不打 WARN。 */
esp_err_t otaTransferUnlocked(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,
bool expect_stale = false);
/** @brief 带重试退避的事务(SPEC §9.2)。
* 同序号重试最多 3 次,退避 10/30/90ms;
* 3 次失败后 HELLO 重同步,再试 1 次。
* 调用者须已持有锁。 */
esp_err_t 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);
// OTA 帧工具(静态,无状态)
static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *out, size_t *out_len);
static bool otaFindFrame(const uint8_t *buf, size_t buf_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len,
size_t *frame_offset);
};
#endif // SPI_MASTER_H