gitbook/编译原理之美/docs/149891.md
2022-09-03 22:05:03 +08:00

19 KiB
Raw Permalink Blame History

加餐 | 汇编代码编程与栈帧管理

在[22讲](https://time.geekbang.org/column/article/147854)中,我们侧重讲解了汇编语言的基础知识,包括构成元素、汇编指令和汇编语言中常用的寄存器。学习完基础知识之后,你要做的就是多加练习,和汇编语言“混熟”。小窍门是查看编译器所生成的汇编代码,跟着学习体会。

不过,可能你是初次使用汇编语言,对很多知识点还会存在疑问,比如:

  • 在汇编语言里调用函数(过程)时,传参和返回值是怎么实现的呢?
  • 21讲中运行期机制所讲的栈帧,如何通过汇编语言实现?
  • 条件语句和循环语句如何实现?
  • ……

为此,我策划了一期加餐,针对性地讲解这样几个实际场景,希望帮你加深对汇编语言的理解。

示例1过程调用和栈帧

这个例子涉及了一个过程调用相当于C语言的函数调用。过程调用是汇编程序中的基础结构它涉及到栈帧的管理、参数的传递这两个很重要的知识点。

假设我们要写一个汇编程序实现下面C语言的功能

/*function-call1.c */
#include <stdio.h>
int fun1(int a, int b){
    int c = 10;
    return a+b+c;
}

int main(int argc, char *argv[]){
    printf("fun1: %d\n", fun1(1,2));
    return 0;
} 

fun1函数接受两个整型的参数a和b来看看这两个参数是怎样被传递过去的手写的汇编代码如下

# function-call1-craft.s 函数调用和参数传递
    # 文本段,纯代码
    .section    __TEXT,__text,regular,pure_instructions

_fun1:
    # 函数调用的序曲,设置栈指针
    pushq   %rbp           # 把调用者的栈帧底部地址保存起来   
    movq    %rsp, %rbp     # 把调用者的栈帧顶部地址,设置为本栈帧的底部

    subq    $4, %rsp       # 扩展栈

    movl    $10, -4(%rbp)  # 变量c赋值为10也可以写成 movl $10, (%rsp)

    # 做加法
    movl    %edi, %eax     # 第一个参数放进%eax
    addl    %esi, %eax     # 把第二个参数加到%eax,%eax同时也是存放返回值的寄存器
    addl    -4(%rbp), %eax # 加上c的值

    addq    $4, %rsp       # 缩小栈

    # 函数调用的尾声,恢复栈指针为原来的值
    popq    %rbp           # 恢复调用者栈帧的底部数值
    retq                   # 返回

    .globl  _main          # .global伪指令让_main函数外部可见
_main:                                  ## @main
    
    # 函数调用的序曲,设置栈指针
    pushq   %rbp           # 把调用者的栈帧底部地址保存起来  
    movq    %rsp, %rbp     # 把调用者的栈帧顶部地址,设置为本栈帧的底部
    
    # 设置第一个和第二个参数,分别为1和2
    movl    $1, %edi
    movl    $2, %esi

    callq   _fun1                # 调用函数

    # 为pritf设置参数
    leaq    L_.str(%rip), %rdi   # 第一个参数是字符串的地址
    movl    %eax, %esi           # 第二个参数是前一个参数的返回值

    callq   _printf              # 调用函数

    # 设置返回值。这句也常用 xorl %esi, %esi 这样的指令,都是置为零
    movl    $0, %eax
    
    # 函数调用的尾声,恢复栈指针为原来的值
    popq    %rbp         # 恢复调用者栈帧的底部数值
    retq                 # 返回

    # 文本段,保存字符串字面量                                  
    .section    __TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
    .asciz  "Hello World! :%d \n"

**需要注意,**手写的代码跟编译器生成的可能有所不同,但功能是等价的,代码里有详细的注释,你肯定能看明白。

**借用这个例子,我们讲一下栈的管理。**在示例代码的两个函数里,有这样的固定结构:

 # 函数调用的序曲,设置栈指针
    pushq	%rbp	     # 把调用者的栈帧底部地址保存起来  
    movq	%rsp, %rbp   # 把调用者的栈帧顶部地址,设置为本栈帧的底部

    ...

    # 函数调用的尾声,恢复栈指针为原来的值
    popq	%rbp         # 恢复调用者栈帧的底部数值

在C语言生成的代码中一般用%rbp寄存器指向栈帧的底部而%rsp则指向栈帧的顶部。**栈主要是通过push和pop这对指令来管理的**push把操作数压到栈里并让%rsp指向新的栈顶pop把栈顶数据取出来同时调整%rsp指向新的栈顶。

在进入函数的时候用pushq %rbp指令把调用者的栈帧地址存起来根据调用约定保护起来而把调用者的栈顶地址设置成自己的栈底地址它等价于下面两条指令你可以不用push指令而是运行下面两条指令

subq $8, %rsp        #把%rsp的值减8也就是栈增长8个字节从高地址向低地址增长
movq %rbp, (%rsp)    #把%rbp的值写到当前栈顶指示的内存位置

而在退出函数前调用了popq %rbp指令。它恢复了之前保存的栈指针的地址等价于下面两条指令

movq (%rsp), %rbp    #把栈顶位置的值恢复回%rbp这是之前保存在栈里的值。
addq $8, %rsp        #把%rsp的值加8也就是栈减少8个字节

上述过程画成一张直观的图,表示如下:

上面每句指令执行以后,我们看看%rbp和%rsp值的变化

再来看看使用局部变量的时候会发生什么:

    subq    $4, %rsp       # 扩展栈

    movl    $10, -4(%rbp)  # 变量c赋值为10也可以写成 movl $10, (%rsp)

    ...

    addq    $4, %rsp       # 缩小栈

我们通过减少%rsp的值来扩展栈然后在扩展出来的4个字节的位置上写入整数这就是变量c的值。在返回函数前我们通过addq $4, %rsp再把栈缩小。这个过程如下图所示

在这个例子中,我们通过移动%rsp指针来改变帧的大小。%rbp和%rsp之间的空间就是当前栈帧。而过程调用和退出过程分别使用call指令和ret指令。“callq _fun1”是调用_fun1过程这个指令相当于下面两句代码它用到了栈来保存返回地址

pushq %rip  # 保存下一条指令的地址,用于函数返回继续执行
jmp _fun1   # 跳转到函数_fun1

_fun1函数用ret指令返回它相当于

popq %rip   #恢复指令指针寄存器
jmp %rip

上一讲我提到在X86-64架构下新的规范让程序可以访问栈顶之外128字节的内存所以我们甚至不需要通过改变%rsp来分配栈空间而是直接用栈顶之外的空间。

上面的示例程序你可以用as命令生成可执行程序运行一下看看然后试着做一下修改逐步熟悉汇编程序的编写思路。

示例2同时使用寄存器和栈来传参

上一个示例中函数传参只使用了两个参数这时是通过两个寄存器传递参数的。这次我们使用8个参数来看看通过寄存器和栈传参这两种不同的机制。

在X86-64架构下有很多的寄存器所以程序调用约定中规定尽量通过寄存器来传递参数而且只要参数不超过6个都可以通过寄存器来传参使用的寄存器如下

超过6个的参数的话我们要再加上栈来传参

根据程序调用约定的规定参数16是放在寄存器里的参数7和8是放到栈里的先放参数8再放参数7。

在23讲我会带你为下面的一段playscript程序生成汇编代码

//asm.play
int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8){
    int c = 10; 
    return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c;
}

