ESP32_COMM · DCU 通信与 OTA 中枢固件 — 项目全景介绍

ESP32-C3FH4 × GD32E508 双 MCU 架构:WiFi / BLE 双通道 OTA 中继 · SPI Link v2 高速桥 · 三路 CANFD 电机总线控制

固件版本 v0.5.2(git 770ae14) · ESP-IDF v6.0 · 平台 ESP32-C3(RISC-V 单核 160MHz) · 文档版本 2026-10-11 · 配套浓缩版 README.md(见文末「文档同步说明」)

1. 项目概述与总体拓扑

ESP32_COMM 是机器人 DCU(Domain Control Unit,域控制器)板上的 ESP32-C3 固件,承担三类职责:

① OTA 中继枢纽:上位机通过 WiFi(HTTP)或 BLE(GATT)把固件交给 ESP32;ESP32 自身固件写入 AB 双分区升级, GD32 固件缓存到本地 Flash 后经 SPI 安全通道转发烧写,电机节点固件经 GD32 的 CAN 总线二次中继。

② SPI Link v2 主站:以 13.33 MHz 五线 SPI(含 DATA_READY 通知线)与 GD32E508 建立带会话、序号、 信用流控和 CRC32 校验的可靠链路,是所有跨芯片业务的唯一通道。

③ 电机总线控制面:把 HTTP/BLE 请求翻译成 GD32 上三路 CAN/CANFD 收发(单帧、批量、跨通道同步突发、 只听模式),并周期回收电机状态、ADC、版本等遥测,通过网页控制台呈现。

上位机 PC / 手机浏览器 网页控制台(tools/) 固件上传 · 状态 · CAN · 电机 ESP32-C3FH4 RISC-V 单核 160MHz · 4MB Flash HTTP 服务器(:80) NimBLE GATT(MTU 512) OtaCoordinator / OtaRelay SPI Link v2 主站 gd32_ota 分区 252KB 固件缓存 app0 / app1 AB 双分区自升级 GD32E508VET6 Cortex-M4 · DCU 业务主控 Bootloader(72KB)/ APP(216KB) AES-128 会话解密 · Flash 烧写 3×CANFD 控制器 + 收发器 电机 OTA CAN 二次中继 ADC / ECAT(LAN9252) 等业务 CAN 通道 0 CANFD 最高 64B CAN 通道 1 桥接 / 过滤可配 CAN 通道 2 电机节点 ×9/通道 WiFi HTTP BLE GATT SPI Link v2 13.33MHz · Mode0 · 5线 CS/MOSI/MISO/SCLK + DATA_READY 通知 OTA · CAN 遥测 电机 OTA · AES-128 TCAN1057AV 收发器 (S 脚可切 Silent 只听) UART0 日志 3Mbps(TX21/RX20 RX泵)+ UDP 日志 6666 → 网页 /logs OTA 数据流:固件经 ESP32 缓存 / 直通,按 目标→通道 分发(详见 §7/§8/§11)
图 1-1 系统总体拓扑:上位机仅经由 ESP32 接入;GD32 的所有远程操作(OTA、CAN、遥测)都以 SPI Link v2 为唯一通道

1.1 固件版本与代码规模

项值说明
固件版本v0.5.2(FW_VERSION_MAJOR/MINOR/PATCH)main/include/version.h 手动维护;构建时间由构建系统自动生成(fwBuildTime())
Git 提交770ae14手动填入 version.h
平台 / 框架ESP32-C3FH4 · ESP-IDF v6.0 · FreeRTOSRISC-V 单核 160MHz,4MB Flash
代码规模约 19,000 行 C++(main/ 下 30 个源文件)另含三工程共享协议 spi_link/spi_link_v2.h(兄弟目录)
对端固件GD32 Bootloader + GD32 DCU_APP + 电机节点本仓只含 ESP32 侧;协议真源 SPEC/spi_link_v2.md 与 GD32 仓共享

1.2 功能一览(实现状态)

功能域能力状态详见
OTA 中继GD32 固件 WiFi/BLE 上传 → SPI 加密转发(六阶段握手/协商/转发/验证)已实现§7
ESP32 自身 AB 分区 OTA + SHA-256 回读校验 + 回滚保护已实现§7.5
电机节点固件 CAN 二次中继(0xD0/D1/D2)已实现§11
GD32 固件包级防呆(Cortex-M 向量表身份校验,禁止 BOOT 包刷入 APP 区)已实现§7.6
传输链路SPI Link v2:会话 / 序号 / 信用流控 / CRC32 / HELLO 重同步 / 延迟一拍协议已实现§5
AES-128 会话密钥挑战-应答握手(GD32 OTA 数据面)已实现§6
链路熔断(3 连败×2s 冷却)、透明重试(kTxRetryMax=3)、撕裂诊断 ring_steal_diag已实现§5.8/§13
CAN 控制0xC0 单帧 / 0xC7 跨通道同步突发(多电机同发 <1ms 偏差)已实现§10
0xC1 统计+ID 快照分页查询 / 0xC2 收模式(全收/过滤/桥接)/ 0xC3 完整快照已实现§10.2
0xC8 收发器 Silent 只听不发(TCAN1057AV S 脚)已实现§10.4
0xC1 GD32 1Hz 主动推送路径保留但默认请求驱动§12.2
接入通道WiFi STA/AP 自动降级 + HTTP 22 端点已实现§9
BLE GATT 窗口 ACK(64)+ 乱序重排 + 负 ACK 续传已实现§8
BLE/WiFi 双通道互斥与射频共存(软挂起)已实现§8.5
监控诊断健康监控(温度/RSSI/堆,4 级状态,OTA 门控)已实现§12.1
日志三路输出(UART 3M / UDP / 环形缓冲 → /logs)+ UART0 RX 泵已实现§12.3
GD32 ADC 遥测(0xC4)/ 电机状态(0xC5×14 页)/ 电机版本(0xC6)/ 初始化状态(0xA3)已实现§12.2
CPU 占用 / 任务栈水位 / 堆低水位快照 / 启动自检位图已实现§4/§12
网页控制台OTA 控制台 / 电机控制台 / CAN 工具 / CAN 监控 四页面已实现§17
DBC 报文编辑、波形显示、启动自检面板、堆/任务抽屉已实现§17
HTTP 接口认证(token)未实现§19
🚫 顺带澄清两处历史文档讹误:本固件没有 mDNS 功能(仅 lwIP 具备 mDNS 解析编译项),设备访问走固定 IP;旧 README 的「115200 调试串口」已过时,日志 UART0 实为 3 Mbps(debug_log.h:34),115200 仅为 sdkconfig 控制台项。

2. 硬件平台与 Flash 分区

2.1 硬件参数

项目规格备注
芯片ESP32-C3FH4(RISC-V 单核 160MHz,Wi-Fi 4 + BLE 5)所有任务固定核 0
Flash4MB,DIO 模式曾试 QIO 致 OTA 读回 SHA 必错,已锁定 DIO(sdkconfig 注释警告)
SPIMOSI=GPIO5 · MISO=GPIO7 · SCLK=GPIO6 · CS=GPIO4 · DATA_READY=GPIO1013.33MHz(CONFIG_DCU_SPI_CLOCK_HZ=13333333)Mode 0;GD32 PB6 开漏驱动 DATA_READY
调试 UART0TX=GPIO21 · RX=GPIO20 · 3,000,000 baud非阻塞直写 FIFO;RX 泵把外部串口行汇入 /logs
欠压检测LVL7(约 2.8V)长断电后 CHIP_EN RC 充电慢,LVL5 会停在半复位状态
GD32 侧存储BOOT 0x08000000~0x08011FFF(72KB)· APP 0x08048000~0x0807DFFB(216KB)· SRAM 0x20000000~0x2001FFFF固件身份校验的判据区间(§7.6)

2.2 4MB Flash 分区地图(config/partitions.csv)

boot 36KB(放大显示) nvs 24KB otadata 8KB(AB 启动标志) gd32_ota 252KB app0 — 1.25MB(ota_0 当前运行) app1 — 1.25MB(ota_1 OTA 目标) spiffs 1.06MB(用户数据) 0x0000 0x9000 0xF000 0x11000 0x50000 0x190000 0x2D0000 0x400000 • gd32_ota:GD32 固件缓存区(spiffs 子类型,便于 esp_partition API 直接读写);OTA 元数据存 NVS 命名空间 ota_meta(total_size / stored_size / last_seq / checksum) • app0/app1:ESP32 自身 AB 分区;配合 bootloader 回滚(CONFIG_APP_ROLLBACK_ENABLE)新固件崩溃自动回退 • 擦除策略:GD32 存储按「擦除水位」按需擦扇区(4KB);电机 OTA 上传则强制整区预擦除,杜绝 NOR 与逻辑残留叠加的单 bit 翻转
图 2-1 Flash 分区布局(示意:早期小分区为可读性放大,app0 起按比例;GD32 OTA 中继与电机 OTA 共用 gd32_ota 分区,靠互斥串行化)

