sum(size) ≤ 256 B、最多 8 组,start 请求体 ≤ 6 KiB(6144 B),表达式节点池 768。容量曾经被扩大过,随后因为静态内存挤占导致 SWD/DAP bring-up 超时,已经回退到 256;任何“更多路数”的旧笔记都不适用于本基线。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 /ws | httpd → URI 模块 / /api JSON router → 各 module;/ws 文本 JSON + 4 字节头二进制数据 | 内嵌 Web UI、脚本 curl、UART 页 |
| 设备本地目标面 | HTTP /api/curve/*、/api/target/*、/ws/curve | curve_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) | elaphureLink | el_dap_work() 握手/命令循环,二进制 DAP 透传 | Keil + elaphureLink 插件、OpenOCD-elaphureLink |
低 16 位 0x8003 / 0x8005 | USBIP OP_REQ_IMPORT / OP_REQ_DEVLIST | usbip_worker() 进入 USBIP 状态机;WinUSB/CMSIS-DAP v2 | usbip-win attach_ude、Linux vhci |
0x47455420(ASCII "GET ") | WebSocket-DAP | websocket_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 task | core 1 / 14(可配) | 3240 接入、首包分发、USBIP 状态机、会话收尾时投递 RESET | 不直接释放 DAP ring;会话结束发 RESET_HANDLE |
| httpd | IDF 默认任务 | 静态页面、JSON API、target/curve HTTP 端点 | 4 KiB 栈量级;start body 按需堆分配(≤6 KiB),解析后释放,不在栈上开大数组 |
| curve worker | 专用任务 | SWD bring-up、按组采样、写 CVF2 ring | stop 必须 resume + 归还 DAP;连续错误 5 次停 |
| curve WS push | core 0 / 3 | 从 ring 取帧并通过 /ws/curve 推送 | 每客户端 20 ms 最小间隔、每批 32 帧、最多 4 客户端 |
| TinyUSB(默认 S3 固件,NCM) | core 0 / 5 | USB 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、mDNS | URI 注册顺序决定路由命中;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/DAP | cmsis-dap/source/DAP.c、DAP_config.h | 纯 CMSIS-DAP 命令层:ID/Info、Transfer、TransferBlock、SWJ 序列、SWO 等。host 有 ASan harness,安全回归(0x7E 递归、TransferBlock 越界)在这里守。 |
components/dap_proxy | tcp_server.c、usbip_server.c、usbip_pipeline*.c、websocket_server.c、DAP_handle.c | 3240 端口三协议分发、USBIP 状态机与多 URB 流水线、DAP 环形缓冲所有权与会话 hand-off。它不实现 SWD 时序,只把命令交给 DAP。 |
components/target_flash | wt_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_sampler | curve_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_dap | wired_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、dataflow | UART 服务、TCP 1346 桥、DataFlow 实例/attach | 与调试面共享 UART 资源;烧录窗口会暂停 TCP 桥,结束后恢复。 |
components/wt_ota | OTA 状态机、分区选择、回滚确认 | 仅 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 | 慢请求/长任务队列与发回回调,避免阻塞 httpd | WiFi 扫描、OTA 进度等异步操作 |
html | 入库的 index.html.gz、ws.sharedworker.js.gz、build-info.json.gz 与 web_version.txt | 只能由 gen_web_assets.sh --install 更新;不要手工替换 |
wifi_manager | AP/STA、Smart APSTA、多凭据记忆、静态 IP、NVS | JSON cmd 11–14 与凭据契约绑定 |
wt_ota / wt_storage / wt_system / wt_mdns / wt_common | OTA 核心、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 全量包,否则会掩盖/跳过首烧回退逻辑。
| 区域 | 偏移 | 大小 | 说明 |
|---|---|---|---|
| bootloader | 0x000000 | — | 二级 bootloader |
| partition table | 0x008000 | 4 KiB | CONFIG_PARTITION_TABLE_OFFSET=0x8000 |
| nvs | 0x009000 | 24 KiB | Wi-Fi 凭据/系统参数 |
| phy_init | 0x00F000 | 4 KiB | 射频校准 |
| otadata | 0x010000 | 8 KiB | 双槽选择/回滚状态 |
| ota_0 | 0x020000 | 3 MiB | 首烧/factory 槽 |
| ota_1 | 0x320000 | 3 MiB | OTA 目标槽 |
| wt_nvs | 0x620000 | 64 KiB | 多 WiFi 凭据等 |
| coredump | 0x630000 | 64 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/selftest | SWD 单拍耗时探测 | body 可为空;N=20 次 halt→读 r13/r7/r15→读 2 word→resume,统计 min/median/p90/max |
| POST | /api/curve/start | 下发采样配置并启动 worker | JSON ≤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 · /stop | UART 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 硬限制
| 项 | 限制 | 说明 |
|---|---|---|
| groups | 1..8 | 每组独立 payload;每 tick 只采样当前组,轮转进行 |
| series / group | 1..64 | 对象浏览时通常按成员数自动切组 |
| series 总数 | 256 | 所有组累计;典型使用只有几十路 |
| 单 series size | 1..8 B | 按目标内存中的标量宽度 |
| 每 group 的 sum(size) | ≤256 B | 决定该组每帧 payload 长度;64 路 ×4 B 恰好 256 B |
| start body | ≤6144 B | 统一 NCM+WiFi 固件按需堆分配请求体;超过返回 413。紧凑表达式 expr_s 是为 6 KiB 预算服务的 |
| 表达式深度 | ≤8 | addr/reg/cfa/add/deref 链 |
| 表达式节点池 | 768 | DAG 去重后共享前缀;超池返回 400 too many expr nodes |
| variants / series | 1..16 | loclist 多段;总 variants ≤256 |
periodic interval_us | 1000..200000 | halt=true 时下限提高到 5000 µs(低于 5 ms 目标几乎一直在停) |
breakpoint / watchpoint 的 interval_us | 5000..1000000 | 它是两次采样之间的节流下限,不是采样周期;默认 20000 |
| 单个 group 的 weight | 1..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_scope | PC 不在作用域/loclist 无匹配导致的跳过数,与 invalid 分开统计 |
ring_frames、served_seq | ring 实际容量(不是名义值)与已交付的最高 seq |
hits、sampled、throttled | breakpoint/watchpoint 命中、实际写帧、被节流丢弃的次数 |
pc_available、last_pc | 帧是否带 PC,以及最后一次读到的 PC(Thumb bit0 已清) |
watchpoint_armed、watchpoint_address、watchpoint_hits、trigger_seq | DWT 比较器状态与最近触发帧的 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 位 | 名称 | 含义与客户端动作 |
|---|---|---|
| bit0 | GAP | since 比 ring 中最旧帧还旧,本批从最旧可用帧开始;客户端应从 first_seq 重新同步,不能假装连续 |
| bit1 | PC_PRESENT | 每帧多 4 B PC;解码器必须按该位决定元数据长度,不能写死 15 B |
| bit2 | TRIGGERED | 本批包含 watchpoint 触发帧;精确 seq 看 status.trigger_seq |
| bit3 | CONDITION | 本批包含条件触发匹配帧;精确 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 → client | binary | CVF2 批次,格式与 /api/curve/frames 完全相同 |
| device → client | text | {"ok":...} 或 {"ok":false,"err":"..."} |
背压规则:每个客户端两次推送至少间隔 20 ms,每批最多 32 帧,最多 4 个客户端;堆不足或 socket 发送失败时跳过该批并计入丢弃,发送失败的连接会被摘除。推送任务独立于采样 worker 和 httpd,慢客户端不会让采样停摆。客户端发二进制或分片消息会被视为协议错误并关闭连接;命令文本也有 256 B 上限。
4.8 错误与状态码
| HTTP | 典型 err | 触发条件/处理 |
|---|---|---|
| 400 | invalid 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;修配置而不是重试 |
| 409 | busy | 引擎正在采样;先 stop 或等待 |
| 413 | JSON body > 6144 bytes | 请求体超限;改用紧凑 expr_s 或减少 series |
| 503 | start: ESP_ERR_TIMEOUT、DAP bring-up 失败 | 目标无响应/SWD 链路问题;检查接线、共地、目标供电,必要时 selftest |
| 500 | 内部错误 | 看串口日志与 last_err |
| 403 | host guard | Host/Origin 不合法;用 IP 访问或加入允许来源 |
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 '{}'
5 采样表达式
表达式(AddressExpr)描述“变量值在目标内存的哪里”,与 Web 端 DWARF 求值使用同一套语义。表达式求的是值的地址,真正读取的字节数由 series 的 size 决定;地址求值失败/越界不会终止采样,只会把该 series 清零并计数。
5.1 五种节点
| kind | JSON | 语义 | 典型来源 |
|---|---|---|---|
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,而不是运行期踩内存。
| 场景 | 典型表达式 | 池占用/注意事项 |
|---|---|---|
| 固定全局标量 | A24002FA8 | 1 节点/路;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 / supported | bool/string | 是否探测到、家族字符串(如 stm32f1)、系列、器件组、是否支持烧录 |
swd_clock_hz / dpidr / cpuid / idcode | number/hex 字符串 | 实际链路时钟、SW-DP ID、Cortex 内核 ID、DBGMCU IDCODE(含 REV_ID) |
uid | 24 hex | 96-bit 唯一 ID 原始值。Web 把它按 8 个 hex 一组显示为 UID0/UID1/UID2,不改变字节顺序;界面不再上传/编辑 UID。 |
flash_base / flash_size / max_image_size | hex/number | 主 Flash 基址(通常 0x08000000)、容量、可接受镜像上限 |
dev_id / flash_kib | number | IDCODE 低 12 位与 FSIZE 换算的 KiB;读不到时为 0 |
model_candidates | string | DEV_ID + FSIZE 缩小后的候选型号组,例如 STM32F100/F101/F102/F103 (MD) [x8, 64 KiB] |
core / detection_confidence | string | CPUID 解出的内核名;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] | STM32F103C8T6 | exact |
| 0x413 / 1 MiB / xG | STM32F407VGT6 | exact |
| 0x483 / 1 MiB | STM32H723VGT6 | exact |
| 0x450 / 2 MiB / 1 MiB / 128 KiB | H743VIT6 / H743VGT6 / H750VBT6 | exact |
| 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-bank | 0x419、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-bank | 0x451(F76x/F77x) | 同 F4 双 bank + NDBANK 单/双 bank 变体;单 bank 2 MiB 映射未建模 |
| H7A/H7B | 0x480(H7A3/H7B3/H7B0) | 现有 H7 驱动只覆盖 0x450/0x483,控制器地址不同 |
| L0 / L1 | 0x417/0x425/0x447/0x457;0x416/0x427/0x429/0x436/0x437 | PECR/PEKEYR 控制器、页 128/256 B、字编程,与 L4 风格完全不同,未实现 |
| L5 | 0x472 | TrustZone:TZEN/RDP 0.5、secure/NS 两套 FLASH 寄存器、SECBB 安全页属性 |
| U5/U0/C5/H5 | 0x455/0x476/0x481/0x482、0x459/0x489、0x44E/0x44F/0x45A、0x478/0x484 | TrustZone + 128-bit quad-word 编程 / NS 寄存器布局,未实现 |
| C0 | 0x443/0x453/0x44C/0x493/0x44D | 不在目标集合内,几何近似 G0 但无参考 cfg,未纳入 |
| WB0x / WL3x | 0x01E/0x028/0x032/0x027 | 无 FSIZE 寄存器(flash_size_shift=0xFF),新布局无参考,仅识别 |
| WB1x/WB3x/WB50x | 0x494/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。
flash_ops 降回 NONE 安全回退为“识别但不烧录”。
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。
| 主机工具 | 连接方式 | 是否推荐 | 备注 |
|---|---|---|---|
| OpenOCD | USBIP attach 后按 CMSIS-DAP 选型;或 OpenOCD-elaphureLink 直连 3240 | ✅ 首选 | 本项目大量真机验证都走这条;F407 1 MiB 读在 master 下约 10.25 s(0.12 约 25.2 s) |
| pyOCD | CMSIS-DAP v2(USBIP 虚拟设备) | ✅ 可用 | 按 CMSIS-DAP 选型,SWD 时钟别一上来拉满 |
| Keil MDK | elaphureLink 插件 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 expose | selectRootByKey/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 注入。
8.7 映射边界
| 边界 | 行为 | 排查提示 |
|---|---|---|
| ELF 与目标固件不匹配 | 变量地址/行表与现场不同,曲线可能是错误内存;页面提供显式 Verify | 用同一次构建的 .elf;比对 build id/时间戳 |
| 非 Flash 代码(RAM 函数) | 默认查不到行断点 | 显式传 allowNonFlash: true 或配置 flashRange |
| 宏/声明/被优化行 | fileLineToAddress 返回 null | gutter 提示无地址;换下一行或关闭优化重编 |
| 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 的嵌入式静态托管 |
| UI | Element Plus + Tailwind + 设计 token | 深色优先,design-tokens.css 最后加载以覆盖主题 |
| i18n | vue-i18n,zh/en 两套 locale | 947 个契约 key,构建前 strict 检查 |
| 实时数据 | SharedWorker 单 WebSocket + 二进制路由 | 多标签页共享一条 /ws;曲线本地采样另有 HTTP/WS |
9.2 页面与关键模块
| 页面/模块 | 职责 | 依赖的设备接口 |
|---|---|---|
Wifi.vue | AP/STA、扫描、多凭据、静态 IP | /ws JSON module 1 |
Uart.vue | Web 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.vue | OTA 上传、进度、运行槽/版本 | /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、构建时间和协议版本。
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 的小缓冲。
/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 + halt | 5–200 ms | 每拍短暂 halt | 可读寄存器/栈帧/指针;10 ms 实测约 100 Hz |
| periodic + no-halt | 1–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.9 | 56.7 | 56.7 |
| 视觉 draw FPS | 29.7 | 27.5 | 24.0 |
| worker draw 平均 | 5.9 ms | 9.5–10.5 ms | 12.2 ms |
| 取帧轮询/s | 49 | 46 | 44.9 |
| long task | 0 | 0 | 0(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/status | running/group/frames/invalid/out_of_scope/overwritten/ring_frames/served_seq/error/last_err/link_hz/expr_pool_used | 采样是否在跑、是否丢帧、卡在哪个组、池是否接近满 |
POST /api/curve/selftest | halt/regs/mem/resume 分段耗时、DPIDR、link_hz | SWD 链路/DAP 是否健康,与目标固件无关 |
GET /api/target/info | DEV_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.py | HIL 门禁:检测、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 与目标固件是否匹配;确认地址窗口 |
| 烧录不支持的家族/双 bank | probe 成功但 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.mjs | CVF2 解码、自动分组、字节预算、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.mjs | DWARF2–5 行表、query、真实 ELF 逐行/抽样验证 | 132 checks |
node scripts/test-dwarf-locations.mjs 等 | DW_OP 求值、loclist 解码、变量树解析 | 独立套件 |
node scripts/test-target-profile.mjs | 16 profile 不变量、FSIZE 缩小、自动匹配、诊断 JSON v2 | 129 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/Web | fake-DAP/Playwright | 真机 | 结论 |
|---|---|---|---|---|
| USBIP/WinUSB/elaphureLink/WS-DAP | DAP ASan + 契约 | 页面 E2E | Windows/USBIP、Keil/elaphureLink、H7 多会话 | ✅ 已验收 |
| 本地采样(周期/固定地址/栈帧/多组/256 路) | 4154 checks | 专项 E2E | H7 100 Hz、多组、256 路 compact | ✅ 已验收 |
| breakpoint / watchpoint / condition | host 解析/状态 | UI 控件 | FPB 真机 155/155;watchpoint/condition 部分真机 | ✅ 主体验收,条件触发组合待更多真机 |
| 目标检测 + 自动匹配 | 129 checks | 41 checks | F103C8T6 识别、H723 识别 | ✅ 识别验收;G4/F0/F3 等无实板 |
| 目标烧录 | 143 checks | 格式转换/上传 UI | F407、H723 全流程 | ✅ 仅 F4/H7 真机;其余待板 |
| G4/L4/G0/WB/WL/F0/F1/F3/F2/F7 烧录 | 几何/边界 | — | 无 | ⏳ 待硬件 |
| OTA + 回滚 | 契约/分区 | UI | 8 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 心跳 | 契约 | 页面 E2E | T1–T8 清单 | ✅ 已验收 |
| 安全项 N1–N9 | ASan + 源码守卫 | — | 部分待客户端真机 | ✅ 修复闭环,残余风险见 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 安全模型(必须接受的默认值)
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 guard | Web API 封装、错误码、manual/developer 文档、host/Node 测试 |
| 给采样加字段 | curve_json.c 解析 + curve_priv.h + status/CVF2 | curveLocal.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、pipeline | OpenOCD 兼容性(u8 count)、README、真机多会话测试 |
| 新增前端页面 | web/src/views + router | locales(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/board | docs/board-pins.md(唯一契约)、check_board_pins.py、三目标构建 |
12.5 贡献入口(建议流程)
- 先读契约与相关实现。
docs/README.md是文档地图;冻结契约在 §12.2;实现说明按组件找 README(curve_sampler、dap_proxy、wt_ota、scripts 等)。不要只看页面行为就改协议。 - 缩小问题并拿到证据。固件问题抓串口日志/status/selftest;Web 问题抓 console/网络/诊断 JSON;目标问题记录 DEV_ID/FSIZE/UID/接线/时钟。没有证据的“感觉”不做根因。
- 在纯逻辑层加测试。能 host/Node 测的边界先钉死;真机相关写清验证条件。容量/协议/几何改动必须有反例(上限、越界、不支持组合)。
- 实现并自测。固件跑对应 host 测试 + 三目标构建;Web 跑模块 Node 测试 + i18n strict + vue-tsc + build;涉及真实 ELF/目标/OTA 的补真机记录。
- 同步文档与门禁。改协议/分区/版本/i18n/引脚必须同步契约与索引;改 Web 资产必须
gen_web_assets.sh --install并检查web_version.txt。 - 提交与评审。提交信息写“改了什么/为什么/证据”;不混无关改动;不单方面改冻结契约。Lead 负责合并、版本 tag 与发布门禁。
- 发布前。按 release checklist 走:干净 tag、构建矩阵、分区/版本/Web 资产门禁、OTA 回滚、硬件项或书面豁免、SHA256 发布包。
12.6 文档与报告的写法
这个仓库的文档不是“宣传页”,而是可复核的工程记录。写实现说明时至少包含:接口/格式、硬限制、错误路径、测量条件(设备、固件版本、ELF sha256、SWD 时钟、运行时长)、真机与 host 的边界、回滚方式。没验证的写“待真机/仅 host”,不要用“应该”“理论上”代替证据;失败记录同样有价值,尤其是“哪个 DEV_ID 不支持、为什么、怎么安全降级”。
