简介函数调用对于程序员来说就像吃饭睡觉一样平常。几乎每一种编程语言都提供了函数定义和函数调用的功能。然而,在看似普通的函数调用背后,系统内核却帮我们做了很多事情。接下来,我打算通过反汇编的方法,从汇编语言的层面来解释函数调用的实现。基础知识先回顾几个概念,可以帮助我们顺利理解后续实验的结果。调用函数(caller)和被调用函数(callee)调用函数(caller)向被调用函数(callee)传递参数,被调用函数(callee)返回结果。首先,必须明确这两个名词,以免被后面的表述混淆。高地址和低地址每个进程都有自己的虚拟地址空间。高地址和低地址是相对的,我们通常用十六进制数来表示一个内存地址。比如相对于0x00,0x04在数值上比0x00大,所以0x04称为高地址,0x00称为低地址。一个进程的内存布局如图所示。一个进程的内存布局是从低地址到高地址的代码段数据段,包括初始化区和未初始化区(bss)栈段内核地址空间栈段(stacksegment)是最常用的数据结构之一,可以是push/pop,并且只允许在一端进行操作,后进先出(LIFO)。但正是这种最简单的数据结构,构成了计算机中程序执行的基础。内核中用于程序执行的栈具有以下特点:每个进程在用户态对应一个调用栈结构(callstack)。完成操作的函数对应一个栈帧(stackframe)。栈帧中存放着函数的局部变量、传递给被调用函数的参数等信息。栈底对应高地址,栈顶对应低地址。进程增长的调用栈图如下:寄存器位于CPU内部,用于存放程序执行时使用的数据和指令。CPU从寄存器中取数据,这比从内存中取数据要快得多。寄存器分为通用寄存器和专用寄存器。通用寄存器包括ax/bx/cx/dx/di/si。虽然这些寄存器在大多数指令中可以任意使用,但也有一些规定,某些指令只能使用特定的“通用”寄存器,比如函数返回时。将返回值移动到ax寄存器;专用寄存器包括bp/sp/ip等,专用寄存器有特定的用途。比如sp寄存器就是用来存放上述栈帧的栈顶地址的。此外,它不用于存储局部变量或其他用途。几个有特定用途的寄存器,简单介绍如下:ax(累加器):可以用来存放函数的返回值bp(基指针):用来存放函数对应的栈帧的底地址beingexecutedsp(stackpointer):用于存放正在执行的函数对应的栈帧的栈顶地址ip(instructionpointer):指向当前正在执行的指令的下一条指令对于不同架构的CPU,寄存器名称分别为用不同的前缀来表示寄存器的大小。例如,对于x86架构,字母“e”作为名称前缀,表示每个寄存器的大小为32位;对于x86_64寄存器,字母“r”用作名称前缀,表示每个寄存器的大小为64位。intel8086汇编或类似知识应该在大学课程中介绍(如微机原理、汇编语言)。我相信应该可以举一反三。在很多情况下,只是寄存器的名字变了,大意还是一样的。函数调用实例掌握了基础知识后,我们选择下面这个简单的实例进行分析。//call_example.cintadd(inta,intb){returna+b;}intmain(void){添加(2,5);return0;}可以通过命令gcccall_example.c-g-ocall_example执行文件call_example。加入参数-g,使目标文件call_example中包含符号表等调试信息。我们可以使用objdump-D-Matt./call_example命令先反汇编call_example看看结果。截取部分结果如下:00000000004004a6:4004a6:55push%rbp4004a7:4889e5mov%rsp,%rbp4004aa:897dfcmov%edi,-0x4(%rbp)4004ad:8975f8mov%esi,-0x8(%rbp)4004b0:8b55fcmov-0x4(%rbp),%edx4004b3:8b45f8mov-0x8(%rbp),%eax4004b6:01d0添加%edx,%eax4004b8:5dpop%rbp4004b9:c3retq00000000004004ba:4004ba:55push%rbp4004bb:4889e5mov%rsp,%rbp4004be:be05000000mov$0x5,%esi4004c200bfmov$0x2,%edi4004c8:e8d9ffffffcallq4004a64004cd:b800000000mov$0x0,%eax4004d2:5dpop%rbp4004d3:c3retq4004d4:662e0f1f840000nopw%cs:0x0(%rax,%rax,1)4004db:0000004004de:6690xchg%ax,%axobjdump是一个很好的工具,但有时它似乎不是如此直观。下面我将重点介绍使用gdb进行分析和反汇编分析。使用gdb进行反汇编分析。我们使用gdb来跟踪main->add的过程。开始使用gdb加载可执行程序call_example$gdb./call_exampleGNUgdb(GDB)7.12.1Readingsymbolsfrom./call_example...done.(gdb)startTemporarybreakpoint1at0x4004be:filecall_example.c,line3.Startingprogram:/tmp/call_exampleTemporarybreakpoint1,main()atcall_example.c:33add(2,5);(gdb)start命令用于拉起被调试的程序,执行到main函数开头,之后该程序被执行与用户态调用堆栈相关联。mainfunction现在程序停在main函数,使用disassemble命令显示当前函数的汇编信息:(gdb)disassemble/mrDumpofassemblercodeforfunctionmain:2intmain(void){0x00000000004004ba<+0>:55push%rbp0x00000000004004bb<+1>:4889e5mov%rsp,%rbp3add(2,5);=>0x00000000004004be<+4>:be05000000mov$0x5,%esi0x00000000004004c3<+9>:bf02000000mov$0x2,%edi0x00000000004004c8<+14>:e8d9ffffffcallq0x4004a64返回0;0x00000000004004cd<+19>:b800000000mov$0x0,%eax400020+0000}40004:5dpop%rbp0x00000000004004d3<+25>:c3retq汇编程序转储结束。(gdb)/m指令反汇编指令在显示汇编指令的同时显示相应的程序源代码;/r指令显示十六进制计算机指令(原始指令)。以上输出的每一行都表示一条汇编指令。除程序源代码外,一共四栏。每一列的含义是:0x00000000004004ba:指令对应的虚拟内存地址Computerinstructionpush%rbp:汇编指令回想一下,我们在用汇编语言编写调用函数的代码时,第一步是“保护场景”,即:将调用函数的栈帧的底地址压入栈中,即bp寄存器的地址,将值压入调用栈中,新建一个栈帧,将栈底地址压入将被调用函数的帧存入bp寄存器,其值为调用函数sp的栈顶地址下面两条指令完成上述动作:push%rbpmov%rsp,%rbp通过objdump和gdb的结果,我们发现main函数也包含这两条指令。这是因为__libc_start_main也会调用main函数,这里就不赘述了。main调用add函数,将两个参数传入通用寄存器:mov$0x5,%esimov$0x2,%edi咦?汇编语言课上老师不是教过传入的参数会入栈吗?实际上x86和x86_64定义了不同的函数调用约定(callingconvention)。x86_64采用的是将参数传入通用寄存器的方式,而x86则是将参数压入调用栈。我们使用gcc-S-m32call_example.c直接生成x86平台的汇编代码,找到传递参数的代码:pushl$5pushl$2calladd就这样啦!准备好参数后,就可以放心大胆的把控制权交给add函数了。callq指令在这里完成交接任务:0x00000000004004c8<+14>:e8d9ffffffcallq0x4004a6callq指令会调用该函数,此时将下一条指令的地址压入栈中。当调用结束时,retq指令会跳转到保存的返回地址继续程序执行。这条callq指令完成了两个任务:将调用函数(main)中的下一条指令(这里是0x00000000004004cd)压入栈中,被调用函数返回后,会取这条指令继续执行修改指令指针的值注册rip,使其指向被调用函数(add)的执行位置,这里是0x00000000004004a6。我们可以使用stepi指令来执行指令级操作。与一般调试相比,逐行调试的粒度会更细。(gdb)stepi3add(a=0,b=4195248)在call_example.c:11intadd(inta,intb){returna+b;}(gdb)反汇编/mrDump函数add:1intadd(inta,intb){returna+b;}=>0x00000000004004a6<+0>:55push%rbp0x00000000004004a7<+1>:4889e5mov%rsp,%rbp0x00000000004004aa8<+4>:7dfcmov%edi,-0x4(%rbp0)0004000x0000>:8975f8mov%esi,-0x8(%rbp)0x00000000004004b0<+10>:8b55fcmov-0x4(%rbp),%edx0x000000004004B3<+13>:8B45F8MOV-0X8(%RBP),%Eax0x000000004004B6<+16>:01D0ADD%EDX,%EAX0x000000004004B8<+18>:5DPOP%RBP0x00004004B9<+19>:C3>C3>retq汇编程序转储结束。(gdb)至此,main函数的执行告一段落,我们进入了add函数的新篇章。add函数add函数也是同样的套路。前两条指令先创建自己的栈帧,然后调用add指令计算结果,结果存入eax寄存器。计算完之后需要“还原场景”:0x00000000004004b8<+18>:5dpop%rbp因为这个例子比较特殊,add函数不包含局部变量,main和add函数的栈顶是完全一样,所以栈顶的rsp被忽略恢复。通常,一个完整的“恢复场景”需要以下两条指令:mov%rbp,%rsppop%rbp参考:https://web.stanford.edu/clas...