在日常开发中,总会遇到一些出人意料的bug,程序跑飞,上电就挂,程序没有按预期执行诸如此类的问题,没有好的调试方法,真的很难定位问题,更别说解决了。在这里分享我用过的一些调试方法,抛砖引玉。
MCU调试
学生时代大家都知道在线调试,这个可以实时查看外设寄存器、cpu通用寄存器数据以及堆栈,可以打上软断点(有数量限制)。这种调试属于侵入式调试,有缺点。
咱们以stm32为例,使用 J-Link 进行调试时,涉及的调试技术和接口主要是 JTAG 或 SWD(Serial Wire Debug),这两种接口允许调试器控制目标微控制器的状态,执行读取内存、设置断点等操作。这种调试方式属于侵入式调试,因为它会对系统的正常运行产生干扰。接下来,我将详细解释这种调试的具体实现及其原理。
调试接口
STM32 微控制器通常支持以下两种调试接口:
- JTAG(Joint Test Action Group):这是早期常见的调试协议,使用多个引脚进行调试,通常需要 5-6 根信号线(如 TCK、TMS、TDI、TDO、TRST 等)。这种接口提供了比较丰富的调试和测试功能。
- SWD(Serial Wire Debug):这是 ARM Cortex-M 微控制器系列的标准调试接口,它是 JTAG 的简化版,只需要 2 根信号线(SWDIO 和 SWCLK)就能进行全功能的调试和编程,因此更节省引脚资源,尤其适用于嵌入式系统。
侵入式调试原理
侵入式调试的核心是在调试器与 MCU 之间建立一种交互式控制,通过调试接口向 MCU 发出命令,读取 MCU 状态或改变其执行流程。调试器会通过调试接口连接 MCU 的内部调试单元(例如 ARM Cortex-M 核心中的 CoreSight 调试架构)。具体操作如下:
步骤:
- 连接调试接口:J-Link 通过 JTAG 或 SWD 连接到 STM32 芯片,调试器可以通过这些接口访问 MCU 的内部寄存器、内存和其他调试资源。
- 调试器控制 MCU:
- 单步执行(Single Stepping):调试器能够让 MCU 一次执行一条指令,方便用户逐行查看代码的执行情况。
- 设置断点(Breakpoint):用户可以在程序的某个位置设置断点,当 MCU 执行到这个位置时,它会自动暂停,让用户检查程序状态。
- 修改寄存器/内存:调试器可以直接修改 MCU 内部寄存器或存储器的值,允许用户在不重新编译代码的情况下改变程序的行为。
- 暂停和恢复运行:在调试过程中,调试器会暂停 MCU 的正常执行,这就是侵入式调试的侵入性部分。暂停后,调试器可以获取当前的寄存器值、内存内容等调试信息。调试结束后,调试器会恢复 MCU 的运行。
调试时的影响:
- 实时性丢失:由于在调试过程中 MCU 被暂停执行,因此 MCU 不再能够实时响应外部事件。这在某些需要实时响应的系统中可能会引发问题。
- 调试开销:虽然 SWD 是一种较为轻量的调试方式,但依然需要占用部分系统资源,如调试接口的引脚、调试单元的部分 CPU 时钟等。
非侵入式调试对比
相比之下,非侵入式调试不会干扰系统的正常运行。ARM Cortex-M 核心提供了 ETM(Embedded Trace Macrocell) 等调试模块,允许程序运行时记录执行流,并通过外部工具分析,但不改变程序的运行状态。这种方式不需要暂停 MCU,也不会影响其实时性。调试工具有J-trace、劳特巴赫(好用),缺点贵,和canoe有一拼。
J-link调试
J-Link 调试器主要依赖编译生成的 可调试文件 来执行调试操作,而非直接使用 .hex
文件。
通常,调试器需要的是包含符号信息的文件,例如:
- .elf(Executable and Linkable Format):这是最常用的文件格式,包含完整的调试符号和程序代码。调试器通过读取这个文件,能够将内存地址与源代码中的变量、函数对应起来,从而方便地进行断点设置、单步调试和变量监控。
调试器和调试符号:J-Link 调试器在调试时,主要使用的文件是编译器生成的带有调试符号的文件(如 .elf
)。该文件包含:
- 符号表:映射源代码变量、函数到目标代码的内存地址。
- 源代码信息:调试器能将二进制指令与源代码行号对应,帮助开发人员调试程序。
调试过程:
在实际调试过程中,J-Link 调试器会通过工具链或 IDE(如 Keil, IAR 等)读取 .elf
文件的调试符号和目标设备的内存状态,允许用户进行断点设置、单步调试、变量监控等操作。
编程和调试的区别:
- 编程:如果你只是将程序下载到 STM32 设备上运行,可以使用
.bin
文件或.hex
文件。 - 调试:进行调试时,需要使用带有调试符号的文件(如
.elf
),以便调试器识别出源代码与内存之间的映射关系。
当然,咱们都要考虑周全,你可能会遇到没有调试器的情况,可能还会出现这种情况,在线调试没问题,但是脱离调试器,程序上电就跑飞了…这些小昭都遇到过,直接吐血,有机会好好探讨。
不使用调试器方法
栈回溯(Stack Backtrace) 是一种常见的方法,用于在程序卡死、异常(如 HardFault)或意外重启时,帮助开发工程师查找问题所在的函数调用栈和程序崩溃时的上下文信息。
栈回溯的原理
栈回溯的核心思想是通过分析堆栈帧(Stack Frame)来重建程序执行时的函数调用顺序。当程序执行一个函数时,会在栈中保存返回地址和函数的局部变量。当发生异常或崩溃时,栈中的这些信息仍然保留,可以通过遍历堆栈找到函数调用链。
在 ARM Cortex-M 的异常处理机制下,CPU 会自动保存一些核心寄存器的值(如 PC、LR、SP 等)到堆栈中,这为我们进行栈回溯提供了重要的信息。
如何实现栈回溯
- 获取发生异常时的堆栈帧
当 STM32 进入异常(如 HardFault)时,可以通过异常处理函数获取堆栈指针,并根据堆栈帧中的内容进行回溯分析。- 异常处理函数(HardFault_Handler) 会将出错时的 CPU 状态保存到栈中。
- Cortex-M 处理器在发生硬件异常时,通常会将以下寄存器内容压入堆栈:R0-R3、R12、LR(Link Register)、PC(Program Counter)和 xPSR。
- 读取堆栈指针:
可以在HardFault_Handler
中通过堆栈指针(SP)来访问这些寄存器的值。 - 回溯函数调用栈:
通过逐步读取栈中的内容(包括返回地址),并结合符号表(如.elf
文件),你可以找到程序在崩溃时的调用链条。
实现步骤
1. 捕获异常
首先,需要在异常处理程序中捕获异常,常见的异常是 HardFault。可以使用如下代码捕获发生 HardFault 时的堆栈帧信息:
c
复制代码
void HardFault_Handler(void) {
__asm volatile
(
"TST lr, #4 \n" // 检查异常的堆栈是否在主堆栈(MSP)还是进程堆栈(PSP)
"ITE EQ \n"
"MRSEQ r0, MSP \n" // 如果是 MSP, 将 MSP 的地址赋值给 r0
"MRSNE r0, PSP \n" // 如果是 PSP, 将 PSP 的地址赋值给 r0
"B hard_fault_handler_c \n" // 跳转到 C 语言处理函数
);
}
2. 提取堆栈信息
hard_fault_handler_c
函数将根据堆栈帧提取重要的寄存器值,如 PC 和 LR。
c
复制代码
void hard_fault_handler_c(unsigned int *hardfault_args) {
unsigned int stacked_r0 = hardfault_args[0];
unsigned int stacked_r1 = hardfault_args[1];
unsigned int stacked_r2 = hardfault_args[2];
unsigned int stacked_r3 = hardfault_args[3];
unsigned int stacked_r12 = hardfault_args[4];
unsigned int stacked_lr = hardfault_args[5]; // Link register
unsigned int stacked_pc = hardfault_args[6]; // Program counter
unsigned int stacked_psr = hardfault_args[7]; // Program status register
// 现在你可以通过 UART 或者其他方式将这些值输出,用于分析
printf("Hard fault:\n");
printf("R0 = %08X\n", stacked_r0);
printf("R1 = %08X\n", stacked_r1);
printf("R2 = %08X\n", stacked_r2);
printf("R3 = %08X\n", stacked_r3);
printf("R12 = %08X\n", stacked_r12);
printf("LR = %08X\n", stacked_lr); // 用于回溯函数调用链
printf("PC = %08X\n", stacked_pc); // 指向出错的指令地址
printf("xPSR = %08X\n", stacked_psr);
}
3. 回溯调用链
通过 LR(链接寄存器),可以回溯调用链。LR 存储的是调用函数的返回地址(即函数返回时需要跳转到的地址)。如果 LR 的最低有效位为 1,表示返回地址是在 Thumb 指令集模式下执行的。
你可以根据 LR 的值继续遍历栈,找到前一个调用的函数地址,并逐步回溯整个调用链。
4. 使用符号表解析地址
堆栈中的 PC 和 LR 是程序的内存地址,通过符号表(如 .elf
文件)可以将这些内存地址映射回具体的源代码位置。你可以使用工具(如 arm-none-eabi-addr2line
)来根据地址找出对应的源代码行:
bash
复制代码
arm-none-eabi-addr2line -e firmware.elf 0x08001234
这条命令会告诉你 0x08001234
地址对应的源代码文件和行号。
注意事项
- 堆栈完整性:栈回溯依赖于函数调用过程中保存的堆栈帧信息,因此,如果程序发生了堆栈溢出或内存被篡改,回溯的结果可能不可靠。
- 优化级别:在高优化级别下,编译器可能会对函数调用进行优化(如内联函数),导致无法准确回溯调用链。
其他补充方法
- 软件看门狗结合栈回溯:在结合看门狗定时器重启系统的同时,通过
HardFault_Handler
捕获堆栈信息,可以记录下系统崩溃的关键点。 - 软件调试器输出:在崩溃发生时输出栈帧信息,或者使用 LED 或 UART 输出执行的关键步骤。
bin文件修改方式调试
通过修改 .bin
文件来定位 程序卡死问题的方法,可能是指插桩(Instrumentation)技术。这种方法是通过在编译生成的二进制文件中手动插入代码,在不重新编译程序的情况下加入一些用于调试的功能,比如记录程序运行状态或函数调用情况。
这种方法可以通过在 .bin
文件中的特定位置手动插入日志输出代码或状态报告代码,然后在运行时帮助定位问题,默认情况下,某些 Cortex-M 内核中的 UsageFault
异常可能是禁用的,但可以通过配置寄存器来启用它,从而在非法操作发生时能够捕获并处理这个异常。
Linux调试
在QNX环境,运行的程序遇到崩溃的情况,系统会自动产生coredump文件,这个coredump文件就包含了前面崩溃的调试信息,里面包含了前面程序的符号表,可以定位到是具体是哪个文件以及函数导致破溃的,还能查看对应堆栈数据(GDB调试)。
。。。。。