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

目录

  • 一、栈回溯
  • 1、寄存器知识
  • 2、示例分析
  • 二、移植 cm_backtrace
  • 1、移植
  • 2、API 说明
  • 2.1 库初始化
  • 2.2 获取函数调用栈
  • 2.3 追踪断言错误信息
  • 2.4 追踪故障错误信息
  • 3、使用示例
  • 4、重新演示

  • 一、栈回溯

    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 上,将部分栈由硬件自动压入。其压入栈的顺序一般如下:

    xPSR->PC(返回地址)->LR->R12->R3->R2->R1->R0
    

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

    2、示例分析

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

    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

    CmBacktrace (Cortex 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、移植

    └── 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 裸机,所以配置如下:

    #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 库初始化

    void cm_backtrace_init(const char *firmware_name, const char *hardware_ver, const char *software_ver)
    
  • firmware_name 固件名称,需与编译器生成的固件名称对应
  • hardware_ver 固件对应的硬件版本号
  • software_ver 固件的软件版本号
  • 注意 :以上入参将会在断言或故障时输出,主要起了追溯的作用

    2.2 获取函数调用栈

    size_t cm_backtrace_call_stack(uint32_t *buffer, size_t size, uint32_t sp)
    
  • buffer 存储函数调用栈的缓冲区
  • size 缓冲区大小
  • sp 待获取的堆栈指针
  • 示例:

    /* 建立深度为 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 追踪断言错误信息

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

    2.4 追踪故障错误信息

    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、使用示例

    // 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

    $ 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 做了一下修改:

    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;
    }
    


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

    作者:Projectsauron

    物联沃分享整理
    物联沃-IOTWORD物联网 » STM32 调试之栈回溯和 CmBacktrace 的使用

    发表回复