3. 软件架构与 FreeRTOS 任务模型

3.1 模块分层

接入层(server/) wifi_ota.cpp STA/AP · 重连 · RSSI · 路由表 http_handlers.cpp 22 个端点 · 流式上传 · JSON 工作区 ble_ota / ble_ota_host / ble_ota_data GATT 服务 · 窗口 ACK · 乱序重排 · 内存池 · 共存挂起 协调层(ota/) OtaCoordinator(单例 · 9 状态系统级状态机) 唯一权威入口 · 互斥 · 忙态看门狗自愈 · epoch 会话保护 · BLE 共存通知 Esp32OtaCore(本机 OTA) begin/write/finalize/abort · SHA-256 读回 · 分区互斥 · SPI 暂停 执行层(relay/ · storage/) OtaRelay(事件驱动状态机 ×7 态) 握手→协商→转发→收尾·验证 · 专用任务 Gd32FlashStore gd32_ota 分区读写 · 擦除水位 · NVS 元数据 · SHA MotorOtaRelay 电机 OTA 喂数任务 SpiMaster Link v2 链路 + worker transport/(底层) monitor/ 横切:健康 · 日志 · 遥测 · 统计
图 3-1 模块分层与依赖:传输层(wifi/ble)不直接触达 OtaRelay/Esp32OtaCore,一律经 OtaCoordinator 统一入口

3.2 FreeRTOS 任务全景

23 21 22 12 8 7 5/4 1 优先级(C3 单核 0) ota_relay(栈 2560)GD32 转发状态机 health_mon(3072)5s 巡检 wifi_ota(3072)WiFi / HTTP / 事件 ble_ota(2560)NimBLE host 初始化 ble_data_worker(2560,prio 12) spi_worker(5120,prio 8)唯一持 SPI motor_feed(4096,prio 7)0xD1 喂数 wifi_reconnect(2560,p5)退避重连 can_fb(4096,p4)0xC5/C4 周期查询 cpu_stats(3072,p1)1Hz 统计 uart_rx 泵(3072,p1)串口行→/logs httpd(7168,lprio) ble_finish / gd32_wait / restart ota_cancel(3072,p4) DATA_IN 队列 144 内存池 8×512B = 4KB · 跨任务队列:wifi/ble → ota_relay 走 dispatchQueue/resultQueue(深度 4);业务任务经 transact Job 池(7)提交 spi_worker 串行执行 · DATA_READY(PB6)下降沿 ISR → 任务通知精确唤醒 spi_worker;EVENT 上报走 4 槽事件环(满丢最旧)+ takeEvent 消费 · 另有 NimBLE host 内部任务;任务栈按板端水位实测收敛(S ≈ 峰值 + 1.5~2KB);httpd 为 esp_http_server 内部任务(lprio)
图 3-2 任务与队列:SPI 总线由 spi_worker 独占串行化,业务任务经 Job 池提交;OTA 优先级高于网络栈任务以保证 50ms ACK 窗口

关键设计约束(线上事故沉淀):UART 日志直写曾阻塞 ota_relay(23)/spi_worker(6) 数毫秒,导致 50ms ACK 窗口拖爆、12/777 包重发——因此日志改非阻塞 FIFO 写、BLE worker 提到 prio 12 尽快排空队列、spi_worker 提到 prio 8(高于 motor_feed 的 7,低于状态机任务)。

4. 启动流程与自检(SystemBoot 位图)

app_main 启动序列(每步成功 → SystemBoot::mark(bit)) 冷启动判定 POWERON·BROWNOUT 先延 200ms netif · 事件循环 DebugLog 环 64 槽 WifiOta.earlyInit 冷启动清 wifi/phy NVS 连接缓存 HealthMonitor + health 任务 温度 1s · 巡检 5s Protocol.init RNG 生成扰码常数 SpiMaster.init L0 自检 · 对端 HELLO 探测 创建业务任务 relay·wifi·ble can_fb·feed ✔ 任务启动后 mark_app_valid_cancel_rollback() — 唯一回滚取消点 打印 banner「OTA Relay v0.5.2 启动」— 前端正则匹配判定重启完成 SystemBoot 16 位自检位图(/system/status → boot{...},前端「启动自检」面板) NETIFcritical EVT_LOOPcritical DBG_LOGcritical WIFI_EARLYcritical HLTH_MONcritical HLTH_TASKcritical CPU_STATS非关键 PROTOCOLcritical OTA_COORDcritical SPIcritical RELAY_TASKcritical WIFI_TASKcritical BLE_TASKcritical CAN_LINK非关键 CAN_FB非关键 OTA_VALID非关键 关键位失败 → DEBUG_ERR + 3 秒后 esp_restart() 非关键位失败仅记录,report() 输出「OK (critical), skipped/failed: 清单」 can_fb 任务:冷启动/欠压复位时额外延迟 1500ms 再开始查询 错开 GD32 上电初始化窗口,避免无效查询风暴 WiFi 侧另有 NVS 不完整元数据检测(stored<total → 清 meta + abort + GD32 APP 探测,防 Bootloader 卡死拖垮 HTTP socket)
图 4-1 启动时序与自检位图:冷启动两级延时(主 200ms / can_fb 1500ms),关键位失败自动重启,全部结果经 /system/status 暴露

5. SPI Link v2 传输层(核心协议)

SPI Link v2 是三个工程(ESP32_COMM / GD32 DCU_APP / GD32 Bootloader)共享的链路协议,真源 spi_link/spi_link_v2.h,PC 端 golden-vector 自测交叉锁定。它解决了裸 SPI 没有「帧界、会话、背压、应答归属」的问题。

5.1 帧格式(16B 头 + 0~236B 载荷 + CRC32,总开销 20B)

SOF0A5 SOF15A VER02 FLAGS位图① SESSION_ID 4B LE会话归属 TX_SEQ发方序号 ACK_SEQ应答归属 CREDIT信用窗口 TYPE帧类型 LEN 2B≤236 RSVD 2B=0 PAYLOAD 0~236B 业务数据(APP 面) CRC32 4B LE MPEG-2 覆盖头+载荷 SPI Link v2 帧 = 16B Header + Payload + CRC32(V2_OVERHEAD=20) ① FLAGS:0x01 REQUEST · 0x02 RESPONSE · 0x04 EVENT · 0x08 ACK_ONLY · 0x10 RESET_SESSION(HELLO 请求 = REQUEST|RESET_SESSION) ② CRC32-MPEG2(多项式 0x04C11DB7,初值 0xFFFFFFFF,无反射),解析器支持帧在缓冲区内任意偏移(前导垃圾字节容忍) ③ 读预算按 GD32 角色:BOOT=128B / APP=256B;短响应类型再收紧到 96B 省填充时钟;发送时钟字节数 clamp(帧长, 64, 256) TYPE 分族: 0x00 HELLO · 0x7E CREDIT_UPDATE · 0x7F LINK_ERROR(err,期望seq) 0x01 OTA_REQ · 0x02 OTA_DATA · 0x03 OTA_CANCEL · 0xB0 ENTER_BOOT(ACK=0xB5) 0xC0 CAN发 · 0xC1 反馈页 · 0xC2 收模式 · 0xC3 ID快照 · 0xC7 突发 · 0xC8 Silent 0xA1 PING · 0xA3 INIT_STATUS · 0xC4 ADC · 0xC5 电机状态 · 0xC6 电机版本 · 0xD0/D1/D2 电机OTA · 0xF7
图 5-1 帧格式:响应帧(0x80 ACK / 0x81 WAIT / 0x82 ERROR)复用同一帧头;LINK_ERROR 携带错误码与期望序号用于失步归因

5.2 五线 SPI 与「延迟一拍」全双工

CS(GP4) SCLK(GP6) MOSI(GP5) 命令帧 #N(tx_seq=T) 命令帧 #N+1 MISO(GP7) 陈旧响应(丢弃) 命令 #N 的真实响应 DATA_READY ↓PB6 下降沿:GD32 有数据(ISR 置位 + 任务通知精确唤醒) 延迟一拍协议: 第 N 次 CS 事务读回的是第 N-1 次 命令的响应。GD32 在 CS 释放 ISR 内 µs 级装载下一响应,ESP32 发完 命令后由 poll 状态机按 DATA_READY 边沿驱动补读真实响应(两步法)。 CS 建立/保持加长:cs_ena_pretrans=64 cs_ena_posttrans=32;事务间隔 ≥1ms
图 5-2 五线时序与延迟一拍:所以 transact 分两步——先发命令(丢弃本笔陈旧 RX),再 poll 等真实响应;CS 事务间最小 1ms 防 GD32 EXTI re-arm 窗口被撞(0.4.5 撕裂根因修复)

