Files
GD32E50X_BOOT/docs/review/GD32E50X_BOOT_专家评审_2026-10-19.html
T

95 lines
40 KiB
HTML
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.
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GD32E50X_BOOT 专家评审 2026-10-19</title><style>
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;margin:0;background:#f5f6f8;color:#1f2328;line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:28px 24px 60px}
h1{font-size:26px;margin:0 0 6px}h2{font-size:20px;margin:36px 0 12px;padding-bottom:6px;border-bottom:2px solid #d0d7de}
h3{font-size:16px;margin:20px 0 8px}
.meta{color:#57606a;font-size:13px}
table{border-collapse:collapse;width:100%;background:#fff;font-size:13px;margin:8px 0}
th,td{border:1px solid #d0d7de;padding:6px 8px;vertical-align:top;text-align:left}
th{background:#eef1f4;white-space:nowrap}
code,.loc{font-family:Consolas,Menlo,monospace;font-size:12px;word-break:break-all}
.sev{display:inline-block;padding:1px 8px;border-radius:10px;color:#fff;font-weight:600;font-size:12px;white-space:nowrap}
.s0{background:#a40e26}.s1{background:#d1242f}.s2{background:#bf8700}.s3{background:#1f6feb}
.card{background:#fff;border:1px solid #d0d7de;border-radius:8px;padding:14px 18px;margin:10px 0}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.kpi{background:#fff;border:1px solid #d0d7de;border-radius:8px;padding:12px;text-align:center}
.kpi b{display:block;font-size:26px}
pre{background:#0d1117;color:#e6edf3;padding:14px;border-radius:8px;overflow:auto;font-size:12px;line-height:1.45}
.ok{color:#1a7f37;font-weight:600}.no{color:#d1242f;font-weight:600}.warn{color:#9a6700;font-weight:600}
</style></head><body><div class="wrap">
<h1>GD32E50X_BOOT 专家评审报告</h1>
<div class="meta">仓库 cbb/GD32E50X_BOOT · 分支 NO_compress · 基线提交 644c8fb(2026-10-13)· 评审日期 2026-10-19 · BOOT 版本 1.1.0.25</div>
<div class="grid" style="margin-top:18px">
<div class="kpi"><b style="color:#a40e26">3</b>致命</div>
<div class="kpi"><b style="color:#d1242f">13</b>严重</div>
<div class="kpi"><b style="color:#bf8700">18</b>一般</div>
<div class="kpi"><b style="color:#1f6feb">6</b>建议</div>
</div>
<div class="card"><b>结论速览:</b>架构思路成熟(A/B 双区 + CRC + 崩溃门 + 三链路 OTA + 共享布局头),但<b>完整性与鉴权链条存在可绕过的缺口</b>:ECAT 门在 APP 崩溃后自动敞开且无鉴权、CRC 擦除态即放行、CRC 只对写后 Flash 盖章、密钥硬编码可重放。建议先完成路线图 P0 再进入量产。综合评分 <b>4.7 / 10</b>。
<ol style="margin:8px 0 0"><li><a href="#R01">R01</a> <span class="sev s0">致命</span> ECAT 刷写门在 APP 任一标志无效时自动敞开,且该路径无任何鉴权</li><li><a href="#R02">R02</a> <span class="sev s0">致命</span> SPI/CAN 通道共用硬编码主密钥,会话密钥仅由主机 nonce 决定,握手可整段重放</li><li><a href="#R03">R03</a> <span class="sev s0">致命</span> CRC 擦除态即判通过,CRC 写失败被吞——无 CRC 镜像可直接启动</li><li><a href="#R04">R04</a> <span class="sev s1">严重</span> CRC 是“写完后对 Flash 现状盖章”,不与主机提供的整镜像校验值比对</li><li><a href="#R06">R06</a> <span class="sev s1">严重</span> 配置页读-擦-写无掉电保护</li></ol></div>
<h2>1. 项目概述与架构</h2>
<p>GD32E508VE 的 Bootloader,常驻三条升级链路:ESP32 经 SPI0 从机(v2 帧协议)中继、CAN-FD 直连、以及 1.1.0.x 起新增的 LAN9252 原生 EtherCAT(CoE SDO)刷写。Flash 布局由 <code>shared/ota_layout.h</code> 统一定义,供 BOOT/APP 共用。</p>
<pre>
┌──────────────── GD32E508VE (Cortex-M33 @180MHz, 512KB Flash) ────────────────┐
ESP32 ──SPI0 从机(PA15 CS/EXTI15, DMA)+PB6 DATA_READY──► SpiOta (v2: SEQ/ACK/CREDIT) ─┐
CAN-FD 总线 ──CAN0 RX0/TX 中断──────────────────────► CAN_transmiter ─────────────────┤
EtherCAT 主站 ──LAN9252 (EXMC/HBI) ─► SSC 5.11 (CoE SDO 0x25xx) ─► boot_ecat_app ──────┤
▼
OTAHandler:握手(K_master→K_session) → SIZE/擦除
→ DATA(AES-ECB 48B + 带钥 CRC32) → FINISH(CRC 盖章/置标志)
ECAT:ecatBegin/Feed/Commit(明文,令牌或“救砖”门)
▼
Flash:0x08000000 BOOT 72KB │ 0x08012000 备份区 216KB │ 0x08048000 主应用区 216KB (VTOR)
0x0807E000 OTA 配置页(10 字:AVAL/EN/CHECK×2、BOOT 版本、ECAT_ARM、崩溃计数) │ 0x0807E800 睡眠页
</pre>
<h3>源码构成</h3>
<table><tr><th>模块</th><th>文件</th><th>行数</th><th>说明</th></tr>
<tr><td>启动/跳转</td><td>main.cpp, sys/initial.*, sys/systick.*</td><td>~1,100</td><td>启动判定、崩溃门、深睡、延时</td></tr>
<tr><td>OTA 核心</td><td>sys/OTA.*, sys/aes128.*, sys/config.h, shared/ota_layout.h</td><td>~2,770</td><td>握手、解密、写 Flash、CRC、配置页、ECAT 刷写接口</td></tr>
<tr><td>SPI 链路</td><td>device/SpiOta.*(+仓库外 spi_link_v2)</td><td>~1,170</td><td>DMA 收发、CS EXTI、SEQ/ACK/CREDIT</td></tr>
<tr><td>CAN 链路</td><td>device/Can.*</td><td>~530</td><td>CAN-FD 收发、软件发送队列</td></tr>
<tr><td>EtherCAT</td><td>comm/ecat/boot_ecat_*.cpp + Beckhoff SSC 5.11</td><td>~1,140 自研 + ~10,000 SSC</td><td>EXMC/HBI 驱动、CoE 对象 0x25xx、跳转</td></tr>
<tr><td>厂商库</td><td>RTE/Device/GD32E508VE</td><td>—</td><td>GD32E50x 标准外设库、启动文件、链接脚本(未评审内容,仅核对区段配置)</td></tr></table>
<h3>启动流程</h3><table><tr><th>阶段</th><th>行为(main.cpp)</th></tr>
<tr><td>1. 初始化</td><td>时钟/SysTick/USART 调试口 → 回写 BOOT 版本号到配置页 → 深睡检查(BootSleepManager::check)</td></tr>
<tr><td>2. 复位原因</td><td>FWDGT 复位 → 崩溃计数 +1,A 区 EN 置无效;计数 ≥ 3 → crash_gate,禁止自动跳转</td></tr>
<tr><td>3. 跳转判定</td><td>A 区 CHECK==AVAL && EN==AVAL && !crash_gate → verifyAppCrc → jumpToApplication</td></tr>
<tr><td>4. 留守</td><td>否则启动 SPI/CAN/ECAT 三条 OTA 链路,APP_DELAY_BOOT_TIME 等待窗口;超时后可尝试 B 区恢复(同样受 crash_gate 约束)</td></tr>
<tr><td>5. 刷写完成</td><td>validateApplication(向量表)→ 计算并写 CRC → 置 CHECK/AVAL/EN → 跳转</td></tr></table>
<h2>2. 评审范围与方法</h2>
<ul>
<li><b>范围:</b>NO_compress 分支全部自研源码与工程配置(cproject/csolution/cbuild-pack/vcpkg、链接区段、.gitignore);SSC 原厂文件只核查配置宏与本地改动(git 显示 46adcb7 之后未再修改);RTE 厂商库只核对区段与中断向量。</li>
<li><b>专家组:</b>① Bootloader/OTA 可靠性与固件安全;② 实时性、中断/DMA 与 SPI/CAN 协议;③ EtherCAT(SSC/LAN9252/CoE);④ 代码质量、构建与仓库卫生。各组并行逐行阅读,结论由主评审在代码中逐条复核行号后汇总。</li>
<li><b>既有记录:</b>全仓搜索 .md/.html,仅发现 <code>.trae/specs/prune-boot-dead-code/</code>(spec/tasks/checklist)。另据 .gitignore 推断曾有未入库的 SECURITY_AUDIT_REPORT.*,无法获取。</li>
<li><b>限制:</b>本次为静态评审,未编译、未上板;外部依赖 spi_link_v2 不在仓库,无法评审;标“待确认”的条目需上板或查看 APP 侧代码验证。</li>
<li><b>严重度:</b>致命=可变砖/不可恢复或可被远程植入任意固件;严重=现场可能故障或明显安全缺陷;一般=特定条件下出错或维护隐患;建议=改进项。</li>
</ul>
<h2>3. 问题清单(40 条)</h2>
<table><tr><th>编号</th><th>严重度</th><th>模块</th><th>问题 / 位置(文件:行号)</th><th>现象</th><th>风险</th><th>修改建议</th></tr><tr id="R01"><td>R01</td><td><span class="sev s0">致命</span></td><td>安全/ECAT</td><td><b>ECAT 刷写门在 APP 任一标志无效时自动敞开,且该路径无任何鉴权</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:850-865;GD32E50X_BOOT/main.cpp:246,266,294,408</span></td><td>ecatArmed() 除令牌外,只要 LATEST_CHECK 或 LATEST_EN 有一个不是 AVAL 就返回 true。FWDGT 复位(main.cpp:246)、超时/崩溃路径(266/294/408)都会把 EN/CHECK 置为 NOT_AVAL。ECAT SDO 下发的是明文镜像,无 AES、无签名、无主机侧 CRC。</td><td>APP 只要崩溃一次(或被诱发看门狗复位),总线上任何主站都可经 CoE 0x25xx 对象刷入任意固件,属于可远程植入。</td><td>救砖门应改为“仅当 A/B 两区都无可启动镜像”才开;ECAT 镜像必须带签名(推荐 Ed25519/ECDSA-P256,公钥固化在 BOOT)或至少带主机预置 CRC/HMAC 并在 commit 前比对。</td></tr><tr id="R02"><td>R02</td><td><span class="sev s0">致命</span></td><td>安全/OTA</td><td><b>SPI/CAN 通道共用硬编码主密钥,会话密钥仅由主机 nonce 决定,握手可整段重放</b><br><span class="loc">GD32E50X_BOOT/sys/config.h:120-123;GD32E50X_BOOT/sys/OTA.cpp:1250-1265</span></td><td>K_master 以常量写死在 BOOT,所有设备相同;K_session = AES(K_master, nonce_server),设备端不贡献随机数,设备 UID 不参与派生。</td><td>录下任意一次合法升级流量即可对任意设备原样重放(含降级到有漏洞的旧版);一旦从任一块板 dump 出 BOOT 即可伪造全部设备的固件。</td><td>改为“签名 + 可选加密”:镜像用私钥签名,BOOT 仅存公钥;加密密钥按设备 UID 派生(KDF(K_master, UID))并由设备贡献 nonce;加入单调版本号防降级。</td></tr><tr id="R03"><td>R03</td><td><span class="sev s0">致命</span></td><td>OTA 可靠性</td><td><b>CRC 擦除态即判通过,CRC 写失败被吞——无 CRC 镜像可直接启动</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:1523-1549(verifyAppCrc)、1559-1577(writeAppCrc)</span></td><td>stored_crc==0xFFFFFFFF 时 verifyAppCrc 直接返回 true;writeAppCrc 写失败只打印、无返回值,调用方(OTA.cpp:385、ECAT commit 959)随后照常置 CHECK/AVAL/EN。</td><td>CRC 槽写失败、或被最后一包数据覆盖时,损坏/不完整镜像依旧被放行启动,设备可能反复崩溃直至触发崩溃门而停机。</td><td>调试烧录的豁免改用显式标志(例如配置页单独一个 DEBUG_NO_CRC 字,量产 BOOT 编译期禁用);writeAppCrc 返回状态并回读校验,失败则不置标志、回 ERROR。</td></tr><tr id="R04"><td>R04</td><td><span class="sev s1">严重</span></td><td>OTA 可靠性</td><td><b>CRC 是“写完后对 Flash 现状盖章”,不与主机提供的整镜像校验值比对</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:376-389(SPI/CAN FINISH)、GD32E50X_BOOT/sys/OTA.cpp:956-960(ECAT commit)</span></td><td>FINISH 时只做向量表合法性检查(validateApplication),随后 calculateAppCrc 对 Flash 当前内容计算并写入。主机从未下发整镜像 CRC/摘要。</td><td>丢包、跳号、写失败留下的空洞都会被计算进“合法 CRC”,CRC 只能防后续位翻转,防不住传输/写入错误。</td><td>协议在 SIZE 阶段同时下发镜像 CRC32(或 SHA-256),FINISH 时比对一致后才写 CRC 与标志。</td></tr><tr id="R05"><td>R05</td><td><span class="sev s1">严重</span></td><td>OTA 可靠性</td><td><b>收包不强制连续序号、可提前 IFEND、Flash 写失败与擦除失败被静默忽略</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:1077-1160(DATA 处理)、818-838(writeApplicationData 失败仅 return)、347-349(擦除后无条件进入 RECEIVING)</span></td><td>DATA 不检查 seq==next_expected;任意包可带 IFEND 结束;writeApplicationData 遇 FMC 错误直接 return,不置错误码;eraseApplicationArea 结果未检查即切到 OTA_STATE_RECEIVING_FIRMWARE。</td><td>与上一条叠加,部分写入的镜像可被判为成功。</td><td>按序号严格推进并在 FINISH 时校验 received_size==expected_size;writeApplicationData/erase 返回 bool,失败转 ERROR 并通知主机。</td></tr><tr id="R06"><td>R06</td><td><span class="sev s1">严重</span></td><td>OTA 可靠性</td><td><b>配置页读-擦-写无掉电保护</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:133-224(OTA_SET);GD32E50X_BOOT/main.cpp:75-128(版本号回写)、153-160(崩溃计数)</span></td><td>10 个配置字放在单页 0x0807E000,任何 1→0 以外的修改都要整页擦除后回写,无双页/日志结构,写完无回读校验。崩溃计数每次 FWDGT 都会擦一次页。</td><td>擦除窗口内掉电,整页变 0xFF:A/B 区标志全失效,好 APP 也停在 BOOT;同时 ecatArmed 判为已 arm(叠加致命#1)。频繁擦写也加速该页磨损。</td><td>改为 A/B 双页 + 序号/CRC 的日志式配置(或至少“先写新页、再擦旧页”);崩溃计数改用备份寄存器/RTC BKP 或日志追加。</td></tr><tr id="R07"><td>R07</td><td><span class="sev s1">严重</span></td><td>OTA 可靠性</td><td><b>新固件 CRC 合法但运行崩溃时无法回滚到 B 区</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:548-549;GD32E50X_BOOT/main.cpp:258-260,282,430</span></td><td>jumpToApplication 每次跳转前都把 AVAL/EN 重新写成 AVAL,使 FWDGT 后置的 EN=NOT_AVAL 门失效;崩溃次数达到 OTA_CRASH_STAY_MAX(3) 后 crash_gate 同时挡住了 B 区恢复分支(main.cpp:430 条件含 !crash_gate)。</td><td>坏版本 APP 会反复启动-崩溃,最终停在 BOOT 等外部灌写,而 B 区里的好版本永远用不上。</td><td>崩溃门触发时应优先尝试从 B 区恢复(恢复后清零计数),仅当 B 区也不可用才停在 BOOT;EN 标志仅由 APP 自检通过后置位。</td></tr><tr id="R08"><td>R08</td><td><span class="sev s1">严重</span></td><td>并发/ISR</td><td><b>OTA 状态机与 Flash 写入在 ISR 与主循环两个上下文并发执行</b><br><span class="loc">GD32E50X_BOOT/device/SpiOta.cpp:502;GD32E50X_BOOT/sys/OTA.cpp:1120(ISR 内 drainFlashBufferOne);GD32E50X_BOOT/device/Can.cpp:182-189</span></td><td>SPI 非 64B 帧在 CS 上升沿 EXTI ISR 内直接调用 handleLinkMessage;缓冲满时会在 ISR 里写 Flash 并 fmc_lock,而主循环可能正处于 writeApplicationData 中途。ota_state、pending_flash_erase、pending_app_jump 等共享变量非 volatile;CAN 从主循环进入,形成第二生产者。</td><td>主循环写到一半 FMC 被 ISR 锁上,后续字写入失败(且被吞,见上条);状态机字段撕裂。</td><td>ISR 只做帧校验与入队,所有 handleLinkMessage/Flash 操作统一在主循环单线程执行;共享标志 volatile + 临界区。</td></tr><tr id="R09"><td>R09</td><td><span class="sev s1">严重</span></td><td>SPI 协议</td><td><b>DATA 队列满时序号已推进,帧被永久丢失;credit 初值大于队列深度</b><br><span class="loc">GD32E50X_BOOT/device/SpiOta.cpp:82,406-408,489-494;GD32E50X_BOOT/sys/config.h:140-142</span></td><td>s_expectedRxSeq 在入队前就 +1;队列满(kOtaDataSlots=4)时只回 WAIT。ESP32 重发同 SEQ 会被判为 DUP(409-427),只回放缓存响应,该包永不处理。g_localCredit 初值/重置为 CONFIG_OTA_FLASH_BUF_SLOTS=8,大于 ISR 队列 4 槽,对端严格遵守 credit 也会溢出。</td><td>高吞吐时丢包且不报错,配合“不强制连续序号”问题,可产出带空洞的镜像。</td><td>入队成功后再推进期望序号;credit 取 min(ISR 槽, Flash 槽);队列满回专用 BUSY 且不推进 SEQ。</td></tr><tr id="R10"><td>R10</td><td><span class="sev s1">严重</span></td><td>SPI 协议</td><td><b>isSpiTransport() 判断恒假,CAN 帧可在 SPI 会话中抢走回包通道</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:323-325;GD32E50X_BOOT/device/SpiOta.cpp:485;GD32E50X_BOOT/device/Can.cpp:174;GD32E50X_BOOT/sys/OTA.cpp:308</span></td><td>SPI ISR 路径设置的是 SpiOta::sender,而 isSpiTransport() 比较的是 ota_spi_sender,两者不同,因此 Can.cpp:174 与 OTA.cpp:308 的“SPI 会话中忽略 CAN”保护在多数时间不生效。</td><td>SPI 升级过程中总线上一帧 CAN OTA 报文即可把 transport_send 切回 CAN,ACK 发错通道,ESP32 侧超时(代码 OTA.cpp:349-355 已出现过同类症状并局部打补丁)。</td><td>用显式枚举 transport_kind 记录当前会话链路,而不是比较函数指针。</td></tr><tr id="R11"><td>R11</td><td><span class="sev s1">严重</span></td><td>SPI 驱动</td><td><b>SPI 从机 NSS 常低,永不按 CS 重新同步位计数;EXTI 双边沿靠读电平判方向</b><br><span class="loc">GD32E50X_BOOT/device/SpiOta.cpp:721,768-771,941-946</span></td><td>spi_nss_internal_low 使 SPI 永远处于选中态;CS 用 EXTI_TRIG_BOTH,ISR 中读引脚电平判断沿方向。ISR 延迟时两个边沿可能合并成一次中断。</td><td>一次 SCK 毛刺或半字节事务后所有后续字节永久错位直至复位;释放沿丢失时两笔事务拼进同一 RX 缓冲。</td><td>使用硬件 NSS(或在 CS 释放时复位 SPI 外设/DMA);上升沿/下降沿分别用独立状态位,处理时以 DMA 剩余计数判帧长。</td></tr><tr id="R12"><td>R12</td><td><span class="sev s1">严重</span></td><td>SPI 驱动</td><td><b>主循环可在 CS 有效、DMA 正在发送时改写 TX 缓冲</b><br><span class="loc">GD32E50X_BOOT/device/SpiOta.cpp:300-308(submitResponse)、954/1012/1020(armSpiDmasForNextCs 前仅检查 !s_csActive)、881-887</span></td><td>主循环检查 !s_csActive 与 armSpiDmasForNextCs 之间无临界区;submitResponse 虽关中断,但关中断挡不住 DMA,仍可在 CS 有效、DMA 读取 s_txBuf 时 memcpy 覆盖;CS 释放时把只发出一半的 ACK 标记为已交付。</td><td>ACK 损坏或丢失,导致对端重传/超时,严重时会话死锁。</td><td>TX 用双缓冲(乒乓),DMA 只读“已提交”缓冲;提交与 armDma 放在同一临界区内。</td></tr><tr id="R13"><td>R13</td><td><span class="sev s1">严重</span></td><td>CAN</td><td><b>CAN 接收只有单槽缓冲,旧帧被覆盖、新帧被重复处理;软件发送队列可能卡死</b><br><span class="loc">GD32E50X_BOOT/device/Can.cpp:167-189,324-337(单个 receive_message + 计数器)、292-293(入队后未使能 TME 中断)</span></td><td>ISR 每收一帧覆盖 receive_message 并 count++;主循环按 count 次数处理同一缓冲。count-- 非原子。入队后未开 TMEIE,排队的 ACK 可能永不发出;直发路径绕过队列造成乱序。</td><td>CAN OTA 在突发流量下丢帧/重复写同一包,或 ACK 永不发出导致会话超时。</td><td>改为 N 槽环形队列(ISR 写 head、主循环读 tail);发送统一走队列并在入队时使能 TME 中断。</td></tr><tr id="R14"><td>R14</td><td><span class="sev s1">严重</span></td><td>CAN</td><td><b>Bus-off 不自动恢复,且无错误中断</b><br><span class="loc">GD32E50X_BOOT/device/Can.cpp:76</span></td><td>auto_bus_off_recovery = DISABLE,代码中无 bus-off/错误被动处理逻辑。</td><td>一次总线干扰进入 bus-off 后 CAN 通道永久失效,只能复位。</td><td>启用自动恢复,或开启 ERR/BOF 中断并在主循环中按策略恢复。</td></tr><tr id="R15"><td>R15</td><td><span class="sev s1">严重</span></td><td>ECAT/跳转</td><td><b>跳转 APP 前不复位 ESC,EXMC 引脚保持 AF 推挽(待上板确认)</b><br><span class="loc">GD32E50X_BOOT/comm/ecat/boot_ecat_hw.cpp:542-556,332-335</span></td><td>escResetForJump() 被注释掉成为死代码;跳转前只关闭 EXMC 时钟,GPIO 仍为 GPIO_MODE_AF_PP,注释“PD/PE 回浮空”与实际不符。INT_EN/IRQ_CFG 残值与 PREOP 状态遗留给 APP。</td><td>APP 起板前 LAN9252 片选/写使能电平不确定,存在误选通与状态残留;这正是历史上 1.1.0.18/22 反复修 warm jump 的根源。</td><td>跳转前把 EXMC 相关 GPIO 显式重置为浮空输入(或拉高 NE/NWE),并按 HBI 使用情况执行 ESC 软复位(0x01F8 / RESET_CTL),在 APP 侧做对称初始化。</td></tr><tr id="R16"><td>R16</td><td><span class="sev s1">严重</span></td><td>安全/资源</td><td><b>主栈仅 1KB,ISR 内叠加 printf、AES、128B 局部数组与 SSC</b><br><span class="loc">GD32E50X_BOOT/RTE/Device/GD32E508VE/regions_GD32E508VE.h:96(__STACK_SIZE 0x400)</span></td><td>EXTI ISR 中调用 handleLinkMessage(含 printf、AES 解密、多个 64~128B 局部缓冲),SSC 邮箱处理也在主循环深调用链上。</td><td>栈溢出会静默覆盖 .bss/堆(SDO 分段缓冲来自 malloc),表现为随机死机或写错 Flash。</td><td>栈至少提高到 4KB,并用栈水位填充(0xDEADBEEF)或 MPU 栈守卫页实测;ISR 中禁止 printf。</td></tr><tr id="R17"><td>R17</td><td><span class="sev s2">一般</span></td><td>OTA</td><td><b>最后一包固定写满 48 字节,镜像接近上限时会覆盖 CRC 槽;SPI 与 ECAT 尺寸上限不一致</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:1173-1175(new_size &lt;= APP_MAX_SIZE);GD32E50X_BOOT/sys/OTA.cpp:890(ECAT 用 APP_FIRMWARE_SIZE);GD32E50X_BOOT/sys/config.h:38</span></td><td>SPI/CAN 路径允许 size 到 APP_MAX_SIZE(0x35FFC),而 CRC 存放在 start+APP_FIRMWARE_SIZE(0x35FF8);ECAT 用 APP_FIRMWARE_SIZE。末 4 字节闲置,与注释“末尾 CRC32”不符。</td><td>大镜像的最后一包会写入 CRC 槽,随后 writeAppCrc 必然 PGERR(被吞)。</td><td>统一上限为 APP_FIRMWARE_SIZE 并把 48B 对齐余量计入;修正注释。</td></tr><tr id="R18"><td>R18</td><td><span class="sev s2">一般</span></td><td>OTA</td><td><b>B 区恢复后不置 CHECK,可能每次上电重复恢复;恢复全程关中断约 108 页</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:1397-1446;GD32E50X_BOOT/main.cpp:430</span></td><td>恢复成功后经 jumpToApplication 只置 AVAL/EN,未置 LATEST_CHECK;CRC 字随数据一起被拷贝,随后 writeAppCrc 对已编程字再写必报 PGERR。(待确认 APP 侧是否会置 CHECK)</td><td>上电时间被拉长且反复擦写 A 区;恢复期间 SPI/CAN/ECAT 全部无响应。</td><td>恢复完成后置 CHECK 并跳过重复写 CRC;分段擦写间开中断喂狗。</td></tr><tr id="R19"><td>R19</td><td><span class="sev s2">一般</span></td><td>OTA</td><td><b>validateApplication 栈顶上界判断用 &lt;,排除了 MSP=RAM 顶端这一常见合法值</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:500</span></td><td>stack_ptr &lt; 0x20020000U;链接器默认 __initial_sp 正好等于 RAM 末地址。</td><td>APP 栈放在 RAM 末端时会被误判无效,停在 BOOT。</td><td>改为 &lt;= 0x20020000 且要求 8 字节对齐。</td></tr><tr id="R20"><td>R20</td><td><span class="sev s2">一般</span></td><td>跳转</td><td><b>跳转前清理不完整</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:515-652</span></td><td>未清 EXTI pending、SysTick/PendSV 挂起位,未关闭 USART0 等外设时钟,并在 BX 之前就清 PRIMASK。</td><td>APP 开中断瞬间可能进入 BOOT 遗留的挂起中断向量。</td><td>跳转前:关所有 NVIC 使能+清 pending、SysTick-&gt;CTRL=0、清 SCB-&gt;ICSR PENDSV/PENDST、复位所用外设(rcu_periph_reset),PRIMASK 留给 APP 自己打开。</td></tr><tr id="R21"><td>R21</td><td><span class="sev s2">一般</span></td><td>OTA</td><td><b>OTA 超时为循环计数,ECAT 会话无超时</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:426-436;GD32E50X_BOOT/sys/config.h:130</span></td><td>OTA_TIMEOUT=50000 次 × 20us 延时 + 循环体,实际时长随负载变化;ECAT begin 后若主站断开,ecat_session 永久为 true,阻塞 SPI/CAN 会话。</td><td>会话无法可靠回收。</td><td>统一用 SysTick 毫秒时间戳计算超时;ECAT 会话加空闲超时自动 abort。</td></tr><tr id="R22"><td>R22</td><td><span class="sev s2">一般</span></td><td>OTA</td><td><b>REQ 分支先读 data[0]/data[1] 再判 len;SIZE 帧非对齐强转</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:1239;GD32E50X_BOOT/sys/OTA.cpp:1173</span></td><td>if (data[0]==0x00 &amp;&amp; data[1]==0xAC &amp;&amp; len==2) 先解引用再判长度;*(u32*)data 对任意偏移 payload 做 32 位读。</td><td>len&lt;2 时越界读;未对齐访问在 M33 上一般可容忍,但若开启 UNALIGN_TRP 会 HardFault。</td><td>先判 len;用 memcpy 读取多字节字段。</td></tr><tr id="R23"><td>R23</td><td><span class="sev s2">一般</span></td><td>睡眠</td><td><b>深睡唤醒写 DISABLE 时可能对非擦除字再编程(待确认)</b><br><span class="loc">GD32E50X_BOOT/sys/initial.cpp:150-180</span></td><td>当 SLEEP_ENABLE 位于页首字时,循环落入 else 分支 writeAddress(address, SLEEP_DISABLE),对已编程字再编程;GD32 FMC 对非 0xFFFFFFFF 字编程报 PGERR,返回值被忽略。</td><td>DISABLE 写不进去,下次上电 check() 仍读到 ENABLE 再次深睡,且 check() 在 OTA 通道启动前执行,只能靠 SWD 救回。</td><td>日志写入一律写到下一个擦除态字;写后回读;check() 前预留按键/串口逃生窗口。</td></tr><tr id="R24"><td>R24</td><td><span class="sev s2">一般</span></td><td>中断</td><td><b>全工程未设置 NVIC 优先级分组,两个中断都配成最高优先级 0</b><br><span class="loc">GD32E50X_BOOT/sys/initial.cpp:117(EXTI5_9 0,0);GD32E50X_BOOT/comm/ecat/boot_ecat_hw.cpp:448(ESC IRQ 0,0);GD32E50X_BOOT/device/SpiOta.cpp:773;GD32E50X_BOOT/device/Can.cpp:125-126</span></td><td>未调用 nvic_priority_group_set,抢占/子优先级的划分依赖复位默认值;ESC IRQ 在 BOOT 中实际不工作(见 ECAT 条目),却占用最高优先级。</td><td>SPI CS 边沿(1,1)可能被延迟,加剧边沿合并问题。</td><td>启动时显式设置分组(如 NVIC_PRIGROUP_PRE2_SUB2),并按 SPI CS &gt; CAN &gt; 其他 规划。</td></tr><tr id="R25"><td>R25</td><td><span class="sev s2">一般</span></td><td>CAN</td><td><b>标准帧 rx_efid 未初始化;接收白名单包含本机发送的 ACK/WAIT ID;滤波器全放行、FIFO 溢出静默清除</b><br><span class="loc">GD32E50X_BOOT/device/Can.cpp:324-334,112-122,346-348,79</span></td><td>temp_message 为栈变量,驱动只在扩展帧时写 efid;ACK/WAIT 也会覆盖待处理帧;mask 全 0,RFO 只清不计数,rec_fifo_overwrite=ENABLE。</td><td>偶发把垃圾值当作 OTA ID 处理;高负载时静默丢帧。</td><td>先判 rx_ff==CAN_FF_EXTENDED;硬件滤波器只放行 REQ/DATA/CANCEL;溢出计数上报。</td></tr><tr id="R26"><td>R26</td><td><span class="sev s2">一般</span></td><td>CAN</td><td><b>CANFD_AsyncSend 用调试冻结位 CAN_CTL_DFZ 判断 FD 模式</b><br><span class="loc">GD32E50X_BOOT/device/Can.cpp:251</span></td><td>DFZ 是 Debug Freeze 位,与 FD 使能无关,当前逻辑碰巧成立。</td><td>调试器配置变化时发送路径行为改变。</td><td>改为检查 FDCTL 的 FDEN 位或用驱动 API。</td></tr><tr id="R27"><td>R27</td><td><span class="sev s2">一般</span></td><td>ECAT</td><td><b>空闲限速使 SSC 1ms 时基变为约 5ms</b><br><span class="loc">GD32E50X_BOOT/comm/ecat/boot_ecat_app.cpp:411,445-449</span></td><td>空闲时每 5ms 才调用一次 MainLoop,而 ECAT_CheckTimer 每次只累加 1ms。</td><td>PREOP/邮箱等 SSC 超时被放大 5 倍,与主站期望不一致。</td><td>按实际经过毫秒数补调 ECAT_CheckTimer,或改用 SysTick ISR 驱动。</td></tr><tr id="R28"><td>R28</td><td><span class="sev s2">一般</span></td><td>ECAT</td><td><b>PRAM FIFO / CSR busy 超时不发 abort</b><br><span class="loc">GD32E50X_BOOT/comm/ecat/boot_ecat_hw.cpp:172-194,213-214,239-240</span></td><td>超时仅 break,未写 0x030C/0x0314 abort 位;CSR busy 超时静默忽略。</td><td>下一笔 PRAM 事务与残留 FIFO 错位,读写到错误数据。</td><td>超时置 abort 并返回错误,上层重试。</td></tr><tr id="R29"><td>R29</td><td><span class="sev s2">一般</span></td><td>ECAT</td><td><b>0x25xx SDO 写回调不校验 dataSize,错误码累积,会话中可改写期望长度</b><br><span class="loc">GD32E50X_BOOT/comm/ecat/boot_ecat_app.cpp:199-243</span></td><td>dlLoadU32 固定读 4 字节;begin 失败时 u32Phase |= 0x10000 不清旧错误;会话进行中仍可改写 sub2 expected。</td><td>短写时越界读;状态码难以诊断;会话中途改长度使 commit 判据失真。</td><td>校验 dataSize==4;错误码改为赋值;ecat_session 期间拒绝写 expected。</td></tr><tr id="R30"><td>R30</td><td><span class="sev s2">一般</span></td><td>ECAT</td><td><b>死代码 HW_Enable/DisableSyncManChannel 写错寄存器,HW_SetLed 对奇地址做 16 位写</b><br><span class="loc">GD32E50X_BOOT/comm/ecat/boot_ecat_hw.cpp:649-665</span></td><td>写 0x0806(PDI 只读),应为 0x0807 bit0;SSC 5.11 实际使用 ecatslv.c 内版本,故未生效。HW_SetLed 对 0x0139 做 size=2 写,违反本文件第 257 行约束。</td><td>日后有人启用这些函数会引入隐蔽故障。</td><td>删除或修正这些未用函数。</td></tr><tr id="R31"><td>R31</td><td><span class="sev s2">一般</span></td><td>ECAT</td><td><b>VENDOR_ID 为 0(未注册),与 SII 一致性待确认</b><br><span class="loc">GD32E50X_BOOT/comm/ecat/ecat_def.h:629</span></td><td>0x1018:01 厂商号为 0x00000000。</td><td>不符合 ETG 一致性要求,多厂商设备共网时主站识别混淆。</td><td>申请 ETG Vendor ID,BOOT/APP/SII 三处统一。</td></tr><tr id="R32"><td>R32</td><td><span class="sev s2">一般</span></td><td>构建</td><td><b>仓库不自包含:依赖仓库外 spi_link 源码与 G: 盘绝对路径</b><br><span class="loc">GD32E50X_BOOT/GD32E50X_BOOT.cproject.yml:27-29,96</span></td><td>spi_link_v2.h/.cpp 不在本仓库,也不在 Gitea 任何仓库;-IG:/Robot_v2/DCU/spi_link 写死。</td><td>克隆即无法编译;SPI v2 帧解析(payload_len 校验等)无法评审,协议版本可能与 APP/ESP32 漂移。</td><td>spi_link 以 submodule 或 shared/ 目录纳入版本管理,使用相对路径。</td></tr><tr id="R33"><td>R33</td><td><span class="sev s2">一般</span></td><td>构建</td><td><b>Release 镜像仍编入全部调试 printf;版本注释悬空、无 CHANGELOG</b><br><span class="loc">GD32E50X_BOOT/sys/config.h:14,19;GD32E50X_BOOT.csolution.yml:28</span></td><td>DEBUG_PRINT 恒为 1,target-set 烧录 Release;644c8fb 删除了约 200 行版本说明,“见上方注释块”已无所指;1.1.0.22→25 无变更记录。</td><td>阻塞式串口输出拉长 ISR 与关键路径;版本追溯困难。</td><td>DEBUG_PRINT 由 build-type 宏控制;新增 CHANGELOG.md。</td></tr><tr id="R34"><td>R34</td><td><span class="sev s2">一般</span></td><td>构建</td><td><b>.gitignore 与实际跟踪内容矛盾</b><br><span class="loc">.gitignore:90-94</span></td><td>忽略 *.html 与 .trae/,但 .trae/specs 已入库;SECURITY_AUDIT_REPORT.* 被忽略,说明曾有本地安全审计稿未入库。</td><td>docs/ 下 HTML 评审在本地无法 git add;有价值的审计结论游离于仓库外。</td><td>改为 !docs/**/*.html;决定 .trae 规格是否入库;将审计结论纳入 docs/。</td></tr><tr id="R35"><td>R35</td><td><span class="sev s3">建议</span></td><td>安全</td><td><b>数据块使用 AES-ECB,带密钥 CRC32 不是 MAC,且不覆盖 seq</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:1093-1098,1470-1498</span></td><td>ECB 下同会话内块可重排;CRC32 线性可伪造。</td><td>完整性依赖 CRC,抗篡改能力弱(在签名方案落地前)。</td><td>改 AES-CTR/GCM 或 CMAC,并把 seq/session 纳入认证数据。</td></tr><tr id="R36"><td>R36</td><td><span class="sev s3">建议</span></td><td>性能</td><td><b>每个数据包都重新做 AES 密钥扩展</b><br><span class="loc">GD32E50X_BOOT/sys/OTA.cpp:1093</span></td><td>aes128_set_key 在每包解密前调用。</td><td>浪费 CPU,延长主循环处理时间。</td><td>会话建立时扩展一次并缓存轮密钥。</td></tr><tr id="R37"><td>R37</td><td><span class="sev s3">建议</span></td><td>ECAT</td><td><b>未使用 ETG 推荐的 BOOT 状态 + FoE,ESC 中断在 BOOT 中实际无效</b><br><span class="loc">GD32E50X_BOOT/comm/ecat/ecat_def.h:273,286;GD32E50X_BOOT/comm/ecat/boot_ecat_app.cpp:379-388;GD32E50X_BOOT/comm/ecat/boot_ecat_hw.cpp:441-449</span></td><td>BOOTSTRAPMODE_SUPPORTED=0、FOE_SUPPORTED=0,固件在 PREOP 经自定义 CoE 对象写入;BOOT 永不进 SAFEOP,PDI_Isr 不做事却配置为最高优先级。</td><td>与通用主站工具(TwinCAT/SOEM FoE)不兼容。</td><td>中长期迁移到 BOOT 状态 + FoE;去掉 BOOT 中无用的 ESC IRQ。</td></tr><tr id="R38"><td>R38</td><td><span class="sev s3">建议</span></td><td>可维护性</td><td><b>SSC 5.11 版本较旧、文件头缺许可证声明;量产代码残留诊断逻辑</b><br><span class="loc">GD32E50X_BOOT/comm/ecat/ecatslv.c:12;GD32E50X_BOOT/comm/ecat/ecatslv.c:1361,2437;GD32E50X_BOOT/comm/ecat/boot_ecat_hw.cpp:394-403</span></td><td>bring-up 期 printf 无条件编译;exmcInit 中留有 D4/D5 短路排查写入。</td><td>代码体积与时序受影响;许可合规风险。</td><td>升级至 SSC 5.13 并保留 Beckhoff 许可头;诊断代码收进 DEBUG 宏。</td></tr><tr id="R39"><td>R39</td><td><span class="sev s3">建议</span></td><td>可维护性</td><td><b>注释与代码不一致、死代码、宏重复定义</b><br><span class="loc">GD32E50X_BOOT/shared/ota_layout.h:5;GD32E50X_BOOT/sys/config.h:41-42;GD32E50X_BOOT/sys/OTA.cpp:25-37,138,957-958;GD32E50X_BOOT/comm/ecat/el9800hw.h:37 与 ecat_def.h:165</span></td><td>ota_layout.h 注释写 ../../../shared,实际 include ../shared;SLEEP_CONFIG 硬编码未引用 OTA_LAYOUT_SLEEP_PAGE;branchToApplication 未使用;OTA_SET 注释“6 个配置字”实为 10 个;ECAT commit 注释引用 OTA.cpp:1327 已过时(实际 1531);ECAT_TIMER_INC_P_MS 两处定义文本不同;崩溃计数清零条件 config.h 与 ota_layout.h 说法不一。</td><td>误导后续维护者。</td><td>统一引用 ota_layout.h;注释改为引用符号名而非行号;删除死代码。</td></tr><tr id="R40"><td>R40</td><td><span class="sev s3">建议</span></td><td>工程</td><td><b>缺 README/协议文档/CI</b><br><span class="loc">仓库根目录</span></td><td>无 README、Flash 布局/OTA 协议/救砖流程文档;无 CI,.trae 规格的编译验证项至今无法从仓库证实。vcpkg-configuration.json 已锁定工具链版本。</td><td>新人上手与回归成本高。</td><td>补充 docs/;Gitea Actions 跑 cbuild + cppcheck。</td></tr></table>
<h2>4. 既有评审结论跟踪</h2>
<p>来源:<code>.trae/specs/prune-boot-dead-code/</code>(随提交 3377a2e,2026-07-15 入库)。原文件保留不动,逐条复核见下表,修订版另存为
<a href="prune-boot-dead-code_复核_2026-10-19.html">docs/review/prune-boot-dead-code_复核_2026-10-19.html</a>。</p>
<table><tr><th>原条目</th><th>结论</th><th>2026-10-19 复核(基于 644c8fb)</th><th>状态</th></tr><tr><td>A</td><td>删除 OTAHandler::ota_stateota_time</td><td>全仓 grep 0 命中</td><td class="ok">仍成立</td></tr><tr><td>B</td><td>删除 handleOTAMessage 旧接口</td><td>0 命中;链路统一走 handleLinkMessage</td><td class="ok">仍成立</td></tr><tr><td>C</td><td>删除 sendDATA_ACK 两个重载</td><td>0 命中</td><td class="ok">仍成立</td></tr><tr><td>D</td><td>删除 can_dma_init 占位</td><td>0 命中;canfd_config 中无调用</td><td class="ok">仍成立</td></tr><tr><td>E</td><td>删除 BootPlatform 及其 4 个预留接口</td><td>0 命中</td><td class="ok">仍成立</td></tr><tr><td>F</td><td>删除 SPI0_IRQHandler 空壳 ISR</td><td>仅启动文件 [WEAK] 处出现;SpiOta.cpp:725-727 仍禁用 SPI0 中断,未使能 SPI0_IRQn</td><td class="ok">仍成立</td></tr><tr><td>G</td><td>删除 .bak 备份与 RTE 冲突副本</td><td>仓库中已不存在</td><td class="ok">仍成立</td></tr><tr><td>H</td><td>C 包装层审计(保留 delay_1ms/1us、system_soft_reset、EXTI/CAN IRQ)</td><td>保留项仍有调用方;spi_ota_* 仅剩类型名 spi_ota_frame_type_t</td><td class="ok">仍成立</td></tr><tr><td>I</td><td>注释一致性(已删符号 0 命中)</td><td>已删符号仍 0 命中;但出现新的注释漂移(见 R39)</td><td class="warn">部分退化</td></tr><tr><td>J</td><td>行为零变化</td><td>该次提交(3377a2e)之后又有 10 个功能提交,行为已演进,此项仅对当时快照有意义</td><td class="warn">不再适用</td></tr><tr><td>K/G2.2</td><td>编译验证(当时未执行)</td><td>仓库仍无 CI 与构建产物,无法从仓库证实;且构建依赖仓库外 spi_link(R31),克隆后无法独立编译</td><td class="no">仍未完成</td></tr><tr><td>L</td><td>“不漏不错”自检</td><td>当时范围内成立;之后新增 branchToApplication(未使用)、HW_*SyncManChannel/HW_SetLed 等死代码(R30、R39)</td><td class="warn">需续做</td></tr></table>
<h2>5. 优先级修复路线图</h2>
<table><tr><th>优先级</th><th>时间</th><th>事项</th><th>关联问题</th></tr>
<tr><td><span class="sev s0">P0</span></td><td>1 周内</td><td>收紧 ECAT 救砖门(A/B 均不可启动才开);verifyAppCrc 取消擦除态豁免、writeAppCrc 返回并回读;FINISH/commit 比对主机下发的整镜像 CRC;擦除/写入失败转 ERROR</td><td>R01 R03 R04 R05</td></tr>
<tr><td><span class="sev s1">P1</span></td><td>2–4 周</td><td>ISR 只入队、OTA/Flash 单线程化;SPI 队列满不推进 SEQ、credit 取 min;修正 isSpiTransport;TX 双缓冲;CAN 环形队列 + bus-off 恢复;崩溃门优先回滚 B 区;栈扩到 4KB 并测水位</td><td>R07–R14 R16</td></tr>
<tr><td><span class="sev s1">P1</span></td><td>2–4 周</td><td>配置页改双页日志结构;跳转前完整外设/中断清理与 EXMC 引脚复位、ESC 复位</td><td>R06 R15 R20</td></tr>
<tr><td><span class="sev s2">P2</span></td><td>1–2 月</td><td>固件签名(ECDSA-P256/Ed25519)+ 防降级版本号 + 按 UID 派生密钥,替换硬编码 K_master 与 ECB/CRC 方案</td><td>R02 R35 R36</td></tr>
<tr><td><span class="sev s2">P2</span></td><td>1–2 月</td><td>spi_link 纳入仓库、Release 关闭 DEBUG_PRINT、CHANGELOG、.gitignore 修正、Gitea Actions CI</td><td>R32–R34 R40</td></tr>
<tr><td><span class="sev s3">P3</span></td><td>后续</td><td>ECAT 迁移 BOOT 状态 + FoE;SSC 升级 5.13;申请 Vendor ID;清理死代码与注释漂移</td><td>R30 R31 R37–R39</td></tr></table>
<h2>6. 总结评分</h2>
<table><tr><th>维度</th><th>评分</th><th>说明</th></tr><tr><td>Bootloader/OTA 可靠性</td><td><b>4.5</b> / 10</td><td>A/B 分区、CRC、崩溃门思路完整,但校验可绕过、配置页无掉电保护、回滚失效</td></tr><tr><td>固件安全</td><td><b>2.5</b> / 10</td><td>硬编码共享密钥、无签名、可重放;ECAT 门在崩溃后敞开</td></tr><tr><td>实时性与中断/DMA</td><td><b>4.5</b> / 10</td><td>已做 DMA 与 ISR 分流,但 ISR 内仍跑状态机/Flash,竞态较多</td></tr><tr><td>通信协议(SPI v2 / CAN-FD)</td><td><b>5.0</b> / 10</td><td>SEQ/ACK/CREDIT 设计合理,实现存在丢包与通道串扰</td></tr><tr><td>EtherCAT 集成</td><td><b>6.0</b> / 10</td><td>SSC 集成可用,刷写流程清晰;跳转前 ESC/引脚处理与时基存疑</td></tr><tr><td>代码质量与可维护性</td><td><b>6.5</b> / 10</td><td>注释详尽、常量集中(ota_layout.h 唯一真源);存在注释漂移与死代码</td></tr><tr><td>构建与仓库卫生</td><td><b>4.0</b> / 10</td><td>依赖仓库外源码与绝对路径,无 README/CI,.gitignore 矛盾</td></tr>
<tr><td><b>综合</b></td><td><b>4.7</b> / 10</td><td>完成 P0+P1 后预计可达 7 分以上</td></tr></table>
<div class="card"><b>亮点:</b>Flash 布局单一真源、配置字以擦除态为“无效”的设计、崩溃门与 ECAT 救砖思路、SPI v2 的 SEQ/CREDIT 流控、ECAT commit 不在 SSC 回调里直接跳转、注释中保留了大量现场问题来由。<br>
<b>主要短板:</b>完整性校验“可绕过/后盖章”、鉴权缺失、ISR 与主循环共享状态机、单页配置无掉电保护、仓库不自包含。</div>
<p class="meta">本报告仅新增 docs/ 下文件,未改动任何源码。</p>
</div></body></html>