STM32 调试之栈回溯和 CmBacktrace 的使用

目录


一、栈回溯

1、寄存器知识

在 Cortex-M 上弄清楚栈的布局,就必须理解 Cortex-M 上的压栈入栈的机制和原理。下面从该体系架构上说说 Cortex-M 上比较重要的细节。

按照 ARM Cortex-M 的设计,一共有 32 个寄存器。

  • 13 个通用寄存器,r0-r12
  • 2 个不同模式下使用的 SP
    • PSP(SP_process)
    • MSP(SP_main)
  • 1 个链接寄存器 LR(r14)
  • 1 个程序计数器(PC)
  • 1 个程序状态寄存器(xPSR)

在不同的模式下,R0-R12、SP、LR 是各有一份的,所以这样算下来,总共是 32 个寄存器,但是在不同的模式下,并不能完全看到这 32 个寄存器的状态,只能看到其中的一部分。

  1. 通用寄存器 R0-R12

上图将通用寄存器分为 low register 和 high registers 就是根据指令集来说的,对于 Thumb 指令,是 16 位的,只能访问到 low register,也就是 R0-R7,而对于 32 位的 Arm 指令,是所有的指令都可以访问到。所以有这样的划分。

  1. 栈指针SP

一旦涉及到参数的压栈与入栈,或者函数的执行返回的时候,必须会涉及到栈指针的变化。在 Cortex-M 由于涉及到两种不同的 SP 的切换,所以在使用 SP 的时候要格外的小心。

  1. 程序链接寄存器 LR

程序的链接寄存器在函数返回的时候会被使用到。

  1. 程序计数寄存器

该寄存器会自动指向当前指向的程序地址。

不同于其他的处理器架构,Cortex-M 的定位一开始就是为实时性、小体积容量的设计考虑的,所以在中断处理这一块,也做了一个十分有意思的设计------自动压栈处理。

一般的 CPU 进入中断后都会去进行压栈操作,因为栈就是函数的现场,保护了栈内容,中断退出的时候只需要恢复栈数据就可以恢复到程序执行的状态了。以往这个阶段都是通过人工操作写程序完成的,在 Cortex-M 上,将部分栈由硬件自动压入。其压入栈的顺序一般如下:

c 复制代码
xPSR->PC(返回地址)->LR->R12->R3->R2->R1->R0

这些寄存器硬件自动压入,效率上应该有较大的提升。另外的一些寄存器可以手动处理。

2、示例分析

为了方便演示,这里写的示例程序非常简单,而在实际的项目场景中,分析起来会非常复杂。

c 复制代码
int test3(int a) {
	int res = (a << 2) + 3;
	return res;
}
 
int test2(int arg) {
	int res = test3(arg) * 2;
	return res;
}
 
int test1(int a, int b) {
	int a2 = test2(a);
	return a2 + b;
}

int main(void)
{
	test1(1, 2);
	
	while (1)
	{
		
	}
}

下面进入调试界面,SP 栈指针指向地址 0x2000 0CC8

现在单步进入 test1:

可以看到,在这一步汇编代码会将 r4-47 以及 lr 入栈:

由汇编代码可以分析出 R0、R1 分别保存的形参 a、b 的值,并分别赋值给了 R5、R4。

栈中数据和前面提到的寄存器是对应的,现在 SP 指针指向了地址 0x2000 0CB8,为什么参考文章 Cortex-M 内核的 OS 特性 双堆栈部分。下面继续分析,进入 test2:

这里的 LR 保存的是刚才 test1 即将执行的指令的地址:

前面的文章提过多次,最低位(lsb) 为 0 表示 arm 指令;最低位为 1 表示 thumb 指令

紧接着又将参数 arg 的值赋给 R0,然后进入到 test3:

到这里就不用入栈了,因为这已经是顶层了。这里的汇编指令也很好分析,不再多讲。最后通过 BX lr 返回到 test2:

注意看,在 test2 中,这里出栈的时候将 LR 的值赋给的 PC 指针,从而完成了回溯的操作。

test1 同理。

