7.4 函数之间的调用关系

关于函数之间的调用关系,不少初学者可能并不清楚,对函数调用的认识也只是表面上的。本节先根据系统提供的函数实现函数间的调用关系,明白了其实现原理之后,再实现函数间的调用关系。

在讲解之前,先来看以下几个函数。

backtrace()函数


int backtrace(void**buffer,int size)


该函数用来获取当前线程的调用堆栈。获取的信息将会被存放在buffer中。buffer是一个指针列表,参数size用来指定buffer中可以保存多少个void*元素。函数返回值是实际获取的指针个数,最大不超过size的大小,在buffer中的指针实际是从堆栈中获取的返回地址,每一个堆栈框架有一个返回地址。

backtrace_symbols()函数


char*backtrace_symbols(voidconst*buffer,int size)


该函数将backtrace()函数获取的信息转化为一个字符串数组,其中,参数buffer就是在backtrace()函数中获取的buffer参数,size是数组中的元素个数,也就是backtrace()函数的返回值。backtrace_symbols()函数的返回值是一个指向字符数组的指针,其大小同buffer相同,数组中的每个元素都包含函数名、函数偏移地址、实际的返回地址。需要注意的是,该函数的返回指针是通过malloc函数申请的空间,因此在调用该函数后还要使用free()函数来释放该函数返回的指针。

backtrace_symbols_fd()函数


void backtrace_symbols_fd(voidconstbuffer,int size,int fd)


该函数与上面的backtrace_symbols()函数的功能一样,区别在于该函数没有返回值,不必采用malloc函数分配内存空间,而是将结果写入文件描述符fd的文件中,每个函数对应文件中的一行。

接下来通过前两个函数来查看函数间的调用关系,代码如下:


include<stdio.h>

include<stdlib.h>

include<execinfo.h>

define MAX_LEVEL 10

void call_2()

{

int i=0;

void*buffer[MAX_LEVEL]={0};

int size=backtrace(buffer,MAX_LEVEL);

char**strings=backtrace_symbols(buffer,size);

printf("Obtained%zd stack frames.\n",size);

for(i=0;i<size;i++)

printf("%s\n",strings[i]);

free(strings);

return;

}

void call_1()

{

call_2();

return;

}

void call()

{

call_1();

return;

}

int main(int argc,char*argv[])

{

call();

return 0;

}


运行结果:


root@ubuntu:/home#gcc backtr.c-rdynamic-o backtr

root@ubuntu:/home#./backtr

Obtained 6 stack frames.

./backtr(call_2+0x35)[0x80486b9]

./backtr(call_1+0xb)[0x804872a]

./backtr(call+0xb)[0x8048737]

./backtr(main+0xb)[0x8048744]

/lib/i386-linux-gnu/libc.so.6(__libc_start_main+0xe7)[0x737e37]

./backtr()[0x80485f1]


分析上面的代码可知,这里采用backtrace_symbols()函数来实现打印,当然也可以采用backtrace_symbols_fd()函数实现将结果保存到一个文件中。在编译的时候指定了一个-rdynamic选项,该选项的功能是支持函数的功能名。从运行结果可以看出,得到的是一些不直观的偏移地址和实际的返回地址,可以将它们转换为代码中的相对位置,这可以通过addr2line工具来实现,接下来简单地讲解一下该工具的使用方法。要通过实际地址得到其在源代码中的相对位置,可以使用如下指令:


addr2line[-f]-e可执行文件名 实际地址


需要注意的是,在使用addr2line工具的时候,需要在编译可执行文件的时候带有-g选项,其中[-f]为可选项,其功能为打印出实际地址所在的函数名称。例如选用上面的第一个实际地址,执行的指令及得到的结果如下:


root@ubuntu:/home#addr2line-f-e backtr 0x80486b9

call_2

/home/backtr.c:11


从运行结果可以看出,该地址位于call_2函数中,位于代码的第11行。

在讲解backtrace()函数的实现原理之前,先来看看函数调用过程中的压栈操作。第1章讲解堆栈时,提到了临时变量的压栈,接下来通过图7-4来分析函数调用过程中的压栈操作。

7.4 函数之间的调用关系 - 图1

图 7-4 函数调用过程中的压栈操作

