esp-idf的内存管理——内存调试

目录

  • 1 堆调试功能的使能
    • 1.1 通过Kconfig使能
    • 1.2 使能之后源码层面的变化
  • 2 堆调试功能——POISONING
  • 3 堆调试功能——TRACE
  • 4 堆调试功能的使用经验
  • 参考

1 堆调试功能的使能

1.1 通过Kconfig使能

idf提供了两种堆调试机制,POISONING以及TRACE。默认情况下,堆调试是未使能的。可以在menuconfig中配置使能,位置在:

  • Component config
    • Heap memory debugging

POISONING默认是no poisoning,但可以选Light impactComprehensive来使能POISONING功能,分别对应下面两个配置宏:

  • CONFIG_HEAP_POISONING_LIGHT
  • CONFIG_HEAP_POISONING_COMPREHENSIVE

POISONING功能被使能之后,还会出现Enable heap task tracking配置项,对应配置宏如下:

  • CONFIG_HEAP_TASK_TRACKING

TRACE默认是Disabled,但可以选Host-basedStandalone来使能TRACE功能,分别对应下面两个配置宏:

  • CONFIG_HEAP_TRACING_TOHOST
  • CONFIG_HEAP_TRACING_STANDALONE

Host-based涉及到idf的app_trace组件,这里暂不讨论,仅关注StandaloneTRACE功能被使能后,以下配置宏会被定义:

  • CONFIG_HEAP_TRACING(表明TRACE功能被使能)

1.2 使能之后源码层面的变化

使能堆调试功能之后,上层同样是调用malloc,但底层行为缺不一样了,这里简单概括一下这中间到底发生了什么。先看POISONING功能:

#ifndef MULTI_HEAP_POISONING
/* if no heap poisoning, public API aliases directly to these implementations */
void *multi_heap_malloc(multi_heap_handle_t heap, size_t size)
    __attribute__((alias("multi_heap_malloc_impl")));
......
#endif

multi_heap_poisoning.c中实现了使能POISONING功能后的相关接口。

再看TRACE功能:

if(CONFIG_HEAP_TRACING)
    set(WRAP_FUNCTIONS
        calloc
        malloc
        free
        realloc
        heap_caps_malloc
        heap_caps_free
        heap_caps_realloc
        heap_caps_malloc_default
        heap_caps_realloc_default)

    foreach(wrap ${WRAP_FUNCTIONS})
        target_link_libraries(${COMPONENT_LIB} INTERFACE "-Wl,--wrap=${wrap}")
    endforeach()
endif()

这样一来就很清楚了,通过GCC的--wrap选项以及alias属性等手段,在基本的内存管理接口的基础上,又包装了一层。进而使得在使能了堆调试功能后,除了调用最基本的内存管理接口外,还会做一些额外的事情。以malloc为例,调用关系总结如下:
esp-idf的内存管理——内存调试_第1张图片
进一步的,要想知道堆调试到底干了什么,只需要关注那些额外的事情的实现细节即可。

2 堆调试功能——POISONING

缓解一下气氛,先说一个小典故。POISONING中毒的意思,堆难道还会中毒嘛?这样的用语自然是有典故的:

金丝雀(canary)是一种鸟,这当然大家都知道。金丝雀这种鸟在自然选择中,进化出了一个非常有用的特性,就是肺活量特别大,这意味着飞翔在同样的高度,金丝雀一口能吸入更多的氧气,进而意味着金丝雀可以比其他鸟飞的更高(能够适应高空更为稀薄的空气),从而躲避捕食者。可爱的金丝雀躲过了猎鹰,却终究没有躲过人类的毒手。上个世纪初,人类矿工利用金丝雀来探测矿井中是否存在有毒气体,正是利用金丝雀肺活量大,对毒性气体更敏感的生理特性。
计算机行业借用这个典故,将一些填充在内存中的特殊字节称为金丝雀字节(canary byte),当金丝雀字节被破坏时,说明内存发生了越界。idf使用POISONING应该也是类似的意思,在申请的内存中填入金丝雀字节,如果金丝雀字节被破坏(金丝雀中毒了),那么说明内存越界了。

