定位内存问题 (踩内存、内存泄漏等)

[English]

常见的内存问题有以下几种:

  • 堆内存泄漏

  • 堆内存覆盖

  • 栈内存溢出

  • 栈内存覆盖

  • 指针在释放后继续使用

  • 指针在初始化之前被使用

  • 双重释放

本节将逐一说明上述内存问题的通用调试方法。

堆内存泄漏

堆内存泄露的表现形式往往为程序分配了一块堆内存,但在使用完毕后未正确释放,导致内存泄漏。这会导致程序运行时消耗的内存逐渐增加,最终可能耗尽系统的可用内存。

这部分可以参考 堆内存调试文档,要点整理如下:

  • 可以使用 heap_caps_get_per_task_info 获得所有任务的内存申请情况

  • 可以使用 heap_caps_get_free_size 对比剩余内存情况,大致确定泄露区域

  • 使能 CONFIG_HEAP_TRACING_STANDALONECONFIG_HEAP_TRACING_TOHOST

  • STANDALONE 模式需要分配 buffer,直接在 ESP 上记录、计算、打印结果,但 RISC-V 架构无法定位代码行

  • TOHOST 需要 UART/JTAG 使用 app_trace 抓取,在主机上分析,无需额外 buffer,可以定位代码行

  • heap_trace_init_standalone 初始化 buffer,heap_trace_start(HEAP_TRACE_LEAKS) 开始记录

  • heap_trace_stop() 停止记录,使用 heap_trace_dump() 打印分析结果

使用上述堆内存调试方法后的典型日志如下:

  1. Xtensa

====== Heap Trace: 2 records (100 capacity) ======
36 bytes (@ 0x3fc9c524, Internal) allocated CPU 0 ccount 0x02f204e0 caller 0x42008cfd:0x42008d73
0x42008cfd: zoo_create at /home/libo/test_github/idf_debug_method/main/idf_debug_method.c:68

0x42008d73: mem_leak_task at /home/libo/test_github/idf_debug_method/main/idf_debug_method.c:96 (discriminator 3)

    24 bytes (@ 0x3fc9c54c, Internal) allocated CPU 0 ccount 0x02f20c00 caller 0x42008cfd:0x42008d73
0x42008cfd: zoo_create at /home/libo/test_github/idf_debug_method/main/idf_debug_method.c:68

0x42008d73: mem_leak_task at /home/libo/test_github/idf_debug_method/main/idf_debug_method.c:96 (discriminator 3)

====== Heap Trace Summary ======
Mode: Heap Trace Leaks
60 bytes 'leaked' in trace (2 allocations)
records: 2 (100 capacity, 3 high water mark)
total allocations: 3
total frees: 1
================================
  1. RISC-V

====== Heap Trace: 3 records (100 capacity) ======
36 bytes (@ 0x3fc91574, Internal) allocated CPU 0 ccount 0x02cf6d38 caller
36 bytes (@ 0x3fc9159c, Internal) allocated CPU 0 ccount 0x02cf72cc caller
====== Heap Trace Summary ======
Mode: Heap Trace Leaks
72 bytes 'leaked' in trace (2 allocations)
records: 2 (100 capacity, 3 high water mark)
total allocations: 3
total frees: 1
================================

堆内存覆盖

堆内存覆盖的表现形式往往为在写入或读取堆内存时,程序访问了超出分配给它的内存范围的区域。这可能导致未定义的行为,破坏了程序的内存结构。该错误对应的日志往往为:

assert failed: remove_free_block tlsf.c:331 (next && "next_free field can not be null")

这部分可以参考 堆内存调试文档,要点整理如下:

  1. 定位 Who and Where

  • Basic(默认): 使用堆属性检测是否被污染

  • Light impact : 给分配的内存前后加上头尾特殊字节 0xABBA1234 0xBAAD5678

  • Comprehensive : 在 light impact 的基础上增加  uninitialized-access 和 use-after-free bugs 检查。内存申请时,所有内存初始化为 0xce, 内存释放后所有空间赋值为 0xfe

  • 开启内存调试以后,等待 crash 或在怀疑踩内存的位置前后主动调用检查内存完整性 heap_caps_check_integrity_all 触发 crash。如果已经定位到踩内存的地址,可以直接使用 heap_caps_check_integrity_addr

    • 踩尾巴,当前内存块操作越界 CORRUPT HEAP: Bad tail at 0x3fc9ad5a. Expected 0xbaad5678 got 0x02020202

    • 踩头,上一个内存块越界 CORRUPT HEAP: Bad head at 0x3fc9a94c. Expected 0xabba1234 got 0x00000000

  • 两种方法可以确认内存块前后邻居

    • 使用 heap trace, 调用 heap_trace_start(HEAP_TRACE_ALL) 收集信息

    • 使用 heap_caps_dump_all 打印收集到的信息 (需要在内存申请之后、踩之前打印)

