2、基础语法

内容来自王争 Java 编程之美

1、变量

我们知道,内存被划分为一个个的内存单元
每个内存单元都对应一个内存地址,方便 CPU 根据内存地址来读取和操作内存单元中的数据

对于高级语言来说,内存地址可读性比较差,所以,就发明了变量这种语法,变量可以看作是内存地址的别名
内存地址和变量的关系,跟 IP 地址和域名的关系类似
在机器码中,我们通过内存地址来实现对内存中数据的读写
在代码中,我们通过变量来实现对内存中数据的读写,编译器在将代码编译成机器码时,会将代码中的变量替换为内存地址

不同的变量有不同的作用域(也可以理解为生命周期),不同作用域的变量,分配在代码段的不同区域,不同的区域有不同的内存管理方式
不同语言对数据段的分区方式会有所不同,但又大同小异(关于 Java 语言如何对数据段分区,我们在 JVM 部分讲解),常见的分区有栈、堆、常量池等,笼统来讲

  • 栈一般存储作用域为 "函数内" 的数据,如函数内部的局部变量、参数等
    它们只在函数内参与计算,函数结束之后,就不再使用了,所占用的内存单元就可以释放,以供其他变量重复使用
  • 堆一般存储作用域不局限于 "函数内" 的数据,如对象
    只有在程序员主动释放(如 C / C++ 语言)或虚拟机判定为不再使用(如 Java 语言)时,对象对应的内存单元才会被释放
  • 常量池一般存储常量等,常量的生命周期跟程序的生命周期一样,只有在程序执行结束之后,对应的内存单元才会被释放

也就是说,对数据段进行分区,是为了方便管理不同生命周期的变量
而之所以不同的变量要设置不同的生命周期,是为了能够有效的利用内存空间,方便在变量生命周期结束之后,对应的内存单元能够快速地被回收,以供重复使用

2、数组

假设我们需要在代码中表示一个学生的 10 门课的成绩,如果编程语言中没有 "数组" 这种语法,我们需要定义 10 个变量,而使用数组就方便了很多
使用数组,我们可以定义一块连续的内存空间,存储这 10 门课的成绩,并通过下标来访问数组中的数据,示例代码如下所示

public class Test {
    public void demo() {
        int[] a = new int[10];
        a[3] = 92;
        System.out.println(a[3]);
    }
}

在上述示例代码中,a 是一个局部变量,存储在栈上
"int[] a = new int[10]" 这条语句表示,在堆上申请一块能够存储下 10 个 int 类型数据的连续的内存空间,并将这块内存空间的首地址存储在 a 变量所对应的内存单元中
实际上,数组是一种引用类型,关于它的内存结构,我们再下一篇文章中详细讲解

当通过下标来访问数组中的元素时,如语句 "a[3]=92",编译器将这条语句分解为多条 CPU 指令,先通过变量 a 中存储的首地址和如下寻址公式,计算出下标为 3 的元素所在的内存地址,然后将 92 写入到这个内存地址对应的内存单元

a[i] 的内存地址 = a 中存储的值(也就是数组的首地址)+ i * 4(4 表示 4 字节,也就是数据类型的长度)

在 Java 语言中,new 申请的数组存储在堆上,首地址赋值给栈上的变量
而在有些语言中,比如 C 语言,"数组" 语法更加灵活,既可以申请在堆上,也可以申请在栈上,如下所示

int a[100]; // 数组在栈中,可以直接类似 a[2] = 92; 这样使用了
int a[100] = malloc(sizeof(int) * 100); // 数组在堆中

实际上,如果你了解 JavaScript 语言,你还会发现,JavaScript 中的数组还可以存储不同类型的数据,如下所示

var arr = new Array(4, 'hello', new Date());
var name = arr[1];

在上述示例中,数组中存储的是不同类型的数据,因此,上文中提到的寻址公式就无法工作了,那 JavaScript 是如何通过下标定位到元素的内存地址的呢?

实际上,不同编程语言中的数组,其在内存中的存储方式并不完全一样,也并非只有在上文中讲到的 "在一块连续的内存空间中存储相同类型的数据" 这样一种存储方式
关于这一点,读者可以参看《数据结构与算法之美》纸质书中的第 19 页(2.2 节:数据结构中的数组和编程语言中的数组的区别)
其中有详细的讲解 C / C++、Java、Javascript 三种语言中的数组在不同情况下的内存存储方式
当然,在下一节中,我们也会详细讲解 Java 语言中数组的内存存储方式