从图7-4可以看出,函数参数的压栈是从右向左进行的,而临时变量的压栈是从上到下进行的,对临时变量中的数组压栈同样是从右向左进行的,即首先压栈的是下标最大的元素,最后压栈的是第一个数组元素。在函数参数和临时变量的压栈之间还有两个值,一个是ebp寄存器的值,另外一个是eip寄存器的值。ebp寄存器保存的是上一个函数的ebp的压栈地址,通过该ebp就能够取出上一个调用函数的ebp值。eip寄存器保存的是CPU下次所要执行的指令地址。ebp寄存器存储的是栈底地址,而这个地址是由esp在函数调用前传递给ebp的。等到调用结束,ebp会把其地址再次回传给esp,这样esp又一次指向了函数调用结束后,即栈顶的地址,通过这样的方法就实现了函数之间的调用。为了加深对栈结构的印象,先来看下面的代码。


include<stdio.h>

void test(int a,int b,int c)

{

int arr[3]={1,2,3};

printf("函数参数地址:&a=%d\t&b=%d\t&c=%d\n",&a,&b,&c);

printf("临时变量地址:&arr[2]=%d\t&arr[1]=%d\t&arr[0]=%d\n",&arr[2],&arr[1],&arr[0]);

return;

}

int main()

{

test(1,2,3);

return 0;

}


运行结果:


函数参数地址:&a=1244968&b=1244972&c=1244976

临时变量地址:&arr[2]=1244956&arr[1]=1244952&arr[0]=1244948


对于上面的运行结果,可以通过图7-5来演示函数的压栈操作,函数参数的压栈和临时变量中数组元素的压栈都符合上面的分析。第一个函数参数地址和数组最后一个元素a[2]地址之差为12,因为这12字节的存储空间中有4字节是数组元素arr[2]的,剩下的8字节存储空间是ebp和eip的。

7.4 函数之间的调用关系 - 图2

图 7-5 函数调用时压栈的存储结构

分析完参数的压栈操作,再来验证当前函数中压栈的ebp寄存器的值是否为上一个函数压栈的ebp寄存器的地址,看下面的代码。


include<stdio.h>

void test1()

{

int a;

printf("test1()函数中ebp的值为:%d\n",*(&a+1));

return;

}

void test()

{

int n;

printf("test()函数中存放ebp寄存器的内存单元地址为:%d\n",&n+1);

test1();

return;

}

int main(int argc,char*argv[])

{

test();

return 0;

}


运行结果:


test()函数中存放ebp寄存器的内存单元地址为:1244972

test1()函数中ebp的值为:1244972


从上面的运行结果可以看出,每个函数中压栈的ebp寄存器的值都是上一个函数中ebp值所在内存单元的地址。了解了函数的压栈操作,接下来实现函数间的调用关系,看下面的代码。


include<stdio.h>

define MAX_LEVEL 4

int backtrace(void**buffer,int size)

{

int n;

int*p=&n;

int i=0;

int ebp=*(p+5);

int eip=*(p+6);

for(i=0;i<size;i++)

{

buffer[i]=(void*)eip;

p=(int*)ebp;

ebp=*p;

eip=*(p+1);

}

return size;

}

void call2()

{

int i=0;

void*buffer[MAX_LEVEL]={0};

int size=backtrace(buffer,MAX_LEVEL);

for(i=0;i<size;i++)

{

printf("called by%p\n",buffer[i]);

}

return;

}

void call1()

{

call2();

return;

}

void call()

{

call1();

return;

}

int main()

{

call();

return 0;

}


运行结果:


root@ubuntu:/home#gcc-g backtr.c-o backtr

root@ubuntu:/home#./backtr

called by 0x804848a

called by 0x80484c7

called by 0x80484d4

called by 0x80484e1


分析上面的代码,首先需要注意的是早期gcc版本中的压栈操作与新版本稍有不同,主要体现在对ebp和eip的压栈处理上。早期的压栈与VC的压栈方法相同,就是在函数参数和临时变量之间压栈,而新版本则把ebp和eip的压栈操作放到了最后一个临时变量的后面。上面的代码是采用新版本的gcc编译的,由于backtrace()函数中有5个临时变量,因此先通过整型指针p取出第一个临时变量的地址,对其加5,指向ebp所在的内存单元地址,然后将其值放到ebp变量中,最后通过一个for循环来逐一取出每个函数中eip的值。

可以对上面结果中的实际地址使用addr2line工具获得其在文件中的相对位置,如:


root@ubuntu:/home#addr2line-f-e backtr 0x804848a

call2

/home/backtr.c:30