By: Ailson Jack
Date: 2018.09.17
个人博客: http://www.only2fire.com/
本文在我博客的地址是:http://www.only2fire.com/archives/80.html,排版更好,便于学习,也可以去我博客逛逛,兴许有你想要的内容呢。
1、C函数调用机制
函数调用操作包括从一块代码到另一块代码之间的双向数据传递和执行控制转移。数据传递通过函数参数和返回值来进行。另外,我们还需要在进入函数时为函数的局部变量分配存储空间,并且在退出函数时收回这部分空间。Intel 80x86 CPU为控制传递提供了简单的指令,而数据的传递和局部变量存储空间的分配与回收则通过栈操作来实现。
(1)、栈帧结构和控制转移权方式
大多数CPU上的程序实现使用栈来支持函数调用操作。栈被用来传递函数参数、存储返回信息、临时保存寄存器原有值以备恢复以及用来存储局部数据。单个函数调用操作所使用的栈部分被称为栈帧(Stack frame)结构,结构如图3-4所示。栈帧结构的两端由两个指针来指定。寄存器ebp通常用作帧指针(frame pointer),而esp则用作栈指针(stack pointer)。在函数执行过程中,栈指针esp会随着数据的入栈和出栈而移动,因此函数中对大部分数据的访问都基于帧指针ebp进行。
对于函数A调用函数B的情况,传递给B的参数包含在A的栈帧中。当A调用B时,函数A的返回地址(调用返回后继续执行的指令地址)被压入栈中,栈中该位置也明确指明了A栈帧的结束处。而B的栈帧则从随后的栈部分开始,即图中保存栈指针(ebp)的地方开始。再随后则用于存放任何保存的寄存器值以及函数的临时值。
栈是往低(小)地址方向扩展的,而esp指向当前栈顶处的元素。通过使用push和pop指令我们可以把数据压入栈中或者从栈中弹出。对于没有指定初始值的数据所需要的存储空间,我们可以通过把栈指针递减适当的值来做到。类似的,通过增加栈指针值我们可以回收栈中已分配的空间。
指令CALL和RET用于处理函数调用和返回操作。调用指令CALL的作用是把返回地址压入栈中并且跳转到被调用函数开始处执行。返回地址是程序中紧随调用指令CALL后面一条指令的地址。因此当被调函数返回时就会从该位置继续执行。返回指令RET用于弹出栈顶处的地址并跳转到该地址处。在使用该指令之前,应该先正确处理栈中内容,使得当前栈指针所指位置内容正是先前CALL指令保存的返回地址。另外,若返回值是一个整数或者一个指针,那么寄存器eax将被默认用来传递返回值。
尽管某一时刻只有一个函数在执行,但我们还是需要确定在一个函数(调用者)调用其他函数(被调用者)时,被调用者不会修改或覆盖掉调用者今后要用到的寄存器内容。因此Intel CPU采用了所有函数必须遵守的寄存器用法统一惯例。该惯例指明,寄存器eax、edx和ecx的内容必须由调用者自己负责保存。当函数B被A调用时,函数B可以在不用保存这些寄存器内容的情况下任意使用它们而不会毁坏函数A所需要的任何数据。另外,寄存器ebx、esi和edi的内容则必须由被调用者B来保护。当被调用者需要使用这些寄存器中的任何一个时,必须首先在栈中保存其内容,并在退出时恢复这些寄存器的内容。因为调用者A(或者一些更高层的函数)并不负责保存这些寄存器的内容,但可能在以后的操作中还需要用到原先的值。还有寄存器ebp和esp也必须遵守第二个惯例用法,即由被调用者来保护。
(2)、函数调用举例
我们观察下面C程序exch.c中函数调用的处理过程。该程序交换两个变量中的值,并返回它们的差值:
void swap(int *a, int *b)
{
int c;
c = *a; *a = *b; *b = c;
}
int main()
{
int a, b;
a = 16; b = 32;
swap(&a, &b);
return (a-b);
}
其中函数swap()用于交换两个变量的值。C程序中的主程序main()也是一个函数(将在本章的后面进行说明),它在调用了swap()之后返回交换的结果。这两个函数的栈帧结构如图3-5所示。可以看出,函数swap()从调用者(main())的栈帧中获取其参数。图中的位置信息相对于寄存器ebp中的帧指针。栈帧左边的数字指出了相对于帧指针的地址偏移值。在象gdb这样的调试器中,这些数值都用2的补码表示,例如,’-4’被表示成’0xfffffffc’,’-12’被表示成’0xfffffff4’。
调用者main()的栈帧结构中,包括局部变量a和b的存储空间,相对于帧指针位于-4和-8偏移处。由于我们需要为这两个局部变量生成地址,因此它们必须保存在栈中而非简单的存放在寄存器中。
使用命令”gcc -Wall -S -o exch.s exch.c”可以生成该C语言程序的汇编程序exch.s代码,见如下所示(删除了一些无关的代码):
.text
_swap:
pushl %ebp #保存原ebp值,设置当前函数的帧指针
movl %esp, %ebp
subl $4, %esp #为局部变量c在栈内分配空间
movl 8(%ebp), %eax #取函数第1个参数,该参数是一个整数类型值的指针
movl (%eax),%ecx #取该指针所指位置的内容,并保存到局部变量c中
movl %ecx, -4(%ebp)
movl 8(%ebp), %eax #再次取第1个参数,然后取第2个参数
movl 12(%ebp), %edx
movl (%edx), %ecx #把第2个参数所指内容放到第1个参数所指的位置
movl %ecx, (%eax)
movl 12(%ebp), %eax #再次取第2个参数
movl -4(%ebp), %ecx #然后把局部变量c中的内容放到这个指针所指位置处
movl %ecx, (%eax)
leave #恢复原ebp、esp值(即movl %ebp,%esp; popl %ebp)
ret
_main:
pushl %ebp #保存原ebp值,设置当前函数的帧指针
movl %esp, %ebp
subl $8, %esp #为整型局部变量a和b在栈中分配空间
movl $16, -4(%ebp) #为局部变量赋初值(a=16,b=32)
movl $32, -8(%ebp)
leal -8(%ebp), %eax #为调用swap()函数作准备,取局部变量b的地址
pushl %eax #作为调用的参数并压入栈中,即先压入第2个参数
leal -4(%ebp), %eax #再取局部变量a的地址,作为第1个参数入栈
pushl %eax
call _swap #再调用函数swap()
movl -4(%ebp), %eax #取第一个局部变量a的值,减去第二个变量b的值
subl -8(%ebp), %eax
leave #恢复原ebp、esp值(即movl %ebp,%esp; popl %ebp)
ret
这两个函数均可以划分为3个部分:”设置”,初始化栈帧结构;”主体”,执行函数的实际计算操作;”结束”,恢复栈状态并从函数中返回。
leave指令用于处理栈内容以准备返回,它的作用等价于下面两个指令:
movl %ebp, %esp #恢复原esp的值(指向栈帧开始处)
popl %ebp #恢复原ebp的值(通常是调用者的帧指针)
变量地址压入栈中的顺序正好与函数声明的参数顺序相反。即函数最后一个参数首先压入栈中,而函数的第1个参数则是最后一个在调用函数指令call之前压入栈中的。
(3)、main也是一个函数
上面提到C程序的主程序main()也是一个函数,这是因为在编译链接时它将会作为crt0.s汇编程序的函数被调用。crt0.s是一个桩(stub)程序,名称中的”crt”是”C run-time”的缩写。该程序的目标文件将被链接在每个用户执行程序的开始部分,主要用于设置一些初始化全局变量等。Linux 0.11中crt0.s汇编程序如下所示,其中建立并初始化全局变量_environ供程序中其他模块使用。
.text
.globl _environ #声明全局变量_environ(对应C程序中的environ变量)
__entry: #代码入口标号
movl 8(%esp), %eax #取程序的环境变量指针envp并保存在_environ中
movl %eax, _environ #envp是execve()函数在加载执行文件时设置的
call _main #调用我们的主程序,其返回状态值在eax寄存器中
pushl %eax #压入返回值作为exit()函数的参数并调用该函数
1: call _exit
jmp 1b #控制应该不会到达这里,若到达这里则继续执行exit()
.data
_environ: #定义变量_environ,为其分配一个长字空间
.long 0
在通常的编译过程中,我们无需特别指定stub模块crt0.o,但是若想从上面给出的汇编程序手工使用ld从exch.o模块链接产生可执行文件exch,那么我们就需要在命令行上特别指明crt0.o这个模块,并且链接的顺序应该是”crt0.o、所有程序模块、库文件”。
为了使用ELF格式的目标文件以及建立共享库模块文件,现在的gcc编译器(2.x)已经把这个crt0扩展成几个模块:crt1.0、crti.o、crtbegin.o、crtend.o和crtn.o。这些模块的链接顺序为“crt1.0、crti.o、crtbegin.o(crtbeginS.o)、所有程序模块、crtend.o(crtendS.o)、crtn.o、库模块文件”。gcc的配置文件specfile指定了这种链接顺序。其中crt1.o、crti.o和crtn.o由C库提供,是C程序的“启动”模块;crtbegin.o和crtend.o是C++语言的启动模块,由编译器gcc提供;而crt1.o则与crt0.o的作用类似,主要用于在调用main()之前做一些初始化工作,全局符号_start就定义在这个模块中。
crtbegin.o和crtend.o主要用于C++语言在.ctors和.dtors区中执行全局构造器(constructor)和析构器(destructor)函数。crtbeginS.o和crtendS.o的作用与前两者类似,但用于创建共享模块中。crti.o用于在.init区中执行初始化函数init()。.init区中包含进程的初始化代码,即当程序开始执行时,系统会在调用main()之前先执行.init中的代码。crtn.o则用于在.fini区中执行进程终止退出处理函数fini()函数,即当程序正常退出时(main()返回之后),系统会安排执行.fini中的代码。
boot/head.s程序中第136-140行就是用于为跳转到init/main.c中的main()函数作准备工作。第139行上的指令在栈中压入了返回地址,而第140行则压入了main()函数代码的地址。当head.s最后在第218行上执行ret指令时就会弹出main()的地址,并把控制权转移到init/main.c程序中。
2、在汇编程序中调用C函数
在汇编程序调用一个C函数时,程序需要首先按照逆向顺序把函数参数压入栈中,即函数最后(最右边的)一个参数先入栈,而最左边的第1个参数在最后调用指令之前入栈,如图3-6所示。然后执行CALL指令去执行被调用的函数。在调用函数返回后,程序需要再把先前压入栈中的函数参数清除掉。
在执行CALL指令时,CPU会把CALL指令下一条指令的地址压入栈中(见图中EIP)。如果调用还涉及到代码特权级变化,那么CPU还会进行堆栈切换,并且把当前堆栈指针、段描述符和调用参数压入新堆栈中。由于Linux内核中只使用中断门和陷阱门方式处理特权级变化时的调用情况,并没有使用CALL指令来处理特权级变化的情况,因此这里对特权级变化时的CALL指令使用方式不再进行说明。
汇编中调用C函数比较“自由”。只要是在栈中适当位置的内容就都可以作为参数供C函数使用。这里以图3-6中具有3个参数的函数调用为例,如果我们没有专门为调用函数func()压入参数就直接调用它的话,那么func()函数仍然会把存放EIP位置以上的栈中其他内容作为自己的参数调用。如果我们为调用func()而仅仅明确的压入了第1、第2个参数,那么func()函数的第3个参数p3就会直接使用p2前的栈中内容。
另外,我们说汇编程序调用C函数比较自由的另一个原因是我们可以根本不用CALL指令而采用JMP指令来同样达到调用函数的目的。方法是在参数入栈后人工把下一条要执行的指令地址压入栈中,然后直接使用JMP指令跳转到被调用函数开始地址处去执行函数。此后,当函数执行完成时就会执行RET指令把我们人工压入栈中的下一条指令地址弹出,作为函数返回的地址。Linux内核中也有多处用到了这种函数调用方法,例如kernel/asm.s程序第62行调用执行traps.c中的do_int3()函数的情况。
3、在C程序中调用汇编函数
从C程序中调用汇编程序函数的方法与汇编程序中调用C函数的原理相同,但Linux内核程序中不常使用。调用方法的着重点仍然是对函数参数在栈中位置的确定上。当然,如果调用的汇编语言程序比较短,那么可以直接在C程序中使用前面介绍的内联汇编语句来实现。下面是一个示例,包含两个函数的汇编程序callee.s如下所示:
#/*
# *By:Ailson Jack
# *Date:2018.08.28
# *Blog:www.only2fire.com
# *Des:本汇编程序利用系统调用sys_write()实现显示函数int mywrite(int fd, char *buf, int count).
# * 函数int myadd(int a, int b, int *res)用于执行a+b=res运算.若函数返回0,则说明溢出.
# *注意:如果在现在的Linux系统(例如RedHat9)下编译,则请去掉函数名前的下划线'_'.
# *编译:as -o callee.o callee.s
#*/
#.code32 #让as汇编器切换为32位代码汇编方式,否则在64位系统下编译时,编译的时候可能会出现"invalid instruction suffix for push"的问题
SYSWRITE = 4 #sys_write()系统调用号
.globl _mywrite, _myadd
.text
_mywrite:
pushl %ebp
movl %esp, %ebp
pushl %ebx
movl 8(%ebp), %ebx #取调用者的第1个参数:文件描述符 fd
movl 12(%ebp), %ecx #取第2个参数:缓冲区指针
movl 16(%ebp), %edx #取第3个参数:显示字符数
movl $SYSWRITE, %eax # %eax中放入系统调用号4
int $0x80 #执行系统调用
popl %ebx
movl %ebp, %esp
popl %ebp
ret
_myadd:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax #取第1个参数:a
movl 12(%ebp), %edx #取第2个参数:b
xorl %ecx, %ecx # %ecx为0表示计算溢出
addl %eax, %edx #执行加法运算
jo 1f #若溢出 则跳转
movl 16(%ebp), %eax #取出第3个参数的指针
movl %edx, (%eax) #把计算结果放入指针所指位置处
incl %ecx #没有发生溢出,于是设置无溢出返回值
1: movl %ecx, %eax # %eax中是函数返回值
movl %ebp, %esp
popl %ebp
ret
该汇编文件中的第1个函数mywrite()利用系统中断0x80调用系统调用sys_write(int fd, char *buf, int count)实现在屏幕上显示信息。对应的系统调用功能号是4(参见include/unistd.h中的__NR_write),三个参数分别为文件描述符、显示缓冲区指针和显示字符数。在执行int 0x80之前,寄存器%eax中需要放入调用功能号(4),寄存器%ebx、%ecx和%edx要按调用规定分别存放fd、buf和count。函数mywrite()的调用参数个数和用途与sys_write()完全一样。
第2个函数myadd(int a, int b, int *res)执行加法运算,其中参数res是运算的结果。函数返回值用于判断是否发生溢出。如果返回值为0表示计算已经发生溢出,结果不可用。否则计算结果将通过res返回给调用者。
注意:如果在现在的Linux系统(例如RedHat 9)下编译callee.s程序,则请去掉函数名前的下划线’_’。调用这两个函数的C程序caller.c如下所示:
/*
*By:Ailson Jack
*Date:2018.08.28
*Blog:www.only2fire.com
*Des:调用汇编函数mywrite(fd, buf, count)显示信息;调用myadd(a, b, result)执行加运算.
*如果myadd()返回0,则表示加函数发生溢出.首先显示开始计算信息,然后显示运算结果.
*/
#include
int main(void)
{
char buf[1024];
int a, b, res;
char *mystr = "Calculating...\r\n";
char *emsg = "Error in adding...\r\n";
a = 5;
b = 10;
mywrite(1, mystr, strlen(mystr));
if(myadd(a, b, &res))
{
sprintf(buf, "The result is %d\r\n", res);
mywrite(1, buf, strlen(buf));
}
else
mywrite(1, emsg, strlen(emsg));
return 0;
}
该函数首先利用汇编函数mywrite()在屏幕上显示开始计算的信息“Calculating…”,然后调用加法计算汇编函数myadd()对a和b两个数进行运算,并在第3个参数res中返回计算结果。最后再利用mywrite()函数把格式化过的结果信息字符串显示在屏幕上。如果函数myadd()返回0,则表示加函数发生溢出,计算结果无效。这两个文件的编译和运行结果如下所示:
# as -o callee.o callee.s
# gcc -o caller caller.c callee.o
# ./caller
注意:上述汇编程序和C程序在我提供的Linux 0.11系统和Ubuntu 14.04的32位系统均可以编译和运行成功;在Ubuntu 14.04的64位系统中可以编译,但是运行不成功。在其他的32位版本Linux系统中应该也可以正常的编译和运行,64位系统可能不行。
上述代码例子我已经上传到云盘,大家可以自行下载研究,下载地址: 密钥:。
排版更好的内容见我博客的地址:http://www.only2fire.com/archives/80.html
注:转载请注明出处,谢谢!^_^