C语言函数调用约定

沈凡宸 2017-11-12 01:39:59

一、概述


本文主要讲解C语言函数调用约定。

或许多数童鞋有点不明白这个东西有什么作用和影响,在不清楚调用规则的情况下,我们依旧可以写出可用的甚至很好的代码。

可以说,C调用约定是一个非常关键和有用的机制,理解函数调用不仅可以使你写出更优质的代码,也可以拓宽你对编程语言的视野,更可以让你了解一些“不让播”的东西~当然,这里我们不过度关注这些方面,可以从第三节的引申部分了解一二。

C语言发展到现在其实有很多种调用方式,例如:stdcall、cdecl、fastcall、thiscall、naked call等。我们不关注64位系统的调用方式,不是因为其不重要,而是部分原理还是相通的。

二、cdecl详解


这里我们关注cdecl的调用机制,cdecl是C语言的缺省调用约定,称为C调用约定。

在这个约定中,不仅规定了函数参数、返回值的输入与输出方式及负责方,还给出了在调用时辅助内容寄存器的使用规定。

下面我们通过一个简单的函数声明来说明一个函数的调用过程:

int foo (int a, int b);

我们将从几个方面分别叙述foo函数被调用时发生的动作,并在最后进行整体梳理。

1、参数调用

​ 我们知道,在C语言中,函数的参数一般都是存放在栈中的(仅考虑IA32架构的CPU)。那么参数是在何时放入栈中的呢?这个问题牵扯到两个概念:调用方(caller)和被调用方(callee)。

​ 我们将一个函数调用拆分成三个时刻:

函数调用三阶段
调用前
调用中
调用后

​ 在调用前 ,cpu控制权是在foo的外层代码中,这个外层很可能是main函数,当然也可能存在多层调用或者嵌套递归一类的情况,不过这并不影响我们的理解。

​ 在调用中,cpu控制权是在foo函数中的代码指令上。

​ 在调用后,cpu控制权回归foo的外层代码。

​ 这里,调用方指的是cpu控制权在被调用函数的外层代码时的情况,即调用前和调用后,都是处于调用方中。而调用中则是属于被调用方。

​ 那么回归我们的问题,参数是在何时放入栈中的呢?是在调用前,由调用方压入栈中的。关于参数的清理,我们在整体梳理的部分进行说明。

​ 下一个问题是,函数参数是以什么顺序压入栈中呢?在C调用约定中,参数是从右向左依次压栈。这样做的目的是什么?或者说这样做的好处是什么呢?

​ 试想每一个学C语言的人一定会用过printf函数,该函数原型是:

    int printf(const char *format, ...);

​ 这里原型中最右侧参数是…,这意味着函数存在着参数不确定的情况,即存在如下写法:

    printf("%s:%d:", __func__, __LINE__);
    printf("abc");
    printf("%s", __func__);

​ 换言之,printf要求,第一个参数必须传入,后续参数可以没有。

​ 那么,每一个函数调用都将被翻译成汇编指令,以上述三种情况为例,同一个函数定义(printf)要处理这三种不同情况,该怎么做呢?一种直观的想法是:函数调用的语法可以生成对应抽象语法树,此时我们可以知道参数个数,再将每一个参数与函数一一匹配压入。

​ 有没有更好的做法呢?那就是从右向左压入,即我可以不依赖函数定义,就可以将参数整理出压栈指令。

​ 当然,这里还要牵扯到两个概念,IA架构中栈是从高地址向低地址增长的,而内存中访问数据是从低地址向高地址寻址的,因此,从右向左压栈的好处就是,最后一个参数会被放在地址最高的地方,而程序中顺序(从左向右)访问每一个参数时则正好是从低地址向高地址访问。为什么会从左向右访问呢?这就涉及到可变参函数的实现机制,可以参考va_start系列函数的使用方式,这里不过多赘述啦。

2、返回值

​ 下面来说说返回值问题。

​ 在C调用约定中,函数的返回值会被调用方固定存放在EAX寄存器中。

​ 下面给出几个例子,这里也顺便普及下C中正确使用指针和返回值的方式。

    char *ret_str(void) {
        char s[] = "hello";
        return s;
    }
    char *ret_str(void) {
        char *s = (char *)malloc(6);
        memcpy(s, "hello", 6);
        return s;
    }
    int ret_int(int a, int b) {
        return a + b;
    }

​ 第一个函数,嗯… 谁用谁悲剧。什么原因呢,s是一个数组首地址,return时,首地址会被放在eax寄存器中返回给调用方。这么说看着还比较正常。但是表忘记,局部变量也是在栈中分配的空间,而且是在被调用方的栈中分配的,因此,当函数返回时,这部分栈会被回收及后续的复用,因此,eax中首地址所指向的地址空间内容很可能已经被后续指令修改过。

​ 第二个函数就很好理解了,malloc会在堆中分配内存(尽管说堆也不算太准确了),堆不会随函数返回而被释放,因此第二个函数会返回一个堆内存块的地址给调用方。

​ 第三个参数也就很好理解了,两个整型的计算结果还是一个整型,符合函数返回类型,直接将该值放入eax寄存器返回即可。

3、函数栈

​ 上面已经将参数和返回值说明完了,下面是关于被调用函数的栈的准备工作。

​ 抛开程序,试想,当我们做一件时,我们最希望这件事和以前做过的事以及未来做的都都尽量无关,这样我们才能最大限度的将注意力都集中在这件事上,而不是要考虑各种影响因素。这用两个字来概括就是——解耦。

​ 函数调用涉及调用方和被调用方,因此当一个函数被调用时,如果没有一个规范来约束,很容易出现寄存器和栈被用乱的情况。

​ 我们要保证当我们调用了一个函数后,所有的函数的局部变量都在一个新栈区域分配,也要保证当函数返回时,栈结构能够完整的被回收。因此,这里就会涉及两个寄存器——ebp和esp。

​ ebp的作用是保存函数调用前的栈基址(IA架构下是高地址),esp则是永远指向最新可用的栈帧地址(逐步递减,当然不排除汇编指令做加减)。因此,在很多用gcc生成的汇编代码中会看到,被调用函数中,上来都是如下指令:

    push ebp
    mov esp, ebp

​ 上面这两个指令所做的事情是:将ebp内容进行压栈保存,此时ebp的内容是什么呢?是外层函数的栈基址,因此一定要保存啊。但是别忘了push指令的side-effect是会导致esp做自减(高地址向低地址增长)。因此esp已经减了4了,此时,再将esp的值保存进ebp中作为栈基址。

​ 有了上面两个指令,怎么能少了返回时的清理呢。

    ... #pop在这之中push的局部变量或者用add指令直接调整esp指向
    pop ebp

​ pop后,ebp寄存器恢复成了外层栈基址,而esp又自增了4字节。

​ 到此,栈的准备和回收就介绍完了。

4、整理梳理

​ 下面给出一个段汇编代码,我们将通过这段指令代码作为本节的收尾。由于手头没找到现成可用32位Linux,外加现有64位Linux上gcc -m32参数有问题,因此就手写了,如有错误还望及时指出,感谢。

    ...
    push b #右侧参数
    push a #左侧参数
    call foo
    ...

foo:
    push ebp
    mov esp ebp
    ... #获取参数内容和处理的指令,并将结果放入eax,之后pop掉所有局部变量,虽然现在用leave指令似乎更常见
    pop ebp
    ret 8

​ 这段代码中有很多眼熟之处,是我们在上面的小节中介绍过的。

​ 汇编代码都是从上到下,从左到右执行的,因此代码先从调用方指令开始执行。

​ 当要调用foo函数时,调用方遵守最右参数先入栈原则,将参数依次压栈;

​ 进入foo函数内后,先要对栈进行准备,因此会要保存调用方的栈基址,然后将被调用方的栈基址保存到ebp中;

​ 在经过一系列逻辑处理后,我们要将返回值放入eax中;

​ 然后恢复ebp的内容为调用方的栈基址;

​ 最后函数返回,这里要额外说明这个指令后面跟的这个8的含义是,在调用栈返回后,让esp加8来将参数清理掉。

​ 这里,可能有人会有点好奇,ret指令是如何知道该返回到调用方的哪一条指令位置呢?

​ 其实当调用方利用call指令调用了foo函数时,会在栈中自动压入cs寄存器内容和eip寄存器内容,即:

call指令调用时栈结构
… //调用方栈帧区域
cs
eip
ebp
… //被调用方栈帧区域

​ 而当foo函数执行了ret指令时,会将eip和cs弹出,cs指向代码段选择子(不引申了。。),eip指向调用方下一条要执行的指令。

三、引申


有关C调用约定,其实可以引申到很多方面,小则C编程的理解和能力提升,大则可以编写操作系统、编译器等。当然,除了这些“正途”外,还有一些“邪门歪道”,例如黑客攻击等。

本小节引申一种机制,参考下面的栈结构:

加入垫片后的栈结构
… //调用方栈帧区域
cs
eip
ebp
垫入区 //被调用方栈帧区域
局部变量

这里可以看到,前面都与我们之前介绍的栈结构一致。但在被调用方的局部变量区域前增加了一个垫入区。

这是个什么东西呢?答案是,一块无用内存,实际指令并不会使用这部分区域。是不是有点多余呢?答案当然是不多余啦。

这部分垫入区是由编译器编译时加入的。那么它的具体用途是什么呢?

嗯…留作一个思考吧,或许下一篇博客会给出答案哈

感谢各位阅读