二、移植 cm_backtrace

CmBacktraceCortex Microcontroller Backtrace)是一款针对 ARM Cortex-M 系列 MCU 的错误代码自动追踪、定位,错误原因自动分析的开源库。主要特性如下:

  • 支持的错误包括:
    • 断言(assert)
    • 故障(Hard Fault, Memory Management Fault, Bus Fault, Usage Fault, Debug Fault)
  • 故障原因 自动诊断 :可在故障发生时,自动分析出故障的原因,定位发生故障的代码位置,而无需再手动分析繁杂的故障寄存器;
  • 输出错误现场的 函数调用栈(需配合 addr2line 工具进行精确定位),还原发生错误时的现场信息,定位问题代码位置、逻辑更加快捷、精准。也可以在正常状态下使用该库,获取当前的函数调用栈;
  • 支持 裸机 及以下操作系统平台:
    • RT-Thread
    • UCOS
    • FreeRTOS(需修改源码)
  • 根据错误现场状态,输出对应的 线程栈 或 C 主栈;
  • 故障诊断信息支持多国语言(目前:简体中文、英文);
  • 适配 Cortex-M0/M3/M4/M7 MCU;
  • 支持 IAR、KEIL、GCC 编译器;

我们可以通过故障寄存器信息来定位故障原因及故障代码地址,虽然这样能解决一小部分问题,但是重复的、繁琐的分析过程也会耽误很多时间。而且对于一些复杂问题,只依靠代码地址是无法解决的,必须得还原错误现场的函数调用逻辑关系。虽然连接仿真器可以查看到的函数调用栈,但故障状态下是无法显示的,所以还是得一步步 F10/F11 单步去定位错误代码的位置。

而通过 cm_backtrace 调试可以大大地提高我们 Debug 的效率。

1、移植

xml 复制代码
└── cm_backtrace
    ├── fault_handler
    |   └── keil
    |       └── cmb_fault.S
    └── Languages
    |   └── en-US
    |       └── cmb_en_US.h
    ├── cm_backtrace.c
    ├── cm_backtrace.h
    ├── cmb_cfg.h
    └── cmb_def.h

将这些文件移植到工程目录下。

