Skip to content

Comments

feat(firmware/c_board): Migrate USB stack from HAL CDC to TinyUSB#20

Merged
qzhhhi merged 2 commits intomainfrom
dev/cboard-tinyusb
Feb 21, 2026
Merged

feat(firmware/c_board): Migrate USB stack from HAL CDC to TinyUSB#20
qzhhhi merged 2 commits intomainfrom
dev/cboard-tinyusb

Conversation

@qzhhhi
Copy link
Member

@qzhhhi qzhhhi commented Feb 20, 2026

Replace the ST USB Device Library (CDC middleware) with TinyUSB, following the migration path outlined in commit 2d6076d.

What changed

  • Added TinyUSB as a git submodule (DWC2 portable driver for STM32F4 USB OTG FS) and integrated it into the CMake build.
  • Removed the entire ST USB Device Library middleware and USB_DEVICE application layer (~6500 lines deleted).
  • Reconfigured CubeMX to expose raw USB_OTG_FS PCD instead of the CDC device class; regenerated HAL accordingly.
  • Rewrote USB descriptors as TinyUSB callbacks with vendor-class device identity (VID 0xA11C, PID 0xD401) and UID-derived serial.
  • Rewrote vendor.cpp/hpp to use TinyUSB vendor class API (tud_vendor_n_write, tud_vendor_rx_cb) in direct mode.
  • Redirected OTG_FS IRQ from HAL_PCD_IRQHandler to tusb_int_handler; added tud_task() to the main loop.

Not yet completed

  • WCID / Microsoft OS Descriptor support is not wired in yet; Windows auto-recognition still requires follow-up work.
  • No on-device regression testing has been performed.

USB 堆栈从 HAL CDC 迁移至 TinyUSB(摘要更新)

本 PR 将固件的 USB 实现从 ST 官方 USB Device(CDC 中间件)全面迁移到 TinyUSB,并集成 DWC2 portable driver(作为子模块)。构建、HAL 配置、描述符与应用层均完成相应重构,旧的 STM32 USB_Device 中间件及其大量源文件被移除。

主要变更点

  • 依赖与构建

    • 新增 TinyUSB 子模块:firmware/c_board/bsp/tinyusb(指向 qzhhhi/tinyusb,子模块指针更新至 commit bb24b0c)。
    • 在 firmware/c_board/CMakeLists.txt 中添加 tinyusb_device OBJECT 库并将其链接到主固件目标;将 TinyUSB 源(tusb.c、usbd.c、vendor_device.c、dwc2 驱动等)加入构建,设置 CFG_TUSB_MCU=OPT_MCU_STM32F4。
    • 新增 tusb_config.h,默认仅启用 Vendor 类(CFG_TUD_VENDOR=1),配置端点大小、direct-mode 缓冲等。
  • 移除 ST USB 中间件

    • 删除 STM32_USB_Device_Library(核心/usbd_core、usbd_ctlreq、usbd_ioreq、CDC 类、usbd_conf、usbd_desc、usbd_cdc_if、usb_device 应用层等)及其 LICENSE.txt,删除量级为数千行(PR 统计约 6500+ 行删除)。
    • 移除 CubeMX 生成的 USB_DEVICE 应用层集成,重构 HAL 项目配置以暴露原始 PCD。
  • HAL / CubeMX 与中断路径改动

    • 重新配置为暴露原始 USB_OTG_FS PCD:新增 usb_otg.h / usb_otg.c,实现 MX_USB_OTG_FS_PCD_Init()、HAL_PCD_MspInit/DeInit(GPIO、时钟、NVIC 配置)。
    • main.c 中将原 MX_USB_DEVICE_Init() 替换为 MX_USB_OTG_FS_PCD_Init()。
    • 在 stm32f4xx_it.c / OTG_FS_IRQHandler 中将中断处理重定向为 tusb_int_handler(0, true)(早期返回,跳过 HAL_PCD_IRQHandler),并在主循环(app.cpp)每次迭代开头调用 tud_task()。
  • TinyUSB 描述符与设备身份

    • 新增 usb_descriptors.hpp/.cpp,提供 UsbDescriptors 类并导出 TinyUSB 回调(tud_descriptor_device_cb、tud_descriptor_configuration_cb、tud_descriptor_string_cb)。
    • 使用 Vendor 设备身份(VID 0xA11C, PID 0xD401),序列号由设备 UID 通过混合算法生成并作为字符串描述符提供。
  • 供应商类驱动重写

    • 重写 vendor.cpp/.hpp,切换为 TinyUSB vendor-class API(tud_vendor_n_write/tud_vendor_rx_cb 等)并以 direct mode 工作。
    • 添加 TinyUSB 事件回调:tud_vendor_rx_cb、tud_suspend_cb、tud_resume_cb、tud_mount_cb、tud_umount_cb,接收数据直接路由到 vendor 的下行处理逻辑。
    • 新的 try_transmit 流程处理批量、按最大包分片、ZLP,并用 tud_ready()/tud_vendor_n_write_available() 判定可写性。
  • 缓冲与中断安全

    • 调整 interrupt_safe_buffer:新增原子锁 is_locked_、try_lock()、try_unlock_and_clear() 与 clear() 方法,以支持在设备不可用或断电时安全清理/禁止分配。
  • 主机端行为调整

    • host/src/transport/usb.cpp 中对 USB 接收回调增加节流:记录 last_rx_callback_timepoint_,限制回调处理频率(约 1s)以减少重复/陈旧事件上报。

已知未完成项与风险

  • WCID / Microsoft OS 描述符支持(Windows 自动识别/WinUSB)的实现尚未接入(相关 wcid.cpp 已被删除),需要后续补充以改善 Windows 兼容性。
  • 尚未在真实硬件上执行完整的设备端回归测试;DWC2/TinyUSB 与 HAL/PCD 的互操作、USB 枚举、挂载/卸载、挂起/唤醒、主机兼容性等需在硬件上验证。
  • 因移除 ST USB 中间件,所有依赖旧 USBD_* API 的代码已被删除或需迁移,合并前需确认上层协议与主机端适配(特别是 Windows 主机工具/驱动)。

影响范围

  • 构建系统:从 STM USB_Device 切换为 tinyusb + DWC2 子模块,CMakeLists 与子模块依赖已更新。
  • 运行时:USB 初始化/中断路径、控制与数据流由原 USBD/Core/PCD/CDC 架构替换为 PCD(HAL)+ TinyUSB(设备类回调);应用层 Vendor 接口改为 TinyUSB API。
  • 兼容性:Windows 的自动识别(WCID/WinUSB)未实现,可能需要人工安装驱动或额外实现 WCID 支持。

变更量化(近似)

  • 删除:约 6500+ 行来自 ST USB Device Library(核心、CDC、目标配置、描述符、应用层等)。
  • 新增/修改:数百行 TinyUSB 集成、描述符实现、vendor 重写、HAL/OTG 文件、缓冲与主循环集成等。

建议(合并前考虑)

  • 在真实硬件(目标板)上执行全面回归测试:枚举、数据传输(包括大包/多事务)、挂载/卸载、挂起/唤醒、Windows 与 Linux 主机兼容性测试。
  • 如需 Windows 原生 WinUSB 支持,补充 WCID / Microsoft OS 描述符与相应响应处理。
  • 在 CI 或板级测试上验证 DWC2/TinyUSB 与现有 HAL 配置的稳定性及内存/中断行为。

@coderabbitai
Copy link

coderabbitai bot commented Feb 20, 2026

Warning

Rate limit exceeded

@qzhhhi has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 15 minutes and 3 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

将 STM32 HAL 的 ST USB 设备栈替换为 TinyUSB:新增 tinyusb 子模块与配置、在 CMake 中集成 TinyUSB,移除 ST USB Device 中间件与相关应用代码,新增 TinyUSB 描述符与回调,调整中断与主循环调用 tud_task(),并改造 Vendor/缓冲/构建相关逻辑。

Changes

