开发者附录:架构、协议与扩展指南

这一页写给要改固件、写主机侧 API、扩展前端、做真机排障的人。它不复述按钮怎么点,而是解释数据从哪里来、在哪些任务与端口之间流动、协议的硬边界在哪里,以及哪些结论已经有真机证据、哪些只能算“代码与 host 测试通过”。

版本 v1.2.0更新 2026-10-24电控组 · WiFiDAP
本页目录
!
先把硬边界说清楚。设备本地采样上限是 256 路,每组最多 64 路、每组 sum(size) ≤ 256 B、最多 8 组,start 请求体 ≤ 6 KiB(6144 B),表达式节点池 768。容量曾经被扩大过,随后因为静态内存挤占导致 SWD/DAP bring-up 超时,已经回退到 256;任何“更多路数”的旧笔记都不适用于本基线。
i
阅读前提。这是一个实验室内网工具:HTTP、WebSocket、USBIP、UART TCP 都是明文且默认无登录,Host/Origin 校验只防浏览器跨源,不是身份认证。下面的接口默认你已经在可信网络、能接受目标被停机/擦写。

1 系统架构

Birdiebot WiFi DAP 的硬件本体是一块带 USB-OTG 的 ESP32 系列主控(发布目标 8 MB Flash),固件由 ESP-IDF 构建;它对外同时扮演三种角色:无线 CMSIS-DAP 探针、自带 Web 管理面板的小型 Web 服务器、以及能在本地直接经 SWD 访问目标的采样/烧录控制器。这三个角色共用同一个 DAP 命令引擎和同一组 SWCLK/SWDIO 引脚,因此“谁能占用 DAP”是整个架构里最重要的互斥关系。

1.1 三条平面

把功能按数据面拆开比按页面拆更清楚。三条平面的共同点是:都不需要 PC 侧装驱动(USBIP 需要 WinUSB,但那是主机侧的事),设备自身始终是 TCP/HTTP 服务端。

