本文通过实例验证了 C 语言函数调用时参数传递机制在 32 位和 64 位时的不同;阅读本文不仅需要 C 语言的知识,还需要有一些汇编语言的知识。

X86的指令集从 16 位的 8086 指令集开始,经历了 40 多年的发展,现在广泛使用的已经是 64 位的 X86-64 指令集,寄存器也从以前的 16 位变成了现在的 64 位,寄存器的数量也大大增加,gcc 当然也必须随着指令集的变化不断升级,在 64 位的时代,C 语言在函数调用时的参数传递也发生了很大的改变,本文通过把 C 语言程序编译成汇编语言的方式来验证这种改变。阅读本文不仅需要 C 语言的知识,还需要有一些汇编语言的知识。本文所有例子在 Ubuntu 20.04 下验证通过,使用的 gcc 版本为 9.4.0。

1. 32位下C语言调用函数时的参数传递

  • 32 位时代,C语言在调用函数时,实际是使用堆栈来传递参数的;

  • 在执行 call 指令前,会首先把需要传递的参数按反顺序依次压入堆栈,比如有三个参数:func(1, 2, 3),则先把 3 压入堆栈,再把 2 压入堆栈,然后再把 1 压入堆栈;

  • 执行 call 指令时,会首先把函数返回地址(也就是 call 指令的下一条指令地址压入堆栈),然后将 eip 寄存器设置为函数的起始地址,就完成了函数调用;

  • 函数执行完毕执行 ret 指令返回时,会将 call 指令压入堆栈的返回地址放入 eip 寄存器,返回过程就结束了;

  • 这个过程和 8086 指令集时基本一样;

  • 我们用一段简单 C 语言程序来验证以上的说法,该程序文件名定为:param1.c

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    #include <stdio.h>
    #include <stdlib.h>
    
    int func1(int i, int j, char *p) {
        int i1, j1;
        char *str;
    
        i1 = i;
        j1 = j;
        str = p;
    
        return 0;
    }
    
    int main(int argc, char **argv) {
        char *str = "Hello world.";
    
        func1(3, 5, str);
    
        return 0;
    }
    
    • 我们把这段程序编译成 32 位汇编语言,编译时带了一些选项,其目的是去掉一些调试信息,使汇编代码看上去更加清爽
    1
    
    gcc -S -m32 -no-pie -fno-pic -fno-asynchronous-unwind-tables param1.c -o param1.32s
    
  • 如果你的 64 位机无法编译 32 位程序,可能你需要安装 32 位支持,参考下面的安装

    1
    
    sudo apt install g++-multilib libc6-dev-i386
    
  • 我们看看编译出来的 32 位的汇编语言是什么样子的

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    
        .file   "param1.c"
        .text
        .globl  func1
        .type   func1, @function
    func1:
        endbr32
        pushl   %ebp
        movl    %esp, %ebp
        subl    $16, %esp
        movl    8(%ebp), %eax         # 第 1 个参数
        movl    %eax, -12(%ebp)
        movl    12(%ebp), %eax        # 第 2 个参数
        movl    %eax, -8(%ebp)
        movl    16(%ebp), %eax        # 第 3 个参数
        movl    %eax, -4(%ebp)
        movl    $0, %eax              # 返回值
        leave
        ret
        .size   func1, .-func1
        .section  .rodata
    .LC0:
        .string   "Hello world."
        .text
        .globl    main
        .type     main, @function
    main:
        endbr32
        pushl   %ebp
        movl    %esp, %ebp
        subl    $16, %esp
        movl    $.LC0, -4(%ebp)
        pushl   -4(%ebp)              # 第 3 个参数 str
        pushl   $5                    # 第 2 个参数 5
        pushl   $3                    # 第 1 个参数 3
        call    func1                 # 调用函数 func1
        addl    $12, %esp
        movl    $0, %eax
        leave
        ret
        .size     main, .-main
        .ident    "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
        .section  .note.GNU-stack,"",@progbits
        .section  .note.gnu.property,"a"
        .align    4
        .long     1f - 0f
        .long     4f - 1f
        .long     5
    0:
        .string   "GNU"
    1:
        .align    4
        .long     0xc0000002
        .long     3f - 2f
    2:
        .long     0x3
    3:
        .align    4
    4:
    
  • 第 26 行开始是 main 函数的汇编代码,第 31 - 35 行在调用函数 func1(3, 5, str),其中 32 行把第 3 个参数 str 压入堆栈,33行将第 2 个参数 5 压入堆栈;34 行将第 1 个参数 3 压入堆栈;

  • 这段汇编程序有两点要注意,一是参数是按照调用顺序的反方向压入堆栈的,即先把第 3 个参数压入堆栈,再把第 2 个参数压入堆栈,最后把第 1 个参数压入堆栈;二是对于整数变量,是直接把整数值压入堆栈,而不是这个整数值存储的地址;

  • 第 5 - 18 行是 func1 函数的汇编代码,第 9 行将堆栈的栈顶扩展了 16 个字节,这块地方用于存储 func1 中定义的变量,也就是 C 代码中的 i1、j1 和 str 三个变量,(ebp - 12)存储变量 i1,(ebp - 8)存储变量 j1,(ebp - 4)存储变量 str;

  • 第 10 行从堆栈中取出第 1 个参数放入 eax 寄存器;第 12 行从堆栈中取出第 2 个参数;第 14 行从堆栈中取出第 3 个参数;

  • 下面这张图,试图描述调用函数 func1 前后堆栈的变化 调用函数 func1 前后堆栈的变化

    • 调用函数 func1 前后堆栈的变化

2. 64位下C语言调用函数时的参数传递

  • 64 位时代,CPU 通用寄存器的数量已经从 32 位时的 6 个(不含 eip, esp, ebp)增加到了 14 个(不含rip, rsp, rbp);
  • 为了提高调用函数的性能,gcc 在调用函数时会尽量使用寄存器来传递参数,而不是像 32 位指令集那样全部使用堆栈来传递参数;
  • 当需要传递的参数少于 6 个时,使用 rdi, rsi, rdx, rcx, r8, r9 这六个寄存器来传递参数;
  • 当传递的参数多于 6 个时,前 6 个参数使用寄存器传递,6 个以上的参数仍然像 32 位时那样使用堆栈传递参数;
  • 这一规则提示我们,在编写 C 语言程序时,调用函数时的参数应该尽量少于 6 个
  • 下面我们仍然用一个简单程序来验证上面的说法,该程序文件名定为:param2.c
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
    #include <stdio.h>
    #include <stdlib.h>
    
    int func2(char *p, int var1, int var2, int var3, int var4, int var5, int var6, int var7) {
        char *str;
        int int_arr[7];
    
        int_arr[0] = var1;
        int_arr[1] = var2;
        int_arr[2] = var3;
        int_arr[3] = var4;
        int_arr[4] = var5;
        int_arr[5] = var6;
        int_arr[6] = var7;
    
        return 0;
    }
    
    int main(int argc, char **argv) {
        char *str = "Hello world.";
    
        func2(str, 2, 3, 4, 5, 6, 7, 8);
    
        return 0;
    }
    
    • 我们把这段程序编译成 64 位汇编语言,编译时所带的选项,与编译 32 位汇编时相比增加了一个 -fno-stack-protector,禁用了堆栈保护,同样是为了让汇编代码看上去更清爽;
    1
    
    gcc -S -no-pie -fno-pic -fno-asynchronous-unwind-tables -fno-stack-protector param2.c -o param2.64s
    
  • 我们看看编译出来的 64 位的汇编语言是什么样子的
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    
        .file   "param2.c"
        .text
        .globl  func2
        .type   func2, @function
    func2:
        endbr64
        pushq   %rbp
        movq    %rsp, %rbp
        movq    %rdi, -40(%rbp)     # (rbp - 40)存放变量str,rdi 为第 1 个参数
        movl    %esi, -44(%rbp)     # (rbp - 44)临时存放 rsi 中的第 2 个参数
        movl    %edx, -48(%rbp)     # (rbp - 48)临时存放 rdx 中的第 3 个参数
        movl    %ecx, -52(%rbp)     # (rbp - 52)临时存放 rcx 中的第 4 个参数
        movl    %r8d, -56(%rbp)     # (rbp - 56)临时存放 r8 中的第 5 个参数
        movl    %r9d, -60(%rbp)     # (rbp - 60)临时存放 r9 中的第 6 个参数
        movl    -44(%rbp), %eax     # 取出第 2 个参数
        movl    %eax, -32(%rbp)     # (rbp - 32)存放变量int_arr[0]
        movl    -48(%rbp), %eax     # 取出第 3 个参数
        movl    %eax, -28(%rbp)     # (rbp - 28)存放变量int_arr[1]
        movl    -52(%rbp), %eax     # 取出第 4 个参数
        movl    %eax, -24(%rbp)     # (rbp - 24)存放变量int_arr[2]
        movl    -56(%rbp), %eax     # 取出第 5 个参数
        movl    %eax, -20(%rbp)     # (rbp - 20)存放变量int_arr[3]
        movl    -60(%rbp), %eax     # 取出第 6 个参数
        movl    %eax, -16(%rbp)     # (rbp - 16)存放变量int_arr[4]
        movl    16(%rbp), %eax      # 第 7 个参数是从堆栈中传过来的
        movl    %eax, -12(%rbp)     # (rbp - 12)存放变量int_arr[5]
        movl    24(%rbp), %eax      # 第 8 个参数是从堆栈中传过来的
        movl    %eax, -8(%rbp)      # (rbp - 8)存放变量int_arr[6]
        movl    $0, %eax
        popq    %rbp
        ret
        .size     func2, .-func2
        .section  .rodata
    .LC0:
        .string   "Hello world."
        .text
        .globl    main
        .type     main, @function
    main:
        endbr64
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $32, %rsp
        movl    %edi, -20(%rbp)
        movq    %rsi, -32(%rbp)
        movq    $.LC0, -8(%rbp)
        movq    -8(%rbp), %rax
        pushq   $8                  # 要传递的第 8 个参数,压入堆栈
        pushq   $7                  # 要传递的第 7 个参数,压入堆栈
        movl    $6, %r9d            # 要传递的第 6 个参数,使用 r9 寄存器
        movl    $5, %r8d            # 要传递的第 5 个参数,使用 r8 寄存器
        movl    $4, %ecx            # 要传递的第 4 个参数,使用 rcx 寄存器
        movl    $3, %edx            # 要传递的第 3 个参数,使用 rdx 寄存器
        movl    $2, %esi            # 要传递的第 2 个参数,使用 rsi 寄存器
        movq    %rax, %rdi          # 要传递的第 1 个参数,使用 rdi 寄存器
        call    func2
        addq    $16, %rsp
        movl    $0, %eax
        leave
        ret
        .size     main, .-main
        .ident    "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
        .section  .note.GNU-stack,"",@progbits
        .section  .note.gnu.property,"a"
        .align    8
        .long     1f - 0f
        .long     4f - 1f
        .long     5
    0:
        .string   "GNU"
    1:
        .align    8
        .long     0xc0000002
        .long     3f - 2f
    2:
        .long     0x3
    3:
        .align    8
    4:
    
  • 汇编代码中的注释已经可以很清楚的看到,在调用 func2 前准备传递参数时,使用堆栈来传递第 7、8 两个参数(48、49行),使用 rdi, rsi, rdx, rcx, r8, r9 来传递前 6 个参数(50 - 55行);
  • 和 32 位汇编代码一样,对于整数参数是直接把整数值压入堆栈或者放如寄存器,而不是使用指针进行传递;
  • 在函数 func2 的汇编代码中,也可以清楚地看到其取参数的规则,与 main 中传递参数的规则一致(9 - 28行);
  • 再次强调,在编写 x86-64 的 C 语言程序,把函数调用时的参数限制在 6 个以内,将可以在一定程度上提高程序的性能

欢迎访问我的博客:https://whowin.cn email: hengch@163.com

donation