1. 为什么我要从 Keil 换到 VS Code 调试 STM32
我第一次用 Keil 调 STM32 的时候,觉得这东西挺省心:装好芯片包,新建工程,点一下 Debug 按钮,断点、单步、看寄存器,一套流程走下来确实顺。但用得越久,问题就越明显。Keil 的编辑器体验停留在十几年前,代码补全基本靠猜,多文件跳转慢得让人抓狂,版本管理更是灾难——.uvprojx文件一冲突,整个工程就得重来。更别提那个深色主题,看久了眼睛真的累。
后来我开始尝试用 VS Code 写 STM32 代码。一开始只是把它当编辑器用,写完代码再切回 Keil 编译下载。这样用了大概两个月,总觉得差点意思——既然 VS Code 能写代码,为什么不能顺便把调试也接管了?于是我开始研究怎么在 VS Code 里直接调试 STM32。
这条路走下来,踩的坑不算少,但收益是实打实的。现在我的工作流是:VS Code 写代码、编译、下载、调试一条龙,Keil 只留着偶尔看看寄存器手册。调试体验上,VS Code 的变量监视、调用栈、内存查看这些功能,配合 Cortex-Debug 插件,完全不输 Keil,甚至在界面响应速度和自定义程度上还更好。
这篇内容适合两类人:一类是已经用 VS Code 写 STM32 代码,但还在用 Keil 或 ST-Link Utility 下载调试的;另一类是刚接触 STM32,想直接搭建一套现代化开发环境的。我会把整个配置过程拆开讲清楚,包括工具链选型、配置文件怎么写、调试时怎么看变量、遇到问题怎么排查。所有步骤都是我实际跑通过的,参数和配置可以直接抄。
2. 调试方案的整体设计与工具选型
2.1 为什么选 Cortex-Debug 而不是其他方案
VS Code 调试 STM32,核心插件就一个:Cortex-Debug。这个插件在 VS Code 市场里搜一下就能找到,安装量很高,维护也算活跃。它的作用是把 GDB 和 VS Code 的调试界面连起来,让你能在 VS Code 里打断点、单步、看变量。
那为什么不用其他方案?我试过几种:
- PlatformIO:确实方便,一键安装工具链,但它的调试配置是封装好的,想自定义参数比较麻烦,而且对某些国产芯片支持不够及时。
- STM32CubeIDE:本质上是 Eclipse 套壳,调试功能没问题,但编辑器体验和 VS Code 差距太大,而且资源占用高。
- OpenOCD + GDB 命令行:最灵活,但每次调试都要敲命令,效率太低,不适合日常开发。
Cortex-Debug 的好处是:它只负责“连接”这一层,底层的 GDB、OpenOCD 你可以自己选版本、自己配参数。这样既保留了灵活性,又有 VS Code 的图形界面。而且它支持多种调试探针,ST-Link、J-Link、CMSIS-DAP 都能用,换硬件不用换插件。
2.2 工具链的组成与各自职责
整套调试环境由四部分组成,我画个表说明各自干什么:
| 组件 | 作用 | 常见选择 |
|---|---|---|
| 编辑器 | 写代码、发起调试 | VS Code |
| 调试插件 | 连接 VS Code 和 GDB | Cortex-Debug |
| 调试服务器 | 跟硬件探针通信 | OpenOCD / ST-Link GDB Server |
| 调试器 | 执行调试命令 | arm-none-eabi-gdb |
| 编译工具链 | 生成可执行文件 | arm-none-eabi-gcc |
这里容易混淆的是“调试服务器”和“调试器”。简单说,OpenOCD 负责跟 ST-Link 硬件打交道,GDB 负责跟 OpenOCD 打交道,Cortex-Debug 负责跟 GDB 打交道,VS Code 负责跟你打交道。每一层都有明确的职责,出问题的时候也可以分层排查。
2.3 调试探针的选择:ST-Link 还是 J-Link
如果你用的是 STM32 官方开发板,板载的基本都是 ST-Link。ST-Link 便宜、够用,配合 OpenOCD 或 ST-Link GDB Server 都能工作。我手头一块 Nucleo-F411 和一块自己画的 F103 板子,用的都是 ST-Link V2 克隆版,十几块钱,调试从来没出过问题。
J-Link 的速度更快,支持更多芯片,但价格贵不少。如果你只是调 STM32,ST-Link 完全够用。需要注意的是,ST-Link 克隆版在升级固件时可能会变砖,所以没事别乱升级。我有一块就是手贱点了升级,结果识别不到了,后来用 ST-Link Utility 重新烧录固件才救回来。
提示:如果你用的是 CMSIS-DAP 探针(比如 DAPLink),Cortex-Debug 也支持,配置里把
servertype改成openocd,然后指定对应的配置文件就行。
3. 环境搭建的完整实操步骤
3.1 安装必要软件与插件
先把该装的都装上,顺序无所谓,但建议按下面的清单逐个确认:
- VS Code:官网下载安装,建议选 System Installer 版本,避免用户目录权限问题。
- Cortex-Debug 插件:在 VS Code 扩展面板搜索
Cortex-Debug,安装。 - arm-none-eabi-gcc:用于编译。Windows 下推荐用 xPack 的构建包,或者直接装 STM32CubeCLT,里面自带 GCC。
- OpenOCD:用于连接调试探针。xPack 也有构建包,下载后解压,把
bin目录加到系统 PATH。 - arm-none-eabi-gdb:通常跟 GCC 一起安装,确认
bin目录下有arm-none-eabi-gdb.exe。
装完之后,打开终端验证一下:
arm-none-eabi-gcc --version openocd --version arm-none-eabi-gdb --version三条命令都能输出版本号,说明环境变量配好了。如果提示找不到命令,检查 PATH 是否包含对应bin目录。
3.2 确认工程能正常编译
调试的前提是有一个能编译通过的工程。我用的是 STM32CubeMX 生成的 Makefile 工程,因为 Makefile 工程跟 VS Code 配合最顺,不依赖 Keil 的工程文件。
如果你用的是 Keil 工程,有两个选择:一是用 STM32CubeMX 重新生成 Makefile 工程,二是用make配合 Keil 的 armcc 编译器(比较麻烦,不推荐)。我建议直接重新生成,CubeMX 里把 Toolchain 选成 Makefile,生成的代码结构清晰,编译速度快。
生成之后,在工程根目录执行:
make -j8如果能生成.elf和.bin文件,说明编译没问题。记下.elf文件的路径,后面调试配置里要用。
3.3 编写 VS Code 调试配置文件
在工程根目录新建.vscode文件夹,里面创建launch.json。这是 Cortex-Debug 的核心配置文件,我直接给一份我常用的模板:
{ "version": "0.2.0", "configurations": [ { "name": "Debug (OpenOCD)", "type": "cortex-debug", "request": "launch", "servertype": "openocd", "cwd": "${workspaceRoot}", "executable": "${workspaceRoot}/build/your_project.elf", "device": "STM32F103C8", "configFiles": [ "interface/stlink.cfg", "target/stm32f1x.cfg" ], "svdFile": "${workspaceRoot}/STM32F103.svd", "runToEntryPoint": "main", "preLaunchTask": "Build" } ] }几个关键字段说明一下:
executable:指向编译生成的.elf文件,路径要对。device:芯片型号,Cortex-Debug 会根据这个自动选一些参数,但主要还是靠configFiles。configFiles:OpenOCD 的配置文件,第一个是探针配置,第二个是芯片配置。这两个文件在 OpenOCD 安装目录的scripts文件夹里,路径要写对。svdFile:SVD 文件,用于在调试时查看外设寄存器。这个文件可以从 ST 官网下载,或者从 Keil 的芯片包里找。runToEntryPoint:启动后自动运行到main函数,省得手动点继续。
如果你用的是 ST-Link GDB Server 而不是 OpenOCD,配置会不一样:
{ "name": "Debug (ST-Link)", "type": "cortex-debug", "request": "launch", "servertype": "stlink", "executable": "${workspaceRoot}/build/your_project.elf", "device": "STM32F103C8", "runToEntryPoint": "main" }ST-Link GDB Server 的好处是配置简单,不用管 OpenOCD 的脚本路径。但它的灵活性不如 OpenOCD,比如想调 SWO 输出就麻烦一些。
3.4 配置编译任务
launch.json里有个preLaunchTask,指向一个 VS Code 任务,用于在调试前自动编译。在.vscode文件夹里新建tasks.json:
{ "version": "2.0.0", "tasks": [ { "label": "Build", "type": "shell", "command": "make", "args": ["-j8"], "group": { "kind": "build", "isDefault": true }, "problemMatcher": ["$gcc"] } ] }这样每次按 F5 调试,VS Code 会先执行make -j8,编译成功后才启动调试。如果编译报错,调试不会启动,省得你调了半天发现跑的是旧固件。
3.5 第一次调试的检查清单
配置写完后,按 F5 启动调试。如果一切正常,你会看到:
- 底部状态栏变成橙色,表示进入调试模式。
- 代码停在
main函数入口。 - 左侧出现变量、监视、调用栈、外设寄存器等面板。
如果没成功,按下面的顺序排查:
- 探针是否被识别:打开设备管理器,看 ST-Link 是否出现在 USB 设备里。如果没有,换根 USB 线试试。
- OpenOCD 能否独立运行:在终端执行
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg,看能否输出芯片信息。如果报错,说明 OpenOCD 配置有问题。 - GDB 能否连接:OpenOCD 运行后,另开终端执行
arm-none-eabi-gdb,然后输入target remote localhost:3333,看能否连上。 - elf 文件路径是否正确:检查
launch.json里的executable路径,确保文件存在。
这四步能定位大部分问题。我遇到过最常见的是 OpenOCD 脚本路径写错,导致找不到配置文件。
4. 调试过程中的核心功能与实操技巧
4.1 断点、单步与变量监视
VS Code 的调试界面跟 Keil 比,最大的优势是布局灵活。你可以把变量面板拖到右边,把调用栈放左边,外设寄存器放底部,完全按自己的习惯来。
打断点很简单,点行号左边就行。条件断点也支持:右键断点,选“编辑断点”,输入条件表达式,比如i == 100。这个在调循环的时候特别有用,不用手动点一百次继续。
变量监视有两个地方:一个是“变量”面板,自动显示当前作用域的局部变量;另一个是“监视”面板,可以手动添加表达式,比如&huart1或者tim1->CNT。我习惯把常用的外设句柄加到监视里,调试的时候一眼就能看到状态。
这里有个细节:Cortex-Debug 默认可能不显示结构体内部成员,需要展开才能看。如果你觉得麻烦,可以在launch.json里加一行:
"showDevDebugOutput": "raw"这样 GDB 的输出会更详细,但也会更吵。我一般不开,需要的时候再临时加。
4.2 外设寄存器查看与 SVD 文件配置
SVD 文件是调试 STM32 的利器。没有它,你只能看内存地址;有了它,你可以直接看GPIOA->ODR、TIM1->CR1这些寄存器,而且每个位域都有名字和说明。
配置方法是在launch.json里指定svdFile路径。SVD 文件可以从几个地方获取:
- ST 官网的芯片页面,下载“SVD”文件。
- Keil 芯片包安装目录下,
Keil_v5/ARM/PACK/Keil/STM32F1xx_DFP/x.x.x/Device/Include里面。 - GitHub 上有一些开源维护的 SVD 集合。
我一般从 Keil 包里拿,因为版本跟芯片匹配。拿到之后放到工程目录,路径写对就行。
调试时,左侧会出现“外设寄存器”面板,按外设分组。展开 GPIOA,你能看到 MODER、OTYPER、ODR、IDR 等寄存器,每个位的值实时更新。调 GPIO 的时候,不用再手动算地址,直接看就行。
注意:SVD 文件如果跟芯片型号不匹配,可能会显示错误的寄存器地址。比如 F103 的 SVD 用在 F407 上,外设基地址不一样,看了反而误导。所以一定要选对型号。
4.3 内存查看与变量修改
调试的时候经常需要看一段内存,比如 DMA 缓冲区、数组、堆栈。VS Code 的调试界面没有直接的内存查看面板,但可以通过“监视”面板实现。在监视里输入:
*(uint8_t*)0x20000000@256这表示从地址0x20000000开始,看 256 个字节。@后面的数字是长度。你也可以用变量名:
rx_buffer@128这样就能看到数组内容。如果数据是十六进制的,GDB 默认按十进制显示,可以在表达式后面加格式符,比如/x:
/x rx_buffer@128修改变量值也很简单:在变量面板里双击值,输入新值,回车。这个在测试边界条件的时候特别方便,不用改代码重新编译。
4.4 调用栈与函数跳转
调用栈面板显示当前函数的调用链。点某一层,编辑器会跳到对应的代码位置,同时变量面板会切换到那个函数的作用域。这个在排查“这个函数是谁调的”这类问题时非常高效。
我遇到过一个问题:程序跑飞了,停在 HardFault_Handler。这时候看调用栈,能直接看到是从哪个函数跳过来的。如果调用栈显示不全,可能是优化等级太高,把栈帧优化掉了。解决办法是在调试配置里把优化改成-O0,或者用-Og。
4.5 调试时的编译优化陷阱
说到优化,这里有个大坑。STM32 工程默认可能是-O2或-Os,编译出来的代码执行效率高,但调试信息会失真。具体表现是:
- 断点位置不准,明明打在
a = 1这行,却跳到了下一行。 - 变量值显示
<optimized out>,看不到实际值。 - 单步执行时跳来跳去,不按代码顺序走。
解决办法是在 Makefile 里把调试版本的优化改成-O0 -g3。CubeMX 生成的 Makefile 通常有DEBUG = 1的开关,打开后会自动用-O0。如果没有,手动改CFLAGS:
CFLAGS += -O0 -g3 -gdwarf-2-g3包含宏定义信息,-gdwarf-2是调试信息格式,GDB 支持得很好。改完之后重新编译,调试体验会好很多。
5. 常见问题排查与避坑经验
5.1 探针连接失败的五种原因
调试最常遇到的问题就是连不上探针。我整理了一个排查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
OpenOCD 报no device found | USB 线只供电不传数据 | 换一根数据线 |
报target not halted | 芯片处于低功耗模式 | 按住复位键再启动调试 |
报invalid ACK | SWD 速率太高 | 在配置里加adapter speed 1000 |
报flash protected | 读保护开启 | 用 ST-Link Utility 解除保护 |
| 时连时断 | 电源不稳或线太长 | 换短线,加滤波电容 |
其中“按住复位键”这招很实用。有些板子程序一跑起来就进低功耗,SWD 来不及连接。按住复位,点调试,等 OpenOCD 连上再松手,基本都能连上。
5.2 断点不生效的几种情况
断点打了但不停,通常有几个原因:
- 优化等级太高:前面说过,改成
-O0。 - 代码没下载:
preLaunchTask没配好,跑的是旧固件。检查编译输出时间。 - 断点打在未使用的函数里:链接器可能把没调用的函数优化掉了,代码根本不存在。
- Flash 断点数量超限:STM32 的硬件断点有限,一般 6 个左右。打太多会失效,改用软件断点或减少数量。
我习惯在main函数开头打一个断点,确认程序确实跑到了这里。如果这个断点都不停,说明下载或连接有问题。
5.3 变量显示异常的排查思路
变量显示<optimized out>是最常见的。除了改优化等级,还可以:
- 把变量声明为
volatile,防止编译器优化掉。 - 在监视里用
*(volatile uint32_t*)&var强制读取。 - 如果变量在中断里修改,主循环里读,一定要加
volatile,否则编译器可能只读一次。
还有一种情况是变量地址不对。比如局部变量在栈上,函数返回后栈被复用,再看这个变量就是垃圾值。这时候要看调用栈,切到对应的栈帧再看。
5.4 OpenOCD 配置文件路径问题
OpenOCD 的-f参数支持相对路径和绝对路径。相对路径是相对于 OpenOCD 的scripts目录,不是当前工作目录。所以interface/stlink.cfg能直接找到,但如果你写./stlink.cfg就找不到。
我建议在launch.json里用绝对路径,或者把 OpenOCD 的scripts目录加到环境变量OPENOCD_SCRIPTS里。这样不管在哪个目录启动,都能找到配置文件。
5.5 调试时程序跑飞的处理
程序跑飞进 HardFault,调试器可能也断了。这时候可以:
- 在
HardFault_Handler里加死循环,防止继续跑飞。 - 调试时看调用栈,找到出错前的函数。
- 检查栈溢出:在
launch.json里看 SP 寄存器,如果接近栈顶,说明栈不够用。 - 检查数组越界:用内存查看功能,看缓冲区前后的数据有没有被踩。
我遇到过一次栈溢出,原因是局部数组太大,默认栈只有 1KB。后来在启动文件里把栈改成 4KB,问题解决。启动文件里Stack_Size那个宏就是干这个的。
6. 进阶技巧:让调试效率翻倍
6.1 多工程配置切换
如果你同时调多个芯片,可以在launch.json里配多个 configuration,每个对应一个工程。VS Code 左上角的调试下拉框可以切换。我一般按芯片型号命名,比如F103 OpenOCD、F411 ST-Link,切换的时候一目了然。
6.2 用任务自动化常用操作
除了编译,还可以配一些其他任务,比如:
Flash:只下载不调试。Erase:擦除整片 Flash。Reset:复位芯片。
这些任务用 OpenOCD 命令实现,配在tasks.json里,需要的时候从命令面板运行。比如擦除:
{ "label": "Erase", "type": "shell", "command": "openocd", "args": [ "-f", "interface/stlink.cfg", "-f", "target/stm32f1x.cfg", "-c", "init; reset halt; stm32f1x mass_erase 0; exit" ] }6.3 结合串口调试助手看输出
调试的时候,除了看变量,经常还要看串口输出。我一般开两个窗口:VS Code 调试,串口调试助手看日志。串口助手用 SSCOM 或者 VS Code 的 Serial Monitor 插件都行。
如果想让串口输出和调试信息在同一个界面,可以用 Cortex-Debug 的 SWO 功能。SWO 是 Cortex-M 的调试输出通道,不占用串口。配置里加:
"swoConfig": { "enabled": true, "source": "probe", "swoFrequency": 2000000, "cpuFrequency": 72000000, "decoders": [ { "type": "console", "label": "ITM", "port": 0 } ] }然后在代码里用ITM_SendChar()输出。这样调试的时候,输出直接显示在 VS Code 的终端里,不用切窗口。不过 SWO 需要探针支持,ST-Link V2 克隆版有的不支持,得试。
6.4 调试信息保存到日志
有时候调试过程需要记录,比如给同事看,或者自己复盘。VS Code 的调试输出可以保存:在调试控制台右键,选“保存输出”。或者用launch.json里的logging配置:
"logging": { "engineLogging": true, "trace": true, "traceResponse": true }这样 GDB 的交互会记录到文件里。不过输出比较底层,适合排查复杂问题。
7. 我踩过的几个坑和最终建议
第一个坑是 OpenOCD 版本。我一开始用的是某教程推荐的旧版本,结果不支持我的 ST-Link 固件,连不上。后来换成 xPack 的最新版,问题解决。所以工具链尽量用新的,别用太老的版本。
第二个坑是 SVD 文件路径。我一开始把 SVD 放在工程根目录,路径写的是相对路径,结果调试时找不到。后来改成${workspaceRoot}/STM32F103.svd才行。VS Code 的变量替换要写对。
第三个坑是优化等级。我调一个 PID 控制的时候,变量老是显示<optimized out>,查了半天才发现 Makefile 里默认是-Os。改成-O0之后,所有变量都能看了。所以调试版本和发布版本一定要分开配置。
第四个坑是断点数量。我调一个状态机的时候,一口气打了十几个断点,结果后面几个不生效。后来才知道 STM32 的硬件断点有限,改成条件断点或者减少数量就好了。
现在我的日常流程是:VS Code 写代码,F5 调试,变量面板看状态,外设寄存器面板看硬件,串口助手看日志。Keil 只留着看芯片手册和偶尔用一下它的模拟器。这套环境搭好之后,开发效率比纯 Keil 高不少,尤其是代码量大的项目,VS Code 的搜索、跳转、重构功能省了很多时间。
如果你刚开始搭,建议先按最小配置跑通:一个 LED 闪烁工程,能编译、能下载、能打断点。跑通之后再逐步加 SVD、SWO、多工程配置。别一上来就搞全套,容易卡在某个细节上。