3、类型

在 CPU 眼里,是没有类型这一概念的,任何类型的数据,在 CPU 眼里都是只有一串二进制码
一串二进制码是表示为字符串还是整型数又或者浮点数,完全看编译器怎么解读,看在代码中怎么定义这块内存单元的类型的

引入类型的目的是,方便程序员编写正确的代码,避免错误的赋值操作
比如,用 int 类型的变量 age 来存储用户的年龄,如果程序员试图将字符串 "wangzheng" 赋值给变量 age,Java 语言就会在编译代码时报错,提醒程序员修改

不同的编程语言具有不同的类型系统,根据变量的 "类型是否可以动态变化" 和 "类型检查发生的时期",我们将类型系统分为静态类型和动态类型

  • 静态类型指的是:一个变量的类型是唯一确定的,类型检查发生在编译期
  • 动态类型指的是:一个变量的类型是可变的,具体看赋值给它的数据是什么类型的,类型的检查发生在运行期

我们拿 Java 和 PHP 举例说明,代码如下所示
在 Java 代码中,变量 s 的类型是确定的,类型检查发生在编译期
在 PHP 代码中,变量 s 的类型不确定,可以赋值为任意类型的数据,变量 s 的类型由当前被赋值的数据的类型来决定
所以,Java 是静态类型语言,PHP 是动态类型语言

// Java 代码
String s = "wangzheng";

// PHP 代码
$s = "wangzheng";
$s = 233;
$s = new Student();

除了静态类型语言和动态类型语言这种分类方式之外,在平时的开发中,我们还经常听到另外一种分类方式:弱类型语言和强类型语言
实际上,这种分类方式没有太大意义,强和弱是对程度的描述,并不是非黑即白
所以,我们很难判定某种语言到底是强类型语言还是弱类型语言,所以,你不要纠结于这种分类方式,稍微了解即可

4、运算

在编程语言中,常见的运算类型有以下 5 种
1、算术运算,比如加、减、乘、除
2、关系运算,比如大于、小于、等于
3、赋值运算,比如 a = 5
4、逻辑运算,比如 &&,||,!
5、位运算,比如 &,|,~,^,>>,<<

以上绝大部分运算在 CPU 中都有对应的指令,不过,不同类型的指令对应的电路逻辑不同,所以,执行花费的时间不同,比如位运算会比较快,乘法、除法比较慢

我们通过一个简单的 C 语言例子,来看一下上述运算对应的汇编指令

#include <stdio.h>

int main() {

    // 赋值
    int a = 1; // 对应 movl 指令
    int b = 2;

    // 算术
    int c = a + b; // 对应 addl 指令
    int d = a * b; // 对应 imull 指令

    // 关系
    if (c < d) {  // 对应 cmpl 和 jge 指令
       printf("c < d");
    }

    // 逻辑
    if (a > 2 || b > 2) { // 对应 cmpl 和 jg 指令
      printf("> 2");
    }

    // 位运算
    int e = a & b; // 对应 andl 指令
    return e;
}
$gcc -S test2.c
_main:                         ## @main
        .cfi_startproc
## %bb.0:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $32, %rsp
        movl    $0, -4(%rbp)
        movl    $1, -8(%rbp)       ; a 存储在栈上,a = 1;
        movl    $2, -12(%rbp)      ; b 存储在栈上,b = 2;
        movl    -8(%rbp), %eax     ; a 的值放入寄存器 eax
        addl    -12(%rbp), %eax    ; a + b,结果放入 eax
        movl    %eax, -16(%rbp)    ; a + b的值放入 c(c 在栈上)
        movl    -8(%rbp), %eax     ; a 的值放入寄存器 eax
        imull   -12(%rbp), %eax    ; a * b,结果放入 eax
        movl    %eax, -20(%rbp)    ; a * b 的值放入 d(d 在栈上)
        movl    -16(%rbp), %eax    ; c 的值放入寄存器 eax
        cmpl    -20(%rbp), %eax    ; c < d,结果放到标志寄存器中
        jge     LBB0_2             ; jge 根据标志寄存器的值做跳转
## %bb.1:
        leaq    L_.str(%rip), %rdi ;printf("c < d");
        movb    $0, %al
        callq   _printf
LBB0_2:
        cmpl    $2, -8(%rbp)       ; 判断 a > 2,结果放到标志寄存器中
        jg      LBB0_4             ; jg 根据标志寄存器的值做跳转
