我的C++大作业是实现一个自己的STL deque,由于构造函数过慢(STL20ns,笔者200ns),笔者自己实现了一个针对于0x1010大小的内存分配器。为了不大量修改代码,我使用了__malloc_hook和\__free_hook这两个全局变量。大致的思路是使用一个全局对象,在其构造函数里先用mmap分配一个区域,然后在这个区域里建好一个0x1010大小的chunk链表:

chxAlloc::chxAlloc()
{
    //std::cout << "invoke chxAlloc\n";
    void *begin =
        mmap(NULL, sizeof(allocChunk) * INIT_NUMBER + 0X10,
             PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
    if ((long long)begin == -1)
    {
        std::cout << "error occur in mmap\n";
        exit(-1);
    }

    for (int i = 0; i < INIT_NUMBER; ++i)
    {
        allocChunk *temp = (allocChunk *)begin;
        temp->nextPtr = fast_TARGET;
        fast_TARGET = temp;
        temp->sign = -TARGET;
        begin = ((char *)begin + sizeof(allocChunk));
    }
}

对GLIBC有了解的读者应该知道,这样其实是手工建立了一个大小为0x1010的fast_chunk链表。由于我不太了解全局对象的构造和其他初始化阶段的顺序,为避免出现问题,我在第一次调用deque的函数时才将malloc和free钩住。

if (__builtin_expect(count == 0, 0))
    {
        __malloc_hook = chx.chenxiAlloc;
        __free_hook = chx.chenxiFree;
        count++;
    }

在这样钩住之后,调用malloc就会调用到chenxiAlloc,调用free就会调用到chenxiFree. 如果申请或释放的大小不是0x1010(这里我们将自己的chunk_size设为-0x1010,在free的时候就可以检测到),我们就调用GLIBC的malloc和free:

__malloc_hook = NULL;
void *ret = malloc(size);
if (ret == NULL)
{
    std::cout << "error occur in glibc malloc\n";
    exit(0);
}
__malloc_hook = &chenxiAlloc;
return ret;
if (*((long long *)ptr - 1) < 0)
 {
    *((void **)ptr - 2) = fast_TARGET;
    fast_TARGET = (allocChunk<TARGET> *)((void **)ptr - 2);
}
else
{
    __free_hook = NULL;
    free(ptr);
    __free_hook = &chenxiFree;
}

有趣的是,在使用O2编译优化的情况下,分配一个大小不为0x1010的块会出现意想不到的结果:free和chenxiFree反复相互调用,最终导致栈地址空间被用尽,产生segment fault.

Program received signal SIGSEGV, Segmentation fault.
0x00007ffff6f1dc25 in __GI___libc_free (mem=0x555555801710) at malloc.c:3094
3094	malloc.c: No such file or directory.
(gdb) bt
#0  0x00007ffff6f1dc25 in __GI___libc_free (mem=0x555555801710)
    at malloc.c:3094
#1  0x000055555555df90 in chxAlloc::chenxiFree(void*) ()
#2  0x00007ffff6f1dc27 in __GI___libc_free (mem=0x555555801710)
    at malloc.c:3094
#3  0x000055555555df90 in chxAlloc::chenxiFree(void*) ()
#4  0x00007ffff6f1dc27 in __GI___libc_free (mem=0x555555801710)
    at malloc.c:3094
#5  0x000055555555df90 in chxAlloc::chenxiFree(void*) ()
#6  0x00007ffff6f1dc27 in __GI___libc_free (mem=0x555555801710)
    at malloc.c:3094
#7  0x000055555555df90 in chxAlloc::chenxiFree(void*) ()
#8  0x00007ffff6f1dc27 in __GI___libc_free (mem=0x555555801710)
    at malloc.c:3094
#9  0x000055555555df90 in chxAlloc::chenxiFree(void*) ()
#10 0x00007ffff6f1dc27 in __GI___libc_free (mem=0x555555801710)
    at malloc.c:3094
#11 0x000055555555df90 in chxAlloc::chenxiFree(void*) ()
#12 0x00007ffff6f1dc27 in __GI___libc_free (mem=0x555555801710)
    at malloc.c:3094
#13 0x000055555555df90 in chxAlloc::chenxiFree(void*) ()
#14 0x00007ffff6f1dc27 in __GI___libc_free (mem=0x555555801710)
    at malloc.c:3094

更有趣的是,如果不开启编译优化,上述行为就不会出现错误。基于此,笔者想到了GCC内嵌汇编中的__volatile__关键字。

__asm__ __volatile__ (...);

这里的__volatile__表示后面的语句不作优化(可以理解为强制按指令顺序执行),但这个关键字并不是标准的C关键字,而是类似于__builtin__expected()的仅GCC实现的关键字。利用这个关键字是否可以实现不去优化呢?

遗憾的是,无论是查找中文还是英文资料,这个关键字都只用这一种用法。但令笔者眼前一亮的是,C/C++语言中有一个类似的标准关键字:volatile.

volatile是一个加在变量的声明前的关键字,类似于这样使用:

int volatile a = 0;

这表示变量a是“易变的”,也就是说,每次访问a时,必须到内存中访问,不可以用寄存器中的值。它多用于多线程环境中。为什么必须访问内存呢?假设a可以被T1和T2两个线程访问,T1首先将a的值写入EAX寄存器,但在T1使用a之前,T2更新了a的值,那么现在T1现在要使用a,应该使用内存中的更新后的值,而非EAX中的值。

笔者立刻修改了__malloc_hook和\__free_hook的声明,都加入了volatile:

typedef void *(*mallocType)(unsigned);
extern volatile mallocType __malloc_hook;

typedef void (*freeType)(void *);
extern volatile freeType __free_hook;

问题果然解决了。可是为什么不加volatile会产生那么滑稽的错误呢?笔者进行了反汇编分析:

(gdb) f 1
#1  0x000055555555df90 in chxAlloc::chenxiFree(void*) ()
(gdb) disass
Dump of assembler code for function _ZN8chxAlloc10chenxiFreeEPv:
   0x000055555555df80 <+0>:	cmpq   $0x0,-0x8(%rdi)
   0x000055555555df85 <+5>:	js     0x55555555dfa8 <_ZN8chxAlloc10chenxiFreeEPv+40>
   0x000055555555df87 <+7>:	sub    $0x8,%rsp
   0x000055555555df8b <+11>:	callq  0x55555555b450 <free@plt>
=> 0x000055555555df90 <+16>:	lea    -0x17(%rip),%rax        # 0x55555555df80 <_ZN8chxAlloc10chenxiFreeEPv>
   0x000055555555df97 <+23>:	mov    %rax,0x28e5f2(%rip)        # 0x5555557ec590 <__free_hook@@GLIBC_2.2.5>
   0x000055555555df9e <+30>:	add    $0x8,%rsp
   0x000055555555dfa2 <+34>:	retq   
   0x000055555555dfa3 <+35>:	nopl   0x0(%rax,%rax,1)
   0x000055555555dfa8 <+40>:	mov    0x28e871(%rip),%rax        # 0x5555557ec820 <_ZN8chxAlloc11fast_TARGETE>
   0x000055555555dfaf <+47>:	sub    $0x10,%rdi
   0x000055555555dfb3 <+51>:	mov    %rax,(%rdi)
   0x000055555555dfb6 <+54>:	mov    %rdi,0x28e863(%rip)        # 0x5555557ec820 <_ZN8chxAlloc11fast_TARGETE>
   0x000055555555dfbd <+61>:	retq   
End of assembler dump.

注意那句callq的上方,反汇编给出的语句是sub $8, %rsp, 也就是将rsp-8h,这语句的作用肯定不是将__free_hook置空,它其实应该在开头执行,是函数常有的抬高栈顶的操作。只是编译器检测出,另外的一条分支不需要抬高栈顶。所以只需要在这个分支做(这是一种编译优化策略,读者可自行查找相关资料)。那么我的那句\__free_hook = NULL去了哪里呢?正是因为这里没有置空,所以才导致了free调用chenxiFree,chenxiFree调用free的死锁。保险起见,我们再用IDA反编译一下:

else
  {
    free((void *)this);
    result = chxAlloc::chenxiFree;
    _free_hook = (__int64)chxAlloc::chenxiFree;
  }

果然如此,将__free_hook置NULL的操作竟然被编译器无视了。为什么会这样?笔者对编译原理了解很肤浅,难以解答这个问题。但是,这个问题确实可以通过加入volatile关键字来解决。很自然地,我们用加入后的反汇编代码做个对比:

(gdb) disass
Dump of assembler code for function _ZN8chxAlloc10chenxiFreeEPv:
=> 0x000055555555df80 <+0>:	hlt    
   0x000055555555df81 <+1>:	cmpq   $0x0,-0x8(%rdi)
   0x000055555555df86 <+6>:	js     0x55555555dfb0 <_ZN8chxAlloc10chenxiFreeEPv+48>
   0x000055555555df88 <+8>:	sub    $0x8,%rsp
   0x000055555555df8c <+12>:	movq   $0x0,0x28e5f9(%rip)        # 0x5555557ec590 <__free_hook@@GLIBC_2.2.5>
   0x000055555555df97 <+23>:	callq  0x55555555b450 <free@plt>
   0x000055555555df9c <+28>:	lea    -0x23(%rip),%rax        # 0x55555555df80 <_ZN8chxAlloc10chenxiFreeEPv>
   0x000055555555dfa3 <+35>:	mov    %rax,0x28e5e6(%rip)        # 0x5555557ec590 <__free_hook@@GLIBC_2.2.5>
   0x000055555555dfaa <+42>:	add    $0x8,%rsp
   0x000055555555dfae <+46>:	retq   
   0x000055555555dfaf <+47>:	nop
   0x000055555555dfb0 <+48>:	mov    0x28e869(%rip),%rax        # 0x5555557ec820 <_ZN8chxAlloc11fast_TARGETE>
   0x000055555555dfb7 <+55>:	sub    $0x10,%rdi
   0x000055555555dfbb <+59>:	mov    %rax,(%rdi)
   0x000055555555dfbe <+62>:	mov    %rdi,0x28e85b(%rip)        # 0x5555557ec820 <_ZN8chxAlloc11fast_TARGETE>
   0x000055555555dfc5 <+69>:	retq   
End of assembler dump.

注意eip+12的位置,这一句话执行了__free_hook = NULL.

最后,用贝木泥舟式的话来说,从这次的事件中我应该得到的教训是:编译优化会产生BUG.