一 C与汇编程序的相互调用
为了提高代码执行效率,内核源代码中有的地方直接使用了汇编语言编制。这就会涉及在两种语言编制的程序之间的相互调用问题。本节首先说明C语言函数的调用机制,然后举例说明两者函数之间的调用方法。
(一) C函数调用机制
在Linux内核程序boot/head.s执行完基本初始化操作之后,就会跳转去执行init/main.c程序。那么head.s程序是如何把执行控制转交给init/main.c程序的呢?即汇编程序是如何调用执行C语言程序的?这里我们首先描述一下C函数的调用机制、控制权传递方式,然后说明head.s程序跳转到C程序的方法。
函数调用操作包括从一块代码到另一块代码之间的双向数据传递和执行控制转移。数据传递通过函数参数和返回值来进行。另外,我们还需要在进入函数时为函数的局部变量分配存储空间,并且在退出函数时收回这部分空间。Intel 80x86 CPU为控制传递提供了简单的指令,而数据的传递和局部变量存储空间的分配与回收则通过栈操作来实现。
1.栈帧结构和控制转移权方式
大多数CPU上的程序实现使用栈来支持函数调用操作。栈被用来传递函数参数、存储返回信息、临时保存寄存器原有值以备恢复以及用来存储局部数据。单个函数调用操作所使用的栈部分被称为栈帧(stack frame)结构,其一般结构如图3-4所示。栈帧结构的两端由两个指针来指定。寄存器ebp通常用做帧指针(frame pointer),而esp则用作栈指针(stack pointer)。在函数执行过程中,栈指针esp会随着数据的入栈和出栈而移动,因此函数中对大部分数据的访问都基于帧指针ebp进行。
![]() |
图3-4 栈中帧结构示意图 |
对于函数A调用函数B的情况,传递给B的参数包含在A的栈帧中。当A调用B时,函数A的返回地址(调用返回后继续执行的指令地址)被压入栈中,栈中该位置也明确指明了A栈帧的结束处。而B的栈帧则从随后的栈部分开始,即图中保存帧指针(ebp)的地方开始。再随后则用于存放任何保存的寄存器值以及函数的临时值。
B函数同样也使用栈来保存不能放在寄存器中的局部变量值。例如由于通常CPU的寄存器数量有限而不能够存放函数的所有局部数据,或者有些局部变量是数组或结构,因此必须使用数组或结构引用来访问。另外,C语言的地址操作符"&"被应用到一个局部变量上时,我们就需要为该变量生成一个地址,即为变量的地址指针分配一空间。最后,B函数会使用栈来保存调用任何其他函数的参数。
栈是往低(小)地址方向扩展的,而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中函数调用的处理过程。该程序交换两个变量中的值,并返回它们的差值。
1 void swap(int * a, int *b) 2 { 3int c; 4c = *a; *a = *b; *b = c; 5 } 6 7 int main() 8 { 9int a, b; 10a = 16; b = 32; 11swap(&a, &b); 12return (a - b); 13 } |
其中函数swap()用于交换两个变量的值。C程序中的主程序main()也是一个函数(将在下面说明),它在调用了swap()之后返回交换后的结果。这两个函数的栈帧结构如图3-5所示。可以看出,函数swap()从调用者main()的栈帧中获取其参数。图中的位置信息相对于寄存器ebp中的帧指针。栈帧左边的数字指出了相对于帧指针的地址偏移值。在像gdb这样的调试器中,这些数值都用2的补码表示。例如,-4被表示成0xFFFFFFFC,-12会被表示成0xFFFFFFF4。
调用者main()的栈帧结构中包括局部变量a和b的存储空间,相对于帧指针位于-4和-8偏移处。由于我们需要为这两个局部变量生成地址,因此它们必须保存在栈中而非简单地存放在寄存器中。
![]() |
图3-5 调用函数main和swap的栈帧结构 |
使用命令"gcc -Wall -S -o exch.s exch.c"可以生成该C语言程序的汇编程序exch.s代码,如下所示(删除了几行与讨论无关的伪指令)。
1 .text 2 _swap: 3 pushl %ebp # 保存原ebp值,设置当前函数的帧指针。 4 movl %esp,%ebp 5 subl $4,%esp # 为局部变量c在栈内分配空间。 6 movl 8(%ebp),%eax # 取函数第1个参数,该参数是一个整数类型值的指针。 7 movl (%eax),%ecx # 取该指针所指位置的内容,并保存到局部变量c中。 8 movl %ecx,-4(%ebp) 9 movl 8(%ebp),%eax # 再次取第1个参数,然后取第2个参数。 10 movl 12(%ebp),%edx 11 movl (%edx),%ecx # 把第2个参数所指内容放到第1个参数所指的位置。 12 movl %ecx,(%eax) 13 movl 12(%ebp),%eax # 再次取第2个参数。 14 movl -4(%ebp),%ecx # 然后把局部变量c中的内容放到这个指针所指位置处。 15 movl %ecx,(%eax) 16 leave # 恢复原ebp、esp值(即movl %ebp,%esp; popl %ebp;)。 17 ret 18 _main: 19 pushl %ebp # 保存原ebp值,设置当前函数的帧指针。 20 movl %esp,%ebp 21 subl $8,%esp # 为整型局部变量a和b在栈中分配空间。 22 movl $16,-4(%ebp) # 为局部变量赋初值(a=16,b=32)。 23 movl $32,-8(%ebp) 24 leal -8(%ebp),%eax # 为调用swap()函数作准备,取局部变量b的地址, 25 pushl %eax # 作为调用的参数并压入栈中。即先压入第2个参数。 26 leal -4(%ebp),%eax # 再取局部变量a的地址,作为第1个参数入栈。 27 pushl %eax 28 call _swap # 调用函数swap()。 29 movl -4(%ebp),%eax # 取第1个局部变量a的值,减去第2个变量b的值。 30 subl -8(%ebp),%eax 31 leave # 恢复原ebp、esp值(即movl %ebp,%esp; popl %ebp;)。 32 ret |
这两个函数均可以划分成三个部分:"设置",初始化栈帧结构;"主体",执行函数的实际计算操作;"结束",恢复栈状态并从函数中返回。对于swap()函数,其设置部分代码是3~5行。前两行用来设置保存调用者的帧指针和设置本函数的栈帧指针,第5行通过把栈指针esp下移4字节为局部变量c分配空间。6~15行是swap函数的主体部分。第6~8行用于取调用者的第1个参数&a,并以该参数作为地址取所存内容到ecx寄存器中,然后保存到为局部变量分配的空间中(-4(%ebp))。第9~12行用于取第2个参数&b,并以该参数值作为地址取其内容放到第1个参数指定的地址处。第13~15行把保存在临时局部变量c中的值存放到第2个参数指定的地址处。第16~17行是函数结束部分。leave指令用于处理栈内容以准备返回,它的作用等价于下面两个指令:
movl %ebp,%esp # 恢复原esp的值(指向栈帧开始处)。 popl %ebp # 恢复原ebp的值(通常是调用者的帧指针)。 |
这部分代码恢复了在进入swap()函数时寄存器esp和ebp的原有值,并执行返回指令ret。
第19~21行是main()函数的设置部分,在保存和重新设置帧指针之后,main()为局部变量a和b在栈中分配了空间。第22~23行为这两个局部变量赋值。从第24~28行可以看出,main()中是如何调用swap()函数的。其中首先使用leal指令(取有效地址)获得变量b和a的地址并分别压入栈中,然后调用swap()函数。变量地址压入栈中的顺序正好与函数申明的参数顺序相反。即函数最后一个参数首先压入栈中,而函数的第1个参数则是最后一个在调用函数指令call之前压入栈中的。第29~30行将两个已经交换过的数字相减,并放在eax寄存器中作为返回值。
从以上分析可知,C语言在调用函数时是在堆栈上临时存放被调函数参数的值,即C语言是传值类语言,没有直接的方法可用来在被调用函数中修改调用者变量的值。因此为了达到修改的目的就需要向函数传递变量的指针(即变量的地址)。
3.main()也是一个函数
上面这段汇编程序是使用gcc 1.40编译产生的,可以看出其中有几行多余的代码。可见当时的gcc编译器还不能产生最高效率的代码,这也是为什么某些关键代码需要直接使用汇编语言编制的原因之一。另外,上面提到C程序的主程序main()也是一个函数。这是因为在编译链接时它将会作为crt0.s汇编程序的函数被调用。crt0.s是一个桩(stub)程序,名称中的"crt"是"C run-time"的缩写。该程序的目标文件将被链接在每个用户执行程序的开始部分,主要用于设置一些初始化全局变量等。Linux 0.12中crt0.s汇编程序如下所示。其中已建立并初始化全局变量_environ供程序中的其他模块使用。
1 .text 2 .globl _environ # 声明全局变量 _environ(对应C程序中的environ变量)。 3 4 __entry: # 代码入口标号。 5movl 8(%esp), %eax # 取程序的环境变量指针envp并保存在_environ中。 6movl %eax, _environ # envp是execve()函数在加载执行文件时设置的。 7call _main # 调用我们的主程序。其返回状态值在eax寄存器中。 8pushl %eax # 压入返回值作为exit()函数的参数并调用该函数。 9 1:call _exit 10jmp 1b # 控制应该不会到达这里。若到达这里则继续执行exit()。 11 .data 12 _environ: # 定义变量_environ,为其分配一个长字空间。 13.long 0 |
通常使用gcc编译链接生成执行文件时,gcc会自动把该文件的代码作为第一个模块链接在可执行程序中。在编译时使用显示详细信息选项"-v"就可以明显地看出这个链接操作过程:
[/usr/root]# gcc -v -o exch exch.s gcc version 1.40 /usr/local/lib/gcc-as -o exch.o exch.s /usr/local/lib/gcc-ld -o exch /usr/local/ lib/crt0.o exch.o /usr/local/lib/gnulib -lc /usr/local/lib/gnulib [/usr/root]# |
因此在通常的编译过程中,我们无需特别指定stub模块crt0.o,但是若想根据上面给出的汇编程序手工使用ld(gld)从exch.o模块链接产生可执行文件exch,那么就需要在命令行上特别指明crt0.o这个模块,并且链接的顺序应该是crt0.o、所有程序模块、库文件。
为了使用ELF格式的目标文件以及建立共享库模块文件,现在的gcc编译器(2.x)已经把这个crt0扩展成几个模块:crt1.o、crti.o、crtbegin.o、crtend.o和crtn.o。这些模块的链接顺序为crt1.o、crti.o、crtbegin.o(crtbeginS.o)、所有程序模块、crtend.o(crtendS.o)、crtn.o、库模块文件。gcc的配置文件specfile指定了这种链接顺序。其中,ctr1.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程序中。
(二) 在汇编程序中调用C函数
从汇编程序中调用C语言函数的方法实际上在上面已经给出。在上面C语言例子对应的汇编程序代码中,我们可以看出汇编程序语句是如何调用swap()函数的。现在我们对调用方法作一总结。
在汇编程序调用一个C函数时,程序需要首先按照逆向顺序把函数参数压入栈中,即函数最后(最右边的)一个参数先入栈,而最左边的第1个参数在最后调用指令之前入栈,如图3-6所示。然后执行CALL指令去执行被调用的函数。在调用函数返回后,程序需要再把先前压入栈中的函数参数清除掉。
![]() |
图3-6 调用函数时压入堆栈的参数 |
在执行CALL指令时,CPU会把CALL指令的下一条指令的地址压入栈中(见图3-6中的EIP)。如果调用还涉及代码特权级变化,那么CPU会进行堆栈切换,并且把当前堆栈指针、段描述符和调用参数压入新堆栈中。由于Linux内核中只使用中断门和陷阱门方式处理特权级变化时的调用情况,并没有使用CALL指令来处理特权级变化的情况,因此这里对特权级变化时的CALL指令使用方式不再进行说明。
汇编中调用C函数比较"自由",只要是在栈中适当位置的内容就都可以作为参数供C函数使用。这里仍然以图3-6中具有3个参数的函数调用为例,如果我们没有专门为调用函数func()压入参数就直接调用它的话,那么func()函数仍然会把存放EIP位置以上的栈中其他内容作为自己的参数使用。如果我们为调用func()而仅仅明确地压入了第1、第2个参数,那么func()函数的第3个参数p3就会直接使用p2前的栈中内容。在Linux 0.1x内核代码中就有几处使用了这种方式。例如在kernel/sys_call.s汇编程序中第231行上调用copy_process()函数(kernel/fork.c中第68行)的情况。在汇编程序函数_sys_fork中虽然只把5个参数压入了栈中,但是copy_process()却带有多达17个参数(见下面的程序)。
// kernel/sys_call.s汇编程序_sys_fork部分。 226push %gs 227pushl %esi 228pushl %edi 229pushl %ebp 230pushl %eax 231call _copy_process # 调用C函数copy_process()(kernel/fork.c,68)。 232addl $20,%esp # 丢弃这里所有压栈内容。 233 1: ret // kernel/fork.c程序。 68 int copy_process(int nr,long ebp,long edi,long esi,long gs,long none, 69 long ebx,long ecx,long edx, long orig_eax, 70 long fs,long es,long ds, 71 long eip,long cs,long eflags,long esp,long ss) |
我们知道,参数越是最后入栈,越是靠近C函数参数左侧。因此实际上调用copy_process()函数之前入栈5个寄存器值就是copy_process()函数的最左面的5个参数。按顺序它们分别对应为入栈的eax(nr)、ebp、edi、esi和寄存器gs的值。而随后的其余参数实际上直接对应堆栈上已有的内容。这些内容是从进入系统调用中断处理过程开始,直到调用本系统调用处理过程时逐步入栈的各寄存器的值。
参数none是sys_call.s程序第99行上利用地址跳转表sys_call_table[](定义在include/linux/ sys.h,93行)调用_sys_fork时的下一条指令的返回地址值。随后的参数是刚进入system_call时在85~91行压入栈的寄存器ebx、ecx、edx、原eax和段寄存器fs、es、ds。最后5个参数是CPU执行中断指令压入返回地址eip和cs、标志寄存器eflags、用户栈地址esp和ss。因为系统调用涉及程序特权级变化,所以CPU会把标志寄存器值和用户栈地址也压入堆栈。在调用C函数copy_process()返回后,_sys_fork也只把自己压入的5个参数丢弃掉,栈中其他值均保存着。其他采用上述用法的函数还有kernel/signal.c中的do_signal()、fs/exec.c中的do_execve()等,请读者自行分析。
另外,我们说汇编程序调用C函数比较自由的另一个原因是我们可以根本不用CALL指令而采用JMP指令来同样达到调用函数的目的。方法是在参数入栈后把下一条要执行的指令地址人工压入栈中,然后直接使用JMP指令跳转到被调用函数开始地址处去执行函数。此后当函数执行完成时就会执行RET指令,把人工压入栈中的下一条指令地址弹出,作为函数返回的地址。Linux内核中也有多处用到了这种函数调用方法,例如kernel/asm.s程序第62行调用执行traps.c中的do_int3()函数的情况。