嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(5):调试进阶篇 —— 从 printf 到完整 GDB 调试环境

张开发
2026/5/4 20:34:27 15 分钟阅读
嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(5):调试进阶篇 —— 从 printf 到完整 GDB 调试环境
嵌入式C教程实战之Linux下的单片机编程从零搭建 STM32 开发工具链5调试进阶篇 —— 从 printf 到完整 GDB 调试环境仓库已经开源https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP所有完整教程 代码都在这里写给所有还在用 printf 调试 STM32 程序、想知道为什么不能像普通程序那样单步调试的朋友。本篇记录我们从零搭建完整调试环境的全过程包括 GDB Server 原理、命令行调试实战、VSCode 图形化配置以及那些让你抓狂的调试问题如何排查。为什么我一定要写调试这一篇回想一下当你写了一个普通的 C 程序想知道某变量为什么值不对的时候你会怎么做直接在 IDE 里打个断点按 F5 运行程序停在那里鼠标悬停在变量上就能看到值单步几步就能定位问题。这套流程你已经用了成千上万次根本不需要过脑子。但当你切换到 STM32 开发时世界突然变了。代码不在你的电脑上跑而是在那块几块钱的板子上你不能直接运行它只能把编译好的二进制文件烧进 Flash。程序跑起来之后你唯一能看到的反馈就是那几个 LED 的闪烁状态或者如果你幸运的话串口打印出来的一些字符。这时候你如果想知道某个变量的值只能加一句 printf重新编译、烧录、观察结果这流程慢得让人抓狂。更糟糕的是printf 调试在嵌入式环境下有严重的局限性。首先它需要串口资源如果你所有的 UART 都已经用作通信了怎么办其次 printf 会占用代码空间和时间时序敏感的代码可能因为加了 printf 就不工作了。最要命的是有些 bug 只在特定条件下出现你加了 printf 之后时序变了bug 就消失了这就是典型的海森堡bug。我在早期折腾 STM32 的时候就是靠这种原始方法过来的。每次改一点代码重新烧录盯着串口输出看半天。有次一个中断服务程序里的 bug我加了十几条打印语句烧了二十几次最后发现是因为中断优先级设置错误。如果有完整的调试环境我只需要在 ISR 里打个断点看一眼调用栈就能定位问题。所以这一篇我要带你搭建一套完整的调试环境让你能够像调试普通程序一样调试 STM32打断点、单步执行、查看变量、监视寄存器、甚至直接修改内存里的值。这套环境一旦跑通你的开发效率会提升一个数量级。先搞清楚为什么不能直接调试在开始动手之前我们得先理解一个核心问题为什么 STM32 程序不能像普通程序那样直接调试当你调试一个普通的 x86 程序时GDB 和被调试程序运行在同一台机器上它们通过操作系统提供的调试接口ptrace通信。操作系统知道进程的所有信息内存布局、寄存器状态、调用栈GDB 只需要向操作系统请求这些信息就行。但 STM32 的情况完全不同。你的程序运行在一块独立的芯片上它的 CPU、内存、外设都和你开发机器物理隔离。GDB 无法直接访问这些资源需要一个中间人来帮忙。这个中间人就是调试探针debug probe比如 ST-Link V2。调试探针通过 SWDSerial Wire Debug协议和 STM32 通信。SWD 是 ARM 专门为调试设计的一种协议只需要两根线SWDIO 和 SWCLK就能实现完整的调试功能读写内存、设置断点、单步执行、查看寄存器。ST-Link 内部有一颗专门的芯片它一边通过 USB 和你的电脑通信另一边通过 SWD 和 STM32 通信扮演着翻译官的角色。但事情还没完。ST-Link 只是硬件层面的桥梁我们还需要软件来驱动它并且把 GDB 的调试命令翻译成 SWD 协议。这个软件就是 OpenOCDOpen On-Chip Debugger。OpenOCD 可以以两种模式运行一种是直接命令模式用来烧录固件另一种是 GDB Server 模式监听一个 TCP 端口等待 GDB 连接。当你启动 OpenOCD 的 GDB Server 后完整的调试链条是这样的GDBclient通过 TCP 连接到 OpenOCDserverOpenOCD 通过 USB 和 ST-Link 通信ST-Link 通过 SWD 和 STM32 通信。这个链条上的每一环都必不可少任何一个环节出问题调试就无法进行。理解这个架构之后你就会知道为什么调试需要这么多步骤也知道出问题时该从哪个环节排查。默认情况下OpenOCD 会在 localhost:3333 端口监听 GDB 连接同时在 localhost:4444 提供 Telnet 控制台可以用来执行 OpenOCD 命令比如手动 halt、resume 等。先从命令行开始GDB 调试实战在配置图形化界面之前我强烈建议你先用命令行跑一遍完整的调试流程。这样做有两个好处一是理解底层原理知道图形界面背后实际在做什么二是当图形界面出问题时你能用命令行快速定位是配置问题还是环境问题。首先启动 OpenOCD server。打开一个终端进入你的项目目录执行openocd-finterface/stlink.cfg-ftarget/stm32f1x.cfg这条命令的含义是使用 stlink.cfg 作为接口配置告诉 OpenOCD 我们用的是 ST-Link使用 stm32f1x.cfg 作为目标配置告诉 OpenOCD 我们要调试的是 STM32F1 系列芯片。如果一切正常你会看到类似这样的输出Open On-Chip Debugger 0.12.0 Licensed under GNU GPL v2 For bug reports, read http://openocd.org/doc/doxygen/bugs.html Info : Listening on port 6666 for tcl connections Info : Listening on port 4444 for telnet connections Info : Listening on port 3333 for gdb connections最后一行告诉我们 GDB server 已经在 3333 端口准备好了。保持这个终端运行不要关闭。接下来打开另一个终端启动 GDB 并连接到 OpenOCDarm-none-eabi-gdb build/stm32_demo.elf这里我们用的是 ARM 版本的 GDBarm-none-eabi-gdb而不是系统自带的普通 GDB。参数是我们编译好的 ELF 文件里面包含了调试符号信息所以 GDB 能知道源代码行号和变量名。进入 GDB 命令行后你会看到(gdb)提示符。现在我们按顺序执行以下命令(gdb) target remote localhost:3333这条命令告诉 GDB 连接到本地的 3333 端口也就是 OpenOCD 的 GDB server。如果连接成功你会看到类似 “Remote debugging using localhost:3333” 的提示。(gdb) load这条命令把 ELF 文件里的代码段和数据段烧录到 STM32 的 Flash 和 RAM 里。你会看到进度条和 “Transfer rate XXX KB/s” 的输出。如果这里报错 “target not halted”说明芯片还在运行需要先执行monitor halt命令让芯片停下来。(gdb) break main在 main 函数入口设置一个断点。GDB 会回复 “Breakpoint 1 at 0x…”告诉你断点设置成功以及它的地址。(gdb) continue让程序继续运行。程序会立即在 main 函数的断点处停下你会看到类似这样的输出Continuing. Breakpoint 1, main () at main.cpp:42 42 HAL_Init();现在程序已经停在了 main 函数的第一行你可以开始单步调试了。step命令会进入函数内部如果当前行是函数调用而next命令会执行当前行并停到下一行不进入函数。我个人的习惯是用next为主只有在确实需要进入某个函数查看细节时才用step。查看变量用print命令(gdb) print counter如果变量是基本类型GDB 会直接显示它的值。如果是数组或结构体GDB 会显示完整的结构。你还可以用print/x以十六进制显示或者print/t以二进制显示。查看寄存器状态用info registers(gdb) info registers这会显示所有通用寄存器r0-r12、sp、lr、pc 以及特殊寄存器xPSR的当前值。在嵌入式调试中有时候你需要查看某个外设寄存器的值比如想知道 GPIOC 的 ODROutput Data Register当前是什么状态可以直接用x命令查看内存(gdb) x/wx 0x4001080Cx/wx的含义是以十六进制x显示一个字w4字节大小的内存内容。0x4001080C 是 GPIOC 的 ODR 寄存器地址这个地址需要查参考手册。GDB 会输出类似0x4001080c: 0x00002000的结果表示这个寄存器的当前值是 0x2000也就是第 13 位被置位GPIOC 的 Pin 13 是板载 LED。如果你想直接修改变量或内存的值可以用set命令(gdb) set var counter 100这在测试某些边界条件时非常有用。比如你想验证当某个计数器溢出时程序的行为可以直接把它设为接近溢出的值而不是傻傻地单步几百次。当你调试完毕想退出时用quit命令。如果芯片还在运行GDB 会问你是否要停止它选择 yes 即可。好了现在把它搬进 VSCode命令行调试确实很酷能让你显得像个老派黑客但说实话日常开发中我还是更愿意用图形界面。能看到源代码、变量列表、调用栈能直接点击设置断点这些便利性不是靠情怀能替代的。VSCode 上调试 STM32 需要安装一个插件Cortex-Debug。它是专门为 ARM Cortex 芯片设计的调试插件支持 OpenOCD、J-Link、ST-Link 等多种调试器。安装完成后我们需要创建一个.vscode/launch.json文件来配置调试行为。让我先给你一个完整的配置然后逐行解释{version:0.2.0,configurations:[{name:STM32 Debug,type:cortex-debug,request:launch,servertype:openocd,cwd:${workspaceRoot},executable:build/stm32_demo.elf,serverpath:/usr/bin/openocd,configFiles:[interface/stlink.cfg,target/stm32f1x.cfg],searchDir:[/usr/share/openocd/scripts],runToEntryPoint:main,device:STM32F103C8T6,interface:swd,serialNumber:}]}name字段是你在 VSCode 调试面板里看到的配置名称可以随便改选一个你能记住的就行。type必须是 “cortex-debug”这告诉 VSCode 用哪个插件来处理这个配置。request用 “launch” 表示我们要启动调试如果你已经有一个正在运行的 OpenOCD server也可以用 “attach” 模式。servertype指定我们用的 GDB server 类型这里填 “openocd”。如果你用 J-Link可以改成 “jlink”但对应的配置也会不同。cwd是当前工作目录用${workspaceRoot}变量会自动设置为你的项目根目录。executable是最重要的一项它指向你编译好的 ELF 文件。注意这里必须用 ELF 而不是 bin因为 ELF 包含调试符号而 bin 只是纯二进制。路径可以是相对路径相对于 workspaceRoot也可以是绝对路径。serverpath指定 OpenOCD 可执行文件的完整路径。在 Ubuntu 和 Arch 上OpenOCD 通常安装在/usr/bin/openocd但如果你手动安装到其他位置这里需要相应修改。Cortex-Debug 插件会自动启动这个 OpenOCD 实例所以你不需要自己手动启动。configFiles数组指定 OpenOCD 的配置文件。这两个文件的路径是相对于searchDir的。interface/stlink.cfg告诉 OpenOCD 我们用的是 ST-Link 调试器target/stm32f1x.cfg告诉它目标芯片是 STM32F1 系列。这些配置文件都是 OpenOCD 自带的位于/usr/share/openocd/scripts目录下大部分 Linux 发行版都是这个路径。searchDir就是我刚才说的那个脚本目录。Cortex-Debug 需要知道在哪里找那些.cfg文件所以这里要指定 OpenOCD 的脚本目录。如果你的系统上 OpenOCD 安装在其他位置比如用源码编译安装到了/usr/local这里可能需要改成/usr/local/share/openocd/scripts。runToEntryPoint是一个非常方便的选项。设为 “main” 后调试会自动在 main 函数入口处停下省去了手动设置断点的麻烦。如果你想从复位向量开始调试比如想看启动文件和系统初始化过程可以把这个选项删掉程序会在Reset_Handler处停下。device字段指定具体的芯片型号。这个信息主要被 Cortex-Debug 用来显示正确的寄存器定义和外设信息。填 “STM32F103C8T6” 就能覆盖我们的 Blue Pill 开发板。interface指定调试接口类型STM32 上一般都是 “swd”Serial Wire Debug只需要两根线。老一点的调试器可能用 “jtag”但现在很少见了。serialNumber用来指定特定的调试器如果你同时连接了多个 ST-Link大部分情况下留空即可。配置完成后回到 VSCode 主界面按 F5 或者点击左侧的运行和调试面板选择STM32 Debug调试就会启动。你会看到底部的调试控制台输出 OpenOCD 的启动信息然后程序会在 main 函数处停住。完整调试 workflow验证一切就绪现在我们有了配置是时候验证整个流程是否真的能跑通了。我会带着你走一遍完整的调试流程确保每一步都按预期工作。首先确保你的 STM32 板子已经通过 ST-Link 连接到电脑并且 OpenOCD 有权限访问 USB 设备WSL 用户记得用 usbipd attach 转发。然后在 VSCode 里按 F5 启动调试。如果一切顺利你应该会看到调试控制台输出类似这样的信息Open On-Chip Debugger 0.12.0 Info : Listening on port 3333 for gdb connections ... Info : stm32f1x.cpu: hardware has 6 breakpoints, 4 watchpoints最后一行告诉你芯片支持 6 个硬件断点和 4 个观察点这是 Cortex-M3 的标准配置。几秒钟后编辑器会自动跳到 main 函数的第一行左侧会显示一个黄色箭头指示当前执行位置。现在试试单步执行。按 F10Step Over会执行当前行并停到下一行。如果你的 main 函数第一行是HAL_Init()按 F10 后黄色箭头会移到下一行但不会进入 HAL_Init 函数内部。如果你想进入函数内部按 F11Step Into。左侧的变量面板会自动显示当前作用域内的所有局部变量和它们的值。如果变量显示optimized out说明编译器把它优化掉了你需要在 CMakeLists.txt 里把优化级别改成-O0或-Og调试优化。在监视面板里你可以手动输入想要监视的表达式。比如输入*GPIOC就能看到 GPIOC 外设的所有寄存器值输入SystemCoreClock就能看到当前系统时钟频率。这在调试时钟配置时非常有用。现在来试一个实战场景监视 GPIO 寄存器。假设你的程序在闪烁 LED你想知道 GPIOC 的 ODR 寄存器什么时候发生变化。在监视面板里输入*(volatile uint32_t*)0x4001080C这是 ODR 寄存器的地址然后按 F5Continue让程序运行。你会发现监视值会随着 LED 状态改变而改变从 0x2000 变成 0x0000 再变回来。如果你想直接修改变量的值来测试某个条件可以在变量面板里右键点击变量选择设置值或者在调试控制台里输入 GDB 命令-exec set var counter 1000-exec前缀告诉 VSCode 把后面的内容传递给 GDB 执行。这个技巧在你想测试边界条件时特别有用。调试过程中你可能会想查看调用栈。比如程序停在了某个中断服务程序里你想知道是从哪里被触发的。左侧的调用堆栈面板会显示完整的调用链从当前函数一直追溯到Reset_Handler。点击任意一层编辑器就会跳到对应的源代码位置并且上下文变量也会切换到那一层。当你调试完毕按 ShiftF5 停止调试。VSCode 会自动关闭 OpenOCD server 并断开与 ST-Link 的连接。到这里你的调试环境就完全验证完毕了。从编译、烧录到调试整个工具链已经就绪你可以开始专心写代码而不是被环境问题困扰。高级调试技巧硬件断点与内存查看上面的内容已经覆盖了 90% 的日常调试需求但有些时候你会遇到更棘手的情况这时候需要一些高级技巧。第一个要讲的是硬件断点 vs 软件断点。你可能听说过Cortex-M3 只支持 6 个硬件断点但软件断点可以设置无数个。这是什么区别呢软件断点是通过在目标地址写入一条特殊指令BKPT来实现的当 CPU 执行到这条指令时会触发调试异常。但 Flash 是只读存储器你无法在运行时修改它的内容所以软件断点只能用在 RAM 里运行的代码。硬件断点则是通过 CPU 内部的比较电路来实现不需要修改代码所以可以设在 Flash 的任何位置但数量受硬件限制Cortex-M3 是 6 个。在实践中这意味着当你设置第 7 个断点时GDB 会报错 “cannot set breakpoint” 或者断点根本不生效。解决方法有两种一是删掉不需要的断点保持活动断点在 6 个以内二是在 RAM 里运行一段代码比如把某个频繁调试的函数复制到 RAM 执行这样就可以用软件断点了。在 GDB 里你可以用info breakpoints查看当前所有断点的状态(gdb) info breakpoints Num Type Disp Enb Address What 1 hw breakpoint keep y 0x080001a8 in main at main.cpp:42注意Type列如果显示hw breakpoint说明用的是硬件断点breakpoint则是软件断点。第二个高级技巧是内存查看。有时候你想查看一大片连续内存的内容比如整个 DMA 缓冲区或者某个结构体数组。用x命令可以实现(gdb) x/10wx 0x20000000这条命令会从 0x20000000 开始以十六进制显示 10 个字每个字 4 字节的内容。x/10gx则可以显示 64 位整数8 字节这在查看双精度浮点数数组时很有用。在 VSCode 里你可以在监视面板输入数组名称来查看数组内容但如果你想查看原始内存可以在调试控制台执行-exec x/32xb 0x20000000这会以字节为单位显示 32 字节的内存内容b表示 byte。这在调试内存对齐问题、DMA 传输问题时非常有用。第三个技巧是关于 RTOS 调试。如果你用了 FreeRTOS 之类的 RTOS你会发现调用栈里充满了xTaskResumeAll、vTaskSwitchContext之类的函数很难找到当前任务的真正入口。Cortex-Debug 插件支持 RTOS 感知调试但需要额外配置。在launch.json里添加rtos:FreeRTOS,rtosConfigFile:${workspaceRoot}/third_party/FreeRTOS/FreeRTOS/Source/include/FreeRTOS.h配置后调试面板会显示一个线程下拉框里面列出所有当前创建的任务你可以像调试多线程程序一样在不同任务之间切换。最后一个要讲的技巧是 SWOSerial Wire Output。SWO 是 ARM Cortex-M 的一种特性可以通过 SWD 接口的高速通道输出调试信息不需要占用 UART 资源而且比 printf 快得多。但 SWO 的配置相对复杂需要设置波特率、配置 TRACETCK 引脚而且不是所有 ST-Link 都支持ST-Link V2 才支持。这块内容比较独立我计划在后续文章里单独讲一篇。常见调试问题排查就算你照着上面的步骤一步步来也难免会遇到各种奇奇怪怪的问题。调试环境涉及的环节多任何一个地方出问题都会导致调试失败。我把我踩过的坑整理了一下按症状分类希望能帮你快速定位。最常见的问题是Error: target not halted。这个错误通常出现在你执行load命令的时候原因是 OpenOCD 无法在芯片运行时烧录 Flash。解决方法是在 load 前先执行monitor halt(gdb) monitor halt (gdb) loadmonitor前缀告诉 GDB 把后面的命令传递给 OpenOCD 而不是自己执行。halt命令会让 CPU 停下来进入调试模式。如果 halt 也报错可能是芯片处于低功耗模式需要更长时间才能唤醒或者 SWD 连接不稳定。第二个常见错误是Error: undefined debug reason 8。这个错误我遇到时也是一头雾水最后查资料发现是因为芯片处于睡眠或停止模式Sleep/Stop Mode调试器无法正常唤醒它。解决方法是在进入低功耗模式前禁用调试器睡眠或者按复位按钮强制芯片退出低功耗状态。第三种情况是断点打上了但程序不停在那里。这有几个可能原因。一是你确实超过了硬件断点限制6 个删掉几个没用的断点试试。二是代码可能根本没被加载到那个地址检查load命令的输出确保确实写入了正确的 Flash 区域。三是代码被优化掉了优化器可能把你打断点的代码整个删除了把编译优化改成-O0再试试。第四个问题是变量显示optimized out或者显示的值明显不对。这几乎都是编译优化导致的。你在调试版本里应该用-Og专门为调试优化的模式或者-O0完全关闭优化而不是-O2或-O3。在 CMakeLists.txt 里你可以为 Debug 配置单独设置优化级别add_compile_options( $$CONFIG:Debug:-Og $$CONFIG:Release:-O2 )还有一种情况是内联函数里的变量因为代码被内联了原来的局部变量可能已经被优化到寄存器里或者彻底消失了GDB 无法追踪。这种情况下你可以用-fno-inline禁止内联或者干脆在更高一层打断点。第五种问题是 VSCode 无法连接到 OpenOCD。错误信息可能是 “Failed to connect to GDB” 或者 “Could not connect to localhost:3333”。首先确认 OpenOCD 没有在其他地方运行比如你之前手动启动的实例还没关闭然后用netstat -tlnp | grep 3333检查端口是否被占用。如果端口被占用要么关掉占用进程要么在launch.json里改用其他端口但 OpenOCD 默认就是 3333改端口需要额外配置不推荐。如果 OpenOCD 根本没启动检查serverpath是否正确。在终端里直接执行/usr/bin/openocd --version如果命令不存在说明 OpenOCD 没安装或者安装在其他位置。用which openocd找到正确路径然后更新launch.json。WSL 用户还有一个特殊问题USB 权限。错误信息通常是LIBUSB_ERROR_ACCESS或者could not open device。首先确认 ST-Link 已经被 usbipd 转发到 WSLlsusb | grep -i stlink应该能看到设备然后用我之前提到的脚本修复权限sudochmod666/dev/bus/usb/001/XXX最后的救命招数是查看 OpenOCD 的详细日志。在launch.json里添加openOCDLaunchCommands:[debug_level 3]这会让 OpenOCD 输出最详细的调试信息虽然看不懂大部分内容但至少能知道它在哪一步卡住了。你也可以在终端手动启动 OpenOCD 并观察输出很多错误信息只有在那里才会显示。到这里就大功告成了如果你跟着前面几篇文章一路走来到现在应该已经拥有了一套完整的 STM32 开发工具链交叉编译器、CMake 构建系统、HAL 库、OpenOCD 烧录工具以及现在刚刚配置好的 GDB 调试环境。从编译、烧录到调试整个流程都能在 Linux 下完成不再依赖 Keil 这种 Windows 专属的 IDE。当你第一次在 VSCode 里按 F5看着程序在 main 函数断点处停下然后单步几行、修改一个变量的值、看着 LED 随之改变闪烁频率那种掌控感是无与伦比的。你不再是盲目地烧录、猜测、再烧录而是能精确地观察程序的每一步执行这才是嵌入式开发应该有的体验。从 Keil 迁移到这套工具链除了跨平台的优势之外还有很多实实在在的好处。你可以用 Vim/Neovim 写代码用 clangd 获得比任何商业 IDE 都强大的代码补全用 Git 管理版本不用再应付那些奇怪的工程文件用 CTest 运行自动化测试。更重要的是这套工具链完全开源、完全可定制遇到问题时你可以阅读源码、修改配置而不是被困在一个黑盒子里。下一步我们终于可以开始讲现代 C 在嵌入式中的应用了。模板、RAII、lambda 表达式、constexpr这些 C 特性如何在资源受限的 STM32 上发挥作用如何写出既现代又高效的嵌入式代码这才是这套教程的真正核心前面的工具链搭建都只是在做准备。但现在有了这套工具链我们可以专心于代码本身而不是被环境问题分心。

更多文章