5.3 信用流控(Credit)与背压

ESP32 发送门 remoteCredit = 对端帧头 CREDIT 初值 8 · 随每帧更新 = 0 时禁发新 REQUEST GD32 接收窗 响应缓冲 → 处理 → 释放 credit 随每帧帧头回传 空闲后不再主动发帧 数据/请求(credit 字段 = 本端剩余接收能力) credit=0 → ESP32 循环 waitSpiDataReady(5ms) 等 PB6 / CREDIT_UPDATE(0x7E) 死锁破除:PB6 高 = GD32 无待发数据 = 响应缓冲已释放 → 陈旧 credit=0,强制 m_remoteCredit=1 放行 credit 更新必须在 session 校验之后(失配帧的 credit 不可信);半会话终止用 resetCredit() 复位 8/8(§11)
图 5-3 信用流控:每帧帧头互报剩余接收能力,credit=0 触发发送背压;两处已知死锁(陈旧锁存 / 失配帧污染)各有针对性破除

5.4 HELLO 建链与自动重同步

HELLO(TYPE 0x00) SESSION_ID = esp_random()|1(非零) FLAGS = REQUEST|RESET_SESSION seq/credit 全零 · role_hint 载荷 1B HELLO_ACK 校验 type=0x00 · RESPONSE · session 回显一致 payload[4]=role(1=APP 2=BOOT) payload[5]=对端 credit_max 链路生效 v2Active=true · credit=8/8 APP 角色解析 16B 身份块② 0xA3 缓存失效 · resyncs++ 连发 2 次 ② 身份块 16B(HELLO_ACK 附加 / 0xA1 PING 同布局): [0..3] APP版本 major.minor.patch.build · [4..7] BOOT版本 · [8..11] uptime_ms · [12] feat_map_ver · [13..14] feature_flags · [15] init_state feature_flags:bit0 ADC反馈 · bit1 UART温度打印 · bit2 SPI_APP_LOG · bit3 CAN_BRS · bit4/5/6 CAN 通道0/1/2 · init_state:0=OK / 1=DEGRADED / 2=CRIT_FAIL / 0xFF=UNKNOWN 自动重同步三触发: ① 收到 session=0 的响应(GD32 重启后 v2 未激活)→ 置 resyncPending;② worker 空闲期发现 resyncPending(5s 冷却)→ HELLO(role=any) ③ GD32 复位导致 LINK_ERROR(0x7F) 后由业务重试自然触发 — 每次成功 HELLO 计入 spi_link.resyncs 角色粘滞纠正:见 APP 独有 TYPE(0xC1/0xC3~0xC6/0xD0~0xD2)即把卡在 boot 的角色纠正回 APP,防止读预算用错 128/256
图 5-4 HELLO 建链:延迟一拍连发两次取回 ACK;session 不匹配的 ACK 视为旧帧重发即可;ENTER_BOOT 前后各有专门的 HELLO/静默窗口

5.5 transact 两步法与透明重试

步骤 1:发送命令 credit 背压检查 · 本笔 RX 丢弃 5ms 静默豁免判定 isAppIsrFastRespType → 免静默,边沿唤醒 步骤 2:poll 响应 DATA_READY 优先 · 盲读受 no_blind 约束 快速类型白名单(GD32 APP ISR µs 级装载):0xA1 0xA3 0xC0 0xC1 0xC2 0xC3 0xC4 0xC5 0xC7 0xD1 0xF7 仅 BOOT 主循环处理的 OTA 命令(0x01/0x02/0x03)保留 5ms 静默 ——过早盲读会打断 BOOT 的 EXTI/DMA 时序(实测 CHALLENGE 超时) poll 规则 · no_blind(快速类型):禁盲读,纯等 PB6 电平 · 否则:前 2 轮盲读 → 每 8 轮一次 → deadline 末强制 · 读预算 = min(短响应 96B, 角色预算 128/256B) 盲读为何危险(0.4.4 实测) PB6 未低时强打 256 时钟 0xFF,若 GD32 CS EXTI 被 ECAT/CAN 中断压住未 re-arm → RX DMA mid-pulse 重装 → 撕裂 0x82 0xE8 → 迟到帧被偷读 → 偷读风暴 透明重试 kTxRetryMax=3(1 首发 + 2 重发,同 TX_SEQ):仅当「未读到任何有效响应」时重发 · 失败路径 seq 不推进 → GD32 按 DUP-SEQ 去重缓存回放,杜绝重复执行 · 读到任何帧(WAIT/ACK/ERROR/陈旧)即返回,GD32 明确拒绝(0x82)不重试 · 耗尽 → tx_fail++ / tx_fail_last_type=type / 熔断计数(3 连败 → 链路离线,2s 冷却后放一笔探针;HELLO/OTA 关键命令不受限) 0xC0 发送另有陈旧 WAIT 排空:读到 WAIT(0x81) 必为上轮电机 OTA 残留,循环最多 4 次直到真 ACK/ERROR,防「失败一次就再也发不出」
图 5-5 transact 流程:发送 →(静默豁免)→ poll →(透明重试);所有业务共用此路径,调用方无感知

5.6 序号 / 会话失配与「偷读」修复

机制规则
seq 推进发送成功且非陈旧读 → m_txSeq++;poll 读到 ACK/WAIT/ERROR 且 ack_seq==lastSent → 推进;WAIT 也必须推进(否则同 seq 重发被判重,feedTask 卡首 chunk)
迟到响应偷读poll 超时放弃后 GD32 迟到响应拉低 PB6 被 worker 按铃读走(帧已上时钟无法退回)→ 同样必须推进 seq(0.4.2 修复,否则下条命令复用旧序号被 GD32 判重吞掉,业务永远读到旧 0x82)
失配帧处理CRC+session 合法的 RESPONSE 一律交调用方(帧已被读走、GD32 已清 pending,丢弃会死等),由 m_lastRxAckMatched 标记归属,业务以结构化 payload 终判
session 失配EVENT 帧放行;session=0 的响应放行(GD32 重启语义)并置重同步;其余拒收且不更新 credit
撕裂诊断ring_steal_diag:偷读计数/最后类型/ack 归属/payload 签名 [rxLen, rb0, rb1](0xE8 撕裂时 rb0=A5 rb1=5A 为期望值,255=未找到 SOF);0x82 归因 rsp_err_last_req/p0..p5,全部进 /system/status

6. 安全握手与加密算法(GD32 OTA 数据面)

GD32 固件转发采用预共享主密钥 + 挑战-应答派生会话密钥,会话内数据 AES-128-ECB 加密。实现于 OtaRelay::doChallengeAndAuth() 与 Protocol(nonce 搅拌生成 / 会话密钥管理)。

ESP32(OtaRelay) GD32 Bootloader 前置:进入 BOOT(ENTER_BOOT 0xB0,ACK=0xB5,最多 3 次 + 整链重同步 + 100ms 静默) nonce_server = scrambleGenerate(tickcount, &栈地址, 0x12345678) ⊗ 每次开机硬件 RNG 的 SCRAMBLE_CONST[16] ① 0x00 0xAC(握手请求) 重试 20 次×1.6s · 每 5 次 HELLO 重同步 ② Challenge 36B [0..11] GD32 UID 12B(禁全 0/全 FF) [12..19] nonce_server[8..15] 回显 8B [20..35] AES-ECB(MASTER_KEY, K_session) 16B ③ 本地验证与派生 K_session = AES-128-ECB_encrypt(MASTER_KEY, nonce_server) 重算 AES(MASTER_KEY, K_session) 与 [20..35] 比对;nonce 片段绑定本会话防重放 ④ CONFIRM:AES(K_session, nonce^0x55) transact 3s · poll 1s×8+8 ⑤ 明文 ACK 0xBA 0xC0 会话生效 · 数据包 K_session 加密 任一步失败 → ERR_TIMEOUT / ERR_AUTH → 状态机 ERROR;会话结束/复位时 volatile 逐字节擦除密钥(抗优化清零)
图 6-1 挑战-应答握手(字节级):MASTER_KEY 预共享(当前为占位值,§19),K_session 每次 OTA 重新派生,nonce 片段回显绑定会话
Nonce 搅拌算法(scrambleGenerate):非标准 KDF,仅作握手 nonce——以 tick 计数、栈地址、固定种子 0x12345678 三输入混合每次开机由硬件 RNG 随机生成的 16B 扰码常数,输出 16B。协议实现另有 PC 端 golden-vector 自测(SPI_LINK_V2_SELFTEST)交叉锁定帧字节。

7. OTA 体系(协调器 / 中继 / 本机 / 存储)

7.1 OtaCoordinator — 系统级 9 状态协调器

