调用C函数

2020-09-28 C语言

  从系统引导过程中的汇编程序跳转到系统主函数中,或者在中断处理的汇编代码中跳转到中断处理函数(传说中的中断上部), 这些过程都是从汇编程序跳转到C程序的,其中不可缺少的有:调用约定,参数传递方式,函数调用方式等。因为这些过程都是在系统内核中,所以,我们讲解的是GNU C语言和AT&T汇编语言。话不多说,下面让我们逐一介绍。

汇编调用C函数

  函数的调用方式

  函数的调用方式其实没那么复杂,基本上就是jmp、call、ret或者他们的变种而已。让我们先看下面的程序。

  int test()

  {

  int i = 0;

  i = 1 + 2;

  return i;

  }

  int main()

  {

  test();

  return 0;

  }

  这段程序基本上没有什么难点,很简单,对吧?唯一要注意的地方是main函数的返回值,这里个人建议大家要使用int类型作为主函数的返回值,而不要使用void,或者其他类型。虽然,在主函数执行到return 0之后就跟我们没有什么关系了。但是,有的编译器要求主函数要有个返回值,或者,在某些场合里,系统环境会用到主函数的返回值。考虑到上述原因,要使用int类型作为主函数的返回值,如果处于某个特殊的或者可预测的环境下,那就无所谓了。

  说了这么多,反汇编一下这段代码,看看汇编语言是怎么调用test函数的。工具objdump,用于反汇编二进制程序,它有很多参数,可以反汇编出各类想要的信息。

  objdump工具命令:

  objdump -d test

  下面是反汇编后的部分代码,把相关的系统运行库等一些与上面C程序不相关的代码忽略掉。经过删减后的反汇编代码如下:

  0000000000400474:

  400474: 55   push %rbp

  400475: 48 89 e5   mov %rsp,%rbp

  400478: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)

  40047f: c7 45 fc 03 00 00 00 movl $0x3,-0x4(%rbp)

  400486: 8b 45 fc   mov -0x4(%rbp),%eax

  400489: c9   leaveq

  40048a: c3   retq

  000000000040048b

  :

  40048b: 55   push %rbp

  40048c: 48 89 e5   mov %rsp,%rbp

  40048f: b8 00 00 00 00  mov $0x0,%eax

  400494: e8 db ff ff ff  callq 400474

  400499: b8 00 00 00 00  mov $0x0,%eax

  40049e: c9   leaveq

  40049f: c3   retq

  大家先看000000000040048b :这一行,这里就是主函数,前面的000000000040048b其实是函数main的地址。一共16个数,16 * 4 = 64,对!这就是64位地址宽度啦。

  乍一看,有好多个“%”符号,还记得2.2.1节里讲的AT&T汇编语法吗?这就是那里面说——引用寄存器的时候要在前面加“%”符号。

  还有一些汇编指令的后缀,如:“l”、“q”。“l”的意思是双字(long型),“q”的意思是四字(64位寄存器的后缀就是这个)。

  如果您仔细观察,是不是会发现有些寄存器rbp,rsp等,感觉会跟ebp和esp有关系呢?答对了,esp寄存器是32位寄存器,而rsp寄存器是64位寄存器。这是Intel对寄存器的一种向下继承性,从最开始一字节的al,ah,到两字节的ax(16位),四字节的eax(32位),再到八字节的rax(64位),寄存器的长度在不断的扩展,对于相关指令的使用,也从“b”、“l”,“q”,也是不断的向下继承或扩展。

  这里有一条指令leaveq,它等效于 movq %rbp, %rsp; popq %rbp;

  callq 400474 这句的意思就是跳转到test函数里执行。其实汇编调用C函数就这么简单,如果把这条callq指令改成jmpq指令也是可以的。这要从call和jmp的区别上说起,call会把在其之后的那条指令的地址压入栈,在上面反汇编后的代码中,就是0000000000400499,然后再跳转到test函数里执行。而jmpq就不会把地址0000000000400499压入栈中。当函数执行完毕,调用retq指令返回的时候,会把栈中的返回地址弹出到rip寄存器中,这样就返回到main函数中继续执行了。

  实现jmpq代替callq的伪代码如下所示:

  pushq $0x0000000000400499

  jmpq 400474

  对于callq 400474 这条指令也可以使用retq来实现。它的实现原理是:指令retq会将栈中的返回地址弹出,并放入到rip寄存器中,然后处理器从rip寄存器所指的地址内取指令后继续执行。根据这个原理,可以先将返回地址0000000000400499压入栈中。然后再将test函数的入口地址0000000000400474压入栈中,接着使用retq指令,以调用返回的形式,从main函数“返回”到test函数中。

  实现retq代替callq的伪代码如下所示:

  pushq $0x0000000000400499

  pushq $0x0000000000400474

  retq

  这些看起来是不是没有想象的那么难?其实把汇编的原理掌握清楚了,这些都是可以灵活运用的,希望这段内容能启发读者的灵感~!

  调用约定

  对于不同的公司,不同的语言以及不同的需求,都是用各自不同的调用约定,而且他们往往差异很大。在IBM兼容机对市场进行洗牌后,微软操作系统和编程工具占据了统治地位,除了微软之外,还有零星的.一些公司,以及开源项目GCC,都各自维护着自己的标准。下面是比较流行的几款调用标准,咱们写的大多数程序都出自这个标准之一。

  stdcall

  1、在进行函数调用的时候,函数的参数是从右向左依次放入栈中的。

  如:

  int function(int first,int second)

  这个函数的参数入栈顺序,首先是参数second,然后是参数first。

  2、函数的栈平衡操作是由被调用函数执行的,使用的指令是 retn X,X表示参数占用的字节数,CPU在ret之后自动弹出X个字节的堆栈空间。例如上面的function函数,当我们把function的函数参数压入栈中后,当function函数执行完毕后,由function函数负责将传递给它的参数first和second从栈中弹出来。

  3、在函数名的前面用下划线修饰,在函数名的后面由@来修饰,并加上栈需要的字节数。如上面的function函数,会被编译器转换为_function@8。

  cdecl

  1、在进行函数调用的时候,和stdcall一样,函数的参数是从右向左依次放入栈中的。

  2、函数的栈平衡操作是由调用函数执行的,这点是与stdcall不同之处。stdcall使用retn X平衡栈,cdecl则使用leave、pop、增加栈指针寄存器的数据等方法平衡栈。

  3、每一个调用它的函数都包含有清空栈的代码,所以编译产生的可执行文件会比调用stdcall约定产生的文件大。

  cdecl是GCC的默认调用约定。但是,GCC在x64位系统环境下,使用寄存器作为函数调用的参数。按照从左向右的顺序,头六个整型参数放在寄存器RDI, RSI, RDX, RCX, R8和R9上,同时XMM0到XMM7用来放置浮点变元,返回值保存在RAX中,并且由调用者负责平衡栈。

  fastcall

  1.函数调用约定规定,函数的参数在可能的情况下使用寄存器传递参数,通常是前两个 DWORD类型的参数或较小的参数使用ECX和EDX寄存器传递,其余参数按照从右向左的顺序入栈。

  2、函数的栈平衡操作是由被调用函数在返回之前负责清除栈中的参数。

  还有很多调用规则,如:thiscall、naked call、pascal等,有兴趣的读者可以自己去研究一下。

  参数传递方式

  函数参数的传递方式无外乎两种,一种是通过寄存器传递,另一种是通过内存传递。这两种传递方式在我们平时的开发中并不会被关注,因为不在特殊情况下,这两种传递方式,都可以满足要求。但是,我们要写的是操作系统,在操作系统里面有很多苛刻的环境要求,这使得我们不得不了解这些参数传递方式,来解决这些问题。

  寄存器传递

  寄存器传递就是将函数的参数放到寄存器里传递,而不是放到栈里传递。这样的好处主要是执行速度快,编译后生成的代码量少。但只有少部分调用规定默认是通过寄存器传递参数,大部分编译器是需要特殊指定使用寄存器传递参数的。

  在X86体系结构下,系统调用一般会使用寄存器传递,由于作者看过的内核种类有限,也不能确定所有的内核都是这么处理的,但是Linux内核肯定是这么做的。因为应用程序的执行空间和系统内核的执行空间是不一样的,如果想从应用层把参数传递到内核层的话,最方便快捷的方法是通过寄存器传递参数,否则需要使用很大的周折才能把数据传递过去,原因会在以后的章节中详细讲述。

  内存传递

  内存传递参数很好理解,在大多数情况下参数传递都是通过内存入栈的形式实现的。

  在X86体系结构下的Linux内核中,中断或异常的处理会使用内存传递参数。因为,在中断产生后,到中断处理的上半部,中间的过渡代码是用汇编实现的。汇编跳转到C语言的过程中,C语言是用堆栈保存参数的,为了无缝衔接,汇编就需要把参数压入栈中,然后再跳转到C语言实现的中断处理程序中。

  以上这些都是在X86体系结构下的参数传递方式,在X64体系结构下,大部分编译器都使用的是寄存器传递参数。因此,内存传递和寄存器传递的区别就不太重要了。

【汇编调用C函数】相关文章:

C++调用C函数的方法11-16

java调用c函数的实例11-28

C语言函数的递归调用10-04

C函数的调用过程11-25

C++如何调用matlab函数11-21

汇编调用c函数为什么要设置栈11-24

C语言函数的运用及调用11-18

C语言函数调用与参数传递02-01

Java程序调用C/C++语言函数的方法11-02

C++画正弦线实例代码 C语言教学中函数的调用问题