背景
最近在做Blackberry时遇到了一个错误tcache_thread_shutdown(): unaligned tcache chunk detected,这个错误是发生在多线程情况下没有做好互斥时发生一个指针被free之后再次使用也就是比较经典的use-after-free的错误,触发了Abort,虽然不是什么大问题,很容易修复,但是这引发了我一个比较好奇的事情,就是为什么会这样?为什么free之后继续使用没报错但是线程退出时导致崩溃了?
原理
我使用的是GCC编译器,标准库是Glibc,对于Glibc而言,每次malloc的内存都要比预期申请的要大一点,因为里面要放入一些malloc的元数据,基本上malloc的内存布局是这样的:
bash
[ malloc chunk header ][ 用户区 64 bytes ]
^
|
p
p是返回给我们使用的user地址,每次当我们释放时Glibc并不会把它归还给系统,而是自己维护一个内存chunk,把这次的Malloc内存放入一个tcache链表维护起来,这样后续在使用可以从这里拿内存提高分配效率,这里就有一个点,就是如果没有归还给系统的话页表的映射还在当前进程上面,也就是说你free之后依然可以随意访问这块地址,并不会发生什么非法地址访问的情况,下面是一个示例:
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char *p = malloc(64);
if (!p) {
return 1;
}
free(p);
p[0] = 0x01;
p[1] = 0x02;
printf("hello world!\n");
return 0;
}
此时hello world会正常打印并且也不会出现任何问题,这种做法与我们平时接触到的C语言教学背道而驰,平常教科书上的教学是针对C语言这门语言特性而Glibc是C语言的标准实现库,它有自己的行为,并且在不同系统上也有不同区别,但是这里我们换一种方式用线程来执行:
c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static void *worker(void *arg)
{
char *a = malloc(64);
printf("[worker] a=%p\n", a);
free(a);
memset(a, 0x55, 64);
printf("[worker] corrupted freed chunk, now return\n");
return NULL;
}
int main(void)
{
pthread_t tid;
if (pthread_create(&tid, NULL, worker, NULL) != 0) {
perror("pthread_create");
return 1;
}
pthread_join(tid, NULL);
printf("[main] worker joined\n");
return 0;
}
运行结果:
bash
[worker] a=0x79d9f0000b70
[worker] corrupted freed chunk, now return
tcache_thread_shutdown(): unaligned tcache chunk detected
[1] 29787 IOT instruction (core dumped) ./a.out
可以看到被异常结束了,是Glibc主动调用了Abort函数发送SIGABRT异常信号结束当前进程,并且打印了tcache_thread_shutdown(),这个原因是C语言的线程检查行为,对于Main而言它虽然也是一个线程但是Glibc对它行为是它是主线程,结束时Glibc调用exit来回收,它与普通线程行为不同,Main的链路是:
_start
-> __libc_start_main(...)
-> 调用main(argc, argv, envp)
-> main return
-> exit(main_return_value)
-> 执行 atexit/on_exit 回调
-> flush/close stdio
-> glibc 内部清理
-> _exit(status)
-> exit_group 系统调用
-> 内核销毁整个进程
普通线程行为调用的是:
pthread_create()
-> glibc clone创建线程
-> 新线程从glibc 的 start_thread入口开始
-> start_thread 调用你的 thread_func(arg)
-> thread_func return
-> glibc 执行 pthread_exit 收尾逻辑
-> 清理当前线程资源
-> 当前线程结束
注意这里调用的是pthread_exit,而不是系统的exit,这里它会去调用tcache_thread_shutdown来检查chunk有没有被破坏,这点你可以在它的源码里面找到,这里我找的是2.x:malloc/malloc.c的源码:
c
static void
tcache_thread_shutdown (void)
{
......
for (i = 0; i < TCACHE_MAX_BINS; ++i) {
while (tcache_tmp->entries[i]) {
tcache_entry *e = tcache_tmp->entries[i];
if (__glibc_unlikely (!aligned_OK (e)))
malloc_printerr ("tcache_thread_shutdown(): "
"unaligned tcache chunk detected");
tcache_tmp->entries[i] = REVEAL_PTR (e->next);
__libc_free (e);
}
}
......
}
可以看到它在循环调用aligned_OK来检查tcache里面的指针是否被破坏的,也就是判断是不是Malloc合法边界,这个是看当前CPU位数,64位是16字节,32位是8字节,用MALLOC_ALIGNMENT 宏表示,在不同平台位数上它的内存颗粒不同,它会多分配一些字节一部分用于维护Malloc元数据一部分给用户使用,在64位上,即使你分配没有16个字节,它仍然会将你的分配按16字节对齐,如果tcache记录的地址里面有一块不是按16字节对齐的,那么就会立马出错,它认为当前线程破坏了tcache,导致内存模型出现了问题可能引发异常错误,就会直接abort,在malloc_printerr里面,会调用abort。
因此当我们平时调试的时候不要总是依靠C语言的标准答案,而是去参考具体平台和标准实现细节,例如对着Glibc库源码分析原因。
Main之所以没有做这层检查是因为Main结束整个应用都要结束了,因此没必要在做这些事情了,直接调用exit让系统一并将这个task全部清理掉,因为它所有的内存数据这些都在task_struct里被记录着,内核一并释放掉就可以了。