单例 + FreeRTOS 互斥(100ms 锁超时),是 WiFi/BLE 传输层到 OTA 执行体的唯一权威入口;同时承担忙态看门狗自愈、epoch 会话保护、BLE 射频共存通知、CAN 反馈暂停与电机 OTA 互斥。

OTA_SYS_IDLE GD32_STORING 收流写 Flash(忙) GD32_STORED GD32_RELAYING SPI 转发中(忙) GD32_COMPLETE notifyRelayComplete(true) ESP32_RECEIVING 收流写 AB 分区(忙) ESP32_FINALIZING SHA+end+set_boot(忙) ESP32_REBOOTING OTA_SYS_ERROR 任意阶段失败 → ERROR(isIdleState 不含它,但可被新请求/软清恢复) · 忙态看门狗 150s(>中继预算 120s+余量):STORING/RELAYING/RECEIVING/FINALIZING 超时 → abort 自愈回 IDLE;FINALIZING 假占用(core 已空闲)直接复位 · epoch:每次 begin 递增;上传失败清理仅当 epoch 匹配才执行,防旧会话清理误杀新会话 · 开机检测 NVS 完整固件自动进 STORED · 入口互斥:电机 OTA 活跃时拒绝一切 GD32/ESP32 OTA(gd32_ota 分区共用);REBOOTING 不入看门狗且拒绝 abort(防与重启竞态)
图 7-1 OtaCoordinator 9 状态:GD32 与 ESP32 两条 OTA 路径互斥推进,任意异常收敛到 ERROR/IDLE 并自愈

7.2 OtaRelay — GD32 转发事件驱动状态机(7 状态)

独立任务(栈 2560B)+ dispatch/result 队列(深度 4)。收到调度信号先 setSuppressRingService(true)(防 SPI worker 把 OTA 响应当 EVENT 偷读——线上事故根因),全程 RAII 持有抑制引用。

RELAY_IDLE HANDSHAKING进BOOT+握手+认证 SIZE_NEG发大小·GD32擦Flash TRANSFERRING逐包加密转发 FINISHINGFINISH+APP验证 RELAY_DONE RELAY_ERROR ERROR/DONE 经 EVT_START 复位回 IDLE;所有失败路径 abortRemoteAndReset(true)(先 SPI CANCEL 0x03 清 GD32 半会话,失败仅告警) 关键时序:握手 20×1.6s(每 5 次 HELLO 重同步)· CHALLENGE 5s · SIZE poll 500ms×120+40(擦 Flash 长 WAIT) 转发预算 10s+25ms/包 上限 120s · 卡死检测 10s 无成功帧 · FINISH 10s×10 重试 · APP 存活 = 静默 500ms + PING 1000ms×20 轮(禁 HELLO 快路径,必须 PING 实证)
图 7-2 OtaRelay 状态机:6 事件(START/RESP_OK/RESP_ERROR/TIMEOUT/HEALTH_CRIT/ABORT)驱动;结果码 -1~-7(TIMEOUT/CRC/AUTH/STORAGE/GD32/HEALTH/USER_ABORT)

7.3 64 字节 OTA 数据包与单包重试

session 4B=nonce[0..3] seq 2BLE 保留 2B AES-128-ECB(K_session, plain48) 48B 3 块连加密 · 末包 0xFF 填充 if_end 4B末包 FFFFFFFF CRC32 4B LE覆盖 K_session+plain48 SPI_OTA_CMD_DATA(0x02) 载荷 = 64B(GD32_OTA_PACKET_SIZE=48 有效载荷,key 混入 CRC) 明文来源:Gd32FlashStore 按需读 gd32_ota 分区(读失败 → ERR_STORAGE);总包数 = ceil(total/48);双 64B tx 槽流水线预组下一包隐藏读 Flash+AES 延迟 单包重试(sendRelayPacketWithRetry): DATA(seq=T) → WAIT(0x81)「从机已收」→ ACK(0xBAC0+seq=T+1);50ms 未到 ACK → 重发同 seq DUP-DATA (GD32 去重缓存回放最终 ACK,重发安全);每包最多 5 次 DATA · 末包期望 ACK seq=0xF0F0F0F0 结构化错误帧:0xBAC0+seq+err——0x04=CRC 错立即放弃整包;其他错误允许带错重发;ACK 每 32 包触发健康检查
图 7-3 数据包结构与重试:GD32 对已写入包号去重(只回 ACK 不二次写 Flash),使「见 WAIT 后重发」安全——旧逻辑 ACK 丢失即永久丢失

7.4 ESP32 本机 OTA(WiFi + BLE 共用)

步骤实现要点
begin选非当前启动分区(优先 ota_0)· esp_ota_begin(OTA_WITH_SEQUENTIAL_WRITES) 边写边擦整区预擦 1.25MB 会堵死 HTTP TCP 窗口(进度卡开头);begin 同时 setSpiPaused(true)——写 Flash 时 cache 反复失效会拖慢 SPI worker 甚至 Cache error;使能回滚时先 invalidate_inactive_ota_data_slot()
writeesp_ota_write 循环 + 喂狗不做增量 SHA;httpd 侧 4KB RAII 接收缓冲(会话内 malloc),堆紧时 pacing(<8KB 减速)
finalize分区读回算 SHA-256(1024B 静态缓冲,每 16 块喂狗)→ 与前端 SHA 大小写不敏感比对 → esp_ota_end → set_boot_partition不匹配返回 ESP_ERR_INVALID_VERSION 并回传设备 SHA 供前端核对;m_ended 标志防 end 后误 abort
回滚保护任务启动完成后 esp_ota_mark_app_valid_cancel_rollback()新固件标记前崩溃 → bootloader 自动回退上一有效分区

7.5 Gd32FlashStore — 存储层

能力实现细节
按需擦除(水位)beginWriteSession(false) 复位水位;ensureErasedForWrite 仅从水位向高地址顺序擦 4KB 扇区(跳空一并擦),禁止重擦已写扇区;每扇区擦后喂狗+yield 让 WiFi/LWIP 有机会运行
整区强制擦除电机 OTA 上传 beginWriteSession(true) 全擦 252KB——NOR「只能 1→0」与残留固件叠加会造成静默单 bit 翻转(实测 0x19→0x18 根因)
NVS 元数据命名空间 ota_meta:total_size / stored_size / last_seq / checksum(blob 32B);读-改-写全程持锁防丢失更新;仅 ACK checkpoint 持久化(NVS commit 毫秒级,不能每包一次)
SHA-256收流收尾统一 metaFinalizeChecksum;relay 门控:checksum 非全零则信任跳过二次全盘 SHA;全零则兜底 metaVerifyChecksum(全零不容忍,防 meta 被清零掩盖损坏)
对齐缓冲OTA 会话 begin 时 calloc(4KB+8),end/abort 释放——消除每非对齐块 malloc/free 堆抖动,非 OTA 期零占用

7.6 包级防呆:固件身份校验(0.3.81)

SHA-256 只能证明「传对了」,不能证明「传的是对的包」。把 BOOT 包(或 ESP32 镜像/任意文件)中继进 GD32 APP 区会擦掉好固件。validateStoredImageIdentity() 读分区头 8B Cortex-M 向量表:初始 MSP 必须落在 GD32 SRAM(0x20000000~0x2001FFFF);复位向量(清 Thumb 位)落在 APP 区 → kApp 放行;落在 BOOT 区(0x08000000~0x08011FFF)→ kBootloader 拒绝;判据与 GD32 BOOT 侧 validateApplication() 完全一致(两侧需同步修改)。/ota/relay 与 BLE 0xF7 都强制 kApp 才放行(BLE 错误码 0x87 ERR_FW_IDENTITY)。

8. BLE OTA(NimBLE)

8.1 GATT 服务

UUID特征属性用途 / 格式
0xFFFFOTA ServicePrimary服务主入口(设备名 ESP32-OTA-RELAY · MTU 512 · 不做 bonding)
0x0001CTRLWrite命令字:0xF7 触发 GD32 中继(≥35B:SHA32+session2)· 0xF8 ESP32 收尾(≥33B:SHA32)· 0xFA 重启 · 0xFB 中止(RELAYING/FINALIZING 拒绝)· 0xF9 SHA 预检仅定义未实现
0x0002DATA_INWrite + WriteNoRsp固件包:cmd 1B(0x00 GD32/0x01 ESP32)+ seq u16 LE + total_size u32 LE(仅 seq=0 有效)+ payload ≤480B;无 CRC(BLE 链路层已有)
0x0003DATA_OUTNotify0xA1 窗口 ACK 8B(cmd+seq+completed_bytes)· 0xB1 FINISH 6B(stage+value)· 0xC1 SHA-256 35B
0x0004VERSIONRead固件版本 + 构建时间字符串