## %bb.3:
        cmpl    $2, -12(%rbp)      ; 判断 b > 2,结果放到标志寄存器中
        jle     LBB0_5             ; jg 根据标志寄存器的值做跳转
LBB0_4:
        leaq    L_.str.1(%rip), %rdi ;printf("> 2");
        movb    $0, %al
        callq   _printf
LBB0_5:
        movl    -8(%rbp), %eax     ; a 放入寄存器 eax
        andl    -12(%rbp), %eax    ; a & b,结果放入 eax
        movl    %eax, -24(%rbp)    ; a & b的结果放入 e(e 在栈上)
        movl    -24(%rbp), %eax    ; 返回值放入 eax
        addq    $32, %rsp
        popq    %rbp
        retq
        .cfi_endproc
                                   ## -- End function
        .section        __TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str  
        .asciz  "c<d"
L_.str.1:                               ## @.str.1 
        .asciz  ">2"
.subsections_via_symbols

这里稍微解释一下,在本节的讲解中,大部分情况下,我们都是采用 C / C++ 语言来举例,因为使用 C / C++ 语言编写的代码,查看编译之后的汇编代码,比较方便
而对于用 Java 语言编写的代码,因为 Java 语言是解释执行的,获取 Java 代码对应的汇编代码的过程比较繁琐,需要借助额外的工具,将 JIT 编译之后的机器码反汇编成汇编语言才可,过程比较复杂
并且,由此得到的汇编代码,因为掺杂了虚拟机的部分代码,可读性也比较差

对于绝大多数 Java 程序员来说,即便是要了解 Java 语言的底层原理或者优化代码性能,也只需要研究到字节码这一层就足够了,完全不需要深入到汇编代码层面
所以,对于如何获取 Java 代码对应的汇编代码,这不是专栏讲解的重点
不过,为了满足一些朋友的好奇心,我编写了查看 Java 代码对应汇编代码的操作文档,放到了资料中(点击此处查看),感兴趣的可以看一下

我们本节给出某些代码的汇编代码的原因,并非是让你学习汇编语言,而只是让你更加直观地理解,编程语言的基础语法在 CPU 层面的表示方式
而且,对于本节讲解的基础语法,绝大部分编程语言的底层实现都是大差不差的,编译成机器码之后,在 CPU 眼里都是一样的
所以,我们使用比较容易获得、比较容易读懂的 C / C++ 代码对应的汇编代码来举例

5、跳转

程序由顺序、选择(或叫分支、条件)、循环三种基本结构构成,其中,选择和循环又统称为跳转
接下来,我们通过一个 C 语言代码示例,来看下两种跳转在 CPU 眼里是如何实现的

#include <stdio.h>

int main() {

    int a = 1;
    int b = 2;

    // 选择
    if (a < b) {
      printf("a < b");
    }

    // 循环
    for (int i = 0; i < 100; ++i) {
      printf("%d", i);
    }

  return 0;
}
$ gcc -S test3.c
_main:                                  ## @main
        .cfi_startproc
## %bb.0:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $16, %rsp
        movl    $0, -4(%rbp)
        movl    $1, -8(%rbp)       ; a = 1,a 存储在栈上
        movl    $2, -12(%rbp)      ; b = 2,b 存储在栈上
        movl    -8(%rbp), %eax     ; a 值放入寄存器 eax
        cmpl    -12(%rbp), %eax    ; a < b,比较结果放入标志寄存器
        jge     LBB0_2             ; 通过标志寄存器判断如何跳转
## %bb.1:
        leaq    L_.str(%rip), %rdi
        movb    $0, %al
        callq   _printf ;printf("a<b");
LBB0_2:
        movl    $0, -16(%rbp)      ; i = 0,i 存储在栈上
LBB0_3:                  ## =>This Inner Loop Header: Depth=1
        cmpl    $100, -16(%rbp)    ; i < 100,比较结果放入标志寄存器
        jge     LBB0_6             ; 通过标志寄存器判断如何跳转
## %bb.4:                ##   in Loop: Header=BB0_3 Depth=1
        movl    -16(%rbp), %esi
        leaq    L_.str.1(%rip), %rdi
        movb    $0, %al
        callq   _printf            ;printf("%d", i);
## %bb.5:                ##   in Loop: Header=BB0_3 Depth=1
        movl    -16(%rbp), %eax    ; i 值放入 eax 寄存器
        addl    $1, %eax           ; eax 寄存器内值 + 1
        movl    %eax, -16(%rbp)    ; eax 寄存器的值赋值给 i,相当于 i++
        jmp     LBB0_3             ; 跳转去判断 i < 100