println("fun1:" + fun1(1,2,3,4,5,6,7,8));

现在,我们可以按照调用约定,先手工编写一段实现相同功能的汇编代码:

# function-call2-craft.s 函数调用和参数传递
    # 文本段,纯代码
    .section    __TEXT,__text,regular,pure_instructions

_fun1:
    # 函数调用的序曲,设置栈指针
    pushq   %rbp           # 把调用者的栈帧底部地址保存起来   
    movq    %rsp, %rbp     # 把调用者的栈帧顶部地址,设置为本栈帧的底部

    movl    $10, -4(%rbp)  # 变量c赋值为10,也可以写成 movl $10, (%rsp)

    # 做加法
    movl    %edi, %eax     # 第一个参数放进%eax
    addl    %esi, %eax     # 加参数2
    addl    %edx, %eax     # 加参数3
    addl    %ecx, %eax     # 加参数4
    addl    %r8d, %eax     # 加参数5
    addl    %r9d, %eax     # 加参数6
    addl    16(%rbp), %eax  # 加参数7
    addl    24(%rbp), %eax  # 加参数8
    
    addl    -4(%rbp), %eax # 加上c的值

    # 函数调用的尾声,恢复栈指针为原来的值
    popq    %rbp           # 恢复调用者栈帧的底部数值
    retq                   # 返回

    .globl  _main          # .global伪指令让_main函数外部可见