FINISH stage:0x02 DONE · 0x07 REBOOT_NOW · 0x10 RELAY_START · 0x11 RELAY_DONE · 0x20 ABORTED;错误码 0x80 BUSY / 0x81 ERR_FINALIZE / 0x83 ERR_MUTEX / 0x84 ERR_GD32_SIZE / 0x85 ERR_SHA_MISMATCH / 0x86 ERR_GD32_RELAY / 0x87 ERR_FW_IDENTITY。连接参数:高速分支 7.5~10ms 间隔 + 2M PHY 请求,Windows 兼容回退 10~12.5ms。

8.2 窗口 ACK、乱序重排与内存池

前端发送窗口(BLE_OTA_ACK_WINDOW = 64)与设备端乱序重排(REORDER_WINDOW = 64) 0✓ 1✓ 2✓ 5暂存 6暂存 3缺口 7暂存 4缺口 …64 窗口内乱序包暂存 slot(lazy malloc ≤480B),3 到达后 flush 连续补齐并 free · seq<expected 重复丢弃(静默,ACK 字节不增长会被前端误判丢包);seq==expected 写 Flash + 连续 flush;seq 超窗 → 丢弃 + ACK 报缺口 · ACK 内容 = expected-1(最近连续落盘序号)+ 累计字节;触发:跨 64 边界 / 尾包(<480B)/ seq=0 首包就绪 / flush 跨界 · 续传:前端 resumeSeq = ceil(completed_bytes / 480);负 ACK(丢包/队列满)节流 64 包或 200ms 一条,ackSeq=expected-1 供续传 · 合并包:NimBLE 可将多次 writeWithoutResponse 合并进同一 mbuf,4096B 静态缓冲内循环解析(≤8 包/批) DATA_IN 内存池 = 8 槽 × 512B = 4KB 一次 calloc 切分;曾为 32KB 致 OTA 收尾 0xF8 OOM (0xF8 收尾需 image verify),缩至 4KB 并修池复用竞态 数据队列深度 144(窗口×2+16)· worker prio 12 栈 2560 收包即入队,Flash 操作全部在 worker(NimBLE host 栈仅 4096B,阻塞会断连);>512B 合并批回退 malloc GD32 数据路径(cmd=0x00)无 reorder、严格按序写: 乱序丢弃 + 节流 ACK;ACK checkpoint(seq==0 / 每 64 包边界 / 尾包)同时是 NVS meta 持久化点 收完自动推 0xC1 设备 SHA,CTRL 0xF7 才触发中继(前端 SHA 三方比对 + 身份校验 → RELAY_START/DONE/0x86) ESP32 路径(cmd=0x01)收尾:0xF8+SHA → 补发最终窗口 ACK → 150ms → finalizeForReboot → 0xC1 SHA 通知 → DONE 0x02 → REBOOT_NOW 0x07
图 8-1 BLE 窗口传输机制:writeWithoutResponse 即发即忘 + 窗口 ACK 兜底 + reorder 吸收乱序;负 ACK 支持断点续传

8.3 BLE/WiFi 射频共存(ESP32-C3 单射频)

方向机制
WiFi OTA 开始 → 挂起 BLE软挂起 suspendStack:停广播 → 断连接(等 30ms)→ CTRL/DATA_IN 拒写(ATT 错误)→ 清数据队列;不 deinit 协议栈(硬 deinit 会打挂共存导致上传失联);挂起期 CONNECT/ADV_COMPLETE 被拒绝/抑制
OTA 中继结束/中止 → 恢复 BLEresumeStack:重新开广播;仅曾 hard-deinit 才完整 serverStart。/ota/relay 失败路径也会 resume
BLE OTA 期间 → WiFi 让步保持 WIFI_PS_NONE,仅暂停 RSSI 轮询(射频活动干扰 BLE 连接事件);标志防重入;断连按系统状态清理会话(GD32_RELAYING/REBOOTING 保留等自然结束,WiFi 通道会话不误杀)

9. WiFi 管理与 HTTP API

9.1 STA → AP 自动降级

STA 优先(编译期) SSID「004舰桥」· FAST_SCAN 国家码 CN(信道 1-13)· WPA2 重连任务(独立,防死锁) 冷启动 retry≤3:退避 2s×retry 否则 1s 固定 · 5 次耗尽 → 放弃 30s 连接超时 waitStaConnected 每 1s 轮询 耗尽立即返回超时 AP 模式 ESP32_OTA_RELAY 12345678 · 192.168.31.1 信道 6 · ≤4 台 超时 降级切换:esp_wifi_stop → deinit → init → 重新注册 WIFI/IP 事件回调(deinit 会丢失)→ initAp() GOT_IP 经信号量通知(DHCP);AP 模式先停 DHCP server、设静态 192.168.31.1/24 再启动 事件回调仅 xTaskNotifyGive 唤醒重连任务(回调里直接 esp_wifi_connect 会死锁);RSSI 每 10s 喂健康监控(BLE OTA 期间暂停);WIFI_PS_NONE 保吞吐 冷启动(POWERON/BROWNOUT):统一等 400ms 再初始化 NVS/WiFi(STA 路径 esp_wifi_start 前再等 300ms) 清 NVS nvs.net80211 连接缓存,欠压复位额外清 phy 强制重校准;NVS 损坏自动擦除重建 扫描用 WIFI_FAST_SCAN(扫到即连)——全信道扫描会让 OTA 重启后页面恢复多等 3~8s;listen_interval=3;无 mDNS,访问靠固定 IP
图 9-1 WiFi 模式管理:STA 优先 + 有界退避重连 + 30s 超时降级 AP;事件驱动 + 独立重连任务避免死锁

9.2 HTTP 服务器与路由表(22 条路由 / 21 个唯一路径)

httpd 端口 80 · httpd 栈 7168B · max_uri_handlers 48(22 条 handler 路由 / 21 个唯一路径,另为每个 path 注册 OPTIONS CORS)· max_open_sockets 6(约束 ≤ LWIP 12 − 内部 3)· LRU 踢除 · recv/send 5s 超时 + keep-alive 探针。无任何认证(见 §19);CORS 全放开(前端本地托管跨源调用)。错误响应统一 {"status":"error","code":"snake_case","message":...}。

方法路径功能
POST/ota/upload固件上传(JSON 头 {"type":"esp32"|"gd32","total_size":N} + 二进制流式);GD32 路径流式写 gd32_ota + NVS meta + 收尾算 SHA;chunked 拒绝
POST/ota/updateESP32 OTA 收尾 {"sha256","verify_only"};SHA 不匹配回 error 并带 device SHA;成功后 100ms 重启
POST/ota/relay触发 GD32 中继:校验前端 SHA-256 + 身份 kApp → 投递 dispatch;失败恢复 BLE
POST/restart重启 ESP32
GET/logs日志环 JSON chunked 流式(512B 栈缓冲攒块,替代 4KB malloc;堆 <4KB 返回 [])
GET/system/status全量状态(?lite=1 轻量、?layout=1 加内存布局);堆极紧自动降级简包
GET/system/probe单次 GD32 APP 存活探测(HELLO/PING),返回版本/uptime
GET/system/heap_low_log最近一条「堆创新低」快照(alloc hook 最后一击归因)
POST/can/send_batchCAN 发送唯一入口,1~128 帧,512B 滚动缓冲流式解析;单帧 1200ms / 多帧 80ms/帧;SPI 失败或 0xE2/0xE3 致命中止并报 skipped
POST/can/send_burst0xC7 跨通道突发(≤24 帧,按容量分块背靠背;id=(node<<16)|suffix)
GET/can/feedback0xC1 按需查询三路统计+ID 快照(?lite=1 轻量)
GET/POST/can/mode查询/切换接收模式 0 全收 / 1 过滤 / 2 CAN0→1/2 桥接
POST/can/silent0xC8 通道收发器 Silent 只听不发(OTA/send_batch 独占 SPI 期间拒绝)
POST/spi/send原始 SPI 帧透传 {"type","data","timeout_ms"}
GET/motor/state电机状态缓存(0xC5;?bin=1 二进制协议 12B 头 + 92B/节点,省 66% 体积)
GET/motor/version电机版本缓存(0xC6 事件推送)
POST/motor/ota电机固件上传(?ch=N;整区强制擦除;SHA 随响应返回)
POST/motor/ota/relay触发电机 OTA(SPI 0xD0 START + feedTask)
GET/motor/ota/status电机 OTA 状态机进度(0xD2 缓存)
POST/motor/ota/cancel取消(异步 cancel task,先 resetCredit 再 CANCEL)
GET/spi/speedtestSPI 性能测试(0xA1 PING 探针,RTT 直方图/5 段细分;临时功能标记可删)
共享 JSON 工作区:3KB .bss 缓冲 + 惰性互斥供 /system/status、/can/feedback、/motor/state(JSON) 等串行复用,避免峰值堆叠 malloc;/system/status 分 4 块 chunked 发送。motor.html 三条轮询链路(/motor/state、/can/feedback、/system/status)服务端保证互不阻塞。