LBB0_6:
        xorl    %eax, %eax
        addq    $16, %rsp
        popq    %rbp
        retq
        .cfi_endproc
                          ## -- End function
        .section        __TEXT,__cstring,cstring_literals
L_.str:                   ## @.str
        .asciz  "a<b"
L_.str.1:                 ## @.str.1
        .asciz  "%d"
.subsections_via_symbols

从上述汇编代码中,我们可以看出,不管是 if 选择语句,还是 for 循环语句,底层都是通过 CPU 的跳转指令(jge、jle、je、jmp 等)来实现的
跳转指令比较类似早期编程语言中的 goto 语法,可以实现随意从代码的一处跳到另一处
而在之后的编程语言的演化中,goto 语法被废弃,那么,为什么要废弃 goto 语法呢

goto 语法使用起来非常灵活,随意使用极容易导致代码可读性变差
你可以想象一下,如果代码执行过程中,一会跳到前面某行,一会又跳到后面某行,跳来跳去,代码的执行顺序将会非常混乱,阅读代码将会十分困难

而之所以,我们能够废弃 goto 语法,就是因为选择、循环这两种基本结构,可以满足编写代码逻辑的过程中对跳转的需求
而且,选择和循环实现的跳转都是局部的,不会到处乱跳,所以,不会影响代码整体的执行顺序,可读性也不会变差

6、函数

编写函数是代码模块化的一种有效手段,几乎所有的编程语言都会提供函数这种语法
函数的底层实现,相对于前面讲的几种基本语法的底层实现,要复杂一些
函数底层实现依赖一个非常重要的东西:栈,就是我们前面讲到的,用来保存局部变量、参数等的内存区域
因为这块内存的访问方式是先进后出,符合栈这种数据结构的特点,所以,也被称为栈

为什么函数底层实现需要用到栈呢?

每个函数都是一个相对封闭的代码块,其运行需要依赖一些局部数据,比如局部变量等,这些数据会存储在内存中
当函数 A 调用另一个函数 B 时,CPU 会跳转去执行函数 B 的代码,函数 B 的执行又会涉及一些局部变量等,这些数据也会存储在内存中(紧挨着函数 A 的内存块)
以此类推,当函数 B 调用另一个函数 C 时,CPU 又会跳转去执行函数 C 的代码,函数 C 的内存块会紧邻函数 B 的内存块,如下图所示
image
当函数 C 执行完成之后,函数 C 中的局部变量等都不再被使用,对应的内存块也可以释放以供复用,并且,CPU 返回执行函数B的代码
函数 B 对应的内存块又开始被使用,同理,函数 B 执行完成之后,其对应的内存块也会被释放,CPU 返回执行函数A的代码
函数 A 对应的内存块又开始被使用,如下图所示
image
从上图,我们可以发现,在函数调用过程中,同一时间只有一个函数的内存块在被使用,并且内存块被释放的顺序为 "先创建者后释放",符合栈的特点:"只在一端操作、先进后出"
所以,编译器把函数调用所使用的整块内存,组织成栈这种数据结构(叫做函数调用栈),我们把每个函数对应的内存块叫做栈帧

当通过函数调用,进入一个新的函数时,编译器会在栈中创建一个栈帧(实际上就是申请一个内存块),存储这个函数的局部变量等数据
当这个函数执行完毕返回上层函数时,栈顶栈帧出栈(也就是释放内存块),此时,新的栈顶栈帧为返回后的函数对应的栈帧
从上图中,我们也可以发现,正在执行的函数对应的栈帧肯定位于栈顶

了解了函数调用栈的大体结构之后,我们思考以下几个问题
1、栈帧中除了保存局部变量之外,还保存哪些其他数据?
2、上一节讲到的 SP 寄存器和 BP 寄存器具体用在哪里?
3、CPU 在执行完某个函数之后,如何知道应该回到上层函数的哪处再继续执行?

我们结合一段 C 语言代码来回答上述几个问题,代码如下所示

#include <stdio.h>

int f_b(int b1, int b2) {
    int b3 = b1 + b2;
    return b3;
}

void f_a(int a1, int a2) {
    int a3 = a1 - a2;
    int a4 = f_b(a1, a2);
    int a5 = a2 - a1;
}