_main:                                  ## @main
    
    # 函数调用的序曲,设置栈指针
    pushq   %rbp           # 把调用者的栈帧底部地址保存起来  
    movq    %rsp, %rbp     # 把调用者的栈帧顶部地址,设置为本栈帧的底部
    
    subq    $16, %rsp      # 这里是为了让栈帧16字节对齐实际使用可以更少

    # 设置参数
    movl    $1, %edi     # 参数1
    movl    $2, %esi     # 参数2
    movl    $3, %edx     # 参数3
    movl    $4, %ecx     # 参数4
    movl    $5, %r8d     # 参数5
    movl    $6, %r9d     # 参数6
    movl    $7, (%rsp)   # 参数7
    movl    $8, 8(%rsp)  # 参数8

    callq   _fun1                # 调用函数

    # 为pritf设置参数
    leaq    L_.str(%rip), %rdi   # 第一个参数是字符串的地址
    movl    %eax, %esi           # 第二个参数是前一个参数的返回值

    callq   _printf              # 调用函数

    # 设置返回值。这句也常用 xorl %esi, %esi 这样的指令,都是置为零
    movl    $0, %eax

    addq    $16, %rsp    # 缩小栈
    
    # 函数调用的尾声,恢复栈指针为原来的值
    popq    %rbp         # 恢复调用者栈帧的底部数值
    retq                 # 返回

    # 文本段,保存字符串字面量                                  
    .section    __TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
    .asciz  "fun1 :%d \n"

用as命令把这段汇编代码生成可执行文件运行后会输出结果“fun1: 46”。

as functio-call2-craft.s -o function-call2
./function-call2

这段程序虽然有点儿长但思路很清晰比如每个函数过程都有固定的结构。710行我叫做序曲是设置栈帧的指针25~26行我叫做尾声是恢复栈底指针并返回13~22行是做一些计算还要为本地变量在栈里分配一些空间。

**我建议你读代码的时候,**对照着每行代码的注释,弄清楚这条代码所做的操作,以及相关的寄存器和内存中值的变化,脑海里有栈帧和寄存器的直观的结构,就很容易理解清楚这段代码了。

除了函数调用以外我们在编程时经常使用循环语句和if语句它们转换成汇编是什么样子呢我们来研究一下首先看看while循环语句。

示例3循环语句的汇编码解析

看看下面这个C语言的语句

void fun1(int a){
    while (a < 10){
        a++;
    }
}

我们要使用"gcc -S ifstmt.c -o ifstmt.s"命令,把它转换成汇编语句(注意不要带优化参数):

 .section    __TEXT,__text,regular,pure_instructions
    .macosx_version_min 10, 15
    .globl  _fun1                   ## -- Begin function fun1
    .p2align    4, 0x90
_fun1:                                  ## @fun1
    .cfi_startproc
## %bb.0:
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    movl    %edi, -4(%rbp)   #把参数a放到栈里
LBB0_1:                      ## =>This Inner Loop Header: Depth=1
    cmpl    $10, -4(%rbp)    #比较参数1和立即数10,设置eflags寄存器
    jge LBB0_3               #如果大于等于则跳转到LBB0_3基本块
## %bb.2:                    ##   in Loop: Header=BB0_1 Depth=1
    movl    -4(%rbp), %eax   #这2行是给a加1
    addl    $1, %eax
    movl    %eax, -4(%rbp)
    jmp LBB0_1
LBB0_3:
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function

.subsections_via_symbols

这段代码的15、16、21行是关键我解释一下

  • 第15行用cmpl指令将%edi寄存器中的参数1即C代码中的参数a和立即数10做比较比较的结果会设置EFLAGS寄存器中的相关位。

EFLAGS中有很多位下图是Intel公司手册中对各个位的解释有的指令会影响这些位的设置比如cmp指令有的指令会从中读取信息比如16行的jge指令

  • 第16行jge指令。jge是“jump if greater or equal”的缩写也就是当大于或等于的时候就跳转。大于等于是从哪知道的呢就是根据EFLAGS中的某些位计算出来的。

  • 第21行跳转到循环的开始。

在这个示例中我们看到了jmp无条件跳转指令和jge条件跳转指令两个跳转指令。条件跳转指令很多它们分别是基于EFLAGS的状态位做不同的计算判断是否满足跳转条件看看下面这张表格

表格中的跳转指令,是基于有符号的整数进行判断的,对于无符号整数、浮点数,还有很多其他的跳转指令。现在你应该体会到,汇编指令为什么这么多了。好在其助记符都是有规律的,可以看做英文缩写,所以还比较容易理解其含义。