10. CANFD 桥接(0xC0~0xC8)

ESP32 不直接接触 CAN 总线:HTTP/BLE 请求翻译为 SPI 命令,GD32 驱动三路 TCAN1057AV 收发器。帧约定 [通道 1B][扩展 ID 4B 大端][数据 0~64B]。

10.1 命令族

TYPE名称载荷 / 行为
0xC0CAN 单帧发送req = ch + id(4B BE) + data(最短 5B,DLC=0);GD32 回 ACK 或错误码 E0~E7(payload[0])
0xC1统计 + ID 快照页req=[ch,page];resp = 28B 固定头(含 mode/snapshot_seq/rx/tx/rx_drop/error/tec/rec/bus_state)+ 条目 (8+dlc)=id 4LE+dlc+flags(FD/BRS/ESI)+rsv+data;快照上限 16 条,page0=条目 0~7、page1=8~15;GD32 亦可 1Hz 主动推送(现默认请求驱动,§12.2)
0xC2设置接收模式0=全收 · 1=按 ID/掩码过滤 · 2=CAN0→CAN1/CAN2 桥接(每帧按原格式自动转发);回实际生效模式
0xC3ID 快照完整版固定头 4B + 条目 (16+dlc),含 seq/rx_count;响应可长,不进短响应读预算白名单
0xC7跨通道突发发送多电机同步:4B 头 [count][dlc][suffix_hi][suffix_lo] + count×(chnode(1B=(ch<<4)|node)+data);单次 ≤16 帧(容量 = min(16,(236−4)/(1+dlc)))
0xC8收发器 Silentreq=[ch, silent 0/1];回 [ch 回显][actual][ch_en];错误 E0 过短/E1 通道非法/E2 S 脚回读不符(标志已回滚)

10.2 0xC7 突发同步原理(多电机同发)

ESP32 一条 0xC7 装载 N 帧 只等「一个」ACK GD32 CS 释放 ISR 内 对每帧调非阻塞 transmitFrame(仅装载邮箱) 全部装载完成后才回一个 ACK ACK payload = 实际 count + 每帧 CanTxResult CAN0 邮箱 → 上线 CAN1 邮箱 → 上线(偏差 <1ms) CAN2 邮箱 → 上线 组内近同时 · 组间背靠背 HTTP 层 /can/send_burst:单请求 ≤24 帧,超出单次容量按 canBurstCapacity(dlc) 分块、块间背靠背 SPI 事务(<1ms);CAN ID = (node<<16)|suffix(CMD=0x0200)。 单帧总线故障(0xE2 BUS_OFF / 0xE3 TEC>96)视为 fatal 中止剩余帧,由 skipped/abort_code 告知前端;0xE4 邮箱满按帧透传状态由前端冷却重试。
图 10-1 0xC7 跨通道突发:装载在 ISR 内完成 + 单 ACK 确认,是「组内近同时」的保证;对比逐帧 0xC0 有帧间调度抖动

10.3 0xC0 错误码(GD32 细分)

码含义ESP32/HTTP 处理
E0payload 过短(<5B)参数错误
E1通道非法参数错误
E2BUS_OFFfatal:批量/突发中止剩余帧
E3TEC>96(对端 ACK 失败率高)fatal:同上
E4邮箱满按帧透传,前端冷却重试
E5参数无效 / 电机 OTA 会话残留(0xD0 语境)motorOtaStart 收到 E5 自动 CANCEL→resetCredit→重试 START 自愈
E6通道处于 Silent(入口即拒)不计错误计数
E7通道被电机 OTA 独占(跨总线排他)OTA 结束自动恢复,不计错误计数

10.4 0xC8 收发器 Silent(只听不发,0.5.2)

GD32 GPIO(S 脚) CAN0=PA8 · CAN1=PB14 · CAN2=PB9 S=1 驱动器关断 / S=0 Normal S 内部上拉:MCU 复位即 Silent TCAN1057AV TXD 关断(驱动器禁发) RXD 仍镜像总线(接收照常) 退 Silent 忙等 tMODE=20µs 再放行 语义与状态 静默通道一切发送回 E6(不计错) 0xC1 接收上报不受影响 bus_state bit5=tx_silent · 不持久化(上电 Normal) 进 Silent 前 GD32 先 abort 本通道 PENDING 邮箱(驱动器已关,留着只涨 TEC);GD32 在 CS EXTI ISR 内直接回 ACK/ERROR 不回 WAIT
图 10-2 Silent 模式原理:收发器级只听(总线无负载注入),用于总线监听/故障排查;HTTP 端点 POST /can/silent {"ch":0,"on":1}

11. 电机 OTA 中继(0xD0~0xD2)

电机节点固件(≤48KB)经 GD32 的 CAN 总线二次烧写。两段式:前端先上传到 gd32_ota 分区(与 GD32 固件共用,上传前 abortCurrent + 清 meta + 整区强制擦除),再触发 relay 由 feedTask 经 SPI 喂给 GD32。

① POST /motor/ota?ch=N 流式写 gd32_ota + meta ② /motor/ota/relay 0xD0 START{cmd,ch,fw_size} ③ feedTask(prio 7,栈 4096) 0xD1 {offset + data ≤232B} 逐块喂 ④ GD32 → CAN 烧写电机 → 0xD2 上报 12B:state/err/acked/total/fw_size/used feedTask 韧性设计 · 整体超时 120s 强制退出(防永久占用 motorOtaIsActive) · 喂料期间 suppress ring(否则吞吐 5→1.5 chunks/s);代价是 0xD2 不更新,改主动探测:8 次 SPI 失败 / 150 次 WAIT → QUERY 偏移幂等与握手门控 · 帧带绝对 offset → SPI 超时重传幂等,GD32 重复追加不刷坏 · 每 8 个 ACK QUERY 一次,确认进 ERASE/TRANSFER 才标「传输中」 (握手阶段 GD32 也收预填充数据,防 UI 误显示 N/912) credit 死锁与修复(线上事故沉淀) 死锁机理:取消的典型时机恰是传输卡住——最后一帧合法响应 credit=0 被锁存;GD32 resetToIdle 转空闲后不再发帧, relay 又跑在 APP(无 session=0 帧)→ 没有入口能把 credit 送回 → 后续一切 transact 被背压拒绝 → 网页消息全断。 修复:SpiMaster::resetCredit() 双侧复位 8/8。三处先复位再动:① motorOtaCancel(resetCredit → 0xD0 CANCEL → 失败重试 → 收尾再对齐) ② motorOtaStart 收 0xE5 残留会话(resetCredit→CANCEL→resetCredit→重试 START);③ OOM 降级同步取消路径 取消异步化:motorOtaCancel 立即返回(前端不卡 "Failed to fetch"),耗时收尾由 ota_cancel task(prio 4)执行,等 feedTask 退出上限 10s, 超时强删并补 setSuppressRingService(false)(否则 ring 永久关闭、收不到任何推送)。 GD32 侧状态机:IDLE→握手×3→ERASE→TRANSFER→FINISH_OTA→(等新 APP 自主发 0x0A 0xAC 运行确认)→FINISHED;另含 PRE_CANCEL/WAIT_APP_RUN
图 11-1 电机 OTA 中继:fire-and-forget START(ACK 常被延迟一拍吞掉但 GD32 已收,重发会被 0xE5 拒)+ 幂等喂数 + credit 自愈

12. 监控与可观测性

12.1 健康监控(HealthMonitor)

SYS_NORMAL (0)允许一切 OTA SYS_WARNING (1)允许(带警告) SYS_CRITICAL (2)ESP32 OTA 禁止 SAFE_SHUTDOWN (3)一切 OTA 禁止·不可逆 恢复:RSSI ≥ -80 逐级回 NORMAL(任意状态可恢复) 触发阈值:温度 ≥80°C → WARNING;RSSI <-80dBm → WARNING / <-85 → CRITICAL;温度 ≥100°C → SAFE_SHUTDOWN(不可逆);堆 <10KB 告警 巡检周期:温度 esp_timer 1s(量程 20~80°C)· 主循环 5s(栈溢出标志/堆低水位快照/任务栈告警 20%)· RSSI 由 WiFi 任务 10s 喂入 OTA 门控:checkBeforeOta() 全检(SAFE_SHUTDOWN/CRITICAL 拒;RSSI ≤15s 时效 <-88 拒);checkBeforeOtaRelay() 仅温度(中继不依赖 WiFi) 堆 alloc hook:armed 后 malloc 只减不增记近似空闲,破新低记「最后一击」(size/caller/类别),供 heap_low_log 归因「碎片 vs 系统级紧」
图 12-1 健康状态机:四级状态 + OTA 门控;温度超 100°C 不可逆关机保护