int main() {
    f_a(1, 2);
    return 0;
}
f_b:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-20], edi
        mov     DWORD PTR [rbp-24], esi
        mov     eax, DWORD PTR [rbp-24]
        mov     edx, DWORD PTR [rbp-20]
        add     eax, edx
        mov     DWORD PTR [rbp-4], eax
        mov     eax, DWORD PTR [rbp-4]
        pop     rbp
        ret
f_a:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 24
        mov     DWORD PTR [rbp-20], edi
        mov     DWORD PTR [rbp-24], esi
        mov     eax, DWORD PTR [rbp-24]
        mov     edx, DWORD PTR [rbp-20]
        sub     edx, eax
        mov     eax, edx
        mov     DWORD PTR [rbp-4], eax
        mov     edx, DWORD PTR [rbp-24]
        mov     eax, DWORD PTR [rbp-20]
        mov     esi, edx
        mov     edi, eax
        call    f_b
        mov     DWORD PTR [rbp-8], eax
        mov     eax, DWORD PTR [rbp-20]
        mov     edx, DWORD PTR [rbp-24]
        sub     edx, eax
        mov     eax, edx
        mov     DWORD PTR [rbp-12], eax
        leave
        ret
main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 2
        mov     edi, 1
        call    f_a
        mov     eax, 0
        pop     rbp
        ret

如果要透彻理解上述汇编代码,需要先了解以下知识点
1、对于 rbp、rsp、eax、edi、esi 等寄存器,前面的 r 表示寄存器是 64 位的,前面的 e 表示寄存器是 32 位的
2、对于 pushq、popq、callq、movl、addl 等指令,后面的 q 表示处理的是 64 位的数据,后面的 l 表示处理的是 32 位的数据
3、栈的内存地址的增长方向是从大到小,也就说栈顶地址比栈底要小
4、rbp 指向栈顶栈帧的底部,rsp 指向栈顶栈帧的顶部
5、函数的返回值通过 eax 寄存器返回给上层函数
6、函数的参数通过 edi、esi、edx、ecx ... 等一系列约定好的寄存器来传递
7、-4(%rbp) 表示 rbp 寄存器中存储的内存地址 -4 对应的内存单元

在上述代码中,main() 函数调用 f_a() 函数,f_a() 函数调用 f_b() 函数,我们从f_a()函数开始分析
当刚进入 f_a() 函数时,栈相关的各个数据如下所示,图中的地址为 64 位,为了方便表示,用 ... 省略掉了前 32 位
除此之外,暂时不要纠结 main() 函数的栈帧里面的具体数据是什么,我们重点放在分析 f_a() 函数的栈帧上
当 f_a() 函数的栈帧分析完之后,main() 函数的栈帧自然就明了了
image
进入 f_a() 函数之后,最先执行 "pushq %rbp" 执行这条指令相当于执行了 "subq $8, %rsp" 和 "movq %rbp, (%rsp)" 这两条指令
把 rsp 寄存器中存储的地址 -8,然后将 rbp 寄存器中存储的值,放入到 rsp 寄存器存储的地址对应的内存单元中
rbp 寄存器中存储的值是 main() 函数的栈帧底地址,换句话说,也就是把 main() 函数的栈帧底地址压入栈
pushq指 令执行后,栈相关的各个数据如下所示
image
接下来执行 "movq %rsp,%rbp",将寄存器 rsp 中的值赋值给 rbp,于是,rsp 和 rbp 存储同一个内存地址,也就相当于指向栈中同一个内存单元(栈顶)
image
接下来执行 "subq $32, %rsp",将寄存器 rsp 中存储的值减去 32,给 f_a() 函数的参数和局部变量预留存储空间
读者可能会说,f_a() 函数中参数和局部变量总共有 5 个 int 类型的,顶多占 20 个字节的存储空间呀,为什么预留 32 个字节?这主要是为了内存对齐
image
接下来执行 "movl %edi, -4(%rbp)" 和 "movl %esi, -8(%rbp)" 这两条指令,将存储在寄存器 edi 和 esi 的参数放入栈中,这样寄存器就可以释放出来以供它用
实际上,参数跟局部变量在本质上是一样的,作用域都在函数内,再接下来的三条指令是计算 a1 - a2,并将计算结果赋值给 a3
这 5 条指令执行完之后,栈相关的数据如下所示
image
接下来调用 f_b() 函数,先将参数值放入 edi 和 esi 寄存器,然后跳转到 f_b() 函数,我们重点看下 callq 指令
执行 "callq _f_b" 相当于执行了 "pushq %rip" 和 "jmp _f_b" 两条指令,将指令指针寄存器 rip 中的值压入栈,然后再跳转去执行 f_b() 函数的代码
rip 寄存器中存储的是 callq 下一条指令的地址,也就是 f_b() 函数执行完成,返回到 f_a() 函数之后,继续往下执行的第一条指令的地址
也就是我们常说的 "返回地址",执行完 callq 指令之后,栈相关的数据如下图所示
image
接下来执行 f_b() 函数,f_b() 函数并没有像 f_a() 函数那样通过 subq 指令预留参数和局部变量的存储空间,这是因为 f_b() 函数是最后一个执行的函数,所以做了一些优化,没有移动 rsp 指针
f_b() 函数中的指令跟 f_a() 函数中的指令类似,我们就不一条一条分析了,在执行到 popq 指令之前,栈相关的数据如下图所示
image
我们重点分析一下 f_b() 函数中的最后两条指令