平面入口设备内部路径典型使用者
调试数据面TCP 3240(USBIP / elaphureLink / WS-DAP 首包自动识别)tcp_server → DAP proxy / USBIP pipeline → DAP_ExecuteCommand → SWD/JTAG 位操作OpenOCD、pyOCD、Keil、浏览器 DAP 工具
管理/配置面HTTP 80、WebSocket /wshttpd → URI 模块 / /api JSON router → 各 module;/ws 文本 JSON + 4 字节头二进制数据内嵌 Web UI、脚本 curl、UART 页
设备本地目标面HTTP /api/curve/*、/api/target/*、/ws/curvecurve_sampler / target_flash → tf_swd_* 直接 AHB-AP 访问,不经过 USBIP 或 WS-DAP曲线页本地采样、目标烧录页、CLI 诊断

另外还有一条带外数据面:UART TCP 桥监听 1346,Web UART 走 /ws 的二进制帧;mDNS(5353/UDP)与 SSDP(1900/UDP)只负责发现,不承载业务。默认 AP 地址是 192.168.1.1;接入 STA 后使用路由器分配的地址,也可以访问 http://birdiebot-dap-XXXX.local/。

1.2 调试数据面:3240 端口的三合一

三种主机传输并不是三个监听端口,而是同一个 TCP 3240 监听 socket 上的三种“首包协议”。tcp_server 接受连接后先读 4 字节,再按下面的表分发;连接存续期间 DAP 由这一个会话独占,直到对端关闭或设备侧主动 kick。

首 4 字节(网络字节序解释)识别结果后续处理典型主机
0x8a656c70(EL_LINK_IDENTIFIER)elaphureLinkel_dap_work() 握手/命令循环,二进制 DAP 透传Keil + elaphureLink 插件、OpenOCD-elaphureLink
低 16 位 0x8003 / 0x8005USBIP OP_REQ_IMPORT / OP_REQ_DEVLISTusbip_worker() 进入 USBIP 状态机;WinUSB/CMSIS-DAP v2usbip-win attach_ude、Linux vhci
0x47455420(ASCII "GET ")WebSocket-DAPwebsocket_worker() 完成 HTTP Upgrade,之后二进制帧 payload 即 CMSIS-DAP 命令包elaphure-dap.js 等浏览器工具

USBIP 路径在当前发布主控上默认带有 B1 多 URB 流水线:TCP 任务立即 ACK OUT URB、把 IN URB 挂到 FIFO,DAP 任务算完响应后直接回对应 IN,从而让最多约 3 个 URB 同时在途;流水线异常时自动退回单 URB 路径。DAP 包大小在当前发布主控上默认 1280 B(OpenOCD 0.12 的 DAP_Transfer 计数是 u8,1280 是安全上限);master 版 OpenOCD 的 DAP_TransferBlock 可以用更大包,但发布默认仍是 1280。

1.3 设备本地目标面:把 WiFi 往返移出关键路径

传统 Web-DAP 采样每拍要走“浏览器 → WiFi → ESP32 → SWD → ESP32 → WiFi → 浏览器”,WiFi 往返直接决定采样抖动。设备本地采样把闭环改成 ESP32 自己按周期 halt/读寄存器/读内存/resume,浏览器只负责下配置和批量取帧。烧录同样如此:浏览器上传裸镜像,ESP32 在本地擦写并回读校验。

Web UI(同源 JS)
   │  POST /api/curve/start(JSON,≤6 KiB,groups/series/expr_s)
   ▼
ESP32 httpd ──解析/校验──► curve_config_t(静态,约 16 KiB 量级)
   │                         │
   │  409 busy / 503          ▼
   │                    curve worker task(专用栈)
   │                         │  tf_swd_link_init → halt → 读 regs/mem → resume
   │                         ▼
   │                    CVF2 ring(8 KiB 堆预算,30–264 帧)
   │                         │
   └── GET /api/curve/frames?since=&max= ◄── 批量二进制
   └── GET /ws/curve(subscribe)     ◄── 20 ms 节流推送,每批 ≤32 帧

这条路径和 Web-DAP 会话互斥:start 会先 tcp_server_kick_session() 踢掉当前 3240 会话,再 dap_handle_request_reset(DELETE_HANDLE) 取得 DAP;stop/错误路径必须 resume 目标并用 RESET_HANDLE 归还 DAP。烧录路径同理,只是它还会临时暂停 UART TCP 桥(1346),WiFi/HTTP/USBIP 监听本身保持可用。

1.4 任务、核与内存所有权

主控的 SWD 是 GPIO 位操作(bit-banging),对时序敏感,因此 DAP 任务固定在 core 1。网络/DAP 代理任务默认也在 core 1,优先级略低于 DAP;默认 S3 固件把 USB-NCM 的 TinyUSB 放 core 0,避免 USB 中断/任务抢 bit-banging。曲线采样器按“httpd 只收配置、worker 做 SWD”拆开,WS 推送另有独立任务,慢客户端不会阻塞采样。

任务/上下文默认核/优先级职责关键约束
DAP task(DAP_Thread)core 1 / 16执行 CMSIS-DAP 命令、SWD/JTAG 位操作、DAP 环形缓冲唯一所有者不长期阻塞;free/recreate ring 只允许它自己做
USBIP/EL/WS-DAP TCP taskcore 1 / 14(可配)3240 接入、首包分发、USBIP 状态机、会话收尾时投递 RESET不直接释放 DAP ring;会话结束发 RESET_HANDLE
httpdIDF 默认任务静态页面、JSON API、target/curve HTTP 端点4 KiB 栈量级;start body 按需堆分配(≤6 KiB),解析后释放,不在栈上开大数组
curve worker专用任务SWD bring-up、按组采样、写 CVF2 ringstop 必须 resume + 归还 DAP;连续错误 5 次停
curve WS pushcore 0 / 3从 ring 取帧并通过 /ws/curve 推送每客户端 20 ms 最小间隔、每批 32 帧、最多 4 客户端
TinyUSB(默认 S3 固件,NCM)core 0 / 5USB CDC-NCM 网卡与网络栈栈 4096、NTB 1+1(统一固件 heap 余量)、DMA;优先级过高会拖垮首个 WiFi-USBIP 会话

内存所有权同样是架构的一部分。DAP 环形缓冲由 DAP task 独占;其他任务只能发 RESET_HANDLE(释放并重建)或 DELETE_HANDLE(只释放,调用者随后 inline 使用 DAP)请求,并用 dap_handle_wait_reset() 有界等待。曲线采样配置是一块静态 curve_config_t,httpd 在引擎空闲时才允许覆盖;start 前会先释放上一次的 ring,避免 cJSON 解析时堆不够。改采样/烧录/传输代码时,这些所有权规则是避免“第一次能跑、第二次 CMD_INFO failed”的关键。

2 仓库与组件布局

仓库是一个标准 ESP-IDF 工程,外加一个独立构建的 Vue 前端和一套 host 测试。固件组件分两类:components/ 是相对底层的驱动/协议实现,project_components/ 是 IDF 5 工程组件(网络服务、Web、Wi-Fi 管理)。位于它们之外、但同样属于固件的是 main/、partitions/、cmake/ 与 scripts/。

2.1 顶层目录

路径作用改动时注意
main/app_main、板级 DAP 配置、OTA 回滚确认钩子启动顺序/任务创建改动会影响 DAP 与 Web 的初始化时序
components/DAP、dap_proxy、USBIP 协议、target_flash、curve_sampler、wired_dap、ws_router、UART 等私有头文件不跨组件 include;改公共头要同步 host 测试
project_components/web_server、api_router、request_runner、html 内嵌资源、wifi_manager、wt_storage、mDNSURI 注册顺序决定路由命中;html/ 是入库产物
web/Vue 3 + Vite + TypeScript 前端源码、构建脚本、纯函数测试构建产物 web/dist-gz 经 gen_web_assets.sh --install 才写入固件
partitions/8 MB 双 OTA / 4 MB 单 app 分区表(冻结契约)偏移/大小变更等于不兼容变更,需要重新烧录
scripts/Docker 构建、分区/板级校验、Web 资产、发布包、OTA/E2E 脚本构建与发布以 scripts/README.md 为准
docs/冻结契约、测试清单、实现说明(本手册的准确来源)改动协议/版本/i18n 时同步对应契约与索引
tests/host/C/Python 纯逻辑与 DAP ASan 回归不依赖 ESP-IDF、网络或硬件,适合作为提交前门禁

2.2 固件组件地图

组件核心文件职责/边界
components/DAPcmsis-dap/source/DAP.c、DAP_config.h纯 CMSIS-DAP 命令层:ID/Info、Transfer、TransferBlock、SWJ 序列、SWO 等。host 有 ASan harness,安全回归(0x7E 递归、TransferBlock 越界)在这里守。
components/dap_proxytcp_server.c、usbip_server.c、usbip_pipeline*.c、websocket_server.c、DAP_handle.c3240 端口三协议分发、USBIP 状态机与多 URB 流水线、DAP 环形缓冲所有权与会话 hand-off。它不实现 SWD 时序,只把命令交给 DAP。
components/target_flashwt_target_flash.c、tf_stm32_id.c、tf_stm32_geom.c、tf_stm32_page.c、tf_stm32f1.c、tf_stm32f4.c、tf_stm32h7.c、tf_swd.c目标探测 + 内部 Flash 擦写 + 回读校验;tf_swd_* 也是曲线采样器的本地 SWD 访问层。几何与 DEV_ID 表是纯逻辑,host 可测。
components/curve_samplercurve_http.c、curve_json.c、curve_expr.c、curve_engine.c、curve_ring.c、curve_cvf2.c、curve_ws*.c、curve_status.c设备本地采样:HTTP 配置/取帧、表达式解析与求值、CVF2 编码、8 KiB ring、WS 推送。所有容量/协议常量集中在 curve_priv.h。
components/wired_dapwired_dap.c、ncm_netif.c默认 ESP32-S3 固件内置:TinyUSB CDC-NCM + 192.168.7.1/24 + DHCP server,复用既有 3240 DAP proxy,与 Wi-Fi 同时可用;C3/ESP32 构建不含 NCM。
components/USBIP、elaphureLink、ws_router协议定义/编解码/路由USBIP 结构体与字节序、elaphureLink 帧、WS 二进制 4 字节头路由。协议边界改动会同时影响主机工具与 Web。
components/uart_service、uart_tcp_bridge、dataflowUART 服务、TCP 1346 桥、DataFlow 实例/attach与调试面共享 UART 资源;烧录窗口会暂停 TCP 桥,结束后恢复。
components/wt_otaOTA 状态机、分区选择、回滚确认仅 8 MB 双槽发布版支持;错误码/状态在 OTA 契约冻结。
components/board、memory_pool、SSDP、utils、global_resource板级引脚、7×2048 B WS 缓冲池、SSDP、通用工具引脚以 docs/board-pins.md 为唯一契约;不要在业务代码里写死目标宏分支。

2.3 project_components 与 Web 服务

组件作用扩展点
web_server启动 httpd、URI 匹配器、Host/Origin 守卫、URI 模块注册机制、SPA fallback新端点优先写成独立 URI module,不要往 web_server.c 里堆 handler
api_router/api JSON 信封按 module/cmd 路由到各模块,处理异步/长任务新增系统命令走 module 注册与错误码表
request_runner慢请求/长任务队列与发回回调,避免阻塞 httpdWiFi 扫描、OTA 进度等异步操作
html入库的 index.html.gz、ws.sharedworker.js.gz、build-info.json.gz 与 web_version.txt只能由 gen_web_assets.sh --install 更新;不要手工替换
wifi_managerAP/STA、Smart APSTA、多凭据记忆、静态 IP、NVSJSON cmd 11–14 与凭据契约绑定
wt_ota / wt_storage / wt_system / wt_mdns / wt_commonOTA 核心、NVS 封装、SN/版本、mDNS、协议版本常量wt_protocol_version.h 是协议版本唯一来源

HTTP 路由的“注册顺序”值得单独记住:httpd 按注册顺序匹配,/ 的 SPA fallback 会吞掉所有没被前面命中的 GET。因此 target 的 4 个端点是在 start_webserver() 里显式先注册的;其他模块通过构造函数优先级排队。数值越小越先注册:WS(101)、target debug(0x70)、curve POST/GET(0x72/0x73)、/ws/curve(0x74)、/api(0x81)、OTA 上传(0x82)、SPA/静态资源(0x90)。新增端点时先想清楚它必须排在 SPA 之前,还是可以被更具体的 URI 匹配到。

2.4 Web 前端目录

web/src/
├── api/            # 设备 API 封装:apiTarget / curveLocal / wsDap / apiDap / apiOTA …
├── views/          # 路由页面:Wifi / Uart / TargetFlash / DebugCurve / Update / About / 404
├── components/
│   ├── object/     # ObjectBrowser(Ozone 式对象浏览器)、objectRoots 纯函数、labels
│   └── source/     # SourceBrowser、lineTarget、projectLoader
├── composables/    # SharedWorker WebSocket、buildMode、notification 等
├── stores/         # pinia:system / uart / dataflow / update / websocket
├── utils/          # ELF/DWARF、行表、loclist、目标 profile、图表/取帧 Worker、录制
├── locales/        # zh.ts / en.ts(i18n 契约 947 key)
├── router/         # hash 路由;OTA 页面默认启用(VITE_ENABLE_OTA=false 可裁剪)
└── assets/         # design tokens、Tailwind、Element Plus 主题覆盖

前端最终被 Vite 打成单文件 index.html,再 gzip 嵌入固件;SharedWorker 单独产出 ws.sharedworker.js。这样做减少了 ESP32 上的 HTTP 连接数和静态资源管理成本,代价是首包较大、调试构建产物不能直接手改。页面运行在普通 HTTP(非 secure context),因此不能依赖 crypto.subtle、File System Access API 等只在 HTTPS/localhost 可用的能力;源码目录选择在 LAN 下会回退到 <input webkitdirectory>。

3 构建与烧录

官方发布的构建方式是 Docker 化的 ESP-IDF v5.2.1。默认发布目标是 8 MB、双 OTA 的 ESP32-S3;默认固件已经同时包含 Wi-Fi 与 USB-NCM(DHCP),4 MB、单 app 目标只做编译验收。旧 scripts/build-wired.sh 保留为兼容/精简构建。

3.1 工具链与一键构建

# 拉取固定版本工具链
docker pull espressif/idf:v5.2.1

# 多目标构建(不同 Flash/分区),带分区/app 大小门禁
# 把 <target> 换成仓库支持的构建目标名
scripts/build_all.sh

# 只构建发布目标;-j 会转成 CMAKE_BUILD_PARALLEL_LEVEL
scripts/build_all.sh -t <target> -j 8

# 干净重建 / 只编 C3+ESP32 / 指定版本
scripts/build_all.sh --clean -t <target>
scripts/build_all.sh -t esp32c3,esp32
scripts/build_all.sh -t <target> --fw-version v1.2.0

每个目标有独立的构建目录和 SDKCONFIG:build/<target>/(每个目标一个目录),完整日志在 build/logs/<target>.log。构建结束后脚本会解析 esp_app_desc.version,确认非空,再跑 scripts/check_partitions.py 校验分区表、app 大小与镜像布局。三个目标共用一个工程根 sdkconfig 会互相覆盖,所以不要绕过脚本手动把 SDKCONFIG 固定到仓库根。

3.2 等价的手工构建

scripts/idf-docker.sh -B build/<target> \
    -D SDKCONFIG_DEFAULTS=sdkconfig.defaults.<target> \
    -D SDKCONFIG=build/<target>/sdkconfig set-target <target>
scripts/idf-docker.sh -B build/<target> \
    -D SDKCONFIG=build/<target>/sdkconfig build

sdkconfig.defaults.<target> 决定默认板卡、分区表、DAP 包大小、回滚等关键选项;不同目标可以使用不同的 Flash/分区配置。板卡与引脚选择见 §3.7,发布构建以对应默认文件为准。

3.3 Web UI 构建与嵌入

cd web
SKIP_NPM_CI=1 ./build.sh       # 本地复用 node_modules;首次可去掉该变量
cd ..
scripts/gen_web_assets.sh --check     # 只校验,不写文件
scripts/gen_web_assets.sh --install   # 显式写入 project_components/html/

web/build.sh 依次执行 i18n strict 检查、vue-tsc 类型检查、Vite 构建,输出 web/dist/ 与 gzip 后的 web/dist-gz/。gen_web_assets.sh 默认只写 build/web-assets/ 暂存区,只有 --install 才会更新固件内嵌资源;--check 校验入库资源和当前 Web 构建是否一致。修改前端后如果忘了 install,烧进去的还是旧页面——这是很常见的“代码改了但界面没变”原因。

3.4 分区、首烧与回滚

8 MB 发布版使用双 OTA 槽;首次烧录只写 bootloader、分区表和 ota_0,让 otadata 保持 0xFF,bootloader 会回退扫描启动 ota_0。不要首烧写 ota_data_initial.bin,也不要用 merge_bin 全量包,否则会掩盖/跳过首烧回退逻辑。

区域偏移大小说明
bootloader0x000000—二级 bootloader
partition table0x0080004 KiBCONFIG_PARTITION_TABLE_OFFSET=0x8000
nvs0x00900024 KiBWi-Fi 凭据/系统参数
phy_init0x00F0004 KiB射频校准
otadata0x0100008 KiB双槽选择/回滚状态
ota_00x0200003 MiB首烧/factory 槽
ota_10x3200003 MiBOTA 目标槽
wt_nvs0x62000064 KiB多 WiFi 凭据等
coredump0x63000064 KiB崩溃转储
# 首次烧录(只写三个镜像,otadata 留空)
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \
  --device=/dev/ttyUSB0 -v "$PWD:/project" -w /project espressif/idf:v5.2.1 \
  bash -c 'esptool.py --chip <target> -p /dev/ttyUSB0 --before default_reset --after hard_reset \
    write_flash --flash_mode dio --flash_freq 80m --flash_size 8MB \
    0x0 build/<target>/bootloader/bootloader.bin \
    0x8000 build/<target>/partition_table/partition-table.bin \
    0x20000 build/<target>/birdiebot_wifi_dap.bin'

OTA 上传写“非当前运行槽”,槽大小 3 MiB;写入期间当前槽不受影响,失败/断电只会留下无效目标槽。CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y 时新镜像首次启动处于 PENDING_VERIFY,应用在 WiFi、Web、DAP/UART 任务就绪后启动 10 s 单次定时器确认;10 s 内崩溃/复位,bootloader 回滚到旧槽。上传成功但页面立刻重启、或回滚后版本没变,优先看串口 reset reason 与 GET_INFO.running/running_ver。

3.5 有线 NCM(默认 S3 固件内置)

scripts/build-wired.sh            # 配置并构建 build/<target>-wired
scripts/build-wired.sh --clean -j 4

默认 S3 构建直接在 sdkconfig.defaults.esp32s3 启用 CONFIG_BIRDIEBOT_WIRED_TRANSPORT_NCM=y、TinyUSB NCM、DMA、NTB 1+1,关闭 USB-Serial/JTAG 从控制台(USB-OTG 与它互斥),控制台保留 UART0。设备侧固定 192.168.7.1/24 并运行 DHCP server(主机通常自动取得 192.168.7.x;静态 .2 兜底),Wi-Fi 同时可用。旧 build-wired.sh 仍可用,但从默认产物已不需要“先刷 wired 构建”。

3.6 版本注入

固件版本来自 git describe --tags --dirty --always --match 'v[0-9]*',失败时回退 version.txt,再回退常量 v1.0.0,并在 CMake 期校验格式;结果写入 PROJECT_VER,最终进入 esp_app_desc.version。当前文档基线是 v1.2.0,Web 包版本 1.2.0,协议版本 1。Web 构建把 package.json 版本、git tag、构建时间注入 window.__BIRDIEBOT_WEB__,About 页与 GET_FM_INFO 可互相对照;版本/协议不匹配只提示,不阻塞使用。

发布前还要跑 scripts/check-version-string.sh、scripts/check-protocol-version.sh、scripts/check-release-versions.sh,并用 scripts/release_local.sh 生成发布包。开发构建出现 v1.2.0-…-dirty 是正常的;发布 tag 必须干净工作区,且 Web 版本与固件 tag 对齐。

3.7 板级 IO / 引脚可重映射

调试器不绑定某一块固定 PCB:SWD、JTAG、UART 桥、状态灯和复位脚都由板级头文件提供,驱动里不再写死 target-specific 引脚。换板、改 IO、做多硬件版本时,优先改板级配置,不改 DAP 核心和采样器核心。

  • 板级头文件:components/board/boards/*.h 定义 PIN_SWCLK、PIN_SWDIO_MOSI、PIN_nRESET、PIN_TDO/PIN_TDI、UART_BRIDGE_TX_PIN/RX_PIN、Led 宏以及 BIRDIEBOT_BOARD_* 身份宏;所有驱动通过这些宏取引脚。
  • Kconfig:idf.py menuconfig → Birdiebot board configuration → Target board 选择板卡;components/board/Kconfig 把板卡与当前 IDF target 绑定,板级默认值由对应的 sdkconfig.defaults.<target> 决定。
  • IO 选择范围:SWD 是 GPIO 位操作,SWCLK/SWDIO 可选主控 IO MUX 支持的普通 GPIO;UART 桥可换 UART 实例和 TX/RX 引脚。避开启动绑带脚、Flash/PSRAM、USB、下载/控制台占用脚,并确认目标侧电平为 3.3 V。
  • 可选信号:只跑 SWD 时 TDO/TDI/nTRST 可以不接;nRESET 可选,但建议保留,便于连接异常时复位目标;UART 桥与固件控制台是两套接口,不要混用同一组引脚。
  • 新增板卡步骤:复制一份板级头文件 → 修改 PIN_* 和板卡身份宏 → 在 components/board/Kconfig 增加 choice → 增加对应 sdkconfig.defaults 板卡默认项 → 编译并跑板级一致性检查 → 更新 docs/board-pins.md。若板卡没有 LED,把对应 HAS_* 置 0(驱动会跳过,而不是驱动随机 GPIO)。
  • 不要改的地方:DAP 命令层、SWD 时序核心、曲线采样协议不应出现某个板子的引脚常量;这样同一固件可以配不同外壳/不同调试接口的硬件。

4 设备本地采样协议

本地采样有三层接口:配置层(POST /api/curve/start)、数据层(HTTP 轮询 GET /api/curve/frames 或 WebSocket /ws/curve 推送)、状态层(GET /api/curve/status)。协议版本在帧格式里体现为 CVF2;旧 CVF1 帧不再由本固件产生,但 Web 解码器保留兼容分支,用于连接旧固件。

4.1 端点总表

方法URI作用要点
POST/api/curve/selftestSWD 单拍耗时探测body 可为空;N=20 次 halt→读 r13/r7/r15→读 2 word→resume,统计 min/median/p90/max
POST/api/curve/start下发采样配置并启动 workerJSON ≤6144 B;引擎忙碌返回 409 busy;成功后目标按配置 halt/恢复
POST/api/curve/stop停止采样、恢复目标、归还 DAP幂等;可选 body 会被读走以保持 keep-alive
GET/api/curve/status状态/计数/错误/容量只读快照,JSON
GET/api/curve/frames?since=&max=批量取 CVF2 帧无新帧返回 204 No Content;max 1..64,默认 32
GET/ws/curve订阅式帧推送先 start 再 subscribe;二进制 CVF2 批次,文本 JSON 回复/错误
POST/api/curve/trace/start · /stopUART trace 启停字节流记录,独立于曲线采样;错误时按 JSON err 处理
GET/api/curve/trace?since=增量读取 UART trace字节流,不是结构化帧;用于调试面板/排障
GET/api/curve/backtrace读取帧指针 backtrace原型能力;优化/无帧指针代码可能不完整
GET/POST/api/target/swd-clock读取/设置手动 SWD 时钟0=auto;100 kHz..10 MHz;作用于检测/烧录/本地采样

所有端点都走与 Web 相同的 web_host_guard(Host/Origin)。非浏览器客户端(curl、脚本)不带 Origin 仍可访问;这不是身份认证。

4.2 硬限制

项限制说明
groups1..8每组独立 payload;每 tick 只采样当前组,轮转进行
series / group1..64对象浏览时通常按成员数自动切组
series 总数256所有组累计;典型使用只有几十路
单 series size1..8 B按目标内存中的标量宽度
每 group 的 sum(size)≤256 B决定该组每帧 payload 长度;64 路 ×4 B 恰好 256 B
start body≤6144 B统一 NCM+WiFi 固件按需堆分配请求体;超过返回 413。紧凑表达式 expr_s 是为 6 KiB 预算服务的
表达式深度≤8addr/reg/cfa/add/deref 链
表达式节点池768DAG 去重后共享前缀;超池返回 400 too many expr nodes
variants / series1..16loclist 多段;总 variants ≤256
periodic interval_us1000..200000halt=true 时下限提高到 5000 µs(低于 5 ms 目标几乎一直在停)
breakpoint / watchpoint 的 interval_us5000..1000000它是两次采样之间的节流下限,不是采样周期;默认 20000
单个 group 的 weight1..8调度表总槽位 ≤64;全 1 等价旧轮转
!
halt=true 的 periodic 周期必须 ≥5 ms。想跑 1 ms/1000 Hz 只能用 halt=false 且表达式不能依赖寄存器(reg/cfa);这类请求会被解析器直接拒绝,而不是悄悄降级。

4.3 start 请求:v3 紧凑格式

固件同时接受三种表达写法:旧版根级 series(单组)、嵌套对象 expr、以及 v3 的紧凑字符串 expr_s。Web 端对每个表达式统一发 expr_s,因为 256 路配置若用嵌套 JSON 会显著放大 cJSON 节点数和堆碎片;紧凑格式把 body 压到 KiB 级。

{
  "interval_us": 20000,
  "halt": true,
  "want_pc": true,
  "trigger": "periodic",
  "groups": [
    {
      "weight": 1,
      "series": [
        {"size": 4, "expr_s": "A24000000"},
        {"size": 4, "expr_s": "D00000000A24002FA8"},
        {"size": 2, "expr_s": "R0D000000F4", "pc_low": 134386244, "pc_high": 134386692},
        {"size": 4, "variants": [
          {"pc_low": 134386244, "pc_high": 134386692, "expr_s": "R0D00000030"},
          {"pc_low": 134386692, "pc_high": 134387000, "expr_s": "R0700000020"}
        ]}
      ]
    }
  ]
}

pc_low/pc_high 只对依赖寄存器/CFA 的 series 有意义:它是“这个栈帧变量当前是否在调用栈上”的判据。loclist 变量则用 variants,每段带自己的 PC 区间和表达式。两者都要求核心可读 PC:periodic 模式必须 halt=true;breakpoint/watchpoint 命中时核心已经停住,不需要额外 halt。

4.4 start 响应与 status

{
  "ok": true,
  "group_count": 1,
  "series_count": 4,
  "sample_bytes": 14,
  "interval_us": 20000,
  "trigger": "periodic",
  "breakpoint_address": 0,
  "halt": true,
  "pc_capture": true,
  "watchpoint_address": 0,
  "watchpoint_size": 0,
  "watchpoint_access": "write",
  "link_hz": 1000000,
  "dpidr": "0x6BA02477"
}

sample_bytes 是所有组 payload 的最大值,CVF2 头里的 frame_bytes 同值;每帧真实的 payload_len 可能更小(不同组宽度不同)。pc_capture 表示本次会话的帧是否带 PC,它由是否成功读 r15 决定,也受 want_pc 控制。

status 字段含义
running、trigger、interval_us、halt会话基本状态
group_count、current_group组数与当前轮转到的组
frames本会话写入 ring 的总帧数(不是客户端已收)
overwritten在被任何客户端取走之前就被新帧覆盖的帧数;持续轮询时通常为 0
invalid地址不可读/表达式求值失败导致清零的 series 数
out_of_scopePC 不在作用域/loclist 无匹配导致的跳过数,与 invalid 分开统计
ring_frames、served_seqring 实际容量(不是名义值)与已交付的最高 seq
hits、sampled、throttledbreakpoint/watchpoint 命中、实际写帧、被节流丢弃的次数
pc_available、last_pc帧是否带 PC,以及最后一次读到的 PC(Thumb bit0 已清)
watchpoint_armed、watchpoint_address、watchpoint_hits、trigger_seqDWT 比较器状态与最近触发帧的 seq
condition_armed、condition_met、condition_hits、condition_trigger_seq可选 trigger_condition 的求值/锁存状态
max_groups、max_series、max_series_per_group、expr_pool_max、expr_pool_used编译期容量与本次实际池用量,用于诊断是否接近上限
error、consecutive_errors、last_err、message、last_tick_us、link_hz累计错误、连续错误、esp_err 名、停止原因、最近一拍耗时、实测 SWD 时钟
watch_count、watchpoint2_*、snapshot_*、backtrace_valid第二 comparator、函数快照与 backtrace 的可选状态;旧固件可能省略,客户端应按不存在处理

start 响应与 status 还可能出现可选字段:watch_count、watchpoint2_address/size/access、snapshot_state、snapshot_frames、backtrace、link_hz;旧字段语义不变。不要假设所有固件都返回这些字段。

4.5 CVF2 帧格式

取帧响应是 little-endian 二进制:16 B 批头 + 变长帧。批头里的 count 是本批帧数,first_seq 是第一帧 seq,frame_bytes 是最大 group payload。每帧按 seq → timestamp_us → group → payload_len → [pc] → payload 排列。

批头(16 B)
offset  size  field
0       4     magic = "CVF2"
4       1     version = 2
5       1     flags(bit0 gap / bit1 PC / bit2 trigger / bit3 condition)
6       2     count
8       4     first_seq
12      2     frame_bytes(最大 group payload)
14      2     reserved = 0

帧(15 B 或 19 B 元数据 + payload)
0       4     seq
4       8     timestamp_us(esp_timer,µs)
12      1     group
13      2     payload_len
15      4     pc(仅 flags.bit1 时存在,Thumb bit0 已清)
...     N     payload(该组 series 按顺序拼接)
flags 位名称含义与客户端动作
bit0GAPsince 比 ring 中最旧帧还旧,本批从最旧可用帧开始;客户端应从 first_seq 重新同步,不能假装连续
bit1PC_PRESENT每帧多 4 B PC;解码器必须按该位决定元数据长度,不能写死 15 B
bit2TRIGGERED本批包含 watchpoint 触发帧;精确 seq 看 status.trigger_seq
bit3CONDITION本批包含条件触发匹配帧;精确 seq 看 status.condition_trigger_seq

ring 的容量由“8 KiB 预算 ÷ 单帧步长”决定,而不是固定帧数:stride = (带 PC ? 19 : 15) + frame_bytes,容量再夹在 16..上限之间。于是 16 B payload 约 264 帧、64 B 约 103 帧、128 B 约 57 帧、256 B 约 30 帧;带 PC 时分别约 234/98/55/29 帧。大 payload 买到的是更少的历史深度,而不是更大的分配;start 不会因为 256 路而申请一块巨型 ring。

4.6 HTTP 轮询语义

since 是客户端已收到的最后一个 seq(首次传 0,seq 从 1 开始),max 是本次最多要多少帧(1..64,超过按 64)。无新帧返回 204,不返回空 JSON。ring 覆盖旧帧时递增 overwritten 并在下一批置 gap;served_seq 在每次成功返回非 204 批次时推进。overwritten 的语义是“在从未被任何客户端取走前就被覆盖”,所以客户端只要跟得上采样率,即使 ring 物理上一直在复用槽位也可以是 0。

Web 端取帧并不是严格串行:固定约 20 ms 定时器触发,最多允许 3 个 frames 请求在途,按 seq 去重、乱序 buffer 连续出队,gap 时重新同步,stop/重连按 runId 丢弃迟到响应。这是为了让 HTTP 往返(实测几十毫秒)不把视觉刷新率压到 10 Hz 量级;WS 推送路径不受该轮询节拍限制。

4.7 /ws/curve 推送

流程是“先 start,再连 WS,再 subscribe”。握手与 UI 的 /ws 完全独立:曲线推送不占用全局 SharedWorker 连接,也不要求 WebSocket-DAP。客户端发文本 JSON,服务端回文本 JSON 或二进制 CVF2 批次。

方向消息说明
client → device{"cmd":"subscribe"}从 ring 最旧帧开始推送;回复 interval_ms、max_batch、format:"CVF2"
client → device{"cmd":"unsubscribe"}停止推送,socket 保持
client → device{"cmd":"ack","seq":N}单调递增的流控确认;当前实现只记录,不改变推送策略
client → device{"cmd":"ping"}回复 {"ok":true,"cmd":"pong"}
device → clientbinaryCVF2 批次,格式与 /api/curve/frames 完全相同
device → clienttext{"ok":...} 或 {"ok":false,"err":"..."}

背压规则:每个客户端两次推送至少间隔 20 ms,每批最多 32 帧,最多 4 个客户端;堆不足或 socket 发送失败时跳过该批并计入丢弃,发送失败的连接会被摘除。推送任务独立于采样 worker 和 httpd,慢客户端不会让采样停摆。客户端发二进制或分片消息会被视为协议错误并关闭连接;命令文本也有 256 B 上限。

4.8 错误与状态码

HTTP典型 err触发条件/处理
400invalid JSON object、empty body、interval_us must be 1000..200000、bad compact expr、groups count must be 1..8、group sum(size) > 256、total series count > 256、too many expr nodes、variants require halt=true配置不合法。Web 直接展示 err;修配置而不是重试
409busy引擎正在采样;先 stop 或等待
413JSON body > 6144 bytes请求体超限;改用紧凑 expr_s 或减少 series
503start: ESP_ERR_TIMEOUT、DAP bring-up 失败目标无响应/SWD 链路问题;检查接线、共地、目标供电,必要时 selftest
500内部错误看串口日志与 last_err
403host guardHost/Origin 不合法;用 IP 访问或加入允许来源
i
invalid 与 out_of_scope 不是一回事。invalid 表示地址/读取失败(会置零该 series 并尽量恢复总线);out_of_scope 表示 PC 门控或 loclist 当前没有匹配段,是预期内的“函数不在栈上/变量不在作用域”,不应被当成采样器故障。诊断“曲线有一段是 0”时先看这两个计数。

4.9 curl 快速验证

# 固定地址 0x24000000 采样,20 ms,halt 读值
curl -s -X POST http://<device-ip>/api/curve/start \
  -H 'Content-Type: application/json' \
  -d '{"interval_us":20000,"halt":true,"series":[{"size":4,"expr_s":"A24000000"}]}'

curl -s http://<device-ip>/api/curve/status

# 取一批帧并十六进制查看
curl -s -o frames.bin 'http://<device-ip>/api/curve/frames?since=0&max=32'
xxd frames.bin | head

curl -s -X POST http://<device-ip>/api/curve/stop -d '{}'
调试曲线页设备本地采样:曲线、统计、分组与传输状态
图 1 曲线页的本地采样:上方是曲线,右侧/状态区能看到采样来源、分组刷新与 invalid/out_of_scope/overwritten 等计数。这些计数的含义见 4.4。

5 采样表达式

表达式(AddressExpr)描述“变量值在目标内存的哪里”,与 Web 端 DWARF 求值使用同一套语义。表达式求的是值的地址,真正读取的字节数由 series 的 size 决定;地址求值失败/越界不会终止采样,只会把该 series 清零并计数。

5.1 五种节点

kindJSON语义典型来源
addr{"kind":"addr","address":u32}字面地址,直接读该地址固定地址全局/静态变量、外设寄存器
reg{"kind":"reg","reg":0..15,"offset":i32}核心寄存器值 + 有符号偏移;reg=13 即 SP,常用来读栈帧局部变量DWARF DW_OP_reg* + DW_OP_plus_uconst
cfa{"kind":"cfa","offset":i32}CFA(这里等价 r13/SP)+ 偏移DWARF CFA 相对位置
add{"kind":"add","inner":expr,"offset":i32}内层求出的地址 + 偏移数组成员、结构体字段偏移
deref{"kind":"deref","inner":expr,"offset":i32}从内层地址读一个 u32 指针,再加偏移->member、指针链、全局对象根

寄存器表达式依赖核心停机:periodic 模式下 halt=false 的配置若包含 reg/cfa 会被 400 拒绝;addr/add/deref 这类纯内存链可以在 no-halt 模式跑,但指针在两次读之间可能被目标改写,因此对象 watch 应优先 halt 采样或接受这种非原子性。

5.2 v3 紧凑编码 expr_s

每个节点是一个前缀字符 + 固定宽度十六进制,子节点递归跟在父节点后面。固定宽度让解析无需分隔符,也避免 cJSON 为一棵树分配几十个对象。

A<addr8>                    addr
R<reg2><off8>               reg(reg 2 位 hex,offset 8 位 hex 的有符号补码)
C<off8>                     cfa
+<off8><inner>              add
D<off8><inner>              deref

# 示例
A24000000                  → 地址 0x24000000
R0D000000F4                → r13 + 0xF4(244)
C00000010                  → SP + 0x10
D00000000A24002FA8         → 先取地址 0x24002FA8,读 u32,再加 0
+FFFFFFF8A24000000         → 0x24000000 + (-8)

偏移按 32 位补码写入 8 个 hex 字符,解析后转 int32_t;深度超过 8 返回 expr depth > 8。expr_s 不能有尾随字节,否则返回 trailing bytes;旧客户端仍可发嵌套 expr,固件两种都接受,但同一 series 至少要有 expr 或 variants。

5.3 求值与每拍读缓存

每 tick 的目标访问顺序是:读 PC(若需要)→ 读本组需要的核心寄存器(去重后一次)→ 对每个 series 求地址 → 批量读值。地址必须落在带 size 对齐的允许窗口内:DTCM/SRAM 0x20000000..0x2001FFFF、AXI SRAM 0x24000000..0x2407FFFF、主 Flash 0x08000000..0x08FFFFFF;越界直接判无效,不会发起 AHB 访问制造总线错误。

对象 watch 的典型形态是“根指针 + 很多成员”,如果每个成员都重新读一次根指针,324 个字段就是 324 次重复 SWD 读。求值器在核心停机时启用每拍指针读缓存(最多 32 个 4 B 指针),同一个 tick 内相同地址只读一次;tick 结束清空,no-halt tick 禁用缓存(运行中的目标可能在两次读之间改内存)。缓存只影响指针读取次数,不改变每个 series 自己地址上的值读取,正确性由 host 测试的读次数断言覆盖。

5.4 PC 门控与 loclist variants

栈帧变量只在函数执行时可信,PC 门控就是给 series 加一个半开区间 [pc_low, pc_high):每拍只读一次 r15,归一化掉 Thumb bit0;PC 不在区间内就把该 series 清零并 out_of_scope++。区间必须落在 Flash(0x08000000..0x08FFFFFF)且 low < high;缺字段/非法区间会静默放弃门控(保持旧客户端兼容)。

loclist 是同一件事的多段版本:一个变量在不同 PC 段可能有不同位置(寄存器、不同栈偏移、甚至不同寄存器)。Web 从 DWARF 的 locationVariants 发 variants,固件选择第一个覆盖当前 PC 的条目;都不覆盖则清零 + out_of_scope++。variants 要求核心可读 PC:periodic 模式必须 halt=true,breakpoint/watchpoint 模式天然满足。单段变量继续用 expr + 可选 pc_low/pc_high,不必强行升级成 variants。

5.5 DAG 去重与池预算

表达式节点入池前会做规范化:新节点的 (kind, inner, offset/reg/address) 若与池中已有节点完全相同,就复用旧索引而不是新增。对象成员路径共享根和前缀,所以“324 个字段、两层解引用”通常只需要三百多个节点,而不是近千个;256 个彼此独立的固定地址变量则各占一个节点。池上限 768 是静态内存和对象 watch 之间的折中:768 × 12 B ≈ 9 KiB,再大就会挤压 WiFi 后的最大空闲块,直接威胁 SWD bring-up。请求用超时返回明确的 400,而不是运行期踩内存。

场景典型表达式池占用/注意事项
固定全局标量A24002FA81 节点/路;256 路约 256 节点,可控
全局对象成员D00000000A24002FA8 + 偏移根指针节点被共享;324 字段约 326 节点
栈帧局部R0D000000F4必须 halt;配合 pc_low/high 避免读到别的调用帧
loclist 多段每段一个 expr_s段数 1..16;总 variants ≤256
数组成员+00000004 + 基址链元素地址不同,但前缀节点可共享

5.6 多组切分与刷新率

Web 端按字节预算自动分组:每组最多 64 路、payload 不超过 256 B;例如 256 路 4 B 变量会切成 4 组,每组 64 路。采样器每个 tick 只采当前组并轮转,所以单条曲线的刷新率不是 1/interval_us,而是约 1/(group_count × interval_us);组权重可以让关键组多拿调度槽(weight 1..8,总槽位 ≤64),但不会改变 payload 上限。看到“256 路时每条曲线很慢”先算这个公式,不要误判成网速或页面性能。

5.7 已知边界

  • 寄存器值变量(DWARF 只有 DW_OP_reg、没有 location/地址)仍不可采样;Web 会标记不可采样,而不是伪造一条曲线。
  • 无 location 的优化变量、被编译器完全优化的变量无法采样;-O0 调试固件的可采样率高得多。
  • 表达式不是原子快照:一组内多个 series 不是同时读的,但 halt 模式下核心不动,组内一致性较好;no-halt 模式要接受目标并发修改。
  • 指针链可能读到无效值:deref 结果越界会判 invalid 并清零,不会让 DAP 进入 sticky FAULT;连续错误达到阈值的会话会被停止并保留诊断。

6 目标检测与烧录 API

目标页(/#/target)做两件事:用 SWD 只读探测目标并在浏览器里做纯前端展示;把用户上传的镜像经 HTTP 流式写进目标内部 Flash。检测与烧录都是 module 6 的设备 HTTP 端点,不走 JSON WebSocket 命令通道。所有端点默认受 Host/Origin 守卫保护。

6.1 端点

方法/路径用途成功响应/失败边界
GET /api/target/info探测并返回目标身份、内核、Flash、UID、候选型号无目标时 HTTP 200 + ok:false, code:5;已识别但不支持烧录时 detected:true, supported:false
POST /api/target/flash上传裸镜像并烧录、回读校验要求 Content-Type: application/octet-stream、Content-Length、X-Target-SHA256(64 hex);成功返回 written/verified/sha256/state
GET /api/target/status烧录引擎进度快照state/total/written/verified/percent/err/message/busy
POST /api/target/reset可选 SWD 复位({"run":true})与烧录结束后的复位共用引擎
GET/POST /api/target/swd-clock读取/设置手动 SWD 时钟0=auto,100 kHz..10 MHz;作用于检测、Web 烧录与本地变量采样
POST /api/dap/kick踢掉当前 3240 DAP 会话,供 Web 侧接管曲线页在建立 WS-DAP/本地采样前使用;空闲时是 no-op

错误码语义:400/411/415 请求头或类型错误,409 忙/单会话冲突,413 超过目标 Flash 容量,422 探测失败或 SHA/回读校验失败,408 上传超时,500/503 内部/超时;JSON 信封里的 code 沿用 2/3/5/6。

GET /api/target/status 新增只读诊断字段:heap_free、heap_largest、dap_session_active、swd_clock_hz、last_error、wired_ncm_active、wired_dhcp_active、wired_ip;可用于确认统一固件下的堆余量、DAP 会话占用与 NCM/DHCP 状态。

6.2 /api/target/info v3 字段

字段类型说明
detected / family / series / device / supportedbool/string是否探测到、家族字符串(如 stm32f1)、系列、器件组、是否支持烧录
swd_clock_hz / dpidr / cpuid / idcodenumber/hex 字符串实际链路时钟、SW-DP ID、Cortex 内核 ID、DBGMCU IDCODE(含 REV_ID)
uid24 hex96-bit 唯一 ID 原始值。Web 把它按 8 个 hex 一组显示为 UID0/UID1/UID2,不改变字节顺序;界面不再上传/编辑 UID。
flash_base / flash_size / max_image_sizehex/number主 Flash 基址(通常 0x08000000)、容量、可接受镜像上限
dev_id / flash_kibnumberIDCODE 低 12 位与 FSIZE 换算的 KiB;读不到时为 0
model_candidatesstringDEV_ID + FSIZE 缩小后的候选型号组,例如 STM32F100/F101/F102/F103 (MD) [x8, 64 KiB]
core / detection_confidencestringCPUID 解出的内核名;unknown/group/flash-narrowed,Web 只写入诊断 JSON,不展示置信度 UI

swd_clock_hz 由 probe/当前设置决定;HTTP 手动时钟通过 /api/target/swd-clock 读写,目标检测、烧录和本地采样共用同一设置。SWD 能读到 DEV_ID、FSIZE、CPUID、UID,但读不到封装、完整 order code、板卡名称,也区分不了同组同容量里的具体成员。所以“芯片型号”只能是内置 profile 的代表型号,不是对完整料号的证明。

6.3 探测顺序与回退

探测先读 DPIDR/IDCODE/CPUID,再按 DEV_ID 表定位器件组,然后从该组的 FSIZE 地址读容量(单位 KiB)并按容量档位缩小候选,最后读 UID。SWD 时钟按 2 MHz → 1 MHz → 500 kHz 回退:目标板布线差、长线、低电压时高时钟会先失败,回退成功仍算识别。DEV_ID 表覆盖 80 个器件组(C0、C5、F0/F1/F2/F3/F4/F7、G0/G4、H5/H7、L0/L1、L4/L5、U0/U5、WB/WL 等);未知 DEV_ID 也可能返回 detected=true,但家族/烧录能力明确为 unknown/false,不猜。

6.4 自动匹配内置 profile

目标页不提供用户上传、编辑或选择 profile 的入口。前端只读使用内置 KNOWN_TARGET_PROFILES(16 个常见 F1/F4/G4/H7 代表型号):DEV_ID + FSIZE 两个条件都命中才算匹配;再用候选 order code 前缀判定 exact 或 flash。每个 (DEV_ID, FSIZE) 组合只放一个代表型号,所以结果确定;同容量/同组的其它型号(例如 F100/F101/F102 与 F103 的 x8 版本)无法区分,只能显示候选组。无匹配返回 null,页面显示 Unknown,不降级猜测其它容量或家族。

检测结果(DEV_ID / FSIZE)自动匹配confidence
0x410 / 64 KiB / MD [x8]STM32F103C8T6exact
0x413 / 1 MiB / xGSTM32F407VGT6exact
0x483 / 1 MiBSTM32H723VGT6exact
0x450 / 2 MiB / 1 MiB / 128 KiBH743VIT6 / H743VGT6 / H750VBT6exact
0x410 / 32 KiB(无内置档)null(UI: Unknown)—

页面还提供“复制诊断 JSON”(v2):包含原始 idcode/cpuid/uid、v3 检测字段、自动匹配结果和紧凑 profile API,方便把现场信息贴进 issue,而不暴露任何可编辑 profile 状态。旧固件没有 v3 字段时,前端会从 idcode & 0xFFF 推导 DEV_ID,再用 flash_size 匹配——前提是 FSIZE 可读。

6.5 烧录流程

烧录是流式的,不需要 staging 分区:HTTP body 边收边写入目标 Flash,最后整镜像回读算 SHA-256 与客户端 X-Target-SHA256 比对。开始前必须先 probe,确认目标存在且 supported=true,并检查 Content-Length ≤ max_image_size;否则在真正擦写之前就以 413/422/400 失败,避免“先擦了再说”。

浏览器/脚本
  │ POST /api/target/flash
  │ Content-Type: application/octet-stream
  │ Content-Length: N        X-Target-SHA256: 64 hex
  ▼
target_flash engine
  1. wt_tf_probe()  → DEV_ID/FSIZE/UID/容量
  2. supported 检查 + max_image_size 检查 + 单会话 busy 检查
  3. wt_tf_flash_begin():取 DAP(DELETE_HANDLE)、halt、解锁、擦除覆盖扇区/页
  4. 循环收 HTTP body → page program(不足编程单元用 0xFF 填充)
  5. program_end 锁定;整镜像回读 SHA-256 比对
  6. 可选 reset(run=true);RESET_HANDLE 归还 DAP、恢复 UART 桥

各家族的擦除/编程粒度不同:F0/F1/F3 是 16-bit half-word 编程(1 KiB/2 KiB 页),G4/L4/G0/WB/WL 是 64-bit double word(页 2 KiB/4 KiB 等),F2/F4/F7 按扇区擦除 + x64(必要时 x32 回退),H7 是 256-bit flash word + 128 KiB 扇区。引擎按 program_unit 统一 flush,未实现的几何/双 bank 组合直接返回 unsupported,而不是按猜测参数硬擦。

烧录/读取期间 DAP 由引擎独占,USBIP/OpenOCD 不能同时访问探针;UART TCP 桥(1346)会暂停,但 WiFi/HTTP/Web UI 保持在线,结束后自动恢复。目标页支持 BIN / ELF / Intel HEX / SREC:前端先转成连续裸镜像(段间空隙用 0xFF 填充),再上传;这是前端转换,不会改变设备端“只收裸镜像”的契约。

6.6 支持矩阵

家族实现状态真机证据
STM32F4(单 bank ≤1 MiB)支持烧录F407 真机已验证
STM32H7(0x450 / 0x483)支持烧录H723 真机已验证
STM32G4(0x468 / 0x469 / 0x479)支持烧录(含 DBANK 几何)仅 host 测试 + 三目标编译;无实板
STM32F0 / F1 / F3(含 F1 XL 双 bank)支持烧录仅 host/编译;无实板
STM32F2(0x411)支持烧录仅 host/编译;无实板
STM32F7(0x449 / 0x452 单 bank)支持烧录仅 host/编译;无实板
STM32L4 / G0 / WB / WL支持烧录(L4 风格控制器)仅 host/编译;无实板

“实现”意味着寄存器序列参考 RM/OpenOCD 驱动、几何为纯函数并被 host 测试覆盖、固件能编译、非支持路径会安全降级;它不等于“已经在你的板子上跑过”。上面标“无实板”的家族,第一次上真机必须按小镜像→整镜像、先 verify 后 run 的顺序验证,并记录 DEV_ID/FSIZE/OPTR/UID、页数、编程速率、回读 SHA 与失败时的 SR 错误位。

6.7 明确不支持的边界

边界DEV_ID/型号原因
F4 2 MiB dual-bank0x419、0x434(F42x/F43x/F469/F479 的双 bank 变体)扇区号是 bank 相对编号(SNB|0x10),需要 bank-gap 扇区表与第二条擦除路径;当前只做单 bank 映射
F4 F413/F423(1.5 MiB)0x463非标准 15 个保护位/1.5 MiB 扇区映射未建模
F7 2 MiB dual-bank0x451(F76x/F77x)同 F4 双 bank + NDBANK 单/双 bank 变体;单 bank 2 MiB 映射未建模
H7A/H7B0x480(H7A3/H7B3/H7B0)现有 H7 驱动只覆盖 0x450/0x483,控制器地址不同
L0 / L10x417/0x425/0x447/0x457;0x416/0x427/0x429/0x436/0x437PECR/PEKEYR 控制器、页 128/256 B、字编程,与 L4 风格完全不同,未实现
L50x472TrustZone:TZEN/RDP 0.5、secure/NS 两套 FLASH 寄存器、SECBB 安全页属性
U5/U0/C5/H50x455/0x476/0x481/0x482、0x459/0x489、0x44E/0x44F/0x45A、0x478/0x484TrustZone + 128-bit quad-word 编程 / NS 寄存器布局,未实现
C00x443/0x453/0x44C/0x493/0x44D不在目标集合内,几何近似 G0 但无参考 cfg,未纳入
WB0x / WL3x0x01E/0x028/0x032/0x027无 FSIZE 寄存器(flash_size_shift=0xFF),新布局无参考,仅识别
WB1x/WB3x/WB50x0x494/0x496(不在 DEV_ID 表)表外 DEV_ID → unknown;不按 WB5x 的 4 KiB 页/1 MiB 假设擦写
MP15 / W108 / XL 非 STM32—不在 DEV_ID 表或非 STM32 内核,仅由 OpenOCD 参考集覆盖,不在此引擎范围

原则只有一句话:不建模就不擦写。family probe 返回 ESP_ERR_NOT_SUPPORTED 时,检测信息仍保留(series/device/FSIZE/UID),只是降级为 identified-only,Web 烧录按钮禁用并显示边界提示;绝不会按猜测的几何去 erase。

6.8 G4 的特别说明

G4 是最近新增的主目标:控制器 0x40022000、KEYR 解锁、PER/PNB/BKER/STRT 擦页、PG + 两个 32-bit DRW 写 64-bit double word,与 OpenOCD 的 stm32l4x driver/loader 保持一致。0x469(G47x/G48x)用 OPTR.DBANK bit22 决定单/双 bank:双 bank 时 page ≥128 要转成 PNB=page-128, BKER=1;裁剪容量导致两 bank 之间有 gap 的组合直接判 unsupported。G4 的 DBGMCU 在 0xE0042000,不是 F0/G0/L0/C0 的 0x40015800。

!
截至本基线,G4/F0/F1/F3/F2/F7/L4/G0/WB/WL 的烧录路径没有实板验证,只有 host 断言(DEV_ID/几何/边界共 143 项)与三目标编译。第一次上真机时优先 G431(128 KiB、单 bank 2 KiB 页)与 G474/G484(0x80000 字节 Flash,DBANK 两种状态都要测),重点看擦除页号、跨 bank 地址、x64 的 PGAERR/SIZERR 和单页擦除超时。失败时把该 DEV_ID 的 flash_ops 降回 NONE 安全回退为“识别但不烧录”。
目标检测页:系列、自动匹配型号、DEV_ID、Flash 大小、UID0/1/2 与烧录边界
图 2 目标检测页:只读展示系列/自动匹配型号/DEV_ID/Flash/UID;UID 以 UID0/UID1/UID2 三个 32-bit 字显示;烧录按钮是否可用由 supported 决定。

7 Web-DAP 传输

主机侧看到的“无线调试器”不是一块 USB 设备,而是一个 TCP 服务:USBIP、elaphureLink、WebSocket-DAP 都监听 3240,靠首包自动识别。理解这一点,才能解释为什么“OpenOCD 明明选了 CMSIS-DAP 却连不上”“Keil 能连但 usbip 报错”“拔掉重插后必须重 attach”这类现象。

7.0 局域网发现:mDNS / SSDP

设备在 80 端口提供 Web 管理页和 JSON API,主机可先用 mDNS 或 SSDP 找 IP,再建立 DAP/HTTP 连接。两种发现都只依赖同一二层局域网,不负责鉴权、加密或跨网段路由。

机制实现要点手工验证
mDNS主机名 birdiebot-dap-XXXX.local,XXXX = STA MAC 最后两个字节(4 位小写十六进制);服务 _http._tcp 端口 80,实例名 Birdiebot WiFi DAP;TXT name=允斯调试器-XX-XX(MAC 后缀);5353/UDP。avahi-browse -rt _http._tcp / dns-sd -B _http._tcp / 浏览器 http://birdiebot-dap-XXXX.local/
SSDP多播 239.255.255.250:1900,TTL 4;M-SEARCH 返回 ST: upnp:rootdevice、USN: uuid:...::upnp:rootdevice、LOCATION: http://<ip>/SSDP.xml;UUID 由 MAC 派生;SSDP.xml 由 Web 服务器提供。Python M-SEARCH 或 UPnP 工具;curl http://<ip>/SSDP.xml
# 最小 SSDP 查询(Python 3,跨平台)
import socket
msg = ('M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n'
       'MAN: "ssdp:discover"\r\nMX: 1\r\nST: upnp:rootdevice\r\n\r\n')
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
s.sendto(msg.encode(), ('239.255.255.250', 1900))
# 解析响应中的 LOCATION;设备 IP 随后用于 HTTP/USBIP/DAP

SSDP.xml 是静态 UPnP 描述(friendlyName、manufacturer、modelName、serialNumber 等);它不暴露凭据,但会暴露设备和目标环境的指纹。发现失败时优先检查:主机与设备是否同一网段、是否访客网络/VLAN 隔离、防火墙是否拦截 5353/1900 UDP、Windows 是否缺少 mDNS 解析器;用 AP 模式 192.168.1.1 或 Wi-Fi 页 STA IP 直连即可绕过发现。

7.1 USBIP / WinUSB(Windows / Linux)

Windows 推荐 usbip-win 0.3.0(kmdf/ude 版本),设备通过 WCID/MS OS 2.0 描述符自动绑定 WinUSB,在工具里显示为 CMSIS-DAP(VID:PID = C251:F00A)。busid 固定是 1-1。设备没有真实 USB 插入动作,所以主机侧看到的是 USBIP 虚拟设备;设备重启/OTA 后主机应 detach 再 attach。

# Windows(管理员 PowerShell/cmd)
usbip.exe attach_ude -r <device-ip> -b 1-1

# 旧版 usbip 用法(历史 HID/WinUSB 构建;默认 S3 固件 USB 口为 NCM + Wi-Fi,不要把 USB 口当 DAP)
usbip.exe -D -a <device-ip> 1-1

# Linux(若使用 vhci;本项目主要验证 Windows)
sudo usbip attach -r <device-ip> -b 1-1

主控的 DAP 包大小默认 1280 B,对应 OpenOCD 0.12 可安全使用的 (1280-4)/5 = 255 个 transfer;B1 流水线允许多个 URB 在途,TCP 收发缓冲调到 32 KiB,DAP 任务 core 1/prio 16、TCP 任务 core 1/prio 14。WiFi 链路慢时优先怀疑 2.4 GHz 干扰、距离、同网段广播风暴,而不是继续抬 SWD 时钟——USBIP 在 WiFi 上是延迟受限,不是带宽受限。

7.2 elaphureLink(Keil 免驱)

Keil 安装 elaphureLink 插件后连接 TCP:3240 即可,不需要 USBIP 驱动。首包是 EL_LINK_IDENTIFIER,固件进入 el_dap_work();协议支持 DAP 透传和 0x88 vendor 命令通道(native passthrough / scope enter/exit),版本 EL_DAP_VERSION=0x10000。OpenOCD 用户可用 OpenOCD-elaphureLink,同样是直接连 3240,不经过虚拟 USB。

elaphureLink 与 USBIP 的差别只在“主机怎么把 CMSIS-DAP 命令送进来”;进入 DAP 命令层之后完全同一套代码、同一个 SWD 时序。因此 DAP 能力、时钟、目标支持与 USBIP 完全一致。

7.3 WebSocket-DAP(legacy,浏览器工具)

在 3240 上先发 HTTP GET 完成 WebSocket 升级,之后每个二进制帧的 payload 就是 CMSIS-DAP 命令包,响应也是二进制帧。它适合 elaphure-dap.js 之类的浏览器工具;默认由 CONFIG_BIRDIEBOT_DAP_WS_ENABLE=y 开启,可关闭,但 USBIP/elaphureLink 不受影响、3240 仍监听。Web UI 的曲线页 Web-DAP 模式用的就是这条通道,并在建立连接前调用 POST /api/dap/kick 让固件踢掉旧会话。

注意区分两个 WebSocket:/ws 是 Web UI 的全局 JSON/二进制数据通道;/ws/curve 是本地采样推送;3240 上的 WebSocket-DAP 则是 CMSIS-DAP 命令隧道。三者不是同一个端点。

7.4 Ozone / OpenOCD 配置

OpenOCD 是推荐路径。先 usbip attach 把探针挂到主机,再按 CMSIS-DAP 选型;或者使用 OpenOCD-elaphureLink 直接连 3240。SWD 时钟在 OpenOCD 里用 adapter speed(kHz)控制,建议从 1000 起,稳定后再试 2000;更高通常不会带来额外吞吐。

# 方式 A:USBIP 已 attach 后,走 CMSIS-DAP v2
openocd \
  -f interface/cmsis-dap.cfg \
  -c "cmsis_dap_vid_pid 0xc251 0xf00a" \
  -c "transport select swd" \
  -f target/stm32h7x.cfg \
  -c "adapter speed 1000" \
  -c "init" -c "reset halt" -c "shutdown"

# 方式 B:OpenOCD-elaphureLink,直接连设备 3240(无需 usbip attach)
# interface 配置由 OpenOCD-elaphureLink 提供,TCP 目标为 <device-ip>:3240

Ozone 要单独说明。SEGGER Ozone 官方只支持 J-Link,不能把 3240 当 CMSIS-DAP 直接填进 Ozone。可行路径有两条:一是用 OpenOCD 起调试服务(GDB server/telnet),在 Ozone 之外用 GDB 或 IDE 调试;二是实验性的“虚拟 J-Link”代理——用代理 DLL 替换 Ozone 的 JLink_x64.dll,代理再通过 OpenOCD telnet 访问真实探针,探针侧仍然落在 3240。该方案已经能连接、读 PC/寄存器并启动 HSS,但 Ozone 不自动调用 HSS_Read,自动采样仍被其内部状态机阻塞,不承诺可用;需要 Ozone Timeline 曲线的场合建议直接用真实 J-Link。

主机工具连接方式是否推荐备注
OpenOCDUSBIP attach 后按 CMSIS-DAP 选型;或 OpenOCD-elaphureLink 直连 3240✅ 首选本项目大量真机验证都走这条;F407 1 MiB 读在 master 下约 10.25 s(0.12 约 25.2 s)
pyOCDCMSIS-DAP v2(USBIP 虚拟设备)✅ 可用按 CMSIS-DAP 选型,SWD 时钟别一上来拉满
Keil MDKelaphureLink 插件 TCP 3240✅ 免驱不需要 usbip-win;首包由设备识别
Ozone官方仅 J-Link;虚拟 J-Link 代理为实验路径⚠️ 实验代理内部仍经 OpenOCD 访问 3240;HSS 自动读取未打通
elaphure-dap.js 等浏览器工具WS-DAP(3240 HTTP Upgrade)✅ 可用需 Kconfig 保持 WS-DAP 开启

“Ozone/OpenOCD 走 3240”准确的说法是:3240 是探针侧唯一的 DAP 传输端口,OpenOCD(无论经 USBIP 还是 elaphureLink)从这里取 DAP;Ozone 本身不直接说这个协议,只能通过 OpenOCD 桥或代理间接使用。不要把 3240 填到 Ozone 的 J-Link 连接设置里,它不会工作。

7.5 有线 USB-NCM(默认 S3 固件内置)

默认 ESP32-S3 固件内置 USB-NCM 与 Wi-Fi:主控原生 USB-OTG 枚举成 CDC-NCM 网卡(Full-Speed 12 Mbps),设备固定 192.168.7.1/24 并运行 DHCP server,主机通常自动取得 192.168.7.x(静态 192.168.7.2/24 为兜底,网关留空)。DAP proxy 仍监听 TCP 3240,主机照常 usbip attach -r 192.168.7.1 -b 1-1。它的意义是绕过 WiFi 抖动,把调试/烧录链路的有线延迟降下来;Wi-Fi 仍保持可用。C3/ESP32 没有 USB-OTG,不包含 NCM。

# 主机侧(Linux 示例):优先等待 DHCP 自动取址
ip -br addr show | grep enx
ping -c2 192.168.7.1
sudo usbip attach -r 192.168.7.1 -b 1-1

# 无自动地址时的静态兜底:
sudo ip addr replace 192.168.7.2/24 dev enx…
sudo ip link set enx… up
# 特殊情况下 NetworkManager 会清掉静态地址时才需要:
# nmcli device set enx… managed no

实测(H7 1 MiB 目标):USB-NCM 烧录 345,600 B 约 5.8 s(57–58 KiB/s),1 MiB 读约 6.6 s(154 KiB/s);WiFi USBIP 烧录约 8.3–8.9 s。第二次会话曾出现 CMD_INFO failed,根因是 TinyUSB 存在时堆碎片导致 DAP ring 重建失败,修复方式是会话结束改为 drain 而不是 free+realloc,并在 tcp_server 收尾时有界等待 reset 生效。NCM 主机侧若 NetworkManager 未托管,可能看到 DHCP 失败后手动地址被清空、USBIP 传输挂死;这是主机配置问题,不是固件 bug。

构建变体的权衡写进 sdkconfig.defaults.esp32s3 / sdkconfig.wired.defaults:TinyUSB 优先级 5(22 会拖垮首个 WiFi-USBIP 会话)、栈 4096、NTB 1+1(统一固件 heap 余量小,3+3 会压垮 SWD bring-up)、DMA 必须开;USB-Serial/JTAG 从控制台关闭,控制台保留 UART0。

7.6 连接互斥与排障

同一时刻 DAP 只能被一个“所有者”使用:3240 的某个会话、target_flash 烧录、curve_sampler 本地采样,三者互斥。3240 的监听 socket 同时只服务一个 accepted 连接(kSock 单例),所以第二个 USBIP/elaphureLink 连接要等第一个关闭;曲线页在接管前发 /api/dap/kick,固件对当前 fd 做 shutdown(),让旧会话走正常 cleanup 后释放 DAP。target_flash 与 curve 采样则通过 DELETE_HANDLE/RESET_HANDLE hand-off,句柄空闲时才能成功;引擎忙时 start/烧录返回 409 或 DAP hand-off timed out。

现象优先检查处理
usbip attach 超时/找不到设备设备 IP、3240 防火墙、busid 是否 1-1、是否 8 MB 发布固件先 ping;关掉占用 3240 的会话;确认 usbip-win 0.3.0+
OpenOCD 报 CMSIS-DAP command CMD_INFO failed是否第二次会话、是否 NCM/TinyUSB 构建、堆是否碎片更新到带 C1 修复的固件;detach/attach 一次;看串口是否有 ring 重建失败日志
Keil 连不上但 curl 正常elaphureLink 插件版本、端口 3240、是否被 USBIP 会话占用断开其它 DAP 会话;用 GET /api/dap/kick 或重启设备
远程桌面/多网卡环境连不上主机路由、设备是否在 AP 网段、mDNS 是否可用用 IP 直连;确认主机与设备同网段;不要依赖主机名
SWD 识别不稳定共地、目标供电、杜邦线长度、SWD 时钟降 adapter speed;短接线;目标自供电;检查 nRESET 是否需要接
NCM 枚举后无 IP主机是否 DHCP、网卡是否被 NM 托管手动配 .7.2/24,nmcli device set enx… managed no;必要时 unbind/bind xHCI 重新枚举

8 源码与 ELF 映射

曲线页的“变量名、源码行、断点地址、当前 PC”都来自同一个 ELF 文件,但走的是三条不同索引:DWARF 变量树(elfDwarf.ts)、DWARF 行号表(dwarfLineTable.ts / elfLineIndex.ts)、位置列表(dwarfLocList.ts)。它们都是纯前端解析,不上传 ELF、不依赖设备端 DWARF 能力;设备只负责按地址读内存。

8.1 解析管线

File(ELF) ──► section 表 ──► .debug_info/.debug_abbrev/.debug_str
   │                         ├─► 变量树(类型/成员/地址/行号/AddressExpr)
   │                         ├─► .debug_line → 行表(PC ↔ file:line)
   │                         └─► .debug_loc/.debug_loclists → 位置段/variants
   ▼
页面用 shallowRef / markRaw 保存大索引,禁止深 reactive(几十万行会拖死渲染)

真实 25-omni 固件(DWARF4):release ELF 有 97 个 CU、343 个文件、过滤后 33,402 行、1,473 个 sequence;debug ELF 97 CU、339 文件、45,214 行、1,707 sequence。行表解析 release 约 230 ms、debug 约 110 ms,pcToLine 单次约 2–8 ms(二分查找)。这些数组是普通对象/数组,约 3–5 MB;放进 Vue 深响应式会让每次采样刷新都遍历整张表,必须用 shallowRef + markRaw,只对查询结果做响应式。

8.2 行号表 API

import {parseElfLineIndex, pcToLine, fileLineToAddress,
        fileLineToAddresses, findElfLineFiles} from '@/utils/elfLineIndex'

const index = parseElfLineIndex(elfBuffer, {flashRange: [0x08000000, 0x09000000]})
const loc = pcToLine(index, pc)          // → {file, line, column, isStmt, flash}
const bp = fileLineToAddress(index, 'src/app/main.c', 42)
const all = fileLineToAddresses(index, 'src/app/main.c', 42) // inline/多 sequence 候选

行表只保留地址非 0、落在 SHF_EXECINSTR|SHF_ALLOC 段、sequence 单调且 terminated 的行;25-omni release 丢弃了 1,358 个不可用 sequence、23,922 行,这是正常降级而非解析失败。查询默认只认 Flash:H7 行断点要求 0x08000000–0x09000000,其它目标可用 flashRange 覆盖。排序优先级是 is_stmt 优先 → Flash 优先 → 地址升序;pcToLine 先按 CU ranges 定位 CU,再在 sequence 内二分,CU ranges 缺失时回退全局 sequence 二分。

8.3 精确 gutter 断点与 PC→源码行

源码视图的行号槽(gutter)点击时,组件把 {path,line} 交给页面;页面用 findElfLineFiles() 解析项目相对路径(exact → suffix → basename),再用 fileLineToAddress() 得到最佳可执行地址,切到本地 Breakpoint 模式并填入地址。没有候选地址时提示“宏/声明/被优化/无行表”,而不是拿函数 low_pc 猜测。多候选(inline、同文件多 sequence)用 fileLineToAddresses() 让用户选择;同一行在优化构建里可能对应多个地址,这是正常现象。

反过来,CVF2 帧里的 pc(Thumb bit0 已清)经 pcToLine() 得到当前 file:line;页面把 {path,line,address} 作为 activeFileLine 传给 SourceBrowser,组件负责打开文件、滚动、用 sb-line-pc 高亮并显示 PC 徽标。关闭 “Follow PC line” 时传 null,避免用户滚动源码时被采样 PC 抢走滚动位置。PC 行高亮与“当前作用域内变量”是两件事:变量的有效范围来自 DWARF pc_low/pc_high 或 loclist variants,不在范围内的变量标 stale(Variables 红点、Source live value 灰显)。

8.4 loclist 与 variants

dwarfLocList.ts 只做“字节 → 地址区间 + 原始表达式字节”的纯解码:.debug_loc(DWARF 2/3/4,base selection entry、terminator、相对 base 的 begin/end)与 .debug_loclists(DWARF 5,header/offset table、DW_LLE_* opcode、ULEB128 表达式长度、DW_LLE_GNU_VIEW_PAIR 扩展)。它不解析 DW_OP_* 语义,坏数据写 warnings 不抛异常。上层 elfDwarf.ts 把每个条目的表达式转换成 AddressExpr,再由 Web 发成采样器的 variants。

采样侧对 variants 的要求不复杂:每段必须有合法 Flash PC 区间、总段数 1..16、总 variants ≤256;每个 tick 读一次 PC,选第一个覆盖的段,没有覆盖就清零并 out_of_scope++。因此 loclist 变量在函数没被调用时不会画出“上个栈帧的假值”。限制是:只有 location 能转成 AddressExpr 的变量才可采样;DW_OP_reg 纯寄存器值没有地址,仍不可采样。

8.5 对象根、ObjectBrowser 与稳定 key

对象浏览器不是另建一套变量解析,而是从 parseElfVariables() 的变量树里筛“全局对象根”:节点必须 sampleable、有 materialise 的子节点、地址固定(排除寄存器/栈帧局部),再按 pointer/object/array 类型过滤;优先名默认 xrobot_debug_handle。25-omni 实测该根是 Robot::Omni_robot *,存储地址 0x24002FA8,顶层 5 个模块(cmd_ / led_ / can_ / bmi088_ / qekf_ins_),子树 324 节点、4 层;根候选 154 个,不能一次性铺平。

两套 key 都稳定:对象路径(如 xrobot_debug_handle->bmi088_.accl_[0])用于树/预设/源码跳转;曲线 key 是 path@address,与 DebugCurve CurveSeries.key、Source live value map 完全一致。指针派生节点 address=0,key 形如 ...->cmd_.mode_@0,路径本身保证唯一。组件同时接受两种 key,所以父页面可以直接把现有曲线 key 列表传给 selectedKeys。

API/行为说明
discoverObjectRoots(variables)根发现 + 确定性排序;优先名命中标记 preferred
buildObjectTree(root, {expanded,maxDepth,maxChildren,maxNodes})只 materialise 根与已展开路径,默认 maxDepth 8、maxChildren 64、maxNodes 4000
searchObjectTree() / findObjectNodes()在原始 DWARF 树上搜索(未展开分支也能搜);预设恢复按路径查找
collectObjectNodes() / collectObjectSubtree()批量 watch/add-curve,默认有批次上限(组件常量),返回截断前总数
ObjectBrowser exposeselectRootByKey/revealPath/expandAll/collapseAll/getVisibleNodeRefs/exportPreset/importPresetJson
预设持久化组件不写 localStorage;父页面用 objectWatchPresets.ts 按根路径显式保存/加载,不自动记忆选择

8.6 SourceBrowser:只读源码浏览

SourceBrowser 用安全上下文下的 File System Access API 选择项目根目录;在 HTTP LAN 下回退到 <input webkitdirectory>。它只读、不上传、不写入项目,按需读取文件并用 LRU 缓存;文件树和代码行都做虚拟化(只渲染可见窗口)。变量按 DWARF relativeFile/file/compDir + line 映射到源码,标识符加粗虚线下划线,hover 显示类型/位置/可采样性;点击变量发出与曲线列表一致的 key。实时值、activeKey、行断点、PC 行高亮分别通过 props liveValues、activeKey、lineAddresses、activeFileLine 接入,组件本身不 import i18n,文案由父页面的 labels 注入。

源码视图:文件树、行号、变量标识符与实时值
图 3 Source 视图把 ELF 行表、DWARF 变量和采样值合到同一屏:行号槽可设精确断点,变量 token 可加入曲线,行尾 chip 显示实时值(stale 时灰显)。

8.7 映射边界

边界行为排查提示
ELF 与目标固件不匹配变量地址/行表与现场不同,曲线可能是错误内存;页面提供显式 Verify用同一次构建的 .elf;比对 build id/时间戳
非 Flash 代码(RAM 函数)默认查不到行断点显式传 allowNonFlash: true 或配置 flashRange
宏/声明/被优化行fileLineToAddress 返回 nullgutter 提示无地址;换下一行或关闭优化重编
inline / 多 sequence 重叠fileLineToAddresses 多候选让用户选;COMDAT 重叠时 pcToLine 取第一个 CU,与 addr2line 一致
DWARF5 字符串形式支持 line_strp/strx/loclistx/rnglistx、rnglists/loclists旧解析器曾把 loclists_base 当 rnglists_base,已在当前实现修正
大索引响应式禁止深 reactive/watch 整个索引用 shallowRef/markRaw;只 watch 查询结果

9 前端结构

Web UI 是 Vue 3 + Vite + TypeScript 单页应用,构建成单文件后嵌入固件。它不是“另一个独立网站”,而是与固件同源、共享 Host/Origin 策略、通过相对路径访问设备 API 的管理界面。前端改动通常比固件改动轻,但版本/i18n/资产嵌入三个门禁都必须过。

9.1 技术栈与构建

层选型说明
框架Vue 3.5 + TypeScript + Vite 5单文件构建(vite-plugin-singlefile),减少设备端静态资源请求
状态/路由pinia + vue-router(hash 模式)路由带 #/,适合无服务端 rewrite 的嵌入式静态托管
UIElement Plus + Tailwind + 设计 token深色优先,design-tokens.css 最后加载以覆盖主题
i18nvue-i18n,zh/en 两套 locale947 个契约 key,构建前 strict 检查
实时数据SharedWorker 单 WebSocket + 二进制路由多标签页共享一条 /ws;曲线本地采样另有 HTTP/WS

9.2 页面与关键模块

页面/模块职责依赖的设备接口
Wifi.vueAP/STA、扫描、多凭据、静态 IP/ws JSON module 1
Uart.vueWeb UART 终端、波特率/格式、数据流 attach/ws JSON + 二进制 RAW
TargetFlash.vue默认首页;Dashboard、首次引导、SWD 时钟、检测、自动匹配、格式转换、上传烧录、进度/复位/api/target/*(含 /swd-clock、/status)
Dashboard / CommandPalette状态总览(DAP/WiFi/目标/链路)与 Ctrl K 全局命令面板;命令面板只搜页面/动作复用 /ws 状态与本地路由
DebugCurve.vue变量/对象/源码三视图、ELF/DWARF、采样、图表、录制、诊断/api/curve/*、/ws/curve、JSON /ws(版本/状态)
Update.vueOTA 上传、进度、运行槽/版本/api/ota/upload + module 5
api/*.ts设备 API 封装;错误统一成 CurveLocalError/HTTP 状态同源相对路径;dev 模式用 VITE_DEVICE_HOST_NAME
components/object、components/source对象浏览器与源码浏览器(泛型组件,不依赖 i18n)无设备依赖,输入是 ELF 解析结果

9.3 运行时数据流

页面启动后,SharedWorker 建立唯一 /ws 连接:文本帧是 JSON module/cmd 请求与响应,二进制帧是 4 字节头(data_type/module/sub_mod/reserved)+ payload。固件每 1500 ms 广播空文本帧做心跳,前端任意消息都会重置 2 s 超时;连接断开由 worker 统一重连,页面只订阅事件。版本握手是连接后发 SYSTEM/GET_FM_INFO,比较 protocol_version 与本端常量,不匹配只提示,不阻塞。

曲线页的设备本地采样不使用全局 /ws 的 DAP 通道:它直接 fetch /api/curve/*,或在 start 后连接 /ws/curve。页面状态(选中变量、渲染模式、Y 轴、时间窗)是本地状态,不自动记忆变量选择;只有显式 preset 会写 localStorage,录制会写 IndexedDB。Web-DAP 模式才需要接管 DAP:先 /api/dap/kick,再打开 3240 的 WS-DAP,采样命令走 CMSIS-DAP。

9.4 Worker、OffscreenCanvas 与 IndexedDB

取帧 Worker(utils/curveFramesWorker.ts)把 fetch(/api/curve/frames) + CVF2 解码移出主线程,主线程接收已解码的 page(结构化克隆);Worker 失败或环境不支持时自动回退主线程 fetch。图表 Worker(utils/curveChartWorker.ts)把当前 X 窗口的序列快照以 transferable Float32Array 发过去,在 OffscreenCanvas 上做 Y 范围、网格/坐标轴、像素列 min/max 降采样与平滑,返回 ImageBitmap;主线程只 drawImage 并画 hover/cursor overlay。没有 Worker/OffscreenCanvas、导出 PNG、或 Worker 出错时回退主线程 drawChartLocal,保证功能不丢。

OffscreenCanvas 并不是“开了就一定快”:256 路 A 模式全绘制时,软件光栅 + ImageBitmap 传输仍是瓶颈,实测 worker draw 约 12.2 ms、视觉约 24 fps;默认 B 模式只画 drawCap 条,成本低得多。因此页面的默认策略是 B + drawCap=64,A 模式作为压力/全量查看选项。IndexedDB 录制(utils/curveRecording.ts)只在用户点 Record 后写入,保存 series 元数据 + 每帧相对时间/group/seq/pc/原始 payload,上限 6000 帧;支持回放(0.5×–4×)与 JSON/CSV 导出。它不保存变量选择,也不自动录制。

9.5 i18n 与文案契约

所有用户可见文案走 zh.ts/en.ts,共 947 个契约 key;新增 key 必须同步两种语言、更新 check-i18n.mjs 的 EXPECTED_TOTAL/EXPECTED_ADDED,并保持占位符一致。构建时 --strict 检查页面级硬编码中文、法语残留、动态拼接 key。组件(ObjectBrowser/SourceBrowser)不 import i18n,默认英文,由父页面用 translateRaw() 注入中文;不能用 translate() 取原始文案,因为 vue-i18n 会把 {matched} 当插值替换成空串。

9.6 测试与调试入口

纯函数/组件测试全部是 Node 脚本,不依赖浏览器:test-curve-local.mjs(CVF2 解码/分组/labels/集成静态契约)、test-object-browser.mjs(208 checks)、test-source-project.mjs(226 checks)、test-elf-line-index.mjs(96 checks)、test-dwarf-line-table.mjs(132 checks)、test-dwarf-locations/loclists/variables.mjs、test-target-profile.mjs(129 checks);再加 vue-tsc 与 build.sh。页面还暴露 window.__curveDebug()(draws/avgDrawMs/drawn/visible/selected)与诊断面板,供 Playwright/性能脚本读取;window.__BIRDIEBOT_WEB__ 暴露 Web 版本、git tag、构建时间和协议版本。

Objects 视图:对象根、惰性对象树、watch 与加入曲线
图 4 Objects 视图:从 xrobot_debug_handle 这类全局根按需展开,成员可以直接 watch / 加入曲线;根发现与稳定路径见 8.5。

10 性能、诊断与故障注入

这一章讨论的是“为什么改一行代码会让 SWD 识别失败”“为什么 256 路页面像卡住”“为什么第二次 USBIP 会话报 CMD_INFO failed”。这些问题的共同点是:它们不是单点 bug,而是内存预算、任务时序、链路延迟和渲染成本共同作用的结果。排障时先定位资源/链路,再看业务逻辑。

10.1 静态内存预算:最容易被忽视的全局约束

主控在 WiFi 启动后,内部 RAM 的最大连续空闲块本来就不大;DAP bring-up、lwIP、OTA、cJSON 都依赖这块堆。曲线采样器把配置、ring、请求体做成静态/受控内存,是为了可预测,但静态内存不是免费的:它会直接减少 heap 的最大空闲块。当前基线的大致账本是:curve_config_t 约 16 KiB 量级(8 组、256 series、256 variants、768 表达式节点)、按需堆分配的 6 KiB 请求体、ring 8 KiB 预算,另有 httpd/WS 的小缓冲。

!
已知回归:不要为了“更多路数”盲目扩大静态配置。曾经把采样容量和表达式池一起扩大,使 WiFi 后的最大空闲块降到约 11 KiB,结果 /api/target/info 和 /api/curve/start 都返回 ESP_ERR_TIMEOUT,而 Web 服务看起来完全正常。修复不是换板,而是把 series 结构压到 16 B、表达式池回到 768、请求体上限回到 6 KiB、总路数回到 256,之后 H7 目标检测与本地采样恢复。改容量常量前先测 heap_caps_get_largest_free_block() 与真机 target detect/start,不要只跑 host 测试。

排障顺序建议:先看串口启动日志里的 free heap/largest block,再看 /api/curve/status 的 last_err,然后用 /api/curve/selftest 验证 DAP/SWD 是否还能 bring-up,最后才怀疑目标固件或接线。如果 selftest 也超时,问题在 DAP/堆/引脚层,不在采样配置。

10.2 采样时序与实际速率

1 MHz SWD 下,一次“halt → 读 r13/r7/r15 → 读 2 个 word → resume”的实测约 1.9 ms:min/median/p90/max = 1924/1926/1971/2065 µs,阶段均值约 halt 274 µs、regs 1246 µs、mem 301 µs、resume 116 µs;DPIDR 0x6BA02477。这决定了 periodic + halt 的合理区间:10 ms 时 halt 占用约 19%,20 ms 约 10%;再快就会明显干扰控制环。

模式interval目标是否停实际能力/注意
periodic + halt5–200 ms每拍短暂 halt可读寄存器/栈帧/指针;10 ms 实测约 100 Hz
periodic + no-halt1–200 ms不停只能读固定地址/纯内存链;1 ms 后端可跑约 1 kHz,但 Web 轮询跟不上
breakpoint(FPB)节流 5–1000 ms命中时停事件驱动;入口处局部变量可能未初始化,建议断在初始化之后
watchpoint(DWT)节流 5–1000 ms命中时停变量写/读写触发;比较器资源与调试器共享
多组轮转每组 interval × group_count按当前组单系列刷新率 ≈ 1/(group_count × interval_us);weight 可倾斜

实测参考:固定 xTickCount 本地采样 100–101 Hz、60 s 共 5,966 帧、invalid/overwritten 均为 0,停止后 Web-DAP 可重连、目标已恢复运行;全局 xrobot_debug_handle->bmi088_.accl_ 100 Hz 数据正常;栈帧变量在函数不在栈上时 out_of_scope 增长而 invalid 为 0,符合预期。256 路 compact v3 请求实测 8,361 B 可被接受并采样,4 组 ×64 路轮转,硬件不丢数据,但每条曲线刷新率会按组数下降。

10.3 浏览器侧性能

性能问题通常不是“画图太慢”,而是“采样统计刷新触发整页 Vue 重渲染”。基线(fake-DAP,10 s 窗口)在 40/128/256 路 B 模式下 rAF 约 56.6/48.5/48.3,128/256 时 10 s 内 42–45 个长任务、累计 2.4–2.8 s;LoAF 显示 90% 以上落在 TimerHandler:setTimeout 触发的重渲染,而不是 FrameRequestCallback。修复手段是 selectedKeySet O(1)、stats flush 降到 1 Hz、变量树 v-memo、每帧一次 X 像素缓存、同色合批、固定 20 ms 取帧定时器 + 最多 3 个在途请求。

指标(10 s 窗口)40 路128 路256 路(A 全绘制)
rAF FPS(优化后,OffscreenCanvas)59.956.756.7
视觉 draw FPS29.727.524.0
worker draw 平均5.9 ms9.5–10.5 ms12.2 ms
取帧轮询/s494644.9
long task000(A 256 的视觉框架仍是边界)
优化前 256 A 的 avgDraw——18.71 ms、rAF 43.7(作为回归对照)

渲染策略:多曲线默认 B 模式,最多画 drawCap 条(默认 64,可 32/64/128),其余仍采样但可在 Selected/图例单独打开;A 模式绘制全部可见曲线并提示性能风险,可见数大于 128 时 B 会自动把 cap 降到 32。点数超过像素列时按列 min/max 降采样;Y 自动缩放有 250 ms TTL 且每轮最多抽样 20k 值;hover 用独立 overlay canvas;页面隐藏时暂停绘制,回前台补一帧;PNG 导出强制主线程同步绘制。

10.4 传输与背压

HTTP 取帧的单次往返在真机上约 63–69 ms,串行轮询只能到 9–12.6 次/s,因此页面改成固定 20 ms 定时器 + 最多 3 个在途请求,按 seq 去重、gap 重同步;/ws/curve 则完全绕过轮询,20 ms 最小推送间隔、每批 32 帧、最多 4 客户端。WS 推送失败/堆不足会跳过该批并计 dropped_batches,发送失败的连接被摘除,采样 worker 不受影响。客户端 ack 目前只是记录,不参与窗口控制;真正的背压靠“每个客户端最小间隔 + 批次上限 + 发送失败摘除”。

10.5 诊断入口

入口能看到什么适合的问题
GET /api/curve/statusrunning/group/frames/invalid/out_of_scope/overwritten/ring_frames/served_seq/error/last_err/link_hz/expr_pool_used采样是否在跑、是否丢帧、卡在哪个组、池是否接近满
POST /api/curve/selftesthalt/regs/mem/resume 分段耗时、DPIDR、link_hzSWD 链路/DAP 是否健康,与目标固件无关
GET /api/target/infoDEV_ID/FSIZE/UID/CPUID/候选型号/supported目标是否识别、为什么烧录按钮灰
GET /api/target/status烧录状态 + heap_free/heap_largest/dap_session_active/swd_clock_hz/last_error/wired_ncm_active/wired_dhcp_active/wired_ip烧录进度、堆余量、DAP 会话、NCM/DHCP 状态
POST /api/curve/trace/start|stop、GET /api/curve/trace?since=UART trace 字节流看目标串口/固件 trace,不依赖 SWO
GET /api/curve/backtrace帧指针 backtrace 原型现场卡死/断点命中时抓调用链;优化代码可能不完整
scripts/hil_gate.pyHIL 门禁:检测、1/5 系列采样、帧率、gap/invalid、heap、DAP 会话、页面 smoke发布前真机回归
页面诊断面板 / 复制 JSON设备 status、ELF 匹配、渲染模式、WS/HTTP 传输、stale 计数、目标信息让现场信息可复现地贴进 issue
window.__curveDebug()draws/lastDrawMs/avgDrawMs/drawn/visible/selected渲染成本与 B/A、drawCap 的关系
串口 UART0(115200)启动日志、任务创建、DAP/curve/target 错误、reset reason崩溃、OOM、DAP ring 重建失败、启动阶段问题
coredump 分区崩溃现场(可能含敏感数据)panic/abort;用匹配版本 IDF 的 idf.py coredump-info

10.6 故障注入矩阵

注入/场景预期如果不符合预期
不接目标 / 目标无供电/api/target/info HTTP 200 + ok:false, code:5;曲线 start 503检查共地、SWDIO/SWCLK、目标电压、nRESET
SWD 时钟过高/线太长探测在 2M→1M→500k 回退后成功,或明确失败降 adapter speed、缩短线、降低干扰
3240 已有 USBIP 会话时 start 本地采样kick 成功则采样启动;否则 409/超时先 detach/关闭会话;看是否有旧固件不支持 kick
start 时引擎正在跑409 busy先 stop;不要并发发两个 start
series 总数/单组大小/body 超限400 或 413,明确 err;不改变现有会话按 4.2 检查分组与 expr_s;减少路数
PC 门控变量不在栈上out_of_scope 增长、值清零、会话继续确认 PC 区间来自正确函数;不要用 invalid 判断
指针越界/非法地址invalid 增长、该 series 清零、必要时 recover_bus检查 ELF 与目标固件是否匹配;确认地址窗口
烧录不支持的家族/双 bankprobe 成功但 supported=false,烧录 400不要改 flash_ops 硬烧;按 6.7 边界处理
上传非 app 镜像/超大文件/SHA 不符413/422,当前运行槽不变检查文件、Content-Length、X-Target-SHA256;不要用 merge/zip
OTA 新镜像 10 s 内崩溃bootloader 回滚旧槽,GET_INFO.running_ver 变回旧版本看 reset reason/coredump;修复后重传,不要关回滚
NCM 主机 NetworkManager 托管网卡可能 DHCP 失败后清掉手动 IP,USBIP 传输挂死nmcli device set … managed no;重新配 192.168.7.2/24

10.7 已知回归与经验

  • 不要扩大静态内存来换路数。见 10.1;这是本项目最贵的一次回归,host 测试全绿但真机 DAP bring-up 失败。
  • ring 用预算换容量,不用固定帧数。256 B payload 只有约 30 帧历史;想看更长历史要降低 payload/组数,而不是偷偷申请更大 ring。
  • 周期与 halt 绑定。halt=true 的下限是 5 ms;想 1 ms 采样必须 no-halt 且不依赖寄存器,这是协议约束,不是 UI 限制。
  • 多组不是“并行采样”。每个 tick 只采一组,总帧率不变;单条曲线刷新率按组数分摊。诊断“曲线卡”先看组刷新率与 frames 增量。
  • 取帧链路有上限。HTTP 串行轮询在 WiFi 下只能到约 10 Hz;WS 推送和“最多 3 个在途请求”是已知解,继续堆 DOM/重渲染不会提高视觉刷新率。
  • A 模式 256 路是压力边界。OffscreenCanvas 仍受软件光栅/ImageBitmap 传输限制;默认 B + drawCap 是产品选择,不要把它当 bug。
  • NCM/TinyUSB 会改变堆布局。TinyUSB 存在时堆更碎,DAP ring 的 free+realloc 可能失败;会话收尾用 drain 而不是重建,主机侧不要和 NetworkManager 抢网卡。

11 测试与验收矩阵

这个项目的测试分四层:纯 C/Python host 契约、纯前端 Node 测试、三目标构建门禁、真机/Playwright。每层的“通过”含义不同:host 通过不代表真机通过,fake-DAP E2E 通过也不代表真实目标时序正确。发布结论必须写清楚证据层级。

11.1 Host 测试(不需要目标硬件)

# 一键 host 测试:C 126 checks + DAP ASan 51 checks + Python 78 tests
scripts/test_host.sh

# 组件级纯逻辑测试
components/curve_sampler/test/host/run.sh        # 采样器容量/解析/ring/CVF2/表达式(需 Docker 取 cJSON)
components/target_flash/test/host/run.sh         # DEV_ID 表/几何/边界 143 项
make -C components/dap_proxy/test test             # USBIP pipeline 配对状态机(ASan 可用 SANITIZE=1)

host 测试的价值是“把能在 PC 上验证的协议/几何/边界全部钉死”:曲线 host 回归当前 4,154 checks,覆盖 64×4B/32×8B/256 路 compact 请求、ring 覆盖语义、CVF2 头、loclist variants、DAG 去重、读缓存、错误路径;target flash host 143 checks,覆盖 80 组 DEV_ID、G4/L4/G0/WB/WL/F0/F1/F3/F2/F4/F7/H7 几何、双 bank 边界、编程粒度;“识别但不支持”的家族也有断言,防止有人误改 flash_ops 把不支持的芯片变成可擦写。DAP ASan harness 编译当前仓库的 DAP.c,N1 0x7E 递归、N9 TransferBlock 越界在 ASan 下守住。

11.2 Web 纯函数/组件测试

脚本覆盖当前规模
node scripts/test-curve-local.mjsCVF2 解码、自动分组、字节预算、labels、i18n、DebugCurve 集成静态契约PASS(157+ checks)
node scripts/test-object-browser.mjs根发现、稳定路径、惰性树、批量 watch/curve、预设、真实 ELF 可选回归208 checks
node scripts/test-source-project.mjs扫描/目录树/ELF、变量→文件匹配、tokenizer/虚拟化/LRU、组件契约309 checks
node scripts/test-elf-line-index.mjs行表集成、过滤、pcToLine、fileLineToAddress、真实 ELF 与 addr2line 对照96 checks
node scripts/test-dwarf-line-table.mjsDWARF2–5 行表、query、真实 ELF 逐行/抽样验证132 checks
node scripts/test-dwarf-locations.mjs 等DW_OP 求值、loclist 解码、变量树解析独立套件
node scripts/test-target-profile.mjs16 profile 不变量、FSIZE 缩小、自动匹配、诊断 JSON v2129 checks

前端提交前至少跑:check-i18n.mjs --strict、vue-tsc --build --force、SKIP_NPM_CI=1 bash build.sh,以及改动模块对应的 Node 测试。涉及真实 ELF 的测试在文件不存在时会 [skip] 并返回 0,所以“全绿”不一定代表覆盖了真实 ELF——看到 skip 要明确记录。

11.3 固件构建/发布门禁

scripts/build_all.sh                     # 多目标构建 + 分区/app 门禁
python3 scripts/check_partitions.py      # 分区契约、app 大小、内嵌版本
python3 scripts/check_board_pins.py      # 板文件与 docs/board-pins.md 一致
scripts/gen_web_assets.sh --check        # 入库 Web gz 与 web/ 构建一致
scripts/check-version-string.sh
scripts/check-protocol-version.sh
scripts/check-release-versions.sh
scripts/release_local.sh                 # 干净工作区 + tag + 发布包/SHA256

默认发布构建必须三目标全绿、分区门禁通过、Web 资产 hash 与当前构建一致。C3/ESP32 只是编译验收,没有 OTA(4 MB 单 app),真机行为不承诺。发布 tag 必须干净工作区、固件版本与 web/package.json 对齐,tag 后还要复跑严格门禁。

11.4 真机与 Playwright

真机验证大致分四块:DAP/USBIP(连续多会话、OpenOCD/Keil、UART/DataFlow)、本地采样(固定地址/栈帧/多组/256 路/停止恢复)、目标烧录(F407、H723、未支持家族的识别边界)、OTA(首烧、上传、回滚、错误注入)。自动化方面,fake-DAP Playwright 能覆盖页面交互与分组/解码/渲染,但不能替代目标时序;真机数据要记录设备 IP、固件版本、ELF sha256、SWD 时钟、帧数/Hz、invalid/overwritten 与停止后目标是否运行。

现有证据快照:目标页简化后的 mock UI 冒烟 41 checks;Source 接入 fake-DAP E2E 33 checks;本地采样回归 70 checks;390×844 移动端无横向溢出;H7 真机 100 Hz/60 s/5,966 帧、invalid/overwritten 0;H7 烧录 1 MiB 约 44.4 s(Web 直烧,OpenOCD 回读 SHA 一致);H7 读取约 53.6 KiB/s(OpenOCD 实测);USB-NCM 1 MiB 读约 154 KiB/s。这些都是特定固件/板子的记录,不是对所有目标的保证。

11.5 验收矩阵

能力Host/Webfake-DAP/Playwright真机结论
USBIP/WinUSB/elaphureLink/WS-DAPDAP ASan + 契约页面 E2EWindows/USBIP、Keil/elaphureLink、H7 多会话✅ 已验收
本地采样(周期/固定地址/栈帧/多组/256 路)4154 checks专项 E2EH7 100 Hz、多组、256 路 compact✅ 已验收
breakpoint / watchpoint / conditionhost 解析/状态UI 控件FPB 真机 155/155;watchpoint/condition 部分真机✅ 主体验收,条件触发组合待更多真机
目标检测 + 自动匹配129 checks41 checksF103C8T6 识别、H723 识别✅ 识别验收;G4/F0/F3 等无实板
目标烧录143 checks格式转换/上传 UIF407、H723 全流程✅ 仅 F4/H7 真机;其余待板
G4/L4/G0/WB/WL/F0/F1/F3/F2/F7 烧录几何/边界—无⏳ 待硬件
OTA + 回滚契约/分区UI8 MB 发布版实测(含 409/413/422、回滚)✅ 已验收
有线 USB-NCM构建门禁—两/三连续会话、DHCP 取址、烧录/读取实测✅ 已验收(默认 S3 固件)
HIL 门禁scripts/hil_gate.py页面 smoke目标检测、1/5 系列采样、帧率、gap/invalid、heap、DAP 会话✅ 脚本入口已建立;结果以当前提交为准
UART / DataFlow / 1.5 s 心跳契约页面 E2ET1–T8 清单✅ 已验收
安全项 N1–N9ASan + 源码守卫—部分待客户端真机✅ 修复闭环,残余风险见 12.1

11.6 G4 等待硬件的验收入口

G4 是当前最需要上板的新家族。建议顺序:G431(0x468,128 KiB,单 bank 2 KiB 页)→ G474/G484(0x469,0x80000 字节 Flash,OPTR.DBANK=0 与 =1 两种)→ 其它。每块板至少记录 DEV_ID/REV/CPUID/FSIZE/OPTR/UID、bank 模式/页数、擦除页数、编程速率、回读 SHA-256;重点注入:跨 128 页的镜像、64-bit double word 的 PGAERR/SIZERR、擦除超时(单页约 20–40 ms 量级)、双 bank 切换后的页号/BKER。失败就把该 DEV_ID 的 flash_ops 降回 NONE,保留识别能力并让 UI 自动禁用烧录。

11.7 退出标准

一次改动可以合入的最低标准:相关 host/Node 测试通过;三目标构建通过;Web 改动通过 i18n strict、类型检查和构建;协议/分区/版本/引脚变更同步契约与文档索引;涉及真机行为的改动给出设备、固件、ELF、测量数据。发布级改动还要:干净工作区 tag、发布包 SHA256 自校验、OTA 回滚验证、安全清单复核、硬件验证或书面豁免。不要把“host 全绿”写成“真机通过”,也不要把“没时间测”写成“已验证”。

12 安全、协议边界、代码规范与贡献入口

最后一章写给准备提交第一个补丁的人。这个仓库的协作成本主要不在写代码,而在“不要破坏契约、不要破坏真机时序、不要引入无法验证的结论”。下面按安全模型、契约边界、代码习惯、常见改法、提交步骤来收束。

12.1 安全模型(必须接受的默认值)

!
设备按“实验室内网”设计:默认无登录/鉴权、HTTP/WS/TCP 明文、OTA 无签名、NVS 明文、AP 默认口令 12345678。Host/Origin 校验只防浏览器跨源(CSWSH/DNS rebinding),不是身份认证;不要把 80/3240/1346 暴露到公网或不可信 Wi-Fi。
端口/面默认风险与关闭方式
80/TCP(Web/API/WS/OTA)开,无鉴权同网段可改配置、操作 UART/OTA;用网络隔离,不靠 Host 校验当鉴权
3240/TCP(USBIP/EL/WS-DAP)开,无鉴权可 attach 探针读写/擦除目标;WS-DAP 可 BIRDIEBOT_DAP_WS_ENABLE=n,USBIP/EL 无单独开关,网络层限制
1346/TCP(UART 桥)开,无鉴权可直连目标串口;BIRDIEBOT_UART_BRIDGE_ENABLE=n 或 ACL
1900/UDP、5353/UDP开仅发现面;不需要时网络层封
OTA 上传开,无签名/无防降级只从可信主机上传;核对 SHA;生产可启 Secure Boot/Flash Encryption(默认未启)
NVS/coredump明文物理读取可获取 Wi-Fi 口令;coredump 按敏感数据处理

安全审查的 N1–N9 中,N1(DAP 0x7E 递归)、N2(USB 字符串描述符越界)、N6(SWJ_Pins 单位)、N7(reset 超时并发)、N8(OUT 短包陈旧数据)、N9(TransferBlock 越界)已修复并有 host/ASan 证据;N3/N4/N5 属于无鉴权/默认配置的历史风险(token 功能已移除),按工作室内网接受。改动 DAP/传输层时,必须保留 tests/host/dap 与 tests/host/test_security_regressions.py 的源码守卫,不能把修复删掉换“看起来更简单”的实现。

12.2 协议边界

冻结契约包括 protocol.md、ws-binary-router.md、ota-local-upload.md、wifi-credential-memory.md、versioning.md、i18n-keys.md、board-pins.md 与 merge-map.md。规则是:实现与契约冲突时先记录差异、走契约变更,不能单方面改契约;新增字段默认是加法(旧客户端忽略未知字段),删除/改名/改变语义属于破坏性变更。设备端 JSON 错误码、HTTP 状态映射、CVF2 flags、OTA 状态机、i18n key 数量都是被测试/前端消费的契约,改动要同步消费方与测试里的冻结副本。

Host/Origin 校验不是可选项:浏览器带 Origin 时必须同源或命中 BIRDIEBOT_WEB_ALLOWED_ORIGIN;Host 必须是 IP literal、birdiebot-dap-XXXX.local 设备主机名或白名单。非浏览器客户端不带 Origin 仍可访问,这是设计而不是漏洞。新增 API 必须自己调用 web_host_guard,不要假设全局中间件已经做过。

12.3 代码规范(从现有代码里能读到的约定)

  • 纯逻辑与平台胶水分层。几何、DEV_ID、表达式、ring 记账、CVF2 编解码、pipeline 配对都做成不依赖 ESP-IDF 的纯 C/TS,host/Node 可测;IDF 相关代码只做胶水。新算法优先放纯逻辑层。
  • 静态内存与堆都要有预算。大缓冲不要放任务栈;httpd 路径不要 malloc 大块;静态数组增长要写清楚“谁在什么时机释放/复用”。曲线采样/ring/WS 池都是这种模式。
  • 错误字符串是 UI 契约。{"ok":false,"err":"..."} 的 err 会直接显示给用户;新增错误要短、可定位,不要用中文/动态拼接。错误路径必须先恢复目标运行/归还 DAP,再返回失败。
  • 任务边界明确。DAP ring 只归 DAP 任务;慢操作丢给 worker/request_runner;ISR/回调里不做阻塞访问;共享状态用 mutex 或有界队列。
  • 前端文案必须走 i18n。组件不 import i18n,用 labels 注入;禁止硬编码中文/动态拼 key;新增 key 同步 zh/en 与检查脚本。
  • 许可证头。仓库是混合许可:原创部分 Apache-2.0,第三方组件保留 MIT/Apache-2.0 头;新增文件按所在目录的既有 SPDX 头处理,不删除上游版权声明。第三方代码的修改要在 NOTICE/文档可追溯。

12.4 常见改动的落点

想做什么先改哪里还要同步
新增一个 HTTP 端点独立 URI module,按优先级注册;自己调 host guardWeb API 封装、错误码、manual/developer 文档、host/Node 测试
给采样加字段curve_json.c 解析 + curve_priv.h + status/CVF2curveLocal.ts、契约 §9、host 测试、Web UI 提示
新增目标家族烧录tf_stm32_id.c/tf_stm32_geom.c/对应 page/f1/f4/h7 驱动host 143 项、支持矩阵、未支持边界、真机记录、UI 文案
改 DAP 传输/包大小dap_proxy Kconfig、dap_configuration.h、pipelineOpenOCD 兼容性(u8 count)、README、真机多会话测试
新增前端页面web/src/views + routerlocales(zh/en)、导航、i18n strict、构建/资产 install
新增 OTA/分区行为wt_ota + partitions + main 回滚钩子OTA 契约、release checklist、回滚真机测试
加有线传输wired_dap + sdkconfig.defaults.esp32s3(默认)+ 旧 build-wired 兼容NCM/DHCP、主机 DHCP 验证、SWD bring-up 与 heap 余量
改引脚/板卡components/boarddocs/board-pins.md(唯一契约)、check_board_pins.py、三目标构建

12.5 贡献入口(建议流程)

  1. 先读契约与相关实现。docs/README.md 是文档地图;冻结契约在 §12.2;实现说明按组件找 README(curve_sampler、dap_proxy、wt_ota、scripts 等)。不要只看页面行为就改协议。
  2. 缩小问题并拿到证据。固件问题抓串口日志/status/selftest;Web 问题抓 console/网络/诊断 JSON;目标问题记录 DEV_ID/FSIZE/UID/接线/时钟。没有证据的“感觉”不做根因。
  3. 在纯逻辑层加测试。能 host/Node 测的边界先钉死;真机相关写清验证条件。容量/协议/几何改动必须有反例(上限、越界、不支持组合)。
  4. 实现并自测。固件跑对应 host 测试 + 三目标构建;Web 跑模块 Node 测试 + i18n strict + vue-tsc + build;涉及真实 ELF/目标/OTA 的补真机记录。
  5. 同步文档与门禁。改协议/分区/版本/i18n/引脚必须同步契约与索引;改 Web 资产必须 gen_web_assets.sh --install 并检查 web_version.txt。
  6. 提交与评审。提交信息写“改了什么/为什么/证据”;不混无关改动;不单方面改冻结契约。Lead 负责合并、版本 tag 与发布门禁。
  7. 发布前。按 release checklist 走:干净 tag、构建矩阵、分区/版本/Web 资产门禁、OTA 回滚、硬件项或书面豁免、SHA256 发布包。

12.6 文档与报告的写法

这个仓库的文档不是“宣传页”,而是可复核的工程记录。写实现说明时至少包含:接口/格式、硬限制、错误路径、测量条件(设备、固件版本、ELF sha256、SWD 时钟、运行时长)、真机与 host 的边界、回滚方式。没验证的写“待真机/仅 host”,不要用“应该”“理论上”代替证据;失败记录同样有价值,尤其是“哪个 DEV_ID 不支持、为什么、怎么安全降级”。