故事讲完,在正儿八经看代码之前,先关注结构,填充的金丝雀字节分为首尾两部分,相应的结构如下:

typedef struct {
    uint32_t head_canary;
    MULTI_HEAP_BLOCK_OWNER
    size_t alloc_size;
} poison_head_t;

typedef struct {
    uint32_t tail_canary;
} poison_tail_t;

这两个结构的大小之和就是POISONING空间开销。当配置了任务跟踪(CONFIG_HEAP_TASK_TRACKING),那么MULTI_HEAP_BLOCK_OWNER为:

TaskHandle_t task;

配置CONFIG_HEAP_POISONING_COMPREHENSIVECONFIG_HEAP_TASK_TRACKING后,下图从左到右分别是内存块空闲状态、刚申请出来,申请出来并使用了一段时间的填充情况:
esp-idf的内存管理——内存调试_第2张图片

铺垫到这里,再来看代码就很丝滑了。仍然以内存申请为例,使能POISONING功能后,multi_heap_malloc会多做两件事,分别是poison_allocated_region以及verify_fill_pattern

void *multi_heap_malloc(multi_heap_handle_t heap, size_t size)
{
    if (!size) {
        return NULL;
    }

    if(size > SIZE_MAX - POISON_OVERHEAD) {
        return NULL;
    }

    multi_heap_internal_lock(heap);
    /* 申请时候要加上金丝雀字节的开销 */
    poison_head_t *head = multi_heap_malloc_impl(heap, size + POISON_OVERHEAD);
    uint8_t *data = NULL;
    if (head != NULL) {
        /*填充金丝雀字节 */
        data = poison_allocated_region(head, size);
#ifdef SLOW
        /* 空闲状态下内存种填充的是FREE_FILL_PATTERN */
        /* 对申请出的内存进行校验,检查是否填充了FREE_FILL_PATTERN */
        /* 与此同时,将填充的FREE_FILL_PATTERN替换为MALLOC_FILL_PATTERN */
        bool ret = verify_fill_pattern(data, size, true, true, true);
        assert( ret );
#endif
    }

    multi_heap_internal_unlock(heap);

    /* 返回给用户的是alloc_size部分,也正是用户申请的大小 */
    return data;
}

其余接口就不赘述了。

3 堆调试功能——TRACE

配置CONFIG_HEAP_TRACING_STANDALONE后,heap_trace_standalone.c中的源码生效。在看源码之前,还是先看结构,支撑该功能的是heap_trace_record_t

typedef struct {
    uint32_t ccount; ///< CCOUNT of the CPU when the allocation was made. LSB (bit value 1) is the CPU number (0 or 1).
    void *address;   ///< Address which was allocated
    size_t size;     ///< Size of the allocation
    void *alloced_by[CONFIG_HEAP_TRACING_STACK_DEPTH]; ///< Call stack of the caller which allocated the memory.
    void *freed_by[CONFIG_HEAP_TRACING_STACK_DEPTH];   ///< Call stack of the caller which freed the memory (all zero if not freed.)
} heap_trace_record_t;

各个字段的含义如下:

  1. ccount:CPU的CCOUNT寄存器,bit0记录CPU的core number
  2. address:申请内存的地址
  3. size:申请内存的大小
  4. alloced_by:申请内存的调用栈记录
  5. freed_by:释放内存的调用栈记录

用户需提供一段连续的内存用来存放跟踪信息,也即提供一个heap_trace_record_t类型的数组:
esp-idf的内存管理——内存调试_第3张图片
对这段连续内存调用heap_trace_init_standalone进行初始化(实质上就是内存清0)。

仍然以内存申请为例,配置CONFIG_HEAP_TRACING_STANDALONE后,申请内存时会多做一些事:

static IRAM_ATTR __attribute__((noinline)) void *trace_malloc(size_t size, uint32_t caps, trace_malloc_mode_t mode)
{
    /* 获取CPU的cycle数 */
    uint32_t ccount = get_ccount();
    void *p;

    if ( mode == TRACE_MALLOC_CAPS ) {
        /* 实质上是调用heap_caps_malloc */
        p = __real_heap_caps_malloc(size, caps);
    } else {
        /* 实质上是调用heap_caps_malloc_default */
        p = __real_heap_caps_malloc_default(size);
    }

    heap_trace_record_t rec = {
        .address = p,
        .ccount = ccount,
        .size = size,
    };
    /* 获取调用栈 */
    get_call_stack(rec.alloced_by);
    /* 记录调用栈 */
    record_allocation(&rec);
    return p;
}

再看get_call_stack

static IRAM_ATTR __attribute__((noinline)) void get_call_stack(void **callers)
{
    /* 内存清0 */
    bzero(callers, sizeof(void *) * STACK_DEPTH);
    /* 使用内建函数__builtin_return_address获得调用栈 */
    TEST_STACK(0);
    ......
    TEST_STACK(9);
}

record_allocation主要就是把获取到得调用栈信息拷贝到用户提供的那段内存里:

static IRAM_ATTR void record_allocation(const heap_trace_record_t *record)
{
    if (!tracing || record->address == NULL) {
        return;
    }

    portENTER_CRITICAL(&trace_mux);
    if (tracing) {
        if (count == total_records) {
            has_overflowed = true;
            /* 用户提供的内存装满了,处理方法是丢弃最早的内存申请记录 */
            /* 就像滑动窗口那样,把最早的记录滑出去,这样就多出一个可用的heap_trace_record_t空间 */
            memmove(&buffer[0], &buffer[1], sizeof(heap_trace_record_t) * (total_records -1));
            count--;
        }
        /* 拷贝本次获得的调用栈信息 */
        memcpy(&buffer[count], record, sizeof(heap_trace_record_t));
        count++;
        /* 总内存申请次数记录值加1 */
        total_allocations++;
    }
    portEXIT_CRITICAL(&trace_mux);
}

其它接口不再赘述。

4 堆调试功能的使用经验

RTOS环境下,可用的内存调试的手段很有限,因此若基于idf开发的话,运用好idf提供的内存调试功能还是很有必要的。先总结一下内存相关的软件bug,大概有以下几种(欢迎补充):

  1. 内存泄露
  2. 内存溢出
  3. 内存溢出
  4. 缓冲区溢出(可能进一步导致堆内存溢出)
  5. 缓冲区溢出(可能进一步导致栈内存溢出)
  6. 静态区缓冲区溢出
  7. 内存使用不当
    7.1 double free(一般都能被assert)
    7.2 use after free、use before malloc(野指针,通常可以通过编程规范来避免)
  8. 以上2种或更多的混合

对于456这种缓冲区溢出,比较麻烦,上述堆调试机制很难派上用场。对于7这样明显的问题,只需要设置一些编程规范就能避免,比如:

  1. 用于申请内存的指针初始化置空
  2. 申请内存后对返回地址进行校验,以确定是否申请成功
  3. 释放内存后将指针置空,避免野指针
  4. 尽可能坚持谁申请谁释放的原则

对于123这几类问题,idf提供的堆调试机制还是能起到一定作用的(动态创建任务时,栈也是在堆上申请的,实质也属于堆内存)。当怀疑是这几类内存问题时,可以这么操作:

  1. 配置CONFIG_HEAP_POISONING_COMPREHENSIVE
  2. 配置CONFIG_HEAP_TASK_TRACKING
  3. 当问题复现时,dump出内存(可以全部dump,若内存太大也可以仅dump重点怀疑的部分内存)
  4. 观察dump出的内存数据,根据填充的金丝雀字节判断内存是否越界,根据记录的task判断谁申请的,谁踩了谁。

参考

[1] esp-idf

你可能感兴趣的:(#,esp-idf的内存管理,idf,heap,嵌入式)