本文最后更新于 2024-06-11,文章内容可能已经过时。

对于绝大部分编程语言来说,不管是 Python、Ruby、PHP、JavaScript,还是 Go、C/C++、Java,其包含的基本语法无外乎这样几种:变量、类型、数组、运算(赋值、算术、逻辑、比较等)、跳转(条件、循环)、函数,而其他语法(比如类、容器、异常等)在 CPU 眼里只不过是语法糖。本文,我们就来看下,编程语言中的这些基本语法,在 CPU 眼里是什么样子的。

一、变量

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

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

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

笼统来讲,栈一般存储的是“函数内”的数据,如函数内部的变量、参数等,他们只在函数内部参与计算,函数结束之后,就不再使用了,所占用的内存单元就可以释放,以供其他变量来使用。堆一般存储的作用域不局限于"函数内的变量",比如对象,只有在程序员主动释放,比如 c 和 C++语言,而对于 Java 语言来说,当虚拟机检测到不再使用的时候,对象对应的内存会被释放。常量池一般存储常量等,常量的生命周期和程序的生命周期一样,只有在程序结束之后,对应的内存单元才会被释放。
总而言之,对数据进行分区,是为了方便管理不同生命周期的变量。而之所以不同的变量要设置不同的生命周期,是为了有效利用内存空间,方便在变量生命周期结束之后,对应的内存单元能够被快速回收,以供重复使用。

二、数组

在编程语言中,要表示一些连续有规律且数据类型相同的数据,我们可以定义一块连续的内存空间,存储此类数据,并通过下标访问这些数据。Java 中的数据定义如下:

class Test{
    public static void main(String[] args){
        int a = new int [10];
        a[3] = 92;
    }
}

在上述代码中,a 表示一个局部变量,存储在栈上。int a = new int [10]; 这条语句表示在堆上申请一块能够存储下 10 个 int 类型数据的连续存储空间,并将这块空间的首地址存储在 a 变量所对应的内存单元中。实际上,数组是一种引用类型的数据。
当通过下标访问数组中的元素时,比如 a[3]=92, 编译器会将这条语句分解为多条 CPU 指令,先通过变量 a 中存储的首地址和如下寻址公式,计算出下标为 3 的元素所在的内存地址,然后将 92 写入到这个内存单元所对应的内存单元。

a[i]的内存地址 = a中存储的值(首地址)+ i*4(int 类型的数据占四个字节)

在 Java 语言中,new 申请的数据存储在堆上,首地址复制给栈上的变量。而在 c 语言中,数组的语法更加灵活,既可以申请在堆上,也可以申请在栈上。如下所示:

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

但是对于 Javascript 语言来说,他的数组类型可以存储不同类型的数据,比如:

let array = [1,"zifuchuan",true]

此时,寻址公式就无法使用了。实际上,对应不同编程语言中的数组,其在内存中的存储方式并不完全一样。

三、类型

CPU 眼里,是没有类型这种概念,任何类型的数据,在 CPU 的眼里,都是一串二进制码,至于这些二进制码是表示字符串还是整数,完全在于编译器的解读。引入类型的目的是:方便程序员正确的编写代码,避免赋值操作。比如,不能将 string 类型的数据赋值给 int 类型,当然,这个是针对 Java 语言而言,其他语言不一定是这样。不同的编程语言具有不同的类型系统。根据变量的类型是否可以动态变化和检查发生的时期,分为静态类型和动态类型,静态类型指的是一个变量的类型是唯一确定,动态类型指的是一个变量的类型是可以发生变化的,具体看赋值什么类型的,类型的检查发生在运行期。比如 Java script 语言,由于其类型系统可以发生变化,所以经常会发生一些变量类型赋值错误的现象,因此,可以使用 typescript 语言来约束变量类型。

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

四、运算

在编程运算中,常见的运算符有以下四种:

  1. 算术运算符
  2. 关系运算符
  3. 赋值运算符
  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语言代码示例,来看下两种跳转在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 语法,就是因为选择、循环这两种基本结构,可以满足编写代码逻辑的过程中对跳转的需求,而且,选择和循环实现的跳转都是局部的,不会到处乱跳,所以,不会影响代码整体的执行顺序,可读性也不会变差。

六、函数

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

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

每个函数都是一个相对封闭的代码块,其运行需要依赖一些局部数据,比如局部变量等。这些数据会存储在内存中。当函数 A 调用另一个函数 B 时,CPU 会跳转去执行函数 B 的代码。函数 B 的执行又会涉及一些局部变量等,这些数据也会存储在内存中(紧挨着函数 A 的内存块)。以此类推,当函数 B 调用另一个函数 C 时,CPU 又会跳转去执行函数 C 的代码。函数 C 的内存块会紧邻函数 B 的内存块。如下图所示。
neicun1.png
当函数 C 执行完成之后,函数 C 中的局部变量等都不再被使用,对应的内存块也可以释放以供复用,并且,CPU 返回执行函数 B 的代码。函数 B 对应的内存块又开始被使用。同理,函数 B 执行完成之后,其对应的内存块也会被释放,CPU 返回执行函数 A 的代码。函数 A 对应的内存块又开始被使用。如下图所示。

neicun2.png

从上图,我们可以发现,在函数调用过程中,同一时间只有一个函数的内存块在被使用,并且内存块被释放的顺序为“先创建者后释放”,符合栈的特点:“只在一端操作、先进后出”。所以,编译器把函数调用所使用的整块内存,组织成栈这种数据结构(叫做函数调用栈)。我们把每个函数对应的内存块叫做栈帧。
当通过函数调用,进入一个新的函数时,编译器会在栈中创建一个栈帧(实际上就是申请一个内存块),存储这个函数的局部变量等数据。当这个函数执行完毕返回上层函数时,栈顶栈帧出栈(也就是释放内存块),此时,新的栈顶栈帧为返回后的函数对应的栈帧。从上图中,我们也可以发现,正在执行的函数对应的栈帧肯定位于栈顶。