函数调用与返回值

开篇不知道写啥

  • 该篇文章主要记录了C语言函数调用以及相关应用的一些想法. 如果你问我为什么要写这篇博客, 这一切都要从一只可爱的蝙蝠说起……

alt too boring

测试环境

  • ubuntu20.04
  • gcc (Ubuntu 9.3.0-10ubuntu2) 9.3.0

C语言中函数调用过程

X86架构下函数调用分析

  • 我们先写一个简单的demo
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #include<stdio.h>
    int add(int x, int y){
    int sum = x + y;
    return sum;
    }
    int main(int argc, char *argv[]){
    int sum = add(2, 3);
    printf("sum: %d\n", sum);
    return 0;
    }
  • 使用 gcc -m32 -O0 -g demo.c 编译, 然后使用 objdump 反汇编一手看看对应的汇编代码
    1
    gcc -m32 -O0 -g demo.c && objdump -d a.out
    反汇编后 main 函数的代码(我们只截取和我们相关的部分)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    000011f1 <main>:
    ....
    1212: 6a 03 push $0x3
    1214: 6a 02 push $0x2
    1216: e8 b2 ff ff ff call 11cd <add>
    121b: 83 c4 08 add $0x8,%esp
    121e: 89 45 f4 mov %eax,-0xc(%ebp)
    1221: 83 ec 08 sub $0x8,%esp
    1224: ff 75 f4 pushl -0xc(%ebp)
    1227: 8d 83 30 e0 ff ff lea -0x1fd0(%ebx),%eax
    122d: 50 push %eax
    122e: e8 3d fe ff ff call 1070 <printf@plt>
    ....
    1244: c3 ret
    在地址 1216 处, 我们可以直接看到 main 函数调用了 add 函数, 即 call 11cd <add>, 我们再看 call 指令之前的两条指令, 分别是 push $0x3push $0x2, 这个我们熟悉啊, 这不就是我们源码中 add(2, 3) 操作的两个参数嘛.
    看样子C语言中函数调用时会先将函数的参数push到栈中(ps:注意两个push操作和add参数位置的关系), 然后使用 call 指令调用对应的函数.

    我们再看看 add 函数的汇编代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    000011cd <add>:
    11cd: f3 0f 1e fb endbr32
    11d1: 55 push %ebp
    11d2: 89 e5 mov %esp,%ebp
    11d4: 83 ec 10 sub $0x10,%esp
    11d7: e8 69 00 00 00 call 1245 <__x86.get_pc_thunk.ax>
    11dc: 05 fc 2d 00 00 add $0x2dfc,%eax
    11e1: 8b 55 08 mov 0x8(%ebp),%edx
    11e4: 8b 45 0c mov 0xc(%ebp),%eax
    11e7: 01 d0 add %edx,%eax
    11e9: 89 45 fc mov %eax,-0x4(%ebp)
    11ec: 8b 45 fc mov -0x4(%ebp),%eax
    11ef: c9 leave
    11f0: c3 ret
    add 函数中, 我们做的事很简单, x 和 y求和, 查看 add 函数的汇编代码, 和 两数相加 有关的操作只有地址为 11dc11e7 两处, 我们使用排除法, 首先必不可能是除了 11dc11e7 以外的指令(开个玩笑, 其实相加操作是 11e7 处的指令)
    查看 11e7 处的指令 add %edx, %eax, edxeax 寄存器中应该就是 xy 的值了, 再查看 11e111e4 处两条指令, 看样子 edxeax 中的值分别来自 0x8(%ebp)0xc(%ebp), 下面是执行到 11d2 指令处处栈的情况:
    1
    2
    3
    4
    |-----3------|    <------- y
    |-----2------| <------- x, 1216 之前两条push指令的结果
    |----eip-----| <------- 执行 1216 地址处 call 命令会将eip压到栈中
    |---old ebp--| <------- 执行到 11e1 时 ebp 的指向.
    情况已经很明了了, 因为我们使用32位编译该程序, 栈中每个元素占4字节, 所以 0x8(%ebp) 指向参数x, 0xc(%ebp) 指向参数y. 现在函数参数的问题已经解决了, 那 add 函数的返回值存放在哪里呢? 分析 11e111ec 的指令, 最后 x+y 的值似乎存放在了 eax中? 没错, add函数的返回值就是通过eax 返回给调用方的!
  • 现在我们知道了, 在X86架构下C语言中函数调用前会将必要的参数压栈, 被调函数执行时会从栈中取得参数, 最后被调函数将返回值放到 eax 中返回给调用方

X86-64架构下的情况

  • x86-64架构下的情况与x86有所不同, 看下图.
    alt X86-64架构下的寄存器
  • X86-64架构下C函数调用时会首先将寄存器当做参数传递给被调函数, rdi作为第一个参数, rsi是第二个, 其余的以此类推. 但是作为函数参数的寄存器总共有6个, 当参数超过6个时, 就和X86 一样通过栈传递参数(注意参数从右向左入栈)
  • 另外, 函数的返回值由 eax 变成了 rax.

总结

  • x86下使用栈进行参数传递, 使用eax作为返回值, x86-64下优先使用寄存器传递参数, 如果参数较多则使用栈, 使用rax作为返回值
  • ps: 以上讨论的都是大多数, 或者通常情况下的处理方法. 在其他情况下, 比如说参数或返回值是浮点数, 返回值太大需要多个寄存器等都会有其他处理方法.

知道了”函数调用”也没什么卵用系列

热身

  • 好了, 现在我们已经了解了C语言中函数调用时发生的事, 可是……
    alt 那又怎样
  • emmmmm, 上面罗里吧嗦说了一大堆, 了解了”函数调用”我们具体能做什么呢? 首先让我们手工模拟一下函数调用的过程.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include<stdio.h>
    int add(int x, int y){
    int sum = x + y;
    printf("%d + %d = %d\n", x, y, sum);
    return sum;
    }
    int main(int argc, char *argv[]){
    int res = 0;
    void (*fuck)() = add;
    asm volatile("pushl $3; pushl $2;");
    fuck();
    asm volatile("":"=a"(res):);
    printf("eax's value: %d\n", res);
    return 0;
    }
  • 和之前不同的是, 这次我们使用 asm volatile("pushl $3; pushl $2;"); 手工将参数压栈, 让函数指针fuck指向add函数, 这样fuck执行时就只会将返回地址压栈, 最后我们使用 asm volatile("":"=a"(res):); 手工将eax中的值取到res中. 编译执行它看看结果
    1
    2
    3
    >>> gcc -m32 demo.c && ./a.out
    2 + 3 = 5
    eax's value: 5
  • 正如我们所预料的, it works well!   可是……
    alt 那又怎样

system call: every different

  • 众所周知, linux使用int 0x80进行系统调用, eax存放系统调用号, 其他的几个寄存器(和具体的平台有关)存放参数, linux0.11中最多只有3个参数, 使用 ebx, ecx, edx存放参数, 而现在的最多可以传递6个参数
  • 假设你现在正在开发自己的kernel, 同样使用 int 0x80 进行系统调用, 同样使用ebx, ecx, edx传递参数, 假设系统调用中断进入的函数是 system_call, 当我们对”函数调用”不熟悉的时候, 我们写出来的代码可能是类似这样的:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    void system_call(){
    number = eax;
    arg1 = ebx;
    arg2 = ecx;
    arg3 = edx;

    if(number == 0){
    sys_call0();
    else if(number == 1){
    sys_call1(arg1);
    }else if(number == 2){
    sys_call2(arg1, arg2);
    }else if(number == 3){
    sys_call3(arg1, arg2, arg3);
    }
    ......
    }
  • emmmm, 看到这堆代码, 怎么感觉……
    alt 代码写的真蔡
  • 没错, 是菜鸟本菜无误了!
  • 如果我们看了linux0.11中的处理, 理解函数调用, 我们就懂了, 其实可以写成这样:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    system_call(){
    ......
    push edx
    push ecx
    push ebx
    call syscall_table[eax]
    ......
    }
    typedef void (*syscall)();
    syscall syscall_table = { sys_call0, syscall1, .....}
  • 我们为什么没有想到这么做? 根据eax的值选择对应的处理函数, 我们其实能想到使用数组, 到时候直接执行syscall_table[eax] 对应的函数多好, 问题就在于每一个系统调用对应的处理函数参数不固定, 所以我们首先将所有可以携带参数的寄存器压栈, 然后直接调用syscall_table[eax]对应的函数, 就像上次手工模拟的那样(先将参数3和2push到栈中,然后fuck())

你不知道的printf

  • 对C语言研究不深的同学(琪….琪玉同学?), 可能不清楚printf的实现, 想一下我们既可以 printf(“%s”, “Hello”), 也能printf(“%s, %s”, “Hello”, “World”), 也就是说printf函数接受可变参数. 我们可以在函数声明中使用 来表示该函数接受可变参数, C语言中提供了相应的库来帮助我们获取参数, 具体可见这里. 但是我们现在不用这些库, 我们还是手工模拟一下.
  • 我们现在立刻试着实现一下print
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include<stdio.h>
    void print(char *fmt, ...){
    unsigned int *args = (unsigned int*)(&fmt) + 1;
    while(*fmt){
    if(*fmt != '%'){
    printf("%c", *fmt++);
    continue;
    }
    switch (*++fmt){
    case 's':
    printf("%s", (char*)(*args++));
    fmt++;
    break;
    default:
    printf("%c%c", '%', *fmt++);
    break;
    }
    }
    }
    int main(int argc, char *argv[]){
    print("%s, %s\n", "Hello", "World!");
    return 0;
    }
  • 编译执行后应该会看到以下结果
    1
    2
    >>> gcc -m32 demo.c && ./a,out
    Hello, World!
  • 想一下函数调用的过程, 被调函数的参数从右到左被压入栈中, 我们只要知道了最左边那个参数的位置, 就相当于知道了所有参数的位置, 那么自然就可以获得参数的值. 上面我们借助printf实现我们自己的print, 不过当你开发一个kernel的时候肯定会实现屏幕输出的接口, 到时候只需要将printf替换一下就OK了. 实际上, 一种实现可能是这样的:
    1
    2
    3
    4
    5
    6
    void print(char *fmt, ...){
    char buffer[256];
    // vsprintf格式化字符串(将fmt中的占位符替换成相应的值), 将目标字符串存放到buffer中
    vsprintf(buffer, fmt, args); // args是指向参数的指针
    put_str(buffer); // kernel中屏幕输出接口
    }
  • emmmmm, 好像有点意思
    alt 奇怪的知识增加了

Reference

  1. Linux内核函数调用规范
  2. Linux的系统调用