12.2 GD32 遥测(can_fb 任务,请求驱动架构)

数据源SPI 命令机制内容
电机状态0xC5ESP32 每 250ms 逐页查询 ×14 页(page0 头部 active_count&0x7F 在线数、bit7 boot_active)9 节点 × 3 通道 = 27 槽(node×ch 双键);每条目 92B:STATUS data[48](字节 32~35=InitStatus)+ CONFIG data[24] + rx/seq 计数
GD32 ADC0xC4ESP32 每 1s 查询20B:temp×100 / vdda_mV / vin_mV / raw 等;VCC = vin_adc × (47k+4.7k)/4.7k = ×11 分压反推;5s 内算 fresh
电机版本0xC6GD32 事件推送(收到电机 0x00FF 应答后按铃)→ can_fb 任务 takeEvent 消费29B/条目 ×9 节点
电机 OTA 进度0xD2GD32 ring push 事件 → 同上消费12B 进度帧
GD32 初始化状态0xA3身份块 [15] 惰性查询,10s 失败退避13 模块各 1B 故障码 + module_mask/critical_fail_mask
为什么改为请求驱动(2026-08):原 GD32 主动按铃推送(0xC5 250ms/0xC4 1Hz)在 OTA 期间会撞上 ESP32 的 ring 抑制窗口,产生「按铃超时取消」刷屏。改为 ESP32 周期查询后按铃只剩真正的事件(0xC6/0xD2),OTA 期间 can_fb 任务被 pauseCanFeedbackTask 暂停,电机 OTA 期间也自动暂停避让 0xD1。0xC1 CAN 反馈则完全由 HTTP 请求时按需查询(APP 查询 0 时保持静默)。

12.3 日志系统(DebugLog)三路输出

DebugLog::log() 级别 ERR/WARN/INFO/DBG 单条 ≤128B · 堆门控分级丢弃 ERR/WARN 永不丢弃 ① UART0 直写(TX21/RX20) 3Mbps · uart_tx_chars 非阻塞,满则丢 ② UDP 单播 192.168.31.3:6666 socket 延迟创建 · 8 连败销毁 + 10s 冷却重建 ③ 环形缓冲 64 槽→开机后缩 16 槽 满环覆盖优先牺牲 I/D 保 ERR/WARN 网页 /logs 面板 HTTP 轮询 · chunked 512B 栈缓冲 UART0 RX 泵任务(prio 1):把 GPIO20 收到的外部串口行(GD32 日志并线 H1/H2)按行转进 ring → 网页可见
图 12-2 日志三路:历史教训——UART 同步阻塞写 5-8ms/条曾拖爆 OTA ACK 窗口(12/777 重发),现全部非阻塞/有界

12.4 其它诊断模块

模块功能
cpu_stats1Hz FreeRTOS runtime stats:总占用率 + 每任务 delta 占用(≤20 任务);httpd 只读静态快照,不在 httpd 线程遍历任务表
task_stack10 项任务栈水位表(创建大小/high watermark/峰值);栈溢出 hook 硬写 UART「STACK OVERFLOW」后 abort(OTA 末尾溢出若不 abort 前端会停在 100%)
gd32_adc_feedback0xC4 快照缓存(单快照覆盖写 + recv_ms 年龄);互斥懒创建,取锁 20ms 超时不阻塞查询节奏
motor_state / motor_version同上缓存模型;motor_state 整表拷贝在锁内一次完成,读方永远拿到一致快照(防撕裂)
spi_speed_testGET /spi/speedtest:0xA1 PING 探针走真实 worker 路径,RTT 直方图 7 档 + 单帧 5 段细分(提交/发送/等应答/读响应/唤醒)+ 等效吞吐;文件头注明删除步骤
build_info构建系统自动生成编译时间(fwBuildTime()),旧手写 BUILD_TIME 曾被误判为 OTA 回退

13. 诊断与容错机制总表

层级机制参数针对的问题(事故沉淀)
SPI 链路透明重试kTxRetryMax=3,同 TX_SEQ,间隔 3ms瞬时无响应;DUP-SEQ 去重保安全
链路熔断3 连败 → 离线,2s 冷却放一笔探针SPI 不通时周期查询死循环阻塞/耗尽
HELLO 重同步session=0 触发 + 5s 冷却GD32 重启后全命令超时死循环
credit 自愈PB6 高强制置 1;resetCredit()credit=0 锁存死锁(§11)
撕裂防护CS 间隔 ≥1ms · 快速类型禁盲读 · 读预算收紧RX DMA mid-pulse 重装 → 0x82 0xE8 → 偷读风暴
OTA 协调忙态看门狗150s(>中继预算 120s)异常退出导致系统永久「忙」
epoch 会话保护begin 递增,清理校验 epoch旧上传失败清理误杀新会话
半会话清理abort 必发 SPI CANCEL 0x03GD32 停在 WAITING_* 半会话
数据完整性SHA-256 双端比对前端算 / 设备读回算 / meta 存传输错误静默落盘
包身份校验向量表区间判定 kAppBOOT 包刷进 APP 区(§7.6)
NOR 全擦电机 OTA 整区预擦残留位叠加单 bit 翻转
资源保护池化/会话内分配BLE 池 4KB · 上传 4KB RAII · 对齐缓冲 OTA 期 callocBLE 收尾 0xF8 OOM(曾 32KB 池)
栈水位收敛S ≈ 峰值 + 1.5~2KBOTA 末尾 httpd 栈溢出假死

14. 关键配置常量速查

类别常量 = 值
SPICONFIG_DCU_SPI_CLOCK_HZ=13333333 · Mode 0 · 队列 4 · SPI_OTA_FRAME_MAX=256 · BOOT/APP TX 缓冲 128/256 · V2_MAX_PAYLOAD=236 · 读预算 96(短响应)· credit 8/8 · kTxRetryMax=3 · 熔断 3 败/2s · CS 间隔 1ms · poll backoff 1ms
GD32 OTAOTA_MAX_FIRMWARE_SIZE=216KB · 包载荷 48B(帧 64B)· OTA_BLOCK_SIZE=4096(RAII)· 每 32 包健康检查 · 预算 10s+25ms/包 ≤120s · 卡死 10s · 协调器看门狗 150s
电机 OTAMOTOR_OTA_MAX_FW_SIZE=48KB(0xC000) · 帧 ≤236B(4B offset + ≤232B data)· feed 120s 整体超时 · 8 败/150 WAIT → QUERY · 退出轮询 8s/150ms
BLEMTU 512 · 载荷 480B · ACK 窗口 64 · reorder 64 槽 · DATA_IN 池 8×512B · 队列 144 · 高速连接 7.5~10ms(回退 10~12.5ms)· 中继等待 120s · 负 ACK 节流 64 包/200ms
WiFi/HTTPSTA 超时 30s · 重试 5(冷启动退避 2s×retry≤3,否则 1s)· AP 192.168.31.1/6/4 台 · 端口 80 · httpd 栈 7168 · sockets 6/LWIP 12 · TCP_WND 8192 / SND 5760 · RSSI 采样 10s
健康温度 80/100°C · RSSI -80/-85/-88dBm(15s 时效)· 堆 10KB · 栈告警 20% · 巡检 5s · 温度采样 1s
日志UART0 3Mbps(TX21/RX20) · UDP 192.168.31.3:6666 · 环 64→16 槽 ×128B · 门控 6KB/10KB/4KB · CONFIG_SPI_DEBUG_LOG_LEVEL=2(编译期裁 SPI DBG/INFO)
任务栈http_server 7168 / wifi_ota 3072 / ota_relay 2560 / spi_worker 5120 / ble_ota 2560 / ble_worker 2560(p12) / health_mon 3072 / cpu_stats 3072 / wifi_reconnect 2560 / can_fb 4096 / motor_feed 4096 / ota_cancel 3072 / uart_rx 3072
欠压/FlashBrownout LVL7(~2.8V) · Flash DIO(禁 QIO)· FreeRTOS runtime stats 开启

15. 目录结构