然后进入 cmb_cfg.h,这里的配置项需要我们自己来配置:

  • cmb_println(...) 错误及诊断信息输出 必须配置
    • 注意 printf 要重定向才能输出到串口
  • CMB_USING_BARE_METAL_PLATFORM 是否使用在裸机平台 使用则定义该宏
  • CMB_USING_OS_PLATFORM 是否使用在操作系统平台 操作系统与裸机必须二选一
  • CMB_OS_PLATFORM_TYPE 操作系统平台如下:
    • CMB_OS_PLATFORM_RTT
    • CMB_OS_PLATFORM_UCOSII
    • CMB_OS_PLATFORM_UCOSIII
    • CMB_OS_PLATFORM_FREERTOS
    • CMB_OS_PLATFORM_RTX5
    • CMB_OS_PLATFORM_THREADX
  • CMB_CPU_PLATFORM_TYPE CPU平台
    • CMB_CPU_ARM_CORTEX_M0
    • CMB_CPU_ARM_CORTEX_M3
    • CMB_CPU_ARM_CORTEX_M4
    • CMB_CPU_ARM_CORTEX_M7
    • CMB_CPU_ARM_CORTEX_M33
  • CMB_USING_DUMP_STACK_INFO 是否使用 Dump 堆栈的功能 使用则定义该宏
  • CMB_PRINT_LANGUAGE 输出信息时的语言 CHINESE/ENGLISH`

我使用的是 STM32F407 裸机,所以配置如下:

c 复制代码
#define cmb_println(...)        printf(__VA_ARGS__);printf("\r\n")  
#define CMB_USING_BARE_METAL_PLATFORM
#define CMB_CPU_PLATFORM_TYPE   CMB_CPU_ARM_CORTEX_M4 
#define CMB_USING_DUMP_STACK_INFO
#define CMB_PRINT_LANGUAGE   CMB_PRINT_LANGUAGE_ENGLISH

然后,如果你原本的程序中有 HardFault_Handler,记得注释掉,因为 cmb_fault.S 中也实现了一个 HardFault_Handler

配置好后就可以使用 CmBacktrace 了。

2、API 说明

2.1 库初始化

c 复制代码
void cm_backtrace_init(const char *firmware_name, const char *hardware_ver, const char *software_ver)
  • firmware_name 固件名称,需与编译器生成的固件名称对应
  • hardware_ver 固件对应的硬件版本号
  • software_ver 固件的软件版本号

注意 :以上入参将会在断言或故障时输出,主要起了追溯的作用

2.2 获取函数调用栈

c 复制代码
size_t cm_backtrace_call_stack(uint32_t *buffer, size_t size, uint32_t sp)
  • buffer 存储函数调用栈的缓冲区
  • size 缓冲区大小
  • sp 待获取的堆栈指针

示例:

c 复制代码
/* 建立深度为 16 的函数调用栈缓冲区,深度大小不应该超过 CMB_CALL_STACK_MAX_DEPTH(默认16) */
uint32_t call_stack[16] = {0};
size_t i, depth = 0;
/* 获取当前环境下的函数调用栈,每个元素将会以 32 位地址形式存储, depth 为函数调用栈实际深度 */
depth = cm_backtrace_call_stack(call_stack, sizeof(call_stack), __get_SP());

/* 输出当前函数调用栈信息
 * 注意:查看函数名称及具体行号时,需要使用 addr2line 工具转换
 */
for (i = 0; i < depth; i++) {
    printf("%08x ", call_stack[i]);
}

2.3 追踪断言错误信息

c 复制代码
void cm_backtrace_assert(uint32_t sp)
  • sp 断言环境时的堆栈指针

注意 :入参 SP 尽量在断言函数内部获取,而且尽可能靠近断言函数开始的位置。当在断言函数的子函数中(例如:在 RT-Thread 的断言钩子方法中)使用时,由于函数嵌套会存在寄存器入栈的操作,此时再获取 SP 将发生变化,就需要人为调整(加减固定的偏差值)入参值,所以作为新手不建议在断言的子函数中使用该函数。

2.4 追踪故障错误信息

c 复制代码
void cm_backtrace_fault(uint32_t fault_handler_lr, uint32_t fault_handler_sp)
  • fault_handler_lr 故障处理函数环境下的 LR 寄存器值
  • fault_handler_sp 故障处理函数环境下的 SP 寄存器值

该函数可以在故障处理函数(例如:HardFault_Handler)中调用。另外,库本身提供了 HardFault 处理的汇编文件(点击查看,需根据自己编译器进行选择),会在故障时自动调用 cm_backtrace_fault 方法。所以移植时,最简单的方式就是直接使用该汇编文件。

3、使用示例

c 复制代码
// fault_test.c
void fault_test_by_unalign(void) {
    volatile int * SCB_CCR = (volatile int *) 0xE000ED14; // SCB->CCR
    volatile int * p;
    volatile int value;

    *SCB_CCR |= (1 << 3); /* bit3: UNALIGN_TRP. */

    p = (int *) 0x00;
    value = *p;
    printf("addr:0x%02X value:0x%08X\r\n", (int) p, value);

    p = (int *) 0x04;
    value = *p;
    printf("addr:0x%02X value:0x%08X\r\n", (int) p, value);

    p = (int *) 0x03;
    value = *p;
    printf("addr:0x%02X value:0x%08X\r\n", (int) p, value);
}

void fault_test_by_div0(void) {
	// 使能除 0 异常,否则会直接将结果当作 0 处理
    volatile int * SCB_CCR = (volatile int *) 0xE000ED14; // SCB->CCR
    int x, y, z;

    *SCB_CCR |= (1 << 4); /* bit4: DIV_0_TRP. */

    x = 10;
    y = 0;
    z = x / y;
    printf("z:%d\n", z);
}

// main.c
#define HARDWARE_VERSION               "V1.0.0"
#define SOFTWARE_VERSION               "V0.1.0"

extern void fault_test_by_unalign(void);
extern void fault_test_by_div0(void);

int main(void)
{
	... // 一系列初始化函数

	cm_backtrace_init("CmBacktrace", HARDWARE_VERSION, SOFTWARE_VERSION);
	
	fault_test_by_div0();
 
 	// 正常情况下不会执行到这
    uint8_t _continue = 1;
    while(_continue == 1) {
        bsp_led_on(GPIO_Pin_5);
        bsp_delay_ms(10000);
        bsp_led_off(GPIO_Pin_5);
        bsp_delay_ms(10000);
    }
}

烧录运行后,从串口打印出如下信息:

这里已经指出了错误原因是 0 做了除数,并还提示我们使用 addr2line 命令,查看函数调用栈详细信息,并定位错误代码:


addr2line

shell 复制代码
$ addr2line --help
Usage: addr2line [option(s)] [addr(s)]
 Convert addresses into line number/file name pairs.
 If no addresses are specified on the command line, they will be read from stdin
 The options are:
  @<file>                Read options from <file>
  -a --addresses         Show addresses
  -b --target=<bfdname>  Set the binary file format
  -e --exe=<executable>  Set the input file name (default is a.out)
  -i --inlines           Unwind inlined functions
  -j --section=<name>    Read section-relative offsets instead of addresses
  -p --pretty-print      Make the output easier to read for humans
  -s --basenames         Strip directory names
  -f --functions         Show function names
  -C --demangle[=style]  Demangle function names
  -h --help              Display this information
  -v --version           Display the program's version

addr2line: supported targets: pe-x86-64 pei-x86-64 pe-bigobj-x86-64 elf64-x86-64 elf64-l1om elf64-k1om pe-i386 pei-i386 elf32-i386 elf64-little elf64-big elf32-little elf32-big plugin srec symbolsrec verilog tekhex binary ihex
Report bugs to <http://www.sourceware.org/bugzilla/>

这里常用的是以下参数

  • -e :指定可执行映像名称
  • -a :显示函数地址
  • -f :显示函数名称

运行一下:

进入 Keil 调试状态,看一下这个地址:

直接就定位到了错误的地方,比我们自己分析要方便不少!

4、重新演示

下面再用 CmBacktrace 重新调试一下最开始我们分析的那个例子,对 test3 做了一下修改:

c 复制代码
int test3(int a) {
	volatile int * SCB_CCR = (volatile int *) 0xE000ED14; // SCB->CCR
	*SCB_CCR |= (1 << 4); /* bit4: DIV_0_TRP. */
	
	int x;
	x = 0;
    int res = a / x;
	
	return res;
}
 
int test2(int arg) {
	int res = test3(arg) * 2;
	return res;
}
 
int test1(int a, int b) {
	int a2 = test2(a);
	return a2 + b;
}


调用栈信息和地址信息都打印出来了。

相关推荐
惜.己4 小时前
Jmeter中的断言(二)
测试工具·jmeter·1024程序员节
文弱书生6564 小时前
输出比较简介
stm32
黑客呀7 小时前
[系统安全]Rootkit基础
stm32·单片机·系统安全
小A1597 小时前
STM32完全学习——使用SysTick精确延时(阻塞式)
stm32·嵌入式硬件·学习
楚灵魈7 小时前
[STM32]从零开始的STM32 HAL库环境搭建
stm32·单片机·嵌入式硬件
小A1597 小时前
STM32完全学习——使用标准库点亮LED
stm32·嵌入式硬件·学习
code_snow9 小时前
STM32--JLINK使用、下载问题记录
stm32·单片机·嵌入式硬件
youcans_12 小时前
【动手学电机驱动】STM32-FOC(8)MCSDK Profiler 电机参数辨识
stm32·单片机·嵌入式硬件·电机控制·foc
西电研梦13 小时前
考研倒计时30天丨和西电一起向前!再向前!
人工智能·考研·1024程序员节·西电·西安电子科技大学
惜.己14 小时前
Jmeter中的断言(四)
测试工具·jmeter·1024程序员节