diff --git a/GD32E508_DCU_APP/device/CANFD.cpp b/GD32E508_DCU_APP/device/CANFD.cpp index 5e252d5..37bacb4 100644 --- a/GD32E508_DCU_APP/device/CANFD.cpp +++ b/GD32E508_DCU_APP/device/CANFD.cpp @@ -1,4 +1,4 @@ -/** +/** * @file canfd.cpp * @brief CANFD 通信模块实现。 */ @@ -1284,6 +1284,14 @@ void CanFd::refreshFeedbackSnapshot() } s_feedbackSeq++; + /* 每 5 秒打印一次 seq 心跳,确认 refreshFeedbackSnapshot 在运行 */ + if (s_feedbackSeq % 5U == 0U) { + printf("[CAN-FB] refresh seq=%lu rx0=%lu rx1=%lu rx2=%lu\r\n", + (unsigned long)s_feedbackSeq, + (unsigned long)s_channelStats[0].rx_count, + (unsigned long)s_channelStats[1].rx_count, + (unsigned long)s_channelStats[2].rx_count); + } for (size_t i = 0; i < kChannelCount; ++i) { s_feedback[i].snapshot_seq = s_feedbackSeq; s_feedback[i].stats = s_channelStats[i]; diff --git a/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_feedback.cpp b/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_feedback.cpp index d42bcff..e94d076 100644 --- a/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_feedback.cpp +++ b/GD32E508_DCU_APP/device/spi/app_spi_cmd_can_feedback.cpp @@ -238,11 +238,23 @@ static SpiCanFeedbackHandler s_handler; * 否则构建当前页反馈 payload 并通过 requestEsp32Read(0xC1) 主动推送到 ESP32, * 然后推进游标。ESP32 检测到 SPIready 下降沿后发起读事务取走该帧, * CS 释放后 GD32 中断唤醒主循环继续推送下一帧。 + * + * 1Hz 节流由任务调度器驱动:taskHeartbeat() 每秒调用 canFeedbackStartRound() + * 激活新轮次,canFeedbackPushStep() 推完一轮(回到 ch=0,page=0)后清除标志。 + * 与 CanFd::refreshFeedbackSnapshot() 的 1Hz 刷新对齐,避免重复推同一个 seq。 */ +static volatile bool s_roundActive = true; /* 首次允许启动,无需等 1s */ + +void canFeedbackStartRound() +{ + s_roundActive = true; +} + void canFeedbackPushStep() { - static uint8_t s_channel = 0U; - static uint8_t s_page = 0U; + static uint8_t s_channel = 0U; + static uint8_t s_page = 0U; + static uint32_t s_pushCount = 0U; /* 已收到 0xB0 进入 Bootloader 请求,停止主动按铃避免干扰 OTA 流程。 * 复位前主循环仍在运行,但不再下拉 SPIready,让 ESP32 的 @@ -251,24 +263,38 @@ void canFeedbackPushStep() return; } + /* 1Hz 节流:当前轮次未激活则等待 taskHeartbeat() 触发 */ + if (!s_roundActive) { + return; + } + /* 上一帧尚未被 ESP32 读走,等待(不占用主线带宽) */ if (AppSpiCommandBus.isRingPending()) { return; } + /* ESP32 尚未完成上一帧处理(未释放 SPIready),等待 EXTI6 上升沿。 + * 防止 GD32 在 ESP32 回到 IDLE 前按铃导致下降沿被 ISR 丢弃。 */ + if (!AppSpiCommandBus.canRingNow()) { + return; + } + uint8_t payload[kFeedbackPayloadLen] = {}; uint16_t payload_len = buildFeedbackPayload(s_channel, s_page, payload, sizeof(payload)); if (payload_len > 0U) { (void)AppSpiCommandBus.requestEsp32Read(SpiDefs::kTypeCanFeedback, payload, payload_len); + ++s_pushCount; /* 仅保留计数,不打印心跳日志 */ } - /* 推进到下一页/下一通道,无限循环 3 通道 × 2 页 */ + /* 推进到下一页/下一通道,循环遍历 3 通道 × 2 页 */ if (++s_page >= 2U) { s_page = 0U; if (++s_channel >= kFeedbackChannelCount) { s_channel = 0U; + /* 回到 ch=0,page=0:一轮推完,等待下一秒 taskHeartbeat 激活 */ + s_roundActive = false; } } } diff --git a/GD32E508_DCU_APP/device/spi/app_spi_command.cpp b/GD32E508_DCU_APP/device/spi/app_spi_command.cpp index 78f2a19..df9f68b 100644 --- a/GD32E508_DCU_APP/device/spi/app_spi_command.cpp +++ b/GD32E508_DCU_APP/device/spi/app_spi_command.cpp @@ -121,6 +121,18 @@ static constexpr uint16_t kSyncHeaderLen = 6U; static constexpr uint16_t kSyncCrcLen = 4U; static constexpr uint16_t kSyncOverhead = (uint16_t)(kSyncHeaderLen + kSyncCrcLen); +/* 扫描前 16 字节查找同步帧头 SOF0 SOF1,容忍 RXORERR 导致的首字节偏移。 + * 与 spi_try_parse_frame 的偏移扫描逻辑对齐。 */ +static bool containsSyncSof(const uint8_t *data, uint16_t len) +{ + if (!data || len < 2U) { return false; } + uint16_t last = (len < 16U) ? (uint16_t)(len - 1U) : 16U; + for (uint16_t i = 0U; i < last; ++i) { + if (data[i] == kSyncSof0 && data[i + 1U] == kSyncSof1) { return true; } + } + return false; +} + /* GD32 APP 侧同步状态机(ISR 与主循环只读/原子写,避免临界区开销)。 */ static volatile uint8_t g_localCredit = 8U; static volatile uint8_t g_remoteCredit = 8U; @@ -384,6 +396,28 @@ void AppSpiCommand::configureExti() nvic_irq_enable(EXTI10_15_IRQn, CONFIG_IRQ_PREEMPT_SPI_USART, 1U); } +void AppSpiCommand::configureSpiReadyExti() +{ + /* PB6 已在 configureGpio() 配置为开漏输出,此处启用 EXTI6 上升沿检测。 + * EXTI 检测引脚实际电平(IDR),与 GPIO 输出模式兼容。 + * 用于检测 ESP32 releaseReady 产生的上升沿(线从忙碌→空闲), + * 作为 ESP32 完成上一帧处理的可靠信号。 */ + gpio_exti_source_select(GPIO_PORT_SOURCE_GPIOB, GPIO_PIN_SOURCE_6); + exti_init(EXTI_6, EXTI_INTERRUPT, EXTI_TRIG_RISING); + exti_interrupt_enable(EXTI_6); + exti_interrupt_flag_clear(EXTI_6); + nvic_irq_enable(EXTI5_9_IRQn, CONFIG_IRQ_PREEMPT_SPI_USART, 1U); +} + +void AppSpiCommand::onSpiReadyExti() +{ + if (exti_interrupt_flag_get(EXTI_6) != RESET) { + exti_interrupt_flag_clear(EXTI_6); + /* 线升高(空闲):ESP32 已 releaseReady,上一帧处理完 */ + m_linkState.onEsp32Complete(); + } +} + /* ============================================================ * 默认命令注册入口(由各 app_spi_cmd_*.cpp 提供) @@ -407,6 +441,7 @@ void AppSpiCommand::init() m_ringPending = false; m_ringDeadline = 0U; m_ringTxLen = 0U; + m_linkState.reset(); memset(m_txBuf, 0xFF, sizeof(m_txBuf)); memset(m_logBuf, 0, sizeof(m_logBuf)); memset(m_commands, 0, sizeof(m_commands)); @@ -422,6 +457,7 @@ void AppSpiCommand::init() configureSpi0(); configureDma(); configureExti(); + configureSpiReadyExti(); } /* ============================================================ @@ -567,6 +603,8 @@ void AppSpiCommand::cancelRing() /* 清除待发响应,防止超时后残留帧在下一次 CS assert 时被错误输出 */ m_txLen = 0U; m_txPos = 0U; + /* 超时后允许重试,不依赖 ESP32 releaseReady 上升沿 */ + m_linkState.onRingTimeout(); /* 释放 SPIready(高阻),避免永久占用线 */ setSpiReady(true); } @@ -589,6 +627,8 @@ bool AppSpiCommand::requestEsp32Read(uint8_t frame_type, const uint8_t *payload, m_txPos = 0U; m_ringDeadline = SysTickTimer::getMs() + kRingTimeoutMs; m_ringPending = true; + /* 消费完成 flag,等 ESP32 releaseReady 触发 EXTI6 上升沿 */ + m_linkState.onRingStart(); /* 下拉 SPIready,通知 ESP32「我有请求帧要给你读」 */ setSpiReady(false); @@ -623,6 +663,7 @@ void AppSpiCommand::poll() uint32_t save2 = RT_CRITICAL_SAVE(); if (m_ringPending) { if ((int32_t)(SysTickTimer::getMs() - m_ringDeadline) >= 0) { + printf("[SPI-APP] 按铃超时取消 (ESP32 未响应, 释放 SPIready 重试)\r\n"); cancelRing(); } } @@ -713,7 +754,7 @@ void AppSpiCommand::onCsRelease() m_txPos = 0U; memset(m_txBuf, 0xFF, sizeof(m_txBuf)); /* 继续走 shouldIgnoreTransaction + processCommand(),不 return */ - } else if (m_rxLen >= 2U && m_rxBuf[0] == kSyncSof0 && m_rxBuf[1] == kSyncSof1) { + } else if (containsSyncSof(m_rxBuf, m_rxLen)) { /* ESP32 发的是协议帧(如 PING/OTA 等),不是按铃响应。 * 取消按铃,清空 TX 让 processCommand() 重新装载对应应答。 * 不释放 SPIready,processCommand() 末尾会释放。 */ @@ -781,8 +822,11 @@ void AppSpiCommand::onInterrupt() /* TBE 已由 DMA 替代,不再处理 */ if (spi_i2s_interrupt_flag_get(SPI0, SPI_I2S_INT_FLAG_RXORERR) != RESET) { + /* 读取并丢弃 overrun 字节以清除标志,但保留已收到的帧数据。 + * 丢失的字节会导致 CRC 校验失败,由 spi_try_parse_frame 优雅处理 + * (返回 false -> processCommand 构建 ERROR 响应), + * 而不是重置 m_rxLen 导致整帧丢失。 */ (void)spi_i2s_data_receive(SPI0); - m_rxLen = 0U; } if (spi_i2s_interrupt_flag_get(SPI0, SPI_I2S_INT_FLAG_FERR) != RESET) { @@ -811,3 +855,8 @@ extern "C" void EXTI10_15_IRQHandler(void) { AppSpiCommandBus.onCsExtiInterrupt(); } + +extern "C" void EXTI5_9_IRQHandler(void) +{ + AppSpiCommandBus.onSpiReadyExti(); +} diff --git a/GD32E508_DCU_APP/device/spi/app_spi_command.h b/GD32E508_DCU_APP/device/spi/app_spi_command.h index eb5297e..1e39eb0 100644 --- a/GD32E508_DCU_APP/device/spi/app_spi_command.h +++ b/GD32E508_DCU_APP/device/spi/app_spi_command.h @@ -5,6 +5,39 @@ class ISpiCommandHandler; +/** + * @brief GD32 侧 SPI 链路状态机:封装按铃状态 + ESP32 完成信号。 + * + * 线状态语义(对齐 OTA): + * - 线 HIGH = 空闲(双方释放),GD32 可发起新按铃 + * - 线 LOW = 忙碌(GD32 按铃下拉 或 ESP32 checkGd32Request 期间下拉) + * + * 状态流转: + * - canRingNow()=true(初始/ESP32 完成后)→ requestEsp32Read() → canRingNow()=false + * - ESP32 releaseReady 上升沿 → onEsp32Complete() → canRingNow()=true + * - 按铃超时 → onRingTimeout() → canRingNow()=true(允许重试) + */ +class SpiLinkState { +public: + /** 是否允许发起新按铃(ESP32 已完成上一帧 或 初始态)。 */ + bool canRingNow() const { return m_esp32Ready; } + + /** 发起按铃时调用(消费完成 flag)。 */ + void onRingStart() { m_esp32Ready = false; } + + /** ESP32 releaseReady 上升沿触发(EXTI6 ISR 调用)。 */ + void onEsp32Complete() { m_esp32Ready = true; } + + /** 按铃超时取消时调用(允许重试,不依赖 EXTI)。 */ + void onRingTimeout() { m_esp32Ready = true; } + + /** 初始化(首帧允许)。 */ + void reset() { m_esp32Ready = true; } + +private: + volatile bool m_esp32Ready = true; +}; + /** * @brief APP 运行期 SPI 链路与命令路由模块。 * @@ -38,6 +71,9 @@ public: /** @brief CS 边沿中断处理入口。 */ void onCsExtiInterrupt(); + /** @brief SPI Ready(PB6) EXTI 上升沿中断处理入口(ESP32 完成信号)。 */ + void onSpiReadyExti(); + /** * @brief 注册一个 SPI 命令处理器,用于 TYPE 路由。 * @param[in] handler 命令处理器指针,调用方需保证其生命周期有效。 @@ -84,6 +120,11 @@ public: /** @brief 查询当前是否有未读按铃(ESP32 尚未来读)。 */ bool isRingPending() const { return m_ringPending; } + /** @brief 查询 ESP32 是否已完成上一帧处理(SPIready 上升沿触发)。 + * 初始为 true 允许第一次按铃;每次 requestEsp32Read 后置 false, + * ESP32 releaseReady 产生上升沿后由 EXTI 置 true。 */ + bool canRingNow() const { return m_linkState.canRingNow(); } + static constexpr uint16_t kRxBufSize = 256U; static constexpr uint16_t kTxBufSize = 256U; static constexpr uint16_t kLogBufSize = 256U; @@ -112,10 +153,14 @@ private: /** 按铃期间暂存的请求帧长度(供 onCsAssert 装入 DMA)。 */ volatile uint16_t m_ringTxLen = 0U; + /** SPI 链路状态机:封装 ESP32 完成信号。 */ + SpiLinkState m_linkState; + void configureGpio(); void configureSpi0(); void configureDma(); void configureExti(); + void configureSpiReadyExti(); void onCsAssert(); void onCsRelease(); void processCommand(); @@ -138,9 +183,15 @@ private: * 主动轮询。每次调用推进一帧:若上一帧尚未被 ESP32 读走则立即返回, * 否则构建当前 (channel, page) 的反馈 payload 并 requestEsp32Read() 推送, * 然后推进到下一页/下一通道。 + * + * 1Hz 节流由 taskHeartbeat() 调用 canFeedbackStartRound() 激活每秒一轮, + * 推完一轮后内部清除激活标志,等待下一次 taskHeartbeat() 触发。 */ void canFeedbackPushStep(); +/** @brief 激活新一轮 CAN 反馈推送(由 1Hz taskHeartbeat 调用)。 */ +void canFeedbackStartRound(); + /** @brief 全局 SPI 命令总线实例。 */ extern AppSpiCommand AppSpiCommandBus; diff --git a/GD32E508_DCU_APP/sys/config.h b/GD32E508_DCU_APP/sys/config.h index 0bc038d..07c2a00 100644 --- a/GD32E508_DCU_APP/sys/config.h +++ b/GD32E508_DCU_APP/sys/config.h @@ -109,9 +109,9 @@ constexpr uint32_t CONFIG_CAN_DATA_BS2 = CAN_BT_BS2_1TQ; #define CONFIG_CAN_FD_BRS_ENABLE 1U /** @brief CAN0 通道使能开关:true=启用初始化/收发/自测,false=完全禁用。 */ -constexpr bool CONFIG_CAN_CH0_ENABLE = false; +constexpr bool CONFIG_CAN_CH0_ENABLE = true; /** @brief CAN1 通道使能开关。 */ -constexpr bool CONFIG_CAN_CH1_ENABLE = false; +constexpr bool CONFIG_CAN_CH1_ENABLE = true; /** @brief CAN2 通道使能开关。 */ constexpr bool CONFIG_CAN_CH2_ENABLE = true; diff --git a/GD32E508_DCU_APP/sys/task_runner.cpp b/GD32E508_DCU_APP/sys/task_runner.cpp index 79d6e11..7726137 100644 --- a/GD32E508_DCU_APP/sys/task_runner.cpp +++ b/GD32E508_DCU_APP/sys/task_runner.cpp @@ -251,7 +251,7 @@ void TaskRunner::taskStatusLed() { /* 预留 */ } * @retval 无返回值。 * @note 当前仅保留任务入口。 */ -void TaskRunner::taskHeartbeat() { OtaBootRequest::process(); CanFd::refreshFeedbackSnapshot(); } +void TaskRunner::taskHeartbeat() { OtaBootRequest::process(); CanFd::refreshFeedbackSnapshot(); canFeedbackStartRound(); } /** * @brief 输出当前温度、电压与 CAN 诊断信息。 @@ -378,6 +378,13 @@ void TaskRunner::runPending() { Watchdog::feed(); + /* poll() 含按铃超时看门狗(cancelRing),必须每轮无条件执行。 + * 否则日志关闭时(CONFIG_SPI_APP_LOG_ENABLE=0) m_logPending 永远为 false, + * TASK_SPI_LOG 永不置位,poll() 永不执行,m_ringPending 卡死导致 + * canFeedbackPushStep() 永久 return -> SPI 反馈链路死锁。 + * 放在 canFeedbackPushStep() 前确保卡死的按铃先被清掉再推送下一帧。 */ + AppSpiCommandBus.poll(); + /* CAN 反馈主动推送:GD32 APP 循环推送 3 通道 × 2 页反馈帧到 ESP32, * 替代 ESP32 主动轮询。非阻塞:上一帧未读走时立即返回。 * 每次主循环迭代调用一次,ESP32 读走一帧后 CS 释放中断唤醒主循环继续推送。 */ @@ -405,7 +412,7 @@ void TaskRunner::runPending() if(pending & (1UL << TASK_STATUS_LED)) taskStatusLed(); if(pending & (1UL << TASK_HEARTBEAT)) taskHeartbeat(); if(pending & (1UL << TASK_DIAGNOSTIC_PRINT)) taskDiagnosticPrint(); - if(pending & (1UL << TASK_SPI_LOG)) AppSpiCommandBus.poll(); + /* TASK_SPI_LOG 的 poll() 已移至 runPending() 开头无条件调用(含按铃看门狗) */ if(pending & (1UL << TASK_SPI_CMD_PROCESS)) taskSpiCmdProcess(); if(pending & (1UL << TASK_ETHERCAT_PROCESS)) taskEthercatProcess(); }