idf提供了两种堆调试机制,POISONING
以及TRACE
。默认情况下,堆调试是未使能的。可以在menuconfig
中配置使能,位置在:
POISONING
默认是no poisoning
,但可以选Light impact
或Comprehensive
来使能POISONING
功能,分别对应下面两个配置宏:
当POISONING
功能被使能之后,还会出现Enable heap task tracking
配置项,对应配置宏如下:
TRACE
默认是Disabled
,但可以选Host-based
或Standalone
来使能TRACE
功能,分别对应下面两个配置宏:
Host-based
涉及到idf的app_trace
组件,这里暂不讨论,仅关注Standalone
。TRACE
功能被使能后,以下配置宏会被定义:
TRACE
功能被使能)使能堆调试功能之后,上层同样是调用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
为例,调用关系总结如下:
进一步的,要想知道堆调试到底干了什么,只需要关注那些额外的事情的实现细节即可。
缓解一下气氛,先说一个小典故。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_COMPREHENSIVE
和CONFIG_HEAP_TASK_TRACKING
后,下图从左到右分别是内存块空闲状态、刚申请出来,申请出来并使用了一段时间的填充情况:
铺垫到这里,再来看代码就很丝滑了。仍然以内存申请为例,使能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;
}
其余接口就不赘述了。
配置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;
各个字段的含义如下:
用户需提供一段连续的内存用来存放跟踪信息,也即提供一个heap_trace_record_t
类型的数组:
对这段连续内存调用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);
}
其它接口不再赘述。
RTOS环境下,可用的内存调试的手段很有限,因此若基于idf开发的话,运用好idf提供的内存调试功能还是很有必要的。先总结一下内存相关的软件bug,大概有以下几种(欢迎补充):
对于4
、5
、6
这种缓冲区溢出,比较麻烦,上述堆调试机制很难派上用场。对于7
这样明显的问题,只需要设置一些编程规范就能避免,比如:
对于1
、2
、3
这几类问题,idf提供的堆调试机制还是能起到一定作用的(动态创建任务时,栈也是在堆上申请的,实质也属于堆内存)。当怀疑是这几类内存问题时,可以这么操作:
task
判断谁申请的,谁踩了谁。[1] esp-idf