debugprobe 源码阅读

0x00 前言

笔者这几天在尝试 DIY 一个 DAPLink,所以来阅读一遍 raspberrypi/debugprobe 的源码。这个项目在 RP2040 上实现了 CMSIS-DAP 调试器,并提供虚拟串口。

在观察代码之前,我们有必要先了解一下 CMSIS-DAP 项目。我们的电脑上有 usb 接口,而单片机上有 swd 或 jtag 调试接口;CMSIS-DAP 即是将它们连接起来的桥梁。

CMSIS-DAP 本身是一种协议,不过 ARM-software/CMSIS-DAP 项目中也提供了一份参考实现。简而言之,一个完全实现了最新版 CMSIS-DAP 协议的调试器,可以提供如下功能:

  • 通过 swd 或 jtag 连接到目标芯片,访问 Cortex-A/R/M 的 CoreSight 寄存器。支持多核调试。
  • 支持 SWO。我们可以将其理解为 MCU 发往主机的单向串口,从而能通过 SWO 实现 printf。它的速度比 semihosting 更快(但是没有 SEGGER RTT 快)。
  • 免驱。CMSIS-DAP v1 使用 usb HID,消息传输的最小间隔是 1ms,拖慢了速度。CMSIS-DAP v2 使用 winusb。见 armbbs 讨论

CMSIS-DAP 有很多种软件实现,除了它自带的参考实现(CMSIS-DAP firmware,支持恩智浦单片机)之外,最著名的是 ARMmbed/DAPLink 项目,该项目是 CMSIS-DAP firmware 的后继项目,所以可以认为 DAPLink 目前是 CMSIS-DAP 的标准实现。它除了实现 CMSIS-DAP v1(usb HID)、CMSIS-DAP v2(winusb)之外,还提供了虚拟串口、拖拽烧录、WebUSB 功能。

我们的开发板一般只引出了 SWCLK 和 SWDIO 这两条调试线。那么,一个极简板的调试器,只需要维护 SWCLK 和 SWDIO 波形。事实上市售的绝大部分调试器都没有完整支持 CMSIS-DAP,而是只提供 swd 接口和虚拟串口。debugprobe 就是 CMSIS-DAP 的简版实现,不支持 jtag,也不支持 SWO。

0x01 背景:usb 和 swd 简述

debugprobe 的一端通过 usb 协议连接电脑,另一端通过 swd 协议连接单片机。我们先粗略了解这两个协议。


usb 协议参考资料:维基百科yooooooo 博客文章

usb 2.0 协议分为低速(Low-Speed,1.5 Mbps,亦称为 usb 1.0)、全速(Full-Speed,12 Mbps,亦称为 usb 1.1)、高速(High-Speed,480 Mbps)模式,通过差分线 D+/D- 传递信息,本文无需关心物理层细节。我们常用的单片机,很多都支持 FS 模式,例如 RP2040 和 STM32F103。

usb 2.0 协议是请求-响应式协议,只能由主机发起请求。每个设备可以有多个端点(endpoint),由逻辑管道(pipe)连接到主机。一个端点对应一条逻辑管道。单个设备至多拥有 16 个输入端点和 16 个输出端点。

pipe 分为 message pipe 和 stream pipe 两种。

  • message pipe 是双向管道,用于控制。每个设备的 0 号端点都是控制端点。
  • stream pipe 是单向管道,连接到单向端点。这类管道用于传输数据,有几种传输方式:
    - interrupt transfer(中断传输)。保证延迟上限。一般用于低延迟应用,例如鼠标、键盘,由主机轮询设备。
    - bulk transfer(批量传输)。尽可能快地传输大量数据,保证数据准确性,但不保证速率。
    - isochronous transfer(等时传输)。保证恒定速率,但可能会丢包。

usb 设备插入主机后的初始化过程称为枚举(enumeration)。在枚举过程中,主机会给设备分配 7 bit 的地址。


swd 协议参考资料:ADIv5ADIv6.0

我们首先观察 RP2040 的处理器结构图:

可以看到,DAP(Debug Access Port)外挂在 Cortex-M0+ 之外,每个核各有一个 DAP 连接到核内的调试接口。DAP 则通过 swd 协议与调试器相连。

ADIv5 手册中的 DAP 结构图:

一个 DAP 中拥有 DP 和 AP。其中 DP(Debug Port)连接到调试器,走 jtag 或 swd 协议(对应的 DP 实现分别称为 JTAG-DP 和 SW-DP):

DAP 中可以有多个 AP(Access Port)。ADIv5 手册中给出了 MEM-AP 和 JTAG-AP 两种选择。前者可以直连调试寄存器,也可以连接到系统总线。

▲ 图中有两个 MEM-AP,一个连接到系统总线,另一个连接到调试器

RP2040 的 DAP 可以访问整个地址空间。详情参考 ARMv6-M 手册中的 debug 部分

接下来我们分析 swd 协议,即调试器与 SW-DP 之间的通讯。每一次 swd 操作包含的步骤是:

  1. packet request,由调试器向 DP 发出请求
  2. acknowledge response,由 DP 返回 ACK 信号
  3. data transfer phase,传输数据。可能是 RDATA(从 DP 发往调试器)或 WDATA(从调试器发往 DP)。

下图展示了一次成功的 write 操作。它包含 8 bit 的 packet request、3 bit 的 ACK,以及 33 bit 的 data write(最后一个 bit 是奇偶校验位)。

对于成功的 read 操作,由主机发送 8 bit 的 packet request,由 DP 返回 ACK 和数据。

对于一次请求,DP 可以返回 WAIT。此时只发回 010 ACK 信号。

类似地,DP 可以返回 FAULT:

以上就是所有的数据传输报文类型。在初始化过程中,调试器会发送一个 line reset 信号,即连续 50 个高电平。协议规定,无论 DP 处于何种状态,只要收到 50 个连续的 1,它就应该重置。调试器在发送 line reset 之后,必须立即读取 IDCODE 寄存器。

寄存器清单:

0x02 通讯实例分析

我们已经了解调试器与 SW-DP 之间的通讯协议,不妨现在来监听一次通讯。调试器选用树莓派 debugprobe,目标 MCU 是 STM32H743VIT6。指令:

openocd -f interface/cmsis-dap.cfg -f target/stm32h7x.cfg

波形:

调试器首先发送了 line reset 信号,然后选择 swd 协议,然后再次发送 line reset 信号,接下来进入通讯流程。读取 IDCODE 寄存器,返回 0x6ba02477

现在,我们试试用 swd 协议读一个特定地址。打开 pyocd cmd,读取芯片的 unique ID(即从 0x1FF1E800 开始的 12 个字节):

$ pyocd cmd -t stm32h743vitx
Connected to STM32H743VITx [Running]: E6633861A352632C

pyocd> read8 0x1FF1E800 12
1ff1e800:  1f 00 2f 00  05 51 33 31  30 38 32 32                 |../..Q310822|

波形如下:

详细观察通讯过程。首先,调试器向 AP4 寄存器写入了我们要读取的地址 0x1ff1e800

接下来读取 APc 寄存器。有趣的是,读出来的值是一个旧值(笔者在 pyocd 中执行的上一条指令是 reg,得到 xpsr 的值是 0x21000000),而不是我们请求的值:

继续读取 APc 寄存器,读出 0x002f001f,这是我们请求的位置的前 4 字节:

继续读取 APc 寄存器,读出 0x31335105,是目标位置的中间 4 字节:

最后一次读取,读的不是 APc,而是 RDBUFF。返回了最后 4 字节:

详细的 swd 通讯过程解释可以参考这篇文章


可以用 wireshark + usbpcap 抓取 usb 通讯。结果如下:

可见,debugprobe 使用 bulk 模式传输数据。报文如下:

PC -> debugprobe: 05 00 04 05 00e8f11f 0f 0f 0f
debugprobe -> PC: 05 04 01 1f002f000551333130383232

按照 CMSIS-DAP 文档,可以解读出内容。这次通讯是 DAP_Transfer 指令,用于读写寄存器。PC 发送的报文中,05 表示指令类型,00 在 swd 模式下被忽略,04 是 Transfer Count,05 是第一次 transfer 的 Transfer Request 信息,指定了 APnDP=1,即要访问的是 AP 寄存器;RnW=0,即写入寄存器,接下来发送了要写入的内容。后三次 transfer 的 Transfer Request 均为 0x0f,读取 AP 寄存器。

0x03 debugprobe 项目结构

在开始阅读 debugprobe 源码之前,我们先观察其结构:

debugprobe
├── CMakeLists.txt
├── CMSIS_5
├── freertos
├── FreeRTOS_Kernel_import.cmake
├── include
│   ├── board_debug_probe_config.h
│   ├── board_example_config.h
│   ├── board_pico_config.h
│   └── DAP_config.h
├── pico_sdk_import.cmake
├── README.md
└── src
    ├── cdc_uart.c
    ├── cdc_uart.h
    ├── FreeRTOSConfig.h
    ├── get_serial.c
    ├── get_serial.h
    ├── led.c
    ├── led.h
    ├── main.c
    ├── probe.c
    ├── probe_config.c
    ├── probe_config.h
    ├── probe.h
    ├── probe_oen.pio
    ├── probe.pio
    ├── sw_dp_pio.c
    ├── tusb_config.h
    ├── tusb_edpt_handler.c
    ├── tusb_edpt_handler.h
    └── usb_descriptors.c

统计一下代码行数:

find . -type f -name '*.c' | xargs cat | wc -l
# 1568

find . -type f -name '*.h' | xargs cat | wc -l
# 1295

可见这是一个小型项目,预计几个小时就能读完代码。编译方式:

git submodule update --init
mkdir build
cd build
PICO_SDK_PATH=../../../../embedded/rp2040/pico-sdk/ cmake -DDEBUG_ON_PICO=ON ..
make

我们先来看 CMakeList。

cmake_minimum_required(VERSION 3.12)

# 引入 pico sdk
include(pico_sdk_import.cmake)

# 引入 FreeRTOS
set(FREERTOS_KERNEL_PATH ${CMAKE_CURRENT_LIST_DIR}/freertos)
include(FreeRTOS_Kernel_import.cmake)

project(debugprobe)

pico_sdk_init()

# 本项目代码
add_executable(debugprobe
        src/probe_config.c
        src/led.c
        src/main.c
        src/usb_descriptors.c
        src/probe.c
        src/cdc_uart.c
        src/get_serial.c
        src/sw_dp_pio.c
        src/tusb_edpt_handler.c
)

# CMSIS-DAP 源码
target_sources(debugprobe PRIVATE
        CMSIS_5/CMSIS/DAP/Firmware/Source/DAP.c
        CMSIS_5/CMSIS/DAP/Firmware/Source/JTAG_DP.c
        CMSIS_5/CMSIS/DAP/Firmware/Source/DAP_vendor.c
        CMSIS_5/CMSIS/DAP/Firmware/Source/SWO.c
        #CMSIS_5/CMSIS/DAP/Firmware/Source/SW_DP.c
        )

target_include_directories(debugprobe PRIVATE
        CMSIS_5/CMSIS/DAP/Firmware/Include/
        CMSIS_5/CMSIS/Core/Include/
        include/
        )

target_compile_options(debugprobe PRIVATE -Wall)

# .pio 转 .h
pico_generate_pio_header(debugprobe ${CMAKE_CURRENT_LIST_DIR}/src/probe.pio)
pico_generate_pio_header(debugprobe ${CMAKE_CURRENT_LIST_DIR}/src/probe_oen.pio)

target_include_directories(debugprobe PRIVATE src)

target_compile_definitions (debugprobe PRIVATE
	PICO_RP2040_USB_DEVICE_ENUMERATION_FIX=1
)

# 为 pico 开发板构建
option (DEBUG_ON_PICO "Compile firmware for the Pico instead of Debug Probe" OFF)
if (DEBUG_ON_PICO)
    target_compile_definitions (debugprobe PRIVATE 
	DEBUG_ON_PICO=1
    )
    set_target_properties(debugprobe PROPERTIES 
        OUTPUT_NAME "debugprobe_on_pico"
    )
endif ()

# 引入库
target_link_libraries(debugprobe PRIVATE
        pico_multicore
        pico_stdlib
        pico_unique_id
        tinyusb_device
        tinyusb_board
        hardware_pio
        FreeRTOS-Kernel
        FreeRTOS-Kernel-Heap1
)

# 在启动时将所有代码搬到 RAM 中执行
pico_set_binary_type(debugprobe copy_to_ram)

pico_add_extra_outputs(debugprobe)

可以看到,debugprobe 使用了三个第三方库:CMSIS_5、FreeRTOS 和 tinyusb。尤其值得注意的是,debugprobe 使用了 CMSIS-DAP firmware 中的 DAP.cJTAG_DP.cDAP_vendor.cSWO.c,但没有使用 SW_DP.c。观察 SW_DP.c,发现其用途是对 SWCLK 和 SWDIO 进行 bit-banging。在 RP2040 上,这份工作可以改用更优雅的 PIO 来实现。

既然「调试器到 DAP」的上层逻辑沿用了 CMSIS-DAP 的代码,那么剩余的工作就是写一份基于 PIO 的底层传输代码,以及实现调试器与主机之间的 usb 通讯。后者建立在 tinyusb 库上。

0x04 include 目录

debugprobe 项目只有四个 .h 文件,所以我们在进入 main 函数之前,先快速浏览一遍这些 .h 文件。

include/board_example_config.h 是 board config 模板。目前 debugprobe 项目适配了官方调试器产品(原理图开源)和 Pico 开发板,它们拥有各自的 board config。来看模板内容,首先是引脚连接方式:

// SWCLK/SWDIO 的物理连接方式。Pico 开发板是 PROBE_IO_RAW,调试器产品是 PROBE_IO_SWDI

/* Select one of these. */
/* Direct connection - SWCLK/SWDIO on two GPIOs */
#define PROBE_IO_RAW
/* SWCLK connected to a GPIO, SWDO driven from a GPIO, SWDI sampled via a level shifter */
#define PROBE_IO_SWDI
/* Level-shifted SWCLK, SWDIO with separate SWDO, SWDI and OE_N pin */
#define PROBE_IO_OEN
💡
在 Pico 开发板上,SWDIO 引脚同时用于输入和输出。但是在调试器产品上,由 GP14 输出,而 SWDIO 信号在通过 74AUP1T17GW 电平转换芯片之后再通入 GPIO13 作为输入。所以调试器产品在目标板电压较低的情况下也能正常通讯。

接下来是 uart 相关设置:

/* Include CDC interface to bridge to target UART. Omit if not used. */
#define PROBE_CDC_UART

/* Board implements hardware flow control for UART RTS/CTS instead of ACM control */
#define PROBE_UART_HWFC

接下来是复位信号:

/* Target reset GPIO (active-low). Omit if not used.*/
#define PROBE_PIN_RESET 1

接着是 PIO 状态机相关的设置:

#define PROBE_SM 0
#define PROBE_PIN_OFFSET 12
/* PIO config for PROBE_IO_RAW */
#if defined(PROBE_IO_RAW)
#define PROBE_PIN_SWCLK (PROBE_PIN_OFFSET + 0)
#define PROBE_PIN_SWDIO (PROBE_PIN_OFFSET + 1)
#endif

/* PIO config for PROBE_IO_SWDI */
#if defined(PROBE_IO_SWDI)
#define PROBE_PIN_SWCLK (PROBE_PIN_OFFSET + 0)
#define PROBE_PIN_SWDIO (PROBE_PIN_OFFSET + 1)
#define PROBE_PIN_SWDI  (PROBE_PIN_OFFSET + 2)
#endif

/* PIO config for PROBE_IO_OEN - note that SWDIOEN and SWCLK are both side_set signals, so must be consecutive. */
#if defined(PROBE_IO_OEN)
#define PROBE_PIN_SWDIOEN (PROBE_PIN_OFFSET + 0)
#define PROBE_PIN_SWCLK (PROBE_PIN_OFFSET + 1)
#define PROBE_PIN_SWDIO (PROBE_PIN_OFFSET + 2)
#define PROBE_PIN_SWDI (PROBE_PIN_OFFSET + 3)
#endif

又是几个 uart 设置:


#if defined(PROBE_CDC_UART)
#define PROBE_UART_TX 4
#define PROBE_UART_RX 5
#define PROBE_UART_INTERFACE uart1
#define PROBE_UART_BAUDRATE 115200

#if defined(PROBE_UART_HWFC)
/* Hardware flow control - see 1.4.3 in the RP2040 datasheet for valid pin settings */
#define PROBE_UART_CTS 6
#define PROBE_UART_RTS 7
#else
/* Software flow control - RTS and DTR can be omitted if not used */
#define PROBE_UART_RTS 9
#endif
#define PROBE_UART_DTR 10

#endif

最后是 LED 设置:

/* LED config - some or all of these can be omitted if not used */
#define PROBE_USB_CONNECTED_LED 2
#define PROBE_DAP_CONNECTED_LED 15
#define PROBE_DAP_RUNNING_LED 16
#define PROBE_UART_RX_LED 7
#define PROBE_UART_TX_LED 8

#define PROBE_PRODUCT_STRING "Example Debug Probe"

综上所述,假如我们想要自己做一块调试器,那么我们可以写一份 board config 文件,自定义 swd 物理连接类型、是否使用 uart、是否使用硬件流控、使用哪些引脚控制 LED。

下面来看 include/board_pico_config.h,我们将 Pico 开发板刷成调试器的时候,使用的就是这份配置。

// SWCLK 和 SWDIO 直连 GPIO
#define PROBE_IO_RAW

// 使用虚拟串口
#define PROBE_CDC_UART

// PIO 相关
#define PROBE_SM 0
#define PROBE_PIN_OFFSET 2
#define PROBE_PIN_SWCLK (PROBE_PIN_OFFSET + 0) // 2
#define PROBE_PIN_SWDIO (PROBE_PIN_OFFSET + 1) // 3

// 不引出 RESET 信号
#if false
#define PROBE_PIN_RESET 1
#endif

// uart 相关
#define PROBE_UART_TX 4
#define PROBE_UART_RX 5
#define PROBE_UART_INTERFACE uart1
#define PROBE_UART_BAUDRATE 115200

// 把板载 LED 作为 connected 信号灯
#define PROBE_USB_CONNECTED_LED 25

// 调试器名称,会显示在 pyocd list 中
#define PROBE_PRODUCT_STRING "Debugprobe on Pico (CMSIS-DAP)"

而调试器产品拥有电平转换电路和五个 LED,它的配置如下:

// SWCLK、SWDO 直连;SWDI 经过电平转换 IC 之后再连入 GPIO
#define PROBE_IO_SWDI
#define PROBE_CDC_UART
// No reset pin 


// PIO config
#define PROBE_SM 0
#define PROBE_PIN_OFFSET 12
#define PROBE_PIN_SWCLK (PROBE_PIN_OFFSET + 0)
// For level-shifted input.
#define PROBE_PIN_SWDI (PROBE_PIN_OFFSET + 1)
#define PROBE_PIN_SWDIO (PROBE_PIN_OFFSET + 2)

// UART config
#define PROBE_UART_TX 4
#define PROBE_UART_RX 5
#define PROBE_UART_INTERFACE uart1
#define PROBE_UART_BAUDRATE 115200

// 五个 LED
#define PROBE_USB_CONNECTED_LED 2
#define PROBE_DAP_CONNECTED_LED 15
#define PROBE_DAP_RUNNING_LED 16
#define PROBE_UART_RX_LED 7
#define PROBE_UART_TX_LED 8

// 调试器名称
#define PROBE_PRODUCT_STRING "Debug Probe (CMSIS-DAP)"

最后,我们还剩 include/DAP_config.h 这个文件要分析。事实上它是从 CMSIS_5/CMSIS/DAP/Firmware/Config/DAP_config.h 修改来的。文件有点长,我们挑重点看:

// 给出 MCU 频率
/// Processor Clock of the Cortex-M MCU used in the Debug Unit.
/// This value is used to calculate the SWD/JTAG clock speed.
/* Debugprobe actually uses kHz rather than Hz, so just lie about it here */
#define CPU_CLOCK               125000000U      ///< Specifies the CPU Clock in Hz.

// IO 速度。RP2040 是 Cortex-M0+,有 single cycle IO
/// Number of processor cycles for I/O Port write operations.
/// This value is used to calculate the SWD/JTAG clock speed that is generated with I/O
/// Port write operations in the Debug Unit by a Cortex-M MCU. Most Cortex-M processors
/// require 2 processor cycles for a I/O Port Write operation.  If the Debug Unit uses
/// a Cortex-M0+ processor with high-speed peripheral I/O only 1 processor cycle might be
/// required.
#define IO_PORT_WRITE_CYCLES    1U              ///< I/O Cycles: 2=default, 1=Cortex-M0+ fast I/0.
#define DELAY_SLOW_CYCLES 1U // We don't differentiate between fast/slow, we've got a 16-bit divisor for that


// 声明自己提供 swd 协议支持
/// Indicate that Serial Wire Debug (SWD) communication mode is available at the Debug Access Port.
/// This information is returned by the command \ref DAP_Info as part of <b>Capabilities</b>.
#define DAP_SWD                 1               ///< SWD Mode:  1 = available, 0 = not available.

// 声明自己不支持 jtag
/// Indicate that JTAG communication mode is available at the Debug Port.
/// This information is returned by the command \ref DAP_Info as part of <b>Capabilities</b>.
#define DAP_JTAG                0               ///< JTAG Mode: 1 = available, 0 = not available.


// 最大 DAP 包长度,采用 64
/// Maximum Package Size for Command and Response data.
/// This configuration settings is used to optimize the communication performance with the
/// debugger and depends on the USB peripheral. Typical vales are 64 for Full-speed USB HID or WinUSB,
/// 1024 for High-speed USB HID and 512 for High-speed USB WinUSB.
#define DAP_PACKET_SIZE         64U            ///< Specifies Packet Size in bytes.


// usb buffer 大小
/// Maximum Package Buffers for Command and Response data.
/// This configuration settings is used to optimize the communication performance with the
/// debugger and depends on the USB peripheral. For devices with limited RAM or USB buffer the
/// setting can be reduced (valid range is 1 .. 255).
#define DAP_PACKET_COUNT        2U              ///< Specifies number of packets buffered.


// 声明自己不支持 SWO
/// Indicate that UART Serial Wire Output (SWO) trace is available.
/// This information is returned by the command \ref DAP_Info as part of <b>Capabilities</b>.
#define SWO_UART                0               ///< SWO UART:  1 = available, 0 = not available.


// 声明自己不支持 DAP uart。此项目自行实现了虚拟串口。
/// Indicate that UART Communication Port is available.
/// This information is returned by the command \ref DAP_Info as part of <b>Capabilities</b>.
#define DAP_UART                0               ///< DAP UART:  1 = available, 0 = not available.

// 不指定 target。所以我们运行 pyocd list 时,debugprobe 没有显示特定的 target
/// Debug Unit is connected to fixed Target Device.
/// The Debug Unit may be part of an evaluation board and always connected to a fixed
/// known device. In this case a Device Vendor, Device Name, Board Vendor and Board Name strings
/// are stored and may be used by the debugger or IDE to configure device parameters.
#define TARGET_FIXED            0               ///< Target: 1 = known, 0 = unknown;

以上就是配置,接下来是一些函数的实现。例如,在 CMSIS 的 DAP_config.h 中,有这样一行:

__STATIC_INLINE void LED_CONNECTED_OUT (uint32_t bit) {}

而在 debugprobe 的 include/DAP_config.h 中,给出了具体实现:

__STATIC_INLINE void LED_RUNNING_OUT (uint32_t bit) {
#ifdef PROBE_DAP_RUNNING_LED
  gpio_put(PROBE_DAP_RUNNING_LED, bit);
#endif
}

debugprobe 不支持复位引脚,所以对于 RESET_TARGET 的实现是:

__STATIC_INLINE uint8_t RESET_TARGET (void) {
  return (0U);             // change to '1' when a device reset sequence is implemented
}

至此,我们看完了 include 目录下的头文件。接下来该关注 src 目录了。

0x05 三个 RTOS 任务

来看 main 函数:

int main(void) {
    // 写一些(二进制层面的)注释,pico examples 中常用。可以通过 picotool 读出来
    bi_decl_config();

    // 初始化调试板,例如点亮 Pico 开发板的 LED
    board_init();
    
    // 用 RP2040 的 unique id 计算出 usb iSerialNumber
    usb_serial_init();
    
    // 初始化 uart 外设
    cdc_uart_init();
    
    // 初始化 tinyusb
    tusb_init();
    
    // 将 printf 输出到 RP2040 自身的串口。应该是调试用途
    stdio_uart_init();

    // 初始化 DAP。这个函数是 DAP.c 提供的
    DAP_Setup();

    // 初始化五个 LED
    led_init();

    probe_info("Welcome to debugprobe!\n");

    // 这份文件的开头已经定义 THREADED = 1,所以一定会执行下面的代码
    if (THREADED) {
        // 调用 FreeRTOS API,创建三个任务
        
        // uart 任务,优先级为 +3
        /* UART needs to preempt USB as if we don't, characters get lost */
        xTaskCreate(cdc_thread, "UART", configMINIMAL_STACK_SIZE, NULL, UART_TASK_PRIO, &uart_taskhandle);
        
        // tinyusb 任务,优先级为 +2
        xTaskCreate(usb_thread, "TUD", configMINIMAL_STACK_SIZE, NULL, TUD_TASK_PRIO, &tud_taskhandle);
        /* Lowest priority thread is debug - need to shuffle buffers before we can toggle swd... */
        
        // DAP 任务,优先级为 +1
        xTaskCreate(dap_thread, "DAP", configMINIMAL_STACK_SIZE, NULL, DAP_TASK_PRIO, &dap_taskhandle);
        
        // 启动 FreeRTOS 调度器
        vTaskStartScheduler();
    }

    // 下面的逻辑不会执行。应该是调试用途
    while (!THREADED) {
        tud_task();
        cdc_task();

#if (PROBE_DEBUG_PROTOCOL == PROTO_DAP_V2)
        if (tud_vendor_available()) {
            uint32_t resp_len;
            tud_vendor_read(RxDataBuffer, sizeof(RxDataBuffer));
            resp_len = DAP_ProcessCommand(RxDataBuffer, TxDataBuffer);
            tud_vendor_write(TxDataBuffer, resp_len);
        }
#endif
    }

    return 0;
}

这份代码的逻辑非常清晰,我们接下来只需要跟进 cdc_threadusb_threaddap_thread 这三个任务。我们就按照优先级从高到低的顺序来分析这些任务,首先是 cdc:

// 简而言之:连续调用 cdc_task()
void cdc_thread(void *ptr)
{
  BaseType_t delayed;
  last_wake = xTaskGetTickCount();
  bool keep_alive;
  /* Threaded with a polling interval that scales according to linerate */
  while (1) {
    keep_alive = cdc_task();
    if (!keep_alive) {
      delayed = xTaskDelayUntil(&last_wake, interval);
        if (delayed == pdFALSE)
          last_wake = xTaskGetTickCount();
    }
  }
}


bool cdc_task(void)
{
    static int was_connected = 0;
    static uint cdc_tx_oe = 0;
    uint rx_len = 0;
    bool keep_alive = false;

    // 把串口收到的数据装进 rx_buf
    // Consume uart fifo regardless even if not connected
    while(uart_is_readable(PROBE_UART_INTERFACE) && (rx_len < sizeof(rx_buf))) {
        rx_buf[rx_len++] = uart_getc(PROBE_UART_INTERFACE);
    }

    // 检查 cdc 是否连接。这是 lib/tinyusb/src/class/cdc/cdc_device.h 提供的 API
    if (tud_cdc_connected()) {
        was_connected = 1;
        int written = 0;
        /* Implicit overflow if we don't write all the bytes to the host.
         * Also throw away bytes if we can't write... */
         
        // 若现在 buf 里面有数据,则应该发给电脑
        if (rx_len) {
#ifdef PROBE_UART_RX_LED
          // 点亮 LED
          gpio_put(PROBE_UART_RX_LED, 1);
          rx_led_debounce = debounce_ticks;
#endif
          // tud_cdc_write_available() 这个 API 返回 cdc 现在能接受多少个字节的写入
          written = MIN(tud_cdc_write_available(), rx_len);
          if (rx_len > written)
              cdc_tx_oe++;

          // 把数据写入 cdc,发给电脑
          if (written > 0) {
            tud_cdc_write(rx_buf, written);
            tud_cdc_write_flush();
          }
        } else {
        // 考虑关闭 RX LED
#ifdef PROBE_UART_RX_LED
          if (rx_led_debounce)
            rx_led_debounce--;
          else
            gpio_put(PROBE_UART_RX_LED, 0);
#endif
        }

      
      /* Reading from a firehose and writing to a FIFO. */
      size_t watermark = MIN(tud_cdc_available(), sizeof(tx_buf));
      if (watermark > 0) {
        size_t tx_len;
#ifdef PROBE_UART_TX_LED
        // 有数据要发送给 uart,点亮 LED
        gpio_put(PROBE_UART_TX_LED, 1);
        tx_led_debounce = debounce_ticks;
#endif
        /* Batch up to half a FIFO of data - don't clog up on RX */
        watermark = MIN(watermark, 16);
        tx_len = tud_cdc_read(tx_buf, watermark);
        
        // 发出数据
        uart_write_blocking(PROBE_UART_INTERFACE, tx_buf, tx_len);
      } else {
        // 没数据要发给 uart,考虑关闭 TX LED
#ifdef PROBE_UART_TX_LED
          if (tx_led_debounce)
            tx_led_debounce--;
          else
            gpio_put(PROBE_UART_TX_LED, 0);
#endif
      }
      /* Pending break handling */
      if (timed_break) {
        if (((int)break_expiry - (int)xTaskGetTickCount()) < 0) {
          timed_break = false;
          uart_set_break(PROBE_UART_INTERFACE, false);
#ifdef PROBE_UART_TX_LED
          tx_led_debounce = 0;
#endif
        } else {
          keep_alive = true;
        }
      }
    } else if (was_connected) {
      // 以下逻辑在 cdc 连接断开时执行
      tud_cdc_write_clear();
      uart_set_break(PROBE_UART_INTERFACE, false);
      timed_break = false;
      was_connected = 0;
#ifdef PROBE_UART_TX_LED
      tx_led_debounce = 0;
#endif
      cdc_tx_oe = 0;
    }
    return keep_alive;
}

上述代码逻辑还算清晰,就是在 tinyusb 的 cdc 与 RP2040 的 uart 外设之间交换数据。tx_bufrx_buf 的长度都是 32 字节。

💡
LED 一旦点亮,则至少亮几十毫秒。这个设计比一些开发板将 LED 直接连到 RX / TX 数据线更好。

接下来分析 usb_thread

void usb_thread(void *ptr)
{
    TickType_t wake;
    wake = xTaskGetTickCount();
    do {
        tud_task();
#ifdef PROBE_USB_CONNECTED_LED
        if (!gpio_get(PROBE_USB_CONNECTED_LED) && tud_ready())
            gpio_put(PROBE_USB_CONNECTED_LED, 1);
        else
            gpio_put(PROBE_USB_CONNECTED_LED, 0);
#endif
        // Go to sleep for up to a tick if nothing to do
        if (!tud_task_event_ready())
            xTaskDelayUntil(&wake, 1);
    } while (1);
}

这个函数的功能就是连续调用 tud_task(),而 tud_task 是 tinyusb 提供的 API。根据 tinyusb 文档,用户应该定期调用这个函数。我们没有跟进的必要。

接下来是最后一个任务,dap_thread。代码位于 src/tusb_edpt_handler.c,内容如下:

void dap_thread(void *ptr)
{
    uint32_t n;
    do
    {
        while(USBRequestBuffer.rptr != USBRequestBuffer.wptr)
        {
            /*
             * Atomic command support - buffer QueueCommands, but don't process them
             * until a non-QueueCommands packet is seen.
             */
            n = USBRequestBuffer.rptr;
            
            // 这是循环队列
            // 队列中可以容纳 DAP_PACKET_COUNT 个元素
            // 有 wptr 和 rptr 这两个指针
            // 向队列中加入元素则 wptr++
            // 从队列中消耗元素则 rptr++
            // 因此,rptr 指向队首,wptr 指向队尾,队列元素是 [rptr, wptr)
            
            // 在这个循环中,n 从队首开始向后走,直到遇到非 ID_DAP_QueueCommands 的元素
            while (USBRequestBuffer.data[n % DAP_PACKET_COUNT][0] == ID_DAP_QueueCommands) {
                probe_info("%u %u DAP queued cmd %s len %02x\n",
                           USBRequestBuffer.wptr, USBRequestBuffer.rptr,
                           dap_cmd_string[USBRequestBuffer.data[n % DAP_PACKET_COUNT][0]], USBRequestBuffer.data[n % DAP_PACKET_COUNT][1]);
                
                // 将队首从 ID_DAP_QueueCommands 改为 ID_DAP_ExecuteCommands
                USBRequestBuffer.data[n % DAP_PACKET_COUNT][0] = ID_DAP_ExecuteCommands;
                n++;
                while (n == USBRequestBuffer.wptr) {
                // 若已经走到队列末尾,则等待新元素入队
                    /* Need yield in a loop here, as IN callbacks will also wake the thread */
                    probe_info("DAP wait\n");
                    vTaskSuspend(dap_taskhandle);
                }
            }
            
            
            // 现在,把 USBRequestBuffer 的队首输出到 DAPRequestBuffer
            // Read a single packet from the USB buffer into the DAP Request buffer
            memcpy(DAPRequestBuffer, RD_SLOT_PTR(USBRequestBuffer), DAP_PACKET_SIZE);
            probe_info("%u %u DAP cmd %s len %02x\n",
                       USBRequestBuffer.wptr, USBRequestBuffer.rptr,
                       dap_cmd_string[DAPRequestBuffer[0]], DAPRequestBuffer[1]);
            USBRequestBuffer.rptr++;

            // If the buffer was full in the out callback, we 
            //   need to queue up another buffer for the endpoint 
            //   to consume, now that we know there is space in the buffer.
            if(USBRequestBuffer.wasFull)
            {
                vTaskSuspendAll(); // Suspend the scheduler to safely update the write index
                USBRequestBuffer.wptr++;
                
                // 把主机传来的数据存进 USBRequestBuffer
                usbd_edpt_xfer(_rhport, _out_ep_addr, WR_SLOT_PTR(USBRequestBuffer), DAP_PACKET_SIZE);
                USBRequestBuffer.wasFull = false;
                xTaskResumeAll();
            }

            // 这个 API 由 DAP.c 提供,执行 DAP 请求并获得响应
            _resp_len = DAP_ExecuteCommand(DAPRequestBuffer, DAPResponseBuffer);
            probe_info("%u %u DAP resp %s\n",
                    USBResponseBuffer.wptr, USBResponseBuffer.rptr,
                    dap_cmd_string[DAPResponseBuffer[0]]);


            //  Suspend the scheduler to avoid stale values/race conditions between threads
            vTaskSuspendAll();

            // 若现在 USBResponseBuffer 为空
            if(buffer_empty(&USBResponseBuffer))
            {
                // 把响应结果拷贝到 USBResponseBuffer
                memcpy(WR_SLOT_PTR(USBResponseBuffer), DAPResponseBuffer, (uint16_t) _resp_len);
                USBResponseBuffer.wptr++;

                // 把响应结果发给主机
                usbd_edpt_xfer(_rhport, _in_ep_addr, RD_SLOT_PTR(USBResponseBuffer), (uint16_t) _resp_len);
            } else {
                // 把响应结果拷贝到 USBResponseBuffer
                memcpy(WR_SLOT_PTR(USBResponseBuffer), DAPResponseBuffer, (uint16_t) _resp_len);
                USBResponseBuffer.wptr++;

                // The In callback needs to check this flag to know when to queue up the next buffer.
                USBResponseBuffer.wasEmpty = false;
            }
            xTaskResumeAll();
        }

        // Suspend DAP thread until it is awoken by a USB thread callback
        vTaskSuspend(dap_taskhandle);

    } while (1);

}

这段代码比较复杂,循环队列也缺乏封装,略微难读。理清逻辑之后,总结如下:

  • 向 DAP 发送 request。其中,QueueCommands 不会被立即发送,而是等到出现非 QueueCommands 的指令,再将它们依次发出。发出时,这些 QueueCommands 的 ID 已经改为 ExecuteCommands
  • 从 DAP 获取 response。
  • 把 response 发回给电脑。

代码中出现了「等待新元素进入 USBRequestBuffer」的过程,我们应该分析一下这会在何种时候发生。直观上看,由于 usb 通讯是由 tinyusb 处理的,则从主机发来消息之后,tinyusb 会调用 debugprobe 指定的回调函数,从而把新的 request 加入 USBRequestBuffer。找到对应函数:

// Manage USBResponseBuffer (request) write and USBRequestBuffer (response) read indices
bool dap_edpt_xfer_cb(uint8_t __unused rhport, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes)
{
    const uint8_t ep_dir = tu_edpt_dir(ep_addr);

    if(ep_dir == TUSB_DIR_IN)
    {
        // 主机发起了一次 IN 传输,即 MCU 现在要向主机回复消息
        if(xferred_bytes >= 0u && xferred_bytes <= DAP_PACKET_SIZE)
        {
            // 发出 USBResponseBuffer 的队首
            USBResponseBuffer.rptr++;

            // This checks that the buffer was not empty in DAP thread, 
            //   which means the next buffer was not queued up for the in endpoint callback
            // So, queue up the buffer at the new read index, since we 
            //   expect read to catch up to write at this point.
            // It is possible for the read index to be multiple spaces 
            //   behind the write index (if the USB callbacks are lagging behind dap thread),
            //   so we account for this by only setting wasEmpty to true 
            //   if the next callback will empty the buffer
            if(!USBResponseBuffer.wasEmpty)
            {
                usbd_edpt_xfer(rhport, ep_addr, RD_SLOT_PTR(USBResponseBuffer), (uint16_t) _resp_len);
                USBResponseBuffer.wasEmpty = (USBResponseBuffer.rptr + 1) == USBResponseBuffer.wptr;
            }

            //  Wake up DAP thread after processing the callback
            vTaskResume(dap_taskhandle);
            return true;
        }

        return false;

    } else if(ep_dir == TUSB_DIR_OUT)    {
        // 主机发起了一次 OUT 传输,即主机现在要通知 MCU 一个 request
        if(xferred_bytes >= 0u && xferred_bytes <= DAP_PACKET_SIZE)
        {
            // Only queue the next buffer in the out callback if the buffer is not full
            // If full, we set the wasFull flag, which will be checked by dap thread
            if(!buffer_full(&USBRequestBuffer))
            {
                // 把新的 request 加入 USBRequestBuffer 队列
                USBRequestBuffer.wptr++;
                usbd_edpt_xfer(rhport, ep_addr, WR_SLOT_PTR(USBRequestBuffer), DAP_PACKET_SIZE);
                USBRequestBuffer.wasFull = false;
            }
            else {
                USBRequestBuffer.wasFull = true;
            }

            //  Wake up DAP thread after processing the callback
            vTaskResume(dap_taskhandle);
            return true;
        }

        return false;
    }
    else return false;
}

至此,我们分析完了高层的任务管理逻辑。拼图还剩下最后的一块——debugprobe 使用了 DAP.c 但没有使用 SW_DP.c,现在需要用 PIO 提供一个替代实现。

0x06 利用 PIO 管理 swd 通讯

在观察 debugprobe 的 swd 通讯相关代码之前,我们先看一看 CMSIS-DAP 中的实现包含了哪些内容。以下是 CMSIS_5/CMSIS/DAP/Firmware/Source/SW_DP.c 代码:

// Generate SWJ Sequence
//   count:  sequence bit count
//   data:   pointer to sequence bit data
//   return: none
#if ((DAP_SWD != 0) || (DAP_JTAG != 0))
void SWJ_Sequence (uint32_t count, const uint8_t *data) {
  uint32_t val;
  uint32_t n;

  // 用 bit-banging 发送 data
  val = 0U;
  n = 0U;
  while (count--) {
    if (n == 0U) {
      val = *data++;
      n = 8U;
    }
    if (val & 1U) {
      PIN_SWDIO_TMS_SET();
    } else {
      PIN_SWDIO_TMS_CLR();
    }
    SW_CLOCK_CYCLE();
    val >>= 1;
    n--;
  }
}

// 要么发出 swdo 内容,要么把信息读进 swdi
#if (DAP_SWD != 0)
void SWD_Sequence (uint32_t info, const uint8_t *swdo, uint8_t *swdi) {
  // bit-banging,略
}


// 执行一次完整的 DAP transfer,即发出 Packet Request、读 ACK、处理数据
#if (DAP_SWD != 0)
#define SWD_TransferFunction(speed)     /**/                                    \
static uint8_t SWD_Transfer##speed (uint32_t request, uint32_t *data) {         \
  // bit-banging,略
}


// 利用上面的宏 SWD_TransferFunction 展开,获得 fast 和 slow 两个传输函数 
#undef  PIN_DELAY
#define PIN_DELAY() PIN_DELAY_FAST()
SWD_TransferFunction(Fast)

#undef  PIN_DELAY
#define PIN_DELAY() PIN_DELAY_SLOW(DAP_Data.clock_delay)
SWD_TransferFunction(Slow)

// swd 传输接口
// SWD Transfer I/O
//   request: A[3:2] RnW APnDP
//   data:    DATA[31:0]
//   return:  ACK[2:0]
uint8_t  SWD_Transfer(uint32_t request, uint32_t *data) {
  if (DAP_Data.fast_clock) {
    return SWD_TransferFast(request, data);
  } else {
    return SWD_TransferSlow(request, data);
  }
}

参考 CMSIS_5/CMSIS/DAP/Firmware/Include/DAP.h,这份代码向外暴露的函数是:

void     SWJ_Sequence    (uint32_t count, const uint8_t *data);
void     SWD_Sequence    (uint32_t info,  const uint8_t *swdo, uint8_t *swdi);
uint8_t  SWD_Transfer    (uint32_t request, uint32_t *data);

现在,我们来看 src/sw_dp_pio.c 的替代实现。前两个函数如下:

#if ((DAP_SWD != 0) || (DAP_JTAG != 0))
void SWJ_Sequence (uint32_t count, const uint8_t *data) {
  uint32_t bits;
  uint32_t n;

  if (DAP_Data.clock_delay != cached_delay) {
    probe_set_swclk_freq(MAKE_KHZ(DAP_Data.clock_delay));
    cached_delay = DAP_Data.clock_delay;
  }
  probe_debug("SWJ sequence count = %d FDB=0x%2x\n", count, data[0]);
  n = count;
  while (n > 0) {
    if (n > 8)
      bits = 8;
    else
      bits = n;
      
    // 调用 probe_write_bits() 发出数据,每次最多 8 bit
    probe_write_bits(bits, *data++);
    n -= bits;
  }
}
#endif


#if (DAP_SWD != 0)
void SWD_Sequence (uint32_t info, const uint8_t *swdo, uint8_t *swdi) {
  uint32_t bits;
  uint32_t n;

  if (DAP_Data.clock_delay != cached_delay) {
    probe_set_swclk_freq(MAKE_KHZ(DAP_Data.clock_delay));
    cached_delay = DAP_Data.clock_delay;
  }
  probe_debug("SWD sequence\n");
  n = info & SWD_SEQUENCE_CLK;
  if (n == 0U) {
    n = 64U;
  }
  bits = n;
  if (info & SWD_SEQUENCE_DIN) {
    while (n > 0) {
      if (n > 8)
        bits = 8;
      else
        bits = n;
      // 一次最多读取 8 bit
      *swdi++ = probe_read_bits(bits);
      n -= bits;
    }
  } else {
    while (n > 0) {
      if (n > 8)
        bits = 8;
      else
        bits = n;
      // 一次最多发出 8 bit
      probe_write_bits(bits, *swdo++);
      n -= bits;
    }
  }
}
#endif

上述代码就是把 bit-banging 改成了调用 probe_read_bits()probe_write_bits() ,从而一次性操作 8 个 bit 而不是 1 bit。接下来是比较复杂的 SWD_Transfer 函数:

#if (DAP_SWD != 0)
// SWD Transfer I/O
//   request: A[3:2] RnW APnDP
//   data:    DATA[31:0]
//   return:  ACK[2:0]
uint8_t SWD_Transfer (uint32_t request, uint32_t *data) {
  uint8_t prq = 0;
  uint8_t ack;
  uint8_t bit;
  uint32_t val = 0;
  uint32_t parity = 0;
  uint32_t n;

  if (DAP_Data.clock_delay != cached_delay) {
    probe_set_swclk_freq(MAKE_KHZ(DAP_Data.clock_delay));
    cached_delay = DAP_Data.clock_delay;
  }
  probe_debug("SWD_transfer\n");
  
  // 组装 packet request 字节
  
  /* Generate the request packet */
  prq |= (1 << 0); /* Start Bit */
  for (n = 1; n < 5; n++) {
    bit = (request >> (n - 1)) & 0x1;
    prq |= bit << n;
    parity += bit;
  }
  prq |= (parity & 0x1) << 5; /* Parity Bit */
  prq |= (0 << 6); /* Stop Bit */
  prq |= (1 << 7); /* Park bit */
  
  // 发出 packet request 字节
  probe_write_bits(8, prq);


  // 制造几个时钟周期,即 ADIv5 所要求的 trn,并读取 ACK 信息
  /* Turnaround (ignore read bits) */
  ack = probe_read_bits(DAP_Data.swd_conf.turnaround + 3);
  ack >>= DAP_Data.swd_conf.turnaround;

  // 若 ACK 正常
  if (ack == DAP_TRANSFER_OK) {
    /* Data transfer phase */
    if (request & DAP_TRANSFER_RnW) {
      // 本次操作的 RnW flag 是 1,即 read
      
      /* Read RDATA[0:31] - note probe_read shifts to LSBs */
      
      // 读出 32 bit 数据
      val = probe_read_bits(32);
      
      // 读出 1 bit 奇偶校验位,并验证
      bit = probe_read_bits(1);
      parity = __builtin_popcount(val);
      if ((parity ^ bit) & 1U) {
        /* Parity error */
        ack = DAP_TRANSFER_ERROR;
      }
      
      if (data)
        *data = val;
      probe_debug("Read %02x ack %02x 0x%08x parity %01x\n",
                      prq, ack, val, bit);
      
      // 制造 trn
      /* Turnaround for line idle */
      probe_hiz_clocks(DAP_Data.swd_conf.turnaround);
    } else {
      // 本次操作的 RnW flag 是 0,即 write
      
      // 制造 trn
      /* Turnaround for write */
      probe_hiz_clocks(DAP_Data.swd_conf.turnaround);

      /* Write WDATA[0:31] */
      val = *data;
      
      // 写入数据
      probe_write_bits(32, val);
      
      // 写入奇偶校验位
      parity = __builtin_popcount(val);
      /* Write Parity Bit */
      probe_write_bits(1, parity & 0x1);
      probe_debug("write %02x ack %02x 0x%08x parity %01x\n",
                      prq, ack, val, parity);
    }

    // 记录时间戳
    /* Capture Timestamp */
    if (request & DAP_TRANSFER_TIMESTAMP) {
      DAP_Data.timestamp = time_us_32();
    }

    // 连续发送若干个 0
    /* Idle cycles - drive 0 for N clocks */
    if (DAP_Data.transfer.idle_cycles) {
      for (n = DAP_Data.transfer.idle_cycles; n; ) {
        if (n > 256) {
          probe_write_bits(256, 0);
          n -= 256;
        } else {
          probe_write_bits(n, 0);
          n -= n;
        }
      }
    }
    return ((uint8_t)ack);
  }

  // 以下是 ACK 不正常情况下的错误处理流程
  
  // 若 ACK 为 WAIT 或 FAULT
  if ((ack == DAP_TRANSFER_WAIT) || (ack == DAP_TRANSFER_FAULT)) {
    if (DAP_Data.swd_conf.data_phase && ((request & DAP_TRANSFER_RnW) != 0U)) {
      // 若操作为 read,则读 33 个垃圾 bit
      /* Dummy Read RDATA[0:31] + Parity */
      probe_read_bits(33);
    }
    
    // 制造 trn
    probe_hiz_clocks(DAP_Data.swd_conf.turnaround);
    
    if (DAP_Data.swd_conf.data_phase && ((request & DAP_TRANSFER_RnW) == 0U)) {
      // 若操作为 read,则发 33 个垃圾 bit
      /* Dummy Write WDATA[0:31] + Parity */
      probe_write_bits(32, 0);
      probe_write_bits(1, 0);
    }
    return ((uint8_t)ack);
  }

  // ACK 信号非预期
  /* Protocol error */
  n = DAP_Data.swd_conf.turnaround + 32U + 1U;
  /* Back off data phase */
  probe_read_bits(n);
  return ((uint8_t)ack);
}

#endif  /* (DAP_SWD != 0) */

这段代码逻辑也非常清晰。总结一下 PIO 模块要提供的接口:

  • probe_read_bits:读取若干个 bit
  • probe_write_bits:写入若干个 bit
  • probe_hiz_clocks:制造若干个时钟周期
  • probe_set_swclk_freq:改变时钟频率

现在,我们开始阅读 src/probe.pio

.program probe
.side_set 1 opt                ; 这里的 opt 表示 side-set 是可选功能,无需在每条指令之后都指定 side-set

public write_cmd:
public turnaround_cmd:                      ; Alias of write, used for probe_oen.pio
    ; 拉取待发送的数据
    pull
write_bitloop:
    ; 设置 SWDIO,拉低 SWCLK,延时一个周期
    out pins, 1             [1]  side 0x0   ; Data is output by host on negedge
    ; 拉高 SWCLK
    jmp x-- write_bitloop   [1]  side 0x1   ; ...and captured by target on posedge
                                            ; Fall through to next command
.wrap_target
public get_next_cmd:
    ; 拉取信息
    pull                         side 0x0   ; SWCLK is initially low
    ; 把 8 个 bit 放进 x,作为下一次传输的 bit count
    out x, 8                                ; Get bit count
    ; 1 个 bit 表示 SWDIO 方向
    out pindirs, 1                          ; Set SWDIO direction
    ; 直接设置 pc 寄存器,强制跳转
    out pc, 5                               ; Go to command routine

read_bitloop:
    nop                                     ; Additional delay on taken loop branch
public read_cmd:
    ; 从 SWDIO 读入一个 bit,拉高 SWCLK
    in pins, 1              [1]  side 0x1   ; Data is captured by host on posedge
    ; 拉低 SWCLK
    jmp x-- read_bitloop         side 0x0
    push
.wrap                                       ; Wrap to next command

这份 PIO 代码写得相当 hack(当然了,大部分 PIO 代码都很 hack,毕竟指令集有限,指令数量也被限制为 32 条)。PIO 的主要入口是 get_next_cmd,MCU 向 tx fifo 中写入控制信息(bit count、方向、要跳转到的位置),然后 PIO 会去执行读取或写入逻辑;接下来 MCU 把具体要发送的数据写入 tx fifo,或者从 rx fifo 读取信息。综上,一次读取或写入操作,要访问两次 PIO fifo,第一次访问是发送控制信息,第二次是真正的数据传输。

值得注意,out pc, 5 是绝对地址跳转,因此 MCU 需要计算出跳转位置,组装进控制信息中。我们来看看是如何实现的:

void probe_write_bits(uint bit_count, uint32_t data_byte) {
    DEBUG_PINS_SET(probe_timing, DBG_PIN_WRITE);
    pio_sm_put_blocking(pio0, PROBE_SM, fmt_probe_command(bit_count, true, CMD_WRITE));
    pio_sm_put_blocking(pio0, PROBE_SM, data_byte);
    probe_dump("Write %d bits 0x%x\n", bit_count, data_byte);
    // Return immediately so we can cue up the next command whilst this one runs
    DEBUG_PINS_CLR(probe_timing, DBG_PIN_WRITE);
}

显然 fmt_probe_command 即为组装控制信息的过程。跟进:

static inline uint32_t fmt_probe_command(uint bit_count, bool out_en, probe_pio_command_t cmd) {
    uint cmd_addr =
        cmd == CMD_WRITE      ? probe.offset + probe_offset_write_cmd :
        cmd == CMD_SKIP       ? probe.offset + probe_offset_get_next_cmd :
        cmd == CMD_TURNAROUND ? probe.offset + probe_offset_turnaround_cmd :
                                probe.offset + probe_offset_read_cmd;
    return ((bit_count - 1) & 0xff) | ((uint)out_en << 8) | (cmd_addr << 9);
}

其中的 probe_offset_* 都是在 .pio 编译成 .h 文件之后产生的。因此可以引用这些 offset,而无需在代码中硬编码 magic number,那样非常不便于维护。

至此,我们已经看完了 debugprobe 的所有 swd 相关代码。由于大量复用了 CMSIS-DAP 和 tinyusb 库,debugprobe 要做的工作事实上并不多。这也为自行移植 CMSIS-DAP 提供了参考:

  • 直接使用 CMSIS-DAP 的 DAP.cDAP_vendor.c,它们似乎是平台无关的。若打算用 bit-banging 实现 swd 协议控制,则使用 SW_DP.c,否则自行实现。
  • CMSIS-DAP 中没有现成的 usb 代码可抄,需要自行实现 usb 相关功能。我们可以考虑复用 debugprobe 的相关代码,这部分代码看起来不太依赖于 RP2040,而且几乎所有常用平台都有 tinyusb 支持。
  • FreeRTOS 应该是可选项。手动轮询或者使用轻量级的任务管理器,大概也能支撑 swd 和 cdc 功能。若想要移植到资源极为受限的环境中,则可以考虑去掉 FreeRTOS。