执行 "popq %rbp" 指令相当于执行了 "movq (%rsp), %rbp" 和 "addq $8, %rsp"
也就是把栈顶的数据(f_a() 函数对应栈帧的帧底地址)取出放入 rbp 寄存器中,执行完popq指令之后,rsp 和 rbp 分别指向 f_a 函数栈帧的底和顶,相当于将 f_b() 函数的栈帧弹出栈,具体如下图所示
image
执行 "retq" 指令相当于执行了 "popq %rip" 指令,也就是将栈顶的数据(f_a() 函数的 "返回地址")取出放入 %rip
这样,CPU 就跳转去执行 f_a() 函数中 callq 指令之后的指令了,执行完 retq 指令之后,栈相关数据如下图所示
image
回到 f_a() 函数之后,执行 callq 后面的 4 条指令,将 a2 - a1 的值只写 a5 中,于是,f_a() 函数也就完全执行完了
接下来是移除 f_a() 函数的栈帧的操作,执行 "addq $32, %rsp" 指令,将 rsp 指向如图所示位置
image
执行 "popq %rbp" 指令,将栈顶的数据(main() 函数的栈帧的帧底地址)赋值给 rbp,此时,rsp 和 rbp 指向 main() 函数的栈帧的帧顶和帧底,相当于将 f_a() 函数的栈帧弹出栈
image
执行 "retq" 指令,相当于执行 "popq %rip" 指令,将 main() 函数的返回地址赋值给 rip,CPU 跳转执行 main() 函数中 callq 之后的指令

详细分析了代码的执行流程之后,对于前面的 3 个问题,我们再总结回答一下

  • 栈帧中除了保存局部变量之外,还保存哪些其他数据?
    栈帧中依次保存:前一个栈帧的帧底地址,参数,局部变量,返回地址
    保存前一个栈帧的帧底地址的目的是:方便当前函数执行完成之后,rbp 指针重新指向前一个栈帧的栈底
    保存返回地址的目的是:方便当前函数执行完成之后,返回到上层函数继续执行
  • 上一节讲到的 SP 寄存器和 BP 寄存器具体用在哪里?
    SP 寄存器存储栈顶地址,方便将新数据压入栈,BP 寄存器存储的是当前栈帧帧底地址,方便基于这个地址的偏移来访问参数、局部变量
  • CPU 在执行完某个函数之后,如何知道应该回到上层函数的哪处再继续执行?
    在通过 callq 指令调用函数时,callq 指令会将当前的 rip 寄存器中的内容(callq 指令的下一条指令的内存地址,即返回地址)存储在栈帧的最顶端
    当被调用的函数执行完之后,被调用函数的栈帧释放
    最后调用 retq 指令(相当于 popq %rip),将返回地址重新赋值给 rip,CPU 就可以从函数中 callq 指令的下一条指令继续执行了

7、课后思考题

1、你熟悉的编程语言中,哪些是静态类型语言?哪些是动态类型语言?

静态类型语言有:C/C++、Java、C# 等
动态类型语言有:JavaScript、Python、PHP、Ruby 等

2、我们提到,goto 语法在编程语言中被废弃,但为什么汇编语言或机器指令中还保留类似 goto 语句的 jmp 指令呢?

汇编语言更加注重灵活性,并且高级语言中的 for 循环等都是通过 jmp 指令来实现的
因此,汇编语言并没有像高级语言那样将类似 goto 语句的 jmp 指令摒弃
posted @ 2023-05-11 17:50  lidongdongdong~  阅读(120)  评论(0编辑  收藏  举报