备注

上述确认内存块状态的具体方式描述可参考 堆内存跟踪

  1. 定位 When

  • 可以在代码里通过 esp_cpu_set_watchpoint(0, (void *)0x3fc9a94c, 4, ESP_CPU_WATCHPOINT_STORE); 设置 CPU 断点。如果不知道哪个内核,需要两个内核都调用一遍

  • CPU 将在该地址写入数据时触发断点,通过 PC 可以定位到代码行,参考日志如下:

    Guru Meditation Error: Core  0 panic'ed (Unhandled debug exception).
    Debug exception reason: Watchpoint 0 triggered
    Core  0 register dump:
    PC      : 0x400570e8  PS      : 0x00060c36  A0      : 0x82008d43  A1      : 0x3fc99f10
    0x400570e8: memset in ROM
    
    A2      : 0x3fc9b3ac  A3      : 0x00000000  A4      : 0x000003e8  A5      : 0x3fc9b75c
    A6      : 0x00000000  A7      : 0x0000003e  A8      : 0x8200333d  A9      : 0x3fc99ee0
    A10     : 0x00000400  A11     : 0x00060c20  A12     : 0x00000000  A13     : 0x00060c23
    A14     : 0xb33fffff  A15     : 0xb33fffff  SAR     : 0x00000004  EXCCAUSE: 0x00000001
    EXCVADDR: 0x00000000  LBEG    : 0x400570e8  LEND    : 0x400570f3  LCOUNT  : 0x00000002
    
    Backtrace: 0x400570e5:0x3fc99f10 0x42008d40:0x3fc99f20 0x4201874b:0x3fc99f50 0x4037a80d:0x3fc99f80
    0x400570e5: memset in ROM
    0x42008d40: app_main at /home/libo/test_github/idf_debug_method/main/idf_debug_method.c:169 (discriminator 3)
    0x4201874b: main_task at /home/libo/esp/github_master/components/freertos/app_startup.c:208 (discriminator 13)
    0x4037a80d: vPortTaskWrapper at /home/libo/esp/github_master/components/freertos/FreeRTOS-Kernel/portable/xtensa/port.c:162
    

栈内存溢出

栈内存溢出的表现形式往往为在函数调用过程中使用栈内存时,如果递归调用或局部变量过多,栈的大小可能会超过系统允许的限制,导致栈内存溢出。以下是 ESP-IDF 里支持的栈内存溢出检测机制:

  1. ESP-IDF FreeRTOS 默认开启栈溢出检测,如果检测到栈溢出,会触发断言,打印对应栈溢出信息,典型日志如下:

    ***ERROR*** A stack overflow in task test_task has been detected.
    

更多细节可以参考 栈溢出 章节。

  1. ESP-IDF 支持开启 End of Stack Watchpoint,在 FreeRTOS 栈溢出触发断言之前,触发断点。

  2. RISC-V 平台支持开启硬件栈溢出检测 (Stack protection fault),具体可参考 硬件堆栈保护

栈内存覆盖

栈内存覆盖的表现形式往往类似于堆内存覆盖,但发生在程序使用栈内存时。写入或读取超出栈分配的内存范围的数据可能导致程序错误。以下是几个注意点:

  1. 可能导致任务堆栈溢出,一般可通过 FreeRTOS 的栈溢出机制检测到。

  2. 可能导致局部变量值被覆盖,导致程序不符合预期。

  3. 可能导致局部指针变量被修改,访问非法指令/数据地址,导致程序崩溃。

  4. 可能导致函数返回地址被覆盖,程序跳转到错误地址,导致程序崩溃。

简单的错误代码示例如下:

int vulnerableFunction() {
    int localArray[5];  // Array allocated on the stack

    // Writing beyond the bounds of the array
    for (int i = 0; i <= 5; ++i) {
        localArray[i] = i;
    }

    return localArray[0];
}

void app_main() {
    printf("Before vulnerable function.\n");

    vulnerableFunction();  // Call the function that causes stack memory corruption

    printf("After vulnerable function.\n");
}

值得一提的是,ESP-IDF 在编译时就会检查到部分此类型错误并给出警告(但仍能编译通过),编译时的警告日志如下:

/home/user/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c: In function 'vulnerableFunction':
/home/user/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:20:23: warning: iteration 5 invokes undefined behavior [-Waggressive-loop-optimizations]
  20 |         localArray[i] = i;
      |         ~~~~~~~~~~~~~~^~~
/home/user/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:19:23: note: within this loop
  19 |     for (int i = 0; i <= 5; ++i) {

指针在释放后继续使用

指针在释放后继续使用的表现形式往往为程序释放了一块内存,但后续仍然使用了指向该内存的指针。这可能导致访问无效内存,引发崩溃或未定义的行为。

此问题可能会导致各种错误,很难通过实际错误来定位到为此问题,因此在开发过程中需要特别注意指针的使用。简单的错误代码示例如下:

void app_main(void)
{
  int *number = (int *)malloc(sizeof(int));  // Allocate memory for an integer

  if (number == NULL) {
      // Handle memory allocation failure
      printf("Memory allocation failed.\n");
  }

  *number = 42;  // Assign a value to the allocated memory

  printf("Value before freeing: %d\n", *number);

  free(number);  // Free the allocated memory

  // Attempt to use the pointer after freeing
  // This will result in undefined behavior
  printf("Value after freeing: %d\n", *number);
}

值得一提的是,ESP-IDF 在编译时就会检查到部分此类型错误并给出警告(但仍能编译通过),编译时的警告日志如下:

/home/user/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c: In function 'app_main':
/home/user/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:32:5: warning: pointer 'number' used after 'free' [-Wuse-after-free]
  32 |     printf("Value after freeing: %d\n", *number);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/home/zhengzhong/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:28:5: note: call to 'free' here
  28 |     free(number);  // Free the allocated memory

指针在初始化之前被使用

指针在初始化之前被使用的表现形式往往为当程序尝试使用尚未初始化的指针时,可能会访问未知的内存区域,导致不稳定的行为。

此问题可能会导致各种错误,很难通过实际错误来定位到为此问题,因此在开发过程中需要特别注意指针的使用。简单的错误代码示例如下:

void app_main(void)
{
    int *number;  // Pointer declared but not initialized

    // Attempt to dereference the uninitialized pointer
    // This will result in undefined behavior
    printf("Value: %d\n", *number);

}

值得一提的是,ESP-IDF 往往在编译时就会检查到部分此类型错误并给出报错提示,编译时的错误日志如下:

/home/user/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c: In function 'app_main':
/home/user/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:21:5: error: 'number' is used uninitialized [-Werror=uninitialized]
  21 |     printf("Value: %d\n", *number);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/home/user/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:17:10: note: 'number' was declared here
  17 |     int *number;  // Pointer declared but not initialized
      |          ^~~~~~
cc1: some warnings being treated as

双重释放

双重释放的表现形式往往为程序释放了已经被释放的内存,这可能导致内存池的破坏,进而导致程序崩溃或产生其他严重问题。错误代码示例如下:

void app_main(void)
{
    // Allocate a block of memory
    int *data = (int *)malloc(sizeof(int));

    // Check if memory allocation is successful
    if (data != NULL) {
        // Assign a value to the allocated memory
        *data = 42;

        // First free
        free(data); // Line 26

        // Second free (double-free)
        free(data);  // Line 29, this is incorrect and may lead to undefined behavior
    }
}

值得一提的是,ESP-IDF 往往在编译时就会检查到部分此类型错误并给出报错提示,编译时的警告日志如下:

/home/zhengzhong/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c: In function 'app_main':
/home/zhengzhong/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:29:9: warning: pointer 'data' used after 'free' [-Wuse-after-free]
  29 |         free(data);  // This is incorrect and may lead to undefined behavior
      |         ^~~~~~~~~~
/home/zhengzhong/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:26:9: note: call to 'free' here
  26 |         free(data);
      |         ^~~~~~~~~~

运行时的错误日志如下:

I (285) main_task: Calling app_main()

assert failed: tlsf_free tlsf.c:1119 (!block_is_free(block) && "block already marked as free")
Core  0 register dump:
Stack dump detected
MEPC    : 0x403805d8  RA      : 0x403838e8  SP      : 0x3fc8f330  GP      : 0x3fc8ae00
0x403805d8: panic_abort at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/esp_system/panic.c:452

0x403838e8: __ubsan_include at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/esp_system/ubsan.c:313

TP      : 0x3fc87110  T0      : 0x37363534  T1      : 0x7271706f  T2      : 0x33323130
S0/FP   : 0x00000069  S1      : 0x00000001  A0      : 0x3fc8f36c  A1      : 0x3fc8acd1
A2      : 0x00000001  A3      : 0x00000029  A4      : 0x00000001  A5      : 0x3fc8c000
A6      : 0x7a797877  A7      : 0x76757473  S2      : 0x00000009  S3      : 0x3fc8f49e
S4      : 0x3fc8acd0  S5      : 0x00000000  S6      : 0x00000000  S7      : 0x00000000
S8      : 0x00000000  S9      : 0x00000000  S10     : 0x00000000  S11     : 0x00000000
T3      : 0x6e6d6c6b  T4      : 0x6a696867  T5      : 0x66656463  T6      : 0x62613938
MSTATUS : 0x00001881  MTVEC   : 0x40380001  MCAUSE  : 0x00000007  MTVAL   : 0x00000000
0x40380001: _vector_table at ??:?

MHARTID : 0x00000000


Backtrace:


panic_abort (details=details@entry=0x3fc8f36c "assert failed: tlsf_free tlsf.c:1119 (!block_is_free(block) && \"block already marked as free\")") at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/esp_system/panic.c:452
452         *((volatile int *) 0) = 0; // NOLINT(clang-analyzer-core.NullDereference) should be an invalid operation on targets
#0  panic_abort (details=details@entry=0x3fc8f36c "assert failed: tlsf_free tlsf.c:1119 (!block_is_free(block) && \"block already marked as free\")") at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/esp_system/panic.c:452
#1  0x403838e8 in esp_system_abort (details=details@entry=0x3fc8f36c "assert failed: tlsf_free tlsf.c:1119 (!block_is_free(block) && \"block already marked as free\")") at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/esp_system/port/esp_system_chip.c:84
#2  0x403890e8 in __assert_func (file=file@entry=0x3c0212f3 "", line=line@entry=1119, func=<optimized out>, func@entry=0x3c021984 <__func__.6> "", expr=expr@entry=0x3c0217ec "") at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/newlib/assert.c:81
#3  0x40387e5e in tlsf_free (tlsf=0x3fc8c574, ptr=ptr@entry=0x3fc8ff20) at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/heap/tlsf/tlsf.c:1119
#4  0x40387a8e in multi_heap_free_impl (heap=0x3fc8c560, p=p@entry=0x3fc8ff20) at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/heap/multi_heap.c:231
#5  0x40380b98 in heap_caps_free (ptr=ptr@entry=0x3fc8ff20) at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/heap/heap_caps.c:388
#6  0x4038910e in free (ptr=ptr@entry=0x3fc8ff20) at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/newlib/heap.c:39
#7  0x4200712a in app_main () at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:29
#8  0x4201498a in main_task (args=<error reading variable: value has been optimized out>) at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/freertos/app_startup.c:208
#9  0x40385a2c in vPortTaskWrapper (pxCode=<optimized out>, pvParameters=<optimized out>) at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/components/freertos/FreeRTOS-Kernel/portable/riscv/port.c:202
ELF file SHA256: 1df25094bc6834da

可以看到 log 有 block already marked as free 提示,以及错误代码定位提示 app_main () at /home/zhengzhong/github/esp-idf/rel5.1/esp-idf/examples/get-started/hello_world/main/hello_world_main.c:29 发现在代码 29 行出现了双重释放的情况,需要删除第二次的 free。