**另外我再强调一下,**刚刚我让你生成汇编时,不要带优化参数,那是因为优化算法很“聪明”,它知道这个循环语句对函数最终的计算结果没什么用,就优化掉了。后面学优化算法时,你会理解这种优化机制。

不过这样做也会有一个不好的影响就是代码不够优化。比如这段代码把参数1拷贝到了栈里在栈里做运算而不是直接基于寄存器做运算这样性能会低很多这是没有做寄存器优化的结果。

示例4if语句的汇编码解析

循环语句看过了if语句如何用汇编代码实现呢

看看下面这段代码:

int fun1(int a){
    if (a > 10){
        return 4;
    }
    else{
        return 8;
    }
}

把上面的C语言代码转换成汇编代码如下

   .section    __TEXT,__text,regular,pure_instructions
    .macosx_version_min 10, 15
    .globl  _fun1                   ## -- Begin function fun1
    .p2align    4, 0x90
_fun1:                                  ## @fun1
    .cfi_startproc
## %bb.0:
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    movl    %edi, -8(%rbp)
    cmpl    $10, -8(%rbp)  #将参数a与10做比较
    jle LBB0_2             #小于等于的话就调转到LBB0_2基本块
## %bb.1:
    movl    $4, -4(%rbp)   #否则就给a赋值为4
    jmp LBB0_3
LBB0_2:
    movl    $8, -4(%rbp)   #给a赋值为8
LBB0_3:
    movl    -4(%rbp), %eax #设置返回值
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function

.subsections_via_symbols

了解了条件跳转指令以后再理解上面的代码容易了很多。还是先做比较设置EFLAGS中的位然后做跳转。

示例5浮点数的使用

之前我们用的例子都是采用整数,现在使用浮点数来做运算,看看会有什么不同。

看看下面这段代码:

float fun1(float a, float b){
    float c = 2.0;
    return a + b + c;
}

使用-O2参数把C语言的程序编译成汇编代码如下

  .section    __TEXT,__text,regular,pure_instructions
    .macosx_version_min 10, 15
    .section    __TEXT,__literal4,4byte_literals
    .p2align    2               ## -- Begin function fun1
LCPI0_0:
    .long   1073741824              ## float 2 常量
    .section    __TEXT,__text,regular,pure_instructions
    .globl  _fun1
    .p2align    4, 0x90
_fun1:                                  ## @fun1
    .cfi_startproc
## %bb.0:
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    addss   %xmm1, %xmm0    #浮点数传参用XMM寄存器加法用addss指令
    addss   LCPI0_0(%rip), %xmm0  #把常量2.0加到xmm0上xmm0保存返回值
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function

.subsections_via_symbols

这个代码的结构你应该熟悉了,栈帧的管理方式都是一样的,都要维护%rbp和%rsp。不一样的地方有几个地方

  • 传参。给函数传递浮点型参数是要使用XMM寄存器。

  • 指令。浮点数的加法运算使用的是addss指令它用于对单精度的标量浮点数做加法计算这是一个SSE1指令。SSE1是一组指令主要是对单精度浮点数(比如C或Java语言中的float)进行运算的而SSE2则包含了一些双精度浮点数比如C或Java语言中的double的运算指令。

  • 返回值。整型返回值是放在%eax寄存器中而浮点数返回值是放在xmm0寄存器中的。调用者可以从这里取出来使用。

课程小结

利用本节课的加餐,我带你把编程中常见的一些场景,所对应的汇编代码做了一些分析。你需要记住的要点如下:

  • 函数调用时会使用寄存器传参超过6个参数时还要再加上栈这都是遵守了调用约定。

  • 通过push、pop指令来使用栈栈与%rbp和%rsp这两个指针有关。你可以图形化地记住栈的增长和回缩的过程。需要注意的是是从高地址向低地址走所以访问栈里的变量都是基于%rbp来减某个值。使用%rbp前要先保护起来别破坏了调用者放在里面的值。

  • 循环语句和if语句的秘密在于比较指令和有条件跳转指令它们都用到了EFLAGS寄存器。

  • 浮点数的计算要用到MMX寄存器指令也有所不同。

通过这次加餐你会更加直观地了解汇编语言接下来的课程中我会带你尝试通过翻译AST自动生成这些汇编代码让你直观理解编译器生成汇编码的过程。

一课一思

你了解到哪些地方会使用汇编语言编程?有没有一些比较有意思的场景?是否实现了一些普通高级语言难以实现的结果?欢迎在留言区分享你的经验。

最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。