X86汇编入门-传参 - 弦外之音

/ 0评 / 1

函数传参,主流 有两种技术方式 实现。

1,堆栈传参。

2,寄存器传参。

堆栈传参 的实现比较简单,不容易出错,所以应用比较广泛。寄存器传参 速度快,性能好。

既然 寄存器传参 的方式那么好,高级语言为什么不全部都用 寄存器传参 实现一些功能,开发难度是一个需要考量的因素,不是哪个性能好就用哪个。

即使性能好,写出来的东西一堆bug怎么办。

所以在 JAVA 里面使用的是堆栈传参,而 C/C++ 语言在 参数不多, 寄存器足够的情况下,会优选使用寄存器传参,不够再使用堆栈传参。这也是 C/C++JAVA 性能好的原因之一。

高级语言中,有些语言也会在某些地方采用 寄存器传参 来代替 堆栈传参,例如 PHP 的 execute_dataopline,如下:



本文 主要从 汇编代码的角度,演示 寄存器传参堆栈传参 区别。举个例子。

ADD reg/mem,%eax

上面的 是 ADD 指令,往 eax 寄存器上加东西,注意这个 source (来源),他可以是 register 寄存器,也可以是 memory 一个内存地址。

# 寄存器传递,把 ebx 的4字节数据加给 eax。
ADD %ebx,%eax
# 注意 (),这个是对地址求值,也就是把 ebp 偏移 8字节的那个块数据的值 加给 eax
ADD 8(%ebp),%eax

这个就是 寄存器传参 跟 堆栈传参的 区别。

  1. 寄存器传参 是调用 ADD 之前 把参数放进去 ebx 寄存器里面。
  2. 堆栈传参 是调用 ADD 之前 把 参数放在 8(%esp) 的位置,也就是内存的位置。

ADD 8(%ebp),%eax 这种只是汇编器封装好的写法,底层还是要从内存拿到 CPU,只不过这个过程对使用者来说是透明的。

ADD 8(%ebp),%eax 内存寻址 翻译成 机器指令 占 3个字节ADD %ebx,%eax 翻译成机器指令 占 2个字节

所以 ADD 指令 从寄存器拿参数,确实是比从 内存地址拿 参数快。

但为什么 说 寄存器传参会比较复杂,请看下面的代码。

下面是 main 函数调用 一个 add_two 函数的汇编代码,采用寄存器传参。

原始C语言代码:

int add_two(int a,int b){
    return a+b;
}
int main() {
    int return_num = add_two(1,8);
    return return_num - 3;
}

寄存器传参代码:

        .text
        .globl  main
        .type   main, @function
        .type   add_two, @function
add_two:
        movl    %ebx, %eax
        addl    %ecx, %eax
        ret
main:
        pushl   %ebp
        movl    %esp, %ebp
        /* 两个参数 分别传进去 ebx ,ecx,所以需要先 保存 ebx ,ecx 的旧值进去 堆栈。 */
        pushl   %ebx
        pushl   %ecx
        /* 开始寄存器传参, 参数 1 丢进去 ebx,参数 8 丢进去 ecx */
        movl    $1, %ebx
        movl    $8, %ecx
        call    add_two
        /* 从堆栈恢复 ecx ebx的值 */
        popl    %ecx
        popl    %ebx
        /* 返回值减 3 */
        subl    $3, %eax
        popl    %ebp
        ret

编译命令如下:

gcc -m32 -o main-32 main-32.s

上面的代码就是寄存器传参 个 add_two 函数,接下来引用 《汇编语言 基于X86处理器》一书中的一段话。

在过程调用之前,任何存放参数的寄存器须首先入栈,然后向其分配过程参数,在过程返回后再恢复其原始值

这些额外的入栈和出栈操作不仅会让代码混乱,还有可能消除性能优势,而这些优势正是通过使用寄存器参数所期望获得的!此外,程序员还要非常仔细地将 PUSH 与相应的 POP 进行匹配,即使代码存在着多个执行路径。

所以,寄存器传参很复杂,我上面的代码还是简单版的,一个 PUSH 要对应一个 POP,add_two 执行完之后要 顺序把之前压进去的数据弹出来给寄存器。如果还有逻辑分支,PUSH 跟 POP 很容易写错。


add_two 堆栈传参版本的代码如下:

        .text
        .globl  main
        .type   main, @function
        .type   add_two, @function
add_two:
        pushl   %ebp
        movl    %esp, %ebp
​
        movl    12(%ebp), %eax
        addl    8(%ebp), %eax
​
        popl    %ebp
        ret
main:
        pushl   %ebp
        movl    %esp, %ebp
        /* 把参数压 进去堆栈  */
        pushl   $1
        pushl   $8
​
        call    add_two
​
        /* 把之前的两个参数移出堆栈 */
        addl    $8,%esp
​
        /* 返回值减 3 */
        subl    $3, %eax
        popl    %ebp
        ret

下面通过 gdb 一步一步讲解 堆栈传参是怎么一回事,开始

gcc -m32 -o main-32 main-32.s
# 运行 gdb
gdb ./main-32
# 显示寄存器窗口
layout regs
# 断点
b *main

从下图可以看出,在 pushl $1 执行之前,esp 等于 0xffffd348,请看下图:

现在 再 敲入命令 ni,执行 pushl $1,如下图所示,esp 变成了 0xffffd344,请看下图:

所以,此时,打印 一下 0xffffd344 ~ 0xffffd348 的内存数据,肯定是之前存进去的 1。如下图所示:

这里注意,我的电脑是 小端 序存储的,所以是倒着来的。

此时,参数 1 已经被放进去内存里面了。参数 8 同理,参数 8 在 0xffffd340 ~ 0xffffd344 。

现在 敲 si 命令,跳进去 add_two 函数的实现,如下图:

这里要注意,call 指令,会把main 的下一个指令地址存进去堆栈,所以call 会用掉 4 字节的堆栈内存,所以 esp 会减 4,此时 esp 已经从 0xffffd340 减成 0xffffd33c,此时打印一下 0xffffd33c ~ 0xffffd340 的内存,就是 addl $8,%esp 的地址 0x565561b4 ,如下图:

执行完 add_two 就会执行 addl $8,%esp

此时 add_two 还没开始执行,ebp 现在的值 0xffffd348 是 上层main 函数的值,因为 ebp 是基址,上层函数可能用这个寄存器干了一些事情,所以 add_two 返回去的时候不能改变 main 的 ebp,但是 add_two 又需要 使用 ebp,所以第一句 就是 pushl %ebp 把 main 的 ebp 先存到堆栈内存里,返回去之前再 恢复 ebp。

add_two 的第一句汇编 pushl %ebp 执行完之后,效果图如下:

此时,内存里又有 4个 字节被使用掉了,esp 又减了 4,此时 esp 已经从 0xffffd33c 减橙 0xffffd338,此时打印一下 0xffffd338 ~ 0xffffd33c 的内存,就是 main 的 ebp 的值 0xffffd348 ,如下图:

在 敲 ni,执行 movl %esp, %ebp,把 esp 的值 拷贝 给 ebp,方便操作。这里注意 esp 可以定位到之前的 1 跟 8那个参数。

此时此刻,esp 等于 0xffffd338 ,之前 的参数1 在 0xffffd344 ~ 0xffffd348 ,参数 8 在 0xffffd340 ~ 0xffffd344。

所以 参数 8 就等于 esp + 8 字节,为什么是 加 8 字节,因为 call add_two 压了 4字节进去堆栈,pushl %ebp 为了保存 main 的 ebp 又压了 4字节,所以一共 8个字节。

参数 1 就等于 esp + 12字节,这个原理也就是 下面两句代码的由来。

movl    12(%ebp), %eax
addl    8(%ebp), %eax

上面这两句 汇编执行完之后,eax 已经有了想要的值 9,最后执行 popl %ebp 恢复 main 的 ebp, 下图是 add_twopopl %ebp 执行完后的状态,ebp 已经恢复到 进入 add_two 之前的值了,也就是 0xffffd348

现在 add_two 还有最后一个指令没执行,也就是 ret

之前说过, add_two 执行完之后,需要把 eip 寄存器改到 main 执行完 add_two 的下一行汇编代码的地址。下一行汇编代码的地址已经在 call add_two 的时候,压进去堆栈了,就是 0xffffd33c ~ 0xffffd340。此刻 esp 正好也是 0xffffd33c ,所以 ret 直接在 esp 的位置取 4个字节 丢进去 eip 就行了。

那 4 个字节 0xffffd33c ~ 0xffffd340 就是 addl $8,%esp ,也就是下一行汇编代码。

ni,让程序 执行完 add_tworet 指令,效果图如下,此刻代码已经回到 main addl $8,%esp 的位置的了 。

现在,此时此刻,堆栈还有两个东西没有处理,就是之前压进去的 参数1 参数 8,在本文代码里不处理这两个东西也可以,因为本文没有嵌套使用 main 函数,但是正规的做法是需要把 这两个参数剔除出 堆栈,怎么剔除?两个参数一共占 8字节,直接把 esp 加 8就行了。

最后 就是 把 add_two 返回的参数,减去 3 。

代码运行完毕。


从上面的流程看,堆栈传参 确实比较方便,只需要记录一下压了多少字节参数进堆栈,出来的之后 esp 加上多少字节就可以了。

寄存器传参 以及 堆栈传参,还有一些更简洁,不容易出错的写法跟指令,在 《X86 汇编语言,基于x86》第 8 章。

相关阅读:

强烈推荐阅读 《X86 汇编语言,基于x86》第 8 章,讲解得更加详细。

  1. 《X86 汇编语言,基于x86》第 8 章
  2. 《汇编中的堆栈传参》

由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 Loken1。QQ:2338195090。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注