ESP32_COMM/
├── CMakeLists.txt                  # project(ESP32_COMM)
├── sdkconfig(.defaults)            # SPI 时钟/欠压 LVL7/LWIP/NimBLE/FreeRTOS stats 等
├── config/partitions.csv           # 4MB 分区表(§2.2)
├── main/
│   ├── include/                    # app_config.h(统一常量)/ version.h(0.5.2)/ build_info.h
│   ├── core/                       # main.cpp 启动编排 · system_boot.cpp 16 位自检 · build_info
│   ├── server/                     # wifi_ota(STA/AP+路由表)· http_handlers(22 端点)· ble_ota*(GATT/窗口/reorder/池)
│   ├── ota/                        # ota_coordinator(9 态单例)· esp32_ota_core(本机 OTA)
│   ├── relay/                      # ota_relay(7 态事件状态机 + 安全握手 + 逐包转发)
│   ├── storage/                    # gd32_flash_store(分区读写/擦除水位/NVS meta/SHA/身份校验)
│   ├── transport/                  # spi_master(Link v2 链路+worker)· protocol(AES/nonce)· canfd_bridge(0xC0~C8 桥)
│   └── monitor/                    # health_monitor · debug_log(3 路+RX泵)· can_feedback_task(0xC4/C5 周期查询)
│                                   # motor_ota_relay(电机 OTA feed)· motor_state/motor_version/gd32_adc_feedback
│                                   # cpu_stats · task_stack · spi_speed_test · spi_ring_cache.h
├── components/transport/           # 组件封装(CMakeLists)
├── docs/                           # can_protocol.md · frontend_architecture.md · 架构评审 · 本文件
├── spi_link/(兄弟目录)            # 三工程共享 SPI Link v2 协议真源 + golden-vector 自测(../../../spi_link/)
└── tools/(已移出仓库)             # 网页控制台;快照在 .cache/ws_era_frontend_backup/(§17)

16. 构建与烧录

16.1 编译(ESP-IDF v6.0,Windows PowerShell)

# 设置工具链环境(IDF_TOOLS_PATH / IDF_PATH / PATH,见仓库历史 README 或 .devcontainer)
Set-Location "G:\Robot_v2\DCU\ESP32_COMM"
python "F:\.espressif\v6.0\esp-idf\tools\idf.py" build
# 产物:build/ESP32_COMM.bin + bootloader + partition-table(零 error/warning 基线)

版本发布流程:手动递增 main/include/version.h 的 MAJOR/MINOR/PATCH 与 FW_VERSION_STRING(并把变更摘要写进该文件注释),GIT_HASH 填 git rev-parse --short HEAD;编译时间自动生成。

16.2 三条烧录路径

路径流程
串口(首次/恢复)idf.py -p COM15 flash monitor;固件反复重启等严重故障时唯一恢复手段
WiFi OTA(ESP32 自升级)网页控制台选 build/ESP32_COMM.bin → 前端算 SHA → /ota/upload → /ota/update(SHA 比对→set_boot→重启)→ 前端按 banner「启动」正则判定恢复(uptime<120s 判重启完成)
BLE OTAWeb Bluetooth(Chrome/Edge):DATA_IN 480B 包 ×64 窗口 → 0xF8+SHA 收尾重启;0xFB 可中止
GD32 中继上传 .bin/.hex 到 gd32_ota → /ota/relay(或 BLE 0xF7)→ 六阶段转发 → PING 确认 APP 存活;可重复触发(STORED/COMPLETE 均可)
电机节点/motor/ota?ch=N 上传 → /motor/ota/relay → 0xD0/0xD1 经 GD32 CAN 烧写 → /motor/ota/status 轮询

17. 网页控制台前端

托管方式:Web 资源不嵌入固件,由 tools/serve.bat(python -m http.server 8080)本地托管,浏览器跨源调用 ESP32 REST API——改网页无需重编固件。零构建原生 ES Modules(ES module 有 file:// CORS 限制,必须经 http 访问)。tools/ 目录已移出本仓库(历史快照在 .cache/ws_era_frontend_backup/),约定文档见 docs/frontend_architecture.md。

页面功能
index.html(OTA 控制台)WiFi/BLE 双通道切换 · GD32/ESP32 目标选择(GD32 支持 .hex 自动转换)· 上传/SHA/阶段流程条驱动 · 设备状态(含启动自检面板 = SystemBoot 位图)· 运行指标(ESP32 温度/GD32 温度 0xC4/输入 VCC 分压反推/GD32 APP·BOOT 版本)· 日志面板(/logs 轮询 + 级别过滤)· 「堆与任务栈」抽屉(CPU 条形/水位/任务表)· CAN/电机页入口
motor.html(电机控制台)独立 seed 主题;节点总览 1Hz · CMD 控制/周期重发 · CONFIG/PID · 版本查询 · NodeID 设置 · 电机 OTA relay(进度 0xD2)· 波形显示;三条轮询链路互不阻塞
can.html / can_monitor.htmlDBC 导入解析 · 报文/信号编辑 · 单发/批量发送 · 发送历史与会话持久化 · 接收监控(独立页/抽屉,0xC1 快照分页)

关键约定:JS import 不带 ?v=(版本戳只放 HTML 标签);统一 data-action 事件委托禁内联 onclick;js/ble/*(Web Bluetooth 时序)与 js/ota/*(OTA 状态机/window.* 总线)为红线区;重启完成判定 = banner 正则 + uptime<120s。

18. 遗留与临时项(补充 §1.2 功能一览)

项状态说明
BLE CTRL 0xF9(SHA 预检)未实现仅 app_config.h 定义常量,CTRL 回调无处理分支;WiFi 侧 /ota/update 有 verify_only 等价功能
0xC3 ID 快照完整版(ESP32 侧)协议就绪/桥未主动调用canfd_bridge.h 有常量与条目布局定义;GD32 侧已实现;ESP32 HTTP 无独立端点
0xC1 GD32 1Hz 主动推送保留协议支持但现架构默认请求驱动(§12.2)
/spi/speedtest临时功能性能剖析用,代码标注可整行删除(3 处挂接点)
HTTP 认证 / 凭据治理未实现见 §19
mDNS不存在历史 README 讹误;仅 lwIP 编译项支持 mDNS 解析

19. 安全审计与已知限制

2026-08-16 安全审计结论(待整改):当前固件面向可信局域网设计,存在以下已知风险—— ① 零认证:HTTP 22 个端点全部无鉴权(含固件上传、重启、CAN 发送),仅靠局域网边界;Bearer token 方案已设计未实施。 ② 凭据硬编码:WiFi SSID/密码、MASTER_KEY 占位值(01 23 45 67 89 AB CD EF FE DC BA 98 76 54 32 10)明文入仓,建议迁 NVS/Kconfig 并做 CI 扫描。 ③ 无固件签名:ESP32 侧仅 SHA-256 完整性校验(非来源认证);GD32 侧依赖预共享 MASTER_KEY 的挑战-应答(对称密钥,ESP32 泄露即整链泄露)。 ④ AES-ECB 模式:OTA 数据包逐 16B 块独立加密(确定性模式),相同明文块产生相同密文块。 缓解现状:BLE 需物理接近;GD32 固件包身份校验防误刷;nonce 每会话随机绑定防重放;诊断统计(0x82 归因/偷读签名/熔断计数)全部可观测。

其他工程性限制:SPI 13.33MHz 为实测上限(≥16MHz 全双工不可行;12M 与 13.33M 重发率相当,时钟非重发主因);Flash 必须 DIO(QIO 读回 SHA 必错);CANFD 数据字段 64B 不可压缩(用户明确使用长帧);GD32 包修复依赖三工程协议同步(spi_link/ 兄弟目录 + 两侧判据成对修改)。

20. 版本演进摘要(0.3.x → 0.5.2)

版本要点
0.3.81~0.3.85GD32 OTA 包级防呆(向量表身份)· UART0 RX 泵(/logs 汇聚外部串口)· GD32 APP 初始化状态可视化(0xA3)· 模块级故障码 hex · CAN 反馈请求驱动改造(0xC5/0xC4 改周期查询)
0.4.2serviceRing 偷读迟到 RESPONSE 的 seq 失配修复(「一直读到 0x82」根因)
0.4.3~0.4.80x82 量化归因插桩 · 快速类型禁盲读 · CS 事务间隔 1ms · ring_steal_diag 撕裂形态签名
0.4.9tx_total/tx_retry/tx_fail 计数进 /system/status(透明重试不再静默)
0.5.0SPI 链路稳健性 + OTA 内存(上传 4KB RAII)+ 状态诊断
0.5.1SPI 调试日志编译期门 CONFIG_SPI_DEBUG_LOG_LEVEL(84 条 DBG/INFO 裁剪)
0.5.2SPI 0xC8 通道收发器 Silent + POST /can/silent · 0xC1 bus_state 新增 bit5=tx_silent · 静默通道发送回 0xE6

21. 文档同步说明(HTML ↔ README)

本文件(docs/project_introduction.html)是项目的完整介绍白皮书:算法细节、协议字节级定义、状态机与事故沉淀。根目录 README.md 是它的浓缩版(快速上手 + 速查表),两者内容同源。

同步规则(请后续维护者遵守): ① 任何影响「功能清单、协议/常量数值、状态机、端点表、实现状态」的代码变更,须同时更新两份文档; ② 变更摘要写入 main/include/version.h 注释,文档头部版本行随之更新; ③ HTML 偏重原理与完整参数,README 偏重速查与上手,细节以 HTML 为准; ④ 两文件顶部均带「文档同步版本」(当前 0.5.2 / 2026-10-11),修改后同步递增日期。