Cohort / File(s) Summary
子模块与构建
\.gitmodules, firmware/c_board/CMakeLists.txt, firmware/c_board/bsp/tinyusb
新增 TinyUSB 子模块并在 CMake 中添加 tinyusb_device OBJECT 库,导入 TinyUSB 源与 include,设置 CFG_TUSB_MCU 并将库链接到最终目标。
移除 ST USB 设备库与应用
firmware/c_board/bsp/HAL/Middlewares/ST/STM32_USB_Device_Library/*, firmware/c_board/bsp/HAL/USB_DEVICE/*, firmware/c_board/bsp/HAL/USB_DEVICE/App/*, firmware/c_board/bsp/HAL/USB_DEVICE/Target/*
删除 ST USB Device 核心头/源(core/ctlreq/ioreq 等)、CDC 类、设备描述、usbd_conf、应用初始化、相关头文件及 LICENSE、并从 CMake 构建移除对应库。
HAL / OTG 集成变更
firmware/c_board/bsp/HAL/Core/Inc/usb_otg.h, firmware/c_board/bsp/HAL/Core/Src/usb_otg.c, firmware/c_board/bsp/HAL/Core/Src/main.c, firmware/c_board/bsp/HAL/Core/Src/stm32f4xx_it.c, firmware/c_board/bsp/HAL/.mxproject, firmware/c_board/bsp/HAL/rmcs_slave.ioc
新增 USB_OTG FS PCD 接口(usb_otg.*),main 改为调用 MX_USB_OTG_FS_PCD_Init;中断处理接入 tusb 中断路径;CubeMX/IOC 配置移除旧 USB_DEVICE 条目并启用 OTG_FS。
TinyUSB 配置与描述符
firmware/c_board/include/tusb_config.h, firmware/c_board/src/usb/usb_descriptors.hpp, firmware/c_board/src/usb/usb_descriptors.cpp
新增 tusb 配置头(仅启用 vendor 类、CFG_TUSB_OS=OPT_OS_NONE 等)和 UsbDescriptors 提供 device/config/string 描述符及序列号生成,并加入 C 链接回调。
应用层与 Vendor 重构
firmware/c_board/src/app.cpp, firmware/c_board/src/usb/vendor.hpp, firmware/c_board/src/usb/vendor.cpp, firmware/c_board/src/usb/wcid.cpp
在主循环前调用 tud_task();用 TinyUSB 回调(tud_vendor_rx_cb、tud_mount/suspend/resume/umount)替换原 ST CDC 回调;Vendor 类改造以使用 tud_ready/tud_vendor_* 接口并内置发送逻辑;删除 WCID 支撑文件。
中断安全缓冲与主机端节流
firmware/c_board/src/usb/interrupt_safe_buffer.hpp, firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp, host/src/transport/usb.cpp
为中断安全缓冲添加 try_lock/try_unlock_and_clear/clear 与锁标志,allocate 在锁定时早退;主机端 USB 接收回调增加 1s 节流。
CubeMX 构建脚本调整
firmware/c_board/bsp/HAL/cmake/stm32cubemx/CMakeLists.txt
移除 USB_Device_Library 构建与引用,调整 STM32 驱动与应用源(替换为 usb_otg.c、sysmem/syscalls/startup 等)。

Sequence Diagram(s)

sequenceDiagram
    participant Main as Main Loop\nfirmware/c_board/src/app.cpp
    participant TinyUSB as TinyUSB Stack\n(tinyusb)
    participant DWC2 as DWC2 Driver\n(dcd_dwc2.c)
    participant HAL as STM32 HAL\n(USB OTG FS)
    participant Vendor as Vendor Handler\nfirmware/c_board/src/usb/vendor.cpp

    Main->>TinyUSB: tud_task()
    activate TinyUSB
    TinyUSB->>DWC2: 调度/触发端点处理
    activate DWC2
    DWC2->>HAL: 硬件访问 / 中断上下文
    HAL-->>DWC2: 中断/状态
    DWC2-->>TinyUSB: 中断处理完成
    deactivate DWC2
    alt 接收数据
        TinyUSB->>Vendor: tud_vendor_rx_cb(buffer, size)
        activate Vendor
        Vendor->>Vendor: handle_downlink(数据段, finished)
        deactivate Vendor
    end
    alt 事件(挂载/挂起/恢复/卸载)
        TinyUSB->>Vendor: tud_mount_cb / tud_suspend_cb / tud_resume_cb / tud_umount_cb
        Vendor->>Vendor: 更新状态
    end
    deactivate TinyUSB
Loading

Estimated code review effort

🎯 4 (复杂) | ⏱️ ~60 分钟

Possibly related PRs

Poem

🐇 我是小兔搬新家,tinyusb 树下搭草棚;
旧库收箱细心放,回调换了新领衔。
tud_task 每圈来巡视,数据小邮差欢然。
中断安稳缓冲清,Vendor 翻开焕然篇。

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题清晰准确地反映了主要变更:将USB堆栈从ST HAL CDC迁移到TinyUSB,涵盖了变更集的核心目标。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev/cboard-tinyusb

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
firmware/c_board/bsp/HAL/rmcs_slave.ioc (1)

232-232: ⚠️ Potential issue | 🟡 Minor

ProjectManager.functionlistsort 包含已删除的 MX_USB_DEVICE_Init 条目

USB_DEVICE 外设已在本 PR 中移除,但第 232 行 functionlistsort 仍保留了 4-MX_USB_DEVICE_Init-USB_DEVICE-false-HAL-false。下次在 CubeMX 中重新打开此 .ioc 文件时,该陈旧条目可能导致代码重新生成行为异常。

🐛 建议修复
-ProjectManager.functionlistsort=1-SystemClock_Config-RCC-false-HAL-false,2-MX_GPIO_Init-GPIO-false-HAL-true,3-MX_SPI1_Init-SPI1-false-HAL-true,4-MX_USB_DEVICE_Init-USB_DEVICE-false-HAL-false,5-MX_CAN1_Init-CAN1-false-HAL-true,6-MX_CAN2_Init-CAN2-false-HAL-true,7-MX_USART1_UART_Init-USART1-false-HAL-true,8-MX_USART3_UART_Init-USART3-false-HAL-true,9-MX_USART6_UART_Init-USART6-false-HAL-true,10-MX_TIM5_Init-TIM5-false-HAL-true
+ProjectManager.functionlistsort=1-SystemClock_Config-RCC-false-HAL-false,2-MX_GPIO_Init-GPIO-false-HAL-true,3-MX_SPI1_Init-SPI1-false-HAL-true,4-MX_USB_OTG_FS_PCD_Init-USB_OTG_FS-false-HAL-true,5-MX_CAN1_Init-CAN1-false-HAL-true,6-MX_CAN2_Init-CAN2-false-HAL-true,7-MX_USART1_UART_Init-USART1-false-HAL-true,8-MX_USART3_UART_Init-USART3-false-HAL-true,9-MX_USART6_UART_Init-USART6-false-HAL-true,10-MX_TIM5_Init-TIM5-false-HAL-true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/c_board/bsp/HAL/rmcs_slave.ioc` at line 232, Remove the stale
MX_USB_DEVICE_Init entry from the ProjectManager.functionlistsort string: delete
the segment "4-MX_USB_DEVICE_Init-USB_DEVICE-false-HAL-false" and adjust the
indices of the following entries (e.g., 5→4, 6→5, etc.) so the sequence remains
contiguous; ensure the remaining entries (e.g., MX_CAN1_Init, MX_CAN2_Init,
MX_USART1_UART_Init, MX_USART3_UART_Init, MX_USART6_UART_Init, MX_TIM5_Init)
keep their original subsystem/tags and HAL flags unchanged.
🧹 Nitpick comments (2)
firmware/c_board/src/usb/usb_descriptors.hpp (1)

34-64: get_string_descriptor 非线程安全 — 在裸机单任务环境下可接受,但值得记录。

descriptor_string_buffer_ 是就地修改的共享成员。当前裸机环境中 TinyUSB 描述符回调只在 tud_task() 上下文调用,不存在竞争。如果未来引入 RTOS 或 ISR 中调用此函数,将产生数据竞争。建议加一行注释说明此假设。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/c_board/src/usb/usb_descriptors.hpp` around lines 34 - 64,
get_string_descriptor mutates shared member descriptor_string_buffer_ and is not
thread-safe; add a clear comment in the get_string_descriptor function
explaining the assumption that this callback is only invoked from the
single-threaded tinyusb context (tud_task()) and that concurrent access from
RTOS tasks or ISRs would cause data races; mention descriptor_string_buffer_,
serial_string_, and TinyUSB/tud_task() to help future maintainers locate the
safe-context assumption and consider protecting or copying the buffer if the
code is later called from multiple contexts.
firmware/c_board/src/usb/vendor.hpp (1)

99-105: 加速度计和陀螺仪回调留空 — 确认是否需要后续实现。

当前为空实现(no-op),如果后续需要从主机侧下发 IMU 配置,需要补充。建议添加 // TODO 注释标记意图。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/c_board/src/usb/vendor.hpp` around lines 99 - 105, The two empty
no-op callbacks accelerometer_deserialized_callback(...) and
gyroscope_deserialized_callback(...) should be annotated to show the no-op is
intentional and to guide future work: add a TODO comment inside each function
noting that IMU configuration from the host is not yet implemented and, if
applicable, outline the intended behavior (e.g., parse the data and forward to
IMU config handler or update settings via
set_imu_config()/apply_imu_settings()); this makes intent explicit and reminds
future contributors to implement the handling when host-side IMU config support
is added.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.gitmodules:
- Around line 4-6: The submodule entry for firmware/c_board/bsp/tinyusb points
to a personal fork (url = https://github.com/qzhhhi/tinyusb) which creates
supply-chain risk; update the .gitmodules entry for the submodule at path
"firmware/c_board/bsp/tinyusb" to point to the upstream official repository
(e.g., https://github.com/hathach/tinyusb) or, if the fork contains necessary
hardware-specific patches, restore the submodule to upstream and add a
PATCHES.md (or note in README adjacent to "firmware/c_board/bsp/tinyusb") that
lists the exact commits/patches and rationale and include a plan/links for
upstreaming those patches (submit a PR to hathach/tinyusb).

In `@firmware/c_board/bsp/tinyusb`:
- Line 1: The repo's tinyusb submodule points to a personal fork URL in
.gitmodules at commit bb24b0c without documentation; either switch the submodule
remote to the official upstream (https://github.com/hathach/tinyusb.git) and
reference a stable upstream tag (e.g., 0.19.0) while keeping project-specific
config in firmware/c_board/include/tusb_config.h, or document the fork rationale
and diffs by adding a TINYUSB_FORK_NOTES.md under firmware/c_board that lists
the upstream base commit/tag, the forked remote URL, and a concise list of code
patches (files/functions affected) that require the fork (include commit SHAs
and why they are necessary) so reviewers and future maintainers can trace and
merge upstream fixes.

In `@firmware/c_board/src/usb/vendor.cpp`:
- Around line 24-31: tud_umount_cb is currently empty but must tell the Vendor
to clear in-progress transfers; add a call in tud_umount_cb to a Vendor method
(e.g., Vendor::ResetTransmission or Vendor::OnUsbUnmount) that atomically clears
transmitting_batch_ and sets transmitted_size_ to 0 (and releases any batch
resources), and ensure this method is safe to call while
device_ready()/tud_ready() may return false after unmount. Locate the Vendor
class that owns transmitting_batch_ and transmitted_size_ and implement the
reset method and invoke it from tud_umount_cb.

---

Outside diff comments:
In `@firmware/c_board/bsp/HAL/rmcs_slave.ioc`:
- Line 232: Remove the stale MX_USB_DEVICE_Init entry from the
ProjectManager.functionlistsort string: delete the segment
"4-MX_USB_DEVICE_Init-USB_DEVICE-false-HAL-false" and adjust the indices of the
following entries (e.g., 5→4, 6→5, etc.) so the sequence remains contiguous;
ensure the remaining entries (e.g., MX_CAN1_Init, MX_CAN2_Init,
MX_USART1_UART_Init, MX_USART3_UART_Init, MX_USART6_UART_Init, MX_TIM5_Init)
keep their original subsystem/tags and HAL flags unchanged.

---

Nitpick comments:
In `@firmware/c_board/src/usb/usb_descriptors.hpp`:
- Around line 34-64: get_string_descriptor mutates shared member
descriptor_string_buffer_ and is not thread-safe; add a clear comment in the
get_string_descriptor function explaining the assumption that this callback is
only invoked from the single-threaded tinyusb context (tud_task()) and that
concurrent access from RTOS tasks or ISRs would cause data races; mention
descriptor_string_buffer_, serial_string_, and TinyUSB/tud_task() to help future
maintainers locate the safe-context assumption and consider protecting or
copying the buffer if the code is later called from multiple contexts.

In `@firmware/c_board/src/usb/vendor.hpp`:
- Around line 99-105: The two empty no-op callbacks
accelerometer_deserialized_callback(...) and
gyroscope_deserialized_callback(...) should be annotated to show the no-op is
intentional and to guide future work: add a TODO comment inside each function
noting that IMU configuration from the host is not yet implemented and, if
applicable, outline the intended behavior (e.g., parse the data and forward to
IMU config handler or update settings via
set_imu_config()/apply_imu_settings()); this makes intent explicit and reminds
future contributors to implement the handling when host-side IMU config support
is added.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
host/src/transport/usb.cpp (1)

425-433: 建议仅在收到实际数据时更新时间戳,以避免 ZLP 重置间隔计时器

当前实现对每次 USB 回调(包括 actual_length == 0 的零长度包)都更新 last_rx_callback_timepoint_。若设备固件复位期间 USB 栈仍生成 ZLP(例如 bulk 端点的流控行为),这些空包会持续重置计时窗口,导致固件恢复后首个真实数据包的间隔 < 1 秒,从而绕过丢弃逻辑——正好是该机制想要防止的情况。

将时间戳更新移至 actual_length > 0 分支内,可使计时器精确反映「上次收到有效数据的时刻」:

♻️ 建议改法
-        const auto now = std::chrono::steady_clock::now();
-        const bool should_drop = now > last_rx_callback_timepoint_ + std::chrono::seconds{1};
-        last_rx_callback_timepoint_ = now;
-
-        if (!should_drop && transfer->actual_length > 0) {
+        if (transfer->actual_length > 0) {
+            const auto now = std::chrono::steady_clock::now();
+            const bool should_drop = now > last_rx_callback_timepoint_ + std::chrono::seconds{1};
+            last_rx_callback_timepoint_ = now;
+
+            if (!should_drop) {
                 const auto* first = reinterpret_cast<std::byte*>(transfer->buffer);
                 const auto size = static_cast<std::size_t>(transfer->actual_length);
                 receive_callback_({first, size});
-        }
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@host/src/transport/usb.cpp` around lines 425 - 433, The timestamp update
currently happens for every USB callback which lets zero-length packets
(transfer->actual_length == 0) reset last_rx_callback_timepoint_ and defeat the
drop logic; change the logic so you compute should_drop using
last_rx_callback_timepoint_ as-is, but only assign last_rx_callback_timepoint_ =
now inside the branch that handles actual data (i.e., when
transfer->actual_length > 0) before calling receive_callback_; do not update
last_rx_callback_timepoint_ for ZLPs so the interval reflects the last received
valid data and the existing should_drop check remains correct.
firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp (2)

25-28: 启动时 is_locked_ 初始值为 true(按当前语义即"可分配"),ISR 在首次 try_transmit() 锁定之前可能写入陈旧数据。

缓冲区初始化后即接受分配,但 USB 尚未就绪。在主循环首次调用 try_transmit() 之前,CAN ISR 可能已经向缓冲区写入数据。虽然后续 try_unlock_and_clear() 会清除这些陈旧数据,但如果缓冲区在启动期间被写满(kBatchCount - 1 个 batch),新的 ISR 数据会被静默丢弃。

如果这是预期行为,建议加个简短注释说明启动时的状态语义。如果不希望启动时接受分配,可将初始值改为 false(即锁定/拒绝状态),等 USB 就绪后再解锁。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp` around lines 25 - 28,
The buffer accepts allocations at startup because is_locked_ is initialized to
allow allocation; to prevent ISR writing stale data before USB is ready,
initialize is_locked_ to the locked state (so allocate() returns {} until USB is
ready) and only set it to unlocked after USB is ready (e.g. when
try_transmit()/USB init path calls the unlock or try_unlock_and_clear()); if the
current behavior is intentional instead, add a short comment near is_locked_ and
allocate() explaining the startup semantic and the kBatchCount-1 overflow risk
so reviewers understand why allocations are allowed during init.

123-136: is_locked_ 布尔语义与名称相反,极易引起误解。

当前 is_locked_true 时,allocate() 正常工作(即缓冲区处于可用/未锁定状态);is_locked_false 时,allocate() 拒绝分配(即缓冲区处于锁定状态)。这与字段名 is_locked_ 的直觉含义完全相反——读者会自然地认为 is_locked_ == true 表示"已锁定"。

建议将字段重命名为 is_accepting_ 或翻转布尔极性,使 is_locked_ == true 真正表示锁定(拒绝分配)。

♻️ 建议:翻转布尔语义使名称与行为一致
-    std::atomic<bool> is_locked_{true};
+    std::atomic<bool> is_locked_{false};

同步修改 allocate()

-        if (!is_locked_.load(std::memory_order::relaxed))
+        if (is_locked_.load(std::memory_order::relaxed))
             return {};

try_lock()

-    void try_lock() { is_locked_.store(false, std::memory_order::relaxed); }
+    void try_lock() { is_locked_.store(true, std::memory_order::relaxed); }

try_unlock_and_clear()

     void try_unlock_and_clear() {
-        if (is_locked_.load(std::memory_order::relaxed))
+        if (!is_locked_.load(std::memory_order::relaxed))
             return;
         clear();
-        is_locked_.store(true, std::memory_order::relaxed);
+        is_locked_.store(false, std::memory_order::relaxed);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp` around lines 123 -
136, The boolean field is_locked_ has inverted semantics (true means available)
which is confusing; change it to a positively named flag (e.g., is_accepting_)
or invert its polarity so true means "locked"/not accepting. Update the field
name and all uses in allocate(), try_lock(), and try_unlock_and_clear():
initialize the flag to the correct default, flip/store the boolean values passed
in those methods (use the same memory_order semantics), and adjust the
conditional in try_unlock_and_clear() (and any allocate() checks) so the logic
reads naturally (true => locked/refuse allocation, false => accepting). Also
update the comment in try_unlock_and_clear() to reflect the new flag meaning and
ensure clear() behavior remains the same.
firmware/rmcs_board/src/usb/vendor.hpp (1)

69-79: 删除关键字段的 assert_debug 调用不会阻止 USB 写入操作在 release 构建中执行。

assert_debug 宏在 release 模式下仅使用 [[assume(condition)]] 提示编译器,但不会阻止函数参数的求值。在 assert_debug(tud_vendor_n_write(0, src, target_size) == target_size) 中,tud_vendor_n_write() 的调用会在 assert_debug 函数被调用之前执行,因此数据会被正常发送。

不过,将副作用与断言混合仍然是不良实践。建议分离调用和断言以提高代码清晰度:

建议改进:分离副作用与断言
     if (target_size) {
         const auto* src = reinterpret_cast<const uint8_t*>(data.data() + transmitted_size_);
-        core::utility::assert_debug(tud_vendor_n_write(0, src, target_size) == target_size);
+        auto sent = tud_vendor_n_write(0, src, target_size);
+        core::utility::assert_debug(sent == target_size);
     } else {
-        core::utility::assert_debug(tud_vendor_n_write_zlp(0));
+        [[maybe_unused]] auto ok = tud_vendor_n_write_zlp(0);
+        core::utility::assert_debug(ok);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/rmcs_board/src/usb/vendor.hpp` around lines 69 - 79, The
assert_debug currently wraps calls with side effects (tud_vendor_n_write and
tud_vendor_n_write_zlp) so those functions execute even in release builds;
separate the side-effect calls from the assertions: compute max_packet_size and
target_size as you already do, then if target_size > 0 call
tud_vendor_n_write(0, src, target_size) into a local variable (e.g. written =
tud_vendor_n_write(...)) and assert_debug(written == target_size); otherwise
call tud_vendor_n_write_zlp(0) into a local bool/result and assert_debug(result)
— this ensures the write is invoked explicitly and the assertion only checks the
result without hiding side effects inside assert_debug and makes the logic
around transmitting_batch_, transmitted_size_, and tud_speed_get/TUSB_SPEED_HIGH
clearer.
firmware/c_board/src/usb/interrupt_safe_buffer.hpp (1)

30-31: is_locked_ 的语义与变量名称相反,try_lock() 命名违反 C++ 惯例

存在两个命名问题:

  1. is_locked_ 语义倒置:变量初始化为 true(Line 137),allocate()!is_locked_ 时拒绝写入(Lines 30–31),try_lock() 存入 false(Line 124),try_unlock_and_clear() 存入 true 表示"解锁"(Line 133)。也就是说:

    • is_locked_ == false → 缓冲区处于锁定状态(ISR 无法写入)
    • is_locked_ == true → 缓冲区处于活跃状态(ISR 可写入)

    变量名暗示 true = 锁定,但实际语义恰好相反。建议改名为 is_active_accepts_allocations_,或反转内部逻辑使 true 对应锁定状态。

  2. try_lock() 违反 C++ 命名惯例:标准库(如 std::mutex::try_lock())中 try_lock() 返回 bool 以表示是否成功。此函数始终成功且返回 void,建议直接命名为 lock()。注意 try_unlock_and_clear() 保留 try_ 前缀是合理的(因为它在 is_locked_ 为真时会提前返回),但与 try_lock() 放在一起会造成语义上的不一致。

♻️ 建议的重命名方案
-    std::atomic<bool> is_locked_{true};
+    std::atomic<bool> is_active_{true};
     std::span<std::byte> allocate(size_t size) noexcept override {
         core::utility::assert_debug(size <= core::protocol::kProtocolBufferSize);
-        if (!is_locked_.load(std::memory_order::relaxed))
+        if (!is_active_.load(std::memory_order::relaxed))
             return {};
-    void try_lock() { is_locked_.store(false, std::memory_order::relaxed); }
+    void lock() { is_active_.store(false, std::memory_order::relaxed); }
     void try_unlock_and_clear() {
-        if (is_locked_.load(std::memory_order::relaxed))
+        if (is_active_.load(std::memory_order::relaxed))
             return;
         clear();
-        is_locked_.store(true, std::memory_order::relaxed);
+        is_active_.store(true, std::memory_order::relaxed);
     }

Also applies to: 124-124, 137-137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/c_board/src/usb/interrupt_safe_buffer.hpp` around lines 30 - 31, The
is_locked_ flag's polarity and the name of try_lock() are misleading: is_locked_
is initialized true but treated as "active/accepting allocations" when true and
"locked" when false; change the flag to a positively named boolean (e.g.,
is_active_ or accepts_allocations_) or invert its logic so true means locked,
and update all uses in allocate(), try_lock(), and try_unlock_and_clear()
accordingly; also rename try_lock() to lock() (since it always succeeds and
returns void) while keeping try_unlock_and_clear() as-is, and update any
initialization site and all references to the old names to preserve correct
semantic behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@firmware/c_board/src/usb/vendor.hpp`:
- Around line 67-72: The USB write calls are executed inside
core::utility::assert_debug which may be disabled in release builds, so extract
the side-effectful calls out of the assert: call tud_vendor_n_write(0, src,
target_size) (using src = reinterpret_cast<const uint8_t*>(data.data() +
transmitted_size_)) when target_size is non-zero and call
tud_vendor_n_write_zlp(0) otherwise, store their return values in a local
variable (e.g., wrote or result), and then use
core::utility::assert_debug(result == target_size) or
core::utility::assert_debug(result) to assert success; keep the existing
variables transmitted_size_, data, target_size and functions tud_vendor_n_write
/ tud_vendor_n_write_zlp in your changes.

---

Nitpick comments:
In `@firmware/c_board/src/usb/interrupt_safe_buffer.hpp`:
- Around line 30-31: The is_locked_ flag's polarity and the name of try_lock()
are misleading: is_locked_ is initialized true but treated as "active/accepting
allocations" when true and "locked" when false; change the flag to a positively
named boolean (e.g., is_active_ or accepts_allocations_) or invert its logic so
true means locked, and update all uses in allocate(), try_lock(), and
try_unlock_and_clear() accordingly; also rename try_lock() to lock() (since it
always succeeds and returns void) while keeping try_unlock_and_clear() as-is,
and update any initialization site and all references to the old names to
preserve correct semantic behavior.

In `@firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp`:
- Around line 25-28: The buffer accepts allocations at startup because
is_locked_ is initialized to allow allocation; to prevent ISR writing stale data
before USB is ready, initialize is_locked_ to the locked state (so allocate()
returns {} until USB is ready) and only set it to unlocked after USB is ready
(e.g. when try_transmit()/USB init path calls the unlock or
try_unlock_and_clear()); if the current behavior is intentional instead, add a
short comment near is_locked_ and allocate() explaining the startup semantic and
the kBatchCount-1 overflow risk so reviewers understand why allocations are
allowed during init.
- Around line 123-136: The boolean field is_locked_ has inverted semantics (true
means available) which is confusing; change it to a positively named flag (e.g.,
is_accepting_) or invert its polarity so true means "locked"/not accepting.
Update the field name and all uses in allocate(), try_lock(), and
try_unlock_and_clear(): initialize the flag to the correct default, flip/store
the boolean values passed in those methods (use the same memory_order
semantics), and adjust the conditional in try_unlock_and_clear() (and any
allocate() checks) so the logic reads naturally (true => locked/refuse
allocation, false => accepting). Also update the comment in
try_unlock_and_clear() to reflect the new flag meaning and ensure clear()
behavior remains the same.

In `@firmware/rmcs_board/src/usb/vendor.hpp`:
- Around line 69-79: The assert_debug currently wraps calls with side effects
(tud_vendor_n_write and tud_vendor_n_write_zlp) so those functions execute even
in release builds; separate the side-effect calls from the assertions: compute
max_packet_size and target_size as you already do, then if target_size > 0 call
tud_vendor_n_write(0, src, target_size) into a local variable (e.g. written =
tud_vendor_n_write(...)) and assert_debug(written == target_size); otherwise
call tud_vendor_n_write_zlp(0) into a local bool/result and assert_debug(result)
— this ensures the write is invoked explicitly and the assertion only checks the
result without hiding side effects inside assert_debug and makes the logic
around transmitting_batch_, transmitted_size_, and tud_speed_get/TUSB_SPEED_HIGH
clearer.

In `@host/src/transport/usb.cpp`:
- Around line 425-433: The timestamp update currently happens for every USB
callback which lets zero-length packets (transfer->actual_length == 0) reset
last_rx_callback_timepoint_ and defeat the drop logic; change the logic so you
compute should_drop using last_rx_callback_timepoint_ as-is, but only assign
last_rx_callback_timepoint_ = now inside the branch that handles actual data
(i.e., when transfer->actual_length > 0) before calling receive_callback_; do
not update last_rx_callback_timepoint_ for ZLPs so the interval reflects the
last received valid data and the existing should_drop check remains correct.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp (1)

123-134: try_unlock_and_clear()is_locked_.clear(relaxed) 可能因编译器重排导致 ISR 看到不一致状态。

clear() 内部使用了 atomic_signal_fence(release) 来防止 batch 的 reset() 操作被重排到 out_.store() 之后。但 release fence 只阻止之前的操作越过 fence,不阻止之后的操作(即 is_locked_.clear())被移到 fence 之前。

在理论上,编译器可以将第 132 行的 is_locked_.clear(relaxed) 重排到 clear() 内部的 batch reset() 之前。如果此时 ISR 触发,allocate() 会看到 is_locked_ 已清除,但 batch 尚未完全重置,可能导致写入脏数据。

建议在 is_locked_.clear() 前增加一个 signal fence,与文件中已有的模式保持一致:

🛡️ 建议修改
     bool try_unlock_and_clear() {
         if (!is_locked_.test(std::memory_order::relaxed))
             return false;

         // Unlocking drops stale queued batches from the last not-ready cycle before
         // new ISR writes are accepted.
         clear();
+        std::atomic_signal_fence(std::memory_order_release);
         is_locked_.clear(std::memory_order::relaxed);
         return true;
     }

注:clear() 内部的 fence 保护的是 reset()out_.store() 的顺序,而此处需要额外保证 clear() 的所有副作用在 is_locked_.clear() 之前对 ISR 可见。在单核裸机环境下,GCC/Clang 实践中不太可能做如此激进的重排,但从 C++ 内存模型的角度来看,加一个 fence 更严谨。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp` around lines 123 -
134, In try_unlock_and_clear(), prevent compiler reordering that could move
is_locked_.clear(std::memory_order::relaxed) before clear()'s reset by inserting
an atomic_signal_fence(std::memory_order::release) immediately before calling
is_locked_.clear(); this mirrors the fence used inside clear() and ensures
clear()'s side-effects (reset() → out_.store()) are observed by the ISR before
the lock bit is cleared, protecting allocate() from seeing an unlocked flag
while batch reset is still in progress; update the try_unlock_and_clear()
function to call atomic_signal_fence(release) then is_locked_.clear(relaxed).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp`:
- Around line 123-134: In try_unlock_and_clear(), prevent compiler reordering
that could move is_locked_.clear(std::memory_order::relaxed) before clear()'s
reset by inserting an atomic_signal_fence(std::memory_order::release)
immediately before calling is_locked_.clear(); this mirrors the fence used
inside clear() and ensures clear()'s side-effects (reset() → out_.store()) are
observed by the ISR before the lock bit is cleared, protecting allocate() from
seeing an unlocked flag while batch reset is still in progress; update the
try_unlock_and_clear() function to call atomic_signal_fence(release) then
is_locked_.clear(relaxed).

@qzhhhi qzhhhi force-pushed the dev/cboard-tinyusb branch from 35cdb42 to ae6be2b Compare February 21, 2026 14:06
Replace the ST USB Device Library (CDC middleware) with TinyUSB, following the migration path outlined in commit 2d6076d.

What changed
- Added TinyUSB as a git submodule (DWC2 portable driver for STM32F4 USB OTG FS) and integrated it into the CMake build.
- Removed the entire ST USB Device Library middleware and USB_DEVICE application layer (~6500 lines deleted).
- Reconfigured CubeMX to expose raw USB_OTG_FS PCD instead of the CDC device class; regenerated HAL accordingly.
- Rewrote USB descriptors as TinyUSB callbacks with vendor-class device identity (VID 0xA11C, PID 0xD401) and UID-derived serial.
- Rewrote vendor.cpp/hpp to use TinyUSB vendor class API (tud_vendor_n_write, tud_vendor_rx_cb) in direct mode.
- Redirected OTG_FS IRQ from HAL_PCD_IRQHandler to tusb_int_handler; added tud_task() to the main loop.

Not yet completed
- WCID / Microsoft OS Descriptor support is not wired in yet; Windows auto-recognition still requires follow-up work.
- No on-device regression testing has been performed.
To prevent residual data from entering the protocol layer during USB disconnects/reconnects, this commit introduces a "flush and drop" mechanism on both the firmware and host sides:

- Firmware: Locks the upstream buffer when USB is not ready. Upon recovery, it flushes the buffer before unlocking. This prevents stale batches queued during the disconnect window from being transmitted after reconnection.
- Host: Introduces a time-window check in the RX callback. After a connection or reconnection, it forces the dropping of the first received data block to ensure protocol-layer isolation.

This approach effectively prevents half/truncated packets and stale data from reaching the deserialization flow, with the acceptable trade-off of intentionally losing the first packet.

- InterruptSafeBuffer: add lock + clear + unlock flow; reject allocation while locked
- Vendor::try_transmit: split ready checks and trigger clear before resuming dequeue
- Host USB transport: track last RX callback time and drop the first callback after a long gap
@qzhhhi qzhhhi force-pushed the dev/cboard-tinyusb branch from ae6be2b to 4918f8c Compare February 21, 2026 14:08
@qzhhhi qzhhhi merged commit 4918f8c into main Feb 21, 2026
4 checks passed
@github-project-automation github-project-automation bot moved this from Todo to Done in RMCS Slave SDK Feb 21, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
host/src/transport/usb.cpp (1)

425-433: ⚠️ Potential issue | 🟠 Major

节流判断逻辑与预期语义相反,设备发送频率 ≤ 1 Hz 时将静默丢弃所有数据

当前逻辑:

const bool should_drop = now > last_rx_callback_timepoint_ + std::chrono::seconds{1};
  • should_drop = true 当距上次包超过 1 秒(即包到达时存在空闲间隔)→ 丢弃
  • !should_drop 时才调用 receive_callback_,意即只有包连续密集到达(间隔 < 1s)时才转发

这与通常的"节流"(限制回调调用的最高频率)完全相反。实际效果是:

  1. 启动时第一个包永远被丢弃(min() 初始化触发)
  2. 若设备每秒发包 ≤ 1 次,每次到来时距上次均超过 1 秒,所有包均被静默丢弃

若意图是过滤重连/启动后首个可能残缺的包(burst warm-up guard),请将判断条件翻转,并添加注释说明;若意图确实是"只在连续流中才处理数据",需评估 ≤ 1 Hz 的设备场景是否永久无数据。

此外,last_rx_callback_timepoint_should_drop == true 时依然更新,实际语义是"上次收到包的时间"而非"上次调用回调的时间",变量命名存在误导性。

🐛 若意图是过滤首包(保留后续数据流),建议修正
-        const bool should_drop = now > last_rx_callback_timepoint_ + std::chrono::seconds{1};
+        // Drop the very first packet after a reconnect/startup to discard potentially
+        // stale data; once packets arrive continuously (gap < 1 s) forward all of them.
+        const bool is_after_gap = now - last_rx_callback_timepoint_ > std::chrono::seconds{1};
         last_rx_callback_timepoint_ = now;

-        if (!should_drop && transfer->actual_length > 0) {
+        if (!is_after_gap && transfer->actual_length > 0) {

若意图是标准节流(至多 1 次/秒),则应在 should_drop == false 时不更新时间戳(或翻转整个判断逻辑):

-        const bool should_drop = now > last_rx_callback_timepoint_ + std::chrono::seconds{1};
-        last_rx_callback_timepoint_ = now;
-
-        if (!should_drop && transfer->actual_length > 0) {
+        // Rate-limit: invoke at most once per second.
+        if (transfer->actual_length > 0 &&
+            now - last_rx_callback_timepoint_ >= std::chrono::seconds{1}) {
+            last_rx_callback_timepoint_ = now;
             receive_callback_({first, size});
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@host/src/transport/usb.cpp` around lines 425 - 433, The throttle logic is
inverted and updates last_rx_callback_timepoint_ even when dropping packets;
change the condition so we drop when the time since last callback is less than
1s (i.e. allow callback only when now >= last_rx_callback_timepoint_ + 1s), only
update last_rx_callback_timepoint_ when you actually call receive_callback_, and
add a brief comment on the intended semantics (throttle to max 1 callback/sec
or, if you meant to drop only the very first warm-up packet, implement that
explicit check instead); refer to last_rx_callback_timepoint_, should_drop (or
remove it), receive_callback_, and transfer->actual_length to locate and fix the
code.
🧹 Nitpick comments (5)
firmware/c_board/src/usb/vendor.hpp (1)

112-112: 考虑为协议错误提供更宽松的恢复路径

error_callback 会在反序列化器遇到任何协议错误(例如宿主端发送的格式错误帧、包损坏)时被调用。当前实现直接调用 assert_failed_always(),会无条件停机。基于项目学习记录中"调试断言应仅用于捕获内部逻辑错误和不变式违反,不应用于运行时可能出现的输入验证场景"的原则,协议错误属于运行时事件,建议在错误时重置反序列化器状态而不是直接 assert-halt,以提升固件健壮性。

♻️ 可选的容错实现
-    void error_callback() override { core::utility::assert_failed_always(); }
+    void error_callback() override {
+        // 协议错误为运行时事件,重置解析器状态以等待下一个合法帧
+        deserializer_ = core::protocol::Deserializer{*this};
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/c_board/src/usb/vendor.hpp` at line 112, error_callback currently
calls core::utility::assert_failed_always(), which unconditionally halts on any
protocol/deserialization error; change it to implement a relaxed recovery path:
inside error_callback (the override in the class that implements the
deserializer callback), clear or reset the deserializer state (e.g., reset
parser indices/buffers), optionally increment a protocol error counter and emit
a runtime log/event, and return without asserting so the firmware continues
running and can recover from malformed/partial frames; locate the error_callback
override and replace the assert call with calls to the deserializer reset
routine (or implement one if missing), logging, and a harmless return.
firmware/c_board/src/usb/interrupt_safe_buffer.hpp (1)

126-135: try_unlock_and_clear()test()clear() 非原子组合——当前安全但脆弱。

is_locked_.test() (line 127) 和 is_locked_.clear() (line 133) 之间不是原子操作。在当前裸机单线程主循环模型下是安全的(try_locktry_unlock_and_clear 都只在主线程调用),但如果将来引入 RTOS 或多个调用者,这里会产生竞态。建议添加简短注释说明此前提。

建议添加注释
     bool try_unlock_and_clear() {
+        // Caller contract: must only be called from the main thread (no concurrent callers).
         if (!is_locked_.test(std::memory_order::relaxed))
             return false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/c_board/src/usb/interrupt_safe_buffer.hpp` around lines 126 - 135,
The calls is_locked_.test(...) and is_locked_.clear(...) inside
try_unlock_and_clear() are not atomic together and rely on the current
single-threaded main-loop assumption; add a concise comment in
try_unlock_and_clear() (referencing is_locked_.test, clear, is_locked_.clear and
try_lock) stating that this non-atomic test/clear sequence is safe only because
try_lock and try_unlock_and_clear are called exclusively from the main thread
(no RTOS/ISR concurrency), and note that if RTOS or multi-caller use is
introduced the sequence must be made atomic (e.g., by using atomic
compare_exchange or a mutex) to avoid races.
firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp (1)

16-141: 两个板级 InterruptSafeBuffer 近乎完全重复,建议提取共享实现。

firmware/c_board/src/usb/interrupt_safe_buffer.hpp 和本文件的 InterruptSafeBuffer 约 95% 代码相同(包括本次新增的 clear()try_lock()try_unlock_and_clear()),唯一差异在于 buffer-full 时的通知钩子(c_board 调用 led::led->uplink_buffer_full(),rmcs_board 留了 TODO)。

可以考虑将通用逻辑提取到一个共享基类或 CRTP/策略模板中,通过模板参数或虚函数注入板级差异(通知钩子),避免同步维护两份几乎一致的代码。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp` around lines 16 - 141,
InterruptSafeBuffer is duplicated across boards—extract the common logic (class
InterruptSafeBuffer: allocate, Batch, pop_batch, release_batch, clear, try_lock,
try_unlock_and_clear, fields in_, out_, batches_, is_locked_) into a shared base
(either a non-virtual template/CRTP base or a concrete base class) and inject
the board-specific "buffer full" behavior via a small virtual hook or policy
template parameter (e.g., notify_buffer_full() or a Policy::on_buffer_full()).
Replace the two near-identical classes with thin board-specific
subclasses/templates that implement the hook (one calling
led::led->uplink_buffer_full(), the other no-op or TODO), keeping all existing
method names (allocate, clear, try_lock, try_unlock_and_clear, pop_batch, Batch,
release_batch) so callers need no changes.
firmware/rmcs_board/src/usb/vendor.hpp (2)

92-120: 回调分发逻辑中 default 分支直接触发 assert_failed_always() ——未知 ID 将导致硬停机。

can_deserialized_callback (line 99) 和 uart_deserialized_callback (line 108) 对未知 id 直接触发断言失败。在固件环境下,如果上位机发送了未知的 field ID(例如协议版本不匹配),设备会直接崩溃。

对于生产固件,建议至少考虑用日志/LED 指示替代硬停机,或者在非调试模式下忽略未知 ID。不过如果当前阶段还在开发调试中,这种"fail-fast"策略也是可以接受的。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/rmcs_board/src/usb/vendor.hpp` around lines 92 - 120,
can_deserialized_callback 和 uart_deserialized_callback 的 default 分支当前直接调用
assert_failed_always(),会因未知 field ID 导致设备硬停机;请改为在默认分支记录一条包含 id
的错误/警告日志并安全返回或忽略该事件(生产固件为忽略/记录,调试构建可保留 fail-fast 行为),也可以在必要时触发非致命指示(例如
LED)并复用现有的 error_callback 路径;参考并修改
can_deserialized_callback、uart_deserialized_callback 和可能的 error_callback,替换或包裹
assert_failed_always() 为基于构建宏(例如 NDEBUG)选择记录+返回或断言的实现。

53-89: try_transmit() 的锁-解锁-传输流程设计合理,有几点值得注意。

整体流程:

  1. 设备未就绪 → 锁定缓冲区阻止 ISR 写入 → 正确
  2. 端点无可用空间 → 直接返回 → 正确
  3. 解锁并清理陈旧数据 → 获取新 batch → 正确
  4. 分包发送 + ZLP 终结逻辑 → USB 传输语义正确

Line 55: try_lock() 的返回值被丢弃是合理的——意图是"确保已锁定",重复调用幂等(已锁定时返回 false,无副作用)。

Line 71: max_packet_size 每次调用都通过 tud_speed_get() 动态计算。虽然对支持 FULL/HIGH 双速的板子来说是必要的,但速度在枚举后不会改变。可以考虑在 mount 回调中缓存一次,避免每次传输重复查询(影响很小,纯建议)。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/rmcs_board/src/usb/vendor.hpp` around lines 53 - 89, The code
recomputes max_packet_size each time in try_transmit() by calling
tud_speed_get(); change this to compute and cache the max packet size once
during USB mount/initialization (e.g., in the USB mount callback such as
tud_mount_cb or your board-specific mount handler) into a member like
usb_max_packet_size_ (or cached_max_packet_size) and have try_transmit() use
that cached value instead of calling tud_speed_get() on each invocation; ensure
the cached value is set to 512 for TUSB_SPEED_HIGH and 64 otherwise and that it
is updated on re-enumeration if you handle that event.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@firmware/c_board/src/usb/vendor.hpp`:
- Around line 34-37: 在 Vendor 构造中直接调用 tusb_rhport_init(0, nullptr) 可以改为显式传入
&init_config 与 rmcs_board 风格保持一致;更重要的是实现 tud_umount_cb() 回调以在 USB 断开时清理传输状态:在
tud_umount_cb() 中将 transmitting_batch_ 置为 nullptr 并将 transmitted_size_
归零,或用等效清理函数重置这两个成员(确保与 Vendor() 中的初始化逻辑一致且不会引入竞态)。
- Around line 47-61: The try_transmit() path leaves transmitting_batch_ and
transmitted_size_ stale when tud_ready() is false, allowing old batch
pointers/offsets to be used after reconnect; fix by clearing state on
disconnect: in tud_umount_cb() reset transmitting_batch_ = nullptr and
transmitted_size_ = 0 (and ensure any held lock is released via
transmit_buffer_.try_unlock_and_clear()), or alternatively at the start of the
!tud_ready() branch in try_transmit() do the same reset and unlock so stale
batches cannot be reused after USB disconnect/reconnect.

---

Outside diff comments:
In `@host/src/transport/usb.cpp`:
- Around line 425-433: The throttle logic is inverted and updates
last_rx_callback_timepoint_ even when dropping packets; change the condition so
we drop when the time since last callback is less than 1s (i.e. allow callback
only when now >= last_rx_callback_timepoint_ + 1s), only update
last_rx_callback_timepoint_ when you actually call receive_callback_, and add a
brief comment on the intended semantics (throttle to max 1 callback/sec or, if
you meant to drop only the very first warm-up packet, implement that explicit
check instead); refer to last_rx_callback_timepoint_, should_drop (or remove
it), receive_callback_, and transfer->actual_length to locate and fix the code.

---

Duplicate comments:
In `@firmware/c_board/src/usb/vendor.hpp`:
- Around line 67-72: The USB write calls inside the conditional are
intentionally wrapped with core::utility::assert_debug and must be evaluated in
both debug and release; add a short inline comment above this block stating that
core::utility::assert_debug is a function (not a macro) and that
tud_vendor_n_write(0, src, target_size) and tud_vendor_n_write_zlp(0) have
required side effects and will be executed in all builds to avoid future
accidental refactors removing these calls (reference variables/functions:
target_size, src, transmitted_size_, core::utility::assert_debug,
tud_vendor_n_write, tud_vendor_n_write_zlp).

---

Nitpick comments:
In `@firmware/c_board/src/usb/interrupt_safe_buffer.hpp`:
- Around line 126-135: The calls is_locked_.test(...) and is_locked_.clear(...)
inside try_unlock_and_clear() are not atomic together and rely on the current
single-threaded main-loop assumption; add a concise comment in
try_unlock_and_clear() (referencing is_locked_.test, clear, is_locked_.clear and
try_lock) stating that this non-atomic test/clear sequence is safe only because
try_lock and try_unlock_and_clear are called exclusively from the main thread
(no RTOS/ISR concurrency), and note that if RTOS or multi-caller use is
introduced the sequence must be made atomic (e.g., by using atomic
compare_exchange or a mutex) to avoid races.

In `@firmware/c_board/src/usb/vendor.hpp`:
- Line 112: error_callback currently calls
core::utility::assert_failed_always(), which unconditionally halts on any
protocol/deserialization error; change it to implement a relaxed recovery path:
inside error_callback (the override in the class that implements the
deserializer callback), clear or reset the deserializer state (e.g., reset
parser indices/buffers), optionally increment a protocol error counter and emit
a runtime log/event, and return without asserting so the firmware continues
running and can recover from malformed/partial frames; locate the error_callback
override and replace the assert call with calls to the deserializer reset
routine (or implement one if missing), logging, and a harmless return.

In `@firmware/rmcs_board/src/usb/interrupt_safe_buffer.hpp`:
- Around line 16-141: InterruptSafeBuffer is duplicated across boards—extract
the common logic (class InterruptSafeBuffer: allocate, Batch, pop_batch,
release_batch, clear, try_lock, try_unlock_and_clear, fields in_, out_,
batches_, is_locked_) into a shared base (either a non-virtual template/CRTP
base or a concrete base class) and inject the board-specific "buffer full"
behavior via a small virtual hook or policy template parameter (e.g.,
notify_buffer_full() or a Policy::on_buffer_full()). Replace the two
near-identical classes with thin board-specific subclasses/templates that
implement the hook (one calling led::led->uplink_buffer_full(), the other no-op
or TODO), keeping all existing method names (allocate, clear, try_lock,
try_unlock_and_clear, pop_batch, Batch, release_batch) so callers need no
changes.

In `@firmware/rmcs_board/src/usb/vendor.hpp`:
- Around line 92-120: can_deserialized_callback 和 uart_deserialized_callback 的
default 分支当前直接调用 assert_failed_always(),会因未知 field ID 导致设备硬停机;请改为在默认分支记录一条包含 id
的错误/警告日志并安全返回或忽略该事件(生产固件为忽略/记录,调试构建可保留 fail-fast 行为),也可以在必要时触发非致命指示(例如
LED)并复用现有的 error_callback 路径;参考并修改
can_deserialized_callback、uart_deserialized_callback 和可能的 error_callback,替换或包裹
assert_failed_always() 为基于构建宏(例如 NDEBUG)选择记录+返回或断言的实现。
- Around line 53-89: The code recomputes max_packet_size each time in
try_transmit() by calling tud_speed_get(); change this to compute and cache the
max packet size once during USB mount/initialization (e.g., in the USB mount
callback such as tud_mount_cb or your board-specific mount handler) into a
member like usb_max_packet_size_ (or cached_max_packet_size) and have
try_transmit() use that cached value instead of calling tud_speed_get() on each
invocation; ensure the cached value is set to 512 for TUSB_SPEED_HIGH and 64
otherwise and that it is updated on re-enumeration if you handle that event.

Comment on lines +34 to +37
Vendor() {
usb::usb_descriptors.init();
core::utility::assert_always(tusb_rhport_init(0, nullptr));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for tusb_rhport_init usage and CFG_TUSB_RHPORT0_MODE configuration
echo "=== Searching for tusb_rhport_init declarations and usage ==="
rg -n "tusb_rhport_init" --type=c --type=h --type=cpp -C2

echo -e "\n=== Searching for CFG_TUSB_RHPORT0_MODE configuration ==="
rg -n "CFG_TUSB_RHPORT0_MODE" -C2

echo -e "\n=== Checking TinyUSB config header location ==="
fd "tusb_config" --type=h

echo -e "\n=== Examining vendor.hpp structure ==="
fd "vendor.hpp" -x wc -l {}

Repository: Alliance-Algorithm/librmcs

Length of output: 2486


🏁 Script executed:

#!/bin/bash
# Examine the target file to understand the full context
echo "=== Reading firmware/c_board/src/usb/vendor.hpp ==="
if [ -f "firmware/c_board/src/usb/vendor.hpp" ]; then
  wc -l "firmware/c_board/src/usb/vendor.hpp"
  cat -n "firmware/c_board/src/usb/vendor.hpp" | head -120
else
  echo "File not found at firmware/c_board/src/usb/vendor.hpp"
  fd "vendor.hpp" -x wc -l {}
  fd "vendor.hpp" | head -3 | xargs -I {} bash -c 'echo "File: {}"; wc -l {}'
fi

Repository: Alliance-Algorithm/librmcs

Length of output: 4918


🏁 Script executed:

#!/bin/bash
# Look for vendor.cpp and tud_umount_cb implementation
echo "=== Searching for tud_umount_cb callback ==="
rg -n "tud_umount_cb" --type=c --type=cpp -C5

echo -e "\n=== Searching for transmitting_batch_ reset/cleanup ==="
rg -n "transmitting_batch_" --type=c --type=cpp -C2

echo -e "\n=== Searching for tusb_init usage ==="
rg -n "tusb_init" --type=c --type=cpp -C2

Repository: Alliance-Algorithm/librmcs

Length of output: 4859


补充 tud_umount_cb() 实现以重置传输状态

第 36 行调用 tusb_rhport_init(0, nullptr) 虽然依赖编译期 CFG_TUSB_RHPORT0_MODE 配置且合法,但不如 rmcs_board 中显式传递 &init_config 参数清晰。更关键的问题是:vendor.cpp 中 tud_umount_cb() 回调为空实现,导致 USB 断开连接时 transmitting_batch_transmitted_size_ 无法重置。若 USB 在传输批次中途断开,重新连接后会从中断位置继续发送旧批次,造成数据损坏。应在 tud_umount_cb() 中添加:

transmitting_batch_ = nullptr;
transmitted_size_ = 0;

或通过其他机制确保 USB 断开时清理这两个状态变量。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/c_board/src/usb/vendor.hpp` around lines 34 - 37, 在 Vendor 构造中直接调用
tusb_rhport_init(0, nullptr) 可以改为显式传入 &init_config 与 rmcs_board 风格保持一致;更重要的是实现
tud_umount_cb() 回调以在 USB 断开时清理传输状态:在 tud_umount_cb() 中将 transmitting_batch_ 置为
nullptr 并将 transmitted_size_ 归零,或用等效清理函数重置这两个成员(确保与 Vendor() 中的初始化逻辑一致且不会引入竞态)。

Comment on lines +47 to +61
bool try_transmit() {
if (!tud_ready()) {
transmit_buffer_.try_lock();
return false;
}

if (!tud_vendor_n_write_available(0))
return false;

if (!transmitting_batch_) {
transmit_buffer_.try_unlock_and_clear();
transmitting_batch_ = transmit_buffer_.pop_batch();
}
if (!transmitting_batch_)
return false;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd -t f "vendor\.(hpp|cpp)" --type f

Repository: Alliance-Algorithm/librmcs

Length of output: 219


🏁 Script executed:

# Check the firmware/c_board/src/usb/vendor.hpp file around lines 47-61
cat -n firmware/c_board/src/usb/vendor.hpp | head -80

Repository: Alliance-Algorithm/librmcs

Length of output: 3115


🏁 Script executed:

# Continue reading the file to see class members and more context
tail -n +80 firmware/c_board/src/usb/vendor.hpp | head -50

Repository: Alliance-Algorithm/librmcs

Length of output: 1582


🏁 Script executed:

# Now check the vendor.cpp file for tud_umount_cb and other USB callbacks
cat -n firmware/c_board/src/usb/vendor.cpp

Repository: Alliance-Algorithm/librmcs

Length of output: 1096


🏁 Script executed:

# Check if there are any other USB callback handlers or state reset mechanisms
rg -n "transmitting_batch_|transmitted_size_|tud_umount_cb|tud_mount_cb" firmware/c_board/src/usb/ -A2 -B2

Repository: Alliance-Algorithm/librmcs

Length of output: 2790


🏁 Script executed:

# Check InterruptSafeBuffer implementation to understand the lifecycle
cat -n firmware/c_board/src/usb/interrupt_safe_buffer.hpp | head -100

Repository: Alliance-Algorithm/librmcs

Length of output: 3962


🏁 Script executed:

# Continue reading InterruptSafeBuffer to see full implementation
tail -n +100 firmware/c_board/src/usb/interrupt_safe_buffer.hpp

Repository: Alliance-Algorithm/librmcs

Length of output: 1514


🏁 Script executed:

# Check if there's any other mechanism that might reset transmitting_batch_ on USB state changes
rg -n "tud_mount_cb|tud_umount_cb|tud_suspend_cb|tud_resume_cb" firmware/c_board/src/usb/ -A5

Repository: Alliance-Algorithm/librmcs

Length of output: 699


USB 断开时未清理批次传输中间状态,重连后可能发送残旧数据

!tud_ready()(USB 断开)且 transmitting_batch_ 非空时,代码仅调用 transmit_buffer_.try_lock() 返回 false,但 transmitting_batch_transmitted_size_ 不会被重置。重连后,第 56 行 !transmitting_batch_ 判断为假,try_unlock_and_clear() 永不执行,导致旧批次指针和传输偏移量继续保留。try_transmit() 随后使用已过期的批次指针继续从旧偏移发送数据,可能将不完整或已被重用的缓冲区内容发送到主机。

firmware/c_board/src/usb/vendor.cpp 中的 tud_umount_cb() 为空实现,无法在 USB 断开时清理这两个成员变量。应在 tud_umount_cb() 中重置 transmitting_batch_nullptrtransmitted_size_0,或在 try_transmit() 首次进入 !tud_ready() 分支时直接清理。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@firmware/c_board/src/usb/vendor.hpp` around lines 47 - 61, The try_transmit()
path leaves transmitting_batch_ and transmitted_size_ stale when tud_ready() is
false, allowing old batch pointers/offsets to be used after reconnect; fix by
clearing state on disconnect: in tud_umount_cb() reset transmitting_batch_ =
nullptr and transmitted_size_ = 0 (and ensure any held lock is released via
transmit_buffer_.try_unlock_and_clear()), or alternatively at the start of the
!tud_ready() branch in try_transmit() do the same reset and unlock so stale
batches cannot be reused after USB disconnect/reconnect.

@qzhhhi qzhhhi deleted the dev/cboard-tinyusb branch February 21, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant