VS Code 连接 pyOCD 后未停在 main 的排查与修复

在 Air001 项目中,F5 本应依次完成固件烧录、启动 pyOCD GDB server、加载 ELF 符号、复位芯片,并停在 main 的第一条可执行语句。工程使用 VS Code C/C++ 扩展提供的 cppdbgarm-none-eabi-gdb

实际调试先后出现两个现象:手工添加 main 断点时报“没有符号表”;符号错误消失后,启动位置又落在 HAL_GetTick() 或主循环。本文说明这两个时序问题,并给出经过硬件验证的最小配置。

修复结论:postRemoteConnectCommands 中先执行 monitor reset halt,再执行 -exec-continue。前一条命令复位并暂停芯片,后一条命令让芯片继续运行到已经创建好的 main 断点。

调试目标与故障现象

调试环境为 Air001、pyOCD 0.44.1、Debug ELF(-O0 -g3)和 VS Code cppdbg。预期链路可以压缩成一行:

烧录 ELF → 启动 GDB server → 加载符号 → reset halt → 运行到 main

第一个故障来自 setupCommands。其中的 -break-insert main 早于 ELF 符号加载,GDB 无法解析 main

第二个故障发生在远程连接阶段。符号和断点都已存在,芯片却没有继续运行到入口断点,VS Code 显示了连接瞬间的随机 PC。

main 入口与第 277 行

文章中的“第 277 行”只对应当时的 Src/main.c

275  int main(void)
276  {
277    HAL_Init();

第 275 行是函数声明,第 276 行是左花括号,第 277 行是第一条可执行 C 语句。编译器会在函数入口生成保存寄存器、调整栈指针等序言指令。GDB 的源码级 break main 通常绑定到序言之后第一条带有源码行号的指令,因此停在第 277 行。

停在第 277 行时,HAL_Init() 尚未执行。这个行号会随源码增删发生变化,真正的判断标准是“停在 main 的第一条可执行语句”。如果需要观察函数序言,应使用指令级断点,例如 break *main

分层排查与启动时序根因

排查按三层进行:

  1. nmobjdump 确认 ELF 中存在 main,DWARF 行号可以映射到第 277 行。
  2. 原生 GDB 执行 monitor reset halttbreak maincontinue,硬件稳定停在第 277 行。
  3. 微软 OpenDebugAD7/cppdbg 使用相同 ELF 和探针时,首个停止位置落在其他函数。

前两层正常,问题范围由此收敛到 cppdbg 的启动阶段。MI engine 日志给出了关键顺序:

-break-insert -f main
-target-select remote localhost:3333
*stopped,frame={func="HAL_GetTick",line="315"}
-interpreter-exec console "monitor reset halt"

target-select remote 连接正在运行的目标后,pyOCD 会暂停内核并返回当前 PC。本次 PC 位于 HAL_GetTick() 第 315 行。cppdbg 将它记录为启动阶段的初始停止事件,随后没有执行原计划放在 launchCompleteCommand 中的继续命令。

monitor reset halt 完成复位后,内核仍处于暂停状态。入口断点已经创建,但 CPU 没有运行到该地址,所以 VS Code 最终显示了远程连接产生的初始停止位置。

符号表报错属于另一个时序点:setupCommands 早于默认的 -file-exec-and-symbols。删除其中依赖符号的 -break-insert main 即可,program 字段会让 cppdbg 在后续阶段加载 ELF。

-exec-continue 的作用与修复条件

-exec-continue 是 GDB/MI 命令,等价于 GDB 控制台中的 continuec。MI 是调试器提供给 IDE 使用的机器接口,命令前的 - 表示这是 MI 命令。

这条命令只负责让当前暂停的目标恢复执行。各步骤的职责如下:

断点只是一个触发条件。只有程序计数器 PC 运行到断点地址,GDB 才会再次收到停止事件。本案的有效时序为:

reset halt → -exec-continue → PC 到达 main → 入口断点命中

因此,-exec-continue 能修复本案的前提是 ELF 已加载、main 断点已创建、远程目标已连接。它需要放在 postRemoteConnectCommands 中,紧跟 monitor reset halt。放在 setupCommands 中会再次遇到符号尚未加载和目标尚未连接的问题。

最小 VS Code 配置

tasks.json 直接调用 pyOCD 烧录固件。烧录进程结束后会释放探针。

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "Flash Air001",
      "type": "process",
      "command": "pyocd",
      "args": [
        "load",
        "${workspaceFolder}/build/Project.elf",
        "-t",
        "Air001"
      ]
    }
  ]
}

launch.jsoncppdbg 启动 pyOCD GDB server。示例假设 pyocd 已加入 VS Code 进程的 PATH。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Air001: GDB Flash & Debug",
      "type": "cppdbg",
      "request": "launch",
      "preLaunchTask": "Flash Air001",
      "program": "${workspaceFolder}/build/Project.elf",
      "cwd": "${workspaceFolder}",
      "MIMode": "gdb",
      "miDebuggerPath": "arm-none-eabi-gdb",
      "miDebuggerServerAddress": "localhost:3333",
      "debugServerPath": "pyocd",
      "debugServerArgs": "gdbserver --port 3333 --target Air001",
      "serverStarted": "GDB server listening on port 3333",
      "filterStderr": true,
      "stopAtEntry": true,
      "launchCompleteCommand": "exec-continue",
      "postRemoteConnectCommands": [
        {
          "text": "-interpreter-exec console \"monitor reset halt\"",
          "ignoreFailures": false
        },
        {
          "text": "-exec-continue",
          "ignoreFailures": false
        }
      ]
    }
  ]
}

pyOCD 未加入 PATH 时,将 debugServerPath 改为本机 pyocd.exe 的完整路径。

验证结果与适用边界

重新构建并烧录后,使用与 VS Code 相同的 OpenDebugAD7/cppdbg 配置启动调试,首帧为:

{
  "function": "main()",
  "line": 277,
  "pc": "0x08002B30",
  "file": "Src/main.c"
}

硬件验证和实际 F5 启动均确认调试器会停在 main 的第一条可执行语句。本文只验证启动阶段的烧录、server 启动、符号加载、复位和入口停靠。普通断点、观察点、重启和多次连续会话仍需单独测试。

这次排查的核心方法是先用原生 GDB 证明底层链路可用,再检查 IDE 调试适配器的首个 *stopped 事件。远程调试停在随机位置时,应先确认 CPU 是否真的执行过“继续运行”这一步。